Merge pull request #1015 from ogrodev/acp-conformance

Add ACP conformance support
This commit is contained in:
Can Bölük
2026-05-13 06:16:16 +02:00
committed by GitHub
45 changed files with 6162 additions and 206 deletions
+35 -1
View File
@@ -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://<N>` / `pr://<N>` (or the long form `issue://<owner>/<repo>/<N>` / `pr://<owner>/<repo>/<N>`); 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://<N>/diff` for the changed-file listing, `pr://<N>/diff/<i>` for a single file slice (1-indexed), and `pr://<N>/diff/all` for the verbatim unified diff. Long-form `pr://<owner>/<repo>/<N>/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://<N>/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/<N>`) 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/<endpoint> -f q=… -F per_page=…` directly so the qualifiers reach GitHub's search API verbatim. `is:issue`/`is:pr` and `repo:<owner>/<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 <path>` 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:<name>` 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 <scheme>:// 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://<N>/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 <path>: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.
+1
View File
@@ -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) },
+24
View File
@@ -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<void> {
const { args, terminalAuth } = prepareAcpTerminalAuthArgs(this.argv);
const parsed = parseArgs(args);
if (!terminalAuth) {
parsed.mode = "acp";
}
await runRootCommand(parsed, args);
}
}
+4 -2
View File
@@ -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<void> {
const parsed = parseArgs(this.argv);
await runRootCommand(parsed, this.argv);
const { args } = prepareAcpTerminalAuthArgs(this.argv);
const parsed = parseArgs(args);
await runRootCommand(parsed, args);
}
}
+22 -1
View File
@@ -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
@@ -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,
},
};
}
@@ -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,
},
};
}
@@ -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;
}
// ═══════════════════════════════════════════════════════════════════════════
@@ -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<LoadS
warnings: [...(result.warnings ?? []).map(w => ({ skillPath: "", message: w })), ...collisionWarnings],
};
}
export interface BuiltSkillPromptMessage {
message: string;
details: SkillPromptDetails;
}
export function getSkillSlashCommandName(skill: Pick<Skill, "name">): string {
return `skill:${skill.name}`;
}
export async function buildSkillPromptMessage(
skill: Pick<Skill, "name" | "filePath">,
args: string,
): Promise<BuiltSkillPromptMessage> {
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,
},
};
}
+3 -3
View File
@@ -660,13 +660,13 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
const cwd = getProjectDir();
const settingsInstance = await logger.time("settings:init", Settings.init, { cwd });
if (parsedArgs.mode === "rpc" || parsedArgs.mode === "rpc-ui") {
if (parsedArgs.mode === "rpc" || parsedArgs.mode === "rpc-ui" || parsedArgs.mode === "acp") {
applyRpcDefaultSettingOverrides();
}
if (parsedArgs.noPty || parsedArgs.mode === "rpc-ui") {
Bun.env.PI_NO_PTY = "1";
}
if (parsedArgs.noTitle || parsedArgs.mode === "rpc" || parsedArgs.mode === "rpc-ui") {
if (parsedArgs.noTitle || parsedArgs.mode === "rpc" || parsedArgs.mode === "rpc-ui" || parsedArgs.mode === "acp") {
Bun.env.PI_NO_TITLE = "1";
}
const { pipedInput, fileText, fileImages } = await logger.time("prepareInitialMessage", async () => {
@@ -850,7 +850,7 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
}
}
if (!isInteractive && !session.model) {
if (!isInteractive && parsedArgs.mode !== "acp" && !session.model) {
if (modelFallbackMessage) {
process.stderr.write(`${chalk.red(modelFallbackMessage)}\n`);
} else {
+361 -54
View File
@@ -4,7 +4,9 @@ import {
type AgentSideConnection,
type AuthenticateRequest,
type AuthenticateResponse,
type AuthMethod,
type AvailableCommand,
type ClientCapabilities,
type CloseSessionRequest,
type CloseSessionResponse,
type ForkSessionRequest,
@@ -37,27 +39,35 @@ import {
type SetSessionModeResponse,
type Usage,
} from "@agentclientprotocol/sdk";
import type { Model } from "@oh-my-pi/pi-ai";
import type { AssistantMessage, Model } from "@oh-my-pi/pi-ai";
import { logger, VERSION } from "@oh-my-pi/pi-utils";
import { disableProvider, enableProvider } from "../../capability";
import { disableProvider, enableProvider, reset as resetCapabilities } from "../../capability";
import { Settings } from "../../config/settings";
import { clearPluginRootsAndCaches, resolveActiveProjectRegistryPath } from "../../discovery/helpers";
import type { ExtensionUIContext } from "../../extensibility/extensions";
import { runExtensionCompact } from "../../extensibility/extensions/compact-handler";
import { buildSkillPromptMessage, getSkillSlashCommandName } from "../../extensibility/skills";
import { loadSlashCommands } from "../../extensibility/slash-commands";
import { MCPManager } from "../../mcp/manager";
import type { MCPServerConfig } from "../../mcp/types";
import { loadAllExtensions } from "../../modes/components/extensions/state-manager";
import { theme } from "../../modes/theme/theme";
import type { AgentSession, AgentSessionEvent } from "../../session/agent-session";
import { SKILL_PROMPT_MESSAGE_TYPE } from "../../session/messages";
import {
SessionManager,
type SessionInfo as StoredSessionInfo,
type UsageStatistics,
} from "../../session/session-manager";
import { ACP_BUILTIN_SLASH_COMMANDS, executeAcpBuiltinSlashCommand } from "../../slash-commands/acp-builtins";
import { parseThinkingLevel } from "../../thinking";
import { createAcpClientBridge } from "./acp-client-bridge";
import { mapAgentSessionEventToAcpSessionUpdates, mapToolKind } from "./acp-event-mapper";
import { ACP_TERMINAL_AUTH_FLAG } from "./terminal-auth";
const ACP_MODE_ID = "default";
const ACP_DEFAULT_MODE_ID = "default";
const ACP_PLAN_MODE_ID = "plan";
const DEFAULT_PLAN_FILE_URL = "local://PLAN.md";
const MODE_CONFIG_ID = "mode";
const MODEL_CONFIG_ID = "model";
const THINKING_CONFIG_ID = "thinking";
@@ -84,7 +94,8 @@ type ManagedSessionRecord = {
session: AgentSession;
mcpManager: MCPManager | undefined;
promptTurn: PromptTurnState | undefined;
liveMessageIds: WeakMap<object, string>;
liveMessageId: string | undefined;
liveMessageProgress: { textEmitted: boolean; thoughtEmitted: boolean } | undefined;
extensionsConfigured: boolean;
};
@@ -152,6 +163,7 @@ export class AcpAgent implements Agent {
#sessions = new Map<string, ManagedSessionRecord>();
#disposePromise: Promise<void> | 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<InitializeResponse> {
async initialize(params: InitializeRequest): Promise<InitializeResponse> {
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<AuthenticateResponse> {
async authenticate(params: AuthenticateRequest): Promise<AuthenticateResponse> {
// 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<ResumeSessionResponse> {
async resumeSession(params: ResumeSessionRequest): Promise<ResumeSessionResponse> {
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<CloseSessionResponse> {
async closeSession(params: CloseSessionRequest): Promise<CloseSessionResponse> {
const record = this.#sessions.get(params.sessionId);
if (!record) {
return {};
@@ -278,12 +309,17 @@ export class AcpAgent implements Agent {
async setSessionMode(params: SetSessionModeRequest): Promise<SetSessionModeResponse> {
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<void> {
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<boolean> {
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<void> {
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<ManagedSessionRecord> {
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<object, string>(),
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<AgentSessionEvent, { type: "agent_end" }>,
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<void> {
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<void> {
@@ -891,6 +1169,33 @@ export class AcpAgent implements Agent {
});
}
async #emitAvailableCommandsUpdate(record: ManagedSessionRecord): Promise<void> {
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<void> {
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<void> {
const sessionId = record.session.sessionId;
@@ -981,14 +1286,15 @@ export class AcpAgent implements Agent {
}
async #replaySessionHistory(record: ManagedSessionRecord): Promise<void> {
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<Pick<ReplayableMessage, "toolCallId" | "toolName">> & 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 }),
];
}
@@ -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<ClientBridgeTerminalHandle> {
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<ClientBridgePermissionOutcome> {
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 } : {}),
};
}
@@ -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<AgentSessionEvent, { type: "message_end" }>,
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<PathContainer>(args, "path");
if (path) {
const seen = new Set<string>();
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<OldPathContainer>(args, "oldPath");
if (oldPath && oldPath !== path) {
locations.push({ path: oldPath });
}
const newPath = extractStringProperty<NewPathContainer>(args, "newPath");
if (newPath && newPath !== path && newPath !== oldPath) {
locations.push({ path: newPath });
}
pushPath(extractStringProperty<PathContainer>(args, "path"));
pushPath(extractStringProperty<OldPathContainer>(args, "oldPath"));
pushPath(extractStringProperty<NewPathContainer>(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<PathContainer>(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<TextLikeContent>(value, "text");
if (!text) {
@@ -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 };
}
@@ -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";
+1
View File
@@ -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),
@@ -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<string, unknown>) : {};
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<string, unknown>;
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<string, "allow_always" | "reject_always"> = 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<T extends AgentTool>(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<PermissionRaceResult>();
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;
}
@@ -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<ClientBridgeTerminalExitStatus>;
currentOutput(): Promise<ClientBridgeTerminalOutput>;
kill(): Promise<void>;
release(): Promise<void>;
}
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<string>;
writeTextFile?(params: { path: string; content: string }): Promise<void>;
createTerminal?(params: ClientBridgeCreateTerminalParams): Promise<ClientBridgeTerminalHandle>;
requestPermission?(
toolCall: ClientBridgePermissionToolCall,
options: ClientBridgePermissionOption[],
signal?: AbortSignal,
): Promise<ClientBridgePermissionOutcome>;
}
@@ -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<AcpBuiltinSlashCommandResult> {
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;
}
File diff suppressed because it is too large Load Diff
@@ -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");
}
}
@@ -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}%`;
}
@@ -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,
});
}
@@ -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);
}
}
@@ -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 };
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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");
}
@@ -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 };
+176 -2
View File
@@ -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);
+3
View File
@@ -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. */
+38 -2
View File
@@ -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;
+22
View File
@@ -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);
+305 -2
View File
@@ -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);
});
});
@@ -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();
}
});
});
@@ -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 });
});
});
@@ -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),
}),
}),
);
});
});
@@ -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);
});
@@ -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);
});
@@ -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" });
});
});
@@ -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");
});
});
@@ -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);
}
@@ -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>();
@@ -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");
});
});
@@ -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();
}
});
});