diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index de309699c..dc1b9532b 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -5,6 +5,7 @@ - Removed `op: issue_view` and `op: pr_view` from the `github` tool. Read single issues/PRs via the `read` tool against `issue://` / `pr://` (or the long form `issue:////` / `pr:////`); append `?comments=0` to drop the comments section. The `issue` and `comments` parameters were removed from the tool schema since no remaining op consumes them. Mutating ops (`pr_create`, `pr_checkout`, `pr_push`), `repo_view`, `search_*`, and `run_watch` are unchanged. - Removed `op: pr_diff` (along with the `nameOnly` and `exclude` schema fields) from the `github` tool. Read PR diffs through the new `pr://` URL family: `pr:///diff` for the changed-file listing, `pr:///diff/` for a single file slice (1-indexed), and `pr:///diff/all` for the verbatim unified diff. Long-form `pr://///diff[/…]` works the same way. All three variants share one `gh pr diff` invocation through a new `pr-diff` cache row, so the listing and per-file slices reconstruct from cached bytes without re-shelling. Diff content is served as `text/plain` so the `read` tool's line selectors (e.g. `pr:///diff/all:200-400`) page the cached output without falsely advertising hashline anchors. +- Renamed ACP custom extension methods from `omp/*` to `_omp/*` to comply with the ACP spec's `_`-prefix requirement for non-spec methods; existing callers must update method names ### Added @@ -14,12 +15,25 @@ - Added new `Approve and compact context` choice to the ExitPlanMode approval selector. Sits between `Approve and execute` (purge session) and `Approve and keep context` (full transcript) — runs `/compact` on the plan-mode transcript with a planning-specific summarization hint, then dispatches the plan-approved execution turn so it lands on a fresh cache anchor with the summarized rationale carried over. Cancelling the compaction (Esc or any other abort source) defers the execution dispatch and surfaces a warning so the operator can resubmit manually; non-abort failures proceed best-effort. - Added `CompactionCancelledError` typed sentinel and `CompactionOutcome` (`"ok" | "cancelled" | "failed"`) return type to `@oh-my-pi/pi-coding-agent/session/compaction`. `CommandController.executeCompaction` and `handleCompactCommand` now return the outcome instead of `void` so callers can discriminate user-driven aborts from generic failures without inspecting error messages. - Added a `credential_disabled` extension event so extensions can subscribe via `pi.on("credential_disabled", handler)` and react when `AuthStorage` automatically soft-disables a credential (e.g. OAuth `invalid_grant`). Replaces the current `agent_end` errorMessage regex pattern downstream extensions have to match against. Handler payload is `{ type, provider, disabledCause }`. `createAgentSession()` subscribes the per-session extension runner to the shared `AuthStorage` via `authStorage.onCredentialDisabled(...)` at the very top of session creation — before any startup model probes run — so events fire on every disable regardless of whether the embedder also has a constructor `onCredentialDisabled` handler attached. The SDK forwards through `ExtensionRunner.emitCredentialDisabled(event)`, which buffers events until `runner.initialize(...)` runs in the mode controller and then flushes them through `emit()` so extension handlers see populated UI/runtime context (rather than the constructor's no-op default with `hasUI=false`, an unset model, and no-op runtime actions). On `session.dispose()` the subscription is unsubscribed; the embedder's constructor-attached listener keeps firing through its own permanent subscription. The outer `createAgentSession()` catch also releases the subscription if startup throws before the dispose-wrap is wired, so repeated retries don't accumulate dead listeners. +- Added `omp acp` subcommand for launching as an ACP (Agent Client Protocol) server over stdio +- Added explicit `type` discriminators to ACP `initialize` auth methods, including a `terminal` setup method gated on `clientCapabilities.auth.terminal` +- Added ACP equivalents for the remaining TUI slash commands (`/jobs`, `/changelog`, `/dump`, `/copy`, `/hotkeys`, `/extensions`, `/agents`, `/model`, `/plan`, `/loop`, `/btw`, `/login`, `/logout`, `/resume`, `/tree`, `/branch`, `/new`, `/drop`, `/handoff`, `/fork`, `/session delete`, `/export`, `/share`, `/todo`, `/memory`, `/move`, `/mcp`, `/ssh`, `/marketplace`, `/plugins`) so ACP clients reach feature parity with the TUI for non-interactive flows +- Added ACP `plan` mode: when `plan.enabled` setting is on, ACP `session/new`/`load`/`resume`/`fork` advertise a `plan` mode alongside `default`; `session/set_mode` toggles plan-mode state so the next agent turn injects the plan-mode system prompt +- Added ACP `ClientBridge` abstraction (`packages/coding-agent/src/session/client-bridge.ts`) that routes tool I/O through the connected client when capabilities are advertised at `initialize`; populated from `AgentSideConnection` in ACP mode +- Added ACP `terminal/*` routing for `bash`: when the client advertises `terminal: true`, the tool creates a client-side terminal, embeds its `terminalId` on the live tool card, polls output, and releases the handle on exit or abort +- Added ACP `fs/read_text_file` and `fs/write_text_file` routing for the `read` and `write` tools: when the client advertises `fs.readTextFile` / `fs.writeTextFile`, plain-text reads/writes go through the editor (surfacing unsaved buffer content and letting the editor track agent writes); falls back to disk only for reads, throws on bridge write failures +- Added ACP `session/request_permission` gate around `bash`, `edit`, `write`, and `ast_edit` when an ACP client is connected; remembers `allow_always` / `reject_always` decisions per tool for the session lifetime +- Added `diff` `ToolCallContent` emission for edit tool results: per-file `oldText`/`newText` is threaded through `EditToolPerFileResult` / `EditToolDetails` so ACP clients can render inline diffs +- Added richer ACP `StopReason` mapping (`max_tokens`, `refusal`, `cancelled`) derived from the last assistant message's internal stop reason; previously only `end_turn`/`cancelled` were emitted +- Added `_meta.messageCount` and `_meta.size` on `session/list` `SessionInfo` entries +- Added ACP `tool_call_update` `locations` refresh from in-flight tool args and final result details so clients can "follow along" multi-file edits in real time ### Changed - Changed issue and pull-request list entries to link to repository-qualified URLs (for example `issue://owner/repo/`) so list items open correctly outside the default repo - Aligned prompt instruction language by defining `NEVER` and `AVOID` as strict aliases for `MUST NOT` and `SHOULD NOT` in the system prompt, and standardized agent, tool, and system prompt templates to use those terms consistently - +- Changed `--mode acp` to apply the same stdout-quiet overrides as `--mode rpc` so no banner or status text leaks into the JSON-RPC channel +- Changed ACP startup to no longer require a configured model so registry validators and clients can complete `initialize` and `authenticate` before any model is selected ### Fixed - Deferred flushing of buffered `credential_disabled` events during extension runner initialization to a microtask so handler failures are now routed through `onError()` registrations made immediately after `initialize()`, preserving extension error reporting @@ -33,6 +47,26 @@ - Fixed `runSubagent` (subagent task executor) carrying the same latent `AuthStorage`/`ModelRegistry` divergence as `createAgentSession()`: when only `options.modelRegistry` was supplied, the executor previously fell through to a fresh `discoverAuthStorage()` and handed that orphan into `createAgentSession()` alongside a registry whose `.authStorage` was a different instance. The executor now reconciles to `modelRegistry.authStorage` before any further work and rejects mismatched `options.authStorage`/`options.modelRegistry.authStorage` pairs the same way the SDK does, so subagents can no longer silently observe a different storage view than their parent. - Fixed `github` tool's `search_issues`/`search_prs`/`search_code`/`search_commits`/`search_repos` ops always returning 0 results when the query contained more than one qualifier (e.g. `is:merged is:pr`, `is:open author:foo`). `gh search …` since the `advanced_search=true` rollout in gh 2.92 silently wraps multi-token positional queries in parentheses and quotes everything after the first qualifier as that qualifier's value (`is:"merged is:pr"`), which GitHub then matches as a literal state filter that no PR can satisfy. The tool now calls `gh api -X GET /search/ -f q=… -F per_page=…` directly so the qualifiers reach GitHub's search API verbatim. `is:issue`/`is:pr` and `repo:/` are appended internally to preserve the previous CLI-flag behavior; the user-facing query string in the formatted output is unchanged. `state` for merged PRs is derived from `pull_request.merged_at` so the rendered `State:` line stays `merged`/`closed`/`open` as before. - Fixed `read` tool renderer rendering failed reads with a success check (`✓`) and styling the error message as file content while the surrounding box was red. The renderer now branches on `isError` for both file and URL paths: header shows `✘ Read ` with a proper error icon and the underlying message is rendered as an error line. `renderReadUrlResult` got the same treatment so failed URL reads also get the cross icon instead of falling through to the `"No response data"` Text fallback. Mirrors the `bash`/`find` renderer error pattern. +- Fixed ACP mode to advertise and handle non-TUI builtin slash commands and `/skill:` commands +- Fixed ACP `session/resume` and `session/close` to dispatch correctly under SDK 0.21 by renaming `unstable_resumeSession` / `unstable_closeSession` to the stable `resumeSession` / `closeSession` method names the SDK now routes to +- Fixed ACP `tool_call` / `tool_call_update` `locations` to always emit absolute paths (resolved against the session cwd) so editor clients can reliably open or focus the referenced file +- Fixed ACP edit `diff` metadata for moves to point at the destination path rather than the now-deleted source so post-edit "open file" actions land on the new file +- Fixed ACP `session/request_permission` `locations` to be absolute and to honor the `requestPermission` capability bit instead of only checking for the method, matching the read/write/bash capability gating +- Fixed ACP `authenticate` to reject `methodId` values that were not advertised by `initialize` so malformed clients fail fast instead of being treated as authenticated +- Fixed ACP mode changes made via `session/set_session_config_option` (`MODE_CONFIG_ID`) to also emit a `current_mode_update` notification, matching `session/set_mode` so clients tracking `modes.currentModeId` stay in sync +- Fixed `/model` ACP builtin to emit a `config_option_update` after switching models so clients show the new model in config selectors immediately +- Fixed `/mcp list` (ACP) to redact query strings and userinfo from server URLs before emitting them, so API keys embedded in URLs (e.g. `?exaApiKey=…`) are not leaked to clients +- Fixed `/mcp test|resources|prompts` (ACP) to wire the auth storage before `prepareConfig` so OAuth-backed MCP servers can refresh tokens and inject `Authorization` headers +- Fixed `/mcp list`, `/mcp test`, `/mcp resources`, `/mcp prompts`, `/mcp enable`, and `/mcp disable` (ACP) to preserve project-over-user precedence when the same server name is defined in both scopes, matching the runtime capability merge so toggling the duplicated name flips the effective entry +- Fixed `/ssh add --port` parsing to reject non-integer values (e.g. `22oops`) instead of silently coercing them via `Number.parseInt` +- Fixed `/ssh list` to deduplicate hosts shared between project and user scopes, listing project entries first to match capability-loader precedence +- Fixed `/export` (ACP) to reject clipboard aliases (`--copy`, `clipboard`, `copy`) instead of using them as the output filename +- Fixed ACP builtin commands (`/compact`, `/force`, `/move`, `/browser`) to surface underlying failures via `output()` instead of swallowing them +- Fixed `/session save|delete` (ACP) to route through the active `SessionManager` so the persist writer is consulted and stale storage references are removed +- Fixed `/reload-plugins`, `/marketplace install|uninstall|upgrade`, and `/plugins enable|disable` (ACP) to refresh slash command registries and emit `available_commands_update` after plugin state changes +- Fixed ACP `usage()` text emission to be awaited so help and error output is not dropped or reordered when commands return immediately +- Fixed ACP `bash` tool to release the client terminal handle on `terminal/output` or `waitForExit` failures, and to race output polling against abort so a stuck RPC cannot delay cancellation +- Fixed ACP `resource` content blocks with `image/*` MIME types to be routed into the LLM `images` array instead of being dropped as opaque blobs - Fixed `pr://` and `issue://` URLs accepting empty, `.`, or `..` path segments. `pr://owner//77`, `pr://owner/repo/77/diff//2`, and `pr://owner/../77/diff` previously slipped past the `.filter(Boolean)` split and were forwarded to `gh`; now they throw `Invalid :// URL: empty or unsafe path segment` before any subprocess work. - Fixed `read` of `issue://` / `pr://` URLs ignoring the read tool's `AbortSignal`. Aborting a long `pr:///diff/all` or stale issue fetch now propagates into the resolver and short-circuits at the handler entry; previously the `gh` round-trip and cache write ran to completion. - Fixed `read :raw` (and the `raw: true` arg) still rendering markdown internal-URL content through the formatted markdown renderer. The TUI now respects the raw selector and falls back to the code-cell renderer so verbatim bytes are shown when requested. diff --git a/packages/coding-agent/src/cli.ts b/packages/coding-agent/src/cli.ts index cafc4692e..e0e7e0f3f 100755 --- a/packages/coding-agent/src/cli.ts +++ b/packages/coding-agent/src/cli.ts @@ -50,6 +50,7 @@ process.title = APP_NAME; const commands: CommandEntry[] = [ { name: "launch", load: () => import("./commands/launch").then(m => m.default) }, + { name: "acp", load: () => import("./commands/acp").then(m => m.default) }, { name: "agents", load: () => import("./commands/agents").then(m => m.default) }, { name: "commit", load: () => import("./commands/commit").then(m => m.default) }, { name: "config", load: () => import("./commands/config").then(m => m.default) }, diff --git a/packages/coding-agent/src/commands/acp.ts b/packages/coding-agent/src/commands/acp.ts new file mode 100644 index 000000000..b62a5d71c --- /dev/null +++ b/packages/coding-agent/src/commands/acp.ts @@ -0,0 +1,24 @@ +/** + * Run Oh My Pi as an ACP (Agent Client Protocol) server over stdio. + * + * Thin wrapper around the launch flow that forces `mode: "acp"` unless the + * ACP terminal-auth flag asks the same command to open the interactive TUI. + */ +import { Command } from "@oh-my-pi/pi-utils/cli"; +import { parseArgs } from "../cli/args"; +import { runRootCommand } from "../main"; +import { prepareAcpTerminalAuthArgs } from "../modes/acp/terminal-auth"; + +export default class Acp extends Command { + static description = "Run Oh My Pi as an ACP (Agent Client Protocol) server over stdio"; + static strict = false; + + async run(): Promise { + const { args, terminalAuth } = prepareAcpTerminalAuthArgs(this.argv); + const parsed = parseArgs(args); + if (!terminalAuth) { + parsed.mode = "acp"; + } + await runRootCommand(parsed, args); + } +} diff --git a/packages/coding-agent/src/commands/launch.ts b/packages/coding-agent/src/commands/launch.ts index b81ab391d..c74392592 100644 --- a/packages/coding-agent/src/commands/launch.ts +++ b/packages/coding-agent/src/commands/launch.ts @@ -7,6 +7,7 @@ import { APP_NAME } from "@oh-my-pi/pi-utils"; import { Args, Command, Flags } from "@oh-my-pi/pi-utils/cli"; import { parseArgs } from "../cli/args"; import { runRootCommand } from "../main"; +import { prepareAcpTerminalAuthArgs } from "../modes/acp/terminal-auth"; export default class Index extends Command { static description = "AI coding assistant"; @@ -135,7 +136,8 @@ export default class Index extends Command { static strict = false; async run(): Promise { - const parsed = parseArgs(this.argv); - await runRootCommand(parsed, this.argv); + const { args } = prepareAcpTerminalAuthArgs(this.argv); + const parsed = parseArgs(args); + await runRootCommand(parsed, args); } } diff --git a/packages/coding-agent/src/edit/index.ts b/packages/coding-agent/src/edit/index.ts index 5cc9b3962..7c193e2d9 100644 --- a/packages/coding-agent/src/edit/index.ts +++ b/packages/coding-agent/src/edit/index.ts @@ -145,13 +145,15 @@ async function executeApplyPatchPerFile( const result = await run(batchRequest); const details = result.details; perFileResults.push({ - path, + path: details?.path ?? path, diff: details?.diff ?? "", firstChangedLine: details?.firstChangedLine, diagnostics: details?.diagnostics, op: details?.op, move: details?.move, meta: details?.meta, + oldText: details?.oldText, + newText: details?.newText, }); const text = result.content?.find(c => c.type === "text")?.text ?? ""; if (text) contentTexts.push(text); @@ -205,6 +207,11 @@ async function executeSinglePathEntries( const diffTexts: string[] = []; let firstChangedLine: number | undefined; let errorCount = 0; + let metadataPath: string | undefined; + let hasFirstOldText = false; + let firstOldText: string | undefined; + let hasLastNewText = false; + let lastNewText: string | undefined; for (let i = 0; i < runs.length; i++) { const isLast = i === runs.length - 1; @@ -217,6 +224,17 @@ async function executeSinglePathEntries( const details = result.details; if (details?.diff) diffTexts.push(details.diff); firstChangedLine ??= details?.firstChangedLine; + if (details?.path) { + metadataPath ??= details.path; + } + if (details && "oldText" in details && !hasFirstOldText) { + firstOldText = details.oldText; + hasFirstOldText = true; + } + if (details && "newText" in details) { + lastNewText = details.newText; + hasLastNewText = true; + } const text = result.content?.find(c => c.type === "text")?.text ?? ""; if (text) contentTexts.push(text); } catch (err) { @@ -242,6 +260,9 @@ async function executeSinglePathEntries( details: { diff: diffTexts.join("\n"), firstChangedLine, + path: metadataPath ?? path, + ...(hasFirstOldText ? { oldText: firstOldText } : {}), + ...(hasLastNewText ? { newText: lastNewText } : {}), }, // Any per-entry failure marks the aggregate result as an error so the // renderer takes the error branch instead of falling through to the diff --git a/packages/coding-agent/src/edit/modes/patch.ts b/packages/coding-agent/src/edit/modes/patch.ts index 8b21ce38b..6eb96b631 100644 --- a/packages/coding-agent/src/edit/modes/patch.ts +++ b/packages/coding-agent/src/edit/modes/patch.ts @@ -1772,15 +1772,25 @@ export async function executePatchSingle( .diagnostics(mergedDiagnostics?.summary ?? "", mergedDiagnostics?.messages ?? []) .get(); + const oldText = result.change.type !== "create" ? result.change.oldContent : undefined; + const newText = result.change.type !== "delete" ? result.change.newContent : undefined; + return { content: [{ type: "text", text: resultText }], details: { diff: diffResult.diff, + // When the patch moves the file, anchor the diff to the destination + // path. ACP `ToolCallContent.diff.path` comes from this field, and + // clients use it to open or focus the file post-change; pointing at + // the (now-deleted) source navigates to nothing. + path: result.change.newPath ?? resolvedPath, firstChangedLine: diffResult.firstChangedLine, diagnostics: mergedDiagnostics, op, move: effectiveRename, meta, + oldText, + newText, }, }; } diff --git a/packages/coding-agent/src/edit/modes/replace.ts b/packages/coding-agent/src/edit/modes/replace.ts index d0568cc82..ad0782941 100644 --- a/packages/coding-agent/src/edit/modes/replace.ts +++ b/packages/coding-agent/src/edit/modes/replace.ts @@ -1094,9 +1094,12 @@ export async function executeReplaceSingle( content: [{ type: "text", text: resultText }], details: { diff: diffResult.diff, + path: absolutePath, firstChangedLine: diffResult.firstChangedLine, diagnostics, meta, + oldText: rawContent, + newText: finalContent, }, }; } diff --git a/packages/coding-agent/src/edit/renderer.ts b/packages/coding-agent/src/edit/renderer.ts index 6d7163e15..111284a3c 100644 --- a/packages/coding-agent/src/edit/renderer.ts +++ b/packages/coding-agent/src/edit/renderer.ts @@ -55,6 +55,10 @@ export interface EditToolPerFileResult { * Set when the underlying error carries a `displayMessage` (e.g. {@link HashlineMismatchError}). */ displayErrorText?: string; meta?: OutputMeta; + /** Source-of-truth content before the edit; `undefined` for create operations. */ + oldText?: string; + /** Source-of-truth content after the edit; `undefined` for delete operations. */ + newText?: string; } export interface EditToolDetails { @@ -72,6 +76,12 @@ export interface EditToolDetails { meta?: OutputMeta; /** Per-file results (multi-file edits) */ perFileResults?: EditToolPerFileResult[]; + /** Absolute file path for single-file edit results. Required by ACP diff metadata consumers. */ + path?: string; + /** Source-of-truth content before the edit; `undefined` for create operations. */ + oldText?: string; + /** Source-of-truth content after the edit; `undefined` for delete operations. */ + newText?: string; } // ═══════════════════════════════════════════════════════════════════════════ diff --git a/packages/coding-agent/src/extensibility/skills.ts b/packages/coding-agent/src/extensibility/skills.ts index ee048738f..06c9e7e13 100644 --- a/packages/coding-agent/src/extensibility/skills.ts +++ b/packages/coding-agent/src/extensibility/skills.ts @@ -6,8 +6,8 @@ import type { SourceMeta } from "../capability/types"; import type { SkillsSettings } from "../config/settings"; import { type Skill as CapabilitySkill, loadCapability } from "../discovery"; import { compareSkillOrder, scanSkillsFromDir } from "../discovery/helpers"; +import type { SkillPromptDetails } from "../session/messages"; import { expandTilde } from "../tools/path-utils"; - export interface Skill { name: string; description: string; @@ -270,3 +270,35 @@ export async function loadSkills(options: LoadSkillsOptions = {}): Promise ({ skillPath: "", message: w })), ...collisionWarnings], }; } + +export interface BuiltSkillPromptMessage { + message: string; + details: SkillPromptDetails; +} + +export function getSkillSlashCommandName(skill: Pick): string { + return `skill:${skill.name}`; +} + +export async function buildSkillPromptMessage( + skill: Pick, + args: string, +): Promise { + const content = await Bun.file(skill.filePath).text(); + const body = content.replace(/^---\n[\s\S]*?\n---\n/, "").trim(); + const metaLines = [`Skill: ${skill.filePath}`]; + const trimmedArgs = args.trim(); + if (trimmedArgs) { + metaLines.push(`User: ${trimmedArgs}`); + } + const message = `${body}\n\n---\n\n${metaLines.join("\n")}`; + return { + message, + details: { + name: skill.name, + path: skill.filePath, + args: trimmedArgs || undefined, + lineCount: body ? body.split("\n").length : 0, + }, + }; +} diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index b8fb0cdde..234972e05 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -660,13 +660,13 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise { @@ -850,7 +850,7 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise; + liveMessageId: string | undefined; + liveMessageProgress: { textEmitted: boolean; thoughtEmitted: boolean } | undefined; extensionsConfigured: boolean; }; @@ -152,6 +163,7 @@ export class AcpAgent implements Agent { #sessions = new Map(); #disposePromise: Promise | undefined; #cleanupRegistered = false; + #clientCapabilities: ClientCapabilities | undefined; constructor(connection: AgentSideConnection, initialSession: AgentSession, createSession: CreateAcpSession) { this.#connection = connection; @@ -159,8 +171,25 @@ export class AcpAgent implements Agent { this.#createSession = createSession; } - async initialize(_params: InitializeRequest): Promise { + async initialize(params: InitializeRequest): Promise { this.#registerConnectionCleanup(); + this.#clientCapabilities = params.clientCapabilities; + const authMethods: AuthMethod[] = [ + { + id: "agent", + name: "Use existing local credentials", + description: "Authenticate via the provider keys/OAuth state already configured under ~/.omp.", + }, + ]; + if (params.clientCapabilities?.auth?.terminal === true) { + authMethods.push({ + type: "terminal", + id: "terminal", + name: "Set up Oh My Pi in terminal", + description: "Launch the omp TUI to add provider keys and select models.", + args: [ACP_TERMINAL_AUTH_FLAG], + }); + } return { protocolVersion: PROTOCOL_VERSION, agentInfo: { @@ -168,13 +197,7 @@ export class AcpAgent implements Agent { title: "Oh My Pi", version: VERSION, }, - authMethods: [ - { - id: "agent", - name: "Agent-managed authentication", - description: "Oh My Pi uses its existing local authentication and provider configuration.", - }, - ], + authMethods, agentCapabilities: { loadSession: true, mcpCapabilities: { @@ -195,7 +218,15 @@ export class AcpAgent implements Agent { }; } - async authenticate(_params: AuthenticateRequest): Promise { + async authenticate(params: AuthenticateRequest): Promise { + // ACP spec: `methodId` must be one of the methods advertised by `initialize`. + // Reject anything else so malformed clients fail fast rather than appearing + // authenticated and surfacing a downstream model failure later. + const supportsTerminalAuth = this.#clientCapabilities?.auth?.terminal === true; + const validMethods = supportsTerminalAuth ? ["agent", "terminal"] : ["agent"]; + if (!validMethods.includes(params.methodId)) { + throw new Error(`Unknown ACP auth method: ${params.methodId}`); + } return {}; } @@ -206,7 +237,7 @@ export class AcpAgent implements Agent { sessionId: record.session.sessionId, configOptions: this.#buildConfigOptions(record.session), models: this.#buildModelState(record.session), - modes: this.#buildModeState(), + modes: this.#buildModeState(record.session), }; this.#scheduleBootstrapUpdates(record.session.sessionId); return response; @@ -219,7 +250,7 @@ export class AcpAgent implements Agent { const response: LoadSessionResponse = { configOptions: this.#buildConfigOptions(record.session), models: this.#buildModelState(record.session), - modes: this.#buildModeState(), + modes: this.#buildModeState(record.session), }; this.#scheduleBootstrapUpdates(record.session.sessionId); return response; @@ -242,13 +273,13 @@ export class AcpAgent implements Agent { }; } - async unstable_resumeSession(params: ResumeSessionRequest): Promise { + async resumeSession(params: ResumeSessionRequest): Promise { this.#assertAbsoluteCwd(params.cwd); const record = await this.#resumeManagedSession(params.sessionId, params.cwd, params.mcpServers ?? []); const response: ResumeSessionResponse = { configOptions: this.#buildConfigOptions(record.session), models: this.#buildModelState(record.session), - modes: this.#buildModeState(), + modes: this.#buildModeState(record.session), }; this.#scheduleBootstrapUpdates(record.session.sessionId); return response; @@ -261,13 +292,13 @@ export class AcpAgent implements Agent { sessionId: record.session.sessionId, configOptions: this.#buildConfigOptions(record.session), models: this.#buildModelState(record.session), - modes: this.#buildModeState(), + modes: this.#buildModeState(record.session), }; this.#scheduleBootstrapUpdates(record.session.sessionId); return response; } - async unstable_closeSession(params: CloseSessionRequest): Promise { + async closeSession(params: CloseSessionRequest): Promise { const record = this.#sessions.get(params.sessionId); if (!record) { return {}; @@ -278,12 +309,17 @@ export class AcpAgent implements Agent { async setSessionMode(params: SetSessionModeRequest): Promise { const record = this.#getSessionRecord(params.sessionId); - if (params.modeId !== ACP_MODE_ID) { - throw new Error(`Unsupported ACP mode: ${params.modeId}`); - } + this.#applyModeChange(record.session, params.modeId); await this.#connection.sessionUpdate({ sessionId: record.session.sessionId, - update: this.#buildCurrentModeUpdate(), + update: this.#buildCurrentModeUpdate(record.session), + }); + await this.#connection.sessionUpdate({ + sessionId: record.session.sessionId, + update: { + sessionUpdate: "config_option_update", + configOptions: this.#buildConfigOptions(record.session), + }, }); return {}; } @@ -296,9 +332,7 @@ export class AcpAgent implements Agent { switch (params.configId) { case MODE_CONFIG_ID: - if (params.value !== ACP_MODE_ID) { - throw new Error(`Unsupported ACP mode config value: ${params.value}`); - } + this.#applyModeChange(record.session, params.value); break; case MODEL_CONFIG_ID: await this.#setModelById(record.session, params.value); @@ -310,6 +344,16 @@ export class AcpAgent implements Agent { throw new Error(`Unknown ACP config option: ${params.configId}`); } + // When mode is changed via the generic config-option API, mirror the + // `current_mode_update` notification that `setSessionMode` emits so + // ACP clients tracking session-mode state see a consistent transition. + if (params.configId === MODE_CONFIG_ID) { + await this.#connection.sessionUpdate({ + sessionId: record.session.sessionId, + update: this.#buildCurrentModeUpdate(record.session), + }); + } + const configOptions = this.#buildConfigOptions(record.session); await this.#connection.sessionUpdate({ sessionId: record.session.sessionId, @@ -356,13 +400,94 @@ export class AcpAgent implements Agent { void this.#handlePromptEvent(record, event); }); - record.session.prompt(converted.text, { images: converted.images }).catch((error: unknown) => { + this.#runPromptOrCommand(record, converted.text, converted.images).catch((error: unknown) => { this.#finishPrompt(record, undefined, error); }); return await pendingPrompt.promise; } + async #runPromptOrCommand(record: ManagedSessionRecord, text: string, images: AgentImageContent[]): Promise { + const skillResult = await this.#tryRunSkillCommand(record, text); + if (skillResult) { + return; + } + + const builtinResult = await executeAcpBuiltinSlashCommand(text, { + session: record.session, + sessionManager: record.session.sessionManager, + settings: Settings.instance, + cwd: record.session.sessionManager.getCwd(), + output: output => this.#emitCommandOutput(record, output), + refreshCommands: () => this.#emitAvailableCommandsUpdate(record), + reloadPlugins: () => this.#reloadPluginState(record), + notifyTitleChanged: async () => { + await this.#connection.sessionUpdate({ + sessionId: record.session.sessionId, + update: { + sessionUpdate: "session_info_update", + title: record.session.sessionName, + updatedAt: new Date().toISOString(), + }, + }); + }, + notifyConfigChanged: async () => { + await this.#connection.sessionUpdate({ + sessionId: record.session.sessionId, + update: { + sessionUpdate: "config_option_update", + configOptions: this.#buildConfigOptions(record.session), + }, + }); + }, + }); + if (builtinResult !== false) { + if ("prompt" in builtinResult) { + await record.session.prompt(builtinResult.prompt, { images }); + return; + } + const promptTurn = record.promptTurn; + this.#finishPrompt(record, { + stopReason: "end_turn", + usage: this.#buildTurnUsage( + promptTurn?.usageBaseline ?? + this.#cloneUsageStatistics(record.session.sessionManager.getUsageStatistics()), + record.session.sessionManager.getUsageStatistics(), + ), + userMessageId: promptTurn?.userMessageId, + }); + return; + } + + await record.session.prompt(text, { images }); + } + + async #tryRunSkillCommand(record: ManagedSessionRecord, text: string): Promise { + if (!text.startsWith("/skill:")) { + return false; + } + if (!record.session.skillsSettings?.enableSkillCommands) { + return false; + } + const spaceIndex = text.indexOf(" "); + const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex); + const args = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1).trim(); + const skillName = commandName.slice("skill:".length); + const skill = record.session.skills.find(candidate => candidate.name === skillName); + if (!skill) { + return false; + } + const built = await buildSkillPromptMessage(skill, args); + await record.session.promptCustomMessage({ + customType: SKILL_PROMPT_MESSAGE_TYPE, + content: built.message, + display: true, + details: built.details, + attribution: "user", + }); + return true; + } + async cancel(params: { sessionId: string }): Promise { const record = this.#getSessionRecord(params.sessionId); const promptTurn = record.promptTurn; @@ -384,7 +509,7 @@ export class AcpAgent implements Agent { async extMethod(method: string, params: { [key: string]: unknown }): Promise<{ [key: string]: unknown }> { switch (method) { - case "omp/sessions/listAll": { + case "_omp/sessions/listAll": { const limit = typeof params.limit === "number" ? Math.max(1, Math.min(5000, params.limit as number)) : 1000; const sessions = await SessionManager.listAll(); const sorted = sessions.sort((l, r) => r.modified.getTime() - l.modified.getTime()).slice(0, limit); @@ -393,7 +518,7 @@ export class AcpAgent implements Agent { total: sessions.length, }; } - case "omp/projects/list": { + case "_omp/projects/list": { const sessions = await SessionManager.listAll(); const buckets = new Map< string, @@ -421,7 +546,7 @@ export class AcpAgent implements Agent { const projects = Array.from(buckets.values()).sort((a, b) => b.lastActivityAt - a.lastActivityAt); return { projects, totalSessions: sessions.length }; } - case "omp/chats/byCwd": { + case "_omp/chats/byCwd": { const cwd = typeof params.cwd === "string" ? (params.cwd as string) : undefined; if (!cwd) throw new Error("cwd required"); const limit = typeof params.limit === "number" ? Math.max(1, Math.min(500, params.limit as number)) : 100; @@ -429,20 +554,20 @@ export class AcpAgent implements Agent { const sorted = sessions.sort((l, r) => r.modified.getTime() - l.modified.getTime()).slice(0, limit); return { sessions: sorted.map(s => this.#toSessionInfo(s)) }; } - case "omp/usage": { + case "_omp/usage": { const [firstRecord] = this.#sessions.values(); const target = firstRecord?.session ?? this.#initialSession; const reports = await target.fetchUsageReports(); return { reports: reports ?? [] }; } - case "omp/extensions": { + case "_omp/extensions": { const cwd = typeof params.cwd === "string" ? (params.cwd as string) : undefined; const sm = await Settings.init(); const disabledIds = (sm.get("disabledExtensions") as string[] | undefined) ?? []; const extensions = await loadAllExtensions(cwd, disabledIds); return { extensions: extensions as unknown as Array<{ [key: string]: unknown }> }; } - case "omp/extensions/toggle": { + case "_omp/extensions/toggle": { const providerId = params.providerId; if (typeof providerId !== "string") throw new Error("providerId required"); if (params.enabled === false) { @@ -562,6 +687,7 @@ export class AcpAgent implements Agent { async #registerPreparedSession(session: AgentSession, mcpServers: McpServer[]): Promise { const record = this.#createManagedSessionRecord(session); + session.setClientBridge(createAcpClientBridge(this.#connection, session.sessionId, this.#clientCapabilities)); try { await this.#configureExtensions(record); await this.#configureMcpServers(record, mcpServers); @@ -578,7 +704,8 @@ export class AcpAgent implements Agent { session, mcpManager: undefined, promptTurn: undefined, - liveMessageIds: new WeakMap(), + liveMessageId: undefined, + liveMessageProgress: undefined, extensionsConfigured: false, }; } @@ -627,33 +754,61 @@ export class AcpAgent implements Agent { return; } + this.#prepareLiveAssistantMessage(record, event); for (const notification of mapAgentSessionEventToAcpSessionUpdates(event, record.session.sessionId, { getMessageId: message => this.#getLiveMessageId(record, message), + getMessageProgress: message => this.#getLiveMessageProgress(record, message), + cwd: record.session.sessionManager.getCwd(), })) { await this.#connection.sessionUpdate(notification); } + this.#clearLiveAssistantMessageAfterEvent(record, event); if (event.type === "agent_end") { await this.#emitEndOfTurnUpdates(record); this.#finishPrompt(record, { - stopReason: promptTurn.cancelRequested ? "cancelled" : "end_turn", + stopReason: this.#resolveStopReason(event, promptTurn.cancelRequested), usage: this.#buildTurnUsage(promptTurn.usageBaseline, record.session.sessionManager.getUsageStatistics()), userMessageId: promptTurn.userMessageId, }); } } + #prepareLiveAssistantMessage(record: ManagedSessionRecord, event: AgentSessionEvent): void { + if ( + (event.type === "message_start" || event.type === "message_update" || event.type === "message_end") && + event.message.role === "assistant" && + (event.type === "message_start" || !record.liveMessageId || !record.liveMessageProgress) + ) { + record.liveMessageId = crypto.randomUUID(); + record.liveMessageProgress = { textEmitted: false, thoughtEmitted: false }; + } + } + + #clearLiveAssistantMessageAfterEvent(record: ManagedSessionRecord, event: AgentSessionEvent): void { + if ((event.type === "message_end" && event.message.role === "assistant") || event.type === "agent_end") { + record.liveMessageId = undefined; + record.liveMessageProgress = undefined; + } + } + #getLiveMessageId(record: ManagedSessionRecord, message: unknown): string | undefined { if (typeof message !== "object" || message === null) { return undefined; } - const existing = record.liveMessageIds.get(message); - if (existing) { - return existing; + record.liveMessageId ??= crypto.randomUUID(); + return record.liveMessageId; + } + + #getLiveMessageProgress( + record: ManagedSessionRecord, + message: unknown, + ): { textEmitted: boolean; thoughtEmitted: boolean } | undefined { + if (typeof message !== "object" || message === null) { + return undefined; } - const nextMessageId = crypto.randomUUID(); - record.liveMessageIds.set(message, nextMessageId); - return nextMessageId; + record.liveMessageProgress ??= { textEmitted: false, thoughtEmitted: false }; + return record.liveMessageProgress; } #finishPrompt(record: ManagedSessionRecord, response?: PromptResponse, error?: unknown): void { @@ -671,6 +826,48 @@ export class AcpAgent implements Agent { promptTurn.resolve(response ?? { stopReason: "end_turn" }); } + #resolveStopReason( + event: Extract, + cancelRequested: boolean, + ): PromptResponse["stopReason"] { + if (cancelRequested) { + return "cancelled"; + } + const lastAssistant = [...event.messages] + .reverse() + .find((message): message is AssistantMessage => message.role === "assistant"); + const reason = lastAssistant?.stopReason; + switch (reason) { + case "aborted": + return "cancelled"; + case "length": + return "max_tokens"; + case "error": { + const errorMessage = lastAssistant?.errorMessage ?? ""; + if (/content[_ ]?filter|refus(al|ed)/i.test(errorMessage)) { + return "refusal"; + } + return "end_turn"; + } + default: + return "end_turn"; + } + } + + async #emitCommandOutput(record: ManagedSessionRecord, text: string): Promise { + if (!text) { + return; + } + await this.#connection.sessionUpdate({ + sessionId: record.session.sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text }, + messageId: crypto.randomUUID(), + }, + }); + } + #assertAbsoluteCwd(cwd: string): void { if (!path.isAbsolute(cwd)) { throw new Error(`ACP cwd must be absolute: ${cwd}`); @@ -691,6 +888,12 @@ export class AcpAgent implements Agent { case "resource": if ("text" in block.resource) { textParts.push(block.resource.text); + } else if (typeof block.resource.mimeType === "string" && block.resource.mimeType.startsWith("image/")) { + // `embeddedContext: true` covers both text and blob resources, but + // blobs aren't directly consumable by the LLM. Route image blobs + // to the images array so the user's intent survives; everything + // else falls back to the URI placeholder below. + images.push({ type: "image", data: block.resource.blob, mimeType: block.resource.mimeType }); } else { textParts.push(`[embedded resource: ${block.resource.uri}]`); } @@ -710,14 +913,20 @@ export class AcpAgent implements Agent { } #buildConfigOptions(session: AgentSession): SessionConfigOption[] { + const currentModeId = this.#getCurrentModeId(session); + const modeOptions = this.#getAvailableModes(session).map(mode => ({ + value: mode.id, + name: mode.name, + description: mode.description, + })); const configOptions: SessionConfigOption[] = [ { id: MODE_CONFIG_ID, name: "Mode", category: "mode", type: "select", - currentValue: ACP_MODE_ID, - options: [{ value: ACP_MODE_ID, name: "Default", description: "Standard ACP headless mode" }], + currentValue: currentModeId, + options: modeOptions, }, ]; @@ -805,17 +1014,52 @@ export class AcpAgent implements Agent { return `${model.provider}/${model.id}`; } - #buildModeState(): SessionModeState { + #getAvailableModes(session: AgentSession): Array<{ id: string; name: string; description: string }> { + const modes = [{ id: ACP_DEFAULT_MODE_ID, name: "Default", description: "Standard ACP headless mode" }]; + if (Settings.instance.get("plan.enabled")) { + modes.push({ + id: ACP_PLAN_MODE_ID, + name: "Plan", + description: "Read-only planning mode that drafts a plan to a markdown file before any code changes", + }); + } + void session; + return modes; + } + + #getCurrentModeId(session: AgentSession): string { + return session.getPlanModeState()?.enabled ? ACP_PLAN_MODE_ID : ACP_DEFAULT_MODE_ID; + } + + #applyModeChange(session: AgentSession, modeId: string): void { + const availableModes = this.#getAvailableModes(session); + if (!availableModes.some(mode => mode.id === modeId)) { + throw new Error(`Unsupported ACP mode: ${modeId}`); + } + if (modeId === ACP_PLAN_MODE_ID) { + const previous = session.getPlanModeState(); + session.setPlanModeState({ + enabled: true, + planFilePath: previous?.planFilePath ?? DEFAULT_PLAN_FILE_URL, + workflow: previous?.workflow ?? "parallel", + reentry: previous !== undefined, + }); + } else { + session.setPlanModeState(undefined); + } + } + + #buildModeState(session: AgentSession): SessionModeState { return { - availableModes: [{ id: ACP_MODE_ID, name: "Default", description: "Standard ACP headless mode" }], - currentModeId: ACP_MODE_ID, + availableModes: this.#getAvailableModes(session), + currentModeId: this.#getCurrentModeId(session), }; } - #buildCurrentModeUpdate(): SessionUpdate { + #buildCurrentModeUpdate(session: AgentSession): SessionUpdate { return { sessionUpdate: "current_mode_update", - currentModeId: ACP_MODE_ID, + currentModeId: this.#getCurrentModeId(session), }; } @@ -830,6 +1074,24 @@ export class AcpAgent implements Agent { commands.push(command); }; + // Advertise in the order dispatch resolves them: ACP builtins first + // (so core commands like `/model`, `/mcp`, `/todo` cannot be shadowed), + // then skills, then custom/user commands, then file-based slash + // commands. `appendCommand` dedupes by name so earlier entries win. + for (const command of ACP_BUILTIN_SLASH_COMMANDS) { + appendCommand(command); + } + + if (session.skillsSettings?.enableSkillCommands) { + for (const skill of session.skills) { + appendCommand({ + name: getSkillSlashCommandName(skill), + description: skill.description || `Run ${skill.name} skill`, + input: { hint: "arguments" }, + }); + } + } + for (const command of session.customCommands) { appendCommand({ name: command.command.name, @@ -854,10 +1116,26 @@ export class AcpAgent implements Agent { cwd: session.cwd, title: session.title, updatedAt: session.modified.toISOString(), + _meta: { + messageCount: session.messageCount, + size: session.size, + }, }; } #scheduleBootstrapUpdates(sessionId: string): void { + // Delay the bootstrap so the client has time to handle the `session/new` + // (or `session/load` / `session/resume`) RPC response and register the + // new sessionId before we start firing notifications against it. Zed's + // agent-client-protocol reader dispatches responses and notifications + // to different async tasks; sending the first `available_commands_update` + // from `setTimeout(0)` reliably loses the race against the response + // handler and Zed logs `Received session notification for unknown + // session` then drops the update — leaving the slash-command palette + // empty (#1015 follow-up; see zed-industries/zed#55965 for the same + // race biting other ACP agents). 50ms is invisible to the operator and + // large enough that the response future has scheduled before our timer + // fires on stdio-only transports. setTimeout(() => { if (this.#connection.signal.aborted) { return; @@ -867,7 +1145,7 @@ export class AcpAgent implements Agent { return; } void this.#emitBootstrapUpdates(sessionId, record); - }, 0); + }, 50); } async #emitBootstrapUpdates(sessionId: string, record: ManagedSessionRecord): Promise { @@ -891,6 +1169,33 @@ export class AcpAgent implements Agent { }); } + async #emitAvailableCommandsUpdate(record: ManagedSessionRecord): Promise { + await this.#connection.sessionUpdate({ + sessionId: record.session.sessionId, + update: { + sessionUpdate: "available_commands_update", + availableCommands: await this.#buildAvailableCommands(record.session), + }, + }); + } + + /** + * Reload plugin/registry state for an ACP session. Mirrors the interactive + * `/reload-plugins` and `/move` flows: invalidates the plugin-roots cache, + * resets the capability cache, refreshes the session's slash-command state, + * then re-advertises commands so the client sees newly installed/disabled + * plugins. + */ + async #reloadPluginState(record: ManagedSessionRecord): Promise { + const cwd = record.session.sessionManager.getCwd(); + const projectPath = await resolveActiveProjectRegistryPath(cwd); + clearPluginRootsAndCaches(projectPath ? [projectPath] : undefined); + resetCapabilities(); + const fileCommands = await loadSlashCommands({ cwd }); + record.session.setSlashCommands(fileCommands); + await this.#emitAvailableCommandsUpdate(record); + } + async #emitEndOfTurnUpdates(record: ManagedSessionRecord): Promise { const sessionId = record.session.sessionId; @@ -981,14 +1286,15 @@ export class AcpAgent implements Agent { } async #replaySessionHistory(record: ManagedSessionRecord): Promise { + const cwd = record.session.sessionManager.getCwd(); for (const message of record.session.sessionManager.buildSessionContext().messages as ReplayableMessage[]) { - for (const notification of this.#messageToReplayNotifications(record.session.sessionId, message)) { + for (const notification of this.#messageToReplayNotifications(record.session.sessionId, message, cwd)) { await this.#connection.sessionUpdate(notification); } } } - #messageToReplayNotifications(sessionId: string, message: ReplayableMessage): SessionNotification[] { + #messageToReplayNotifications(sessionId: string, message: ReplayableMessage, cwd: string): SessionNotification[] { if (message.role === "assistant") { return this.#replayAssistantMessage(sessionId, message); } @@ -1010,7 +1316,7 @@ export class AcpAgent implements Agent { typeof message.toolCallId === "string" && typeof message.toolName === "string" ) { - return this.#replayToolResult(sessionId, { + return this.#replayToolResult(sessionId, cwd, { ...message, toolCallId: message.toolCallId, toolName: message.toolName, @@ -1102,6 +1408,7 @@ export class AcpAgent implements Agent { #replayToolResult( sessionId: string, + cwd: string, message: Required> & ReplayableMessage, ): SessionNotification[] { const args = this.#buildReplayToolArgs(message.details); @@ -1123,8 +1430,8 @@ export class AcpAgent implements Agent { }, }; return [ - ...mapAgentSessionEventToAcpSessionUpdates(startEvent, sessionId), - ...mapAgentSessionEventToAcpSessionUpdates(endEvent, sessionId), + ...mapAgentSessionEventToAcpSessionUpdates(startEvent, sessionId, { cwd }), + ...mapAgentSessionEventToAcpSessionUpdates(endEvent, sessionId, { cwd }), ]; } diff --git a/packages/coding-agent/src/modes/acp/acp-client-bridge.ts b/packages/coding-agent/src/modes/acp/acp-client-bridge.ts new file mode 100644 index 000000000..3eab0c987 --- /dev/null +++ b/packages/coding-agent/src/modes/acp/acp-client-bridge.ts @@ -0,0 +1,152 @@ +/** + * ACP-side `ClientBridge` implementation. Wraps `AgentSideConnection` so the + * `read`/`write`/`bash`/`edit` tools (and the permission gate in + * `AgentSession`) can route through the client when it advertises the + * relevant capabilities at `initialize` time. + */ +import type { + PermissionOption as AcpPermissionOption, + TerminalHandle as AcpTerminalHandle, + AgentSideConnection, + ClientCapabilities, + RequestPermissionRequest, + ToolCallUpdate, +} from "@agentclientprotocol/sdk"; +import type { + ClientBridge, + ClientBridgeCapabilities, + ClientBridgeCreateTerminalParams, + ClientBridgePermissionOption, + ClientBridgePermissionOutcome, + ClientBridgePermissionToolCall, + ClientBridgeTerminalHandle, +} from "../../session/client-bridge"; + +export function createAcpClientBridge( + connection: AgentSideConnection, + sessionId: string, + clientCapabilities: ClientCapabilities | undefined, +): ClientBridge { + const capabilities: ClientBridgeCapabilities = { + readTextFile: clientCapabilities?.fs?.readTextFile === true, + writeTextFile: clientCapabilities?.fs?.writeTextFile === true, + terminal: clientCapabilities?.terminal === true, + // Permission requests are always usable on the connection; gating is + // the agent's policy choice rather than a client capability. + requestPermission: true, + }; + + const bridge: ClientBridge = { capabilities }; + + if (capabilities.readTextFile) { + bridge.readTextFile = async params => { + const response = await connection.readTextFile({ + sessionId, + path: params.path, + ...(typeof params.line === "number" ? { line: params.line } : {}), + ...(typeof params.limit === "number" ? { limit: params.limit } : {}), + }); + return response.content; + }; + } + + if (capabilities.writeTextFile) { + bridge.writeTextFile = async params => { + await connection.writeTextFile({ + sessionId, + path: params.path, + content: params.content, + }); + }; + } + + if (capabilities.terminal) { + bridge.createTerminal = (params: ClientBridgeCreateTerminalParams) => + createTerminalHandle(connection, sessionId, params); + } + + bridge.requestPermission = (toolCall, options, signal) => + requestPermission(connection, sessionId, toolCall, options, signal); + + return bridge; +} + +async function createTerminalHandle( + connection: AgentSideConnection, + sessionId: string, + params: ClientBridgeCreateTerminalParams, +): Promise { + const handle = await connection.createTerminal({ + sessionId, + command: params.command, + ...(params.args ? { args: params.args } : {}), + ...(params.env ? { env: params.env } : {}), + ...(params.cwd ? { cwd: params.cwd } : {}), + ...(typeof params.outputByteLimit === "number" ? { outputByteLimit: params.outputByteLimit } : {}), + }); + return wrapTerminalHandle(handle); +} + +function wrapTerminalHandle(handle: AcpTerminalHandle): ClientBridgeTerminalHandle { + return { + terminalId: handle.id, + async currentOutput() { + const out = await handle.currentOutput(); + return { + output: out.output, + truncated: out.truncated, + exitStatus: out.exitStatus ?? null, + }; + }, + async waitForExit() { + const status = await handle.waitForExit(); + return { exitCode: status.exitCode ?? null, signal: status.signal ?? null }; + }, + async kill() { + await handle.kill(); + }, + async release() { + await handle.release(); + }, + }; +} + +async function requestPermission( + connection: AgentSideConnection, + sessionId: string, + toolCall: ClientBridgePermissionToolCall, + options: ClientBridgePermissionOption[], + signal: AbortSignal | undefined, +): Promise { + const update: ToolCallUpdate = { + toolCallId: toolCall.toolCallId, + title: toolCall.title, + ...(toolCall.kind ? { kind: toolCall.kind as ToolCallUpdate["kind"] } : {}), + ...(toolCall.rawInput !== undefined ? { rawInput: toolCall.rawInput } : {}), + ...(toolCall.locations ? { locations: toolCall.locations } : {}), + }; + const acpOptions: AcpPermissionOption[] = options.map(option => ({ + optionId: option.optionId, + name: option.name, + kind: option.kind, + })); + const request: RequestPermissionRequest = { + sessionId, + toolCall: update, + options: acpOptions, + }; + if (signal?.aborted) { + return { outcome: "cancelled" }; + } + const response = await connection.requestPermission(request); + const outcome = response.outcome; + if (outcome.outcome === "cancelled") { + return { outcome: "cancelled" }; + } + const matched = options.find(option => option.optionId === outcome.optionId); + return { + outcome: "selected", + optionId: outcome.optionId, + ...(matched ? { kind: matched.kind } : {}), + }; +} diff --git a/packages/coding-agent/src/modes/acp/acp-event-mapper.ts b/packages/coding-agent/src/modes/acp/acp-event-mapper.ts index f67a1c550..593fc205a 100644 --- a/packages/coding-agent/src/modes/acp/acp-event-mapper.ts +++ b/packages/coding-agent/src/modes/acp/acp-event-mapper.ts @@ -6,10 +6,24 @@ import type { ToolKind, } from "@agentclientprotocol/sdk"; import type { AgentSessionEvent } from "../../session/agent-session"; +import { resolveToCwd } from "../../tools/path-utils"; import type { TodoStatus } from "../../tools/todo-write"; +interface MessageProgress { + textEmitted: boolean; + thoughtEmitted: boolean; +} + interface AcpEventMapperOptions { getMessageId?: (message: unknown) => string | undefined; + getMessageProgress?: (message: unknown) => MessageProgress | undefined; + /** + * Session cwd. Tool call locations sent to ACP clients must be absolute + * (the editor host needs them to open or focus files). When provided, + * the mapper resolves raw `path`/`file`/etc. args against this cwd + * before emitting `ToolCallLocation` entries. + */ + cwd?: string; } interface ContentArrayContainer { @@ -127,6 +141,8 @@ export function mapAgentSessionEventToAcpSessionUpdates( switch (event.type) { case "message_update": return mapAssistantMessageUpdate(event, sessionId, options); + case "message_end": + return mapAssistantMessageEnd(event, sessionId, options); case "tool_execution_start": { const update: SessionUpdate = { sessionUpdate: "tool_call", @@ -136,14 +152,16 @@ export function mapAgentSessionEventToAcpSessionUpdates( status: "pending", rawInput: event.args, }; - const locations = extractToolLocations(event.args); + const locations = extractToolLocations(event.args, options.cwd); if (locations.length > 0) { update.locations = locations; } return [toSessionNotification(sessionId, update)]; } case "tool_execution_update": { - const content = extractToolCallContent(event.partialResult); + const terminalContent = extractTerminalToolCallContent(event.partialResult); + const otherContent = terminalContent.length > 0 ? [] : extractToolCallContent(event.partialResult); + const content = [...terminalContent, ...otherContent]; const update: SessionUpdate = { sessionUpdate: "tool_call_update", toolCallId: event.toolCallId, @@ -153,10 +171,17 @@ export function mapAgentSessionEventToAcpSessionUpdates( if (content.length > 0) { update.content = content; } + const locations = extractToolLocations(event.args, options.cwd); + if (locations.length > 0) { + update.locations = locations; + } return [toSessionNotification(sessionId, update)]; } case "tool_execution_end": { - const content = extractToolCallContent(event.result); + const diffContent = extractDiffToolCallContent(event.result); + const terminalContent = extractTerminalToolCallContent(event.result); + const otherContent = extractToolCallContent(event.result); + const content = [...diffContent, ...terminalContent, ...otherContent]; const update: SessionUpdate = { sessionUpdate: "tool_call_update", toolCallId: event.toolCallId, @@ -166,6 +191,10 @@ export function mapAgentSessionEventToAcpSessionUpdates( if (content.length > 0) { update.content = content; } + const locations = extractToolLocationsFromResult(event.result, options.cwd); + if (locations.length > 0) { + update.locations = locations; + } return [toSessionNotification(sessionId, update)]; } case "todo_reminder": { @@ -194,14 +223,31 @@ function mapAssistantMessageUpdate( let sessionUpdate: "agent_message_chunk" | "agent_thought_chunk"; let text: string; + const progress = options.getMessageProgress?.(event.message); switch (event.assistantMessageEvent.type) { case "text_delta": sessionUpdate = "agent_message_chunk"; text = event.assistantMessageEvent.delta; + if (text.length > 0 && progress) { + progress.textEmitted = true; + } break; case "thinking_delta": sessionUpdate = "agent_thought_chunk"; text = event.assistantMessageEvent.delta; + if (text.length > 0 && progress) { + progress.thoughtEmitted = true; + } + break; + case "done": + if (progress?.textEmitted) { + return []; + } + sessionUpdate = "agent_message_chunk"; + text = extractAssistantMessageText(event.assistantMessageEvent.message); + if (text.length > 0 && progress) { + progress.textEmitted = true; + } break; case "error": sessionUpdate = "agent_message_chunk"; @@ -224,6 +270,33 @@ function mapAssistantMessageUpdate( ]; } +function mapAssistantMessageEnd( + event: Extract, + sessionId: string, + options: AcpEventMapperOptions, +): SessionNotification[] { + if (!isAssistantMessage(event.message)) { + return []; + } + const progress = options.getMessageProgress?.(event.message); + if (!progress || progress.textEmitted) { + return []; + } + const text = extractAssistantMessageText(event.message); + if (text.length === 0) { + return []; + } + progress.textEmitted = true; + const messageId = options.getMessageId?.(event.message); + return [ + toSessionNotification(sessionId, { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text }, + messageId, + }), + ]; +} + function toSessionNotification(sessionId: string, update: SessionUpdate): SessionNotification { return { sessionId, update }; } @@ -257,26 +330,104 @@ function buildToolTitle(toolName: string, args: unknown, intent: string | undefi return toolName; } -function extractToolLocations(args: unknown): ToolCallLocation[] { +/** + * Resolve a single raw path against cwd for an ACP location. When `cwd` is + * omitted we pass the value through unchanged (callers without session + * context, e.g. some legacy entry points and tests); the ACP-side caller + * always supplies cwd so notifications carry absolute paths. + */ +function toAcpLocationPath(value: string, cwd?: string): string { + if (!cwd) return value; + try { + return resolveToCwd(value, cwd); + } catch { + return value; + } +} + +function extractToolLocations(args: unknown, cwd?: string): ToolCallLocation[] { const locations: ToolCallLocation[] = []; - const path = extractStringProperty(args, "path"); - if (path) { + const seen = new Set(); + const pushPath = (raw: string | undefined) => { + if (!raw) return; + const path = toAcpLocationPath(raw, cwd); + if (seen.has(path)) return; + seen.add(path); locations.push({ path }); - } + }; - const oldPath = extractStringProperty(args, "oldPath"); - if (oldPath && oldPath !== path) { - locations.push({ path: oldPath }); - } - - const newPath = extractStringProperty(args, "newPath"); - if (newPath && newPath !== path && newPath !== oldPath) { - locations.push({ path: newPath }); - } + pushPath(extractStringProperty(args, "path")); + pushPath(extractStringProperty(args, "oldPath")); + pushPath(extractStringProperty(args, "newPath")); return locations; } +/** Pull locations from a tool result's details (e.g. EditToolDetails.perFileResults[].path). */ +function extractToolLocationsFromResult(result: unknown, cwd?: string): ToolCallLocation[] { + if (typeof result !== "object" || result === null) return []; + const details = (result as { details?: unknown }).details; + if (typeof details !== "object" || details === null) return []; + const direct = extractToolLocations(details, cwd); + const perFile = (details as { perFileResults?: unknown }).perFileResults; + if (!Array.isArray(perFile)) { + return direct; + } + const seen = new Set(direct.map(loc => loc.path)); + const locations = [...direct]; + for (const entry of perFile) { + const raw = extractStringProperty(entry, "path"); + if (!raw) continue; + const path = toAcpLocationPath(raw, cwd); + if (seen.has(path)) continue; + seen.add(path); + locations.push({ path }); + } + return locations; +} + +/** Emit a `diff` ToolCallContent for each per-file edit result that carries oldText/newText. */ +function extractDiffToolCallContent(result: unknown): ToolCallContent[] { + if (typeof result !== "object" || result === null) return []; + const details = (result as { details?: unknown }).details; + if (typeof details !== "object" || details === null) return []; + const blocks: ToolCallContent[] = []; + const perFile = (details as { perFileResults?: unknown }).perFileResults; + const entries: unknown[] = Array.isArray(perFile) ? perFile : [details]; + for (const entry of entries) { + const block = buildDiffContent(entry); + if (block) blocks.push(block); + } + return blocks; +} + +function buildDiffContent(entry: unknown): ToolCallContent | undefined { + if (typeof entry !== "object" || entry === null) return undefined; + const candidate = entry as { path?: unknown; oldText?: unknown; newText?: unknown; isError?: unknown }; + if (candidate.isError === true) return undefined; + const path = typeof candidate.path === "string" && candidate.path.length > 0 ? candidate.path : undefined; + if (!path) return undefined; + const oldText = typeof candidate.oldText === "string" ? candidate.oldText : undefined; + const newText = typeof candidate.newText === "string" ? candidate.newText : undefined; + if (oldText === undefined && newText === undefined) return undefined; + return { + type: "diff", + path, + oldText: oldText ?? null, + newText: newText ?? "", + }; +} + +/** Emit a `terminal` ToolCallContent when a tool result carries a `details.terminalId` (e.g. bash routed through ACP terminal/*). */ +function extractTerminalToolCallContent(result: unknown): ToolCallContent[] { + if (typeof result !== "object" || result === null) return []; + const details = (result as { details?: unknown }).details; + if (typeof details !== "object" || details === null) return []; + const terminalId = (details as { terminalId?: unknown }).terminalId; + if (typeof terminalId !== "string" || terminalId.length === 0) return []; + return [{ type: "terminal", terminalId }]; +} + function extractToolCallContent(value: unknown): ToolCallContent[] { const richContent = extractStructuredToolCallContent(value); const fallbackText = extractReadableText(value); @@ -479,6 +630,20 @@ function extractReadableText(value: unknown): string | undefined { return normalizeText(serialized); } +function extractAssistantMessageText(value: unknown): string { + if (typeof value !== "object" || value === null || !("content" in value)) { + return ""; + } + const content = (value as ContentArrayContainer).content; + if (!Array.isArray(content)) { + return ""; + } + return content + .map(block => extractStructuredText(block)) + .filter((chunk): chunk is string => typeof chunk === "string" && chunk.length > 0) + .join("\n"); +} + function extractStructuredText(value: unknown): string | undefined { const text = extractStringProperty(value, "text"); if (!text) { diff --git a/packages/coding-agent/src/modes/acp/terminal-auth.ts b/packages/coding-agent/src/modes/acp/terminal-auth.ts new file mode 100644 index 000000000..3a195e1ee --- /dev/null +++ b/packages/coding-agent/src/modes/acp/terminal-auth.ts @@ -0,0 +1,37 @@ +export const ACP_TERMINAL_AUTH_FLAG = "--acp-terminal-auth"; + +export interface AcpTerminalAuthArgs { + args: string[]; + terminalAuth: boolean; +} + +export function prepareAcpTerminalAuthArgs(rawArgs: readonly string[]): AcpTerminalAuthArgs { + const withoutAuthFlag: string[] = []; + let terminalAuth = false; + for (const arg of rawArgs) { + if (arg === ACP_TERMINAL_AUTH_FLAG) { + terminalAuth = true; + continue; + } + withoutAuthFlag.push(arg); + } + + if (!terminalAuth) { + return { args: withoutAuthFlag, terminalAuth: false }; + } + + const args: string[] = []; + for (let i = 0; i < withoutAuthFlag.length; i++) { + const arg = withoutAuthFlag[i]; + if (arg === "--mode") { + i++; + continue; + } + if (arg.startsWith("--mode=")) { + continue; + } + args.push(arg); + } + + return { args, terminalAuth: true }; +} diff --git a/packages/coding-agent/src/modes/controllers/input-controller.ts b/packages/coding-agent/src/modes/controllers/input-controller.ts index b3885662d..09e694fe7 100644 --- a/packages/coding-agent/src/modes/controllers/input-controller.ts +++ b/packages/coding-agent/src/modes/controllers/input-controller.ts @@ -4,11 +4,12 @@ import { sanitizeText } from "@oh-my-pi/pi-natives"; import type { AutocompleteProvider, SlashCommand } from "@oh-my-pi/pi-tui"; import { $env } from "@oh-my-pi/pi-utils"; import { settings } from "../../config/settings"; +import { buildSkillPromptMessage } from "../../extensibility/skills"; import { createPromptActionAutocompleteProvider } from "../../modes/prompt-action-autocomplete"; import { theme } from "../../modes/theme/theme"; import type { InteractiveModeContext } from "../../modes/types"; import type { AgentSessionEvent } from "../../session/agent-session"; -import { SKILL_PROMPT_MESSAGE_TYPE, type SkillPromptDetails } from "../../session/messages"; +import { SKILL_PROMPT_MESSAGE_TYPE } from "../../session/messages"; import { executeBuiltinSlashCommand } from "../../slash-commands/builtin-registry"; import { copyToClipboard, readImageFromClipboard } from "../../utils/clipboard"; import { getEditorCommand, openInEditor } from "../../utils/external-editor"; diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index 95944f098..2411b46a9 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -1045,6 +1045,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} getModelString: () => (hasExplicitModel && model ? formatModelString(model) : undefined), getActiveModelString, getPlanModeState: () => session.getPlanModeState(), + getClientBridge: () => session?.clientBridge, getCompactContext: () => session.formatCompactContext(), getTodoPhases: () => session.getTodoPhases(), setTodoPhases: phases => session.setTodoPhases(phases), diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 6d2e80c9d..8aec02120 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -143,7 +143,7 @@ import { outputMeta } from "../tools/output-meta"; import { normalizeLocalScheme, resolveToCwd } from "../tools/path-utils"; import { isAutoQaEnabled } from "../tools/report-tool-issue"; import { getLatestTodoPhasesFromEntries, type TodoItem, type TodoPhase } from "../tools/todo-write"; -import { ToolError } from "../tools/tool-errors"; +import { ToolAbortError, ToolError } from "../tools/tool-errors"; import { clampTimeout } from "../tools/tool-timeouts"; import { parseCommandArgs } from "../utils/command-args"; import { type EditMode, resolveEditMode } from "../utils/edit-mode"; @@ -151,6 +151,7 @@ import { resolveFileDisplayMode } from "../utils/file-display-mode"; import { extractFileMentions, generateFileMentionMessages } from "../utils/file-mentions"; import { buildNamedToolChoice } from "../utils/tool-choice"; import type { AuthStorage } from "./auth-storage"; +import type { ClientBridge, ClientBridgePermissionOption, ClientBridgePermissionOutcome } from "./client-bridge"; import { CompactionCancelledError, type CompactionPreparation, @@ -499,6 +500,96 @@ const noOpUIContext: ExtensionUIContext = { setToolsExpanded: () => {}, }; +// ============================================================================ +// ACP Permission Gate +// ============================================================================ + +/** Tools that require user permission before execution when an ACP client is connected. */ +const PERMISSION_REQUIRED_TOOLS = new Set(["bash", "edit", "write", "ast_edit", "delete", "move"]); + +/** Permission options presented to the client on each gated tool call. */ +const PERMISSION_OPTIONS: ClientBridgePermissionOption[] = [ + { optionId: "allow_once", name: "Allow once", kind: "allow_once" }, + { optionId: "allow_always", name: "Always allow", kind: "allow_always" }, + { optionId: "reject_once", name: "Reject", kind: "reject_once" }, + { optionId: "reject_always", name: "Always reject", kind: "reject_always" }, +]; + +const PERMISSION_OPTIONS_BY_ID = new Map(PERMISSION_OPTIONS.map(option => [option.optionId, option])); + +function derivePermissionTitle(toolName: string, args: unknown): string { + const a = args && typeof args === "object" ? (args as Record) : {}; + if (toolName === "bash") { + const cmd = typeof a.command === "string" ? a.command.slice(0, 80) : undefined; + if (cmd) return cmd; + } else if (toolName === "edit" || toolName === "write" || toolName === "delete") { + const p = typeof a.path === "string" ? a.path : undefined; + if (p) { + const verb = toolName === "edit" ? "Edit" : toolName === "write" ? "Write" : "Delete"; + return `${verb} ${p}`; + } + } else if (toolName === "move") { + const from = + typeof a.oldPath === "string" + ? a.oldPath + : typeof a.path === "string" + ? a.path + : typeof a.from === "string" + ? a.from + : undefined; + const to = + typeof a.newPath === "string" + ? a.newPath + : typeof a.to === "string" + ? a.to + : typeof a.destination === "string" + ? a.destination + : undefined; + if (from && to) return `Move ${from} to ${to}`; + if (from) return `Move ${from}`; + } else if (toolName === "ast_edit") { + const paths = Array.isArray(a.paths) + ? (a.paths as unknown[]).filter(x => typeof x === "string").join(", ") + : undefined; + if (paths) return `AST edit ${paths}`; + } + return toolName; +} + +function extractPermissionLocations(args: unknown, cwd: string): { path: string; line?: number }[] { + if (!args || typeof args !== "object") return []; + const a = args as Record; + const out: { path: string; line?: number }[] = []; + const pushPath = (value: unknown) => { + if (typeof value !== "string" || value.length === 0) return; + // ACP locations carry file paths that the editor host will open or focus; + // they must be absolute or the client cannot resolve them. Resolve raw + // tool args (often cwd-relative) against the session cwd before sending. + let resolved: string; + try { + resolved = resolveToCwd(value, cwd); + } catch { + return; + } + if (out.some(location => location.path === resolved)) return; + out.push({ path: resolved }); + }; + pushPath(a.path); + pushPath(a.file); + if (Array.isArray(a.paths)) { + for (const p of a.paths) { + pushPath(p); + } + } + pushPath(a.oldPath); + pushPath(a.newPath); + pushPath(a.from); + pushPath(a.to); + pushPath(a.source); + pushPath(a.destination); + return out; +} + // ============================================================================ // AgentSession Class // ============================================================================ @@ -531,6 +622,9 @@ export class AgentSession { #planModeState: PlanModeState | undefined; #planReferenceSent = false; #planReferencePath = "local://PLAN.md"; + #clientBridge: ClientBridge | undefined; + /** Per-session memory of allow_always / reject_always decisions for gated tools. */ + #acpPermissionDecisions: Map = new Map(); // Compaction state #compactionAbortController: AbortController | undefined = undefined; @@ -2548,6 +2642,85 @@ export class AgentSession { return [...new Set(activated)]; } + /** + * Wrap a tool with a permission-gate proxy when an ACP client is connected. + * Only wraps tools whose name is in PERMISSION_REQUIRED_TOOLS and only when + * the bridge exposes `requestPermission`. No-ops for all other cases. + */ + #wrapToolForAcpPermission(tool: T): T { + const bridge = this.#clientBridge; + // Match the capability+method gating pattern used by read/write/bash. + if (!bridge?.capabilities.requestPermission || !bridge.requestPermission) return tool; + if (!PERMISSION_REQUIRED_TOOLS.has(tool.name)) return tool; + return new Proxy(tool, { + get: (target, prop, receiver) => { + if (prop !== "execute") return Reflect.get(target, prop, receiver); + return async ( + toolCallId: string, + args: unknown, + signal: AbortSignal | undefined, + onUpdate: never, + ctx: never, + ) => { + // Short-circuit on persisted decisions. + const persisted = this.#acpPermissionDecisions.get(target.name); + if (persisted === "allow_always") { + return await target.execute(toolCallId, args as never, signal, onUpdate, ctx); + } + if (persisted === "reject_always") { + throw new ToolError(`Tool call rejected by user (preference)`); + } + if (signal?.aborted) { + throw new ToolAbortError("Permission request cancelled"); + } + type PermissionRaceResult = + | { kind: "permission"; outcome: ClientBridgePermissionOutcome } + | { kind: "aborted" }; + const { promise: abortPromise, resolve: resolveAbort } = Promise.withResolvers(); + const onAbort = () => resolveAbort({ kind: "aborted" }); + signal?.addEventListener("abort", onAbort, { once: true }); + let raced: PermissionRaceResult; + try { + const permissionPromise = bridge.requestPermission!( + { + toolCallId, + toolName: target.name, + title: derivePermissionTitle(target.name, args), + rawInput: args, + locations: extractPermissionLocations(args, this.sessionManager.getCwd()), + }, + PERMISSION_OPTIONS, + signal, + ).then(outcome => ({ kind: "permission" as const, outcome })); + raced = await Promise.race([permissionPromise, abortPromise]); + } finally { + signal?.removeEventListener("abort", onAbort); + } + if (raced.kind === "aborted" || signal?.aborted) { + throw new ToolAbortError("Permission request cancelled"); + } + const outcome = raced.outcome; + if (outcome.outcome === "cancelled") { + throw new ToolAbortError("Permission request cancelled"); + } + const selectedOption = PERMISSION_OPTIONS_BY_ID.get(outcome.optionId); + if (!selectedOption) { + throw new ToolError(`Tool permission response used unknown option ID: ${outcome.optionId}`); + } + if (selectedOption.kind === "allow_always") { + this.#acpPermissionDecisions.set(target.name, "allow_always"); + } else if (selectedOption.kind === "reject_always") { + this.#acpPermissionDecisions.set(target.name, "reject_always"); + } + if (selectedOption.kind === "reject_once" || selectedOption.kind === "reject_always") { + throw new ToolError(`Tool call rejected by user (${target.name})`); + } + return await target.execute(toolCallId, args as never, signal, onUpdate, ctx); + }; + }, + }) as T; + } + async #applyActiveToolsByName( toolNames: string[], options?: { persistMCPSelection?: boolean; previousSelectedMCPToolNames?: string[] }, @@ -2559,7 +2732,7 @@ export class AgentSession { for (const name of toolNames) { const tool = this.#toolRegistry.get(name); if (tool) { - tools.push(tool); + tools.push(this.#wrapToolForAcpPermission(tool)); validToolNames.push(name); } } @@ -2567,7 +2740,7 @@ export class AgentSession { if (isAutoQaEnabled(this.settings) && !validToolNames.includes("report_tool_issue")) { const qaTool = this.#toolRegistry.get("report_tool_issue"); if (qaTool) { - tools.push(qaTool); + tools.push(this.#wrapToolForAcpPermission(qaTool)); validToolNames.push("report_tool_issue"); } } @@ -2975,6 +3148,21 @@ export class AgentSession { this.#planReferencePath = path; } + get clientBridge(): ClientBridge | undefined { + return this.#clientBridge; + } + + setClientBridge(bridge: ClientBridge | undefined): void { + this.#clientBridge = bridge; + this.#acpPermissionDecisions.clear(); + const activeToolNames = this.getActiveToolNames(); + const activeTools = activeToolNames + .map(name => this.#toolRegistry.get(name)) + .filter((tool): tool is AgentTool => tool !== undefined) + .map(tool => this.#wrapToolForAcpPermission(tool)); + this.agent.setTools(activeTools); + } + getCheckpointState(): CheckpointState | undefined { return this.#checkpointState; } diff --git a/packages/coding-agent/src/session/client-bridge.ts b/packages/coding-agent/src/session/client-bridge.ts new file mode 100644 index 000000000..1422b57ad --- /dev/null +++ b/packages/coding-agent/src/session/client-bridge.ts @@ -0,0 +1,81 @@ +/** + * ClientBridge — abstraction over capabilities provided by an external client + * (e.g. ACP editor host) that the agent can route through instead of operating + * directly on the local filesystem / spawning local subprocesses. + * + * When `undefined`, tools fall back to local IO. When populated (currently + * only by `AcpAgent`), tools route requests through the client so it can + * surface unsaved buffer state, render terminals in the IDE, or gate + * destructive operations behind user permission prompts. + */ + +export interface ClientBridgeCapabilities { + /** Client implements `fs/read_text_file`. */ + readTextFile?: boolean; + /** Client implements `fs/write_text_file`. */ + writeTextFile?: boolean; + /** Client implements the `terminal/*` family. */ + terminal?: boolean; + /** Client implements `session/request_permission`. */ + requestPermission?: boolean; +} + +export interface ClientBridgePermissionToolCall { + toolCallId: string; + toolName: string; + title: string; + kind?: string; + rawInput?: unknown; + locations?: { path: string; line?: number }[]; +} + +export type ClientBridgePermissionOptionKind = "allow_once" | "allow_always" | "reject_once" | "reject_always"; + +export interface ClientBridgePermissionOption { + optionId: string; + name: string; + kind: ClientBridgePermissionOptionKind; +} + +export type ClientBridgePermissionOutcome = + | { outcome: "cancelled" } + | { outcome: "selected"; optionId: string; kind?: ClientBridgePermissionOptionKind }; + +export interface ClientBridgeTerminalExitStatus { + exitCode?: number | null; + signal?: string | null; +} + +export interface ClientBridgeTerminalOutput { + output: string; + truncated: boolean; + exitStatus?: ClientBridgeTerminalExitStatus | null; +} + +export interface ClientBridgeTerminalHandle { + terminalId: string; + waitForExit(): Promise; + currentOutput(): Promise; + kill(): Promise; + release(): Promise; +} + +export interface ClientBridgeCreateTerminalParams { + command: string; + args?: string[]; + env?: Array<{ name: string; value: string }>; + cwd?: string; + outputByteLimit?: number; +} + +export interface ClientBridge { + readonly capabilities: ClientBridgeCapabilities; + readTextFile?(params: { path: string; line?: number; limit?: number }): Promise; + writeTextFile?(params: { path: string; content: string }): Promise; + createTerminal?(params: ClientBridgeCreateTerminalParams): Promise; + requestPermission?( + toolCall: ClientBridgePermissionToolCall, + options: ClientBridgePermissionOption[], + signal?: AbortSignal, + ): Promise; +} diff --git a/packages/coding-agent/src/slash-commands/acp-builtins.ts b/packages/coding-agent/src/slash-commands/acp-builtins.ts new file mode 100644 index 000000000..15c7b335d --- /dev/null +++ b/packages/coding-agent/src/slash-commands/acp-builtins.ts @@ -0,0 +1,46 @@ +import type { AvailableCommand } from "@agentclientprotocol/sdk"; +import { BUILTIN_SLASH_COMMANDS_INTERNAL, lookupBuiltinSlashCommand } from "./builtin-registry"; +import { parseSlashCommand } from "./helpers/parse"; +import type { AcpBuiltinCommandRuntime, AcpBuiltinSlashCommandResult } from "./types"; + +export type { AcpBuiltinCommandRuntime, AcpBuiltinSlashCommandResult } from "./types"; + +/** + * Commands advertised to ACP clients. Entries without a text-mode `handle` + * (e.g. `/quit`, `/login`, dashboards) are filtered out so the client doesn't + * see commands it cannot drive. + */ +export const ACP_BUILTIN_SLASH_COMMANDS: AvailableCommand[] = BUILTIN_SLASH_COMMANDS_INTERNAL.filter( + command => command.handle !== undefined, +).map(command => { + // Honor mode-specific copy: ACP clients receive concise text-mode + // descriptions/hints when the spec sets `acpDescription` / `acpInputHint`, + // otherwise fall back to the unified `description` / `inlineHint`. + const hint = command.acpInputHint ?? command.inlineHint; + return { + name: command.name, + description: command.acpDescription ?? command.description, + input: hint ? { hint } : undefined, + }; +}); + +/** + * Dispatch a slash command in ACP/text mode. Returns: + * - `false` when no builtin matched (or matched a TUI-only entry); the caller + * should forward the input as a prompt. + * - `{ consumed: true }` when the command handled the input entirely. + * - `{ prompt }` when the command was handled but a residual prompt should be + * sent to the model. + */ +export async function executeAcpBuiltinSlashCommand( + text: string, + runtime: AcpBuiltinCommandRuntime, +): Promise { + const parsed = parseSlashCommand(text); + if (!parsed) return false; + const command = lookupBuiltinSlashCommand(parsed.name); + if (!command || !command.handle) return false; + const result = await command.handle(parsed, runtime); + if (result === undefined) return { consumed: true }; + return result; +} diff --git a/packages/coding-agent/src/slash-commands/builtin-registry.ts b/packages/coding-agent/src/slash-commands/builtin-registry.ts index 96df2ca12..3ba387279 100644 --- a/packages/coding-agent/src/slash-commands/builtin-registry.ts +++ b/packages/coding-agent/src/slash-commands/builtin-registry.ts @@ -1,4 +1,9 @@ +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; import { getOAuthProviders } from "@oh-my-pi/pi-ai/utils/oauth"; +import { setProjectDir, Snowflake } from "@oh-my-pi/pi-utils"; +import { $ } from "bun"; import type { SettingPath, SettingValue } from "../config/settings"; import { settings } from "../config/settings"; import { @@ -14,8 +19,31 @@ import { getPluginsCacheDir, MarketplaceManager, } from "../extensibility/plugins/marketplace"; +import { resolveMemoryBackend } from "../memory-backend"; import type { InteractiveModeContext } from "../modes/types"; +import { getChangelogPath, parseChangelog } from "../utils/changelog"; +import { buildContextReportText } from "./helpers/context-report"; +import { createMarketplaceManager } from "./helpers/marketplace-manager"; +import { handleMcpAcp } from "./helpers/mcp"; +import { commandConsumed, errorMessage, parseSlashCommand, parseSubcommand, usage } from "./helpers/parse"; +import { handleSshAcp } from "./helpers/ssh"; +import { handleTodoAcp } from "./helpers/todo"; +import { buildUsageReportText } from "./helpers/usage-report"; +import { formatDuration } from "./helpers/format"; import { parseMarketplaceInstallArgs, parsePluginScopeArgs } from "./marketplace-install-parser"; +import type { + BuiltinSlashCommand, + ParsedSlashCommand, + SlashCommandResult, + SlashCommandRuntime, + SlashCommandSpec, + TuiSlashCommandRuntime, +} from "./types"; + +export type { BuiltinSlashCommand, SubcommandDef } from "./types"; + +/** TUI-specific runtime accepted by `executeBuiltinSlashCommand`. */ +export type BuiltinSlashCommandRuntime = TuiSlashCommandRuntime; function refreshStatusLine(ctx: InteractiveModeContext): void { ctx.statusLine.invalidate(); @@ -23,84 +51,16 @@ function refreshStatusLine(ctx: InteractiveModeContext): void { ctx.ui.requestRender(); } -/** Declarative subcommand definition for commands like /mcp. */ -export interface SubcommandDef { - name: string; - description: string; - /** Usage hint shown as dim ghost text, e.g. " [--scope project|user]". */ - usage?: string; -} - -/** Declarative builtin slash command definition used by autocomplete and help UI. */ -export interface BuiltinSlashCommand { - name: string; - description: string; - /** Subcommands for dropdown completion (e.g. /mcp add, /mcp list). */ - subcommands?: SubcommandDef[]; - /** Static inline hint when command takes a simple argument (no subcommands). */ - inlineHint?: string; -} - -interface ParsedBuiltinSlashCommand { - name: string; - args: string; - text: string; -} - -interface BuiltinSlashCommandSpec extends BuiltinSlashCommand { - aliases?: string[]; - allowArgs?: boolean; - /** - * Handle the command. Return a string to pass remaining text through as prompt input. - * Return void/undefined to consume the input entirely. - */ - handle: ( - command: ParsedBuiltinSlashCommand, - runtime: BuiltinSlashCommandRuntime, - // biome-ignore lint/suspicious/noConfusingVoidType: void needed so async handlers returning nothing are assignable - ) => Promise | string | void; -} - -export interface BuiltinSlashCommandRuntime { - ctx: InteractiveModeContext; - handleBackgroundCommand: () => void; -} - -function parseBuiltinSlashCommand(text: string): ParsedBuiltinSlashCommand | null { - if (!text.startsWith("/")) return null; - const body = text.slice(1); - if (!body) return null; - - const firstWhitespace = body.search(/\s/); - const firstColon = body.indexOf(":"); - const firstSeparator = - firstWhitespace === -1 ? firstColon : firstColon === -1 ? firstWhitespace : Math.min(firstWhitespace, firstColon); - - if (firstSeparator === -1) { - return { - name: body, - args: "", - text, - }; - } - - return { - name: body.slice(0, firstSeparator), - args: body.slice(firstSeparator + 1).trim(), - text, - }; -} - -const shutdownHandler = (_command: ParsedBuiltinSlashCommand, runtime: BuiltinSlashCommandRuntime): void => { +const shutdownHandlerTui = (_command: ParsedSlashCommand, runtime: TuiSlashCommandRuntime): void => { runtime.ctx.editor.setText(""); void runtime.ctx.shutdown(); }; -const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ +const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ { name: "settings", description: "Open settings menu", - handle: (_command, runtime) => { + handleTui: (_command, runtime) => { runtime.ctx.showSettingsSelector(); runtime.ctx.editor.setText(""); }, @@ -110,7 +70,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ description: "Toggle plan mode (agent plans before executing)", inlineHint: "[prompt]", allowArgs: true, - handle: async (command, runtime) => { + handleTui: async (command, runtime) => { await runtime.ctx.handlePlanModeCommand(command.args || undefined); runtime.ctx.editor.setText(""); }, @@ -121,7 +81,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ "Toggle loop mode. While enabled, the next prompt you send re-submits after every yield. Esc cancels the current iteration; /loop again to disable.", inlineHint: "[count|duration]", allowArgs: true, - handle: async (command, runtime) => { + handleTui: async (command, runtime) => { await runtime.ctx.handleLoopCommand(command.args); runtime.ctx.editor.setText(""); }, @@ -130,7 +90,38 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ name: "model", aliases: ["models"], description: "Select model (opens selector UI)", - handle: (_command, runtime) => { + acpDescription: "Show current model selection", + handle: async (command, runtime) => { + if (command.args) { + const modelId = command.args.trim(); + const availableModels = runtime.session.getAvailableModels?.() ?? []; + const match = availableModels.find( + model => model.id === modelId || `${model.provider}/${model.id}` === modelId, + ); + if (!match) { + return usage( + `Unknown model: ${modelId}. Use ACP \`session/setModel\` for picker-driven selection or list available models with /model.`, + runtime, + ); + } + try { + await runtime.session.setModel(match); + await runtime.output(`Model set to ${match.provider}/${match.id}.`); + await runtime.notifyTitleChanged?.(); + await runtime.notifyConfigChanged?.(); + return commandConsumed(); + } catch (err) { + return usage(`Failed to set model: ${errorMessage(err)}`, runtime); + } + } + + const model = runtime.session.model; + await runtime.output( + model ? `Current model: ${model.provider}/${model.id}` : "No model is currently selected.", + ); + return commandConsumed(); + }, + handleTui: (_command, runtime) => { runtime.ctx.showModelSelector(); runtime.ctx.editor.setText(""); }, @@ -138,13 +129,38 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ { name: "fast", description: "Toggle fast mode (OpenAI service tier priority)", + acpDescription: "Toggle fast mode", + acpInputHint: "[on|off|status]", subcommands: [ { name: "on", description: "Enable fast mode" }, { name: "off", description: "Disable fast mode" }, { name: "status", description: "Show fast mode status" }, ], allowArgs: true, - handle: (command, runtime) => { + handle: async (command, runtime) => { + const arg = command.args.toLowerCase(); + if (!arg || arg === "toggle") { + const enabled = runtime.session.toggleFastMode(); + await runtime.output(`Fast mode ${enabled ? "enabled" : "disabled"}.`); + return commandConsumed(); + } + if (arg === "on") { + runtime.session.setFastMode(true); + await runtime.output("Fast mode enabled."); + return commandConsumed(); + } + if (arg === "off") { + runtime.session.setFastMode(false); + await runtime.output("Fast mode disabled."); + return commandConsumed(); + } + if (arg === "status") { + await runtime.output(`Fast mode is ${runtime.session.isFastModeEnabled() ? "on" : "off"}.`); + return commandConsumed(); + } + return usage("Usage: /fast [on|off|status]", runtime); + }, + handleTui: (command, runtime) => { const arg = command.args.trim().toLowerCase(); if (!arg || arg === "toggle") { const enabled = runtime.ctx.session.toggleFastMode(); @@ -183,6 +199,23 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ inlineHint: "[path]", allowArgs: true, handle: async (command, runtime) => { + const arg = command.args.trim(); + // Match the interactive `/export` behavior: clipboard aliases are not a + // valid export target. Without this, the literal value (`copy`, + // `--copy`, `clipboard`) is passed to `exportToHtml` and becomes the + // output filename. + if (arg === "--copy" || arg === "clipboard" || arg === "copy") { + return usage("Use /dump to copy the session to clipboard.", runtime); + } + try { + const filePath = await runtime.session.exportToHtml(arg || undefined); + await runtime.output(`Session exported to: ${filePath}`); + return commandConsumed(); + } catch (err) { + return usage(`Failed to export session: ${errorMessage(err)}`, runtime); + } + }, + handleTui: async (command, runtime) => { await runtime.ctx.handleExportCommand(command.text); runtime.ctx.editor.setText(""); }, @@ -190,7 +223,13 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ { name: "dump", description: "Copy session transcript to clipboard", + acpDescription: "Return full transcript as plain text", handle: async (_command, runtime) => { + const text = runtime.session.formatSessionAsText(); + await runtime.output(text || "No messages to dump yet."); + return commandConsumed(); + }, + handleTui: async (_command, runtime) => { await runtime.ctx.handleDumpCommand(); runtime.ctx.editor.setText(""); }, @@ -199,6 +238,32 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ name: "share", description: "Share session as a secret GitHub gist", handle: async (_command, runtime) => { + const tmpFile = path.join(os.tmpdir(), `${Snowflake.next()}.html`); + try { + try { + await runtime.session.exportToHtml(tmpFile); + } catch (err) { + return usage(`Failed to export session: ${errorMessage(err)}`, runtime); + } + const result = await $`gh gist create --public=false ${tmpFile}`.quiet().nothrow(); + if (result.exitCode !== 0) { + return usage( + `Failed to create gist: ${result.stderr.toString("utf-8").trim() || "unknown error"}`, + runtime, + ); + } + const gistUrl = result.stdout.toString("utf-8").trim(); + const gistId = gistUrl.split("/").pop(); + if (!gistId) return usage("Failed to parse gist ID from gh output", runtime); + await runtime.output(`Share URL: https://gistpreview.github.io/?${gistId}\nGist: ${gistUrl}`); + return commandConsumed(); + } catch { + return usage("GitHub CLI (gh) is required for /share. Install it from https://cli.github.com/.", runtime); + } finally { + await fs.rm(tmpFile, { force: true }).catch(() => {}); + } + }, + handleTui: async (_command, runtime) => { await runtime.ctx.handleShareCommand(); runtime.ctx.editor.setText(""); }, @@ -206,12 +271,40 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ { name: "browser", description: "Toggle browser headless vs visible mode", + acpInputHint: "[headless|visible]", subcommands: [ { name: "headless", description: "Switch to headless mode" }, { name: "visible", description: "Switch to visible mode" }, ], allowArgs: true, handle: async (command, runtime) => { + const arg = command.args.toLowerCase(); + const enabled = runtime.settings.get("browser.enabled" as SettingPath) as boolean; + if (!enabled) return usage("Browser tool is disabled (enable in settings).", runtime); + const current = runtime.settings.get("browser.headless" as SettingPath) as boolean; + let next = current; + if (!arg) next = !current; + else if (arg === "headless" || arg === "hidden") next = true; + else if (arg === "visible" || arg === "show" || arg === "headful") next = false; + else return usage("Usage: /browser [headless|visible]", runtime); + runtime.settings.set("browser.headless" as SettingPath, next as SettingValue); + const tool = runtime.session.getToolByName("browser"); + if (tool && "restartForModeChange" in tool) { + try { + await (tool as { restartForModeChange: () => Promise }).restartForModeChange(); + } catch (err) { + // Setting was already mutated; surface the restart failure so the + // user knows the browser is in an inconsistent state. + await runtime.output( + `Browser mode set to ${next ? "headless" : "visible"}, but restart failed: ${errorMessage(err)}`, + ); + return commandConsumed(); + } + } + await runtime.output(`Browser mode: ${next ? "headless" : "visible"}`); + return commandConsumed(); + }, + handleTui: async (command, runtime) => { const arg = command.args.toLowerCase(); const current = settings.get("browser.headless" as SettingPath) as boolean; let next = current; @@ -222,9 +315,9 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ } if (!arg) { next = !current; - } else if (["headless", "hidden"].includes(arg)) { + } else if (arg === "headless" || arg === "hidden") { next = true; - } else if (["visible", "show", "headful"].includes(arg)) { + } else if (arg === "visible" || arg === "show" || arg === "headful") { next = false; } else { runtime.ctx.showStatus("Usage: /browser [headless|visible]"); @@ -237,9 +330,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ try { await (tool as { restartForModeChange: () => Promise }).restartForModeChange(); } catch (error) { - runtime.ctx.showWarning( - `Failed to restart browser: ${error instanceof Error ? error.message : String(error)}`, - ); + runtime.ctx.showWarning(`Failed to restart browser: ${errorMessage(error)}`); runtime.ctx.editor.setText(""); return; } @@ -258,7 +349,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ { name: "cmd", description: "Copy last bash/python command" }, ], allowArgs: true, - handle: async (command, runtime) => { + handleTui: async (command, runtime) => { const sub = command.args.trim().toLowerCase() || undefined; await runtime.ctx.handleCopyCommand(sub); runtime.ctx.editor.setText(""); @@ -267,6 +358,8 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ { name: "todo", description: "View or modify the agent's todo list", + acpDescription: "Manage todos", + acpInputHint: "", subcommands: [ { name: "edit", description: "Open todos in $EDITOR (Markdown round-trip)" }, { name: "copy", description: "Copy todos as Markdown to clipboard" }, @@ -283,7 +376,8 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ { name: "rm", description: "Remove task/phase/all (fuzzy-matched)", usage: "[]" }, ], allowArgs: true, - handle: async (command, runtime) => { + handle: handleTodoAcp, + handleTui: async (command, runtime) => { await runtime.ctx.handleTodoCommand(command.args); runtime.ctx.editor.setText(""); }, @@ -291,12 +385,46 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ { name: "session", description: "Session management commands", + acpDescription: "Show session information", + acpInputHint: "info|delete", subcommands: [ { name: "info", description: "Show session info and stats" }, { name: "delete", description: "Delete current session and return to selector" }, ], allowArgs: true, handle: async (command, runtime) => { + if (!command.args || command.args === "info") { + await runtime.output( + [ + `Session: ${runtime.session.sessionId}`, + `Title: ${runtime.session.sessionName}`, + `CWD: ${runtime.cwd}`, + ].join("\n"), + ); + return commandConsumed(); + } + if (command.args === "delete") { + if (runtime.session.isStreaming) return usage("Cannot delete the session while streaming.", runtime); + const sessionFile = runtime.sessionManager.getSessionFile(); + if (!sessionFile) return usage("No session file to delete (in-memory session).", runtime); + // Route through the active SessionManager so the persist writer is + // closed before the file is deleted. Constructing a fresh + // FileSessionStorage and calling deleteSessionWithArtifacts leaves + // the active writer attached to the now-deleted path, so the next + // prompt would silently resurrect or corrupt the "deleted" file. + try { + await runtime.sessionManager.dropSession(sessionFile); + } catch (err) { + return usage(`Failed to delete session: ${errorMessage(err)}`, runtime); + } + await runtime.output( + `Session deleted: ${sessionFile}. Use ACP \`session/load\` to switch to another session.`, + ); + return commandConsumed(); + } + return usage("Usage: /session [info|delete]", runtime); + }, + handleTui: async (command, runtime) => { const sub = command.args.trim().toLowerCase() || "info"; if (sub === "delete") { runtime.ctx.editor.setText(""); @@ -311,7 +439,35 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ { name: "jobs", description: "Show async background jobs status", + acpDescription: "Show background jobs", handle: async (_command, runtime) => { + const snapshot = runtime.session.getAsyncJobSnapshot({ recentLimit: 5 }); + if (!snapshot || (snapshot.running.length === 0 && snapshot.recent.length === 0)) { + await runtime.output( + "No background jobs running. (Background jobs run async tools — e.g. long-running bash, debug, or task subagents that would otherwise tie up a turn. They appear here while alive and for ~5 minutes after.)", + ); + return commandConsumed(); + } + const now = Date.now(); + const lines: string[] = ["Background Jobs", `Running: ${snapshot.running.length}`]; + if (snapshot.running.length > 0) { + lines.push("", "Running Jobs"); + for (const job of snapshot.running) { + lines.push(` [${job.id}] ${job.type} (${job.status}) — ${formatDuration(now - job.startTime)}`); + lines.push(` ${job.label}`); + } + } + if (snapshot.recent.length > 0) { + lines.push("", "Recent Jobs"); + for (const job of snapshot.recent) { + lines.push(` [${job.id}] ${job.type} (${job.status}) — ${formatDuration(now - job.startTime)}`); + lines.push(` ${job.label}`); + } + } + await runtime.output(lines.join("\n")); + return commandConsumed(); + }, + handleTui: async (_command, runtime) => { await runtime.ctx.handleJobsCommand(); runtime.ctx.editor.setText(""); }, @@ -319,7 +475,12 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ { name: "usage", description: "Show provider usage and limits", + acpDescription: "Show token usage", handle: async (_command, runtime) => { + await runtime.output(await buildUsageReportText(runtime)); + return commandConsumed(); + }, + handleTui: async (_command, runtime) => { await runtime.ctx.handleUsageCommand(); runtime.ctx.editor.setText(""); }, @@ -327,9 +488,28 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ { name: "changelog", description: "Show changelog entries", + acpDescription: "Show changelog", + acpInputHint: "[full]", subcommands: [{ name: "full", description: "Show complete changelog" }], allowArgs: true, handle: async (command, runtime) => { + const changelogPath = getChangelogPath(); + const allEntries = await parseChangelog(changelogPath); + const showFull = command.args.trim().toLowerCase() === "full"; + const entriesToShow = showFull ? allEntries : allEntries.slice(0, 3); + if (entriesToShow.length === 0) { + await runtime.output("No changelog entries found."); + return commandConsumed(); + } + await runtime.output( + [...entriesToShow] + .reverse() + .map(entry => entry.content) + .join("\n\n"), + ); + return commandConsumed(); + }, + handleTui: async (command, runtime) => { const showFull = command.args.split(/\s+/).filter(Boolean).includes("full"); await runtime.ctx.handleChangelogCommand(showFull); runtime.ctx.editor.setText(""); @@ -338,7 +518,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ { name: "hotkeys", description: "Show all keyboard shortcuts", - handle: (_command, runtime) => { + handleTui: (_command, runtime) => { runtime.ctx.handleHotkeysCommand(); runtime.ctx.editor.setText(""); }, @@ -346,7 +526,18 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ { name: "tools", description: "Show tools currently visible to the agent", - handle: (_command, runtime) => { + acpDescription: "Show available tools", + handle: async (_command, runtime) => { + const active = runtime.session.getActiveToolNames(); + const all = runtime.session.getAllToolNames(); + if (all.length === 0) { + await runtime.output("No tools are available."); + return commandConsumed(); + } + await runtime.output(all.map(name => `${active.includes(name) ? "*" : "-"} ${name}`).join("\n")); + return commandConsumed(); + }, + handleTui: (_command, runtime) => { runtime.ctx.handleToolsCommand(); runtime.ctx.editor.setText(""); }, @@ -354,7 +545,12 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ { name: "context", description: "Show estimated context usage breakdown", - handle: (_command, runtime) => { + acpDescription: "Show context usage", + handle: async (_command, runtime) => { + await runtime.output(buildContextReportText(runtime)); + return commandConsumed(); + }, + handleTui: (_command, runtime) => { runtime.ctx.handleContextCommand(); runtime.ctx.editor.setText(""); }, @@ -363,7 +559,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ name: "extensions", aliases: ["status"], description: "Open Extension Control Center dashboard", - handle: (_command, runtime) => { + handleTui: (_command, runtime) => { runtime.ctx.showExtensionsDashboard(); runtime.ctx.editor.setText(""); }, @@ -371,7 +567,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ { name: "agents", description: "Open Agent Control Center dashboard", - handle: (_command, runtime) => { + handleTui: (_command, runtime) => { runtime.ctx.showAgentsDashboard(); runtime.ctx.editor.setText(""); }, @@ -379,7 +575,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ { name: "branch", description: "Create a new branch from a previous message", - handle: (_command, runtime) => { + handleTui: (_command, runtime) => { if (settings.get("doubleEscapeAction") === "tree") { runtime.ctx.showTreeSelector(); } else { @@ -391,7 +587,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ { name: "fork", description: "Create a new fork from a previous message", - handle: async (_command, runtime) => { + handleTui: async (_command, runtime) => { runtime.ctx.editor.setText(""); await runtime.ctx.handleForkCommand(); }, @@ -399,7 +595,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ { name: "tree", description: "Navigate session tree (switch branches)", - handle: (_command, runtime) => { + handleTui: (_command, runtime) => { runtime.ctx.showTreeSelector(); runtime.ctx.editor.setText(""); }, @@ -409,7 +605,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ description: "Login with OAuth provider", inlineHint: "[provider|redirect URL]", allowArgs: true, - handle: (command, runtime) => { + handleTui: (command, runtime) => { const manualInput = runtime.ctx.oauthManualInput; const args = command.args.trim(); if (args.length > 0) { @@ -455,7 +651,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ { name: "logout", description: "Logout from OAuth provider", - handle: (_command, runtime) => { + handleTui: (_command, runtime) => { void runtime.ctx.showOAuthSelector("logout"); runtime.ctx.editor.setText(""); }, @@ -463,6 +659,8 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ { name: "mcp", description: "Manage MCP servers (add, list, remove, test)", + acpDescription: "Manage MCP servers", + inlineHint: "", subcommands: [ { name: "add", @@ -491,7 +689,8 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ { name: "help", description: "Show help message" }, ], allowArgs: true, - handle: async (command, runtime) => { + handle: handleMcpAcp, + handleTui: async (command, runtime) => { runtime.ctx.editor.addToHistory(command.text); runtime.ctx.editor.setText(""); await runtime.ctx.handleMCPCommand(command.text); @@ -500,6 +699,8 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ { name: "ssh", description: "Manage SSH hosts (add, list, remove)", + acpDescription: "Manage SSH connections", + inlineHint: "", subcommands: [ { name: "add", @@ -511,7 +712,8 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ { name: "help", description: "Show help message" }, ], allowArgs: true, - handle: async (command, runtime) => { + handle: handleSshAcp, + handleTui: async (command, runtime) => { runtime.ctx.editor.addToHistory(command.text); runtime.ctx.editor.setText(""); await runtime.ctx.handleSSHCommand(command.text); @@ -520,7 +722,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ { name: "new", description: "Start a new session", - handle: async (_command, runtime) => { + handleTui: async (_command, runtime) => { runtime.ctx.editor.setText(""); await runtime.ctx.handleClearCommand(); }, @@ -528,7 +730,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ { name: "drop", description: "Delete the current session and start a new one", - handle: async (_command, runtime) => { + handleTui: async (_command, runtime) => { runtime.ctx.editor.setText(""); await runtime.ctx.handleDropCommand(); }, @@ -536,9 +738,31 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ { name: "compact", description: "Manually compact the session context", + acpDescription: "Compact the conversation", inlineHint: "[focus instructions]", allowArgs: true, handle: async (command, runtime) => { + const before = runtime.session.getContextUsage?.(); + const beforeTokens = before?.tokens; + try { + await runtime.session.compact(command.args || undefined); + } catch (err) { + // Compaction precondition failures (no model, already compacted, too + // small) and provider errors propagate as plain Errors; surface them + // via runtime.output so they don't fail the ACP prompt turn. + return usage(`Compaction failed: ${errorMessage(err)}`, runtime); + } + const after = runtime.session.getContextUsage?.(); + const afterTokens = after?.tokens; + if (beforeTokens != null && afterTokens != null) { + const saved = beforeTokens - afterTokens; + await runtime.output(`Compaction complete. Tokens: ${beforeTokens} -> ${afterTokens} (saved ${saved}).`); + } else { + await runtime.output("Compaction complete."); + } + return commandConsumed(); + }, + handleTui: async (command, runtime) => { const customInstructions = command.args || undefined; runtime.ctx.editor.setText(""); await runtime.ctx.handleCompactCommand(customInstructions); @@ -549,7 +773,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ description: "Hand off session context to a new session", inlineHint: "[focus instructions]", allowArgs: true, - handle: async (command, runtime) => { + handleTui: async (command, runtime) => { const customInstructions = command.args || undefined; runtime.ctx.editor.setText(""); await runtime.ctx.handleHandoffCommand(customInstructions); @@ -558,7 +782,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ { name: "resume", description: "Resume a different session", - handle: (_command, runtime) => { + handleTui: (_command, runtime) => { runtime.ctx.showSessionSelector(); runtime.ctx.editor.setText(""); }, @@ -568,7 +792,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ description: "Ask an ephemeral side question using the current session context", inlineHint: "", allowArgs: true, - handle: async (command, runtime) => { + handleTui: async (command, runtime) => { const question = command.text.slice(`/${command.name}`.length).trim(); runtime.ctx.editor.setText(""); await runtime.ctx.handleBtwCommand(question); @@ -577,7 +801,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ { name: "retry", description: "Retry the last failed agent turn", - handle: async (_command, runtime) => { + handleTui: async (_command, runtime) => { const didRetry = await runtime.ctx.session.retry(); if (!didRetry) { runtime.ctx.showStatus("Nothing to retry"); @@ -589,7 +813,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ name: "background", aliases: ["bg"], description: "Detach UI and continue running in background", - handle: (_command, runtime) => { + handleTui: (_command, runtime) => { runtime.ctx.editor.setText(""); runtime.handleBackgroundCommand(); }, @@ -597,7 +821,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ { name: "debug", description: "Open debug tools selector", - handle: (_command, runtime) => { + handleTui: (_command, runtime) => { runtime.ctx.showDebugSelector(); runtime.ctx.editor.setText(""); }, @@ -605,6 +829,8 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ { name: "memory", description: "Inspect and operate memory maintenance", + acpDescription: "Manage memory", + acpInputHint: "", subcommands: [ { name: "view", description: "Show current memory injection payload" }, { name: "clear", description: "Clear persisted memory data and artifacts" }, @@ -624,6 +850,41 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ ], allowArgs: true, handle: async (command, runtime) => { + const verb = (command.args.trim().split(/\s+/)[0] ?? "").toLowerCase() || "view"; + const backend = resolveMemoryBackend(runtime.settings); + switch (verb) { + case "view": { + const payload = await backend.buildDeveloperInstructions( + runtime.settings.getAgentDir(), + runtime.settings, + runtime.session, + ); + await runtime.output(payload || "Memory payload is empty."); + return commandConsumed(); + } + case "clear": + case "reset": { + await backend.clear(runtime.settings.getAgentDir(), runtime.cwd, runtime.session); + await runtime.session.refreshBaseSystemPrompt(); + await runtime.output("Memory cleared."); + return commandConsumed(); + } + case "enqueue": + case "rebuild": { + await backend.enqueue(runtime.settings.getAgentDir(), runtime.cwd, runtime.session); + await runtime.output("Memory consolidation enqueued."); + return commandConsumed(); + } + case "mm": + return usage( + "Mental-model maintenance via /memory mm is unsupported in ACP mode; use the hindsight HTTP API directly.", + runtime, + ); + default: + return usage("Usage: /memory ", runtime); + } + }, + handleTui: async (command, runtime) => { runtime.ctx.editor.setText(""); await runtime.ctx.handleMemoryCommand(command.text); }, @@ -634,6 +895,17 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ inlineHint: "", allowArgs: true, handle: async (command, runtime) => { + if (!command.args) return usage("Usage: /rename <title>", runtime); + const ok = await runtime.sessionManager.setSessionName(command.args, "user"); + if (!ok) { + await runtime.output("Session name not changed (a user-set name takes precedence)."); + return commandConsumed(); + } + await runtime.notifyTitleChanged?.(); + await runtime.output(`Session renamed to ${command.args}.`); + return commandConsumed(); + }, + handleTui: async (command, runtime) => { const title = command.args.trim(); if (!title) { runtime.ctx.showError("Usage: /rename <title>"); @@ -644,13 +916,38 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [ await runtime.ctx.handleRenameCommand(title); }, }, - { name: "move", description: "Move session to a different working directory", + acpDescription: "Move the current session file", inlineHint: "<path>", allowArgs: true, handle: async (command, runtime) => { + if (runtime.session.isStreaming) return usage("Cannot move while streaming.", runtime); + if (!command.args) return usage("Usage: /move <path>", runtime); + const resolvedPath = path.resolve(runtime.cwd, command.args); + let isDirectory: boolean; + try { + isDirectory = (await fs.stat(resolvedPath)).isDirectory(); + } catch { + return usage(`Directory does not exist or is not a directory: ${resolvedPath}`, runtime); + } + if (!isDirectory) return usage(`Directory does not exist or is not a directory: ${resolvedPath}`, runtime); + try { + await runtime.sessionManager.flush(); + await runtime.sessionManager.moveTo(resolvedPath); + } catch (err) { + return usage(`Move failed: ${errorMessage(err)}`, runtime); + } + setProjectDir(resolvedPath); + // Reload plugin/capability caches so the next prompt sees commands and + // capabilities scoped to the new cwd. + await runtime.reloadPlugins(); + await runtime.notifyTitleChanged?.(); + await runtime.output(`Session moved to ${runtime.sessionManager.getCwd()}.`); + return commandConsumed(); + }, + handleTui: async (command, runtime) => { const targetPath = command.args; if (!targetPath) { runtime.ctx.showError("Usage: /move <path>"); @@ -664,11 +961,13 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [ { name: "exit", description: "Exit the application", - handle: shutdownHandler, + handleTui: shutdownHandlerTui, }, { name: "marketplace", description: "Manage marketplace plugin sources and installed plugins", + acpDescription: "Manage plugins from marketplaces", + acpInputHint: "<subcommand>", subcommands: [ { name: "add", description: "Add a marketplace source", usage: "<source>" }, { name: "remove", description: "Remove a marketplace source", usage: "<name>" }, @@ -687,6 +986,175 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [ ], allowArgs: true, handle: async (command, runtime) => { + const { verb, rest } = parseSubcommand(command.args); + if (!verb) { + try { + const manager = await createMarketplaceManager(runtime); + const marketplaces = await manager.listMarketplaces(); + if (marketplaces.length === 0) { + await runtime.output( + "No marketplaces configured.\n\nGet started:\n /marketplace add anthropics/claude-plugins-official\n\nThen browse with /marketplace discover", + ); + } else { + const lines = marketplaces.map(m => ` ${m.name} ${m.sourceUri}`); + await runtime.output( + `Marketplaces:\n${lines.join("\n")}\n\nUse /marketplace discover to browse plugins, or /marketplace help for all commands`, + ); + } + return commandConsumed(); + } catch (err) { + return usage(`Marketplace error: ${errorMessage(err)}`, runtime); + } + } + if (verb === "help") { + await runtime.output( + [ + "Marketplace commands:", + " /marketplace List configured marketplaces", + " /marketplace add <source> Add a marketplace (e.g. owner/repo)", + " /marketplace remove <name> Remove a marketplace", + " /marketplace update [name] Re-fetch catalog(s)", + " /marketplace list List configured marketplaces", + " /marketplace discover [marketplace] Browse available plugins", + " /marketplace install <name@marketplace> Install a plugin", + " /marketplace uninstall <name@marketplace> Uninstall a plugin", + " /marketplace installed List installed plugins", + " /marketplace upgrade [name@marketplace] Upgrade plugin(s)", + "", + "Quick start:", + " /marketplace add anthropics/claude-plugins-official", + ].join("\n"), + ); + return commandConsumed(); + } + if ((verb === "install" || verb === "uninstall") && !rest) { + return usage( + "Interactive plugin pickers are TUI-only. Pass an explicit name@marketplace argument.", + runtime, + ); + } + try { + const manager = await createMarketplaceManager(runtime); + switch (verb) { + case "add": { + if (!rest) return usage("Usage: /marketplace add <source>", runtime); + const entry = await manager.addMarketplace(rest); + await runtime.output(`Added marketplace: ${entry.name}`); + return commandConsumed(); + } + case "remove": + case "rm": { + if (!rest) return usage("Usage: /marketplace remove <name>", runtime); + await manager.removeMarketplace(rest); + await runtime.output(`Removed marketplace: ${rest}`); + return commandConsumed(); + } + case "update": { + if (rest) { + await manager.updateMarketplace(rest); + await runtime.output(`Updated marketplace: ${rest}`); + } else { + const results = await manager.updateAllMarketplaces(); + await runtime.output(`Updated ${results.length} marketplace(s)`); + } + return commandConsumed(); + } + case "list": { + const marketplaces = await manager.listMarketplaces(); + if (marketplaces.length === 0) { + await runtime.output("No marketplaces configured."); + } else { + const lines = marketplaces.map(m => ` ${m.name} ${m.sourceUri}`); + await runtime.output(`Marketplaces:\n${lines.join("\n")}`); + } + return commandConsumed(); + } + case "discover": { + const plugins = await manager.listAvailablePlugins(rest || undefined); + if (plugins.length === 0) { + const marketplaces = await manager.listMarketplaces(); + await runtime.output( + marketplaces.length === 0 + ? "No marketplaces configured. Try:\n /marketplace add anthropics/claude-plugins-official" + : "No plugins available in configured marketplaces", + ); + return commandConsumed(); + } + const lines = ["Available plugins:"]; + for (const plugin of plugins) { + lines.push(` - ${plugin.name}${plugin.version ? `@${plugin.version}` : ""}`); + if (plugin.description) lines.push(` ${plugin.description}`); + } + await runtime.output(lines.join("\n")); + return commandConsumed(); + } + case "install": { + const parsed = parseMarketplaceInstallArgs(rest); + if ("error" in parsed) return usage(parsed.error, runtime); + const atIndex = parsed.installSpec.lastIndexOf("@"); + const pluginName = parsed.installSpec.slice(0, atIndex); + const marketplace = parsed.installSpec.slice(atIndex + 1); + await manager.installPlugin(pluginName, marketplace, { force: parsed.force, scope: parsed.scope }); + await runtime.reloadPlugins(); + await runtime.output(`Installed ${pluginName} from ${marketplace}`); + return commandConsumed(); + } + case "uninstall": { + const parsed = parsePluginScopeArgs( + rest, + "Usage: /marketplace uninstall [--scope user|project] <name@marketplace>", + ); + if ("error" in parsed) return usage(parsed.error, runtime); + await manager.uninstallPlugin(parsed.pluginId, parsed.scope); + await runtime.reloadPlugins(); + await runtime.output(`Uninstalled ${parsed.pluginId}`); + return commandConsumed(); + } + case "installed": { + const installed = await manager.listInstalledPlugins(); + if (installed.length === 0) { + await runtime.output("No marketplace plugins installed"); + } else { + const lines = installed.map( + p => ` ${p.id} [${p.scope}]${p.shadowedBy ? " [shadowed]" : ""} (${p.entries.length} entry)`, + ); + await runtime.output(`Installed plugins:\n${lines.join("\n")}`); + } + return commandConsumed(); + } + case "upgrade": { + if (rest) { + const parsed = parsePluginScopeArgs( + rest, + "Usage: /marketplace upgrade [--scope user|project] <name@marketplace>", + ); + if ("error" in parsed) return usage(parsed.error, runtime); + const result = await manager.upgradePlugin(parsed.pluginId, parsed.scope); + await runtime.reloadPlugins(); + await runtime.output(`Upgraded ${parsed.pluginId} to ${result.version}`); + return commandConsumed(); + } + const results = await manager.upgradeAllPlugins(); + if (results.length === 0) { + await runtime.output("All marketplace plugins are up to date"); + } else { + await runtime.reloadPlugins(); + const lines = results.map(r => ` ${r.pluginId}: ${r.from} -> ${r.to}`); + await runtime.output(`Upgraded ${results.length} plugin(s):\n${lines.join("\n")}`); + } + return commandConsumed(); + } + default: + return usage( + `Unknown /marketplace subcommand: ${verb}. Use /marketplace help for available commands.`, + runtime, + ); + } + } catch (err) { + return usage(`Marketplace error: ${errorMessage(err)}`, runtime); + } + }, + handleTui: async (command, runtime) => { runtime.ctx.editor.setText(""); const args = command.args.trim().split(/\s+/); const sub = args[0] || "install"; @@ -877,6 +1345,8 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [ { name: "plugins", description: "View and manage installed plugins", + acpDescription: "Manage plugins", + acpInputHint: "[list|enable|disable]", subcommands: [ { name: "list", description: "List all installed plugins (npm + marketplace)" }, { name: "enable", description: "Enable a marketplace plugin", usage: "<name@marketplace>" }, @@ -884,6 +1354,53 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [ ], allowArgs: true, handle: async (command, runtime) => { + const { verb, rest } = parseSubcommand(command.args); + try { + if (verb === "enable" || verb === "disable") { + const parsed = parsePluginScopeArgs( + rest, + `Usage: /plugins ${verb} [--scope user|project] <name@marketplace>`, + ); + if ("error" in parsed) return usage(parsed.error, runtime); + const manager = await createMarketplaceManager(runtime); + const isEnable = verb === "enable"; + await manager.setPluginEnabled(parsed.pluginId, isEnable, parsed.scope); + await runtime.reloadPlugins(); + await runtime.output(`${isEnable ? "Enabled" : "Disabled"} ${parsed.pluginId}`); + return commandConsumed(); + } + // Default: list + const lines: string[] = []; + const npmManager = new PluginManager(); + const npmPlugins = await npmManager.list(); + if (npmPlugins.length > 0) { + lines.push("npm plugins:"); + for (const plugin of npmPlugins) { + const status = plugin.enabled === false ? " (disabled)" : ""; + lines.push(` ${plugin.name}@${plugin.version}${status}`); + } + } + + const marketplaceManager = await createMarketplaceManager(runtime); + const marketplacePlugins = await marketplaceManager.listInstalledPlugins(); + if (marketplacePlugins.length > 0) { + if (lines.length > 0) lines.push(""); + lines.push("marketplace plugins:"); + for (const plugin of marketplacePlugins) { + const entry = plugin.entries[0]; + const status = entry?.enabled === false ? " (disabled)" : ""; + const shadowed = plugin.shadowedBy ? " [shadowed]" : ""; + lines.push(` ${plugin.id} v${entry?.version ?? "?"}${status} [${plugin.scope}]${shadowed}`); + } + } + + await runtime.output(lines.length === 0 ? "No plugins installed" : lines.join("\n")); + return commandConsumed(); + } catch (err) { + return usage(`Plugin error: ${errorMessage(err)}`, runtime); + } + }, + handleTui: async (command, runtime) => { runtime.ctx.editor.setText(""); const args = command.args.trim().split(/\s+/); const sub = args[0] || "list"; @@ -958,7 +1475,13 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [ { name: "reload-plugins", description: "Reload all plugins (skills, commands, hooks, tools, agents, MCP)", + acpDescription: "Reload all plugins", handle: async (_command, runtime) => { + await runtime.reloadPlugins(); + await runtime.output("Plugins reloaded."); + return commandConsumed(); + }, + handleTui: async (_command, runtime) => { // Invalidate registry fs caches and the plugin roots cache so // listClaudePluginRoots re-reads from disk on next access. const projectPath = await resolveActiveProjectRegistryPath(runtime.ctx.sessionManager.getCwd()); @@ -971,9 +1494,23 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [ { name: "force", description: "Force next turn to use a specific tool", + aliases: ["force:"], inlineHint: "<tool-name> [prompt]", allowArgs: true, - handle: (command, runtime) => { + handle: async (command, runtime) => { + const spaceIdx = command.args.indexOf(" "); + const toolName = spaceIdx === -1 ? command.args : command.args.slice(0, spaceIdx); + const prompt = spaceIdx === -1 ? "" : command.args.slice(spaceIdx + 1).trim(); + if (!toolName) return usage("Usage: /force:<tool-name> [prompt]", runtime); + try { + runtime.session.setForcedToolChoice(toolName); + } catch (err) { + return usage(errorMessage(err), runtime); + } + await runtime.output(`Next turn forced to use ${toolName}.`); + return prompt ? { prompt } : commandConsumed(); + }, + handleTui: (command, runtime) => { const spaceIdx = command.args.indexOf(" "); const toolName = spaceIdx === -1 ? command.args : command.args.slice(0, spaceIdx); const prompt = spaceIdx === -1 ? "" : command.args.slice(spaceIdx + 1).trim(); @@ -988,7 +1525,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [ runtime.ctx.session.setForcedToolChoice(toolName); runtime.ctx.showStatus(`Next turn forced to use ${toolName}.`); } catch (error) { - runtime.ctx.showError(error instanceof Error ? error.message : String(error)); + runtime.ctx.showError(errorMessage(error)); runtime.ctx.editor.setText(""); return; } @@ -996,17 +1533,17 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [ runtime.ctx.editor.setText(""); // If a prompt was provided, pass it through as input - if (prompt) return prompt; + if (prompt) return { prompt }; }, }, { name: "quit", description: "Quit the application", - handle: shutdownHandler, + handleTui: shutdownHandlerTui, }, ]; -const BUILTIN_SLASH_COMMAND_LOOKUP = new Map<string, BuiltinSlashCommandSpec>(); +const BUILTIN_SLASH_COMMAND_LOOKUP = new Map<string, SlashCommandSpec>(); for (const command of BUILTIN_SLASH_COMMAND_REGISTRY) { BUILTIN_SLASH_COMMAND_LOOKUP.set(command.name, command); for (const alias of command.aliases ?? []) { @@ -1025,17 +1562,24 @@ export const BUILTIN_SLASH_COMMAND_DEFS: ReadonlyArray<BuiltinSlashCommand> = BU ); /** - * Execute a builtin slash command when it matches known command syntax. + * Unified registry exposed for cross-mode tooling. Each spec carries at least + * one of `handle` / `handleTui`. The TUI dispatcher prefers `handleTui`; the + * ACP dispatcher requires `handle` and skips TUI-only entries. + */ +export const BUILTIN_SLASH_COMMANDS_INTERNAL: ReadonlyArray<SlashCommandSpec> = BUILTIN_SLASH_COMMAND_REGISTRY; + +/** + * Execute a builtin slash command in the interactive TUI. * - * Returns `false` when no builtin matched. Returns `true` when a command consumed - * the input entirely. Returns a `string` when the command was handled but remaining - * text should be sent as a prompt. + * Returns `false` when no builtin matched. Returns `true` when a command + * consumed the input entirely. Returns a `string` when the command was handled + * but remaining text should be sent as a prompt. */ export async function executeBuiltinSlashCommand( text: string, runtime: BuiltinSlashCommandRuntime, ): Promise<string | boolean> { - const parsed = parseBuiltinSlashCommand(text); + const parsed = parseSlashCommand(text); if (!parsed) return false; const command = BUILTIN_SLASH_COMMAND_LOOKUP.get(parsed.name); @@ -1043,7 +1587,45 @@ export async function executeBuiltinSlashCommand( if (parsed.args.length > 0 && !command.allowArgs) { return false; } - - const remaining = await command.handle(parsed, runtime); - return remaining ?? true; + if (command.handleTui) { + const result = await command.handleTui(parsed, runtime); + if (result && typeof result === "object" && "prompt" in result) return result.prompt; + return true; + } + if (command.handle) { + // No TUI-specific override → adapt the ACP/text-mode `handle` to the + // TUI by routing `runtime.output` through `ctx.showStatus`, clearing + // the editor after the call, and reusing the active session's plugin + // reload pipeline. Spec authors get a single body usable from either + // dispatcher without forcing every TUI test to construct the full + // `SlashCommandRuntime` shape. + const ctx = runtime.ctx; + const adapted: SlashCommandRuntime = { + session: ctx.session, + sessionManager: ctx.sessionManager, + settings: ctx.settings, + cwd: ctx.sessionManager.getCwd(), + output: (text: string) => { + ctx.showStatus(text); + }, + refreshCommands: () => ctx.refreshSlashCommandState(), + reloadPlugins: async () => { + const projectPath = await resolveActiveProjectRegistryPath(ctx.sessionManager.getCwd()); + clearPluginRootsAndCaches(projectPath ? [projectPath] : undefined); + await ctx.refreshSlashCommandState(); + }, + }; + const result = await command.handle(parsed, adapted); + ctx.editor.setText(""); + if (result && typeof result === "object" && "prompt" in result) return result.prompt; + return true; + } + return false; } + +/** Look up a unified spec by name or alias. Used by the ACP dispatcher. */ +export function lookupBuiltinSlashCommand(name: string): SlashCommandSpec | undefined { + return BUILTIN_SLASH_COMMAND_LOOKUP.get(name); +} + +export type { ParsedSlashCommand, SlashCommandResult, SlashCommandRuntime, SlashCommandSpec, TuiSlashCommandRuntime }; diff --git a/packages/coding-agent/src/slash-commands/helpers/context-report.ts b/packages/coding-agent/src/slash-commands/helpers/context-report.ts new file mode 100644 index 000000000..0b4708b0b --- /dev/null +++ b/packages/coding-agent/src/slash-commands/helpers/context-report.ts @@ -0,0 +1,39 @@ +import { computeContextBreakdown } from "../../modes/utils/context-usage"; +import type { SlashCommandRuntime } from "../types"; +import { renderAsciiBar } from "./format"; + +/** + * Build the `/context` ACP-mode text. Tries the rich breakdown first + * (categories + auto-compact buffer + free slack) and falls back to the + * minimal "window/used" lines when the breakdown helper throws. + */ +export function buildContextReportText(runtime: SlashCommandRuntime): string { + try { + const breakdown = computeContextBreakdown(runtime.session); + if (breakdown.contextWindow <= 0) { + return "Context usage is unavailable: no model is selected for this session."; + } + const usedPct = Math.round((breakdown.usedTokens / breakdown.contextWindow) * 100); + const lines = [`Context window: ${breakdown.contextWindow} tokens (${usedPct}% used)`]; + for (const category of breakdown.categories) { + if (category.tokens === 0) continue; + const fraction = category.tokens / breakdown.contextWindow; + lines.push(` ${category.label.padEnd(16)} ${renderAsciiBar(fraction)} ${category.tokens} tokens`); + } + if (breakdown.autoCompactBufferTokens > 0) { + const fraction = breakdown.autoCompactBufferTokens / breakdown.contextWindow; + lines.push( + ` ${"Auto-compact buf".padEnd(16)} ${renderAsciiBar(fraction)} ${breakdown.autoCompactBufferTokens} tokens`, + ); + } + if (breakdown.freeTokens > 0) { + const fraction = breakdown.freeTokens / breakdown.contextWindow; + lines.push(` ${"Free".padEnd(16)} ${renderAsciiBar(fraction)} ${breakdown.freeTokens} tokens`); + } + return lines.join("\n"); + } catch { + const fallback = runtime.session.getContextUsage(); + if (!fallback) return "Context usage is unavailable."; + return ["Context", `Window: ${fallback.contextWindow}`, `Used: ${fallback.tokens ?? 0}`].join("\n"); + } +} diff --git a/packages/coding-agent/src/slash-commands/helpers/format.ts b/packages/coding-agent/src/slash-commands/helpers/format.ts new file mode 100644 index 000000000..9cd909e3d --- /dev/null +++ b/packages/coding-agent/src/slash-commands/helpers/format.ts @@ -0,0 +1,23 @@ +/** Format a millisecond duration as a coarse-grained human label. */ +export function formatDuration(ms: number): string { + const seconds = Math.max(0, Math.round(ms / 1000)); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.round(seconds / 60); + if (minutes < 60) return `${minutes}m`; + const hours = Math.round(minutes / 60); + if (hours < 48) return `${hours}h`; + const days = Math.round(hours / 24); + return `${days}d`; +} + +/** + * Render an ASCII progress bar with a trailing percent label. + * `fraction` is clamped to `[0, 1]`. `undefined` renders a dotted placeholder. + */ +export function renderAsciiBar(fraction: number | undefined, width = 24): string { + if (fraction === undefined) return `[${"·".repeat(width)}]`; + const clamped = Math.min(Math.max(fraction, 0), 1); + const filled = Math.round(clamped * width); + const pct = Math.round(clamped * 100); + return `[${"█".repeat(filled)}${"░".repeat(Math.max(0, width - filled))}] ${pct}%`; +} diff --git a/packages/coding-agent/src/slash-commands/helpers/marketplace-manager.ts b/packages/coding-agent/src/slash-commands/helpers/marketplace-manager.ts new file mode 100644 index 000000000..eb3047790 --- /dev/null +++ b/packages/coding-agent/src/slash-commands/helpers/marketplace-manager.ts @@ -0,0 +1,25 @@ +import { clearPluginRootsAndCaches, resolveOrDefaultProjectRegistryPath } from "../../discovery/helpers"; +import { + getInstalledPluginsRegistryPath, + getMarketplacesCacheDir, + getMarketplacesRegistryPath, + getPluginsCacheDir, + MarketplaceManager, +} from "../../extensibility/plugins/marketplace"; +import type { SlashCommandRuntime } from "../types"; + +/** + * Build a `MarketplaceManager` wired up with the active project's registry + * paths and the shared plugin-root cache invalidator. Reused by both `/plugins` + * and `/marketplace` handlers so cache invalidation stays consistent. + */ +export async function createMarketplaceManager(runtime: SlashCommandRuntime): Promise<MarketplaceManager> { + return new MarketplaceManager({ + marketplacesRegistryPath: getMarketplacesRegistryPath(), + installedRegistryPath: getInstalledPluginsRegistryPath(), + projectInstalledRegistryPath: await resolveOrDefaultProjectRegistryPath(runtime.cwd), + marketplacesCacheDir: getMarketplacesCacheDir(), + pluginsCacheDir: getPluginsCacheDir(), + clearPluginRootsCache: clearPluginRootsAndCaches, + }); +} diff --git a/packages/coding-agent/src/slash-commands/helpers/mcp.ts b/packages/coding-agent/src/slash-commands/helpers/mcp.ts new file mode 100644 index 000000000..60c637a99 --- /dev/null +++ b/packages/coding-agent/src/slash-commands/helpers/mcp.ts @@ -0,0 +1,532 @@ +import { getMCPConfigPath, logger } from "@oh-my-pi/pi-utils"; +import { connectToServer, disconnectServer, listPrompts, listResources, listTools } from "../../mcp/client"; +import { + addMCPServer, + readDisabledServers, + readMCPConfigFile, + removeMCPServer, + setServerDisabled, + updateMCPServer, +} from "../../mcp/config-writer"; +import { MCPManager } from "../../mcp/manager"; +import { getSmitheryApiKey } from "../../mcp/smithery-auth"; +import { searchSmitheryRegistry } from "../../mcp/smithery-registry"; +import type { MCPServerConfig, MCPServerConnection } from "../../mcp/types"; +import { parseCommandArgs } from "../../utils/command-args"; +import type { ParsedSlashCommand, SlashCommandResult, SlashCommandRuntime } from "../types"; +import { commandConsumed, errorMessage, parseNamedScopeArgs, parseSubcommand, usage } from "./parse"; + +type AcpMcpScope = "user" | "project"; + +interface ParsedMcpAddArgs { + name?: string; + scope: AcpMcpScope; + url?: string; + transport: "http" | "sse"; + authToken?: string; + commandTokens?: string[]; + error?: string; +} + +interface ParsedMcpSearchArgs { + keyword: string; + scope: AcpMcpScope; + limit: number; + semantic: boolean; + error?: string; +} + +type McpAddOptionParser = (parsed: ParsedMcpAddArgs, value: string | undefined) => string | undefined; + +const MCP_ADD_USAGE = + "Usage: /mcp add <name> [--scope project|user] [--url <url> --transport http|sse] [--token <token>] [-- <command...>]"; + +const MCP_ADD_OPTION_PARSERS = new Map<string, McpAddOptionParser>([ + [ + "--scope", + (parsed, value) => { + if (!value || (value !== "project" && value !== "user")) return "Invalid --scope value. Use project or user."; + parsed.scope = value; + return undefined; + }, + ], + [ + "--url", + (parsed, value) => { + if (!value) return "Missing value for --url."; + parsed.url = value; + return undefined; + }, + ], + [ + "--transport", + (parsed, value) => { + if (!value || (value !== "http" && value !== "sse")) return "Invalid --transport value. Use http or sse."; + parsed.transport = value; + return undefined; + }, + ], + [ + "--token", + (parsed, value) => { + if (!value) return "Missing value for --token."; + parsed.authToken = value; + return undefined; + }, + ], +]); + +async function getMcpConfiguredServers( + cwd: string, +): Promise<Array<{ name: string; config: MCPServerConfig; scope: AcpMcpScope }>> { + const userPath = getMCPConfigPath("user", cwd); + const projectPath = getMCPConfigPath("project", cwd); + const [userConfig, projectConfig] = await Promise.all([readMCPConfigFile(userPath), readMCPConfigFile(projectPath)]); + const servers: Array<{ name: string; config: MCPServerConfig; scope: AcpMcpScope }> = []; + const seen = new Set<string>(); + for (const [name, config] of Object.entries(projectConfig.mcpServers ?? {})) { + if (config.enabled !== false) { + servers.push({ name, config, scope: "project" }); + seen.add(name); + } + } + for (const [name, config] of Object.entries(userConfig.mcpServers ?? {})) { + if (!seen.has(name) && config.enabled !== false) servers.push({ name, config, scope: "user" }); + } + return servers; +} + +function validateParsedMcpAddArgs(parsed: ParsedMcpAddArgs): ParsedMcpAddArgs { + const hasCommand = (parsed.commandTokens?.length ?? 0) > 0; + const hasUrl = Boolean(parsed.url); + if (!hasCommand && !hasUrl) { + return { + ...parsed, + error: "Provide --url or -- <command...> for non-interactive add. Usage: /mcp add <name> [--scope project|user] [--url <url> --transport http|sse] [--token <token>] [-- <command...>]", + }; + } + if (!parsed.name) return { ...parsed, error: "Server name required. Usage: /mcp add <name> ..." }; + if (hasCommand && hasUrl) return { ...parsed, error: "Use either --url or -- <command...>, not both." }; + if (parsed.authToken && !hasUrl) return { ...parsed, error: "--token requires --url (HTTP/SSE transport)." }; + return parsed; +} + +function parseMcpAddArgs(rest: string): ParsedMcpAddArgs { + const tokens = parseCommandArgs(rest); + const parsed: ParsedMcpAddArgs = { scope: "project", transport: "http" }; + if (tokens.length === 0) return parsed; + + let index = 0; + if (!tokens[0]!.startsWith("-")) { + parsed.name = tokens[0]; + index = 1; + } + + while (index < tokens.length) { + const arg = tokens[index]!; + if (arg === "--") { + parsed.commandTokens = tokens.slice(index + 1); + break; + } + const parser = MCP_ADD_OPTION_PARSERS.get(arg); + if (!parser) return { ...parsed, error: `Unknown option: ${arg}` }; + const error = parser(parsed, tokens[index + 1]); + if (error) return { ...parsed, error }; + index += 2; + } + + return validateParsedMcpAddArgs(parsed); +} + +function parseMcpSearchArgs(rest: string): ParsedMcpSearchArgs { + const tokens = parseCommandArgs(rest); + const missingKeyword: ParsedMcpSearchArgs = { + keyword: "", + scope: "project", + limit: 20, + semantic: false, + error: "Keyword required. Usage: /mcp smithery-search <keyword> [--scope project|user] [--limit <1-100>] [--semantic]", + }; + if (tokens.length === 0) return missingKeyword; + + const keywordParts: string[] = []; + let scope: AcpMcpScope = "project"; + let limit = 20; + let semantic = false; + + for (let index = 0; index < tokens.length; index++) { + const token = tokens[index]!; + if (token === "--scope") { + const value = tokens[index + 1]; + if (!value || (value !== "project" && value !== "user")) { + return { keyword: "", scope, limit, semantic, error: "Invalid --scope value. Use project or user." }; + } + scope = value; + index++; + continue; + } + if (token === "--limit") { + const value = tokens[index + 1]; + if (!value) return { keyword: "", scope, limit, semantic, error: "Missing value for --limit." }; + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < 1 || parsed > 100) { + return { + keyword: "", + scope, + limit, + semantic, + error: "Invalid --limit value. Use an integer between 1 and 100.", + }; + } + limit = parsed; + index++; + continue; + } + if (token === "--semantic") { + semantic = true; + continue; + } + if (token.startsWith("--")) return { keyword: "", scope, limit, semantic, error: `Unknown option: ${token}` }; + keywordParts.push(token); + } + + const keyword = keywordParts.join(" ").trim(); + if (!keyword) return { ...missingKeyword, scope, limit, semantic }; + return { keyword, scope, limit, semantic }; +} + +async function withPreparedMcpConnection<T>( + runtime: SlashCommandRuntime, + name: string, + config: MCPServerConfig, + fn: (connection: MCPServerConnection) => Promise<T>, +): Promise<T> { + let connection: MCPServerConnection | undefined; + try { + const manager = new MCPManager(runtime.cwd); + // Auth storage must be wired in before prepareConfig so OAuth-backed + // servers can refresh credentials and inject Authorization headers. + // Without this, `/mcp test|resources|prompts` silently fails for any + // server saved by the TUI/reauth path. + manager.setAuthStorage(runtime.session.modelRegistry.authStorage); + const resolvedConfig = await manager.prepareConfig(config); + connection = await connectToServer(name, resolvedConfig); + return await fn(connection); + } finally { + if (connection) { + // Await cleanup so the stdio subprocess / HTTP DELETE has actually + // released the resource before this helper returns. Fire-and-forget + // here races with subsequent connect attempts and turns close + // failures into unhandled rejections. + try { + await disconnectServer(connection); + } catch (err) { + logger.warn("MCP disconnect after temporary connection failed", { name, err }); + } + } + } +} + +async function collectConnectedMcpLines( + runtime: SlashCommandRuntime, + collect: (serverName: string, connection: MCPServerConnection) => Promise<string[]>, +): Promise<string[] | undefined> { + const servers = await getMcpConfiguredServers(runtime.cwd); + if (servers.length === 0) return undefined; + + const lines: string[] = []; + for (const { name, config } of servers) { + try { + const collected = await withPreparedMcpConnection(runtime, name, config, connection => + collect(name, connection), + ); + lines.push(...collected); + } catch { + // unreachable server: skip silently + } + } + return lines; +} + +async function handleResourcesCommand(runtime: SlashCommandRuntime): Promise<SlashCommandResult> { + const lines = await collectConnectedMcpLines(runtime, async (name, connection) => { + const resources = await listResources(connection); + return resources.map(resource => `${name}/${resource.uri}`); + }); + if (!lines) { + await runtime.output("No MCP servers configured."); + return commandConsumed(); + } + await runtime.output(lines.length > 0 ? lines.join("\n") : "No resources available on connected servers."); + return commandConsumed(); +} + +async function handlePromptsCommand(runtime: SlashCommandRuntime): Promise<SlashCommandResult> { + const lines = await collectConnectedMcpLines(runtime, async (name, connection) => { + const prompts = await listPrompts(connection); + return prompts.map(prompt => `${name}/${prompt.name}${prompt.description ? ` — ${prompt.description}` : ""}`); + }); + if (!lines) { + await runtime.output("No MCP servers configured."); + return commandConsumed(); + } + await runtime.output(lines.length > 0 ? lines.join("\n") : "No prompts available on connected servers."); + return commandConsumed(); +} + +async function handleTestCommand(rest: string, runtime: SlashCommandRuntime): Promise<SlashCommandResult> { + const name = rest.split(/\s+/)[0]?.trim() ?? ""; + if (!name) return usage("Usage: /mcp test <name>", runtime); + const servers = await getMcpConfiguredServers(runtime.cwd); + const server = servers.find(item => item.name === name); + if (!server) return usage(`Server "${name}" not found. Run /mcp list to see configured servers.`, runtime); + + try { + return await withPreparedMcpConnection(runtime, name, server.config, async connection => { + const tools = await listTools(connection); + const lines = [`Server "${name}" connected (${tools.length} tools).`]; + for (const tool of tools) lines.push(` - ${tool.name}`); + await runtime.output(lines.join("\n")); + return commandConsumed(); + }); + } catch (err) { + return usage(`Connection to "${name}" failed: ${errorMessage(err)}`, runtime); + } +} + +function buildMcpServerConfig(parsed: ParsedMcpAddArgs): MCPServerConfig | undefined { + if (parsed.commandTokens && parsed.commandTokens.length > 0) { + const [command, ...args] = parsed.commandTokens; + return { type: "stdio", command: command!, args: args.length > 0 ? args : undefined } as MCPServerConfig; + } + if (!parsed.url) return undefined; + const normalizedUrl = /^https?:\/\//i.test(parsed.url) ? parsed.url : `https://${parsed.url}`; + return { + type: parsed.transport === "sse" ? "sse" : "http", + url: normalizedUrl, + headers: parsed.authToken ? { Authorization: `Bearer ${parsed.authToken}` } : undefined, + } as MCPServerConfig; +} + +async function handleAddCommand(rest: string, runtime: SlashCommandRuntime): Promise<SlashCommandResult> { + if (!rest) return usage(MCP_ADD_USAGE, runtime); + const parsed = parseMcpAddArgs(rest); + if (parsed.error) return usage(parsed.error, runtime); + if (!parsed.name) return usage(MCP_ADD_USAGE, runtime); + const config = buildMcpServerConfig(parsed); + if (!config) return usage(MCP_ADD_USAGE, runtime); + try { + const filePath = getMCPConfigPath(parsed.scope, runtime.cwd); + await addMCPServer(filePath, parsed.name, config); + await runtime.output(`Added MCP server "${parsed.name}" (${parsed.scope}).`); + return commandConsumed(); + } catch (err) { + return usage(`Failed to add server: ${errorMessage(err)}`, runtime); + } +} + +async function handleSmitherySearchCommand(rest: string, runtime: SlashCommandRuntime): Promise<SlashCommandResult> { + const parsed = parseMcpSearchArgs(rest); + if (parsed.error) return usage(parsed.error, runtime); + try { + const apiKey = await getSmitheryApiKey(); + const results = await searchSmitheryRegistry(parsed.keyword, { + limit: parsed.limit, + apiKey: apiKey ?? undefined, + includeSemantic: parsed.semantic, + }); + if (results.length === 0) { + await runtime.output(`No Smithery results found for "${parsed.keyword}".`); + return commandConsumed(); + } + await runtime.output( + results + .map( + result => + `${result.display.displayName} (${result.name})${result.display.description ? ` — ${result.display.description}` : ""}`, + ) + .join("\n"), + ); + return commandConsumed(); + } catch (err) { + const message = errorMessage(err); + if (/401|403|unauthorized|forbidden/i.test(message)) { + return usage( + "Smithery authentication required. Run /mcp smithery-login in the TUI client or add an API key to ~/.omp/agent/smithery.json.", + runtime, + ); + } + return usage(`Smithery search failed: ${message}`, runtime); + } +} + +async function handleListCommand(runtime: SlashCommandRuntime): Promise<SlashCommandResult> { + try { + const userPath = getMCPConfigPath("user", runtime.cwd); + const projectPath = getMCPConfigPath("project", runtime.cwd); + const [userConfig, projectConfig] = await Promise.all([ + readMCPConfigFile(userPath), + readMCPConfigFile(projectPath), + ]); + const disabledSet = new Set(await readDisabledServers(userPath)); + const entries: Array<{ name: string; config: MCPServerConfig; scope: string }> = []; + for (const [name, config] of Object.entries(userConfig.mcpServers ?? {})) { + entries.push({ name, config, scope: "user" }); + } + for (const [name, config] of Object.entries(projectConfig.mcpServers ?? {})) { + if (!entries.some(entry => entry.name === name)) entries.push({ name, config, scope: "project" }); + } + if (entries.length === 0) { + await runtime.output("No MCP servers configured."); + return commandConsumed(); + } + await runtime.output( + entries + .map(({ name, config, scope }) => { + const type = config.type ?? "stdio"; + const enabled = config.enabled !== false && !disabledSet.has(name) ? "enabled" : "disabled"; + let location: string | undefined; + if (config.type === "http" || config.type === "sse") { + // Strip query string and userinfo from URLs to avoid leaking + // API keys carried in the query (e.g. `?apiKey=…`). Skip the + // redaction entirely for missing/empty URLs so the row falls + // back to `(unknown)` rather than the misleading `(hidden)` + // label reserved for unparseable values. + const raw = (config as { url?: string }).url; + if (raw) { + try { + const parsed = new URL(raw); + const pathOnly = parsed.pathname && parsed.pathname !== "/" ? parsed.pathname : ""; + location = `${parsed.origin}${pathOnly}`; + } catch { + location = "(hidden)"; + } + } + } else { + location = (config as { command: string }).command; + } + return `${name} | ${type} | ${enabled} | ${location ?? "(unknown)"} [${scope}]`; + }) + .join("\n"), + ); + return commandConsumed(); + } catch (err) { + return usage(`Failed to list MCP servers: ${errorMessage(err)}`, runtime); + } +} + +async function handleEnableDisableCommand( + verb: "enable" | "disable", + rest: string, + runtime: SlashCommandRuntime, +): Promise<SlashCommandResult> { + const name = rest.split(/\s+/)[0] ?? ""; + if (!name) return usage(`Usage: /mcp ${verb} <name>`, runtime); + const enabled = verb === "enable"; + try { + const userPath = getMCPConfigPath("user", runtime.cwd); + const projectPath = getMCPConfigPath("project", runtime.cwd); + const [userConfig, projectConfig] = await Promise.all([ + readMCPConfigFile(userPath), + readMCPConfigFile(projectPath), + ]); + if (projectConfig.mcpServers?.[name] !== undefined) { + await updateMCPServer(projectPath, name, { ...projectConfig.mcpServers[name], enabled } as MCPServerConfig); + await runtime.output(`Server "${name}" ${enabled ? "enabled" : "disabled"} (project config).`); + return commandConsumed(); + } + if (userConfig.mcpServers?.[name] !== undefined) { + await updateMCPServer(userPath, name, { ...userConfig.mcpServers[name], enabled } as MCPServerConfig); + await runtime.output(`Server "${name}" ${enabled ? "enabled" : "disabled"} (user config).`); + return commandConsumed(); + } + const disabledList = await readDisabledServers(userPath); + if (!enabled || disabledList.includes(name)) { + await setServerDisabled(userPath, name, !enabled); + await runtime.output(`Server "${name}" ${enabled ? "enabled" : "disabled"}.`); + return commandConsumed(); + } + return usage(`Server "${name}" not found in user or project config.`, runtime); + } catch (err) { + return usage(`Failed to ${verb} MCP server: ${errorMessage(err)}`, runtime); + } +} + +async function handleRemoveCommand(rest: string, runtime: SlashCommandRuntime): Promise<SlashCommandResult> { + const parsed = parseNamedScopeArgs(rest, "Invalid --scope value. Use project or user."); + if (parsed.error) return usage(parsed.error, runtime); + if (!parsed.name) return usage("Usage: /mcp remove <name> [--scope project|user]", runtime); + try { + const filePath = getMCPConfigPath(parsed.scope, runtime.cwd); + await removeMCPServer(filePath, parsed.name); + await runtime.output(`Removed server "${parsed.name}" from ${parsed.scope} config.`); + return commandConsumed(); + } catch (err) { + return usage(`Failed to remove MCP server: ${errorMessage(err)}`, runtime); + } +} + +const MCP_HELP_TEXT = [ + "MCP server management (ACP mode)", + " /mcp list List configured servers", + " /mcp enable <name> Enable a server", + " /mcp disable <name> Disable a server", + " /mcp remove <name> [--scope project|user] Remove a server", + " /mcp reload Reload MCP runtime", + " /mcp resources List resources from all servers", + " /mcp prompts List prompts from all servers", + " /mcp test <name> Test connection to a server", + " /mcp add <name> [--scope project|user] [--url <url>] Add a server (non-interactive)", + " /mcp add <name> [-- <command...>] Add a stdio server", + " /mcp smithery-search <kw> [--scope project|user] Search Smithery registry", + " /mcp help Show this help", +].join("\n"); + +const TUI_ONLY_MCP_VERBS = new Set(["reauth", "unauth", "smithery-login", "smithery-logout", "reconnect"]); + +/** ACP/text-mode `/mcp` handler. Shared by both dispatchers via the spec. */ +export async function handleMcpAcp( + command: ParsedSlashCommand, + runtime: SlashCommandRuntime, +): Promise<SlashCommandResult> { + const { verb, rest } = parseSubcommand(command.args); + if (!verb || verb === "help") { + await runtime.output(MCP_HELP_TEXT); + return commandConsumed(); + } + if (verb === "notifications") { + return usage( + "MCP notifications require the TUI client (live MCPManager). Use /mcp list to see server status.", + runtime, + ); + } + if (TUI_ONLY_MCP_VERBS.has(verb)) { + return usage(`/mcp ${verb} requires OAuth or browser flows only available in the TUI client.`, runtime); + } + switch (verb) { + case "resources": + return await handleResourcesCommand(runtime); + case "prompts": + return await handlePromptsCommand(runtime); + case "test": + return await handleTestCommand(rest, runtime); + case "add": + return await handleAddCommand(rest, runtime); + case "smithery-search": + return await handleSmitherySearchCommand(rest, runtime); + case "reload": + await runtime.refreshCommands(); + await runtime.output("MCP runtime reload requested."); + return commandConsumed(); + case "list": + return await handleListCommand(runtime); + case "enable": + case "disable": + return await handleEnableDisableCommand(verb, rest, runtime); + case "remove": + case "rm": + return await handleRemoveCommand(rest, runtime); + default: + return usage(`Unknown /mcp subcommand: ${verb}. Use /mcp help for available subcommands.`, runtime); + } +} diff --git a/packages/coding-agent/src/slash-commands/helpers/parse.ts b/packages/coding-agent/src/slash-commands/helpers/parse.ts new file mode 100644 index 000000000..9332042b8 --- /dev/null +++ b/packages/coding-agent/src/slash-commands/helpers/parse.ts @@ -0,0 +1,85 @@ +import type { ParsedSlashCommand, SlashCommandResult, SlashCommandRuntime } from "../types"; + +export interface ParsedSubcommand { + verb: string; + rest: string; +} + +export type ConfigScope = "user" | "project"; + +export interface NamedScopeArgs { + name?: string; + scope: ConfigScope; + error?: string; +} + +/** + * Parse a slash-invocation string into `name`/`args`. + * + * The separator is the earliest whitespace or `:` character so that both + * `/foo bar` and `/foo:bar` map to `{ name: "foo", args: "bar" }`. + */ +export function parseSlashCommand(text: string): ParsedSlashCommand | null { + if (!text.startsWith("/")) return null; + const body = text.slice(1); + if (!body) return null; + const firstWhitespace = body.search(/\s/); + const firstColon = body.indexOf(":"); + const firstSeparator = + firstWhitespace === -1 ? firstColon : firstColon === -1 ? firstWhitespace : Math.min(firstWhitespace, firstColon); + if (firstSeparator === -1) return { name: body, args: "", text }; + return { + name: body.slice(0, firstSeparator), + args: body.slice(firstSeparator + 1).trim(), + text, + }; +} + +/** Mark a command as fully consumed in the ACP shape. */ +export function commandConsumed(): { consumed: true } { + return { consumed: true }; +} + +/** Emit a usage/error message and consume the command. */ +export async function usage(text: string, runtime: SlashCommandRuntime): Promise<SlashCommandResult> { + await runtime.output(text); + return commandConsumed(); +} + +/** Split `<verb> <rest>` on the first whitespace; lowercases `verb`. */ +export function parseSubcommand(input: string): ParsedSubcommand { + const trimmed = input.trim(); + if (!trimmed) return { verb: "", rest: "" }; + const spaceIdx = trimmed.search(/\s/); + if (spaceIdx === -1) return { verb: trimmed.toLowerCase(), rest: "" }; + return { verb: trimmed.slice(0, spaceIdx).toLowerCase(), rest: trimmed.slice(spaceIdx + 1).trim() }; +} + +export function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** + * Parse `<name?> [--scope project|user]`-style argument strings used by + * remove/rm-style subcommands. `name` is optional so callers can surface + * "name required" diagnostics with their own messaging. + */ +export function parseNamedScopeArgs(rest: string, invalidScopeMessage: string): NamedScopeArgs { + const tokens = rest.split(/\s+/).filter(Boolean); + let name: string | undefined; + let scope: ConfigScope = "project"; + let i = 0; + if (tokens.length > 0 && !tokens[0]!.startsWith("-")) { + name = tokens[0]; + i = 1; + } + while (i < tokens.length) { + const token = tokens[i]!; + if (token !== "--scope") return { scope, error: `Unknown option: ${token}` }; + const value = tokens[i + 1]; + if (!value || (value !== "project" && value !== "user")) return { scope, error: invalidScopeMessage }; + scope = value; + i += 2; + } + return { name, scope }; +} diff --git a/packages/coding-agent/src/slash-commands/helpers/ssh.ts b/packages/coding-agent/src/slash-commands/helpers/ssh.ts new file mode 100644 index 000000000..07b5cd4a8 --- /dev/null +++ b/packages/coding-agent/src/slash-commands/helpers/ssh.ts @@ -0,0 +1,193 @@ +import { getSSHConfigPath } from "@oh-my-pi/pi-utils"; +import { addSSHHost, readSSHConfigFile, removeSSHHost, type SSHHostConfig } from "../../ssh/config-writer"; +import { parseCommandArgs } from "../../utils/command-args"; +import type { ParsedSlashCommand, SlashCommandResult, SlashCommandRuntime } from "../types"; +import { commandConsumed, errorMessage, parseNamedScopeArgs, parseSubcommand, usage } from "./parse"; + +interface ParsedSshAddArgs { + name?: string; + scope: "user" | "project"; + host?: string; + username?: string; + port?: number; + keyPath?: string; + error?: string; +} + +type SshAddOptionParser = (parsed: ParsedSshAddArgs, value: string | undefined) => string | undefined; + +const SSH_ADD_USAGE = + "Usage: /ssh add <name> --host <host> [--user <user>] [--port <port>] [--key <keyPath>] [--scope project|user]"; + +const SSH_ADD_OPTION_PARSERS = new Map<string, SshAddOptionParser>([ + [ + "--host", + (parsed, value) => { + if (!value) return "Missing value for --host."; + parsed.host = value; + return undefined; + }, + ], + [ + "--user", + (parsed, value) => { + if (!value) return "Missing value for --user."; + parsed.username = value; + return undefined; + }, + ], + [ + "--port", + (parsed, value) => { + if (!value) return "Missing value for --port."; + // Reject any non-integer token. `Number.parseInt` accepts trailing + // garbage (parseInt("22oops") === 22) which silently coerces typos + // to valid-looking ports. + if (!/^\d+$/.test(value)) { + return "Invalid --port value. Must be an integer between 1 and 65535."; + } + const port = Number.parseInt(value, 10); + if (port < 1 || port > 65535) { + return "Invalid --port value. Must be an integer between 1 and 65535."; + } + parsed.port = port; + return undefined; + }, + ], + [ + "--key", + (parsed, value) => { + if (!value) return "Missing value for --key."; + parsed.keyPath = value; + return undefined; + }, + ], + [ + "--scope", + (parsed, value) => { + if (!value || (value !== "project" && value !== "user")) return "Invalid --scope value. Use project or user."; + parsed.scope = value; + return undefined; + }, + ], +]); + +function parseSshAddArgs(rest: string): ParsedSshAddArgs { + const tokens = parseCommandArgs(rest); + const parsed: ParsedSshAddArgs = { scope: "project" }; + let index = 0; + if (tokens.length > 0 && !tokens[0]!.startsWith("-")) { + parsed.name = tokens[0]; + index = 1; + } + while (index < tokens.length) { + const arg = tokens[index]!; + const parser = SSH_ADD_OPTION_PARSERS.get(arg); + if (!parser) return { ...parsed, error: `Unknown option: ${arg}` }; + const error = parser(parsed, tokens[index + 1]); + if (error) return { ...parsed, error }; + index += 2; + } + return parsed; +} + +const SSH_HELP_TEXT = [ + "SSH host management (ACP mode)", + " /ssh add <name> --host <host> [--user <user>] [--port <port>] [--key <keyPath>] [--scope project|user]", + " /ssh list List configured SSH hosts", + " /ssh remove <name> [--scope project|user] Remove an SSH host", + " /ssh help Show this help", +].join("\n"); + +async function handleListCommand(runtime: SlashCommandRuntime): Promise<SlashCommandResult> { + try { + const userPath = getSSHConfigPath("user", runtime.cwd); + const projectPath = getSSHConfigPath("project", runtime.cwd); + const [userConfig, projectConfig] = await Promise.all([ + readSSHConfigFile(userPath), + readSSHConfigFile(projectPath), + ]); + const entries: Array<{ name: string; host: string; user?: string; port?: number; scope: string }> = []; + // Capability loader resolves project before user, so list project hosts + // first and let the user-scope loop skip duplicates. Otherwise a host + // shared between scopes shows up under "user" when the project entry + // is the one actually in effect. + for (const [name, config] of Object.entries(projectConfig.hosts ?? {})) { + entries.push({ name, host: config.host, user: config.username, port: config.port, scope: "project" }); + } + for (const [name, config] of Object.entries(userConfig.hosts ?? {})) { + if (!entries.some(entry => entry.name === name)) { + entries.push({ name, host: config.host, user: config.username, port: config.port, scope: "user" }); + } + } + if (entries.length === 0) { + await runtime.output("No SSH hosts configured."); + return commandConsumed(); + } + await runtime.output( + entries + .map(entry => `${entry.name} | ${entry.host} | ${entry.user ?? "-"} | ${entry.port ?? 22} [${entry.scope}]`) + .join("\n"), + ); + return commandConsumed(); + } catch (err) { + return usage(`Failed to list SSH hosts: ${errorMessage(err)}`, runtime); + } +} + +async function handleRemoveCommand(rest: string, runtime: SlashCommandRuntime): Promise<SlashCommandResult> { + const parsed = parseNamedScopeArgs(rest, "Invalid --scope value. Use project or user."); + if (parsed.error) return usage(parsed.error, runtime); + if (!parsed.name) return usage("Usage: /ssh remove <name> [--scope project|user]", runtime); + try { + const filePath = getSSHConfigPath(parsed.scope, runtime.cwd); + await removeSSHHost(filePath, parsed.name); + await runtime.output(`Removed SSH host "${parsed.name}" from ${parsed.scope} config.`); + return commandConsumed(); + } catch (err) { + return usage(`Failed to remove SSH host: ${errorMessage(err)}`, runtime); + } +} + +async function handleAddCommand(rest: string, runtime: SlashCommandRuntime): Promise<SlashCommandResult> { + if (!rest) return usage(SSH_ADD_USAGE, runtime); + const parsed = parseSshAddArgs(rest); + if (parsed.error) return usage(parsed.error, runtime); + if (!parsed.name) return usage("Host name required. Usage: /ssh add <name> --host <host> ...", runtime); + if (!parsed.host) return usage("--host is required. Usage: /ssh add <name> --host <host> ...", runtime); + const hostConfig: SSHHostConfig = { host: parsed.host }; + if (parsed.username) hostConfig.username = parsed.username; + if (parsed.port) hostConfig.port = parsed.port; + if (parsed.keyPath) hostConfig.keyPath = parsed.keyPath; + try { + const filePath = getSSHConfigPath(parsed.scope, runtime.cwd); + await addSSHHost(filePath, parsed.name, hostConfig); + await runtime.output(`Added SSH host "${parsed.name}" (${parsed.scope}).`); + return commandConsumed(); + } catch (err) { + return usage(`Failed to add SSH host: ${errorMessage(err)}`, runtime); + } +} + +/** ACP/text-mode `/ssh` handler. Shared by both dispatchers via the spec. */ +export async function handleSshAcp( + command: ParsedSlashCommand, + runtime: SlashCommandRuntime, +): Promise<SlashCommandResult> { + const { verb, rest } = parseSubcommand(command.args); + if (!verb || verb === "help") { + await runtime.output(SSH_HELP_TEXT); + return commandConsumed(); + } + switch (verb) { + case "list": + return await handleListCommand(runtime); + case "remove": + case "rm": + return await handleRemoveCommand(rest, runtime); + case "add": + return await handleAddCommand(rest, runtime); + default: + return usage(`Unknown /ssh subcommand: ${verb}. Use /ssh help for available subcommands.`, runtime); + } +} diff --git a/packages/coding-agent/src/slash-commands/helpers/todo.ts b/packages/coding-agent/src/slash-commands/helpers/todo.ts new file mode 100644 index 000000000..54158a7db --- /dev/null +++ b/packages/coding-agent/src/slash-commands/helpers/todo.ts @@ -0,0 +1,279 @@ +import * as path from "node:path"; +import type { TodoPhase } from "../../tools/todo-write"; +import { + applyOpsToPhases, + getLatestTodoPhasesFromEntries, + markdownToPhases, + phasesToMarkdown, + USER_TODO_EDIT_CUSTOM_TYPE, +} from "../../tools/todo-write"; +import type { ParsedSlashCommand, SlashCommandResult, SlashCommandRuntime } from "../types"; +import { commandConsumed, parseSubcommand, usage } from "./parse"; + +type TodoMutationVerb = "done" | "drop" | "rm"; + +interface TodoTaskMatch { + task: { content: string; status: string }; + phase: TodoPhase; +} + +function tokenize(input: string): string[] { + const tokens: string[] = []; + let current = ""; + let inQuote = false; + for (let index = 0; index < input.length; index++) { + const ch = input[index]; + if (ch === "\\" && index + 1 < input.length) { + current += input[++index]; + continue; + } + if (ch === '"') { + inQuote = !inQuote; + continue; + } + if (!inQuote && /\s/.test(ch)) { + if (current) { + tokens.push(current); + current = ""; + } + continue; + } + current += ch; + } + if (current) tokens.push(current); + return tokens; +} + +function titleCaseWords(text: string): string { + return text + .split(/\s+/) + .filter(Boolean) + .map(word => word[0].toUpperCase() + word.slice(1)) + .join(" "); +} + +function titleCaseSentence(text: string): string { + const trimmed = text.trim(); + if (!trimmed) return trimmed; + return trimmed[0].toUpperCase() + trimmed.slice(1); +} + +function findPhaseFuzzy(phases: TodoPhase[], query: string): TodoPhase | undefined { + const normalizedQuery = query.trim().toLowerCase(); + if (!normalizedQuery) return undefined; + const exact = phases.find(phase => phase.name.toLowerCase() === normalizedQuery); + if (exact) return exact; + const prefixMatches = phases.filter(phase => phase.name.toLowerCase().startsWith(normalizedQuery)); + if (prefixMatches.length === 1) return prefixMatches[0]; + const substringMatches = phases.filter(phase => phase.name.toLowerCase().includes(normalizedQuery)); + if (substringMatches.length === 1) return substringMatches[0]; + return undefined; +} + +function findTaskFuzzy(phases: TodoPhase[], query: string): TodoTaskMatch | undefined { + const normalizedQuery = query.trim().toLowerCase(); + if (!normalizedQuery) return undefined; + for (const phase of phases) { + for (const task of phase.tasks) { + if (task.content.toLowerCase() === normalizedQuery) return { task, phase }; + } + } + const matches: TodoTaskMatch[] = []; + for (const phase of phases) { + for (const task of phase.tasks) { + if (task.content.toLowerCase().includes(normalizedQuery)) matches.push({ task, phase }); + } + } + if (matches.length === 1) return matches[0]; + const active = matches.filter(match => match.task.status === "in_progress" || match.task.status === "pending"); + if (active.length === 1) return active[0]; + return undefined; +} + +function currentPhases(runtime: SlashCommandRuntime): TodoPhase[] { + const fromEntries = getLatestTodoPhasesFromEntries(runtime.sessionManager.getBranch()); + return fromEntries.length > 0 ? fromEntries : runtime.session.getTodoPhases(); +} + +function commitTodos(runtime: SlashCommandRuntime, phases: TodoPhase[]): void { + runtime.session.setTodoPhases(phases); + runtime.sessionManager.appendCustomEntry(USER_TODO_EDIT_CUSTOM_TYPE, { phases }); +} + +const TODO_HELP_TEXT = [ + "Usage: /todo <verb> [args]", + " /todo Show current todos", + " /todo edit (TUI only) open in $EDITOR", + " /todo copy Print todos as Markdown", + " /todo export [<path>] Write todos to file (default: TODO.md)", + " /todo import [<path>] Replace todos from file (default: TODO.md)", + " /todo append [<phase>] <task...> Append a task", + " /todo start <task> Mark task in_progress (fuzzy match)", + " /todo done [<task|phase>] Mark task/phase/all completed", + " /todo drop [<task|phase>] Mark task/phase/all abandoned", + " /todo rm [<task|phase>] Remove task/phase/all", +].join("\n"); + +async function handleTodoCopyCommand(runtime: SlashCommandRuntime): Promise<SlashCommandResult> { + const phases = currentPhases(runtime); + const markdown = phases.length === 0 ? "" : phasesToMarkdown(phases).trimEnd(); + await runtime.output(`Copy not available in ACP mode; printing instead:\n\n${markdown || "No todos."}`); + return commandConsumed(); +} + +async function handleTodoExportCommand(restArgs: string, runtime: SlashCommandRuntime): Promise<SlashCommandResult> { + const phases = currentPhases(runtime); + if (phases.length === 0) { + await runtime.output("No todos to export."); + return commandConsumed(); + } + const target = restArgs ? path.resolve(runtime.cwd, restArgs) : path.resolve(runtime.cwd, "TODO.md"); + await Bun.write(target, phasesToMarkdown(phases)); + await runtime.output(`Wrote todos to ${target}`); + return commandConsumed(); +} + +async function handleTodoImportCommand(restArgs: string, runtime: SlashCommandRuntime): Promise<SlashCommandResult> { + const target = restArgs ? path.resolve(runtime.cwd, restArgs) : path.resolve(runtime.cwd, "TODO.md"); + let content: string; + try { + content = await Bun.file(target).text(); + } catch (err) { + return usage(`Failed to read ${target}: ${err instanceof Error ? err.message : String(err)}`, runtime); + } + const { phases, errors } = markdownToPhases(content); + if (errors.length > 0) return usage(`Could not parse ${target}:\n ${errors.join("\n ")}`, runtime); + commitTodos(runtime, phases); + const taskCount = phases.reduce((sum, phase) => sum + phase.tasks.length, 0); + await runtime.output(`Imported ${phases.length} phase(s), ${taskCount} task(s) from ${target}.`); + return commandConsumed(); +} + +async function handleTodoAppendCommand(restArgs: string, runtime: SlashCommandRuntime): Promise<SlashCommandResult> { + const tokens = tokenize(restArgs); + if (tokens.length === 0) return usage("Usage: /todo append [<phase>] <task...>", runtime); + + const current = currentPhases(runtime); + const phaseName = tokens.length === 1 ? undefined : tokens[0]; + const content = tokens.length === 1 ? tokens[0]! : tokens.slice(1).join(" "); + const next = current.map(phase => ({ ...phase, tasks: phase.tasks.slice() })); + let targetPhase: TodoPhase; + + if (phaseName) { + const existing = findPhaseFuzzy(next, phaseName); + targetPhase = existing ?? { name: titleCaseWords(phaseName), tasks: [] }; + if (!existing) next.push(targetPhase); + } else if (next.length > 0) { + targetPhase = next[next.length - 1]!; + } else { + targetPhase = { name: "Todos", tasks: [] }; + next.push(targetPhase); + } + + const finalContent = titleCaseSentence(content); + targetPhase.tasks.push({ content: finalContent, status: "pending" }); + commitTodos(runtime, next); + await runtime.output(`Appended to ${targetPhase.name}: ${finalContent}`); + return commandConsumed(); +} + +async function handleTodoStartCommand(restArgs: string, runtime: SlashCommandRuntime): Promise<SlashCommandResult> { + if (!restArgs) return usage("Usage: /todo start <task>", runtime); + const current = currentPhases(runtime); + const query = tokenize(restArgs).join(" ") || restArgs; + const hit = findTaskFuzzy(current, query); + if (!hit) return usage(`No task matched "${restArgs}". Use /todo to list current tasks.`, runtime); + const { phases } = applyOpsToPhases(current, [{ op: "start", task: hit.task.content }]); + commitTodos(runtime, phases); + await runtime.output(`Started: ${hit.task.content}`); + return commandConsumed(); +} + +async function handleTodoMutationCommand( + verb: TodoMutationVerb, + restArgs: string, + runtime: SlashCommandRuntime, +): Promise<SlashCommandResult> { + const current = currentPhases(runtime); + const trimmedArg = restArgs.trim(); + if (!trimmedArg) { + if (verb === "rm") { + commitTodos(runtime, []); + await runtime.output("Cleared all todos."); + return commandConsumed(); + } + const { phases } = applyOpsToPhases(current, [{ op: verb }]); + commitTodos(runtime, phases); + await runtime.output(verb === "done" ? "Marked all tasks completed." : "Marked all tasks abandoned."); + return commandConsumed(); + } + + const taskHit = findTaskFuzzy(current, trimmedArg); + if (taskHit) { + const { phases } = applyOpsToPhases(current, [{ op: verb, task: taskHit.task.content }]); + commitTodos(runtime, phases); + const label = verb === "done" ? "Marked completed" : verb === "drop" ? "Marked abandoned" : "Removed"; + await runtime.output(`${label}: ${taskHit.task.content}`); + return commandConsumed(); + } + + const phaseHit = findPhaseFuzzy(current, trimmedArg); + if (phaseHit) { + const { phases } = applyOpsToPhases(current, [{ op: verb, phase: phaseHit.name }]); + commitTodos(runtime, phases); + const message = + verb === "done" + ? `Marked phase ${phaseHit.name} completed.` + : verb === "drop" + ? `Marked phase ${phaseHit.name} abandoned.` + : `Removed phase: ${phaseHit.name}`; + await runtime.output(message); + return commandConsumed(); + } + + return usage(`No task or phase matched "${trimmedArg}".`, runtime); +} + +/** ACP/text-mode `/todo` handler. Shared by both dispatchers via the spec. */ +export async function handleTodoAcp( + command: ParsedSlashCommand, + runtime: SlashCommandRuntime, +): Promise<SlashCommandResult> { + const trimmed = command.args.trim(); + if (!trimmed) { + const phases = currentPhases(runtime); + await runtime.output( + phases.length === 0 ? "No todos. Use /todo append <task> to start one." : phasesToMarkdown(phases).trimEnd(), + ); + return commandConsumed(); + } + + const { verb, rest } = parseSubcommand(trimmed); + switch (verb) { + case "copy": + return await handleTodoCopyCommand(runtime); + case "export": + return await handleTodoExportCommand(rest, runtime); + case "import": + return await handleTodoImportCommand(rest, runtime); + case "append": + return await handleTodoAppendCommand(rest, runtime); + case "start": + return await handleTodoStartCommand(rest, runtime); + case "done": + case "drop": + case "rm": + return await handleTodoMutationCommand(verb, rest, runtime); + case "edit": + return usage( + "/todo edit requires the TUI editor; use /todo export then /todo import for non-interactive edits.", + runtime, + ); + case "help": + case "?": + await runtime.output(TODO_HELP_TEXT); + return commandConsumed(); + default: + return usage("Unknown /todo subcommand. Use append, start, done, drop, rm, copy, export, import.", runtime); + } +} diff --git a/packages/coding-agent/src/slash-commands/helpers/usage-report.ts b/packages/coding-agent/src/slash-commands/helpers/usage-report.ts new file mode 100644 index 000000000..9d737ce9f --- /dev/null +++ b/packages/coding-agent/src/slash-commands/helpers/usage-report.ts @@ -0,0 +1,91 @@ +import type { UsageLimit, UsageReport } from "@oh-my-pi/pi-ai"; +import type { SlashCommandRuntime } from "../types"; +import { formatDuration, renderAsciiBar } from "./format"; + +function formatProviderName(provider: string): string { + return provider + .split(/[-_]/g) + .map(part => (part ? part[0].toUpperCase() + part.slice(1) : "")) + .join(" "); +} + +function formatUsageAmount(limit: UsageLimit): string { + const amount = limit.amount; + const used = amount.used ?? (amount.usedFraction !== undefined ? amount.usedFraction * 100 : undefined); + const remainingFraction = + amount.remainingFraction ?? + (amount.usedFraction !== undefined ? Math.max(0, 1 - amount.usedFraction) : undefined); + const unit = amount.unit === "percent" ? "%" : ` ${amount.unit}`; + const usedText = used === undefined ? "unknown used" : `${used.toFixed(2)}${unit} used`; + const remainingText = remainingFraction === undefined ? "" : ` (${(remainingFraction * 100).toFixed(1)}% left)`; + return `${usedText}${remainingText}`; +} + +function formatUsageReportAccount(report: UsageReport, limit: UsageLimit, index: number): string { + const email = report.metadata?.email; + if (typeof email === "string" && email) return email; + const accountId = report.metadata?.accountId ?? limit.scope.accountId; + if (typeof accountId === "string" && accountId) return accountId; + return `account ${index + 1}`; +} + +function renderUsageReports(reports: UsageReport[], nowMs: number): string { + const latestFetchedAt = Math.max(...reports.map(report => report.fetchedAt ?? 0)); + const lines = [`Usage${latestFetchedAt ? ` (${formatDuration(nowMs - latestFetchedAt)} ago)` : ""}`]; + const grouped = new Map<string, UsageReport[]>(); + for (const report of reports) { + const providerReports = grouped.get(report.provider) ?? []; + providerReports.push(report); + grouped.set(report.provider, providerReports); + } + + for (const [provider, providerReports] of [...grouped.entries()].sort(([left], [right]) => + left.localeCompare(right), + )) { + lines.push("", formatProviderName(provider)); + for (const report of providerReports) { + if (report.limits.length === 0) { + const email = typeof report.metadata?.email === "string" ? report.metadata.email : "account"; + lines.push(`- ${email}: no limits reported`); + continue; + } + for (let index = 0; index < report.limits.length; index++) { + const limit = report.limits[index]!; + const window = limit.window?.label ?? limit.scope.windowId; + const tier = limit.scope.tier ? ` (${limit.scope.tier})` : ""; + lines.push(`- ${limit.label}${tier}${window ? ` — ${window}` : ""}`); + lines.push(` ${formatUsageReportAccount(report, limit, index)}: ${formatUsageAmount(limit)}`); + lines.push(` ${renderAsciiBar(limit.amount.usedFraction)}`); + if (limit.window?.resetsAt) lines.push(` resets in ${formatDuration(limit.window.resetsAt - nowMs)}`); + if (limit.notes && limit.notes.length > 0) lines.push(` ${limit.notes.join(" • ")}`); + } + } + } + return ["```", ...lines, "```"].join("\n"); +} + +/** + * Build the `/usage` ACP-mode text. Prefers provider-reported limits when the + * session exposes `fetchUsageReports`; otherwise falls back to the local + * session-manager tallies. + */ +export async function buildUsageReportText(runtime: SlashCommandRuntime): Promise<string> { + const provider = runtime.session as SlashCommandRuntime["session"] & { + fetchUsageReports?: () => Promise<UsageReport[] | null>; + }; + if (provider.fetchUsageReports) { + const reports = await provider.fetchUsageReports(); + if (reports && reports.length > 0) return renderUsageReports(reports, Date.now()); + } + + const stats = runtime.session.sessionManager.getUsageStatistics(); + return [ + "Usage", + `Input tokens: ${stats.input}`, + `Output tokens: ${stats.output}`, + `Cache read tokens: ${stats.cacheRead}`, + `Cache write tokens: ${stats.cacheWrite}`, + `Premium requests: ${stats.premiumRequests}`, + `Cost: $${stats.cost.toFixed(6)}`, + ].join("\n"); +} diff --git a/packages/coding-agent/src/slash-commands/types.ts b/packages/coding-agent/src/slash-commands/types.ts new file mode 100644 index 000000000..4d4114cc4 --- /dev/null +++ b/packages/coding-agent/src/slash-commands/types.ts @@ -0,0 +1,127 @@ +import type { Settings } from "../config/settings"; +import type { InteractiveModeContext } from "../modes/types"; +import type { AgentSession } from "../session/agent-session"; +import type { SessionManager } from "../session/session-manager"; + +/** Declarative subcommand definition for commands like /mcp. */ +export interface SubcommandDef { + name: string; + description: string; + /** Usage hint shown as dim ghost text, e.g. "<name> [--scope project|user]". */ + usage?: string; +} + +/** Declarative builtin slash command metadata used by autocomplete and help UI. */ +export interface BuiltinSlashCommand { + name: string; + description: string; + /** Subcommands for dropdown completion (e.g. /mcp add, /mcp list). */ + subcommands?: SubcommandDef[]; + /** Static inline hint when command takes a simple argument (no subcommands). */ + inlineHint?: string; +} + +/** Parsed slash-command text after stripping the leading "/". */ +export interface ParsedSlashCommand { + name: string; + args: string; + text: string; +} + +/** + * Result returned by a slash-command handler. + * + * - `void` / `undefined` — command was handled and consumed; no further input. + * - `{ consumed: true }` — explicit equivalent of the above (ACP shape). + * - `{ prompt: string }` — command handled, pass `prompt` through as the new + * user input (e.g. `/force <tool> <prompt>` keeps `<prompt>` as the message). + */ +export type SlashCommandResult = void | { consumed: true } | { prompt: string }; + +/** + * Runtime visible to slash-command handlers that run in text/ACP mode. + * + * Both the TUI dispatcher (when invoking a `handle` via its adapter) and the + * ACP dispatcher pass this shape. Implementations MUST NOT depend on TUI-only + * state (editor, selectors, status line). + */ +export interface SlashCommandRuntime { + session: AgentSession; + sessionManager: SessionManager; + settings: Settings; + cwd: string; + /** Emit text to the operator. TUI maps to `ctx.showStatus`, ACP to `sessionUpdate`. */ + output: (text: string) => Promise<void> | void; + /** Re-advertise the available command list (no-op outside ACP). */ + refreshCommands: () => Promise<void> | void; + /** + * Reload plugin state (caches, slash command registry, project registries) + * and re-emit available commands. Used by `/reload-plugins`, `/move`, and + * `/marketplace`/`/plugins` mutations so the session sees a consistent view + * after plugin or project-scope changes. + */ + reloadPlugins: () => Promise<void>; + notifyTitleChanged?: () => Promise<void> | void; + notifyConfigChanged?: () => Promise<void> | void; +} + +/** + * Runtime visible to TUI-only handlers (`handleTui`). Carries the interactive + * mode context plus the background-detach hook. Intentionally narrower than + * `SlashCommandRuntime` so existing callers can keep building it from just + * `{ ctx, handleBackgroundCommand }`; when the TUI dispatcher needs to invoke + * a `handle` (no `handleTui` override), it synthesizes a `SlashCommandRuntime` + * from `ctx`. + */ +export interface TuiSlashCommandRuntime { + ctx: InteractiveModeContext; + handleBackgroundCommand: () => void; +} + +/** Unified slash-command spec consumed by both TUI and ACP dispatchers. */ +export interface SlashCommandSpec extends BuiltinSlashCommand { + aliases?: string[]; + /** When false, the dispatcher refuses to handle invocations that include arguments. */ + allowArgs?: boolean; + /** + * ACP-specific override for `description`. Used by `ACP_BUILTIN_SLASH_COMMANDS` + * when building `available_commands_update` payloads so the client receives + * mode-appropriate copy (e.g. `/dump` advertises "Return full transcript as + * plain text" in ACP rather than the TUI's clipboard-centric copy). + */ + acpDescription?: string; + /** + * ACP-specific override for the advertised input hint. `subcommands`-only + * specs that historically advertised `<subcommand>` / `[on|off|status]` / + * `info|delete` to ACP clients carry the hint here so the unification does + * not silently drop it from `available_commands_update`. + */ + acpInputHint?: string; + /** + * Text/ACP-mode handler. The same body is invoked from the ACP dispatcher + * and, via the TUI adapter, when no `handleTui` override is provided. + */ + handle?: ( + command: ParsedSlashCommand, + runtime: SlashCommandRuntime, + ) => Promise<SlashCommandResult> | SlashCommandResult; + /** + * TUI-only handler that supersedes `handle` when both are present. Use for + * selectors, wizards, dashboards, and anything else that requires + * `InteractiveModeContext`. + */ + handleTui?: ( + command: ParsedSlashCommand, + runtime: TuiSlashCommandRuntime, + // biome-ignore lint/suspicious/noConfusingVoidType: void needed so async handlers returning nothing are assignable + ) => Promise<SlashCommandResult> | SlashCommandResult; +} + +/** + * @deprecated Use `SlashCommandRuntime` directly. Retained as an alias so + * downstream code that imported the ACP-specific name keeps compiling. + */ +export type AcpBuiltinCommandRuntime = SlashCommandRuntime; + +/** Result returned by `executeAcpBuiltinSlashCommand`. */ +export type AcpBuiltinSlashCommandResult = false | { consumed: true } | { prompt: string }; diff --git a/packages/coding-agent/src/tools/bash.ts b/packages/coding-agent/src/tools/bash.ts index 31f47fc35..3a1841d22 100644 --- a/packages/coding-agent/src/tools/bash.ts +++ b/packages/coding-agent/src/tools/bash.ts @@ -2,7 +2,7 @@ import * as fs from "node:fs"; import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; import type { Component } from "@oh-my-pi/pi-tui"; import { ImageProtocol, TERMINAL, Text } from "@oh-my-pi/pi-tui"; -import { $env, getProjectDir, isEnoent, prompt } from "@oh-my-pi/pi-utils"; +import { $env, getProjectDir, isEnoent, logger, prompt } from "@oh-my-pi/pi-utils"; import { Type } from "@sinclair/typebox"; import { AsyncJobManager } from "../async"; import { type BashResult, executeBash } from "../exec/bash-executor"; @@ -11,6 +11,7 @@ import { InternalUrlRouter } from "../internal-urls"; import { truncateToVisualLines } from "../modes/components/visual-truncate"; import type { Theme } from "../modes/theme/theme"; import bashDescription from "../prompts/tools/bash.md" with { type: "text" }; +import type { ClientBridgeTerminalExitStatus, ClientBridgeTerminalOutput } from "../session/client-bridge"; import { DEFAULT_MAX_BYTES, streamTailUpdates, TailBuffer } from "../session/streaming-output"; import { renderStatusLine } from "../tui"; import { CachedOutputBlock } from "../tui/output-block"; @@ -84,6 +85,7 @@ export interface BashToolDetails { meta?: OutputMeta; timeoutSeconds?: number; requestedTimeoutSeconds?: number; + terminalId?: string; async?: { state: "running" | "completed" | "failed"; jobId: string; @@ -289,7 +291,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> { #buildCompletedResult( result: BashResult | BashInteractiveResult, timeoutSec: number, - options: { requestedTimeoutSec?: number; notices?: string[] } = {}, + options: { requestedTimeoutSec?: number; notices?: string[]; terminalId?: string } = {}, ): AgentToolResult<BashToolDetails> { const outputLines = [this.#formatResultOutput(result)]; const notices = options.notices?.filter(Boolean) ?? []; @@ -299,6 +301,9 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> { if (options.requestedTimeoutSec !== undefined && options.requestedTimeoutSec !== timeoutSec) { details.requestedTimeoutSeconds = options.requestedTimeoutSec; } + if (options.terminalId !== undefined) { + details.terminalId = options.terminalId; + } const resultBuilder = toolResult(details).text(outputText).truncationFromSummary(result, { direction: "tail" }); this.#buildResultText(result, timeoutSec, outputText); return resultBuilder.done(); @@ -618,6 +623,175 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> { }); } + // Route through the client terminal when the client advertises the terminal capability. + // Skip when pty=true (PTY needs the local terminal UI). + const clientBridge = this.session.getClientBridge?.(); + if (clientBridge?.capabilities.terminal && clientBridge.createTerminal && !pty) { + const handle = await clientBridge.createTerminal({ + command, + cwd: commandCwd, + env: resolvedEnv + ? Object.entries(resolvedEnv).map(([name, value]) => ({ name, value: value as string })) + : undefined, + outputByteLimit: DEFAULT_MAX_BYTES, + }); + + // Emit partial update so the editor can embed the live terminal card. + onUpdate?.({ content: [], details: { terminalId: handle.terminalId } }); + + const exitPromise = handle.waitForExit(); + let exitStatus!: ClientBridgeTerminalExitStatus; + + type BridgeRaceResult = + | { kind: "exit"; status: ClientBridgeTerminalExitStatus } + | { kind: "poll" } + | { kind: "timeout" } + | { kind: "aborted" }; + + // Set up abort listener before entering the poll loop. The listener + // kicks off `handle.kill()` synchronously so a `session/cancel` + // arriving mid-poll terminates the remote command immediately, + // instead of waiting for the next `currentOutput()` to return. + const { promise: abortedP, resolve: resolveAborted } = Promise.withResolvers<void>(); + let killStarted = false; + const fireKill = (): Promise<void> => { + if (killStarted) return Promise.resolve(); + killStarted = true; + return handle.kill().catch((error: unknown) => { + logger.warn("ACP terminal kill failed", { terminalId: handle.terminalId, error }); + }); + }; + const onAbortSignal = () => { + resolveAborted(); + void fireKill(); + }; + signal?.addEventListener("abort", onAbortSignal, { once: true }); + + try { + try { + if (signal?.aborted) { + await fireKill(); + throw new ToolAbortError("Command aborted"); + } + + const timeoutPromise = Bun.sleep(timeoutMs).then(() => ({ kind: "timeout" as const })); + // Poll until the process exits, times out, or the caller aborts. + for (;;) { + const racers: Array<Promise<BridgeRaceResult>> = [ + exitPromise.then(s => ({ kind: "exit" as const, status: s })), + timeoutPromise, + Bun.sleep(250).then(() => ({ kind: "poll" as const })), + ]; + if (signal) { + racers.push(abortedP.then(() => ({ kind: "aborted" as const }))); + } + const raced = await Promise.race(racers); + + if (raced.kind === "aborted" || signal?.aborted) { + await fireKill(); + throw new ToolAbortError("Command aborted"); + } + + if (raced.kind === "timeout") { + // Kill before reading final output so a slow `terminal/output` + // RPC cannot let a timed-out command keep running past the + // enforced timeout. The handle stays valid post-kill so the + // buffered output is still readable. + await fireKill(); + let current = { output: "", truncated: false }; + try { + current = await handle.currentOutput(); + } catch (error) { + logger.warn("ACP terminal final output read failed", { + terminalId: handle.terminalId, + error, + }); + } + const timedOutResult: BashInteractiveResult = { + output: current.output, + exitCode: undefined, + cancelled: false, + timedOut: true, + truncated: current.truncated, + totalLines: current.output.length > 0 ? current.output.split("\n").length : 0, + totalBytes: current.output.length, + outputLines: current.output.length > 0 ? current.output.split("\n").length : 0, + outputBytes: current.output.length, + }; + return this.#buildCompletedResult(timedOutResult, timeoutSec, { + requestedTimeoutSec, + notices: [timeoutClampNotice].filter((notice): notice is string => Boolean(notice)), + terminalId: handle.terminalId, + }); + } + + if (raced.kind === "exit") { + exitStatus = raced.status; + break; + } + + // Poll tick: push current output so agent-loop transcript stays consistent. + // Race the read against abort so a stuck `terminal/output` RPC does not + // delay cancellation. + const pollOutput = await Promise.race([ + handle.currentOutput(), + abortedP.then(() => undefined as ClientBridgeTerminalOutput | undefined), + ]); + if (pollOutput === undefined) { + // Abort fired during the poll-tick read; let the next loop iteration + // observe `signal?.aborted` and exit via the abort branch. + continue; + } + onUpdate?.({ + content: [{ type: "text", text: pollOutput.output }], + details: { terminalId: handle.terminalId }, + }); + } + } finally { + signal?.removeEventListener("abort", onAbortSignal); + } + + // Fetch final output; the terminal is released in the outer finally. + const finalOutput = await handle.currentOutput(); + + // Map exit status: null exitCode with a signal → treat as signal kill (137). + const rawExitCode = exitStatus.exitCode; + const exitCode: number | undefined = + rawExitCode != null ? rawExitCode : exitStatus.signal ? 137 : undefined; + + const outputText = finalOutput.output; + const outputByteLen = outputText.length; + const outputLineCount = outputText.length > 0 ? outputText.split("\n").length : 0; + + const bridgeResult: BashResult = { + output: outputText, + exitCode, + cancelled: false, + truncated: finalOutput.truncated, + totalLines: outputLineCount, + totalBytes: outputByteLen, + outputLines: outputLineCount, + outputBytes: outputByteLen, + }; + + const bridgeNotices: string[] = []; + if (finalOutput.truncated) bridgeNotices.push("(output truncated)"); + if (timeoutClampNotice) bridgeNotices.push(timeoutClampNotice); + + return this.#buildCompletedResult(bridgeResult, timeoutSec, { + requestedTimeoutSec, + notices: bridgeNotices, + terminalId: handle.terminalId, + }); + } finally { + try { + await handle.release(); + } catch (error) { + logger.warn("ACP terminal release failed", { terminalId: handle.terminalId, error }); + } + } + } + // Track output for streaming updates (tail only) const tailBuffer = new TailBuffer(DEFAULT_MAX_BYTES); diff --git a/packages/coding-agent/src/tools/index.ts b/packages/coding-agent/src/tools/index.ts index 519dfb21f..ac0740f54 100644 --- a/packages/coding-agent/src/tools/index.ts +++ b/packages/coding-agent/src/tools/index.ts @@ -11,6 +11,7 @@ import { LspTool } from "../lsp"; import type { PlanModeState } from "../plan-mode/state"; import { type AgentRegistry, MAIN_AGENT_ID } from "../registry/agent-registry"; import type { ArtifactManager } from "../session/artifacts"; +import type { ClientBridge } from "../session/client-bridge"; import type { CustomMessage } from "../session/messages"; import type { ToolChoiceQueue } from "../session/tool-choice-queue"; import { TaskTool } from "../task"; @@ -178,6 +179,8 @@ export interface ToolSession { settings: Settings; /** Plan mode state (if active) */ getPlanModeState?: () => PlanModeState | undefined; + /** Bridge to the connected client (e.g. ACP editor host). Tools should route fs/terminal/permission requests through this when available. */ + getClientBridge?: () => ClientBridge | undefined; /** Get compact conversation context for subagents (excludes tool results, system prompts) */ getCompactContext?: () => string; /** Get cached todo phases for this session. */ diff --git a/packages/coding-agent/src/tools/read.ts b/packages/coding-agent/src/tools/read.ts index f4645be60..f1b981d8f 100644 --- a/packages/coding-agent/src/tools/read.ts +++ b/packages/coding-agent/src/tools/read.ts @@ -6,7 +6,7 @@ import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai"; import { glob, type SummaryResult, summarizeCode } from "@oh-my-pi/pi-natives"; import type { Component } from "@oh-my-pi/pi-tui"; import { Text } from "@oh-my-pi/pi-tui"; -import { getRemoteDir, prompt, readImageMetadata, untilAborted } from "@oh-my-pi/pi-utils"; +import { getRemoteDir, logger, prompt, readImageMetadata, untilAborted } from "@oh-my-pi/pi-utils"; import { type Static, Type } from "@sinclair/typebox"; import { getFileReadCache } from "../edit/file-read-cache"; import { isNotebookPath, readEditableNotebookText } from "../edit/notebook"; @@ -1045,12 +1045,25 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> { } } + #routeReadThroughBridge( + absolutePath: string, + options?: { line?: number; limit?: number }, + ): Promise<string> | undefined { + const bridge = this.session.getClientBridge?.(); + if (!bridge?.capabilities.readTextFile || !bridge.readTextFile) return undefined; + return bridge.readTextFile({ path: absolutePath, ...options }); + } + async #trySummarize(absolutePath: string, fileSize: number, signal?: AbortSignal): Promise<SummaryResult | null> { if (fileSize > MAX_SUMMARY_BYTES) return null; try { throwIfAborted(signal); - const code = await Bun.file(absolutePath).text(); + const bridgePromise = this.#routeReadThroughBridge(absolutePath); + const code = + bridgePromise !== undefined + ? await bridgePromise.catch(() => Bun.file(absolutePath).text()) + : await Bun.file(absolutePath).text(); throwIfAborted(signal); if (countTextLines(code) > MAX_SUMMARY_LINES) return null; @@ -1413,6 +1426,29 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> { if (!content) { // Raw text or line-range mode const { offset, limit } = selToOffsetLimit(parsed); + // Try ACP bridge first — editor's in-memory buffer is source of truth. + // Request full text so local range rendering keeps normal context and line numbers. + const bridgePromise = this.#routeReadThroughBridge(absolutePath); + if (bridgePromise !== undefined) { + try { + const bridgeText = await bridgePromise; + const bridgeResult = this.#buildInMemoryTextResult(bridgeText, offset, limit, { + details: { resolvedPath: absolutePath, suffixResolution }, + sourcePath: absolutePath, + entityLabel: "file", + raw: isRawSelector(parsed), + }); + if (suffixResolution) { + const notice = `[Path '${suffixResolution.from}' not found; resolved to '${suffixResolution.to}' via suffix match]`; + const firstText = bridgeResult.content.find((c): c is TextContent => c.type === "text"); + if (firstText) firstText.text = `${notice}\n${firstText.text}`; + } + return bridgeResult; + } catch (error) { + logger.warn("ACP fs readTextFile failed; falling back to disk", { path: absolutePath, error }); + } + } + // User-requested 0-indexed range start. Lines BEFORE this become // leading context (added below if offset is explicit). const requestedStart = offset ? Math.max(0, offset - 1) : 0; diff --git a/packages/coding-agent/src/tools/write.ts b/packages/coding-agent/src/tools/write.ts index 6e87fe446..74705ffde 100644 --- a/packages/coding-agent/src/tools/write.ts +++ b/packages/coding-agent/src/tools/write.ts @@ -609,6 +609,11 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails }; } + #routeWriteThroughBridge(absolutePath: string, content: string): Promise<void> | undefined { + const bridge = this.session.getClientBridge?.(); + if (!bridge?.capabilities.writeTextFile || !bridge.writeTextFile) return undefined; + return bridge.writeTextFile({ path: absolutePath, content }); + } async execute( _toolCallId: string, { path, content }: WriteParams, @@ -682,6 +687,23 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails await assertEditableFile(absolutePath, path); } + // Try ACP bridge first — no disk write when client handles it + const bridgePromise = this.#routeWriteThroughBridge(absolutePath, cleanContent); + if (bridgePromise !== undefined) { + try { + await bridgePromise; + } catch (error) { + throw new ToolError(error instanceof Error ? error.message : String(error)); + } + invalidateFsScanAfterWrite(absolutePath); + const displayPath = formatPathRelativeToCwd(absolutePath, this.session.cwd); + let resultText = `Successfully wrote ${cleanContent.length} bytes to ${displayPath}`; + if (stripped) { + resultText += `\nNote: auto-stripped hashline display prefixes from content before writing.`; + } + return { content: [{ type: "text", text: resultText }], details: {} }; + } + const diagnostics = await this.#writethrough(absolutePath, cleanContent, signal, undefined, batchRequest); invalidateFsScanAfterWrite(absolutePath); diff --git a/packages/coding-agent/test/acp-agent.test.ts b/packages/coding-agent/test/acp-agent.test.ts index 64a96930d..ee169311a 100644 --- a/packages/coding-agent/test/acp-agent.test.ts +++ b/packages/coding-agent/test/acp-agent.test.ts @@ -3,11 +3,21 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import type { AgentSideConnection, PromptRequest, SessionNotification } from "@agentclientprotocol/sdk"; +import { + zForkSessionResponse, + zLoadSessionResponse, + zNewSessionResponse, + zPromptResponse, + zSessionNotification, +} from "@agentclientprotocol/sdk/dist/schema/zod.gen.js"; import type { Model } from "@oh-my-pi/pi-ai"; import { getConfigRootDir, setAgentDir } from "@oh-my-pi/pi-utils"; +import { _resetSettingsForTest, Settings } from "../src/config/settings"; import { AcpAgent } from "../src/modes/acp/acp-agent"; +import type { PlanModeState } from "../src/plan-mode/state"; import type { AgentSession, AgentSessionEvent } from "../src/session/agent-session"; import { SessionManager } from "../src/session/session-manager"; +import { expectAcpStructure } from "./helpers/acp-schema"; const TEST_MODELS: Model[] = [ { @@ -74,6 +84,13 @@ class FakeAgentSession { queuedMessageCount = 0; systemPrompt = "system"; disposed = false; + fastMode = false; + forcedToolChoice: string | undefined; + promptCalls: string[] = []; + customMessages: Array<{ customType: string; content: string; details?: unknown }> = []; + skillsSettings = { enableSkillCommands: true }; + skills: Array<{ name: string; description: string; filePath: string; baseDir: string; source: string }> = []; + planModeState: PlanModeState | undefined; #listeners = new Set<(event: AgentSessionEvent) => void>(); constructor( @@ -111,6 +128,10 @@ class FakeAgentSession { this.thinkingLevel = level; } + setSlashCommands(_commands: unknown[]): void { + // no-op for tests + } + async setModel(model: Model): Promise<void> { this.model = model; } @@ -123,6 +144,7 @@ class FakeAgentSession { } async prompt(text: string): Promise<void> { + this.promptCalls.push(text); this.isStreaming = true; this.sessionManager.appendMessage({ role: "user", content: text, timestamp: Date.now() }); const assistantMessage = makeAssistantMessage("pong"); @@ -147,6 +169,27 @@ class FakeAgentSession { this.isStreaming = false; } + async promptCustomMessage(message: { customType: string; content: string; details?: unknown }): Promise<void> { + this.customMessages.push(message); + this.isStreaming = true; + const assistantMessage = makeAssistantMessage("skill pong"); + for (const listener of this.#listeners) { + listener({ + type: "message_update", + message: assistantMessage, + assistantMessageEvent: { type: "text_delta", delta: "skill pong" }, + } as AgentSessionEvent); + } + this.sessionManager.appendMessage(assistantMessage); + for (const listener of this.#listeners) { + listener({ + type: "agent_end", + messages: [assistantMessage], + } as AgentSessionEvent); + } + this.isStreaming = false; + } + async refreshMCPTools(_tools: unknown[]): Promise<void> {} getContextUsage(): undefined { @@ -192,6 +235,37 @@ class FakeAgentSession { setActiveToolsByName(_toolNames: string[]): void {} + setClientBridge(_bridge: unknown): void {} + + getPlanModeState(): PlanModeState | undefined { + return this.planModeState; + } + + setPlanModeState(state: PlanModeState | undefined): void { + this.planModeState = state; + } + + getToolByName(_name: string): undefined { + return undefined; + } + + toggleFastMode(): boolean { + this.fastMode = !this.fastMode; + return this.fastMode; + } + + setFastMode(enabled: boolean): void { + this.fastMode = enabled; + } + + isFastModeEnabled(): boolean { + return this.fastMode; + } + + setForcedToolChoice(toolName: string): void { + this.forcedToolChoice = toolName; + } + async sendCustomMessage(_message: string, _options?: unknown): Promise<void> {} async sendUserMessage(_content: string, _options?: unknown): Promise<void> {} @@ -225,6 +299,12 @@ function getChunkMessageId(notification: SessionNotification): string | undefine return typeof update.messageId === "string" ? update.messageId : undefined; } +function expectAcpNotifications(updates: SessionNotification[]): void { + for (const update of updates) { + expectAcpStructure(zSessionNotification, update); + } +} + const cleanupRoots: string[] = []; const originalAgentDir = process.env.PI_CODING_AGENT_DIR; const fallbackAgentDir = path.join(getConfigRootDir(), "agent"); @@ -236,6 +316,7 @@ afterEach(async () => { setAgentDir(fallbackAgentDir); delete process.env.PI_CODING_AGENT_DIR; } + _resetSettingsForTest(); for (const root of cleanupRoots.splice(0)) { await fs.promises.rm(root, { recursive: true, force: true }); @@ -252,6 +333,7 @@ async function createHarness(): Promise<AgentHarness> { await fs.promises.mkdir(cwdA, { recursive: true }); await fs.promises.mkdir(cwdB, { recursive: true }); setAgentDir(agentDir); + await Settings.init({ agentDir, inMemory: true }); const updates: SessionNotification[] = []; const abortController = new AbortController(); @@ -288,6 +370,8 @@ describe("ACP agent", () => { const harness = await createHarness(); const first = await harness.agent.newSession({ cwd: harness.cwdA, mcpServers: [] }); const second = await harness.agent.newSession({ cwd: harness.cwdB, mcpServers: [] }); + expectAcpStructure(zNewSessionResponse, first); + expectAcpStructure(zNewSessionResponse, second); expect(first.models?.availableModels.map(model => model.modelId)).toEqual( TEST_MODELS.map(model => `${model.provider}/${model.id}`), @@ -302,6 +386,15 @@ describe("ACP agent", () => { configId: "thinking", value: "high", }); + // Both model and thinking-level changes must surface as ACP + // `config_option_update` notifications scoped to the right session; + // the schema check alone would still pass if either method stopped + // emitting notifications entirely. + const configUpdatesForFirst = harness.updates.filter( + n => n.sessionId === first.sessionId && n.update.sessionUpdate === "config_option_update", + ); + expect(configUpdatesForFirst.length).toBeGreaterThanOrEqual(2); + expectAcpNotifications(harness.updates); const firstSession = harness.findSession(first.sessionId); const secondSession = harness.findSession(second.sessionId); @@ -318,12 +411,13 @@ describe("ACP agent", () => { cwd: harness.cwdA, mcpServers: [], }); + expectAcpStructure(zForkSessionResponse, forked); const forkedSession = harness.findSession(forked.sessionId); const forkedMessages = forkedSession?.sessionManager.buildSessionContext().messages ?? []; expect(forked.sessionId).not.toBe(first.sessionId); expect(forkedMessages.some(message => message.role === "user" && message.content === "fork me")).toBe(true); - await harness.agent.unstable_closeSession({ sessionId: forked.sessionId }); + await harness.agent.closeSession({ sessionId: forked.sessionId }); await expect(harness.agent.setSessionMode({ sessionId: forked.sessionId, modeId: "default" })).rejects.toThrow( "Unsupported ACP session", ); @@ -332,6 +426,72 @@ describe("ACP agent", () => { await Bun.sleep(0); }); + it("advertises plan mode and emits schema-valid mode updates", async () => { + const harness = await createHarness(); + Settings.instance.set("plan.enabled", true); + + const created = await harness.agent.newSession({ cwd: harness.cwdA, mcpServers: [] }); + expectAcpStructure(zNewSessionResponse, created); + expect(created.modes?.availableModes.map(mode => mode.id)).toEqual(["default", "plan"]); + const initialModeConfig = created.configOptions?.find(option => option.id === "mode") as + | { currentValue?: unknown; options?: Array<{ value: string }> } + | undefined; + expect(initialModeConfig?.currentValue).toBe("default"); + expect(initialModeConfig?.options?.map(option => option.value)).toEqual(["default", "plan"]); + + await harness.agent.setSessionMode({ sessionId: created.sessionId, modeId: "plan" }); + + const session = harness.findSession(created.sessionId)!; + expect(session.planModeState).toEqual( + expect.objectContaining({ enabled: true, planFilePath: "local://PLAN.md", workflow: "parallel" }), + ); + const modeNotifications = harness.updates.filter( + notification => + notification.sessionId === created.sessionId && + (notification.update.sessionUpdate === "current_mode_update" || + notification.update.sessionUpdate === "config_option_update"), + ); + expectAcpNotifications(modeNotifications); + expect( + modeNotifications.some( + notification => + notification.update.sessionUpdate === "current_mode_update" && + notification.update.currentModeId === "plan", + ), + ).toBe(true); + const configNotification = modeNotifications.findLast( + notification => notification.update.sessionUpdate === "config_option_update", + ); + const currentModeConfig = + configNotification?.update.sessionUpdate === "config_option_update" + ? (configNotification.update.configOptions.find(option => option.id === "mode") as + | { currentValue?: unknown } + | undefined) + : undefined; + expect(currentModeConfig?.currentValue).toBe("plan"); + + await harness.agent.setSessionMode({ sessionId: created.sessionId, modeId: "default" }); + expect(session.planModeState).toBeUndefined(); + + harness.abortController.abort(); + await Bun.sleep(0); + }); + + it("accepts only ACP underscore-prefixed extension methods", async () => { + const harness = await createHarness(); + + const result = await harness.agent.extMethod("_omp/sessions/listAll", { limit: 2 }); + + expect(Array.isArray(result.sessions)).toBe(true); + expect(typeof result.total).toBe("number"); + await expect(harness.agent.extMethod("omp/sessions/listAll", { limit: 2 })).rejects.toThrow( + "Unknown ACP ext method", + ); + + harness.abortController.abort(); + await Bun.sleep(0); + }); + it("replays messageIds and returns turn usage for prompts", async () => { const harness = await createHarness(); const stored = new FakeAgentSession(harness.cwdA); @@ -341,7 +501,12 @@ describe("ACP agent", () => { await stored.sessionManager.ensureOnDisk(); await stored.sessionManager.flush(); - await harness.agent.loadSession({ sessionId: stored.sessionId, cwd: harness.cwdA, mcpServers: [] }); + const loaded = await harness.agent.loadSession({ + sessionId: stored.sessionId, + cwd: harness.cwdA, + mcpServers: [], + }); + expectAcpStructure(zLoadSessionResponse, loaded); const replayChunks = harness.updates.filter( update => update.sessionId === stored.sessionId && @@ -368,6 +533,8 @@ describe("ACP agent", () => { messageId: "05b17a6f-b310-4be7-b767-6b4f3a84eb63", prompt: [{ type: "text", text: "ping" }], } as PromptRequest); + expectAcpStructure(zPromptResponse, response); + expectAcpNotifications(harness.updates); const liveChunks = harness.updates.filter( update => update.sessionId === live.sessionId && update.update.sessionUpdate === "agent_message_chunk", @@ -389,4 +556,140 @@ describe("ACP agent", () => { harness.abortController.abort(); await Bun.sleep(0); }); + + it("advertises ACP-safe builtins and skill commands", async () => { + const harness = await createHarness(); + const created = await harness.agent.newSession({ cwd: harness.cwdA, mcpServers: [] }); + const session = harness.findSession(created.sessionId)!; + const skillDir = path.join(harness.cwdA, ".skills", "sample"); + const skillPath = path.join(skillDir, "SKILL.md"); + await fs.promises.mkdir(skillDir, { recursive: true }); + await fs.promises.writeFile(skillPath, "---\ndescription: Sample skill\n---\n# Sample\nDo work.\n"); + session.skills = [ + { + name: "sample", + description: "Sample skill", + filePath: skillPath, + baseDir: skillDir, + source: "test", + }, + ]; + await harness.agent.prompt({ + sessionId: created.sessionId, + messageId: "00000000-0000-4000-8000-000000000004", + prompt: [{ type: "text", text: "/reload-plugins" }], + } as PromptRequest); + + const commandUpdates = harness.updates.filter( + update => + update.sessionId === created.sessionId && update.update.sessionUpdate === "available_commands_update", + ); + const names = commandUpdates.flatMap(update => + update.update.sessionUpdate === "available_commands_update" + ? update.update.availableCommands.map(command => command.name) + : [], + ); + expect(names).toContain("fast"); + expect(names).toContain("force"); + expect(names).toContain("skill:sample"); + expect(names).not.toContain("settings"); + expect(names).not.toContain("copy"); + expect(names).not.toContain("plan"); + expect(names).not.toContain("loop"); + expect(names).not.toContain("login"); + expect(names).not.toContain("new"); + expect(names).not.toContain("handoff"); + expect(names).not.toContain("fork"); + expect(names).not.toContain("btw"); + expect(names).not.toContain("drop"); + expect(names).not.toContain("resume"); + expect(names).not.toContain("agents"); + expect(names).not.toContain("extensions"); + expect(names).not.toContain("hotkeys"); + + harness.abortController.abort(); + await Bun.sleep(0); + }); + + it("executes skill commands through custom skill messages", async () => { + const harness = await createHarness(); + const created = await harness.agent.newSession({ cwd: harness.cwdA, mcpServers: [] }); + const session = harness.findSession(created.sessionId)!; + const skillDir = path.join(harness.cwdA, ".skills", "sample"); + const skillPath = path.join(skillDir, "SKILL.md"); + await fs.promises.mkdir(skillDir, { recursive: true }); + await fs.promises.writeFile(skillPath, "---\ndescription: Sample skill\n---\n# Sample\nDo work.\n"); + session.skills = [ + { + name: "sample", + description: "Sample skill", + filePath: skillPath, + baseDir: skillDir, + source: "test", + }, + ]; + + await harness.agent.prompt({ + sessionId: created.sessionId, + messageId: "00000000-0000-4000-8000-000000000001", + prompt: [{ type: "text", text: "/skill:sample extra context" }], + } as PromptRequest); + + expect(session.promptCalls).toEqual([]); + expect(session.customMessages).toHaveLength(1); + expect(session.customMessages[0]!.customType).toBe("skill-prompt"); + expect(session.customMessages[0]!.content).toContain("# Sample\nDo work."); + expect(session.customMessages[0]!.content).toContain(`Skill: ${skillPath}`); + expect(session.customMessages[0]!.content).toContain("User: extra context"); + + harness.abortController.abort(); + await Bun.sleep(0); + }); + + it("executes consumed ACP builtins without prompting the agent", async () => { + const harness = await createHarness(); + const created = await harness.agent.newSession({ cwd: harness.cwdA, mcpServers: [] }); + const session = harness.findSession(created.sessionId)!; + + const response = await harness.agent.prompt({ + sessionId: created.sessionId, + messageId: "00000000-0000-4000-8000-000000000002", + prompt: [{ type: "text", text: "/fast status" }], + } as PromptRequest); + + const chunks = harness.updates.filter( + update => update.sessionId === created.sessionId && update.update.sessionUpdate === "agent_message_chunk", + ); + expect(response.userMessageId).toBe("00000000-0000-4000-8000-000000000002"); + expect(session.promptCalls).toEqual([]); + expect( + chunks.some( + update => + update.update.sessionUpdate === "agent_message_chunk" && + update.update.content.type === "text" && + update.update.content.text === "Fast mode is off.", + ), + ).toBe(true); + + harness.abortController.abort(); + await Bun.sleep(0); + }); + + it("executes force builtins and forwards remaining prompt text", async () => { + const harness = await createHarness(); + const created = await harness.agent.newSession({ cwd: harness.cwdA, mcpServers: [] }); + const session = harness.findSession(created.sessionId)!; + + await harness.agent.prompt({ + sessionId: created.sessionId, + messageId: "00000000-0000-4000-8000-000000000003", + prompt: [{ type: "text", text: "/force read inspect package.json" }], + } as PromptRequest); + + expect(session.forcedToolChoice).toBe("read"); + expect(session.promptCalls).toEqual(["inspect package.json"]); + + harness.abortController.abort(); + await Bun.sleep(0); + }); }); diff --git a/packages/coding-agent/test/acp-builtins.test.ts b/packages/coding-agent/test/acp-builtins.test.ts new file mode 100644 index 000000000..b7e14a1b4 --- /dev/null +++ b/packages/coding-agent/test/acp-builtins.test.ts @@ -0,0 +1,891 @@ +import { describe, expect, it, spyOn } from "bun:test"; +import { Settings } from "../src/config/settings"; +import type { AgentSession } from "../src/session/agent-session"; +import type { SessionManager } from "../src/session/session-manager"; +import { executeAcpBuiltinSlashCommand } from "../src/slash-commands/acp-builtins"; + +interface FakeAcpBuiltinSession { + fastMode: boolean; + forcedToolChoice: string | undefined; + isStreaming: boolean; + sessionFile: string | undefined; + sessionId: string; + sessionName: string; + _todoPhases: Array<{ name: string; tasks: Array<{ content: string; status: string }> }>; + toggleFastMode(): boolean; + setFastMode(enabled: boolean): void; + isFastModeEnabled(): boolean; + setForcedToolChoice(toolName: string): void; + fetchUsageReports?: () => Promise<unknown>; + getAsyncJobSnapshot: (opts?: { recentLimit?: number }) => { running: unknown[]; recent: unknown[] } | null; + formatSessionAsText: () => string; + getLastAssistantText: () => string | undefined; + messages: unknown[]; + model: { provider: string; id: string } | undefined; + newSession(opts?: { drop?: boolean; parentSession?: string }): Promise<boolean>; + fork(): Promise<boolean>; + handoff(instr?: string): Promise<{ document: string; savedPath?: string } | undefined>; + exportToHtml(outputPath?: string): Promise<string>; + getTodoPhases(): Array<{ name: string; tasks: Array<{ content: string; status: string }> }>; + setTodoPhases(phases: Array<{ name: string; tasks: Array<{ content: string; status: string }> }>): void; + refreshBaseSystemPrompt(): Promise<void>; + getToolByName(name: string): unknown; + compact(args?: string): Promise<void>; + getContextUsage(): { tokens?: number; contextWindow: number } | undefined; + getAvailableModels(): Array<{ provider: string; id: string; contextWindow?: number }>; + setModel(model: unknown): Promise<void>; +} + +function createRuntime() { + const output: string[] = []; + const session: FakeAcpBuiltinSession = { + fastMode: false, + forcedToolChoice: undefined as string | undefined, + isStreaming: false, + sessionFile: undefined, + sessionId: "fake-session-id", + sessionName: "Fake Session", + _todoPhases: [], + toggleFastMode() { + this.fastMode = !this.fastMode; + return this.fastMode; + }, + setFastMode(enabled: boolean) { + this.fastMode = enabled; + }, + isFastModeEnabled() { + return this.fastMode; + }, + setForcedToolChoice(toolName: string) { + this.forcedToolChoice = toolName; + }, + async newSession(_opts?: { drop?: boolean; parentSession?: string }) { + return true; + }, + async fork() { + return true; + }, + async handoff(_instr?: string) { + return undefined; + }, + async exportToHtml(outputPath?: string) { + return outputPath ?? "/tmp/exported-session.html"; + }, + getTodoPhases() { + return this._todoPhases; + }, + setTodoPhases(phases) { + this._todoPhases = phases; + }, + async refreshBaseSystemPrompt() {}, + getAsyncJobSnapshot: () => null, + formatSessionAsText: () => "", + getLastAssistantText: () => undefined, + messages: [], + model: undefined, + getToolByName: (_name: string) => undefined, + async compact(_args?: string) {}, + getContextUsage: () => undefined, + getAvailableModels: () => [] as Array<{ provider: string; id: string; contextWindow?: number }>, + async setModel(_model: unknown) {}, + }; + const typedSession = session as unknown as AgentSession & FakeAcpBuiltinSession; + const fakeSessionManager = { + _sessionFile: undefined as string | undefined, + _cwd: "/tmp/project", + _entries: [] as { type: string }[], + _customEntries: [] as Array<{ customType: string; data: unknown }>, + _movedTo: undefined as string | undefined, + _flushed: false, + _sessionName: undefined as string | undefined, + getSessionId(): string { + return "fake-session-id"; + }, + getSessionFile(): string | undefined { + return this._sessionFile; + }, + getEntries(): { type: string }[] { + return this._entries; + }, + getBranch(): { type: string }[] { + return this._entries; + }, + appendCustomEntry(customType: string, data?: unknown): string { + this._customEntries.push({ customType, data }); + return "fake-entry-id"; + }, + async flush() { + this._flushed = true; + }, + async moveTo(newCwd: string) { + this._cwd = newCwd; + this._movedTo = newCwd; + }, + getCwd(): string { + return this._cwd; + }, + async setSessionName(name: string, _source: string): Promise<boolean> { + this._sessionName = name; + return true; + }, + }; + return { + output, + session, + fakeSessionManager, + runtime: { + session: typedSession, + sessionManager: fakeSessionManager as unknown as SessionManager, + settings: Settings.isolated(), + cwd: "/tmp/project", + output: (text: string) => { + output.push(text); + }, + refreshCommands: () => {}, + reloadPlugins: async () => {}, + notifyTitleChanged: undefined as (() => Promise<void> | void) | undefined, + notifyConfigChanged: undefined as (() => Promise<void> | void) | undefined, + }, + }; +} + +describe("ACP builtin slash commands", () => { + it("consumes fast status without returning prompt text", async () => { + const { output, runtime } = createRuntime(); + + const result = await executeAcpBuiltinSlashCommand("/fast status", runtime); + + expect(result).toEqual({ consumed: true }); + expect(output).toEqual(["Fast mode is off."]); + }); + + it("forces a tool and returns remaining prompt text", async () => { + const { output, runtime } = createRuntime(); + + const result = await executeAcpBuiltinSlashCommand("/force read inspect package.json", runtime); + + expect(result).toEqual({ prompt: "inspect package.json" }); + expect(runtime.session.forcedToolChoice).toBe("read"); + expect(output).toEqual(["Next turn forced to use read."]); + }); + + it("renders provider usage reports when the session can fetch them", async () => { + const { output, runtime } = createRuntime(); + runtime.session.fetchUsageReports = async () => [ + { + provider: "openai-codex", + fetchedAt: Date.now(), + limits: [ + { + id: "codex-5h", + label: "5 hours", + scope: { provider: "openai-codex", tier: "prolite", accountId: "account-1" }, + window: { id: "5h", label: "5 hours", resetsAt: Date.now() + 60 * 60 * 1000 }, + amount: { used: 0.24, usedFraction: 0.24, unit: "unknown" }, + }, + ], + metadata: { email: "user@example.com" }, + }, + ]; + + const result = await executeAcpBuiltinSlashCommand("/usage", runtime); + + expect(result).toEqual({ consumed: true }); + expect(output[0]).toContain("Openai Codex"); + expect(output[0]).toContain("5 hours (prolite)"); + expect(output[0]).toContain("user@example.com: 0.24 unknown used (76.0% left)"); + expect(output[0]).toContain("resets in"); + }); + + it("returns false for unknown commands", async () => { + const { runtime } = createRuntime(); + + const result = await executeAcpBuiltinSlashCommand("/not-a-real-command-xyz", runtime); + + expect(result).toBe(false); + }); + + // /jobs + it("jobs: shows informative message when snapshot is null", async () => { + const { output, runtime } = createRuntime(); + const result = await executeAcpBuiltinSlashCommand("/jobs", runtime); + expect(result).toEqual({ consumed: true }); + expect(output[0]).toContain("background jobs"); + }); + + it("jobs: lists running and recent jobs from snapshot", async () => { + const { output, runtime } = createRuntime(); + runtime.session.getAsyncJobSnapshot = () => ({ + running: [{ id: "j1", type: "bash", status: "running", label: "npm install", startTime: Date.now() - 5000 }], + recent: [{ id: "j2", type: "task", status: "completed", label: "build done", startTime: Date.now() - 60_000 }], + }); + + const result = await executeAcpBuiltinSlashCommand("/jobs", runtime); + + expect(result).toEqual({ consumed: true }); + expect(output[0]).toContain("npm install"); + expect(output[0]).toContain("build done"); + expect(output[0]).toContain("Running Jobs"); + expect(output[0]).toContain("Recent Jobs"); + }); + + // /dump + it("dump: outputs transcript when present", async () => { + const { output, runtime } = createRuntime(); + runtime.session.formatSessionAsText = () => "Session content here"; + + const result = await executeAcpBuiltinSlashCommand("/dump", runtime); + + expect(result).toEqual({ consumed: true }); + expect(output[0]).toBe("Session content here"); + }); + + it("dump: outputs empty-state message when no messages", async () => { + const { output, runtime } = createRuntime(); + + const result = await executeAcpBuiltinSlashCommand("/dump", runtime); + + expect(result).toEqual({ consumed: true }); + expect(output[0]).toContain("No messages"); + }); + + // /model + it("model: returns current model when set", async () => { + const { output, runtime } = createRuntime(); + runtime.session.model = { provider: "anthropic", id: "claude-opus-4-5" } as never; + + const result = await executeAcpBuiltinSlashCommand("/model", runtime); + + expect(result).toEqual({ consumed: true }); + expect(output[0]).toContain("anthropic/claude-opus-4-5"); + }); + + it("model: returns no-selection message when undefined", async () => { + const { output, runtime } = createRuntime(); + + const result = await executeAcpBuiltinSlashCommand("/model", runtime); + + expect(result).toEqual({ consumed: true }); + expect(output[0]).toContain("No model"); + }); + + it("model: returns ACP usage message when args provided", async () => { + const { output, runtime } = createRuntime(); + + const result = await executeAcpBuiltinSlashCommand("/model claude-3-5-sonnet", runtime); + + expect(result).toEqual({ consumed: true }); + expect(output[0]?.toLowerCase()).toContain("acp"); + }); + + it("model: applies known id and emits both title + config change notifications", async () => { + const { output, runtime, session } = createRuntime(); + const available = [{ provider: "anthropic", id: "claude-3-5-sonnet", contextWindow: 200_000 }]; + session.getAvailableModels = () => available; + let titleNotified = 0; + let configNotified = 0; + runtime.notifyTitleChanged = () => { + titleNotified++; + }; + runtime.notifyConfigChanged = () => { + configNotified++; + }; + const setModelSpy = spyOn(session, "setModel").mockResolvedValue(undefined); + + const result = await executeAcpBuiltinSlashCommand("/model claude-3-5-sonnet", runtime); + + expect(result).toEqual({ consumed: true }); + expect(setModelSpy).toHaveBeenCalledWith(available[0]); + expect(output[0]).toContain("Model set to anthropic/claude-3-5-sonnet"); + expect(titleNotified).toBe(1); + expect(configNotified).toBe(1); + }); + + it("model: does not emit config change when id is unknown", async () => { + const { runtime } = createRuntime(); + let configNotified = 0; + runtime.notifyConfigChanged = () => { + configNotified++; + }; + + await executeAcpBuiltinSlashCommand("/model nonexistent", runtime); + + expect(configNotified).toBe(0); + }); + + // Removed TUI-only and dropped commands fall through as false + it("removed commands return false (fall through to model)", async () => { + const removedCommands = [ + "/login", + "/logout", + "/resume", + "/tree", + "/branch", + "/plan", + "/loop", + "/hotkeys", + "/extensions", + "/agents", + "/copy", + "/btw hi", + "/new", + "/drop", + "/handoff", + "/fork", + ]; + for (const cmd of removedCommands) { + const { runtime } = createRuntime(); + const result = await executeAcpBuiltinSlashCommand(cmd, runtime); + expect(result).toBe(false); + } + }); +}); + +describe("session lifecycle commands", () => { + it("/session delete: returns in-memory usage when no sessionFile", async () => { + const { output, runtime } = createRuntime(); + const result = await executeAcpBuiltinSlashCommand("/session delete", runtime); + expect(result).toEqual({ consumed: true }); + expect(output[0]).toContain("in-memory"); + }); + + it("/session delete: refuses while streaming", async () => { + const { output, session, fakeSessionManager, runtime } = createRuntime(); + session.isStreaming = true; + fakeSessionManager._sessionFile = "/tmp/session.jsonl"; + const result = await executeAcpBuiltinSlashCommand("/session delete", runtime); + expect(result).toEqual({ consumed: true }); + expect(output[0]).toContain("streaming"); + }); + + it("/rename: renames and calls notifyTitleChanged on success", async () => { + const { output, fakeSessionManager, runtime } = createRuntime(); + let notified = false; + runtime.notifyTitleChanged = async () => { + notified = true; + }; + const result = await executeAcpBuiltinSlashCommand("/rename Project Apex", runtime); + expect(result).toEqual({ consumed: true }); + expect(fakeSessionManager._sessionName).toBe("Project Apex"); + expect(output[0]).toBe("Session renamed to Project Apex."); + expect(notified).toBe(true); + }); + + it("/rename: outputs precedence message when setSessionName returns false", async () => { + const { output, fakeSessionManager, runtime } = createRuntime(); + let notified = false; + runtime.notifyTitleChanged = async () => { + notified = true; + }; + fakeSessionManager.setSessionName = async () => false; + const result = await executeAcpBuiltinSlashCommand("/rename Bar", runtime); + expect(result).toEqual({ consumed: true }); + expect(output[0]).toContain("takes precedence"); + expect(notified).toBe(false); + }); + + it("/move: reports moved path via sessionManager.getCwd() and calls notifyTitleChanged", async () => { + const { output, fakeSessionManager, runtime } = createRuntime(); + let notified = false; + runtime.notifyTitleChanged = async () => { + notified = true; + }; + const result = await executeAcpBuiltinSlashCommand("/move /tmp", runtime); + expect(result).toEqual({ consumed: true }); + expect(fakeSessionManager._flushed).toBe(true); + expect(fakeSessionManager._movedTo).toBe("/tmp"); + expect(output[0]).toContain("/tmp"); + expect(notified).toBe(true); + }); + + it("/move: refuses while streaming", async () => { + const { output, session, runtime } = createRuntime(); + session.isStreaming = true; + const result = await executeAcpBuiltinSlashCommand("/move /tmp", runtime); + expect(result).toEqual({ consumed: true }); + expect(output[0]).toContain("streaming"); + }); +}); + +describe("wave 3 commands", () => { + // /export + it("/export: calls exportToHtml with the given arg and outputs the path", async () => { + const { output, runtime } = createRuntime(); + const result = await executeAcpBuiltinSlashCommand("/export /tmp/out.html", runtime); + expect(result).toEqual({ consumed: true }); + expect(output[0]).toBe("Session exported to: /tmp/out.html"); + }); + + it("/export: uses default path when no arg given", async () => { + const { output, runtime } = createRuntime(); + const result = await executeAcpBuiltinSlashCommand("/export", runtime); + expect(result).toEqual({ consumed: true }); + expect(output[0]).toContain("Session exported to:"); + }); + + it("/export: returns usage on exportToHtml failure", async () => { + const { output, session, runtime } = createRuntime(); + session.exportToHtml = async () => { + throw new Error("disk full"); + }; + const result = await executeAcpBuiltinSlashCommand("/export", runtime); + expect(result).toEqual({ consumed: true }); + expect(output[0]).toContain("Failed to export session: disk full"); + }); + + // /todo + it("/todo no-args: outputs empty state message when no todos", async () => { + const { output, runtime } = createRuntime(); + const result = await executeAcpBuiltinSlashCommand("/todo", runtime); + expect(result).toEqual({ consumed: true }); + expect(output[0]).toBe("No todos. Use /todo append <task> to start one."); + }); + + it("/todo append: stores phases and records custom entry", async () => { + const { session, fakeSessionManager, runtime } = createRuntime(); + const result = await executeAcpBuiltinSlashCommand('/todo append "Build" "Wire setup"', runtime); + expect(result).toEqual({ consumed: true }); + expect(session._todoPhases).toHaveLength(1); + expect(session._todoPhases[0]?.name).toBe("Build"); + expect(session._todoPhases[0]?.tasks[0]?.content).toBe("Wire setup"); + expect(fakeSessionManager._customEntries).toHaveLength(1); + expect(fakeSessionManager._customEntries[0]?.customType).toBe("user_todo_edit"); + }); + + it("/todo edit: returns TUI-only usage message", async () => { + const { output, runtime } = createRuntime(); + const result = await executeAcpBuiltinSlashCommand("/todo edit", runtime); + expect(result).toEqual({ consumed: true }); + expect(output[0]).toContain("TUI editor"); + }); + + it("/todo unknown: returns usage message", async () => { + const { output, runtime } = createRuntime(); + const result = await executeAcpBuiltinSlashCommand("/todo foobar", runtime); + expect(result).toEqual({ consumed: true }); + expect(output[0]).toContain("Unknown /todo subcommand"); + }); + + // /move + it("/move: returns usage when no arg", async () => { + const { output, runtime } = createRuntime(); + const result = await executeAcpBuiltinSlashCommand("/move", runtime); + expect(result).toEqual({ consumed: true }); + expect(output[0]).toContain("Usage: /move"); + }); + + it("/move: returns usage when path does not exist", async () => { + const { output, runtime } = createRuntime(); + const result = await executeAcpBuiltinSlashCommand("/move /no/such/path/xyz", runtime); + expect(result).toEqual({ consumed: true }); + expect(output[0]).toContain("does not exist"); + }); + + // /memory + it("/memory unknown: returns usage message", async () => { + const { output, runtime } = createRuntime(); + const result = await executeAcpBuiltinSlashCommand("/memory unknownverb", runtime); + expect(result).toEqual({ consumed: true }); + expect(output[0]).toContain("Usage: /memory"); + }); + + it("/memory view: outputs memory payload (or empty message)", async () => { + const { output, runtime } = createRuntime(); + const result = await executeAcpBuiltinSlashCommand("/memory view", runtime); + expect(result).toEqual({ consumed: true }); + expect(output.length).toBeGreaterThan(0); + }); + + it("/memory (no args): defaults to view", async () => { + const { output, runtime } = createRuntime(); + const result = await executeAcpBuiltinSlashCommand("/memory", runtime); + expect(result).toEqual({ consumed: true }); + expect(output.length).toBeGreaterThan(0); + }); + + // /todo start fuzzy match + it("/todo start: finds pending task by substring and starts it", async () => { + const { output, session, runtime } = createRuntime(); + session._todoPhases = [{ name: "Setup", tasks: [{ content: "Wire up router", status: "pending" }] }]; + const result = await executeAcpBuiltinSlashCommand('/todo start "wire"', runtime); + expect(result).toEqual({ consumed: true }); + expect(output[0]).toContain("Wire up router"); + expect(session._todoPhases[0]?.tasks[0]?.status).toBe("in_progress"); + }); + + // /browser + it("/browser visible: sets headless=false; second call is idempotent", async () => { + const { runtime } = createRuntime(); + runtime.settings.set("browser.enabled" as never, true as never); + runtime.settings.set("browser.headless" as never, true as never); + const r1 = await executeAcpBuiltinSlashCommand("/browser visible", runtime); + expect(r1).toEqual({ consumed: true }); + expect(runtime.settings.get("browser.headless" as never)).toBe(false); + const r2 = await executeAcpBuiltinSlashCommand("/browser visible", runtime); + expect(r2).toEqual({ consumed: true }); + expect(runtime.settings.get("browser.headless" as never)).toBe(false); + }); + + it("/browser no-arg after /browser visible toggles to headless", async () => { + const { output, runtime } = createRuntime(); + runtime.settings.set("browser.enabled" as never, true as never); + runtime.settings.set("browser.headless" as never, true as never); + await executeAcpBuiltinSlashCommand("/browser visible", runtime); + const r = await executeAcpBuiltinSlashCommand("/browser", runtime); + expect(r).toEqual({ consumed: true }); + expect(output[output.length - 1]).toContain("headless"); + expect(runtime.settings.get("browser.headless" as never)).toBe(true); + }); + + // /compact + it("/compact: reports Compaction complete. after session.compact resolves", async () => { + const { output, session, runtime } = createRuntime(); + let compactCalled = false; + session.compact = async (_args?: string) => { + compactCalled = true; + }; + const result = await executeAcpBuiltinSlashCommand("/compact", runtime); + expect(result).toEqual({ consumed: true }); + expect(compactCalled).toBe(true); + expect(output[0]).toContain("Compaction complete."); + }); +}); + +describe("wave 4 commands", () => { + // /mcp + it("/mcp (no args): outputs help text containing list, enable, disable, remove, reload", async () => { + const { output, runtime } = createRuntime(); + const result = await executeAcpBuiltinSlashCommand("/mcp", runtime); + expect(result).toEqual({ consumed: true }); + expect(output[0]).toContain("list"); + expect(output[0]).toContain("enable"); + expect(output[0]).toContain("disable"); + expect(output[0]).toContain("remove"); + expect(output[0]).toContain("reload"); + }); + + it("/mcp help: outputs help text containing list, enable, disable, remove, reload", async () => { + const { output, runtime } = createRuntime(); + const result = await executeAcpBuiltinSlashCommand("/mcp help", runtime); + expect(result).toEqual({ consumed: true }); + expect(output[0]).toContain("list"); + expect(output[0]).toContain("enable"); + expect(output[0]).toContain("disable"); + expect(output[0]).toContain("remove"); + expect(output[0]).toContain("reload"); + }); + + it("/mcp add (no args): returns usage string", async () => { + const { output, runtime } = createRuntime(); + const result = await executeAcpBuiltinSlashCommand("/mcp add", runtime); + expect(result).toEqual({ consumed: true }); + expect(output[0]).toContain("Usage"); + }); + + it("/mcp reload: calls refreshCommands and outputs confirmation", async () => { + let refreshCalled = false; + const { output, runtime } = createRuntime(); + runtime.refreshCommands = () => { + refreshCalled = true; + }; + const result = await executeAcpBuiltinSlashCommand("/mcp reload", runtime); + expect(result).toEqual({ consumed: true }); + expect(refreshCalled).toBe(true); + expect(output[0]).toContain("reload"); + }); + + it("/mcp resources: outputs server list or no-server message", async () => { + const { output, runtime } = createRuntime(); + const result = await executeAcpBuiltinSlashCommand("/mcp resources", runtime); + expect(result).toEqual({ consumed: true }); + // No servers configured in tmp project dir — should report that + expect(output[0]).toMatch(/No MCP servers configured|No resources/); + }); + + it("/mcp unknown-verb: returns usage pointing to help", async () => { + const { output, runtime } = createRuntime(); + const result = await executeAcpBuiltinSlashCommand("/mcp frobnicate", runtime); + expect(result).toEqual({ consumed: true }); + expect(output[0]).toContain("Unknown"); + }); + + // /ssh + it("/ssh (no args): outputs help text containing list and remove", async () => { + const { output, runtime } = createRuntime(); + const result = await executeAcpBuiltinSlashCommand("/ssh", runtime); + expect(result).toEqual({ consumed: true }); + expect(output[0]).toContain("list"); + expect(output[0]).toContain("remove"); + }); + + it("/ssh help: outputs help text containing list and remove", async () => { + const { output, runtime } = createRuntime(); + const result = await executeAcpBuiltinSlashCommand("/ssh help", runtime); + expect(result).toEqual({ consumed: true }); + expect(output[0]).toContain("list"); + expect(output[0]).toContain("remove"); + }); + + it("/ssh add (no args): returns usage", async () => { + const { output, runtime } = createRuntime(); + const result = await executeAcpBuiltinSlashCommand("/ssh add", runtime); + expect(result).toEqual({ consumed: true }); + expect(output[0]).toContain("Usage"); + }); + + it("/ssh unknown-verb: returns unknown subcommand message", async () => { + const { output, runtime } = createRuntime(); + const result = await executeAcpBuiltinSlashCommand("/ssh frobnicate", runtime); + expect(result).toEqual({ consumed: true }); + expect(output[0]).toContain("Unknown"); + }); + + // /marketplace + it("/marketplace help: outputs help text", async () => { + const { output, runtime } = createRuntime(); + const result = await executeAcpBuiltinSlashCommand("/marketplace help", runtime); + expect(result).toEqual({ consumed: true }); + expect(output[0]).toContain("Marketplace commands"); + expect(output[0]).toContain("install"); + }); + + it("/marketplace install (no args): returns interactive picker usage", async () => { + const { output, runtime } = createRuntime(); + const result = await executeAcpBuiltinSlashCommand("/marketplace install", runtime); + expect(result).toEqual({ consumed: true }); + expect(output[0]).toContain("TUI-only"); + }); + + it("/marketplace uninstall (no args): returns interactive picker usage", async () => { + const { output, runtime } = createRuntime(); + const result = await executeAcpBuiltinSlashCommand("/marketplace uninstall", runtime); + expect(result).toEqual({ consumed: true }); + expect(output[0]).toContain("TUI-only"); + }); + + // /plugins + it("/plugins list: outputs without throwing when registries are empty", async () => { + const { MarketplaceManager } = await import("../src/extensibility/plugins/marketplace"); + const { PluginManager } = await import("../src/extensibility/plugins"); + const listInstalledSpy = spyOn(MarketplaceManager.prototype, "listInstalledPlugins").mockResolvedValue([]); + const npmListSpy = spyOn(PluginManager.prototype, "list").mockResolvedValue([]); + try { + const { output, runtime } = createRuntime(); + const result = await executeAcpBuiltinSlashCommand("/plugins list", runtime); + expect(result).toEqual({ consumed: true }); + expect(output.length).toBeGreaterThan(0); + } finally { + listInstalledSpy.mockRestore(); + npmListSpy.mockRestore(); + } + }); + + it("/plugins (no args): defaults to list", async () => { + const { MarketplaceManager } = await import("../src/extensibility/plugins/marketplace"); + const { PluginManager } = await import("../src/extensibility/plugins"); + const listInstalledSpy = spyOn(MarketplaceManager.prototype, "listInstalledPlugins").mockResolvedValue([]); + const npmListSpy = spyOn(PluginManager.prototype, "list").mockResolvedValue([]); + try { + const { output, runtime } = createRuntime(); + const result = await executeAcpBuiltinSlashCommand("/plugins", runtime); + expect(result).toEqual({ consumed: true }); + expect(output.length).toBeGreaterThan(0); + } finally { + listInstalledSpy.mockRestore(); + npmListSpy.mockRestore(); + } + }); + + // /todo start with in_progress status in fuzzy list + it("/todo start: resolves ambiguous matches by preferring active tasks", async () => { + const { output, session, runtime } = createRuntime(); + session._todoPhases = [ + { + name: "Phase 1", + tasks: [ + { content: "Wire auth middleware", status: "pending" }, + { content: "Wire session store", status: "completed" }, + ], + }, + ]; + const result = await executeAcpBuiltinSlashCommand('/todo start "wire"', runtime); + expect(result).toEqual({ consumed: true }); + expect(output[0]).toContain("Wire auth middleware"); + }); +}); + +describe("wave 5 — adapters and polish", () => { + // /mcp help lists new subcommands + it("/mcp help: lists resources, prompts, test, add, smithery-search", async () => { + const { output, runtime } = createRuntime(); + const result = await executeAcpBuiltinSlashCommand("/mcp help", runtime); + expect(result).toEqual({ consumed: true }); + expect(output[0]).toContain("resources"); + expect(output[0]).toContain("prompts"); + expect(output[0]).toContain("test"); + expect(output[0]).toContain("add"); + expect(output[0]).toContain("smithery-search"); + }); + + // /mcp add — verify parsing and output message + it("/mcp add foo --url https://example.com --token X --scope project: outputs success or propagates write error", async () => { + // Uses project scope so it writes to /tmp/project/.omp/mcp.json which test infra controls. + // We verify the command either reports success or a meaningful error (not a parse error). + const mcpModule = await import("../src/mcp/config-writer"); + const spy = spyOn(mcpModule, "addMCPServer").mockResolvedValue(undefined); + try { + const { output, runtime } = createRuntime(); + const result = await executeAcpBuiltinSlashCommand( + "/mcp add foo --url https://example.com --token X --scope project", + runtime, + ); + expect(result).toEqual({ consumed: true }); + expect(output[0]).toContain('Added MCP server "foo" (project).'); + expect(spy).toHaveBeenCalledTimes(1); + // Lock in the parsed call shape so future regressions in + // `--url` / `--token` / `--scope` parsing fail this test instead of + // silently writing a different config. + const [configPath, serverName, serverConfig] = spy.mock.calls[0]!; + expect(configPath).toContain("project"); + expect(serverName).toBe("foo"); + expect(serverConfig).toMatchObject({ + type: "http", + url: "https://example.com", + headers: { Authorization: "Bearer X" }, + }); + } finally { + spy.mockRestore(); + } + }); + + // /mcp test — spy on connectToServer + it("/mcp test bogus: returns error when server not found in config", async () => { + const { output, runtime } = createRuntime(); + // No servers in /tmp/project config — server not found + const result = await executeAcpBuiltinSlashCommand("/mcp test bogus", runtime); + expect(result).toEqual({ consumed: true }); + expect(output[0]).toContain("not found"); + }); + + // /ssh add — spy on addSSHHost + it("/ssh add foo --host x --user y --scope user: calls addSSHHost", async () => { + const sshModule = await import("../src/ssh/config-writer"); + const spy = spyOn(sshModule, "addSSHHost").mockResolvedValue(undefined); + try { + const { output, runtime } = createRuntime(); + const result = await executeAcpBuiltinSlashCommand("/ssh add foo --host x --user y --scope user", runtime); + expect(result).toEqual({ consumed: true }); + expect(output[0]).toContain('Added SSH host "foo" (user).'); + // Without this assertion, the command could succeed via a side-effect-free + // path that prints the success message without writing the host config. + expect(spy).toHaveBeenCalledTimes(1); + const [configPath, name, hostConfig] = spy.mock.calls[0]!; + expect(typeof configPath).toBe("string"); + expect(name).toBe("foo"); + expect(hostConfig).toMatchObject({ host: "x", username: "y" }); + } finally { + spy.mockRestore(); + } + }); + + // /model with unknown id + it("/model gpt-fake-9000: returns unknown-model message", async () => { + const { output, runtime } = createRuntime(); + const result = await executeAcpBuiltinSlashCommand("/model gpt-fake-9000", runtime); + expect(result).toEqual({ consumed: true }); + expect(output[0]).toContain("Unknown model"); + }); + + // /model with known id (fake registry) + it("/model known-id: reports model set and triggers notifyTitleChanged", async () => { + const { output, session, runtime } = createRuntime(); + session.getAvailableModels = () => [{ provider: "anthropic", id: "claude-sonnet-test" }]; + let titleChanged = false; + runtime.notifyTitleChanged = () => { + titleChanged = true; + }; + const result = await executeAcpBuiltinSlashCommand("/model claude-sonnet-test", runtime); + expect(result).toEqual({ consumed: true }); + expect(output[0]).toContain("Model set to anthropic/claude-sonnet-test."); + expect(titleChanged).toBe(true); + }); + + // /usage bar character + it("/usage: includes bar character when usedFraction is 0.5", async () => { + const { output, runtime } = createRuntime(); + runtime.session.fetchUsageReports = async () => [ + { + provider: "test-provider", + fetchedAt: Date.now(), + limits: [ + { + id: "test-limit", + label: "Monthly", + scope: { provider: "test-provider", tier: "pro", accountId: "acct-1" }, + window: { id: "monthly", label: "monthly", resetsAt: Date.now() + 30 * 86400_000 }, + amount: { used: 50, usedFraction: 0.5, unit: "requests" }, + }, + ], + metadata: {}, + }, + ]; + const result = await executeAcpBuiltinSlashCommand("/usage", runtime); + expect(result).toEqual({ consumed: true }); + expect(output[0]).toContain("█"); + }); + + // /context breakdown + it("/context: lists more than one breakdown line for session with messages", async () => { + const { output, session, runtime } = createRuntime(); + // computeContextBreakdown needs model.contextWindow; fake session falls back gracefully + (session as unknown as Record<string, unknown>).model = { + provider: "anthropic", + id: "claude-test", + contextWindow: 200_000, + }; + (session as unknown as Record<string, unknown>).skills = []; + (session as unknown as Record<string, unknown>).agent = { state: { tools: [] } }; + (session as unknown as Record<string, unknown>).systemPrompt = ["You are a helpful assistant."]; + (session as unknown as Record<string, unknown>).settings = { + getGroup: () => ({ enabled: false, strategy: "off" }), + }; + session.messages = [ + { role: "user", content: "Hello, how are you?" }, + { role: "assistant", content: "I am doing well." }, + ]; + const result = await executeAcpBuiltinSlashCommand("/context", runtime); + expect(result).toEqual({ consumed: true }); + // Should show the breakdown with multiple lines (Messages category visible) + const text = output[0] ?? ""; + expect(text).toContain("tokens"); + expect(text.split("\n").length).toBeGreaterThan(1); + }); + + // /jobs empty state + it("/jobs: empty-state output mentions background jobs definition", async () => { + const { output, runtime } = createRuntime(); + // Return empty snapshot (running=[], recent=[]) + runtime.session.getAsyncJobSnapshot = () => ({ running: [], recent: [] }); + const result = await executeAcpBuiltinSlashCommand("/jobs", runtime); + expect(result).toEqual({ consumed: true }); + expect(output[0]).toContain("background jobs"); + }); + + // /marketplace discover bulleted list + it("/marketplace discover: output is bulleted with ' - ' token", async () => { + const { MarketplaceManager } = await import("../src/extensibility/plugins/marketplace"); + const discoverSpy = spyOn(MarketplaceManager.prototype, "listAvailablePlugins").mockResolvedValue([ + { name: "hello", version: "1.0.0", description: "A greeting plugin" } as never, + { name: "world", version: "2.0.0", description: undefined } as never, + ]); + try { + const { output, runtime } = createRuntime(); + const result = await executeAcpBuiltinSlashCommand("/marketplace discover", runtime); + expect(result).toEqual({ consumed: true }); + expect(output[0]).toContain(" - "); + expect(output[0]).toContain("hello@1.0.0"); + } finally { + discoverSpy.mockRestore(); + } + }); +}); diff --git a/packages/coding-agent/test/acp-event-mapper.test.ts b/packages/coding-agent/test/acp-event-mapper.test.ts index a95cc0293..1b259e358 100644 --- a/packages/coding-agent/test/acp-event-mapper.test.ts +++ b/packages/coding-agent/test/acp-event-mapper.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "bun:test"; +import type { SessionNotification } from "@agentclientprotocol/sdk"; +import { zSessionNotification } from "@agentclientprotocol/sdk/dist/schema/zod.gen.js"; import { mapAgentSessionEventToAcpSessionUpdates } from "../src/modes/acp/acp-event-mapper"; import type { AgentSessionEvent } from "../src/session/agent-session"; +import { expectAcpStructure, expectAcpStructureRejects } from "./helpers/acp-schema"; function makeAssistantMessage(text: string) { return { @@ -27,6 +30,12 @@ function getChunkMessageId(event: { update: object }): string | undefined { return typeof update.messageId === "string" ? update.messageId : undefined; } +function expectAcpNotifications(updates: SessionNotification[]): void { + for (const update of updates) { + expectAcpStructure(zSessionNotification, update); + } +} + describe("ACP event mapper", () => { it("attaches a stable messageId to live assistant chunks", () => { const assistantMessage = makeAssistantMessage("chunk"); @@ -54,6 +63,7 @@ describe("ACP event mapper", () => { expect(textUpdates).toHaveLength(1); expect(thoughtUpdates).toHaveLength(1); + expectAcpNotifications([...textUpdates, ...thoughtUpdates]); expect(textUpdates[0] ? getChunkMessageId(textUpdates[0]) : undefined).toBe( "a80f1ff7-4f0a-4e6b-9f09-c94857b62a4a", ); @@ -61,4 +71,210 @@ describe("ACP event mapper", () => { "a80f1ff7-4f0a-4e6b-9f09-c94857b62a4a", ); }); + + it("emits final assistant text when no text deltas were observed", () => { + const assistantMessage = makeAssistantMessage("final response"); + const progress = { textEmitted: false, thoughtEmitted: false }; + + const updates = mapAgentSessionEventToAcpSessionUpdates( + { + type: "message_end", + message: assistantMessage, + } as AgentSessionEvent, + "session-1", + { getMessageProgress: message => (message === assistantMessage ? progress : undefined) }, + ); + + expect(updates).toEqual([ + { + sessionId: "session-1", + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "final response" }, + messageId: undefined, + }, + }, + ]); + expectAcpNotifications(updates); + expect(progress.textEmitted).toBe(true); + }); + + it("does not duplicate final assistant text after streaming deltas", () => { + const assistantMessage = makeAssistantMessage("streamed response"); + const progress = { textEmitted: false, thoughtEmitted: false }; + const options = { + getMessageProgress: (message: unknown) => (message === assistantMessage ? progress : undefined), + }; + + const deltaUpdates = mapAgentSessionEventToAcpSessionUpdates( + { + type: "message_update", + message: assistantMessage, + assistantMessageEvent: { type: "text_delta", delta: "streamed response" }, + } as AgentSessionEvent, + "session-1", + options, + ); + const doneUpdates = mapAgentSessionEventToAcpSessionUpdates( + { + type: "message_end", + message: assistantMessage, + } as AgentSessionEvent, + "session-1", + options, + ); + + expect(deltaUpdates).toHaveLength(1); + expectAcpNotifications(deltaUpdates); + expect(doneUpdates).toEqual([]); + }); + + it("emits a diff ToolCallContent for each per-file edit result", () => { + const updates = mapAgentSessionEventToAcpSessionUpdates( + { + type: "tool_execution_end", + toolCallId: "tc-1", + toolName: "edit", + isError: false, + result: { + content: [{ type: "text", text: "applied" }], + details: { + diff: "--- a/foo\n+++ b/foo\n", + perFileResults: [ + { path: "foo.ts", diff: "...", oldText: "before\n", newText: "after\n" }, + { path: "bar.ts", diff: "...", oldText: undefined, newText: "created\n" }, + { path: "skipped.ts", diff: "", isError: true, errorText: "boom" }, + ], + }, + }, + } as AgentSessionEvent, + "session-1", + ); + + expect(updates).toHaveLength(1); + expectAcpNotifications(updates); + const update = updates[0]!.update as { + sessionUpdate: string; + content?: Array<{ type: string; path?: string; oldText?: string | null; newText?: string }>; + locations?: { path: string }[]; + }; + expect(update.sessionUpdate).toBe("tool_call_update"); + const diffBlocks = update.content?.filter(block => block.type === "diff") ?? []; + expect(diffBlocks).toEqual([ + { type: "diff", path: "foo.ts", oldText: "before\n", newText: "after\n" }, + { type: "diff", path: "bar.ts", oldText: null, newText: "created\n" }, + ]); + expect(update.locations).toEqual([{ path: "foo.ts" }, { path: "bar.ts" }, { path: "skipped.ts" }]); + }); + + it("emits a diff ToolCallContent for single-file edit details", () => { + const updates = mapAgentSessionEventToAcpSessionUpdates( + { + type: "tool_execution_end", + toolCallId: "tc-single", + toolName: "edit", + isError: false, + result: { + content: [{ type: "text", text: "applied" }], + details: { + path: "single.ts", + diff: "--- a/single.ts\n+++ b/single.ts\n", + oldText: "before\n", + newText: "after\n", + }, + }, + } as AgentSessionEvent, + "session-1", + ); + + expect(updates).toHaveLength(1); + expectAcpNotifications(updates); + const update = updates[0]!.update as { + sessionUpdate: string; + content?: Array<{ type: string; path?: string; oldText?: string | null; newText?: string }>; + locations?: { path: string }[]; + }; + expect(update.sessionUpdate).toBe("tool_call_update"); + expect(update.content?.filter(block => block.type === "diff")).toEqual([ + { type: "diff", path: "single.ts", oldText: "before\n", newText: "after\n" }, + ]); + expect(update.locations).toEqual([{ path: "single.ts" }]); + }); + + it("emits locations on tool_execution_update from args", () => { + const updates = mapAgentSessionEventToAcpSessionUpdates( + { + type: "tool_execution_update", + toolCallId: "tc-2", + toolName: "edit", + args: { path: "src/foo.ts" }, + partialResult: { content: [{ type: "text", text: "in progress" }] }, + } as AgentSessionEvent, + "session-1", + ); + + expect(updates).toHaveLength(1); + expectAcpNotifications(updates); + const update = updates[0]!.update as { sessionUpdate: string; locations?: { path: string }[] }; + expect(update.sessionUpdate).toBe("tool_call_update"); + expect(update.locations).toEqual([{ path: "src/foo.ts" }]); + }); + + it("emits a terminal ToolCallContent when tool details carry a terminalId", () => { + const updates = mapAgentSessionEventToAcpSessionUpdates( + { + type: "tool_execution_update", + toolCallId: "tc-3", + toolName: "bash", + args: { command: "echo hi" }, + partialResult: { content: [], details: { terminalId: "term-42" } }, + } as AgentSessionEvent, + "session-1", + ); + + expect(updates).toHaveLength(1); + expectAcpNotifications(updates); + const update = updates[0]!.update as { + sessionUpdate: string; + content?: Array<{ type: string; terminalId?: string }>; + }; + expect(update.sessionUpdate).toBe("tool_call_update"); + expect(update.content).toEqual([{ type: "terminal", terminalId: "term-42" }]); + }); + it("emits distinct locations for move-style path arguments", () => { + const updates = mapAgentSessionEventToAcpSessionUpdates( + { + type: "tool_execution_start", + toolCallId: "tc-move", + toolName: "move", + args: { path: "src/current.ts", oldPath: "src/old.ts", newPath: "src/new.ts" }, + } as AgentSessionEvent, + "session-1", + ); + + expect(updates).toHaveLength(1); + expectAcpNotifications(updates); + const update = updates[0]!.update as { sessionUpdate: string; locations?: { path: string }[] }; + expect(update.sessionUpdate).toBe("tool_call"); + expect(update.locations).toEqual([{ path: "src/current.ts" }, { path: "src/old.ts" }, { path: "src/new.ts" }]); + }); + + it("rejects mutated ACP notification discriminators", () => { + const [notification] = mapAgentSessionEventToAcpSessionUpdates( + { + type: "tool_execution_start", + toolCallId: "tc-schema", + toolName: "read", + args: { path: "package.json" }, + } as AgentSessionEvent, + "session-1", + ); + + expectAcpStructure(zSessionNotification, notification); + expectAcpStructureRejects(zSessionNotification, { + ...notification, + update: { ...notification!.update, sessionUpdate: "tool_call_updates" }, + }); + expectAcpStructureRejects(zSessionNotification, { ...notification, sessionId: 42 }); + }); }); diff --git a/packages/coding-agent/test/acp-initialize-conformance.test.ts b/packages/coding-agent/test/acp-initialize-conformance.test.ts new file mode 100644 index 000000000..7ec1de87b --- /dev/null +++ b/packages/coding-agent/test/acp-initialize-conformance.test.ts @@ -0,0 +1,247 @@ +/** + * ACP `initialize` conformance — gates `terminal` auth methods on + * `clientCapabilities.auth.terminal`, advertises stable agentInfo, and keeps + * the agentCapabilities contract that downstream clients rely on. + */ +import { afterEach, describe, expect, it } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import type { AgentSideConnection, InitializeRequest } from "@agentclientprotocol/sdk"; +import { zInitializeResponse } from "@agentclientprotocol/sdk/dist/schema/zod.gen.js"; +import type { Model } from "@oh-my-pi/pi-ai"; +import { getConfigRootDir, setAgentDir, VERSION } from "@oh-my-pi/pi-utils"; +import { AcpAgent } from "../src/modes/acp/acp-agent"; +import { ACP_TERMINAL_AUTH_FLAG, prepareAcpTerminalAuthArgs } from "../src/modes/acp/terminal-auth"; +import type { AgentSession } from "../src/session/agent-session"; +import { SessionManager } from "../src/session/session-manager"; +import { expectAcpStructure } from "./helpers/acp-schema"; + +const TEST_MODELS: Model[] = [ + { + id: "claude-sonnet-4-20250514", + name: "Claude Sonnet", + api: "anthropic-messages", + provider: "anthropic", + baseUrl: "https://example.invalid", + reasoning: true, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200_000, + maxTokens: 8_192, + }, +]; + +class FakeAgentSession { + sessionManager: SessionManager; + sessionId: string; + agent: { sessionId: string; waitForIdle: () => Promise<void> }; + model: Model | undefined = TEST_MODELS[0]; + thinkingLevel: string | undefined; + customCommands: [] = []; + extensionRunner = undefined; + isStreaming = false; + queuedMessageCount = 0; + systemPrompt = "system"; + disposed = false; + + constructor(cwd: string) { + this.sessionManager = SessionManager.create(cwd); + this.sessionId = this.sessionManager.getSessionId(); + this.agent = { sessionId: this.sessionId, waitForIdle: async () => {} }; + } + + get sessionName(): string { + return this.sessionManager.getHeader()?.title ?? `Session ${this.sessionId}`; + } + + get modelRegistry(): { getApiKey: (model: Model) => Promise<string> } { + return { getApiKey: async (_model: Model) => "test-key" }; + } + + getAvailableModels(): Model[] { + return TEST_MODELS; + } + + getAvailableThinkingLevels(): ReadonlyArray<string> { + return ["low", "medium", "high"]; + } + + setThinkingLevel(): void {} + async setModel(): Promise<void> {} + subscribe(): () => void { + return () => {}; + } + async prompt(): Promise<void> {} + async abort(): Promise<void> {} + async refreshMCPTools(): Promise<void> {} + getContextUsage(): undefined { + return undefined; + } + async switchSession(): Promise<boolean> { + return false; + } + async dispose(): Promise<void> { + this.disposed = true; + await this.sessionManager.close(); + } + async reload(): Promise<void> {} + async newSession(): Promise<boolean> { + return false; + } + async branch(): Promise<{ cancelled: boolean }> { + return { cancelled: false }; + } + async navigateTree(): Promise<{ cancelled: boolean }> { + return { cancelled: false }; + } + getActiveToolNames(): string[] { + return []; + } + getAllToolNames(): string[] { + return []; + } + setActiveToolsByName(): void {} + setClientBridge(): void {} + getPlanModeState(): undefined { + return undefined; + } + setPlanModeState(): void {} + async sendCustomMessage(): Promise<void> {} + async sendUserMessage(): Promise<void> {} + async compact(): Promise<void> {} + async fork(): Promise<boolean> { + return false; + } +} + +const cleanupRoots: string[] = []; +const originalAgentDir = process.env.PI_CODING_AGENT_DIR; +const fallbackAgentDir = path.join(getConfigRootDir(), "agent"); + +afterEach(async () => { + if (originalAgentDir) { + setAgentDir(originalAgentDir); + } else { + setAgentDir(fallbackAgentDir); + delete process.env.PI_CODING_AGENT_DIR; + } + for (const root of cleanupRoots.splice(0)) { + await fs.promises.rm(root, { recursive: true, force: true }); + } +}); + +async function createAgent(): Promise<AcpAgent> { + const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "omp-acp-init-")); + cleanupRoots.push(root); + const agentDir = path.join(root, "agent"); + const cwd = path.join(root, "cwd"); + await fs.promises.mkdir(agentDir, { recursive: true }); + await fs.promises.mkdir(cwd, { recursive: true }); + setAgentDir(agentDir); + + const abortController = new AbortController(); + const connection = { + sessionUpdate: async () => {}, + signal: abortController.signal, + closed: Promise.withResolvers<void>().promise, + } as unknown as AgentSideConnection; + + const initialSession = new FakeAgentSession(cwd); + const factory = async (next: string): Promise<AgentSession> => new FakeAgentSession(next) as unknown as AgentSession; + return new AcpAgent(connection, initialSession as unknown as AgentSession, factory); +} + +function buildInitializeRequest(overrides: Partial<InitializeRequest> = {}): InitializeRequest { + return { + protocolVersion: 1, + clientCapabilities: {}, + ...overrides, + } as InitializeRequest; +} + +describe("ACP initialize conformance", () => { + it("only advertises the agent-managed auth method when the client lacks terminal capability", async () => { + const agent = await createAgent(); + const response = await agent.initialize(buildInitializeRequest()); + expectAcpStructure(zInitializeResponse, response); + expect(response.authMethods).toHaveLength(1); + const [agentMethod] = response.authMethods!; + // AuthMethodAgent omits the `type` discriminator per ACP spec — the absence is the signal. + expect((agentMethod as { type?: string }).type).toBeUndefined(); + expect(agentMethod).toEqual( + expect.objectContaining({ + id: "agent", + name: expect.any(String), + description: expect.any(String), + }), + ); + }); + + it("appends the terminal setup method when the client opts in via clientCapabilities.auth.terminal", async () => { + const agent = await createAgent(); + const response = await agent.initialize( + buildInitializeRequest({ clientCapabilities: { auth: { terminal: true } } }), + ); + expectAcpStructure(zInitializeResponse, response); + expect(response.authMethods).toHaveLength(2); + const [first, second] = response.authMethods!; + expect((first as { type?: string }).type).toBeUndefined(); + expect(first).toEqual(expect.objectContaining({ id: "agent" })); + expect(response.authMethods![1]).toEqual( + expect.objectContaining({ + type: "terminal", + id: "terminal", + args: [ACP_TERMINAL_AUTH_FLAG], + }), + ); + void second; + }); + + it("uses a terminal auth arg that removes ACP mode before launching the interactive setup flow", () => { + const result = prepareAcpTerminalAuthArgs(["--mode", "acp", "--no-extensions", ACP_TERMINAL_AUTH_FLAG]); + + expect(result).toEqual({ + args: ["--no-extensions"], + terminalAuth: true, + }); + expect(prepareAcpTerminalAuthArgs(["--mode=acp", ACP_TERMINAL_AUTH_FLAG])).toEqual({ + args: [], + terminalAuth: true, + }); + }); + + it("declares agentInfo.version that matches the published package version", async () => { + const agent = await createAgent(); + const response = await agent.initialize(buildInitializeRequest()); + const pkgPath = path.join(import.meta.dir, "..", "package.json"); + const pkg = (await Bun.file(pkgPath).json()) as { version: string }; + expect(response.agentInfo).toEqual( + expect.objectContaining({ + name: "oh-my-pi", + title: "Oh My Pi", + version: VERSION, + }), + ); + expect(response.agentInfo!.version).toBe(pkg.version); + }); + + it("preserves the agentCapabilities contract clients depend on", async () => { + const agent = await createAgent(); + const response = await agent.initialize(buildInitializeRequest()); + expectAcpStructure(zInitializeResponse, response); + expect(response.agentCapabilities).toEqual( + expect.objectContaining({ + loadSession: true, + mcpCapabilities: expect.objectContaining({ http: true, sse: true }), + promptCapabilities: expect.objectContaining({ embeddedContext: true, image: true }), + sessionCapabilities: expect.objectContaining({ + list: expect.any(Object), + fork: expect.any(Object), + resume: expect.any(Object), + close: expect.any(Object), + }), + }), + ); + }); +}); diff --git a/packages/coding-agent/test/acp-stdout-hygiene.test.ts b/packages/coding-agent/test/acp-stdout-hygiene.test.ts new file mode 100644 index 000000000..84a8a484e --- /dev/null +++ b/packages/coding-agent/test/acp-stdout-hygiene.test.ts @@ -0,0 +1,153 @@ +/** + * ACP stdout-hygiene smoke: launching `omp acp` must not leak any banner, + * progress text, or stray non-JSON bytes onto stdout — that channel is owned + * by the JSON-RPC protocol. We spawn the CLI as a subprocess, send a single + * `initialize` frame, and assert the first stdout line parses cleanly as a + * JSON-RPC response. + */ +import { afterEach, describe, expect, it } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +const repoRoot = path.resolve(import.meta.dir, "..", "..", ".."); +const cliEntry = path.join(repoRoot, "packages", "coding-agent", "src", "cli.ts"); + +const cleanupRoots: string[] = []; +let activeProc: ReturnType<typeof Bun.spawn> | undefined; + +afterEach(async () => { + if (activeProc) { + try { + activeProc.kill(); + await activeProc.exited; + } catch { + // ignore + } + activeProc = undefined; + } + for (const root of cleanupRoots.splice(0)) { + await fs.promises.rm(root, { recursive: true, force: true }); + } +}); + +async function readFirstFrame(stream: ReadableStream<Uint8Array>): Promise<string> { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + while (true) { + const { value, done } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const newlineIdx = buffer.indexOf("\n"); + if (newlineIdx >= 0) { + reader.releaseLock(); + return buffer.slice(0, newlineIdx); + } + } + reader.releaseLock(); + return buffer; +} + +describe("ACP stdout hygiene", () => { + it("emits a JSON-RPC initialize response as the first bytes on stdout", async () => { + const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "omp-acp-stdout-")); + cleanupRoots.push(root); + const home = path.join(root, "home"); + const xdg = path.join(root, "xdg"); + const agentDir = path.join(root, "agent"); + await fs.promises.mkdir(home, { recursive: true }); + await fs.promises.mkdir(xdg, { recursive: true }); + await fs.promises.mkdir(agentDir, { recursive: true }); + + const proc = Bun.spawn(["bun", cliEntry, "acp"], { + cwd: repoRoot, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + env: { + ...process.env, + HOME: home, + XDG_DATA_HOME: xdg, + XDG_CONFIG_HOME: xdg, + PI_CODING_AGENT_DIR: agentDir, + PI_NO_TITLE: "1", + }, + }); + activeProc = proc; + + const initRequest = { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { protocolVersion: 1, clientCapabilities: { auth: { terminal: true } } }, + }; + proc.stdin.write(new TextEncoder().encode(`${JSON.stringify(initRequest)}\n`)); + proc.stdin.flush(); + + // Capture stderr in parallel so we can verify it does not carry any + // JSON-RPC frame. ACP owns stdout; banners, progress text, or stray + // protocol bytes on stderr indicate a misroute. + const stderrChunks: Uint8Array[] = []; + const stderrPump = (async () => { + const reader = (proc.stderr as ReadableStream<Uint8Array>).getReader(); + try { + while (true) { + const { value, done } = await reader.read(); + if (done) break; + if (value) stderrChunks.push(value); + // Stop once the first stdout frame arrives so the pump terminates + // alongside the test rather than waiting for process exit. + if (stderrChunks.length > 32) break; + } + } finally { + reader.releaseLock(); + } + })(); + + const firstLine = await readFirstFrame(proc.stdout as ReadableStream<Uint8Array>); + expect(firstLine.length).toBeGreaterThan(0); + expect(firstLine[0]).toBe("{"); + + const message = JSON.parse(firstLine) as { + jsonrpc?: string; + id?: unknown; + result?: { protocolVersion?: number; authMethods?: Array<{ type?: string; id?: string }> }; + error?: unknown; + }; + expect(message.jsonrpc).toBe("2.0"); + expect(message.id).toBe(1); + expect(message.error).toBeUndefined(); + expect(message.result?.protocolVersion).toBe(1); + expect(message.result?.authMethods).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: "agent" }), + expect.objectContaining({ type: "terminal", id: "terminal" }), + ]), + ); + + // Terminate the process so the stderr pump promise resolves. Race with a + // short timeout in case stderr is empty (common path). + try { + proc.kill(); + } catch { + // process may already be exiting + } + await Promise.race([stderrPump, new Promise(resolve => setTimeout(resolve, 500))]); + const stderrText = new TextDecoder().decode(new Uint8Array(stderrChunks.flatMap(chunk => Array.from(chunk)))); + // Guard against JSON-RPC frames sneaking onto stderr. We allow normal + // stderr output (warnings, telemetry, etc.) but reject anything that + // parses as a JSON-RPC envelope on the wrong channel. + for (const line of stderrText.split("\n")) { + const trimmed = line.trim(); + if (!trimmed.startsWith("{")) continue; + let parsed: { jsonrpc?: unknown } | undefined; + try { + parsed = JSON.parse(trimmed) as { jsonrpc?: unknown }; + } catch { + continue; + } + expect(parsed?.jsonrpc, `JSON-RPC frame leaked to stderr: ${trimmed}`).toBeUndefined(); + } + }, 20_000); +}); diff --git a/packages/coding-agent/test/agent-session-acp-permission.test.ts b/packages/coding-agent/test/agent-session-acp-permission.test.ts new file mode 100644 index 000000000..40874ac19 --- /dev/null +++ b/packages/coding-agent/test/agent-session-acp-permission.test.ts @@ -0,0 +1,309 @@ +/** + * Tests for the ACP permission gate in AgentSession. + * + * Verifies that sensitive tools (bash, edit, write, ast_edit, delete, move) are gated behind + * `ClientBridge.requestPermission` when a bridge is set, and that allow/reject + * decisions are cached appropriately for allow_always / reject_always. + */ +import { afterEach, beforeEach, expect, it, spyOn } from "bun:test"; +import * as path from "node:path"; +import { Agent, type AgentTool } from "@oh-my-pi/pi-agent-core"; +import { getBundledModel } from "@oh-my-pi/pi-ai"; +import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream"; +import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; +import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; +import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; +import type { + ClientBridge, + ClientBridgePermissionOutcome, + ClientBridgePermissionToolCall, +} from "@oh-my-pi/pi-coding-agent/session/client-bridge"; +import { convertToLlm } from "@oh-my-pi/pi-coding-agent/session/messages"; +import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; +import { TempDir } from "@oh-my-pi/pi-utils"; +import { Type } from "@sinclair/typebox"; + +class MockAssistantStream extends AssistantMessageEventStream {} + +// --------------------------------------------------------------------------- +// Shared setup +// --------------------------------------------------------------------------- + +let tempDir: TempDir; +let authStorage: AuthStorage | undefined; +let session: AgentSession; + +/** Fake bash tool that records execute calls. */ +function makeFakeTool(name: string): AgentTool & { executeCalls: number } { + const tool = { + name, + label: name, + description: `Fake ${name}`, + parameters: Type.Object({ command: Type.Optional(Type.String()) }), + executeCalls: 0, + async execute() { + tool.executeCalls++; + return { content: [{ type: "text" as const, text: "ok" }] }; + }, + }; + return tool; +} + +/** Build a minimal ClientBridge whose requestPermission resolves to the given outcome. */ +function makeBridge(outcome: ClientBridgePermissionOutcome): ClientBridge { + return { + capabilities: { requestPermission: true }, + async requestPermission(_toolCall, _options, _signal) { + return outcome; + }, + }; +} + +async function createSession(tools: AgentTool[], bridge?: ClientBridge): Promise<AgentSession> { + const model = getBundledModel("anthropic", "claude-sonnet-4-5"); + if (!model) throw new Error("Expected claude-sonnet-4-5 model to exist"); + + const settings = Settings.isolated({ "compaction.enabled": false }); + const sessionManager = SessionManager.inMemory(tempDir.path()); + const registry = new ModelRegistry(authStorage!, path.join(tempDir.path(), "models.yml")); + + const agent = new Agent({ + getApiKey: () => "test-key", + initialState: { + model, + systemPrompt: ["Test"], + tools, + messages: [], + }, + convertToLlm, + streamFn: () => new MockAssistantStream(), + }); + + const sess = new AgentSession({ + agent, + sessionManager, + settings, + modelRegistry: registry, + toolRegistry: new Map(tools.map(t => [t.name, t])), + }); + + if (bridge) sess.setClientBridge(bridge); + return sess; +} + +beforeEach(async () => { + tempDir = TempDir.createSync("@pi-acp-permission-test-"); + authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db")); + authStorage.setRuntimeApiKey("anthropic", "test-key"); +}); + +afterEach(async () => { + await session?.dispose(); + authStorage?.close(); + authStorage = undefined; + tempDir.removeSync(); +}); + +// --------------------------------------------------------------------------- +// 1. Allow once: bridge called once, underlying execute called once +// --------------------------------------------------------------------------- + +it("allow_once: calls bridge once and executes the underlying tool", async () => { + const bashTool = makeFakeTool("bash"); + const bridge = makeBridge({ outcome: "selected", optionId: "allow_once", kind: "allow_once" }); + const permissionSpy = spyOn(bridge, "requestPermission"); + session = await createSession([bashTool], bridge); + + await session.setActiveToolsByName(["bash"]); + // Get the wrapped tool from the agent's active set. + const wrappedBash = session.agent.state.tools.find(t => t.name === "bash"); + expect(wrappedBash).toBeDefined(); + + await wrappedBash!.execute("call-1", { command: "echo hi" }, undefined, undefined as never, undefined as never); + + expect(permissionSpy).toHaveBeenCalledTimes(1); + expect(bashTool.executeCalls).toBe(1); +}); + +it("delete and move tools request ACP permission before executing", async () => { + const deleteTool = makeFakeTool("delete"); + const moveTool = makeFakeTool("move"); + const requests: ClientBridgePermissionToolCall[] = []; + const bridge: ClientBridge = { + capabilities: { requestPermission: true }, + async requestPermission(toolCall, _options, _signal) { + requests.push(toolCall); + return { outcome: "selected", optionId: "allow_once", kind: "allow_once" }; + }, + }; + const permissionSpy = spyOn(bridge, "requestPermission"); + session = await createSession([deleteTool, moveTool], bridge); + + await session.setActiveToolsByName(["delete", "move"]); + const wrappedDelete = session.agent.state.tools.find(t => t.name === "delete"); + const wrappedMove = session.agent.state.tools.find(t => t.name === "move"); + expect(wrappedDelete).toBeDefined(); + expect(wrappedMove).toBeDefined(); + + await wrappedDelete!.execute( + "call-delete", + { path: "/tmp/gone.ts" }, + undefined, + undefined as never, + undefined as never, + ); + await wrappedMove!.execute( + "call-move", + { oldPath: "/tmp/old.ts", newPath: "/tmp/new.ts" }, + undefined, + undefined as never, + undefined as never, + ); + + expect(permissionSpy).toHaveBeenCalledTimes(2); + expect(requests.map(({ toolName, title, locations }) => ({ toolName, title, locations }))).toEqual([ + { toolName: "delete", title: "Delete /tmp/gone.ts", locations: [{ path: "/tmp/gone.ts" }] }, + { + toolName: "move", + title: "Move /tmp/old.ts to /tmp/new.ts", + locations: [{ path: "/tmp/old.ts" }, { path: "/tmp/new.ts" }], + }, + ]); + expect(deleteTool.executeCalls).toBe(1); + expect(moveTool.executeCalls).toBe(1); +}); + +it("setClientBridge wraps tools that were already active", async () => { + const bashTool = makeFakeTool("bash"); + const bridge = makeBridge({ outcome: "selected", optionId: "allow_once", kind: "allow_once" }); + const permissionSpy = spyOn(bridge, "requestPermission"); + session = await createSession([bashTool]); + + session.setClientBridge(bridge); + const wrappedBash = session.agent.state.tools.find(t => t.name === "bash"); + expect(wrappedBash).toBeDefined(); + + await wrappedBash!.execute("call-1", { command: "echo hi" }, undefined, undefined as never, undefined as never); + + expect(permissionSpy).toHaveBeenCalledTimes(1); + expect(bashTool.executeCalls).toBe(1); +}); + +it("aborting an open permission request rejects without executing the tool", async () => { + const bashTool = makeFakeTool("bash"); + const pending = Promise.withResolvers<ClientBridgePermissionOutcome>(); + const bridge: ClientBridge = { + capabilities: { requestPermission: true }, + requestPermission: async () => pending.promise, + }; + session = await createSession([bashTool], bridge); + await session.setActiveToolsByName(["bash"]); + const wrappedBash = session.agent.state.tools.find(t => t.name === "bash"); + expect(wrappedBash).toBeDefined(); + + const abortController = new AbortController(); + const execution = wrappedBash!.execute( + "call-1", + { command: "echo hi" }, + abortController.signal, + undefined as never, + undefined as never, + ); + abortController.abort(); + + await expect(execution).rejects.toThrow(/Permission request cancelled/); + expect(bashTool.executeCalls).toBe(0); + pending.resolve({ outcome: "cancelled" }); +}); + +// --------------------------------------------------------------------------- +// 2. Reject once: throws, underlying execute never called +// --------------------------------------------------------------------------- + +it("reject_once: throws ToolError and never calls underlying execute", async () => { + const editTool = makeFakeTool("edit"); + const bridge = makeBridge({ outcome: "selected", optionId: "reject_once", kind: "reject_once" }); + session = await createSession([editTool], bridge); + + await session.setActiveToolsByName(["edit"]); + const wrappedEdit = session.agent.state.tools.find(t => t.name === "edit"); + expect(wrappedEdit).toBeDefined(); + + await expect( + wrappedEdit!.execute("call-1", { path: "/tmp/foo.ts" }, undefined, undefined as never, undefined as never), + ).rejects.toThrow(/rejected by user/); + + expect(editTool.executeCalls).toBe(0); +}); + +it("unknown selected permission option ID fails closed without executing", async () => { + const editTool = makeFakeTool("edit"); + const bridge = makeBridge({ outcome: "selected", optionId: "allow_typo" }); + session = await createSession([editTool], bridge); + + await session.setActiveToolsByName(["edit"]); + const wrappedEdit = session.agent.state.tools.find(t => t.name === "edit"); + expect(wrappedEdit).toBeDefined(); + + await expect( + wrappedEdit!.execute("call-unknown", { path: "/tmp/foo.ts" }, undefined, undefined as never, undefined as never), + ).rejects.toThrow(/unknown option ID/); + expect(editTool.executeCalls).toBe(0); +}); + +// --------------------------------------------------------------------------- +// 3. Always allow caches: bridge called exactly once across two executions +// --------------------------------------------------------------------------- + +it("allow_always: caches decision and calls bridge only once for subsequent executes", async () => { + const writeTool = makeFakeTool("write"); + const bridge = makeBridge({ outcome: "selected", optionId: "allow_always", kind: "allow_always" }); + const permissionSpy = spyOn(bridge, "requestPermission"); + session = await createSession([writeTool], bridge); + + await session.setActiveToolsByName(["write"]); + const wrappedWrite = session.agent.state.tools.find(t => t.name === "write"); + expect(wrappedWrite).toBeDefined(); + + // First call — bridge is consulted, decision cached. + await wrappedWrite!.execute("call-1", { path: "/tmp/a.ts" }, undefined, undefined as never, undefined as never); + // Second call — must skip the bridge entirely. + await wrappedWrite!.execute("call-2", { path: "/tmp/b.ts" }, undefined, undefined as never, undefined as never); + + expect(permissionSpy).toHaveBeenCalledTimes(1); + expect(writeTool.executeCalls).toBe(2); +}); + +// --------------------------------------------------------------------------- +// 4. Read tool not gated: bridge never called even when bridge is set +// --------------------------------------------------------------------------- + +it("read tool: requestPermission is never called for non-gated tools", async () => { + const readTool = makeFakeTool("read"); + const bridge = makeBridge({ outcome: "selected", optionId: "allow_once", kind: "allow_once" }); + const permissionSpy = spyOn(bridge, "requestPermission"); + session = await createSession([readTool], bridge); + + await session.setActiveToolsByName(["read"]); + const wrappedRead = session.agent.state.tools.find(t => t.name === "read"); + expect(wrappedRead).toBeDefined(); + + await wrappedRead!.execute("call-1", {}, undefined, undefined as never, undefined as never); + + expect(permissionSpy).toHaveBeenCalledTimes(0); + expect(readTool.executeCalls).toBe(1); +}); + +// --------------------------------------------------------------------------- +// 5. No bridge → original tool object identity preserved (no wrapping) +// --------------------------------------------------------------------------- + +it("no bridge: original tool object is returned unchanged", async () => { + const bashTool = makeFakeTool("bash"); + session = await createSession([bashTool]); // no bridge + + await session.setActiveToolsByName(["bash"]); + const activeBash = session.agent.state.tools.find(t => t.name === "bash"); + expect(activeBash).toBe(bashTool); +}); diff --git a/packages/coding-agent/test/bash-acp-terminal.test.ts b/packages/coding-agent/test/bash-acp-terminal.test.ts new file mode 100644 index 000000000..d97115e70 --- /dev/null +++ b/packages/coding-agent/test/bash-acp-terminal.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it, spyOn } from "bun:test"; +import type { ClientBridge, ClientBridgeTerminalHandle } from "../src/session/client-bridge"; +import type { ToolSession } from "../src/tools"; +import { BashTool } from "../src/tools/bash"; + +function makeSession(bridge: ClientBridge): ToolSession { + return { + cwd: "/tmp", + hasUI: false, + skills: [], + getSessionFile: () => null, + settings: { + get(key: string) { + if (key === "async.enabled") return false; + if (key === "bash.autoBackground.enabled") return false; + if (key === "bash.autoBackground.thresholdMs") return 60_000; + if (key === "bashInterceptor.enabled") return false; + if (key === "astGrep.enabled") return false; + if (key === "astEdit.enabled") return false; + if (key === "search.enabled") return false; + if (key === "find.enabled") return false; + return undefined; + }, + getBashInterceptorRules() { + return []; + }, + }, + getClientBridge: () => bridge, + } as unknown as ToolSession; +} + +describe("BashTool ACP terminal routing", () => { + it("routes through bridge, emits terminalId update, and releases the handle", async () => { + const stubText = "hello from terminal\n"; + + const handle: ClientBridgeTerminalHandle = { + terminalId: "term-xyz", + waitForExit: async () => ({ exitCode: 0, signal: null }), + currentOutput: async () => ({ output: stubText, truncated: false }), + kill: async () => {}, + release: async () => {}, + }; + + const bridge: ClientBridge = { + capabilities: { terminal: true }, + createTerminal: async () => handle, + }; + + const createSpy = spyOn(bridge, "createTerminal"); + const releaseSpy = spyOn(handle, "release"); + + const updates: Array<{ details?: { terminalId?: string } }> = []; + + const tool = new BashTool(makeSession(bridge)); + const result = await tool.execute("call-1", { command: "echo hi" }, undefined, update => { + updates.push(update as { details?: { terminalId?: string } }); + }); + + // createTerminal must be called with the expanded command + expect(createSpy).toHaveBeenCalledTimes(1); + const params = createSpy.mock.calls[0]![0]; + expect(params.command).toBe("echo hi"); + + // The first onUpdate must carry the terminalId so the editor can embed it + expect(updates.length).toBeGreaterThanOrEqual(1); + expect(updates[0]!.details?.terminalId).toBe("term-xyz"); + + // The final result text must contain the stub output + const text = result.content.find(c => c.type === "text"); + expect(text?.text).toContain("hello from terminal"); + + // The result details must carry terminalId for the ACP event mapper + expect(result.details?.terminalId).toBe("term-xyz"); + + // The handle must always be released + expect(releaseSpy).toHaveBeenCalledTimes(1); + }); + + it("releases the client terminal when final output retrieval fails", async () => { + const handle: ClientBridgeTerminalHandle = { + terminalId: "term-output-failure", + waitForExit: async () => ({ exitCode: 0, signal: null }), + currentOutput: async () => { + throw new Error("client output unavailable"); + }, + kill: async () => {}, + release: async () => {}, + }; + const bridge: ClientBridge = { + capabilities: { terminal: true }, + createTerminal: async () => handle, + }; + const releaseSpy = spyOn(handle, "release"); + + const tool = new BashTool(makeSession(bridge)); + + await expect(tool.execute("call-output-failure", { command: "echo hi" })).rejects.toThrow( + /client output unavailable/, + ); + expect(releaseSpy).toHaveBeenCalledTimes(1); + }); + + it("releases the client terminal when waiting for exit fails", async () => { + const handle: ClientBridgeTerminalHandle = { + terminalId: "term-exit-failure", + waitForExit: async () => { + throw new Error("client wait unavailable"); + }, + currentOutput: async () => ({ output: "", truncated: false }), + kill: async () => {}, + release: async () => {}, + }; + const bridge: ClientBridge = { + capabilities: { terminal: true }, + createTerminal: async () => handle, + }; + const releaseSpy = spyOn(handle, "release"); + + const tool = new BashTool(makeSession(bridge)); + + await expect(tool.execute("call-exit-failure", { command: "echo hi" })).rejects.toThrow( + /client wait unavailable/, + ); + expect(releaseSpy).toHaveBeenCalledTimes(1); + }); + + it("kills and releases the client terminal when the command times out", async () => { + const pendingExit = Promise.withResolvers<{ exitCode: number | null; signal: string | null }>(); + const handle: ClientBridgeTerminalHandle = { + terminalId: "term-timeout", + waitForExit: async () => pendingExit.promise, + currentOutput: async () => ({ output: "", truncated: false }), + kill: async () => {}, + release: async () => {}, + }; + const bridge: ClientBridge = { + capabilities: { terminal: true }, + createTerminal: async () => handle, + }; + const killSpy = spyOn(handle, "kill"); + const releaseSpy = spyOn(handle, "release"); + + const tool = new BashTool(makeSession(bridge)); + + await expect(tool.execute("call-timeout", { command: "sleep 60", timeout: 1 })).rejects.toThrow( + /Command timed out after 1 seconds/, + ); + + expect(killSpy).toHaveBeenCalledTimes(1); + expect(releaseSpy).toHaveBeenCalledTimes(1); + pendingExit.resolve({ exitCode: null, signal: "TERM" }); + }); +}); diff --git a/packages/coding-agent/test/edit-per-file-diff-content.test.ts b/packages/coding-agent/test/edit-per-file-diff-content.test.ts new file mode 100644 index 000000000..f08f76e51 --- /dev/null +++ b/packages/coding-agent/test/edit-per-file-diff-content.test.ts @@ -0,0 +1,164 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { + DEFAULT_FUZZY_THRESHOLD, + EditTool, + type EditToolDetails, + executePatchSingle, + executeReplaceSingle, +} from "@oh-my-pi/pi-coding-agent/edit"; +import { writethroughNoop } from "@oh-my-pi/pi-coding-agent/lsp"; +import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; + +// ─── Minimal ToolSession stub ──────────────────────────────────────────────── + +function makeSession(cwd: string): ToolSession { + return { + cwd, + hasUI: false, + getSessionFile: () => null, + getSessionSpawns: () => "*", + enableLsp: false, + settings: Settings.isolated({ "edit.mode": "patch" }), + getArtifactsDir: () => null, + getSessionId: () => null, + getPlanModeState: () => undefined, + } as unknown as ToolSession; +} + +const noopBeginDeferred = (_p: string) => ({ + onDeferredDiagnostics: () => {}, + signal: new AbortController().signal, + finalize: () => {}, +}); + +// ─── Setup / teardown ──────────────────────────────────────────────────────── + +let tempDir: string; + +beforeEach(async () => { + _resetSettingsForTest(); + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-edit-diff-")); + await Settings.init({ inMemory: true, cwd: tempDir }); +}); + +afterEach(async () => { + _resetSettingsForTest(); + await fs.rm(tempDir, { recursive: true, force: true }); +}); + +// ─── executePatchSingle ─────────────────────────────────────────────────────── + +describe("executePatchSingle — oldText/newText propagation", () => { + test("update: oldText is pre-edit content, newText is post-edit content", async () => { + await Bun.write(path.join(tempDir, "foo.txt"), "a\n"); + + const result = await executePatchSingle({ + session: makeSession(tempDir), + path: "foo.txt", + params: { op: "update", diff: "@@\n-a\n+b" }, + allowFuzzy: true, + fuzzyThreshold: DEFAULT_FUZZY_THRESHOLD, + writethrough: writethroughNoop, + beginDeferredDiagnosticsForPath: noopBeginDeferred, + }); + + expect(result.details?.path).toBe(path.join(tempDir, "foo.txt")); + expect(result.details?.oldText).toBe("a\n"); + expect(result.details?.newText).toBe("b\n"); + }); + + test("create: oldText is undefined, newText is the created content", async () => { + const result = await executePatchSingle({ + session: makeSession(tempDir), + path: "new.txt", + params: { op: "create", diff: "hello\n" }, + allowFuzzy: true, + fuzzyThreshold: DEFAULT_FUZZY_THRESHOLD, + writethrough: writethroughNoop, + beginDeferredDiagnosticsForPath: noopBeginDeferred, + }); + + expect(result.details?.path).toBe(path.join(tempDir, "new.txt")); + expect(result.details?.oldText).toBeUndefined(); + expect(result.details?.newText).toBe("hello\n"); + }); + + test("delete: oldText is prior content, newText is undefined", async () => { + await Bun.write(path.join(tempDir, "gone.txt"), "will be deleted\n"); + + const result = await executePatchSingle({ + session: makeSession(tempDir), + path: "gone.txt", + params: { op: "delete" }, + allowFuzzy: true, + fuzzyThreshold: DEFAULT_FUZZY_THRESHOLD, + writethrough: writethroughNoop, + beginDeferredDiagnosticsForPath: noopBeginDeferred, + }); + + expect(result.details?.path).toBe(path.join(tempDir, "gone.txt")); + expect(result.details?.oldText).toBe("will be deleted\n"); + expect(result.details?.newText).toBeUndefined(); + }); +}); + +describe("EditTool patch aggregation — oldText/newText propagation", () => { + test("create followed by update preserves create-shaped oldText", async () => { + const tool = new EditTool(makeSession(tempDir)); + + const result = await tool.execute("call-create-update", { + path: "created.txt", + edits: [ + { op: "create", diff: "a\n" }, + { op: "update", diff: "@@\n-a\n+b" }, + ], + }); + const details = result.details as EditToolDetails; + expect(details.path).toBe(path.join(tempDir, "created.txt")); + expect("oldText" in details).toBe(true); + expect(details.oldText).toBeUndefined(); + expect(details.newText).toBe("b\n"); + }); + + test("update followed by delete preserves delete-shaped newText", async () => { + await Bun.write(path.join(tempDir, "updated-then-gone.txt"), "a\n"); + const tool = new EditTool(makeSession(tempDir)); + + const result = await tool.execute("call-update-delete", { + path: "updated-then-gone.txt", + edits: [{ op: "update", diff: "@@\n-a\n+b" }, { op: "delete" }], + }); + const details = result.details as EditToolDetails; + expect(details.path).toBe(path.join(tempDir, "updated-then-gone.txt")); + expect(details.oldText).toBe("a\n"); + expect("newText" in details).toBe(true); + expect(details.newText).toBeUndefined(); + }); +}); + +// ─── executeReplaceSingle ───────────────────────────────────────────────────── + +describe("executeReplaceSingle — oldText/newText propagation", () => { + test("replace: oldText is full file before, newText is full file after", async () => { + const originalContent = "line one\nline two\nline three\n"; + await Bun.write(path.join(tempDir, "bar.txt"), originalContent); + + const result = await executeReplaceSingle({ + session: makeSession(tempDir), + path: "bar.txt", + params: { old_text: "line two", new_text: "line TWO" }, + allowFuzzy: false, + fuzzyThreshold: DEFAULT_FUZZY_THRESHOLD, + writethrough: writethroughNoop, + beginDeferredDiagnosticsForPath: noopBeginDeferred, + }); + + expect(result.details?.path).toBe(path.join(tempDir, "bar.txt")); + expect(result.details?.oldText).toBe(originalContent); + expect(result.details?.newText).toBe("line one\nline TWO\nline three\n"); + }); +}); diff --git a/packages/coding-agent/test/helpers/acp-schema.ts b/packages/coding-agent/test/helpers/acp-schema.ts new file mode 100644 index 000000000..e4d4d05ac --- /dev/null +++ b/packages/coding-agent/test/helpers/acp-schema.ts @@ -0,0 +1,16 @@ +import { expect } from "bun:test"; +import type * as z from "zod/v4"; + +function formatIssues(error: z.ZodError): string { + return error.issues.map(issue => `${issue.path.join(".") || "<root>"}: ${issue.message}`).join("\n"); +} + +export function expectAcpStructure(schema: z.ZodType, value: unknown): void { + const result = schema.safeParse(value); + expect(result.success, result.success ? undefined : formatIssues(result.error)).toBe(true); +} + +export function expectAcpStructureRejects(schema: z.ZodType, value: unknown): void { + const result = schema.safeParse(value); + expect(result.success).toBe(false); +} diff --git a/packages/coding-agent/test/modes/components/assistant-message-mermaid.test.ts b/packages/coding-agent/test/modes/components/assistant-message-mermaid.test.ts index 3c3865120..9a0f690f6 100644 --- a/packages/coding-agent/test/modes/components/assistant-message-mermaid.test.ts +++ b/packages/coding-agent/test/modes/components/assistant-message-mermaid.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import * as path from "node:path"; import type { AssistantMessage } from "@oh-my-pi/pi-ai"; import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { AssistantMessageComponent } from "@oh-my-pi/pi-coding-agent/modes/components/assistant-message"; @@ -75,7 +76,9 @@ describe("AssistantMessageComponent mermaid markdown", () => { describe("AssistantMessageComponent tool images", () => { it("converts WebP tool images for Kitty terminal rendering", async () => { - const webpBase64 = Buffer.from(await Bun.file("../../assets/python.webp").arrayBuffer()).toBase64(); + const webpBase64 = Buffer.from( + await Bun.file(path.join(import.meta.dir, "../../../../../assets/python.webp")).arrayBuffer(), + ).toBase64(); setTerminalImageProtocol(ImageProtocol.Kitty); const converted = Promise.withResolvers<void>(); diff --git a/packages/coding-agent/test/read-acp-fs.test.ts b/packages/coding-agent/test/read-acp-fs.test.ts new file mode 100644 index 000000000..76a26a17a --- /dev/null +++ b/packages/coding-agent/test/read-acp-fs.test.ts @@ -0,0 +1,113 @@ +import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import type { AgentToolResult } from "@oh-my-pi/pi-agent-core"; +import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import type { ClientBridge } from "@oh-my-pi/pi-coding-agent/session/client-bridge"; +import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; +import type { ReadToolDetails } from "@oh-my-pi/pi-coding-agent/tools/read"; +import { ReadTool } from "@oh-my-pi/pi-coding-agent/tools/read"; + +const BRIDGE_CONTENT = "// content from editor buffer\nexport function greet() { return 'bridge'; }\n"; + +function textOutput(result: AgentToolResult<ReadToolDetails>): string { + return result.content + .filter(c => c.type === "text") + .map(c => c.text) + .join("\n"); +} + +function createSession(cwd: string, bridge?: ClientBridge): ToolSession { + return { + cwd, + hasUI: false, + getSessionFile: () => path.join(cwd, "session.jsonl"), + getSessionSpawns: () => "*", + getArtifactsDir: () => path.join(cwd, "artifacts"), + allocateOutputArtifact: async () => ({ id: "artifact-1", path: path.join(cwd, "artifact-1.log") }), + settings: Settings.isolated(), + getClientBridge: bridge ? () => bridge : undefined, + }; +} + +describe("read tool ACP fs routing", () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "read-acp-fs-test-")); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + it("routes plain text reads through the bridge and does not call Bun.file().text()", async () => { + // .ts file so summarize would normally run (read.summarize.enabled defaults to true) + const filePath = path.join(tmpDir, "example.ts"); + await fs.writeFile(filePath, "export function greet() { return 'disk'; }\n"); + + const bridge: ClientBridge = { + capabilities: { readTextFile: true }, + readTextFile: async () => BRIDGE_CONTENT, + }; + const bridgeSpy = spyOn(bridge, "readTextFile"); + + // Wrap Bun.file() to detect any .text() calls + let textCallCount = 0; + const origBunFile = Bun.file.bind(Bun); + const bunFileSpy = spyOn(Bun, "file").mockImplementation( + (arg: string | URL | Uint8Array | ArrayBufferLike | number, opts?: BlobPropertyBag) => { + const bunFile = origBunFile(arg as string, opts); + const origText = bunFile.text.bind(bunFile); + bunFile.text = async () => { + textCallCount++; + return origText(); + }; + return bunFile; + }, + ); + + try { + const session = createSession(tmpDir, bridge); + const tool = new ReadTool(session); + + const result = await tool.execute("call-1", { path: filePath }); + const text = textOutput(result); + + // Bridge content should appear in output + expect(text).toContain("content from editor buffer"); + // Bridge readTextFile was invoked + expect(bridgeSpy).toHaveBeenCalled(); + // Bun.file().text() must not have been called — bridge is source of truth + expect(textCallCount).toBe(0); + } finally { + bunFileSpy.mockRestore(); + } + }); + + it("applies requested line ranges to bridge content exactly once", async () => { + const filePath = path.join(tmpDir, "range.txt"); + await fs.writeFile(filePath, "disk one\ndisk two\ndisk three\n"); + const bridgeContent = "bridge one\nbridge two\nbridge three\n"; + const bridge: ClientBridge = { + capabilities: { readTextFile: true }, + readTextFile: async params => { + if (typeof params.line !== "number") return bridgeContent; + const lines = bridgeContent.split("\n"); + const start = Math.max(0, params.line - 1); + return lines.slice(start, params.limit === undefined ? undefined : start + params.limit).join("\n"); + }, + }; + + const session = createSession(tmpDir, bridge); + const tool = new ReadTool(session); + + const result = await tool.execute("call-range", { path: `${filePath}:2+1` }); + const text = textOutput(result); + + expect(text).toContain("bridge two"); + expect(text).not.toContain("Line 2 is beyond end"); + expect(text).not.toContain("disk two"); + }); +}); diff --git a/packages/coding-agent/test/write-acp-fs.test.ts b/packages/coding-agent/test/write-acp-fs.test.ts new file mode 100644 index 000000000..a376b0d77 --- /dev/null +++ b/packages/coding-agent/test/write-acp-fs.test.ts @@ -0,0 +1,62 @@ +import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import type { ClientBridge } from "@oh-my-pi/pi-coding-agent/session/client-bridge"; +import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; +import { WriteTool } from "@oh-my-pi/pi-coding-agent/tools/write"; + +const FILE_CONTENT = "bridge write content\n"; + +function createSession(cwd: string, bridge?: ClientBridge): ToolSession { + return { + cwd, + hasUI: false, + getSessionFile: () => path.join(cwd, "session.jsonl"), + getSessionSpawns: () => "*", + getArtifactsDir: () => path.join(cwd, "artifacts"), + allocateOutputArtifact: async () => ({ id: "artifact-1", path: path.join(cwd, "artifact-1.log") }), + settings: Settings.isolated(), + getClientBridge: bridge ? () => bridge : undefined, + }; +} + +describe("write tool ACP fs routing", () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "write-acp-fs-test-")); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + it("routes plain text writes through the bridge and does not call Bun.write", async () => { + const filePath = path.join(tmpDir, "output.txt"); + + const bridge: ClientBridge = { + capabilities: { writeTextFile: true }, + writeTextFile: async () => undefined, + }; + + const bridgeSpy = spyOn(bridge, "writeTextFile"); + const bunWriteSpy = spyOn(Bun, "write"); + + try { + const session = createSession(tmpDir, bridge); + const tool = new WriteTool(session); + + await tool.execute("call-1", { path: filePath, content: FILE_CONTENT }); + + // Bridge was called with the exact path and content + expect(bridgeSpy).toHaveBeenCalledTimes(1); + expect(bridgeSpy).toHaveBeenCalledWith({ path: filePath, content: FILE_CONTENT }); + // Disk write must not have been called — bridge is the destination + expect(bunWriteSpy).not.toHaveBeenCalled(); + } finally { + bunWriteSpy.mockRestore(); + } + }); +});