Merge pull request #1015 from ogrodev/acp-conformance
Add ACP conformance support
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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) },
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user