mirror of
https://github.com/logancyang/obsidian-copilot.git
synced 2026-07-22 07:50:24 +00:00
feat(agent-mode): introduce Agent Mode feature
Squashed from 8 commits on zero/acp-test: - feat(agent-mode): introduce Agent Mode feature (squashed) - Fix eslint - hide add context button - chore: ban parent-relative imports and rewrite existing ones to @/ aliases - wip(agent-mode): skills management + slash command revamp - fix(agent-mode): initialize SkillManager before preload probes - fix(build): align svgr jsxRuntime with tsconfig classic jsx - fix(test): stub ItemView/WorkspaceLeaf in ChatSingleMessage obsidian mock
This commit is contained in:
parent
bd8829f538
commit
c2ac27edef
269 changed files with 41803 additions and 1466 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -18,6 +18,7 @@ styles.css
|
|||
|
||||
# obsidian
|
||||
data.json
|
||||
data/
|
||||
|
||||
# Exclude macOS Finder (System Explorer) View States
|
||||
.DS_Store
|
||||
|
|
@ -31,3 +32,7 @@ data.json
|
|||
|
||||
# Development session tracking
|
||||
TODO.md
|
||||
|
||||
# ACP full-frame debug log (vault sidecar, may contain prompt/tool data)
|
||||
copilot/acp-frames.ndjson
|
||||
copilot/acp-frames.old.ndjson
|
||||
|
|
|
|||
18
AGENTS.md
18
AGENTS.md
|
|
@ -36,12 +36,6 @@ The Obsidian desktop app includes a CLI for plugin development. Use the full pat
|
|||
/Applications/Obsidian.app/Contents/MacOS/obsidian <command>
|
||||
```
|
||||
|
||||
**Plugin reload** (after `npm run build`):
|
||||
|
||||
```bash
|
||||
/Applications/Obsidian.app/Contents/MacOS/obsidian plugin:reload id=copilot
|
||||
```
|
||||
|
||||
**Console debugging** (requires attaching debugger first):
|
||||
|
||||
```bash
|
||||
|
|
@ -65,28 +59,24 @@ Run `obsidian help` for the full command list.
|
|||
### Core Systems
|
||||
|
||||
1. **LLM Provider System** (`src/LLMProviders/`)
|
||||
|
||||
- Provider implementations for OpenAI, Anthropic, Google, Azure, local models
|
||||
- `LLMProviderManager` handles provider lifecycle and switching
|
||||
- Stream-based responses with error handling and rate limiting
|
||||
- Custom model configuration support
|
||||
|
||||
2. **Chain Factory Pattern** (`src/chainFactory.ts`)
|
||||
|
||||
- Different chain types for various AI operations (chat, copilot, adhoc prompts)
|
||||
- LangChain integration for complex workflows
|
||||
- Memory management for conversation context
|
||||
- Tool integration (search, file operations, time queries)
|
||||
|
||||
3. **Vector Store & Search** (`src/search/`)
|
||||
|
||||
- `VectorStoreManager` manages embeddings and semantic search
|
||||
- `ChunkedStorage` for efficient large document handling
|
||||
- Event-driven index updates via `IndexManager`
|
||||
- Multiple embedding providers support
|
||||
|
||||
4. **UI Component System** (`src/components/`)
|
||||
|
||||
- React functional components with Radix UI primitives
|
||||
- Tailwind CSS with class variance authority (CVA)
|
||||
- Modal system for user interactions
|
||||
|
|
@ -94,7 +84,6 @@ Run `obsidian help` for the full command list.
|
|||
- Settings UI with versioned components
|
||||
|
||||
5. **Message Management Architecture** (`src/core/`, `src/state/`)
|
||||
|
||||
- **MessageRepository** (`src/core/MessageRepository.ts`): Single source of truth for all messages
|
||||
- Stores each message once with both `displayText` and `processedText`
|
||||
- Provides computed views for UI display and LLM processing
|
||||
|
|
@ -116,7 +105,6 @@ Run `obsidian help` for the full command list.
|
|||
- Reprocesses context when messages are edited
|
||||
|
||||
6. **Settings Management**
|
||||
|
||||
- Jotai for atomic settings state management
|
||||
- React contexts for feature-specific state
|
||||
|
||||
|
|
@ -147,14 +135,12 @@ For detailed architecture diagrams and documentation, see [`MESSAGE_ARCHITECTURE
|
|||
### Core Classes and Flow
|
||||
|
||||
1. **MessageRepository** (`src/core/MessageRepository.ts`)
|
||||
|
||||
- Single source of truth for all messages
|
||||
- Stores `StoredMessage` objects with both `displayText` and `processedText`
|
||||
- Provides computed views via `getDisplayMessages()` and `getLLMMessages()`
|
||||
- No complex dual-array synchronization or ID matching
|
||||
|
||||
2. **ChatManager** (`src/core/ChatManager.ts`)
|
||||
|
||||
- Central business logic coordinator
|
||||
- Orchestrates MessageRepository, ContextManager, and LLM operations
|
||||
- Handles all message CRUD operations with proper error handling
|
||||
|
|
@ -166,14 +152,12 @@ For detailed architecture diagrams and documentation, see [`MESSAGE_ARCHITECTURE
|
|||
- Creates new empty repository for each project (no message caching)
|
||||
|
||||
3. **ChatUIState** (`src/state/ChatUIState.ts`)
|
||||
|
||||
- Clean UI-only state manager
|
||||
- Delegates all business logic to ChatManager
|
||||
- Provides React integration with subscription mechanism
|
||||
- Replaces legacy SharedState with minimal, focused approach
|
||||
|
||||
4. **ContextManager** (`src/core/ContextManager.ts`)
|
||||
|
||||
- Handles context processing (notes, URLs, selected text)
|
||||
- Reprocesses context when messages are edited
|
||||
- Ensures fresh context for LLM processing
|
||||
|
|
@ -233,6 +217,8 @@ For detailed architecture diagrams and documentation, see [`MESSAGE_ARCHITECTURE
|
|||
- **Tailwind classes**: Use Tailwind utility classes in components (see `tailwind.config.js` for available classes)
|
||||
- **Custom CSS**: Add custom styles to `src/styles/tailwind.css` after the `@import` statements
|
||||
- After editing CSS, always run `npm run build` to regenerate `styles.css`
|
||||
- **No arbitrary font-size values**: Never use Tailwind's arbitrary-value syntax for typography (e.g. `tw-text-[10.5px]`, `tw-text-[13px]`). Stick to the configured `fontSize` tokens (`tw-text-ui-smaller`, `tw-text-ui-small`, `tw-text-xs`, `tw-text-smallest`, etc.) so type stays consistent with Obsidian's CSS variables. If none of the existing tokens fit, extend the `fontSize` scale in `tailwind.config.js` rather than hard-coding a pixel value at the call site.
|
||||
- **No inline `style={{ ... }}`**: Reserve the `style` prop for values that must change dynamically at runtime (computed positions, animated transforms). Static visual changes belong in Tailwind classes or the shared component (e.g. `Button` variants/sizes) — do not zero out component chrome with inline `border: 0`, `background: "transparent"`, `padding: 0`, etc.
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
# CLAUDE.md
|
||||
|
||||
@AGENTS.md
|
||||
@AGENTS.md
|
||||
|
|
|
|||
62
__mocks__/@agentclientprotocol/sdk.js
Normal file
62
__mocks__/@agentclientprotocol/sdk.js
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
// Lightweight mock for @agentclientprotocol/sdk so unit tests can import
|
||||
// modules that reference its runtime values (RequestError, ClientSideConnection,
|
||||
// PROTOCOL_VERSION, ndJsonStream) without pulling the real ESM package
|
||||
// through ts-jest. Tests that need behavior beyond this mock should stub
|
||||
// their own.
|
||||
|
||||
|
||||
class RequestError extends Error {
|
||||
constructor(code, message, data) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
this.data = data;
|
||||
this.name = "RequestError";
|
||||
}
|
||||
static parseError(data, msg) {
|
||||
return new RequestError(-32700, msg ?? "Parse error", data);
|
||||
}
|
||||
static invalidRequest(data, msg) {
|
||||
return new RequestError(-32600, msg ?? "Invalid Request", data);
|
||||
}
|
||||
static methodNotFound(method) {
|
||||
return new RequestError(-32601, `Method not found: ${method}`);
|
||||
}
|
||||
static invalidParams(data, msg) {
|
||||
return new RequestError(-32602, msg ?? "Invalid params", data);
|
||||
}
|
||||
static internalError(data, msg) {
|
||||
return new RequestError(-32603, msg ?? "Internal error", data);
|
||||
}
|
||||
static authRequired(data, msg) {
|
||||
return new RequestError(-32000, msg ?? "Authentication required", data);
|
||||
}
|
||||
static resourceNotFound(uri) {
|
||||
return new RequestError(-32001, `Resource not found${uri ? `: ${uri}` : ""}`);
|
||||
}
|
||||
toResult() {
|
||||
return { error: this.toErrorResponse() };
|
||||
}
|
||||
toErrorResponse() {
|
||||
return { code: this.code, message: this.message, data: this.data };
|
||||
}
|
||||
}
|
||||
|
||||
class ClientSideConnection {
|
||||
constructor(toClient, _stream) {
|
||||
this._client = toClient(this);
|
||||
}
|
||||
initialize = jest.fn(async () => ({ protocolVersion: 1 }));
|
||||
newSession = jest.fn(async () => ({ sessionId: "test-session" }));
|
||||
prompt = jest.fn(async () => ({ stopReason: "end_turn" }));
|
||||
cancel = jest.fn(async () => undefined);
|
||||
}
|
||||
|
||||
const ndJsonStream = jest.fn(() => ({}));
|
||||
const PROTOCOL_VERSION = 1;
|
||||
|
||||
module.exports = {
|
||||
RequestError,
|
||||
ClientSideConnection,
|
||||
ndJsonStream,
|
||||
PROTOCOL_VERSION,
|
||||
};
|
||||
44
__mocks__/@anthropic-ai/claude-agent-sdk.js
Normal file
44
__mocks__/@anthropic-ai/claude-agent-sdk.js
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
// Lightweight mock for @anthropic-ai/claude-agent-sdk so unit tests can
|
||||
// import modules that reference its runtime values (`query`,
|
||||
// `createSdkMcpServer`, `tool`) without pulling the real ESM package
|
||||
// through ts-jest. Tests that exercise SDK behavior should stub these.
|
||||
|
||||
|
||||
function query() {
|
||||
// Minimal Query stub: empty async generator + control methods. Tests
|
||||
// that need real behavior should mock `query` themselves.
|
||||
const iter = (async function* () {})();
|
||||
return Object.assign(iter, {
|
||||
interrupt: async () => {},
|
||||
setModel: async () => {},
|
||||
setPermissionMode: async () => {},
|
||||
setMaxThinkingTokens: async () => {},
|
||||
applyFlagSettings: async () => {},
|
||||
initializationResult: async () => ({}),
|
||||
supportedCommands: async () => [],
|
||||
supportedModels: async () => [],
|
||||
supportedAgents: async () => [],
|
||||
mcpServerStatus: async () => [],
|
||||
getContextUsage: async () => ({}),
|
||||
readFile: async () => null,
|
||||
reloadPlugins: async () => ({}),
|
||||
accountInfo: async () => ({}),
|
||||
});
|
||||
}
|
||||
|
||||
function createSdkMcpServer(options) {
|
||||
return { type: "sdk", instance: { name: options?.name ?? "mock", tools: options?.tools ?? [] } };
|
||||
}
|
||||
|
||||
function tool(name, description, inputSchema, handler) {
|
||||
return { name, description, inputSchema, handler };
|
||||
}
|
||||
|
||||
class AbortError extends Error {}
|
||||
|
||||
module.exports = {
|
||||
query,
|
||||
createSdkMcpServer,
|
||||
tool,
|
||||
AbortError,
|
||||
};
|
||||
|
|
@ -13,8 +13,6 @@ let requestUrlImpl = jest.fn().mockResolvedValue({
|
|||
});
|
||||
|
||||
module.exports = {
|
||||
// Reason: normalizePath is used by projectPaths.ts; identity function is sufficient for tests
|
||||
normalizePath: jest.fn().mockImplementation((p) => p),
|
||||
moment: jest.requireActual("moment"),
|
||||
requestUrl: (...args) => requestUrlImpl(...args),
|
||||
__setRequestUrlImpl: (impl) => {
|
||||
|
|
@ -45,7 +43,22 @@ module.exports = {
|
|||
}),
|
||||
Platform: {
|
||||
isDesktop: true,
|
||||
isDesktopApp: true,
|
||||
isMobile: false,
|
||||
},
|
||||
FileSystemAdapter: class FileSystemAdapter {
|
||||
constructor(basePath = "/vault") {
|
||||
this._basePath = basePath;
|
||||
this.read = jest.fn();
|
||||
this.write = jest.fn();
|
||||
this.exists = jest.fn().mockResolvedValue(true);
|
||||
this.mkdir = jest.fn().mockResolvedValue(undefined);
|
||||
}
|
||||
getBasePath() {
|
||||
return this._basePath;
|
||||
}
|
||||
},
|
||||
normalizePath: (p) => String(p).replace(/\\\\/g, "/").replace(/\/+/g, "/"),
|
||||
parseYaml: jest.fn().mockImplementation((content) => {
|
||||
return parseYamlString(content);
|
||||
}),
|
||||
|
|
|
|||
6
__mocks__/svg.js
Normal file
6
__mocks__/svg.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
|
||||
import React from "react";
|
||||
|
||||
const SvgStub = (props) => React.createElement("svg", props);
|
||||
module.exports = SvgStub;
|
||||
module.exports.default = SvgStub;
|
||||
709
designdocs/ACP_LIMITATIONS_REPORT.md
Normal file
709
designdocs/ACP_LIMITATIONS_REPORT.md
Normal file
|
|
@ -0,0 +1,709 @@
|
|||
# ACP Protocol & `claude-agent-acp` Wrapper: Concrete Limitations Encountered During SDK Migration
|
||||
|
||||
**From:** Obsidian Copilot plugin team
|
||||
**Re:** Migration from `@agentclientprotocol/claude-agent-acp` → `@anthropic-ai/claude-agent-sdk`
|
||||
**Status:** Feedback / RFC. We still ship `@agentclientprotocol/sdk` for our OpenCode and Codex backends; this is not a teardown.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR
|
||||
|
||||
We ship Copilot for Obsidian, an Obsidian plugin that runs an in-vault AI agent inside an Electron renderer. We've used `@agentclientprotocol/claude-agent-acp` (v0.31.4) for our Claude backend since launch. As Claude Code / Claude Agent SDK matured, we hit a series of capability ceilings that ACP-as-a-protocol and the wrapper-as-an-implementation cannot pass through, and we've now migrated our Claude backend to call `@anthropic-ai/claude-agent-sdk` directly. The five gaps that forced the migration:
|
||||
|
||||
1. **`AskUserQuestion` is hard-blocked** at the wrapper (`acp-agent.ts:1813`). Claude can never ask the user a multi-choice clarifying question through ACP.
|
||||
2. **Only `PostToolUse` hooks are exposed** (`acp-agent.ts:1864-1885`). No `PreToolUse`, no `UserPromptSubmit`, no `Stop`, no `SessionStart/End`, no `SubagentStart/Stop`. A whole class of host policy/audit/UX features is unreachable.
|
||||
3. **No in-process MCP server transport.** ACP forwards only `stdio | http | sse` (`acp-agent.ts:1749-1773`). For an Obsidian plugin, every vault file op needs to flow through `vault.adapter` (so sync, frontmatter, plugin events fire). Under ACP we'd have to spawn a localhost MCP subprocess that round-trips back into the renderer per call — defeats the point.
|
||||
4. **Many SDK system & content events are silently dropped** (`acp-agent.ts:837-852, 1132-1136, 2738-2747`). Compaction, task progress, memory recall, rate-limit, redacted thinking, `input_json_delta`, citations — all gone. Hosts cannot show "context compacted" UI, live tool-input typing, structured citations, or backoff state.
|
||||
5. **Streaming model collapses too early.** Tool inputs only arrive as completed JSON blocks (`input_json_delta` discarded); thinking surfaces only as plain text (`acp-agent.ts:2576-2585`); subagent runs surface only as flat `tool_call`s tagged with `parent_tool_use_id`.
|
||||
|
||||
The rest of this doc walks each gap with code citations on both sides — so each section can be lifted into a tracked issue. A prioritized list lives in §5. We also call out what ACP got right in §4; the protocol's session-update streaming and `requestPermission` shapes are genuinely good, just under-expressive at the edges.
|
||||
|
||||
All ACP-side citations refer to `@agentclientprotocol/claude-agent-acp` v0.31.4 (`~/Developer/claude-agent-acp/src/`). All host-side citations refer to obsidian-copilot branch `zero/acp-test`.
|
||||
|
||||
---
|
||||
|
||||
## 2. How we use the protocol
|
||||
|
||||
Our host:
|
||||
|
||||
- Is an Obsidian plugin running inside an Electron renderer (no Node CLI shell, no easy access to spawn pipes from the renderer).
|
||||
- Drives **multiple agent backends** behind a single UI: Claude (now via SDK), OpenCode (ACP), Codex (ACP). The multi-backend story is exactly why ACP is valuable to us — and exactly why we're writing this rather than walking away.
|
||||
- Operates on **vault files**, not OS files. Every file mutation must go through `vault.adapter` / `vault.modify` to keep Obsidian's sync, frontmatter parsing, plugin event bus, and encryption-at-rest providers consistent. Direct FS writes are observable but lossy.
|
||||
- Streams to a custom React UI that wants to render: live thinking deltas, live tool-input deltas, plan proposals, multi-choice elicitation modals, per-turn cost, compaction notices, and rate-limit backoff.
|
||||
- Targets desktop and mobile Obsidian. Mobile has no `claude` CLI binary available; we work around this only on desktop today.
|
||||
|
||||
---
|
||||
|
||||
## 3. Limitations by category
|
||||
|
||||
Each subsection follows the same shape:
|
||||
|
||||
> **What ACP / claude-agent-acp does today** — with `path:line` citation.
|
||||
> **What we needed** — concrete UX example.
|
||||
> **Workaround attempted under ACP** — or "no workaround possible."
|
||||
> **What the SDK exposes natively** — with citation.
|
||||
> **Suggested protocol change** — one line the ACP team can act on.
|
||||
|
||||
### 3.1 No `AskUserQuestion` — agent cannot ask multi-choice clarifying questions
|
||||
|
||||
**ACP today.** The wrapper hard-codes `AskUserQuestion` into a disallowed-tools list:
|
||||
|
||||
```ts
|
||||
// claude-agent-acp/src/acp-agent.ts:1812-1813
|
||||
// Disable this for now, not a great way to expose this over ACP at the moment
|
||||
// (in progress work so we can revisit)
|
||||
const disallowedTools = ["AskUserQuestion"];
|
||||
```
|
||||
|
||||
That list is then merged into the SDK options unconditionally (`acp-agent.ts:1862`). There is no `_meta` opt-in, no client capability flag, no way for a host that _does_ know how to render multi-choice prompts to receive them.
|
||||
|
||||
**What we needed.** When a user types "rename the daily note", Claude should be able to ask "Which one — today's, yesterday's, or both?" with three buttons, get back the user's selection, and continue the turn without having to abandon and re-prompt.
|
||||
|
||||
**Workaround under ACP.** None. The tool is gone from Claude's tool list before the prompt even starts. There is no equivalent ACP notification (`session/elicitation`, `session/ask_question`, etc.).
|
||||
|
||||
**SDK native.** The SDK ships `AskUserQuestion` as a built-in tool whose dispatch flows through `canUseTool`. Our `permissionBridge.ts` intercepts:
|
||||
|
||||
```ts
|
||||
// obsidian-copilot/src/agentMode/sdk/permissionBridge.ts
|
||||
if (toolName === "AskUserQuestion") {
|
||||
const answers = await openAskUserQuestionModal(input.questions);
|
||||
return { behavior: "allow", updatedInput: { questions: input.questions, answers } };
|
||||
}
|
||||
```
|
||||
|
||||
The host opens the modal, the user clicks, the answers thread back through `updatedInput`, and the agent continues the same turn. No protocol-level gymnastics required.
|
||||
|
||||
**Suggested protocol change.** Add a `session/elicitation` notification with structured options:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"method": "session/elicitation",
|
||||
"params": {
|
||||
"sessionId": "…",
|
||||
"questions": [
|
||||
{
|
||||
"question": "Which note?",
|
||||
"header": "note-pick",
|
||||
"options": [
|
||||
{ "label": "Today", "description": "2026-05-03.md" },
|
||||
{ "label": "Yesterday", "description": "2026-05-02.md" },
|
||||
],
|
||||
"multiSelect": false,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Client returns answers in the response. Hosts that don't implement it can advertise so via capability and the wrapper falls back to the current "disallowed" behavior.
|
||||
|
||||
---
|
||||
|
||||
### 3.2 Hooks: only `PostToolUse` is exposed
|
||||
|
||||
**ACP today.** The wrapper hard-wires exactly one hook surface and uses it internally:
|
||||
|
||||
```ts
|
||||
// claude-agent-acp/src/acp-agent.ts:1864-1885
|
||||
hooks: {
|
||||
...userProvidedOptions?.hooks,
|
||||
PostToolUse: [
|
||||
...(userProvidedOptions?.hooks?.PostToolUse || []),
|
||||
{
|
||||
hooks: [
|
||||
createPostToolUseHook(this.logger, {
|
||||
onEnterPlanMode: async () => {
|
||||
await this.client.sessionUpdate({
|
||||
sessionId,
|
||||
update: { sessionUpdate: "current_mode_update", currentModeId: "plan" },
|
||||
});
|
||||
await this.updateConfigOption(sessionId, "mode", "plan");
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
```
|
||||
|
||||
User-provided `hooks.PreToolUse`, `hooks.UserPromptSubmit`, `hooks.Stop`, `hooks.SessionStart`, `hooks.SessionEnd`, `hooks.SubagentStart`, `hooks.SubagentStop`, `hooks.PermissionRequest` etc. are silently passed into `userProvidedOptions?.hooks` only if the _caller_ is a Node consumer that already has SDK types — which an ACP client by construction is not. Across the wire, ACP has no hook concept at all.
|
||||
|
||||
The SDK-side hook lifecycle events that _do_ fire (`hook_started`, `hook_progress`, `hook_response`) are then explicitly dropped:
|
||||
|
||||
```ts
|
||||
// claude-agent-acp/src/acp-agent.ts:837-839
|
||||
case "hook_started":
|
||||
case "hook_progress":
|
||||
case "hook_response":
|
||||
```
|
||||
|
||||
(Falls through to the no-op default; cf. lines 837-852.)
|
||||
|
||||
**What we needed.**
|
||||
|
||||
- **`PreToolUse`** to gate destructive vault writes against a per-vault audit policy _before_ the tool runs (so a denial prevents partial state).
|
||||
- **`UserPromptSubmit`** to inject vault-context (current note title, selection, frontmatter) into every prompt without polluting the system prompt.
|
||||
- **`Stop`** to flush a turn-end event for our chat persistence layer.
|
||||
- **`SubagentStart/Stop`** to render a hierarchical run tree in the UI.
|
||||
|
||||
**Workaround under ACP.** Partial. The wrapper's own `PostToolUse` handler emits `current_mode_update` for `EnterPlanMode` and forwards Edit-tool diffs (`tools.ts:771-798`), but this is a private side channel; we cannot intercept it. For `PreToolUse` we'd have to abuse `requestPermission` (which only fires for tools that require approval — read-only tools never hit it).
|
||||
|
||||
**SDK native.** Full hook surface across the SDK's `Hook` lifecycle. We don't yet use all of them in obsidian-copilot, but the migration _unblocks_ them:
|
||||
|
||||
```ts
|
||||
// (planned) src/agentMode/sdk/ClaudeSdkBackendProcess.ts
|
||||
hooks: {
|
||||
PreToolUse: [{ hooks: [auditWriteHook] }],
|
||||
UserPromptSubmit: [{ hooks: [vaultContextInjector] }],
|
||||
Stop: [{ hooks: [persistTurnHook] }],
|
||||
}
|
||||
```
|
||||
|
||||
**Suggested protocol change.** Add a `session/hook_event` notification with `phase: "pre_tool_use" | "post_tool_use" | "user_prompt_submit" | "stop" | "session_start" | "session_end" | "subagent_start" | "subagent_stop" | "permission_request"` and an opaque `payload` blob. Clients that handle it can return a hook _response_ (block / modify / continue) via the response object. Wrappers stay free to map this onto whatever native hook system the underlying agent has.
|
||||
|
||||
---
|
||||
|
||||
### 3.3 No in-process MCP servers
|
||||
|
||||
**ACP today.** ACP's MCP forwarding accepts exactly three transport types:
|
||||
|
||||
```ts
|
||||
// claude-agent-acp/src/acp-agent.ts:1749-1773
|
||||
const mcpServers: Record<string, McpServerConfig> = {};
|
||||
if (Array.isArray(params.mcpServers)) {
|
||||
for (const server of params.mcpServers) {
|
||||
if ("type" in server && (server.type === "http" || server.type === "sse")) {
|
||||
mcpServers[server.name] = {
|
||||
type: server.type,
|
||||
url: server.url,
|
||||
headers: server.headers ? Object.fromEntries(...) : undefined,
|
||||
};
|
||||
} else {
|
||||
// Stdio type (with or without explicit type field)
|
||||
mcpServers[server.name] = {
|
||||
type: "stdio",
|
||||
command: server.command,
|
||||
args: server.args,
|
||||
env: server.env ? Object.fromEntries(...) : undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
There is no fourth case for "host implements these tools in-process and the wrapper should call back via JSON-RPC."
|
||||
|
||||
**What we needed.** Vault file ops (`vault_read`, `vault_write`, `vault_edit`, `vault_list`, `vault_glob`, `vault_grep`) MUST flow through `app.vault.adapter` and `app.vault.modify/create`. Anything else — direct FS access from a spawned subprocess, for instance — bypasses Obsidian's:
|
||||
|
||||
- file-modified event bus (other plugins watching for changes break)
|
||||
- frontmatter parser / metadata cache
|
||||
- third-party sync drivers (Obsidian Sync, iCloud, Self-hosted LiveSync)
|
||||
- mobile compatibility (mobile has no spawnable processes at all)
|
||||
- vault encryption-at-rest plugins
|
||||
|
||||
**Workaround under ACP.** We considered shipping a localhost stdio MCP subprocess that round-trips every read/write back into the renderer over WebSocket or named pipe. This (a) cannot work on mobile, (b) requires shipping platform-specific binaries inside an Obsidian plugin, which is not a supported distribution shape, and (c) adds a per-tool-call latency floor for what should be a memory-speed operation.
|
||||
|
||||
**SDK native.** The SDK accepts an `sdk`-typed MCP server whose tools are JS callbacks the agent invokes in-process:
|
||||
|
||||
```ts
|
||||
// obsidian-copilot/src/agentMode/sdk/vaultMcpServer.ts
|
||||
const server = createSdkMcpServer({
|
||||
name: "obsidian-vault",
|
||||
tools: [
|
||||
tool("vault_read", "Read a vault file", { path: z.string() }, async ({ path }) => {
|
||||
return { content: [{ type: "text", text: await app.vault.adapter.read(path) }] };
|
||||
}),
|
||||
// … vault_write, vault_edit, vault_list, vault_glob, vault_grep
|
||||
],
|
||||
});
|
||||
|
||||
// in query options:
|
||||
mcpServers: { "obsidian-vault": { type: "sdk", instance: server } }
|
||||
```
|
||||
|
||||
The agent sees the tools as normal MCP tools; the host runs them with full vault-API access. No subprocess, no IPC, works on mobile.
|
||||
|
||||
**Suggested protocol change.** Add an `mcp/sdk` (or `mcp/host`) transport. The wrapper-side adapter would expose the tools as if they were stdio MCP, but route every `tools/call` back to the ACP client as a `session/host_tool_call` notification, awaiting a response. Implementation cost on both sides is moderate; the payoff is hosts can finally implement their own first-class tools without process management.
|
||||
|
||||
---
|
||||
|
||||
### 3.4 Silent message drops
|
||||
|
||||
**ACP today.** Two large switch statements in `acp-agent.ts` and `tools.ts` enumerate SDK events the wrapper does not translate. Falling through means the host literally never sees them.
|
||||
|
||||
System messages (`acp-agent.ts:837-852`):
|
||||
|
||||
```ts
|
||||
case "hook_started":
|
||||
case "hook_progress":
|
||||
case "hook_response":
|
||||
case "files_persisted":
|
||||
case "task_started":
|
||||
case "task_notification":
|
||||
case "task_progress":
|
||||
case "task_updated":
|
||||
case "elicitation_complete":
|
||||
case "plugin_install":
|
||||
case "memory_recall":
|
||||
case "notification":
|
||||
case "api_retry":
|
||||
case "mirror_error":
|
||||
```
|
||||
|
||||
Tool-progress / auth / billing (`acp-agent.ts:1132-1136`):
|
||||
|
||||
```ts
|
||||
case "tool_progress":
|
||||
case "tool_use_summary":
|
||||
case "auth_status":
|
||||
case "prompt_suggestion":
|
||||
case "rate_limit_event":
|
||||
```
|
||||
|
||||
Content block types (`acp-agent.ts:2738-2747`):
|
||||
|
||||
```ts
|
||||
case "document":
|
||||
case "search_result":
|
||||
case "redacted_thinking":
|
||||
case "input_json_delta":
|
||||
case "citations_delta":
|
||||
case "signature_delta":
|
||||
case "container_upload":
|
||||
case "compaction":
|
||||
case "compaction_delta":
|
||||
case "advisor_tool_result":
|
||||
break;
|
||||
```
|
||||
|
||||
**What we needed (host-by-host UX impact).**
|
||||
|
||||
| Dropped event | UX we wanted but cannot ship |
|
||||
| -------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `compaction`, `compaction_delta` | Show "Context window compacted (X→Y tokens)" notice in chat history so users understand why earlier messages stop influencing the model |
|
||||
| `task_started`, `task_progress`, `task_updated`, `task_notification` | Render running subagents with live progress; show their tools/output without polling |
|
||||
| `memory_recall` | Surface "Agent recalled this from session 2026-04-12" as inline citation |
|
||||
| `redacted_thinking` | Show a redaction marker (current behavior: thinking just silently disappears) |
|
||||
| `input_json_delta` | Live "Bash command being typed" in tool-call UI (see §3.6) |
|
||||
| `citations_delta` | Structured citations from `WebSearch` / `WebFetch` results — currently lost and re-derived from text |
|
||||
| `signature_delta` | Cryptographic signing of thinking blocks (used for cache-eligibility) — required for cache semantics in extended-thinking turns |
|
||||
| `rate_limit_event`, `api_retry` | Show "Rate-limited, retrying in 12s" UI; today the user just sees an unexplained stall |
|
||||
| `auth_status` | React to mid-turn auth-token refresh failures |
|
||||
| `tool_progress` | Long-running tool progress bars (Bash, WebFetch with large responses) |
|
||||
|
||||
**Workaround under ACP.** None. The events do not cross the wire.
|
||||
|
||||
**SDK native.** All the above events are first-class on `SDKMessage` / `stream_event`. The host can subscribe selectively.
|
||||
|
||||
**Suggested protocol change.** Two-part fix:
|
||||
|
||||
1. **Add explicit variants** for the high-value ones (`session/compaction`, `session/task_progress`, `session/rate_limit`, `session/citation`).
|
||||
2. **Add a passthrough envelope** `session/raw_event` with `{ source: "claude_code" | "opencode" | …, eventType: string, payload: unknown }` for everything else, behind a client capability flag. Hosts that opt in get to render whatever they want; hosts that don't get current behavior.
|
||||
|
||||
---
|
||||
|
||||
### 3.5 Information flattening on tool calls
|
||||
|
||||
**ACP today.** The wrapper recognizes 8 named tools (Agent/Task, Bash, Read, Write, Edit, Glob, Grep, WebFetch, WebSearch — `tools.ts:121-411`) and collapses everything else into `kind: "other"` with the input JSON-stringified.
|
||||
|
||||
```ts
|
||||
// claude-agent-acp/src/tools.ts:121-145 (excerpt)
|
||||
export function toolInfoFromToolUse(
|
||||
toolUse: any,
|
||||
supportsTerminalOutput = false,
|
||||
cwd?: string
|
||||
): ToolInfo {
|
||||
const name = toolUse.name;
|
||||
switch (name) {
|
||||
case "Agent":
|
||||
case "Task": {
|
||||
/* kind: "think" */
|
||||
}
|
||||
case "Bash": {
|
||||
/* kind: "execute" */
|
||||
}
|
||||
case "Read": {
|
||||
/* kind: "read" */
|
||||
}
|
||||
case "Write":
|
||||
case "Edit": {
|
||||
/* kind: "edit" */
|
||||
}
|
||||
case "Glob":
|
||||
case "Grep": {
|
||||
/* kind: "search" */
|
||||
}
|
||||
case "WebFetch":
|
||||
case "WebSearch": {
|
||||
/* kind: "fetch" */
|
||||
}
|
||||
// … TodoWrite (special-cased to plan update), ExitPlanMode (switch_mode)
|
||||
default:
|
||||
return {
|
||||
title: name,
|
||||
kind: "other",
|
||||
content: [
|
||||
{
|
||||
type: "content",
|
||||
content: { type: "text", text: JSON.stringify(toolUse.input, null, 2) },
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`Edit` and `Write` results are translated to **empty** updates, with the actual diff smuggled through the `PostToolUse` hook side channel (`tools.ts:413-552`):
|
||||
|
||||
```ts
|
||||
case "Edit":
|
||||
case "Write":
|
||||
// diffs are sent via PostToolUse hook only
|
||||
return {};
|
||||
```
|
||||
|
||||
Image content in tool results is downconverted to a text stub (`tools.ts:632`):
|
||||
|
||||
```ts
|
||||
return [{ type: "content", content: { type: "text", text: `Fetched: ${block.url ?? ""}` } }];
|
||||
```
|
||||
|
||||
**What we needed.** Any user-defined MCP tool — say a `notion_search` tool from a Notion MCP server — should render with a meaningful icon and title, not "Other: { query: 'foo' }". And any tool whose result is an image (a chart-rendering MCP tool, an OCR tool) should keep the image.
|
||||
|
||||
**Workaround under ACP.** None for non-builtin tools. For the image case, only inline base64 images survive the round-trip; URL- and file-based images become text.
|
||||
|
||||
**SDK native.** Tool results carry full `content` arrays through unchanged. MCP tools advertise their own metadata (description, inputSchema, annotations) at registration; hosts that consume the SDK directly can read the registry.
|
||||
|
||||
**Suggested protocol change.** Add a `tool_call.metadata` field populated from MCP tool descriptors at registration time:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"sessionUpdate": "tool_call",
|
||||
"toolCallId": "…",
|
||||
"title": "notion_search",
|
||||
"kind": "search",
|
||||
"metadata": {
|
||||
"mcpServer": "notion",
|
||||
"description": "Search Notion pages",
|
||||
"icon": "notion",
|
||||
},
|
||||
"rawInput": { "query": "foo" },
|
||||
}
|
||||
```
|
||||
|
||||
Plus: stop dropping image content blocks unconditionally — pass them through with their original `source` shape (URL / file / base64) and let the host decide.
|
||||
|
||||
---
|
||||
|
||||
### 3.6 Streaming model: no partial tool inputs, no thinking deltas
|
||||
|
||||
**ACP today.** Two specific deltas are silently dropped on the content-block level (`acp-agent.ts:2738-2747`, repeated for emphasis):
|
||||
|
||||
- `input_json_delta` — the SDK streams tool-call arguments incrementally. ACP discards.
|
||||
- `signature_delta` — used to sign thinking blocks for cache eligibility. ACP discards.
|
||||
|
||||
Thinking blocks themselves _do_ survive but only as plain text (`acp-agent.ts:2576-2585`):
|
||||
|
||||
```ts
|
||||
// excerpt — every thinking block becomes a flat agent_thought_chunk
|
||||
case "thinking":
|
||||
case "thinking_delta":
|
||||
update = { sessionUpdate: "agent_thought_chunk", content: { type: "text", text: block.text ?? block.delta } };
|
||||
```
|
||||
|
||||
Structured budget, signature, encrypted-thinking metadata — all lost.
|
||||
|
||||
**What we needed.**
|
||||
|
||||
- Live tool-input UI: as Claude types out a Bash command, show it filling in token-by-token. Not just "spinner → completed JSON dump."
|
||||
- Cacheable thinking turns: keep the signature so the next turn can re-use the cache.
|
||||
- Thinking metadata UI: render budget consumed / remaining as a progress bar.
|
||||
|
||||
**Workaround under ACP.** The wrapper _does_ call `toAcpNotifications` recursively on a streaming `content_block_start` (`acp-agent.ts:2784-2790`), which means the _first_ time a `tool_use` block opens we get a `tool_call` notification — but its `input` field is the empty object the SDK started with, since `input_json_delta` updates between `start` and `stop` are not propagated. We can't reconstruct progressive input client-side.
|
||||
|
||||
**SDK native.** Full `stream_event` access including `input_json_delta`, `signature_delta`, `text_delta`, `thinking_delta`. Our translator now reassembles per content-block index:
|
||||
|
||||
```ts
|
||||
// obsidian-copilot/src/agentMode/sdk/sdkMessageTranslator.ts
|
||||
case "input_json_delta": {
|
||||
const block = state.toolUseBlocks.get(event.index);
|
||||
if (!block) break;
|
||||
block.partial += event.delta.partial_json;
|
||||
try {
|
||||
block.parsed = parsePartialJson(block.partial);
|
||||
} catch { /* still incomplete */ }
|
||||
emit({ sessionUpdate: "tool_call_update", toolCallId: block.id, rawInput: block.parsed });
|
||||
}
|
||||
```
|
||||
|
||||
The UI shows the Bash command typing itself in.
|
||||
|
||||
**Suggested protocol change.** Add two notification variants:
|
||||
|
||||
- `tool_call_input_chunk { toolCallId, partialInputJson }` — fired as each `input_json_delta` arrives. Hosts can either ignore (current behavior) or use it to drive live UI.
|
||||
- `agent_thought_chunk` already exists; extend with optional `signature?: string`, `budgetTokensUsed?: number`, `budgetTokensRemaining?: number` fields.
|
||||
|
||||
---
|
||||
|
||||
### 3.7 No fine-grained permission control
|
||||
|
||||
**ACP today.** The wrapper exposes exactly three session config options (`acp-agent.ts:2256-2339`): `mode`, `model`, `effort`. The `mode` enum (`acp-agent.ts:1319-1324`) covers `auto | default | acceptEdits | bypassPermissions | dontAsk | plan`. There is no separate axis for `permissionMode` — the two are conflated.
|
||||
|
||||
`requestPermission` outcomes are limited to:
|
||||
|
||||
- `allow_once`
|
||||
- `allow_always` with a label string ("all Bash", "Bash(npm test:\*)")
|
||||
- `reject_once`
|
||||
|
||||
**What we needed.**
|
||||
|
||||
- The SDK's `canUseTool` callback can return `updatedPermissions: PermissionUpdate[]` along with the decision — a structured array of allow/deny rules to install for the rest of the session. Far richer than a label string.
|
||||
- Independent `permissionMode` (gates whether tools require approval) and "agent mode" (e.g. `plan` is conceptually different from `acceptEdits`).
|
||||
|
||||
**Workaround under ACP.** We parse the `allow_always` label heuristically to derive "always allow Bash with prefix `npm test`" rules. Brittle and lossy — the label is human text, not a structured rule.
|
||||
|
||||
**SDK native.**
|
||||
|
||||
```ts
|
||||
// SDK type
|
||||
type PermissionResult =
|
||||
| { behavior: "allow"; updatedInput?: unknown; updatedPermissions?: PermissionUpdate[] }
|
||||
| { behavior: "deny"; reason: string };
|
||||
|
||||
type PermissionUpdate =
|
||||
| { type: "addRules"; rules: PermissionRule[]; behavior: "allow" | "deny" | "ask"; destination: "session" | "project" | "user" }
|
||||
| { type: "removeRules"; rules: PermissionRule[]; … }
|
||||
| { type: "setMode"; mode: PermissionMode; … };
|
||||
```
|
||||
|
||||
**Suggested protocol change.** Extend the `session/request_permission` response schema with an optional `updatedPermissions: PermissionUpdate[]` array using a structured rule shape rather than a free-form label.
|
||||
|
||||
---
|
||||
|
||||
### 3.8 Session control plane is RPC-only, not live
|
||||
|
||||
**ACP today.** Switching model or mode mid-session is a separate RPC roundtrip:
|
||||
|
||||
- `session/set_session_model`
|
||||
- `session/set_session_config_option` (for `mode` / `effort`)
|
||||
|
||||
Plus capability probing — the wrapper detects whether the agent supports them at all (the obsidian-copilot side handles the probe in `src/agentMode/acp/AcpBackendProcess.ts:96-101`).
|
||||
|
||||
**What we needed.** A user is mid-turn on Sonnet, sees Claude is going to need a long thinking pass, and wants to switch to Opus _without_ aborting and re-prompting (which would discard the partial output and lose the cache).
|
||||
|
||||
**Workaround under ACP.** Abort the turn (`session/cancel`), wait for the cancel to settle, send `session/set_session_model`, re-prompt with the original user input. User sees their progress disappear and a fresh latency cost.
|
||||
|
||||
**SDK native.** The SDK's `query()` exposes async `setModel(modelId)` and `setPermissionMode(mode)` methods callable mid-stream. Internally these flow through the same JSON-RPC framing, but they're applied to the _live_ generator without interrupting it. Our adapter forces streaming-input mode (`ClaudeSdkBackendProcess.ts:218`) to enable this.
|
||||
|
||||
```ts
|
||||
// obsidian-copilot/src/agentMode/sdk/ClaudeSdkBackendProcess.ts:330-360
|
||||
async setModel(model: string) {
|
||||
await this.activeQuery?.setModel(model);
|
||||
}
|
||||
async setPermissionMode(mode: PermissionMode) {
|
||||
await this.activeQuery?.setPermissionMode(mode);
|
||||
}
|
||||
```
|
||||
|
||||
**Suggested protocol change.** Define the existing `session/set_session_model` and `session/set_session_config_option` RPCs as **valid mid-turn** (not just between turns), and require wrappers to apply them to the in-flight generator without interrupting. Document the semantics: the new value applies starting from the _next_ assistant message or tool call within the current turn.
|
||||
|
||||
---
|
||||
|
||||
### 3.9 No subagent control surface
|
||||
|
||||
**ACP today.** The Agent / Task tool is mapped onto `kind: "think"` (`tools.ts:129-144`):
|
||||
|
||||
```ts
|
||||
case "Agent":
|
||||
case "Task": {
|
||||
const input = toolUse.input as AgentInput | BashInput | undefined;
|
||||
return { title: input?.description ?? "Task", kind: "think", … };
|
||||
}
|
||||
```
|
||||
|
||||
Subagent runs surface as nested `tool_call` updates tagged with `_meta.claudeCode.parentToolUseId` (`acp-agent.ts:2755-2762`). There is no first-class "subagent started / progressed / finished / errored" event family.
|
||||
|
||||
**What we needed.**
|
||||
|
||||
- Render running subagents with their model, allowed tools, and cumulative token use.
|
||||
- Allow the user to interrupt a specific subagent without aborting the whole turn.
|
||||
- Show a hierarchical run tree (parent agent → subagent → sub-subagent) instead of a flat list of `tool_call`s.
|
||||
|
||||
**Workaround under ACP.** Reconstruct the tree client-side from `parent_tool_use_id`. Possible but fragile, and doesn't unlock interruption or per-subagent status.
|
||||
|
||||
**SDK native.** The SDK exposes `agents: { name: AgentDefinition }` config, an `Agent` tool, and `task_*` events (`task_started`, `task_progress`, `task_notification`, `task_updated`) — all of which ACP drops (§3.4).
|
||||
|
||||
**Suggested protocol change.** Add a `session/subagent_*` notification family:
|
||||
|
||||
- `session/subagent_started { taskId, parentTaskId?, name, model, tools }`
|
||||
- `session/subagent_progress { taskId, message? }`
|
||||
- `session/subagent_finished { taskId, status: "ok" | "error" | "cancelled", usage? }`
|
||||
|
||||
Plus a `session/cancel_subagent { taskId }` RPC.
|
||||
|
||||
---
|
||||
|
||||
### 3.10 System prompt locked to preset
|
||||
|
||||
**ACP today.** `acp-agent.ts:1775-1791` constructs the system prompt; the default and the "custom" path both keep `type: "preset"`:
|
||||
|
||||
```ts
|
||||
let systemPrompt: Options["systemPrompt"] = { type: "preset", preset: "claude_code" };
|
||||
if (params._meta?.systemPrompt) {
|
||||
const customPrompt = params._meta.systemPrompt;
|
||||
if (typeof customPrompt === "string") {
|
||||
systemPrompt = customPrompt; // raw string supported via _meta
|
||||
} else {
|
||||
systemPrompt = {
|
||||
type: "preset",
|
||||
preset: "claude_code",
|
||||
append: customPrompt.append,
|
||||
// …
|
||||
} as Options["systemPrompt"];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
So a host _can_ fully override by passing a raw string via `_meta.claudeCode.systemPrompt`, but the only documented protocol-level way to customize is append-style on top of the locked `claude_code` preset.
|
||||
|
||||
**What we needed.** Insert vault-specific scaffolding ("paths are vault-relative; never use absolute FS paths; the user's current note is X; selection is Y") _without_ the Claude Code preset clobbering it. The Claude Code preset assumes a CLI environment, references `.claude/` files, and instructs about behaviors that don't apply inside Obsidian.
|
||||
|
||||
**Workaround under ACP.** Use `_meta.claudeCode.systemPrompt` as a string. Works, but is an undocumented escape hatch and reaches outside the protocol contract — not portable to non-Claude ACP backends.
|
||||
|
||||
**SDK native.** `systemPrompt: string` is fully supported as a first-class field on `Options`.
|
||||
|
||||
**Suggested protocol change.** Promote `systemPrompt` to a documented protocol-level field on `session/new` with two shapes:
|
||||
|
||||
```ts
|
||||
type SystemPrompt =
|
||||
| { type: "preset"; preset: string; append?: string; excludeDynamicSections?: string[] }
|
||||
| { type: "custom"; text: string };
|
||||
```
|
||||
|
||||
Wrappers translate to whatever the underlying agent supports; agents that only have presets can refuse `type: "custom"` with a clear error.
|
||||
|
||||
---
|
||||
|
||||
### 3.11 Slash command / skill allowlist filtering
|
||||
|
||||
**ACP today.** The wrapper hard-codes a list of slash commands that are stripped before being advertised to the client (`acp-agent.ts:2379-2410`):
|
||||
|
||||
```ts
|
||||
const UNSUPPORTED_COMMANDS = [
|
||||
"cost",
|
||||
"keybindings-help",
|
||||
"login",
|
||||
"logout",
|
||||
"output-style:new",
|
||||
"release-notes",
|
||||
"todos",
|
||||
];
|
||||
```
|
||||
|
||||
Plus MCP commands get renamed: `/mcp:server:cmd` → `/server:cmd (MCP)`.
|
||||
|
||||
**What we needed.** Some of these we'd actually like to surface — `/cost` could populate our settings panel, `/release-notes` could show a changelog. We have a different login flow, so `/login` and `/logout` can stay filtered, but that should be a host _choice_, not a wrapper _fact_.
|
||||
|
||||
**Workaround under ACP.** None. The wrapper strips them before they reach us.
|
||||
|
||||
**SDK native.** `query.supportedCommands()` returns the full list with metadata; the host filters as it sees fit.
|
||||
|
||||
**Suggested protocol change.** Pass through all commands by default. Let the host opt out via capability:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"clientCapabilities": {
|
||||
"slashCommands": { "exclude": ["login", "logout"] },
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.12 Auth and CLI binary discovery (wrapper, not protocol)
|
||||
|
||||
These aren't ACP-protocol issues per se, but anyone consuming `claude-agent-acp` from a non-Node-CLI environment hits them:
|
||||
|
||||
- **Binary discovery.** The wrapper calls `claudeCliPath()` (`acp-agent.ts:1857`) which assumes a normal `PATH` environment. Inside Obsidian / Electron renderer, `PATH` doesn't include user shell paths; we had to ship a `claudeBinaryResolver` (in `obsidian-copilot/src/agentMode/sdk/`) that probes Volta, asdf, NVM, Homebrew, npm-global, and Windows-specific locations.
|
||||
- **Electron renderer crashes.** Both the SDK's process-transport-close and the MCP SDK's stdio-close-wait call `.unref()` on a `setTimeout` handle. In the Electron renderer this is unsafe and crashes during teardown. We patch this at bundle time with `scripts/patchRendererUnsafeUnref.js`. The wrapper inherits this directly from the SDK — same patch would be needed for any host that bundles `claude-agent-acp` into a renderer.
|
||||
- **Mobile.** No spawnable binaries. The wrapper has no escape hatch for "no CLI available, fall back to remote API."
|
||||
|
||||
**Suggested protocol change / wrapper change.**
|
||||
|
||||
1. Document the binary-discovery contract (wrapper reads `CLAUDE_CODE_EXECUTABLE`; if unset, calls `claudeCliPath()`; recommended host behavior is to set the env var explicitly).
|
||||
2. Ship a renderer-safe build of the wrapper (or guard the unsafe `.unref()` sites internally).
|
||||
3. (Long-term) Add a `mode: "remote" | "local"` option that routes through the Anthropic API directly when no local binary is available — useful for mobile and serverless hosts.
|
||||
|
||||
---
|
||||
|
||||
## 4. What ACP got right
|
||||
|
||||
It's worth stating clearly: the protocol's bones are good, and the gaps above are fixable without breaking the abstraction.
|
||||
|
||||
- **Session-update streaming** (`SessionNotification` + `sessionUpdate` discriminator) is the right shape for tail-of-turn events. Adding new variants is non-breaking.
|
||||
- **`requestPermission`** has the right semantics — synchronous client decision blocking the agent. The under-expressiveness (§3.7) is an extension point, not a redesign.
|
||||
- **Multi-backend uniformity** is the actual reason we wrote this letter rather than walking away. Our OpenCode and Codex backends still go through ACP, and the win of "one UI, one chat-history layer, one permission modal across three different agents" is large enough that we tolerated each gap individually for a long time. We're not asking for ACP to absorb every Claude-specific feature — we're asking for the protocol to grow generic versions of the categories the SDK has shown to be valuable.
|
||||
|
||||
---
|
||||
|
||||
## 5. Prioritized request list
|
||||
|
||||
| Priority | Gap | Section |
|
||||
| ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||
| **P0** | `AskUserQuestion` / structured elicitation | §3.1 |
|
||||
| **P0** | Hook lifecycle events (`PreToolUse`, `UserPromptSubmit`, `Stop`, `Subagent*`, `SessionStart/End`) | §3.2 |
|
||||
| **P0** | In-process MCP transport (`mcp/sdk` or `mcp/host`) | §3.3 |
|
||||
| **P1** | Pass through dropped events: `compaction`, `task_*`, `memory_recall`, `rate_limit_event`, `redacted_thinking`, `citations_delta`, `signature_delta` | §3.4 |
|
||||
| **P1** | Streaming tool input deltas (`tool_call_input_chunk`) | §3.6 |
|
||||
| **P1** | Structured `updatedPermissions` array on `requestPermission` response | §3.7 |
|
||||
| **P2** | Documented custom system prompt (`type: "custom"`) | §3.10 |
|
||||
| **P2** | First-class subagent notifications | §3.9 |
|
||||
| **P2** | Mid-turn model / mode / config switches applied to live generator | §3.8 |
|
||||
| **P3** | Slash command passthrough by default | §3.11 |
|
||||
| **P3** | `tool_call.metadata` for non-builtin tools + image content preservation | §3.5 |
|
||||
| **Wrapper** | Renderer-safe build, documented binary discovery, mobile/remote escape hatch | §3.12 |
|
||||
|
||||
---
|
||||
|
||||
## 6. Appendix
|
||||
|
||||
### 6.1 Citation index (ACP wrapper)
|
||||
|
||||
- `acp-agent.ts:837-852` — system message switch dropping `hook_*`, `task_*`, `files_persisted`, `elicitation_complete`, `plugin_install`, `memory_recall`, `notification`, `api_retry`, `mirror_error`
|
||||
- `acp-agent.ts:1132-1136` — `tool_progress`, `tool_use_summary`, `auth_status`, `prompt_suggestion`, `rate_limit_event` dropped
|
||||
- `acp-agent.ts:1319-1324` — `mode` enum: `auto`, `default`, `acceptEdits`, `bypassPermissions`, `dontAsk`, `plan`
|
||||
- `acp-agent.ts:1749-1773` — MCP transport handling (stdio / http / sse only)
|
||||
- `acp-agent.ts:1775-1791` — system prompt construction (locked to preset over the wire)
|
||||
- `acp-agent.ts:1812-1813` — `disallowedTools = ["AskUserQuestion"]`
|
||||
- `acp-agent.ts:1864-1885` — hook config (PostToolUse only; internal `onEnterPlanMode`)
|
||||
- `acp-agent.ts:2256-2339` — exposed config options (mode, model, effort)
|
||||
- `acp-agent.ts:2379-2410` — `UNSUPPORTED_COMMANDS` slash-command filter
|
||||
- `acp-agent.ts:2576-2585` — thinking blocks → flat `agent_thought_chunk`
|
||||
- `acp-agent.ts:2738-2747` — content block types dropped
|
||||
- `acp-agent.ts:2755-2762` — subagent surface via `_meta.claudeCode.parentToolUseId`
|
||||
- `tools.ts:121-411` — 8-tool kind allowlist
|
||||
- `tools.ts:413-552` — Edit/Write tool result transforms (returns `{}`)
|
||||
- `tools.ts:632` — image content downconverted to `"Fetched: <url>"` text stub
|
||||
- `tools.ts:771-798` — internal PostToolUse hook for plan-mode and Edit-diff propagation
|
||||
|
||||
### 6.2 Citation index (host adapter that demonstrates the SDK surface)
|
||||
|
||||
- `obsidian-copilot/src/agentMode/sdk/ClaudeSdkBackendProcess.ts` — query lifecycle, mid-turn `setModel`/`setPermissionMode`, MCP server registration
|
||||
- `obsidian-copilot/src/agentMode/sdk/sdkMessageTranslator.ts` — `input_json_delta` reassembly, thinking-delta handling, `EnterPlanMode` detection
|
||||
- `obsidian-copilot/src/agentMode/sdk/vaultMcpServer.ts` — in-process MCP server using `vault.adapter`
|
||||
- `obsidian-copilot/src/agentMode/sdk/permissionBridge.ts` — `canUseTool` integration, AskUserQuestion modal hookup
|
||||
- `obsidian-copilot/src/agentMode/acp/AcpBackendProcess.ts` — the ACP integration we kept for OpenCode and Codex
|
||||
|
||||
### 6.3 Versions tested
|
||||
|
||||
- `@agentclientprotocol/claude-agent-acp` v0.31.4
|
||||
- `@anthropic-ai/claude-agent-sdk` v0.2.123 (the version `claude-agent-acp` itself depends on)
|
||||
- `@agentclientprotocol/sdk` v0.21.0
|
||||
|
||||
### 6.4 Cross-reference
|
||||
|
||||
Internal migration roadmap: [`designdocs/todo/CLAUDE_AGENT_SDK_MIGRATION.md`](./todo/CLAUDE_AGENT_SDK_MIGRATION.md). That document covers our implementation plan, milestones, and Electron-renderer spike findings — this document is its outward-facing protocol-feedback companion.
|
||||
|
||||
---
|
||||
|
||||
_We're happy to chat through any of these in more detail, contribute fixes upstream, or test pre-release wrapper changes against our codebase. The fastest channel for follow-ups is the obsidian-copilot GitHub issues; tag them `acp-feedback`._
|
||||
274
designdocs/AGENT_PROCESSING_UI.md
Normal file
274
designdocs/AGENT_PROCESSING_UI.md
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
# Agent Processing UI — Design Doc
|
||||
|
||||
## Context
|
||||
|
||||
Agent mode streams a mix of events while a turn is in flight: tool calls, tool results, model reasoning tokens, sub-agent invocations, plan proposals, and prose deltas. The UI has to make this trail **scannable, verifiable, and unobtrusive** for knowledge workers — without devolving into a debug log.
|
||||
|
||||
Today the chat surface renders a flat list of tool-call cards, prose, and a (legacy) reasoning block. It works for simple turns but breaks down on three patterns we now hit regularly:
|
||||
|
||||
1. **Burst tool calls** — the agent fires 5 consecutive `Edit`s and floods the message with near-identical cards.
|
||||
2. **Sub-agents** — Claude Code's Task tool spawns a child agent whose tool calls and reasoning currently render as flat top-level entries, indistinguishable from the parent's work.
|
||||
3. **Reasoning streams** — agent-mode `thought` parts exist in the data model but aren't hooked to the existing `AgentReasoningBlock` UI, so reasoning either disappears or renders as a static `<details>` block.
|
||||
|
||||
This doc proposes a unified component model that handles all three, and the visual rules that make it readable.
|
||||
|
||||
## Design principles
|
||||
|
||||
1. **What was consulted matters more than how the model thought.** Tool calls are the primary signal. Reasoning is secondary, collapsed by default after streaming.
|
||||
2. **One visual rhythm.** Every action — tool, sub-agent, reasoning — follows the same `icon · verb · target · outcome` pattern, so the eye learns to scan once.
|
||||
3. **Progressive disclosure.** Default = one dense line. Expand for details. Re-expand for raw payloads. Never dump full tool results inline.
|
||||
4. **Verifiable.** Every claim in the prose answer must be traceable to a card; every card must show _which note / URL / line range_ it touched.
|
||||
5. **Compact > complete.** A 5-action turn = 5 lines, not 5 paragraphs. Prefer aggregation (one `Edited 5 notes` card) over enumeration when actions are homogeneous.
|
||||
6. **Don't invent abstractions over the data model.** The backend already emits tool calls, reasoning, and parent-child links. The UI's job is to render them well, not to introduce a separate "Chain of Thought" or "Task" layer on top.
|
||||
|
||||
## Component taxonomy
|
||||
|
||||
We collapse the four Vercel SDK concepts (Reasoning, Tool, Task, Chain of Thought) into **two render primitives**:
|
||||
|
||||
| Primitive | Renders | Source |
|
||||
| ------------------- | ----------------------------------------------------------------------------------- | --------------------------------------------------------------- |
|
||||
| **Action card** | A tool call, an aggregate of consecutive same-tool calls, OR a sub-agent invocation | `AgentMessagePart.tool_call` (with optional `parentToolCallId`) |
|
||||
| **Reasoning block** | The model's internal thinking tokens | `AgentMessagePart.thought` |
|
||||
|
||||
Sub-agents reuse the action-card primitive — they're just an action whose "result" is a nested trail of more action cards. **No separate `Task` or `Chain of Thought` component.**
|
||||
|
||||
## Action card
|
||||
|
||||
### Anatomy (collapsed)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ 🔍 Searched web · "jazz piano voicings" ✓ ⌄ │
|
||||
│ 5 results · 1.2s │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **Icon** — tool family (search, read, edit, fetch, vault, agent, time, …)
|
||||
- **Verb · target** — past-tense action + the thing acted on
|
||||
- **Outcome line** — small, muted: count + duration
|
||||
- **Status** — `⟳` running, `✓` done, `⚠` failed, `∅` empty result
|
||||
- **Affordance** — `⌄` to expand
|
||||
|
||||
### Tool-aware summaries
|
||||
|
||||
The collapsed line speaks the user's language per tool. Same shape, tool-specific outcome:
|
||||
|
||||
```
|
||||
📄 Read music-theory.md · lines 10–48 ✓
|
||||
🗂️ Searched vault · "scales" · 12 matches ✓
|
||||
🔗 Fetched pianogroove.com/voicings ✓
|
||||
✏️ Edited practice-log.md · +12 / −0 lines ✓
|
||||
🧠 Indexed 47 notes · jazz folder ✓
|
||||
🕒 Time "what day is today" ✓
|
||||
```
|
||||
|
||||
### Expanded view
|
||||
|
||||
Shows **inputs** (so the user can verify the agent searched what they'd expect) and **outputs** in cited form (clickable note paths and URLs). Full result body is _not_ dumped — there's a second-level expand for the raw payload when needed.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ 🔍 Searched web · "jazz piano voicings" ✓ ⌃ │
|
||||
│ 5 results · 1.2s │
|
||||
│ ───────────────────────────────────────────────── │
|
||||
│ Query: jazz piano voicings │
|
||||
│ │
|
||||
│ Results: │
|
||||
│ 1. Jazz Piano Voicings — pianogroove.com ↗ │
|
||||
│ 2. Modern Jazz Voicings — open-studio.com ↗ │
|
||||
│ 3. Rootless Voicings Guide — jazzwise.com ↗ │
|
||||
│ 4. ... │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### States
|
||||
|
||||
```
|
||||
⟳ Searching web · "jazz piano voicings"… (running, animated)
|
||||
✓ Searched web · "jazz piano voicings" · 5 results (done)
|
||||
⚠ Search failed · network error [retry] (error)
|
||||
∅ Read note · daily/2024-03-15.md · empty (degenerate ok)
|
||||
```
|
||||
|
||||
Empty results render explicitly — knowledge workers care about negative findings ("no, you don't have notes on this").
|
||||
|
||||
## Compaction (consecutive tool calls)
|
||||
|
||||
### Rule
|
||||
|
||||
At render time, fold a run of N ≥ 2 cards into a single aggregate when:
|
||||
|
||||
- **Same tool type** (all `Edit`, all `Read`, all `WebSearch`)
|
||||
- **Strictly consecutive in the part stream** — no intervening tool of a different type, no streamed prose, no `thought` part between them
|
||||
- **Same nesting level** — never compact across a sub-agent boundary
|
||||
|
||||
No fancier heuristics (no "detect read-edit cycles", no topic clustering). The backend may also pass an explicit `group_id`/`batch_id` in `_meta` to force-group; the UI honors it but doesn't depend on it.
|
||||
|
||||
### Aggregate card
|
||||
|
||||
The collapsed view shows a tool-aware aggregate stat, _not_ the first item:
|
||||
|
||||
```
|
||||
✏️ Edited 5 notes · +47 / −12 lines ✓ ⌄
|
||||
📄 Read 3 notes · 4.5k tokens ✓ ⌄
|
||||
🔍 Searched web · 3 queries · 17 unique results ✓ ⌄
|
||||
🗂️ Searched vault · 4 queries · 28 matches ✓ ⌄
|
||||
```
|
||||
|
||||
Mixed status surfaces in the line: `✏️ Edited 5 notes · 4 ✓ · 1 ⚠`.
|
||||
|
||||
### Expanded aggregate
|
||||
|
||||
Per-item rows with their own micro-summary; each row is itself expandable to show the underlying card detail:
|
||||
|
||||
```
|
||||
✏️ Edited 5 notes · +47 / −12 lines ✓ ⌃
|
||||
─────────────────────────────────────────────────
|
||||
✏️ practice-log.md · +12 / −0
|
||||
✏️ jazz/voicings.md · +18 / −8
|
||||
✏️ jazz/scales.md · +8 / −2
|
||||
✏️ daily/2024-03-15.md · +6 / −0
|
||||
✏️ ⚠ archive/old.md · failed (file locked)
|
||||
```
|
||||
|
||||
### What we lose
|
||||
|
||||
Strict per-call timestamps in the collapsed view. Acceptable: the expanded view restores everything, and 95% of the value is the aggregate stat.
|
||||
|
||||
## Sub-agent nesting
|
||||
|
||||
Sub-agents use the **same action-card primitive** with a different shape: the child trail lives inside the expanded card. They are identified by `parentToolCallId` on child tool parts (already extracted in `backendMeta.ts`; needs to be propagated into `AgentMessagePart`).
|
||||
|
||||
### Collapsed parent card
|
||||
|
||||
```
|
||||
🤖 research-agent · "find all jazz voicings notes" ✓ ⌄
|
||||
3 tools · 1 reasoning · 4.2s · 7 matches found
|
||||
```
|
||||
|
||||
- Sub-agent identity (`research-agent`, `code-reviewer`, `explore`) is first-class — surfaced in the title.
|
||||
- Outcome line summarizes both _the work_ (tool/reasoning counts, duration) and _the result_ (whatever the sub-agent returned).
|
||||
|
||||
### Expanded parent card — full nested trail
|
||||
|
||||
```
|
||||
🤖 research-agent · "find all jazz voicings notes" ✓ ⌃
|
||||
3 tools · 1 reasoning · 4.2s
|
||||
┃
|
||||
┃ 🗂️ Searched vault · "voicings" · 7 matches ✓
|
||||
┃ 💭 Reasoning · 250 tokens ⌄
|
||||
┃ 📄 Read voicings.md · 1.2k ✓
|
||||
┃ 📄 Read 2024-02-piano.md · 800 ✓
|
||||
┃ ─────────────────────────────────────────────
|
||||
┃ Returned: 7 notes referencing voicings,
|
||||
┃ primarily under jazz/ and practice/
|
||||
```
|
||||
|
||||
A vertical guide rail (`┃`) and indentation make nesting unambiguous. Child cards are the _same components_ as top-level cards, just rendered inside a parent. Compaction applies inside the nest the same way (5 sub-agent edits → one aggregate inside the parent).
|
||||
|
||||
### Streaming behavior
|
||||
|
||||
Default for all sub-agents:
|
||||
|
||||
```
|
||||
🤖 research-agent · "find all jazz voicings notes" ⟳
|
||||
2 tools so far…
|
||||
```
|
||||
|
||||
Collapsed parent shows running counter; expanding lets the user watch live. (Auto-expand-while-running, auto-collapse-on-done is a reasonable v2 enhancement for long primary sub-agents — defer until users ask for it.)
|
||||
|
||||
### Recursion
|
||||
|
||||
Cap visible nesting at 2–3 levels. Beyond the cap, deeper sub-agents render as collapsed `🤖 …` cards inside the parent — still clickable to drill in, but indentation stops growing. Without a cap, deep traces become unreadable horizontal noise.
|
||||
|
||||
### Sub-agent textual return values
|
||||
|
||||
Some sub-agents return prose. Render it as a quoted block at the bottom of the expanded parent — keeps the parent card self-contained, no scroll-elsewhere required:
|
||||
|
||||
```
|
||||
🤖 code-reviewer · "review auth changes" ✓ ⌃
|
||||
5 tools · 2 reasoning · 12s
|
||||
┃ ...child cards...
|
||||
┃ ─────────────────────────────────────────────
|
||||
┃ > The migration is safe under concurrent
|
||||
┃ > writes. The backfill default handles…
|
||||
```
|
||||
|
||||
## Reasoning block
|
||||
|
||||
Renders the model's `thought` parts. Visually distinct from action cards — lower weight, no border, muted text — to signal "secondary information."
|
||||
|
||||
```
|
||||
💭 Reasoning · 250 tokens · 1.8s ⌄
|
||||
```
|
||||
|
||||
### Streaming
|
||||
|
||||
While streaming: a small pulse + live token counter, no auto-expand. (Vercel's auto-open-during-stream pattern is distracting in a chat UI; users can click to watch live if they want.)
|
||||
|
||||
### After streaming
|
||||
|
||||
Collapsed by default. Click to read. The block can show either raw thinking text or, if the backend emits structured reasoning steps, a step list — but the same component handles both.
|
||||
|
||||
## Composition with the chat message
|
||||
|
||||
```
|
||||
You ▸ summarize what I've written about jazz voicings
|
||||
|
||||
Assistant
|
||||
🗂️ Searched vault · "voicings" · 7 matches ✓
|
||||
💭 Reasoning · 250 tokens ⌄
|
||||
📄 Read 2 notes · 2.0k tokens ✓
|
||||
─────────────────────────────────────────────────────
|
||||
Across your notes, you've explored three voicing
|
||||
families: rootless (Bill Evans style), quartal
|
||||
(McCoy Tyner), and shell voicings…
|
||||
|
||||
[Sources: voicings.md, 2024-02-piano.md]
|
||||
```
|
||||
|
||||
Action cards and reasoning blocks form a header strip above the prose answer. Citations at the bottom link prose claims back to specific notes — they tie the cards (process) to the prose (result).
|
||||
|
||||
## Edge cases
|
||||
|
||||
- **Parallel tool calls** (agent fires 3 reads in one turn): order chronologically by completion. If they're same-tool, compaction folds them naturally.
|
||||
- **Mid-trail failure**: parent stays `⚠`, child cards remain visible — the partial trail is the most useful debugging artifact.
|
||||
- **Empty results**: surface explicitly (`∅ Searched vault · 0 matches`). Don't collapse to "Done."
|
||||
- **Permission-gated tools** (e.g., `ExitPlanMode` plan proposals): existing `PlanProposalCard` handles this — keep it as a specialized action-card variant, not a separate primitive.
|
||||
- **Backend group hint**: an explicit `group_id` in `_meta` overrides the consecutive-same-tool heuristic, letting agent authors mark logically grouped operations even when interleaved.
|
||||
- **Tool that produces no output** (fire-and-forget): show with `✓` and an outcome line like "no return value."
|
||||
|
||||
## Current state & gaps
|
||||
|
||||
References from `zero/acp-test`:
|
||||
|
||||
| Area | File | Status |
|
||||
| --------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Streaming events | `src/agentMode/acp/AcpBackendProcess.ts`, `src/agentMode/session/AgentSession.ts` | ✅ tool_call / tool_call_update / agent_message_chunk / thought / plan all flowing |
|
||||
| Tool card UI | `src/agentMode/ui/AgentToolCall.tsx` | ✅ renders `tool_call` / `thought` / `plan`, status icons, diff view, file annotations |
|
||||
| Plan proposal | `src/agentMode/ui/PlanProposalCard.tsx` (commit `fa7a65d`) | ✅ specialized card, permission flow, preview view |
|
||||
| Backend meta | `src/agentMode/session/backendMeta.ts`, `src/agentMode/backends/claude-code/meta.ts` | ✅ `parentToolCallId`, `vendorToolName`, `isPlanProposal` extracted |
|
||||
| Reasoning UI | `src/components/chat-components/AgentReasoningBlock.tsx` | ⚠ exists for legacy chat, **not wired** to agent-mode `thought` parts |
|
||||
| **Sub-agent nesting** | — | ❌ `AgentMessagePart.tool_call` lacks a `parentToolCallId` field; tool parts are flattened in the store; no UI hierarchy |
|
||||
| **Tool compaction** | — | ❌ no aggregation/grouping logic; consecutive same-tool calls render as N separate cards |
|
||||
| **Aggregate stats** | — | ❌ no tool-aware summary helpers (line deltas, token totals, unique-result counts) |
|
||||
|
||||
### Implementation pointers (high-level — not a step-by-step plan)
|
||||
|
||||
- **Data model**: add `parentToolCallId?: string` to `AgentMessagePart.tool_call` and propagate it from `backendMeta.ts` into the store. This is the foundation for both sub-agent nesting and ensuring compaction never crosses a parent/child boundary.
|
||||
- **Render-time grouping**: introduce a pure function that takes the flat `AgentMessagePart[]` for an assistant turn and returns a tree of render nodes — `Card | AggregateCard | SubAgentCard | ReasoningBlock`. Compaction is a fold over consecutive same-tool peers at each level. Keep this layer purely derivational so the store stays flat.
|
||||
- **Tool-aware summary registry**: a small registry mapping tool name → `(parts) => { line: string, stat: string }`. Each tool family (read, edit, search, fetch, vault-search, …) contributes one entry. Falls back to a generic summary for unknown tools.
|
||||
- **Reasoning hookup**: route agent-mode `thought` parts through `AgentReasoningBlock`. The legacy block already supports streaming + collapse-on-done; the gap is wiring, not new UI.
|
||||
- **Sub-agent component**: a thin variant of `AgentToolCall` that, when expanded, renders its child render tree (recursively reusing the same renderer). Indentation + a left guide rail handles the visual.
|
||||
|
||||
## Verification
|
||||
|
||||
- **Unit**: cover the grouping/aggregation function with cases for (a) heterogeneous runs (no grouping), (b) consecutive same-tool, (c) interleaved with reasoning (no grouping), (d) sub-agent boundary (no grouping across), (e) explicit `group_id`.
|
||||
- **Visual / live**: drive each case end-to-end via agent mode in Obsidian — single tool, 5-edit burst, sub-agent invocation (e.g., `Task` from Claude Code backend), reasoning-only turn, mixed turn. Use the Obsidian CLI (`obsidian dev:debug` / `dev:console`) to confirm the underlying parts and screenshot the rendered cards.
|
||||
- **Regression**: existing flat-card layouts still render correctly when no nesting / grouping applies (single tool calls, plan proposals).
|
||||
|
||||
## Open questions
|
||||
|
||||
- Should the reasoning block live **inline among action cards** (as in the composition mockup), or always **at the top** of the assistant turn? Inline preserves chronological truth; top is calmer visually. Recommend inline; flag for review.
|
||||
- Auto-collapse threshold for sub-agents — collapse on done always, or only when child count ≥ 3? Recommend always-collapse for consistency.
|
||||
- Tool icon source — Lucide set, custom set, or per-backend overrides? Decide before implementation since it touches every card.
|
||||
433
designdocs/CODE_REVIEW_GUIDE.md
Normal file
433
designdocs/CODE_REVIEW_GUIDE.md
Normal file
|
|
@ -0,0 +1,433 @@
|
|||
# Branch review guide — `zero/acp-test`
|
||||
|
||||
## Context
|
||||
|
||||
One week of vibe-coded work that adds **Agent Mode** to the plugin: a chat
|
||||
surface that drives external coding agents (OpenCode, Codex, Claude). The
|
||||
branch is large (160 files, +31k / −9k LOC across 42 commits) but the
|
||||
architecture is clean — **six element types** with ESLint-enforced boundaries
|
||||
(`src/agentMode/CLAUDE.md`, `.eslintrc` `boundaries/elements`).
|
||||
|
||||
This is a **review tour by architectural concern**, not by commit
|
||||
chronology. Read in this order; each group is sized for one or two
|
||||
sittings.
|
||||
|
||||
---
|
||||
|
||||
## Architecture in one diagram
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
│ Host plugin │
|
||||
│ (main.ts, CopilotView, ChatInput) │
|
||||
└────────────────────┬─────────────────────┘
|
||||
│ only via @/agentMode barrel
|
||||
▼
|
||||
┌──────────────────────────────────────────┐
|
||||
│ ui/ │ GROUP 5
|
||||
│ Backend-agnostic React; reads │
|
||||
│ BackendDescriptor │
|
||||
└─────────┬────────────────────────┬───────┘
|
||||
│ runtime + types │ catalog
|
||||
│ ▼
|
||||
│ ┌─────────────────────────┐
|
||||
│ │ backends/registry.ts │ GROUP 3
|
||||
│ │ (its own element type) │
|
||||
│ └────────────┬────────────┘
|
||||
│ │
|
||||
│ ▼
|
||||
│ ┌─────────────────────────┐
|
||||
│ │ backends/<id>/ │ GROUP 3
|
||||
│ │ opencode, codex, claude│
|
||||
│ │ + backends/_shared/ │
|
||||
│ └────┬───────────────┬────┘
|
||||
│ │ │
|
||||
│ ┌──────────▼─────┐ ┌──────▼──────┐
|
||||
│ │ acp/ │ │ sdk/ │ GROUP 2
|
||||
│ │ JSON-RPC over │ │ In-process │
|
||||
│ │ stdio │ │ Claude SDK │
|
||||
│ └──────────┬─────┘ └──────┬──────┘
|
||||
│ │ siblings — │
|
||||
│ │ neither imports the other
|
||||
▼ ▼ ▼
|
||||
┌──────────────────────────────────────────┐
|
||||
│ session/ │ GROUP 1 + 4
|
||||
│ The contract sink. Owns BackendProcess, │
|
||||
│ BackendDescriptor, errors, debug sink. │
|
||||
│ Also: AgentSession, message store, │
|
||||
│ multi-tab manager, persistence, │
|
||||
│ mode/effort/MCP/model adapters. │
|
||||
│ Imports nothing from agentMode. │
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Dependency direction in one sentence: **all arrows point toward `session/`**.
|
||||
It's a sink — every other element imports types from it; it imports nothing
|
||||
back. `ui/` reaches the backend catalog via `registry` (carved out as its own
|
||||
element type) so it can stay oblivious to which specific backends exist.
|
||||
|
||||
The **contract** that ties this all together (Group 1) lives in
|
||||
`session/types.ts`, `session/errors.ts`, and `src/agentMode/CLAUDE.md`. Read
|
||||
those first or nothing else will land cleanly.
|
||||
|
||||
A **vertical slice** (Group 6 — plan mode & permissions) crosses every
|
||||
layer above; it's worth reading after you've seen the layers in
|
||||
isolation.
|
||||
|
||||
---
|
||||
|
||||
## Group 1 — The contract (start here)
|
||||
|
||||
**Why first:** every other file conforms to interfaces declared here. If
|
||||
you read these in any other order, you'll re-derive them yourself as you
|
||||
go.
|
||||
|
||||
**Files (~700 lines total):**
|
||||
|
||||
- `src/agentMode/CLAUDE.md` — layer rules, descriptor surface, debug tips
|
||||
(the spec; authoritative when this guide and it disagree)
|
||||
- `src/agentMode/session/types.ts` — **the central vocabulary**: the
|
||||
`BackendProcess` interface, `BackendDescriptor`, `InstallState`, the event
|
||||
union the session emits to the UI (532 lines)
|
||||
- `src/agentMode/session/errors.ts` — `MethodUnsupportedError` and
|
||||
`JSONRPC_METHOD_NOT_FOUND` (extracted out of types.ts so backends can
|
||||
throw them without a circular import)
|
||||
- `src/agentMode/session/debugSink.ts` — shared NDJSON frame sink fed by
|
||||
both transport layers (`acp/debugTap.ts` and `sdk/sdkDebugTap.ts`)
|
||||
- `src/agentMode/index.ts` — public surface (one barrel; nothing outside
|
||||
agentMode is allowed to deep-import past this)
|
||||
- `src/agentMode/backends/registry.ts` — the only file you edit when
|
||||
adding a new backend (32 lines). Carved out as its own boundary element
|
||||
so `ui/` can read the catalog without unlocking deep imports into
|
||||
individual backends — this is why the element count is **six**, not five.
|
||||
- `.eslintrc` — find the `boundaries/elements` and
|
||||
`boundaries/dependencies` blocks; this is what enforces the layering at
|
||||
lint time
|
||||
|
||||
**Validate understanding:** can you describe (a) what `BackendProcess`
|
||||
exposes to `session/`, (b) what `BackendDescriptor` exposes to `ui/` and
|
||||
`registry`, and (c) why those two interfaces are different?
|
||||
|
||||
---
|
||||
|
||||
## Group 2 — Agent transport (two runtimes, one contract)
|
||||
|
||||
**Why grouped together:** ACP and the Claude Agent SDK are the same
|
||||
architectural layer — both translate "an external agent's event stream"
|
||||
into the session's internal event vocabulary, and both implement
|
||||
`BackendProcess`. They are **siblings: neither imports the other**, and
|
||||
`session/` doesn't import either — `session/` consumes the `BackendProcess`
|
||||
interface only. Reading them side-by-side is the fastest way to see what
|
||||
the contract actually buys you.
|
||||
|
||||
**The shared shape both runtimes expose to `session/`:** the
|
||||
`BackendProcess` interface — `start(prompt)`, `interrupt()`, `setModel()`,
|
||||
`setPermissionMode()`, `close()`, plus a stream of `SessionNotification`
|
||||
events.
|
||||
|
||||
### 2a. ACP runtime — JSON-RPC subprocess
|
||||
|
||||
`src/agentMode/acp/`
|
||||
|
||||
- `types.ts` — ACP wire-format types
|
||||
- `AcpProcessManager.ts` — subprocess spawn / lifecycle
|
||||
- `AcpBackendProcess.ts` — `BackendProcess` impl: JSON-RPC frame loop + ACP
|
||||
event translation (446 lines, the central file)
|
||||
- `VaultClient.ts` — host-side handler for the agent's `fs/*` requests
|
||||
(this is how the agent reads/writes vault files via the host, not the
|
||||
OS filesystem)
|
||||
- `debugTap.ts` — opt-in JSON-RPC frame tap; writes into
|
||||
`session/debugSink.ts` (the standalone `frameSink.ts` from earlier
|
||||
iterations is gone — the sink lives in `session/` now)
|
||||
- `nodeShebangPath.ts` — CLI shebang resolution
|
||||
|
||||
### 2b. Claude SDK runtime — in-process query iterator
|
||||
|
||||
`src/agentMode/sdk/`
|
||||
|
||||
- `ClaudeSdkBackendProcess.ts` — `BackendProcess` impl that wraps `query()`
|
||||
from `@anthropic-ai/claude-agent-sdk` (636 lines, the central file)
|
||||
- `sdkMessageTranslator.ts` — translates `SDKMessage` stream → the same
|
||||
internal event vocabulary the ACP path uses (this is what lets
|
||||
`session/` stay unchanged across both runtimes)
|
||||
- `vaultMcpServer.ts` — in-process MCP server wrapping `app.vault.adapter`
|
||||
so writes flow through Obsidian (file watchers, links, sync). The
|
||||
SDK's built-in Read/Write/Edit are explicitly disallowed.
|
||||
- `permissionBridge.ts` — translates the SDK's `canUseTool` callback into
|
||||
the existing permission flow, including new `AskUserQuestion` handling
|
||||
- `effortOption.ts` — resolves reasoning-effort options for the SDK
|
||||
(consumed by `backends/claude/descriptor.ts`)
|
||||
- `toolMeta.ts` — SDK-side tool metadata (icons / display strings)
|
||||
- `sdkDebugTap.ts` — SDK-side frame tap; feeds `session/debugSink.ts` so
|
||||
ACP and SDK turns appear in the same NDJSON file
|
||||
|
||||
(Note: `claudeBinaryResolver.ts` used to live here but moved to
|
||||
`backends/claude/` — see Group 3.)
|
||||
|
||||
**Companion docs:**
|
||||
|
||||
- `designdocs/todo/CLAUDE_AGENT_SDK_MIGRATION.md` — capability diff and
|
||||
why the SDK path was added alongside ACP rather than replacing it.
|
||||
Read §1 (capability table) and §2 (target architecture).
|
||||
|
||||
**Validate:** turn on full ACP frame logging (Settings → Advanced), run a
|
||||
turn on opencode and a turn on claude. The internal events emitted to
|
||||
`session/` should look the same shape; only the inputs differ.
|
||||
|
||||
---
|
||||
|
||||
## Group 3 — Backend catalog (which agents exist, how they install)
|
||||
|
||||
**Why a separate group:** "how do we talk to an agent" (Group 2) and
|
||||
"which specific agents do we ship" are different concerns. A backend
|
||||
descriptor picks a transport, owns its install/binary story, exposes a
|
||||
settings panel, and registers itself.
|
||||
|
||||
**The pattern (per backend):**
|
||||
|
||||
```
|
||||
backends/<id>/
|
||||
descriptor.ts — the BackendDescriptor export (settings glue,
|
||||
install state, createBackendProcess factory)
|
||||
<Id>Backend.ts — subprocess track only: implements the
|
||||
AcpBackend helper interface used by
|
||||
simpleBinaryBackendProcess (a thin wrapper).
|
||||
SDK-track backends skip this — the descriptor's
|
||||
createBackendProcess constructs the
|
||||
BackendProcess directly.
|
||||
<Id>InstallModal.tsx — onboarding UI (BYOK key, binary path)
|
||||
<Id>SettingsPanel.tsx — settings-page panel
|
||||
index.ts — re-exports the descriptor
|
||||
```
|
||||
|
||||
**Files:**
|
||||
|
||||
- `src/agentMode/backends/registry.ts` — already read in Group 1
|
||||
- `src/agentMode/backends/_shared/` — common scaffolding three of the
|
||||
three backends use:
|
||||
- `simpleBinaryBackend.ts` — boilerplate for "user installs a CLI, we
|
||||
detect it and pass the path"
|
||||
- `BinaryInstallContent.tsx` — install-modal body
|
||||
- `SimpleBackendSettingsPanel.tsx` — settings-panel body
|
||||
- `src/agentMode/backends/opencode/` — the **most complete example**;
|
||||
read this first. Includes:
|
||||
- `OpencodeBinaryManager.ts` (612 lines) — plugin-managed download +
|
||||
extract + verify (this is the only backend with a fully managed
|
||||
install)
|
||||
- `platformResolver.ts` — picks the right tarball per OS/arch
|
||||
- `src/agentMode/backends/codex/` — minimal example (no managed binary)
|
||||
- `src/agentMode/backends/claude/` — uses Group 2b (`sdk/`) instead of
|
||||
ACP, so it has no `<Id>Backend.ts`. Includes:
|
||||
- `AskUserQuestionModal.tsx` — Claude-only multi-choice question modal
|
||||
(a capability ACP didn't have)
|
||||
- `claudeBinaryResolver.ts(+test)` — locates the user-installed
|
||||
`claude` CLI across Volta / asdf / NVM / Homebrew / npm-global on
|
||||
macOS / Linux / Windows. Lives here (not in `sdk/`) because
|
||||
cross-platform CLI resolution is Claude-specific.
|
||||
- `src/utils/detectBinary.ts` — generic binary-on-PATH detection used by
|
||||
the simple backend scaffold
|
||||
|
||||
**Validate:** open Settings → Agents tab. Switch active backend. Each
|
||||
backend should expose its own install state, settings panel, and BYOK
|
||||
fields without any other code changing.
|
||||
|
||||
---
|
||||
|
||||
## Group 4 — Session core (state machine + adapters)
|
||||
|
||||
**Why grouped together:** the state machine and the adapters are tightly
|
||||
coupled. The adapters exist _because_ the session has to present a
|
||||
uniform model/mode/effort UX over backends that disagree on what those
|
||||
mean. Reading the state machine in isolation makes the adapters look
|
||||
incidental, and vice versa.
|
||||
|
||||
### 4a. State machine
|
||||
|
||||
- `src/agentMode/session/AgentSession.ts` — **1301 lines, the heart**.
|
||||
Owns: turn state, message queue, plan/permission resolvers, interrupt,
|
||||
model/mode/effort changes, agent-supplied title, the event handler
|
||||
switch. Skim once for public methods, then read each event handler in
|
||||
sequence.
|
||||
- `src/agentMode/session/AgentSessionManager.ts` — multi-tab orchestration
|
||||
(779 lines): per-tab session creation, active-session selection,
|
||||
cross-session lifecycle, persistence wiring
|
||||
- `src/agentMode/session/AgentMessageStore.ts` — append-only store
|
||||
- `src/agentMode/session/AgentChatUIState.ts` — React subscription bridge
|
||||
- `src/agentMode/session/AgentChatBackend.ts` — small interface the
|
||||
session uses to talk to its current backend
|
||||
- `src/agentMode/session/AgentChatPersistenceManager.ts` — debounced
|
||||
markdown auto-save, project-scoped file naming
|
||||
- `src/agentMode/session/index.ts` — public surface for the layer
|
||||
|
||||
(The contract files — `types.ts`, `errors.ts`, `debugSink.ts` — are
|
||||
already covered in Group 1.)
|
||||
|
||||
### 4b. Cross-cutting adapters (per-backend normalizers)
|
||||
|
||||
- `session/modeAdapter.ts` — canonical `default | plan | yolo` modes
|
||||
mapped to per-backend strings
|
||||
- `session/effortAdapter.ts` — reasoning effort levels per backend,
|
||||
including the `modelId#effort` encoding
|
||||
- `session/mcpResolver.ts` — user-configured MCP servers (stdio/http/sse)
|
||||
- `session/AgentModelPreloader.ts` — kicks model lists at plugin load
|
||||
- `session/modelEnable.ts` — per-backend model curation
|
||||
- `session/backendMeta.ts`, `session/backendSettingsAccess.ts` — small
|
||||
helpers
|
||||
- `ui/useAgentModelPicker.ts` (445 lines) + `ui/agentModelPickerHelpers.ts`
|
||||
— the UI hook that consumes the adapters; lives in `ui/` but is
|
||||
conceptually part of this group
|
||||
- `ui/McpServersPanel.tsx` — settings panel for `mcpResolver`
|
||||
|
||||
**Validate:** start two tabs on different backends, send messages in
|
||||
both, change model mid-turn (it applies on next send), set per-backend
|
||||
default mode, configure an MCP server. Reload the plugin — sessions
|
||||
restore from disk.
|
||||
|
||||
---
|
||||
|
||||
## Group 5 — Chat surface UI (backend-agnostic)
|
||||
|
||||
**Why grouped:** all of `ui/` (minus the plan/permission vertical in
|
||||
Group 6) is React rendering against the session layer. No file in here
|
||||
imports from `acp/`, `sdk/`, or specific backends — only from `session/`
|
||||
and `backends/registry.ts`. This is exactly what `.eslintrc`'s
|
||||
`from: { type: "ui" }` rule allows; if you want to verify a specific
|
||||
import, check that rule.
|
||||
|
||||
### 5a. Shell
|
||||
|
||||
- `ui/CopilotAgentView.tsx` — Obsidian view registration
|
||||
- `ui/AgentModeChat.tsx` — top-level chat surface
|
||||
- `ui/AgentChat.tsx` (511 lines) — main chat container
|
||||
- `ui/AgentChatControls.tsx` — header controls
|
||||
- `ui/AgentTabStrip.tsx` — multi-session tabs
|
||||
- `ui/AgentModeStatus.tsx` — install / connection status
|
||||
- `ui/AgentChatMessages.tsx` — message list
|
||||
- `ui/AgentMarkdownText.tsx` — markdown rendering helper
|
||||
|
||||
### 5b. Action-card trail (most recent UI rewrite)
|
||||
|
||||
The chronological trail of tool calls + reasoning + text:
|
||||
|
||||
- `ui/agentTrail.ts` — chronological merge of text/tool/reasoning parts
|
||||
- `ui/AgentTrailView.tsx` — renderer
|
||||
- `ui/ActionCard.tsx`, `AggregateCard.tsx`, `SubAgentCard.tsx` — card
|
||||
variants
|
||||
- `ui/ReasoningBlock.tsx` + `components/chat-components/AgentReasoningBlock.tsx`
|
||||
— reasoning timer
|
||||
- `ui/toolSummaries.ts` (+ test) + `toolIcons.ts` — per-tool one-liners
|
||||
- `ui/diffRender.ts` — edit-tool diff display
|
||||
- `ui/vaultPath.ts` — vault-relative path display
|
||||
|
||||
**Validate:** run a multi-tool turn (read + edit + bash). The trail
|
||||
should show one card per tool call, reasoning blocks interleaved
|
||||
chronologically, and a live timer on in-flight reasoning.
|
||||
|
||||
---
|
||||
|
||||
## Group 6 — Plan mode & permissions (vertical slice across all layers)
|
||||
|
||||
**Why a separate group:** this is the trickiest interaction surface in
|
||||
the whole feature, and it crosses every layer. Reading it as a vertical
|
||||
shows you how transport / session / UI cooperate around a single user
|
||||
flow. Save it for after you understand the layers in isolation.
|
||||
|
||||
**The flow:** agent emits a `plan` notification → session creates a plan
|
||||
proposal → UI renders the floating card → user approves → session sends
|
||||
an `ExitPlanMode` permission → plan transitions into apply.
|
||||
|
||||
**Files (cross-layer):**
|
||||
|
||||
Transport side (Group 2):
|
||||
|
||||
- ACP: the `requestPermission` RPC handler in
|
||||
`acp/AcpBackendProcess.ts` and `permissionPrompter.ts`
|
||||
- SDK: `sdk/permissionBridge.ts` (translates `canUseTool` callback,
|
||||
including `AskUserQuestion`)
|
||||
|
||||
Session side (Group 4):
|
||||
|
||||
- The plan / permission slices of `session/AgentSession.ts` (search for
|
||||
`plan`, `EnterPlanMode`, `ExitPlanMode`)
|
||||
|
||||
UI side:
|
||||
|
||||
- `ui/permissionPrompter.ts` — routing layer; special-cases
|
||||
`ExitPlanMode` switch_mode
|
||||
- `ui/AcpPermissionModal.tsx` — generic permission modal
|
||||
- `ui/PlanProposalCard.tsx` — inline plan card
|
||||
- `ui/PlanPreviewView.tsx` — full-screen plan preview (custom Obsidian
|
||||
view registered in `main.ts`)
|
||||
- `ui/planEntryStyles.ts`
|
||||
- `backends/claude/AskUserQuestionModal.tsx` — Claude-only
|
||||
multi-choice question modal
|
||||
|
||||
**Known landmines fixed in this branch:** ghost plan card on late
|
||||
`tool_call_update` (`e5b1dcb`), markdown class on plan preview
|
||||
(`060e7d5`).
|
||||
|
||||
**Validate:** trigger a multi-step task in plan mode. Card renders →
|
||||
preview opens → approve → session transitions into apply. No ghost card
|
||||
on late events.
|
||||
|
||||
---
|
||||
|
||||
## Group 7 — Host integration & settings (the outer seam)
|
||||
|
||||
**Why last:** these are the files outside `agentMode/` that hook the
|
||||
feature into the plugin. Reading them last is correct; the layered
|
||||
mental model has to be in your head first or you'll mistake plumbing for
|
||||
architecture.
|
||||
|
||||
**Plugin entry & view registration:**
|
||||
|
||||
- `src/main.ts` (193-line diff) — `createAgentSessionManager()` call,
|
||||
view registration (chat + plan-preview), mobile gate, lifecycle
|
||||
- `src/components/CopilotView.tsx` — agent-mode chat-surface mounting
|
||||
|
||||
**Chat input integration:**
|
||||
|
||||
- `src/components/chat-components/ChatInput.tsx` — agent-mode-aware input
|
||||
(queue messages while a turn runs, ESC to stop, Shift-Tab to cycle
|
||||
modes)
|
||||
- `src/components/chat-components/ChatModeInput.tsx` — extracted
|
||||
agent-mode toggle wrapper
|
||||
- `src/components/chat-components/ChatMessages.tsx`,
|
||||
`AgentReasoningBlock.tsx`,
|
||||
`BottomLoadingIndicator.tsx`,
|
||||
`attachChatViewLayoutObservers.ts`
|
||||
|
||||
**Settings:**
|
||||
|
||||
- `src/settings/model.ts` — new `agentMode.*` settings tree (active
|
||||
backend, per-backend slices, sticky model/mode/effort, MCP servers)
|
||||
- `src/settings/v2/components/AgentSettings.tsx` — dedicated Agents tab
|
||||
- `src/settings/v2/components/AdvancedSettings.tsx` — frame-log toggle
|
||||
- `src/settings/v2/SettingsMainV2.tsx` — tab registration
|
||||
- `src/components/agent/BinaryPathSetting.tsx` — generic binary-path
|
||||
picker shared by backends
|
||||
|
||||
**Build / runtime shims (needed because the SDK assumes Node, but
|
||||
Obsidian runs in Electron's renderer):**
|
||||
|
||||
- `nodeModuleShim.mjs`
|
||||
- `scripts/patchRendererUnsafeUnref.js`
|
||||
- `esbuild.config.mjs`
|
||||
- `__mocks__/@anthropic-ai/`
|
||||
- `typings/global.d.ts`
|
||||
|
||||
---
|
||||
|
||||
## Reading order at a glance
|
||||
|
||||
1. **Group 1 — Contract** (must be first)
|
||||
2. **Group 2 — Transport: ACP + SDK side-by-side**
|
||||
3. **Group 3 — Backend catalog**
|
||||
4. **Group 4 — Session core + adapters**
|
||||
5. **Group 5 — Chat UI**
|
||||
6. **Group 6 — Plan/permission vertical**
|
||||
7. **Group 7 — Host seam**
|
||||
|
||||
After Group 1 the order is mostly negotiable — Groups 2/3/4 can flex
|
||||
based on what you want to validate first. But Group 6 should always come
|
||||
after 2/4/5, and Group 7 should always come last.
|
||||
433
designdocs/SETTINGS_REDESIGN_PRD.md
Normal file
433
designdocs/SETTINGS_REDESIGN_PRD.md
Normal file
|
|
@ -0,0 +1,433 @@
|
|||
# Settings Redesign PRD — Copilot for Obsidian
|
||||
|
||||
**Status:** Draft for design handoff
|
||||
**Audience:** Senior product designer (Claude Design) doing IA work
|
||||
**Out of scope:** Visual restyling, color/typography choices, chat UI redesign, settings storage schema
|
||||
|
||||
---
|
||||
|
||||
## 1. Context & Problem
|
||||
|
||||
Copilot for Obsidian currently exposes **~150 user-configurable settings** across 7 tabs (Basic, Model, QA, Command, Plus, Advanced, Agent). The information architecture has accumulated through feature additions rather than user-journey design. The result:
|
||||
|
||||
- Beginners encounter expert knobs (lexical search RAM limit, embedding batch size, partition count, auto-compact token threshold, agent backend internals) on the same page as their first decisions (default model, send shortcut). They get confused and bounce.
|
||||
- Related settings live in different surfaces. API keys hide in a modal launched from Basic. Custom system prompts hide in Advanced. Plus features render conditionally inside a sub-component. Self-host/Miyo settings only appear after server-side eligibility checks pass.
|
||||
- Conditional/nested settings have no discoverability. A user who isn't sure if a setting exists can't find out without trial and error.
|
||||
- There's no settings search.
|
||||
- Deprecated and unused settings (`autoIncludeTextSelection`, `chatNoteContextPath`, `inlineEditCommands`, `stream`) still ship in the schema.
|
||||
|
||||
**This redesign happens in parallel with the ACP-Centric Revamp** (see audit at `~/Developer/zeroliu_second_brain/notes/Copilot Feature Audit for ACP - Centric Revamp.md`), which collapses the four chat modes (Chat / Vault QA / Copilot Plus / Projects) into one chat surface with a single Agent Mode toggle, retires `@`-commands, converts custom prompts and custom commands into "skills," and adds a dedicated Agent Mode configuration tab. **This PRD describes the post-revamp world**, not today's state. Settings being dropped in the revamp are listed in §6 so the designer doesn't design around them.
|
||||
|
||||
### Success criteria
|
||||
|
||||
1. **Beginner finishes setup in <60 seconds.** API key in, default model picked, chat works.
|
||||
2. **Power user can still find every knob.** No setting goes missing; advanced surfaces are reachable in ≤2 clicks from any entry.
|
||||
3. **Agent Mode is a first-class section** with a clean per-backend pattern.
|
||||
4. **Settings search exists** and reduces "where is X" support questions.
|
||||
5. **Locked / eligibility-gated states are explicit** — never silently hidden.
|
||||
|
||||
---
|
||||
|
||||
## 2. Users & Audiences
|
||||
|
||||
Four tiers, used as labels throughout this doc. Use them to drive progressive disclosure.
|
||||
|
||||
| Tier | Description | What they configure |
|
||||
| ---------------- | ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| **Beginner** | Just installed Copilot. Has an API key from one provider. Doesn't know what an embedding is. Wants chat to work. | API key, default model, where chat opens, send shortcut. |
|
||||
| **Intermediate** | Several weeks in. Has a saved-conversations workflow. Uses vault QA. May have configured 2–3 models. | Save folders, conversation tags, filename templates, indexing strategy, embedding model, exclusions/inclusions, skills folder. |
|
||||
| **Power user** | Multi-provider, custom models, self-hosts. Tunes rate limits and partition counts. Configures agent backends. | Performance tuning, custom model registration, Azure/Bedrock/proxies, agent backend binaries, MCP servers, self-host/Miyo. |
|
||||
| **Developer** | Debugging the plugin or contributing. | Debug logs, raw ACP frame capture, log files, internal toggles. |
|
||||
|
||||
Each setting in §5 has a tier label.
|
||||
|
||||
---
|
||||
|
||||
## 3. Goals & Non-Goals
|
||||
|
||||
### Goals
|
||||
|
||||
- Progressive disclosure: essentials default-visible, advanced collapsed.
|
||||
- A clear **quick-setup path** for first-run users.
|
||||
- Settings search/filter across the entire surface.
|
||||
- Distinct grouping: **credentials**, **default behavior**, **performance tuning**, **experimental**.
|
||||
- Agent Mode promoted to a top-level section (per audit).
|
||||
- All conditional/eligibility-gated settings have a visible "locked" state with a reason.
|
||||
- A clear mobile story for desktop-only features.
|
||||
|
||||
### Non-Goals
|
||||
|
||||
- Changing the _meaning_ of any setting. This is IA only.
|
||||
- Visual restyling beyond what IA decisions require.
|
||||
- Modifying the underlying settings storage schema (`CopilotSettings` interface).
|
||||
- Redesigning the chat UI, command palette, or modals.
|
||||
- Adding new functionality. (Skills replace custom prompts/commands as part of the parallel ACP revamp — that work is upstream of this PRD; here we just need to know skills exist.)
|
||||
|
||||
---
|
||||
|
||||
## 4. Platform & Tier Constraints
|
||||
|
||||
The designer must respect these. They're not negotiable.
|
||||
|
||||
### Obsidian native settings shell
|
||||
|
||||
The plugin renders inside Obsidian's settings modal. Standard convention is a left-rail tab list with a scrollable right-pane content area. Redesigns can deviate, but they must still feel native to Obsidian — no full-bleed marketing pages, no animated backgrounds, no overlays that obscure the modal chrome. Width is constrained by the Obsidian modal (roughly 700–900px effective).
|
||||
|
||||
### Mobile vs desktop
|
||||
|
||||
- **Agent Mode is desktop-only.** External ACP backends (opencode / claude-code / codex) require local binaries; mobile cannot run them. The legacy in-process agent is the only path on mobile.
|
||||
- **Vault index can be disabled on mobile** (default on) to save battery/storage.
|
||||
- The settings UI must degrade gracefully on Obsidian mobile (smaller width, touch targets). Desktop-only sections should be visible on mobile but show a "desktop only" state — don't hide them silently or users will think they don't exist.
|
||||
|
||||
### Plus / Believer / Self-host tiers
|
||||
|
||||
- **Free** — BYOK API keys, vault QA with own embeddings, basic agent (legacy).
|
||||
- **Plus** — hosted models, web search, YouTube transcripts, PDF parsing, reranker, memory.
|
||||
- **Believer** (lifetime supporter) — unlocks Self-host mode + Miyo (private indexing infra).
|
||||
|
||||
When a feature requires a tier the user doesn't have, **show the section with a locked state** and a one-line "what this does + how to unlock." Don't hide it silently.
|
||||
|
||||
### Eligibility-gated settings
|
||||
|
||||
Some panes (Self-host activation, Miyo enable) require live server-side validation. The 3-strike grace flow allows up to 3 successful validations to grant offline-permanent access. Designer needs a UI pattern for **"verified eligible"** vs **"checking…"** vs **"not eligible — here's why"** states.
|
||||
|
||||
### Settings that trigger expensive work
|
||||
|
||||
Some settings cause heavy side effects when changed. The IA must surface this **inline at the setting**, not in a separate "danger zone."
|
||||
|
||||
- Changing the embedding model → full vault reindex.
|
||||
- Changing partition count → index rebuild.
|
||||
- Toggling semantic search on/off → index state changes.
|
||||
- Disabling index sync → moves index to/from `.obsidian` folder.
|
||||
|
||||
---
|
||||
|
||||
## 5. Settings Inventory — Post-Revamp State
|
||||
|
||||
Settings are grouped by **functional purpose**, not by current tab. The designer decides where each group lives in the new IA. Each setting has audience tier, default, and notes.
|
||||
|
||||
Tier shorthand: `B` Beginner · `I` Intermediate · `P` Power · `D` Developer.
|
||||
|
||||
### A. Account & Credentials
|
||||
|
||||
| Setting | Tier | Default | Notes |
|
||||
| --------------------------------------------------------------------------- | ---- | ------- | --------------------------------------------------------------------------------------------------------- |
|
||||
| Plus license key | B | empty | Validated online; success unlocks Plus features and triggers welcome modal. Drives visibility of group G. |
|
||||
| OpenAI API key | B | empty | Most common; should be prominent. |
|
||||
| Anthropic API key | B | empty | |
|
||||
| Google (Gemini) API key | B | empty | |
|
||||
| OpenRouter API key | I | empty | |
|
||||
| Cohere API key | I | empty | Often used for embeddings/reranker. |
|
||||
| Groq, Mistral, xAI, DeepSeek, SiliconFlow, HuggingFace API keys | I | empty | Long tail; collapse. |
|
||||
| OpenAI org id | I | empty | |
|
||||
| OpenAI proxy base URL | P | empty | Custom OpenAI-compatible endpoint. |
|
||||
| Embedding proxy base URL | P | empty | Separate from chat proxy. |
|
||||
| Azure OpenAI (api key, instance, deployment, version, embedding deployment) | P | empty | Five fields, only meaningful together. |
|
||||
| AWS Bedrock (api key, region) | P | empty | Two fields, only meaningful together. |
|
||||
| GitHub Copilot (access token, token, expiry) | P | empty | OAuth-driven; expiry auto-refreshed. |
|
||||
|
||||
**Today** these live in a separate modal (`ApiKeyDialog`) launched from Basic. Open question: should they stay modal or move inline into the IA?
|
||||
|
||||
### B. Default Chat Behavior
|
||||
|
||||
| Setting | Tier | Default | Notes |
|
||||
| ------------------------------- | ---- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
|
||||
| Default chat model | B | `google/gemini-2.5-flash\|openrouterai` | The single most important setting. Drives 90% of UX. Picker reads from enabled chat-model registry (group D). |
|
||||
| Send shortcut | B | Enter | Enter vs Shift+Enter. |
|
||||
| Where chat opens | B | Sidebar view | Sidebar vs main editor. |
|
||||
| Auto-add active note to context | B | on | Convenience. |
|
||||
| Auto-add selection to context | B | off | |
|
||||
| Pass markdown images | I | on | Only meaningful with multimodal models. |
|
||||
| Show suggested prompts | B | on | UI affordance toggle. |
|
||||
| Show relevant notes | B | on | UI affordance toggle. |
|
||||
|
||||
### C. Conversation Storage
|
||||
|
||||
| Setting | Tier | Default | Notes |
|
||||
| ------------------------ | ---- | ------------------------------- | -------------------------------------------------------------------- |
|
||||
| Autosave chat | B | on | Saves after each turn. |
|
||||
| Save folder | B | `copilot/copilot-conversations` | |
|
||||
| Default conversation tag | B | `copilot-conversation` | |
|
||||
| Filename template | I | `{$date}_{$time}__{$topic}` | Variables: `{$date}`, `{$time}`, `{$topic}`. Must include all three. |
|
||||
| AI-generated chat titles | I | on | When off, falls back to first ~10 words. |
|
||||
| Chat history sort | B | recent | recent / created / name / manual. |
|
||||
| Project list sort | B | recent | Same options. |
|
||||
|
||||
### D. Models — Chat & Embedding Registries
|
||||
|
||||
These are **CRUD surfaces**, not single-value settings. The IA must accommodate list/table views inside settings.
|
||||
|
||||
**Chat models registry** (I→P)
|
||||
|
||||
- 36+ built-in models pre-populated. Enable/disable per model. Drag-reorder. Add custom model (provider, name, model id, capabilities).
|
||||
- Per-model capabilities: vision, reasoning.
|
||||
- Per-model overrides (where supported): temperature, max tokens, reasoning effort, verbosity.
|
||||
|
||||
**Embedding models registry** (I→P)
|
||||
|
||||
- 12 built-in. Same enable/disable pattern.
|
||||
- Selected embedding model is the one used for QA. **Changing it triggers full reindex** — surface inline.
|
||||
|
||||
**Conversation context** (cross-cutting)
|
||||
|
||||
- Context turns to include in chat history (I; default 15, range 1–50). 1 turn = user message + AI reply.
|
||||
- Auto-compact threshold in tokens (P; default 128000, range 64000–1000000). When context exceeds this, older turns are summarized.
|
||||
- Default reasoning effort (P; for o1-class models). Values: minimal / low / medium / high.
|
||||
- Default verbosity (P; for GPT-5 class). Values: low / medium / high.
|
||||
- Default temperature (P; default 0.1).
|
||||
- Default max tokens (P; default 6000).
|
||||
|
||||
### E. Vault Search & Indexing
|
||||
|
||||
| Setting | Tier | Default | Notes |
|
||||
| ----------------------------- | ---- | -------------- | ----------------------------------------------------------- |
|
||||
| Semantic search enabled | B | off | When off: lexical-only. Toggling requires reindex. |
|
||||
| Inline citations in responses | B | on | Experimental; doesn't work with all models. |
|
||||
| Indexing strategy | I | on mode switch | never / on startup / on mode switch. Cost implications. |
|
||||
| Max source chunks per QA call | I | 30 | Range 1–128. More = slower + more context. |
|
||||
| QA inclusions | I | empty | Folder/tag/title patterns. Empty = index everything. |
|
||||
| QA exclusions | I | `copilot` | Always includes copilot folder. |
|
||||
| Index sync via Obsidian Sync | I | on | When off: index goes to `.copilot/` instead of `.obsidian`. |
|
||||
| Disable index on mobile | B | on | QA modes unavailable on mobile when on. |
|
||||
|
||||
**Performance tuning (collapse by default, all P)**
|
||||
|
||||
- Embedding requests per minute (default 60, range 10–60).
|
||||
- Embedding batch size (default 16, range 1–128).
|
||||
- Index partition count (default 1; values 1, 2, 4, 8, 16, 32, 40). For large vaults. Requires rebuild on change.
|
||||
- Lexical search RAM limit MB (default 100, range 20–1000).
|
||||
- Lexical boosts enabled (default on). Folder/graph relevance boosts.
|
||||
|
||||
**Index management actions** (I) — these are buttons, not toggles. The IA needs to show actions inside a settings page gracefully.
|
||||
|
||||
- Rebuild index
|
||||
- Force reindex
|
||||
- Clear index
|
||||
- Garbage-collect index
|
||||
- List indexed files
|
||||
- Inspect file by path
|
||||
- Clear index cache
|
||||
|
||||
### F. Agent Mode (NEW dedicated top-level section per audit)
|
||||
|
||||
**Desktop-only.** On mobile, show the section with a "desktop only" explainer.
|
||||
|
||||
| Setting | Tier | Default | Notes |
|
||||
| -------------------- | ---- | -------- | ------------------------------------------ |
|
||||
| Master enable | P | off | Toggles entire agent UI. |
|
||||
| Active backend | P | opencode | opencode / claude-code / codex. |
|
||||
| Max agent iterations | P | 4 | Range 4–16. |
|
||||
| Auto-accept edits | P | off | When on, file edits apply without preview. |
|
||||
| Diff view mode | P | split | side-by-side / split. |
|
||||
|
||||
**Per-backend slice** — repeat this pattern for each of the 3 backends. Designer needs a clean repeating layout pattern.
|
||||
|
||||
- Binary path (string).
|
||||
- Binary version (read-only, detected).
|
||||
- Binary source: managed / custom (radio).
|
||||
- Selected model key (dropdown from probe).
|
||||
- Selected effort/reasoning level (where applicable; e.g., codex uses `gpt-5-codex/high`).
|
||||
- Selected operational mode: default / plan / auto.
|
||||
- Per-model enable/disable overrides (table or list — power users curate which models appear in the picker).
|
||||
|
||||
**Tools allowlist** (I) — checkbox list of tools the agent may use:
|
||||
|
||||
- localSearch, readNote, webSearch, pomodoro, youtubeTranscription, writeFile, editFile, updateMemory.
|
||||
|
||||
**MCP servers** (P) — list/CRUD surface. Each entry has: name, command/URL, args, env vars, enabled. Designer needs a list-with-edit-form pattern.
|
||||
|
||||
### G. Plus & Self-Host (gated)
|
||||
|
||||
Show as locked section when license tier is insufficient. Show eligibility-pending state when validation is in flight.
|
||||
|
||||
**Plus features** (require Plus license)
|
||||
|
||||
- Self-host search provider (P): Firecrawl / Perplexity.
|
||||
- Firecrawl API key (P).
|
||||
- Perplexity API key (P).
|
||||
- Supadata API key (P) — for YouTube transcripts.
|
||||
|
||||
**Memory system** (I, experimental, Plus)
|
||||
|
||||
- Memory folder (default `copilot/memory`).
|
||||
- Recent conversations enabled (default on).
|
||||
- Max recent conversations (default 30, range 10–50).
|
||||
- Saved memory enabled (default on).
|
||||
|
||||
**Document processor** (I, Plus)
|
||||
|
||||
- Converted document output folder (string; empty = don't save converted markdown).
|
||||
|
||||
**Self-host mode** (Believer-only — eligibility-gated)
|
||||
|
||||
- Self-host mode enabled (P; default off). Requires Believer license + 15-day re-validation. After 3 successful validations, becomes offline-permanent.
|
||||
- Self-host backend URL (P).
|
||||
- Self-host API key (P).
|
||||
|
||||
**Miyo (private index)** — requires self-host mode
|
||||
|
||||
- Miyo enabled (P; default off).
|
||||
- Miyo server URL (P; empty = local discovery).
|
||||
- Miyo search-all (P; default off — when off, limited to current vault folder).
|
||||
|
||||
### H. Skills & System Prompts
|
||||
|
||||
Per the ACP audit, **custom prompts and custom commands collapse into "skills."** Skills are file-based — each skill is a markdown file in a configured folder. The settings UI mostly governs _where they live_ and _how they're sorted_; the skills themselves are managed in a CRUD surface.
|
||||
|
||||
| Setting | Tier | Default | Notes |
|
||||
| ---------------------------- | ---- | -------------------------------- | ------------------------------------------------------------------------------------------ |
|
||||
| Skills folder | I | `copilot/copilot-custom-prompts` | Auto-loads `.md` files. |
|
||||
| Default system prompt | P | empty | File name from custom system prompts folder. Empty = built-in. |
|
||||
| Custom system prompts folder | P | `copilot/system-prompts` | |
|
||||
| Skill sort | B | timestamp | timestamp / alphabetical / manual (drag). |
|
||||
| Template variables enabled | I | on | When off: raw prompt text. Variables: `{activeNote}`, `{[[Note]]}`, `{#tag}`, `{folder/}`. |
|
||||
|
||||
The skills _list_ itself (CRUD: create, edit, reorder, delete, toggle visibility in slash menu, toggle visibility in context menu) is the larger surface and the IA must accommodate it.
|
||||
|
||||
### I. Projects (referenced, not configured here)
|
||||
|
||||
Projects live in the chat sidebar, not the settings page. Mentioned because the designer should know:
|
||||
|
||||
- Per-project: system prompt, vault scope (folders/tag patterns), web/YouTube sources, context cache.
|
||||
- **Per-project model and parameters are dropped in the revamp** — global default applies.
|
||||
|
||||
### J. System & Privacy
|
||||
|
||||
| Setting | Tier | Default | Notes |
|
||||
| ------------------------ | ---- | ------- | -------------------------------------------------------------------------------------- |
|
||||
| Encrypt API keys at rest | P | off | Encryption-at-rest for stored credentials. |
|
||||
| Debug logging | D | off | Console + log file. Performance impact. |
|
||||
| ACP raw frame logging | D | off | `agentMode.debugFullFrames`. Writes NDJSON to disk; sensitive data. Surface a warning. |
|
||||
| Open log file | D | action | Button. |
|
||||
| User ID | D | UUID | Read-only, auto-generated. Analytics. |
|
||||
|
||||
---
|
||||
|
||||
## 6. Removed / Deprecated (don't design around these)
|
||||
|
||||
These existed in the current schema but go away in the post-revamp world (per audit and cleanup pass). The designer should not surface them.
|
||||
|
||||
- **`defaultChainType`** — the four chat modes (Chat / Vault QA / Copilot Plus / Projects) collapse into one chat surface.
|
||||
- **`@`-command syntax for tools** (`@vault`, `@web`, `@memory`) — agent calls tools directly. `@`-mention for notes/folders/tags/URLs _stays_.
|
||||
- **Per-project model and parameters** — global default applies.
|
||||
- **`inlineEditCommands`** — legacy command format, migrated to file-based skills.
|
||||
- **`autoIncludeTextSelection`** — renamed to `autoAddSelectionToContext`.
|
||||
- **`chatNoteContextPath`, `chatNoteContextTags`** — unused.
|
||||
- **`stream`** — hard-coded true; not a user choice.
|
||||
- **YouTube transcript modal** — dropped.
|
||||
- **Agent-tool exposure of file parsing** (PDFs, images, YouTube, web URLs) — backends handle these natively.
|
||||
|
||||
---
|
||||
|
||||
## 7. New (introduced by revamp)
|
||||
|
||||
- **Agent Mode tab** with per-backend configuration (binary path, model, mode, model-enable overrides). See §5.F.
|
||||
- **MCP server management** as a list/CRUD surface inside Agent Mode.
|
||||
- **Skills** consolidate custom prompts + custom commands into one file-based system.
|
||||
- **Backend session id** persisted per chat (no setting; storage detail).
|
||||
- **Eligibility-gated panes** (Self-host, Miyo) with three states: not eligible, checking, verified.
|
||||
- **Settings search** (new affordance — required by this redesign).
|
||||
|
||||
---
|
||||
|
||||
## 8. IA Principles (asks for the design)
|
||||
|
||||
1. **Quick-setup first.** New user lands on a screen that gets them to a working chat in under a minute: pick a provider, paste an API key, pick a default model. Optional: Plus license. Everything else is reachable but not in their face.
|
||||
2. **Progressive disclosure.** Each section has an essentials view by default; "advanced," "performance," and "experimental" knobs collapse behind a disclosure toggle. Beginner labels never appear next to expert knobs without a separator.
|
||||
3. **Settings search.** Inventory is too large to navigate by tab alone. Search/filter must work across all settings (label, description, alias).
|
||||
4. **Audience tiering visible.** Power-user knobs visually distinct (subdued, badged, or grouped under "Advanced"). Beginners should see "this is normal to skip."
|
||||
5. **Action surfaces inside settings.** Index rebuild, log file open, license validation, eligibility re-check are buttons, not toggles. The IA must accommodate them gracefully — don't force them into "ghost toggles."
|
||||
6. **No silent hiding.** When a setting is gated by tier or eligibility, render it with a locked / not-eligible / desktop-only state. Show what unlocks it.
|
||||
7. **Inline side-effect warnings.** Settings that trigger reindex / migration / restart announce that _at the setting_, not in a separate "danger zone."
|
||||
8. **Mobile-aware.** Desktop-only sections (Agent Mode, parts of Self-host) need a clear story on mobile.
|
||||
9. **CRUD surfaces are first-class.** Model registries, skills list, MCP servers — each is a list with rows, edit forms, and reordering. The IA pattern for "settings page that's mostly a list" should be repeatable and consistent across these three.
|
||||
10. **Repeatable patterns over snowflakes.** The 3 agent backends share structure → use one component pattern. The 19 BYOK providers share structure → use one component pattern. Avoid bespoke layouts per provider/backend.
|
||||
|
||||
---
|
||||
|
||||
## 9. Open Questions for the Designer
|
||||
|
||||
1. **API keys: modal or inline?** Today they're behind a modal. Should they live inline in the new IA, in a dedicated "Credentials" section, or stay modal-launched from a single "Manage API keys" entry?
|
||||
2. **Top-level shape:** tabbed left-rail (current), single-page-with-anchors, sidebar-tree, or hybrid? Which fits Obsidian's settings shell best given the inventory size?
|
||||
3. **Model registry placement:** inside settings as a CRUD list, or in a dedicated "Models" management view reachable from settings?
|
||||
4. **Skills placement:** same question — settings or dedicated management view? Skills also surface in chat (slash menu) which complicates ownership.
|
||||
5. **Inline reindex warnings:** what's the right pattern? Inline banner under the setting? Confirmation modal on save? Toast after save?
|
||||
6. **Plus / Self-host / Miyo grouping:** keep separate sections with their own gating, or merge into one "Cloud & self-host" group with sub-states?
|
||||
7. **Locked states:** badge, disabled-with-tooltip, expandable upsell card, or something else?
|
||||
8. **Search UX:** persistent search bar, command-palette-style overlay, or filter chips by audience tier?
|
||||
9. **"Quick setup" path:** is it a separate screen on first run, a pinned section at the top of settings, or integrated into the chat onboarding flow?
|
||||
10. **Settings density on mobile:** same layout collapsed, or a fundamentally different mobile IA?
|
||||
|
||||
---
|
||||
|
||||
## 10. Briefing Prompt for Claude Design
|
||||
|
||||
Paste the prompt below into a fresh Claude Design conversation alongside this document.
|
||||
|
||||
---
|
||||
|
||||
> # Brief: IA Redesign for Copilot for Obsidian Settings
|
||||
>
|
||||
> You are a senior product designer being asked to propose an **information architecture** redesign for the settings UI of _Copilot for Obsidian_, a popular Obsidian plugin that integrates LLM chat, vault Q&A, and an agent mode.
|
||||
>
|
||||
> ## What you're working from
|
||||
>
|
||||
> The accompanying document `SETTINGS_REDESIGN_PRD.md` is the spec. It contains:
|
||||
>
|
||||
> - The user audiences (Beginner / Intermediate / Power / Developer) with examples.
|
||||
> - A complete inventory of every setting that exists in the post-revamp world, grouped by _functional purpose_ (not current tabs), with audience tier and notes for each.
|
||||
> - Platform constraints (Obsidian's settings modal, mobile vs desktop, Plus/Believer tiers, eligibility-gated settings).
|
||||
> - 10 IA principles to design against.
|
||||
> - 10 open questions you're expected to engage with.
|
||||
> - A list of removed/deprecated settings (don't design around those).
|
||||
>
|
||||
> Read it carefully before proposing anything.
|
||||
>
|
||||
> ## What I need from you
|
||||
>
|
||||
> 1. **Top-level IA proposal.** What are the top-level sections? In what order? Why?
|
||||
> 2. **Section-by-section breakdown.** For each top-level section, list its sub-groups and which settings (from the PRD inventory) live there. Use the PRD's setting names verbatim so I can map back.
|
||||
> 3. **Progressive disclosure pattern.** Concrete pattern for how essentials show by default and how advanced/performance/experimental knobs collapse. Show the same pattern applied to two different sections so I can see it's repeatable.
|
||||
> 4. **Settings search behavior.** Where it lives, what it searches, what filters it offers, what happens when a setting is in a collapsed group or a gated section.
|
||||
> 5. **Locked / eligibility states.** Visual treatment pattern for: not-Plus, not-Believer, eligibility-checking, eligibility-failed, desktop-only. Show one example per state.
|
||||
> 6. **CRUD surface pattern.** A single repeatable pattern for "settings page that's mostly a list" — applied consistently to the three lists (chat models registry, skills, MCP servers). Show the pattern once and note any per-list deviations.
|
||||
> 7. **Per-backend pattern (Agent Mode).** A repeatable layout for the three agent backends (opencode, claude-code, codex), since their settings share structure.
|
||||
> 8. **Mobile fallback.** What happens on Obsidian mobile? Specifically, how Agent Mode appears.
|
||||
> 9. **Quick-setup path.** How does a brand-new user get from "just installed" to "chat works" in under 60 seconds? Where does this flow live?
|
||||
> 10. **Two layout sketches.** One desktop, one mobile. ASCII or markdown is fine. Show one section at depth — preferably the busiest one (Agent Mode or Vault Search & Indexing) — so I can see how density and disclosure feel.
|
||||
>
|
||||
> ## Engage with the open questions
|
||||
>
|
||||
> The PRD has 10 open questions in §9. Pick a position on each and explain your reasoning briefly. Don't punt.
|
||||
>
|
||||
> ## What you can change
|
||||
>
|
||||
> - You may **rename** settings if a current name is unclear (note the rename and why).
|
||||
> - You may **merge** settings if two are conceptually one (note what you merged).
|
||||
> - You may **split** a setting if one knob is doing two jobs.
|
||||
> - You may **reorder** anything in the inventory.
|
||||
> - You may propose **new affordances** like inline help, contextual tooltips, examples — as long as they serve IA goals.
|
||||
>
|
||||
> ## What you cannot change
|
||||
>
|
||||
> - Don't propose new settings or remove existing ones (the inventory is fixed by the parallel ACP revamp).
|
||||
> - Don't propose visual style choices (colors, typography, iconography). This is IA only.
|
||||
> - Don't redesign the chat UI or any modal flow — only the settings surface.
|
||||
>
|
||||
> ## How to deliver
|
||||
>
|
||||
> One markdown response. Use headings, lists, and ASCII/markdown sketches where helpful. If you need to ask clarifying questions before committing to a structure, ask them first — I'd rather answer 3 good questions than receive a guess.
|
||||
>
|
||||
> Be opinionated. I want a designer's point of view, not a survey of options. Where you make a judgment call, name the trade-off you accepted.
|
||||
|
||||
---
|
||||
|
||||
## Appendix: Reference files
|
||||
|
||||
For implementers (not the designer):
|
||||
|
||||
- [src/settings/model.ts](../src/settings/model.ts) — `CopilotSettings` interface; source of truth for what exists today.
|
||||
- [src/constants.ts](../src/constants.ts) — `DEFAULT_SETTINGS` defaults.
|
||||
- [src/settings/v2/SettingsMainV2.tsx](../src/settings/v2/SettingsMainV2.tsx) — current tab container.
|
||||
- [src/settings/v2/components/](../src/settings/v2/components/) — current per-tab components.
|
||||
- `~/Developer/zeroliu_second_brain/notes/Copilot Feature Audit for ACP - Centric Revamp.md` — source of truth for the post-revamp world.
|
||||
1132
designdocs/SKILLS_MANAGEMENT.md
Normal file
1132
designdocs/SKILLS_MANAGEMENT.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -572,6 +572,10 @@ The following items are intentionally deferred from MVP and should be revisited
|
|||
|
||||
- Decide vault-relative only skills folders vs external/absolute path support.
|
||||
|
||||
9. Advanced UI polish (end-of-project nits)
|
||||
|
||||
- Improve the advanced-section UI in agent mode settings. The current button styling looks rough; revisit visual treatment, spacing, and affordance once functionality is locked in. Bundle with other end-of-project polish passes.
|
||||
|
||||
## 14. Acceptance Criteria
|
||||
|
||||
- Agent mode can run Claude Code, Codex, OpenCode.
|
||||
|
|
|
|||
107
designdocs/todo/AGENT_MODE_TODOS.md
Normal file
107
designdocs/todo/AGENT_MODE_TODOS.md
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
# ACP Agent Mode TODOs
|
||||
|
||||
- P0: Chat history
|
||||
- [x] Load chat history from agents
|
||||
- [x] Save chat history to notes
|
||||
- [x] P0: Thoroughly assess whether migrating to Anthropic agent SDK is worth it.
|
||||
- [ ] P0: [Bug] effort is not passed to claude code correctly on first load if not making any changes
|
||||
- [ ] P0: Test Windows devices
|
||||
- [ ] P0: Make sure bash command shows what command it runs (visibility)
|
||||
- [ ] P0: Provide copilot specific system prompt
|
||||
- [ ] Allow users to share system prompt
|
||||
- [ ] Do a quick spike on the concrete behavior
|
||||
- [ ] Investigate opencode provider specific prompt
|
||||
- [ ] P0: Skills
|
||||
- [x] Check out cc-switch to understand how to make skills compatible cross other agents https://github.com/farion1231/cc-switch
|
||||
- [ ] P0: Permission management
|
||||
- [ ] Permission UI improvement
|
||||
- [ ] Permission "always allow" doesn't seem to persist
|
||||
- [ ] P0: How to design the settings to configure the provider?
|
||||
- [ ] Redesign the model settings - discussed on May 7 group meeting
|
||||
- [ ] support self host model
|
||||
- [ ] remove built-in models
|
||||
- [ ] P0: Fix broken legacy agent mode
|
||||
- [ ] Can we only support basic chat - not now but eventually yes
|
||||
- [ ] P0: Auto-save chat history controls
|
||||
- [ ] P0: Support image context
|
||||
- [ ] P1: [BUG] Check active note path. Sometimes the agent will start from a path that does not exist
|
||||
- [ ] P1: MCP
|
||||
- Basic functionality is ready
|
||||
- [ ] P1: Surface externally-managed MCP servers (claude.ai remote, plugin-provided) — see [MCP_EXTERNALLY_MANAGED_SERVERS.md](./MCP_EXTERNALLY_MANAGED_SERVERS.md)
|
||||
- [ ] P1: Support oauth for MCP servers (the one example that I tested didn't work)
|
||||
- [ ] P1: Support setting MCP by copy pasting JSON blobs
|
||||
- [ ] P1: Content type support (image, audio) - https://agentclientprotocol.com/protocol/content
|
||||
- [ ] P1: Edit diff UI - https://agentclientprotocol.com/protocol/tool-calls#diffs
|
||||
- The edit diff should be based on well rendered markdown, not raw markdown file. For example, table is impossible to understand the diff with the raw format
|
||||
- [ ] P1: Thoroughly test opencode, codex, and claude code with different test cases
|
||||
- [ ] Create sample vaults for test cases.
|
||||
- [ ] P1: Fix session title for claude code agent
|
||||
- [ ] P1: Agent upgrade detection and helper UI in settings
|
||||
- [ ] P1: Forward web-source context to the agent
|
||||
- The agent should be able to access the content of the rendered tab
|
||||
- Right-click "Add to Copilot context" excerpts from web tabs and the
|
||||
"include active web tab" toggle currently surface a Notice and are
|
||||
dropped before the prompt is built. Wire them into the
|
||||
`<copilot-context>` envelope (e.g. a `Web excerpts:` section with
|
||||
`title (url): content`) so the agent can actually read them.
|
||||
- [ ] P1: Token counter
|
||||
- To know how many context is left in the current session
|
||||
- Nice-to-have: Cost estimate
|
||||
- [ ] P1: Integrate copilot plus tool calls (convert them to skills)
|
||||
- [ ] vault search (make it work with Miyo)
|
||||
- may want to rename
|
||||
- challenge - how to enable it in an agent
|
||||
- challenge - agentic search often is better than RAG, how does the agent know when to trigger it
|
||||
- we don't need index-free search
|
||||
- [ ] web search (paid feature only)
|
||||
- [ ] ~~edit~~
|
||||
- [ ] youtube transcription (paid feature only)
|
||||
- [ ] obsidian CLI
|
||||
- [ ] P1: Opencode plan mode fine-tuning
|
||||
- [ ] P1: compaction
|
||||
- [ ] manual trigger
|
||||
- [ ] configure when to auto compact
|
||||
- [ ] P1: Project mode
|
||||
- [ ] P2: Claude vscode plugin add comment to plan capability
|
||||
- It makes iterating on plan a lot easier
|
||||
- [ ] P2: [UX] Make mode more obvious
|
||||
- idea: consider change the chat border color for different modes
|
||||
- [ ] P2: [UX] fix the brief moment of "Read Read" tool call message
|
||||
- [ ] P2: Edit previous user message
|
||||
- [ ] P2: New agent command (/new, /usage)
|
||||
- [ ] P2: Claude code / Codex authentication
|
||||
- [ ] P2: Steering conversation (instead of queue)
|
||||
- [ ] P2: Rollback everything to the state of previous message
|
||||
- [ ] P3: Rerun agent response
|
||||
- [ ] P3: Agent todo list
|
||||
- [ ] make sure in case it renders, it won't be buggy
|
||||
- [x] P1: Queue messages
|
||||
- [x] P1: Only include the provided models
|
||||
- hide openrouter models behind a modal selector
|
||||
- [x] P2: [UX] Thought for x second shows 0 second
|
||||
- [x] P2: Subagent nested tool calls
|
||||
- [x] P2: Keyboard shortcut
|
||||
- [x] P1: Model, effort, and mode is not persisted across sessions
|
||||
- [x] P2: Rebuild agent session tabs and right click context menu
|
||||
- [x] P2: Add agent brand indicator in chat
|
||||
- so user can know which agent harness they are interacting with
|
||||
- [x] P2: Rebrand chat send button
|
||||
- Make it a send icon to save space and get rid of "chat" label which no longer applies
|
||||
- [x] P1: Merge copilot models with opencode models
|
||||
- [x] P1: Agent effort selector
|
||||
- [x] P1: Codex support
|
||||
- [x] P1: Cancel chat
|
||||
- [x] P1: Clean up opencode model list (maybe it's related to the "effort" feature)
|
||||
- [x] P1: Clicking new chat should reset the tab label
|
||||
- [x] P2: Agent survey (asking for user input)
|
||||
- ~~Not possible with ACP, need more digging~~ now possible after migrating to agent SDK
|
||||
- Need to convert the UI to inline card in chat
|
||||
- [x] P1: Agent message is not rendered in the correct order with the tool calls
|
||||
- [x] P1: Plan mode preview display
|
||||
- [x] P1: Support note context input
|
||||
- [[note]], the "+ Note" picker, "include active note", and right-click
|
||||
"Add to Copilot context" now forward vault-relative paths / inlined
|
||||
excerpts in a `<copilot-context>` envelope so the agent's Read tool
|
||||
can fetch them via `VaultClient.readTextFile`.
|
||||
- [x] P1: Agent mode selector (yolo, plan, safe) - https://agentclientprotocol.com/protocol/session-config-options
|
||||
- [x] P1: Basic functionality is added but doesn't work well yet. Need thorough test.
|
||||
59
designdocs/todo/MCP_EXTERNALLY_MANAGED_SERVERS.md
Normal file
59
designdocs/todo/MCP_EXTERNALLY_MANAGED_SERVERS.md
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
# MCP servers managed outside the local config
|
||||
|
||||
## Problem
|
||||
|
||||
When the Claude Code backend is active, the running `claude` binary connects to MCP servers from at least three sources we do not control through `~/.claude.json` or `.mcp.json`:
|
||||
|
||||
1. **claude.ai-provisioned remote MCPs** — Gmail, Google Calendar, Google Drive, Slack, etc. Provisioned server-side per claude.ai account; the binary connects to them at startup over HTTPS.
|
||||
2. **Plugin-provided MCPs** — e.g. `plugin:context7:context7`. Bundled by Claude Code plugins, not declared in any user-editable file.
|
||||
3. **(Already covered by the main sync plan)** Local config MCPs in `~/.claude.json` and `.mcp.json`.
|
||||
|
||||
`claude mcp list` enumerates all three at runtime. `~/.claude.json` only contains category 3. The only local hint of category 1 is a `claudeAiMcpEverConnected: string[]` field, which is informational ("ever connected", not "currently active") and does not include category 2 at all.
|
||||
|
||||
### Why this is bad
|
||||
|
||||
If the obsidian-copilot MCP panel only shows local-config entries, a user who already has `claude.ai Gmail` connected may add their own `gmail` MCP through the panel. Both register tools under similar/overlapping namespaces; the agent calls become ambiguous, and the user has no signal that the conflict exists.
|
||||
|
||||
We've already locked down "extra registration via ACP" by passing `mcpServers: []` when the backend owns runtime registration (see `MCP_BACKEND_SYNC` plan). That stops obsidian-copilot from causing duplicates itself, but it does not prevent the user from creating a duplicate **inside** the local config that collides with a remote claude.ai MCP.
|
||||
|
||||
## What we cannot do
|
||||
|
||||
- **Disable claude.ai MCPs locally.** No CLI flag, no env var, no config key. Only off-switch is at claude.ai/settings → Connectors (web) or signing the connector out.
|
||||
- **Disable plugin MCPs without disabling the plugin.** No per-MCP toggle.
|
||||
- **Get a structured (JSON) list from `claude mcp list`.** Output is human-formatted; parsing is brittle and version-coupled.
|
||||
|
||||
Treating these as user-editable is therefore not on the table. The only available shape is **read-only awareness**.
|
||||
|
||||
## Options
|
||||
|
||||
| Option | Effect | Cost |
|
||||
| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
|
||||
| **A. Surface via `claude mcp list`** | Shell out, parse output, render claude.ai/_ and plugin/_ entries as read-only "Managed externally" rows alongside editable local ones. Solves "I don't see it". | Brittle parsing; spawns `claude` (~500ms–1s) on panel mount. Output format may change between Claude Code versions. |
|
||||
| **B. Static informational note** | Show "Claude Code may connect to remote claude.ai MCPs (Gmail, Calendar, etc.) that aren't editable here. Manage them at claude.ai/settings → Connectors." Just text, no enumeration. | Cheap. Doesn't tell the user _which_ servers, so they can still duplicate by name. |
|
||||
| **C. Hybrid: warn on name conflict** | Pre-fill known claude.ai MCP names (gmail, calendar, slack, drive — small static list) and warn if user adds a server matching a known remote. | Heuristic; misses unknown remote MCPs and any future additions. |
|
||||
| **D. Do nothing** | Status quo. User experiences conflict, debugs, learns. | Bad UX. |
|
||||
|
||||
### Recommendation
|
||||
|
||||
**A + B combined.** Shell out to `claude mcp list` once when the panel mounts, render claude.ai/_ and plugin/_ entries as read-only rows tagged "Managed by claude.ai" / "Managed by plugin", with a static note pointing to claude.ai/settings → Connectors. This is exactly what the user already sees in the terminal, lifted into Obsidian. Cache the result for the lifetime of the panel mount; add a manual "Refresh" button to reread.
|
||||
|
||||
Risks to mitigate before implementation:
|
||||
|
||||
- Parser must tolerate format changes — fail closed (omit external rows + log warning), never crash the panel.
|
||||
- The shell-out must be debounced and cancellable; don't block initial render.
|
||||
- If `claude` is not on PATH at panel mount (binary configured via custom path only), fall back to invoking via the configured `binaryPath` if the same binary supports `mcp list` (it does — same binary).
|
||||
|
||||
## Open questions
|
||||
|
||||
1. **Render claude.ai/plugin entries inline, or behind a "show external MCPs" disclosure toggle?** Inline is more discoverable; toggle keeps the panel uncluttered for users who never touch external MCPs.
|
||||
2. **Refresh cadence:** mount-only + manual button, or also re-fetch on `subscribe()` callback from the local-config file watch (since external state can change without local file changes)?
|
||||
3. **Backend coverage:** does the same problem exist for OpenCode / future Codex backend? If so, the read-only surface should live on `McpStorageAdapter` (`listExternal()` method) rather than be Claude-Code specific.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Any attempt to disable, mute, or hide claude.ai MCPs at runtime. The user must do that through claude.ai web settings; we can only point them there.
|
||||
- Forking or patching `claude-agent-acp`.
|
||||
|
||||
## Related
|
||||
|
||||
- Main sync plan: see [`AGENT_MODE_TODOS.md`](./AGENT_MODE_TODOS.md) — _P1: Syncing and managing MCP registered in the backends_. This doc covers the externally-managed sub-case that the main sync plan deliberately does not address.
|
||||
|
|
@ -1,9 +1,35 @@
|
|||
import esbuild from "esbuild";
|
||||
import svgPlugin from "esbuild-plugin-svg";
|
||||
import { transform as svgrTransform } from "@svgr/core";
|
||||
import jsxPlugin from "@svgr/plugin-jsx";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import process from "process";
|
||||
import { createRequire } from "module";
|
||||
import wasmPlugin from "./wasmPlugin.mjs";
|
||||
import nodeModuleShim from "./nodeModuleShim.mjs";
|
||||
|
||||
// Inline SVGR plugin: each `import Foo from "./foo.svg"` resolves to a React
|
||||
// component (`React.FC<SVGProps<SVGSVGElement>>`) instead of a raw string.
|
||||
// Source SVGs use `fill="currentColor"`, so theme color follows automatically.
|
||||
const svgrPlugin = {
|
||||
name: "svgr",
|
||||
setup(build) {
|
||||
build.onLoad({ filter: /\.svg$/ }, async (args) => {
|
||||
const svg = await readFile(args.path, "utf8");
|
||||
const contents = await svgrTransform(
|
||||
svg,
|
||||
{ jsxRuntime: "classic", typescript: false, plugins: [jsxPlugin] },
|
||||
{ filePath: args.path, caller: { name: "esbuild-plugin-inline-svgr" } }
|
||||
);
|
||||
return { contents, loader: "jsx" };
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
// CommonJS plugin loaded via createRequire — pure JS, no ESM export needed.
|
||||
const patchRendererUnsafeUnref = createRequire(import.meta.url)(
|
||||
"./scripts/patchRendererUnsafeUnref.js"
|
||||
);
|
||||
|
||||
const banner = `/*
|
||||
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
|
||||
if you want to view the source, please visit the github repository of this plugin
|
||||
|
|
@ -39,14 +65,38 @@ const context = await esbuild.context({
|
|||
"@lezer/common",
|
||||
"@lezer/highlight",
|
||||
"@lezer/lr",
|
||||
// Node.js built-in modules (available in Electron) - except node:module which we shim
|
||||
// Node.js built-in modules (available in Electron) - except `module` /
|
||||
// `node:module` which we shim. Both prefixed (`node:foo`) and bare
|
||||
// (`foo`) forms are listed because @anthropic-ai/claude-agent-sdk and
|
||||
// its transitive deps mix the two styles.
|
||||
"node:fs",
|
||||
"node:fs/promises",
|
||||
"node:path",
|
||||
"node:os",
|
||||
"node:url",
|
||||
"node:buffer",
|
||||
"node:stream",
|
||||
"node:crypto",
|
||||
"node:events",
|
||||
"node:async_hooks",
|
||||
"node:child_process",
|
||||
"node:http",
|
||||
"node:https",
|
||||
"node:util",
|
||||
"node:readline",
|
||||
"node:process",
|
||||
"async_hooks",
|
||||
"child_process",
|
||||
"crypto",
|
||||
"events",
|
||||
"fs",
|
||||
"fs/promises",
|
||||
"os",
|
||||
"path",
|
||||
"process",
|
||||
"readline",
|
||||
"url",
|
||||
"util",
|
||||
],
|
||||
format: "cjs",
|
||||
target: "es2020",
|
||||
|
|
@ -54,7 +104,7 @@ const context = await esbuild.context({
|
|||
sourcemap: prod ? false : "inline",
|
||||
treeShaking: true,
|
||||
outfile: "main.js",
|
||||
plugins: [nodeModuleShim, svgPlugin(), wasmPlugin],
|
||||
plugins: [nodeModuleShim, svgrPlugin, wasmPlugin, patchRendererUnsafeUnref],
|
||||
define: {
|
||||
global: "window",
|
||||
"process.env.NODE_ENV": prod ? '"production"' : '"development"',
|
||||
|
|
|
|||
|
|
@ -2,18 +2,12 @@ import obsidianmd from "eslint-plugin-obsidianmd";
|
|||
import eslintReact from "@eslint-react/eslint-plugin";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import tailwind from "eslint-plugin-tailwindcss";
|
||||
import boundaries from "eslint-plugin-boundaries";
|
||||
import globals from "globals";
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: [
|
||||
"node_modules/**",
|
||||
"main.js",
|
||||
"styles.css",
|
||||
"data.json",
|
||||
"designdocs/**",
|
||||
"docs/**",
|
||||
],
|
||||
ignores: ["node_modules/**", "main.js", "styles.css", "data.json", "designdocs/**", "docs/**"],
|
||||
},
|
||||
|
||||
// obsidianmd recommended brings:
|
||||
|
|
@ -119,16 +113,35 @@ export default [
|
|||
},
|
||||
},
|
||||
|
||||
// Guardrail: every standalone React root in the plugin must go through
|
||||
// `createPluginRoot` so descendants can rely on `useApp()` unconditionally
|
||||
// (the bug class fixed in PR #2466). Forbid importing `createRoot` from
|
||||
// `react-dom/client` anywhere except the helper itself.
|
||||
// Two AST-level import bans, combined in one block:
|
||||
//
|
||||
// 1. Parent-relative imports (`../foo`, `..`) — use the `@/` path alias
|
||||
// instead. Survives file moves, keeps grep unambiguous, avoids long
|
||||
// `../../../` chains. Same-directory `./foo` remains allowed.
|
||||
//
|
||||
// 2. `createRoot` from `react-dom/client` outside `createPluginRoot` —
|
||||
// every standalone React root must go through that helper so descendants
|
||||
// can rely on `useApp()` unconditionally (bug class fixed in PR #2466).
|
||||
//
|
||||
// Both selectors must live in the same block: flat config replaces (does
|
||||
// not merge) rule values when the same rule key appears in multiple
|
||||
// matching blocks, so splitting them would silently disable the earlier
|
||||
// ban on every file the later block also matches.
|
||||
//
|
||||
// `createPluginRoot.tsx` is exempted via `ignores` — it owns `createRoot`,
|
||||
// and has no parent imports today.
|
||||
{
|
||||
files: ["src/**/*.{ts,tsx}"],
|
||||
ignores: ["src/utils/react/createPluginRoot.tsx"],
|
||||
rules: {
|
||||
"no-restricted-syntax": [
|
||||
"error",
|
||||
{
|
||||
selector:
|
||||
"ImportDeclaration[source.value=/^\\.\\.($|\\u002f)/], ImportExpression[source.value=/^\\.\\.($|\\u002f)/]",
|
||||
message:
|
||||
"Parent-relative imports (`../foo`) are banned. Use the `@/` path alias (e.g. `@/components/Foo`) instead.",
|
||||
},
|
||||
{
|
||||
selector:
|
||||
"ImportDeclaration[source.value='react-dom/client'] ImportSpecifier[imported.name='createRoot']",
|
||||
|
|
@ -153,6 +166,10 @@ export default [
|
|||
// Tests use intentional `any` mocks; disable type-safety rules that flood
|
||||
// the test suite without adding signal.
|
||||
"@typescript-eslint/no-unsafe-member-access": "off",
|
||||
// Tests freely reach across layers and import ACP wire types directly to
|
||||
// build fixtures; the layer enforcement only applies to production code.
|
||||
"boundaries/dependencies": "off",
|
||||
"no-restricted-imports": "off",
|
||||
},
|
||||
},
|
||||
|
||||
|
|
@ -196,6 +213,130 @@ export default [
|
|||
},
|
||||
},
|
||||
|
||||
// Agent Mode: backends spawn subprocesses (ACP) and the in-process Claude
|
||||
// SDK uses node:async_hooks. The plugin runs in Electron renderer where
|
||||
// these modules are available; the desktop-only Agent Mode is also gated by
|
||||
// `Platform.isMobile` at runtime in main.ts.
|
||||
// detectBinary / rendererEventsShim are sibling utilities pulled in by
|
||||
// agent-mode wiring and share the same Electron-renderer assumptions.
|
||||
{
|
||||
files: ["src/agentMode/**", "src/utils/detectBinary.ts", "src/utils/rendererEventsShim.ts"],
|
||||
rules: {
|
||||
"import/no-nodejs-modules": "off",
|
||||
},
|
||||
},
|
||||
|
||||
// Element types (order matters — first match wins; files before folders):
|
||||
// registry src/agentMode/backends/registry.ts (file)
|
||||
// barrel src/agentMode/index.ts (file)
|
||||
// session src/agentMode/session
|
||||
// acp src/agentMode/acp
|
||||
// sdk src/agentMode/sdk
|
||||
// backend src/agentMode/backends/<name>
|
||||
// ui src/agentMode/ui
|
||||
// skills src/agentMode/skills
|
||||
// host src/** (everything else under src/)
|
||||
{
|
||||
files: ["src/**/*.{ts,tsx,js,jsx}"],
|
||||
plugins: { boundaries },
|
||||
settings: {
|
||||
// Required so `eslint-plugin-boundaries` can resolve `@/*` path aliases
|
||||
// to their `src/*` targets.
|
||||
"import/resolver": {
|
||||
typescript: { project: "./tsconfig.json" },
|
||||
node: true,
|
||||
},
|
||||
"boundaries/include": ["src/**/*"],
|
||||
"boundaries/elements": [
|
||||
{ type: "registry", pattern: "src/agentMode/backends/registry.ts", mode: "file" },
|
||||
{ type: "barrel", pattern: "src/agentMode/index.ts", mode: "file" },
|
||||
{ type: "session", pattern: "src/agentMode/session" },
|
||||
{ type: "acp", pattern: "src/agentMode/acp" },
|
||||
{ type: "sdk", pattern: "src/agentMode/sdk" },
|
||||
{ type: "backend", pattern: "src/agentMode/backends/*", capture: ["name"] },
|
||||
{ type: "ui", pattern: "src/agentMode/ui" },
|
||||
{ type: "skills", pattern: "src/agentMode/skills" },
|
||||
{ type: "host", pattern: "src/**" },
|
||||
],
|
||||
},
|
||||
rules: {
|
||||
"boundaries/dependencies": [
|
||||
"error",
|
||||
{
|
||||
default: "disallow",
|
||||
rules: [
|
||||
{ from: { type: "session" }, allow: { to: { type: ["session", "host"] } } },
|
||||
{ from: { type: "acp" }, allow: { to: { type: ["acp", "session", "host"] } } },
|
||||
{ from: { type: "sdk" }, allow: { to: { type: ["sdk", "session", "host"] } } },
|
||||
{
|
||||
from: { type: "backend" },
|
||||
allow: [
|
||||
{ to: { type: ["acp", "sdk", "session", "skills", "host"] } },
|
||||
{ to: { type: "backend", captured: { name: "{{from.captured.name}}" } } },
|
||||
{ to: { type: "backend", captured: { name: "shared" } } },
|
||||
],
|
||||
},
|
||||
{ from: { type: "registry" }, allow: { to: { type: ["backend", "session", "host"] } } },
|
||||
{
|
||||
from: { type: "ui" },
|
||||
allow: {
|
||||
to: { type: ["ui", "session", "registry", "skills", "host"] },
|
||||
},
|
||||
},
|
||||
{
|
||||
from: { type: "skills" },
|
||||
allow: { to: { type: ["skills", "session", "host", "registry"] } },
|
||||
},
|
||||
{
|
||||
from: { type: "barrel" },
|
||||
allow: {
|
||||
to: {
|
||||
type: ["acp", "session", "sdk", "backend", "registry", "ui", "skills", "host"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{ from: { type: "host" }, allow: { to: { type: ["host", "barrel"] } } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
// Re-disable boundaries/dependencies for tests — the block above otherwise
|
||||
// re-enables the rule for test files via the broader `src/**` pattern.
|
||||
{
|
||||
files: ["**/*.test.{js,jsx,ts,tsx}", "jest.setup.js", "__mocks__/**"],
|
||||
rules: {
|
||||
"boundaries/dependencies": "off",
|
||||
},
|
||||
},
|
||||
|
||||
// Only acp/ may import `@agentclientprotocol/sdk`. Tests are exempted via
|
||||
// the test block; acp/ itself is exempted below.
|
||||
{
|
||||
files: ["src/**/*.{ts,tsx}"],
|
||||
rules: {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
paths: [
|
||||
{
|
||||
name: "@agentclientprotocol/sdk",
|
||||
message:
|
||||
"ACP wire types are confined to src/agentMode/acp/. session/, sdk/, ui/, backends/, and skills/ should depend on the session-domain types in @/agentMode/session/types instead. See src/agentMode/AGENTS.md.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["src/agentMode/acp/**/*.{ts,tsx}"],
|
||||
rules: {
|
||||
"no-restricted-imports": "off",
|
||||
},
|
||||
},
|
||||
|
||||
// TypeScript-specific overrides (the @typescript-eslint plugin is registered
|
||||
// by obsidianmd's recommended config only for .ts/.tsx files).
|
||||
{
|
||||
|
|
@ -227,6 +368,20 @@ export default [
|
|||
},
|
||||
},
|
||||
|
||||
// Agent Mode tests use heavy `any` mocking for backend / SDK / ACP wire
|
||||
// types whose real shapes are vendor-controlled and inconvenient to model
|
||||
// in test scaffolding. Loosen the test-only unsafe rules for the
|
||||
// agent-mode subtree only; production code stays enforced. Placed after the
|
||||
// general TS block so it actually overrides.
|
||||
{
|
||||
files: ["src/agentMode/**/*.test.{ts,tsx}"],
|
||||
rules: {
|
||||
"@typescript-eslint/no-unsafe-assignment": "off",
|
||||
"@typescript-eslint/no-unsafe-argument": "off",
|
||||
"@typescript-eslint/no-unsafe-return": "off",
|
||||
},
|
||||
},
|
||||
|
||||
// Non-TS files aren't in tsconfig.json — disable type-aware rules that
|
||||
// obsidianmd's recommended config enables globally. Most typed obsidianmd
|
||||
// rules are already gated to **/*.ts(x); only no-plugin-as-component leaks
|
||||
|
|
@ -249,7 +404,9 @@ export default [
|
|||
"error",
|
||||
{
|
||||
presets: ["native", "microutilities", "preferred"],
|
||||
allowed: [],
|
||||
// dotenv is used only by integration tests to load .env.test;
|
||||
// the native --env-file flag doesn't work in jest.
|
||||
allowed: ["dotenv"],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -6,12 +6,15 @@ module.exports = {
|
|||
"^.+\\.(js|jsx|ts|tsx)$": "ts-jest",
|
||||
},
|
||||
moduleNameMapper: {
|
||||
"\\.svg$": "<rootDir>/__mocks__/svg.js",
|
||||
"^@/(.*)$": "<rootDir>/src/$1",
|
||||
"^obsidian$": "<rootDir>/__mocks__/obsidian.js",
|
||||
// The yaml package's "exports" field defaults to a browser ESM entry under
|
||||
// jsdom; Jest can't parse ESM without extra config, so point at the CJS
|
||||
// build it ships under dist/.
|
||||
"^yaml$": "<rootDir>/node_modules/yaml/dist/index.js",
|
||||
"^@agentclientprotocol/sdk$": "<rootDir>/__mocks__/@agentclientprotocol/sdk.js",
|
||||
"^@anthropic-ai/claude-agent-sdk$": "<rootDir>/__mocks__/@anthropic-ai/claude-agent-sdk.js",
|
||||
},
|
||||
testRegex: ".*\\.test\\.(jsx?|tsx?)$",
|
||||
moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"],
|
||||
|
|
|
|||
|
|
@ -2,8 +2,10 @@
|
|||
const nodeModuleShim = {
|
||||
name: "node-module-shim",
|
||||
setup(build) {
|
||||
// Intercept node:module imports and provide a shim
|
||||
build.onResolve({ filter: /^node:module$/ }, (args) => {
|
||||
// Intercept node:module / module imports and provide a shim. Both prefixed
|
||||
// and bare forms are matched — @anthropic-ai/claude-agent-sdk imports the
|
||||
// bare form, while @langchain/community uses node:module.
|
||||
build.onResolve({ filter: /^(node:)?module$/ }, (args) => {
|
||||
return {
|
||||
path: args.path,
|
||||
namespace: "node-module-shim",
|
||||
|
|
|
|||
2811
package-lock.json
generated
2811
package-lock.json
generated
File diff suppressed because it is too large
Load diff
16
package.json
16
package.json
|
|
@ -40,6 +40,8 @@
|
|||
"@google/generative-ai": "^0.24.0",
|
||||
"@jest/globals": "^29.7.0",
|
||||
"@langchain/ollama": "^1.2.2",
|
||||
"@svgr/core": "^8.1.0",
|
||||
"@svgr/plugin-jsx": "^8.1.0",
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
"@testing-library/react": "^14.0.0",
|
||||
"@types/diff": "^7.0.1",
|
||||
|
|
@ -54,6 +56,8 @@
|
|||
"esbuild": "^0.25.0",
|
||||
"esbuild-plugin-svg": "^0.1.0",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-import-resolver-typescript": "^4.4.4",
|
||||
"eslint-plugin-boundaries": "^6.0.2",
|
||||
"eslint-plugin-obsidianmd": "^0.3.0",
|
||||
"eslint-plugin-react-hooks": "^5.1.0",
|
||||
"eslint-plugin-tailwindcss": "^3.18.0",
|
||||
|
|
@ -64,7 +68,7 @@
|
|||
"nano-staged": "^0.8.0",
|
||||
"npm-run-all2": "^7.0.2",
|
||||
"obsidian": "^1.2.5",
|
||||
"prettier": "^3.3.3",
|
||||
"prettier": "3.8.3",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"ts-jest": "^29.1.0",
|
||||
|
|
@ -74,6 +78,8 @@
|
|||
"yaml": "^2.6.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.20.0",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.126",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
|
|
@ -107,19 +113,23 @@
|
|||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"diff": "^7.0.0",
|
||||
"dotenv": "^17.4.2",
|
||||
"fuzzysort": "^3.1.0",
|
||||
"jotai": "^2.10.3",
|
||||
"lexical": "^0.34.0",
|
||||
"lucide-react": "^0.462.0",
|
||||
"luxon": "^3.5.0",
|
||||
"minisearch": "^7.2.0",
|
||||
"openai": "^6.10.0",
|
||||
"openai": "^6.34.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-resizable-panels": "^3.0.2",
|
||||
"tailwind-merge": "^2.5.5",
|
||||
"turndown": "^7.2.2",
|
||||
"uuid": "^11.1.0",
|
||||
"zod": "^3.25.76"
|
||||
"zod": "^4.4.2"
|
||||
},
|
||||
"overrides": {
|
||||
"@browserbasehq/stagehand": "3.2.1"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
241
scripts/patchRendererUnsafeUnref.js
Normal file
241
scripts/patchRendererUnsafeUnref.js
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
"use strict";
|
||||
|
||||
const fs = require("fs");
|
||||
|
||||
/**
|
||||
* Esbuild plugin: rewrite every `setTimeout(...).unref()` site in the bundled
|
||||
* output to `(t=>{t&&t.unref&&t.unref()})(setTimeout(...))`.
|
||||
*
|
||||
* Why: the `@anthropic-ai/claude-agent-sdk` process-transport-close path and
|
||||
* the transitive `@modelcontextprotocol/sdk` stdio-close-wait both call
|
||||
* `.unref()` on a `setTimeout` handle. In Electron's renderer process the
|
||||
* timer object lacks `.unref()`, and calling it crashes the renderer at
|
||||
* teardown. The wrapper preserves Node-side semantics and is a safe no-op
|
||||
* in the renderer.
|
||||
*
|
||||
* Runs as an `onEnd` plugin so it sees the final, post-minify, on-disk
|
||||
* output. After rewriting it re-scans the file and throws if any
|
||||
* unmatched site remains, failing the build.
|
||||
*/
|
||||
const patchRendererUnsafeUnref = {
|
||||
name: "patch-renderer-unsafe-unref",
|
||||
setup(build) {
|
||||
build.onEnd((result) => {
|
||||
// Skip when the build itself failed — esbuild has already surfaced errors.
|
||||
if (result.errors && result.errors.length > 0) return;
|
||||
|
||||
const outfile = build.initialOptions.outfile;
|
||||
if (!outfile) {
|
||||
throw new Error(
|
||||
"[patch-renderer-unsafe-unref] expected build.initialOptions.outfile to be set"
|
||||
);
|
||||
}
|
||||
|
||||
let source;
|
||||
try {
|
||||
source = fs.readFileSync(outfile, "utf8");
|
||||
} catch (err) {
|
||||
throw new Error(`[patch-renderer-unsafe-unref] failed to read ${outfile}: ${err.message}`);
|
||||
}
|
||||
|
||||
// The SDKs whose unsafe `.unref()` sites motivate this patch
|
||||
// (@anthropic-ai/claude-agent-sdk, @modelcontextprotocol/sdk) only get
|
||||
// bundled once host code actually imports them. Until that happens the
|
||||
// bundle has no `setTimeout(...).unref()` sites — that's fine, the
|
||||
// patch becomes a no-op. The marker probe below distinguishes that
|
||||
// benign case from "SDK is bundled but the matcher failed."
|
||||
const sdkBundled = source.includes("@anthropic-ai/claude-agent-sdk");
|
||||
|
||||
// Iterate: each pass finds all top-level `setTimeout(...).unref()`
|
||||
// sites, rewrites them right-to-left, and re-scans. Nested sites
|
||||
// (e.g. an inner `setTimeout(...).unref()` living inside an outer
|
||||
// setTimeout's argument list) are shadowed by their outer site on
|
||||
// any single pass, so without iteration the verifier complains they
|
||||
// remain. Cap at a few passes so a matcher bug can't infinite-loop.
|
||||
let out = source;
|
||||
let totalRewritten = 0;
|
||||
const MAX_PASSES = 8;
|
||||
for (let pass = 0; pass < MAX_PASSES; pass++) {
|
||||
const sites = findUnsafeSites(out);
|
||||
if (sites.length === 0) break;
|
||||
for (let i = sites.length - 1; i >= 0; i--) {
|
||||
const { setTimeoutStart, setTimeoutEnd, fullEnd } = sites[i];
|
||||
const setTimeoutCall = out.slice(setTimeoutStart, setTimeoutEnd);
|
||||
const wrapped = `(t=>{t&&t.unref&&t.unref()})(${setTimeoutCall})`;
|
||||
out = out.slice(0, setTimeoutStart) + wrapped + out.slice(fullEnd);
|
||||
}
|
||||
totalRewritten += sites.length;
|
||||
}
|
||||
|
||||
if (totalRewritten === 0) {
|
||||
if (sdkBundled) {
|
||||
throw new Error(
|
||||
"[patch-renderer-unsafe-unref] @anthropic-ai/claude-agent-sdk " +
|
||||
"appears bundled but no `setTimeout(...).unref()` sites were " +
|
||||
"found. The matcher likely missed them after a minifier change."
|
||||
);
|
||||
}
|
||||
|
||||
console.log("[patch-renderer-unsafe-unref] no sites to rewrite (SDK not bundled yet)");
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify: re-scan the final rewritten output. Any remaining site means
|
||||
// the scanner missed something or iteration didn't converge — fail
|
||||
// loud.
|
||||
const remaining = findUnsafeSites(out);
|
||||
if (remaining.length > 0) {
|
||||
throw new Error(
|
||||
`[patch-renderer-unsafe-unref] verifier found ${remaining.length} ` +
|
||||
"unsafe `setTimeout(...).unref()` site(s) still present after " +
|
||||
"rewrite. First site near offset " +
|
||||
remaining[0].setTimeoutStart +
|
||||
"."
|
||||
);
|
||||
}
|
||||
|
||||
fs.writeFileSync(outfile, out, "utf8");
|
||||
|
||||
console.log(`[patch-renderer-unsafe-unref] rewrote ${totalRewritten} site(s)`);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Find every `setTimeout(<balanced>)\.unref\(\)` site in the source.
|
||||
* Returns an array sorted by offset.
|
||||
*
|
||||
* The scanner is string-literal-aware (single, double, backtick, and
|
||||
* line/block comments) so parentheses inside string contents don't
|
||||
* unbalance the count. Regex literals are intentionally not handled —
|
||||
* minified output rarely emits regex literals adjacent to `(` of a call,
|
||||
* and the SDK call sites are simple.
|
||||
*/
|
||||
function findUnsafeSites(source) {
|
||||
const NEEDLE = "setTimeout(";
|
||||
const sites = [];
|
||||
let i = 0;
|
||||
while (i <= source.length - NEEDLE.length) {
|
||||
const at = source.indexOf(NEEDLE, i);
|
||||
if (at === -1) break;
|
||||
// Reject identifier-prefixed matches like `mySetTimeout(` — the char before
|
||||
// must not be an identifier-continuation character or `.`.
|
||||
if (at > 0) {
|
||||
const prev = source.charCodeAt(at - 1);
|
||||
const isIdentChar =
|
||||
(prev >= 0x30 && prev <= 0x39) || // 0-9
|
||||
(prev >= 0x41 && prev <= 0x5a) || // A-Z
|
||||
(prev >= 0x61 && prev <= 0x7a) || // a-z
|
||||
prev === 0x24 || // $
|
||||
prev === 0x5f || // _
|
||||
prev === 0x2e; // .
|
||||
if (isIdentChar) {
|
||||
i = at + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const setTimeoutStart = at;
|
||||
const argStart = at + NEEDLE.length;
|
||||
const closeIdx = findMatchingParen(source, argStart);
|
||||
if (closeIdx === -1) {
|
||||
i = at + 1;
|
||||
continue;
|
||||
}
|
||||
const setTimeoutEnd = closeIdx + 1; // one past the `)`
|
||||
if (source.startsWith(".unref()", setTimeoutEnd)) {
|
||||
sites.push({
|
||||
setTimeoutStart,
|
||||
setTimeoutEnd,
|
||||
fullEnd: setTimeoutEnd + ".unref()".length,
|
||||
});
|
||||
i = setTimeoutEnd + ".unref()".length;
|
||||
} else {
|
||||
i = at + 1;
|
||||
}
|
||||
}
|
||||
return sites;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a position immediately after an opening `(`, return the index of
|
||||
* its matching `)`, accounting for nested parens, string literals
|
||||
* (`'`, `"`, `` ` ``), and `// ` / `/* ... *\/` comments. Returns -1 if
|
||||
* unbalanced.
|
||||
*/
|
||||
function findMatchingParen(source, start) {
|
||||
let depth = 1;
|
||||
let i = start;
|
||||
while (i < source.length) {
|
||||
const ch = source[i];
|
||||
if (ch === "(") {
|
||||
depth++;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === ")") {
|
||||
depth--;
|
||||
if (depth === 0) return i;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === "'" || ch === '"' || ch === "`") {
|
||||
i = skipString(source, i, ch);
|
||||
continue;
|
||||
}
|
||||
if (ch === "/" && i + 1 < source.length) {
|
||||
const next = source[i + 1];
|
||||
if (next === "/") {
|
||||
i = source.indexOf("\n", i + 2);
|
||||
if (i === -1) return -1;
|
||||
continue;
|
||||
}
|
||||
if (next === "*") {
|
||||
const end = source.indexOf("*/", i + 2);
|
||||
if (end === -1) return -1;
|
||||
i = end + 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
i++;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Skip a string literal starting at `i` whose opening quote is `quote`.
|
||||
* Returns the index just past the closing quote. Handles backslash escapes.
|
||||
* For template literals, recursively skips `${...}` expressions.
|
||||
*/
|
||||
function skipString(source, i, quote) {
|
||||
i++; // past opening quote
|
||||
while (i < source.length) {
|
||||
const ch = source[i];
|
||||
if (ch === "\\") {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (ch === quote) return i + 1;
|
||||
if (quote === "`" && ch === "$" && source[i + 1] === "{") {
|
||||
// Skip the `${ ... }` interpolation, balancing inner braces.
|
||||
let depth = 1;
|
||||
let j = i + 2;
|
||||
while (j < source.length && depth > 0) {
|
||||
const c = source[j];
|
||||
if (c === "{") depth++;
|
||||
else if (c === "}") depth--;
|
||||
else if (c === "'" || c === '"' || c === "`") {
|
||||
j = skipString(source, j, c);
|
||||
continue;
|
||||
}
|
||||
j++;
|
||||
}
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
return i;
|
||||
}
|
||||
|
||||
module.exports = patchRendererUnsafeUnref;
|
||||
|
|
@ -2,7 +2,7 @@ import { ABORT_REASON, AI_SENDER } from "@/constants";
|
|||
import { logError, logInfo } from "@/logger";
|
||||
import { ChatMessage, ResponseMetadata } from "@/types/message";
|
||||
import { err2String, formatDateTime } from "@/utils";
|
||||
import ChainManager from "../chainManager";
|
||||
import ChainManager from "@/LLMProviders/chainManager";
|
||||
|
||||
export interface ChainRunner {
|
||||
run(
|
||||
|
|
|
|||
172
src/agentMode/AGENTS.md
Normal file
172
src/agentMode/AGENTS.md
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
# Agent Mode — layer rules
|
||||
|
||||
Six element types, strict imports. Enforced by `eslint-plugin-boundaries`
|
||||
(see root `eslint.config.mjs`). The list below mirrors `boundaries/elements` and
|
||||
`boundaries/dependencies` exactly — when in doubt, the lint config wins.
|
||||
|
||||
1. **`session/`** — **the contract layer.** Hosts the
|
||||
backend-agnostic session, message store, UI-state bridge,
|
||||
persistence manager.
|
||||
2. **`acp/`** — the generic ACP runtime (subprocess, JSON-RPC connection,
|
||||
vault MCP client, JSON-RPC stream tap) —
|
||||
the only place that touches `@agentclientprotocol/sdk`. **All ACP knowledge is confined here.**
|
||||
3. **`sdk/`** — in-process SDK adapters that implement
|
||||
`BackendProcess` directly. Today
|
||||
this hosts the Claude Agent SDK driver. **Does not import `acp/` or `@agentclientprotocol/sdk`** —
|
||||
`acp/` and `sdk/` are siblings, and the SDK adapter speaks the
|
||||
session domain natively.
|
||||
4. **`backends/<id>/`** — one backend per folder. Exports a
|
||||
`BackendDescriptor` whose `createBackendProcess` returns a finished
|
||||
`BackendProcess`.
|
||||
5. **`backends/registry.ts`** — the only place that names every
|
||||
backend.
|
||||
6. **`ui/`** — backend-agnostic React UI. — permission modals, model pickers, and
|
||||
trail rendering all read session-domain types;
|
||||
7. **`skills/`** — canonical-store discovery, symlink lifecycle, reconciliation,
|
||||
and the Skills settings UI.
|
||||
|
||||
## Why two adapters under one session
|
||||
|
||||
The contract lives in `session/types.ts` (`BackendProcess`,
|
||||
`BackendDescriptor`, the full session-domain type set) and
|
||||
`session/errors.ts` (`MethodUnsupportedError`). `acp/AcpBackendProcess`
|
||||
wraps a JSON-RPC subprocess and translates ACP wire ↔ session-domain
|
||||
at its public boundary (via `acp/wireTranslate.ts`);
|
||||
`sdk/ClaudeSdkBackendProcess` wraps an in-process async generator and
|
||||
emits session-domain events natively. Both produce the same
|
||||
`SessionEvent` stream and `BackendState`, so `AgentSession` stays
|
||||
oblivious. Crucially, **neither adapter imports the other**,
|
||||
`session/` doesn't import either, and `@agentclientprotocol/sdk`
|
||||
imports outside `acp/` are blocked by lint
|
||||
(`no-restricted-imports`).
|
||||
|
||||
## Adding a new backend
|
||||
|
||||
Pick a track based on what the agent gives you:
|
||||
|
||||
- **Subprocess track** (codex, opencode) — the agent speaks ACP
|
||||
over stdio. Implement `AcpBackend` in `Backend.ts` and have the
|
||||
descriptor's `createBackendProcess(args)` call
|
||||
`simpleBinaryBackendProcess(args, new <Id>Backend())` from
|
||||
`backends/shared/simpleBinaryBackend.ts`. The helper wraps the
|
||||
spawn descriptor in `AcpBackendProcess` for you.
|
||||
- **In-process / SDK track** (claude) — the agent ships an
|
||||
in-process SDK. Put the `BackendProcess` implementation in `sdk/`
|
||||
if any logic is reusable (translator, debug tap, MCP shim) and
|
||||
have the descriptor's `createBackendProcess(args)` construct it
|
||||
directly.
|
||||
|
||||
Then in either case:
|
||||
|
||||
1. Create `backends/<id>/` with:
|
||||
- `descriptor.ts` — `export const <Id>BackendDescriptor: BackendDescriptor = {…}`
|
||||
- `index.ts` — re-exports the descriptor
|
||||
- any backend-specific UI (install modal, settings panel,
|
||||
permission modal) co-located here
|
||||
- `Backend.ts` (subprocess track only)
|
||||
2. Add the entry to `backends/registry.ts`.
|
||||
3. Settings: store backend-specific config under `agentMode.backends.<id>`
|
||||
(extend `CopilotSettings.agentMode.backends` in `src/settings/model.ts`).
|
||||
4. Done. **No edits to `acp/`, `session/`, `sdk/`, or `ui/` should be
|
||||
required.** If you need one, the boundary is leaking — extend the
|
||||
descriptor surface instead.
|
||||
|
||||
## Adding a new layer
|
||||
|
||||
1. Create `src/agentMode/<layer>/`.
|
||||
2. Add an entry under `boundaries/elements` in root `eslint.config.mjs` and a
|
||||
corresponding rule in `boundaries/dependencies`.
|
||||
3. Re-export from `src/agentMode/index.ts` if it should be visible to
|
||||
plugin host code.
|
||||
4. Update this doc.
|
||||
|
||||
## What lives where (cheatsheet)
|
||||
|
||||
- "Backend-agnostic contract — `BackendProcess`, `BackendDescriptor`,
|
||||
the session-domain types, `MethodUnsupportedError`, debug sink,
|
||||
`translateBackendState` helper" → `session/`
|
||||
- "ACP wire types, JSON-RPC subprocess plumbing, vault MCP client,
|
||||
ACP↔domain translators" → `acp/` (the **only** layer that imports
|
||||
`@agentclientprotocol/sdk`)
|
||||
- "In-process driver for an SDK that produces session-domain events
|
||||
natively" → `sdk/`
|
||||
- "Helper used by more than one backend" → `backends/shared/`
|
||||
- "Knows about a specific binary, install path, BYOK keys, vendor models" → `backends/<id>/`
|
||||
- "React component or modal" → `ui/` (generic) or `backends/<id>/`
|
||||
(backend-specific)
|
||||
- "Canonical-store skill discovery, symlink lifecycle, SKILL.md
|
||||
parser/serializer, Skills settings tab (reads `backends/registry.ts`
|
||||
for the brand list)" → `skills/`
|
||||
- "Plugin-level wiring" → `index.ts` only
|
||||
|
||||
## Modals and dialogs
|
||||
|
||||
**Always prefer Obsidian's native `Modal`** (from `obsidian`) over the
|
||||
Radix-based `Dialog` primitive in `@/components/ui/dialog`. The native
|
||||
modal gives us correct popout-window behavior, native header chrome,
|
||||
ESC handling, and visual consistency with the rest of the plugin.
|
||||
|
||||
The standard pattern (see `src/components/modals/ConfirmModal.tsx` and
|
||||
`src/agentMode/skills/ui/DeleteConfirmDialog.tsx`):
|
||||
|
||||
Only reach for the Radix `Dialog` when the surface needs to live
|
||||
_inside_ an existing React tree (e.g. nested inside another modal)
|
||||
and spawning a separate Obsidian `Modal` would break the focus or
|
||||
layout flow.
|
||||
|
||||
## BackendDescriptor surface
|
||||
|
||||
The descriptor is the contract `session/`, `sdk/`, and `ui/` rely on.
|
||||
|
||||
If a UI component needs something the descriptor doesn't expose, **add
|
||||
it to the descriptor** — don't reach into a specific backend. The
|
||||
descriptor will keep growing; that's by design.
|
||||
|
||||
## Debugging tips
|
||||
|
||||
### Inspect full Agent Mode frames
|
||||
|
||||
The default debug log truncates each frame's payload to 400 chars. For
|
||||
diagnostic frame logs with larger payload summaries, turn on
|
||||
**Settings → Advanced → Log Full Agent Mode Frames**.
|
||||
|
||||
When enabled, every parsed frame is appended as one NDJSON line outside the
|
||||
vault, under the OS temp directory:
|
||||
|
||||
```text
|
||||
<tmp>/obsidian-copilot/acp-frames/<vault-hash>/acp-frames.ndjson
|
||||
```
|
||||
|
||||
Each line is a `FrameRecord` (`src/agentMode/session/debugSink.ts`):
|
||||
|
||||
```ts
|
||||
{ ts, dir: "→" | "←", tag, kind: "request" | "notif" | "result" | "error" | "raw",
|
||||
method, id, payload }
|
||||
```
|
||||
|
||||
`dir` is from the plugin's perspective: `→` = sent to the agent,
|
||||
`←` = received from the agent. `tag` is the backend id (e.g. `claude-sdk`,
|
||||
`opencode`, `codex`). The ACP runtime (`acp/debugTap`) and the Claude SDK
|
||||
adapter (`sdk/sdkDebugTap`) both feed the shared sink, so JSON-RPC and
|
||||
SDK turns appear in the same file.
|
||||
|
||||
Useful queries:
|
||||
|
||||
```bash
|
||||
LOG="<path shown in Settings → Advanced → Agent Mode Frame Log>"
|
||||
|
||||
# count frames by method
|
||||
jq -r .method "$LOG" | sort | uniq -c | sort -rn
|
||||
|
||||
# inspect every session/update payload
|
||||
jq -c 'select(.method=="session/update") | .payload' "$LOG"
|
||||
|
||||
# only frames for one backend
|
||||
jq -c 'select(.tag=="claude-sdk")' "$LOG"
|
||||
```
|
||||
|
||||
The file is append-only and bounded. Oversized individual frames are replaced
|
||||
with a `__truncated` summary, and at 50 MB the file rotates to
|
||||
`acp-frames.old.ndjson` (overwriting any prior `.old`) in the same temp
|
||||
folder. Use the **Open** / **Clear** buttons in the same settings section, or
|
||||
delete the files directly. Disable the toggle when not actively debugging.
|
||||
1
src/agentMode/CLAUDE.md
Symbolic link
1
src/agentMode/CLAUDE.md
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
AGENTS.md
|
||||
182
src/agentMode/acp/AcpBackendProcess.test.ts
Normal file
182
src/agentMode/acp/AcpBackendProcess.test.ts
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
import { FileSystemAdapter, App } from "obsidian";
|
||||
import type { BackendDescriptor } from "@/agentMode/session/types";
|
||||
import { AcpBackendProcess } from "./AcpBackendProcess";
|
||||
import type { AcpBackend } from "./types";
|
||||
import type { VaultClient } from "./VaultClient";
|
||||
|
||||
jest.mock("@/logger", () => ({
|
||||
logInfo: jest.fn(),
|
||||
logWarn: jest.fn(),
|
||||
logError: jest.fn(),
|
||||
}));
|
||||
|
||||
const exitListeners = new Set<() => void>();
|
||||
let mockProcessIsRunning = true;
|
||||
|
||||
jest.mock("./AcpProcessManager", () => ({
|
||||
AcpProcessManager: jest.fn().mockImplementation(() => ({
|
||||
start: () => ({
|
||||
stdin: new WritableStream<Uint8Array>(),
|
||||
stdout: new ReadableStream<Uint8Array>(),
|
||||
}),
|
||||
onExit: (fn: () => void) => {
|
||||
exitListeners.add(fn);
|
||||
return () => exitListeners.delete(fn);
|
||||
},
|
||||
isRunning: () => mockProcessIsRunning,
|
||||
shutdown: jest.fn().mockResolvedValue(undefined),
|
||||
})),
|
||||
}));
|
||||
|
||||
function buildApp(basePath = "/vault"): App {
|
||||
const adapter = new (FileSystemAdapter as unknown as new (basePath: string) => unknown)(basePath);
|
||||
return { vault: { adapter } } as unknown as App;
|
||||
}
|
||||
|
||||
function buildStubBackend(): AcpBackend {
|
||||
return {
|
||||
id: "opencode",
|
||||
displayName: "opencode",
|
||||
buildSpawnDescriptor: jest.fn().mockResolvedValue({
|
||||
command: "/bin/true",
|
||||
args: [],
|
||||
env: {},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function buildStubDescriptor(): BackendDescriptor {
|
||||
return {
|
||||
id: "opencode",
|
||||
displayName: "opencode",
|
||||
} as unknown as BackendDescriptor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the VaultClient that AcpBackendProcess wires into the mock
|
||||
* ClientSideConnection. The mock stores the `toClient(this)` result on
|
||||
* `_client`, which lets tests trigger routing/permission paths the same way
|
||||
* the agent backend would.
|
||||
*/
|
||||
function getVaultClient(backend: AcpBackendProcess): VaultClient {
|
||||
const connection = (backend as unknown as { connection: { _client: VaultClient } }).connection;
|
||||
return connection._client;
|
||||
}
|
||||
|
||||
describe("AcpBackendProcess", () => {
|
||||
beforeEach(() => {
|
||||
exitListeners.clear();
|
||||
mockProcessIsRunning = true;
|
||||
});
|
||||
|
||||
it("routes session updates to the matching session handler and drops unknown ones", async () => {
|
||||
const backend = new AcpBackendProcess(
|
||||
buildApp(),
|
||||
buildStubBackend(),
|
||||
"1.0.0",
|
||||
buildStubDescriptor()
|
||||
);
|
||||
await backend.start();
|
||||
|
||||
const handler = jest.fn();
|
||||
backend.registerSessionHandler("session-known", handler);
|
||||
|
||||
const client = getVaultClient(backend);
|
||||
const knownUpdate = {
|
||||
sessionId: "session-known",
|
||||
update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "ok" } },
|
||||
} as unknown as Parameters<typeof client.sessionUpdate>[0];
|
||||
await client.sessionUpdate(knownUpdate);
|
||||
// Handler is called with a SessionEvent (translated from the wire shape).
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
const got = handler.mock.calls[0][0];
|
||||
expect(got.sessionId).toBe("session-known");
|
||||
expect(got.update.sessionUpdate).toBe("agent_message_chunk");
|
||||
|
||||
const strayUpdate = {
|
||||
sessionId: "session-unknown",
|
||||
update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "x" } },
|
||||
} as unknown as Parameters<typeof client.sessionUpdate>[0];
|
||||
await expect(client.sessionUpdate(strayUpdate)).resolves.toBeUndefined();
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns cancelled outcome when permission is requested but no prompter is registered", async () => {
|
||||
const backend = new AcpBackendProcess(
|
||||
buildApp(),
|
||||
buildStubBackend(),
|
||||
"1.0.0",
|
||||
buildStubDescriptor()
|
||||
);
|
||||
await backend.start();
|
||||
|
||||
const client = getVaultClient(backend);
|
||||
const response = await client.requestPermission({
|
||||
sessionId: "s1",
|
||||
toolCall: {
|
||||
toolCallId: "tc1",
|
||||
title: "Run dangerous thing",
|
||||
},
|
||||
options: [{ optionId: "ok", name: "Allow", kind: "allow_once" }],
|
||||
} as unknown as Parameters<typeof client.requestPermission>[0]);
|
||||
expect(response).toEqual({ outcome: { outcome: "cancelled" } });
|
||||
});
|
||||
|
||||
it("delegates to the registered prompter and forwards the response", async () => {
|
||||
const backend = new AcpBackendProcess(
|
||||
buildApp(),
|
||||
buildStubBackend(),
|
||||
"1.0.0",
|
||||
buildStubDescriptor()
|
||||
);
|
||||
await backend.start();
|
||||
|
||||
const prompter = jest
|
||||
.fn()
|
||||
.mockResolvedValue({ outcome: { outcome: "selected", optionId: "ok" } });
|
||||
backend.setPermissionPrompter(prompter);
|
||||
|
||||
const client = getVaultClient(backend);
|
||||
const req = {
|
||||
sessionId: "s1",
|
||||
toolCall: { toolCallId: "tc1", title: "Read" },
|
||||
options: [{ optionId: "ok", name: "Allow", kind: "allow_once" }],
|
||||
} as unknown as Parameters<typeof client.requestPermission>[0];
|
||||
const response = await client.requestPermission(req);
|
||||
expect(prompter).toHaveBeenCalledTimes(1);
|
||||
// Prompter receives a session-domain `PermissionPrompt`.
|
||||
const prompt = prompter.mock.calls[0][0];
|
||||
expect(prompt.sessionId).toBe("s1");
|
||||
expect(prompt.toolCall.toolCallId).toBe("tc1");
|
||||
expect(response).toEqual({ outcome: { outcome: "selected", optionId: "ok" } });
|
||||
});
|
||||
|
||||
it("clears connection state on subprocess exit so subsequent ops fail with a clear error", async () => {
|
||||
const backend = new AcpBackendProcess(
|
||||
buildApp(),
|
||||
buildStubBackend(),
|
||||
"1.0.0",
|
||||
buildStubDescriptor()
|
||||
);
|
||||
await backend.start();
|
||||
const handler = jest.fn();
|
||||
backend.registerSessionHandler("s1", handler);
|
||||
|
||||
// Simulate the subprocess dying.
|
||||
mockProcessIsRunning = false;
|
||||
for (const fn of exitListeners) fn();
|
||||
|
||||
await expect(backend.prompt({ sessionId: "s1", prompt: [] })).rejects.toThrow(/has exited/);
|
||||
expect(backend.isRunning()).toBe(false);
|
||||
});
|
||||
|
||||
it("throws if start() was never called", async () => {
|
||||
const backend = new AcpBackendProcess(
|
||||
buildApp(),
|
||||
buildStubBackend(),
|
||||
"1.0.0",
|
||||
buildStubDescriptor()
|
||||
);
|
||||
await expect(backend.prompt({ sessionId: "s1", prompt: [] })).rejects.toThrow(/start\(\)/);
|
||||
});
|
||||
});
|
||||
549
src/agentMode/acp/AcpBackendProcess.ts
Normal file
549
src/agentMode/acp/AcpBackendProcess.ts
Normal file
|
|
@ -0,0 +1,549 @@
|
|||
import { logError, logInfo, logWarn } from "@/logger";
|
||||
import {
|
||||
ClientSideConnection,
|
||||
PROTOCOL_VERSION,
|
||||
RequestError,
|
||||
ndJsonStream,
|
||||
type RequestPermissionRequest,
|
||||
type RequestPermissionResponse,
|
||||
type SessionConfigOption,
|
||||
type SessionId as AcpSessionId,
|
||||
type SessionModeState,
|
||||
type SessionModelState,
|
||||
type SessionNotification,
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import { App, FileSystemAdapter } from "obsidian";
|
||||
import { AcpProcessManager, AcpProcessManagerOptions } from "./AcpProcessManager";
|
||||
import { VaultClient } from "./VaultClient";
|
||||
import { JSONRPC_METHOD_NOT_FOUND, MethodUnsupportedError } from "@/agentMode/session/errors";
|
||||
import type {
|
||||
BackendDescriptor,
|
||||
BackendProcess,
|
||||
BackendState,
|
||||
CancelInput,
|
||||
ListSessionsInput,
|
||||
ListSessionsOutput,
|
||||
LoadSessionInput,
|
||||
LoadSessionOutput,
|
||||
OpenSessionInput,
|
||||
OpenSessionOutput,
|
||||
PermissionDecision,
|
||||
PermissionPrompt,
|
||||
PromptInput,
|
||||
PromptOutput,
|
||||
ResumeSessionInput,
|
||||
ResumeSessionOutput,
|
||||
SessionId,
|
||||
SessionUpdateHandler as DomainSessionUpdateHandler,
|
||||
} from "@/agentMode/session/types";
|
||||
import { wrapStreamsForDebug } from "./debugTap";
|
||||
import { AcpBackend } from "./types";
|
||||
import {
|
||||
acpNotificationToEvent,
|
||||
acpPermissionRequestToPrompt,
|
||||
acpStateToBackendState,
|
||||
cancelInputToAcp,
|
||||
decisionToAcpResponse,
|
||||
listedSessionFromAcp,
|
||||
mcpServerSpecToAcp,
|
||||
promptContentToAcp,
|
||||
sessionIdFromAcp,
|
||||
sessionIdToAcp,
|
||||
stopReasonFromAcp,
|
||||
} from "./wireTranslate";
|
||||
|
||||
/**
|
||||
* Capabilities the agent may or may not implement. Tracked in a single Set so
|
||||
* adding a new capability is one constant + one branch in the unsupported
|
||||
* handler instead of touching reset / probe / getter sites.
|
||||
*/
|
||||
export type AcpCapability =
|
||||
| "session/list"
|
||||
| "session/resume"
|
||||
| "session/load"
|
||||
| "session/set_model"
|
||||
| "session/set_mode"
|
||||
| "session/set_config_option"
|
||||
| "mcp/http"
|
||||
| "mcp/sse";
|
||||
|
||||
/**
|
||||
* Detect a JSON-RPC -32601 (method not found) error from the ACP SDK. The SDK
|
||||
* surfaces these as `RequestError` instances; we also tolerate plain objects
|
||||
* shaped like `{ code: number }` defensively.
|
||||
*/
|
||||
function isMethodNotFoundError(err: unknown): boolean {
|
||||
if (err instanceof RequestError) return err.code === JSONRPC_METHOD_NOT_FOUND;
|
||||
if (typeof err === "object" && err !== null && "code" in err) {
|
||||
return err.code === JSONRPC_METHOD_NOT_FOUND;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const COPILOT_CLIENT_NAME = "obsidian-copilot";
|
||||
|
||||
/**
|
||||
* Per-session bookkeeping for the latest known wire-shaped catalogs. We keep
|
||||
* these so that mid-session `current_mode_update` / `config_option_update`
|
||||
* notifications and per-dimension `setSession*` calls can produce a fresh
|
||||
* `BackendState` without having to refetch from the agent.
|
||||
*/
|
||||
interface SessionWireState {
|
||||
models: SessionModelState | null;
|
||||
modes: SessionModeState | null;
|
||||
configOptions: SessionConfigOption[] | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* One-per-vault wrapper around an ACP-speaking subprocess. Owns the
|
||||
* `ClientSideConnection`, the `AcpProcessManager`, and the demultiplexer
|
||||
* that fans `session/update` notifications out to the right `AgentSession`.
|
||||
*
|
||||
* Lifecycle: `start()` exactly once, then any number of `newSession`/`prompt`
|
||||
* calls, finally `shutdown()`. All sessions on this backend share the
|
||||
* subprocess and die together if it exits.
|
||||
*/
|
||||
export class AcpBackendProcess implements BackendProcess {
|
||||
private process: AcpProcessManager | null = null;
|
||||
private connection: ClientSideConnection | null = null;
|
||||
private readonly domainHandlers = new Map<SessionId, DomainSessionUpdateHandler>();
|
||||
/**
|
||||
* Per-session FIFO of `session/update` notifications that arrived before a
|
||||
* handler was registered. Buffers the wire-shaped notification so we
|
||||
* translate at replay time (the destination handler is domain-typed).
|
||||
*/
|
||||
private readonly pendingUpdates = new Map<SessionId, SessionNotification[]>();
|
||||
private static readonly PENDING_UPDATE_LIMIT = 32;
|
||||
private permissionPrompter: ((req: PermissionPrompt) => Promise<PermissionDecision>) | null =
|
||||
null;
|
||||
private exitListeners = new Set<() => void>();
|
||||
private capabilities = new Map<AcpCapability, boolean>();
|
||||
private readonly sessionWireState = new Map<SessionId, SessionWireState>();
|
||||
|
||||
constructor(
|
||||
private readonly app: App,
|
||||
private readonly backend: AcpBackend,
|
||||
private readonly clientVersion: string,
|
||||
private readonly descriptor: BackendDescriptor
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Spawn the subprocess and complete the ACP `initialize` handshake.
|
||||
* Idempotent: a second call while an existing connection is live is a
|
||||
* no-op.
|
||||
*/
|
||||
async start(): Promise<void> {
|
||||
if (this.connection) return;
|
||||
const adapter = this.app.vault.adapter;
|
||||
if (!(adapter instanceof FileSystemAdapter)) {
|
||||
throw new Error("Agent Mode requires desktop Obsidian (FileSystemAdapter).");
|
||||
}
|
||||
const descriptor = await this.backend.buildSpawnDescriptor({
|
||||
vaultBasePath: adapter.getBasePath(),
|
||||
});
|
||||
|
||||
const procOpts: AcpProcessManagerOptions = {
|
||||
command: descriptor.command,
|
||||
args: descriptor.args,
|
||||
env: descriptor.env,
|
||||
logTag: this.backend.id,
|
||||
};
|
||||
const proc = new AcpProcessManager(procOpts);
|
||||
this.process = proc;
|
||||
const raw = proc.start();
|
||||
const { stdin, stdout } = wrapStreamsForDebug(raw.stdin, raw.stdout, this.backend.id);
|
||||
|
||||
proc.onExit(() => {
|
||||
logWarn(`[AgentMode] backend ${this.backend.id} exited`);
|
||||
this.connection = null;
|
||||
this.domainHandlers.clear();
|
||||
this.pendingUpdates.clear();
|
||||
this.sessionWireState.clear();
|
||||
this.permissionPrompter = null;
|
||||
this.capabilities.clear();
|
||||
for (const fn of this.exitListeners) {
|
||||
try {
|
||||
fn();
|
||||
} catch (e) {
|
||||
logWarn("[AgentMode] exit listener threw", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const stream = ndJsonStream(stdin, stdout);
|
||||
const client = new VaultClient(this.app, {
|
||||
onSessionUpdate: (sessionId, update) => this.routeSessionUpdate(sessionId, update),
|
||||
requestPermission: (req) => this.handlePermission(req),
|
||||
});
|
||||
this.connection = new ClientSideConnection(() => client, stream);
|
||||
|
||||
try {
|
||||
const init = await this.connection.initialize({
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
clientCapabilities: {
|
||||
fs: { readTextFile: true, writeTextFile: true },
|
||||
},
|
||||
clientInfo: {
|
||||
name: COPILOT_CLIENT_NAME,
|
||||
version: this.clientVersion,
|
||||
},
|
||||
});
|
||||
if (init.agentCapabilities?.sessionCapabilities?.list != null) {
|
||||
this.capabilities.set("session/list", true);
|
||||
}
|
||||
if (init.agentCapabilities?.sessionCapabilities?.resume != null) {
|
||||
this.capabilities.set("session/resume", true);
|
||||
}
|
||||
if (init.agentCapabilities?.loadSession === true) {
|
||||
this.capabilities.set("session/load", true);
|
||||
}
|
||||
if (init.agentCapabilities?.mcpCapabilities?.http === true) {
|
||||
this.capabilities.set("mcp/http", true);
|
||||
}
|
||||
if (init.agentCapabilities?.mcpCapabilities?.sse === true) {
|
||||
this.capabilities.set("mcp/sse", true);
|
||||
}
|
||||
logInfo(
|
||||
`[AgentMode] initialized backend ${this.backend.id} (negotiated protocol v${init.protocolVersion}, listSessions=${this.hasCapability("session/list")}, resumeSession=${this.hasCapability("session/resume")}, loadSession=${this.hasCapability("session/load")}, mcp.http=${this.hasCapability("mcp/http")}, mcp.sse=${this.hasCapability("mcp/sse")})`
|
||||
);
|
||||
} catch (err) {
|
||||
logError(
|
||||
`[AgentMode] initialize failed for ${this.backend.id}; tearing down subprocess`,
|
||||
err
|
||||
);
|
||||
this.connection = null;
|
||||
try {
|
||||
await proc.shutdown();
|
||||
} catch (e) {
|
||||
logError("[AgentMode] shutdown after failed initialize threw", e);
|
||||
}
|
||||
this.process = null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
isRunning(): boolean {
|
||||
return this.process?.isRunning() ?? false;
|
||||
}
|
||||
|
||||
onExit(listener: () => void): () => void {
|
||||
this.exitListeners.add(listener);
|
||||
return () => this.exitListeners.delete(listener);
|
||||
}
|
||||
|
||||
setPermissionPrompter(fn: (req: PermissionPrompt) => Promise<PermissionDecision>): void {
|
||||
this.permissionPrompter = fn;
|
||||
}
|
||||
|
||||
registerSessionHandler(sessionId: SessionId, handler: DomainSessionUpdateHandler): () => void {
|
||||
this.domainHandlers.set(sessionId, handler);
|
||||
const buffered = this.pendingUpdates.get(sessionId);
|
||||
if (buffered) {
|
||||
this.pendingUpdates.delete(sessionId);
|
||||
for (const wire of buffered) {
|
||||
try {
|
||||
handler(acpNotificationToEvent(wire));
|
||||
} catch (e) {
|
||||
logWarn(`[AgentMode] replay of buffered session/update threw for ${sessionId}`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
return () => {
|
||||
if (this.domainHandlers.get(sessionId) === handler) {
|
||||
this.domainHandlers.delete(sessionId);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async newSession(params: OpenSessionInput): Promise<OpenSessionOutput> {
|
||||
const wireResp = await this.requireConnection().newSession({
|
||||
cwd: params.cwd,
|
||||
mcpServers: params.mcpServers.map(mcpServerSpecToAcp),
|
||||
});
|
||||
this.recordWireState(wireResp.sessionId, {
|
||||
models: wireResp.models ?? null,
|
||||
modes: wireResp.modes ?? null,
|
||||
configOptions: wireResp.configOptions ?? null,
|
||||
});
|
||||
return {
|
||||
sessionId: sessionIdFromAcp(wireResp.sessionId),
|
||||
state: this.computeState(wireResp.sessionId),
|
||||
};
|
||||
}
|
||||
|
||||
async prompt(params: PromptInput): Promise<PromptOutput> {
|
||||
const resp = await this.requireConnection().prompt({
|
||||
sessionId: sessionIdToAcp(params.sessionId),
|
||||
prompt: promptContentToAcp(params.prompt),
|
||||
});
|
||||
return { stopReason: stopReasonFromAcp(resp.stopReason) };
|
||||
}
|
||||
|
||||
async cancel(params: CancelInput): Promise<void> {
|
||||
return this.requireConnection().cancel(cancelInputToAcp(params));
|
||||
}
|
||||
|
||||
hasCapability(cap: AcpCapability): boolean {
|
||||
return this.capabilities.get(cap) === true;
|
||||
}
|
||||
|
||||
supportsMcpTransport(transport: "http" | "sse"): boolean {
|
||||
return this.hasCapability(transport === "http" ? "mcp/http" : "mcp/sse");
|
||||
}
|
||||
|
||||
async setSessionModel(params: { sessionId: SessionId; modelId: string }): Promise<BackendState> {
|
||||
await this.dispatchCapability("session/set_model", (c) =>
|
||||
c.unstable_setSessionModel({
|
||||
sessionId: sessionIdToAcp(params.sessionId),
|
||||
modelId: params.modelId,
|
||||
})
|
||||
);
|
||||
const wire = this.sessionWireState.get(params.sessionId);
|
||||
if (wire) {
|
||||
const current = wire.models;
|
||||
wire.models = current
|
||||
? { ...current, currentModelId: params.modelId }
|
||||
: { availableModels: [], currentModelId: params.modelId };
|
||||
}
|
||||
return this.computeState(params.sessionId);
|
||||
}
|
||||
|
||||
isSetSessionModelSupported(): boolean | null {
|
||||
return this.capabilitySupported("session/set_model");
|
||||
}
|
||||
|
||||
async setSessionMode(params: { sessionId: SessionId; modeId: string }): Promise<BackendState> {
|
||||
await this.dispatchCapability("session/set_mode", (c) =>
|
||||
c.setSessionMode({
|
||||
sessionId: sessionIdToAcp(params.sessionId),
|
||||
modeId: params.modeId,
|
||||
})
|
||||
);
|
||||
const wire = this.sessionWireState.get(params.sessionId);
|
||||
if (wire) {
|
||||
const seed: SessionModeState = wire.modes ?? { availableModes: [], currentModeId: "" };
|
||||
wire.modes = { ...seed, currentModeId: params.modeId };
|
||||
}
|
||||
return this.computeState(params.sessionId);
|
||||
}
|
||||
|
||||
isSetSessionModeSupported(): boolean | null {
|
||||
return this.capabilitySupported("session/set_mode");
|
||||
}
|
||||
|
||||
async setSessionConfigOption(params: {
|
||||
sessionId: SessionId;
|
||||
configId: string;
|
||||
value: string;
|
||||
}): Promise<BackendState> {
|
||||
const resp = await this.dispatchCapability("session/set_config_option", (c) =>
|
||||
c.setSessionConfigOption({
|
||||
sessionId: sessionIdToAcp(params.sessionId),
|
||||
configId: params.configId,
|
||||
value: params.value,
|
||||
})
|
||||
);
|
||||
const wire = this.sessionWireState.get(params.sessionId);
|
||||
if (wire) {
|
||||
wire.configOptions = resp.configOptions;
|
||||
}
|
||||
return this.computeState(params.sessionId);
|
||||
}
|
||||
|
||||
isSetSessionConfigOptionSupported(): boolean | null {
|
||||
return this.capabilitySupported("session/set_config_option");
|
||||
}
|
||||
|
||||
/**
|
||||
* Run an RPC gated by capability. Throws `MethodUnsupportedError` if the
|
||||
* capability is known unsupported (advertised off, or a previous -32601).
|
||||
* On a fresh -32601 reply, cache the negative result and rethrow.
|
||||
*/
|
||||
private async dispatchCapability<T>(
|
||||
capability: AcpCapability,
|
||||
run: (c: ClientSideConnection) => Promise<T>,
|
||||
opts: { mustBeAdvertised?: boolean } = {}
|
||||
): Promise<T> {
|
||||
const known = this.capabilities.get(capability);
|
||||
if (known === false || (opts.mustBeAdvertised && known !== true)) {
|
||||
throw new MethodUnsupportedError(capability);
|
||||
}
|
||||
try {
|
||||
const resp = await run(this.requireConnection());
|
||||
this.capabilities.set(capability, true);
|
||||
return resp;
|
||||
} catch (err) {
|
||||
if (isMethodNotFoundError(err)) {
|
||||
this.capabilities.set(capability, false);
|
||||
throw new MethodUnsupportedError(capability);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private capabilitySupported(capability: AcpCapability): boolean | null {
|
||||
return this.capabilities.has(capability) ? this.capabilities.get(capability)! : null;
|
||||
}
|
||||
|
||||
async listSessions(params: ListSessionsInput): Promise<ListSessionsOutput> {
|
||||
const resp = await this.dispatchCapability(
|
||||
"session/list",
|
||||
(c) => c.listSessions(params.cwd ? { cwd: params.cwd } : {}),
|
||||
{ mustBeAdvertised: true }
|
||||
);
|
||||
return {
|
||||
sessions: resp.sessions.map((s) =>
|
||||
listedSessionFromAcp({
|
||||
sessionId: s.sessionId,
|
||||
cwd: s.cwd,
|
||||
title: (s as { title?: string | null }).title ?? null,
|
||||
updatedAt: (s as { updatedAt?: string | null }).updatedAt ?? null,
|
||||
})
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async resumeSession(params: ResumeSessionInput): Promise<ResumeSessionOutput> {
|
||||
const wireResp = await this.dispatchCapability(
|
||||
"session/resume",
|
||||
(c) =>
|
||||
c.resumeSession({
|
||||
sessionId: sessionIdToAcp(params.sessionId),
|
||||
cwd: params.cwd,
|
||||
mcpServers: params.mcpServers.map(mcpServerSpecToAcp),
|
||||
}),
|
||||
{ mustBeAdvertised: true }
|
||||
);
|
||||
this.recordWireState(sessionIdToAcp(params.sessionId), {
|
||||
models: wireResp.models ?? null,
|
||||
modes: wireResp.modes ?? null,
|
||||
configOptions: wireResp.configOptions ?? null,
|
||||
});
|
||||
return {
|
||||
sessionId: params.sessionId,
|
||||
state: this.computeState(sessionIdToAcp(params.sessionId)),
|
||||
};
|
||||
}
|
||||
|
||||
async loadSession(params: LoadSessionInput): Promise<LoadSessionOutput> {
|
||||
const wireResp = await this.dispatchCapability(
|
||||
"session/load",
|
||||
(c) =>
|
||||
c.loadSession({
|
||||
sessionId: sessionIdToAcp(params.sessionId),
|
||||
cwd: params.cwd,
|
||||
mcpServers: params.mcpServers.map(mcpServerSpecToAcp),
|
||||
}),
|
||||
{ mustBeAdvertised: true }
|
||||
);
|
||||
this.recordWireState(sessionIdToAcp(params.sessionId), {
|
||||
models: wireResp.models ?? null,
|
||||
modes: wireResp.modes ?? null,
|
||||
configOptions: wireResp.configOptions ?? null,
|
||||
});
|
||||
return {
|
||||
sessionId: params.sessionId,
|
||||
state: this.computeState(sessionIdToAcp(params.sessionId)),
|
||||
};
|
||||
}
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
this.connection = null;
|
||||
this.domainHandlers.clear();
|
||||
this.pendingUpdates.clear();
|
||||
this.sessionWireState.clear();
|
||||
this.permissionPrompter = null;
|
||||
this.capabilities.clear();
|
||||
if (this.process) {
|
||||
try {
|
||||
await this.process.shutdown();
|
||||
} catch (e) {
|
||||
logError("[AgentMode] backend shutdown failed", e);
|
||||
}
|
||||
this.process = null;
|
||||
}
|
||||
}
|
||||
|
||||
private requireConnection(): ClientSideConnection {
|
||||
if (!this.connection) {
|
||||
throw new Error(
|
||||
this.process
|
||||
? "AcpBackendProcess subprocess has exited"
|
||||
: "AcpBackendProcess.start() not called"
|
||||
);
|
||||
}
|
||||
return this.connection;
|
||||
}
|
||||
|
||||
private recordWireState(sessionId: AcpSessionId, wire: SessionWireState): void {
|
||||
this.sessionWireState.set(sessionIdFromAcp(sessionId), wire);
|
||||
}
|
||||
|
||||
private computeState(sessionId: AcpSessionId): BackendState {
|
||||
const wire = this.sessionWireState.get(sessionIdFromAcp(sessionId)) ?? {
|
||||
models: null,
|
||||
modes: null,
|
||||
configOptions: null,
|
||||
};
|
||||
return acpStateToBackendState(wire.models, wire.modes, wire.configOptions, this.descriptor);
|
||||
}
|
||||
|
||||
private routeSessionUpdate(acpSessionId: AcpSessionId, update: SessionNotification): void {
|
||||
const sessionId = sessionIdFromAcp(acpSessionId);
|
||||
// Mirror per-dimension wire updates into our cache so subsequent
|
||||
// setSession* calls (and the next `state_changed` event) reflect reality.
|
||||
const wire = this.sessionWireState.get(sessionId);
|
||||
if (wire) {
|
||||
const u = update.update;
|
||||
if (u.sessionUpdate === "current_mode_update") {
|
||||
const seed = wire.modes ?? { availableModes: [], currentModeId: "" };
|
||||
wire.modes = { ...seed, currentModeId: u.currentModeId };
|
||||
} else if (u.sessionUpdate === "config_option_update") {
|
||||
wire.configOptions = u.configOptions;
|
||||
}
|
||||
}
|
||||
|
||||
const handler = this.domainHandlers.get(sessionId);
|
||||
if (!handler) {
|
||||
let queue = this.pendingUpdates.get(sessionId);
|
||||
if (!queue) {
|
||||
queue = [];
|
||||
this.pendingUpdates.set(sessionId, queue);
|
||||
}
|
||||
if (queue.length >= AcpBackendProcess.PENDING_UPDATE_LIMIT) {
|
||||
const kind = update.update.sessionUpdate ?? "unknown";
|
||||
logWarn(
|
||||
`[AgentMode] dropping session/update for ${sessionId}: pending buffer full (${queue.length}, kind=${kind})`
|
||||
);
|
||||
return;
|
||||
}
|
||||
queue.push(update);
|
||||
return;
|
||||
}
|
||||
|
||||
// Per-dimension wire updates already mutated `wire` above; AgentSession
|
||||
// ignores them and waits for the synthesized `state_changed` we publish
|
||||
// below. Skip the original to avoid a wasted translation + dispatch.
|
||||
const sub = update.update.sessionUpdate;
|
||||
if (sub === "current_mode_update" || sub === "config_option_update") {
|
||||
handler({
|
||||
sessionId,
|
||||
update: { sessionUpdate: "state_changed", state: this.computeState(sessionId) },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
handler(acpNotificationToEvent(update));
|
||||
}
|
||||
|
||||
private async handlePermission(
|
||||
req: RequestPermissionRequest
|
||||
): Promise<RequestPermissionResponse> {
|
||||
if (!this.permissionPrompter) {
|
||||
logWarn(`[AgentMode] permission requested but no prompter is registered; auto-cancelling`);
|
||||
return { outcome: { outcome: "cancelled" } };
|
||||
}
|
||||
const decision = await this.permissionPrompter(acpPermissionRequestToPrompt(req));
|
||||
return decisionToAcpResponse(decision);
|
||||
}
|
||||
}
|
||||
176
src/agentMode/acp/AcpProcessManager.ts
Normal file
176
src/agentMode/acp/AcpProcessManager.ts
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
import { logError, logInfo, logWarn } from "@/logger";
|
||||
import { ChildProcessByStdio, spawn } from "node:child_process";
|
||||
import { Readable, Writable } from "node:stream";
|
||||
|
||||
const SIGTERM_GRACE_MS = 3_000;
|
||||
|
||||
export interface AcpProcessManagerOptions {
|
||||
/** Absolute path to the agent backend binary (e.g. opencode). */
|
||||
command: string;
|
||||
/** Process arguments. */
|
||||
args: string[];
|
||||
/** Environment for the child. Pass through `process.env` plus any overrides. */
|
||||
env: NodeJS.ProcessEnv;
|
||||
/** Tag used in stderr/log lines so multiple agents can be distinguished. */
|
||||
logTag?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawns and supervises the ACP-speaking agent backend subprocess. Exposes
|
||||
* the child's stdin/stdout as Web Streams (suitable for
|
||||
* `@agentclientprotocol/sdk`'s `ndJsonStream`) and pipes stderr line-by-line
|
||||
* into the Copilot logger.
|
||||
*
|
||||
* Single-shot: `start()` may only be called once. Use `shutdown()` for a
|
||||
* graceful SIGTERM→SIGKILL teardown.
|
||||
*/
|
||||
export class AcpProcessManager {
|
||||
private child: ChildProcessByStdio<Writable, Readable, Readable> | null = null;
|
||||
private exitListeners = new Set<(code: number | null, signal: NodeJS.Signals | null) => void>();
|
||||
private hasExited = false;
|
||||
private exitCode: number | null = null;
|
||||
private exitSignal: NodeJS.Signals | null = null;
|
||||
|
||||
constructor(private readonly opts: AcpProcessManagerOptions) {}
|
||||
|
||||
/**
|
||||
* Spawn the child and return its stdin/stdout as Web Streams. Stderr is
|
||||
* consumed internally and routed to the logger; callers don't see it.
|
||||
*/
|
||||
start(): { stdin: WritableStream<Uint8Array>; stdout: ReadableStream<Uint8Array> } {
|
||||
if (this.child) {
|
||||
throw new Error("AcpProcessManager already started");
|
||||
}
|
||||
const tag = this.opts.logTag ?? "acp";
|
||||
logInfo(`[AgentMode] spawning ${this.opts.command} ${this.opts.args.join(" ")} (tag=${tag})`);
|
||||
const child = spawn(this.opts.command, this.opts.args, {
|
||||
env: this.opts.env,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
windowsHide: true,
|
||||
});
|
||||
this.child = child;
|
||||
|
||||
child.on("error", (err) => {
|
||||
logError(`[AgentMode] subprocess error (${tag})`, err);
|
||||
});
|
||||
child.on("exit", (code, signal) => {
|
||||
this.hasExited = true;
|
||||
this.exitCode = code;
|
||||
this.exitSignal = signal;
|
||||
logInfo(`[AgentMode] subprocess exit (${tag}) code=${code} signal=${signal}`);
|
||||
for (const fn of this.exitListeners) {
|
||||
try {
|
||||
fn(code, signal);
|
||||
} catch (e) {
|
||||
logWarn(`[AgentMode] exit listener threw`, e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
pipeStderrToLogger(child.stderr, tag);
|
||||
|
||||
// Bridge Node streams → Web Streams for `@agentclientprotocol/sdk`'s
|
||||
// `ndJsonStream`. `toWeb` is Node ≥17 (Electron 27 ships Node 18), but
|
||||
// missing from the older `@types/node` this project pins, hence the
|
||||
// cast.
|
||||
const writableToWeb = (
|
||||
Writable as unknown as {
|
||||
toWeb: (s: NodeJS.WritableStream) => WritableStream<Uint8Array>;
|
||||
}
|
||||
).toWeb;
|
||||
const readableToWeb = (
|
||||
Readable as unknown as {
|
||||
toWeb: (s: NodeJS.ReadableStream) => ReadableStream<Uint8Array>;
|
||||
}
|
||||
).toWeb;
|
||||
return {
|
||||
stdin: writableToWeb(child.stdin),
|
||||
stdout: readableToWeb(child.stdout),
|
||||
};
|
||||
}
|
||||
|
||||
onExit(listener: (code: number | null, signal: NodeJS.Signals | null) => void): () => void {
|
||||
if (this.hasExited) {
|
||||
// Fire synchronously so callers don't miss the event when subscribing
|
||||
// after exit (e.g. crash before they wired up).
|
||||
try {
|
||||
listener(this.exitCode, this.exitSignal);
|
||||
} catch (e) {
|
||||
logWarn(`[AgentMode] exit listener threw`, e);
|
||||
}
|
||||
return () => {};
|
||||
}
|
||||
this.exitListeners.add(listener);
|
||||
return () => this.exitListeners.delete(listener);
|
||||
}
|
||||
|
||||
isRunning(): boolean {
|
||||
return this.child !== null && !this.hasExited;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send SIGTERM, wait up to SIGTERM_GRACE_MS, then escalate to SIGKILL if
|
||||
* the child is still alive. Resolves once the child has exited (or
|
||||
* immediately if it already had).
|
||||
*/
|
||||
async shutdown(): Promise<void> {
|
||||
if (!this.child || this.hasExited) return;
|
||||
const child = this.child;
|
||||
const tag = this.opts.logTag ?? "acp";
|
||||
|
||||
const exited = new Promise<void>((resolve) => {
|
||||
this.onExit(() => resolve());
|
||||
});
|
||||
|
||||
try {
|
||||
child.kill("SIGTERM");
|
||||
} catch (e) {
|
||||
logWarn(`[AgentMode] SIGTERM failed (${tag})`, e);
|
||||
}
|
||||
|
||||
const timeout = new Promise<"timeout">((resolve) =>
|
||||
window.setTimeout(() => resolve("timeout"), SIGTERM_GRACE_MS)
|
||||
);
|
||||
const winner = await Promise.race([exited.then(() => "exited" as const), timeout]);
|
||||
if (winner === "timeout" && !this.hasExited) {
|
||||
logWarn(`[AgentMode] subprocess (${tag}) did not exit within ${SIGTERM_GRACE_MS}ms; SIGKILL`);
|
||||
try {
|
||||
child.kill("SIGKILL");
|
||||
} catch (e) {
|
||||
logWarn(`[AgentMode] SIGKILL failed (${tag})`, e);
|
||||
}
|
||||
await exited;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function pipeStderrToLogger(stderr: Readable, tag: string): void {
|
||||
let buffer = "";
|
||||
stderr.setEncoding("utf-8");
|
||||
stderr.on("data", (chunk: string) => {
|
||||
buffer += chunk;
|
||||
let nlIdx: number;
|
||||
while ((nlIdx = buffer.indexOf("\n")) !== -1) {
|
||||
const line = buffer.slice(0, nlIdx).trimEnd();
|
||||
buffer = buffer.slice(nlIdx + 1);
|
||||
if (line) emitStderrLine(line, tag);
|
||||
}
|
||||
});
|
||||
stderr.on("end", () => {
|
||||
if (buffer.trim()) emitStderrLine(buffer.trim(), tag);
|
||||
});
|
||||
}
|
||||
|
||||
function emitStderrLine(line: string, tag: string): void {
|
||||
// Heuristic: lines starting with "error" / "ERROR" / "fatal" go to error,
|
||||
// everything else stays at info. We don't want to drown the user log in
|
||||
// opencode's noisy debug output, so warnings stay warnings.
|
||||
const lower = line.toLowerCase();
|
||||
if (lower.startsWith("error") || lower.startsWith("fatal")) {
|
||||
logError(`[AgentMode][${tag}] ${line}`);
|
||||
} else if (lower.startsWith("warn")) {
|
||||
logWarn(`[AgentMode][${tag}] ${line}`);
|
||||
} else {
|
||||
logInfo(`[AgentMode][${tag}] ${line}`);
|
||||
}
|
||||
}
|
||||
163
src/agentMode/acp/VaultClient.test.ts
Normal file
163
src/agentMode/acp/VaultClient.test.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
import { FileSystemAdapter, App } from "obsidian";
|
||||
import { sliceLines, VaultClient } from "./VaultClient";
|
||||
|
||||
jest.mock("@/logger", () => ({
|
||||
logInfo: jest.fn(),
|
||||
logWarn: jest.fn(),
|
||||
logError: jest.fn(),
|
||||
}));
|
||||
|
||||
describe("sliceLines", () => {
|
||||
const text = "a\nb\nc\nd\ne";
|
||||
|
||||
it("returns full content when both line and limit are null", () => {
|
||||
expect(sliceLines(text, null, null)).toBe(text);
|
||||
});
|
||||
|
||||
it("returns from line N (1-based) to end when limit is null", () => {
|
||||
expect(sliceLines(text, 3, null)).toBe("c\nd\ne");
|
||||
});
|
||||
|
||||
it("returns first N lines when line is null", () => {
|
||||
expect(sliceLines(text, null, 2)).toBe("a\nb");
|
||||
});
|
||||
|
||||
it("respects both line and limit", () => {
|
||||
expect(sliceLines(text, 2, 2)).toBe("b\nc");
|
||||
});
|
||||
|
||||
it("returns empty string when line is past end", () => {
|
||||
expect(sliceLines(text, 99, 5)).toBe("");
|
||||
});
|
||||
|
||||
it("clamps limit when it overruns", () => {
|
||||
expect(sliceLines(text, 4, 100)).toBe("d\ne");
|
||||
});
|
||||
});
|
||||
|
||||
describe("VaultClient", () => {
|
||||
function buildApp(basePath = "/vault"): App {
|
||||
// The mocked FileSystemAdapter takes the basePath via constructor; the
|
||||
// real Obsidian type has a no-arg constructor, hence the cast.
|
||||
const adapter = new (FileSystemAdapter as unknown as new (basePath: string) => unknown)(
|
||||
basePath
|
||||
);
|
||||
return { vault: { adapter } } as unknown as App;
|
||||
}
|
||||
|
||||
it("readTextFile reads via adapter and applies line/limit", async () => {
|
||||
const app = buildApp();
|
||||
(app.vault.adapter as unknown as { read: jest.Mock }).read.mockResolvedValue(
|
||||
"one\ntwo\nthree\nfour"
|
||||
);
|
||||
const client = new VaultClient(app, {
|
||||
onSessionUpdate: () => {},
|
||||
requestPermission: () => Promise.resolve({ outcome: { outcome: "cancelled" } }),
|
||||
});
|
||||
const resp = await client.readTextFile({
|
||||
sessionId: "s1",
|
||||
path: "notes/foo.md",
|
||||
line: 2,
|
||||
limit: 2,
|
||||
});
|
||||
expect(resp.content).toBe("two\nthree");
|
||||
expect((app.vault.adapter as unknown as { read: jest.Mock }).read).toHaveBeenCalledWith(
|
||||
"notes/foo.md"
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects out-of-vault relative paths with .. traversal", async () => {
|
||||
const app = buildApp("/Users/me/vault");
|
||||
const client = new VaultClient(app, {
|
||||
onSessionUpdate: () => {},
|
||||
requestPermission: () => Promise.resolve({ outcome: { outcome: "cancelled" } }),
|
||||
});
|
||||
await expect(client.readTextFile({ sessionId: "s1", path: "../outside.md" })).rejects.toThrow(
|
||||
/outside the vault/
|
||||
);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line obsidianmd/hardcoded-config-path -- test fixture for hidden-dir guard
|
||||
it("rejects dotfile paths under the vault (e.g. .obsidian config)", async () => {
|
||||
const app = buildApp("/Users/me/vault");
|
||||
const client = new VaultClient(app, {
|
||||
onSessionUpdate: () => {},
|
||||
requestPermission: () => Promise.resolve({ outcome: { outcome: "cancelled" } }),
|
||||
});
|
||||
await expect(
|
||||
// eslint-disable-next-line obsidianmd/hardcoded-config-path -- test fixture
|
||||
client.readTextFile({ sessionId: "s1", path: ".obsidian/plugins/copilot/data.json" })
|
||||
).rejects.toThrow(/hidden directory/);
|
||||
await expect(
|
||||
client.writeTextFile({ sessionId: "s1", path: ".git/config", content: "x" })
|
||||
).rejects.toThrow(/hidden directory/);
|
||||
});
|
||||
|
||||
it("rejects absolute paths outside the vault base", async () => {
|
||||
const app = buildApp("/Users/me/vault");
|
||||
const client = new VaultClient(app, {
|
||||
onSessionUpdate: () => {},
|
||||
requestPermission: () => Promise.resolve({ outcome: { outcome: "cancelled" } }),
|
||||
});
|
||||
await expect(client.readTextFile({ sessionId: "s1", path: "/etc/passwd" })).rejects.toThrow(
|
||||
/outside the vault/
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts absolute paths inside the vault base and routes to adapter as relative", async () => {
|
||||
const app = buildApp("/Users/me/vault");
|
||||
(app.vault.adapter as unknown as { read: jest.Mock }).read.mockResolvedValue("hello");
|
||||
const client = new VaultClient(app, {
|
||||
onSessionUpdate: () => {},
|
||||
requestPermission: () => Promise.resolve({ outcome: { outcome: "cancelled" } }),
|
||||
});
|
||||
const resp = await client.readTextFile({
|
||||
sessionId: "s1",
|
||||
path: "/Users/me/vault/notes/foo.md",
|
||||
});
|
||||
expect(resp.content).toBe("hello");
|
||||
expect((app.vault.adapter as unknown as { read: jest.Mock }).read).toHaveBeenCalledWith(
|
||||
"notes/foo.md"
|
||||
);
|
||||
});
|
||||
|
||||
it("writeTextFile creates parent dir if missing", async () => {
|
||||
const app = buildApp();
|
||||
const adapter = app.vault.adapter as unknown as {
|
||||
exists: jest.Mock;
|
||||
mkdir: jest.Mock;
|
||||
write: jest.Mock;
|
||||
};
|
||||
adapter.exists.mockResolvedValueOnce(false);
|
||||
adapter.write.mockResolvedValue(undefined);
|
||||
const client = new VaultClient(app, {
|
||||
onSessionUpdate: () => {},
|
||||
requestPermission: () => Promise.resolve({ outcome: { outcome: "cancelled" } }),
|
||||
});
|
||||
await client.writeTextFile({
|
||||
sessionId: "s1",
|
||||
path: "Inbox/note.md",
|
||||
content: "hi",
|
||||
});
|
||||
expect(adapter.mkdir).toHaveBeenCalledWith("Inbox");
|
||||
expect(adapter.write).toHaveBeenCalledWith("Inbox/note.md", "hi");
|
||||
});
|
||||
|
||||
it("sessionUpdate forwards to handler", async () => {
|
||||
const app = buildApp();
|
||||
const onSessionUpdate = jest.fn();
|
||||
const client = new VaultClient(app, {
|
||||
onSessionUpdate,
|
||||
requestPermission: () => Promise.resolve({ outcome: { outcome: "cancelled" } }),
|
||||
});
|
||||
const update = {
|
||||
sessionId: "s1",
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text: "hi" },
|
||||
},
|
||||
} as unknown as Parameters<typeof client.sessionUpdate>[0];
|
||||
await client.sessionUpdate(update);
|
||||
expect(onSessionUpdate).toHaveBeenCalledWith("s1", update);
|
||||
});
|
||||
});
|
||||
129
src/agentMode/acp/VaultClient.ts
Normal file
129
src/agentMode/acp/VaultClient.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import type {
|
||||
Client,
|
||||
ReadTextFileRequest,
|
||||
ReadTextFileResponse,
|
||||
RequestPermissionRequest,
|
||||
RequestPermissionResponse,
|
||||
SessionId,
|
||||
SessionNotification,
|
||||
WriteTextFileRequest,
|
||||
WriteTextFileResponse,
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import { RequestError } from "@agentclientprotocol/sdk";
|
||||
import { logInfo, logWarn } from "@/logger";
|
||||
import { App, FileSystemAdapter, normalizePath } from "obsidian";
|
||||
import * as path from "node:path";
|
||||
|
||||
export interface PermissionPrompter {
|
||||
(req: RequestPermissionRequest): Promise<RequestPermissionResponse>;
|
||||
}
|
||||
|
||||
export interface VaultClientHandlers {
|
||||
/** Routes a session/update to the right AgentSession. */
|
||||
onSessionUpdate: (sessionId: SessionId, update: SessionNotification) => void;
|
||||
/** Opens the permission UI; resolves with the user's choice. */
|
||||
requestPermission: PermissionPrompter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements the ACP `Client` interface against an Obsidian vault.
|
||||
*
|
||||
* - `readTextFile`/`writeTextFile` route through `app.vault.adapter`, with
|
||||
* strict vault-relative path resolution. Out-of-vault paths are rejected
|
||||
* with `invalidParams` so the agent gets a clear error.
|
||||
* - `sessionUpdate` is demultiplexed via `handlers.onSessionUpdate`.
|
||||
* - `requestPermission` defers to `handlers.requestPermission` which opens
|
||||
* the modal UI.
|
||||
*
|
||||
* Terminal capabilities are deliberately *not* implemented — we don't
|
||||
* advertise the capability, and opencode falls back to its internal PTY.
|
||||
*/
|
||||
export class VaultClient implements Client {
|
||||
constructor(
|
||||
private readonly app: App,
|
||||
private readonly handlers: VaultClientHandlers
|
||||
) {}
|
||||
|
||||
async sessionUpdate(params: SessionNotification): Promise<void> {
|
||||
try {
|
||||
this.handlers.onSessionUpdate(params.sessionId, params);
|
||||
} catch (e) {
|
||||
logWarn(`[AgentMode] sessionUpdate handler threw`, e);
|
||||
}
|
||||
}
|
||||
|
||||
async requestPermission(params: RequestPermissionRequest): Promise<RequestPermissionResponse> {
|
||||
return this.handlers.requestPermission(params);
|
||||
}
|
||||
|
||||
async readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse> {
|
||||
const rel = this.resolveVaultRelative(params.path);
|
||||
const full = await this.app.vault.adapter.read(rel);
|
||||
const content = sliceLines(full, params.line ?? null, params.limit ?? null);
|
||||
logInfo(`[AgentMode] readTextFile ${rel} (${content.length} chars)`);
|
||||
return { content };
|
||||
}
|
||||
|
||||
async writeTextFile(params: WriteTextFileRequest): Promise<WriteTextFileResponse> {
|
||||
const rel = this.resolveVaultRelative(params.path);
|
||||
const adapter = this.app.vault.adapter;
|
||||
const dir = path.posix.dirname(rel);
|
||||
if (dir && dir !== "." && dir !== "/" && !(await adapter.exists(dir))) {
|
||||
await adapter.mkdir(dir);
|
||||
}
|
||||
await adapter.write(rel, params.content);
|
||||
logInfo(`[AgentMode] writeTextFile ${rel} (${params.content.length} chars)`);
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve `p` against the vault root. Returns a vault-relative,
|
||||
* forward-slashed path for `app.vault.adapter`. Throws
|
||||
* `RequestError.invalidParams` if the path escapes the vault.
|
||||
*/
|
||||
private resolveVaultRelative(p: string): string {
|
||||
const adapter = this.app.vault.adapter;
|
||||
if (!(adapter instanceof FileSystemAdapter)) {
|
||||
throw RequestError.invalidParams(
|
||||
undefined,
|
||||
"Agent Mode requires a FileSystemAdapter (desktop)."
|
||||
);
|
||||
}
|
||||
const vaultBase = path.resolve(adapter.getBasePath());
|
||||
const resolved = path.isAbsolute(p) ? path.resolve(p) : path.resolve(vaultBase, p);
|
||||
const rel = path.relative(vaultBase, resolved);
|
||||
if (rel.startsWith("..") || path.isAbsolute(rel)) {
|
||||
throw RequestError.invalidParams(
|
||||
{ path: p },
|
||||
`Path "${p}" is outside the vault and cannot be accessed by Agent Mode.`
|
||||
);
|
||||
}
|
||||
const normalized = normalizePath(rel.split(path.sep).join("/"));
|
||||
// Block dotfile dirs/files at the vault root (`.obsidian/`, `.copilot/`,
|
||||
// `.git/`, etc.). They contain plugin settings — including encrypted-at-
|
||||
// rest API keys, hotkey config, vault metadata — that the agent has no
|
||||
// business reading or writing without an explicit user-facing flow.
|
||||
const firstSegment = normalized.split("/")[0] ?? "";
|
||||
if (firstSegment.startsWith(".")) {
|
||||
throw RequestError.invalidParams(
|
||||
{ path: p },
|
||||
`Path "${p}" is in a hidden directory (${firstSegment}) and is not accessible to Agent Mode.`
|
||||
);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a 1-based line slice with an optional limit. Mirrors ACP's
|
||||
* `ReadTextFileRequest.{line, limit}` semantics: read N lines starting from
|
||||
* line `line`. Out-of-range starts return empty.
|
||||
*/
|
||||
export function sliceLines(content: string, line: number | null, limit: number | null): string {
|
||||
if (line == null && limit == null) return content;
|
||||
const lines = content.split("\n");
|
||||
const start = line != null ? Math.max(0, line - 1) : 0;
|
||||
const end = limit != null ? Math.min(lines.length, start + limit) : lines.length;
|
||||
if (start >= lines.length) return "";
|
||||
return lines.slice(start, end).join("\n");
|
||||
}
|
||||
182
src/agentMode/acp/debugTap.ts
Normal file
182
src/agentMode/acp/debugTap.ts
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
import { logInfo } from "@/logger";
|
||||
import { getSettings } from "@/settings/model";
|
||||
import { formatPayload, frameSink, type FrameRecord } from "@/agentMode/session/debugSink";
|
||||
|
||||
interface JsonRpcFrame {
|
||||
jsonrpc?: string;
|
||||
id?: number | string;
|
||||
method?: string;
|
||||
params?: unknown;
|
||||
result?: unknown;
|
||||
error?: { code?: number; message?: string; data?: unknown };
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap the subprocess stdin/stdout streams so every NDJSON-framed
|
||||
* JSON-RPC message is logged in both directions. Outbound is what
|
||||
* `ClientSideConnection` writes to stdin; inbound is what we read from
|
||||
* stdout. The taps are passthroughs — bytes flow through unchanged.
|
||||
*
|
||||
* Method names are remembered per request id so that responses (which
|
||||
* carry only id + result/error) can be labeled with the method they
|
||||
* answered.
|
||||
*/
|
||||
export function wrapStreamsForDebug(
|
||||
stdin: WritableStream<Uint8Array>,
|
||||
stdout: ReadableStream<Uint8Array>,
|
||||
tag: string
|
||||
): { stdin: WritableStream<Uint8Array>; stdout: ReadableStream<Uint8Array> } {
|
||||
const outboundPending = new Map<string, string>();
|
||||
const inboundPending = new Map<string, string>();
|
||||
|
||||
return {
|
||||
stdin: tapWritable(stdin, (line) => logFrame("→", line, tag, outboundPending, inboundPending)),
|
||||
stdout: tapReadable(stdout, (line) =>
|
||||
logFrame("←", line, tag, inboundPending, outboundPending)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function tapWritable(
|
||||
inner: WritableStream<Uint8Array>,
|
||||
onLine: (line: string) => void
|
||||
): WritableStream<Uint8Array> {
|
||||
// Avoid `TransformStream` / `pipeTo` because the inner stream comes from
|
||||
// Node's `Writable.toWeb()` and is branded against `node:internal/
|
||||
// webstreams`; mixing it with global-realm streams throws
|
||||
// `ERR_INVALID_ARG_TYPE`. A hand-rolled WritableStream that delegates to
|
||||
// the inner writer side-steps the realm check entirely.
|
||||
const writer = inner.getWriter();
|
||||
const splitter = new NdjsonLineSplitter(onLine);
|
||||
return new WritableStream<Uint8Array>({
|
||||
async write(chunk) {
|
||||
splitter.push(chunk);
|
||||
await writer.write(chunk);
|
||||
},
|
||||
async close() {
|
||||
splitter.flush();
|
||||
await writer.close();
|
||||
},
|
||||
async abort(reason) {
|
||||
splitter.flush();
|
||||
await writer.abort(reason);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function tapReadable(
|
||||
inner: ReadableStream<Uint8Array>,
|
||||
onLine: (line: string) => void
|
||||
): ReadableStream<Uint8Array> {
|
||||
// `tee()` is a same-realm method (no class mismatch), so we get two
|
||||
// branded-equivalent ReadableStreams: one for the SDK to consume, one we
|
||||
// drain ourselves for logging.
|
||||
const [forConsumer, forLogging] = inner.tee();
|
||||
const splitter = new NdjsonLineSplitter(onLine);
|
||||
void (async () => {
|
||||
const reader = forLogging.getReader();
|
||||
try {
|
||||
for (;;) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
if (value) splitter.push(value);
|
||||
}
|
||||
splitter.flush();
|
||||
} catch {
|
||||
// Stream closed/aborted; nothing for the tap to do.
|
||||
}
|
||||
})();
|
||||
return forConsumer;
|
||||
}
|
||||
|
||||
class NdjsonLineSplitter {
|
||||
private buffer = "";
|
||||
private decoder = new TextDecoder();
|
||||
|
||||
constructor(private readonly onLine: (line: string) => void) {}
|
||||
|
||||
push(chunk: Uint8Array): void {
|
||||
this.buffer += this.decoder.decode(chunk, { stream: true });
|
||||
let nl: number;
|
||||
while ((nl = this.buffer.indexOf("\n")) !== -1) {
|
||||
const line = this.buffer.slice(0, nl).trim();
|
||||
this.buffer = this.buffer.slice(nl + 1);
|
||||
if (line) this.emit(line);
|
||||
}
|
||||
}
|
||||
|
||||
flush(): void {
|
||||
const tail = this.buffer.trim();
|
||||
this.buffer = "";
|
||||
if (tail) this.emit(tail);
|
||||
}
|
||||
|
||||
private emit(line: string): void {
|
||||
try {
|
||||
this.onLine(line);
|
||||
} catch {
|
||||
// Logging must never break the protocol stream.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function logFrame(
|
||||
arrow: "→" | "←",
|
||||
line: string,
|
||||
tag: string,
|
||||
/** Pending requests originated by *our* side of this stream. */
|
||||
ownPending: Map<string, string>,
|
||||
/** Pending requests originated by the *other* side of this stream. */
|
||||
peerPending: Map<string, string>
|
||||
): void {
|
||||
// Read once per frame so the hot path doesn't allocate a record + timestamp
|
||||
// when the toggle is off.
|
||||
const fullFramesOn = !!getSettings().agentMode?.debugFullFrames;
|
||||
const emit = fullFramesOn
|
||||
? (kind: FrameRecord["kind"], method: string, id: string | null, payload: unknown) =>
|
||||
frameSink.append({
|
||||
ts: new Date().toISOString(),
|
||||
dir: arrow,
|
||||
tag,
|
||||
kind,
|
||||
method,
|
||||
id,
|
||||
payload,
|
||||
})
|
||||
: null;
|
||||
|
||||
let frame: JsonRpcFrame;
|
||||
try {
|
||||
frame = JSON.parse(line);
|
||||
} catch {
|
||||
logInfo(`[ACP ${arrow}][${tag}] (unparsed) ${formatPayload(line)}`);
|
||||
emit?.("raw", "(unparsed)", null, { raw: line });
|
||||
return;
|
||||
}
|
||||
|
||||
const idStr = frame.id !== undefined ? String(frame.id) : null;
|
||||
|
||||
if (frame.method) {
|
||||
// Request or notification.
|
||||
const method = frame.method;
|
||||
const idLabel = idStr !== null ? `#${idStr}` : "(notif)";
|
||||
if (idStr !== null) ownPending.set(idStr, method);
|
||||
logInfo(`[ACP ${arrow}][${tag}] ${method} ${idLabel} ${formatPayload(frame.params)}`);
|
||||
emit?.(idStr !== null ? "request" : "notif", method, idStr, frame.params);
|
||||
return;
|
||||
}
|
||||
|
||||
// Response (result or error). Method name comes from the side that
|
||||
// originated the request — that's `peerPending` from this stream's
|
||||
// perspective.
|
||||
const method = idStr !== null ? (peerPending.get(idStr) ?? "(unknown)") : "(unknown)";
|
||||
if (idStr !== null) peerPending.delete(idStr);
|
||||
const idLabel = idStr !== null ? `#${idStr}` : "(no-id)";
|
||||
if (frame.error) {
|
||||
logInfo(`[ACP ${arrow}][${tag}] (error) ${method} ${idLabel} ${formatPayload(frame.error)}`);
|
||||
emit?.("error", method, idStr, frame.error);
|
||||
} else {
|
||||
logInfo(`[ACP ${arrow}][${tag}] ${method} ${idLabel} ${formatPayload(frame.result)}`);
|
||||
emit?.("result", method, idStr, frame.result);
|
||||
}
|
||||
}
|
||||
4
src/agentMode/acp/index.ts
Normal file
4
src/agentMode/acp/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export { AcpBackendProcess } from "./AcpBackendProcess";
|
||||
export { AcpProcessManager, type AcpProcessManagerOptions } from "./AcpProcessManager";
|
||||
export { VaultClient } from "./VaultClient";
|
||||
export type { AcpBackend, AcpSpawnDescriptor } from "./types";
|
||||
35
src/agentMode/acp/nodeShebangPath.ts
Normal file
35
src/agentMode/acp/nodeShebangPath.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import * as path from "node:path";
|
||||
|
||||
/**
|
||||
* macOS GUI apps (Obsidian) inherit a minimal PATH that omits Homebrew and
|
||||
* common Node installer locations. Adapters that ship as `#!/usr/bin/env
|
||||
* node` launchers fail to spawn with `env: node: No such file or directory`
|
||||
* unless we put `node` on PATH ourselves.
|
||||
*
|
||||
* Prepend the directory containing the binary (npm globals install the
|
||||
* launcher script next to `node`) plus the well-known Homebrew / system
|
||||
* prefixes, then keep the inherited PATH for everything else.
|
||||
*/
|
||||
export function augmentPathForNodeShebang(
|
||||
binaryPath: string,
|
||||
inherited: string | undefined
|
||||
): string {
|
||||
const sep = process.platform === "win32" ? ";" : ":";
|
||||
const candidates = [
|
||||
path.dirname(binaryPath),
|
||||
"/opt/homebrew/bin",
|
||||
"/usr/local/bin",
|
||||
"/usr/bin",
|
||||
"/bin",
|
||||
];
|
||||
const inheritedParts = (inherited ?? "").split(sep).filter(Boolean);
|
||||
const seen = new Set<string>();
|
||||
const merged: string[] = [];
|
||||
for (const p of [...candidates, ...inheritedParts]) {
|
||||
if (!seen.has(p)) {
|
||||
seen.add(p);
|
||||
merged.push(p);
|
||||
}
|
||||
}
|
||||
return merged.join(sep);
|
||||
}
|
||||
27
src/agentMode/acp/types.ts
Normal file
27
src/agentMode/acp/types.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import type { BackendId } from "@/agentMode/session/types";
|
||||
|
||||
/**
|
||||
* Spawn descriptor for an ACP-speaking agent backend. Backends produce these
|
||||
* lazily because they may need to read settings (BYOK keys, MCP config) at
|
||||
* spawn time.
|
||||
*/
|
||||
export interface AcpSpawnDescriptor {
|
||||
command: string;
|
||||
args: string[];
|
||||
env: NodeJS.ProcessEnv;
|
||||
}
|
||||
|
||||
/**
|
||||
* One ACP-track agent backend. Implementers (OpencodeBackend, CodexBackend,
|
||||
* etc.) own the spawn-time contract. The rest of Agent Mode —
|
||||
* `AcpBackendProcess`, `AgentSession`, `VaultClient` — stays
|
||||
* backend-agnostic.
|
||||
*/
|
||||
export interface AcpBackend {
|
||||
/** Stable identifier, used for logging and settings selection. */
|
||||
readonly id: BackendId;
|
||||
/** Human-readable name surfaced in the UI. */
|
||||
readonly displayName: string;
|
||||
/** Build the spawn descriptor (BYOK keys decrypted, env composed). */
|
||||
buildSpawnDescriptor(ctx: { vaultBasePath: string }): Promise<AcpSpawnDescriptor>;
|
||||
}
|
||||
425
src/agentMode/acp/wireTranslate.ts
Normal file
425
src/agentMode/acp/wireTranslate.ts
Normal file
|
|
@ -0,0 +1,425 @@
|
|||
/**
|
||||
* Pure translators between ACP wire types and the session-domain types
|
||||
* defined in `session/types.ts`. The shapes mostly mirror each other (we
|
||||
* intentionally modelled the session domain on ACP's vocabulary), so most
|
||||
* translators are structural identity casts. Keeping them in one file
|
||||
* isolates the seam: when ACP evolves, this is the only place that needs
|
||||
* updating.
|
||||
*/
|
||||
import type {
|
||||
CancelNotification,
|
||||
ContentBlock,
|
||||
McpServer as AcpMcpServer,
|
||||
ModelInfo as AcpModelInfo,
|
||||
PermissionOption as AcpPermissionOption,
|
||||
Plan as AcpPlan,
|
||||
RequestPermissionRequest,
|
||||
RequestPermissionResponse,
|
||||
SessionConfigOption,
|
||||
SessionId as AcpSessionId,
|
||||
SessionModeState,
|
||||
SessionModelState,
|
||||
SessionNotification,
|
||||
StopReason as AcpStopReason,
|
||||
ToolCall,
|
||||
ToolCallContent as AcpToolCallContent,
|
||||
ToolCallUpdate,
|
||||
ToolKind as AcpToolKind,
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import type {
|
||||
AgentToolKind,
|
||||
AgentToolStatus,
|
||||
BackendConfigOption,
|
||||
BackendDescriptor,
|
||||
RawModeState,
|
||||
RawModelState,
|
||||
BackendState,
|
||||
CancelInput,
|
||||
ListedSessionInfo,
|
||||
McpServerSpec,
|
||||
PermissionDecision,
|
||||
PermissionOption,
|
||||
PermissionPrompt,
|
||||
PromptContent,
|
||||
SessionEvent,
|
||||
SessionId,
|
||||
SessionUpdate,
|
||||
StopReason,
|
||||
ToolCallContent,
|
||||
ToolCallDelta,
|
||||
ToolCallSnapshot,
|
||||
} from "@/agentMode/session/types";
|
||||
import { PERMISSION_OPTION_KINDS } from "@/agentMode/session/types";
|
||||
import { translateBackendState } from "@/agentMode/session/translateBackendState";
|
||||
|
||||
// ---- Catalog wire → neutral (pass-through, structural alias) -----------
|
||||
|
||||
export function modelStateFromAcp(
|
||||
state: SessionModelState | null | undefined
|
||||
): RawModelState | null {
|
||||
if (!state) return null;
|
||||
return {
|
||||
currentModelId: state.currentModelId,
|
||||
availableModels: state.availableModels.map((m) => ({
|
||||
modelId: m.modelId,
|
||||
name: m.name,
|
||||
description: m.description ?? undefined,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function modeStateFromAcp(state: SessionModeState | null | undefined): RawModeState | null {
|
||||
if (!state) return null;
|
||||
return {
|
||||
currentModeId: state.currentModeId,
|
||||
availableModes: state.availableModes.map((m) => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
description: m.description ?? undefined,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function configOptionsFromAcp(
|
||||
options: SessionConfigOption[] | null | undefined
|
||||
): BackendConfigOption[] | null {
|
||||
if (!options) return null;
|
||||
return options.map(configOptionFromAcp);
|
||||
}
|
||||
|
||||
function configOptionFromAcp(opt: SessionConfigOption): BackendConfigOption {
|
||||
if (opt.type === "select") {
|
||||
return {
|
||||
id: opt.id,
|
||||
type: "select",
|
||||
name: opt.name,
|
||||
description: opt.description ?? undefined,
|
||||
category: opt.category ?? null,
|
||||
currentValue: String(opt.currentValue),
|
||||
options: opt.options.map((entry) => {
|
||||
if ("options" in entry) {
|
||||
return {
|
||||
name: entry.name,
|
||||
options: entry.options.map((inner) => ({
|
||||
value: inner.value,
|
||||
name: inner.name,
|
||||
description: inner.description ?? undefined,
|
||||
})),
|
||||
};
|
||||
}
|
||||
return {
|
||||
value: entry.value,
|
||||
name: entry.name,
|
||||
description: entry.description ?? undefined,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: opt.id,
|
||||
type: "boolean",
|
||||
name: opt.name,
|
||||
description: opt.description ?? undefined,
|
||||
category: opt.category ?? null,
|
||||
currentValue: Boolean((opt as { currentValue: unknown }).currentValue),
|
||||
};
|
||||
}
|
||||
|
||||
export function configOptionToAcp(opt: BackendConfigOption): SessionConfigOption {
|
||||
// SDK's SessionConfigOption shape mirrors our neutral shape; cast through.
|
||||
return opt as unknown as SessionConfigOption;
|
||||
}
|
||||
|
||||
export function modelInfoFromAcp(model: AcpModelInfo): {
|
||||
modelId: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
} {
|
||||
return {
|
||||
modelId: model.modelId,
|
||||
name: model.name,
|
||||
description: model.description ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function acpStateToBackendState(
|
||||
models: SessionModelState | null | undefined,
|
||||
modes: SessionModeState | null | undefined,
|
||||
configOptions: SessionConfigOption[] | null | undefined,
|
||||
descriptor: BackendDescriptor
|
||||
): BackendState {
|
||||
return translateBackendState(
|
||||
{
|
||||
models: modelStateFromAcp(models),
|
||||
modes: modeStateFromAcp(modes),
|
||||
configOptions: configOptionsFromAcp(configOptions),
|
||||
},
|
||||
descriptor
|
||||
);
|
||||
}
|
||||
|
||||
// ---- StopReason --------------------------------------------------------
|
||||
|
||||
export function stopReasonFromAcp(reason: AcpStopReason): StopReason {
|
||||
switch (reason) {
|
||||
case "end_turn":
|
||||
case "cancelled":
|
||||
case "refusal":
|
||||
case "max_tokens":
|
||||
case "max_turn_requests":
|
||||
return reason;
|
||||
default:
|
||||
return "end_turn";
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Tool kind / status (ACP enum subsets) -----------------------------
|
||||
|
||||
export function toolKindFromAcp(kind: AcpToolKind | undefined): AgentToolKind | undefined {
|
||||
if (kind == null) return undefined;
|
||||
return kind;
|
||||
}
|
||||
|
||||
function toolStatusFromAcp(status: string | undefined): AgentToolStatus | undefined {
|
||||
if (!status) return undefined;
|
||||
return status as AgentToolStatus;
|
||||
}
|
||||
|
||||
// ---- Content blocks ----------------------------------------------------
|
||||
|
||||
export function promptContentToAcp(blocks: PromptContent[]): ContentBlock[] {
|
||||
return blocks.map((b): ContentBlock => {
|
||||
if (b.type === "text") return { type: "text", text: b.text };
|
||||
if (b.type === "image") return { type: "image", mimeType: b.mimeType, data: b.data };
|
||||
return { type: "resource_link", uri: b.uri, name: b.name ?? b.uri };
|
||||
});
|
||||
}
|
||||
|
||||
export function promptContentFromAcp(block: ContentBlock): PromptContent | null {
|
||||
if (block.type === "text") return { type: "text", text: block.text };
|
||||
if (block.type === "image") return { type: "image", mimeType: block.mimeType, data: block.data };
|
||||
if (block.type === "resource_link")
|
||||
return { type: "resource_link", uri: block.uri, name: block.name ?? undefined };
|
||||
return null;
|
||||
}
|
||||
|
||||
function toolCallContentFromAcp(
|
||||
content: AcpToolCallContent[] | null | undefined
|
||||
): ToolCallContent[] | undefined {
|
||||
if (!content) return undefined;
|
||||
const out: ToolCallContent[] = [];
|
||||
for (const item of content) {
|
||||
if (item.type === "content" && item.content.type === "text") {
|
||||
out.push({ type: "content", content: { type: "text", text: item.content.text } });
|
||||
} else if (item.type === "diff") {
|
||||
out.push({
|
||||
type: "diff",
|
||||
path: item.path,
|
||||
oldText: item.oldText ?? null,
|
||||
newText: item.newText,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out.length > 0 ? out : undefined;
|
||||
}
|
||||
|
||||
function toolCallSnapshotFromAcp(
|
||||
call: ToolCall & { sessionUpdate?: "tool_call" }
|
||||
): ToolCallSnapshot {
|
||||
return {
|
||||
toolCallId: call.toolCallId,
|
||||
title: call.title,
|
||||
kind: toolKindFromAcp(call.kind),
|
||||
status: toolStatusFromAcp(call.status),
|
||||
rawInput: call.rawInput,
|
||||
content: toolCallContentFromAcp(call.content),
|
||||
locations: call.locations?.map((l) => ({ path: l.path, line: l.line ?? undefined })),
|
||||
};
|
||||
}
|
||||
|
||||
function mapNullable<T, R>(value: T | null | undefined, fn: (value: T) => R): R | null | undefined {
|
||||
if (value === null) return null;
|
||||
if (value === undefined) return undefined;
|
||||
return fn(value);
|
||||
}
|
||||
|
||||
function toolCallDeltaFromAcp(
|
||||
upd: ToolCallUpdate & { sessionUpdate?: "tool_call_update" }
|
||||
): ToolCallDelta {
|
||||
return {
|
||||
toolCallId: upd.toolCallId,
|
||||
title: upd.title ?? undefined,
|
||||
kind: toolKindFromAcp(upd.kind ?? undefined),
|
||||
status: toolStatusFromAcp(upd.status as string | undefined),
|
||||
rawInput: upd.rawInput,
|
||||
content: mapNullable(upd.content, toolCallContentFromAcp),
|
||||
locations: mapNullable(upd.locations, (locs) =>
|
||||
locs.map((l) => ({ path: l.path, line: l.line ?? undefined }))
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Notification → SessionEvent --------------------------------------
|
||||
|
||||
export function acpNotificationToEvent(n: SessionNotification): SessionEvent {
|
||||
return {
|
||||
sessionId: sessionIdFromAcp(n.sessionId),
|
||||
update: acpUpdateToSessionUpdate(n.update),
|
||||
};
|
||||
}
|
||||
|
||||
function acpUpdateToSessionUpdate(update: SessionNotification["update"]): SessionUpdate {
|
||||
switch (update.sessionUpdate) {
|
||||
case "agent_message_chunk":
|
||||
return {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: promptContentFromAcp(update.content) ?? { type: "text", text: "" },
|
||||
};
|
||||
case "agent_thought_chunk":
|
||||
return {
|
||||
sessionUpdate: "agent_thought_chunk",
|
||||
content: promptContentFromAcp(update.content) ?? { type: "text", text: "" },
|
||||
};
|
||||
case "tool_call":
|
||||
return {
|
||||
sessionUpdate: "tool_call",
|
||||
...toolCallSnapshotFromAcp(update),
|
||||
};
|
||||
case "tool_call_update":
|
||||
return {
|
||||
sessionUpdate: "tool_call_update",
|
||||
...toolCallDeltaFromAcp(update),
|
||||
};
|
||||
case "plan":
|
||||
return {
|
||||
sessionUpdate: "plan",
|
||||
entries: update.entries.map((e: AcpPlan["entries"][number]) => ({
|
||||
content: e.content,
|
||||
priority: e.priority,
|
||||
status: e.status,
|
||||
})),
|
||||
};
|
||||
case "session_info_update":
|
||||
return {
|
||||
sessionUpdate: "session_info_update",
|
||||
title: (update as { title?: string | null }).title ?? null,
|
||||
};
|
||||
case "current_mode_update":
|
||||
return {
|
||||
sessionUpdate: "current_mode_update",
|
||||
currentModeId: update.currentModeId,
|
||||
};
|
||||
case "config_option_update":
|
||||
return {
|
||||
sessionUpdate: "config_option_update",
|
||||
configOptions: configOptionsFromAcp(update.configOptions) ?? [],
|
||||
};
|
||||
default:
|
||||
// Unknown discriminant — fall back to a benign session_info_update with no title.
|
||||
return { sessionUpdate: "session_info_update", title: null };
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Permission prompt / decision -------------------------------------
|
||||
|
||||
export function acpPermissionRequestToPrompt(req: RequestPermissionRequest): PermissionPrompt {
|
||||
const call = req.toolCall;
|
||||
return {
|
||||
sessionId: sessionIdFromAcp(req.sessionId),
|
||||
toolCall: {
|
||||
toolCallId: call.toolCallId,
|
||||
title: call.title ?? "Tool call",
|
||||
kind: toolKindFromAcp(call.kind ?? undefined),
|
||||
status: toolStatusFromAcp(call.status as string | undefined) ?? "pending",
|
||||
rawInput: call.rawInput,
|
||||
content: toolCallContentFromAcp(call.content),
|
||||
locations: call.locations?.map((l) => ({ path: l.path, line: l.line ?? undefined })),
|
||||
},
|
||||
options: req.options.map(permissionOptionFromAcp),
|
||||
};
|
||||
}
|
||||
|
||||
function permissionOptionFromAcp(opt: AcpPermissionOption): PermissionOption {
|
||||
return {
|
||||
optionId: opt.optionId,
|
||||
name: opt.name,
|
||||
kind: (PERMISSION_OPTION_KINDS as readonly string[]).includes(opt.kind)
|
||||
? opt.kind
|
||||
: "reject_once",
|
||||
};
|
||||
}
|
||||
|
||||
export function permissionPromptToAcp(prompt: PermissionPrompt): RequestPermissionRequest {
|
||||
return {
|
||||
sessionId: prompt.sessionId,
|
||||
toolCall: {
|
||||
toolCallId: prompt.toolCall.toolCallId,
|
||||
title: prompt.toolCall.title,
|
||||
kind: prompt.toolCall.kind,
|
||||
status: prompt.toolCall.status ?? "pending",
|
||||
rawInput: prompt.toolCall.rawInput,
|
||||
},
|
||||
options: prompt.options.map((o) => ({
|
||||
optionId: o.optionId,
|
||||
name: o.name,
|
||||
kind: o.kind,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function acpDecisionFromResponse(resp: RequestPermissionResponse): PermissionDecision {
|
||||
if (resp.outcome.outcome === "cancelled") {
|
||||
return { outcome: { outcome: "cancelled" } };
|
||||
}
|
||||
return { outcome: { outcome: "selected", optionId: resp.outcome.optionId } };
|
||||
}
|
||||
|
||||
export function decisionToAcpResponse(decision: PermissionDecision): RequestPermissionResponse {
|
||||
return decision;
|
||||
}
|
||||
|
||||
// ---- MCP server --------------------------------------------------------
|
||||
|
||||
export function mcpServerSpecToAcp(spec: McpServerSpec): AcpMcpServer {
|
||||
if ("type" in spec && spec.type === "http") {
|
||||
return { type: "http", name: spec.name, url: spec.url, headers: spec.headers };
|
||||
}
|
||||
if ("type" in spec && spec.type === "sse") {
|
||||
return { type: "sse", name: spec.name, url: spec.url, headers: spec.headers };
|
||||
}
|
||||
// stdio
|
||||
return {
|
||||
name: spec.name,
|
||||
command: spec.command,
|
||||
args: spec.args,
|
||||
env: spec.env,
|
||||
};
|
||||
}
|
||||
|
||||
// ---- SessionId / Cancel -----------------------------------------------
|
||||
|
||||
export function sessionIdFromAcp(id: AcpSessionId): SessionId {
|
||||
return id;
|
||||
}
|
||||
|
||||
export function sessionIdToAcp(id: SessionId): AcpSessionId {
|
||||
return id;
|
||||
}
|
||||
|
||||
export function cancelInputToAcp(input: CancelInput): CancelNotification {
|
||||
return { sessionId: sessionIdToAcp(input.sessionId) };
|
||||
}
|
||||
|
||||
export function listedSessionFromAcp(s: {
|
||||
sessionId: AcpSessionId;
|
||||
cwd: string;
|
||||
title?: string | null;
|
||||
updatedAt?: string | null;
|
||||
}): ListedSessionInfo {
|
||||
return {
|
||||
sessionId: sessionIdFromAcp(s.sessionId),
|
||||
cwd: s.cwd,
|
||||
title: s.title ?? null,
|
||||
updatedAt: s.updatedAt ?? null,
|
||||
};
|
||||
}
|
||||
164
src/agentMode/backends/claude/AskUserQuestionModal.tsx
Normal file
164
src/agentMode/backends/claude/AskUserQuestionModal.tsx
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { createPluginRoot } from "@/utils/react/createPluginRoot";
|
||||
import { App, Modal } from "obsidian";
|
||||
import React from "react";
|
||||
import { Root } from "react-dom/client";
|
||||
import type { AskUserQuestionInput } from "@/agentMode/sdk/permissionBridge";
|
||||
|
||||
type Questions = AskUserQuestionInput["questions"];
|
||||
type Answers = { [questionText: string]: string };
|
||||
|
||||
interface ContentProps {
|
||||
questions: Questions;
|
||||
onSubmit: (answers: Answers) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal that renders the SDK's `AskUserQuestion` payload as a series of
|
||||
* single- or multi-choice question blocks. Resolves with `{ questionText:
|
||||
* "label" }` (single-select) or `{ questionText: "label1, label2" }`
|
||||
* (multi-select). Closing without submitting resolves with `{}` — the
|
||||
* permission bridge treats an empty answer map as a cancellation.
|
||||
*/
|
||||
const AskUserQuestionContent: React.FC<ContentProps> = ({ questions, onSubmit, onCancel }) => {
|
||||
// Per-question selection: a single label for radio, a Set of labels for checkbox.
|
||||
const [selections, setSelections] = React.useState<Record<number, string | Set<string>>>({});
|
||||
|
||||
const canSubmit = questions.every((q, idx) => {
|
||||
if (q.multiSelect) return true;
|
||||
return typeof selections[idx] === "string" && selections[idx] !== "";
|
||||
});
|
||||
|
||||
const submit = (): void => {
|
||||
const answers: Answers = {};
|
||||
for (let i = 0; i < questions.length; i++) {
|
||||
const q = questions[i];
|
||||
const sel = selections[i];
|
||||
if (q.multiSelect) {
|
||||
answers[q.question] = sel instanceof Set ? Array.from(sel).join(", ") : "";
|
||||
} else {
|
||||
answers[q.question] = typeof sel === "string" ? sel : "";
|
||||
}
|
||||
}
|
||||
onSubmit(answers);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="tw-flex tw-flex-col tw-gap-4">
|
||||
{questions.map((q, idx) => (
|
||||
<div key={q.question} className="tw-flex tw-flex-col tw-gap-2">
|
||||
{q.header && (
|
||||
<div className="tw-text-xs tw-font-semibold tw-uppercase tw-text-muted">{q.header}</div>
|
||||
)}
|
||||
<div className="tw-text-sm">{q.question}</div>
|
||||
<div className="tw-flex tw-flex-col tw-gap-1">
|
||||
{q.options.map((opt) => {
|
||||
const sel = selections[idx];
|
||||
const checked = q.multiSelect
|
||||
? sel instanceof Set && sel.has(opt.label)
|
||||
: sel === opt.label;
|
||||
return (
|
||||
<label
|
||||
key={opt.label}
|
||||
className="tw-flex tw-cursor-pointer tw-items-start tw-gap-2 tw-rounded tw-px-2 tw-py-1 hover:tw-bg-modifier-hover"
|
||||
>
|
||||
<input
|
||||
type={q.multiSelect ? "checkbox" : "radio"}
|
||||
name={`askq-${idx}`}
|
||||
checked={checked}
|
||||
onChange={() => {
|
||||
setSelections((prev) => {
|
||||
if (q.multiSelect) {
|
||||
const cur = prev[idx];
|
||||
const next = new Set(cur instanceof Set ? cur : []);
|
||||
if (next.has(opt.label)) next.delete(opt.label);
|
||||
else next.add(opt.label);
|
||||
return { ...prev, [idx]: next };
|
||||
}
|
||||
return { ...prev, [idx]: opt.label };
|
||||
});
|
||||
}}
|
||||
className="tw-mt-0.5"
|
||||
/>
|
||||
<div className="tw-min-w-0">
|
||||
<div className="tw-text-sm">{opt.label}</div>
|
||||
{opt.description && (
|
||||
<div className="tw-text-xs tw-text-muted">{opt.description}</div>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="tw-mt-2 tw-flex tw-justify-end tw-gap-2">
|
||||
<Button variant="secondary" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="default" onClick={submit} disabled={!canSubmit}>
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Open the AskUserQuestion modal for one Claude SDK `canUseTool` invocation.
|
||||
* Resolves with the answers map (or `{}` on cancel — the bridge maps empty
|
||||
* to a deny-with-cancelled message).
|
||||
*/
|
||||
export function openAskUserQuestionModal(app: App, questions: Questions): Promise<Answers> {
|
||||
return new Promise((resolve) => {
|
||||
const modal = new AskUserQuestionModal(app, questions, resolve);
|
||||
modal.open();
|
||||
});
|
||||
}
|
||||
|
||||
class AskUserQuestionModal extends Modal {
|
||||
private root: Root | null = null;
|
||||
private settled = false;
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
private readonly questions: Questions,
|
||||
private readonly onSettle: (answers: Answers) => void
|
||||
) {
|
||||
super(app);
|
||||
this.titleEl.setText("Agent Mode — Question from Claude");
|
||||
}
|
||||
|
||||
onOpen(): void {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
this.root = createPluginRoot(contentEl, this.app);
|
||||
this.root.render(
|
||||
<AskUserQuestionContent
|
||||
questions={this.questions}
|
||||
onSubmit={(answers) => {
|
||||
this.settled = true;
|
||||
this.onSettle(answers);
|
||||
this.close();
|
||||
}}
|
||||
onCancel={() => {
|
||||
this.settled = true;
|
||||
this.onSettle({});
|
||||
this.close();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
this.root?.unmount();
|
||||
this.root = null;
|
||||
this.contentEl.empty();
|
||||
if (!this.settled) {
|
||||
this.settled = true;
|
||||
this.onSettle({});
|
||||
}
|
||||
}
|
||||
}
|
||||
46
src/agentMode/backends/claude/ClaudeInstallModal.tsx
Normal file
46
src/agentMode/backends/claude/ClaudeInstallModal.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { App, Modal, Setting } from "obsidian";
|
||||
import { CLAUDE_INSTALL_COMMAND } from "./descriptor";
|
||||
|
||||
/**
|
||||
* Onboarding modal shown when the `claude` CLI cannot be located. Tells
|
||||
* the user the exact install command and offers a "Re-detect" button (the
|
||||
* resolver runs each time the descriptor's `getInstallState` is queried).
|
||||
*/
|
||||
export class ClaudeInstallModal extends Modal {
|
||||
constructor(app: App) {
|
||||
super(app);
|
||||
}
|
||||
|
||||
onOpen(): void {
|
||||
const { contentEl } = this;
|
||||
contentEl.createEl("h2", { text: "Install Claude CLI" });
|
||||
contentEl.createEl("p", {
|
||||
text:
|
||||
"The Claude (SDK) backend requires the official `claude` CLI installed on your system. " +
|
||||
"Run this command in a terminal:",
|
||||
});
|
||||
const code = contentEl.createEl("pre");
|
||||
code.createEl("code", { text: CLAUDE_INSTALL_COMMAND });
|
||||
contentEl.createEl("p", {
|
||||
text:
|
||||
"The Claude Agent SDK is bundled with this plugin; the `claude` CLI provides " +
|
||||
"authentication and runs the model — that's why you install `@anthropic-ai/claude-code`.",
|
||||
});
|
||||
contentEl.createEl("p", {
|
||||
text:
|
||||
"Then return to Obsidian and click 'Re-detect' in Agent Mode advanced settings. " +
|
||||
"Authentication is inherited from the CLI's login state — run `claude` once to sign in if " +
|
||||
"you haven't already, or set ANTHROPIC_API_KEY in your shell environment.",
|
||||
});
|
||||
new Setting(contentEl).addButton((btn) =>
|
||||
btn
|
||||
.setButtonText("Close")
|
||||
.setCta()
|
||||
.onClick(() => this.close())
|
||||
);
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
this.contentEl.empty();
|
||||
}
|
||||
}
|
||||
148
src/agentMode/backends/claude/ClaudeSettingsPanel.tsx
Normal file
148
src/agentMode/backends/claude/ClaudeSettingsPanel.tsx
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
import { BinaryPathSetting } from "@/components/agent/BinaryPathSetting";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SettingItem } from "@/components/ui/setting-item";
|
||||
import type CopilotPlugin from "@/main";
|
||||
import { setSettings, useSettingsValue } from "@/settings/model";
|
||||
import { validateExecutableFile } from "@/utils/detectBinary";
|
||||
import type { App } from "obsidian";
|
||||
import { Notice } from "obsidian";
|
||||
import React from "react";
|
||||
import { CLAUDE_INSTALL_COMMAND, resolveClaudeCliPath, updateClaudeFields } from "./descriptor";
|
||||
|
||||
interface Props {
|
||||
plugin: CopilotPlugin;
|
||||
app: App;
|
||||
}
|
||||
|
||||
interface AuthEnvSummary {
|
||||
bedrock: boolean;
|
||||
vertex: boolean;
|
||||
apiKey: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read Claude SDK auth-related env vars once. The SDK resolves credentials
|
||||
* through the spawned `claude` CLI, so any of these may be unset and the
|
||||
* agent still works (the CLI's saved login covers it).
|
||||
*/
|
||||
function readAuthEnv(): AuthEnvSummary {
|
||||
return {
|
||||
apiKey: Boolean(process.env.ANTHROPIC_API_KEY),
|
||||
bedrock: Boolean(process.env.CLAUDE_CODE_USE_BEDROCK),
|
||||
vertex: Boolean(process.env.CLAUDE_CODE_USE_VERTEX),
|
||||
};
|
||||
}
|
||||
|
||||
function renderAuthDescription(env: AuthEnvSummary): React.ReactNode {
|
||||
const parts: string[] = [];
|
||||
if (env.apiKey) parts.push("Anthropic API key set");
|
||||
if (env.bedrock) parts.push("Bedrock configured");
|
||||
if (env.vertex) parts.push("Vertex configured");
|
||||
if (parts.length === 0) {
|
||||
return (
|
||||
<span className="tw-text-muted">
|
||||
No auth env vars set — credentials inherit from the <code>claude</code> CLI login state.
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <div>{parts.join(" · ")}</div>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings panel for the Claude (Agent SDK) backend. Shows the resolved
|
||||
* `claude` CLI status, lets users force a re-detect, override the path, and
|
||||
* inspects auth-relevant env vars. There is no managed install for this
|
||||
* backend — the user must install the `claude` CLI themselves.
|
||||
*/
|
||||
export const ClaudeSettingsPanel: React.FC<Props> = () => {
|
||||
const settings = useSettingsValue();
|
||||
const [, force] = React.useReducer((x: number) => x + 1, 0);
|
||||
|
||||
const overridePath = settings.agentMode?.claudeCli?.path ?? "";
|
||||
// Each render re-walks fs.existsSync via the resolver — pressing
|
||||
// "Re-detect" simply forces a new render via `force`.
|
||||
const resolvedPath = resolveClaudeCliPath(settings);
|
||||
const isCustom = Boolean(overridePath);
|
||||
|
||||
// Env doesn't change without an Obsidian restart; read once on mount.
|
||||
const authEnv = React.useMemo(readAuthEnv, []);
|
||||
|
||||
const statusDescription = resolvedPath ? (
|
||||
<>
|
||||
<div>
|
||||
Ready — <code>claude</code>
|
||||
{isCustom && <span className="tw-text-muted"> (custom)</span>}
|
||||
</div>
|
||||
<div className="tw-break-all tw-font-mono tw-text-xs">{resolvedPath}</div>
|
||||
</>
|
||||
) : (
|
||||
<span className="tw-text-warning">
|
||||
Setup required — Claude CLI not found. Install with <code>{CLAUDE_INSTALL_COMMAND}</code>.
|
||||
</span>
|
||||
);
|
||||
|
||||
const onSaveCustomPath = React.useCallback(async (path: string): Promise<string | null> => {
|
||||
const err = await validateExecutableFile(path);
|
||||
if (err) return err;
|
||||
setSettings((cur) => ({
|
||||
agentMode: { ...cur.agentMode, claudeCli: { path } },
|
||||
}));
|
||||
new Notice("Claude CLI path saved.");
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
const clearCustomPath = (): void => {
|
||||
setSettings((cur) => ({
|
||||
agentMode: { ...cur.agentMode, claudeCli: undefined },
|
||||
}));
|
||||
new Notice("Claude CLI override cleared. Auto-detection will be used.");
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingItem type="custom" title="Claude CLI" description={statusDescription}>
|
||||
<div className="tw-flex tw-flex-wrap tw-justify-end tw-gap-2">
|
||||
<Button variant="secondary" onClick={force}>
|
||||
Re-detect
|
||||
</Button>
|
||||
{isCustom && (
|
||||
<Button variant="destructive" onClick={clearCustomPath}>
|
||||
Clear path
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
type="custom"
|
||||
title="Use custom Claude CLI path"
|
||||
description="Skip auto-detection and point Agent Mode at a `claude` binary you already have on disk. Useful for non-standard prefixes (Volta, asdf, NVM, etc.)."
|
||||
>
|
||||
<BinaryPathSetting
|
||||
binaryName="claude"
|
||||
placeholder="/absolute/path/to/claude"
|
||||
initialPath={overridePath}
|
||||
notFoundHint={`claude not found on PATH. Install with \`${CLAUDE_INSTALL_COMMAND}\` and try again.`}
|
||||
onSave={onSaveCustomPath}
|
||||
persistOnAutoDetect
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
type="custom"
|
||||
title="Authentication"
|
||||
description={renderAuthDescription(authEnv)}
|
||||
>
|
||||
<span />
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
type="switch"
|
||||
title="Show extended thinking"
|
||||
description="Stream the model's reasoning blocks during a turn. Increases token usage."
|
||||
checked={Boolean(settings.agentMode?.backends?.claude?.enableThinking)}
|
||||
onCheckedChange={(checked) => updateClaudeFields({ enableThinking: checked })}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
155
src/agentMode/backends/claude/claudeBinaryResolver.test.ts
Normal file
155
src/agentMode/backends/claude/claudeBinaryResolver.test.ts
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import {
|
||||
resolveClaudeBinary,
|
||||
type ClaudeBinaryResolverFs,
|
||||
type ClaudeBinaryResolverInput,
|
||||
} from "./claudeBinaryResolver";
|
||||
|
||||
function makeFs(
|
||||
paths: Iterable<string>,
|
||||
contents: Record<string, string> = {}
|
||||
): ClaudeBinaryResolverFs {
|
||||
const set = new Set(paths);
|
||||
return {
|
||||
existsSync: (p: string) => set.has(p),
|
||||
readFileSync: (p: string) => {
|
||||
if (p in contents) return contents[p];
|
||||
const err: NodeJS.ErrnoException = new Error(`ENOENT: ${p}`);
|
||||
err.code = "ENOENT";
|
||||
throw err;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function unixInput(
|
||||
fs: ClaudeBinaryResolverFs,
|
||||
overrides: Partial<ClaudeBinaryResolverInput> = {}
|
||||
): ClaudeBinaryResolverInput {
|
||||
return {
|
||||
homeDir: "/home/me",
|
||||
platform: "linux",
|
||||
env: {},
|
||||
fs,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function winInput(
|
||||
fs: ClaudeBinaryResolverFs,
|
||||
overrides: Partial<ClaudeBinaryResolverInput> = {}
|
||||
): ClaudeBinaryResolverInput {
|
||||
return {
|
||||
homeDir: "C:\\Users\\me",
|
||||
platform: "win32",
|
||||
env: { APPDATA: "C:\\Users\\me\\AppData\\Roaming" },
|
||||
fs,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolveClaudeBinary — Unix", () => {
|
||||
it("returns the override when it exists", () => {
|
||||
const fs = makeFs(["/custom/claude"]);
|
||||
const path = resolveClaudeBinary(unixInput(fs, { override: "/custom/claude" }));
|
||||
expect(path).toBe("/custom/claude");
|
||||
});
|
||||
|
||||
it("falls through to the default search when the override is missing", () => {
|
||||
const fs = makeFs(["/home/me/.claude/local/claude"]);
|
||||
const path = resolveClaudeBinary(unixInput(fs, { override: "/missing" }));
|
||||
expect(path).toBe("/home/me/.claude/local/claude");
|
||||
});
|
||||
|
||||
it("prefers ~/.claude/local/claude over later candidates", () => {
|
||||
const fs = makeFs([
|
||||
"/home/me/.claude/local/claude",
|
||||
"/usr/local/bin/claude",
|
||||
"/opt/homebrew/bin/claude",
|
||||
]);
|
||||
expect(resolveClaudeBinary(unixInput(fs))).toBe("/home/me/.claude/local/claude");
|
||||
});
|
||||
|
||||
it("finds /opt/homebrew/bin/claude when no earlier candidate exists", () => {
|
||||
const fs = makeFs(["/opt/homebrew/bin/claude"]);
|
||||
expect(resolveClaudeBinary(unixInput(fs))).toBe("/opt/homebrew/bin/claude");
|
||||
});
|
||||
|
||||
it("finds ~/.volta/bin/claude", () => {
|
||||
const fs = makeFs(["/home/me/.volta/bin/claude"]);
|
||||
expect(resolveClaudeBinary(unixInput(fs))).toBe("/home/me/.volta/bin/claude");
|
||||
});
|
||||
|
||||
it("finds ~/.asdf/shims/claude", () => {
|
||||
const fs = makeFs(["/home/me/.asdf/shims/claude"]);
|
||||
expect(resolveClaudeBinary(unixInput(fs))).toBe("/home/me/.asdf/shims/claude");
|
||||
});
|
||||
|
||||
it("uses npm_config_prefix when set", () => {
|
||||
const fs = makeFs(["/opt/myprefix/bin/claude"]);
|
||||
const path = resolveClaudeBinary(
|
||||
unixInput(fs, { env: { npm_config_prefix: "/opt/myprefix" } })
|
||||
);
|
||||
expect(path).toBe("/opt/myprefix/bin/claude");
|
||||
});
|
||||
|
||||
it("falls back to NVM default alias when no other candidate exists", () => {
|
||||
const aliasPath = "/home/me/.nvm/alias/default";
|
||||
const claudePath = "/home/me/.nvm/versions/node/v20.11.0/bin/claude";
|
||||
const fs = makeFs([aliasPath, claudePath], { [aliasPath]: "v20.11.0\n" });
|
||||
expect(resolveClaudeBinary(unixInput(fs))).toBe(claudePath);
|
||||
});
|
||||
|
||||
it("accepts NVM alias contents without the leading 'v'", () => {
|
||||
const aliasPath = "/home/me/.nvm/alias/default";
|
||||
const claudePath = "/home/me/.nvm/versions/node/v18.19.0/bin/claude";
|
||||
const fs = makeFs([aliasPath, claudePath], { [aliasPath]: "18.19.0" });
|
||||
expect(resolveClaudeBinary(unixInput(fs))).toBe(claudePath);
|
||||
});
|
||||
|
||||
it("returns null when NVM alias is unparseable (e.g. 'lts/*')", () => {
|
||||
const aliasPath = "/home/me/.nvm/alias/default";
|
||||
const fs = makeFs([aliasPath], { [aliasPath]: "lts/*" });
|
||||
expect(resolveClaudeBinary(unixInput(fs))).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to cli.js under the npm-global lib root", () => {
|
||||
const fs = makeFs(["/home/me/.npm-global/lib/node_modules/@anthropic-ai/claude-code/cli.js"]);
|
||||
expect(resolveClaudeBinary(unixInput(fs))).toBe(
|
||||
"/home/me/.npm-global/lib/node_modules/@anthropic-ai/claude-code/cli.js"
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null when nothing is found", () => {
|
||||
const fs = makeFs([]);
|
||||
expect(resolveClaudeBinary(unixInput(fs))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveClaudeBinary — Windows", () => {
|
||||
it("prefers claude.exe over claude.cmd in the same dir", () => {
|
||||
const fs = makeFs([
|
||||
"C:\\Users\\me\\AppData\\Roaming\\npm\\claude.exe",
|
||||
"C:\\Users\\me\\AppData\\Roaming\\npm\\claude.cmd",
|
||||
]);
|
||||
expect(resolveClaudeBinary(winInput(fs))).toBe(
|
||||
"C:\\Users\\me\\AppData\\Roaming\\npm\\claude.exe"
|
||||
);
|
||||
});
|
||||
|
||||
it("never picks claude.cmd even when it is the only file present", () => {
|
||||
const fs = makeFs(["C:\\Users\\me\\AppData\\Roaming\\npm\\claude.cmd"]);
|
||||
expect(resolveClaudeBinary(winInput(fs))).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to cli.js under node_modules\\@anthropic-ai\\claude-code", () => {
|
||||
const cliJs =
|
||||
"C:\\Users\\me\\AppData\\Roaming\\npm\\node_modules\\@anthropic-ai\\claude-code\\cli.js";
|
||||
const fs = makeFs([cliJs]);
|
||||
expect(resolveClaudeBinary(winInput(fs))).toBe(cliJs);
|
||||
});
|
||||
|
||||
it("respects the override on Windows", () => {
|
||||
const override = "C:\\tools\\claude.exe";
|
||||
const fs = makeFs([override]);
|
||||
expect(resolveClaudeBinary(winInput(fs, { override }))).toBe(override);
|
||||
});
|
||||
});
|
||||
123
src/agentMode/backends/claude/claudeBinaryResolver.ts
Normal file
123
src/agentMode/backends/claude/claudeBinaryResolver.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
/**
|
||||
* Locate the user-installed `claude` CLI to pass as
|
||||
* `pathToClaudeCodeExecutable`. The SDK's auto-discovery walks
|
||||
* `import.meta.url`, which fails inside Obsidian's bundled `main.js`.
|
||||
*
|
||||
* Pure leaf: callers inject `homeDir`, `platform`, `env`, and `fs` so tests
|
||||
* don't touch real disk.
|
||||
*/
|
||||
import * as path from "node:path";
|
||||
|
||||
export interface ClaudeBinaryResolverFs {
|
||||
existsSync: (path: string) => boolean;
|
||||
readFileSync: (path: string, encoding: "utf8") => string;
|
||||
}
|
||||
|
||||
export interface ClaudeBinaryResolverInput {
|
||||
/** User-configured override path. If set and exists, returned as-is. */
|
||||
override?: string;
|
||||
homeDir: string;
|
||||
platform: NodeJS.Platform;
|
||||
env: { NVM_BIN?: string; npm_config_prefix?: string; APPDATA?: string };
|
||||
fs: ClaudeBinaryResolverFs;
|
||||
}
|
||||
|
||||
export function resolveClaudeBinary(input: ClaudeBinaryResolverInput): string | null {
|
||||
const { override, fs } = input;
|
||||
|
||||
if (override && fs.existsSync(override)) {
|
||||
return override;
|
||||
}
|
||||
|
||||
const candidates = input.platform === "win32" ? windowsCandidates(input) : unixCandidates(input);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (candidate && fs.existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const posix = path.posix;
|
||||
const win = path.win32;
|
||||
|
||||
function unixCandidates(input: ClaudeBinaryResolverInput): Array<string | null> {
|
||||
const { homeDir, env, fs } = input;
|
||||
return [
|
||||
posix.join(homeDir, ".claude", "local", "claude"),
|
||||
posix.join(homeDir, ".local", "bin", "claude"),
|
||||
posix.join(homeDir, ".volta", "bin", "claude"),
|
||||
posix.join(homeDir, ".asdf", "shims", "claude"),
|
||||
posix.join(homeDir, ".asdf", "bin", "claude"),
|
||||
"/usr/local/bin/claude",
|
||||
"/opt/homebrew/bin/claude",
|
||||
posix.join(homeDir, ".npm-global", "bin", "claude"),
|
||||
env.npm_config_prefix ? posix.join(env.npm_config_prefix, "bin", "claude") : null,
|
||||
env.NVM_BIN ? posix.join(env.NVM_BIN, "claude") : null,
|
||||
resolveNvmDefaultClaude(homeDir, fs),
|
||||
posix.join(
|
||||
homeDir,
|
||||
".npm-global",
|
||||
"lib",
|
||||
"node_modules",
|
||||
"@anthropic-ai",
|
||||
"claude-code",
|
||||
"cli.js"
|
||||
),
|
||||
env.npm_config_prefix
|
||||
? posix.join(
|
||||
env.npm_config_prefix,
|
||||
"lib",
|
||||
"node_modules",
|
||||
"@anthropic-ai",
|
||||
"claude-code",
|
||||
"cli.js"
|
||||
)
|
||||
: null,
|
||||
"/usr/local/lib/node_modules/@anthropic-ai/claude-code/cli.js",
|
||||
"/opt/homebrew/lib/node_modules/@anthropic-ai/claude-code/cli.js",
|
||||
];
|
||||
}
|
||||
|
||||
function windowsCandidates(input: ClaudeBinaryResolverInput): Array<string | null> {
|
||||
const { homeDir, env } = input;
|
||||
// Per-dir, prefer `claude.exe`, then `cli.js` under that dir's
|
||||
// node_modules. Never pick `claude.cmd` — it requires `shell: true` and
|
||||
// breaks SDK stdio streaming.
|
||||
const dirs = [
|
||||
env.APPDATA ? win.join(env.APPDATA, "npm") : null,
|
||||
env.npm_config_prefix ?? null,
|
||||
win.join(homeDir, "AppData", "Roaming", "npm"),
|
||||
];
|
||||
const out: Array<string | null> = [];
|
||||
for (const dir of dirs) {
|
||||
if (!dir) continue;
|
||||
out.push(win.join(dir, "claude.exe"));
|
||||
out.push(win.join(dir, "node_modules", "@anthropic-ai", "claude-code", "cli.js"));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* NVM doesn't export `NVM_BIN` to GUI applications on macOS, so reading
|
||||
* `~/.nvm/alias/default` is the most reliable way to find the user's default
|
||||
* Node install. The file may contain `vX.Y.Z`, `X.Y.Z`, or an unresolvable
|
||||
* alias like `lts/*` — the latter we silently skip.
|
||||
*/
|
||||
function resolveNvmDefaultClaude(homeDir: string, fs: ClaudeBinaryResolverFs): string | null {
|
||||
const aliasPath = posix.join(homeDir, ".nvm", "alias", "default");
|
||||
let raw: string;
|
||||
try {
|
||||
raw = fs.readFileSync(aliasPath, "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return null;
|
||||
const versionPattern = /^v?\d+\.\d+\.\d+/;
|
||||
if (!versionPattern.test(trimmed)) return null;
|
||||
const version = trimmed.startsWith("v") ? trimmed : `v${trimmed}`;
|
||||
return posix.join(homeDir, ".nvm", "versions", "node", version, "bin", "claude");
|
||||
}
|
||||
284
src/agentMode/backends/claude/descriptor.ts
Normal file
284
src/agentMode/backends/claude/descriptor.ts
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { logWarn } from "@/logger";
|
||||
import type CopilotPlugin from "@/main";
|
||||
import {
|
||||
getSettings,
|
||||
subscribeToSettingsChange,
|
||||
updateAgentModeBackendFields,
|
||||
type ClaudeBackendSettings,
|
||||
type CopilotSettings,
|
||||
} from "@/settings/model";
|
||||
import type { AgentSession } from "@/agentMode/session/AgentSession";
|
||||
import { applyPersistedMode } from "@/agentMode/session/applyPersistedMode";
|
||||
import { MethodUnsupportedError } from "@/agentMode/session/errors";
|
||||
import { resolveClaudeBinary } from "./claudeBinaryResolver";
|
||||
import { ClaudeSdkBackendProcess } from "@/agentMode/sdk/ClaudeSdkBackendProcess";
|
||||
import { getCachedSdkCatalog, synthesizeEffortConfigOption } from "@/agentMode/sdk/effortOption";
|
||||
import {
|
||||
buildSkillCreationDirective,
|
||||
DEFAULT_SKILLS_FOLDER,
|
||||
SkillManager,
|
||||
} from "@/agentMode/skills";
|
||||
import type {
|
||||
BackendConfigOption,
|
||||
CopilotMode,
|
||||
ModeMapping,
|
||||
ModelSelection,
|
||||
ModelWireCodec,
|
||||
} from "@/agentMode/session/types";
|
||||
import type { BackendDescriptor, BackendProcess, InstallState } from "@/agentMode/session/types";
|
||||
import { ClaudeInstallModal } from "./ClaudeInstallModal";
|
||||
import ClaudeLogo from "./logo.svg";
|
||||
import { ClaudeSettingsPanel } from "./ClaudeSettingsPanel";
|
||||
import { openAskUserQuestionModal } from "./AskUserQuestionModal";
|
||||
|
||||
export const CLAUDE_INSTALL_COMMAND = "npm install -g @anthropic-ai/claude-code";
|
||||
|
||||
export function updateClaudeFields(partial: Partial<ClaudeBackendSettings>): void {
|
||||
updateAgentModeBackendFields("claude", partial);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire-format codec for Claude — bare base id only. Effort is dispatched
|
||||
* via `setSessionConfigOption`, not encoded in the model id, so `encode`
|
||||
* drops `effort` and `effortConfigFor` provides the config option spec.
|
||||
*/
|
||||
const claudeWire: ModelWireCodec = {
|
||||
encode: (selection: ModelSelection) => selection.baseModelId,
|
||||
decode: (wireId: string) => ({
|
||||
selection: { baseModelId: wireId, effort: null },
|
||||
provider: "anthropic",
|
||||
}),
|
||||
effortConfigFor: (baseModelId: string): BackendConfigOption | null => {
|
||||
const catalog = getCachedSdkCatalog();
|
||||
if (!catalog) return null;
|
||||
const modelInfo = catalog.find((m) => m.value === baseModelId);
|
||||
if (!modelInfo) return null;
|
||||
return synthesizeEffortConfigOption(modelInfo, undefined);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the `claude` CLI path from settings + auto-detection. Mirrors the
|
||||
* `getInstallState` logic: explicit override wins, otherwise the resolver
|
||||
* walks Volta/asdf/NVM/Homebrew/npm-global.
|
||||
*/
|
||||
export function resolveClaudeCliPath(settings: CopilotSettings): string | null {
|
||||
const override = settings.agentMode?.claudeCli?.path;
|
||||
return resolveClaudeBinary({
|
||||
override,
|
||||
homeDir: os.homedir(),
|
||||
platform: process.platform,
|
||||
env: {
|
||||
NVM_BIN: process.env.NVM_BIN,
|
||||
npm_config_prefix: process.env.npm_config_prefix,
|
||||
APPDATA: process.env.APPDATA,
|
||||
},
|
||||
fs: {
|
||||
existsSync: (p) => fs.existsSync(p),
|
||||
readFileSync: (p, encoding) => fs.readFileSync(p, encoding),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Plan mode writes its proposal to `<claude-config-dir>/plans/<slug>.md`
|
||||
* (typically `~/.claude/plans/`, but `CLAUDE_CONFIG_DIR` / `XDG_CONFIG_HOME`
|
||||
* can relocate it). We suffix-match on `.claude/plans` rather than prefix-
|
||||
* matching `os.homedir()` so the predicate stays correct under those env
|
||||
* overrides and across platforms — `path.dirname` + `path.join` produce
|
||||
* native separators on macOS/Linux/Windows.
|
||||
*/
|
||||
function isClaudePlanModePlanFilePath(absolutePath: string): boolean {
|
||||
if (!path.isAbsolute(absolutePath)) return false;
|
||||
if (!absolutePath.endsWith(".md")) return false;
|
||||
const dir = path.dirname(absolutePath);
|
||||
return dir.endsWith(path.join(".claude", "plans"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude backend backed by the official `@anthropic-ai/claude-agent-sdk`.
|
||||
* Replaces the legacy `claude-code-acp` shim. Auth is inherited from the
|
||||
* user-installed `claude` CLI's login state (or `ANTHROPIC_API_KEY` /
|
||||
* Bedrock / Vertex env if configured) — the SDK handles credential
|
||||
* resolution through the spawned CLI; we never see or pass the secret.
|
||||
*/
|
||||
export const ClaudeBackendDescriptor: BackendDescriptor = {
|
||||
id: "claude",
|
||||
displayName: "Claude",
|
||||
Icon: ClaudeLogo,
|
||||
skillsProjectDir: ".claude/skills",
|
||||
crossDiscoveredAgents: [],
|
||||
restartOnManagedSkillsChange: false,
|
||||
wire: claudeWire,
|
||||
|
||||
getInstallState(settings: CopilotSettings): InstallState {
|
||||
const path = resolveClaudeCliPath(settings);
|
||||
if (!path) return { kind: "absent" };
|
||||
return {
|
||||
kind: "ready",
|
||||
source: settings.agentMode?.claudeCli?.path ? "custom" : "managed",
|
||||
};
|
||||
},
|
||||
|
||||
subscribeInstallState(_plugin: CopilotPlugin, cb: () => void): () => void {
|
||||
return subscribeToSettingsChange((prev, next) => {
|
||||
if (prev.agentMode?.claudeCli?.path !== next.agentMode?.claudeCli?.path) {
|
||||
cb();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
openInstallUI(plugin: CopilotPlugin): void {
|
||||
new ClaudeInstallModal(plugin.app).open();
|
||||
},
|
||||
|
||||
isPlanModePlanFilePath(absolutePath: string): boolean {
|
||||
return isClaudePlanModePlanFilePath(absolutePath);
|
||||
},
|
||||
|
||||
async applySelection(session: AgentSession, selection: ModelSelection): Promise<void> {
|
||||
// Claude's wire id is just the baseModelId — effort travels through
|
||||
// `setConfigOption`, not the model id. Skip the model round-trip when
|
||||
// the base hasn't changed, otherwise effort-only ticks would fire a
|
||||
// pointless `setSessionModel` on every slider drag.
|
||||
const currentBase = session.getState()?.model?.current.baseModelId;
|
||||
if (currentBase !== selection.baseModelId) {
|
||||
await session.setModel(claudeWire.encode(selection));
|
||||
}
|
||||
if (selection.effort === null) return;
|
||||
const cfgOpt = claudeWire.effortConfigFor?.(selection.baseModelId);
|
||||
if (!cfgOpt) return;
|
||||
try {
|
||||
await session.setConfigOption(cfgOpt.id, selection.effort);
|
||||
} catch (e) {
|
||||
if (!(e instanceof MethodUnsupportedError)) throw e;
|
||||
}
|
||||
},
|
||||
|
||||
createBackendProcess(args): BackendProcess {
|
||||
const claudePath = resolveClaudeCliPath(getSettings());
|
||||
if (!claudePath) {
|
||||
throw new Error(
|
||||
`Claude CLI not found. Install with: ${CLAUDE_INSTALL_COMMAND}, ` +
|
||||
`or set agentMode.claudeCli.path in settings.`
|
||||
);
|
||||
}
|
||||
return new ClaudeSdkBackendProcess({
|
||||
pathToClaudeCodeExecutable: claudePath,
|
||||
app: args.app,
|
||||
clientVersion: args.clientVersion,
|
||||
descriptor: args.descriptor,
|
||||
askUserQuestion: (questions) => openAskUserQuestionModal(args.app, questions),
|
||||
getEnableThinking: () => Boolean(getSettings().agentMode?.backends?.claude?.enableThinking),
|
||||
isPlanModePlanFilePath: isClaudePlanModePlanFilePath,
|
||||
getDefaultModelId: () => getSettings().agentMode?.backends?.claude?.defaultModel?.baseModelId,
|
||||
// Spawn-time skill-creation directive: read the current
|
||||
// `agentMode.skills.folder` so the directive templates the live value
|
||||
// on every new session. See the Skills Management spec.
|
||||
//
|
||||
// Claude has no cross-discovery surface — it only loads
|
||||
// `.claude/skills/`, and the symlink fanout already enforces
|
||||
// visibility (no link = not seen). If the Claude Agent SDK ever
|
||||
// grows a per-skill deny hook, wire `composeDenyList(getManagedSkills(),
|
||||
// "claude")` in here.
|
||||
getSkillCreationDirective: () => {
|
||||
const folder = getSettings().agentMode?.skills?.folder ?? DEFAULT_SKILLS_FOLDER;
|
||||
const dirs = Object.values(SkillManager.getInstance().getAgentDirsProjectRel());
|
||||
return buildSkillCreationDirective("claude", folder, dirs);
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
SettingsPanel: ClaudeSettingsPanel,
|
||||
|
||||
/**
|
||||
* Map Copilot's canonical modes onto the SDK's `PermissionMode` strings.
|
||||
* `acceptEdits` / `dontAsk` exist upstream but stay hidden — the picker is
|
||||
* a 3-mode UI (default / plan / auto). The session adapter normalizes
|
||||
* unknown ids to `default`.
|
||||
*/
|
||||
getModeMapping(): ModeMapping {
|
||||
return {
|
||||
kind: "setMode",
|
||||
canonical: {
|
||||
default: "default",
|
||||
plan: "plan",
|
||||
auto: "bypassPermissions",
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
async persistModeSelection(value: CopilotMode, _plugin: CopilotPlugin): Promise<void> {
|
||||
updateClaudeFields({ selectedMode: value });
|
||||
},
|
||||
|
||||
/**
|
||||
* Replay persisted mode + effort on a freshly created session. The
|
||||
* Claude SDK adapter probes the model catalog asynchronously, so the
|
||||
* effort `SessionConfigOption` may not be present yet when this runs;
|
||||
* `replayPersistedEffort` subscribes to the session and applies once the
|
||||
* option arrives (with a timeout guard to avoid leaking listeners on
|
||||
* agents that never report effort).
|
||||
*/
|
||||
async applyInitialSessionConfig(session: AgentSession, settings: CopilotSettings): Promise<void> {
|
||||
const claudeSettings = settings.agentMode?.backends?.claude;
|
||||
const persistedEffort = claudeSettings?.defaultModel?.effort ?? null;
|
||||
await Promise.all([
|
||||
applyPersistedMode(session, claudeSettings?.selectedMode ?? "default"),
|
||||
replayPersistedEffort(session, persistedEffort ?? undefined),
|
||||
]);
|
||||
},
|
||||
};
|
||||
|
||||
async function replayPersistedEffort(
|
||||
session: AgentSession,
|
||||
persistedEffort: string | undefined
|
||||
): Promise<void> {
|
||||
if (!persistedEffort) return;
|
||||
const tryApply = async (): Promise<boolean> => {
|
||||
const state = session.getState();
|
||||
const current = state?.model?.current;
|
||||
if (!current) return false;
|
||||
if (current.effort === persistedEffort) return true;
|
||||
const entry = state?.model?.availableModels.find((e) => e.baseModelId === current.baseModelId);
|
||||
if (!entry?.effortOptions.some((o) => o.value === persistedEffort)) return true;
|
||||
const cfgOpt = ClaudeBackendDescriptor.wire.effortConfigFor?.(current.baseModelId);
|
||||
if (!cfgOpt) return true;
|
||||
try {
|
||||
await session.setConfigOption(cfgOpt.id, persistedEffort);
|
||||
} catch (e) {
|
||||
if (e instanceof MethodUnsupportedError) return true;
|
||||
logWarn(`[AgentMode] could not apply default effort ${persistedEffort}`, e);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
if (await tryApply()) return;
|
||||
|
||||
// Effort hasn't been advertised yet — wait for the first
|
||||
// config_option_update. Bound the wait so we don't keep a listener alive
|
||||
// on agents that never emit an effort option.
|
||||
await new Promise<void>((resolve) => {
|
||||
let settled = false;
|
||||
const finish = (): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
unsub();
|
||||
window.clearTimeout(timer);
|
||||
resolve();
|
||||
};
|
||||
const unsub = session.subscribe({
|
||||
onMessagesChanged: () => {},
|
||||
onStatusChanged: () => {},
|
||||
onModelChanged: () => {
|
||||
void tryApply().then((applied) => {
|
||||
if (applied) finish();
|
||||
});
|
||||
},
|
||||
});
|
||||
const timer = window.setTimeout(finish, 10_000);
|
||||
});
|
||||
}
|
||||
1
src/agentMode/backends/claude/index.ts
Normal file
1
src/agentMode/backends/claude/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { ClaudeBackendDescriptor } from "./descriptor";
|
||||
1
src/agentMode/backends/claude/logo.svg
Normal file
1
src/agentMode/backends/claude/logo.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg fill="currentColor" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="m4.7144 15.9555 4.7174-2.6471.079-.2307-.079-.1275h-.2307l-.7893-.0486-2.6956-.0729-2.3375-.0971-2.2646-.1214-.5707-.1215-.5343-.7042.0546-.3522.4797-.3218.686.0608 1.5179.1032 2.2767.1578 1.6514.0972 2.4468.255h.3886l.0546-.1579-.1336-.0971-.1032-.0972L6.973 9.8356l-2.55-1.6879-1.3356-.9714-.7225-.4918-.3643-.4614-.1578-1.0078.6557-.7225.8803.0607.2246.0607.8925.686 1.9064 1.4754 2.4893 1.8336.3643.3035.1457-.1032.0182-.0728-.164-.2733-1.3539-2.4467-1.445-2.4893-.6435-1.032-.17-.6194c-.0607-.255-.1032-.4674-.1032-.7285L6.287.1335 6.6997 0l.9957.1336.419.3642.6192 1.4147 1.0018 2.2282 1.5543 3.0296.4553.8985.2429.8318.091.255h.1579v-.1457l.1275-1.706.2368-2.0947.2307-2.6957.0789-.7589.3764-.9107.7468-.4918.5828.2793.4797.686-.0668.4433-.2853 1.8517-.5586 2.9021-.3643 1.9429h.2125l.2429-.2429.9835-1.3053 1.6514-2.0643.7286-.8196.85-.9046.5464-.4311h1.0321l.759 1.1293-.34 1.1657-1.0625 1.3478-.8804 1.1414-1.2628 1.7-.7893 1.36.0729.1093.1882-.0183 2.8535-.607 1.5421-.2794 1.8396-.3157.8318.3886.091.3946-.3278.8075-1.967.4857-2.3072.4614-3.4364.8136-.0425.0304.0486.0607 1.5482.1457.6618.0364h1.621l3.0175.2247.7892.522.4736.6376-.079.4857-1.2142.6193-1.6393-.3886-3.825-.9107-1.3113-.3279h-.1822v.1093l1.0929 1.0686 2.0035 1.8092 2.5075 2.3314.1275.5768-.3218.4554-.34-.0486-2.2039-1.6575-.85-.7468-1.9246-1.621h-.1275v.17l.4432.6496 2.3436 3.5214.1214 1.0807-.17.3521-.6071.2125-.6679-.1214-1.3721-1.9246L14.38 17.959l-1.1414-1.9428-.1397.079-.674 7.2552-.3156.3703-.7286.2793-.6071-.4614-.3218-.7468.3218-1.4753.3886-1.9246.3157-1.53.2853-1.9004.17-.6314-.0121-.0425-.1397.0182-1.4328 1.9672-2.1796 2.9446-1.7243 1.8456-.4128.164-.7164-.3704.0667-.6618.4008-.5889 2.386-3.0357 1.4389-1.882.929-1.0868-.0062-.1579h-.0546l-6.3385 4.1164-1.1293.1457-.4857-.4554.0608-.7467.2307-.2429 1.9064-1.3114Z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
132
src/agentMode/backends/codex/CodexBackend.test.ts
Normal file
132
src/agentMode/backends/codex/CodexBackend.test.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import { resetSettings, setSettings } from "@/settings/model";
|
||||
import { CodexBackend, toTomlBasicString } from "./CodexBackend";
|
||||
|
||||
jest.mock("@/logger", () => ({
|
||||
logInfo: jest.fn(),
|
||||
logWarn: jest.fn(),
|
||||
logError: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("@/agentMode/skills", () => {
|
||||
const actual = jest.requireActual("@/agentMode/skills");
|
||||
return {
|
||||
...actual,
|
||||
SkillManager: {
|
||||
hasInstance: () => true,
|
||||
getInstance: () => ({
|
||||
getAgentDirsProjectRel: () => ({
|
||||
claude: ".claude/skills",
|
||||
codex: ".agents/skills",
|
||||
opencode: ".opencode/skills",
|
||||
}),
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe("CodexBackend.buildSpawnDescriptor", () => {
|
||||
beforeEach(() => {
|
||||
resetSettings();
|
||||
setSettings({
|
||||
agentMode: {
|
||||
enabled: true,
|
||||
byok: {},
|
||||
mcpServers: [],
|
||||
activeBackend: "codex",
|
||||
debugFullFrames: false,
|
||||
skills: { folder: "copilot/skills" },
|
||||
backends: {
|
||||
codex: { binaryPath: "/usr/local/bin/codex-acp" },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("injects the skill-creation directive via -c developer_instructions", async () => {
|
||||
const backend = new CodexBackend();
|
||||
const desc = await backend.buildSpawnDescriptor({ vaultBasePath: "/vault" });
|
||||
expect(desc.command).toBe("/usr/local/bin/codex-acp");
|
||||
const cIdx = desc.args.indexOf("-c");
|
||||
expect(cIdx).toBeGreaterThanOrEqual(0);
|
||||
const value = desc.args[cIdx + 1];
|
||||
expect(value.startsWith("developer_instructions=")).toBe(true);
|
||||
// The TOML value carries the directive text (newlines escaped as \n).
|
||||
expect(value).toContain('metadata.copilot-enabled-agents: \\"codex\\"');
|
||||
expect(value).toContain("copilot/skills/<name>/SKILL.md");
|
||||
expect(value).toContain(".claude/skills/");
|
||||
expect(value).toContain(".agents/skills/");
|
||||
expect(value).toContain(".opencode/skills/");
|
||||
});
|
||||
|
||||
it("templates a custom skills folder at spawn time", async () => {
|
||||
setSettings({
|
||||
agentMode: {
|
||||
enabled: true,
|
||||
byok: {},
|
||||
mcpServers: [],
|
||||
activeBackend: "codex",
|
||||
debugFullFrames: false,
|
||||
skills: { folder: "team-skills" },
|
||||
backends: { codex: { binaryPath: "/usr/local/bin/codex-acp" } },
|
||||
},
|
||||
});
|
||||
const backend = new CodexBackend();
|
||||
const desc = await backend.buildSpawnDescriptor({ vaultBasePath: "/vault" });
|
||||
const cIdx = desc.args.indexOf("-c");
|
||||
const value = desc.args[cIdx + 1];
|
||||
expect(value).toContain("team-skills/<name>/SKILL.md");
|
||||
expect(value).not.toContain("copilot/skills");
|
||||
});
|
||||
|
||||
it("escapes embedded double quotes and backslashes for TOML safety", async () => {
|
||||
// Folders can't contain quotes in practice (validateSkillsFolder
|
||||
// strips them), but the escape logic should still be airtight — the
|
||||
// resulting -c value is consumed by a TOML parser, so an unescaped
|
||||
// quote would terminate the basic-string literal and break
|
||||
// codex-acp's startup.
|
||||
const backend = new CodexBackend();
|
||||
const desc = await backend.buildSpawnDescriptor({ vaultBasePath: "/vault" });
|
||||
const cIdx = desc.args.indexOf("-c");
|
||||
const value = desc.args[cIdx + 1];
|
||||
// The value is wrapped in unescaped outer quotes; any inner double
|
||||
// quote must be `\"` and every newline `\n` (no raw newlines, which
|
||||
// would also break TOML basic strings).
|
||||
expect(value).not.toMatch(/\n/);
|
||||
// Confirm the outer literal is well-formed: starts with `key="…` and
|
||||
// ends with `…"` (the closing quote of the TOML string).
|
||||
expect(value.startsWith('developer_instructions="')).toBe(true);
|
||||
expect(value.endsWith('"')).toBe(true);
|
||||
});
|
||||
|
||||
it("escapes the full TOML basic-string control set", () => {
|
||||
// Named escapes per the TOML 1.0 spec.
|
||||
expect(toTomlBasicString("a\bb\tc\nd\fe\rf")).toBe('"a\\bb\\tc\\nd\\fe\\rf"');
|
||||
// Backslash + double-quote.
|
||||
expect(toTomlBasicString('back\\slash"quote')).toBe('"back\\\\slash\\"quote"');
|
||||
// Other controls fall through as \\uXXXX. Build the input from char
|
||||
// codes so the source file stays plain ASCII (and copies/pastes cleanly).
|
||||
const controls =
|
||||
String.fromCharCode(0x01) + String.fromCharCode(0x1f) + String.fromCharCode(0x7f);
|
||||
expect(toTomlBasicString(controls)).toBe('"\\u0001\\u001f\\u007f"');
|
||||
// Non-ASCII passes through unescaped.
|
||||
expect(toTomlBasicString("über — café")).toBe('"über — café"');
|
||||
});
|
||||
|
||||
it("throws when the codex binary path is unset", async () => {
|
||||
setSettings({
|
||||
agentMode: {
|
||||
enabled: true,
|
||||
byok: {},
|
||||
mcpServers: [],
|
||||
activeBackend: "codex",
|
||||
debugFullFrames: false,
|
||||
skills: { folder: "copilot/skills" },
|
||||
backends: {},
|
||||
},
|
||||
});
|
||||
const backend = new CodexBackend();
|
||||
await expect(backend.buildSpawnDescriptor({ vaultBasePath: "/vault" })).rejects.toThrow(
|
||||
/Codex binary path not configured/
|
||||
);
|
||||
});
|
||||
});
|
||||
77
src/agentMode/backends/codex/CodexBackend.ts
Normal file
77
src/agentMode/backends/codex/CodexBackend.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { getSettings } from "@/settings/model";
|
||||
import { AcpBackend, AcpSpawnDescriptor } from "@/agentMode/acp/types";
|
||||
import { buildSimpleSpawnDescriptor } from "@/agentMode/backends/shared/simpleBinaryBackend";
|
||||
import {
|
||||
buildSkillCreationDirective,
|
||||
DEFAULT_SKILLS_FOLDER,
|
||||
SkillManager,
|
||||
} from "@/agentMode/skills";
|
||||
|
||||
/**
|
||||
* Spawns the user-provided `codex-acp` binary
|
||||
* (`@zed-industries/codex-acp`). The package wraps the local `codex` CLI
|
||||
* and exposes it as an ACP server over stdio. Authentication is inherited
|
||||
* from the user's existing `codex login` (`~/.codex/auth.json`) or
|
||||
* `OPENAI_API_KEY` / `CODEX_API_KEY` exported in the user's shell — we
|
||||
* deliberately do not inject keys so ChatGPT-login subscriptions work
|
||||
* transparently.
|
||||
*/
|
||||
export class CodexBackend implements AcpBackend {
|
||||
readonly id = "codex" as const;
|
||||
readonly displayName = "Codex";
|
||||
|
||||
async buildSpawnDescriptor(_ctx: { vaultBasePath: string }): Promise<AcpSpawnDescriptor> {
|
||||
const descriptor = buildSimpleSpawnDescriptor(
|
||||
getSettings().agentMode?.backends?.codex?.binaryPath,
|
||||
"Codex binary path not configured. Open Agent Mode settings and set the path to codex-acp."
|
||||
);
|
||||
// Spawn-time skill-creation directive: forwarded into codex's
|
||||
// `developer_instructions` config field via codex-acp's `-c key=value`
|
||||
// override (see codex's `core/src/config/mod.rs`: "Developer
|
||||
// instructions override injected as a separate message"). The value
|
||||
// is wrapped in a TOML 1.0 basic string — the `-c` parser runs through
|
||||
// TOML, so we escape per the spec rules (`\`, `"`, the named escapes
|
||||
// `\b \t \n \f \r`, and remaining controls as `\uXXXX`). Folder is
|
||||
// read live from settings so a setting change applies on the next
|
||||
// session. See the Skills Management spec.
|
||||
const skillsFolder = getSettings().agentMode?.skills?.folder ?? DEFAULT_SKILLS_FOLDER;
|
||||
const dirs = Object.values(SkillManager.getInstance().getAgentDirsProjectRel());
|
||||
const directive = buildSkillCreationDirective("codex", skillsFolder, dirs);
|
||||
descriptor.args = [
|
||||
...descriptor.args,
|
||||
"-c",
|
||||
`developer_instructions=${toTomlBasicString(directive)}`,
|
||||
];
|
||||
return descriptor;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode `value` as a TOML 1.0 basic string (double-quoted). Escapes:
|
||||
* - `\` and `"`
|
||||
* - named escapes `\b \t \n \f \r`
|
||||
* - any other byte in 0x00–0x1F and 0x7F as `\uXXXX`
|
||||
*
|
||||
* Non-ASCII characters above 0x7F are valid in basic strings and pass
|
||||
* through unescaped. Exported for unit testing.
|
||||
*/
|
||||
export function toTomlBasicString(value: string): string {
|
||||
let out = '"';
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
const ch = value.charCodeAt(i);
|
||||
if (ch === 0x5c) out += "\\\\";
|
||||
else if (ch === 0x22) out += '\\"';
|
||||
else if (ch === 0x08) out += "\\b";
|
||||
else if (ch === 0x09) out += "\\t";
|
||||
else if (ch === 0x0a) out += "\\n";
|
||||
else if (ch === 0x0c) out += "\\f";
|
||||
else if (ch === 0x0d) out += "\\r";
|
||||
else if (ch < 0x20 || ch === 0x7f) {
|
||||
out += "\\u" + ch.toString(16).padStart(4, "0");
|
||||
} else {
|
||||
out += value[i];
|
||||
}
|
||||
}
|
||||
out += '"';
|
||||
return out;
|
||||
}
|
||||
27
src/agentMode/backends/codex/CodexInstallModal.tsx
Normal file
27
src/agentMode/backends/codex/CodexInstallModal.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { getSettings } from "@/settings/model";
|
||||
import { App } from "obsidian";
|
||||
import React from "react";
|
||||
import { BinaryInstallModal } from "@/agentMode/backends/shared/BinaryInstallContent";
|
||||
import { CODEX_BINARY_NAME, CODEX_INSTALL_COMMAND, updateCodexFields } from "./descriptor";
|
||||
|
||||
export class CodexInstallModal extends BinaryInstallModal {
|
||||
constructor(app: App) {
|
||||
super(app, {
|
||||
modalTitle: "Configure Codex (Agent backend)",
|
||||
binaryDisplayName: "Codex",
|
||||
binaryName: CODEX_BINARY_NAME,
|
||||
installCommand: CODEX_INSTALL_COMMAND,
|
||||
pathPlaceholder: "/absolute/path/to/codex-acp",
|
||||
initialPath: getSettings().agentMode?.backends?.codex?.binaryPath ?? "",
|
||||
description: (
|
||||
<>
|
||||
Codex uses the official <code>@zed-industries/codex-acp</code> adapter, which wraps the
|
||||
local <code>codex</code> CLI. It inherits your existing <code>codex login</code>{" "}
|
||||
credentials — or set <code>OPENAI_API_KEY</code> / <code>CODEX_API_KEY</code> in your
|
||||
shell if you prefer API-key auth.
|
||||
</>
|
||||
),
|
||||
onPersist: (path) => updateCodexFields({ binaryPath: path }),
|
||||
});
|
||||
}
|
||||
}
|
||||
26
src/agentMode/backends/codex/CodexSettingsPanel.tsx
Normal file
26
src/agentMode/backends/codex/CodexSettingsPanel.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import type CopilotPlugin from "@/main";
|
||||
import { getSettings } from "@/settings/model";
|
||||
import type { App } from "obsidian";
|
||||
import React from "react";
|
||||
import { SimpleBackendSettingsPanel } from "@/agentMode/backends/shared/SimpleBackendSettingsPanel";
|
||||
import { CodexInstallModal } from "./CodexInstallModal";
|
||||
import { CODEX_BINARY_NAME, CODEX_INSTALL_COMMAND, updateCodexFields } from "./descriptor";
|
||||
|
||||
interface Props {
|
||||
plugin: CopilotPlugin;
|
||||
app: App;
|
||||
}
|
||||
|
||||
export const CodexSettingsPanel: React.FC<Props> = ({ app }) => (
|
||||
<SimpleBackendSettingsPanel
|
||||
displayName="Codex"
|
||||
binaryName={CODEX_BINARY_NAME}
|
||||
installCommand={CODEX_INSTALL_COMMAND}
|
||||
pathPlaceholder="/absolute/path/to/codex-acp"
|
||||
customPathTitle="Custom codex-acp path"
|
||||
customPathDescription="Point Agent Mode at the codex-acp binary. Codex inherits auth from your local `codex login` credentials, or from `OPENAI_API_KEY` / `CODEX_API_KEY` exported in your shell."
|
||||
readStoredPath={() => getSettings().agentMode?.backends?.codex?.binaryPath ?? ""}
|
||||
persistPath={(path) => updateCodexFields({ binaryPath: path })}
|
||||
openInstallModal={() => new CodexInstallModal(app).open()}
|
||||
/>
|
||||
);
|
||||
31
src/agentMode/backends/codex/descriptor.test.ts
Normal file
31
src/agentMode/backends/codex/descriptor.test.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { CodexBackendDescriptor } from "./descriptor";
|
||||
|
||||
jest.mock("@/logger", () => ({
|
||||
logInfo: jest.fn(),
|
||||
logWarn: jest.fn(),
|
||||
logError: jest.fn(),
|
||||
}));
|
||||
|
||||
describe("CodexBackendDescriptor.isModelEnabledByDefault", () => {
|
||||
const fn = CodexBackendDescriptor.isModelEnabledByDefault!;
|
||||
|
||||
it("matches gpt-5.5 family by modelId", () => {
|
||||
expect(fn({ modelId: "gpt-5.5", name: "GPT-5.5" })).toBe(true);
|
||||
expect(fn({ modelId: "gpt-5.5/high", name: "GPT-5.5 (high)" })).toBe(true);
|
||||
});
|
||||
|
||||
it("matches gpt-5.5 family by display name", () => {
|
||||
expect(fn({ modelId: "some-internal-id", name: "GPT-5.5" })).toBe(true);
|
||||
});
|
||||
|
||||
it("does not match version numbers that merely contain 5.5", () => {
|
||||
expect(fn({ modelId: "model-15.5-x", name: "Model 15.5x" })).toBe(false);
|
||||
expect(fn({ modelId: "model-5.50", name: "Model 5.50" })).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects unrelated models", () => {
|
||||
expect(fn({ modelId: "gpt-5", name: "GPT-5" })).toBe(false);
|
||||
expect(fn({ modelId: "gpt-5-codex/high", name: "GPT-5 Codex (high)" })).toBe(false);
|
||||
expect(fn({ modelId: "o3", name: "o3" })).toBe(false);
|
||||
});
|
||||
});
|
||||
157
src/agentMode/backends/codex/descriptor.ts
Normal file
157
src/agentMode/backends/codex/descriptor.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import type CopilotPlugin from "@/main";
|
||||
import {
|
||||
subscribeToSettingsChange,
|
||||
updateAgentModeBackendFields,
|
||||
type CodexBackendSettings,
|
||||
type CopilotSettings,
|
||||
} from "@/settings/model";
|
||||
import { CodexBackend } from "./CodexBackend";
|
||||
import { CodexInstallModal } from "./CodexInstallModal";
|
||||
import CodexLogo from "./logo.svg";
|
||||
import { CodexSettingsPanel } from "./CodexSettingsPanel";
|
||||
import type { AgentSession } from "@/agentMode/session/AgentSession";
|
||||
import { applyPersistedMode } from "@/agentMode/session/applyPersistedMode";
|
||||
import {
|
||||
binaryPathInstallState,
|
||||
simpleBinaryBackendProcess,
|
||||
} from "@/agentMode/backends/shared/simpleBinaryBackend";
|
||||
import type {
|
||||
CopilotMode,
|
||||
ModeMapping,
|
||||
ModelSelection,
|
||||
ModelWireCodec,
|
||||
} from "@/agentMode/session/types";
|
||||
import type { BackendDescriptor, BackendProcess, InstallState } from "@/agentMode/session/types";
|
||||
|
||||
export const CODEX_BINARY_NAME = "codex-acp";
|
||||
export const CODEX_INSTALL_COMMAND = "npm install -g @zed-industries/codex-acp";
|
||||
|
||||
/**
|
||||
* Vocabulary mirrors codex-acp's advertised efforts. `minimal` is included
|
||||
* for forward-compat — codex CLI accepts it as a reasoning level even though
|
||||
* codex-acp doesn't currently advertise it.
|
||||
*/
|
||||
const KNOWN_CODEX_EFFORTS = new Set(["minimal", "low", "medium", "high", "xhigh"]);
|
||||
|
||||
export function updateCodexFields(partial: Partial<CodexBackendSettings>): void {
|
||||
updateAgentModeBackendFields("codex", partial);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire-format codec for Codex — `<base>[/<effort>]`. No provider segment
|
||||
* (Codex's catalog isn't routed through Copilot BYOK keys, so
|
||||
* `decode().provider` stays `null`).
|
||||
*/
|
||||
const codexWire: ModelWireCodec = {
|
||||
encode: (selection: ModelSelection) =>
|
||||
selection.effort ? `${selection.baseModelId}/${selection.effort}` : selection.baseModelId,
|
||||
decode: (wireId: string) => {
|
||||
if (!wireId) return { selection: { baseModelId: wireId, effort: null }, provider: null };
|
||||
const segments = wireId.split("/");
|
||||
if (segments.length === 1) {
|
||||
return { selection: { baseModelId: wireId, effort: null }, provider: null };
|
||||
}
|
||||
if (segments.length === 2 && KNOWN_CODEX_EFFORTS.has(segments[1])) {
|
||||
return {
|
||||
selection: { baseModelId: segments[0], effort: segments[1] },
|
||||
provider: null,
|
||||
};
|
||||
}
|
||||
return { selection: { baseModelId: wireId, effort: null }, provider: null };
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Codex backend — wraps `@zed-industries/codex-acp`, which inherits auth
|
||||
* from the local `codex` CLI login. Independent of Copilot's
|
||||
* `activeModels` / BYOK keys, so the picker is fed entirely by live
|
||||
* `availableModels` (active session or preloader cache).
|
||||
*
|
||||
* Effort is surfaced via opencode-style model-id parsing — codex-acp
|
||||
* advertises one model per (base × effort) combination, and we collapse
|
||||
* them into a single picker row plus a sibling effort dropdown.
|
||||
*/
|
||||
export const CodexBackendDescriptor: BackendDescriptor = {
|
||||
id: "codex",
|
||||
displayName: "Codex",
|
||||
Icon: CodexLogo,
|
||||
skillsProjectDir: ".agents/skills",
|
||||
crossDiscoveredAgents: [],
|
||||
restartOnManagedSkillsChange: false,
|
||||
wire: codexWire,
|
||||
|
||||
getInstallState(settings: CopilotSettings): InstallState {
|
||||
return binaryPathInstallState(settings.agentMode?.backends?.codex?.binaryPath);
|
||||
},
|
||||
|
||||
subscribeInstallState(_plugin: CopilotPlugin, cb: () => void): () => void {
|
||||
return subscribeToSettingsChange((prev, next) => {
|
||||
if (
|
||||
prev.agentMode?.backends?.codex?.binaryPath !== next.agentMode?.backends?.codex?.binaryPath
|
||||
) {
|
||||
cb();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
openInstallUI(plugin: CopilotPlugin): void {
|
||||
new CodexInstallModal(plugin.app).open();
|
||||
},
|
||||
|
||||
async applySelection(session: AgentSession, selection: ModelSelection): Promise<void> {
|
||||
await session.setModel(codexWire.encode(selection));
|
||||
},
|
||||
|
||||
createBackendProcess(args): BackendProcess {
|
||||
// Codex sees managed skills only via the `.agents/skills/<name>`
|
||||
// symlink. The per-agent toggle drives whether the symlink exists; no
|
||||
// deny synthesis is needed because Codex does not cross-discover from
|
||||
// `.claude/skills/` or `.opencode/skills/`.
|
||||
return simpleBinaryBackendProcess(args, new CodexBackend());
|
||||
},
|
||||
|
||||
SettingsPanel: CodexSettingsPanel,
|
||||
|
||||
isModelEnabledByDefault(model) {
|
||||
// Default-enable only gpt-5.5; digit-boundary on each side avoids
|
||||
// matching `15.5` or `5.50`. Users widen via the Agents tab toggles.
|
||||
const re = /(^|[^0-9])5\.5([^0-9]|$)/;
|
||||
return re.test(model.name) || re.test(model.modelId);
|
||||
},
|
||||
|
||||
/**
|
||||
* Codex exposes sandbox/approval presets via ACP setMode: `read-only`,
|
||||
* `auto`, and `full-access`. We surface all three:
|
||||
* - build → "auto" (workspace-write, on-request approvals)
|
||||
* - plan → "read-only" (no writes, no exec; closest ACP analog)
|
||||
* - auto-build → "full-access" (no sandbox, no approvals)
|
||||
*
|
||||
* Note: this is a sandbox restriction, not Codex CLI's real `ModeKind::Plan`
|
||||
* (which would draft a plan artifact). That mode lives behind the app-server
|
||||
* `turn/start.collaborationMode` field, which `@zed-industries/codex-acp`
|
||||
* does not forward — it translates ACP modes to
|
||||
* `Op::OverrideTurnContext { approval_policy, sandbox_policy }` only.
|
||||
* Read-only is the closest available analog: the agent can read and reason
|
||||
* but cannot mutate the vault, which matches user intent for "Plan".
|
||||
*/
|
||||
getModeMapping(): ModeMapping {
|
||||
return {
|
||||
kind: "setMode",
|
||||
canonical: { default: "auto", plan: "read-only", auto: "full-access" },
|
||||
};
|
||||
},
|
||||
|
||||
async persistModeSelection(value: CopilotMode, _plugin: CopilotPlugin): Promise<void> {
|
||||
updateCodexFields({ selectedMode: value });
|
||||
},
|
||||
|
||||
/**
|
||||
* Replay the persisted mode on a freshly created session. Skipped when
|
||||
* the agent doesn't advertise modes, or when the persisted mode's native
|
||||
* id isn't currently in `availableModes` (filtered out by `getModeMapping`).
|
||||
*/
|
||||
async applyInitialSessionConfig(session: AgentSession, settings: CopilotSettings): Promise<void> {
|
||||
const persistedMode = settings.agentMode?.backends?.codex?.selectedMode ?? "default";
|
||||
await applyPersistedMode(session, persistedMode);
|
||||
},
|
||||
};
|
||||
1
src/agentMode/backends/codex/index.ts
Normal file
1
src/agentMode/backends/codex/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { CodexBackendDescriptor } from "./descriptor";
|
||||
1
src/agentMode/backends/codex/logo.svg
Normal file
1
src/agentMode/backends/codex/logo.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" preserveAspectRatio="xMidYMid" viewBox="0 0 256 260"><path d="M239.184 106.203a64.716 64.716 0 0 0-5.576-53.103C219.452 28.459 191 15.784 163.213 21.74A65.586 65.586 0 0 0 52.096 45.22a64.716 64.716 0 0 0-43.23 31.36c-14.31 24.602-11.061 55.634 8.033 76.74a64.665 64.665 0 0 0 5.525 53.102c14.174 24.65 42.644 37.324 70.446 31.36a64.72 64.72 0 0 0 48.754 21.744c28.481.025 53.714-18.361 62.414-45.481a64.767 64.767 0 0 0 43.229-31.36c14.137-24.558 10.875-55.423-8.083-76.483Zm-97.56 136.338a48.397 48.397 0 0 1-31.105-11.255l1.535-.87 51.67-29.825a8.595 8.595 0 0 0 4.247-7.367v-72.85l21.845 12.636c.218.111.37.32.409.563v60.367c-.056 26.818-21.783 48.545-48.601 48.601Zm-104.466-44.61a48.345 48.345 0 0 1-5.781-32.589l1.534.921 51.722 29.826a8.339 8.339 0 0 0 8.441 0l63.181-36.425v25.221a.87.87 0 0 1-.358.665l-52.335 30.184c-23.257 13.398-52.97 5.431-66.404-17.803ZM23.549 85.38a48.499 48.499 0 0 1 25.58-21.333v61.39a8.288 8.288 0 0 0 4.195 7.316l62.874 36.272-21.845 12.636a.819.819 0 0 1-.767 0L41.353 151.53c-23.211-13.454-31.171-43.144-17.804-66.405v.256Zm179.466 41.695-63.08-36.63L161.73 77.86a.819.819 0 0 1 .768 0l52.233 30.184a48.6 48.6 0 0 1-7.316 87.635v-61.391a8.544 8.544 0 0 0-4.4-7.213Zm21.742-32.69-1.535-.922-51.619-30.081a8.39 8.39 0 0 0-8.492 0L99.98 99.808V74.587a.716.716 0 0 1 .307-.665l52.233-30.133a48.652 48.652 0 0 1 72.236 50.391v.205ZM88.061 139.097l-21.845-12.585a.87.87 0 0 1-.41-.614V65.685a48.652 48.652 0 0 1 79.757-37.346l-1.535.87-51.67 29.825a8.595 8.595 0 0 0-4.246 7.367l-.051 72.697Zm11.868-25.58 28.138-16.217 28.188 16.218v32.434l-28.086 16.218-28.188-16.218-.052-32.434Z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
560
src/agentMode/backends/opencode/OpencodeBackend.test.ts
Normal file
560
src/agentMode/backends/opencode/OpencodeBackend.test.ts
Normal file
|
|
@ -0,0 +1,560 @@
|
|||
import { ChatModelProviders } from "@/constants";
|
||||
import { resetSettings, setSettings, updateSetting } from "@/settings/model";
|
||||
import type { Skill } from "@/agentMode/skills";
|
||||
import { buildOpencodeConfig, OPENCODE_PROVIDER_MAP, OpencodeBackend } from "./OpencodeBackend";
|
||||
import { COPILOT_PROMPT_BASE, selectCopilotPrompt } from "./prompts";
|
||||
|
||||
jest.mock("@/logger", () => ({
|
||||
logInfo: jest.fn(),
|
||||
logWarn: jest.fn(),
|
||||
logError: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock the skills package so we can drive the deny-list synthesis path
|
||||
// without booting the real jotai store / Obsidian App singleton.
|
||||
let mockSkills: Skill[] = [];
|
||||
let mockSkillManagerReady = false;
|
||||
|
||||
jest.mock("@/agentMode/skills", () => {
|
||||
const actual = jest.requireActual("@/agentMode/skills");
|
||||
return {
|
||||
...actual,
|
||||
getManagedSkills: () => mockSkills,
|
||||
SkillManager: {
|
||||
hasInstance: () => mockSkillManagerReady,
|
||||
getInstance: () => {
|
||||
if (!mockSkillManagerReady) {
|
||||
throw new Error("SkillManager.getInstance called before initialize");
|
||||
}
|
||||
return { getAgentDirsProjectRel: () => ({}) } as unknown;
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
function makeSkill(name: string, enabledAgents: Skill["enabledAgents"]): Skill {
|
||||
return {
|
||||
name,
|
||||
description: `${name} skill`,
|
||||
filePath: `/x/${name}/SKILL.md`,
|
||||
dirPath: `/x/${name}`,
|
||||
body: "",
|
||||
enabledAgents,
|
||||
};
|
||||
}
|
||||
|
||||
function seedSkills(skills: Skill[]): void {
|
||||
mockSkills = skills;
|
||||
mockSkillManagerReady = skills.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Most tests below disable the built-in `activeModels` so injection is a
|
||||
* blank slate. The few that exercise injection set their own active models
|
||||
* explicitly.
|
||||
*/
|
||||
function clearActiveModels() {
|
||||
setSettings({ activeModels: [] });
|
||||
}
|
||||
|
||||
describe("buildOpencodeConfig", () => {
|
||||
beforeEach(() => {
|
||||
resetSettings();
|
||||
clearActiveModels();
|
||||
seedSkills([]);
|
||||
});
|
||||
|
||||
it("emits provider entries only for non-empty keys", async () => {
|
||||
updateSetting("anthropicApiKey", "anth-123");
|
||||
updateSetting("openAIApiKey", "");
|
||||
updateSetting("googleApiKey", "g-456");
|
||||
const cfg = (await buildOpencodeConfig()) as { provider: Record<string, unknown> };
|
||||
expect(Object.keys(cfg.provider).sort()).toEqual(["anthropic", "google"]);
|
||||
expect(cfg.provider.anthropic).toEqual({ options: { apiKey: "anth-123" } });
|
||||
expect(cfg.provider.google).toEqual({ options: { apiKey: "g-456" } });
|
||||
});
|
||||
|
||||
it("returns empty provider map when no keys are set", async () => {
|
||||
const cfg = (await buildOpencodeConfig()) as { provider: Record<string, unknown> };
|
||||
expect(cfg.provider).toEqual({});
|
||||
});
|
||||
|
||||
it("injects enabled active models under their provider's `models` map", async () => {
|
||||
updateSetting("anthropicApiKey", "anth-123");
|
||||
setSettings({
|
||||
activeModels: [
|
||||
{
|
||||
name: "claude-sonnet-4-6",
|
||||
provider: ChatModelProviders.ANTHROPIC,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
name: "claude-haiku",
|
||||
provider: ChatModelProviders.ANTHROPIC,
|
||||
enabled: false, // disabled — should NOT inject
|
||||
},
|
||||
],
|
||||
});
|
||||
const cfg = (await buildOpencodeConfig()) as {
|
||||
provider: Record<string, { options?: unknown; models?: Record<string, unknown> }>;
|
||||
};
|
||||
expect(cfg.provider.anthropic.models).toEqual({ "claude-sonnet-4-6": {} });
|
||||
});
|
||||
|
||||
it("does not inject a model when neither top-level nor per-model key is available", async () => {
|
||||
setSettings({
|
||||
activeModels: [
|
||||
{
|
||||
name: "gpt-5",
|
||||
provider: ChatModelProviders.OPENAI,
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
// Neither openAIApiKey nor model.apiKey configured
|
||||
const cfg = (await buildOpencodeConfig()) as { provider: Record<string, unknown> };
|
||||
expect(cfg.provider).toEqual({});
|
||||
});
|
||||
|
||||
it("falls back to per-model apiKey when top-level provider key is missing", async () => {
|
||||
setSettings({
|
||||
activeModels: [
|
||||
{
|
||||
name: "gpt-5",
|
||||
provider: ChatModelProviders.OPENAI,
|
||||
enabled: true,
|
||||
apiKey: "per-model-key",
|
||||
},
|
||||
],
|
||||
});
|
||||
// No openAIApiKey configured globally, but the model carries its own key.
|
||||
const cfg = (await buildOpencodeConfig()) as {
|
||||
provider: Record<string, { options?: { apiKey?: string }; models?: Record<string, unknown> }>;
|
||||
};
|
||||
expect(cfg.provider.openai.options).toEqual({ apiKey: "per-model-key" });
|
||||
expect(cfg.provider.openai.models).toEqual({ "gpt-5": {} });
|
||||
});
|
||||
|
||||
it("prefers the top-level provider key when both are present", async () => {
|
||||
updateSetting("openAIApiKey", "global-key");
|
||||
setSettings({
|
||||
activeModels: [
|
||||
{
|
||||
name: "gpt-5",
|
||||
provider: ChatModelProviders.OPENAI,
|
||||
enabled: true,
|
||||
apiKey: "per-model-key",
|
||||
},
|
||||
],
|
||||
});
|
||||
const cfg = (await buildOpencodeConfig()) as {
|
||||
provider: Record<string, { options?: { apiKey?: string } }>;
|
||||
};
|
||||
// Top-level wins because the provider entry is built before the
|
||||
// per-model fallback runs — keeps the historical behaviour.
|
||||
expect(cfg.provider.openai.options).toEqual({ apiKey: "global-key" });
|
||||
});
|
||||
|
||||
it("does not inject models for providers OpenCode cannot route", async () => {
|
||||
setSettings({
|
||||
activeModels: [
|
||||
{
|
||||
name: "claude-via-bedrock",
|
||||
provider: ChatModelProviders.AMAZON_BEDROCK,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
name: "llama",
|
||||
provider: ChatModelProviders.OLLAMA,
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
const cfg = (await buildOpencodeConfig()) as { provider: Record<string, unknown> };
|
||||
expect(cfg.provider).toEqual({});
|
||||
});
|
||||
|
||||
it("skips embedding models", async () => {
|
||||
updateSetting("openAIApiKey", "key");
|
||||
setSettings({
|
||||
activeModels: [
|
||||
{
|
||||
name: "text-embedding-3-large",
|
||||
provider: ChatModelProviders.OPENAI,
|
||||
enabled: true,
|
||||
isEmbeddingModel: true,
|
||||
},
|
||||
{
|
||||
name: "gpt-test",
|
||||
provider: ChatModelProviders.OPENAI,
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
const cfg = (await buildOpencodeConfig()) as {
|
||||
provider: Record<string, { models?: Record<string, unknown> }>;
|
||||
};
|
||||
// gpt-test is injected; the embedding model is not, even though both have
|
||||
// the same provider and enabled flag.
|
||||
expect(cfg.provider.openai.models).toHaveProperty("gpt-test");
|
||||
expect(cfg.provider.openai.models).not.toHaveProperty("text-embedding-3-large");
|
||||
});
|
||||
|
||||
it("sets top-level model from the persisted defaultModel.baseModelId", async () => {
|
||||
updateSetting("anthropicApiKey", "anth-123");
|
||||
setSettings({
|
||||
agentMode: {
|
||||
enabled: true,
|
||||
byok: {},
|
||||
mcpServers: [],
|
||||
activeBackend: "opencode",
|
||||
debugFullFrames: false,
|
||||
skills: { folder: "copilot/skills" },
|
||||
backends: {
|
||||
opencode: {
|
||||
binaryPath: "/x",
|
||||
defaultModel: { baseModelId: "anthropic/claude-sonnet-4-6", effort: null },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const cfg = (await buildOpencodeConfig()) as { model?: string };
|
||||
expect(cfg.model).toBe("anthropic/claude-sonnet-4-6");
|
||||
});
|
||||
|
||||
it("appends effort suffix when defaultModel.effort is set", async () => {
|
||||
updateSetting("anthropicApiKey", "anth-123");
|
||||
setSettings({
|
||||
agentMode: {
|
||||
enabled: true,
|
||||
byok: {},
|
||||
mcpServers: [],
|
||||
activeBackend: "opencode",
|
||||
debugFullFrames: false,
|
||||
skills: { folder: "copilot/skills" },
|
||||
backends: {
|
||||
opencode: {
|
||||
binaryPath: "/x",
|
||||
defaultModel: { baseModelId: "anthropic/claude-sonnet-4-6", effort: "high" },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const cfg = (await buildOpencodeConfig()) as { model?: string };
|
||||
expect(cfg.model).toBe("anthropic/claude-sonnet-4-6/high");
|
||||
});
|
||||
|
||||
it("registers a custom copilot-plus provider when plusLicenseKey is set", async () => {
|
||||
updateSetting("plusLicenseKey", "plus-token-123");
|
||||
setSettings({
|
||||
activeModels: [
|
||||
{
|
||||
name: "copilot-plus-flash",
|
||||
provider: ChatModelProviders.COPILOT_PLUS,
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
const cfg = (await buildOpencodeConfig()) as {
|
||||
provider: Record<
|
||||
string,
|
||||
{
|
||||
npm?: string;
|
||||
name?: string;
|
||||
options?: { baseURL?: string; apiKey?: string };
|
||||
models?: Record<string, unknown>;
|
||||
}
|
||||
>;
|
||||
};
|
||||
const cp = cfg.provider["copilot-plus"];
|
||||
expect(cp.npm).toBe("@ai-sdk/openai-compatible");
|
||||
expect(cp.name).toBe("Copilot Plus");
|
||||
expect(cp.options?.baseURL).toBe("https://models.brevilabs.com/v1");
|
||||
expect(cp.options?.apiKey).toBe("plus-token-123");
|
||||
expect(cp.models).toEqual({ "copilot-plus-flash": {} });
|
||||
});
|
||||
|
||||
it("does not register copilot-plus provider when plusLicenseKey is empty", async () => {
|
||||
setSettings({
|
||||
activeModels: [
|
||||
{
|
||||
name: "copilot-plus-flash",
|
||||
provider: ChatModelProviders.COPILOT_PLUS,
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
const cfg = (await buildOpencodeConfig()) as { provider: Record<string, unknown> };
|
||||
expect(cfg.provider["copilot-plus"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses a Copilot-Plus-shaped defaultModel.baseModelId verbatim", async () => {
|
||||
updateSetting("plusLicenseKey", "plus-token-123");
|
||||
setSettings({
|
||||
activeModels: [
|
||||
{
|
||||
name: "copilot-plus-flash",
|
||||
provider: ChatModelProviders.COPILOT_PLUS,
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
agentMode: {
|
||||
enabled: true,
|
||||
byok: {},
|
||||
mcpServers: [],
|
||||
activeBackend: "opencode",
|
||||
debugFullFrames: false,
|
||||
skills: { folder: "copilot/skills" },
|
||||
backends: {
|
||||
opencode: {
|
||||
binaryPath: "/x",
|
||||
defaultModel: { baseModelId: "copilot-plus/copilot-plus-flash", effort: null },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const cfg = (await buildOpencodeConfig()) as { model?: string };
|
||||
expect(cfg.model).toBe("copilot-plus/copilot-plus-flash");
|
||||
});
|
||||
|
||||
it("sets default_agent from persisted selectedMode (canonical → native id)", async () => {
|
||||
setSettings({
|
||||
agentMode: {
|
||||
enabled: true,
|
||||
byok: {},
|
||||
mcpServers: [],
|
||||
activeBackend: "opencode",
|
||||
debugFullFrames: false,
|
||||
skills: { folder: "copilot/skills" },
|
||||
backends: {
|
||||
opencode: {
|
||||
selectedMode: "default",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const defaultCfg = (await buildOpencodeConfig()) as { default_agent?: string };
|
||||
expect(defaultCfg.default_agent).toBe("copilot-build");
|
||||
|
||||
setSettings({
|
||||
agentMode: {
|
||||
enabled: true,
|
||||
byok: {},
|
||||
mcpServers: [],
|
||||
activeBackend: "opencode",
|
||||
debugFullFrames: false,
|
||||
skills: { folder: "copilot/skills" },
|
||||
backends: { opencode: { selectedMode: "auto" } },
|
||||
},
|
||||
});
|
||||
const autoCfg = (await buildOpencodeConfig()) as { default_agent?: string };
|
||||
expect(autoCfg.default_agent).toBe("build");
|
||||
});
|
||||
|
||||
it("falls back to canonical default (copilot-build) when no mode is persisted", async () => {
|
||||
const cfg = (await buildOpencodeConfig()) as { default_agent?: string };
|
||||
expect(cfg.default_agent).toBe("copilot-build");
|
||||
});
|
||||
|
||||
it("overrides system prompt on both build and copilot-build agents", async () => {
|
||||
const cfg = (await buildOpencodeConfig()) as {
|
||||
agent: Record<string, { prompt?: string; permission?: unknown; mode?: string }>;
|
||||
};
|
||||
// Prompt now starts with the COPILOT_PROMPT_BASE and ends with the
|
||||
// spawn-time skill-creation directive (see the Skills Management
|
||||
// spec). Assert the base is the prefix so future directive changes
|
||||
// don't break this test.
|
||||
expect(cfg.agent["copilot-build"].prompt?.startsWith(COPILOT_PROMPT_BASE)).toBe(true);
|
||||
expect(cfg.agent.build.prompt?.startsWith(COPILOT_PROMPT_BASE)).toBe(true);
|
||||
expect(cfg.agent["copilot-build"].prompt).toContain(
|
||||
'metadata.copilot-enabled-agents: "opencode"'
|
||||
);
|
||||
expect(cfg.agent.build.prompt).toContain('metadata.copilot-enabled-agents: "opencode"');
|
||||
// Regression guard: the copilot-build permission block must survive
|
||||
// alongside the new prompt field — opencode's field-wise merge depends
|
||||
// on us not stomping native fields.
|
||||
expect(cfg.agent["copilot-build"].permission).toEqual({ bash: "ask", edit: "ask" });
|
||||
expect(cfg.agent["copilot-build"].mode).toBe("primary");
|
||||
});
|
||||
|
||||
it("templates a custom skills folder into the opencode directive", async () => {
|
||||
setSettings({
|
||||
agentMode: {
|
||||
enabled: true,
|
||||
byok: {},
|
||||
mcpServers: [],
|
||||
activeBackend: "opencode",
|
||||
debugFullFrames: false,
|
||||
skills: { folder: "team-skills" },
|
||||
backends: {},
|
||||
},
|
||||
});
|
||||
const cfg = (await buildOpencodeConfig()) as {
|
||||
agent: Record<string, { prompt?: string }>;
|
||||
};
|
||||
expect(cfg.agent["copilot-build"].prompt).toContain("<vault>/team-skills/<name>/SKILL.md");
|
||||
expect(cfg.agent.build.prompt).toContain("<vault>/team-skills/<name>/SKILL.md");
|
||||
});
|
||||
|
||||
it("denies a skill enabled for Claude only (cross-discovered, not enabled for opencode)", async () => {
|
||||
seedSkills([makeSkill("foo", ["claude"])]);
|
||||
const cfg = (await buildOpencodeConfig()) as {
|
||||
permission?: { skill?: Record<string, string> };
|
||||
};
|
||||
expect(cfg.permission?.skill?.foo).toBe("deny");
|
||||
});
|
||||
|
||||
it("does not deny a skill enabled for both Claude and OpenCode", async () => {
|
||||
seedSkills([makeSkill("foo", ["claude", "opencode"])]);
|
||||
const cfg = (await buildOpencodeConfig()) as {
|
||||
permission?: { skill?: Record<string, string> };
|
||||
};
|
||||
expect(cfg.permission?.skill?.foo).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not emit a permission.skill block when no skills need denying", async () => {
|
||||
seedSkills([makeSkill("foo", ["opencode"])]);
|
||||
const cfg = (await buildOpencodeConfig()) as {
|
||||
permission?: { skill?: Record<string, string> };
|
||||
};
|
||||
expect(cfg.permission).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not emit a permission.skill block when there are no skills at all", async () => {
|
||||
seedSkills([]);
|
||||
const cfg = (await buildOpencodeConfig()) as { permission?: unknown };
|
||||
expect(cfg.permission).toBeUndefined();
|
||||
});
|
||||
|
||||
it("synthesises deny rules for a mix of skills (only cross-discovered + not-enabled wins)", async () => {
|
||||
seedSkills([
|
||||
makeSkill("a", ["claude"]),
|
||||
makeSkill("b", ["claude", "opencode"]),
|
||||
makeSkill("c", []),
|
||||
makeSkill("d", ["opencode"]),
|
||||
makeSkill("e", ["codex"]),
|
||||
]);
|
||||
const cfg = (await buildOpencodeConfig()) as {
|
||||
permission?: { skill?: Record<string, string> };
|
||||
};
|
||||
// a is claude-only → denied. e is codex-only → denied (codex also
|
||||
// populates the cross-discovered `.agents/skills/` path). b/c/d not denied.
|
||||
expect(cfg.permission?.skill).toEqual({ a: "deny", e: "deny" });
|
||||
});
|
||||
|
||||
it("skips deny synthesis when SkillManager has not initialised yet", async () => {
|
||||
// Place a skill in the snapshot, but mark the singleton as not ready.
|
||||
mockSkills = [makeSkill("foo", ["claude"])];
|
||||
mockSkillManagerReady = false;
|
||||
const cfg = (await buildOpencodeConfig()) as { permission?: unknown };
|
||||
expect(cfg.permission).toBeUndefined();
|
||||
});
|
||||
|
||||
it("omits cfg.model when no defaultModel is set", async () => {
|
||||
updateSetting("anthropicApiKey", "anth-123");
|
||||
setSettings({
|
||||
activeModels: [],
|
||||
agentMode: {
|
||||
enabled: true,
|
||||
byok: {},
|
||||
mcpServers: [],
|
||||
activeBackend: "opencode",
|
||||
debugFullFrames: false,
|
||||
skills: { folder: "copilot/skills" },
|
||||
backends: {
|
||||
opencode: { binaryPath: "/x" },
|
||||
},
|
||||
},
|
||||
});
|
||||
const cfg = (await buildOpencodeConfig()) as { model?: string };
|
||||
expect(cfg.model).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("OpencodeBackend.buildSpawnDescriptor", () => {
|
||||
beforeEach(() => {
|
||||
resetSettings();
|
||||
clearActiveModels();
|
||||
});
|
||||
|
||||
it("throws if no binary is installed", async () => {
|
||||
const backend = new OpencodeBackend();
|
||||
await expect(backend.buildSpawnDescriptor({ vaultBasePath: "/vault" })).rejects.toThrow(
|
||||
/binary not installed/
|
||||
);
|
||||
});
|
||||
|
||||
it("uses agentMode.backends.opencode.binaryPath as command and passes cwd in args", async () => {
|
||||
updateSetting("agentMode", {
|
||||
enabled: true,
|
||||
byok: {},
|
||||
mcpServers: [],
|
||||
activeBackend: "opencode",
|
||||
debugFullFrames: false,
|
||||
skills: { folder: "copilot/skills" },
|
||||
backends: {
|
||||
opencode: {
|
||||
binaryPath: "/path/to/opencode",
|
||||
binaryVersion: "1.3.17",
|
||||
binarySource: "managed",
|
||||
},
|
||||
},
|
||||
});
|
||||
updateSetting("anthropicApiKey", "anth-xyz");
|
||||
const backend = new OpencodeBackend();
|
||||
const desc = await backend.buildSpawnDescriptor({ vaultBasePath: "/vault/abs" });
|
||||
expect(desc.command).toBe("/path/to/opencode");
|
||||
expect(desc.args).toEqual(["acp", "--cwd", "/vault/abs"]);
|
||||
expect(desc.env.OPENCODE_CONFIG_CONTENT).toBeDefined();
|
||||
const cfg = JSON.parse(desc.env.OPENCODE_CONFIG_CONTENT as string);
|
||||
expect(cfg.provider.anthropic.options).toEqual({ apiKey: "anth-xyz" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectCopilotPrompt", () => {
|
||||
it("returns COPILOT_PROMPT_BASE for any model id (no per-provider variants yet)", () => {
|
||||
expect(selectCopilotPrompt(undefined)).toBe(COPILOT_PROMPT_BASE);
|
||||
expect(selectCopilotPrompt("copilot-plus-flash")).toBe(COPILOT_PROMPT_BASE);
|
||||
expect(selectCopilotPrompt("copilot-plus/copilot-plus-flash")).toBe(COPILOT_PROMPT_BASE);
|
||||
expect(selectCopilotPrompt("anthropic/claude-sonnet-4-6")).toBe(COPILOT_PROMPT_BASE);
|
||||
expect(selectCopilotPrompt("google/gemini-2.5-flash")).toBe(COPILOT_PROMPT_BASE);
|
||||
});
|
||||
});
|
||||
|
||||
describe("COPILOT_PROMPT_BASE", () => {
|
||||
it("establishes Obsidian Copilot identity, not a CLI/coding agent", () => {
|
||||
expect(COPILOT_PROMPT_BASE).toMatch(/Obsidian Copilot/);
|
||||
expect(COPILOT_PROMPT_BASE).toMatch(/NOT a software-engineering agent or CLI coding tool/);
|
||||
});
|
||||
|
||||
it("does not carry chat-mode-only baggage that misfires in tool-driven agents", () => {
|
||||
// @vault and getCurrentTime/getTimeRangeMs are chat-mode injections that
|
||||
// do not exist in opencode. YouTube auto-transcription is also chat-only.
|
||||
expect(COPILOT_PROMPT_BASE).not.toMatch(/@vault/);
|
||||
expect(COPILOT_PROMPT_BASE).not.toMatch(/getCurrentTime/);
|
||||
expect(COPILOT_PROMPT_BASE).not.toMatch(/getTimeRangeMs/);
|
||||
expect(COPILOT_PROMPT_BASE).not.toMatch(/YouTube/);
|
||||
});
|
||||
|
||||
it("ports AGENT_LOOP_GUIDANCE behavior bullets", () => {
|
||||
expect(COPILOT_PROMPT_BASE).toMatch(/NEVER search for the same/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("OPENCODE_PROVIDER_MAP", () => {
|
||||
it("includes the eight BYOK-mapped providers plus Copilot Plus", () => {
|
||||
expect(Object.keys(OPENCODE_PROVIDER_MAP).sort()).toEqual(
|
||||
[
|
||||
ChatModelProviders.ANTHROPIC,
|
||||
ChatModelProviders.COPILOT_PLUS,
|
||||
ChatModelProviders.DEEPSEEK,
|
||||
ChatModelProviders.GOOGLE,
|
||||
ChatModelProviders.GROQ,
|
||||
ChatModelProviders.MISTRAL,
|
||||
ChatModelProviders.OPENAI,
|
||||
ChatModelProviders.OPENROUTERAI,
|
||||
ChatModelProviders.XAI,
|
||||
].sort()
|
||||
);
|
||||
});
|
||||
});
|
||||
315
src/agentMode/backends/opencode/OpencodeBackend.ts
Normal file
315
src/agentMode/backends/opencode/OpencodeBackend.ts
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
import { BREVILABS_MODELS_BASE_URL, ChatModelProviders } from "@/constants";
|
||||
import { getDecryptedKey } from "@/encryptionService";
|
||||
import { logInfo, logWarn } from "@/logger";
|
||||
import { getSettings } from "@/settings/model";
|
||||
import { AcpBackend, AcpSpawnDescriptor } from "@/agentMode/acp/types";
|
||||
import type { CopilotMode } from "@/agentMode/session/types";
|
||||
import {
|
||||
buildSkillCreationDirective,
|
||||
composeDenyList,
|
||||
DEFAULT_SKILLS_FOLDER,
|
||||
getManagedSkills,
|
||||
SkillManager,
|
||||
} from "@/agentMode/skills";
|
||||
import { OpencodeBackendDescriptor } from "./descriptor";
|
||||
import { selectCopilotPrompt } from "./prompts";
|
||||
|
||||
/**
|
||||
* Map from Copilot's `ChatModelProviders` enum value (as stored in
|
||||
* `CustomModel.provider`) to OpenCode's provider id (as it appears in
|
||||
* OpenCode's `availableModels` and config). Only providers in this map are
|
||||
* routable through OpenCode; everything else (Azure, Bedrock, Ollama,
|
||||
* LM Studio, GitHub Copilot, etc.) is filtered out of the picker.
|
||||
*
|
||||
* Copilot Plus is handled separately because it isn't a built-in OpenCode
|
||||
* provider — we register it as a custom `@ai-sdk/openai-compatible` entry
|
||||
* pointing at brevilabs and authed via the user's `plusLicenseKey`.
|
||||
*/
|
||||
export const OPENCODE_PROVIDER_MAP: Partial<Record<ChatModelProviders, string>> = {
|
||||
[ChatModelProviders.ANTHROPIC]: "anthropic",
|
||||
[ChatModelProviders.OPENAI]: "openai",
|
||||
[ChatModelProviders.GOOGLE]: "google",
|
||||
[ChatModelProviders.GROQ]: "groq",
|
||||
[ChatModelProviders.MISTRAL]: "mistral",
|
||||
[ChatModelProviders.DEEPSEEK]: "deepseek",
|
||||
[ChatModelProviders.OPENROUTERAI]: "openrouter",
|
||||
[ChatModelProviders.XAI]: "xai",
|
||||
[ChatModelProviders.COPILOT_PLUS]: "copilot-plus",
|
||||
};
|
||||
|
||||
/** OpenCode provider id reserved for Copilot Plus's brevilabs proxy. */
|
||||
const COPILOT_PLUS_PROVIDER_ID = "copilot-plus";
|
||||
|
||||
/**
|
||||
* Custom OpenCode agent id provisioned via `OPENCODE_CONFIG_CONTENT`. Maps
|
||||
* to Copilot's canonical `default` mode (writes/exec allowed, but the user
|
||||
* approves each request). The built-in `build` agent doesn't ask.
|
||||
*/
|
||||
export const OPENCODE_COPILOT_BUILD_AGENT_ID = "copilot-build";
|
||||
|
||||
/** OpenCode's built-in build agent id (full perms, no permission asks). */
|
||||
export const OPENCODE_BUILTIN_BUILD_AGENT_ID = "build";
|
||||
|
||||
/**
|
||||
* Shared canonical→native agent-id mapping for OpenCode. Used both at spawn
|
||||
* time (`buildOpencodeConfig` sets `default_agent`) and at runtime (the
|
||||
* descriptor's `getModeMapping` for `session/set_config_option`). Keeping
|
||||
* one source of truth so the spawn-time default and the runtime picker
|
||||
* never disagree. Plan mode is intentionally absent — opencode's plan
|
||||
* agent has no ACP-visible finalization tool, so we don't expose it.
|
||||
*/
|
||||
export const OPENCODE_CANONICAL_MODE_AGENT_IDS: Partial<Record<CopilotMode, string>> = {
|
||||
default: OPENCODE_COPILOT_BUILD_AGENT_ID,
|
||||
auto: OPENCODE_BUILTIN_BUILD_AGENT_ID,
|
||||
};
|
||||
|
||||
/**
|
||||
* Spawns `opencode acp --cwd <vault>` with `OPENCODE_CONFIG_CONTENT`
|
||||
* containing decrypted BYOK keys pulled from the existing Copilot settings.
|
||||
*
|
||||
* Reuses Copilot's top-level `*ApiKey` fields so users don't have to re-enter
|
||||
* them in an Agent Mode-specific settings panel.
|
||||
*/
|
||||
export class OpencodeBackend implements AcpBackend {
|
||||
readonly id = "opencode" as const;
|
||||
readonly displayName = "opencode";
|
||||
|
||||
async buildSpawnDescriptor(ctx: { vaultBasePath: string }): Promise<AcpSpawnDescriptor> {
|
||||
const binaryPath = getSettings().agentMode?.backends?.opencode?.binaryPath;
|
||||
if (!binaryPath) {
|
||||
throw new Error(
|
||||
"opencode binary not installed. Open Agent Mode settings and install it before starting a session."
|
||||
);
|
||||
}
|
||||
|
||||
const config = await buildOpencodeConfig();
|
||||
|
||||
return {
|
||||
command: binaryPath,
|
||||
args: ["acp", "--cwd", ctx.vaultBasePath],
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCODE_CONFIG_CONTENT: JSON.stringify(config),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `OPENCODE_CONFIG_CONTENT` payload from current Copilot settings.
|
||||
*
|
||||
* - Per-provider `options.apiKey` is set for any BYOK key configured in
|
||||
* Copilot, decrypted in-process.
|
||||
* - Each enabled `activeModel` whose provider is in `OPENCODE_PROVIDER_MAP`
|
||||
* is registered under `provider.<id>.models.<modelName>` so OpenCode
|
||||
* reports it in `NewSessionResponse.models.availableModels`. Built-in
|
||||
* providers (anthropic, openai, …) carry their own models.dev snapshot
|
||||
* so this is largely additive there; for the custom Copilot Plus
|
||||
* provider — and for OpenRouter models the snapshot doesn't cover —
|
||||
* the registration is what makes the model visible at all. The
|
||||
* Agents-tab catalog modal then curates from opencode's reported
|
||||
* `availableModels`.
|
||||
* - The top-level `model` field carries the user's sticky preference so
|
||||
* a fresh session boots with the right default, even before
|
||||
* `unstable_setSessionModel` is called.
|
||||
*
|
||||
* Exported for unit tests.
|
||||
*/
|
||||
export async function buildOpencodeConfig(): Promise<Record<string, unknown>> {
|
||||
const s = getSettings();
|
||||
|
||||
type Mapping = { providerId: string; settingsKey: keyof typeof s };
|
||||
const mappings: Mapping[] = [
|
||||
{ providerId: "anthropic", settingsKey: "anthropicApiKey" },
|
||||
{ providerId: "openai", settingsKey: "openAIApiKey" },
|
||||
{ providerId: "google", settingsKey: "googleApiKey" },
|
||||
{ providerId: "groq", settingsKey: "groqApiKey" },
|
||||
{ providerId: "mistral", settingsKey: "mistralApiKey" },
|
||||
{ providerId: "deepseek", settingsKey: "deepseekApiKey" },
|
||||
{ providerId: "openrouter", settingsKey: "openRouterAiApiKey" },
|
||||
{ providerId: "xai", settingsKey: "xaiApiKey" },
|
||||
];
|
||||
|
||||
const decrypted = await Promise.all(
|
||||
mappings.map(async (m) => {
|
||||
const raw = s[m.settingsKey];
|
||||
if (typeof raw !== "string" || !raw) return null;
|
||||
const apiKey = await getDecryptedKey(raw);
|
||||
if (!apiKey) return null;
|
||||
return { providerId: m.providerId, apiKey };
|
||||
})
|
||||
);
|
||||
|
||||
type ProviderConfig = {
|
||||
npm?: string;
|
||||
name?: string;
|
||||
options?: { apiKey?: string; baseURL?: string; headers?: Record<string, string> };
|
||||
models?: Record<string, Record<string, unknown>>;
|
||||
};
|
||||
const provider: Record<string, ProviderConfig> = {};
|
||||
for (const entry of decrypted) {
|
||||
if (entry) provider[entry.providerId] = { options: { apiKey: entry.apiKey } };
|
||||
}
|
||||
|
||||
// Copilot Plus speaks OpenAI's wire format but isn't a built-in OpenCode
|
||||
// provider. Register it as a custom `@ai-sdk/openai-compatible` entry
|
||||
// pointing at brevilabs and authed via the user's `plusLicenseKey`.
|
||||
if (typeof s.plusLicenseKey === "string" && s.plusLicenseKey) {
|
||||
const licenseKey = await getDecryptedKey(s.plusLicenseKey);
|
||||
if (licenseKey) {
|
||||
provider[COPILOT_PLUS_PROVIDER_ID] = {
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
name: "Copilot Plus",
|
||||
options: { baseURL: BREVILABS_MODELS_BASE_URL, apiKey: licenseKey },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Register Copilot-configured models under their respective providers so
|
||||
// OpenCode treats them as known when reporting `availableModels`. When the
|
||||
// top-level provider key is absent, fall back to the per-model `apiKey` so
|
||||
// models the user configured with a model-specific key still reach the
|
||||
// agent. Without this fallback any such model would be silently dropped.
|
||||
const injected: string[] = [];
|
||||
for (const model of s.activeModels ?? []) {
|
||||
if (!model.enabled) continue;
|
||||
if (model.isEmbeddingModel) continue;
|
||||
const providerId = OPENCODE_PROVIDER_MAP[model.provider as ChatModelProviders];
|
||||
if (!providerId) continue;
|
||||
|
||||
if (!provider[providerId]) {
|
||||
const perModel = model.apiKey ? await getDecryptedKey(model.apiKey) : null;
|
||||
if (!perModel) {
|
||||
logWarn(
|
||||
`[AgentMode] skipping ${model.provider}/${model.name}: no API key (set the provider key in Copilot settings or on the model itself)`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
provider[providerId] = { options: { apiKey: perModel } };
|
||||
}
|
||||
|
||||
if (!provider[providerId].models) provider[providerId].models = {};
|
||||
provider[providerId].models[model.name] = {};
|
||||
injected.push(`${providerId}/${model.name}`);
|
||||
}
|
||||
|
||||
if (injected.length > 0) {
|
||||
logInfo(
|
||||
`[AgentMode] injected ${injected.length} model(s) into opencode config: ${injected.join(", ")}`
|
||||
);
|
||||
} else if (Object.keys(provider).length === 0) {
|
||||
logInfo(
|
||||
"[AgentMode] no BYOK keys found; opencode will rely on its own auth. Set provider keys in Copilot settings to use Agent Mode end-to-end."
|
||||
);
|
||||
}
|
||||
|
||||
const config: Record<string, unknown> = { provider };
|
||||
|
||||
// Inject a managed `copilot-build` agent so the mode picker can offer the
|
||||
// canonical "default" semantic — let the agent edit, but ask first. The
|
||||
// built-in `build` agent never asks (used as our `auto` mode); it doesn't
|
||||
// cover ask-before-write, hence the custom agent.
|
||||
//
|
||||
// Both agents also carry a Copilot-authored `prompt` that overrides
|
||||
// opencode's provider-default prompt picker (`session/system.ts`). Without
|
||||
// this override, Copilot Plus model names (e.g. `copilot-plus-flash`) miss
|
||||
// every substring branch and fall through to the generic `default.txt`
|
||||
// CLI-coding-agent prompt — wrong domain for an Obsidian vault assistant.
|
||||
// opencode's `cfg.agent.<id>` merge is field-wise, so adding `prompt` to
|
||||
// the built-in `build` agent leaves its native permissions intact.
|
||||
const basePrompt = selectCopilotPrompt(
|
||||
s.agentMode?.backends?.opencode?.defaultModel?.baseModelId
|
||||
);
|
||||
// Append the spawn-time skill-creation directive so agent-authored skills
|
||||
// land in the canonical managed folder instead of `.opencode/skills/`.
|
||||
// Folder is read live from settings on each spawn — see the Skills
|
||||
// Management spec.
|
||||
const skillsFolder = s.agentMode?.skills?.folder ?? DEFAULT_SKILLS_FOLDER;
|
||||
const skillManagerReady = SkillManager.hasInstance();
|
||||
const skillsDirs = skillManagerReady
|
||||
? Object.values(SkillManager.getInstance().getAgentDirsProjectRel())
|
||||
: [];
|
||||
const prompt = `${basePrompt}\n\n${buildSkillCreationDirective("opencode", skillsFolder, skillsDirs)}`;
|
||||
config.agent = {
|
||||
[OPENCODE_BUILTIN_BUILD_AGENT_ID]: {
|
||||
prompt,
|
||||
},
|
||||
[OPENCODE_COPILOT_BUILD_AGENT_ID]: {
|
||||
mode: "primary",
|
||||
permission: { bash: "ask", edit: "ask" },
|
||||
prompt,
|
||||
},
|
||||
};
|
||||
|
||||
// Apply sticky model preference at spawn so the very first turn (before
|
||||
// `unstable_setSessionModel` lands) uses the user's pick. The persisted
|
||||
// shape is `{ baseModelId, effort }` where `baseModelId` is opencode's
|
||||
// `<provider>/<model>` form — append the effort suffix when present.
|
||||
const defaultModel = s.agentMode?.backends?.opencode?.defaultModel;
|
||||
if (defaultModel?.baseModelId) {
|
||||
config.model = defaultModel.effort
|
||||
? `${defaultModel.baseModelId}/${defaultModel.effort}`
|
||||
: defaultModel.baseModelId;
|
||||
}
|
||||
|
||||
// Apply sticky mode preference at spawn via OpenCode's `default_agent` so
|
||||
// the first turn already runs in the user's chosen agent — closes the
|
||||
// cold-start gap where the `mode` configOption isn't yet registered.
|
||||
// Falls back to canonical `default` (ask-before-write `copilot-build`);
|
||||
// otherwise OpenCode would land on its no-ask built-in `build` agent.
|
||||
const selectedMode = s.agentMode?.backends?.opencode?.selectedMode ?? "default";
|
||||
config.default_agent =
|
||||
OPENCODE_CANONICAL_MODE_AGENT_IDS[selectedMode] ?? OPENCODE_COPILOT_BUILD_AGENT_ID;
|
||||
|
||||
// Synthesize deny rules for managed skills that OpenCode would
|
||||
// cross-discover (via `.claude/skills/` and `.agents/skills/`) but are
|
||||
// not enabled for OpenCode in their `metadata.copilot-enabled-agents`.
|
||||
// Read SkillManager live at spawn time — same pattern as the
|
||||
// skill-creation directive. If SkillManager isn't initialised yet (plugin
|
||||
// still booting; OpenCode session spawned before the Skills tab has
|
||||
// hydrated), fall back to an empty deny list — the next reconciliation
|
||||
// pass + session restart closes the eventual-consistency window.
|
||||
//
|
||||
// Note: we intentionally do NOT set `OPENCODE_DISABLE_EXTERNAL_SKILLS` or
|
||||
// `OPENCODE_DISABLE_CLAUDE_CODE_SKILLS`. We want OpenCode to walk the
|
||||
// cross-discovery paths so the per-name `permission.skill.<name> = "deny"`
|
||||
// entries below can take effect.
|
||||
if (!skillManagerReady) {
|
||||
// SkillManager initialises asynchronously from `main.ts onload`. If
|
||||
// OpenCode spawns before that finishes, we ship an empty deny list
|
||||
// for this session — the next OpenCode spawn (after SkillManager
|
||||
// hydrates) gets the correct one.
|
||||
logInfo(
|
||||
"[AgentMode] SkillManager not yet initialised at OpenCode spawn — shipping empty deny list; next session will pick it up."
|
||||
);
|
||||
}
|
||||
const managedSkills = skillManagerReady ? getManagedSkills() : [];
|
||||
const denyNames = composeDenyList(
|
||||
managedSkills,
|
||||
OpencodeBackendDescriptor.id,
|
||||
OpencodeBackendDescriptor.crossDiscoveredAgents
|
||||
);
|
||||
if (denyNames.length > 0) {
|
||||
// Be additive: opencode's config schema allows `permission` as a
|
||||
// top-level key with sub-fields (`skill`, `tool`, `write`, …). Preserve
|
||||
// any existing `permission.*` settings the user may have provided
|
||||
// through other surfaces.
|
||||
const existingPermission = config.permission as Record<string, unknown> | undefined;
|
||||
const existingSkillMap = existingPermission?.skill as Record<string, string> | undefined;
|
||||
const mergedSkillMap: Record<string, string> = { ...(existingSkillMap ?? {}) };
|
||||
for (const name of denyNames) {
|
||||
// User-provided entries win — if the user explicitly allowed a skill
|
||||
// we'd otherwise deny, respect their override.
|
||||
if (!(name in mergedSkillMap)) mergedSkillMap[name] = "deny";
|
||||
}
|
||||
config.permission = {
|
||||
...(existingPermission ?? {}),
|
||||
skill: mergedSkillMap,
|
||||
};
|
||||
logInfo(
|
||||
`[AgentMode] opencode deny list: ${denyNames.length} cross-discovered skill(s) denied (${denyNames.join(", ")})`
|
||||
);
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
317
src/agentMode/backends/opencode/OpencodeBinaryManager.test.ts
Normal file
317
src/agentMode/backends/opencode/OpencodeBinaryManager.test.ts
Normal file
|
|
@ -0,0 +1,317 @@
|
|||
jest.mock("obsidian", () => ({
|
||||
// pickMatchingAsset is pure; FileSystemAdapter and requestUrl are
|
||||
// referenced by the manager class but not by these tests.
|
||||
FileSystemAdapter: class {},
|
||||
requestUrl: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("@/logger", () => ({
|
||||
logInfo: jest.fn(),
|
||||
logWarn: jest.fn(),
|
||||
logError: jest.fn(),
|
||||
}));
|
||||
|
||||
// In-memory settings store for the manager's getSettings/setSettings calls.
|
||||
// Defined inside jest.mock so it's hoisted alongside the factory.
|
||||
jest.mock("@/settings/model", () => {
|
||||
type OpencodeSlice = {
|
||||
binaryPath?: string;
|
||||
binaryVersion?: string;
|
||||
binarySource?: "managed" | "custom";
|
||||
};
|
||||
type AgentMode = {
|
||||
enabled?: boolean;
|
||||
activeBackend?: string;
|
||||
backends?: { opencode?: OpencodeSlice };
|
||||
};
|
||||
type Store = { agentMode: AgentMode };
|
||||
let store: Store = {
|
||||
agentMode: { backends: { opencode: {} } },
|
||||
};
|
||||
return {
|
||||
__esModule: true,
|
||||
__reset: (initial: OpencodeSlice = {}) => {
|
||||
store = { agentMode: { backends: { opencode: { ...initial } } } };
|
||||
},
|
||||
__get: () => store.agentMode.backends?.opencode ?? {},
|
||||
getSettings: () => store,
|
||||
setSettings: (settings: Partial<Store> | ((current: Store) => Partial<Store>)) => {
|
||||
const partial = typeof settings === "function" ? settings(store) : settings;
|
||||
store = { ...store, ...partial };
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import {
|
||||
computeInstallState,
|
||||
OpencodeBinaryManager,
|
||||
parseVersionFromStdout,
|
||||
pickMatchingAsset,
|
||||
verifyOpencodeBinary,
|
||||
} from "./OpencodeBinaryManager";
|
||||
|
||||
// Pull the mock helpers off the mocked module without TS complaints.
|
||||
const settingsMock = jest.requireMock("@/settings/model");
|
||||
|
||||
// Minimal CopilotPlugin stand-in. Only `manifest.id` and `app.vault.adapter`
|
||||
// are touched by the methods under test, and only via getDataDir() (which we
|
||||
// don't exercise here).
|
||||
const fakePlugin = {
|
||||
app: { vault: { adapter: {} } },
|
||||
manifest: { id: "copilot-test" },
|
||||
} as never;
|
||||
|
||||
describe("pickMatchingAsset", () => {
|
||||
const release = {
|
||||
tag_name: "v1.14.24",
|
||||
assets: [
|
||||
{
|
||||
name: "opencode-darwin-arm64.zip",
|
||||
size: 100,
|
||||
browser_download_url: "https://example.com/opencode-darwin-arm64.zip",
|
||||
},
|
||||
{
|
||||
name: "opencode-linux-x64.tar.gz",
|
||||
size: 100,
|
||||
browser_download_url: "https://example.com/opencode-linux-x64.tar.gz",
|
||||
},
|
||||
{
|
||||
name: "opencode-linux-x64-musl.tar.gz",
|
||||
size: 100,
|
||||
browser_download_url: "https://example.com/opencode-linux-x64-musl.tar.gz",
|
||||
},
|
||||
{
|
||||
name: "opencode-windows-x64.zip",
|
||||
size: 100,
|
||||
browser_download_url: "https://example.com/opencode-windows-x64.zip",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it("picks the first matching candidate stem", () => {
|
||||
const asset = pickMatchingAsset(release, ["opencode-darwin-arm64"]);
|
||||
expect(asset.name).toBe("opencode-darwin-arm64.zip");
|
||||
});
|
||||
|
||||
it("falls back to the next candidate when the preferred one is missing", () => {
|
||||
const asset = pickMatchingAsset(release, [
|
||||
"opencode-linux-x64-musl-baseline",
|
||||
"opencode-linux-x64-musl",
|
||||
"opencode-linux-x64",
|
||||
]);
|
||||
expect(asset.name).toBe("opencode-linux-x64-musl.tar.gz");
|
||||
});
|
||||
|
||||
it("strips .tar.gz before matching stems", () => {
|
||||
const asset = pickMatchingAsset(release, ["opencode-linux-x64"]);
|
||||
expect(asset.name).toBe("opencode-linux-x64.tar.gz");
|
||||
});
|
||||
|
||||
it("throws when no candidate matches", () => {
|
||||
expect(() => pickMatchingAsset(release, ["opencode-windows-arm64"])).toThrow(
|
||||
/No matching opencode release asset/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyOpencodeBinary", () => {
|
||||
// We use the running Node binary as a stand-in for any executable that
|
||||
// accepts `--version` and exits 0. This exercises the success path without
|
||||
// requiring a real opencode binary on disk.
|
||||
it("resolves when the binary returns 0 to --version", async () => {
|
||||
const result = await verifyOpencodeBinary(process.execPath);
|
||||
expect(result.stdout).toMatch(/^v\d+\./);
|
||||
});
|
||||
|
||||
it("throws ENOENT-style error for a non-existent path", async () => {
|
||||
await expect(verifyOpencodeBinary("/definitely/not/a/real/path/opencode")).rejects.toThrow(
|
||||
/No file at/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeInstallState", () => {
|
||||
it("absent when no path is set", () => {
|
||||
expect(computeInstallState({})).toEqual({ kind: "absent" });
|
||||
expect(computeInstallState(undefined)).toEqual({ kind: "absent" });
|
||||
});
|
||||
|
||||
it("absent when path is set but version is missing", () => {
|
||||
// We no longer surface a path-only state — without a version we can't
|
||||
// tell what binary the user is pointing at, so the manager forces
|
||||
// install/setCustomBinaryPath to populate both fields together.
|
||||
expect(computeInstallState({ binaryPath: "/p" })).toEqual({ kind: "absent" });
|
||||
});
|
||||
|
||||
it("installed (managed) when source is missing — legacy data defaults to managed", () => {
|
||||
expect(computeInstallState({ binaryPath: "/p", binaryVersion: "1.2.3" })).toEqual({
|
||||
kind: "installed",
|
||||
version: "1.2.3",
|
||||
path: "/p",
|
||||
source: "managed",
|
||||
});
|
||||
});
|
||||
|
||||
it("installed with explicit source preserves the value", () => {
|
||||
expect(
|
||||
computeInstallState({ binaryPath: "/p", binaryVersion: "1.2.3", binarySource: "custom" })
|
||||
).toEqual({ kind: "installed", version: "1.2.3", path: "/p", source: "custom" });
|
||||
expect(
|
||||
computeInstallState({ binaryPath: "/p", binaryVersion: "1.2.3", binarySource: "managed" })
|
||||
).toEqual({ kind: "installed", version: "1.2.3", path: "/p", source: "managed" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseVersionFromStdout", () => {
|
||||
it.each([
|
||||
["1.14.24", "1.14.24"],
|
||||
["v1.14.24", "1.14.24"],
|
||||
["opencode 1.14.24", "1.14.24"],
|
||||
["opencode\nversion: 1.14.24\n", "1.14.24"],
|
||||
["1.14.24-rc.1", "1.14.24-rc.1"],
|
||||
["1.14.24+build.5", "1.14.24+build.5"],
|
||||
])("parses %j → %s", (input, expected) => {
|
||||
expect(parseVersionFromStdout(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it("returns undefined when no semver-shaped token is present", () => {
|
||||
expect(parseVersionFromStdout("not a version")).toBeUndefined();
|
||||
expect(parseVersionFromStdout("")).toBeUndefined();
|
||||
expect(parseVersionFromStdout("1.2")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("OpencodeBinaryManager.refreshInstallState", () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "opencode-mgr-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.promises.rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("no-op for absent state", async () => {
|
||||
settingsMock.__reset({});
|
||||
const mgr = new OpencodeBinaryManager(fakePlugin);
|
||||
await mgr.refreshInstallState();
|
||||
expect(settingsMock.__get()).toEqual({});
|
||||
});
|
||||
|
||||
it("no-op for custom-source installs even if the file is missing", async () => {
|
||||
// Custom paths are validated at config time and shouldn't be re-checked
|
||||
// on every plugin load — a transient mount issue shouldn't wipe user state.
|
||||
const ghost = path.join(tmpDir, "does-not-exist", "opencode");
|
||||
settingsMock.__reset({
|
||||
binaryPath: ghost,
|
||||
binaryVersion: "1.14.24",
|
||||
binarySource: "custom",
|
||||
});
|
||||
const mgr = new OpencodeBinaryManager(fakePlugin);
|
||||
await mgr.refreshInstallState();
|
||||
expect(settingsMock.__get()).toEqual({
|
||||
binaryPath: ghost,
|
||||
binaryVersion: "1.14.24",
|
||||
binarySource: "custom",
|
||||
});
|
||||
});
|
||||
|
||||
it("clears settings when persisted managed binary is missing on disk", async () => {
|
||||
const ghost = path.join(tmpDir, "does-not-exist", "opencode");
|
||||
settingsMock.__reset({
|
||||
binaryPath: ghost,
|
||||
binaryVersion: "1.14.24",
|
||||
binarySource: "managed",
|
||||
});
|
||||
const mgr = new OpencodeBinaryManager(fakePlugin);
|
||||
await mgr.refreshInstallState();
|
||||
expect(settingsMock.__get()).toEqual({
|
||||
binaryPath: undefined,
|
||||
binaryVersion: undefined,
|
||||
binarySource: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves settings intact when persisted managed binary is present", async () => {
|
||||
const realFile = path.join(tmpDir, "opencode");
|
||||
await fs.promises.writeFile(realFile, "");
|
||||
settingsMock.__reset({
|
||||
binaryPath: realFile,
|
||||
binaryVersion: "1.14.24",
|
||||
binarySource: "managed",
|
||||
});
|
||||
const mgr = new OpencodeBinaryManager(fakePlugin);
|
||||
await mgr.refreshInstallState();
|
||||
expect(settingsMock.__get()).toEqual({
|
||||
binaryPath: realFile,
|
||||
binaryVersion: "1.14.24",
|
||||
binarySource: "managed",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("OpencodeBinaryManager.setCustomBinaryPath", () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
settingsMock.__reset({});
|
||||
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "opencode-custom-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.promises.rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("rejects when the file does not exist", async () => {
|
||||
const mgr = new OpencodeBinaryManager(fakePlugin);
|
||||
await expect(mgr.setCustomBinaryPath(path.join(tmpDir, "nope"))).rejects.toThrow(/No file at/);
|
||||
expect(settingsMock.__get()).toEqual({});
|
||||
});
|
||||
|
||||
it("rejects when the path is a directory, not a file", async () => {
|
||||
const mgr = new OpencodeBinaryManager(fakePlugin);
|
||||
await expect(mgr.setCustomBinaryPath(tmpDir)).rejects.toThrow(/No file at/);
|
||||
expect(settingsMock.__get()).toEqual({});
|
||||
});
|
||||
|
||||
it("rejects non-executable files on POSIX", async () => {
|
||||
if (process.platform === "win32") return; // skip — XOK semantics differ on Windows
|
||||
const file = path.join(tmpDir, "not-exec");
|
||||
await fs.promises.writeFile(file, "");
|
||||
await fs.promises.chmod(file, 0o644);
|
||||
const mgr = new OpencodeBinaryManager(fakePlugin);
|
||||
await expect(mgr.setCustomBinaryPath(file)).rejects.toThrow(/not executable/);
|
||||
expect(settingsMock.__get()).toEqual({});
|
||||
});
|
||||
|
||||
it("clearing (null) wipes all binary fields and does not touch disk", async () => {
|
||||
settingsMock.__reset({
|
||||
binaryPath: "/p",
|
||||
binaryVersion: "1.0.0",
|
||||
binarySource: "managed",
|
||||
});
|
||||
const mgr = new OpencodeBinaryManager(fakePlugin);
|
||||
await mgr.setCustomBinaryPath(null);
|
||||
expect(settingsMock.__get()).toEqual({
|
||||
binaryPath: undefined,
|
||||
binaryVersion: undefined,
|
||||
binarySource: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("accepting a real binary captures version from --version and tags source as custom", async () => {
|
||||
// Use the running node binary as a stand-in: it exists, is executable,
|
||||
// and `--version` exits 0 — the same shape verifyOpencodeBinary expects.
|
||||
// Node prints `v22.x.y`; the parser strips the `v` and gives us a semver.
|
||||
const mgr = new OpencodeBinaryManager(fakePlugin);
|
||||
await mgr.setCustomBinaryPath(process.execPath);
|
||||
const stored = settingsMock.__get();
|
||||
expect(stored.binaryPath).toBe(process.execPath);
|
||||
expect(stored.binarySource).toBe("custom");
|
||||
expect(stored.binaryVersion).toMatch(/^\d+\.\d+\.\d+/);
|
||||
});
|
||||
});
|
||||
599
src/agentMode/backends/opencode/OpencodeBinaryManager.ts
Normal file
599
src/agentMode/backends/opencode/OpencodeBinaryManager.ts
Normal file
|
|
@ -0,0 +1,599 @@
|
|||
import { OPENCODE_PINNED_VERSION, OPENCODE_RELEASE_API_URL_TEMPLATE } from "@/constants";
|
||||
import { logError, logInfo, logWarn } from "@/logger";
|
||||
import type CopilotPlugin from "@/main";
|
||||
import { getSettings, setSettings, type OpencodeBackendSettings } from "@/settings/model";
|
||||
import { execFile, spawn } from "node:child_process";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import * as fs from "node:fs";
|
||||
import * as https from "node:https";
|
||||
import { IncomingMessage } from "node:http";
|
||||
import { FileSystemAdapter, requestUrl } from "obsidian";
|
||||
import * as path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { renameWithRetry } from "@/agentMode/skills/renameWithRetry";
|
||||
import { expectedBinaryName, resolveOpencodeTarget } from "./platformResolver";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const DOWNLOAD_INACTIVITY_TIMEOUT_MS = 30_000;
|
||||
// Generous: first-run on Windows (Defender real-time scan) and macOS
|
||||
// (Gatekeeper translocation) can add a few seconds before the binary responds.
|
||||
const VERIFY_BINARY_TIMEOUT_MS = 8_000;
|
||||
|
||||
export type ProgressEvent =
|
||||
| { phase: "resolve"; message: string }
|
||||
| { phase: "download"; received: number; total?: number; assetName: string }
|
||||
| { phase: "extract"; message: string }
|
||||
| { phase: "done"; version: string; path: string };
|
||||
|
||||
export interface InstallOptions {
|
||||
onProgress?: (e: ProgressEvent) => void;
|
||||
signal?: AbortSignal;
|
||||
/** Override pinned version. Defaults to OPENCODE_PINNED_VERSION. */
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export type InstallState =
|
||||
| { kind: "absent" }
|
||||
| { kind: "installed"; version: string; path: string; source: "managed" | "custom" };
|
||||
|
||||
interface GithubAsset {
|
||||
name: string;
|
||||
size: number;
|
||||
browser_download_url: string;
|
||||
}
|
||||
|
||||
interface GithubRelease {
|
||||
tag_name: string;
|
||||
assets: GithubAsset[];
|
||||
}
|
||||
|
||||
interface InstallManifest {
|
||||
version: string;
|
||||
assetName: string;
|
||||
installedAt: string;
|
||||
}
|
||||
|
||||
export class AbortError extends Error {
|
||||
constructor() {
|
||||
super("Aborted");
|
||||
this.name = "AbortError";
|
||||
}
|
||||
}
|
||||
|
||||
export function pickMatchingAsset(release: GithubRelease, candidates: string[]): GithubAsset {
|
||||
const ARCHIVE_EXTS = [".zip", ".tar.gz", ".tar.xz", ".tgz"];
|
||||
const stemOf = (name: string): string => {
|
||||
for (const ext of ARCHIVE_EXTS) {
|
||||
if (name.endsWith(ext)) return name.slice(0, -ext.length);
|
||||
}
|
||||
return name;
|
||||
};
|
||||
|
||||
const byStem = new Map<string, GithubAsset>();
|
||||
for (const a of release.assets) {
|
||||
byStem.set(stemOf(a.name), a);
|
||||
}
|
||||
|
||||
for (const stem of candidates) {
|
||||
const asset = byStem.get(stem);
|
||||
if (asset) return asset;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`No matching opencode release asset found. Tried: ${candidates.join(", ")}. ` +
|
||||
`Available: ${release.assets.map((a) => a.name).join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
/** Derive the install state from the persisted `agentMode.backends.opencode` slice. */
|
||||
export function computeInstallState(opencode: OpencodeBackendSettings | undefined): InstallState {
|
||||
const s = opencode ?? {};
|
||||
if (s.binaryPath && s.binaryVersion) {
|
||||
return {
|
||||
kind: "installed",
|
||||
version: s.binaryVersion,
|
||||
path: s.binaryPath,
|
||||
source: s.binarySource ?? "managed",
|
||||
};
|
||||
}
|
||||
return { kind: "absent" };
|
||||
}
|
||||
|
||||
/** Read the OpenCode-specific settings slice from current settings. */
|
||||
export function readOpencodeSettings(): OpencodeBackendSettings {
|
||||
return getSettings().agentMode?.backends?.opencode ?? {};
|
||||
}
|
||||
|
||||
function updateOpencodeFields(partial: Partial<OpencodeBackendSettings>): void {
|
||||
setSettings((cur) => ({
|
||||
agentMode: {
|
||||
...cur.agentMode,
|
||||
backends: {
|
||||
...cur.agentMode.backends,
|
||||
opencode: { ...(cur.agentMode.backends?.opencode ?? {}), ...partial },
|
||||
},
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
function clearOpencodeBinary(): void {
|
||||
updateOpencodeFields({
|
||||
binaryVersion: undefined,
|
||||
binaryPath: undefined,
|
||||
binarySource: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages the lifecycle of the opencode binary on disk: platform-aware
|
||||
* download from GitHub releases, extraction into the plugin data dir, and
|
||||
* persistence of the install location into `settings.agentMode`. Desktop-only.
|
||||
*/
|
||||
export class OpencodeBinaryManager {
|
||||
constructor(private readonly plugin: CopilotPlugin) {}
|
||||
|
||||
getInstallState(): InstallState {
|
||||
return computeInstallState(readOpencodeSettings());
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile persisted install state with what's actually on disk. If we
|
||||
* believe a managed install exists but the binary is gone (user deleted it,
|
||||
* restored a vault from backup, etc.), demote to `absent`. Skipped for
|
||||
* custom-source installs — re-checking on every plugin load would punish
|
||||
* users for transient filesystem hiccups (network mounts, etc.).
|
||||
*/
|
||||
async refreshInstallState(): Promise<void> {
|
||||
const state = this.getInstallState();
|
||||
if (state.kind !== "installed") return;
|
||||
if (state.source !== "managed") return;
|
||||
if (await fileExists(state.path)) return;
|
||||
logWarn(`[AgentMode] persisted opencode binary missing at ${state.path}; clearing settings.`);
|
||||
clearOpencodeBinary();
|
||||
}
|
||||
|
||||
/** Absolute path to `<vault>/.obsidian/plugins/<id>/data/opencode`. */
|
||||
getDataDir(): string {
|
||||
const adapter = this.plugin.app.vault.adapter;
|
||||
if (!(adapter instanceof FileSystemAdapter)) {
|
||||
throw new Error("Agent Mode requires desktop Obsidian (FileSystemAdapter).");
|
||||
}
|
||||
const base = adapter.getBasePath();
|
||||
const id = this.plugin.manifest.id;
|
||||
return path.join(base, this.plugin.app.vault.configDir, "plugins", id, "data", "opencode");
|
||||
}
|
||||
|
||||
getPinnedVersion(): string {
|
||||
return OPENCODE_PINNED_VERSION;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full install pipeline: resolve target → fetch release metadata →
|
||||
* download → extract → atomic rename → persist settings. Idempotent when
|
||||
* an existing install matches the pinned manifest.
|
||||
*/
|
||||
async install(opts: InstallOptions = {}): Promise<{ version: string; path: string }> {
|
||||
const version = opts.version ?? OPENCODE_PINNED_VERSION;
|
||||
const dataDir = this.getDataDir();
|
||||
const versionDir = path.join(dataDir, version);
|
||||
|
||||
opts.onProgress?.({ phase: "resolve", message: "Resolving platform asset…" });
|
||||
const { target, candidates } = await resolveOpencodeTarget();
|
||||
this.throwIfAborted(opts.signal);
|
||||
|
||||
const release = await this.fetchReleaseMetadata(version);
|
||||
this.throwIfAborted(opts.signal);
|
||||
|
||||
const asset = pickMatchingAsset(release, candidates);
|
||||
|
||||
const binName = expectedBinaryName(target.platform);
|
||||
const finalBinPath = path.join(versionDir, "bin", binName);
|
||||
|
||||
// Idempotency: if the existing manifest matches and the binary is in place, no-op.
|
||||
const existing = await readManifest(path.join(versionDir, "install-manifest.json"));
|
||||
if (existing && existing.assetName === asset.name && (await fileExists(finalBinPath))) {
|
||||
logInfo(`[AgentMode] opencode ${version} already installed at ${finalBinPath}`);
|
||||
// Skip the write when settings already match — avoids spuriously waking
|
||||
// every settings subscriber on healthy plugin loads.
|
||||
const cur = readOpencodeSettings();
|
||||
if (
|
||||
cur.binaryVersion !== version ||
|
||||
cur.binaryPath !== finalBinPath ||
|
||||
cur.binarySource !== "managed"
|
||||
) {
|
||||
updateOpencodeFields({
|
||||
binaryVersion: version,
|
||||
binaryPath: finalBinPath,
|
||||
binarySource: "managed",
|
||||
});
|
||||
}
|
||||
opts.onProgress?.({ phase: "done", version, path: finalBinPath });
|
||||
return { version, path: finalBinPath };
|
||||
}
|
||||
|
||||
await fs.promises.mkdir(dataDir, { recursive: true });
|
||||
const tmpDir = path.join(dataDir, `.tmp-${version}-${randomBytes(4).toString("hex")}`);
|
||||
await fs.promises.mkdir(tmpDir, { recursive: true });
|
||||
|
||||
try {
|
||||
const archivePath = path.join(tmpDir, asset.name);
|
||||
await downloadToFile(asset.browser_download_url, archivePath, asset.size, opts);
|
||||
this.throwIfAborted(opts.signal);
|
||||
|
||||
opts.onProgress?.({ phase: "extract", message: "Extracting archive…" });
|
||||
const extractDir = path.join(tmpDir, "extract");
|
||||
await fs.promises.mkdir(extractDir, { recursive: true });
|
||||
await extractArchive(archivePath, extractDir);
|
||||
this.throwIfAborted(opts.signal);
|
||||
|
||||
const extractedBin = await locateFile(extractDir, binName);
|
||||
if (target.platform !== "windows") {
|
||||
await fs.promises.chmod(extractedBin, 0o755);
|
||||
}
|
||||
|
||||
// Stage the final layout under tmpDir, then atomically rename into place.
|
||||
const stageDir = path.join(tmpDir, "stage");
|
||||
const stageBinDir = path.join(stageDir, "bin");
|
||||
await fs.promises.mkdir(stageBinDir, { recursive: true });
|
||||
await fs.promises.rename(extractedBin, path.join(stageBinDir, binName));
|
||||
|
||||
const manifest: InstallManifest = {
|
||||
version,
|
||||
assetName: asset.name,
|
||||
installedAt: new Date().toISOString(),
|
||||
};
|
||||
await fs.promises.writeFile(
|
||||
path.join(stageDir, "install-manifest.json"),
|
||||
JSON.stringify(manifest, null, 2)
|
||||
);
|
||||
|
||||
// Rename-aside-then-rename: move any existing versionDir out of the way,
|
||||
// promote the staged dir into place, and only then delete the old one.
|
||||
// If the second rename fails, we restore the original so the user keeps
|
||||
// a working install instead of a half-deleted one.
|
||||
let asideDir: string | null = null;
|
||||
if (await fileExists(versionDir)) {
|
||||
asideDir = `${versionDir}.old-${randomBytes(4).toString("hex")}`;
|
||||
await renameWithRetry(versionDir, asideDir);
|
||||
}
|
||||
try {
|
||||
await renameWithRetry(stageDir, versionDir);
|
||||
} catch (e) {
|
||||
if (asideDir) {
|
||||
await renameWithRetry(asideDir, versionDir).catch((restoreErr) =>
|
||||
logError("[AgentMode] failed to restore previous opencode install", restoreErr)
|
||||
);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
if (asideDir) {
|
||||
await removeDir(asideDir).catch((rmErr) =>
|
||||
logWarn(`[AgentMode] failed to remove ${asideDir}: ${rmErr}`)
|
||||
);
|
||||
}
|
||||
|
||||
// Smoke-test the installed binary. Catches corrupt extracts and
|
||||
// platform/libc mismatches before the user hits them at ACP boot —
|
||||
// failures here surface in the install Modal where a Retry is one click
|
||||
// away.
|
||||
await verifyOpencodeBinary(finalBinPath);
|
||||
this.throwIfAborted(opts.signal);
|
||||
|
||||
updateOpencodeFields({
|
||||
binaryVersion: version,
|
||||
binaryPath: finalBinPath,
|
||||
binarySource: "managed",
|
||||
});
|
||||
opts.onProgress?.({ phase: "done", version, path: finalBinPath });
|
||||
logInfo(`[AgentMode] opencode ${version} installed at ${finalBinPath}`);
|
||||
return { version, path: finalBinPath };
|
||||
} catch (err) {
|
||||
logError("[AgentMode] opencode install failed", err);
|
||||
throw err;
|
||||
} finally {
|
||||
await removeDir(tmpDir).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the active managed version dir and clear settings. For a custom
|
||||
* binary this only clears settings — the binary on disk belongs to the user
|
||||
* and we don't touch it. Other version dirs are kept either way.
|
||||
*/
|
||||
async uninstall(): Promise<void> {
|
||||
const s = readOpencodeSettings();
|
||||
if (s.binarySource === "managed" && s.binaryVersion) {
|
||||
const versionDir = path.join(this.getDataDir(), s.binaryVersion);
|
||||
await removeDir(versionDir).catch((e) =>
|
||||
logError(`[AgentMode] failed to remove ${versionDir}`, e)
|
||||
);
|
||||
}
|
||||
clearOpencodeBinary();
|
||||
}
|
||||
|
||||
/**
|
||||
* Point Agent Mode at a user-supplied opencode binary. Pass `null` to
|
||||
* clear the override (without removing any managed install on disk).
|
||||
* Performs filesystem checks plus a `--version` smoke test so misconfigured
|
||||
* paths are caught at config time rather than later when ACP tries to boot.
|
||||
*/
|
||||
async setCustomBinaryPath(p: string | null): Promise<void> {
|
||||
if (p === null) {
|
||||
clearOpencodeBinary();
|
||||
return;
|
||||
}
|
||||
const stat = await fs.promises.stat(p).catch(() => null);
|
||||
if (!stat || !stat.isFile()) {
|
||||
throw new Error(`No file at ${p}`);
|
||||
}
|
||||
if (process.platform !== "win32") {
|
||||
try {
|
||||
await fs.promises.access(p, fs.constants.X_OK);
|
||||
} catch {
|
||||
throw new Error(`${p} is not executable. chmod +x and try again.`);
|
||||
}
|
||||
}
|
||||
const { stdout } = await verifyOpencodeBinary(p);
|
||||
const version = parseVersionFromStdout(stdout);
|
||||
if (!version) {
|
||||
throw new Error(
|
||||
`${p} --version output didn't include a version number. Is this an opencode binary?`
|
||||
);
|
||||
}
|
||||
updateOpencodeFields({ binaryVersion: version, binaryPath: p, binarySource: "custom" });
|
||||
}
|
||||
|
||||
private async fetchReleaseMetadata(version: string): Promise<GithubRelease> {
|
||||
const url = OPENCODE_RELEASE_API_URL_TEMPLATE.replace("{version}", version);
|
||||
const res = await requestUrl({
|
||||
url,
|
||||
method: "GET",
|
||||
headers: { Accept: "application/vnd.github+json" },
|
||||
throw: false,
|
||||
});
|
||||
if (res.status === 403) {
|
||||
throw new Error(
|
||||
"GitHub API rate-limited (60/hour for unauthenticated requests). Set GITHUB_TOKEN or retry later."
|
||||
);
|
||||
}
|
||||
if (res.status === 404) {
|
||||
throw new Error(`opencode release v${version} not found on GitHub.`);
|
||||
}
|
||||
if (res.status < 200 || res.status >= 300) {
|
||||
throw new Error(`GitHub release fetch failed with status ${res.status}`);
|
||||
}
|
||||
return res.json as GithubRelease;
|
||||
}
|
||||
|
||||
private throwIfAborted(signal?: AbortSignal): void {
|
||||
if (signal?.aborted) throw new AbortError();
|
||||
}
|
||||
}
|
||||
|
||||
// Tolerant of leading `v`, build metadata, etc. — keeps the parser working
|
||||
// across opencode releases that decorate the version string differently.
|
||||
export function parseVersionFromStdout(stdout: string): string | undefined {
|
||||
const match = stdout.match(/\d+\.\d+\.\d+(?:[-+][\w.]+)?/);
|
||||
return match?.[0];
|
||||
}
|
||||
|
||||
async function fileExists(p: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.promises.access(p);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function readManifest(p: string): Promise<InstallManifest | null> {
|
||||
try {
|
||||
const raw = await fs.promises.readFile(p, "utf-8");
|
||||
return JSON.parse(raw) as InstallManifest;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeDir(p: string): Promise<void> {
|
||||
await fs.promises.rm(p, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue a GET against `url`, following up to `maxRedirects` 3xx hops. Resolves
|
||||
* with the response stream on a 2xx or rejects on any other terminal status.
|
||||
* Honors the supplied AbortSignal at every step.
|
||||
*/
|
||||
function httpsGetWithRedirects(
|
||||
url: string,
|
||||
signal: AbortSignal | undefined,
|
||||
maxRedirects = 5
|
||||
): Promise<IncomingMessage> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = (currentUrl: string, redirectsLeft: number): void => {
|
||||
let onAbort: (() => void) | null = null;
|
||||
const detachAbort = (): void => {
|
||||
if (onAbort) signal?.removeEventListener("abort", onAbort);
|
||||
onAbort = null;
|
||||
};
|
||||
const req = https.get(currentUrl, (res) => {
|
||||
const status = res.statusCode ?? 0;
|
||||
if (status >= 300 && status < 400 && res.headers.location) {
|
||||
detachAbort();
|
||||
if (redirectsLeft <= 0) {
|
||||
res.resume();
|
||||
reject(new Error(`Too many redirects fetching ${url}`));
|
||||
return;
|
||||
}
|
||||
res.resume();
|
||||
const next = new URL(res.headers.location, currentUrl).toString();
|
||||
request(next, redirectsLeft - 1);
|
||||
return;
|
||||
}
|
||||
if (status !== 200) {
|
||||
detachAbort();
|
||||
res.resume();
|
||||
reject(new Error(`HTTP ${status} fetching ${currentUrl}`));
|
||||
return;
|
||||
}
|
||||
// Hand the response off without detaching: the consumer may still
|
||||
// need to abort mid-stream. Cleanup happens on res 'close'/'end'.
|
||||
res.on("close", detachAbort);
|
||||
resolve(res);
|
||||
});
|
||||
req.on("error", (e) => {
|
||||
detachAbort();
|
||||
reject(e);
|
||||
});
|
||||
if (signal) {
|
||||
if (signal.aborted) {
|
||||
req.destroy(new AbortError());
|
||||
} else {
|
||||
onAbort = (): void => {
|
||||
req.destroy(new AbortError());
|
||||
};
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
}
|
||||
};
|
||||
request(url, maxRedirects);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream a remote asset to `dest`, emitting progress events and aborting if
|
||||
* no bytes arrive for `DOWNLOAD_INACTIVITY_TIMEOUT_MS`. Stalled connections
|
||||
* surface a clear "download stalled" error instead of hanging forever.
|
||||
*/
|
||||
async function downloadToFile(
|
||||
url: string,
|
||||
dest: string,
|
||||
expectedSize: number | undefined,
|
||||
opts: InstallOptions
|
||||
): Promise<void> {
|
||||
const assetName = path.basename(dest);
|
||||
const res = await httpsGetWithRedirects(url, opts.signal);
|
||||
const total =
|
||||
expectedSize ??
|
||||
(res.headers["content-length"] ? Number(res.headers["content-length"]) : undefined);
|
||||
|
||||
let received = 0;
|
||||
let stalled = false;
|
||||
let inactivityTimer: number | null = null;
|
||||
const out = fs.createWriteStream(dest);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const clearInactivity = (): void => {
|
||||
if (inactivityTimer) {
|
||||
window.clearTimeout(inactivityTimer);
|
||||
inactivityTimer = null;
|
||||
}
|
||||
};
|
||||
const armInactivity = (): void => {
|
||||
clearInactivity();
|
||||
inactivityTimer = window.setTimeout(() => {
|
||||
stalled = true;
|
||||
res.destroy(
|
||||
new Error(
|
||||
`Download stalled — no bytes received for ${Math.round(DOWNLOAD_INACTIVITY_TIMEOUT_MS / 1000)}s. Check your network and retry.`
|
||||
)
|
||||
);
|
||||
}, DOWNLOAD_INACTIVITY_TIMEOUT_MS);
|
||||
};
|
||||
armInactivity();
|
||||
res.on("data", (chunk: Uint8Array) => {
|
||||
received += chunk.length;
|
||||
armInactivity();
|
||||
opts.onProgress?.({ phase: "download", received, total, assetName });
|
||||
});
|
||||
const fail = (e: Error): void => {
|
||||
clearInactivity();
|
||||
reject(e);
|
||||
};
|
||||
res.on("error", fail);
|
||||
out.on("error", fail);
|
||||
out.on("finish", () => {
|
||||
clearInactivity();
|
||||
if (stalled) return; // already rejected via res.destroy()
|
||||
resolve();
|
||||
});
|
||||
res.pipe(out);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract `archivePath` into `destDir` by shelling out to the system `tar`
|
||||
* (bsdtar on Windows 10 1803+). Distinguishes "tar not found" from
|
||||
* non-zero exits so the user gets actionable error text.
|
||||
*
|
||||
* Path-traversal note: both GNU tar and bsdtar strip leading `/` and refuse
|
||||
* to follow `..` outside the extraction root by default, so a malicious
|
||||
* archive cannot escape `destDir`. We rely on that default rather than
|
||||
* re-implementing extraction in JS.
|
||||
*/
|
||||
async function extractArchive(archivePath: string, destDir: string): Promise<void> {
|
||||
// Cross-platform: macOS and Linux ship `tar`; Windows 10 1803+ ships `tar.exe`
|
||||
// built in (`bsdtar`), which handles .zip / .tar.gz / .tar.xz transparently.
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const proc = spawn("tar", ["-xf", archivePath, "-C", destDir], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let stderr = "";
|
||||
proc.stderr.on("data", (d: Uint8Array) => {
|
||||
stderr += Buffer.from(d).toString();
|
||||
});
|
||||
proc.on("error", (e: NodeJS.ErrnoException) => {
|
||||
if (e.code === "ENOENT") {
|
||||
reject(
|
||||
new Error(
|
||||
"`tar` was not found on PATH. macOS/Linux ship it by default; on Windows you need 10 1803+ (which ships `tar.exe`/bsdtar) or to install bsdtar manually."
|
||||
)
|
||||
);
|
||||
} else {
|
||||
reject(new Error(`Failed to launch tar: ${e instanceof Error ? e.message : String(e)}`));
|
||||
}
|
||||
});
|
||||
proc.on("close", (code) => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`tar exited with code ${code}: ${stderr.slice(0, 500)}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// BFS because the binary's depth inside the upstream archive varies across
|
||||
// opencode releases — first match wins.
|
||||
async function locateFile(root: string, name: string): Promise<string> {
|
||||
const queue: string[] = [root];
|
||||
while (queue.length > 0) {
|
||||
const dir = queue.shift() as string;
|
||||
const entries = await fs.promises.readdir(dir, { withFileTypes: true });
|
||||
for (const e of entries) {
|
||||
const full = path.join(dir, e.name);
|
||||
if (e.isDirectory()) queue.push(full);
|
||||
else if (e.isFile() && e.name === name) return full;
|
||||
}
|
||||
}
|
||||
throw new Error(`File "${name}" not found anywhere under ${root}`);
|
||||
}
|
||||
|
||||
export async function verifyOpencodeBinary(p: string): Promise<{ stdout: string }> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(p, ["--version"], {
|
||||
timeout: VERIFY_BINARY_TIMEOUT_MS,
|
||||
windowsHide: true,
|
||||
});
|
||||
return { stdout: stdout.toString().trim() };
|
||||
} catch (e) {
|
||||
const err = e as NodeJS.ErrnoException & { signal?: string; code?: string | number };
|
||||
if (err.code === "ENOENT") {
|
||||
throw new Error(`No file at ${p}`);
|
||||
}
|
||||
if (err.signal === "SIGTERM") {
|
||||
throw new Error(
|
||||
`${p} did not respond to --version within ${VERIFY_BINARY_TIMEOUT_MS}ms. Is this an opencode binary?`
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
`${p} --version failed: ${err.message ?? String(err)}. Is this an opencode binary?`
|
||||
);
|
||||
}
|
||||
}
|
||||
209
src/agentMode/backends/opencode/OpencodeInstallModal.tsx
Normal file
209
src/agentMode/backends/opencode/OpencodeInstallModal.tsx
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
import { ReactModal } from "@/components/modals/ReactModal";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import {
|
||||
AbortError,
|
||||
InstallOptions,
|
||||
ProgressEvent,
|
||||
} from "@/agentMode/backends/opencode/OpencodeBinaryManager";
|
||||
import type { OpencodeBinaryManager } from "@/agentMode/backends/opencode/OpencodeBinaryManager";
|
||||
import { OPENCODE_PINNED_VERSION } from "@/constants";
|
||||
import { App } from "obsidian";
|
||||
import React from "react";
|
||||
|
||||
type ModalState =
|
||||
| { kind: "confirm" }
|
||||
| { kind: "running"; progress: ProgressEvent | null }
|
||||
| { kind: "success"; version: string; path: string }
|
||||
| { kind: "error"; message: string };
|
||||
|
||||
interface ContentProps {
|
||||
manager: OpencodeBinaryManager;
|
||||
hostPlatform: string;
|
||||
hostArch: string;
|
||||
pinnedVersion: string;
|
||||
destinationDir: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const formatBytes = (bytes: number): string => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
const phaseLabel = (e: ProgressEvent | null): string => {
|
||||
if (!e) return "Starting…";
|
||||
switch (e.phase) {
|
||||
case "resolve":
|
||||
return e.message;
|
||||
case "download":
|
||||
if (e.total) {
|
||||
const pct = Math.floor((e.received / e.total) * 100);
|
||||
return `Downloading ${e.assetName} — ${formatBytes(e.received)} / ${formatBytes(e.total)} (${pct}%)`;
|
||||
}
|
||||
return `Downloading ${e.assetName} — ${formatBytes(e.received)}`;
|
||||
case "extract":
|
||||
return e.message;
|
||||
case "done":
|
||||
return "Done";
|
||||
}
|
||||
};
|
||||
|
||||
const phaseProgress = (e: ProgressEvent | null): number | undefined => {
|
||||
if (!e) return undefined;
|
||||
if (e.phase === "download" && e.total) {
|
||||
return Math.min(100, Math.floor((e.received / e.total) * 100));
|
||||
}
|
||||
if (e.phase === "extract") return 98;
|
||||
if (e.phase === "done") return 100;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const OpencodeInstallContent: React.FC<ContentProps> = ({
|
||||
manager,
|
||||
hostPlatform,
|
||||
hostArch,
|
||||
pinnedVersion,
|
||||
destinationDir,
|
||||
onClose,
|
||||
}) => {
|
||||
const [state, setState] = React.useState<ModalState>({ kind: "confirm" });
|
||||
const abortRef = React.useRef<AbortController | null>(null);
|
||||
|
||||
const startInstall = React.useCallback(() => {
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
setState({ kind: "running", progress: null });
|
||||
|
||||
const opts: InstallOptions = {
|
||||
signal: controller.signal,
|
||||
onProgress: (e) => setState({ kind: "running", progress: e }),
|
||||
};
|
||||
|
||||
manager
|
||||
.install(opts)
|
||||
.then(({ version, path }) => setState({ kind: "success", version, path }))
|
||||
.catch((err: unknown) => {
|
||||
if (err instanceof AbortError || (err as Error)?.name === "AbortError") {
|
||||
// User cancelled — go back to confirm so they can retry.
|
||||
setState({ kind: "confirm" });
|
||||
return;
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setState({ kind: "error", message });
|
||||
});
|
||||
}, [manager]);
|
||||
|
||||
const cancelInstall = React.useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (state.kind === "confirm") {
|
||||
return (
|
||||
<div className="tw-flex tw-flex-col tw-gap-4">
|
||||
<p>
|
||||
opencode runs locally on your machine. The official binary will be downloaded from{" "}
|
||||
<code>github.com/sst/opencode/releases</code> over HTTPS and smoke-tested with{" "}
|
||||
<code>--version</code> before being activated.
|
||||
</p>
|
||||
<dl className="tw-grid tw-grid-cols-[max-content_1fr] tw-gap-x-4 tw-gap-y-1 tw-text-sm">
|
||||
<dt className="tw-text-muted">Platform</dt>
|
||||
<dd className="tw-font-mono">
|
||||
{hostPlatform}-{hostArch}
|
||||
</dd>
|
||||
<dt className="tw-text-muted">Version</dt>
|
||||
<dd className="tw-font-mono">v{pinnedVersion} (pinned)</dd>
|
||||
<dt className="tw-text-muted">Destination</dt>
|
||||
<dd className="tw-break-all tw-font-mono tw-text-xs">{destinationDir}</dd>
|
||||
</dl>
|
||||
<div className="tw-flex tw-justify-end tw-gap-2">
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="default" onClick={startInstall}>
|
||||
Download & install
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.kind === "running") {
|
||||
const pct = phaseProgress(state.progress);
|
||||
return (
|
||||
<div className="tw-flex tw-flex-col tw-gap-4">
|
||||
<p className="tw-text-sm">{phaseLabel(state.progress)}</p>
|
||||
<Progress value={pct ?? 0} />
|
||||
<div className="tw-flex tw-justify-end">
|
||||
<Button variant="ghost" onClick={cancelInstall}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.kind === "success") {
|
||||
return (
|
||||
<div className="tw-flex tw-flex-col tw-gap-4">
|
||||
<p>
|
||||
opencode <code>v{state.version}</code> installed successfully.
|
||||
</p>
|
||||
<p className="tw-break-all tw-font-mono tw-text-xs tw-text-muted">{state.path}</p>
|
||||
<div className="tw-flex tw-justify-end">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// error
|
||||
return (
|
||||
<div className="tw-flex tw-flex-col tw-gap-4">
|
||||
<p className="tw-text-error">Install failed.</p>
|
||||
<pre className="tw-max-h-32 tw-overflow-auto tw-whitespace-pre-wrap tw-rounded tw-bg-secondary tw-p-2 tw-text-xs">
|
||||
{state.message}
|
||||
</pre>
|
||||
<div className="tw-flex tw-justify-end tw-gap-2">
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
<Button variant="default" onClick={startInstall}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export class OpencodeInstallModal extends ReactModal {
|
||||
constructor(
|
||||
app: App,
|
||||
private readonly manager: OpencodeBinaryManager,
|
||||
private readonly hostInfo: { platform: string; arch: string }
|
||||
) {
|
||||
super(app, "Install opencode (BYOK Agent backend)");
|
||||
}
|
||||
|
||||
protected renderContent(close: () => void): React.ReactElement {
|
||||
return (
|
||||
<OpencodeInstallContent
|
||||
manager={this.manager}
|
||||
hostPlatform={this.hostInfo.platform}
|
||||
hostArch={this.hostInfo.arch}
|
||||
pinnedVersion={OPENCODE_PINNED_VERSION}
|
||||
destinationDir={this.manager.getDataDir()}
|
||||
onClose={close}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
151
src/agentMode/backends/opencode/OpencodeSettingsPanel.tsx
Normal file
151
src/agentMode/backends/opencode/OpencodeSettingsPanel.tsx
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
import { OpencodeInstallModal } from "@/agentMode/backends/opencode/OpencodeInstallModal";
|
||||
import { BinaryPathSetting } from "@/components/agent/BinaryPathSetting";
|
||||
import { ConfirmModal } from "@/components/modals/ConfirmModal";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SettingItem } from "@/components/ui/setting-item";
|
||||
import { logError } from "@/logger";
|
||||
import type CopilotPlugin from "@/main";
|
||||
import { useSettingsValue } from "@/settings/model";
|
||||
import type { App } from "obsidian";
|
||||
import { Notice } from "obsidian";
|
||||
import React from "react";
|
||||
import { computeInstallState, type InstallState } from "./OpencodeBinaryManager";
|
||||
import { getOpencodeBinaryManager } from "./descriptor";
|
||||
import { mapNodeArch, mapNodePlatform } from "./platformResolver";
|
||||
|
||||
interface Props {
|
||||
plugin: CopilotPlugin;
|
||||
app: App;
|
||||
}
|
||||
|
||||
function renderStatusDescription(state: InstallState): React.ReactNode {
|
||||
if (state.kind === "absent") {
|
||||
return <span className="tw-text-warning">Setup required — opencode is not installed.</span>;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
Ready — opencode <code>v{state.version}</code>
|
||||
{state.source === "custom" && <span className="tw-text-muted"> (custom)</span>}
|
||||
</div>
|
||||
<div className="tw-break-all tw-font-mono tw-text-xs">{state.path}</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenCode-specific settings panel. Lives in the OpenCode backend folder so
|
||||
* the generic Agent Mode settings tab stays backend-agnostic — it just
|
||||
* renders `descriptor.SettingsPanel`.
|
||||
*/
|
||||
export const OpencodeSettingsPanel: React.FC<Props> = ({ plugin, app }) => {
|
||||
const settings = useSettingsValue();
|
||||
const manager = getOpencodeBinaryManager(plugin);
|
||||
const [showAdvanced, setShowAdvanced] = React.useState(false);
|
||||
|
||||
const installState = computeInstallState(settings.agentMode?.backends?.opencode);
|
||||
|
||||
const openInstallModal = (): void => {
|
||||
new OpencodeInstallModal(app, manager, {
|
||||
platform: mapNodePlatform(process.platform) ?? process.platform,
|
||||
arch: mapNodeArch(process.arch) ?? process.arch,
|
||||
}).open();
|
||||
};
|
||||
|
||||
const handleUninstall = (): void => {
|
||||
new ConfirmModal(
|
||||
app,
|
||||
async () => {
|
||||
try {
|
||||
await manager.uninstall();
|
||||
new Notice("opencode uninstalled.");
|
||||
} catch (e) {
|
||||
logError("[AgentMode] uninstall failed", e);
|
||||
new Notice(`Uninstall failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
},
|
||||
"Remove the installed opencode binary? Your BYOK keys and MCP config will be kept.",
|
||||
"Uninstall opencode",
|
||||
"Uninstall"
|
||||
).open();
|
||||
};
|
||||
|
||||
const onSaveCustomPath = React.useCallback(
|
||||
async (path: string): Promise<string | null> => {
|
||||
try {
|
||||
await manager.setCustomBinaryPath(path);
|
||||
} catch (e) {
|
||||
return e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
new Notice("Custom opencode binary path saved.");
|
||||
return null;
|
||||
},
|
||||
[manager]
|
||||
);
|
||||
|
||||
const clearCustomPath = async (): Promise<void> => {
|
||||
await manager.setCustomBinaryPath(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingItem
|
||||
type="custom"
|
||||
title="opencode binary"
|
||||
description={renderStatusDescription(installState)}
|
||||
>
|
||||
<div className="tw-flex tw-flex-wrap tw-justify-end tw-gap-2">
|
||||
{installState.kind === "absent" && (
|
||||
<Button variant="default" onClick={openInstallModal}>
|
||||
Install opencode
|
||||
</Button>
|
||||
)}
|
||||
{installState.kind === "installed" && installState.source === "managed" && (
|
||||
<>
|
||||
<Button variant="secondary" onClick={openInstallModal}>
|
||||
Reinstall
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleUninstall}>
|
||||
Uninstall
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{installState.kind === "installed" && installState.source === "custom" && (
|
||||
<Button variant="secondary" onClick={clearCustomPath}>
|
||||
Clear custom path
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</SettingItem>
|
||||
|
||||
{!(installState.kind === "installed" && installState.source === "custom") && (
|
||||
<div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowAdvanced((s) => !s)}
|
||||
aria-expanded={showAdvanced}
|
||||
>
|
||||
{showAdvanced ? "▾ Advanced" : "▸ Advanced"}
|
||||
</Button>
|
||||
{showAdvanced && (
|
||||
<SettingItem
|
||||
type="custom"
|
||||
title="Use custom opencode binary path"
|
||||
description="Skip the managed install and point Agent Mode at a binary you already have on disk. Useful for self-builders or air-gapped machines."
|
||||
>
|
||||
<BinaryPathSetting
|
||||
binaryName="opencode"
|
||||
placeholder="/absolute/path/to/opencode"
|
||||
initialPath=""
|
||||
notFoundHint="opencode not found on PATH. Install it or paste a custom path manually."
|
||||
onSave={onSaveCustomPath}
|
||||
persistOnAutoDetect
|
||||
/>
|
||||
</SettingItem>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
151
src/agentMode/backends/opencode/descriptor.test.ts
Normal file
151
src/agentMode/backends/opencode/descriptor.test.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
import { OpencodeBackendDescriptor } from "./descriptor";
|
||||
|
||||
jest.mock("@/logger", () => ({
|
||||
logInfo: jest.fn(),
|
||||
logWarn: jest.fn(),
|
||||
logError: jest.fn(),
|
||||
}));
|
||||
|
||||
describe("OpencodeBackendDescriptor.wire.decode", () => {
|
||||
const decode = OpencodeBackendDescriptor.wire.decode;
|
||||
|
||||
it("parses 2-segment ids as bare/default with provider mapped to Copilot", () => {
|
||||
expect(decode("anthropic/claude-sonnet-4-5")).toEqual({
|
||||
selection: { baseModelId: "anthropic/claude-sonnet-4-5", effort: null },
|
||||
provider: "anthropic",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses 3-segment ids as variants when the suffix is a known effort", () => {
|
||||
expect(decode("anthropic/claude-sonnet-4-5/medium")).toEqual({
|
||||
selection: { baseModelId: "anthropic/claude-sonnet-4-5", effort: "medium" },
|
||||
provider: "anthropic",
|
||||
});
|
||||
expect(decode("openai/gpt-5/minimal")).toEqual({
|
||||
selection: { baseModelId: "openai/gpt-5", effort: "minimal" },
|
||||
provider: "openai",
|
||||
});
|
||||
});
|
||||
|
||||
it("recognizes opencode's full effort vocabulary (none/minimal/low/medium/high/xhigh/max)", () => {
|
||||
// Opencode advertises Anthropic models with `/max` and `/xhigh` and
|
||||
// OpenRouter reasoning models with `/none`. Each must collapse onto
|
||||
// its bare base.
|
||||
for (const effort of ["none", "minimal", "low", "medium", "high", "xhigh", "max"]) {
|
||||
expect(decode(`anthropic/claude-opus-4-7/${effort}`)).toEqual({
|
||||
selection: { baseModelId: "anthropic/claude-opus-4-7", effort },
|
||||
provider: "anthropic",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("returns no-effort representation for 3-segment ids whose suffix isn't a known effort", () => {
|
||||
// OpenRouter-style 3-segment ids without an effort suffix — the
|
||||
// trailing segment is part of the model name. The whole id is the
|
||||
// baseModelId; provider is still attributed from the leading segment.
|
||||
expect(decode("openrouter/anthropic/claude-sonnet-4-5")).toEqual({
|
||||
selection: { baseModelId: "openrouter/anthropic/claude-sonnet-4-5", effort: null },
|
||||
provider: "openrouterai",
|
||||
});
|
||||
expect(decode("openrouter/anthropic/claude-3.5-haiku")).toEqual({
|
||||
selection: { baseModelId: "openrouter/anthropic/claude-3.5-haiku", effort: null },
|
||||
provider: "openrouterai",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses 4-segment umbrella ids as variants when the last segment is a known effort", () => {
|
||||
// OpenRouter wraps native ids under `openrouter/`, so its variants
|
||||
// are 4-segment: `openrouter/<sub>/<model>/<effort>`. Without this
|
||||
// case the picker would render seven duplicate rows per OpenRouter
|
||||
// reasoning model.
|
||||
expect(decode("openrouter/anthropic/claude-sonnet-4.5/high")).toEqual({
|
||||
selection: { baseModelId: "openrouter/anthropic/claude-sonnet-4.5", effort: "high" },
|
||||
provider: "openrouterai",
|
||||
});
|
||||
expect(decode("openrouter/anthropic/claude-sonnet-4.5/none")).toEqual({
|
||||
selection: { baseModelId: "openrouter/anthropic/claude-sonnet-4.5", effort: "none" },
|
||||
provider: "openrouterai",
|
||||
});
|
||||
expect(decode("openrouter/openai/gpt-5/xhigh")).toEqual({
|
||||
selection: { baseModelId: "openrouter/openai/gpt-5", effort: "xhigh" },
|
||||
provider: "openrouterai",
|
||||
});
|
||||
// OpenRouter route variants like `:exacto` live inside the model
|
||||
// segment — the effort suffix still attaches at the trailing slash.
|
||||
expect(decode("openrouter/openai/gpt-oss-120b:exacto/none")).toEqual({
|
||||
selection: { baseModelId: "openrouter/openai/gpt-oss-120b:exacto", effort: "none" },
|
||||
provider: "openrouterai",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns no-effort representation for unparseable shapes (1 segment or unknown trailing segment)", () => {
|
||||
// 1-segment ids have no provider segment to attribute.
|
||||
expect(decode("just-a-name")).toEqual({
|
||||
selection: { baseModelId: "just-a-name", effort: null },
|
||||
provider: null,
|
||||
});
|
||||
// 4+ segment ids whose trailing segment isn't a known effort fall
|
||||
// through to a no-effort representation. The leading segment still
|
||||
// attributes a provider when it maps.
|
||||
expect(decode("anthropic/foo/bar/baz")).toEqual({
|
||||
selection: { baseModelId: "anthropic/foo/bar/baz", effort: null },
|
||||
provider: "anthropic",
|
||||
});
|
||||
expect(decode("a/b/c/d")).toEqual({
|
||||
selection: { baseModelId: "a/b/c/d", effort: null },
|
||||
provider: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("OpencodeBackendDescriptor.wire.encode", () => {
|
||||
const encode = OpencodeBackendDescriptor.wire.encode;
|
||||
|
||||
it("returns the bare baseModelId when effort is null", () => {
|
||||
expect(encode({ baseModelId: "anthropic/claude-sonnet-4-5", effort: null })).toBe(
|
||||
"anthropic/claude-sonnet-4-5"
|
||||
);
|
||||
});
|
||||
|
||||
it("appends the variant when effort is set", () => {
|
||||
expect(encode({ baseModelId: "anthropic/claude-sonnet-4-5", effort: "high" })).toBe(
|
||||
"anthropic/claude-sonnet-4-5/high"
|
||||
);
|
||||
});
|
||||
|
||||
it("round-trips via wire.decode", () => {
|
||||
const ids = [
|
||||
"anthropic/claude-sonnet-4-5",
|
||||
"anthropic/claude-sonnet-4-5/low",
|
||||
"openai/gpt-5/high",
|
||||
"anthropic/claude-opus-4-7/max",
|
||||
"openrouter/anthropic/claude-sonnet-4.5",
|
||||
"openrouter/anthropic/claude-sonnet-4.5/none",
|
||||
"openrouter/anthropic/claude-sonnet-4.5/high",
|
||||
];
|
||||
for (const id of ids) {
|
||||
const decoded = OpencodeBackendDescriptor.wire.decode(id);
|
||||
expect(encode(decoded.selection)).toBe(id);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("OpencodeBackendDescriptor.isModelEnabledByDefault", () => {
|
||||
const fn = OpencodeBackendDescriptor.isModelEnabledByDefault!;
|
||||
|
||||
it("matches 'Big Pickle' in name", () => {
|
||||
expect(fn({ modelId: "anthropic/foo", name: "Big Pickle" })).toBe(true);
|
||||
expect(fn({ modelId: "anthropic/foo", name: "BIG PICKLE" })).toBe(true);
|
||||
expect(fn({ modelId: "anthropic/foo", name: "big-pickle" })).toBe(true);
|
||||
});
|
||||
|
||||
it("matches 'big-pickle' in modelId", () => {
|
||||
expect(fn({ modelId: "openai/big-pickle", name: "Some Display" })).toBe(true);
|
||||
expect(fn({ modelId: "openai/big_pickle", name: "Some Display" })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for unrelated models", () => {
|
||||
expect(fn({ modelId: "anthropic/claude-sonnet-4-5", name: "Claude Sonnet 4.5" })).toBe(false);
|
||||
expect(fn({ modelId: "openai/gpt-5", name: "GPT-5" })).toBe(false);
|
||||
});
|
||||
});
|
||||
202
src/agentMode/backends/opencode/descriptor.ts
Normal file
202
src/agentMode/backends/opencode/descriptor.ts
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
import { OpencodeInstallModal } from "@/agentMode/backends/opencode/OpencodeInstallModal";
|
||||
import OpencodeLogo from "@/agentMode/backends/opencode/logo.svg";
|
||||
import type CopilotPlugin from "@/main";
|
||||
import {
|
||||
subscribeToSettingsChange,
|
||||
updateAgentModeBackendFields,
|
||||
type CopilotSettings,
|
||||
} from "@/settings/model";
|
||||
import {
|
||||
OPENCODE_CANONICAL_MODE_AGENT_IDS,
|
||||
OpencodeBackend,
|
||||
OPENCODE_PROVIDER_MAP,
|
||||
} from "./OpencodeBackend";
|
||||
import { computeInstallState, OpencodeBinaryManager } from "./OpencodeBinaryManager";
|
||||
import { OpencodeSettingsPanel } from "./OpencodeSettingsPanel";
|
||||
import { mapNodeArch, mapNodePlatform } from "./platformResolver";
|
||||
import type { AgentSession } from "@/agentMode/session/AgentSession";
|
||||
import { applyPersistedMode } from "@/agentMode/session/applyPersistedMode";
|
||||
import { simpleBinaryBackendProcess } from "@/agentMode/backends/shared/simpleBinaryBackend";
|
||||
import type {
|
||||
CopilotMode,
|
||||
ModeMapping,
|
||||
ModelSelection,
|
||||
ModelWireCodec,
|
||||
} from "@/agentMode/session/types";
|
||||
import type { BackendDescriptor, BackendProcess, InstallState } from "@/agentMode/session/types";
|
||||
|
||||
/** Config option id OpenCode uses to switch the active agent at runtime. */
|
||||
const OPENCODE_MODE_CONFIG_OPTION_ID = "mode";
|
||||
|
||||
// Lazy-created singleton manager. The first plugin to ask for it wins; in a
|
||||
// running Obsidian instance there's exactly one CopilotPlugin so this is safe.
|
||||
let managerRef: OpencodeBinaryManager | null = null;
|
||||
|
||||
/**
|
||||
* Effort suffixes opencode appends to model ids. Used to disambiguate
|
||||
* genuine effort variants from ids whose trailing segment is part of
|
||||
* the model name (e.g. `openrouter/anthropic/claude-3.5-haiku` — the
|
||||
* last segment `claude-3.5-haiku` is the model, not an effort).
|
||||
*/
|
||||
const KNOWN_OPENCODE_EFFORTS = new Set([
|
||||
"none",
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh",
|
||||
"max",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Wire-format codec for Opencode. Native providers emit
|
||||
* `<provider>/<model>[/<effort>]` (3 segments with effort); umbrella
|
||||
* providers like OpenRouter emit `<provider>/<sub>/<model>[/<effort>]`
|
||||
* (4 segments with effort). The leading segment is always the opencode
|
||||
* provider id, mapped onto a Copilot `ChatModelProviders` value via
|
||||
* `OPENCODE_PROVIDER_MAP` for picker section grouping. We classify the
|
||||
* trailing segment as effort iff it's in the known effort vocabulary —
|
||||
* that gates out 3-seg umbrella ids whose last segment is part of the
|
||||
* model name (e.g. `openrouter/anthropic/claude-3.5-haiku`).
|
||||
*/
|
||||
const opencodeWire: ModelWireCodec = {
|
||||
encode: (selection: ModelSelection) =>
|
||||
selection.effort ? `${selection.baseModelId}/${selection.effort}` : selection.baseModelId,
|
||||
decode: (wireId: string) => {
|
||||
if (!wireId) return { selection: { baseModelId: wireId, effort: null }, provider: null };
|
||||
const segments = wireId.split("/");
|
||||
const provider = segments.length >= 2 ? opencodeProviderToCopilot(segments[0]) : null;
|
||||
const last = segments[segments.length - 1];
|
||||
if (segments.length >= 3 && KNOWN_OPENCODE_EFFORTS.has(last)) {
|
||||
return {
|
||||
selection: { baseModelId: segments.slice(0, -1).join("/"), effort: last },
|
||||
provider,
|
||||
};
|
||||
}
|
||||
return { selection: { baseModelId: wireId, effort: null }, provider };
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the lazy `OpencodeBinaryManager` instance owned by this descriptor.
|
||||
* The plugin no longer holds a top-level reference — ownership lives next to
|
||||
* the backend that uses it.
|
||||
*/
|
||||
export function getOpencodeBinaryManager(plugin: CopilotPlugin): OpencodeBinaryManager {
|
||||
if (!managerRef) managerRef = new OpencodeBinaryManager(plugin);
|
||||
return managerRef;
|
||||
}
|
||||
|
||||
/**
|
||||
* Descriptor for the OpenCode backend. This is the contract `session/` and
|
||||
* `ui/` consume — the rest of Agent Mode never imports `OpencodeBackend`,
|
||||
* `OpencodeBinaryManager`, or `OpencodeInstallModal` directly.
|
||||
*/
|
||||
export const OpencodeBackendDescriptor: BackendDescriptor = {
|
||||
id: "opencode",
|
||||
displayName: "opencode",
|
||||
Icon: OpencodeLogo,
|
||||
skillsProjectDir: ".opencode/skills",
|
||||
crossDiscoveredAgents: ["claude", "codex"],
|
||||
restartOnManagedSkillsChange: true,
|
||||
wire: opencodeWire,
|
||||
|
||||
getInstallState(settings: CopilotSettings): InstallState {
|
||||
const raw = computeInstallState(settings.agentMode?.backends?.opencode);
|
||||
if (raw.kind === "absent") return { kind: "absent" };
|
||||
return { kind: "ready", source: raw.source };
|
||||
},
|
||||
|
||||
subscribeInstallState(_plugin: CopilotPlugin, cb: () => void): () => void {
|
||||
return subscribeToSettingsChange((prev, next) => {
|
||||
if (prev.agentMode?.backends?.opencode !== next.agentMode?.backends?.opencode) {
|
||||
cb();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
openInstallUI(plugin: CopilotPlugin): void {
|
||||
new OpencodeInstallModal(plugin.app, getOpencodeBinaryManager(plugin), {
|
||||
platform: mapNodePlatform(process.platform) ?? process.platform,
|
||||
arch: mapNodeArch(process.arch) ?? process.arch,
|
||||
}).open();
|
||||
},
|
||||
|
||||
async applySelection(session: AgentSession, selection: ModelSelection): Promise<void> {
|
||||
await session.setModel(opencodeWire.encode(selection));
|
||||
},
|
||||
|
||||
createBackendProcess(args): BackendProcess {
|
||||
return simpleBinaryBackendProcess(args, new OpencodeBackend());
|
||||
},
|
||||
|
||||
SettingsPanel: OpencodeSettingsPanel,
|
||||
|
||||
async onPluginLoad(plugin: CopilotPlugin): Promise<void> {
|
||||
await getOpencodeBinaryManager(plugin).refreshInstallState();
|
||||
},
|
||||
|
||||
isModelEnabledByDefault(model) {
|
||||
// Default-enable only "Big Pickle"; users widen the catalog via the
|
||||
// per-model toggles in the Agents tab.
|
||||
const re = /big[\s_-]*pickle/i;
|
||||
return re.test(model.name) || re.test(model.modelId);
|
||||
},
|
||||
|
||||
getProbeSessionId(settings: CopilotSettings): string | undefined {
|
||||
const id = settings.agentMode?.backends?.opencode?.probeSessionId;
|
||||
return id && id.length > 0 ? id : undefined;
|
||||
},
|
||||
|
||||
async persistProbeSessionId(sessionId: string, _plugin: CopilotPlugin): Promise<void> {
|
||||
updateAgentModeBackendFields("opencode", { probeSessionId: sessionId });
|
||||
},
|
||||
|
||||
/**
|
||||
* OpenCode doesn't use ACP `availableModes` — its "modes" are agents,
|
||||
* switched at runtime via `session/set_config_option` with `configId:
|
||||
* "mode"`. The `copilot-build` agent is provisioned in the spawn-time
|
||||
* config (see `OpencodeBackend.buildOpencodeConfig`); `build` is the
|
||||
* OpenCode built-in we surface as canonical `auto`. Plan mode is not
|
||||
* exposed for opencode (no ACP-visible plan finalization tool).
|
||||
*/
|
||||
getModeMapping(_modeState, configOptions): ModeMapping | null {
|
||||
if (!configOptions) return null;
|
||||
const opt = configOptions.find((o) => o.id === OPENCODE_MODE_CONFIG_OPTION_ID);
|
||||
if (!opt) return null;
|
||||
return {
|
||||
kind: "configOption",
|
||||
configId: OPENCODE_MODE_CONFIG_OPTION_ID,
|
||||
canonical: { ...OPENCODE_CANONICAL_MODE_AGENT_IDS },
|
||||
};
|
||||
},
|
||||
|
||||
async persistModeSelection(value: CopilotMode, _plugin: CopilotPlugin): Promise<void> {
|
||||
updateAgentModeBackendFields("opencode", { selectedMode: value });
|
||||
},
|
||||
|
||||
/**
|
||||
* Defense-in-depth replay of the persisted mode on a freshly created
|
||||
* session. The primary path is `default_agent` baked into the spawn-time
|
||||
* `OPENCODE_CONFIG_CONTENT` (see `OpencodeBackend.buildOpencodeConfig`),
|
||||
* which guarantees the very first turn runs in the right agent. This
|
||||
* runtime call only fires if the spawn-time default didn't take and the
|
||||
* `mode` configOption is already registered.
|
||||
*/
|
||||
async applyInitialSessionConfig(session: AgentSession, settings: CopilotSettings): Promise<void> {
|
||||
const persistedMode = settings.agentMode?.backends?.opencode?.selectedMode ?? "default";
|
||||
await applyPersistedMode(session, persistedMode);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Map an OpenCode provider id (the leading segment of a wire-form modelId)
|
||||
* back to its Copilot `ChatModelProviders` value, or `null` for OpenCode-
|
||||
* native providers that don't correspond to any Copilot provider.
|
||||
*/
|
||||
function opencodeProviderToCopilot(opencodeProviderId: string): string | null {
|
||||
for (const [copilotProvider, oId] of Object.entries(OPENCODE_PROVIDER_MAP)) {
|
||||
if (oId === opencodeProviderId) return copilotProvider;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
1
src/agentMode/backends/opencode/index.ts
Normal file
1
src/agentMode/backends/opencode/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { OpencodeBackendDescriptor } from "./descriptor";
|
||||
1
src/agentMode/backends/opencode/logo.svg
Normal file
1
src/agentMode/backends/opencode/logo.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg viewBox="0 0 512 512" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M384 416H128V96H384V416ZM320 160H192V352H320V160Z"/></svg>
|
||||
|
After Width: | Height: | Size: 190 B |
110
src/agentMode/backends/opencode/platformResolver.test.ts
Normal file
110
src/agentMode/backends/opencode/platformResolver.test.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import {
|
||||
buildAssetCandidates,
|
||||
expectedBinaryName,
|
||||
mapNodeArch,
|
||||
mapNodePlatform,
|
||||
} from "./platformResolver";
|
||||
|
||||
describe("buildAssetCandidates", () => {
|
||||
it("darwin arm64 → single candidate", () => {
|
||||
expect(buildAssetCandidates({ platform: "darwin", arch: "arm64" })).toEqual([
|
||||
"opencode-darwin-arm64",
|
||||
]);
|
||||
});
|
||||
|
||||
it("darwin x64 with avx2 → single candidate (no baseline)", () => {
|
||||
expect(buildAssetCandidates({ platform: "darwin", arch: "x64", hasAvx2: true })).toEqual([
|
||||
"opencode-darwin-x64",
|
||||
]);
|
||||
});
|
||||
|
||||
it("darwin x64 without avx2 → baseline first, regular as fallback", () => {
|
||||
expect(buildAssetCandidates({ platform: "darwin", arch: "x64", hasAvx2: false })).toEqual([
|
||||
"opencode-darwin-x64-baseline",
|
||||
"opencode-darwin-x64",
|
||||
]);
|
||||
});
|
||||
|
||||
it("linux x64 glibc with avx2 → single candidate", () => {
|
||||
expect(
|
||||
buildAssetCandidates({ platform: "linux", arch: "x64", libc: "glibc", hasAvx2: true })
|
||||
).toEqual(["opencode-linux-x64"]);
|
||||
});
|
||||
|
||||
it("linux x64 glibc without avx2 → baseline first, regular as fallback", () => {
|
||||
expect(
|
||||
buildAssetCandidates({ platform: "linux", arch: "x64", libc: "glibc", hasAvx2: false })
|
||||
).toEqual(["opencode-linux-x64-baseline", "opencode-linux-x64"]);
|
||||
});
|
||||
|
||||
it("linux x64 musl with avx2 → musl first, regular as fallback", () => {
|
||||
expect(
|
||||
buildAssetCandidates({ platform: "linux", arch: "x64", libc: "musl", hasAvx2: true })
|
||||
).toEqual(["opencode-linux-x64-musl", "opencode-linux-x64"]);
|
||||
});
|
||||
|
||||
it("linux x64 musl without avx2 → musl, baseline, regular", () => {
|
||||
expect(
|
||||
buildAssetCandidates({ platform: "linux", arch: "x64", libc: "musl", hasAvx2: false })
|
||||
).toEqual(["opencode-linux-x64-musl", "opencode-linux-x64-baseline", "opencode-linux-x64"]);
|
||||
});
|
||||
|
||||
it("linux arm64 → single candidate", () => {
|
||||
expect(buildAssetCandidates({ platform: "linux", arch: "arm64" })).toEqual([
|
||||
"opencode-linux-arm64",
|
||||
]);
|
||||
});
|
||||
|
||||
it("windows x64 → single candidate", () => {
|
||||
expect(buildAssetCandidates({ platform: "windows", arch: "x64", hasAvx2: true })).toEqual([
|
||||
"opencode-windows-x64",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not duplicate candidates", () => {
|
||||
const candidates = buildAssetCandidates({
|
||||
platform: "linux",
|
||||
arch: "arm64",
|
||||
libc: "glibc",
|
||||
});
|
||||
expect(candidates.length).toBe(new Set(candidates).size);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapNodePlatform", () => {
|
||||
it.each([
|
||||
["darwin", "darwin"],
|
||||
["linux", "linux"],
|
||||
["win32", "windows"],
|
||||
])("%s → %s", (input, expected) => {
|
||||
expect(mapNodePlatform(input as NodeJS.Platform)).toBe(expected);
|
||||
});
|
||||
|
||||
it("returns undefined for unsupported platforms", () => {
|
||||
expect(mapNodePlatform("freebsd")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapNodeArch", () => {
|
||||
it.each([
|
||||
["x64", "x64"],
|
||||
["arm64", "arm64"],
|
||||
["arm", "arm"],
|
||||
])("%s → %s", (input, expected) => {
|
||||
expect(mapNodeArch(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it("returns undefined for unsupported archs", () => {
|
||||
expect(mapNodeArch("ia32")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("expectedBinaryName", () => {
|
||||
it("appends .exe on windows", () => {
|
||||
expect(expectedBinaryName("windows")).toBe("opencode.exe");
|
||||
});
|
||||
it("plain on darwin/linux", () => {
|
||||
expect(expectedBinaryName("darwin")).toBe("opencode");
|
||||
expect(expectedBinaryName("linux")).toBe("opencode");
|
||||
});
|
||||
});
|
||||
140
src/agentMode/backends/opencode/platformResolver.ts
Normal file
140
src/agentMode/backends/opencode/platformResolver.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import { execFile as execFileCb } from "node:child_process";
|
||||
import * as fs from "node:fs";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFile = promisify(execFileCb);
|
||||
|
||||
export type OpencodePlatform = "darwin" | "linux" | "windows";
|
||||
export type OpencodeArch = "x64" | "arm64" | "arm";
|
||||
export type OpencodeLibc = "glibc" | "musl";
|
||||
|
||||
export interface AssetTarget {
|
||||
platform: OpencodePlatform;
|
||||
arch: OpencodeArch;
|
||||
/** Only set on linux. */
|
||||
libc?: OpencodeLibc;
|
||||
/** Only set on x64. `undefined` ⇒ assume modern (AVX2 present). */
|
||||
hasAvx2?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the prioritized list of opencode release asset stems (no extension)
|
||||
* for the given target. The first match wins; later entries are fallbacks
|
||||
* when the preferred variant is not published for a release.
|
||||
*
|
||||
* Mirrors the fallback order in opencode's own launcher script
|
||||
* (`bin/opencode` in sst/opencode).
|
||||
*/
|
||||
export function buildAssetCandidates(target: AssetTarget): string[] {
|
||||
const base = `opencode-${target.platform}-${target.arch}`;
|
||||
const out: string[] = [];
|
||||
|
||||
if (target.platform === "linux" && target.libc === "musl") {
|
||||
out.push(`${base}-musl`);
|
||||
}
|
||||
|
||||
if (target.arch === "x64" && target.hasAvx2 === false) {
|
||||
out.push(`${base}-baseline`);
|
||||
}
|
||||
|
||||
out.push(base);
|
||||
|
||||
return [...new Set(out)];
|
||||
}
|
||||
|
||||
export function mapNodePlatform(nodePlatform: NodeJS.Platform): OpencodePlatform | undefined {
|
||||
if (nodePlatform === "darwin") return "darwin";
|
||||
if (nodePlatform === "linux") return "linux";
|
||||
if (nodePlatform === "win32") return "windows";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function mapNodeArch(nodeArch: string): OpencodeArch | undefined {
|
||||
if (nodeArch === "x64") return "x64";
|
||||
if (nodeArch === "arm64") return "arm64";
|
||||
if (nodeArch === "arm") return "arm";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort musl libc detection. Linux only; returns false on other OSes.
|
||||
* Falls back to false if probes fail (glibc is the safer default).
|
||||
*/
|
||||
export async function detectMusl(): Promise<boolean> {
|
||||
if (process.platform !== "linux") return false;
|
||||
try {
|
||||
await fs.promises.access("/etc/alpine-release");
|
||||
return true;
|
||||
} catch {
|
||||
// not alpine; try ldd
|
||||
}
|
||||
try {
|
||||
const { stdout, stderr } = await execFile("ldd", ["--version"]);
|
||||
return /musl/i.test(`${stdout}\n${stderr}`);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort AVX2 detection on x64 hosts. Returns `true` when the probe
|
||||
* fails — modern hardware is the safer default and the manager already
|
||||
* falls back to the non-baseline asset if the baseline asset is missing.
|
||||
*/
|
||||
export async function detectAvx2(): Promise<boolean> {
|
||||
if (process.arch !== "x64") return false;
|
||||
try {
|
||||
if (process.platform === "darwin") {
|
||||
const { stdout } = await execFile("sysctl", ["-n", "hw.optional.avx2_0"]);
|
||||
return stdout.trim() === "1";
|
||||
}
|
||||
if (process.platform === "linux") {
|
||||
const cpuinfo = await fs.promises.readFile("/proc/cpuinfo", "utf-8");
|
||||
return /\bavx2\b/.test(cpuinfo);
|
||||
}
|
||||
if (process.platform === "win32") {
|
||||
const { stdout } = await execFile("powershell.exe", [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"[System.Runtime.Intrinsics.X86.Avx2]::IsSupported",
|
||||
]);
|
||||
return /true/i.test(stdout);
|
||||
}
|
||||
} catch {
|
||||
// probe failed → assume modern
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export interface ResolvedTarget {
|
||||
target: AssetTarget;
|
||||
candidates: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the current host's opencode asset target by probing the system,
|
||||
* and return the prioritized asset-stem candidate list.
|
||||
*/
|
||||
export async function resolveOpencodeTarget(): Promise<ResolvedTarget> {
|
||||
const platform = mapNodePlatform(process.platform);
|
||||
const arch = mapNodeArch(process.arch);
|
||||
if (!platform || !arch) {
|
||||
throw new Error(
|
||||
`Unsupported platform/arch: ${process.platform}/${process.arch}. Agent Mode requires darwin/linux/windows on x64/arm64.`
|
||||
);
|
||||
}
|
||||
const [muslIsh, avx2] = await Promise.all([
|
||||
platform === "linux" ? detectMusl() : Promise.resolve(false),
|
||||
arch === "x64" ? detectAvx2() : Promise.resolve(undefined),
|
||||
]);
|
||||
const libc: OpencodeLibc | undefined =
|
||||
platform === "linux" ? (muslIsh ? "musl" : "glibc") : undefined;
|
||||
const hasAvx2 = arch === "x64" ? avx2 : undefined;
|
||||
const target: AssetTarget = { platform, arch, libc, hasAvx2 };
|
||||
return { target, candidates: buildAssetCandidates(target) };
|
||||
}
|
||||
|
||||
/** Expected binary file name inside the extracted archive. */
|
||||
export function expectedBinaryName(platform: OpencodePlatform): string {
|
||||
return platform === "windows" ? "opencode.exe" : "opencode";
|
||||
}
|
||||
77
src/agentMode/backends/opencode/prompts.ts
Normal file
77
src/agentMode/backends/opencode/prompts.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
/**
|
||||
* Copilot Agent Mode system prompts for opencode.
|
||||
*
|
||||
* Why this exists: opencode picks a provider-default system prompt by
|
||||
* substring-matching `model.api.id` (see `opencode/packages/opencode/src/
|
||||
* session/system.ts:19-33`). For Copilot Plus models — registered under
|
||||
* names like `copilot-plus-flash` — that match falls through every branch
|
||||
* to `default.txt`, opencode's terse CLI-coding-agent prompt. The
|
||||
* resulting "you are a CLI software engineering tool" framing is wrong
|
||||
* for an Obsidian vault assistant and degrades behavior badly on smaller
|
||||
* fast models like Gemini 2.5 Flash.
|
||||
*
|
||||
* The fix: inject our own prompt via `cfg.agent.<id>.prompt` at spawn
|
||||
* time so opencode's substring picker is bypassed regardless of model.
|
||||
*
|
||||
* Content is curated, not invented. Two existing Copilot prompts cover
|
||||
* most of what's needed:
|
||||
*
|
||||
* - `DEFAULT_SYSTEM_PROMPT` (`src/constants.ts`) — the chat-mode
|
||||
* identity and formatting rules. Most rules port directly; chat-only
|
||||
* hooks (`@vault`, `getCurrentTime`, YouTube auto-transcribe) are
|
||||
* dropped because that infrastructure does not exist in opencode.
|
||||
* - `AGENT_LOOP_GUIDANCE` (`src/LLMProviders/chainRunner/
|
||||
* AutonomousAgentChainRunner.ts`) — the in-process autonomous
|
||||
* agent's loop bullets. Ported verbatim — the agent shape is the
|
||||
* same.
|
||||
*/
|
||||
|
||||
export const COPILOT_PROMPT_BASE = `You are Obsidian Copilot, an AI assistant that helps users work with their Obsidian vault — markdown notes for knowledge management, writing, and research. You are NOT a software-engineering agent or CLI coding tool. The working directory is the user's Obsidian vault: a collection of markdown notes, not a code repository. Disregard any framing in environment metadata that suggests otherwise.
|
||||
|
||||
## Grounding
|
||||
- The user's vault contains markdown notes. When the user says "note", they mean an Obsidian note in this vault.
|
||||
- When the user mentions "tags", they usually mean tags in Obsidian note properties.
|
||||
- Never claim you do not have access to something. Rely on the user's provided context and the tools available to you.
|
||||
- If you are unsure, say so and ask for more context — don't guess.
|
||||
- Always respond in the language of the user's query.
|
||||
|
||||
## Tool Behavior
|
||||
- Prefer evidence from \`read\`, \`grep\`, and \`glob\` over assumption. Don't infer what a note contains from its title — read it.
|
||||
- NEVER search for the same or very similar query twice. If results were insufficient, try substantially different terms.
|
||||
- After 1-2 searches, synthesize an answer from the results you have. Do not keep searching unless the results are clearly insufficient.
|
||||
- If you have enough information to answer, respond directly without calling any more tools.
|
||||
|
||||
## Response Style
|
||||
- Respond at length appropriate to note-taking and knowledge work. Do NOT default to 1-3 line CLI cadence — give the user enough context to understand and act on your answer.
|
||||
- Be direct and concrete. Don't pad with preamble or postamble.
|
||||
|
||||
## Markdown Formatting
|
||||
- Use \`$...$\` for LaTeX equations, never \`\\[...\\]\` or \`\\(...\\)\`.
|
||||
- For markdown lists, always use \`- \` (hyphen followed by exactly one space) for bullet points. Never use \`*\` for bullets.
|
||||
- For tables, use GitHub-flavored markdown.
|
||||
- When referring to an Obsidian note in your written reply, use \`[[title]]\` format (no backticks around it). To actually read or modify a note, call the \`read\` or \`edit\` tool — don't infer note contents from a wikilink title alone.
|
||||
- For Obsidian-internal image links, use \`![[link]]\` format. For web image links, use \`\` format.`;
|
||||
|
||||
/**
|
||||
* Pick the Copilot system prompt for a given model.
|
||||
*
|
||||
* Today returns `COPILOT_PROMPT_BASE` for every model. The function exists
|
||||
* so per-provider variants — for prompt-style differences across model
|
||||
* families (Anthropic vs Gemini vs GPT, larger vs smaller) — can be added
|
||||
* by branching on `modelApiId`, mirroring opencode's own substring picker
|
||||
* at `session/system.ts:19-33` but with our own content.
|
||||
*
|
||||
* Limitation worth knowing for future per-provider work: opencode reads
|
||||
* `cfg.agent.<id>.prompt` at spawn time, so the chosen prompt is fixed
|
||||
* for the session lifetime. Per-model prompts that follow mid-session
|
||||
* model switches would require either spawn-time recycling or an
|
||||
* opencode `experimental.chat.system.transform` plugin. For v1 the
|
||||
* `modelApiId` argument carries the user's sticky default at spawn.
|
||||
*/
|
||||
export function selectCopilotPrompt(modelApiId: string | undefined): string {
|
||||
// Future per-provider branching goes here, e.g.:
|
||||
// if (modelApiId?.includes("gemini")) return COPILOT_PROMPT_GEMINI;
|
||||
// if (modelApiId?.includes("claude")) return COPILOT_PROMPT_ANTHROPIC;
|
||||
void modelApiId;
|
||||
return COPILOT_PROMPT_BASE;
|
||||
}
|
||||
42
src/agentMode/backends/registry.test.ts
Normal file
42
src/agentMode/backends/registry.test.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import type { CopilotSettings } from "@/settings/model";
|
||||
import { getActiveBackendDescriptor, listBackendDescriptors } from "./registry";
|
||||
import { OpencodeBackendDescriptor } from "./opencode/descriptor";
|
||||
|
||||
jest.mock("@/agentMode/backends/opencode/OpencodeInstallModal", () => ({
|
||||
OpencodeInstallModal: class {},
|
||||
}));
|
||||
jest.mock("@/components/modals/ConfirmModal", () => ({ ConfirmModal: class {} }));
|
||||
jest.mock("@/components/ui/setting-item", () => ({ SettingItem: () => null }));
|
||||
jest.mock("@/components/ui/button", () => ({ Button: () => null }));
|
||||
jest.mock("@/components/ui/input", () => ({ Input: () => null }));
|
||||
jest.mock("@/logger", () => ({ logInfo: jest.fn(), logWarn: jest.fn(), logError: jest.fn() }));
|
||||
jest.mock("obsidian", () => ({
|
||||
Modal: class {},
|
||||
Notice: class {},
|
||||
Platform: { isMobile: false },
|
||||
}));
|
||||
|
||||
describe("backendRegistry", () => {
|
||||
const baseSettings = (activeBackend?: string): CopilotSettings =>
|
||||
({
|
||||
agentMode: {
|
||||
enabled: true,
|
||||
byok: {},
|
||||
mcpServers: [],
|
||||
activeBackend: activeBackend ?? "opencode",
|
||||
backends: { opencode: {} },
|
||||
},
|
||||
}) as unknown as CopilotSettings;
|
||||
|
||||
it("returns the OpenCode descriptor by default", () => {
|
||||
expect(getActiveBackendDescriptor(baseSettings())).toBe(OpencodeBackendDescriptor);
|
||||
});
|
||||
|
||||
it("falls back to OpenCode when an unknown backend is selected", () => {
|
||||
expect(getActiveBackendDescriptor(baseSettings("nonexistent"))).toBe(OpencodeBackendDescriptor);
|
||||
});
|
||||
|
||||
it("listBackendDescriptors includes OpenCode", () => {
|
||||
expect(listBackendDescriptors()).toContain(OpencodeBackendDescriptor);
|
||||
});
|
||||
});
|
||||
32
src/agentMode/backends/registry.ts
Normal file
32
src/agentMode/backends/registry.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import type { CopilotSettings } from "@/settings/model";
|
||||
import { ClaudeBackendDescriptor } from "./claude";
|
||||
import { CodexBackendDescriptor } from "./codex/descriptor";
|
||||
import { OpencodeBackendDescriptor } from "./opencode/descriptor";
|
||||
import type { BackendDescriptor, BackendId } from "@/agentMode/session/types";
|
||||
|
||||
/**
|
||||
* Registry of all known backends. Adding a new backend is exactly:
|
||||
*
|
||||
* 1. Implement `AcpBackend` and export a `BackendDescriptor` from
|
||||
* `backends/<id>/`.
|
||||
* 2. Add the entry below.
|
||||
* 3. Persist the backend's per-id slice in `agentMode.backends.<id>`.
|
||||
*
|
||||
* No edits to `acp/`, `session/`, or `ui/` should be required.
|
||||
*/
|
||||
export const backendRegistry: Record<BackendId, BackendDescriptor> = {
|
||||
opencode: OpencodeBackendDescriptor,
|
||||
claude: ClaudeBackendDescriptor,
|
||||
codex: CodexBackendDescriptor,
|
||||
};
|
||||
|
||||
/** Resolve the active backend descriptor from settings. Falls back to `opencode`. */
|
||||
export function getActiveBackendDescriptor(settings: CopilotSettings): BackendDescriptor {
|
||||
const id = settings.agentMode?.activeBackend ?? "opencode";
|
||||
return backendRegistry[id] ?? OpencodeBackendDescriptor;
|
||||
}
|
||||
|
||||
/** List all registered backend descriptors (e.g. for a backend picker). */
|
||||
export function listBackendDescriptors(): BackendDescriptor[] {
|
||||
return Object.values(backendRegistry);
|
||||
}
|
||||
106
src/agentMode/backends/shared/BinaryInstallContent.tsx
Normal file
106
src/agentMode/backends/shared/BinaryInstallContent.tsx
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import { ReactModal } from "@/components/modals/ReactModal";
|
||||
import { BinaryPathSetting } from "@/components/agent/BinaryPathSetting";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { logError } from "@/logger";
|
||||
import { validateExecutableFile } from "@/utils/detectBinary";
|
||||
import { App, Notice } from "obsidian";
|
||||
import React from "react";
|
||||
|
||||
export interface BinaryInstallContentProps {
|
||||
/** Modal title (e.g. "Configure Claude Code (Agent backend)"). */
|
||||
modalTitle: string;
|
||||
/** Lower-case display label for save toast (e.g. "Claude Code"). */
|
||||
binaryDisplayName: string;
|
||||
/** Binary lookup name (e.g. "claude-agent-acp"). */
|
||||
binaryName: string;
|
||||
/** Shell command to install the binary. */
|
||||
installCommand: string;
|
||||
/** Placeholder shown in the binary-path input. */
|
||||
pathPlaceholder: string;
|
||||
/** Initial path read from settings. */
|
||||
initialPath: string;
|
||||
/** Body paragraph above the install command. */
|
||||
description: React.ReactNode;
|
||||
/** Persist the validated path back to settings. */
|
||||
onPersist: (path: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const BinaryInstallContent: React.FC<BinaryInstallContentProps> = ({
|
||||
binaryDisplayName,
|
||||
binaryName,
|
||||
installCommand,
|
||||
pathPlaceholder,
|
||||
initialPath,
|
||||
description,
|
||||
onPersist,
|
||||
onClose,
|
||||
}) => {
|
||||
const onSave = React.useCallback(
|
||||
async (path: string): Promise<string | null> => {
|
||||
const err = await validateExecutableFile(path);
|
||||
if (err) return err;
|
||||
onPersist(path);
|
||||
new Notice(`${binaryDisplayName} binary path saved.`);
|
||||
onClose();
|
||||
return null;
|
||||
},
|
||||
[binaryDisplayName, onPersist, onClose]
|
||||
);
|
||||
|
||||
const copy = React.useCallback((): void => {
|
||||
navigator.clipboard.writeText(installCommand).catch((e) => {
|
||||
logError("[AgentMode] copy install command failed", e);
|
||||
});
|
||||
new Notice("Copied to clipboard.");
|
||||
}, [installCommand]);
|
||||
|
||||
return (
|
||||
<div className="tw-flex tw-flex-col tw-gap-4">
|
||||
<p>{description}</p>
|
||||
<div className="tw-flex tw-flex-col tw-gap-1">
|
||||
<span className="tw-text-xs tw-text-muted">Install command</span>
|
||||
<div className="tw-flex tw-items-center tw-gap-2">
|
||||
<code className="tw-flex-1 tw-break-all tw-rounded tw-bg-secondary tw-p-2 tw-text-xs">
|
||||
{installCommand}
|
||||
</code>
|
||||
<Button variant="ghost" size="sm" onClick={copy}>
|
||||
Copy
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="tw-flex tw-flex-col tw-gap-2">
|
||||
<span className="tw-text-xs tw-text-muted">Binary path</span>
|
||||
<BinaryPathSetting
|
||||
binaryName={binaryName}
|
||||
placeholder={pathPlaceholder}
|
||||
initialPath={initialPath}
|
||||
notFoundHint={`${binaryName} not found on PATH. Run the install command above, then click Auto-detect again.`}
|
||||
onSave={onSave}
|
||||
/>
|
||||
</div>
|
||||
<div className="tw-flex tw-justify-end">
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Generic install modal for backends whose configuration is "point us at a
|
||||
* binary." Backends provide identifying strings + a persist callback.
|
||||
*/
|
||||
export class BinaryInstallModal extends ReactModal {
|
||||
constructor(
|
||||
app: App,
|
||||
private readonly props: Omit<BinaryInstallContentProps, "onClose">
|
||||
) {
|
||||
super(app, props.modalTitle);
|
||||
}
|
||||
|
||||
protected renderContent(close: () => void): React.ReactElement {
|
||||
return <BinaryInstallContent {...this.props} onClose={close} />;
|
||||
}
|
||||
}
|
||||
107
src/agentMode/backends/shared/SimpleBackendSettingsPanel.tsx
Normal file
107
src/agentMode/backends/shared/SimpleBackendSettingsPanel.tsx
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import { BinaryPathSetting } from "@/components/agent/BinaryPathSetting";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SettingItem } from "@/components/ui/setting-item";
|
||||
import { useSettingsValue } from "@/settings/model";
|
||||
import { validateExecutableFile } from "@/utils/detectBinary";
|
||||
import { Notice } from "obsidian";
|
||||
import React from "react";
|
||||
|
||||
export interface SimpleBackendSettingsPanelProps {
|
||||
/** Lower-case display name (e.g. "Claude Code"). */
|
||||
displayName: string;
|
||||
/** Binary lookup name (e.g. "claude-agent-acp"). */
|
||||
binaryName: string;
|
||||
/** Shell command users can run to install the binary. */
|
||||
installCommand: string;
|
||||
/** Placeholder for the binary-path input. */
|
||||
pathPlaceholder: string;
|
||||
/** Title of the custom-path SettingItem. */
|
||||
customPathTitle: string;
|
||||
/** Description of the custom-path SettingItem. */
|
||||
customPathDescription: string;
|
||||
/** Read the configured binary path from current settings. */
|
||||
readStoredPath: () => string;
|
||||
/** Persist `path` (or clear when `undefined`) back to settings. */
|
||||
persistPath: (path: string | undefined) => void;
|
||||
/** Open the matching install modal. */
|
||||
openInstallModal: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings panel for backends whose configuration is a single binary path
|
||||
* (Claude Code, Codex). Manages the "Configure / Clear path" SettingItem
|
||||
* plus a custom-path entry.
|
||||
*/
|
||||
export const SimpleBackendSettingsPanel: React.FC<SimpleBackendSettingsPanelProps> = ({
|
||||
displayName,
|
||||
binaryName,
|
||||
installCommand,
|
||||
pathPlaceholder,
|
||||
customPathTitle,
|
||||
customPathDescription,
|
||||
readStoredPath,
|
||||
persistPath,
|
||||
openInstallModal,
|
||||
}) => {
|
||||
// Re-render whenever settings change.
|
||||
useSettingsValue();
|
||||
const stored = readStoredPath();
|
||||
|
||||
const onSave = React.useCallback(
|
||||
async (path: string): Promise<string | null> => {
|
||||
const err = await validateExecutableFile(path);
|
||||
if (err) return err;
|
||||
persistPath(path);
|
||||
new Notice(`${displayName} binary path saved.`);
|
||||
return null;
|
||||
},
|
||||
[displayName, persistPath]
|
||||
);
|
||||
|
||||
const clear = React.useCallback((): void => {
|
||||
persistPath(undefined);
|
||||
}, [persistPath]);
|
||||
|
||||
const description = stored ? (
|
||||
<>
|
||||
<div>
|
||||
Ready — <code>{binaryName}</code> (custom path)
|
||||
</div>
|
||||
<div className="tw-break-all tw-font-mono tw-text-xs">{stored}</div>
|
||||
</>
|
||||
) : (
|
||||
<span className="tw-text-warning">
|
||||
Setup required — {displayName} binary path not configured.
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingItem type="custom" title={`${displayName} binary`} description={description}>
|
||||
<div className="tw-flex tw-flex-wrap tw-justify-end tw-gap-2">
|
||||
{!stored && (
|
||||
<Button variant="default" onClick={openInstallModal}>
|
||||
Configure
|
||||
</Button>
|
||||
)}
|
||||
{stored && (
|
||||
<Button variant="destructive" onClick={clear}>
|
||||
Clear path
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem type="custom" title={customPathTitle} description={customPathDescription}>
|
||||
<BinaryPathSetting
|
||||
binaryName={binaryName}
|
||||
placeholder={pathPlaceholder}
|
||||
initialPath={stored}
|
||||
notFoundHint={`${binaryName} not found on PATH. Install with \`${installCommand}\` and try again.`}
|
||||
onSave={onSave}
|
||||
persistOnAutoDetect
|
||||
/>
|
||||
</SettingItem>
|
||||
</>
|
||||
);
|
||||
};
|
||||
52
src/agentMode/backends/shared/simpleBinaryBackend.ts
Normal file
52
src/agentMode/backends/shared/simpleBinaryBackend.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import type { App } from "obsidian";
|
||||
import type CopilotPlugin from "@/main";
|
||||
import { AcpBackendProcess } from "@/agentMode/acp/AcpBackendProcess";
|
||||
import type { AcpBackend, AcpSpawnDescriptor } from "@/agentMode/acp/types";
|
||||
import { augmentPathForNodeShebang } from "@/agentMode/acp/nodeShebangPath";
|
||||
import type { BackendDescriptor, BackendProcess, InstallState } from "@/agentMode/session/types";
|
||||
|
||||
/**
|
||||
* Build a spawn descriptor for a backend whose only configuration is a
|
||||
* user-provided binary path (no managed install, no extra args). Auth is
|
||||
* inherited from the user's environment / login state — no API key
|
||||
* injection.
|
||||
*/
|
||||
export function buildSimpleSpawnDescriptor(
|
||||
binaryPath: string | undefined,
|
||||
configErrorMessage: string
|
||||
): AcpSpawnDescriptor {
|
||||
if (!binaryPath) throw new Error(configErrorMessage);
|
||||
return {
|
||||
command: binaryPath,
|
||||
args: [],
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: augmentPathForNodeShebang(binaryPath, process.env.PATH),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* `InstallState` for the same shape: a binaryPath either is set
|
||||
* (`ready/custom`) or it is not (`absent`).
|
||||
*/
|
||||
export function binaryPathInstallState(binaryPath: string | undefined): InstallState {
|
||||
return binaryPath ? { kind: "ready", source: "custom" } : { kind: "absent" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap an `AcpBackend` in `AcpBackendProcess` to satisfy the descriptor's
|
||||
* `createBackendProcess` factory. Centralizes the "ACP-track plumbing" so
|
||||
* subprocess backends (codex, opencode) don't repeat the construction.
|
||||
*/
|
||||
export function simpleBinaryBackendProcess(
|
||||
args: {
|
||||
plugin: CopilotPlugin;
|
||||
app: App;
|
||||
clientVersion: string;
|
||||
descriptor: BackendDescriptor;
|
||||
},
|
||||
backend: AcpBackend
|
||||
): BackendProcess {
|
||||
return new AcpBackendProcess(args.app, backend, args.clientVersion, args.descriptor);
|
||||
}
|
||||
136
src/agentMode/index.ts
Normal file
136
src/agentMode/index.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
import type { App } from "obsidian";
|
||||
import type CopilotPlugin from "@/main";
|
||||
import { logError } from "@/logger";
|
||||
import { getSettings } from "@/settings/model";
|
||||
import { backendRegistry, listBackendDescriptors } from "./backends/registry";
|
||||
import { AgentChatPersistenceManager } from "./session/AgentChatPersistenceManager";
|
||||
import { AgentModelPreloader } from "./session/AgentModelPreloader";
|
||||
import { AgentSessionManager } from "./session/AgentSessionManager";
|
||||
import { SkillManager } from "./skills";
|
||||
import { createDefaultPermissionPrompter } from "./ui/permissionPrompter";
|
||||
|
||||
export { AGENT_CHAT_MODE } from "./session/AgentChatPersistenceManager";
|
||||
export { AgentModeChat } from "./ui/AgentModeChat";
|
||||
export { default as CopilotAgentView } from "./ui/CopilotAgentView";
|
||||
export {
|
||||
useActiveBackendDescriptor,
|
||||
useBackendInstallState,
|
||||
useSessionBackendDescriptor,
|
||||
} from "./ui/useBackendDescriptor";
|
||||
export { useAgentModelPicker } from "./ui/useAgentModelPicker";
|
||||
export type { AgentModelPickerOverride } from "./ui/useAgentModelPicker";
|
||||
export { useAgentModePicker } from "./ui/useAgentModePicker";
|
||||
export type { AgentModePickerOverride } from "./ui/useAgentModePicker";
|
||||
export type { AgentSessionManager } from "./session/AgentSessionManager";
|
||||
export type { AgentBrand, BackendDescriptor, BackendId, InstallState } from "./session/types";
|
||||
export { isAgentModelEnabled, writeAgentModelOverride } from "./session/modelEnable";
|
||||
export { getBackendModelOverrides } from "./session/backendSettingsAccess";
|
||||
export type {
|
||||
BackendState,
|
||||
CopilotMode,
|
||||
EffortOption,
|
||||
ModelEntry,
|
||||
ModelSelection,
|
||||
ModelState,
|
||||
} from "./session/types";
|
||||
export type { StoredMcpServer, McpTransport } from "./session/mcpResolver";
|
||||
export { sanitizeStoredMcpServers } from "./session/mcpResolver";
|
||||
export { McpServersPanel } from "./ui/McpServersPanel";
|
||||
export { SelectedModelsList } from "./ui/SelectedModelsList";
|
||||
export { PlanPreviewView, PLAN_PREVIEW_VIEW_TYPE } from "./ui/PlanPreviewView";
|
||||
export type { PlanPreviewViewState } from "./ui/PlanPreviewView";
|
||||
export { getActiveBackendDescriptor, listBackendDescriptors } from "./backends/registry";
|
||||
export { frameSink as acpFrameSink } from "./session/debugSink";
|
||||
export { SkillManager, SkillsSettings, useManagedSkills } from "./skills";
|
||||
export type { Skill } from "./skills";
|
||||
|
||||
/**
|
||||
* Collect each registered backend's project-relative skills directory into
|
||||
* a `BackendId → path` map. The skills layer is forbidden by
|
||||
* `boundaries/dependencies` from importing the registry, so this lives in
|
||||
* the host-side barrel and is injected into `SkillManager.initialize`.
|
||||
*/
|
||||
function collectAgentSkillsDirsProjectRel(): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const descriptor of listBackendDescriptors()) {
|
||||
out[descriptor.id] = descriptor.skillsProjectDir;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single seam between the plugin host (`main.ts`) and Agent Mode. Initialises
|
||||
* the SkillManager singleton, wires the default permission prompter into a
|
||||
* fresh `AgentSessionManager`, kicks off every registered backend
|
||||
* descriptor's load-time reconcile (e.g. clear stale managed install), and
|
||||
* starts the model-catalog preload probes. The manager itself is
|
||||
* backend-agnostic — backends are spawned lazily on first session creation.
|
||||
*
|
||||
* SkillManager must be initialised before the preload probes fire: the
|
||||
* Claude descriptor's `getSkillCreationDirective` reads
|
||||
* `SkillManager.getInstance()` synchronously inside `newSession()`, which
|
||||
* the probe calls. Doing it in this function (rather than from `main.ts`
|
||||
* via a separate call) keeps the dependency order obvious and prevents the
|
||||
* preload race that would otherwise throw "called before initialize".
|
||||
*
|
||||
* `main.ts` calls this once on plugin load. To swap prompters, shut down
|
||||
* the existing manager and call this again.
|
||||
*/
|
||||
export function createAgentSessionManager(app: App, plugin: CopilotPlugin): AgentSessionManager {
|
||||
const skillManager = SkillManager.initialize(app, collectAgentSkillsDirsProjectRel());
|
||||
const preloader = new AgentModelPreloader(app, plugin, (id) => backendRegistry[id]);
|
||||
const persistenceManager = new AgentChatPersistenceManager(app);
|
||||
// Mutable ref breaks the construction cycle: the prompter needs the
|
||||
// manager, but handlers only fire after a session exists, which can't
|
||||
// happen before assignment below.
|
||||
let managerRef: AgentSessionManager | null = null;
|
||||
const prompter = createDefaultPermissionPrompter(
|
||||
app,
|
||||
(id) => managerRef?.getSessionByBackendId(id) ?? null
|
||||
);
|
||||
const manager = new AgentSessionManager(app, plugin, {
|
||||
permissionPrompter: prompter,
|
||||
resolveDescriptor: (id) => backendRegistry[id],
|
||||
modelPreloader: preloader,
|
||||
persistenceManager,
|
||||
});
|
||||
managerRef = manager;
|
||||
// Skill-set changes restart the affected backend when its descriptor
|
||||
// opts in via `restartOnManagedSkillsChange`, so native skill command
|
||||
// caches stay fresh.
|
||||
skillManager.subscribeToSkillSetChange((backendId) => {
|
||||
const descriptor = backendRegistry[backendId];
|
||||
if (!descriptor?.restartOnManagedSkillsChange) return;
|
||||
void manager
|
||||
.restartBackend(backendId, "managed skills changed")
|
||||
.catch((error) =>
|
||||
logError(`[Skills] Failed to refresh backend after skill change: ${backendId}`, error)
|
||||
);
|
||||
});
|
||||
void skillManager.refresh().catch((error) => {
|
||||
logError("[Skills] Initial discovery pass failed", error);
|
||||
});
|
||||
// Non-blocking — plugin load should not wait on disk reconcile.
|
||||
for (const descriptor of listBackendDescriptors()) {
|
||||
descriptor
|
||||
.onPluginLoad?.(plugin)
|
||||
.catch((e) => logError(`[AgentMode] backend ${descriptor.id} onPluginLoad failed`, e));
|
||||
}
|
||||
|
||||
const settings = getSettings();
|
||||
if (!settings.agentMode?.enabled) return manager;
|
||||
const preloads: Promise<void>[] = [];
|
||||
for (const descriptor of listBackendDescriptors()) {
|
||||
if (descriptor.getInstallState(settings).kind !== "ready") continue;
|
||||
preloads.push(
|
||||
manager
|
||||
.preloadModels(descriptor.id)
|
||||
.catch((e) => logError(`[AgentMode] preload ${descriptor.id} failed`, e))
|
||||
);
|
||||
}
|
||||
// Aggregate so the chat UI can gate its first render until every
|
||||
// backend's catalog has settled — the model picker should never flash
|
||||
// empty before the cache populates.
|
||||
manager.setPreloadPromise(Promise.allSettled(preloads).then(() => undefined));
|
||||
return manager;
|
||||
}
|
||||
419
src/agentMode/sdk/ClaudeSdkBackendProcess.test.ts
Normal file
419
src/agentMode/sdk/ClaudeSdkBackendProcess.test.ts
Normal file
|
|
@ -0,0 +1,419 @@
|
|||
import type { ModelInfo, SDKMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { BackendDescriptor, SessionEvent } from "@/agentMode/session/types";
|
||||
|
||||
const queryMock = jest.fn();
|
||||
const createSdkMcpServerMock = jest.fn((opts: unknown) => ({ type: "sdk", instance: opts }));
|
||||
|
||||
jest.mock("@anthropic-ai/claude-agent-sdk", () => ({
|
||||
query: (...args: unknown[]) => queryMock(...args),
|
||||
createSdkMcpServer: (opts: unknown) => createSdkMcpServerMock(opts),
|
||||
tool: (name: string, description: string, inputSchema: unknown, handler: unknown) => ({
|
||||
name,
|
||||
description,
|
||||
inputSchema,
|
||||
handler,
|
||||
}),
|
||||
}));
|
||||
|
||||
const FAKE_CATALOG: ModelInfo[] = [
|
||||
{
|
||||
value: "claude-fake-pro",
|
||||
displayName: "Claude Fake Pro",
|
||||
description: "test",
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ["low", "medium", "high"],
|
||||
},
|
||||
{
|
||||
value: "claude-fake-mini",
|
||||
displayName: "Claude Fake Mini",
|
||||
description: "test",
|
||||
supportsEffort: false,
|
||||
},
|
||||
];
|
||||
|
||||
jest.mock("@/logger", () => ({
|
||||
logInfo: jest.fn(),
|
||||
logWarn: jest.fn(),
|
||||
logError: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("@/settings/model", () => ({
|
||||
getSettings: () => ({ agentMode: { debugFullFrames: false } }),
|
||||
}));
|
||||
|
||||
jest.mock("@/agentMode/session/debugSink", () => ({
|
||||
frameSink: { append: jest.fn() },
|
||||
formatPayload: () => "",
|
||||
}));
|
||||
|
||||
jest.mock("./effortOption", () => ({
|
||||
...jest.requireActual("./effortOption"),
|
||||
getCachedSdkCatalog: jest.fn(),
|
||||
}));
|
||||
|
||||
import { ClaudeSdkBackendProcess } from "./ClaudeSdkBackendProcess";
|
||||
import { getCachedSdkCatalog } from "./effortOption";
|
||||
|
||||
beforeEach(() => {
|
||||
(getCachedSdkCatalog as jest.Mock).mockReturnValue(FAKE_CATALOG);
|
||||
});
|
||||
|
||||
function fakeDescriptor(): BackendDescriptor {
|
||||
return {
|
||||
id: "claude",
|
||||
displayName: "Claude",
|
||||
wire: {
|
||||
encode: (sel: { baseModelId: string; effort: string | null }) => sel.baseModelId,
|
||||
decode: (id: string) => ({
|
||||
selection: { baseModelId: id, effort: null },
|
||||
provider: "anthropic",
|
||||
}),
|
||||
effortConfigFor: (baseModelId: string) => {
|
||||
const m = FAKE_CATALOG.find((x) => x.value === baseModelId);
|
||||
if (!m?.supportsEffort) return null;
|
||||
const levels = m.supportedEffortLevels ?? [];
|
||||
if (levels.length === 0) return null;
|
||||
return {
|
||||
id: "effort",
|
||||
type: "select",
|
||||
category: "thought_level",
|
||||
name: "Effort",
|
||||
currentValue: levels[0],
|
||||
options: levels.map((v) => ({ value: v, name: v })),
|
||||
};
|
||||
},
|
||||
},
|
||||
} as unknown as BackendDescriptor;
|
||||
}
|
||||
|
||||
function makeQuery(messages: SDKMessage[]) {
|
||||
const iter = (async function* () {
|
||||
for (const m of messages) yield m;
|
||||
})();
|
||||
return Object.assign(iter, {
|
||||
interrupt: jest.fn().mockResolvedValue(undefined),
|
||||
setModel: jest.fn().mockResolvedValue(undefined),
|
||||
setPermissionMode: jest.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
}
|
||||
|
||||
function streamEvent(event: object): SDKMessage {
|
||||
return {
|
||||
type: "stream_event",
|
||||
event,
|
||||
parent_tool_use_id: null,
|
||||
uuid: "uuid-x" as `${string}-${string}-${string}-${string}-${string}`,
|
||||
session_id: "irrelevant",
|
||||
} as SDKMessage;
|
||||
}
|
||||
|
||||
function resultMessage(): SDKMessage {
|
||||
return {
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
duration_ms: 1,
|
||||
duration_api_ms: 1,
|
||||
is_error: false,
|
||||
num_turns: 1,
|
||||
result: "ok",
|
||||
stop_reason: "end_turn",
|
||||
total_cost_usd: 0,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
usage: {} as any,
|
||||
modelUsage: {},
|
||||
permission_denials: [],
|
||||
uuid: "uuid-r" as `${string}-${string}-${string}-${string}-${string}`,
|
||||
session_id: "irrelevant",
|
||||
};
|
||||
}
|
||||
|
||||
function getPromptQueryCalls(): unknown[][] {
|
||||
return queryMock.mock.calls.filter((c) => {
|
||||
const opts = (c[0] as { options?: { cwd?: unknown } } | undefined)?.options;
|
||||
return opts?.cwd !== undefined;
|
||||
});
|
||||
}
|
||||
|
||||
describe("ClaudeSdkBackendProcess.prompt happy path", () => {
|
||||
beforeEach(() => {
|
||||
queryMock.mockReset();
|
||||
createSdkMcpServerMock.mockClear();
|
||||
});
|
||||
|
||||
it("translates SDK text deltas to agent_message_chunk and resolves with end_turn", async () => {
|
||||
queryMock.mockImplementation(() =>
|
||||
makeQuery([
|
||||
streamEvent({ type: "message_start", message: {} }),
|
||||
streamEvent({
|
||||
type: "content_block_delta",
|
||||
index: 0,
|
||||
delta: { type: "text_delta", text: "hello" },
|
||||
}),
|
||||
resultMessage(),
|
||||
])
|
||||
);
|
||||
|
||||
const proc = new ClaudeSdkBackendProcess({
|
||||
pathToClaudeCodeExecutable: "/usr/local/bin/claude",
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
app: { vault: {} } as any,
|
||||
clientVersion: "1.2.3",
|
||||
descriptor: fakeDescriptor(),
|
||||
});
|
||||
|
||||
const { sessionId, state } = await proc.newSession({ cwd: "/vault", mcpServers: [] });
|
||||
expect(sessionId).toBeTruthy();
|
||||
expect(state.model?.current.baseModelId).toBe("claude-fake-pro");
|
||||
|
||||
const events: SessionEvent[] = [];
|
||||
proc.registerSessionHandler(sessionId, (e) => events.push(e));
|
||||
|
||||
const resp = await proc.prompt({
|
||||
sessionId,
|
||||
prompt: [{ type: "text", text: "hi" }],
|
||||
});
|
||||
expect(resp.stopReason).toBe("end_turn");
|
||||
|
||||
const chunks = events.filter((u) => u.update.sessionUpdate === "agent_message_chunk");
|
||||
expect(chunks).toHaveLength(1);
|
||||
const chunk = chunks[0].update;
|
||||
if (chunk.sessionUpdate === "agent_message_chunk" && chunk.content.type === "text") {
|
||||
expect(chunk.content.text).toBe("hello");
|
||||
} else {
|
||||
throw new Error("expected agent_message_chunk text update");
|
||||
}
|
||||
|
||||
const promptCalls = getPromptQueryCalls();
|
||||
expect(promptCalls).toHaveLength(1);
|
||||
const call = promptCalls[0][0] as { options: Record<string, unknown> };
|
||||
expect(call.options.pathToClaudeCodeExecutable).toBe("/usr/local/bin/claude");
|
||||
expect(Object.keys(call.options.mcpServers as object)).not.toContain("obsidian-vault");
|
||||
expect(call.options.allowedTools).toEqual(["Read", "Write", "Edit", "Glob", "Grep", "LS"]);
|
||||
expect(call.options.disallowedTools).toBeUndefined();
|
||||
// First turn → sessionId is seeded, no resume.
|
||||
expect(call.options.sessionId).toBe(sessionId);
|
||||
expect(call.options.resume).toBeUndefined();
|
||||
// No skill-creation directive opt passed → no systemPrompt override.
|
||||
expect(call.options.systemPrompt).toBeUndefined();
|
||||
});
|
||||
|
||||
it("forwards the spawn-time skill-creation directive via systemPrompt append", async () => {
|
||||
queryMock.mockImplementation(() =>
|
||||
makeQuery([streamEvent({ type: "message_start", message: {} }), resultMessage()])
|
||||
);
|
||||
|
||||
const proc = new ClaudeSdkBackendProcess({
|
||||
pathToClaudeCodeExecutable: "/usr/local/bin/claude",
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
app: { vault: {} } as any,
|
||||
clientVersion: "1.2.3",
|
||||
descriptor: fakeDescriptor(),
|
||||
getSkillCreationDirective: () => "DO THIS THING WITH SKILLS",
|
||||
});
|
||||
|
||||
const { sessionId } = await proc.newSession({ cwd: "/vault", mcpServers: [] });
|
||||
await proc.prompt({ sessionId, prompt: [{ type: "text", text: "hi" }] });
|
||||
|
||||
const calls = getPromptQueryCalls();
|
||||
const opts = (calls[0][0] as { options: Record<string, unknown> }).options;
|
||||
expect(opts.systemPrompt).toEqual({
|
||||
type: "preset",
|
||||
preset: "claude_code",
|
||||
append: "DO THIS THING WITH SKILLS",
|
||||
});
|
||||
});
|
||||
|
||||
it("captures the directive at newSession time and ignores later setting changes mid-session", async () => {
|
||||
queryMock.mockImplementation(() =>
|
||||
makeQuery([streamEvent({ type: "message_start", message: {} }), resultMessage()])
|
||||
);
|
||||
|
||||
let current = "FIRST DIRECTIVE";
|
||||
const proc = new ClaudeSdkBackendProcess({
|
||||
pathToClaudeCodeExecutable: "/usr/local/bin/claude",
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
app: { vault: {} } as any,
|
||||
clientVersion: "1.2.3",
|
||||
descriptor: fakeDescriptor(),
|
||||
getSkillCreationDirective: () => current,
|
||||
});
|
||||
|
||||
const { sessionId } = await proc.newSession({ cwd: "/vault", mcpServers: [] });
|
||||
// Mutate the "setting" after newSession → the session's first turn must
|
||||
// still use the original directive, proving capture-at-spawn semantics.
|
||||
current = "SECOND DIRECTIVE";
|
||||
await proc.prompt({ sessionId, prompt: [{ type: "text", text: "hi" }] });
|
||||
|
||||
const opts = (getPromptQueryCalls()[0][0] as { options: Record<string, unknown> }).options;
|
||||
expect(opts.systemPrompt).toEqual({
|
||||
type: "preset",
|
||||
preset: "claude_code",
|
||||
append: "FIRST DIRECTIVE",
|
||||
});
|
||||
});
|
||||
|
||||
it("buffers events emitted before a session handler is registered and replays them", async () => {
|
||||
queryMock.mockImplementation(() =>
|
||||
makeQuery([
|
||||
streamEvent({ type: "message_start", message: {} }),
|
||||
streamEvent({
|
||||
type: "content_block_delta",
|
||||
index: 0,
|
||||
delta: { type: "text_delta", text: "buffered" },
|
||||
}),
|
||||
resultMessage(),
|
||||
])
|
||||
);
|
||||
|
||||
const proc = new ClaudeSdkBackendProcess({
|
||||
pathToClaudeCodeExecutable: "/usr/local/bin/claude",
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
app: { vault: {} } as any,
|
||||
clientVersion: "1.2.3",
|
||||
descriptor: fakeDescriptor(),
|
||||
});
|
||||
|
||||
const { sessionId } = await proc.newSession({ cwd: "/vault", mcpServers: [] });
|
||||
// Kick off prompt without a handler — events are buffered.
|
||||
const promptPromise = proc.prompt({
|
||||
sessionId,
|
||||
prompt: [{ type: "text", text: "hi" }],
|
||||
});
|
||||
|
||||
const seen: SessionEvent[] = [];
|
||||
proc.registerSessionHandler(sessionId, (e) => seen.push(e));
|
||||
await promptPromise;
|
||||
|
||||
const chunks = seen.filter((u) => u.update.sessionUpdate === "agent_message_chunk");
|
||||
expect(chunks.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("passes resume on the second prompt for the same session", async () => {
|
||||
queryMock.mockImplementation(() => makeQuery([resultMessage()]));
|
||||
|
||||
const proc = new ClaudeSdkBackendProcess({
|
||||
pathToClaudeCodeExecutable: "/usr/local/bin/claude",
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
app: { vault: {} } as any,
|
||||
clientVersion: "1.2.3",
|
||||
descriptor: fakeDescriptor(),
|
||||
});
|
||||
|
||||
const { sessionId } = await proc.newSession({ cwd: "/vault", mcpServers: [] });
|
||||
proc.registerSessionHandler(sessionId, () => {});
|
||||
|
||||
await proc.prompt({ sessionId, prompt: [{ type: "text", text: "1" }] });
|
||||
await proc.prompt({ sessionId, prompt: [{ type: "text", text: "2" }] });
|
||||
|
||||
const promptCalls = getPromptQueryCalls();
|
||||
expect(promptCalls).toHaveLength(2);
|
||||
const second = promptCalls[1][0] as { options: Record<string, unknown> };
|
||||
expect(second.options.resume).toBe(sessionId);
|
||||
expect(second.options.sessionId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ClaudeSdkBackendProcess.newSession dynamic catalog", () => {
|
||||
beforeEach(() => {
|
||||
queryMock.mockReset();
|
||||
createSdkMcpServerMock.mockClear();
|
||||
});
|
||||
|
||||
it("returns BackendState with current model + effort options from the cached catalog", async () => {
|
||||
const proc = new ClaudeSdkBackendProcess({
|
||||
pathToClaudeCodeExecutable: "/usr/local/bin/claude",
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
app: { vault: {} } as any,
|
||||
clientVersion: "1.2.3",
|
||||
descriptor: fakeDescriptor(),
|
||||
});
|
||||
|
||||
const resp = await proc.newSession({ cwd: "/vault", mcpServers: [] });
|
||||
expect(resp.state.model?.current.baseModelId).toBe("claude-fake-pro");
|
||||
const ids = resp.state.model?.availableModels.map((m) => m.baseModelId);
|
||||
expect(ids).toContain("claude-fake-pro");
|
||||
expect(ids).toContain("claude-fake-mini");
|
||||
const proEffort = resp.state.model?.availableModels
|
||||
.find((m) => m.baseModelId === "claude-fake-pro")
|
||||
?.effortOptions.map((o) => o.value);
|
||||
expect(proEffort).toEqual(["low", "medium", "high"]);
|
||||
});
|
||||
|
||||
it("honors persisted default model when it appears in the catalog", async () => {
|
||||
const proc = new ClaudeSdkBackendProcess({
|
||||
pathToClaudeCodeExecutable: "/usr/local/bin/claude",
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
app: { vault: {} } as any,
|
||||
clientVersion: "1.2.3",
|
||||
descriptor: fakeDescriptor(),
|
||||
getDefaultModelId: () => "claude-fake-mini",
|
||||
});
|
||||
|
||||
const resp = await proc.newSession({ cwd: "/vault", mcpServers: [] });
|
||||
expect(resp.state.model?.current.baseModelId).toBe("claude-fake-mini");
|
||||
const miniEffort = resp.state.model?.availableModels.find(
|
||||
(m) => m.baseModelId === "claude-fake-mini"
|
||||
)?.effortOptions;
|
||||
expect(miniEffort).toEqual([]);
|
||||
});
|
||||
|
||||
it("falls back to catalog default when the default model is gone", async () => {
|
||||
const proc = new ClaudeSdkBackendProcess({
|
||||
pathToClaudeCodeExecutable: "/usr/local/bin/claude",
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
app: { vault: {} } as any,
|
||||
clientVersion: "1.2.3",
|
||||
descriptor: fakeDescriptor(),
|
||||
getDefaultModelId: () => "claude-removed-by-cli-upgrade",
|
||||
});
|
||||
|
||||
const resp = await proc.newSession({ cwd: "/vault", mcpServers: [] });
|
||||
expect(resp.state.model?.current.baseModelId).toBe("claude-fake-pro");
|
||||
});
|
||||
|
||||
it("seeds session.model so prompt() sends options.model", async () => {
|
||||
queryMock.mockImplementation(() => makeQuery([resultMessage()]));
|
||||
const proc = new ClaudeSdkBackendProcess({
|
||||
pathToClaudeCodeExecutable: "/usr/local/bin/claude",
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
app: { vault: {} } as any,
|
||||
clientVersion: "1.2.3",
|
||||
descriptor: fakeDescriptor(),
|
||||
});
|
||||
|
||||
const { sessionId } = await proc.newSession({ cwd: "/vault", mcpServers: [] });
|
||||
proc.registerSessionHandler(sessionId, () => {});
|
||||
await proc.prompt({ sessionId, prompt: [{ type: "text", text: "hi" }] });
|
||||
|
||||
const promptCalls = getPromptQueryCalls();
|
||||
expect(promptCalls).toHaveLength(1);
|
||||
const call = promptCalls[0][0] as { options: { model?: string } };
|
||||
expect(call.options.model).toBe("claude-fake-pro");
|
||||
});
|
||||
|
||||
it("setSessionConfigOption('effort', …) clamps + persists the level on the session", async () => {
|
||||
queryMock.mockImplementation(() => makeQuery([resultMessage()]));
|
||||
const proc = new ClaudeSdkBackendProcess({
|
||||
pathToClaudeCodeExecutable: "/usr/local/bin/claude",
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
app: { vault: {} } as any,
|
||||
clientVersion: "1.2.3",
|
||||
descriptor: fakeDescriptor(),
|
||||
});
|
||||
|
||||
const { sessionId } = await proc.newSession({ cwd: "/vault", mcpServers: [] });
|
||||
proc.registerSessionHandler(sessionId, () => {});
|
||||
const stateAfter = await proc.setSessionConfigOption({
|
||||
sessionId,
|
||||
configId: "effort",
|
||||
value: "high",
|
||||
});
|
||||
expect(stateAfter.model?.current.effort).toBe("high");
|
||||
|
||||
await proc.prompt({ sessionId, prompt: [{ type: "text", text: "hi" }] });
|
||||
const promptCalls = getPromptQueryCalls();
|
||||
expect(promptCalls).toHaveLength(1);
|
||||
const call = promptCalls[0][0] as { options: { effort?: string } };
|
||||
expect(call.options.effort).toBe("high");
|
||||
});
|
||||
});
|
||||
622
src/agentMode/sdk/ClaudeSdkBackendProcess.ts
Normal file
622
src/agentMode/sdk/ClaudeSdkBackendProcess.ts
Normal file
|
|
@ -0,0 +1,622 @@
|
|||
/**
|
||||
* In-process driver for the Claude Agent SDK that implements `BackendProcess`,
|
||||
* the same interface `AgentSession` consumes for ACP backends. Every SDK
|
||||
* message is translated to a session-domain `SessionEvent` and dispatched to
|
||||
* the per-session handler. From `AgentSession`'s perspective there's no
|
||||
* difference between this adapter and `AcpBackendProcess`.
|
||||
*
|
||||
* Lifecycle differs from ACP: there's no long-lived subprocess. Each
|
||||
* `prompt()` call starts a fresh `query()` (with `resume: <sessionId>` after
|
||||
* the first turn so the SDK loads prior conversation state).
|
||||
*/
|
||||
import { logError, logInfo, logWarn } from "@/logger";
|
||||
import { err2String } from "@/utils";
|
||||
import {
|
||||
query,
|
||||
type EffortLevel,
|
||||
type McpServerConfig,
|
||||
type ModelInfo,
|
||||
type Options,
|
||||
type PermissionMode,
|
||||
type Query,
|
||||
type SDKUserMessage,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
import { App } from "obsidian";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { translateBackendState } from "@/agentMode/session/translateBackendState";
|
||||
import type {
|
||||
BackendConfigOption,
|
||||
BackendDescriptor,
|
||||
BackendProcess,
|
||||
RawModelState,
|
||||
RawModeState,
|
||||
BackendState,
|
||||
CancelInput,
|
||||
ListSessionsInput,
|
||||
ListSessionsOutput,
|
||||
LoadSessionInput,
|
||||
LoadSessionOutput,
|
||||
McpServerSpec,
|
||||
OpenSessionInput,
|
||||
OpenSessionOutput,
|
||||
PermissionDecision,
|
||||
PermissionPrompt,
|
||||
PromptInput,
|
||||
PromptOutput,
|
||||
ResumeSessionInput,
|
||||
ResumeSessionOutput,
|
||||
SessionEvent,
|
||||
SessionId,
|
||||
SessionUpdateHandler,
|
||||
StopReason,
|
||||
} from "@/agentMode/session/types";
|
||||
import { MethodUnsupportedError } from "@/agentMode/session/errors";
|
||||
import { createTranslatorState, mapStopReason, translateSdkMessage } from "./sdkMessageTranslator";
|
||||
import { PermissionBridge, type AskUserQuestionHandler } from "./permissionBridge";
|
||||
import {
|
||||
getCachedSdkCatalog,
|
||||
probeClaudeSdkCatalog,
|
||||
resolveSeedModelId,
|
||||
synthesizeEffortConfigOption,
|
||||
} from "./effortOption";
|
||||
import {
|
||||
describeSdkMessage,
|
||||
logSdkError,
|
||||
logSdkInbound,
|
||||
logSdkOutbound,
|
||||
logSdkOutboundResult,
|
||||
} from "./sdkDebugTap";
|
||||
|
||||
interface SessionState {
|
||||
cwd: string | null;
|
||||
/**
|
||||
* Drives whether the next `query()` passes `resume: <sessionId>` (continue
|
||||
* the persisted conversation) or `sessionId: <ourId>` (mint a new SDK-side
|
||||
* session with our pre-allocated id).
|
||||
*/
|
||||
firstPromptStarted: boolean;
|
||||
model?: string;
|
||||
permissionMode?: PermissionMode;
|
||||
/**
|
||||
* Effort tier passed to `query()`'s `options.effort` on the next prompt.
|
||||
* The vocabulary is per-model — the runtime catalog
|
||||
* (`ModelInfo.supportedEffortLevels`) is the source of truth and is
|
||||
* pulled via `ensureModelCatalog()`.
|
||||
*/
|
||||
effort?: EffortLevel;
|
||||
mcpServers: Record<string, McpServerConfig>;
|
||||
active?: Query;
|
||||
/**
|
||||
* Snapshot of the skill-creation system-prompt directive captured at
|
||||
* `newSession()` time so the setting change takes effect on the next
|
||||
* session rather than mid-conversation. Empty string = no directive.
|
||||
* Appended to Claude's default `claude_code` preset via
|
||||
* `options.systemPrompt.append`.
|
||||
*/
|
||||
systemPromptAppend: string;
|
||||
}
|
||||
|
||||
export interface ClaudeSdkBackendProcessOptions {
|
||||
pathToClaudeCodeExecutable: string;
|
||||
app: App;
|
||||
clientVersion: string;
|
||||
descriptor: BackendDescriptor;
|
||||
askUserQuestion?: AskUserQuestionHandler;
|
||||
/**
|
||||
* Read at the start of every `prompt()` so a settings change live-applies on
|
||||
* the next turn.
|
||||
*/
|
||||
getEnableThinking?: () => boolean;
|
||||
/**
|
||||
* Predicate identifying plan-mode plan files (e.g. `~/.claude/plans/*.md`).
|
||||
* When set, `Write` calls targeting these paths are auto-allowed via
|
||||
* `canUseTool`; every other `Write` is routed through the permission
|
||||
* prompter like any other tool.
|
||||
*/
|
||||
isPlanModePlanFilePath?: (absolutePath: string) => boolean;
|
||||
/**
|
||||
* Returns the user's persisted model preference. Read at session start
|
||||
* to seed `session.model` from the live catalog (so the SDK uses what
|
||||
* the picker shows, instead of falling back to its own internal default).
|
||||
*/
|
||||
getDefaultModelId?: () => string | undefined;
|
||||
/**
|
||||
* Returns the spawn-time skill-creation directive to append to Claude's
|
||||
* default `claude_code` system prompt. Read once per `newSession()` so
|
||||
* a settings change applies to the next session rather than mid-turn.
|
||||
* Empty string / undefined disables the append.
|
||||
*/
|
||||
getSkillCreationDirective?: () => string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Static mode catalog for the Claude SDK. `acceptEdits` and `dontAsk`
|
||||
* are intentionally excluded from the picker.
|
||||
*/
|
||||
const STATIC_SDK_MODES: RawModeState = {
|
||||
currentModeId: "default",
|
||||
availableModes: [
|
||||
{ id: "default", name: "Default" },
|
||||
{ id: "plan", name: "Plan" },
|
||||
{ id: "bypassPermissions", name: "Auto" },
|
||||
],
|
||||
};
|
||||
|
||||
export class ClaudeSdkBackendProcess implements BackendProcess {
|
||||
private readonly sessionHandlers = new Map<SessionId, SessionUpdateHandler>();
|
||||
private readonly pendingUpdates = new Map<SessionId, SessionEvent[]>();
|
||||
private static readonly PENDING_UPDATE_LIMIT = 32;
|
||||
private readonly sessions = new Map<SessionId, SessionState>();
|
||||
private permissionPrompter: ((req: PermissionPrompt) => Promise<PermissionDecision>) | null =
|
||||
null;
|
||||
private exitListeners = new Set<() => void>();
|
||||
private shuttingDown = false;
|
||||
private readonly bridge: PermissionBridge;
|
||||
/**
|
||||
* Process-scoped cache of the SDK's model catalog. Populated lazily by
|
||||
* `ensureModelCatalog()` so we only spawn one extra `claude` subprocess
|
||||
* per backend lifetime.
|
||||
*/
|
||||
private cachedModels: ModelInfo[] | null = null;
|
||||
private cachedModelsProbe: Promise<ModelInfo[]> | null = null;
|
||||
|
||||
constructor(private readonly opts: ClaudeSdkBackendProcessOptions) {
|
||||
this.bridge = new PermissionBridge({
|
||||
getPrompter: () => this.permissionPrompter,
|
||||
askUserQuestion: opts.askUserQuestion,
|
||||
isPlanModePlanFilePath: opts.isPlanModePlanFilePath,
|
||||
});
|
||||
logInfo(
|
||||
`[AgentMode] ClaudeSdkBackendProcess constructed (claude=${opts.pathToClaudeCodeExecutable})`
|
||||
);
|
||||
}
|
||||
|
||||
isRunning(): boolean {
|
||||
return !this.shuttingDown;
|
||||
}
|
||||
|
||||
onExit(listener: () => void): () => void {
|
||||
this.exitListeners.add(listener);
|
||||
return () => this.exitListeners.delete(listener);
|
||||
}
|
||||
|
||||
setPermissionPrompter(fn: (req: PermissionPrompt) => Promise<PermissionDecision>): void {
|
||||
this.permissionPrompter = fn;
|
||||
}
|
||||
|
||||
registerSessionHandler(sessionId: SessionId, handler: SessionUpdateHandler): () => void {
|
||||
this.sessionHandlers.set(sessionId, handler);
|
||||
const buffered = this.pendingUpdates.get(sessionId);
|
||||
if (buffered) {
|
||||
this.pendingUpdates.delete(sessionId);
|
||||
for (const event of buffered) {
|
||||
try {
|
||||
handler(event);
|
||||
} catch (e) {
|
||||
logWarn(`[AgentMode] replay of buffered SDK event threw for ${sessionId}`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
return () => {
|
||||
if (this.sessionHandlers.get(sessionId) === handler) {
|
||||
this.sessionHandlers.delete(sessionId);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async newSession(params: OpenSessionInput): Promise<OpenSessionOutput> {
|
||||
logSdkOutbound("newSession", { cwd: params.cwd, mcpServers: params.mcpServers });
|
||||
const sessionId = uuidv4();
|
||||
const cwd = params.cwd ?? null;
|
||||
const mcp: Record<string, McpServerConfig> = {};
|
||||
for (const server of params.mcpServers ?? []) {
|
||||
const cfg = mcpServerSpecToSdkConfig(server);
|
||||
if (cfg) mcp[server.name] = cfg;
|
||||
}
|
||||
// Resolve the catalog before returning so the picker never sees an
|
||||
// empty model list. On a probe miss, at most one subprocess is
|
||||
// spawned (deduped via cachedModelsProbe).
|
||||
const catalog = await this.ensureModelCatalog();
|
||||
const defaultId = this.opts.getDefaultModelId?.();
|
||||
const seedModelId = resolveSeedModelId(catalog, defaultId);
|
||||
|
||||
this.sessions.set(sessionId, {
|
||||
cwd,
|
||||
firstPromptStarted: false,
|
||||
mcpServers: mcp,
|
||||
model: seedModelId,
|
||||
systemPromptAppend: this.opts.getSkillCreationDirective?.() ?? "",
|
||||
});
|
||||
|
||||
const state = this.computeState(sessionId);
|
||||
logSdkOutboundResult(
|
||||
"newSession",
|
||||
{ sessionId, currentModelId: seedModelId ?? null, hasEffort: state.model !== null },
|
||||
sessionId
|
||||
);
|
||||
return { sessionId, state };
|
||||
}
|
||||
|
||||
async prompt(params: PromptInput): Promise<PromptOutput> {
|
||||
const session = this.sessions.get(params.sessionId);
|
||||
if (!session) {
|
||||
throw new Error(`Unknown session ${params.sessionId}`);
|
||||
}
|
||||
|
||||
// Streaming-input mode (AsyncIterable) is required to expose
|
||||
// interrupt/setModel/setPermissionMode on the returned Query — without it
|
||||
// those control calls reject with "only available in streaming input mode".
|
||||
const promptText = extractPromptText(params);
|
||||
const promptStream = makePromptStream(promptText, params.sessionId);
|
||||
|
||||
this.bridge.setSessionContext(params.sessionId);
|
||||
|
||||
const options: Options = {
|
||||
pathToClaudeCodeExecutable: this.opts.pathToClaudeCodeExecutable,
|
||||
cwd: session.cwd ?? undefined,
|
||||
includePartialMessages: true,
|
||||
mcpServers: session.mcpServers,
|
||||
allowedTools: ["Read", "Write", "Edit", "Glob", "Grep", "LS"],
|
||||
canUseTool: this.bridge.canUseTool,
|
||||
};
|
||||
// Append the skill-creation directive (captured at newSession time) to
|
||||
// Claude's default `claude_code` preset. The SDK's preset+append form
|
||||
// preserves the full default system prompt; we only add the directive.
|
||||
if (session.systemPromptAppend) {
|
||||
options.systemPrompt = {
|
||||
type: "preset",
|
||||
preset: "claude_code",
|
||||
append: session.systemPromptAppend,
|
||||
};
|
||||
}
|
||||
if (session.firstPromptStarted) {
|
||||
options.resume = params.sessionId;
|
||||
} else {
|
||||
// First turn: tell the SDK to use *our* pre-allocated session id so
|
||||
// future `resume` calls match.
|
||||
options.sessionId = params.sessionId;
|
||||
}
|
||||
if (session.model) options.model = session.model;
|
||||
if (session.permissionMode) options.permissionMode = session.permissionMode;
|
||||
if (session.effort) options.effort = session.effort;
|
||||
if (this.opts.getEnableThinking?.()) {
|
||||
options.thinking = { type: "adaptive" };
|
||||
}
|
||||
|
||||
logSdkOutbound(
|
||||
"prompt",
|
||||
{
|
||||
prompt: promptText,
|
||||
resume: options.resume ?? null,
|
||||
sessionIdSeed: options.sessionId ?? null,
|
||||
model: options.model ?? null,
|
||||
permissionMode: options.permissionMode ?? null,
|
||||
effort: options.effort ?? null,
|
||||
mcpServers: Object.keys(options.mcpServers ?? {}),
|
||||
allowedTools: options.allowedTools,
|
||||
},
|
||||
params.sessionId
|
||||
);
|
||||
|
||||
const q = query({ prompt: promptStream, options });
|
||||
session.active = q;
|
||||
session.firstPromptStarted = true;
|
||||
|
||||
const translatorState = createTranslatorState();
|
||||
let stopReason: StopReason = "end_turn";
|
||||
let resultErrorMessage: string | null = null;
|
||||
try {
|
||||
for await (const sdkMsg of q) {
|
||||
if (this.shuttingDown) break;
|
||||
logSdkInbound(describeSdkMessage(sdkMsg), sdkMsg, params.sessionId);
|
||||
const events = translateSdkMessage(sdkMsg, params.sessionId, translatorState);
|
||||
for (const e of events) this.dispatchEvent(e);
|
||||
if (sdkMsg.type === "result") {
|
||||
stopReason = mapStopReason(sdkMsg);
|
||||
if (stopReason !== "end_turn" && sdkMsg.subtype !== "success") {
|
||||
const errs = "errors" in sdkMsg ? sdkMsg.errors : undefined;
|
||||
if (errs && errs.length > 0) {
|
||||
resultErrorMessage = errs.join("; ");
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (session.active === q) session.active = undefined;
|
||||
this.bridge.clearSessionContext();
|
||||
}
|
||||
|
||||
if (resultErrorMessage) {
|
||||
logSdkError("→", "prompt", { error: resultErrorMessage }, params.sessionId);
|
||||
throw new Error(resultErrorMessage);
|
||||
}
|
||||
logSdkOutboundResult("prompt", { stopReason }, params.sessionId);
|
||||
return { stopReason };
|
||||
}
|
||||
|
||||
async cancel(params: CancelInput): Promise<void> {
|
||||
logSdkOutbound("cancel", {}, params.sessionId);
|
||||
const session = this.sessions.get(params.sessionId);
|
||||
if (!session?.active) return;
|
||||
try {
|
||||
await session.active.interrupt();
|
||||
} catch (e) {
|
||||
logWarn("[AgentMode] SDK query.interrupt() threw", e);
|
||||
logSdkError("→", "interrupt", { error: err2String(e) }, params.sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
async setSessionModel(params: { sessionId: SessionId; modelId: string }): Promise<BackendState> {
|
||||
logSdkOutbound("setSessionModel", { modelId: params.modelId }, params.sessionId);
|
||||
const session = this.sessions.get(params.sessionId);
|
||||
if (!session) throw new Error(`Unknown session ${params.sessionId}`);
|
||||
session.model = params.modelId;
|
||||
if (this.cachedModels && session.effort) {
|
||||
const info = this.cachedModels.find((m) => m.value === params.modelId);
|
||||
const levels = info?.supportedEffortLevels ?? [];
|
||||
if (!levels.includes(session.effort)) {
|
||||
session.effort = levels[0];
|
||||
}
|
||||
}
|
||||
if (session.active) {
|
||||
try {
|
||||
await session.active.setModel(params.modelId);
|
||||
} catch (e) {
|
||||
logWarn("[AgentMode] SDK query.setModel() threw (will apply on next turn)", e);
|
||||
logSdkError("→", "setModel", { error: err2String(e) }, params.sessionId);
|
||||
}
|
||||
}
|
||||
const state = this.computeState(params.sessionId);
|
||||
this.dispatchStateChanged(params.sessionId, state);
|
||||
return state;
|
||||
}
|
||||
|
||||
isSetSessionModelSupported(): boolean | null {
|
||||
return true;
|
||||
}
|
||||
|
||||
async setSessionMode(params: { sessionId: SessionId; modeId: string }): Promise<BackendState> {
|
||||
logSdkOutbound("setSessionMode", { modeId: params.modeId }, params.sessionId);
|
||||
const session = this.sessions.get(params.sessionId);
|
||||
if (!session) throw new Error(`Unknown session ${params.sessionId}`);
|
||||
const mode = canonicalModeToSdk(params.modeId);
|
||||
if (!mode) {
|
||||
throw new Error(`Unsupported mode ${params.modeId}`);
|
||||
}
|
||||
session.permissionMode = mode;
|
||||
if (session.active) {
|
||||
try {
|
||||
await session.active.setPermissionMode(mode);
|
||||
} catch (e) {
|
||||
logWarn("[AgentMode] SDK query.setPermissionMode() threw (will apply on next turn)", e);
|
||||
logSdkError("→", "setPermissionMode", { error: err2String(e) }, params.sessionId);
|
||||
}
|
||||
}
|
||||
const state = this.computeState(params.sessionId);
|
||||
this.dispatchStateChanged(params.sessionId, state);
|
||||
return state;
|
||||
}
|
||||
|
||||
isSetSessionModeSupported(): boolean | null {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Only `effort` is exposed as a session config option for this backend.
|
||||
* We synthesize the option from the SDK's per-model
|
||||
* `ModelInfo.supportedEffortLevels`, store the pick on the session, and
|
||||
* apply it as `options.effort` on the next `query()` — the SDK has no
|
||||
* runtime RPC for changing effort mid-turn.
|
||||
*/
|
||||
async setSessionConfigOption(params: {
|
||||
sessionId: SessionId;
|
||||
configId: string;
|
||||
value: string;
|
||||
}): Promise<BackendState> {
|
||||
logSdkOutbound(
|
||||
"setSessionConfigOption",
|
||||
{ configId: params.configId, value: params.value },
|
||||
params.sessionId
|
||||
);
|
||||
if (params.configId !== "effort") {
|
||||
throw new MethodUnsupportedError("session/set_config_option");
|
||||
}
|
||||
const session = this.sessions.get(params.sessionId);
|
||||
if (!session) throw new Error(`Unknown session ${params.sessionId}`);
|
||||
const models = await this.ensureModelCatalog();
|
||||
const modelInfo = models.find((m) => m.value === session.model);
|
||||
const levels = modelInfo?.supportedEffortLevels ?? [];
|
||||
if (!levels.includes(params.value as EffortLevel)) {
|
||||
throw new Error(
|
||||
`Effort '${params.value}' not supported by ${session.model ?? "default model"}`
|
||||
);
|
||||
}
|
||||
session.effort = params.value as EffortLevel;
|
||||
const state = this.computeState(params.sessionId);
|
||||
this.dispatchStateChanged(params.sessionId, state);
|
||||
return state;
|
||||
}
|
||||
|
||||
isSetSessionConfigOptionSupported(): boolean | null {
|
||||
return true;
|
||||
}
|
||||
|
||||
async listSessions(_params: ListSessionsInput): Promise<ListSessionsOutput> {
|
||||
throw new MethodUnsupportedError("session/list");
|
||||
}
|
||||
|
||||
async resumeSession(_params: ResumeSessionInput): Promise<ResumeSessionOutput> {
|
||||
throw new MethodUnsupportedError("session/resume");
|
||||
}
|
||||
|
||||
async loadSession(_params: LoadSessionInput): Promise<LoadSessionOutput> {
|
||||
throw new MethodUnsupportedError("session/load");
|
||||
}
|
||||
|
||||
supportsMcpTransport(_transport: "http" | "sse"): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
this.shuttingDown = true;
|
||||
for (const session of this.sessions.values()) {
|
||||
const q = session.active;
|
||||
if (!q) continue;
|
||||
try {
|
||||
await q.interrupt();
|
||||
} catch (e) {
|
||||
logWarn("[AgentMode] interrupt during shutdown threw", e);
|
||||
}
|
||||
}
|
||||
this.sessions.clear();
|
||||
this.sessionHandlers.clear();
|
||||
this.pendingUpdates.clear();
|
||||
for (const fn of this.exitListeners) {
|
||||
try {
|
||||
fn();
|
||||
} catch (e) {
|
||||
logWarn("[AgentMode] SDK exit listener threw", e);
|
||||
}
|
||||
}
|
||||
this.exitListeners.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the SDK's model catalog. Falls back to an on-demand probe
|
||||
* only when the shared cache is cold; at most one subprocess is
|
||||
* spawned per backend lifetime (deduped via `cachedModelsProbe`).
|
||||
* Failures resolve to `[]` so callers degrade gracefully.
|
||||
*/
|
||||
private ensureModelCatalog(): Promise<ModelInfo[]> {
|
||||
if (this.cachedModels) return Promise.resolve(this.cachedModels);
|
||||
const fromCache = getCachedSdkCatalog();
|
||||
if (fromCache && fromCache.length > 0) {
|
||||
this.cachedModels = fromCache;
|
||||
return Promise.resolve(fromCache);
|
||||
}
|
||||
if (this.cachedModelsProbe) return this.cachedModelsProbe;
|
||||
const probePromise = probeClaudeSdkCatalog(this.opts.pathToClaudeCodeExecutable).then(
|
||||
(models) => {
|
||||
if (models.length > 0) this.cachedModels = models;
|
||||
else this.cachedModelsProbe = null;
|
||||
return models;
|
||||
}
|
||||
);
|
||||
this.cachedModelsProbe = probePromise;
|
||||
return probePromise;
|
||||
}
|
||||
|
||||
private computeState(sessionId: SessionId): BackendState {
|
||||
const session = this.sessions.get(sessionId);
|
||||
const catalog = this.cachedModels ?? [];
|
||||
const seedModel = session?.model;
|
||||
const models: RawModelState | null =
|
||||
catalog.length > 0 && seedModel
|
||||
? {
|
||||
currentModelId: seedModel,
|
||||
availableModels: catalog.map((m) => ({ modelId: m.value, name: m.displayName })),
|
||||
}
|
||||
: null;
|
||||
const modes: RawModeState = {
|
||||
...STATIC_SDK_MODES,
|
||||
currentModeId: session?.permissionMode ?? STATIC_SDK_MODES.currentModeId,
|
||||
availableModes: [...STATIC_SDK_MODES.availableModes],
|
||||
};
|
||||
const modelInfo = seedModel ? catalog.find((m) => m.value === seedModel) : undefined;
|
||||
const effortOpt = synthesizeEffortConfigOption(modelInfo, session?.effort);
|
||||
const configOptions: BackendConfigOption[] | null = effortOpt ? [effortOpt] : null;
|
||||
return translateBackendState({ models, modes, configOptions }, this.opts.descriptor);
|
||||
}
|
||||
|
||||
private dispatchStateChanged(sessionId: SessionId, state: BackendState): void {
|
||||
this.dispatchEvent({
|
||||
sessionId,
|
||||
update: { sessionUpdate: "state_changed", state },
|
||||
});
|
||||
}
|
||||
|
||||
private dispatchEvent(event: SessionEvent): void {
|
||||
const handler = this.sessionHandlers.get(event.sessionId);
|
||||
if (!handler) {
|
||||
let queue = this.pendingUpdates.get(event.sessionId);
|
||||
if (!queue) {
|
||||
queue = [];
|
||||
this.pendingUpdates.set(event.sessionId, queue);
|
||||
}
|
||||
if (queue.length >= ClaudeSdkBackendProcess.PENDING_UPDATE_LIMIT) {
|
||||
const kind = event.update.sessionUpdate;
|
||||
logWarn(
|
||||
`[AgentMode] dropping SDK event for ${event.sessionId}: pending buffer full (${queue.length}, kind=${kind})`
|
||||
);
|
||||
return;
|
||||
}
|
||||
queue.push(event);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
handler(event);
|
||||
} catch (e) {
|
||||
logError(`[AgentMode] SDK event handler threw for ${event.sessionId}`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function extractPromptText(req: PromptInput): string {
|
||||
const parts: string[] = [];
|
||||
for (const block of req.prompt) {
|
||||
if (block.type === "text" && block.text.length > 0) parts.push(block.text);
|
||||
}
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
async function* makePromptStream(
|
||||
text: string,
|
||||
sessionId: SessionId
|
||||
): AsyncIterable<SDKUserMessage> {
|
||||
yield {
|
||||
type: "user",
|
||||
message: { role: "user", content: text },
|
||||
parent_tool_use_id: null,
|
||||
session_id: sessionId,
|
||||
};
|
||||
}
|
||||
|
||||
function mcpServerSpecToSdkConfig(server: McpServerSpec): McpServerConfig | null {
|
||||
if ("type" in server && server.type === "http") {
|
||||
return { type: "http", url: server.url, headers: kvListToRecord(server.headers) };
|
||||
}
|
||||
if ("type" in server && server.type === "sse") {
|
||||
return { type: "sse", url: server.url, headers: kvListToRecord(server.headers) };
|
||||
}
|
||||
if ("command" in server) {
|
||||
return {
|
||||
type: "stdio",
|
||||
command: server.command,
|
||||
args: server.args ?? [],
|
||||
env: kvListToRecord(server.env),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function kvListToRecord(
|
||||
list: Array<{ name: string; value: string }> | undefined
|
||||
): Record<string, string> | undefined {
|
||||
if (!list || list.length === 0) return undefined;
|
||||
const out: Record<string, string> = {};
|
||||
for (const { name, value } of list) out[name] = value;
|
||||
return out;
|
||||
}
|
||||
|
||||
function canonicalModeToSdk(modeId: string): PermissionMode | null {
|
||||
switch (modeId) {
|
||||
case "default":
|
||||
case "acceptEdits":
|
||||
case "bypassPermissions":
|
||||
case "plan":
|
||||
return modeId;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
105
src/agentMode/sdk/effortOption.ts
Normal file
105
src/agentMode/sdk/effortOption.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import type { BackendConfigOption } from "@/agentMode/session/types";
|
||||
import {
|
||||
query,
|
||||
type EffortLevel,
|
||||
type ModelInfo,
|
||||
type SDKUserMessage,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
import { logWarn } from "@/logger";
|
||||
|
||||
/**
|
||||
* Build a single-select effort `BackendConfigOption` from a model's
|
||||
* advertised `supportedEffortLevels`. Returns `null` when the model
|
||||
* doesn't support effort or the SDK reports an empty list.
|
||||
*
|
||||
* The category is `"thought_level"` (the spec-conformant
|
||||
* `SessionConfigOptionCategory` reserved name) so future ACP-aware UIs
|
||||
* recognize it.
|
||||
*/
|
||||
export function synthesizeEffortConfigOption(
|
||||
modelInfo: ModelInfo | undefined,
|
||||
currentEffort: EffortLevel | undefined
|
||||
): BackendConfigOption | null {
|
||||
const levels = modelInfo?.supportsEffort ? (modelInfo.supportedEffortLevels ?? []) : [];
|
||||
if (levels.length === 0) return null;
|
||||
const value = currentEffort && levels.includes(currentEffort) ? currentEffort : levels[0];
|
||||
return {
|
||||
id: "effort",
|
||||
type: "select",
|
||||
category: "thought_level",
|
||||
name: "Effort",
|
||||
currentValue: value,
|
||||
options: levels.map((v) => ({ value: v, name: v })),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the model id to seed a session with. Honors a persisted preference
|
||||
* when it still appears in the live catalog (CLI revs can drop/rename
|
||||
* models); falls back to the first catalog entry. Returns `undefined` when
|
||||
* the catalog is empty — callers then send no `options.model` and the SDK
|
||||
* uses its default.
|
||||
*/
|
||||
export function resolveSeedModelId(
|
||||
catalog: ModelInfo[],
|
||||
defaultId: string | undefined
|
||||
): string | undefined {
|
||||
if (defaultId && catalog.some((m) => m.value === defaultId)) return defaultId;
|
||||
if (defaultId) {
|
||||
logWarn(
|
||||
`[AgentMode] persisted Claude model "${defaultId}" not in live catalog; falling back to default`
|
||||
);
|
||||
}
|
||||
return catalog[0]?.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin-lifetime cache of the SDK's model catalog, shared across every
|
||||
* `ClaudeSdkBackendProcess` instance so opening a chat doesn't re-spawn
|
||||
* the `claude` CLI to read the model list.
|
||||
*/
|
||||
let cachedSdkCatalog: ModelInfo[] | null = null;
|
||||
|
||||
export function getCachedSdkCatalog(): ModelInfo[] | null {
|
||||
return cachedSdkCatalog;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn a one-shot SDK `query()` solely to read its initialization
|
||||
* handshake — which carries the catalog of models the bundled `claude`
|
||||
* CLI advertises (per-model `supportsEffort` + `supportedEffortLevels`).
|
||||
*
|
||||
* The SDK requires streaming-input mode to expose `initializationResult()`,
|
||||
* so we feed it a generator that never yields and tear the query down
|
||||
* via `interrupt()` once the handshake completes. Failures resolve to
|
||||
* an empty array (logged) so callers can degrade gracefully.
|
||||
*
|
||||
* Successful, non-empty probes update the module-level cache so a later
|
||||
* `getCachedSdkCatalog()` returns hot data without re-probing.
|
||||
*/
|
||||
export async function probeClaudeSdkCatalog(
|
||||
pathToClaudeCodeExecutable: string
|
||||
): Promise<ModelInfo[]> {
|
||||
// eslint-disable-next-line require-yield
|
||||
const noopPrompt = (async function* (): AsyncIterable<SDKUserMessage> {
|
||||
await new Promise<void>(() => {});
|
||||
})();
|
||||
const probe = query({
|
||||
prompt: noopPrompt,
|
||||
options: { pathToClaudeCodeExecutable },
|
||||
});
|
||||
try {
|
||||
const init = await probe.initializationResult();
|
||||
if (init.models.length > 0) cachedSdkCatalog = init.models;
|
||||
return init.models;
|
||||
} catch (e) {
|
||||
logWarn("[AgentMode] Claude SDK init probe failed", e);
|
||||
return [];
|
||||
} finally {
|
||||
try {
|
||||
await probe.interrupt();
|
||||
} catch {
|
||||
// Probe is being torn down; swallow.
|
||||
}
|
||||
}
|
||||
}
|
||||
279
src/agentMode/sdk/permissionBridge.test.ts
Normal file
279
src/agentMode/sdk/permissionBridge.test.ts
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
import type { PermissionDecision, PermissionPrompt } from "@/agentMode/session/types";
|
||||
import { PermissionBridge } from "./permissionBridge";
|
||||
|
||||
describe("PermissionBridge.canUseTool", () => {
|
||||
function makeBridge(
|
||||
prompter: ((req: PermissionPrompt) => Promise<PermissionDecision>) | null,
|
||||
askUserQuestion?: (
|
||||
questions: Array<{ question: string; options: Array<{ label: string }> }>
|
||||
) => Promise<{ [q: string]: string }>
|
||||
) {
|
||||
const bridge = new PermissionBridge({
|
||||
getPrompter: () => prompter,
|
||||
|
||||
askUserQuestion: askUserQuestion,
|
||||
});
|
||||
bridge.setSessionContext("session-1");
|
||||
return bridge;
|
||||
}
|
||||
|
||||
const ctx = {
|
||||
signal: new AbortController().signal,
|
||||
toolUseID: "toolu_test_id",
|
||||
} as unknown as Parameters<PermissionBridge["canUseTool"]>[2];
|
||||
|
||||
it("denies when no prompter is registered", async () => {
|
||||
const bridge = new PermissionBridge({ getPrompter: () => null });
|
||||
bridge.setSessionContext("session-1");
|
||||
const result = await bridge.canUseTool("Edit", { file_path: "a.md" }, ctx);
|
||||
expect(result.behavior).toBe("deny");
|
||||
});
|
||||
|
||||
it("synthesizes a PermissionPrompt with kind from toolName", async () => {
|
||||
let captured: PermissionPrompt | null = null;
|
||||
const bridge = makeBridge(async (req) => {
|
||||
captured = req;
|
||||
return { outcome: { outcome: "selected", optionId: "allow_once" } };
|
||||
});
|
||||
await bridge.canUseTool("Edit", { file_path: "a.md" }, ctx);
|
||||
expect(captured).not.toBeNull();
|
||||
expect(captured!.toolCall.kind).toBe("edit");
|
||||
expect(captured!.toolCall.rawInput).toEqual({ file_path: "a.md" });
|
||||
expect(captured!.options.map((o) => o.kind)).toEqual([
|
||||
"allow_once",
|
||||
"allow_always",
|
||||
"reject_once",
|
||||
"reject_always",
|
||||
]);
|
||||
});
|
||||
|
||||
it("propagates ctx.toolUseID as PermissionPrompt.toolCall.toolCallId", async () => {
|
||||
// The trail UI pairs each permission prompt with the corresponding
|
||||
// `tool_call` notification by id. If the bridge mints a fresh uuid
|
||||
// here instead of reusing the SDK's `tool_use_id`, the prompt and the
|
||||
// notification disagree and the action card cannot be resolved.
|
||||
let captured: PermissionPrompt | null = null;
|
||||
const bridge = makeBridge(async (req) => {
|
||||
captured = req;
|
||||
return { outcome: { outcome: "selected", optionId: "reject_once" } };
|
||||
});
|
||||
const ctxWithToolUse = {
|
||||
signal: new AbortController().signal,
|
||||
toolUseID: "toolu_abc123",
|
||||
} as unknown as Parameters<PermissionBridge["canUseTool"]>[2];
|
||||
await bridge.canUseTool("Edit", { file_path: "a.md" }, ctxWithToolUse);
|
||||
expect(captured).not.toBeNull();
|
||||
expect(captured!.toolCall.toolCallId).toBe("toolu_abc123");
|
||||
});
|
||||
|
||||
it("maps allow_once to allow with updatedInput echoing the original input", async () => {
|
||||
const bridge = makeBridge(async () => ({
|
||||
outcome: { outcome: "selected", optionId: "allow_once" },
|
||||
}));
|
||||
const result = await bridge.canUseTool("Bash", { command: "ls" }, ctx);
|
||||
expect(result).toEqual({ behavior: "allow", updatedInput: { command: "ls" } });
|
||||
});
|
||||
|
||||
it("maps allow_always with suggestions to allow + updatedInput + updatedPermissions", async () => {
|
||||
const bridge = makeBridge(async () => ({
|
||||
outcome: { outcome: "selected", optionId: "allow_always" },
|
||||
}));
|
||||
const ctxWithSuggestions = {
|
||||
signal: new AbortController().signal,
|
||||
suggestions: [
|
||||
{
|
||||
type: "addRules",
|
||||
rules: [{ toolName: "Bash" }],
|
||||
behavior: "allow",
|
||||
destination: "session",
|
||||
} as unknown,
|
||||
],
|
||||
} as unknown as Parameters<PermissionBridge["canUseTool"]>[2];
|
||||
const result = await bridge.canUseTool("Bash", { command: "ls" }, ctxWithSuggestions);
|
||||
expect(result.behavior).toBe("allow");
|
||||
if (result.behavior === "allow") {
|
||||
expect(result.updatedInput).toEqual({ command: "ls" });
|
||||
expect(result.updatedPermissions).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("maps reject_once to deny with a message", async () => {
|
||||
const bridge = makeBridge(async () => ({
|
||||
outcome: { outcome: "selected", optionId: "reject_once" },
|
||||
}));
|
||||
const result = await bridge.canUseTool("Bash", { command: "ls" }, ctx);
|
||||
expect(result.behavior).toBe("deny");
|
||||
if (result.behavior === "deny") expect(result.message).toContain("declined");
|
||||
});
|
||||
|
||||
it("forwards decision.denyMessage as the deny message on reject", async () => {
|
||||
const bridge = makeBridge(async () => ({
|
||||
outcome: { outcome: "selected", optionId: "reject_once" },
|
||||
denyMessage: "Please drop the second step and only do step 1.",
|
||||
}));
|
||||
const result = await bridge.canUseTool("ExitPlanMode", { plan: "# x" }, ctx);
|
||||
expect(result.behavior).toBe("deny");
|
||||
if (result.behavior === "deny") {
|
||||
expect(result.message).toBe("Please drop the second step and only do step 1.");
|
||||
}
|
||||
});
|
||||
|
||||
it("ignores denyMessage when the decision is allow", async () => {
|
||||
const bridge = makeBridge(async () => ({
|
||||
outcome: { outcome: "selected", optionId: "allow_once" },
|
||||
denyMessage: "this should be ignored",
|
||||
}));
|
||||
const result = await bridge.canUseTool("Bash", { command: "ls" }, ctx);
|
||||
expect(result.behavior).toBe("allow");
|
||||
});
|
||||
|
||||
it("maps cancelled outcome to deny", async () => {
|
||||
const bridge = makeBridge(async () => ({ outcome: { outcome: "cancelled" } }));
|
||||
const result = await bridge.canUseTool("Bash", {}, ctx);
|
||||
expect(result.behavior).toBe("deny");
|
||||
});
|
||||
|
||||
it("routes AskUserQuestion to the dedicated handler with answers", async () => {
|
||||
const handler = jest.fn(async () => ({ "What's your favorite color?": "Blue" }));
|
||||
const bridge = makeBridge(null, handler);
|
||||
const result = await bridge.canUseTool(
|
||||
"AskUserQuestion",
|
||||
{
|
||||
questions: [{ question: "What's your favorite color?", options: [{ label: "Blue" }] }],
|
||||
},
|
||||
ctx
|
||||
);
|
||||
expect(handler).toHaveBeenCalled();
|
||||
expect(result.behavior).toBe("allow");
|
||||
if (result.behavior === "allow") {
|
||||
expect(result.updatedInput).toMatchObject({
|
||||
answers: { "What's your favorite color?": "Blue" },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("denies AskUserQuestion when no handler is configured", async () => {
|
||||
const bridge = makeBridge(async () => ({ outcome: { outcome: "cancelled" } }));
|
||||
const result = await bridge.canUseTool(
|
||||
"AskUserQuestion",
|
||||
{ questions: [{ question: "Q", options: [{ label: "A" }] }] },
|
||||
ctx
|
||||
);
|
||||
expect(result.behavior).toBe("deny");
|
||||
});
|
||||
|
||||
it("treats empty AskUserQuestion answers as cancelled", async () => {
|
||||
const handler = jest.fn(async () => ({}));
|
||||
const bridge = makeBridge(null, handler);
|
||||
const result = await bridge.canUseTool(
|
||||
"AskUserQuestion",
|
||||
{ questions: [{ question: "Q", options: [{ label: "A" }] }] },
|
||||
ctx
|
||||
);
|
||||
expect(result.behavior).toBe("deny");
|
||||
});
|
||||
|
||||
describe("Write tool gating", () => {
|
||||
function makeBridgeWithPlanMatcher(
|
||||
isPlanModePlanFilePath: (p: string) => boolean,
|
||||
prompter: ((req: PermissionPrompt) => Promise<PermissionDecision>) | null = null
|
||||
) {
|
||||
const bridge = new PermissionBridge({
|
||||
getPrompter: () => prompter,
|
||||
isPlanModePlanFilePath,
|
||||
});
|
||||
bridge.setSessionContext("session-1");
|
||||
return bridge;
|
||||
}
|
||||
|
||||
it("auto-allows Write when file_path matches the plan-mode predicate", async () => {
|
||||
const prompter = jest.fn();
|
||||
const bridge = makeBridgeWithPlanMatcher(
|
||||
(p) => p.endsWith("/.claude/plans/foo.md"),
|
||||
prompter
|
||||
);
|
||||
const result = await bridge.canUseTool(
|
||||
"Write",
|
||||
{ file_path: "/Users/x/.claude/plans/foo.md", content: "# plan" },
|
||||
ctx
|
||||
);
|
||||
expect(result.behavior).toBe("allow");
|
||||
if (result.behavior === "allow") {
|
||||
expect(result.updatedInput).toEqual({
|
||||
file_path: "/Users/x/.claude/plans/foo.md",
|
||||
content: "# plan",
|
||||
});
|
||||
}
|
||||
expect(prompter).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("routes non-plan Write through the permission prompter", async () => {
|
||||
let captured: PermissionPrompt | null = null;
|
||||
const bridge = makeBridgeWithPlanMatcher(
|
||||
() => false,
|
||||
async (req) => {
|
||||
captured = req;
|
||||
return { outcome: { outcome: "selected", optionId: "allow_once" } };
|
||||
}
|
||||
);
|
||||
const result = await bridge.canUseTool(
|
||||
"Write",
|
||||
{ file_path: "/tmp/foo.md", content: "x" },
|
||||
ctx
|
||||
);
|
||||
expect(captured).not.toBeNull();
|
||||
expect(captured!.toolCall.kind).toBe("edit");
|
||||
expect(captured!.toolCall.vendorToolName).toBe("Write");
|
||||
expect(result).toEqual({
|
||||
behavior: "allow",
|
||||
updatedInput: { file_path: "/tmp/foo.md", content: "x" },
|
||||
});
|
||||
});
|
||||
|
||||
it("routes Write through the prompter even with no plan predicate configured", async () => {
|
||||
const prompter = jest.fn(async () => ({
|
||||
outcome: { outcome: "selected" as const, optionId: "reject_once" as const },
|
||||
}));
|
||||
const bridge = new PermissionBridge({ getPrompter: () => prompter });
|
||||
bridge.setSessionContext("session-1");
|
||||
const result = await bridge.canUseTool(
|
||||
"Write",
|
||||
{ file_path: "/Users/x/.claude/plans/foo.md", content: "x" },
|
||||
ctx
|
||||
);
|
||||
expect(prompter).toHaveBeenCalled();
|
||||
expect(result.behavior).toBe("deny");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ExitPlanMode handling", () => {
|
||||
it("synthesizes a prompt with switch_mode kind and isPlanProposal=true", async () => {
|
||||
let captured: PermissionPrompt | null = null;
|
||||
const bridge = makeBridge(async (req) => {
|
||||
captured = req;
|
||||
return { outcome: { outcome: "selected", optionId: "allow_once" } };
|
||||
});
|
||||
const ctxWithToolUse = {
|
||||
signal: new AbortController().signal,
|
||||
toolUseID: "toolu_plan_xyz",
|
||||
} as unknown as Parameters<PermissionBridge["canUseTool"]>[2];
|
||||
await bridge.canUseTool("ExitPlanMode", { plan: "# do thing" }, ctxWithToolUse);
|
||||
expect(captured).not.toBeNull();
|
||||
expect(captured!.toolCall.kind).toBe("switch_mode");
|
||||
expect(captured!.toolCall.toolCallId).toBe("toolu_plan_xyz");
|
||||
expect(captured!.toolCall.rawInput).toEqual({ plan: "# do thing" });
|
||||
expect(captured!.toolCall.vendorToolName).toBe("ExitPlanMode");
|
||||
expect(captured!.toolCall.isPlanProposal).toBe(true);
|
||||
});
|
||||
|
||||
it("does not set isPlanProposal for non-ExitPlanMode tools", async () => {
|
||||
let captured: PermissionPrompt | null = null;
|
||||
const bridge = makeBridge(async (req) => {
|
||||
captured = req;
|
||||
return { outcome: { outcome: "selected", optionId: "allow_once" } };
|
||||
});
|
||||
await bridge.canUseTool("Bash", { command: "ls" }, ctx);
|
||||
expect(captured!.toolCall.isPlanProposal).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
206
src/agentMode/sdk/permissionBridge.ts
Normal file
206
src/agentMode/sdk/permissionBridge.ts
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
/**
|
||||
* Bridge between the Claude SDK's `canUseTool` callback and Agent Mode's
|
||||
* session-domain permission prompter. Each `canUseTool` invocation is
|
||||
* translated to a `PermissionPrompt`, dispatched through the prompter, then
|
||||
* translated back to a SDK `PermissionResult`. AskUserQuestion gets a
|
||||
* separate branch that opens a dedicated multi-choice modal.
|
||||
*/
|
||||
import type {
|
||||
CanUseTool,
|
||||
PermissionResult,
|
||||
PermissionUpdate,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
import type {
|
||||
PermissionDecision,
|
||||
PermissionOption,
|
||||
PermissionOptionKind,
|
||||
PermissionPrompt,
|
||||
SessionId,
|
||||
} from "@/agentMode/session/types";
|
||||
import { PERMISSION_OPTION_KINDS } from "@/agentMode/session/types";
|
||||
import { err2String } from "@/utils";
|
||||
import { logSdkInbound, logSdkOutbound } from "./sdkDebugTap";
|
||||
import { deriveToolKind, deriveToolTitle, vendorMetaFields } from "./toolMeta";
|
||||
|
||||
export type Prompter = (req: PermissionPrompt) => Promise<PermissionDecision>;
|
||||
|
||||
export type AskUserQuestionHandler = (
|
||||
questions: AskUserQuestionInput["questions"]
|
||||
) => Promise<{ [questionText: string]: string }>;
|
||||
|
||||
export interface AskUserQuestionInput {
|
||||
questions: Array<{
|
||||
question: string;
|
||||
header?: string;
|
||||
options: Array<{ label: string; description?: string }>;
|
||||
multiSelect?: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface PermissionBridgeOptions {
|
||||
getPrompter: () => Prompter | null;
|
||||
askUserQuestion?: AskUserQuestionHandler;
|
||||
/**
|
||||
* Predicate identifying plan-mode plan files. When provided, the bridge
|
||||
* auto-allows `Write` calls whose `file_path` satisfies the predicate so
|
||||
* plan mode can finalize its proposal at `~/.claude/plans/*.md` without
|
||||
* a prompt. Every other `Write` is routed through the permission
|
||||
* prompter like any other tool.
|
||||
*/
|
||||
isPlanModePlanFilePath?: (absolutePath: string) => boolean;
|
||||
}
|
||||
|
||||
export class PermissionBridge {
|
||||
constructor(private readonly opts: PermissionBridgeOptions) {}
|
||||
|
||||
/**
|
||||
* Single-field rather than keyed-by-toolCallId because each backend has
|
||||
* exactly one in-flight `query()` at a time. If we ever support concurrent
|
||||
* prompts on the same backend instance, key this by toolCallId.
|
||||
*/
|
||||
private currentSessionId: SessionId | null = null;
|
||||
|
||||
setSessionContext(sessionId: SessionId): void {
|
||||
this.currentSessionId = sessionId;
|
||||
}
|
||||
|
||||
clearSessionContext(): void {
|
||||
this.currentSessionId = null;
|
||||
}
|
||||
|
||||
canUseTool: CanUseTool = async (toolName, input, ctx) => {
|
||||
if (toolName === "AskUserQuestion") {
|
||||
return this.handleAskUserQuestion(input as unknown as AskUserQuestionInput);
|
||||
}
|
||||
|
||||
const sessionId = this.currentSessionId;
|
||||
logSdkInbound(
|
||||
`canUseTool:request`,
|
||||
{ toolName, input, suggestions: ctx.suggestions },
|
||||
sessionId
|
||||
);
|
||||
|
||||
if (toolName === "Write") {
|
||||
const filePath = typeof input.file_path === "string" ? input.file_path : null;
|
||||
if (filePath && this.opts.isPlanModePlanFilePath?.(filePath)) {
|
||||
const result: PermissionResult = { behavior: "allow", updatedInput: input };
|
||||
logSdkOutbound("canUseTool:response:auto-allow-plan", result, sessionId);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
const prompter = this.opts.getPrompter();
|
||||
if (!prompter) {
|
||||
return this.deny("canUseTool:response", "No permission prompter available", sessionId);
|
||||
}
|
||||
if (!sessionId) {
|
||||
return this.deny("canUseTool:response", "Permission requested outside a session", sessionId);
|
||||
}
|
||||
|
||||
const prompt = synthesizePermissionPrompt(toolName, input, sessionId, ctx);
|
||||
const decision = await prompter(prompt);
|
||||
const result = mapDecisionToSdk(decision, ctx.suggestions, input);
|
||||
logSdkOutbound("canUseTool:response", result, sessionId);
|
||||
return result;
|
||||
};
|
||||
|
||||
private async handleAskUserQuestion(input: AskUserQuestionInput): Promise<PermissionResult> {
|
||||
const sessionId = this.currentSessionId;
|
||||
logSdkInbound("askUserQuestion:request", input, sessionId);
|
||||
if (!this.opts.askUserQuestion) {
|
||||
return this.deny(
|
||||
"askUserQuestion:response",
|
||||
"AskUserQuestion is not yet supported",
|
||||
sessionId
|
||||
);
|
||||
}
|
||||
try {
|
||||
const answers = await this.opts.askUserQuestion(input.questions);
|
||||
if (Object.keys(answers).length === 0) {
|
||||
return this.deny("askUserQuestion:response", "User cancelled the question", sessionId);
|
||||
}
|
||||
const result: PermissionResult = {
|
||||
behavior: "allow",
|
||||
updatedInput: { questions: input.questions, answers },
|
||||
};
|
||||
logSdkOutbound("askUserQuestion:response", result, sessionId);
|
||||
return result;
|
||||
} catch (e) {
|
||||
return this.deny(
|
||||
"askUserQuestion:response",
|
||||
`AskUserQuestion failed: ${err2String(e)}`,
|
||||
sessionId
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private deny(method: string, message: string, sessionId: SessionId | null): PermissionResult {
|
||||
const result: PermissionResult = { behavior: "deny", message };
|
||||
logSdkOutbound(method, result, sessionId);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
const STANDARD_OPTION_NAMES: Record<PermissionOptionKind, string> = {
|
||||
allow_once: "Allow once",
|
||||
allow_always: "Allow always",
|
||||
reject_once: "Deny once",
|
||||
reject_always: "Deny always",
|
||||
};
|
||||
const STANDARD_OPTIONS: PermissionOption[] = PERMISSION_OPTION_KINDS.map((kind) => ({
|
||||
optionId: kind,
|
||||
name: STANDARD_OPTION_NAMES[kind],
|
||||
kind,
|
||||
}));
|
||||
const STANDARD_OPTION_IDS = new Set<string>(PERMISSION_OPTION_KINDS);
|
||||
|
||||
function synthesizePermissionPrompt(
|
||||
toolName: string,
|
||||
input: Record<string, unknown>,
|
||||
sessionId: SessionId,
|
||||
ctx: Parameters<CanUseTool>[2]
|
||||
): PermissionPrompt {
|
||||
return {
|
||||
sessionId,
|
||||
toolCall: {
|
||||
// Reuse the SDK's `tool_use_id` so prompt and `tool_call` notification
|
||||
// share an id — the trail UI and plan-card resolver pair them by id.
|
||||
toolCallId: ctx.toolUseID,
|
||||
kind: deriveToolKind(toolName),
|
||||
status: "pending",
|
||||
title: deriveToolTitle(
|
||||
toolName,
|
||||
input,
|
||||
typeof ctx.title === "string" ? ctx.title : undefined
|
||||
),
|
||||
rawInput: input,
|
||||
...vendorMetaFields(toolName),
|
||||
},
|
||||
options: STANDARD_OPTIONS,
|
||||
};
|
||||
}
|
||||
|
||||
function mapDecisionToSdk(
|
||||
decision: PermissionDecision,
|
||||
suggestions: PermissionUpdate[] | undefined,
|
||||
input: Record<string, unknown>
|
||||
): PermissionResult {
|
||||
if (decision.outcome.outcome === "cancelled") {
|
||||
return { behavior: "deny", message: "User cancelled" };
|
||||
}
|
||||
// Defensive default: unknown ids collapse to deny so they don't silently allow.
|
||||
const optionKind = STANDARD_OPTION_IDS.has(decision.outcome.optionId)
|
||||
? (decision.outcome.optionId as PermissionOptionKind)
|
||||
: "reject_once";
|
||||
switch (optionKind) {
|
||||
case "allow_once":
|
||||
// SDK runtime schema requires `updatedInput` even though the type marks
|
||||
// it optional. Echo the original — we don't modify tool args from the prompt.
|
||||
return { behavior: "allow", updatedInput: input };
|
||||
case "allow_always":
|
||||
return { behavior: "allow", updatedInput: input, updatedPermissions: suggestions ?? [] };
|
||||
case "reject_once":
|
||||
case "reject_always":
|
||||
return { behavior: "deny", message: decision.denyMessage ?? "User declined" };
|
||||
}
|
||||
}
|
||||
117
src/agentMode/sdk/sdkDebugTap.test.ts
Normal file
117
src/agentMode/sdk/sdkDebugTap.test.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import {
|
||||
describeSdkMessage,
|
||||
logSdkInbound,
|
||||
logSdkOutbound,
|
||||
SDK_FRAME_TAG,
|
||||
type SdkFrameSinkLike,
|
||||
} from "./sdkDebugTap";
|
||||
|
||||
const mockLogInfo = jest.fn();
|
||||
jest.mock("@/logger", () => ({
|
||||
logInfo: (...args: unknown[]) => mockLogInfo(...args),
|
||||
}));
|
||||
|
||||
let debugFullFrames = false;
|
||||
jest.mock("@/settings/model", () => ({
|
||||
getSettings: () => ({ agentMode: { debugFullFrames } }),
|
||||
}));
|
||||
|
||||
function fakeSink(): { sink: SdkFrameSinkLike; records: unknown[] } {
|
||||
const records: unknown[] = [];
|
||||
return {
|
||||
records,
|
||||
sink: { append: (r) => records.push(r) },
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockLogInfo.mockClear();
|
||||
debugFullFrames = false;
|
||||
});
|
||||
|
||||
describe("sdkDebugTap", () => {
|
||||
it("always emits a console line with the claude-sdk tag", () => {
|
||||
const { sink, records } = fakeSink();
|
||||
logSdkOutbound("prompt", { hi: "there" }, "session-1", sink);
|
||||
expect(mockLogInfo).toHaveBeenCalledTimes(1);
|
||||
const line = mockLogInfo.mock.calls[0][0] as string;
|
||||
expect(line).toContain(`[ACP →][${SDK_FRAME_TAG}]`);
|
||||
expect(line).toContain("prompt");
|
||||
expect(line).toContain("#session-1");
|
||||
expect(line).toContain('{"hi":"there"}');
|
||||
expect(records).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not write to disk when debugFullFrames is off", () => {
|
||||
const { sink, records } = fakeSink();
|
||||
debugFullFrames = false;
|
||||
logSdkInbound("stream_event:content_block_delta", { x: 1 }, "s", sink);
|
||||
expect(records).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("writes a full FrameRecord to disk when debugFullFrames is on", () => {
|
||||
const { sink, records } = fakeSink();
|
||||
debugFullFrames = true;
|
||||
logSdkInbound("stream_event:content_block_delta", { x: 1 }, "s", sink);
|
||||
expect(records).toHaveLength(1);
|
||||
const rec = records[0] as Record<string, unknown>;
|
||||
expect(rec.dir).toBe("←");
|
||||
expect(rec.tag).toBe(SDK_FRAME_TAG);
|
||||
expect(rec.kind).toBe("notif");
|
||||
expect(rec.method).toBe("stream_event:content_block_delta");
|
||||
expect(rec.id).toBe("s");
|
||||
expect(rec.payload).toEqual({ x: 1 });
|
||||
expect(typeof rec.ts).toBe("string");
|
||||
});
|
||||
|
||||
it("truncates the console payload past the 400-char limit", () => {
|
||||
const { sink } = fakeSink();
|
||||
const big = "x".repeat(800);
|
||||
logSdkOutbound("prompt", { big }, "s", sink);
|
||||
const line = mockLogInfo.mock.calls[0][0] as string;
|
||||
expect(line).toContain("…(+");
|
||||
expect(line.length).toBeLessThan(800);
|
||||
});
|
||||
|
||||
it("survives unserializable payloads on the console path", () => {
|
||||
const { sink } = fakeSink();
|
||||
const cyclic: Record<string, unknown> = {};
|
||||
cyclic.self = cyclic;
|
||||
expect(() => logSdkOutbound("prompt", cyclic, null, sink)).not.toThrow();
|
||||
expect(mockLogInfo).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("uses (no-id) label when id is omitted on outbound, (notif) on inbound", () => {
|
||||
const { sink } = fakeSink();
|
||||
logSdkOutbound("cancel", {}, null, sink);
|
||||
logSdkInbound("acp_notify:plan", {}, null, sink);
|
||||
const lines = mockLogInfo.mock.calls.map((c) => c[0] as string);
|
||||
expect(lines[0]).toContain("(no-id)");
|
||||
expect(lines[1]).toContain("(notif)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("describeSdkMessage", () => {
|
||||
it("annotates stream_event with inner event type", () => {
|
||||
expect(
|
||||
describeSdkMessage({
|
||||
type: "stream_event",
|
||||
event: { type: "content_block_delta" },
|
||||
})
|
||||
).toBe("stream_event:content_block_delta");
|
||||
});
|
||||
|
||||
it("annotates result with subtype", () => {
|
||||
expect(describeSdkMessage({ type: "result", subtype: "success" })).toBe("result:success");
|
||||
});
|
||||
|
||||
it("returns the bare type for assistant/user messages", () => {
|
||||
expect(describeSdkMessage({ type: "assistant" })).toBe("assistant");
|
||||
expect(describeSdkMessage({ type: "user" })).toBe("user");
|
||||
});
|
||||
|
||||
it("returns (unknown) for malformed inputs", () => {
|
||||
expect(describeSdkMessage(null)).toBe("(unknown)");
|
||||
expect(describeSdkMessage({})).toBe("(unknown)");
|
||||
});
|
||||
});
|
||||
112
src/agentMode/sdk/sdkDebugTap.ts
Normal file
112
src/agentMode/sdk/sdkDebugTap.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
/**
|
||||
* Debug tap for the Claude Agent SDK adapter. Each call logs one frame to the
|
||||
* console (truncated) and, when `agentMode.debugFullFrames` is on, appends
|
||||
* the full payload to the shared `acp-frames.ndjson` sink. Frames carry
|
||||
* `tag: "claude-sdk"` to distinguish from ACP frames in the same log.
|
||||
* @see ../acp/debugTap.ts for the JSON-RPC stream variant.
|
||||
*/
|
||||
import { logInfo } from "@/logger";
|
||||
import { getSettings } from "@/settings/model";
|
||||
import { formatPayload, frameSink, type FrameRecord } from "@/agentMode/session/debugSink";
|
||||
|
||||
export const SDK_FRAME_TAG = "claude-sdk";
|
||||
|
||||
export type SdkFrameKind = FrameRecord["kind"];
|
||||
export type SdkFrameDir = "→" | "←";
|
||||
|
||||
export interface SdkFrameSinkLike {
|
||||
append(record: FrameRecord): void;
|
||||
}
|
||||
|
||||
export interface LogSdkFrameArgs {
|
||||
dir: SdkFrameDir;
|
||||
method: string;
|
||||
/** SDK session id, request id, or any correlator. Null/undefined for un-keyed frames. */
|
||||
id?: string | null;
|
||||
payload?: unknown;
|
||||
/** Defaults to "request" for outbound, "notif" for inbound. */
|
||||
kind?: SdkFrameKind;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal entry point. Exposed for tests; production callers should use
|
||||
* the convenience wrappers below.
|
||||
*/
|
||||
export function logSdkFrame(args: LogSdkFrameArgs, sink: SdkFrameSinkLike = frameSink): void {
|
||||
const id = args.id ?? null;
|
||||
const idLabel = id !== null ? `#${id}` : args.kind === "notif" ? "(notif)" : "(no-id)";
|
||||
logInfo(
|
||||
`[ACP ${args.dir}][${SDK_FRAME_TAG}] ${args.method} ${idLabel} ${formatPayload(args.payload)}`
|
||||
);
|
||||
|
||||
if (!getSettings().agentMode?.debugFullFrames) return;
|
||||
sink.append({
|
||||
ts: new Date().toISOString(),
|
||||
dir: args.dir,
|
||||
tag: SDK_FRAME_TAG,
|
||||
kind: args.kind ?? (args.dir === "→" ? "request" : "notif"),
|
||||
method: args.method,
|
||||
id,
|
||||
payload: args.payload,
|
||||
});
|
||||
}
|
||||
|
||||
/** Outbound RPC or control call (we → SDK). */
|
||||
export function logSdkOutbound(
|
||||
method: string,
|
||||
payload: unknown,
|
||||
id?: string | null,
|
||||
sink?: SdkFrameSinkLike
|
||||
): void {
|
||||
logSdkFrame({ dir: "→", method, id, payload, kind: "request" }, sink);
|
||||
}
|
||||
|
||||
/** Outbound RPC result (we → caller / response we are about to return). */
|
||||
export function logSdkOutboundResult(
|
||||
method: string,
|
||||
payload: unknown,
|
||||
id?: string | null,
|
||||
sink?: SdkFrameSinkLike
|
||||
): void {
|
||||
logSdkFrame({ dir: "→", method, id, payload, kind: "result" }, sink);
|
||||
}
|
||||
|
||||
/** Inbound SDK message or translated ACP notification (SDK → us). */
|
||||
export function logSdkInbound(
|
||||
method: string,
|
||||
payload: unknown,
|
||||
id?: string | null,
|
||||
sink?: SdkFrameSinkLike
|
||||
): void {
|
||||
logSdkFrame({ dir: "←", method, id, payload, kind: "notif" }, sink);
|
||||
}
|
||||
|
||||
/** Inbound or outbound error frame. */
|
||||
export function logSdkError(
|
||||
dir: SdkFrameDir,
|
||||
method: string,
|
||||
payload: unknown,
|
||||
id?: string | null,
|
||||
sink?: SdkFrameSinkLike
|
||||
): void {
|
||||
logSdkFrame({ dir, method, id, payload, kind: "error" }, sink);
|
||||
}
|
||||
|
||||
/**
|
||||
* Synthesize a stable "method" label for an SDK message so the trace reads
|
||||
* similarly to ACP JSON-RPC method names. `stream_event` carries the inner
|
||||
* event type to make the high-frequency stream readable.
|
||||
*/
|
||||
export function describeSdkMessage(msg: unknown): string {
|
||||
const m = msg as { type?: unknown; event?: { type?: unknown }; subtype?: unknown };
|
||||
if (!m || typeof m.type !== "string") return "(unknown)";
|
||||
if (m.type === "stream_event") {
|
||||
const ev = m.event && typeof m.event === "object" ? m.event : null;
|
||||
const evType = ev && typeof ev.type === "string" ? ev.type : "?";
|
||||
return `stream_event:${evType}`;
|
||||
}
|
||||
if (m.type === "result" && typeof m.subtype === "string") {
|
||||
return `result:${m.subtype}`;
|
||||
}
|
||||
return m.type;
|
||||
}
|
||||
537
src/agentMode/sdk/sdkMessageTranslator.test.ts
Normal file
537
src/agentMode/sdk/sdkMessageTranslator.test.ts
Normal file
|
|
@ -0,0 +1,537 @@
|
|||
import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { createTranslatorState, mapStopReason, translateSdkMessage } from "./sdkMessageTranslator";
|
||||
|
||||
const SESSION_ID = "session-test-1";
|
||||
|
||||
function streamEvent(event: object): SDKMessage {
|
||||
return {
|
||||
type: "stream_event",
|
||||
event,
|
||||
parent_tool_use_id: null,
|
||||
uuid: "uuid-1" as `${string}-${string}-${string}-${string}-${string}`,
|
||||
session_id: SESSION_ID,
|
||||
} as SDKMessage;
|
||||
}
|
||||
|
||||
describe("translateSdkMessage", () => {
|
||||
it("emits agent_message_chunk for text deltas", () => {
|
||||
const state = createTranslatorState();
|
||||
const out = translateSdkMessage(
|
||||
streamEvent({
|
||||
type: "content_block_delta",
|
||||
index: 0,
|
||||
delta: { type: "text_delta", text: "Hello, world" },
|
||||
}),
|
||||
SESSION_ID,
|
||||
state
|
||||
);
|
||||
expect(out).toEqual([
|
||||
{
|
||||
sessionId: SESSION_ID,
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text: "Hello, world" },
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits agent_thought_chunk for thinking deltas", () => {
|
||||
const state = createTranslatorState();
|
||||
const out = translateSdkMessage(
|
||||
streamEvent({
|
||||
type: "content_block_delta",
|
||||
index: 0,
|
||||
delta: { type: "thinking_delta", thinking: "Let me think..." },
|
||||
}),
|
||||
SESSION_ID,
|
||||
state
|
||||
);
|
||||
expect(out).toEqual([
|
||||
{
|
||||
sessionId: SESSION_ID,
|
||||
update: {
|
||||
sessionUpdate: "agent_thought_chunk",
|
||||
content: { type: "text", text: "Let me think..." },
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores non-text deltas (input_json, signature, citations)", () => {
|
||||
const state = createTranslatorState();
|
||||
expect(
|
||||
translateSdkMessage(
|
||||
streamEvent({
|
||||
type: "content_block_delta",
|
||||
index: 0,
|
||||
delta: { type: "input_json_delta", partial_json: '{"a":1}' },
|
||||
}),
|
||||
SESSION_ID,
|
||||
state
|
||||
)
|
||||
).toEqual([]);
|
||||
expect(
|
||||
translateSdkMessage(
|
||||
streamEvent({
|
||||
type: "content_block_delta",
|
||||
index: 0,
|
||||
delta: { type: "signature_delta", signature: "sig" },
|
||||
}),
|
||||
SESSION_ID,
|
||||
state
|
||||
)
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("emits nothing for message_start/stop and content_block_start/stop in Chunk 1", () => {
|
||||
const state = createTranslatorState();
|
||||
expect(
|
||||
translateSdkMessage(streamEvent({ type: "message_start", message: {} }), SESSION_ID, state)
|
||||
).toEqual([]);
|
||||
expect(translateSdkMessage(streamEvent({ type: "message_stop" }), SESSION_ID, state)).toEqual(
|
||||
[]
|
||||
);
|
||||
expect(
|
||||
translateSdkMessage(
|
||||
streamEvent({
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: { type: "text", text: "" },
|
||||
}),
|
||||
SESSION_ID,
|
||||
state
|
||||
)
|
||||
).toEqual([]);
|
||||
expect(
|
||||
translateSdkMessage(streamEvent({ type: "content_block_stop", index: 0 }), SESSION_ID, state)
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("clears toolUseBlocks state on message_start", () => {
|
||||
const state = createTranslatorState();
|
||||
state.toolUseBlocks.set(0, {
|
||||
id: "t1",
|
||||
name: "Tool",
|
||||
inputJsonAcc: "",
|
||||
lastParsedInput: {},
|
||||
emittedToolCall: false,
|
||||
});
|
||||
translateSdkMessage(streamEvent({ type: "message_start", message: {} }), SESSION_ID, state);
|
||||
expect(state.toolUseBlocks.size).toBe(0);
|
||||
});
|
||||
|
||||
it("returns [] for `result` (caller resolves the prompt promise separately)", () => {
|
||||
const state = createTranslatorState();
|
||||
const out = translateSdkMessage(
|
||||
{
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
duration_ms: 0,
|
||||
duration_api_ms: 0,
|
||||
is_error: false,
|
||||
num_turns: 1,
|
||||
result: "ok",
|
||||
stop_reason: "end_turn",
|
||||
total_cost_usd: 0,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
usage: {} as any,
|
||||
modelUsage: {},
|
||||
permission_denials: [],
|
||||
uuid: "uuid-2" as `${string}-${string}-${string}-${string}-${string}`,
|
||||
session_id: SESSION_ID,
|
||||
},
|
||||
SESSION_ID,
|
||||
state
|
||||
);
|
||||
expect(out).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores assistant messages whose tool_use blocks were already streamed", () => {
|
||||
const state = createTranslatorState();
|
||||
// Pretend the streaming path already saw this tool_use.
|
||||
state.emittedToolUseIds.add("tool-1");
|
||||
expect(
|
||||
translateSdkMessage(
|
||||
{
|
||||
type: "assistant",
|
||||
|
||||
message: {
|
||||
content: [
|
||||
{ type: "tool_use", id: "tool-1", name: "Read", input: { file_path: "a.md" } },
|
||||
],
|
||||
} as never,
|
||||
parent_tool_use_id: null,
|
||||
uuid: "uuid-a" as `${string}-${string}-${string}-${string}-${string}`,
|
||||
session_id: SESSION_ID,
|
||||
},
|
||||
SESSION_ID,
|
||||
state
|
||||
)
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("emits tool_call on content_block_start for tool_use blocks", () => {
|
||||
const state = createTranslatorState();
|
||||
const out = translateSdkMessage(
|
||||
streamEvent({
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: { type: "tool_use", id: "tu-1", name: "Read", input: {} },
|
||||
}),
|
||||
SESSION_ID,
|
||||
state
|
||||
);
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0].update).toMatchObject({
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: "tu-1",
|
||||
kind: "read",
|
||||
vendorToolName: "Read",
|
||||
});
|
||||
});
|
||||
|
||||
it("emits tool_call_update with parsed rawInput on input_json_delta", () => {
|
||||
const state = createTranslatorState();
|
||||
translateSdkMessage(
|
||||
streamEvent({
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: { type: "tool_use", id: "tu-2", name: "Read", input: {} },
|
||||
}),
|
||||
SESSION_ID,
|
||||
state
|
||||
);
|
||||
const out = translateSdkMessage(
|
||||
streamEvent({
|
||||
type: "content_block_delta",
|
||||
index: 0,
|
||||
delta: { type: "input_json_delta", partial_json: '{"file_path":"a.md"}' },
|
||||
}),
|
||||
SESSION_ID,
|
||||
state
|
||||
);
|
||||
expect(out[0].update).toMatchObject({
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: "tu-2",
|
||||
rawInput: { file_path: "a.md" },
|
||||
});
|
||||
});
|
||||
|
||||
it("emits tool_call_update with status in_progress on content_block_stop", () => {
|
||||
const state = createTranslatorState();
|
||||
translateSdkMessage(
|
||||
streamEvent({
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: { type: "tool_use", id: "tu-3", name: "ExitPlanMode", input: {} },
|
||||
}),
|
||||
SESSION_ID,
|
||||
state
|
||||
);
|
||||
translateSdkMessage(
|
||||
streamEvent({
|
||||
type: "content_block_delta",
|
||||
index: 0,
|
||||
delta: { type: "input_json_delta", partial_json: '{"plan":"Step 1"}' },
|
||||
}),
|
||||
SESSION_ID,
|
||||
state
|
||||
);
|
||||
const out = translateSdkMessage(
|
||||
streamEvent({ type: "content_block_stop", index: 0 }),
|
||||
SESSION_ID,
|
||||
state
|
||||
);
|
||||
expect(out[0].update).toMatchObject({
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: "tu-3",
|
||||
rawInput: { plan: "Step 1" },
|
||||
status: "in_progress",
|
||||
});
|
||||
});
|
||||
|
||||
it("emits tool_call_update with status completed for tool_result (success)", () => {
|
||||
const state = createTranslatorState();
|
||||
const out = translateSdkMessage(
|
||||
{
|
||||
type: "user",
|
||||
|
||||
message: {
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "tu-4",
|
||||
content: "vault contents",
|
||||
is_error: false,
|
||||
},
|
||||
],
|
||||
} as never,
|
||||
parent_tool_use_id: null,
|
||||
session_id: SESSION_ID,
|
||||
},
|
||||
SESSION_ID,
|
||||
state
|
||||
);
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0].update).toMatchObject({
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: "tu-4",
|
||||
status: "completed",
|
||||
});
|
||||
});
|
||||
|
||||
it("emits tool_call_update with status failed when tool_result.is_error is true", () => {
|
||||
const state = createTranslatorState();
|
||||
const out = translateSdkMessage(
|
||||
{
|
||||
type: "user",
|
||||
|
||||
message: {
|
||||
content: [{ type: "tool_result", tool_use_id: "tu-5", content: "boom", is_error: true }],
|
||||
} as never,
|
||||
parent_tool_use_id: null,
|
||||
session_id: SESSION_ID,
|
||||
},
|
||||
SESSION_ID,
|
||||
state
|
||||
);
|
||||
expect(out[0].update).toMatchObject({ status: "failed" });
|
||||
});
|
||||
|
||||
it("synthesizes current_mode_update on EnterPlanMode tool_use", () => {
|
||||
const state = createTranslatorState();
|
||||
const out = translateSdkMessage(
|
||||
streamEvent({
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: { type: "tool_use", id: "tu-plan", name: "EnterPlanMode", input: {} },
|
||||
}),
|
||||
SESSION_ID,
|
||||
state
|
||||
);
|
||||
expect(out).toHaveLength(2);
|
||||
expect(out[0].update).toMatchObject({ sessionUpdate: "tool_call", toolCallId: "tu-plan" });
|
||||
expect(out[1].update).toMatchObject({
|
||||
sessionUpdate: "current_mode_update",
|
||||
currentModeId: "plan",
|
||||
});
|
||||
});
|
||||
|
||||
it("strips the mcp__<server>__ prefix when the server name itself contains underscores", () => {
|
||||
const state = createTranslatorState();
|
||||
const out = translateSdkMessage(
|
||||
streamEvent({
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: {
|
||||
type: "tool_use",
|
||||
id: "tu-mcp-underscored",
|
||||
name: "mcp__my_server__do_thing",
|
||||
input: {},
|
||||
},
|
||||
}),
|
||||
SESSION_ID,
|
||||
state
|
||||
);
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0].update).toMatchObject({
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: "tu-mcp-underscored",
|
||||
vendorToolName: "do_thing",
|
||||
});
|
||||
});
|
||||
|
||||
it("strips the mcp__<server>__ prefix from MCP tool names so kind/title/meta see the bare name", () => {
|
||||
const state = createTranslatorState();
|
||||
const out = translateSdkMessage(
|
||||
streamEvent({
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: {
|
||||
type: "tool_use",
|
||||
id: "tu-mcp",
|
||||
name: "mcp__custom-server__do_thing",
|
||||
input: { path: "Daily/2026-05-01.md" },
|
||||
},
|
||||
}),
|
||||
SESSION_ID,
|
||||
state
|
||||
);
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0].update).toMatchObject({
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: "tu-mcp",
|
||||
title: "do_thing Daily/2026-05-01.md",
|
||||
vendorToolName: "do_thing",
|
||||
});
|
||||
});
|
||||
|
||||
it("emits ExitPlanMode tool_call with kind=switch_mode (routes through plan-proposal flow)", () => {
|
||||
const state = createTranslatorState();
|
||||
const out = translateSdkMessage(
|
||||
streamEvent({
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: { type: "tool_use", id: "tu-exit", name: "ExitPlanMode", input: {} },
|
||||
}),
|
||||
SESSION_ID,
|
||||
state
|
||||
);
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0].update).toMatchObject({
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: "tu-exit",
|
||||
kind: "switch_mode",
|
||||
vendorToolName: "ExitPlanMode",
|
||||
isPlanProposal: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("threads parent_tool_use_id into parentToolCallId on streamed tool_use", () => {
|
||||
const state = createTranslatorState();
|
||||
const out = translateSdkMessage(
|
||||
{
|
||||
type: "stream_event",
|
||||
event: {
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: { type: "tool_use", id: "child-1", name: "Read", input: {} },
|
||||
},
|
||||
parent_tool_use_id: "task-parent-1",
|
||||
uuid: "uuid-p" as `${string}-${string}-${string}-${string}-${string}`,
|
||||
session_id: SESSION_ID,
|
||||
},
|
||||
SESSION_ID,
|
||||
state
|
||||
);
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0].update).toMatchObject({
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: "child-1",
|
||||
vendorToolName: "Read",
|
||||
parentToolCallId: "task-parent-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("threads parent_tool_use_id through tool_call_update on input_json_delta", () => {
|
||||
const state = createTranslatorState();
|
||||
translateSdkMessage(
|
||||
{
|
||||
type: "stream_event",
|
||||
event: {
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: { type: "tool_use", id: "child-2", name: "Read", input: {} },
|
||||
},
|
||||
parent_tool_use_id: "task-parent-2",
|
||||
uuid: "uuid-p2" as `${string}-${string}-${string}-${string}-${string}`,
|
||||
session_id: SESSION_ID,
|
||||
},
|
||||
SESSION_ID,
|
||||
state
|
||||
);
|
||||
const out = translateSdkMessage(
|
||||
{
|
||||
type: "stream_event",
|
||||
event: {
|
||||
type: "content_block_delta",
|
||||
index: 0,
|
||||
delta: { type: "input_json_delta", partial_json: '{"path":"a.md"}' },
|
||||
},
|
||||
parent_tool_use_id: "task-parent-2",
|
||||
uuid: "uuid-p3" as `${string}-${string}-${string}-${string}-${string}`,
|
||||
session_id: SESSION_ID,
|
||||
},
|
||||
SESSION_ID,
|
||||
state
|
||||
);
|
||||
expect(out[0].update).toMatchObject({
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: "child-2",
|
||||
vendorToolName: "Read",
|
||||
parentToolCallId: "task-parent-2",
|
||||
});
|
||||
});
|
||||
|
||||
it("omits parentToolCallId when parent_tool_use_id is null", () => {
|
||||
const state = createTranslatorState();
|
||||
const out = translateSdkMessage(
|
||||
streamEvent({
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: { type: "tool_use", id: "tu-top", name: "Read", input: {} },
|
||||
}),
|
||||
SESSION_ID,
|
||||
state
|
||||
);
|
||||
expect(out[0].update).toMatchObject({
|
||||
sessionUpdate: "tool_call",
|
||||
vendorToolName: "Read",
|
||||
});
|
||||
expect(out[0].update).not.toHaveProperty("parentToolCallId");
|
||||
});
|
||||
|
||||
it("threads parent_tool_use_id on assistant-message fallback path", () => {
|
||||
const state = createTranslatorState();
|
||||
const out = translateSdkMessage(
|
||||
{
|
||||
type: "assistant",
|
||||
|
||||
message: {
|
||||
content: [{ type: "tool_use", id: "child-3", name: "Read", input: { path: "a.md" } }],
|
||||
} as never,
|
||||
parent_tool_use_id: "task-parent-3",
|
||||
uuid: "uuid-p4" as `${string}-${string}-${string}-${string}-${string}`,
|
||||
session_id: SESSION_ID,
|
||||
},
|
||||
SESSION_ID,
|
||||
state
|
||||
);
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0].update).toMatchObject({
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: "child-3",
|
||||
vendorToolName: "Read",
|
||||
parentToolCallId: "task-parent-3",
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores partial input_json that doesn't parse yet", () => {
|
||||
const state = createTranslatorState();
|
||||
translateSdkMessage(
|
||||
streamEvent({
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: { type: "tool_use", id: "tu-6", name: "Read", input: {} },
|
||||
}),
|
||||
SESSION_ID,
|
||||
state
|
||||
);
|
||||
const out = translateSdkMessage(
|
||||
streamEvent({
|
||||
type: "content_block_delta",
|
||||
index: 0,
|
||||
delta: { type: "input_json_delta", partial_json: '{"path":"a.md' },
|
||||
}),
|
||||
SESSION_ID,
|
||||
state
|
||||
);
|
||||
expect(out).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapStopReason", () => {
|
||||
it("maps success → end_turn", () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect(mapStopReason({ type: "result", subtype: "success" } as any)).toBe("end_turn");
|
||||
});
|
||||
it("maps error variants → cancelled", () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect(mapStopReason({ type: "result", subtype: "error_during_execution" } as any)).toBe(
|
||||
"cancelled"
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect(mapStopReason({ type: "result", subtype: "error_max_turns" } as any)).toBe("cancelled");
|
||||
});
|
||||
});
|
||||
323
src/agentMode/sdk/sdkMessageTranslator.ts
Normal file
323
src/agentMode/sdk/sdkMessageTranslator.ts
Normal file
|
|
@ -0,0 +1,323 @@
|
|||
/** Pure translator: Claude Agent SDK `SDKMessage` → session-domain `SessionUpdate`. */
|
||||
import type {
|
||||
SDKAssistantMessage,
|
||||
SDKMessage,
|
||||
SDKPartialAssistantMessage,
|
||||
SDKResultMessage,
|
||||
SDKUserMessage,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
import type {
|
||||
AgentToolStatus,
|
||||
SessionEvent,
|
||||
SessionId,
|
||||
SessionUpdate,
|
||||
ToolCallContent,
|
||||
} from "@/agentMode/session/types";
|
||||
import { deriveToolKind, deriveToolTitle, vendorMetaFields } from "./toolMeta";
|
||||
|
||||
/**
|
||||
* Mutable per-query translator state. One instance lives for the duration of
|
||||
* a single `query()` call; reset whenever a new turn starts.
|
||||
*/
|
||||
export interface TranslatorState {
|
||||
toolUseBlocks: Map<
|
||||
number,
|
||||
{
|
||||
id: string;
|
||||
name: string;
|
||||
inputJsonAcc: string;
|
||||
lastParsedInput: unknown;
|
||||
emittedToolCall: boolean;
|
||||
}
|
||||
>;
|
||||
/** Tool-use ids already emitted in this turn — used to dedupe in the assistant-message fallback path. */
|
||||
emittedToolUseIds: Set<string>;
|
||||
}
|
||||
|
||||
export function createTranslatorState(): TranslatorState {
|
||||
return { toolUseBlocks: new Map(), emittedToolUseIds: new Set() };
|
||||
}
|
||||
|
||||
function event(sessionId: SessionId, update: SessionUpdate): SessionEvent {
|
||||
return { sessionId, update };
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate one SDK message to zero or more session-domain events. Returning
|
||||
* an array (rather than firing a callback) keeps the function pure and
|
||||
* trivially testable; the caller decides what to do with the events and when
|
||||
* to terminate the prompt promise.
|
||||
*/
|
||||
export function translateSdkMessage(
|
||||
msg: SDKMessage,
|
||||
sessionId: SessionId,
|
||||
state: TranslatorState
|
||||
): SessionEvent[] {
|
||||
switch (msg.type) {
|
||||
case "stream_event":
|
||||
return translateStreamEvent(msg, sessionId, state);
|
||||
case "assistant":
|
||||
return translateAssistantMessage(msg, sessionId, state);
|
||||
case "user":
|
||||
return translateUserMessage(msg, sessionId, state);
|
||||
case "result":
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function mapStopReason(msg: SDKResultMessage): "end_turn" | "cancelled" | "refusal" {
|
||||
if (msg.subtype === "success") return "end_turn";
|
||||
return "cancelled";
|
||||
}
|
||||
|
||||
function translateStreamEvent(
|
||||
msg: SDKPartialAssistantMessage,
|
||||
sessionId: SessionId,
|
||||
state: TranslatorState
|
||||
): SessionEvent[] {
|
||||
const parentToolUseId = msg.parent_tool_use_id ?? undefined;
|
||||
const sdkEvent = msg.event as
|
||||
| { type: "message_start"; message?: unknown }
|
||||
| { type: "message_stop" }
|
||||
| { type: "message_delta"; delta?: unknown; usage?: unknown }
|
||||
| {
|
||||
type: "content_block_start";
|
||||
index: number;
|
||||
content_block:
|
||||
| { type: "text"; text: string }
|
||||
| { type: "tool_use"; id: string; name: string; input: unknown }
|
||||
| { type: "thinking"; thinking: string }
|
||||
| { type: "redacted_thinking" };
|
||||
}
|
||||
| {
|
||||
type: "content_block_delta";
|
||||
index: number;
|
||||
delta:
|
||||
| { type: "text_delta"; text: string }
|
||||
| { type: "thinking_delta"; thinking: string }
|
||||
| { type: "input_json_delta"; partial_json: string }
|
||||
| { type: "signature_delta"; signature: string }
|
||||
| { type: "citations_delta"; citation: unknown };
|
||||
}
|
||||
| { type: "content_block_stop"; index: number };
|
||||
|
||||
switch (sdkEvent.type) {
|
||||
case "message_start":
|
||||
state.toolUseBlocks.clear();
|
||||
return [];
|
||||
case "content_block_start": {
|
||||
const block = sdkEvent.content_block;
|
||||
if (block.type === "tool_use") {
|
||||
const name = normalizeToolName(block.name);
|
||||
state.toolUseBlocks.set(sdkEvent.index, {
|
||||
id: block.id,
|
||||
name,
|
||||
inputJsonAcc: "",
|
||||
lastParsedInput: block.input ?? {},
|
||||
emittedToolCall: true,
|
||||
});
|
||||
state.emittedToolUseIds.add(block.id);
|
||||
const out: SessionEvent[] = [
|
||||
event(sessionId, makeToolCallUpdate(block.id, name, block.input ?? {}, parentToolUseId)),
|
||||
];
|
||||
if (name === "EnterPlanMode") {
|
||||
out.push(
|
||||
event(sessionId, {
|
||||
sessionUpdate: "current_mode_update",
|
||||
currentModeId: "plan",
|
||||
})
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
case "content_block_delta": {
|
||||
const delta = sdkEvent.delta;
|
||||
if (delta.type === "text_delta") {
|
||||
return [
|
||||
event(sessionId, {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text: delta.text },
|
||||
}),
|
||||
];
|
||||
}
|
||||
if (delta.type === "thinking_delta") {
|
||||
return [
|
||||
event(sessionId, {
|
||||
sessionUpdate: "agent_thought_chunk",
|
||||
content: { type: "text", text: delta.thinking },
|
||||
}),
|
||||
];
|
||||
}
|
||||
if (delta.type === "input_json_delta") {
|
||||
const block = state.toolUseBlocks.get(sdkEvent.index);
|
||||
if (!block) return [];
|
||||
block.inputJsonAcc += delta.partial_json;
|
||||
// Cheap pre-check: a complete JSON value's last non-whitespace byte
|
||||
// is `}`, `]`, `"`, a digit, or one of the literals' last letters.
|
||||
// Skipping JSON.parse on obviously-incomplete buffers (mid-key,
|
||||
// mid-string) avoids O(N) work per delta when a large tool input
|
||||
// streams across many small chunks.
|
||||
if (!couldBeCompleteJson(block.inputJsonAcc)) return [];
|
||||
const parsed = tryParseJson(block.inputJsonAcc);
|
||||
if (!parsed.ok) return [];
|
||||
block.lastParsedInput = parsed.value;
|
||||
return [
|
||||
event(sessionId, {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: block.id,
|
||||
rawInput: parsed.value,
|
||||
...vendorMetaFields(block.name, parentToolUseId),
|
||||
}),
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
case "content_block_stop": {
|
||||
const block = state.toolUseBlocks.get(sdkEvent.index);
|
||||
if (!block) return [];
|
||||
const parsed = tryParseJson(block.inputJsonAcc);
|
||||
const finalInput = parsed.ok ? parsed.value : block.lastParsedInput;
|
||||
block.lastParsedInput = finalInput;
|
||||
return [
|
||||
event(sessionId, {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: block.id,
|
||||
rawInput: finalInput,
|
||||
status: "in_progress" as AgentToolStatus,
|
||||
...vendorMetaFields(block.name, parentToolUseId),
|
||||
}),
|
||||
];
|
||||
}
|
||||
case "message_delta":
|
||||
case "message_stop":
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function translateAssistantMessage(
|
||||
msg: SDKAssistantMessage,
|
||||
sessionId: SessionId,
|
||||
state: TranslatorState
|
||||
): SessionEvent[] {
|
||||
const out: SessionEvent[] = [];
|
||||
const content = (msg.message as { content?: unknown }).content;
|
||||
if (!Array.isArray(content)) return out;
|
||||
const parentToolUseId = msg.parent_tool_use_id ?? undefined;
|
||||
for (const block of content) {
|
||||
const b = block as { type?: string; id?: string; name?: string; input?: unknown };
|
||||
if (b.type !== "tool_use" || !b.id || !b.name) continue;
|
||||
if (state.emittedToolUseIds.has(b.id)) continue;
|
||||
state.emittedToolUseIds.add(b.id);
|
||||
out.push(event(sessionId, makeToolCallUpdate(b.id, b.name, b.input ?? {}, parentToolUseId)));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function translateUserMessage(
|
||||
msg: SDKUserMessage,
|
||||
sessionId: SessionId,
|
||||
_state: TranslatorState
|
||||
): SessionEvent[] {
|
||||
const content = (msg.message as { content?: unknown }).content;
|
||||
if (!Array.isArray(content)) return [];
|
||||
const out: SessionEvent[] = [];
|
||||
for (const block of content) {
|
||||
const b = block as {
|
||||
type?: string;
|
||||
tool_use_id?: string;
|
||||
content?: unknown;
|
||||
is_error?: boolean;
|
||||
};
|
||||
if (b.type !== "tool_result" || !b.tool_use_id) continue;
|
||||
const status: AgentToolStatus = b.is_error ? "failed" : "completed";
|
||||
const outputs = toolResultContent(b.content);
|
||||
out.push(
|
||||
event(sessionId, {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: b.tool_use_id,
|
||||
status,
|
||||
content: outputs,
|
||||
})
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function makeToolCallUpdate(
|
||||
toolCallId: string,
|
||||
normalizedName: string,
|
||||
rawInput: unknown,
|
||||
parentToolUseId?: string
|
||||
): SessionUpdate {
|
||||
return {
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId,
|
||||
title: deriveToolTitle(normalizedName, rawInput),
|
||||
kind: deriveToolKind(normalizedName),
|
||||
status: "in_progress" as AgentToolStatus,
|
||||
rawInput,
|
||||
...vendorMetaFields(normalizedName, parentToolUseId),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip the SDK's `mcp__<server>__` prefix on MCP tool names so downstream UI
|
||||
* mapping (kind / title / vendorToolName) sees the bare tool name. The
|
||||
* non-greedy middle segment tolerates server names containing underscores.
|
||||
*/
|
||||
function normalizeToolName(name: string): string {
|
||||
const m = /^mcp__.+?__(.+)$/.exec(name);
|
||||
return m ? m[1] : name;
|
||||
}
|
||||
|
||||
function toolResultContent(content: unknown): ToolCallContent[] | undefined {
|
||||
if (typeof content === "string") {
|
||||
return [{ type: "content", content: { type: "text", text: content } }];
|
||||
}
|
||||
if (!Array.isArray(content)) return undefined;
|
||||
const out: ToolCallContent[] = [];
|
||||
for (const block of content) {
|
||||
const b = block as { type?: string; text?: unknown };
|
||||
if (b.type === "text" && typeof b.text === "string") {
|
||||
out.push({ type: "content", content: { type: "text", text: b.text } });
|
||||
}
|
||||
}
|
||||
return out.length > 0 ? out : undefined;
|
||||
}
|
||||
|
||||
type ParseResult = { ok: true; value: unknown } | { ok: false };
|
||||
|
||||
function tryParseJson(raw: string): ParseResult {
|
||||
if (raw.trim().length === 0) return { ok: true, value: {} };
|
||||
try {
|
||||
return { ok: true, value: JSON.parse(raw) };
|
||||
} catch {
|
||||
return { ok: false };
|
||||
}
|
||||
}
|
||||
|
||||
function couldBeCompleteJson(raw: string): boolean {
|
||||
let i = raw.length - 1;
|
||||
while (i >= 0) {
|
||||
const c = raw.charCodeAt(i);
|
||||
// Skip ASCII whitespace.
|
||||
if (c === 0x20 || c === 0x09 || c === 0x0a || c === 0x0d) {
|
||||
i--;
|
||||
continue;
|
||||
}
|
||||
// }, ], ", e (true/false), l (null), or any digit can end a JSON value.
|
||||
return (
|
||||
c === 0x7d || // }
|
||||
c === 0x5d || // ]
|
||||
c === 0x22 || // "
|
||||
c === 0x65 || // e
|
||||
c === 0x6c || // l
|
||||
(c >= 0x30 && c <= 0x39) // 0-9
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
63
src/agentMode/sdk/toolMeta.ts
Normal file
63
src/agentMode/sdk/toolMeta.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import type { AgentToolKind } from "@/agentMode/session/types";
|
||||
|
||||
export interface VendorMetaFields {
|
||||
vendorToolName: string;
|
||||
parentToolCallId?: string;
|
||||
isPlanProposal?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Caller passes the normalized tool name (any `mcp__server__` prefix
|
||||
* already stripped). `isPlanProposal` is omitted unless true so the flag
|
||||
* doesn't leak onto unrelated tool calls.
|
||||
*/
|
||||
export function vendorMetaFields(
|
||||
normalizedName: string,
|
||||
parentToolCallId?: string
|
||||
): VendorMetaFields {
|
||||
const fields: VendorMetaFields = { vendorToolName: normalizedName };
|
||||
if (parentToolCallId) fields.parentToolCallId = parentToolCallId;
|
||||
if (normalizedName === "ExitPlanMode") fields.isPlanProposal = true;
|
||||
return fields;
|
||||
}
|
||||
|
||||
export function deriveToolKind(toolName: string): AgentToolKind {
|
||||
if (toolName === "ExitPlanMode" || toolName === "EnterPlanMode") return "switch_mode";
|
||||
const lower = toolName.toLowerCase();
|
||||
if (lower === "read" || lower === "glob" || lower === "grep" || lower === "ls") {
|
||||
return "read";
|
||||
}
|
||||
if (lower === "write" || lower === "edit" || lower === "multiedit") {
|
||||
return "edit";
|
||||
}
|
||||
if (lower === "bash") return "execute";
|
||||
if (lower === "websearch" || lower === "webfetch") return "fetch";
|
||||
if (lower === "todowrite" || lower === "task" || lower === "agent") return "think";
|
||||
return "other";
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a one-line "what is the agent doing" title surfaced on the action
|
||||
* card. `titleOverride` short-circuits when the SDK already supplied one
|
||||
* (e.g. via `canUseTool` ctx).
|
||||
*/
|
||||
export function deriveToolTitle(
|
||||
toolName: string,
|
||||
rawInput: unknown,
|
||||
titleOverride?: string
|
||||
): string {
|
||||
if (typeof titleOverride === "string" && titleOverride.length > 0) return titleOverride;
|
||||
const input = rawInput as Record<string, unknown> | null | undefined;
|
||||
if (input && typeof input === "object") {
|
||||
if (typeof input.path === "string") return `${toolName} ${input.path}`;
|
||||
if (typeof input.file_path === "string") return `${toolName} ${input.file_path}`;
|
||||
if (typeof input.command === "string") return `${toolName}: ${truncate(input.command, 60)}`;
|
||||
if (typeof input.pattern === "string") return `${toolName} ${truncate(input.pattern, 60)}`;
|
||||
if (typeof input.url === "string") return `${toolName} ${input.url}`;
|
||||
}
|
||||
return toolName;
|
||||
}
|
||||
|
||||
export function truncate(s: string, n: number): string {
|
||||
return s.length > n ? `${s.slice(0, n - 1)}…` : s;
|
||||
}
|
||||
76
src/agentMode/session/AgentChatBackend.ts
Normal file
76
src/agentMode/session/AgentChatBackend.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import type { MessageContext } from "@/types/message";
|
||||
import type { AgentChatMessage, BackendState, CurrentPlan, PlanDecisionAction } from "./types";
|
||||
|
||||
/**
|
||||
* Narrow interface the Agent Mode UI tree consumes. Implemented by
|
||||
* `AgentChatUIState`. Distinct from the legacy `ChatUIState` because Agent
|
||||
* Mode has no edit/regenerate/persistence flow and no chain-type or
|
||||
* include-active-note plumbing — ACP owns those concerns server-side.
|
||||
*
|
||||
* `sendMessage` returns `{ id, turn }` so the caller can synchronously read
|
||||
* the new user message id (for input history) and separately await the full
|
||||
* turn for loading-state management.
|
||||
*/
|
||||
export interface AgentChatBackend {
|
||||
subscribe(listener: () => void): () => void;
|
||||
sendMessage(
|
||||
text: string,
|
||||
context?: MessageContext,
|
||||
content?: unknown[]
|
||||
): { id: string; turn: Promise<void> };
|
||||
cancel(): Promise<void>;
|
||||
deleteMessage(id: string): Promise<boolean>;
|
||||
clearMessages(): void;
|
||||
getMessages(): AgentChatMessage[];
|
||||
|
||||
/** True while ACP `session/new` is still in flight. Send is gated on this. */
|
||||
isStarting(): boolean;
|
||||
|
||||
/** Latest unified picker state, or `null` while the backend session is still starting. */
|
||||
getBackendState(): BackendState | null;
|
||||
/**
|
||||
* Intent-level capability probes. Tri-state: null = not yet probed,
|
||||
* true/false = result. The session encapsulates wire routing
|
||||
* (descriptor-style vs suffix-style effort, `setMode` vs
|
||||
* `setConfigOption` mode dispatch) — UI consumers ask intent only.
|
||||
*/
|
||||
canSwitchModel(): boolean | null;
|
||||
canSwitchEffort(): boolean | null;
|
||||
canSwitchMode(): boolean | null;
|
||||
|
||||
/**
|
||||
* Resolve the current plan proposal the user has decided on. Branches on
|
||||
* `currentPlan.permissionGated`:
|
||||
* - gated (Claude Code ExitPlanMode): resolves the underlying ACP
|
||||
* permission as allow/deny. Approve auto-continues the agent's turn;
|
||||
* Reject ends the turn; Feedback denies with `feedbackText` as the
|
||||
* agent-visible deny reason.
|
||||
* - non-gated (OpenCode end-of-turn, or backends whose plan-exit signal
|
||||
* carries no permission): Approve switches to canonical `build` mode
|
||||
* (when the descriptor advertises one) and sends a `Proceed with the
|
||||
* plan.` follow-up; Reject is informational; Feedback sends
|
||||
* `feedbackText` as the next user turn (mode stays in plan).
|
||||
*
|
||||
* `proposalId` must match the current `getCurrentPlan().id` — stale
|
||||
* resolutions (the user clicked a card that has since been replaced)
|
||||
* are silently ignored.
|
||||
*/
|
||||
resolvePlanProposal(
|
||||
proposalId: string,
|
||||
decision: PlanDecisionAction,
|
||||
feedbackText?: string
|
||||
): Promise<void>;
|
||||
|
||||
/**
|
||||
* Singleton plan-mode review state, or `null` when there's nothing to
|
||||
* surface. The floating plan card and the editor preview tab read this.
|
||||
*/
|
||||
getCurrentPlan(): CurrentPlan | null;
|
||||
|
||||
/**
|
||||
* True when an ExitPlanMode permission is currently pending. The chat input
|
||||
* disables itself while one is outstanding so the user is funneled to the
|
||||
* proposal card's actions.
|
||||
*/
|
||||
hasPendingPlanPermission(): boolean;
|
||||
}
|
||||
182
src/agentMode/session/AgentChatPersistenceManager.test.ts
Normal file
182
src/agentMode/session/AgentChatPersistenceManager.test.ts
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
/* eslint-disable obsidianmd/no-tfile-tfolder-cast -- test fixtures; not real TFiles */
|
||||
import { AI_SENDER, USER_SENDER } from "@/constants";
|
||||
import { AgentChatPersistenceManager } from "./AgentChatPersistenceManager";
|
||||
import type { AgentChatMessage } from "./types";
|
||||
import type { App, TFile } from "obsidian";
|
||||
|
||||
jest.mock("obsidian", () => ({
|
||||
Notice: jest.fn(),
|
||||
TFile: jest.fn(),
|
||||
}));
|
||||
jest.mock("@/logger");
|
||||
jest.mock("@/settings/model", () => ({
|
||||
getSettings: jest.fn().mockReturnValue({
|
||||
defaultSaveFolder: "test-folder",
|
||||
defaultConversationTag: "copilot-conversation",
|
||||
defaultConversationNoteName: "{$date}_{$time}__{$topic}",
|
||||
}),
|
||||
}));
|
||||
jest.mock("@/utils", () => ({
|
||||
ensureFolderExists: jest.fn(async () => {}),
|
||||
formatDateTime: jest.fn(() => ({
|
||||
fileName: "20260101_120000",
|
||||
display: "2026/01/01 12:00:00",
|
||||
})),
|
||||
getUtf8ByteLength: jest.fn((s: string) => new TextEncoder().encode(s).length),
|
||||
truncateToByteLimit: jest.fn((s: string, n: number) => {
|
||||
const bytes = new TextEncoder().encode(s);
|
||||
if (bytes.length <= n) return s;
|
||||
return new TextDecoder().decode(bytes.slice(0, n));
|
||||
}),
|
||||
}));
|
||||
jest.mock("@/utils/vaultAdapterUtils", () => ({
|
||||
isInVaultCache: jest.fn(() => false),
|
||||
listMarkdownFiles: jest.fn().mockResolvedValue([]),
|
||||
readFrontmatterViaAdapter: jest.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
interface FakeFile {
|
||||
path: string;
|
||||
basename: string;
|
||||
contents?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a minimal in-memory `app` mock that records files written via
|
||||
* `vault.create` / `vault.adapter.write` so a round-trip save/load test can
|
||||
* read what the previous step wrote without wiring real disk I/O.
|
||||
*/
|
||||
function makeApp() {
|
||||
const files = new Map<string, FakeFile>();
|
||||
return {
|
||||
files,
|
||||
vault: {
|
||||
getAbstractFileByPath: jest.fn((path: string) => files.get(path) ?? null),
|
||||
create: jest.fn(async (path: string, content: string) => {
|
||||
const basename = path.split("/").pop()!.replace(/\.md$/, "");
|
||||
const file = { path, basename, contents: content };
|
||||
files.set(path, file);
|
||||
return file;
|
||||
}),
|
||||
modify: jest.fn(async (file: FakeFile, content: string) => {
|
||||
file.contents = content;
|
||||
}),
|
||||
read: jest.fn(async (file: FakeFile) => file.contents ?? ""),
|
||||
delete: jest.fn(async (file: FakeFile) => {
|
||||
files.delete(file.path);
|
||||
}),
|
||||
adapter: {
|
||||
exists: jest.fn(async (path: string) => files.has(path)),
|
||||
read: jest.fn(async (path: string) => files.get(path)?.contents ?? ""),
|
||||
write: jest.fn(async (path: string, content: string) => {
|
||||
const existing = files.get(path);
|
||||
if (existing) {
|
||||
existing.contents = content;
|
||||
} else {
|
||||
const basename = path.split("/").pop()!.replace(/\.md$/, "");
|
||||
files.set(path, { path, basename, contents: content });
|
||||
}
|
||||
}),
|
||||
remove: jest.fn(async (path: string) => {
|
||||
files.delete(path);
|
||||
}),
|
||||
},
|
||||
},
|
||||
metadataCache: {
|
||||
getFileCache: jest.fn(() => undefined),
|
||||
},
|
||||
fileManager: {
|
||||
processFrontMatter: jest.fn(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeMessage(sender: string, message: string, epoch = 1735732800000): AgentChatMessage {
|
||||
return {
|
||||
id: `msg-${epoch}`,
|
||||
sender,
|
||||
message,
|
||||
isVisible: true,
|
||||
timestamp: { epoch, display: "2026/01/01 12:00:00", fileName: "20260101_120000" },
|
||||
};
|
||||
}
|
||||
|
||||
describe("AgentChatPersistenceManager", () => {
|
||||
let app: ReturnType<typeof makeApp>;
|
||||
let manager: AgentChatPersistenceManager;
|
||||
|
||||
beforeEach(() => {
|
||||
app = makeApp();
|
||||
manager = new AgentChatPersistenceManager(app as unknown as App);
|
||||
});
|
||||
|
||||
it("round-trips messages, backendId, and label", async () => {
|
||||
const messages = [makeMessage(USER_SENDER, "hello world"), makeMessage(AI_SENDER, "hi back")];
|
||||
const saved = await manager.saveSession(messages, "claude-code", { label: "My chat" });
|
||||
expect(saved).not.toBeNull();
|
||||
|
||||
const file = app.files.get(saved!.path)!;
|
||||
const loaded = await manager.loadFile(file as unknown as TFile);
|
||||
expect(loaded.backendId).toBe("claude-code");
|
||||
expect(loaded.label).toBe("My chat");
|
||||
expect(loaded.messages).toHaveLength(2);
|
||||
expect(loaded.messages[0].sender).toBe(USER_SENDER);
|
||||
expect(loaded.messages[0].message).toBe("hello world");
|
||||
expect(loaded.messages[1].sender).toBe(AI_SENDER);
|
||||
expect(loaded.messages[1].message).toBe("hi back");
|
||||
});
|
||||
|
||||
it("escapes and round-trips a label containing quotes and backslashes", async () => {
|
||||
const tricky = 'has "quotes" and \\backslashes\\';
|
||||
const messages = [makeMessage(USER_SENDER, "hi")];
|
||||
const saved = await manager.saveSession(messages, "opencode", { label: tricky });
|
||||
expect(saved).not.toBeNull();
|
||||
const loaded = await manager.loadFile(app.files.get(saved!.path) as unknown as TFile);
|
||||
expect(loaded.label).toBe(tricky);
|
||||
});
|
||||
|
||||
it("strips control characters from labels so they can't break frontmatter", async () => {
|
||||
const messages = [makeMessage(USER_SENDER, "hi")];
|
||||
const saved = await manager.saveSession(messages, "opencode", {
|
||||
label: "first\nsecond\rthird",
|
||||
});
|
||||
const raw = app.files.get(saved!.path)!.contents!;
|
||||
// The label line must remain a single key:value entry.
|
||||
const labelLines = raw.split("\n").filter((l) => l.startsWith("agentLabel:"));
|
||||
expect(labelLines).toHaveLength(1);
|
||||
const loaded = await manager.loadFile(app.files.get(saved!.path) as unknown as TFile);
|
||||
expect(loaded.label).toBe("first second third");
|
||||
});
|
||||
|
||||
it("throws on missing backendId instead of silently defaulting", async () => {
|
||||
const path = "test-folder/agent__broken.md";
|
||||
await app.vault.adapter.write(
|
||||
path,
|
||||
["---", "epoch: 1735732800000", "mode: agent", "---", "", "**user**: hi"].join("\n")
|
||||
);
|
||||
await expect(
|
||||
manager.loadFile({ path, basename: "agent__broken" } as unknown as TFile)
|
||||
).rejects.toThrow(/Missing backendId/);
|
||||
});
|
||||
|
||||
it("assigns deterministic ids that depend only on message timestamp", async () => {
|
||||
const messages = [
|
||||
makeMessage(USER_SENDER, "first", 1700000000000),
|
||||
makeMessage(AI_SENDER, "second", 1700000000001),
|
||||
];
|
||||
const saved = await manager.saveSession(messages, "claude-code");
|
||||
const file = app.files.get(saved!.path)!;
|
||||
|
||||
const loadedA = await manager.loadFile(file as unknown as TFile);
|
||||
const loadedB = await manager.loadFile(file as unknown as TFile);
|
||||
// The key contract: same file + same content → same ids across reloads.
|
||||
expect(loadedA.messages.map((m) => m.id)).toEqual(loadedB.messages.map((m) => m.id));
|
||||
expect(loadedA.messages[0].id.startsWith("loaded-0-")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns null when given zero messages instead of writing an empty file", async () => {
|
||||
const result = await manager.saveSession([], "opencode");
|
||||
expect(result).toBeNull();
|
||||
expect(app.files.size).toBe(0);
|
||||
});
|
||||
});
|
||||
453
src/agentMode/session/AgentChatPersistenceManager.ts
Normal file
453
src/agentMode/session/AgentChatPersistenceManager.ts
Normal file
|
|
@ -0,0 +1,453 @@
|
|||
import { AI_SENDER, USER_SENDER } from "@/constants";
|
||||
import { logError, logInfo, logWarn } from "@/logger";
|
||||
import { getSettings } from "@/settings/model";
|
||||
import { FormattedDateTime } from "@/types/message";
|
||||
import {
|
||||
ensureFolderExists,
|
||||
formatDateTime,
|
||||
getUtf8ByteLength,
|
||||
truncateToByteLimit,
|
||||
} from "@/utils";
|
||||
import {
|
||||
isFileAlreadyExistsError,
|
||||
isInVaultCache,
|
||||
isNameTooLongError,
|
||||
listMarkdownFiles,
|
||||
patchFrontmatter,
|
||||
readFrontmatterViaAdapter,
|
||||
trashFile,
|
||||
} from "@/utils/vaultAdapterUtils";
|
||||
import { TFile, type App } from "obsidian";
|
||||
import { Notice } from "obsidian";
|
||||
import type { AgentChatMessage, BackendId } from "./types";
|
||||
|
||||
const SAFE_FILENAME_BYTE_LIMIT = 100;
|
||||
export const AGENT_FILENAME_PREFIX = "agent__";
|
||||
export const AGENT_CHAT_MODE = "agent";
|
||||
|
||||
/**
|
||||
* Result of `loadFile` — restores display-only Agent Mode messages plus
|
||||
* routing info needed to spawn the right backend session.
|
||||
*/
|
||||
export interface LoadedAgentChat {
|
||||
messages: AgentChatMessage[];
|
||||
backendId: BackendId;
|
||||
topic?: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
interface ExistingMeta {
|
||||
topic?: string;
|
||||
label?: string;
|
||||
lastAccessedAt?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape a string for safe YAML double-quoted string value. Strips control
|
||||
* chars (including newlines) up front — a stray `\n` in the user's topic
|
||||
* would otherwise terminate the line and corrupt the rest of the frontmatter.
|
||||
*/
|
||||
function escapeYamlString(str: string): string {
|
||||
return (
|
||||
str
|
||||
// eslint-disable-next-line no-control-regex
|
||||
.replace(/[\x00-\x1F\x7F]/g, " ")
|
||||
.replace(/\\/g, "\\\\")
|
||||
.replace(/"/g, '\\"')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inverse of `escapeYamlString` for the values our hand-rolled frontmatter
|
||||
* parser extracts. Only handles the two escapes we emit (`\\` and `\"`).
|
||||
*/
|
||||
function unescapeYamlString(str: string): string {
|
||||
let out = "";
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const c = str[i];
|
||||
if (c === "\\" && i + 1 < str.length) {
|
||||
const next = str[i + 1];
|
||||
if (next === "\\" || next === '"') {
|
||||
out += next;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out += c;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Backend-agnostic on-disk persistence for Agent Mode sessions. Mirrors the
|
||||
* legacy `ChatPersistenceManager` shape so a single `ChatHistoryPopover` can
|
||||
* render both lists, but with three differences:
|
||||
*
|
||||
* 1. Files are prefixed with `agent__` so they never collide with legacy
|
||||
* project-prefixed (`{projectId}__`) or unprefixed chats.
|
||||
* 2. Frontmatter records `mode: agent` and `backendId: <id>` so the loader
|
||||
* can route a history click to the right backend.
|
||||
* 3. No project / AI-topic generation — Agent Mode has no project concept
|
||||
* and no chain manager to generate titles with.
|
||||
*
|
||||
* Sessions with zero visible messages are never written.
|
||||
*/
|
||||
export class AgentChatPersistenceManager {
|
||||
constructor(private readonly app: App) {}
|
||||
|
||||
/**
|
||||
* Save the supplied messages to disk. Returns the resulting file (or the
|
||||
* existing path on hidden-directory writes), or `null` when the session has
|
||||
* nothing user-visible to persist.
|
||||
*
|
||||
* `existingPath` lets the caller pin updates to a previously-saved file so
|
||||
* they're applied even if the messages list shrinks below the original
|
||||
* `firstMessageEpoch`. When omitted, the file is matched by epoch.
|
||||
*/
|
||||
async saveSession(
|
||||
messages: AgentChatMessage[],
|
||||
backendId: BackendId,
|
||||
options?: { label?: string | null; modelKey?: string; existingPath?: string }
|
||||
): Promise<{ path: string } | null> {
|
||||
if (messages.length === 0) return null;
|
||||
|
||||
try {
|
||||
const settings = getSettings();
|
||||
const chatContent = this.formatChatContent(messages);
|
||||
const firstMessageEpoch = messages[0].timestamp?.epoch ?? Date.now();
|
||||
|
||||
await ensureFolderExists(settings.defaultSaveFolder);
|
||||
|
||||
const existingFile = options?.existingPath
|
||||
? this.resolveExistingFile(options.existingPath)
|
||||
: null;
|
||||
const existingMeta = existingFile ? await this.readExistingMeta(existingFile) : {};
|
||||
|
||||
const preferredFileName = existingFile
|
||||
? existingFile.path
|
||||
: this.generateFileName(messages, firstMessageEpoch, existingMeta.topic);
|
||||
|
||||
const noteContent = this.generateNoteContent({
|
||||
chatContent,
|
||||
firstMessageEpoch,
|
||||
backendId,
|
||||
topic: existingMeta.topic,
|
||||
label: options?.label ?? existingMeta.label,
|
||||
modelKey: options?.modelKey,
|
||||
lastAccessedAt: existingMeta.lastAccessedAt,
|
||||
});
|
||||
|
||||
if (existingFile && isInVaultCache(this.app, existingFile.path)) {
|
||||
await this.app.vault.modify(existingFile, noteContent);
|
||||
return { path: existingFile.path };
|
||||
}
|
||||
|
||||
if (
|
||||
!isInVaultCache(this.app, preferredFileName) &&
|
||||
(await this.app.vault.adapter.exists(preferredFileName))
|
||||
) {
|
||||
await this.app.vault.adapter.write(preferredFileName, noteContent);
|
||||
return { path: preferredFileName };
|
||||
}
|
||||
|
||||
try {
|
||||
const created = await this.app.vault.create(preferredFileName, noteContent);
|
||||
return { path: created.path };
|
||||
} catch (err) {
|
||||
if (isFileAlreadyExistsError(err)) {
|
||||
await this.app.vault.adapter.write(preferredFileName, noteContent);
|
||||
return { path: preferredFileName };
|
||||
}
|
||||
if (isNameTooLongError(err)) {
|
||||
logWarn("[AgentChatPersistenceManager] Filename too long, falling back to minimal name");
|
||||
const fallback = `${settings.defaultSaveFolder}/${AGENT_FILENAME_PREFIX}chat-${firstMessageEpoch}.md`;
|
||||
try {
|
||||
const created = await this.app.vault.create(fallback, noteContent);
|
||||
return { path: created.path };
|
||||
} catch (fallbackErr) {
|
||||
if (isFileAlreadyExistsError(fallbackErr)) {
|
||||
await this.app.vault.adapter.write(fallback, noteContent);
|
||||
return { path: fallback };
|
||||
}
|
||||
throw fallbackErr;
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
} catch (error) {
|
||||
logError("[AgentChatPersistenceManager] Error saving session:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a saved agent chat file back into `AgentChatMessage`s and routing
|
||||
* info. Tool/plan/thought parts are not restored — the markdown format only
|
||||
* preserves sender + text (display-only history, mirroring legacy mode).
|
||||
*/
|
||||
async loadFile(file: TFile): Promise<LoadedAgentChat> {
|
||||
let content: string;
|
||||
try {
|
||||
content = await this.app.vault.read(file);
|
||||
} catch {
|
||||
content = await this.app.vault.adapter.read(file.path);
|
||||
}
|
||||
|
||||
const { frontmatter, body } = this.splitFrontmatter(content);
|
||||
const backendId = (frontmatter.backendId ?? "").trim();
|
||||
if (!backendId) {
|
||||
throw new Error(`Missing backendId in agent chat frontmatter: ${file.path}`);
|
||||
}
|
||||
const topic = frontmatter.topic?.trim() || undefined;
|
||||
const label = frontmatter.agentLabel?.trim() || undefined;
|
||||
const messages = this.parseChatBody(body);
|
||||
|
||||
logInfo(
|
||||
`[AgentChatPersistenceManager] Loaded ${messages.length} messages from ${file.path} (backend=${backendId})`
|
||||
);
|
||||
return { messages, backendId, topic, label };
|
||||
}
|
||||
|
||||
/**
|
||||
* List every persisted Agent Mode chat file (across all backends). Filters
|
||||
* by the `agent__` filename prefix so the result is backend-agnostic and
|
||||
* never collides with legacy or project chats.
|
||||
*/
|
||||
async getAgentChatHistoryFiles(): Promise<TFile[]> {
|
||||
const settings = getSettings();
|
||||
const files = await listMarkdownFiles(this.app, settings.defaultSaveFolder);
|
||||
return files.filter((file) => file.basename.startsWith(AGENT_FILENAME_PREFIX));
|
||||
}
|
||||
|
||||
/** Update the user-visible topic in frontmatter. */
|
||||
async updateTopic(fileId: string, newTopic: string): Promise<void> {
|
||||
await patchFrontmatter(this.app, fileId, { topic: newTopic.trim() });
|
||||
}
|
||||
|
||||
async deleteFile(fileId: string): Promise<void> {
|
||||
const file = this.app.vault.getAbstractFileByPath(fileId);
|
||||
if (file) {
|
||||
await trashFile(this.app, file);
|
||||
new Notice("Chat moved to trash.");
|
||||
return;
|
||||
}
|
||||
if (await this.app.vault.adapter.exists(fileId)) {
|
||||
await this.app.vault.adapter.remove(fileId);
|
||||
new Notice("Chat deleted.");
|
||||
return;
|
||||
}
|
||||
throw new Error("Chat file not found.");
|
||||
}
|
||||
|
||||
private resolveExistingFile(path: string): TFile | null {
|
||||
const file = this.app.vault.getAbstractFileByPath(path);
|
||||
return file instanceof TFile ? file : null;
|
||||
}
|
||||
|
||||
private async readExistingMeta(file: TFile): Promise<ExistingMeta> {
|
||||
const cached = this.app.metadataCache.getFileCache(file)?.frontmatter;
|
||||
if (cached) {
|
||||
return {
|
||||
topic: cached.topic,
|
||||
label: cached.agentLabel,
|
||||
lastAccessedAt:
|
||||
typeof cached.lastAccessedAt === "number" ? cached.lastAccessedAt : undefined,
|
||||
};
|
||||
}
|
||||
try {
|
||||
const fm = await readFrontmatterViaAdapter(this.app, file.path);
|
||||
if (!fm) return {};
|
||||
const lastAccessed = fm.lastAccessedAt ? Number(fm.lastAccessedAt) : undefined;
|
||||
return {
|
||||
topic: fm.topic,
|
||||
label: fm.agentLabel,
|
||||
lastAccessedAt: lastAccessed && Number.isFinite(lastAccessed) ? lastAccessed : undefined,
|
||||
};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
private formatChatContent(messages: AgentChatMessage[]): string {
|
||||
return messages
|
||||
.map((m) => {
|
||||
const ts = m.timestamp ? m.timestamp.display : "Unknown time";
|
||||
return `**${m.sender}**: ${m.message}\n[Timestamp: ${ts}]`;
|
||||
})
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
private parseChatBody(body: string): AgentChatMessage[] {
|
||||
const messages: AgentChatMessage[] = [];
|
||||
const pattern = /\*\*(user|ai)\*\*: ([\s\S]*?)(?=(?:\n\*\*(?:user|ai)\*\*: )|$)/g;
|
||||
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = pattern.exec(body)) !== null) {
|
||||
const sender = match[1] === "user" ? USER_SENDER : AI_SENDER;
|
||||
const fullContent = match[2].trim();
|
||||
|
||||
const lines = fullContent.split("\n");
|
||||
let endIndex = lines.length;
|
||||
let timestampStr = "Unknown time";
|
||||
|
||||
if (lines[endIndex - 1]?.startsWith("[Timestamp: ")) {
|
||||
const tsMatch = lines[endIndex - 1].match(/\[Timestamp: (.*?)\]/);
|
||||
if (tsMatch) {
|
||||
timestampStr = tsMatch[1];
|
||||
endIndex--;
|
||||
}
|
||||
}
|
||||
|
||||
const messageText = lines.slice(0, endIndex).join("\n").trim();
|
||||
|
||||
let timestamp: FormattedDateTime | null = null;
|
||||
if (timestampStr !== "Unknown time") {
|
||||
const date = new Date(timestampStr);
|
||||
if (!isNaN(date.getTime())) {
|
||||
timestamp = {
|
||||
epoch: date.getTime(),
|
||||
display: timestampStr,
|
||||
fileName: "",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Deterministic id: stable across reloads so React keeps message
|
||||
// identity when the UI re-renders the same loaded chat. Uses the
|
||||
// message's own epoch when present, falling back to the index.
|
||||
const id = timestamp
|
||||
? `loaded-${messages.length}-${timestamp.epoch}`
|
||||
: `loaded-${messages.length}`;
|
||||
messages.push({
|
||||
id,
|
||||
message: messageText,
|
||||
sender,
|
||||
isVisible: true,
|
||||
timestamp,
|
||||
});
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
private splitFrontmatter(content: string): {
|
||||
frontmatter: Record<string, string>;
|
||||
body: string;
|
||||
} {
|
||||
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
||||
if (!match) return { frontmatter: {}, body: content };
|
||||
const frontmatter: Record<string, string> = {};
|
||||
for (const line of match[1].split("\n")) {
|
||||
const m = line.match(/^(\w+):\s*(.+)/);
|
||||
if (!m) continue;
|
||||
const raw = m[2].trim();
|
||||
// Unquote and unescape: only double-quoted values were escaped on save,
|
||||
// so single-quoted / unquoted values are returned verbatim.
|
||||
let value: string;
|
||||
if (raw.startsWith('"') && raw.endsWith('"') && raw.length >= 2) {
|
||||
value = unescapeYamlString(raw.slice(1, -1));
|
||||
} else if (raw.startsWith("'") && raw.endsWith("'") && raw.length >= 2) {
|
||||
value = raw.slice(1, -1);
|
||||
} else {
|
||||
value = raw;
|
||||
}
|
||||
frontmatter[m[1]] = value;
|
||||
}
|
||||
return { frontmatter, body: content.slice(match[0].length).trim() };
|
||||
}
|
||||
|
||||
private generateFileName(
|
||||
messages: AgentChatMessage[],
|
||||
firstMessageEpoch: number,
|
||||
topic?: string
|
||||
): string {
|
||||
const settings = getSettings();
|
||||
const formatted = formatDateTime(new Date(firstMessageEpoch));
|
||||
const timestampFileName = formatted.fileName;
|
||||
|
||||
let topicForFilename: string;
|
||||
if (topic) {
|
||||
topicForFilename = topic;
|
||||
} else {
|
||||
const firstUser = messages.find((m) => m.sender === USER_SENDER);
|
||||
topicForFilename = firstUser
|
||||
? firstUser.message
|
||||
.replace(/\[\[([^\]]+)\]\]/g, "$1")
|
||||
.replace(/[{}[\]]/g, "")
|
||||
.split(/\s+/)
|
||||
.slice(0, 10)
|
||||
.join(" ")
|
||||
// eslint-disable-next-line no-control-regex
|
||||
.replace(/[\\/:*?"<>|\x00-\x1F]/g, "")
|
||||
.trim() || "Untitled Agent Chat"
|
||||
: "Untitled Agent Chat";
|
||||
}
|
||||
|
||||
let customFileName = settings.defaultConversationNoteName || "{$date}_{$time}__{$topic}";
|
||||
const filePrefix = AGENT_FILENAME_PREFIX;
|
||||
|
||||
const extensionBytes = getUtf8ByteLength(".md");
|
||||
const filePrefixBytes = getUtf8ByteLength(filePrefix);
|
||||
|
||||
const formatOverhead = customFileName
|
||||
.replace("{$topic}", "")
|
||||
.replace("{$date}", timestampFileName.split("_")[0])
|
||||
.replace("{$time}", timestampFileName.split("_")[1]);
|
||||
const formatOverheadBytes = getUtf8ByteLength(formatOverhead);
|
||||
|
||||
const topicByteBudget = Math.max(
|
||||
20,
|
||||
SAFE_FILENAME_BYTE_LIMIT - extensionBytes - filePrefixBytes - formatOverheadBytes
|
||||
);
|
||||
|
||||
const topicWithUnderscores = topicForFilename.replace(/\s+/g, "_");
|
||||
const truncatedTopic = truncateToByteLimit(topicWithUnderscores, topicByteBudget);
|
||||
|
||||
customFileName = customFileName
|
||||
.replace("{$topic}", truncatedTopic)
|
||||
.replace("{$date}", timestampFileName.split("_")[0])
|
||||
.replace("{$time}", timestampFileName.split("_")[1]);
|
||||
|
||||
const sanitizedFileName = customFileName
|
||||
.replace(/\[\[([^\]]+)\]\]/g, "$1")
|
||||
.replace(/[{}[\]]/g, "_")
|
||||
// eslint-disable-next-line no-control-regex
|
||||
.replace(/[\\/:*?"<>|\x00-\x1F]/g, "_");
|
||||
|
||||
const baseNameWithPrefix = `${filePrefix}${sanitizedFileName}.md`;
|
||||
if (getUtf8ByteLength(baseNameWithPrefix) > SAFE_FILENAME_BYTE_LIMIT) {
|
||||
const availableForBasename = SAFE_FILENAME_BYTE_LIMIT - extensionBytes - filePrefixBytes;
|
||||
const truncatedBasename = truncateToByteLimit(sanitizedFileName, availableForBasename);
|
||||
return `${settings.defaultSaveFolder}/${filePrefix}${truncatedBasename}.md`;
|
||||
}
|
||||
|
||||
return `${settings.defaultSaveFolder}/${baseNameWithPrefix}`;
|
||||
}
|
||||
|
||||
private generateNoteContent(args: {
|
||||
chatContent: string;
|
||||
firstMessageEpoch: number;
|
||||
backendId: BackendId;
|
||||
topic?: string;
|
||||
label?: string | null;
|
||||
modelKey?: string;
|
||||
lastAccessedAt?: number;
|
||||
}): string {
|
||||
const settings = getSettings();
|
||||
const lines: string[] = [
|
||||
"---",
|
||||
`epoch: ${args.firstMessageEpoch}`,
|
||||
`mode: ${AGENT_CHAT_MODE}`,
|
||||
`backendId: ${args.backendId}`,
|
||||
];
|
||||
if (args.topic) lines.push(`topic: "${escapeYamlString(args.topic)}"`);
|
||||
if (args.label) lines.push(`agentLabel: "${escapeYamlString(args.label)}"`);
|
||||
if (args.modelKey) lines.push(`modelKey: "${escapeYamlString(args.modelKey)}"`);
|
||||
if (args.lastAccessedAt) lines.push(`lastAccessedAt: ${args.lastAccessedAt}`);
|
||||
lines.push("tags:");
|
||||
lines.push(` - ${settings.defaultConversationTag}`);
|
||||
lines.push("---");
|
||||
lines.push("");
|
||||
lines.push(args.chatContent);
|
||||
return lines.join("\n");
|
||||
}
|
||||
}
|
||||
151
src/agentMode/session/AgentChatUIState.ts
Normal file
151
src/agentMode/session/AgentChatUIState.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
import { logError, logWarn } from "@/logger";
|
||||
import type { AgentChatBackend } from "@/agentMode/session/AgentChatBackend";
|
||||
import type { AgentSession } from "@/agentMode/session/AgentSession";
|
||||
import type {
|
||||
AgentChatMessage,
|
||||
BackendState,
|
||||
CurrentPlan,
|
||||
PlanDecisionAction,
|
||||
} from "@/agentMode/session/types";
|
||||
import type { MessageContext } from "@/types/message";
|
||||
|
||||
/**
|
||||
* `AgentChatBackend` implementation backed by an `AgentSession`. The Agent
|
||||
* Mode UI tree consumes this exclusively — it knows nothing about the legacy
|
||||
* `ChatUIState` / `ChatManager` stack.
|
||||
*
|
||||
* Edit, regenerate, and persistence operations are intentionally absent —
|
||||
* they don't have ACP semantics and Agent Mode chat persistence is deferred.
|
||||
*/
|
||||
export class AgentChatUIState implements AgentChatBackend {
|
||||
private listeners = new Set<() => void>();
|
||||
|
||||
constructor(private readonly session: AgentSession) {
|
||||
// Forward message, status, and model changes. The chat UI gates the
|
||||
// send button on `isStarting()`, so it needs to re-render when status
|
||||
// transitions out of `"starting"`.
|
||||
this.session.subscribe({
|
||||
onMessagesChanged: () => this.notifyListeners(),
|
||||
onStatusChanged: () => this.notifyListeners(),
|
||||
onModelChanged: () => this.notifyListeners(),
|
||||
onCurrentPlanChanged: () => this.notifyListeners(),
|
||||
});
|
||||
}
|
||||
|
||||
subscribe(listener: () => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
private notifyListeners(): void {
|
||||
for (const l of this.listeners) {
|
||||
try {
|
||||
l();
|
||||
} catch (e) {
|
||||
logWarn("[AgentChatUIState] listener threw", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a user message and kick off the ACP turn. Returns the new user
|
||||
* message id synchronously plus a `turn` promise the caller can await for
|
||||
* loading-state lifecycle (Stop button, input lock).
|
||||
*/
|
||||
sendMessage(
|
||||
text: string,
|
||||
context?: MessageContext,
|
||||
content?: unknown[]
|
||||
): { id: string; turn: Promise<void> } {
|
||||
const { userMessageId, turn } = this.session.sendPrompt(text, context, content);
|
||||
this.notifyListeners();
|
||||
const wrapped = turn.then(
|
||||
() => undefined,
|
||||
(err) => {
|
||||
logError("[AgentMode] turn failed", err);
|
||||
}
|
||||
);
|
||||
return { id: userMessageId, turn: wrapped };
|
||||
}
|
||||
|
||||
async cancel(): Promise<void> {
|
||||
await this.session.cancel();
|
||||
}
|
||||
|
||||
async deleteMessage(id: string): Promise<boolean> {
|
||||
// Refuse delete during an in-flight turn: the placeholder assistant
|
||||
// message is what streaming notifications target, and removing it would
|
||||
// leave the session writing into a vanished id.
|
||||
const status = this.session.getStatus();
|
||||
if (status === "running" || status === "awaiting_permission") {
|
||||
logWarn("[AgentChatUIState] delete refused while turn is in flight");
|
||||
return false;
|
||||
}
|
||||
const ok = this.session.store.deleteMessage(id);
|
||||
if (ok) this.notifyListeners();
|
||||
return ok;
|
||||
}
|
||||
|
||||
clearMessages(): void {
|
||||
this.session.store.clear();
|
||||
this.notifyListeners();
|
||||
}
|
||||
|
||||
getMessages(): AgentChatMessage[] {
|
||||
return this.session.store.getDisplayMessages();
|
||||
}
|
||||
|
||||
isStarting(): boolean {
|
||||
return this.session.getStatus() === "starting";
|
||||
}
|
||||
|
||||
getBackendState(): BackendState | null {
|
||||
return this.session.getState();
|
||||
}
|
||||
|
||||
canSwitchModel(): boolean | null {
|
||||
return this.session.canSwitchModel();
|
||||
}
|
||||
|
||||
canSwitchEffort(): boolean | null {
|
||||
return this.session.canSwitchEffort();
|
||||
}
|
||||
|
||||
canSwitchMode(): boolean | null {
|
||||
return this.session.canSwitchMode();
|
||||
}
|
||||
|
||||
hasPendingPlanPermission(): boolean {
|
||||
return this.session.hasPendingPlanPermission();
|
||||
}
|
||||
|
||||
getCurrentPlan(): CurrentPlan | null {
|
||||
return this.session.getCurrentPlan();
|
||||
}
|
||||
|
||||
async resolvePlanProposal(
|
||||
proposalId: string,
|
||||
decision: PlanDecisionAction,
|
||||
feedbackText?: string
|
||||
): Promise<void> {
|
||||
const plan = this.session.getCurrentPlan();
|
||||
if (!plan || plan.id !== proposalId || plan.decision !== "pending") return;
|
||||
if (!plan.permissionGated || !plan.pendingToolCallId) {
|
||||
logWarn("[AgentChatUIState] non-gated plan card has no resolution path");
|
||||
return;
|
||||
}
|
||||
const trimmedFeedback = decision === "feedback" ? feedbackText?.trim() : undefined;
|
||||
// Resolve the underlying ACP permission. Approve unblocks the agent
|
||||
// and continues the same turn; reject denies with `"User declined"`;
|
||||
// feedback rides the typed text through the same deny `message` so
|
||||
// the agent revises in-turn instead of receiving a separate
|
||||
// follow-up prompt.
|
||||
this.session.resolvePlanProposalPermission(
|
||||
plan.pendingToolCallId,
|
||||
decision === "approve",
|
||||
trimmedFeedback
|
||||
);
|
||||
this.session.finalizePlanDecision(plan.id);
|
||||
this.notifyListeners();
|
||||
}
|
||||
}
|
||||
223
src/agentMode/session/AgentMessageStore.test.ts
Normal file
223
src/agentMode/session/AgentMessageStore.test.ts
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
import { AI_SENDER } from "@/constants";
|
||||
import { AgentMessagePart } from "@/agentMode/session/types";
|
||||
import { formatDateTime } from "@/utils";
|
||||
import { AgentMessageStore } from "./AgentMessageStore";
|
||||
|
||||
jest.mock("@/logger", () => ({
|
||||
logInfo: jest.fn(),
|
||||
logWarn: jest.fn(),
|
||||
logError: jest.fn(),
|
||||
}));
|
||||
|
||||
describe("AgentMessageStore", () => {
|
||||
const placeholder = () => ({
|
||||
message: "",
|
||||
sender: AI_SENDER,
|
||||
timestamp: formatDateTime(new Date()),
|
||||
isVisible: true as const,
|
||||
parts: [] as AgentMessagePart[],
|
||||
});
|
||||
|
||||
it("appendDisplayText accumulates streaming chunks", () => {
|
||||
const store = new AgentMessageStore();
|
||||
const id = store.addMessage(placeholder());
|
||||
store.appendDisplayText(id, "Hello, ");
|
||||
store.appendDisplayText(id, "world.");
|
||||
expect(store.getMessage(id)?.message).toBe("Hello, world.");
|
||||
});
|
||||
|
||||
it("appendDisplayText returns false for unknown message", () => {
|
||||
const store = new AgentMessageStore();
|
||||
expect(store.appendDisplayText("missing", "x")).toBe(false);
|
||||
});
|
||||
|
||||
it("appendAgentText folds successive chunks into one trailing text part", () => {
|
||||
const store = new AgentMessageStore();
|
||||
const id = store.addMessage(placeholder());
|
||||
store.appendAgentText(id, "Hello, ");
|
||||
store.appendAgentText(id, "world.");
|
||||
const msg = store.getMessage(id);
|
||||
const parts = msg?.parts ?? [];
|
||||
expect(parts).toHaveLength(1);
|
||||
expect(parts[0]).toEqual({ kind: "text", text: "Hello, world." });
|
||||
// Flat body stays in sync for persistence / search / error append.
|
||||
expect(msg?.message).toBe("Hello, world.");
|
||||
});
|
||||
|
||||
it("appendAgentText starts a new text part when interrupted by a tool call", () => {
|
||||
const store = new AgentMessageStore();
|
||||
const id = store.addMessage(placeholder());
|
||||
store.appendAgentText(id, "before");
|
||||
store.upsertAgentPart(id, {
|
||||
kind: "tool_call",
|
||||
id: "tc1",
|
||||
title: "Read README",
|
||||
status: "completed",
|
||||
});
|
||||
store.appendAgentText(id, "after");
|
||||
const parts = store.getMessage(id)?.parts ?? [];
|
||||
expect(parts.map((p) => p.kind)).toEqual(["text", "tool_call", "text"]);
|
||||
expect(parts[0]).toEqual({ kind: "text", text: "before" });
|
||||
expect(parts[2]).toEqual({ kind: "text", text: "after" });
|
||||
});
|
||||
|
||||
it("appendAgentText returns false for unknown message", () => {
|
||||
const store = new AgentMessageStore();
|
||||
expect(store.appendAgentText("missing", "x")).toBe(false);
|
||||
});
|
||||
|
||||
it("appendAgentThought folds successive chunks into one part", () => {
|
||||
const store = new AgentMessageStore();
|
||||
const id = store.addMessage(placeholder());
|
||||
store.appendAgentThought(id, "Thinking");
|
||||
store.appendAgentThought(id, " harder");
|
||||
const parts = store.getMessage(id)?.parts ?? [];
|
||||
expect(parts).toHaveLength(1);
|
||||
expect(parts[0]).toEqual({ kind: "thought", text: "Thinking harder" });
|
||||
});
|
||||
|
||||
it("upsertAgentPart appends new tool_call by toolCallId", () => {
|
||||
const store = new AgentMessageStore();
|
||||
const id = store.addMessage(placeholder());
|
||||
store.upsertAgentPart(id, {
|
||||
kind: "tool_call",
|
||||
id: "tc1",
|
||||
title: "Read README",
|
||||
status: "pending",
|
||||
});
|
||||
const parts = store.getMessage(id)?.parts ?? [];
|
||||
expect(parts).toHaveLength(1);
|
||||
expect(parts[0]).toMatchObject({ kind: "tool_call", id: "tc1", title: "Read README" });
|
||||
});
|
||||
|
||||
it("upsertAgentPart replaces existing tool_call when ids match", () => {
|
||||
const store = new AgentMessageStore();
|
||||
const id = store.addMessage(placeholder());
|
||||
store.upsertAgentPart(id, {
|
||||
kind: "tool_call",
|
||||
id: "tc1",
|
||||
title: "Read README",
|
||||
status: "pending",
|
||||
});
|
||||
store.upsertAgentPart(id, {
|
||||
kind: "tool_call",
|
||||
id: "tc1",
|
||||
title: "Read README",
|
||||
status: "completed",
|
||||
output: [{ type: "text", text: "ok" }],
|
||||
});
|
||||
const parts = store.getMessage(id)?.parts ?? [];
|
||||
expect(parts).toHaveLength(1);
|
||||
expect(parts[0]).toMatchObject({
|
||||
kind: "tool_call",
|
||||
id: "tc1",
|
||||
status: "completed",
|
||||
output: [{ type: "text", text: "ok" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("upsertAgentPart returns false when re-applying an identical snapshot", () => {
|
||||
const store = new AgentMessageStore();
|
||||
const id = store.addMessage(placeholder());
|
||||
const part: AgentMessagePart = {
|
||||
kind: "tool_call",
|
||||
id: "tc1",
|
||||
title: "Read README",
|
||||
status: "pending",
|
||||
};
|
||||
expect(store.upsertAgentPart(id, part)).toBe(true);
|
||||
expect(store.upsertAgentPart(id, { ...part })).toBe(false);
|
||||
});
|
||||
|
||||
it("upsertAgentPart compares large repeated tool outputs without duplicating", () => {
|
||||
const store = new AgentMessageStore();
|
||||
const id = store.addMessage(placeholder());
|
||||
const part: AgentMessagePart = {
|
||||
kind: "tool_call",
|
||||
id: "tc1",
|
||||
title: "Search",
|
||||
status: "completed",
|
||||
input: { query: "communication drill", nested: { text: "x".repeat(20_000) } },
|
||||
output: [
|
||||
{
|
||||
type: "text",
|
||||
text:
|
||||
"a".repeat(12_000) +
|
||||
"\n\n[Tool output truncated in Copilot UI: 8,000 characters omitted.]",
|
||||
truncated: true,
|
||||
originalLength: 20_000,
|
||||
omittedLength: 8_000,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(store.upsertAgentPart(id, part)).toBe(true);
|
||||
expect(
|
||||
store.upsertAgentPart(id, { ...part, output: part.output?.map((o) => ({ ...o })) })
|
||||
).toBe(false);
|
||||
expect(store.getMessage(id)?.parts).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("upsertAgentPart treats plan as singleton (replace, not duplicate)", () => {
|
||||
const store = new AgentMessageStore();
|
||||
const id = store.addMessage(placeholder());
|
||||
store.upsertAgentPart(id, {
|
||||
kind: "plan",
|
||||
entries: [{ content: "step 1", priority: "high", status: "pending" }],
|
||||
});
|
||||
store.upsertAgentPart(id, {
|
||||
kind: "plan",
|
||||
entries: [
|
||||
{ content: "step 1", priority: "high", status: "completed" },
|
||||
{ content: "step 2", priority: "medium", status: "pending" },
|
||||
],
|
||||
});
|
||||
const parts = store.getMessage(id)?.parts ?? [];
|
||||
const planParts = parts.filter((p) => p.kind === "plan");
|
||||
expect(planParts).toHaveLength(1);
|
||||
expect(planParts[0]).toMatchObject({
|
||||
kind: "plan",
|
||||
entries: expect.arrayContaining([
|
||||
expect.objectContaining({ content: "step 1", status: "completed" }),
|
||||
expect.objectContaining({ content: "step 2" }),
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
it("getDisplayMessages includes parts", () => {
|
||||
const store = new AgentMessageStore();
|
||||
const id = store.addMessage(placeholder());
|
||||
store.upsertAgentPart(id, {
|
||||
kind: "tool_call",
|
||||
id: "tc1",
|
||||
title: "x",
|
||||
status: "pending",
|
||||
});
|
||||
const msg = store.getDisplayMessages().find((m) => m.id === id);
|
||||
expect(msg?.parts).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("markMessageError flags the message and appends formatted error text", () => {
|
||||
const store = new AgentMessageStore();
|
||||
const id = store.addMessage({
|
||||
message: "partial reply",
|
||||
sender: AI_SENDER,
|
||||
timestamp: formatDateTime(new Date()),
|
||||
isVisible: true,
|
||||
});
|
||||
store.markMessageError(id, "boom");
|
||||
const msg = store.getMessage(id);
|
||||
expect(msg?.isErrorMessage).toBe(true);
|
||||
expect(msg?.message).toContain("partial reply");
|
||||
expect(msg?.message).toContain("**Error:** boom");
|
||||
});
|
||||
|
||||
it("truncateAfterMessageId drops everything after the target", () => {
|
||||
const store = new AgentMessageStore();
|
||||
const a = store.addMessage(placeholder());
|
||||
store.addMessage(placeholder());
|
||||
store.addMessage(placeholder());
|
||||
store.truncateAfterMessageId(a);
|
||||
expect(store.getDisplayMessages()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
394
src/agentMode/session/AgentMessageStore.ts
Normal file
394
src/agentMode/session/AgentMessageStore.ts
Normal file
|
|
@ -0,0 +1,394 @@
|
|||
import { logInfo } from "@/logger";
|
||||
import {
|
||||
AgentChatMessage,
|
||||
AgentMessagePart,
|
||||
AgentToolCallOutput,
|
||||
NewAgentChatMessage,
|
||||
StopReason,
|
||||
} from "@/agentMode/session/types";
|
||||
import { FormattedDateTime, MessageContext } from "@/types/message";
|
||||
import { formatDateTime } from "@/utils";
|
||||
|
||||
/**
|
||||
* Internal storage shape for one Agent Mode message. Mirrors `AgentChatMessage`
|
||||
* but uses `displayText` internally to keep the streaming append APIs explicit
|
||||
* and to leave room for future fields without changing the public type.
|
||||
*/
|
||||
interface StoredAgentMessage {
|
||||
id: string;
|
||||
displayText: string;
|
||||
sender: string;
|
||||
timestamp: FormattedDateTime;
|
||||
isVisible: boolean;
|
||||
isErrorMessage?: boolean;
|
||||
parts?: AgentMessagePart[];
|
||||
context?: MessageContext;
|
||||
content?: unknown[];
|
||||
turnStopReason?: StopReason;
|
||||
turnDurationMs?: number;
|
||||
}
|
||||
|
||||
const MAX_COMPARE_JSON_CHARS = 8_000;
|
||||
const MAX_COMPARE_EDGE_CHARS = 512;
|
||||
const MAX_COMPARE_TEXT_EDGE_CHARS = 128;
|
||||
|
||||
/**
|
||||
* Stable identity for an agent part. Tool calls key on `tool:<toolCallId>` so
|
||||
* a `tool_call_update` notification can find and replace the right entry.
|
||||
* `plan` parts are singletons per message so they key on the literal `"plan"`.
|
||||
* Thoughts have no key — they're folded by `appendAgentThought`.
|
||||
*/
|
||||
function agentPartId(part: AgentMessagePart): string | undefined {
|
||||
if (part.kind === "tool_call") return `tool:${part.id}`;
|
||||
if (part.kind === "plan") return "plan";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Structural equality for agent parts. Lets `upsertAgentPart` skip a
|
||||
* notify/re-render when the ACP transport resends a tool-call snapshot whose
|
||||
* fields haven't actually changed.
|
||||
*/
|
||||
function partsEqual(a: AgentMessagePart, b: AgentMessagePart): boolean {
|
||||
if (a === b) return true;
|
||||
if (a.kind !== b.kind) return false;
|
||||
|
||||
switch (a.kind) {
|
||||
case "text":
|
||||
if (b.kind !== "text") return false;
|
||||
return a.text === b.text;
|
||||
case "thought":
|
||||
if (b.kind !== "thought") return false;
|
||||
return a.text === b.text;
|
||||
case "plan":
|
||||
if (b.kind !== "plan") return false;
|
||||
return planEntriesEqual(a.entries, b.entries);
|
||||
case "tool_call":
|
||||
if (b.kind !== "tool_call") return false;
|
||||
return (
|
||||
a.id === b.id &&
|
||||
a.title === b.title &&
|
||||
a.toolKind === b.toolKind &&
|
||||
a.status === b.status &&
|
||||
a.vendorToolName === b.vendorToolName &&
|
||||
a.parentToolCallId === b.parentToolCallId &&
|
||||
boundedValueEqual(a.input, b.input) &&
|
||||
locationsEqual(a.locations, b.locations) &&
|
||||
toolOutputsEqual(a.output, b.output)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Compare plan entries without stringifying the whole part object. */
|
||||
function planEntriesEqual(
|
||||
a: Extract<AgentMessagePart, { kind: "plan" }>["entries"],
|
||||
b: Extract<AgentMessagePart, { kind: "plan" }>["entries"]
|
||||
): boolean {
|
||||
if (a === b) return true;
|
||||
if (a.length !== b.length) return false;
|
||||
return a.every(
|
||||
(entry, index) =>
|
||||
entry.content === b[index].content &&
|
||||
entry.priority === b[index].priority &&
|
||||
entry.status === b[index].status
|
||||
);
|
||||
}
|
||||
|
||||
/** Compare tool locations by their scalar fields. */
|
||||
function locationsEqual(
|
||||
a: Extract<AgentMessagePart, { kind: "tool_call" }>["locations"],
|
||||
b: Extract<AgentMessagePart, { kind: "tool_call" }>["locations"]
|
||||
): boolean {
|
||||
if (a === b) return true;
|
||||
if (!a || !b) return a === b;
|
||||
if (a.length !== b.length) return false;
|
||||
return a.every((loc, index) => loc.path === b[index].path && loc.line === b[index].line);
|
||||
}
|
||||
|
||||
/** Compare rendered tool outputs with bounded string work. */
|
||||
function toolOutputsEqual(
|
||||
a: AgentToolCallOutput[] | undefined,
|
||||
b: AgentToolCallOutput[] | undefined
|
||||
): boolean {
|
||||
if (a === b) return true;
|
||||
if (!a || !b) return a === b;
|
||||
if (a.length !== b.length) return false;
|
||||
|
||||
return a.every((output, index) => {
|
||||
const other = b[index];
|
||||
if (output.type !== other.type) return false;
|
||||
if (output.type === "diff" && other.type === "diff") {
|
||||
return (
|
||||
output.path === other.path &&
|
||||
output.oldText === other.oldText &&
|
||||
output.newText === other.newText
|
||||
);
|
||||
}
|
||||
if (output.type === "text" && other.type === "text") {
|
||||
return (
|
||||
output.truncated === other.truncated &&
|
||||
output.originalLength === other.originalLength &&
|
||||
output.omittedLength === other.omittedLength &&
|
||||
textFingerprint(output.text) === textFingerprint(other.text)
|
||||
);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
/** Compare arbitrary tool input with bounded stringify work. */
|
||||
function boundedValueEqual(a: unknown, b: unknown): boolean {
|
||||
if (a === b) return true;
|
||||
return valueFingerprint(a) === valueFingerprint(b);
|
||||
}
|
||||
|
||||
/** Create a stable, bounded comparison key for arbitrary JSON-like values. */
|
||||
function valueFingerprint(value: unknown): string {
|
||||
let json: string;
|
||||
try {
|
||||
json = JSON.stringify(value);
|
||||
} catch {
|
||||
json = String(value);
|
||||
}
|
||||
if (json.length <= MAX_COMPARE_JSON_CHARS) return json;
|
||||
return `${json.length}:${json.slice(0, MAX_COMPARE_EDGE_CHARS)}:${json.slice(
|
||||
-MAX_COMPARE_EDGE_CHARS
|
||||
)}`;
|
||||
}
|
||||
|
||||
/** Create a bounded comparison key for long text outputs. */
|
||||
function textFingerprint(text: string): string {
|
||||
if (text.length <= MAX_COMPARE_TEXT_EDGE_CHARS * 2) return text;
|
||||
return `${text.length}:${text.slice(0, MAX_COMPARE_TEXT_EDGE_CHARS)}:${text.slice(
|
||||
-MAX_COMPARE_TEXT_EDGE_CHARS
|
||||
)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single source of truth for one Agent Mode chat session. The UI subscribes
|
||||
* via the surrounding `AgentSession`, the session writes streamed updates
|
||||
* here, and computed views feed React.
|
||||
*
|
||||
* Distinct from the legacy `MessageRepository` because Agent Mode messages
|
||||
* have structured `parts` (tool calls, thoughts, plans) and Agent Mode has no
|
||||
* concept of `processedText` / `contextEnvelope` — ACP owns the model's view
|
||||
* of the conversation.
|
||||
*/
|
||||
export class AgentMessageStore {
|
||||
private messages: StoredAgentMessage[] = [];
|
||||
|
||||
private generateId(): string {
|
||||
return `msg-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
|
||||
}
|
||||
|
||||
/** Add a message; returns its assigned id. */
|
||||
addMessage(message: NewAgentChatMessage): string {
|
||||
const id = message.id || this.generateId();
|
||||
const timestamp = message.timestamp || formatDateTime(new Date());
|
||||
this.messages.push({
|
||||
id,
|
||||
displayText: message.message,
|
||||
sender: message.sender,
|
||||
timestamp,
|
||||
context: message.context,
|
||||
isVisible: message.isVisible !== false,
|
||||
isErrorMessage: message.isErrorMessage,
|
||||
content: message.content,
|
||||
parts: message.parts,
|
||||
turnStopReason: message.turnStopReason,
|
||||
turnDurationMs: message.turnDurationMs,
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp a finished turn's `stopReason` and frozen `durationMs` onto its
|
||||
* placeholder assistant message. Returns false if the message is missing or
|
||||
* already marked complete — the latter lets callers skip notifying.
|
||||
*/
|
||||
markTurnComplete(id: string, stopReason: StopReason, durationMs: number): boolean {
|
||||
const msg = this.messages.find((m) => m.id === id);
|
||||
if (!msg) return false;
|
||||
if (msg.turnStopReason !== undefined) return false;
|
||||
msg.turnStopReason = stopReason;
|
||||
msg.turnDurationMs = durationMs;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append text to a message body. Used to stream `agent_message_chunk`
|
||||
* updates into the placeholder assistant message. Returns false if the
|
||||
* target message is missing (the session was likely reset mid-turn).
|
||||
*/
|
||||
appendDisplayText(id: string, chunk: string): boolean {
|
||||
const msg = this.messages.find((m) => m.id === id);
|
||||
if (!msg) return false;
|
||||
msg.displayText += chunk;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append assistant prose to the trailing `text` part, creating one if the
|
||||
* last part is a different kind. Mirrors `appendAgentThought` so streamed
|
||||
* `agent_message_chunk`s interleave chronologically with tool calls and
|
||||
* thoughts inside `parts[]`. Also keeps `displayText` in sync so callers
|
||||
* that read the flattened body (persistence, search, error append) stay
|
||||
* correct.
|
||||
*/
|
||||
appendAgentText(id: string, text: string): boolean {
|
||||
const msg = this.messages.find((m) => m.id === id);
|
||||
if (!msg) return false;
|
||||
msg.displayText += text;
|
||||
if (!msg.parts) msg.parts = [];
|
||||
const last = msg.parts[msg.parts.length - 1];
|
||||
if (last && last.kind === "text") {
|
||||
last.text += text;
|
||||
} else {
|
||||
msg.parts.push({ kind: "text", text });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append text to the trailing `thought` part, creating one if absent. Folds
|
||||
* multiple `agent_thought_chunk` updates into a single collapsible block
|
||||
* instead of one block per chunk.
|
||||
*/
|
||||
appendAgentThought(id: string, text: string): boolean {
|
||||
const msg = this.messages.find((m) => m.id === id);
|
||||
if (!msg) return false;
|
||||
if (!msg.parts) msg.parts = [];
|
||||
const last = msg.parts[msg.parts.length - 1];
|
||||
if (last && last.kind === "thought") {
|
||||
last.text += text;
|
||||
} else {
|
||||
msg.parts.push({ kind: "thought", text });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace an agent part by its stable identity (see `agentPartId`). Appends
|
||||
* if no existing part matches. Returns false when the message is missing OR
|
||||
* when the new part is structurally identical to the existing one — callers
|
||||
* use this to skip redundant React notifications.
|
||||
*/
|
||||
upsertAgentPart(id: string, part: AgentMessagePart): boolean {
|
||||
const msg = this.messages.find((m) => m.id === id);
|
||||
if (!msg) return false;
|
||||
if (!msg.parts) msg.parts = [];
|
||||
const partId = agentPartId(part);
|
||||
if (partId !== undefined) {
|
||||
const idx = msg.parts.findIndex((p) => agentPartId(p) === partId);
|
||||
if (idx !== -1) {
|
||||
if (partsEqual(msg.parts[idx], part)) return false;
|
||||
msg.parts[idx] = part;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
msg.parts.push(part);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a message as an error and append the error text to its display body.
|
||||
* Used when a turn rejects mid-stream so the partial placeholder gets a
|
||||
* visible error instead of looking like a normal truncated reply.
|
||||
*/
|
||||
markMessageError(id: string, errorText: string): boolean {
|
||||
const msg = this.messages.find((m) => m.id === id);
|
||||
if (!msg) return false;
|
||||
msg.isErrorMessage = true;
|
||||
const suffix = msg.displayText.length > 0 ? "\n\n" : "";
|
||||
msg.displayText += `${suffix}**Error:** ${errorText}`;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an assistant placeholder has emitted any user-visible activity.
|
||||
* Used to distinguish a legitimate completed turn from a blank backend
|
||||
* response that would otherwise render as an empty assistant block.
|
||||
*/
|
||||
hasAssistantActivity(id: string): boolean {
|
||||
const msg = this.messages.find((m) => m.id === id);
|
||||
if (!msg) return false;
|
||||
if (msg.displayText.trim().length > 0) return true;
|
||||
return (msg.parts ?? []).some((part) => {
|
||||
if (part.kind === "text" || part.kind === "thought") {
|
||||
return part.text.trim().length > 0;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
deleteMessage(id: string): boolean {
|
||||
const idx = this.messages.findIndex((m) => m.id === id);
|
||||
if (idx === -1) return false;
|
||||
this.messages.splice(idx, 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.messages = [];
|
||||
}
|
||||
|
||||
truncateAfterMessageId(messageId: string): void {
|
||||
const idx = this.messages.findIndex((m) => m.id === messageId);
|
||||
if (idx !== -1) {
|
||||
this.messages = this.messages.slice(0, idx + 1);
|
||||
}
|
||||
}
|
||||
|
||||
/** Visible messages, shaped for the UI. */
|
||||
getDisplayMessages(): AgentChatMessage[] {
|
||||
return this.messages.filter((m) => m.isVisible).map((m) => this.toAgentChatMessage(m));
|
||||
}
|
||||
|
||||
getMessage(id: string): AgentChatMessage | undefined {
|
||||
const msg = this.messages.find((m) => m.id === id);
|
||||
return msg ? this.toAgentChatMessage(msg) : undefined;
|
||||
}
|
||||
|
||||
loadMessages(messages: AgentChatMessage[]): void {
|
||||
this.clear();
|
||||
for (const msg of messages) {
|
||||
this.messages.push({
|
||||
id: msg.id || this.generateId(),
|
||||
displayText: msg.message,
|
||||
sender: msg.sender,
|
||||
timestamp: msg.timestamp || formatDateTime(new Date()),
|
||||
context: msg.context,
|
||||
isVisible: msg.isVisible !== false,
|
||||
isErrorMessage: msg.isErrorMessage,
|
||||
content: msg.content,
|
||||
parts: msg.parts,
|
||||
turnStopReason: msg.turnStopReason,
|
||||
turnDurationMs: msg.turnDurationMs,
|
||||
});
|
||||
}
|
||||
logInfo(`[AgentMessageStore] Loaded ${messages.length} messages`);
|
||||
}
|
||||
|
||||
getDebugInfo() {
|
||||
return {
|
||||
totalMessages: this.messages.length,
|
||||
visibleMessages: this.messages.filter((m) => m.isVisible).length,
|
||||
};
|
||||
}
|
||||
|
||||
private toAgentChatMessage(m: StoredAgentMessage): AgentChatMessage {
|
||||
return {
|
||||
id: m.id,
|
||||
message: m.displayText,
|
||||
sender: m.sender,
|
||||
timestamp: m.timestamp,
|
||||
isVisible: m.isVisible,
|
||||
context: m.context,
|
||||
isErrorMessage: m.isErrorMessage,
|
||||
content: m.content,
|
||||
parts: m.parts,
|
||||
turnStopReason: m.turnStopReason,
|
||||
turnDurationMs: m.turnDurationMs,
|
||||
};
|
||||
}
|
||||
}
|
||||
194
src/agentMode/session/AgentModelPreloader.ts
Normal file
194
src/agentMode/session/AgentModelPreloader.ts
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
import { logError, logInfo, logWarn } from "@/logger";
|
||||
import type CopilotPlugin from "@/main";
|
||||
import { getSettings } from "@/settings/model";
|
||||
import { App, FileSystemAdapter, Platform } from "obsidian";
|
||||
import { MethodUnsupportedError } from "./errors";
|
||||
import { backendStateSignature } from "./translateBackendState";
|
||||
import type { BackendDescriptor, BackendId, BackendProcess, BackendState } from "./types";
|
||||
|
||||
/**
|
||||
* Plugin-lifetime cache of per-backend session state. Backends expose
|
||||
* `BackendState` only as a side-effect of session creation / resume / load,
|
||||
* so without this preload the picker would show no entries for non-active
|
||||
* backends and would blink empty during the round-trip on a fresh session.
|
||||
*
|
||||
* Probes once per backend at startup: prefer resume of a persisted probe
|
||||
* sessionId, fall back to load, then to new (and persist the new id so the
|
||||
* next reload can reuse it — keeps the agent-side session store at one stale
|
||||
* entry per machine instead of growing with each reload).
|
||||
*/
|
||||
export class AgentModelPreloader {
|
||||
private readonly cache = new Map<BackendId, BackendState>();
|
||||
private readonly inflight = new Map<BackendId, Promise<void>>();
|
||||
private readonly listeners = new Set<() => void>();
|
||||
private disposed = false;
|
||||
|
||||
constructor(
|
||||
private readonly app: App,
|
||||
private readonly plugin: CopilotPlugin,
|
||||
private readonly resolveDescriptor: (id: BackendId) => BackendDescriptor | undefined
|
||||
) {}
|
||||
|
||||
getCachedBackendState(backendId: BackendId): BackendState | null {
|
||||
return this.cache.get(backendId) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the cached entry for `backendId`. No-op when the signature
|
||||
* is unchanged, to avoid spurious picker rebuilds.
|
||||
*/
|
||||
setCached(backendId: BackendId, state: BackendState): void {
|
||||
if (this.disposed) return;
|
||||
const prev = this.cache.get(backendId) ?? null;
|
||||
if (backendStateSignature(prev) === backendStateSignature(state)) return;
|
||||
this.cache.set(backendId, state);
|
||||
this.notify();
|
||||
}
|
||||
|
||||
/** Remove the cached entry for `backendId` after its backend is restarted. */
|
||||
clearCached(backendId: BackendId): void {
|
||||
if (this.disposed) return;
|
||||
if (!this.cache.delete(backendId)) return;
|
||||
this.notify();
|
||||
}
|
||||
|
||||
/** Best-effort probe; failures are logged and swallowed. Dedupes per backend. */
|
||||
preload(backendId: BackendId): Promise<void> {
|
||||
if (this.disposed) return Promise.resolve();
|
||||
const existing = this.inflight.get(backendId);
|
||||
if (existing) return existing;
|
||||
const promise = this.runProbe(backendId).finally(() => {
|
||||
this.inflight.delete(backendId);
|
||||
});
|
||||
this.inflight.set(backendId, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
subscribe(listener: () => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
shutdown(): void {
|
||||
this.disposed = true;
|
||||
this.cache.clear();
|
||||
this.inflight.clear();
|
||||
this.listeners.clear();
|
||||
}
|
||||
|
||||
private notify(): void {
|
||||
for (const l of this.listeners) {
|
||||
try {
|
||||
l();
|
||||
} catch (e) {
|
||||
logWarn("[AgentMode] preload listener threw", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async runProbe(backendId: BackendId): Promise<void> {
|
||||
if (Platform.isMobile) return;
|
||||
const adapter = this.app.vault.adapter;
|
||||
if (!(adapter instanceof FileSystemAdapter)) return;
|
||||
const cwd = adapter.getBasePath();
|
||||
|
||||
const descriptor = this.resolveDescriptor(backendId);
|
||||
if (!descriptor) {
|
||||
logWarn(`[AgentMode] preload skipped: unknown backend ${backendId}`);
|
||||
return;
|
||||
}
|
||||
if (descriptor.getInstallState(getSettings()).kind !== "ready") return;
|
||||
|
||||
const proc = descriptor.createBackendProcess({
|
||||
plugin: this.plugin,
|
||||
app: this.app,
|
||||
clientVersion: this.plugin.manifest.version,
|
||||
descriptor,
|
||||
});
|
||||
|
||||
try {
|
||||
await proc.start?.();
|
||||
const storedId = descriptor.getProbeSessionId?.(getSettings());
|
||||
const state = await this.fetchInitialState(proc, descriptor, backendId, storedId, cwd);
|
||||
if (this.disposed) return;
|
||||
if (state.model || state.mode) {
|
||||
this.setCached(backendId, state);
|
||||
logProbeResult(backendId, "session probe", state);
|
||||
} else {
|
||||
logInfo(`[AgentMode] preload ${backendId}: agent did not report any initial state`);
|
||||
}
|
||||
} catch (err) {
|
||||
logError(`[AgentMode] preload ${backendId} failed`, err);
|
||||
} finally {
|
||||
try {
|
||||
await proc.shutdown();
|
||||
} catch (e) {
|
||||
logWarn(`[AgentMode] preload ${backendId}: shutdown failed`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchInitialState(
|
||||
proc: BackendProcess,
|
||||
descriptor: BackendDescriptor,
|
||||
backendId: BackendId,
|
||||
storedId: string | undefined,
|
||||
cwd: string
|
||||
): Promise<BackendState> {
|
||||
type Strategy = {
|
||||
label: string;
|
||||
sessionId: string;
|
||||
run: () => Promise<{ sessionId: string; state: BackendState }>;
|
||||
};
|
||||
const strategies: Strategy[] = [];
|
||||
if (storedId) {
|
||||
strategies.push({
|
||||
label: `resumed probe session ${storedId}`,
|
||||
sessionId: storedId,
|
||||
run: () => proc.resumeSession({ sessionId: storedId, cwd, mcpServers: [] }),
|
||||
});
|
||||
strategies.push({
|
||||
label: `loaded probe session ${storedId}`,
|
||||
sessionId: storedId,
|
||||
run: () => proc.loadSession({ sessionId: storedId, cwd, mcpServers: [] }),
|
||||
});
|
||||
}
|
||||
|
||||
for (const { label, sessionId, run } of strategies) {
|
||||
try {
|
||||
proc.registerSessionHandler(sessionId, () => {});
|
||||
const resp = await run();
|
||||
logInfo(`[AgentMode] preload ${backendId}: ${label}`);
|
||||
return resp.state;
|
||||
} catch (err) {
|
||||
if (!(err instanceof MethodUnsupportedError)) {
|
||||
logWarn(`[AgentMode] preload ${backendId}: ${label} failed (will fall back)`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const resp = await proc.newSession({ cwd, mcpServers: [] });
|
||||
proc.registerSessionHandler(resp.sessionId, () => {});
|
||||
logInfo(`[AgentMode] preload ${backendId}: created probe session ${resp.sessionId}`);
|
||||
if (descriptor.persistProbeSessionId) {
|
||||
try {
|
||||
await descriptor.persistProbeSessionId(resp.sessionId, this.plugin);
|
||||
} catch (e) {
|
||||
logWarn(`[AgentMode] preload ${backendId}: persistProbeSessionId failed`, e);
|
||||
}
|
||||
}
|
||||
return resp.state;
|
||||
}
|
||||
}
|
||||
|
||||
function logProbeResult(backendId: BackendId, label: string, state: BackendState): void {
|
||||
const ids = state.model?.availableModels.map((m) => m.baseModelId).join(", ") ?? "";
|
||||
const modeOpts = state.mode?.options.map((o) => o.value).join(", ") ?? "";
|
||||
const currentBaseId = state.model?.current.baseModelId ?? "-";
|
||||
const currentEntry = state.model?.availableModels.find((e) => e.baseModelId === currentBaseId);
|
||||
const effortOpts = currentEntry?.effortOptions.map((o) => o.value ?? "default").join(", ") ?? "";
|
||||
logInfo(
|
||||
`[AgentMode] preload ${backendId} (${label}): models=[${ids}] (current=${currentBaseId}), ` +
|
||||
`mode=[${modeOpts}] effort=[${effortOpts}]`
|
||||
);
|
||||
}
|
||||
1408
src/agentMode/session/AgentSession.test.ts
Normal file
1408
src/agentMode/session/AgentSession.test.ts
Normal file
File diff suppressed because it is too large
Load diff
1206
src/agentMode/session/AgentSession.ts
Normal file
1206
src/agentMode/session/AgentSession.ts
Normal file
File diff suppressed because it is too large
Load diff
815
src/agentMode/session/AgentSessionManager.test.ts
Normal file
815
src/agentMode/session/AgentSessionManager.test.ts
Normal file
|
|
@ -0,0 +1,815 @@
|
|||
/**
|
||||
* Pool-semantics tests for AgentSessionManager. The shared backend
|
||||
* subprocess and the AgentSession factory are mocked so we can exercise
|
||||
* session-pool invariants without touching ACP or spawning a child process.
|
||||
*/
|
||||
import { FileSystemAdapter, App } from "obsidian";
|
||||
import { AgentSession } from "./AgentSession";
|
||||
import { AgentSessionManager } from "./AgentSessionManager";
|
||||
import { setSettings as mockedSetSettings } from "@/settings/model";
|
||||
import type { BackendDescriptor } from "./types";
|
||||
|
||||
jest.mock("@/logger", () => ({
|
||||
logInfo: jest.fn(),
|
||||
logWarn: jest.fn(),
|
||||
logError: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("@/settings/model", () => ({
|
||||
getSettings: jest.fn(() => ({
|
||||
agentMode: { activeBackend: "opencode", backends: {} },
|
||||
})),
|
||||
setSettings: jest.fn(),
|
||||
}));
|
||||
|
||||
let mockBackendIsRunning = true;
|
||||
const mockBackendShutdown = jest.fn(async () => undefined);
|
||||
const mockBackendStart = jest.fn(async () => undefined);
|
||||
const mockBackendExitListeners = new Set<() => void>();
|
||||
const mockSetPermissionPrompter = jest.fn();
|
||||
|
||||
function makeMockBackendProcess() {
|
||||
return {
|
||||
start: mockBackendStart,
|
||||
setPermissionPrompter: mockSetPermissionPrompter,
|
||||
onExit: (fn: () => void) => {
|
||||
mockBackendExitListeners.add(fn);
|
||||
return () => mockBackendExitListeners.delete(fn);
|
||||
},
|
||||
isRunning: () => mockBackendIsRunning,
|
||||
shutdown: mockBackendShutdown,
|
||||
};
|
||||
}
|
||||
|
||||
const mockSessionDispose = jest.fn(async () => undefined);
|
||||
const mockSessionCancel = jest.fn(async () => undefined);
|
||||
let nextBackendSessionId = 1;
|
||||
|
||||
interface MockSessionTestHandle {
|
||||
/** Drive the mock session's status the way the real session does. */
|
||||
setStatus(
|
||||
status: "starting" | "idle" | "running" | "awaiting_permission" | "error" | "closed"
|
||||
): void;
|
||||
}
|
||||
|
||||
const sessionTestHandles = new Map<string, MockSessionTestHandle>();
|
||||
|
||||
function getSessionTestHandle(session: AgentSession): MockSessionTestHandle {
|
||||
const handle = sessionTestHandles.get(session.internalId);
|
||||
if (!handle) throw new Error(`No test handle for ${session.internalId}`);
|
||||
return handle;
|
||||
}
|
||||
|
||||
function makeMockSession(overrides: {
|
||||
internalId: string;
|
||||
backendSessionId?: string;
|
||||
backendId: string;
|
||||
ready?: Promise<void>;
|
||||
}): AgentSession {
|
||||
const sessionId = overrides.backendSessionId ?? `backend-${nextBackendSessionId++}`;
|
||||
let status: "starting" | "idle" | "running" | "awaiting_permission" | "error" | "closed" = "idle";
|
||||
let needsAttention = false;
|
||||
const listeners = new Set<{
|
||||
onStatusChanged?: (s: typeof status) => void;
|
||||
onNeedsAttentionChanged?: (v: boolean) => void;
|
||||
}>();
|
||||
const session = {
|
||||
internalId: overrides.internalId,
|
||||
backendId: overrides.backendId,
|
||||
ready: overrides.ready ?? Promise.resolve(),
|
||||
getBackendSessionId: () => sessionId,
|
||||
getStatus: () => status,
|
||||
cancel: mockSessionCancel,
|
||||
dispose: mockSessionDispose,
|
||||
setModel: jest.fn(),
|
||||
setMode: jest.fn(),
|
||||
setConfigOption: jest.fn(),
|
||||
getLabel: () => null,
|
||||
setLabel: jest.fn(),
|
||||
subscribe: (l: Parameters<typeof listeners.add>[0]) => {
|
||||
listeners.add(l);
|
||||
return () => listeners.delete(l);
|
||||
},
|
||||
hasUserVisibleMessages: () => false,
|
||||
getState: () => null,
|
||||
getRawSnapshot: () => ({ models: null, modes: null, configOptions: null }),
|
||||
getNeedsAttention: () => needsAttention,
|
||||
markNeedsAttention: () => {
|
||||
if (needsAttention) return;
|
||||
needsAttention = true;
|
||||
for (const l of listeners) l.onNeedsAttentionChanged?.(true);
|
||||
},
|
||||
clearNeedsAttention: () => {
|
||||
if (!needsAttention) return;
|
||||
needsAttention = false;
|
||||
for (const l of listeners) l.onNeedsAttentionChanged?.(false);
|
||||
},
|
||||
} as unknown as AgentSession;
|
||||
sessionTestHandles.set(overrides.internalId, {
|
||||
setStatus: (next) => {
|
||||
if (status === next) return;
|
||||
status = next;
|
||||
for (const l of listeners) l.onStatusChanged?.(next);
|
||||
},
|
||||
});
|
||||
return session;
|
||||
}
|
||||
|
||||
const sessionCreateSpy = jest
|
||||
.spyOn(AgentSession, "start")
|
||||
.mockImplementation((opts) =>
|
||||
makeMockSession({ internalId: opts.internalId, backendId: opts.backendId })
|
||||
);
|
||||
|
||||
function buildApp(basePath = "/vault"): App {
|
||||
const adapter = new (FileSystemAdapter as unknown as new (basePath: string) => unknown)(basePath);
|
||||
return { vault: { adapter } } as unknown as App;
|
||||
}
|
||||
|
||||
function buildPlugin(): { manifest: { version: string } } {
|
||||
return { manifest: { version: "1.0.0" } };
|
||||
}
|
||||
|
||||
function buildDescriptor(): BackendDescriptor {
|
||||
return {
|
||||
id: "opencode",
|
||||
displayName: "opencode",
|
||||
getInstallState: jest.fn(),
|
||||
subscribeInstallState: jest.fn(),
|
||||
openInstallUI: jest.fn(),
|
||||
createBackendProcess: jest.fn(() => makeMockBackendProcess()),
|
||||
} as unknown as BackendDescriptor;
|
||||
}
|
||||
|
||||
function buildManager(): AgentSessionManager {
|
||||
const descriptor = buildDescriptor();
|
||||
const modelPreloader = {
|
||||
getCachedBackendState: jest.fn(() => null),
|
||||
preload: jest.fn(async () => undefined),
|
||||
subscribe: jest.fn(() => () => {}),
|
||||
shutdown: jest.fn(),
|
||||
setCached: jest.fn(),
|
||||
clearCached: jest.fn(),
|
||||
};
|
||||
return new AgentSessionManager(
|
||||
buildApp(),
|
||||
buildPlugin() as unknown as ConstructorParameters<typeof AgentSessionManager>[1],
|
||||
{
|
||||
permissionPrompter: jest.fn(),
|
||||
resolveDescriptor: (id) => (id === descriptor.id ? descriptor : undefined),
|
||||
modelPreloader: modelPreloader as unknown as ConstructorParameters<
|
||||
typeof AgentSessionManager
|
||||
>[2]["modelPreloader"],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockBackendIsRunning = true;
|
||||
mockBackendStart.mockClear();
|
||||
mockBackendShutdown.mockClear();
|
||||
mockSetPermissionPrompter.mockClear();
|
||||
mockBackendExitListeners.clear();
|
||||
mockSessionCancel.mockClear();
|
||||
mockSessionDispose.mockClear();
|
||||
sessionCreateSpy.mockClear();
|
||||
nextBackendSessionId = 1;
|
||||
});
|
||||
|
||||
describe("AgentSessionManager.createSession", () => {
|
||||
it("creates a session and sets it as the active one", async () => {
|
||||
const mgr = buildManager();
|
||||
const session = await mgr.createSession();
|
||||
expect(mgr.getSessions()).toEqual([session]);
|
||||
expect(mgr.getActiveSession()).toBe(session);
|
||||
expect(mgr.getActiveChatUIState()).not.toBeNull();
|
||||
expect(mgr.getChatUIState(session.internalId)).toBe(mgr.getActiveChatUIState());
|
||||
});
|
||||
|
||||
it("creating a second session sets it as active but keeps the first in the pool", async () => {
|
||||
const mgr = buildManager();
|
||||
const a = await mgr.createSession();
|
||||
const b = await mgr.createSession();
|
||||
expect(mgr.getSessions()).toEqual([a, b]);
|
||||
expect(mgr.getActiveSession()).toBe(b);
|
||||
});
|
||||
|
||||
it("two concurrent createSession calls each spawn their own session", async () => {
|
||||
const mgr = buildManager();
|
||||
const [a, b] = await Promise.all([mgr.createSession(), mgr.createSession()]);
|
||||
expect(a).not.toBe(b);
|
||||
expect(sessionCreateSpy).toHaveBeenCalledTimes(2);
|
||||
expect(mgr.getSessions()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("only spawns the backend once across multiple createSession calls", async () => {
|
||||
const mgr = buildManager();
|
||||
await mgr.createSession();
|
||||
await mgr.createSession();
|
||||
await mgr.createSession();
|
||||
expect(mockBackendStart).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("mirrors the new session's unified state into the preloader cache", async () => {
|
||||
const cache = new Map<string, unknown>();
|
||||
const modelPreloader = {
|
||||
getCachedBackendState: jest.fn((id: string) => cache.get(id) ?? null),
|
||||
preload: jest.fn(async () => undefined),
|
||||
subscribe: jest.fn(() => () => {}),
|
||||
shutdown: jest.fn(),
|
||||
setCached: jest.fn((id: string, state: unknown) => {
|
||||
cache.set(id, state);
|
||||
}),
|
||||
clearCached: jest.fn((id: string) => {
|
||||
cache.delete(id);
|
||||
}),
|
||||
};
|
||||
const descriptor = buildDescriptor();
|
||||
const mgr = new AgentSessionManager(
|
||||
buildApp(),
|
||||
buildPlugin() as unknown as ConstructorParameters<typeof AgentSessionManager>[1],
|
||||
{
|
||||
permissionPrompter: jest.fn(),
|
||||
resolveDescriptor: (id) => (id === descriptor.id ? descriptor : undefined),
|
||||
modelPreloader: modelPreloader as unknown as ConstructorParameters<
|
||||
typeof AgentSessionManager
|
||||
>[2]["modelPreloader"],
|
||||
}
|
||||
);
|
||||
|
||||
const modelEntry = {
|
||||
baseModelId: "anthropic/sonnet",
|
||||
name: "Claude Sonnet",
|
||||
provider: "anthropic",
|
||||
effortOptions: [],
|
||||
};
|
||||
const unified = {
|
||||
model: { current: { model: modelEntry, effort: null }, availableModels: [modelEntry] },
|
||||
mode: null,
|
||||
};
|
||||
sessionCreateSpy.mockImplementationOnce((opts) => {
|
||||
const s = makeMockSession({ internalId: opts.internalId, backendId: opts.backendId });
|
||||
(s as unknown as { getState: () => unknown }).getState = () => unified;
|
||||
return s;
|
||||
});
|
||||
|
||||
await mgr.createSession();
|
||||
expect(modelPreloader.setCached).toHaveBeenCalledWith("opencode", unified);
|
||||
expect(mgr.getCachedBackendState("opencode")).toBe(unified);
|
||||
|
||||
// Spawning a second session before its session/new resolves must not
|
||||
// overwrite the cached state with nulls.
|
||||
sessionCreateSpy.mockImplementationOnce((opts) =>
|
||||
makeMockSession({ internalId: opts.internalId, backendId: opts.backendId })
|
||||
);
|
||||
await mgr.createSession();
|
||||
expect(mgr.getCachedBackendState("opencode")).toBe(unified);
|
||||
});
|
||||
|
||||
it("a concurrent create that succeeds does not wipe a sibling create's lastError", async () => {
|
||||
const mgr = buildManager();
|
||||
// First call fails. Second call starts before first settles, so the
|
||||
// pre-fix code would have cleared `lastError` at the second call's start
|
||||
// and the failure surfaced by the first would be lost.
|
||||
sessionCreateSpy
|
||||
.mockImplementationOnce((opts) =>
|
||||
makeMockSession({
|
||||
internalId: opts.internalId,
|
||||
backendId: opts.backendId,
|
||||
// Failing session: ready rejects after a microtask. The second
|
||||
// create's ready resolves immediately; with concurrent flushing,
|
||||
// we still want the first failure to win in lastError.
|
||||
ready: (async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
throw new Error("boom");
|
||||
})(),
|
||||
})
|
||||
)
|
||||
.mockImplementationOnce((opts) =>
|
||||
makeMockSession({
|
||||
internalId: opts.internalId,
|
||||
backendSessionId: "backend-ok",
|
||||
backendId: opts.backendId,
|
||||
})
|
||||
);
|
||||
|
||||
const failingSession = await mgr.createSession();
|
||||
const succeedingSession = await mgr.createSession();
|
||||
// Drain the ready continuations so lastError is populated.
|
||||
await failingSession.ready.catch(() => undefined);
|
||||
await succeedingSession.ready;
|
||||
// Allow the manager's `.finally` continuation to run.
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(mgr.getLastError()).toMatch(/boom/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AgentSessionManager.getOrCreateActiveSession", () => {
|
||||
it("dedupes concurrent auto-spawn callers into a single session", async () => {
|
||||
const mgr = buildManager();
|
||||
const [a, b] = await Promise.all([
|
||||
mgr.getOrCreateActiveSession(),
|
||||
mgr.getOrCreateActiveSession(),
|
||||
]);
|
||||
expect(a).toBe(b);
|
||||
expect(sessionCreateSpy).toHaveBeenCalledTimes(1);
|
||||
expect(mgr.getSessions()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("returns the existing active session on subsequent calls", async () => {
|
||||
const mgr = buildManager();
|
||||
const a = await mgr.getOrCreateActiveSession();
|
||||
const again = await mgr.getOrCreateActiveSession();
|
||||
expect(again).toBe(a);
|
||||
expect(sessionCreateSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AgentSessionManager.closeSession", () => {
|
||||
it("removes the session from the pool and cancels + disposes it", async () => {
|
||||
const mgr = buildManager();
|
||||
const a = await mgr.createSession();
|
||||
await mgr.closeSession(a.internalId);
|
||||
expect(mgr.getSessions()).toEqual([]);
|
||||
expect(mgr.getActiveSession()).toBeNull();
|
||||
expect(mockSessionCancel).toHaveBeenCalled();
|
||||
expect(mockSessionDispose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("when the active session is closed, picks the right neighbor as active", async () => {
|
||||
const mgr = buildManager();
|
||||
const a = await mgr.createSession();
|
||||
const b = await mgr.createSession();
|
||||
const c = await mgr.createSession();
|
||||
mgr.setActiveSession(b.internalId);
|
||||
await mgr.closeSession(b.internalId);
|
||||
// [a, b, c] -> close b (idx 1) -> remaining [a, c] -> idx 1 -> c
|
||||
expect(mgr.getActiveSession()).toBe(c);
|
||||
expect(mgr.getSessions()).toEqual([a, c]);
|
||||
});
|
||||
|
||||
it("when the rightmost active session is closed, falls back to the new last", async () => {
|
||||
const mgr = buildManager();
|
||||
const a = await mgr.createSession();
|
||||
const b = await mgr.createSession();
|
||||
expect(mgr.getActiveSession()).toBe(b);
|
||||
await mgr.closeSession(b.internalId);
|
||||
// [a, b] -> close b (idx 1) -> remaining [a] -> idx min(1, 0) = 0 -> a
|
||||
expect(mgr.getActiveSession()).toBe(a);
|
||||
});
|
||||
|
||||
it("closing a non-active session leaves the active pointer alone", async () => {
|
||||
const mgr = buildManager();
|
||||
const a = await mgr.createSession();
|
||||
const b = await mgr.createSession();
|
||||
await mgr.closeSession(a.internalId);
|
||||
expect(mgr.getActiveSession()).toBe(b);
|
||||
expect(mgr.getSessions()).toEqual([b]);
|
||||
});
|
||||
|
||||
it("is a no-op for unknown ids", async () => {
|
||||
const mgr = buildManager();
|
||||
await mgr.closeSession("does-not-exist");
|
||||
expect(mgr.getSessions()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AgentSessionManager.setActiveSession", () => {
|
||||
it("moves the active pointer to the given id", async () => {
|
||||
const mgr = buildManager();
|
||||
const a = await mgr.createSession();
|
||||
const b = await mgr.createSession();
|
||||
expect(mgr.getActiveSession()).toBe(b);
|
||||
mgr.setActiveSession(a.internalId);
|
||||
expect(mgr.getActiveSession()).toBe(a);
|
||||
});
|
||||
|
||||
it("is a silent no-op on unknown id", async () => {
|
||||
const mgr = buildManager();
|
||||
const a = await mgr.createSession();
|
||||
expect(() => mgr.setActiveSession("nope")).not.toThrow();
|
||||
expect(mgr.getActiveSession()).toBe(a);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AgentSessionManager.restartBackend", () => {
|
||||
it("returns false when the backend has not been started", async () => {
|
||||
const mgr = buildManager();
|
||||
|
||||
await expect(mgr.restartBackend("opencode", "skills changed")).resolves.toBe(false);
|
||||
expect(mockBackendShutdown).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("restarts an idle backend and replaces the active affected session", async () => {
|
||||
const mgr = buildManager();
|
||||
const first = await mgr.createSession();
|
||||
|
||||
await expect(mgr.restartBackend("opencode", "skills changed")).resolves.toBe(true);
|
||||
|
||||
expect(mockSessionCancel).toHaveBeenCalledWith();
|
||||
expect(mockSessionDispose).toHaveBeenCalledWith();
|
||||
expect(mockBackendShutdown).toHaveBeenCalledTimes(1);
|
||||
expect(mgr.getSessions()).toHaveLength(1);
|
||||
expect(mgr.getActiveSession()).not.toBe(first);
|
||||
expect(mgr.getActiveSession()?.backendId).toBe("opencode");
|
||||
});
|
||||
|
||||
it("defers restart until an active turn leaves running", async () => {
|
||||
const mgr = buildManager();
|
||||
const first = await mgr.createSession();
|
||||
getSessionTestHandle(first).setStatus("running");
|
||||
|
||||
await expect(mgr.restartBackend("opencode", "skills changed")).resolves.toBe(true);
|
||||
expect(mockBackendShutdown).not.toHaveBeenCalled();
|
||||
|
||||
getSessionTestHandle(first).setStatus("idle");
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
|
||||
expect(mockBackendShutdown).toHaveBeenCalledTimes(1);
|
||||
expect(mgr.getActiveSession()).not.toBe(first);
|
||||
expect(mgr.getActiveSession()?.backendId).toBe("opencode");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AgentSessionManager attention tracking", () => {
|
||||
it("flags a backgrounded session that finishes a turn", async () => {
|
||||
const mgr = buildManager();
|
||||
const a = await mgr.createSession();
|
||||
const b = await mgr.createSession();
|
||||
// b is active by default; switch to a so b runs in the background.
|
||||
mgr.setActiveSession(a.internalId);
|
||||
const bHandle = getSessionTestHandle(b);
|
||||
bHandle.setStatus("running");
|
||||
bHandle.setStatus("idle");
|
||||
expect(b.getNeedsAttention()).toBe(true);
|
||||
expect(a.getNeedsAttention()).toBe(false);
|
||||
});
|
||||
|
||||
it("flags a backgrounded session that errors out", async () => {
|
||||
const mgr = buildManager();
|
||||
const a = await mgr.createSession();
|
||||
const b = await mgr.createSession();
|
||||
mgr.setActiveSession(a.internalId);
|
||||
const bHandle = getSessionTestHandle(b);
|
||||
bHandle.setStatus("running");
|
||||
bHandle.setStatus("error");
|
||||
expect(b.getNeedsAttention()).toBe(true);
|
||||
});
|
||||
|
||||
it("flags a backgrounded session that pauses for permission", async () => {
|
||||
const mgr = buildManager();
|
||||
const a = await mgr.createSession();
|
||||
const b = await mgr.createSession();
|
||||
mgr.setActiveSession(a.internalId);
|
||||
const bHandle = getSessionTestHandle(b);
|
||||
bHandle.setStatus("running");
|
||||
bHandle.setStatus("awaiting_permission");
|
||||
expect(b.getNeedsAttention()).toBe(true);
|
||||
});
|
||||
|
||||
it("does not flag the active session", async () => {
|
||||
const mgr = buildManager();
|
||||
const a = await mgr.createSession();
|
||||
expect(mgr.getActiveSession()).toBe(a);
|
||||
const aHandle = getSessionTestHandle(a);
|
||||
aHandle.setStatus("running");
|
||||
aHandle.setStatus("idle");
|
||||
expect(a.getNeedsAttention()).toBe(false);
|
||||
});
|
||||
|
||||
it("does not flag the starting → idle transition", async () => {
|
||||
const mgr = buildManager();
|
||||
const a = await mgr.createSession();
|
||||
const b = await mgr.createSession();
|
||||
mgr.setActiveSession(a.internalId);
|
||||
const bHandle = getSessionTestHandle(b);
|
||||
// simulate a fresh boot (mock starts at idle, force a starting → idle).
|
||||
bHandle.setStatus("starting");
|
||||
bHandle.setStatus("idle");
|
||||
expect(b.getNeedsAttention()).toBe(false);
|
||||
});
|
||||
|
||||
it("clears the flag when the user activates the tab", async () => {
|
||||
const mgr = buildManager();
|
||||
const a = await mgr.createSession();
|
||||
const b = await mgr.createSession();
|
||||
mgr.setActiveSession(a.internalId);
|
||||
const bHandle = getSessionTestHandle(b);
|
||||
bHandle.setStatus("running");
|
||||
bHandle.setStatus("idle");
|
||||
expect(b.getNeedsAttention()).toBe(true);
|
||||
mgr.setActiveSession(b.internalId);
|
||||
expect(b.getNeedsAttention()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AgentSessionManager.replaceSessionInPlace", () => {
|
||||
// Drains the fire-and-forget `closeSession` chain that
|
||||
// replaceSessionInPlace kicks off, so assertions about pool removal
|
||||
// and dispose can run synchronously after.
|
||||
async function flushBackgroundClose(): Promise<void> {
|
||||
for (let i = 0; i < 5; i++) await Promise.resolve();
|
||||
}
|
||||
|
||||
it("inserts the replacement at the old session's tab-strip index", async () => {
|
||||
const mgr = buildManager();
|
||||
const a = await mgr.createSession();
|
||||
const b = await mgr.createSession();
|
||||
const c = await mgr.createSession();
|
||||
// Replace the middle tab — the regression case is "new chat hijacks
|
||||
// a sibling slot" because the new session is appended at the end.
|
||||
const replacement = await mgr.replaceSessionInPlace(b.internalId);
|
||||
await flushBackgroundClose();
|
||||
expect(mgr.getSessions()).toEqual([a, replacement, c]);
|
||||
expect(mgr.getActiveSession()).toBe(replacement);
|
||||
});
|
||||
|
||||
it("preserves the leftmost slot when replacing the first tab", async () => {
|
||||
const mgr = buildManager();
|
||||
const a = await mgr.createSession();
|
||||
const b = await mgr.createSession();
|
||||
mgr.setActiveSession(a.internalId);
|
||||
const replacement = await mgr.replaceSessionInPlace(a.internalId);
|
||||
await flushBackgroundClose();
|
||||
expect(mgr.getSessions()).toEqual([replacement, b]);
|
||||
expect(mgr.getActiveSession()).toBe(replacement);
|
||||
});
|
||||
|
||||
it("closes the old session in the background", async () => {
|
||||
const mgr = buildManager();
|
||||
const a = await mgr.createSession();
|
||||
await mgr.replaceSessionInPlace(a.internalId);
|
||||
await flushBackgroundClose();
|
||||
expect(mockSessionCancel).toHaveBeenCalled();
|
||||
expect(mockSessionDispose).toHaveBeenCalled();
|
||||
expect(mgr.getSessions().some((s) => s.internalId === a.internalId)).toBe(false);
|
||||
});
|
||||
|
||||
it("forwards the explicit backendId to createSession", async () => {
|
||||
const mgr = buildManager();
|
||||
const a = await mgr.createSession();
|
||||
await mgr.replaceSessionInPlace(a.internalId, "opencode");
|
||||
// The mocked AgentSession.start records the backendId on the session,
|
||||
// so we can assert it landed on the replacement.
|
||||
const replacement = mgr.getActiveSession();
|
||||
expect(replacement?.backendId).toBe("opencode");
|
||||
});
|
||||
|
||||
it("falls back to plain create when the old id is unknown", async () => {
|
||||
const mgr = buildManager();
|
||||
const replacement = await mgr.replaceSessionInPlace("does-not-exist");
|
||||
expect(mgr.getSessions()).toEqual([replacement]);
|
||||
expect(mgr.getActiveSession()).toBe(replacement);
|
||||
});
|
||||
|
||||
it("the replacement also takes the chatUIState slot at the same index", async () => {
|
||||
// The chatUIStates map is parallel to sessions — if it isn't reordered
|
||||
// alongside, getActiveChatUIState would point at the wrong session.
|
||||
const mgr = buildManager();
|
||||
const a = await mgr.createSession();
|
||||
const b = await mgr.createSession();
|
||||
mgr.setActiveSession(a.internalId);
|
||||
const replacement = await mgr.replaceSessionInPlace(a.internalId);
|
||||
await flushBackgroundClose();
|
||||
expect(mgr.getActiveChatUIState()).toBe(mgr.getChatUIState(replacement.internalId));
|
||||
expect(mgr.getChatUIState(b.internalId)).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AgentSessionManager.subscribe / shutdown", () => {
|
||||
it("notifies subscribers on session create / close / activate", async () => {
|
||||
const mgr = buildManager();
|
||||
const listener = jest.fn();
|
||||
mgr.subscribe(listener);
|
||||
|
||||
const a = await mgr.createSession();
|
||||
const b = await mgr.createSession();
|
||||
mgr.setActiveSession(a.internalId);
|
||||
await mgr.closeSession(b.internalId);
|
||||
|
||||
expect(listener.mock.calls.length).toBeGreaterThanOrEqual(4);
|
||||
});
|
||||
|
||||
it("shutdown cancels and disposes every session and clears state", async () => {
|
||||
const mgr = buildManager();
|
||||
await mgr.createSession();
|
||||
await mgr.createSession();
|
||||
expect(mgr.getSessions()).toHaveLength(2);
|
||||
|
||||
await mgr.shutdown();
|
||||
expect(mgr.getSessions()).toEqual([]);
|
||||
expect(mgr.getActiveSession()).toBeNull();
|
||||
expect(mockSessionCancel).toHaveBeenCalledTimes(2);
|
||||
expect(mockSessionDispose).toHaveBeenCalledTimes(2);
|
||||
expect(mockBackendShutdown).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("backend exit drops every session and surfaces lastError", async () => {
|
||||
const mgr = buildManager();
|
||||
const listener = jest.fn();
|
||||
mgr.subscribe(listener);
|
||||
await mgr.createSession();
|
||||
await mgr.createSession();
|
||||
|
||||
// Simulate the subprocess exiting.
|
||||
for (const fn of mockBackendExitListeners) fn();
|
||||
|
||||
expect(mgr.getSessions()).toEqual([]);
|
||||
expect(mgr.getActiveSession()).toBeNull();
|
||||
expect(mgr.getLastError()).toMatch(/exited unexpectedly/);
|
||||
expect(listener).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AgentSessionManager.applySelection", () => {
|
||||
it("no-ops when no session is active", async () => {
|
||||
const mgr = buildManager();
|
||||
await expect(
|
||||
mgr.applySelection({ effort: "high" }, { expectBackendId: "opencode" })
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("no-ops when the active session is on a different backend", async () => {
|
||||
const mgr = buildManager();
|
||||
await mgr.createSession();
|
||||
// Active session is on `opencode`; asking for a different backend
|
||||
// must refuse so a stray cross-backend apply can't slip through.
|
||||
await expect(
|
||||
mgr.applySelection({ effort: "high" }, { expectBackendId: "claude-code" })
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("delegates dispatch to descriptor.applySelection with the resolved selection", async () => {
|
||||
const applySelectionMock = jest.fn(async () => {});
|
||||
const descriptor = {
|
||||
id: "opencode",
|
||||
displayName: "opencode",
|
||||
getInstallState: jest.fn(),
|
||||
subscribeInstallState: jest.fn(),
|
||||
openInstallUI: jest.fn(),
|
||||
createBackendProcess: jest.fn(() => makeMockBackendProcess()),
|
||||
wire: {
|
||||
encode: ({ baseModelId, effort }: { baseModelId: string; effort: string | null }) =>
|
||||
effort ? `${baseModelId}/${effort}` : baseModelId,
|
||||
decode: (id: string) => ({
|
||||
selection: { baseModelId: id, effort: null },
|
||||
provider: null,
|
||||
}),
|
||||
},
|
||||
applySelection: applySelectionMock,
|
||||
} as unknown as BackendDescriptor;
|
||||
const modelPreloader = {
|
||||
getCachedBackendState: jest.fn(() => null),
|
||||
preload: jest.fn(async () => undefined),
|
||||
subscribe: jest.fn(() => () => {}),
|
||||
shutdown: jest.fn(),
|
||||
setCached: jest.fn(),
|
||||
clearCached: jest.fn(),
|
||||
};
|
||||
const mgr = new AgentSessionManager(
|
||||
buildApp(),
|
||||
buildPlugin() as unknown as ConstructorParameters<typeof AgentSessionManager>[1],
|
||||
{
|
||||
permissionPrompter: jest.fn(),
|
||||
resolveDescriptor: (id) => (id === descriptor.id ? descriptor : undefined),
|
||||
modelPreloader: modelPreloader as unknown as ConstructorParameters<
|
||||
typeof AgentSessionManager
|
||||
>[2]["modelPreloader"],
|
||||
}
|
||||
);
|
||||
const entry = {
|
||||
baseModelId: "anthropic/sonnet",
|
||||
name: "Sonnet",
|
||||
provider: "anthropic",
|
||||
effortOptions: [
|
||||
{ value: null, label: "Default" },
|
||||
{ value: "high", label: "High" },
|
||||
],
|
||||
};
|
||||
sessionCreateSpy.mockImplementationOnce((opts) => {
|
||||
const s = makeMockSession({ internalId: opts.internalId, backendId: opts.backendId });
|
||||
(s as unknown as { getState: () => unknown }).getState = () => ({
|
||||
model: {
|
||||
current: { baseModelId: entry.baseModelId, effort: null },
|
||||
availableModels: [entry],
|
||||
},
|
||||
mode: null,
|
||||
});
|
||||
return s;
|
||||
});
|
||||
const session = await mgr.createSession();
|
||||
|
||||
// Effort-only patch: baseModelId resolves from current state.
|
||||
(mockedSetSettings as jest.Mock).mockClear();
|
||||
await mgr.applySelection({ effort: "high" }, { expectBackendId: "opencode" });
|
||||
expect(applySelectionMock).toHaveBeenCalledWith(session, {
|
||||
baseModelId: "anthropic/sonnet",
|
||||
effort: "high",
|
||||
});
|
||||
// Resolved selection is also persisted to settings.
|
||||
const persistedAfterEffort = readPersistedDefault(mockedSetSettings as jest.Mock, "opencode");
|
||||
expect(persistedAfterEffort).toEqual({
|
||||
baseModelId: "anthropic/sonnet",
|
||||
effort: "high",
|
||||
});
|
||||
|
||||
// Full patch: both fields land verbatim.
|
||||
applySelectionMock.mockClear();
|
||||
(mockedSetSettings as jest.Mock).mockClear();
|
||||
await mgr.applySelection({ baseModelId: "anthropic/opus", effort: null });
|
||||
expect(applySelectionMock).toHaveBeenCalledWith(session, {
|
||||
baseModelId: "anthropic/opus",
|
||||
effort: null,
|
||||
});
|
||||
const persistedAfterFull = readPersistedDefault(mockedSetSettings as jest.Mock, "opencode");
|
||||
expect(persistedAfterFull).toEqual({
|
||||
baseModelId: "anthropic/opus",
|
||||
effort: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not persist when the descriptor's applySelection throws", async () => {
|
||||
const applySelectionMock = jest.fn(async () => {
|
||||
throw new Error("nope");
|
||||
});
|
||||
const descriptor = {
|
||||
id: "opencode",
|
||||
displayName: "opencode",
|
||||
getInstallState: jest.fn(),
|
||||
subscribeInstallState: jest.fn(),
|
||||
openInstallUI: jest.fn(),
|
||||
createBackendProcess: jest.fn(() => makeMockBackendProcess()),
|
||||
wire: {
|
||||
encode: ({ baseModelId }: { baseModelId: string }) => baseModelId,
|
||||
decode: (id: string) => ({
|
||||
selection: { baseModelId: id, effort: null },
|
||||
provider: null,
|
||||
}),
|
||||
},
|
||||
applySelection: applySelectionMock,
|
||||
} as unknown as BackendDescriptor;
|
||||
const modelPreloader = {
|
||||
getCachedBackendState: jest.fn(() => null),
|
||||
preload: jest.fn(async () => undefined),
|
||||
subscribe: jest.fn(() => () => {}),
|
||||
shutdown: jest.fn(),
|
||||
setCached: jest.fn(),
|
||||
clearCached: jest.fn(),
|
||||
};
|
||||
const mgr = new AgentSessionManager(
|
||||
buildApp(),
|
||||
buildPlugin() as unknown as ConstructorParameters<typeof AgentSessionManager>[1],
|
||||
{
|
||||
permissionPrompter: jest.fn(),
|
||||
resolveDescriptor: (id) => (id === descriptor.id ? descriptor : undefined),
|
||||
modelPreloader: modelPreloader as unknown as ConstructorParameters<
|
||||
typeof AgentSessionManager
|
||||
>[2]["modelPreloader"],
|
||||
}
|
||||
);
|
||||
sessionCreateSpy.mockImplementationOnce((opts) => {
|
||||
const s = makeMockSession({ internalId: opts.internalId, backendId: opts.backendId });
|
||||
(s as unknown as { getState: () => unknown }).getState = () => ({
|
||||
model: {
|
||||
current: { baseModelId: "anthropic/sonnet", effort: null },
|
||||
availableModels: [
|
||||
{ baseModelId: "anthropic/sonnet", name: "Sonnet", provider: null, effortOptions: [] },
|
||||
],
|
||||
},
|
||||
mode: null,
|
||||
});
|
||||
return s;
|
||||
});
|
||||
await mgr.createSession();
|
||||
(mockedSetSettings as jest.Mock).mockClear();
|
||||
await expect(
|
||||
mgr.applySelection({ baseModelId: "anthropic/opus", effort: "high" })
|
||||
).rejects.toThrow("nope");
|
||||
// No persistence after a failed apply.
|
||||
expect(readPersistedDefault(mockedSetSettings as jest.Mock, "opencode")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Walk through `setSettings` calls (each carries an updater function) and
|
||||
* return the most recent `defaultModel` written for `backendId`.
|
||||
*/
|
||||
function readPersistedDefault(
|
||||
setSettings: jest.Mock,
|
||||
backendId: string
|
||||
): { baseModelId: string; effort: string | null } | undefined {
|
||||
let backends: Record<string, { defaultModel?: { baseModelId: string; effort: string | null } }> =
|
||||
{};
|
||||
for (const call of setSettings.mock.calls) {
|
||||
const updater = call[0];
|
||||
if (typeof updater !== "function") continue;
|
||||
const patch = updater({ agentMode: { backends } });
|
||||
if (patch?.agentMode?.backends) {
|
||||
backends = { ...backends, ...patch.agentMode.backends };
|
||||
}
|
||||
}
|
||||
return backends[backendId]?.defaultModel;
|
||||
}
|
||||
941
src/agentMode/session/AgentSessionManager.ts
Normal file
941
src/agentMode/session/AgentSessionManager.ts
Normal file
|
|
@ -0,0 +1,941 @@
|
|||
import { logError, logInfo, logWarn } from "@/logger";
|
||||
import type CopilotPlugin from "@/main";
|
||||
import { AgentChatUIState } from "@/agentMode/session/AgentChatUIState";
|
||||
import { getSettings, setSettings } from "@/settings/model";
|
||||
import { err2String } from "@/utils";
|
||||
import type { ChatHistoryItem } from "@/components/chat-components/ChatHistoryPopover";
|
||||
import { fileToHistoryItem } from "@/utils/chatHistoryUtils";
|
||||
import { App, FileSystemAdapter, Notice, Platform, TFile } from "obsidian";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { AgentSession, ATTENTION_TRIGGER_STATUSES } from "./AgentSession";
|
||||
import type { AgentChatPersistenceManager } from "./AgentChatPersistenceManager";
|
||||
import type { AgentModelPreloader } from "./AgentModelPreloader";
|
||||
import type {
|
||||
BackendDescriptor,
|
||||
BackendId,
|
||||
BackendProcess,
|
||||
BackendState,
|
||||
CopilotMode,
|
||||
ModeApplySpec,
|
||||
ModelSelection,
|
||||
PermissionDecision,
|
||||
PermissionPrompt,
|
||||
} from "./types";
|
||||
|
||||
const AUTOSAVE_DEBOUNCE_MS = 500;
|
||||
|
||||
export type PermissionPrompter = (req: PermissionPrompt) => Promise<PermissionDecision>;
|
||||
|
||||
// Injected by the barrel so `session/` doesn't have to import
|
||||
// `backends/registry` directly (would breach the layer boundary).
|
||||
export type DescriptorResolver = (id: BackendId) => BackendDescriptor | undefined;
|
||||
|
||||
export interface AgentSessionManagerOptions {
|
||||
permissionPrompter: PermissionPrompter;
|
||||
resolveDescriptor: DescriptorResolver;
|
||||
modelPreloader: AgentModelPreloader;
|
||||
/**
|
||||
* Persistence layer for Agent Mode chats. Optional only so legacy callers
|
||||
* (tests) can omit it; production wiring always supplies one via the
|
||||
* barrel in `agentMode/index.ts`.
|
||||
*/
|
||||
persistenceManager?: AgentChatPersistenceManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin-scoped coordinator for Agent Mode. Owns one `AcpBackendProcess` per
|
||||
* registered backend (lazy-spawned on first `createSession(backendId)`) and a
|
||||
* pool of `AgentSession`s, each tagged with the backend it was created on.
|
||||
* Tears every backend down on plugin unload via `shutdown()`.
|
||||
*
|
||||
* Backend pluggability is handled via `BackendDescriptor`: the manager
|
||||
* resolves descriptors from `backendRegistry` and calls
|
||||
* `descriptor.createBackend(plugin)` to construct each `AcpBackend` — it
|
||||
* never imports a specific backend class. The permission prompter is
|
||||
* injected so this file stays out of the UI layer.
|
||||
*/
|
||||
export class AgentSessionManager {
|
||||
private backends = new Map<BackendId, BackendProcess>();
|
||||
private starting = new Map<BackendId, Promise<BackendProcess>>();
|
||||
private sessions = new Map<string, AgentSession>();
|
||||
private chatUIStates = new Map<string, AgentChatUIState>();
|
||||
private activeSessionId: string | null = null;
|
||||
// Dedupe only the auto-spawn path. Direct `createSession()` calls (e.g. `+`
|
||||
// clicks) are independent — concurrent ones each spawn their own session.
|
||||
private firstSessionPromise: Promise<AgentSession> | null = null;
|
||||
private pendingCreates = 0;
|
||||
private listeners = new Set<() => void>();
|
||||
private disposed = false;
|
||||
private startingBackendId: BackendId | null = null;
|
||||
private lastError: string | null = null;
|
||||
private readonly pendingBackendRestarts = new Map<BackendId, string>();
|
||||
private readonly restartingBackends = new Set<BackendId>();
|
||||
private readonly preloader: AgentModelPreloader;
|
||||
/**
|
||||
* Resolves once the plugin-load preload phase has settled (regardless of
|
||||
* per-backend success). Lets the chat UI gate its first render so the
|
||||
* picker never flashes empty before the cache populates.
|
||||
*/
|
||||
private preloadPromise: Promise<void> = Promise.resolve();
|
||||
private preloadReady = true;
|
||||
// Per-session bookkeeping, all keyed by `internalId`. Mixes persistence
|
||||
// bookkeeping with subscription teardowns — the unifying property is
|
||||
// "must be cleaned up when the session is detached":
|
||||
// - `path`: persisted file (set after first successful save)
|
||||
// - `timer`: pending debounce timer
|
||||
// - `unsub`: tear-down for the auto-save `session.subscribe()`
|
||||
// - `signature`: last serialized snapshot, for no-op skipping
|
||||
// - `modelCacheUnsub`: tear-down for the model-cache mirror subscription
|
||||
// - `attentionUnsub`: tear-down for the needs-attention status watcher
|
||||
private readonly sessionState = new Map<
|
||||
string,
|
||||
{
|
||||
path?: string;
|
||||
timer?: number;
|
||||
unsub?: () => void;
|
||||
signature?: string;
|
||||
modelCacheUnsub?: () => void;
|
||||
attentionUnsub?: () => void;
|
||||
}
|
||||
>();
|
||||
|
||||
private getSessionState(internalId: string) {
|
||||
let entry = this.sessionState.get(internalId);
|
||||
if (!entry) {
|
||||
entry = {};
|
||||
this.sessionState.set(internalId, entry);
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly app: App,
|
||||
private readonly plugin: CopilotPlugin,
|
||||
private readonly opts: AgentSessionManagerOptions
|
||||
) {
|
||||
if (Platform.isMobile) {
|
||||
throw new Error("AgentSessionManager is desktop only");
|
||||
}
|
||||
this.preloader = opts.modelPreloader;
|
||||
}
|
||||
|
||||
/**
|
||||
* List every persisted Agent Mode chat as a `ChatHistoryItem` ranked using
|
||||
* the plugin's shared in-memory `lastAccessedAt` tracker. Returns `[]` when
|
||||
* persistence isn't configured.
|
||||
*/
|
||||
async getChatHistoryItems(): Promise<ChatHistoryItem[]> {
|
||||
const persistence = this.opts.persistenceManager;
|
||||
if (!persistence) return [];
|
||||
const files = await persistence.getAgentChatHistoryFiles();
|
||||
const tracker = this.plugin.getChatHistoryLastAccessedAtManager();
|
||||
return files.map((file) => fileToHistoryItem(file, tracker));
|
||||
}
|
||||
|
||||
/** Update the user-visible title (frontmatter `topic`) of a saved chat. */
|
||||
async updateChatTitle(fileId: string, newTitle: string): Promise<void> {
|
||||
const persistence = this.opts.persistenceManager;
|
||||
if (!persistence) throw new Error("Agent chat persistence is not configured.");
|
||||
await persistence.updateTopic(fileId, newTitle);
|
||||
}
|
||||
|
||||
/** Delete a saved chat by file path. */
|
||||
async deleteChatHistory(fileId: string): Promise<void> {
|
||||
const persistence = this.opts.persistenceManager;
|
||||
if (!persistence) throw new Error("Agent chat persistence is not configured.");
|
||||
await persistence.deleteFile(fileId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the active `AgentSession` if one exists, otherwise create one.
|
||||
* Used by the router to lazily seed the first session on chain switch.
|
||||
* Subsequent `+` clicks should call `createSession()` directly.
|
||||
*/
|
||||
async getOrCreateActiveSession(): Promise<AgentSession> {
|
||||
if (this.disposed) {
|
||||
throw new Error("AgentSessionManager has been shut down");
|
||||
}
|
||||
const active = this.getActiveSession();
|
||||
if (active && active.getStatus() !== "closed") return active;
|
||||
// Dedupe rapid auto-spawn callers (e.g. the router effect re-running
|
||||
// before the first create has populated the pool) so we don't seed two
|
||||
// sessions when one was asked for.
|
||||
if (this.firstSessionPromise) return this.firstSessionPromise;
|
||||
this.firstSessionPromise = this.createSession();
|
||||
try {
|
||||
return await this.firstSessionPromise;
|
||||
} finally {
|
||||
this.firstSessionPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn a fresh `AgentSession`. Lazily starts the requested backend on its
|
||||
* first call. The new session becomes the active one. `backendId` defaults
|
||||
* to `settings.agentMode.activeBackend` (the model-picker keeps that in
|
||||
* sync with the user's most recently selected default model).
|
||||
*
|
||||
* The new session's initial (model, effort) is read from the persisted
|
||||
* default for `backendId` via `getDefaultSelection`. Picker call sites that
|
||||
* want a specific selection on a new backend should call
|
||||
* `persistDefaultSelection` first.
|
||||
*/
|
||||
async createSession(backendId?: BackendId): Promise<AgentSession> {
|
||||
if (this.disposed) {
|
||||
throw new Error("AgentSessionManager has been shut down");
|
||||
}
|
||||
|
||||
const adapter = this.app.vault.adapter;
|
||||
if (!(adapter instanceof FileSystemAdapter)) {
|
||||
throw new Error("Agent Mode requires desktop Obsidian (FileSystemAdapter).");
|
||||
}
|
||||
const vaultBasePath = adapter.getBasePath();
|
||||
|
||||
const resolvedId = backendId ?? getSettings().agentMode?.activeBackend ?? "opencode";
|
||||
const descriptor = this.resolveDescriptor(resolvedId);
|
||||
|
||||
this.pendingCreates++;
|
||||
this.startingBackendId = resolvedId;
|
||||
this.notify();
|
||||
|
||||
let backend: BackendProcess;
|
||||
try {
|
||||
backend = await this.ensureBackend(resolvedId, descriptor);
|
||||
} catch (err) {
|
||||
this.lastError = err2String(err);
|
||||
this.finishPendingCreate();
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (this.disposed) {
|
||||
this.finishPendingCreate();
|
||||
throw new Error("AgentSessionManager was shut down during session creation");
|
||||
}
|
||||
|
||||
const seedSelection = this.getDefaultSelection(resolvedId);
|
||||
const defaultModelId = seedSelection ? descriptor.wire.encode(seedSelection) : undefined;
|
||||
const session = AgentSession.start({
|
||||
backend,
|
||||
cwd: vaultBasePath,
|
||||
internalId: uuidv4(),
|
||||
backendId: resolvedId,
|
||||
defaultModelId,
|
||||
getDescriptor: () => this.opts.resolveDescriptor(resolvedId),
|
||||
});
|
||||
this.sessions.set(session.internalId, session);
|
||||
this.chatUIStates.set(session.internalId, new AgentChatUIState(session));
|
||||
this.activeSessionId = session.internalId;
|
||||
this.attachAutoSave(session);
|
||||
this.attachModelCacheSync(session);
|
||||
this.attachAttentionTracking(session);
|
||||
this.notify();
|
||||
|
||||
// Once the ACP session is ready, apply backend-specific persisted state
|
||||
// (claude-code's effort, future config-option preferences) and clear the
|
||||
// "starting" pill. On failure, capture into `lastError` so the status
|
||||
// surface and retry handler can react. The session itself transitions to
|
||||
// status "error" inside its own `initialize`.
|
||||
void session.ready
|
||||
.then(async () => {
|
||||
if (descriptor.applyInitialSessionConfig) {
|
||||
try {
|
||||
await descriptor.applyInitialSessionConfig(session, getSettings());
|
||||
} catch (e) {
|
||||
logWarn(
|
||||
`[AgentMode] applyInitialSessionConfig failed for ${resolvedId}; continuing`,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
this.lastError = null;
|
||||
logInfo(
|
||||
`[AgentMode] session ready (internal=${session.internalId} backend-id=${session.getBackendSessionId()} backend=${resolvedId}); pool size=${this.sessions.size}`
|
||||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
this.lastError = err2String(err);
|
||||
})
|
||||
.finally(() => this.finishPendingCreate());
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
private finishPendingCreate(): void {
|
||||
this.pendingCreates--;
|
||||
if (this.pendingCreates === 0) this.startingBackendId = null;
|
||||
this.notify();
|
||||
}
|
||||
|
||||
private resolveDescriptor(backendId: BackendId): BackendDescriptor {
|
||||
const descriptor = this.opts.resolveDescriptor(backendId);
|
||||
if (!descriptor) {
|
||||
throw new Error(`Unknown backend "${backendId}". Did you forget to register it?`);
|
||||
}
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
setDefaultBackend(backendId: BackendId): void {
|
||||
if (getSettings().agentMode?.activeBackend === backendId) return;
|
||||
setSettings((cur) => ({
|
||||
agentMode: { ...cur.agentMode, activeBackend: backendId },
|
||||
}));
|
||||
this.notify();
|
||||
}
|
||||
|
||||
/** Read the user's sticky model preference for `backendId`, or `null` if none. */
|
||||
getDefaultSelection(backendId: BackendId): ModelSelection | null {
|
||||
const backends = getSettings().agentMode?.backends as
|
||||
| Record<string, { defaultModel?: ModelSelection | null } | undefined>
|
||||
| undefined;
|
||||
return backends?.[backendId]?.defaultModel ?? null;
|
||||
}
|
||||
|
||||
/** Persist a sticky model preference for `backendId`. Pass `null` to clear. */
|
||||
async persistDefaultSelection(
|
||||
backendId: BackendId,
|
||||
selection: ModelSelection | null
|
||||
): Promise<void> {
|
||||
setSettings((cur) => {
|
||||
const existing = (cur.agentMode.backends as Record<string, unknown> | undefined)?.[
|
||||
backendId
|
||||
] as Record<string, unknown> | undefined;
|
||||
return {
|
||||
agentMode: {
|
||||
...cur.agentMode,
|
||||
backends: {
|
||||
...cur.agentMode.backends,
|
||||
[backendId]: { ...(existing ?? {}), defaultModel: selection },
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Persist a sticky mode preference for `backendId`. No-op if the descriptor doesn't opt in. */
|
||||
async persistModeFor(backendId: BackendId, value: CopilotMode): Promise<void> {
|
||||
const descriptor = this.resolveDescriptor(backendId);
|
||||
if (!descriptor.persistModeSelection) return;
|
||||
await descriptor.persistModeSelection(value, this.plugin);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a (baseModelId, effort) selection to the active session. Both
|
||||
* fields are optional patches against the current selection:
|
||||
* - `baseModelId` omitted → keep current
|
||||
* - `effort` omitted → keep current
|
||||
* - `effort: null` → explicit "default" (no-op for descriptor-style
|
||||
* backends, encoded as the bare model id for suffix-style backends)
|
||||
*
|
||||
* `opts.expectBackendId`, when provided, makes this a silent no-op if
|
||||
* the active session is on a different backend. Used by the effort
|
||||
* sibling, which captures the backend id at picker-build time and
|
||||
* might fire after a session swap.
|
||||
*
|
||||
* After a successful descriptor apply, the resolved selection is also
|
||||
* written to the persisted default for the active backend — symmetric
|
||||
* with `applyMode`. If the descriptor throws, no persistence occurs.
|
||||
*/
|
||||
async applySelection(
|
||||
patch: { baseModelId?: string; effort?: string | null },
|
||||
opts?: { expectBackendId?: BackendId }
|
||||
): Promise<void> {
|
||||
const session = this.getActiveSession();
|
||||
if (!session) return;
|
||||
if (opts?.expectBackendId && session.backendId !== opts.expectBackendId) return;
|
||||
const current = session.getState()?.model?.current;
|
||||
if (!current) return;
|
||||
const descriptor = this.resolveDescriptor(session.backendId);
|
||||
const resolved: ModelSelection = {
|
||||
baseModelId: patch.baseModelId ?? current.baseModelId,
|
||||
effort: patch.effort !== undefined ? patch.effort : current.effort,
|
||||
};
|
||||
await descriptor.applySelection(session, resolved);
|
||||
await this.persistDefaultSelection(session.backendId, resolved);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a canonical mode change against the active session. `value` is
|
||||
* the canonical id (used for persistence); `spec` carries the native
|
||||
* dispatch info (which ACP RPC + payload).
|
||||
*/
|
||||
async applyMode(backendId: BackendId, value: CopilotMode, spec: ModeApplySpec): Promise<void> {
|
||||
const session = this.getActiveSession();
|
||||
if (!session || session.backendId !== backendId) return;
|
||||
if (spec.kind === "setMode") {
|
||||
await session.setMode(spec.nativeId);
|
||||
} else {
|
||||
await session.setConfigOption(spec.configId, spec.value);
|
||||
}
|
||||
await this.persistModeFor(backendId, value);
|
||||
}
|
||||
|
||||
getBackendProcess(backendId: BackendId): BackendProcess | null {
|
||||
return this.backends.get(backendId) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart a backend process so spawn-time configuration, including native
|
||||
* skill discovery and deny rules, is rebuilt from current settings. If a
|
||||
* session on that backend is busy, the restart is deferred until it is idle.
|
||||
*
|
||||
* Returns `true` when a running backend was restarted or a restart was
|
||||
* scheduled; `false` when no backend process exists yet.
|
||||
*/
|
||||
async restartBackend(backendId: BackendId, reason: string): Promise<boolean> {
|
||||
if (this.disposed) return false;
|
||||
const inflight = this.starting.get(backendId);
|
||||
if (inflight) {
|
||||
await inflight.catch(() => undefined);
|
||||
}
|
||||
const backend = this.backends.get(backendId);
|
||||
if (!backend) return false;
|
||||
if (this.hasBusySession(backendId)) {
|
||||
const prev = this.pendingBackendRestarts.get(backendId);
|
||||
this.pendingBackendRestarts.set(backendId, prev ? `${prev}; ${reason}` : reason);
|
||||
logInfo(`[AgentMode] deferred ${backendId} backend restart: ${reason}`);
|
||||
return true;
|
||||
}
|
||||
await this.restartBackendNow(backendId, reason);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Cached unified backend state for `backendId`, populated by the model preloader. */
|
||||
getCachedBackendState(backendId: BackendId): BackendState | null {
|
||||
return this.preloader.getCachedBackendState(backendId);
|
||||
}
|
||||
|
||||
/**
|
||||
* The agent's catalog-declared default base model id for `backendId`.
|
||||
* Trusts `availableModels` ordering (agents put their recommended model
|
||||
* first). Returns `null` when the catalog hasn't been probed yet.
|
||||
*/
|
||||
getDefaultBaseModelId(backendId: BackendId): string | null {
|
||||
const state = this.preloader.getCachedBackendState(backendId);
|
||||
return state?.model?.availableModels[0]?.baseModelId ?? null;
|
||||
}
|
||||
|
||||
/** Subscribe to preloader cache updates. Used by the picker hook. */
|
||||
subscribeModelCache(listener: () => void): () => void {
|
||||
return this.preloader.subscribe(listener);
|
||||
}
|
||||
|
||||
/** Kick off a (best-effort) model probe for `backendId`. */
|
||||
preloadModels(backendId: BackendId): Promise<void> {
|
||||
return this.preloader.preload(backendId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the aggregate preload promise — typically the `Promise.allSettled`
|
||||
* of every backend's `preloadModels` call from plugin bring-up. While it
|
||||
* pends, `isPreloadReady()` returns `false`; the chat UI uses that flag to
|
||||
* render a "Loading…" placeholder instead of an empty picker.
|
||||
*/
|
||||
setPreloadPromise(promise: Promise<void>): void {
|
||||
this.preloadReady = false;
|
||||
this.preloadPromise = promise;
|
||||
void promise.then(() => {
|
||||
this.preloadReady = true;
|
||||
this.notify();
|
||||
});
|
||||
this.notify();
|
||||
}
|
||||
|
||||
/** Resolves once `setPreloadPromise`'s promise settles. */
|
||||
whenPreloadReady(): Promise<void> {
|
||||
return this.preloadPromise;
|
||||
}
|
||||
|
||||
/** Synchronous check, suitable for React render gates. */
|
||||
isPreloadReady(): boolean {
|
||||
return this.preloadReady;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel any in-flight turn, dispose the session, and remove it from the
|
||||
* pool. If the closed session was active, picks the right neighbor (or the
|
||||
* last remaining session) as the new active — `null` when none remain.
|
||||
* Backend stays up.
|
||||
*/
|
||||
async closeSession(id: string): Promise<void> {
|
||||
const session = this.sessions.get(id);
|
||||
if (!session) return;
|
||||
// Capture the closed tab's index BEFORE delete so we can pick the
|
||||
// neighbor that currently sits to its right.
|
||||
const idsBefore = Array.from(this.sessions.keys());
|
||||
const closedIdx = idsBefore.indexOf(id);
|
||||
try {
|
||||
await session.cancel();
|
||||
} catch (e) {
|
||||
logWarn(`[AgentMode] cancel during closeSession failed`, e);
|
||||
}
|
||||
// Drain any pending debounced auto-save before tearing the session
|
||||
// down — otherwise the last few tokens of a fast turn never reach disk.
|
||||
await this.drainAutoSave(session);
|
||||
try {
|
||||
await session.dispose();
|
||||
} catch (e) {
|
||||
logWarn(`[AgentMode] dispose during closeSession failed`, e);
|
||||
}
|
||||
this.detachAutoSave(id);
|
||||
this.sessions.delete(id);
|
||||
this.chatUIStates.delete(id);
|
||||
if (this.activeSessionId === id) {
|
||||
const remaining = Array.from(this.sessions.keys());
|
||||
this.activeSessionId =
|
||||
remaining.length === 0 ? null : remaining[Math.min(closedIdx, remaining.length - 1)];
|
||||
}
|
||||
this.notify();
|
||||
}
|
||||
|
||||
/** Move the active pointer to `id`. No-op if `id` is unknown. */
|
||||
setActiveSession(id: string): void {
|
||||
const session = this.sessions.get(id);
|
||||
if (!session) return;
|
||||
if (this.activeSessionId === id) return;
|
||||
this.activeSessionId = id;
|
||||
session.clearNeedsAttention();
|
||||
this.notify();
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn a fresh session at `oldId`'s tab-strip position and close `oldId`
|
||||
* in the background. Used by the in-tab "New Chat" button so the
|
||||
* replacement chat takes the same slot the user was looking at instead of
|
||||
* appearing at the end of the strip (which made it look like focus had
|
||||
* jumped to a sibling tab). `backendId` defaults to the same fallback as
|
||||
* `createSession`.
|
||||
*/
|
||||
async replaceSessionInPlace(oldId: string, backendId?: BackendId): Promise<AgentSession> {
|
||||
const oldIdx = Array.from(this.sessions.keys()).indexOf(oldId);
|
||||
const created = await this.createSession(backendId);
|
||||
if (oldIdx >= 0) {
|
||||
this.moveMapEntry(this.sessions, created.internalId, oldIdx);
|
||||
this.moveMapEntry(this.chatUIStates, created.internalId, oldIdx);
|
||||
this.notify();
|
||||
}
|
||||
void this.closeSession(oldId).catch((e) =>
|
||||
logWarn(`[AgentMode] closeSession during replaceSessionInPlace failed`, e)
|
||||
);
|
||||
return created;
|
||||
}
|
||||
|
||||
// Maps preserve insertion order, so reordering means rebuilding the map.
|
||||
// Used to land a freshly-created session at a specific tab-strip index.
|
||||
private moveMapEntry<V>(map: Map<string, V>, key: string, targetIdx: number): void {
|
||||
if (!map.has(key)) return;
|
||||
const entries = Array.from(map.entries());
|
||||
const fromIdx = entries.findIndex(([k]) => k === key);
|
||||
if (fromIdx === -1 || fromIdx === targetIdx) return;
|
||||
const [entry] = entries.splice(fromIdx, 1);
|
||||
entries.splice(targetIdx, 0, entry);
|
||||
map.clear();
|
||||
for (const [k, v] of entries) map.set(k, v);
|
||||
}
|
||||
|
||||
/** Update a session's user-visible label. No-op if `id` is unknown. */
|
||||
renameSession(id: string, label: string | null): void {
|
||||
const session = this.sessions.get(id);
|
||||
if (!session) return;
|
||||
session.setLabel(label);
|
||||
this.notify();
|
||||
}
|
||||
|
||||
getIsStarting(): boolean {
|
||||
return this.startingBackendId !== null;
|
||||
}
|
||||
|
||||
/** Backend id currently being booted, or null when no create is in flight. */
|
||||
getStartingBackendId(): BackendId | null {
|
||||
return this.startingBackendId;
|
||||
}
|
||||
|
||||
getLastError(): string | null {
|
||||
return this.lastError;
|
||||
}
|
||||
|
||||
getSession(id: string): AgentSession | null {
|
||||
return this.sessions.get(id) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a session by its backend `sessionId` (the agent-side identifier
|
||||
* embedded in `requestPermission` / `session/update` notifications).
|
||||
* Distinct from the internal id keying our own pool. Returns null while
|
||||
* the session is still starting (no backend id yet) or when no session
|
||||
* matches.
|
||||
*/
|
||||
getSessionByBackendId(backendSessionId: string): AgentSession | null {
|
||||
for (const session of this.sessions.values()) {
|
||||
if (session.getBackendSessionId() === backendSessionId) return session;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
getChatUIState(id: string): AgentChatUIState | null {
|
||||
return this.chatUIStates.get(id) ?? null;
|
||||
}
|
||||
|
||||
getActiveSession(): AgentSession | null {
|
||||
return this.activeSessionId ? (this.sessions.get(this.activeSessionId) ?? null) : null;
|
||||
}
|
||||
|
||||
getActiveChatUIState(): AgentChatUIState | null {
|
||||
return this.activeSessionId ? (this.chatUIStates.get(this.activeSessionId) ?? null) : null;
|
||||
}
|
||||
|
||||
/** All sessions in creation order (Map iteration order). */
|
||||
getSessions(): AgentSession[] {
|
||||
return Array.from(this.sessions.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to lifecycle changes (session created/closed/active changed/
|
||||
* label changed, backend exit, isStarting/lastError flips). Returns an
|
||||
* unsubscribe function. Listeners must not throw.
|
||||
*/
|
||||
subscribe(listener: () => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
private notify(): void {
|
||||
for (const l of this.listeners) {
|
||||
try {
|
||||
l();
|
||||
} catch (e) {
|
||||
logError("[AgentMode] manager listener threw", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Cancel any in-flight turn on the active session. Backend stays up. */
|
||||
async cancel(): Promise<void> {
|
||||
await this.getActiveSession()?.cancel();
|
||||
}
|
||||
|
||||
/**
|
||||
* Tear down every session and every spawned backend subprocess. Safe to
|
||||
* call when nothing was started; safe to call multiple times.
|
||||
*/
|
||||
async shutdown(): Promise<void> {
|
||||
if (this.disposed) return;
|
||||
this.disposed = true;
|
||||
logInfo(
|
||||
`[AgentMode] shutdown (pool size=${this.sessions.size}, backends=${this.backends.size})`
|
||||
);
|
||||
|
||||
const allSessions = Array.from(this.sessions.values());
|
||||
// Drain pending auto-saves for every session before disposing — same
|
||||
// reasoning as `closeSession`. Done before the per-session unsubscribe so
|
||||
// the timers don't fire with a half-disposed session.
|
||||
await Promise.allSettled(allSessions.map((s) => this.drainAutoSave(s)));
|
||||
for (const id of Array.from(this.sessionState.keys())) {
|
||||
this.detachAutoSave(id);
|
||||
}
|
||||
|
||||
await Promise.allSettled(
|
||||
allSessions.map(async (session) => {
|
||||
try {
|
||||
await session.cancel();
|
||||
} catch (e) {
|
||||
logError("[AgentMode] cancel during shutdown failed", e);
|
||||
}
|
||||
try {
|
||||
await session.dispose();
|
||||
} catch (e) {
|
||||
logError("[AgentMode] dispose during shutdown failed", e);
|
||||
}
|
||||
})
|
||||
);
|
||||
this.sessions.clear();
|
||||
this.chatUIStates.clear();
|
||||
this.activeSessionId = null;
|
||||
|
||||
const allBackends = Array.from(this.backends.values());
|
||||
await Promise.allSettled(
|
||||
allBackends.map(async (proc) => {
|
||||
try {
|
||||
await proc.shutdown();
|
||||
} catch (e) {
|
||||
logError("[AgentMode] backend shutdown failed", e);
|
||||
}
|
||||
})
|
||||
);
|
||||
this.backends.clear();
|
||||
this.starting.clear();
|
||||
this.startingBackendId = null;
|
||||
this.listeners.clear();
|
||||
this.preloader.shutdown();
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a previously-saved Agent Mode chat. If a live session is already
|
||||
* bound to that file (because the user opened it earlier this run), focus
|
||||
* its tab instead of spawning a duplicate. Otherwise, spin up a fresh
|
||||
* session on the saved backend, seed its store with the persisted display
|
||||
* messages, and pin the persisted-path map so subsequent turns update the
|
||||
* same file.
|
||||
*/
|
||||
async loadSessionFromHistory(file: TFile): Promise<AgentSession> {
|
||||
if (!this.opts.persistenceManager) {
|
||||
throw new Error("Agent chat persistence is not configured.");
|
||||
}
|
||||
if (this.disposed) {
|
||||
throw new Error("AgentSessionManager has been shut down");
|
||||
}
|
||||
|
||||
for (const [internalId, state] of this.sessionState.entries()) {
|
||||
if (state.path !== file.path) continue;
|
||||
const existing = this.sessions.get(internalId);
|
||||
if (existing && existing.getStatus() !== "closed") {
|
||||
this.setActiveSession(internalId);
|
||||
return existing;
|
||||
}
|
||||
state.path = undefined;
|
||||
}
|
||||
|
||||
const loaded = await this.opts.persistenceManager.loadFile(file);
|
||||
const session = await this.createSession(loaded.backendId);
|
||||
session.store.loadMessages(loaded.messages);
|
||||
if (loaded.label) session.setLabel(loaded.label);
|
||||
this.getSessionState(session.internalId).path = file.path;
|
||||
this.notify();
|
||||
return session;
|
||||
}
|
||||
|
||||
private attachAutoSave(session: AgentSession): void {
|
||||
const persistence = this.opts.persistenceManager;
|
||||
if (!persistence) return;
|
||||
|
||||
const trigger = () => this.scheduleAutoSave(session);
|
||||
const unsubscribe = session.subscribe({
|
||||
onMessagesChanged: trigger,
|
||||
onStatusChanged: () => {},
|
||||
onLabelChanged: trigger,
|
||||
});
|
||||
this.getSessionState(session.internalId).unsub = unsubscribe;
|
||||
}
|
||||
|
||||
private scheduleAutoSave(session: AgentSession): void {
|
||||
const state = this.getSessionState(session.internalId);
|
||||
if (state.timer) window.clearTimeout(state.timer);
|
||||
state.timer = window.setTimeout(() => {
|
||||
state.timer = undefined;
|
||||
this.flushAutoSave(session).catch((e) =>
|
||||
logWarn(`[AgentMode] auto-save failed for ${session.internalId}`, e)
|
||||
);
|
||||
}, AUTOSAVE_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
private async flushAutoSave(session: AgentSession): Promise<void> {
|
||||
const persistence = this.opts.persistenceManager;
|
||||
if (!persistence) return;
|
||||
if (!this.sessions.has(session.internalId)) return;
|
||||
|
||||
const messages = session.store.getDisplayMessages();
|
||||
if (messages.length === 0) return;
|
||||
|
||||
const label = session.getLabel();
|
||||
// Skip the write when nothing user-visible has changed since the last
|
||||
// save. Streaming token updates and idempotent label notifications
|
||||
// otherwise rewrite the entire file on every debounce tick.
|
||||
const signature = `${label ?? ""}-${messages.length}-${
|
||||
messages[messages.length - 1]?.message ?? ""
|
||||
}`;
|
||||
const state = this.getSessionState(session.internalId);
|
||||
if (state.signature === signature) return;
|
||||
|
||||
const result = await persistence.saveSession(messages, session.backendId, {
|
||||
label,
|
||||
existingPath: state.path,
|
||||
});
|
||||
if (result) {
|
||||
state.path = result.path;
|
||||
state.signature = signature;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel any pending debounced auto-save for `session` and run it
|
||||
* synchronously, so the on-disk file reflects the final state before the
|
||||
* session is disposed. Safe to call when no save is pending.
|
||||
*/
|
||||
private async drainAutoSave(session: AgentSession): Promise<void> {
|
||||
const state = this.sessionState.get(session.internalId);
|
||||
if (!state?.timer) return;
|
||||
window.clearTimeout(state.timer);
|
||||
state.timer = undefined;
|
||||
try {
|
||||
await this.flushAutoSave(session);
|
||||
} catch (e) {
|
||||
logWarn(`[AgentMode] drain auto-save failed for ${session.internalId}`, e);
|
||||
}
|
||||
}
|
||||
|
||||
private detachAutoSave(internalId: string): void {
|
||||
const state = this.sessionState.get(internalId);
|
||||
if (!state) return;
|
||||
if (state.timer) window.clearTimeout(state.timer);
|
||||
state.unsub?.();
|
||||
state.modelCacheUnsub?.();
|
||||
state.attentionUnsub?.();
|
||||
this.sessionState.delete(internalId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror this session's unified `BackendState` into the preloader cache
|
||||
* so the picker reflects current state. Skips when the session has no
|
||||
* usable state yet (during the `"starting"` window) — a naive sync
|
||||
* would clobber the previous session's cached entries with an empty
|
||||
* snapshot.
|
||||
*/
|
||||
private attachModelCacheSync(session: AgentSession): void {
|
||||
const sync = (): void => {
|
||||
const state = session.getState();
|
||||
if (!state) return;
|
||||
if (!state.model && !state.mode) return;
|
||||
this.preloader.setCached(session.backendId, state);
|
||||
};
|
||||
sync();
|
||||
const unsubscribe = session.subscribe({
|
||||
onMessagesChanged: () => {},
|
||||
onStatusChanged: () => {},
|
||||
onModelChanged: () => sync(),
|
||||
});
|
||||
this.getSessionState(session.internalId).modelCacheUnsub = unsubscribe;
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch this session's status transitions and flag `needsAttention` when
|
||||
* it transitions out of `running` into a state that demands the user's
|
||||
* eye (turn ended, errored, or paused for permission) while a *different*
|
||||
* tab is active. The flag is cleared in `setActiveSession` when the user
|
||||
* clicks back to this tab.
|
||||
*/
|
||||
private attachAttentionTracking(session: AgentSession): void {
|
||||
let prev = session.getStatus();
|
||||
const unsubscribe = session.subscribe({
|
||||
onMessagesChanged: () => {},
|
||||
onStatusChanged: (next) => {
|
||||
const wasRunning = prev === "running";
|
||||
prev = next;
|
||||
void this.flushDeferredBackendRestartIfReady(session.backendId);
|
||||
if (!wasRunning) return;
|
||||
if (!ATTENTION_TRIGGER_STATUSES.has(next)) return;
|
||||
if (this.activeSessionId === session.internalId) return;
|
||||
session.markNeedsAttention();
|
||||
},
|
||||
});
|
||||
this.getSessionState(session.internalId).attentionUnsub = unsubscribe;
|
||||
}
|
||||
|
||||
private async ensureBackend(
|
||||
backendId: BackendId,
|
||||
descriptor: BackendDescriptor
|
||||
): Promise<BackendProcess> {
|
||||
const existing = this.backends.get(backendId);
|
||||
if (existing && existing.isRunning()) return existing;
|
||||
const inflight = this.starting.get(backendId);
|
||||
if (inflight) return inflight;
|
||||
|
||||
const proc = descriptor.createBackendProcess({
|
||||
plugin: this.plugin,
|
||||
app: this.app,
|
||||
clientVersion: this.plugin.manifest.version,
|
||||
descriptor,
|
||||
});
|
||||
const startPromise = (async () => {
|
||||
// ACP backends declare `start()` to spawn the subprocess and run the
|
||||
// initialize handshake. In-process adapters (Claude SDK) omit it.
|
||||
if (proc.start) await proc.start();
|
||||
proc.setPermissionPrompter(this.opts.permissionPrompter);
|
||||
proc.onExit(() => {
|
||||
// Backend died unexpectedly. Sessions belonging to *this* backend
|
||||
// are now unusable (their backend session ids are dead) — but other
|
||||
// backends keep running. Preserving message history across crashes
|
||||
// is M5.
|
||||
if (this.backends.get(backendId) === proc) this.backends.delete(backendId);
|
||||
const dead = Array.from(this.sessions.values()).filter((s) => s.backendId === backendId);
|
||||
if (dead.length === 0) return;
|
||||
for (const s of dead) {
|
||||
this.detachAutoSave(s.internalId);
|
||||
this.sessions.delete(s.internalId);
|
||||
this.chatUIStates.delete(s.internalId);
|
||||
s.cancel().catch(() => {});
|
||||
s.dispose().catch(() => {});
|
||||
}
|
||||
if (this.activeSessionId && !this.sessions.has(this.activeSessionId)) {
|
||||
const remaining = Array.from(this.sessions.keys());
|
||||
this.activeSessionId = remaining[0] ?? null;
|
||||
}
|
||||
// Surface the crash so the empty-state pill shows it and the
|
||||
// router's auto-spawn effect (which bails on lastError) doesn't
|
||||
// immediately respawn behind the user's back. The next explicit
|
||||
// create call clears it.
|
||||
this.lastError = `${descriptor.displayName} backend exited unexpectedly.`;
|
||||
this.notify();
|
||||
});
|
||||
this.backends.set(backendId, proc);
|
||||
return proc;
|
||||
})();
|
||||
this.starting.set(backendId, startPromise);
|
||||
try {
|
||||
return await startPromise;
|
||||
} finally {
|
||||
this.starting.delete(backendId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether any session for `backendId` is not safe to dispose yet. */
|
||||
private hasBusySession(backendId: BackendId): boolean {
|
||||
return Array.from(this.sessions.values()).some((session) => {
|
||||
if (session.backendId !== backendId) return false;
|
||||
const status = session.getStatus();
|
||||
return status === "starting" || status === "running" || status === "awaiting_permission";
|
||||
});
|
||||
}
|
||||
|
||||
/** Execute a pending backend restart once every session for that backend is idle. */
|
||||
private async flushDeferredBackendRestartIfReady(backendId: BackendId): Promise<void> {
|
||||
const reason = this.pendingBackendRestarts.get(backendId);
|
||||
if (!reason) return;
|
||||
if (this.hasBusySession(backendId)) return;
|
||||
this.pendingBackendRestarts.delete(backendId);
|
||||
try {
|
||||
await this.restartBackendNow(backendId, reason);
|
||||
} catch (err) {
|
||||
this.lastError = err2String(err);
|
||||
logError(`[AgentMode] deferred ${backendId} backend restart failed`, err);
|
||||
this.notify();
|
||||
}
|
||||
}
|
||||
|
||||
/** Immediately tear down `backendId` and replace the active affected tab. */
|
||||
private async restartBackendNow(backendId: BackendId, reason: string): Promise<void> {
|
||||
if (this.restartingBackends.has(backendId)) return;
|
||||
const proc = this.backends.get(backendId);
|
||||
if (!proc) return;
|
||||
this.restartingBackends.add(backendId);
|
||||
logInfo(`[AgentMode] restarting ${backendId} backend: ${reason}`);
|
||||
try {
|
||||
const affected = Array.from(this.sessions.values()).filter((s) => s.backendId === backendId);
|
||||
const shouldCreateReplacement =
|
||||
affected.length > 0 && affected.some((s) => s.internalId === this.activeSessionId);
|
||||
for (const session of affected) {
|
||||
await this.closeSession(session.internalId);
|
||||
}
|
||||
await proc.shutdown();
|
||||
if (this.backends.get(backendId) === proc) {
|
||||
this.backends.delete(backendId);
|
||||
}
|
||||
this.preloader.clearCached(backendId);
|
||||
new Notice(`${this.resolveDescriptor(backendId).displayName} refreshed after skill changes.`);
|
||||
if (shouldCreateReplacement && !this.disposed) {
|
||||
await this.createSession(backendId);
|
||||
}
|
||||
this.notify();
|
||||
} finally {
|
||||
this.restartingBackends.delete(backendId);
|
||||
}
|
||||
}
|
||||
}
|
||||
32
src/agentMode/session/applyPersistedMode.ts
Normal file
32
src/agentMode/session/applyPersistedMode.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { logWarn } from "@/logger";
|
||||
import type { AgentSession } from "@/agentMode/session/AgentSession";
|
||||
import { MethodUnsupportedError } from "@/agentMode/session/errors";
|
||||
import type { CopilotMode } from "@/agentMode/session/types";
|
||||
|
||||
/**
|
||||
* Replay a persisted mode on a freshly created session. Skipped when the
|
||||
* agent doesn't advertise modes or when the persisted mode isn't currently
|
||||
* mappable (filtered out by the descriptor's `getModeMapping`). Dispatches
|
||||
* on the apply-spec kind so backends with `setMode` channels and backends
|
||||
* with `configOption`-driven modes share one implementation.
|
||||
*/
|
||||
export async function applyPersistedMode(
|
||||
session: AgentSession,
|
||||
persistedMode: CopilotMode
|
||||
): Promise<void> {
|
||||
const state = session.getState();
|
||||
if (!state?.mode) return;
|
||||
if (state.mode.current === persistedMode) return;
|
||||
const spec = state.mode.apply[persistedMode];
|
||||
if (!spec) return;
|
||||
try {
|
||||
if (spec.kind === "setMode") {
|
||||
await session.setMode(spec.nativeId);
|
||||
} else {
|
||||
await session.setConfigOption(spec.configId, spec.value);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof MethodUnsupportedError) return;
|
||||
logWarn(`[AgentMode] could not apply default mode ${persistedMode}`, e);
|
||||
}
|
||||
}
|
||||
20
src/agentMode/session/backendSettingsAccess.ts
Normal file
20
src/agentMode/session/backendSettingsAccess.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import type { CopilotSettings } from "@/settings/model";
|
||||
import type { BackendId } from "./types";
|
||||
|
||||
interface BackendSliceWithOverrides {
|
||||
modelEnabledOverrides?: Record<string, boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* `undefined` means "no overrides written" — callers should fall back to the
|
||||
* descriptor's default policy.
|
||||
*/
|
||||
export function getBackendModelOverrides(
|
||||
settings: CopilotSettings,
|
||||
backendId: BackendId
|
||||
): Record<string, boolean> | undefined {
|
||||
const backends = settings.agentMode?.backends as
|
||||
| Record<string, BackendSliceWithOverrides | undefined>
|
||||
| undefined;
|
||||
return backends?.[backendId]?.modelEnabledOverrides;
|
||||
}
|
||||
101
src/agentMode/session/debugSink.test.ts
Normal file
101
src/agentMode/session/debugSink.test.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import { FrameSink, getFrameLogPaths, NodeRuntime } from "./debugSink";
|
||||
|
||||
interface FakeRuntime extends NodeRuntime {
|
||||
files: Map<string, string>;
|
||||
removedPaths: string[];
|
||||
}
|
||||
|
||||
/** Create an in-memory runtime for exercising the frame sink without disk IO. */
|
||||
function makeRuntime(tmpDir = "/tmp"): FakeRuntime {
|
||||
const files = new Map<string, string>();
|
||||
const removedPaths: string[] = [];
|
||||
const join = (...parts: string[]) => parts.join("/").replace(/\/+/g, "/");
|
||||
return {
|
||||
files,
|
||||
removedPaths,
|
||||
tmpdir: () => tmpDir,
|
||||
join,
|
||||
dirname: (path) => path.slice(0, path.lastIndexOf("/")) || "/",
|
||||
mkdir: jest.fn(async () => undefined),
|
||||
appendFile: jest.fn(async (path, data) => {
|
||||
files.set(path, (files.get(path) ?? "") + data);
|
||||
}),
|
||||
writeFile: jest.fn(async (path, data) => {
|
||||
files.set(path, data);
|
||||
}),
|
||||
rm: jest.fn(async (path) => {
|
||||
removedPaths.push(path);
|
||||
files.delete(path);
|
||||
}),
|
||||
stat: jest.fn(async (path) => {
|
||||
const data = files.get(path);
|
||||
if (data === undefined) throw new Error("ENOENT");
|
||||
return { size: data.length };
|
||||
}),
|
||||
rename: jest.fn(async (oldPath, newPath) => {
|
||||
const data = files.get(oldPath);
|
||||
if (data === undefined) throw new Error("ENOENT");
|
||||
files.set(newPath, data);
|
||||
files.delete(oldPath);
|
||||
}),
|
||||
openPath: jest.fn(async () => ""),
|
||||
};
|
||||
}
|
||||
|
||||
describe("FrameSink", () => {
|
||||
it("stores frame logs in a per-vault temp directory", () => {
|
||||
const runtime = makeRuntime("C:/Users/zero/AppData/Local/Temp");
|
||||
const first = getFrameLogPaths("C:/Users/zero/Vault", runtime);
|
||||
const second = getFrameLogPaths("C:/Users/zero/OtherVault", runtime);
|
||||
|
||||
expect(first.logPath).toContain("/obsidian-copilot/acp-frames/");
|
||||
expect(first.logPath).toMatch(/\/acp-frames\.ndjson$/);
|
||||
expect(first.rotatedPath).toMatch(/\/acp-frames\.old\.ndjson$/);
|
||||
expect(first.dirPath).not.toBe(second.dirPath);
|
||||
});
|
||||
|
||||
it("summarizes oversized frames before appending", async () => {
|
||||
const runtime = makeRuntime();
|
||||
const sink = new FrameSink({ vaultBasePath: "/vault", runtime });
|
||||
const paths = getFrameLogPaths("/vault", runtime);
|
||||
|
||||
sink.append({
|
||||
ts: "2026-05-12T00:00:00.000Z",
|
||||
dir: "←",
|
||||
tag: "codex",
|
||||
kind: "notif",
|
||||
method: "session/update",
|
||||
id: null,
|
||||
payload: {
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: "call-1",
|
||||
},
|
||||
content: "x".repeat(100_000),
|
||||
},
|
||||
});
|
||||
await sink.flush();
|
||||
|
||||
const log = runtime.files.get(paths.logPath) ?? "";
|
||||
expect(log.length).toBeLessThan(5_000);
|
||||
expect(log).toContain('"__truncated":true');
|
||||
expect(log).toContain("sessionUpdate=tool_call_update");
|
||||
expect(log).toContain("toolCallId=call-1");
|
||||
});
|
||||
|
||||
it("clears active and rotated log files", async () => {
|
||||
const runtime = makeRuntime();
|
||||
const sink = new FrameSink({ vaultBasePath: "/vault", runtime });
|
||||
const paths = getFrameLogPaths("/vault", runtime);
|
||||
runtime.files.set(paths.logPath, "active");
|
||||
runtime.files.set(paths.rotatedPath, "old");
|
||||
|
||||
await sink.clear();
|
||||
|
||||
expect(runtime.files.has(paths.logPath)).toBe(false);
|
||||
expect(runtime.files.has(paths.rotatedPath)).toBe(false);
|
||||
expect(runtime.removedPaths).toEqual(
|
||||
expect.arrayContaining([paths.logPath, paths.rotatedPath])
|
||||
);
|
||||
});
|
||||
});
|
||||
410
src/agentMode/session/debugSink.ts
Normal file
410
src/agentMode/session/debugSink.ts
Normal file
|
|
@ -0,0 +1,410 @@
|
|||
import { FileSystemAdapter } from "obsidian";
|
||||
|
||||
/**
|
||||
* Sidecar logger and payload formatter shared by every backend's debug tap.
|
||||
* The ACP runtime (`acp/debugTap.ts`) and the SDK adapter
|
||||
* (`sdk/sdkDebugTap.ts`) both feed `frameSink` so JSON-RPC and SDK turns
|
||||
* land in the same NDJSON file. `tag` distinguishes the source.
|
||||
*/
|
||||
|
||||
export interface FrameRecord {
|
||||
ts: string;
|
||||
dir: "→" | "←";
|
||||
tag: string;
|
||||
kind: "request" | "notif" | "result" | "error" | "raw";
|
||||
method: string;
|
||||
id: string | null;
|
||||
payload: unknown;
|
||||
}
|
||||
|
||||
const LOG_FILE_NAME = "acp-frames.ndjson";
|
||||
const ROTATED_FILE_NAME = "acp-frames.old.ndjson";
|
||||
const DESKTOP_UNAVAILABLE_PATH = "(Agent Mode frame logs are desktop-only)";
|
||||
const LOG_DIR_PREFIX = ["obsidian-copilot", "acp-frames"] as const;
|
||||
const ROTATE_BYTES = 50 * 1024 * 1024;
|
||||
// Per-frame cap. Some backends (notably codex) re-emit the full cumulative
|
||||
// tool output on every `tool_call_update`, so a single frame can exceed 1 MB.
|
||||
// We replace the payload with a `__truncated` stub above this threshold.
|
||||
const MAX_LINE_BYTES = 64 * 1024;
|
||||
// Bound the in-flight write queue. Without this, a 160 fps frame storm pins
|
||||
// hundreds of MB of stringified lines as closures in `writeChain`.
|
||||
const MAX_QUEUE_FRAMES = 32;
|
||||
const MAX_QUEUE_BYTES = 8 * 1024 * 1024;
|
||||
// Stat-based rotation check every N writes. With MAX_LINE_BYTES capped at
|
||||
// 64 KB, the worst-case overshoot per check window is ~1.6 MB — well under
|
||||
// any reasonable disk budget.
|
||||
const ROTATE_CHECK_EVERY = 25;
|
||||
const MAX_PAYLOAD_CHARS = 400;
|
||||
|
||||
export interface FrameLogPaths {
|
||||
dirPath: string;
|
||||
logPath: string;
|
||||
rotatedPath: string;
|
||||
}
|
||||
|
||||
export interface NodeRuntime {
|
||||
tmpdir: () => string;
|
||||
join: (...parts: string[]) => string;
|
||||
dirname: (path: string) => string;
|
||||
mkdir: (path: string, opts: { recursive: boolean }) => Promise<void>;
|
||||
appendFile: (path: string, data: string, encoding: "utf8") => Promise<void>;
|
||||
writeFile: (path: string, data: string, encoding: "utf8") => Promise<void>;
|
||||
rm: (path: string, opts: { force: boolean; recursive?: boolean }) => Promise<void>;
|
||||
stat: (path: string) => Promise<{ size: number }>;
|
||||
rename: (oldPath: string, newPath: string) => Promise<void>;
|
||||
openPath?: (path: string) => Promise<string | void>;
|
||||
showItemInFolder?: (path: string) => void;
|
||||
}
|
||||
|
||||
export interface FrameSinkOptions {
|
||||
vaultBasePath?: string | null;
|
||||
runtime?: NodeRuntime | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sidecar logger for full backend frames. Writes are append-only NDJSON to
|
||||
* keep the file grep/jq-friendly. Writes are serialized through a single
|
||||
* promise chain so concurrent calls don't interleave partial lines.
|
||||
*
|
||||
* Rotation: every ROTATE_CHECK_EVERY writes, stat the file; if it exceeds
|
||||
* ROTATE_BYTES, rename to `.old.ndjson` (overwriting any prior `.old`) and
|
||||
* start a fresh file. Bounds disk use without losing the most recent session.
|
||||
*/
|
||||
export class FrameSink {
|
||||
private writeChain: Promise<void> = Promise.resolve();
|
||||
private ensuredDirPath: string | null = null;
|
||||
private writeCount = 0;
|
||||
private pendingFrames = 0;
|
||||
private pendingBytes = 0;
|
||||
private droppedSinceLastWrite = 0;
|
||||
|
||||
constructor(private readonly options: FrameSinkOptions = {}) {}
|
||||
|
||||
/** Return the current NDJSON log path, or a desktop-unavailable placeholder. */
|
||||
getPath(): string {
|
||||
return this.resolvePaths()?.logPath ?? DESKTOP_UNAVAILABLE_PATH;
|
||||
}
|
||||
|
||||
/** Schedule a write. Returns immediately; failures are swallowed. */
|
||||
append(record: FrameRecord): void {
|
||||
const paths = this.resolvePaths();
|
||||
if (!paths) return;
|
||||
|
||||
const line = this.toLine(record);
|
||||
|
||||
// Backpressure: drop new frames when the queue is saturated. Without
|
||||
// this, bursty backends (codex emitting cumulative content at 160 fps)
|
||||
// pin hundreds of MB of stringified lines while the vault adapter
|
||||
// catches up.
|
||||
if (
|
||||
this.pendingFrames >= MAX_QUEUE_FRAMES ||
|
||||
this.pendingBytes + line.length > MAX_QUEUE_BYTES
|
||||
) {
|
||||
this.droppedSinceLastWrite++;
|
||||
return;
|
||||
}
|
||||
|
||||
const lineBytes = line.length;
|
||||
this.pendingFrames++;
|
||||
this.pendingBytes += lineBytes;
|
||||
|
||||
this.writeChain = this.writeChain
|
||||
.then(() => this.doAppend(paths, line))
|
||||
.then(
|
||||
() => {
|
||||
this.pendingFrames--;
|
||||
this.pendingBytes -= lineBytes;
|
||||
},
|
||||
() => {
|
||||
this.pendingFrames--;
|
||||
this.pendingBytes -= lineBytes;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/** Delete the active and rotated log files after queued writes finish. */
|
||||
async clear(): Promise<void> {
|
||||
const task = this.writeChain.then(async () => {
|
||||
const paths = this.resolvePaths();
|
||||
if (!paths) return;
|
||||
const runtime = this.getRuntime();
|
||||
if (!runtime) return;
|
||||
await removeIfExists(runtime, paths.logPath);
|
||||
await removeIfExists(runtime, paths.rotatedPath);
|
||||
});
|
||||
this.writeChain = task.catch(() => {});
|
||||
return task;
|
||||
}
|
||||
|
||||
/** Ensure the log exists and open it with the desktop file handler. */
|
||||
async open(): Promise<void> {
|
||||
const task = this.writeChain.then(async () => {
|
||||
const paths = this.resolvePaths();
|
||||
if (!paths) return;
|
||||
const runtime = this.getRuntime();
|
||||
if (!runtime) return;
|
||||
await this.ensureFolder(runtime, paths.dirPath);
|
||||
await ensureFileExists(runtime, paths.logPath);
|
||||
});
|
||||
this.writeChain = task.catch(() => {});
|
||||
await task;
|
||||
const paths = this.resolvePaths();
|
||||
if (!paths) return;
|
||||
const runtime = this.getRuntime();
|
||||
if (!runtime) return;
|
||||
if (runtime.openPath) {
|
||||
const errorMessage = await runtime.openPath(paths.logPath);
|
||||
if (typeof errorMessage === "string" && errorMessage.length > 0) {
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (runtime.showItemInFolder) {
|
||||
runtime.showItemInFolder(paths.logPath);
|
||||
return;
|
||||
}
|
||||
throw new Error("No OS file opener is available.");
|
||||
}
|
||||
|
||||
/** Wait for all queued writes to settle. Intended for tests and tooling. */
|
||||
async flush(): Promise<void> {
|
||||
await this.writeChain;
|
||||
}
|
||||
|
||||
private resolvePaths(): FrameLogPaths | null {
|
||||
const runtime = this.getRuntime();
|
||||
if (!runtime) return null;
|
||||
const vaultBasePath = this.options.vaultBasePath ?? getVaultBasePath();
|
||||
if (!vaultBasePath) return null;
|
||||
return getFrameLogPaths(vaultBasePath, runtime);
|
||||
}
|
||||
|
||||
private getRuntime(): NodeRuntime | null {
|
||||
return this.options.runtime ?? getNodeRuntime();
|
||||
}
|
||||
|
||||
private async ensureFolder(runtime: NodeRuntime, dirPath: string): Promise<void> {
|
||||
if (this.ensuredDirPath === dirPath) return;
|
||||
await runtime.mkdir(dirPath, { recursive: true });
|
||||
this.ensuredDirPath = dirPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a record to a single NDJSON line, replacing payloads that
|
||||
* exceed MAX_LINE_BYTES with a `__truncated` stub so a single huge frame
|
||||
* can't dominate the queue or the on-disk file.
|
||||
*/
|
||||
private toLine(record: FrameRecord): string {
|
||||
let line: string;
|
||||
try {
|
||||
line = JSON.stringify(record) + "\n";
|
||||
} catch {
|
||||
// Payload not serializable (e.g. circular). Fall back to a stub so the
|
||||
// frame still shows up in the log.
|
||||
return (
|
||||
JSON.stringify({
|
||||
...record,
|
||||
payload: { __unserializable: true },
|
||||
}) + "\n"
|
||||
);
|
||||
}
|
||||
|
||||
if (line.length <= MAX_LINE_BYTES) return line;
|
||||
|
||||
let payloadBytes = 0;
|
||||
try {
|
||||
payloadBytes = JSON.stringify(record.payload).length;
|
||||
} catch {
|
||||
payloadBytes = 0;
|
||||
}
|
||||
return (
|
||||
JSON.stringify({
|
||||
...record,
|
||||
payload: {
|
||||
__truncated: true,
|
||||
originalBytes: payloadBytes,
|
||||
summary: summarizePayload(record.payload),
|
||||
},
|
||||
}) + "\n"
|
||||
);
|
||||
}
|
||||
|
||||
private async doAppend(paths: FrameLogPaths, line: string): Promise<void> {
|
||||
const runtime = this.getRuntime();
|
||||
if (!runtime) return;
|
||||
|
||||
// Surface dropped-frame counts inline so debugging-the-debugger is
|
||||
// possible without code reading. Reset BEFORE writing so concurrent
|
||||
// drops accumulate into the next note.
|
||||
let payload = line;
|
||||
if (this.droppedSinceLastWrite > 0) {
|
||||
const dropped = this.droppedSinceLastWrite;
|
||||
this.droppedSinceLastWrite = 0;
|
||||
const note =
|
||||
JSON.stringify({
|
||||
ts: new Date().toISOString(),
|
||||
dir: "→",
|
||||
tag: "frameSink",
|
||||
kind: "raw",
|
||||
method: "frameSink.dropped",
|
||||
id: null,
|
||||
payload: { dropped },
|
||||
}) + "\n";
|
||||
payload = note + line;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.ensureFolder(runtime, paths.dirPath);
|
||||
await runtime.appendFile(paths.logPath, payload, "utf8");
|
||||
} catch {
|
||||
// appendFile can fail if the directory was removed while a write was
|
||||
// queued; recreate the folder and write the frame as a fresh file.
|
||||
try {
|
||||
await runtime.mkdir(runtime.dirname(paths.logPath), { recursive: true });
|
||||
await runtime.writeFile(paths.logPath, payload, "utf8");
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.writeCount++;
|
||||
if (this.writeCount % ROTATE_CHECK_EVERY === 0) {
|
||||
await this.maybeRotate(runtime, paths);
|
||||
}
|
||||
}
|
||||
|
||||
private async maybeRotate(runtime: NodeRuntime, paths: FrameLogPaths): Promise<void> {
|
||||
try {
|
||||
const stat = await runtime.stat(paths.logPath);
|
||||
if (stat.size < ROTATE_BYTES) return;
|
||||
await removeIfExists(runtime, paths.rotatedPath);
|
||||
await runtime.rename(paths.logPath, paths.rotatedPath);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Build the per-vault temp NDJSON paths used by the full-frame sink. */
|
||||
export function getFrameLogPaths(vaultBasePath: string, runtime: NodeRuntime): FrameLogPaths {
|
||||
const vaultHash = stableHash(vaultBasePath);
|
||||
const dirPath = runtime.join(runtime.tmpdir(), ...LOG_DIR_PREFIX, vaultHash);
|
||||
return {
|
||||
dirPath,
|
||||
logPath: runtime.join(dirPath, LOG_FILE_NAME),
|
||||
rotatedPath: runtime.join(dirPath, ROTATED_FILE_NAME),
|
||||
};
|
||||
}
|
||||
|
||||
function getVaultBasePath(): string | null {
|
||||
if (typeof app === "undefined") return null;
|
||||
const adapter = app.vault?.adapter;
|
||||
if (!(adapter instanceof FileSystemAdapter)) return null;
|
||||
return adapter.getBasePath();
|
||||
}
|
||||
|
||||
function getNodeRuntime(): NodeRuntime | null {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const fs = require("node:fs/promises") as typeof import("node:fs/promises");
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const os = require("node:os") as typeof import("node:os");
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const path = require("node:path") as typeof import("node:path");
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const electron = require("electron") as {
|
||||
shell?: {
|
||||
openPath?: (path: string) => Promise<string>;
|
||||
showItemInFolder?: (path: string) => void;
|
||||
};
|
||||
remote?: {
|
||||
shell?: {
|
||||
openPath?: (path: string) => Promise<string>;
|
||||
showItemInFolder?: (path: string) => void;
|
||||
};
|
||||
};
|
||||
};
|
||||
const shell = electron.shell ?? electron.remote?.shell;
|
||||
return {
|
||||
tmpdir: () => os.tmpdir(),
|
||||
join: (...segs: string[]) => path.join(...segs),
|
||||
dirname: (p: string) => path.dirname(p),
|
||||
mkdir: async (dirPath, opts) => {
|
||||
await fs.mkdir(dirPath, opts);
|
||||
},
|
||||
appendFile: fs.appendFile,
|
||||
writeFile: fs.writeFile,
|
||||
rm: fs.rm,
|
||||
stat: fs.stat,
|
||||
rename: fs.rename,
|
||||
openPath: shell?.openPath?.bind(shell),
|
||||
showItemInFolder: shell?.showItemInFolder?.bind(shell),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureFileExists(runtime: NodeRuntime, path: string): Promise<void> {
|
||||
try {
|
||||
await runtime.stat(path);
|
||||
} catch {
|
||||
await runtime.writeFile(path, "", "utf8");
|
||||
}
|
||||
}
|
||||
|
||||
async function removeIfExists(runtime: NodeRuntime, path: string): Promise<void> {
|
||||
try {
|
||||
await runtime.rm(path, { force: true });
|
||||
} catch {
|
||||
// ignore — file already gone or adapter unavailable
|
||||
}
|
||||
}
|
||||
|
||||
function stableHash(value: string): string {
|
||||
let hash = 0x811c9dc5;
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
hash ^= value.charCodeAt(i);
|
||||
hash = Math.imul(hash, 0x01000193);
|
||||
}
|
||||
return (hash >>> 0).toString(16).padStart(8, "0");
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort one-line summary for a truncated payload. Keeps the most
|
||||
* useful identifying fields (`sessionUpdate`, `toolCallId`, `method`) so a
|
||||
* truncated frame still tells the reader which call it belonged to.
|
||||
*/
|
||||
function summarizePayload(payload: unknown): string {
|
||||
if (!payload || typeof payload !== "object") return String(payload);
|
||||
const obj = payload as Record<string, unknown>;
|
||||
const update = obj.update as Record<string, unknown> | undefined;
|
||||
const parts: string[] = [];
|
||||
if (typeof obj.method === "string") parts.push(`method=${obj.method}`);
|
||||
if (update && typeof update.sessionUpdate === "string") {
|
||||
parts.push(`sessionUpdate=${update.sessionUpdate}`);
|
||||
if (typeof update.toolCallId === "string") parts.push(`toolCallId=${update.toolCallId}`);
|
||||
}
|
||||
return parts.join(" ") || "<no summary>";
|
||||
}
|
||||
|
||||
export const frameSink = new FrameSink();
|
||||
|
||||
/**
|
||||
* Stringify a payload for the truncated console log. Returns "" for
|
||||
* undefined so the log line stays compact.
|
||||
*/
|
||||
export function formatPayload(value: unknown): string {
|
||||
if (value === undefined) return "";
|
||||
let s: string;
|
||||
try {
|
||||
s = JSON.stringify(value);
|
||||
} catch {
|
||||
s =
|
||||
typeof value === "string" || typeof value === "number" || typeof value === "boolean"
|
||||
? String(value)
|
||||
: Object.prototype.toString.call(value);
|
||||
}
|
||||
if (s.length <= MAX_PAYLOAD_CHARS) return s;
|
||||
return s.slice(0, MAX_PAYLOAD_CHARS) + `…(+${s.length - MAX_PAYLOAD_CHARS})`;
|
||||
}
|
||||
191
src/agentMode/session/descriptor.ts
Normal file
191
src/agentMode/session/descriptor.ts
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
import type { App } from "obsidian";
|
||||
import type React from "react";
|
||||
import type CopilotPlugin from "@/main";
|
||||
import type { CopilotSettings } from "@/settings/model";
|
||||
import type { AgentSession } from "@/agentMode/session/AgentSession";
|
||||
import type {
|
||||
BackendConfigOption,
|
||||
BackendId,
|
||||
BackendModelInfo,
|
||||
BackendProcess,
|
||||
CopilotMode,
|
||||
ModelSelection,
|
||||
ModelWireCodec,
|
||||
ModeMapping,
|
||||
RawModeState,
|
||||
} from "./types";
|
||||
|
||||
/** UI-facing install/setup state for a backend. */
|
||||
export type InstallState =
|
||||
| { kind: "absent" }
|
||||
| { kind: "ready"; source: "managed" | "custom" }
|
||||
| { kind: "error"; message: string };
|
||||
|
||||
/**
|
||||
* Backend-agnostic descriptor consumed by `session/` and `ui/`. Each backend
|
||||
* exports one of these from its own folder; the registry maps `BackendId →
|
||||
* BackendDescriptor`. Adding a new backend is exactly: implement
|
||||
* `createBackendProcess`, export a `BackendDescriptor`, register it. No
|
||||
* edits to session or UI.
|
||||
*/
|
||||
export interface BackendDescriptor {
|
||||
readonly id: BackendId;
|
||||
readonly displayName: string;
|
||||
|
||||
/**
|
||||
* Brand icon component for this backend. Rendered in the session tab strip
|
||||
* and anywhere else the UI surfaces backend identity. Should accept a
|
||||
* `className` for sizing/coloring and use `currentColor` for fill so it
|
||||
* adopts the surrounding theme color.
|
||||
*/
|
||||
readonly Icon: React.ComponentType<{ className?: string }>;
|
||||
|
||||
/**
|
||||
* Project-relative POSIX path of the directory this backend reads skills
|
||||
* from. No leading slash. The symlink fanout writes
|
||||
* `<vault>/<skillsProjectDir>/<skill-name>` for every enabled skill.
|
||||
*/
|
||||
readonly skillsProjectDir: string;
|
||||
|
||||
/**
|
||||
* Other backends whose skill directories this backend also loads skills
|
||||
* from at spawn time, beyond its own `skillsProjectDir`. Drives the deny
|
||||
* list for cross-discovered managed skills (see
|
||||
* `skills/denyListComposer.ts`).
|
||||
*
|
||||
* Required (not optional) so a new backend must make an explicit decision.
|
||||
* `[]` is the right answer when there is no cross-discovery surface.
|
||||
*/
|
||||
readonly crossDiscoveredAgents: ReadonlyArray<BackendId>;
|
||||
|
||||
/**
|
||||
* When true, the host restarts this backend whenever the effective managed
|
||||
* skill set changes. Set for backends (opencode) whose native skill-command
|
||||
* cache is built at spawn and won't otherwise pick up symlink fanout changes.
|
||||
*
|
||||
* Required (not optional) so a new backend must make an explicit decision.
|
||||
*/
|
||||
readonly restartOnManagedSkillsChange: boolean;
|
||||
|
||||
/** Sync read of install/setup state from settings + last-known disk reconcile. */
|
||||
getInstallState(settings: CopilotSettings): InstallState;
|
||||
|
||||
/** Subscribe to settings/disk changes affecting install state. Returns unsubscribe. */
|
||||
subscribeInstallState(plugin: CopilotPlugin, cb: () => void): () => void;
|
||||
|
||||
/** Open backend-specific install/setup modal. */
|
||||
openInstallUI(plugin: CopilotPlugin): void;
|
||||
|
||||
/**
|
||||
* Construct the backend process the session manager will drive. ACP-style
|
||||
* backends typically delegate to `simpleBinaryBackendProcess` from
|
||||
* `backends/shared/`, which wraps `AcpBackendProcess` around an
|
||||
* `AcpBackend` spawn descriptor. In-process adapters (e.g. the Claude
|
||||
* Agent SDK) construct their own `BackendProcess` implementation directly.
|
||||
*
|
||||
* `descriptor` is the descriptor itself — passed back so the backend
|
||||
* process can call dispatch hooks (`getModeMapping`, `wire.decode`,
|
||||
* `wire.encode`, `wire.effortConfigFor`) when producing `BackendState`
|
||||
* from its native catalogs.
|
||||
*/
|
||||
createBackendProcess(args: {
|
||||
plugin: CopilotPlugin;
|
||||
app: App;
|
||||
clientVersion: string;
|
||||
descriptor: BackendDescriptor;
|
||||
}): BackendProcess;
|
||||
|
||||
/** Optional: backend-specific settings panel. Rendered inside the Agent Mode tab. */
|
||||
SettingsPanel?: React.FC<{ plugin: CopilotPlugin; app: App }>;
|
||||
|
||||
/** Optional: reconcile install state on plugin load (e.g. clear stale managed install). */
|
||||
onPluginLoad?(plugin: CopilotPlugin): Promise<void>;
|
||||
|
||||
/**
|
||||
* Wire-format codec for this backend's model ids. The single point of
|
||||
* truth for "how does this backend pack model+effort into one
|
||||
* `RawModelState.availableModels[].modelId` string." Used at the
|
||||
* agent boundary by the translator (decode incoming catalog) and the
|
||||
* session manager (encode outgoing `setSessionModel`); never invoked
|
||||
* by the application layer.
|
||||
*/
|
||||
readonly wire: ModelWireCodec;
|
||||
|
||||
/**
|
||||
* Apply a (baseModelId, effort) selection to a live session. The descriptor
|
||||
* decides whether effort travels in the wire model id (suffix-style
|
||||
* backends: codex, opencode) or via a separate `setConfigOption` call
|
||||
* (descriptor-style: Claude SDK).
|
||||
*
|
||||
* `effort: null` means "default" — descriptor-style backends typically
|
||||
* no-op the effort dispatch on null (no "clear to default" config call
|
||||
* exists); suffix-style backends encode the null and re-emit the bare
|
||||
* model id.
|
||||
*
|
||||
* Implementations are expected to swallow `MethodUnsupportedError` from
|
||||
* the underlying `session.setConfigOption` call (the backend may simply
|
||||
* lack the capability) and propagate everything else.
|
||||
*/
|
||||
applySelection(session: AgentSession, selection: ModelSelection): Promise<void>;
|
||||
|
||||
/**
|
||||
* Optional: return the canonical → native mode mapping for this backend
|
||||
* given the current session state. Returning `null` hides the mode picker
|
||||
* for this backend. The mode adapter dispatches on `mapping.kind` to pick
|
||||
* between "set mode" and "set config option" channels.
|
||||
*/
|
||||
getModeMapping?(
|
||||
modeState: RawModeState | null,
|
||||
configOptions: BackendConfigOption[] | null
|
||||
): ModeMapping | null;
|
||||
|
||||
/**
|
||||
* Optional: persist the user's chosen mode so the next session can replay
|
||||
* it. Called by `AgentSessionManager.persistModeFor`.
|
||||
*/
|
||||
persistModeSelection?(value: CopilotMode, plugin: CopilotPlugin): Promise<void>;
|
||||
|
||||
/**
|
||||
* Optional: replay persisted state on a freshly created session. Runs
|
||||
* once after `createSession` resolves.
|
||||
*/
|
||||
applyInitialSessionConfig?(session: AgentSession, settings: CopilotSettings): Promise<void>;
|
||||
|
||||
/**
|
||||
* Optional: identify the backend's own plan-mode plan files. Used by the
|
||||
* Claude SDK permission bridge to auto-allow `Write` calls that target
|
||||
* backend-owned plan markdown (`~/.claude/plans/*.md`) while rejecting
|
||||
* arbitrary built-in writes. No other consumer today.
|
||||
*
|
||||
* `cwd` is the session's working directory; pass `null` when unknown
|
||||
* (the matcher should still recognize absolute data-dir paths).
|
||||
*/
|
||||
isPlanModePlanFilePath?(absolutePath: string, cwd: string | null | undefined): boolean;
|
||||
|
||||
/**
|
||||
* Optional: default enable/disable policy for an agent-reported model when
|
||||
* the user has no explicit `modelEnabledOverrides` entry. Returning `true`
|
||||
* surfaces the model in the chat picker and the settings tab; `false`
|
||||
* hides it. Omit to default-enable every agent-reported model.
|
||||
*
|
||||
* Used as a no-config curation knob — Codex and Opencode advertise large
|
||||
* catalogs and we ship with one-model defaults; Claude Code defaults to
|
||||
* showing all reported models.
|
||||
*/
|
||||
isModelEnabledByDefault?(model: BackendModelInfo): boolean;
|
||||
|
||||
/**
|
||||
* Optional: previously-stored sessionId of the backend's dedicated
|
||||
* "probe session", used by `AgentModelPreloader` to enumerate live models
|
||||
* across plugin reloads without accumulating one fresh agent-side session
|
||||
* record per startup. Returns `undefined` when no probe has run yet.
|
||||
*/
|
||||
getProbeSessionId?(settings: CopilotSettings): string | undefined;
|
||||
|
||||
/**
|
||||
* Optional: persist the probe sessionId returned by a successful
|
||||
* `session/new` probe so the next plugin load can reuse it via
|
||||
* `resumeSession` or `loadSession`. Only called by `AgentModelPreloader`.
|
||||
*/
|
||||
persistProbeSessionId?(sessionId: string, plugin: CopilotPlugin): Promise<void>;
|
||||
}
|
||||
23
src/agentMode/session/errors.ts
Normal file
23
src/agentMode/session/errors.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
/**
|
||||
* Cross-layer error types raised by the `BackendProcess` contract. Both the
|
||||
* ACP runtime (`acp/AcpBackendProcess`) and the in-process SDK adapter
|
||||
* (`sdk/ClaudeSdkBackendProcess`) raise `MethodUnsupportedError` when the
|
||||
* backend doesn't implement an optional capability; callers in `session/`,
|
||||
* `backends/*`, and `ui/` catch it and degrade gracefully.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Thrown when a backend does not implement an optional `BackendProcess`
|
||||
* method (e.g. `setSessionModel`, `resumeSession`, `loadSession`). Callers
|
||||
* should catch this and degrade gracefully (e.g. disable the model picker,
|
||||
* fall through to the next preloader strategy).
|
||||
*/
|
||||
export class MethodUnsupportedError extends Error {
|
||||
constructor(method: string) {
|
||||
super(`Agent does not implement ${method}`);
|
||||
this.name = "MethodUnsupportedError";
|
||||
}
|
||||
}
|
||||
|
||||
/** JSON-RPC standard "Method not found" error code. */
|
||||
export const JSONRPC_METHOD_NOT_FOUND = -32601;
|
||||
141
src/agentMode/session/expandCustomCommandPrefix.test.ts
Normal file
141
src/agentMode/session/expandCustomCommandPrefix.test.ts
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
import { mockTFile } from "@/__tests__/mockObsidian";
|
||||
import { expandCustomCommandPrefix } from "@/agentMode/session/expandCustomCommandPrefix";
|
||||
import { CustomCommand } from "@/commands/type";
|
||||
import { extractTemplateNoteFiles } from "@/utils";
|
||||
import { Vault } from "obsidian";
|
||||
|
||||
jest.mock("obsidian", () => ({
|
||||
Notice: jest.fn(),
|
||||
TFile: jest.fn(),
|
||||
Vault: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("@/logger", () => ({
|
||||
logWarn: jest.fn(),
|
||||
logInfo: jest.fn(),
|
||||
logError: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("@/utils", () => {
|
||||
const actual = jest.requireActual<{ stripFrontmatter: unknown }>("@/utils");
|
||||
return {
|
||||
extractTemplateNoteFiles: jest.fn().mockReturnValue([]),
|
||||
getFileContent: jest.fn(),
|
||||
getFileName: jest.fn(),
|
||||
getNotesFromPath: jest.fn(),
|
||||
getNotesFromTags: jest.fn(),
|
||||
processVariableNameForNotePath: jest.fn(),
|
||||
stripFrontmatter: actual.stripFrontmatter,
|
||||
};
|
||||
});
|
||||
|
||||
const makeCommand = (overrides: Partial<CustomCommand>): CustomCommand => ({
|
||||
title: "test",
|
||||
content: "",
|
||||
showInContextMenu: false,
|
||||
showInSlashMenu: true,
|
||||
order: 0,
|
||||
modelKey: "",
|
||||
lastUsedMs: 0,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("expandCustomCommandPrefix", () => {
|
||||
let vault: Vault;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(extractTemplateNoteFiles as jest.Mock).mockReturnValue([]);
|
||||
vault = {
|
||||
adapter: {
|
||||
stat: jest.fn().mockResolvedValue({ ctime: Date.now(), mtime: Date.now() }),
|
||||
},
|
||||
} as unknown as Vault;
|
||||
});
|
||||
|
||||
it("returns input unchanged when text does not start with slash", async () => {
|
||||
const result = await expandCustomCommandPrefix("hello world", [], vault, "", null);
|
||||
expect(result).toEqual({ text: "hello world" });
|
||||
});
|
||||
|
||||
it("returns input unchanged when no command matches", async () => {
|
||||
const cmds = [makeCommand({ title: "foo", content: "FOO BODY" })];
|
||||
const result = await expandCustomCommandPrefix("/unknown", cmds, vault, "", null);
|
||||
expect(result).toEqual({ text: "/unknown" });
|
||||
});
|
||||
|
||||
it("returns input unchanged for a lone slash", async () => {
|
||||
const cmds = [makeCommand({ title: "foo", content: "FOO BODY" })];
|
||||
const result = await expandCustomCommandPrefix("/", cmds, vault, "", null);
|
||||
expect(result).toEqual({ text: "/" });
|
||||
});
|
||||
|
||||
it("returns input unchanged when an empty commands list is given (skill collision case)", async () => {
|
||||
// Mirrors composeSlashMenuItems: if a skill shadows the command title,
|
||||
// the command never reaches this expander, so an empty list = pass-through.
|
||||
const result = await expandCustomCommandPrefix("/foo", [], vault, "", null);
|
||||
expect(result).toEqual({ text: "/foo" });
|
||||
});
|
||||
|
||||
it("expands `/foo` exactly to the command body (no args)", async () => {
|
||||
const cmd = makeCommand({ title: "foo", content: "FOO BODY" });
|
||||
const result = await expandCustomCommandPrefix("/foo", [cmd], vault, "", null);
|
||||
expect(result.matched).toBe(cmd);
|
||||
expect(result.text).toBe("FOO BODY\n\n");
|
||||
});
|
||||
|
||||
it("is case-insensitive on the command title", async () => {
|
||||
const cmd = makeCommand({ title: "Random-Hello", content: "Say hi" });
|
||||
const result = await expandCustomCommandPrefix("/random-hello", [cmd], vault, "", null);
|
||||
expect(result.matched).toBe(cmd);
|
||||
expect(result.text).toBe("Say hi\n\n");
|
||||
});
|
||||
|
||||
it("appends trailing args to the body before processing", async () => {
|
||||
const cmd = makeCommand({ title: "foo", content: "FOO BODY" });
|
||||
const result = await expandCustomCommandPrefix("/foo bar baz", [cmd], vault, "", null);
|
||||
expect(result.matched).toBe(cmd);
|
||||
expect(result.text).toBe("FOO BODY\n\nbar baz\n\n");
|
||||
});
|
||||
|
||||
it("prefers the longest matching title", async () => {
|
||||
const fooBar = makeCommand({ title: "foo-bar", content: "LONG" });
|
||||
const foo = makeCommand({ title: "foo", content: "SHORT" });
|
||||
const result = await expandCustomCommandPrefix("/foo-bar args", [foo, fooBar], vault, "", null);
|
||||
expect(result.matched).toBe(fooBar);
|
||||
expect(result.text).toBe("LONG\n\nargs\n\n");
|
||||
});
|
||||
|
||||
it("does not match when the title is a prefix but not followed by whitespace", async () => {
|
||||
const foo = makeCommand({ title: "foo", content: "FOO" });
|
||||
const result = await expandCustomCommandPrefix("/foobar", [foo], vault, "", null);
|
||||
expect(result).toEqual({ text: "/foobar" });
|
||||
});
|
||||
|
||||
it("expands `{}` against selected text", async () => {
|
||||
const cmd = makeCommand({ title: "rewrite", content: "Rewrite this: {}" });
|
||||
const result = await expandCustomCommandPrefix(
|
||||
"/rewrite",
|
||||
[cmd],
|
||||
vault,
|
||||
"the selected paragraph",
|
||||
null
|
||||
);
|
||||
expect(result.matched).toBe(cmd);
|
||||
expect(result.text).toBe(
|
||||
"Rewrite this: {selected_text}\n\n<selected_text>\nthe selected paragraph\n</selected_text>"
|
||||
);
|
||||
});
|
||||
|
||||
it("treats activeNote as the {} target when no selection is present", async () => {
|
||||
const cmd = makeCommand({ title: "summarize", content: "Summarize: {}" });
|
||||
const activeNote = mockTFile({ path: "notes/today.md", basename: "today" });
|
||||
const utils = jest.requireMock<{ getFileContent: jest.Mock }>("@/utils");
|
||||
utils.getFileContent.mockResolvedValue("note body");
|
||||
|
||||
const result = await expandCustomCommandPrefix("/summarize", [cmd], vault, "", activeNote);
|
||||
expect(result.matched).toBe(cmd);
|
||||
expect(result.text).toContain('<selected_text type="active_note">');
|
||||
expect(result.text).toContain("note body");
|
||||
});
|
||||
});
|
||||
53
src/agentMode/session/expandCustomCommandPrefix.ts
Normal file
53
src/agentMode/session/expandCustomCommandPrefix.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { processPrompt } from "@/commands/customCommandUtils";
|
||||
import type { CustomCommand } from "@/commands/type";
|
||||
import type { TFile, Vault } from "obsidian";
|
||||
|
||||
export interface ExpandCustomCommandResult {
|
||||
/** Final text to send to the backend. Equal to input when no command matched. */
|
||||
text: string;
|
||||
/** The matched command, if `input` started with `/<command-title>`. */
|
||||
matched?: CustomCommand;
|
||||
}
|
||||
|
||||
/**
|
||||
* If `input` starts with `/<command-title>` (optionally followed by
|
||||
* whitespace + args), substitute the command's body and return the
|
||||
* processed prompt. Otherwise return `input` unchanged.
|
||||
*
|
||||
* Args typed after the command name are appended to the command body
|
||||
* (separated by a blank line) so `processPrompt` can resolve `{}` /
|
||||
* `{selection}` against either selected text or the trailing args.
|
||||
*
|
||||
* Matching is case-insensitive on `title`. When multiple titles share a
|
||||
* prefix (e.g. `foo` and `foo-bar`), the longest match wins. The match
|
||||
* must be followed by whitespace or end-of-string so `/foobar` does not
|
||||
* match a `foo` command.
|
||||
*/
|
||||
export async function expandCustomCommandPrefix(
|
||||
input: string,
|
||||
commands: readonly CustomCommand[],
|
||||
vault: Vault,
|
||||
selectedText: string,
|
||||
activeNote: TFile | null
|
||||
): Promise<ExpandCustomCommandResult> {
|
||||
if (!input.startsWith("/") || input.length < 2) return { text: input };
|
||||
|
||||
const afterSlash = input.slice(1);
|
||||
const lowerAfterSlash = afterSlash.toLowerCase();
|
||||
|
||||
// Longest-first so `/foo-bar` wins over `/foo`.
|
||||
const candidates = [...commands].sort((a, b) => b.title.length - a.title.length);
|
||||
const matched = candidates.find((cmd) => {
|
||||
const title = cmd.title.toLowerCase();
|
||||
if (!lowerAfterSlash.startsWith(title)) return false;
|
||||
const next = afterSlash.charAt(title.length);
|
||||
return next === "" || /\s/.test(next);
|
||||
});
|
||||
if (!matched) return { text: input };
|
||||
|
||||
const args = afterSlash.slice(matched.title.length).trim();
|
||||
const body = args ? `${matched.content}\n\n${args}` : matched.content;
|
||||
|
||||
const result = await processPrompt(body, selectedText, vault, activeNote, false);
|
||||
return { text: result.processedPrompt, matched };
|
||||
}
|
||||
26
src/agentMode/session/index.ts
Normal file
26
src/agentMode/session/index.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
export {
|
||||
AgentSessionManager,
|
||||
type AgentSessionManagerOptions,
|
||||
type PermissionPrompter,
|
||||
} from "./AgentSessionManager";
|
||||
export { AgentSession, type AgentSessionStatus, type AgentSessionListener } from "./AgentSession";
|
||||
export { AgentChatUIState } from "./AgentChatUIState";
|
||||
export type { AgentChatBackend } from "./AgentChatBackend";
|
||||
export { AgentMessageStore } from "./AgentMessageStore";
|
||||
export type {
|
||||
BackendId,
|
||||
BackendProcess,
|
||||
AgentChatMessage,
|
||||
AgentMessagePart,
|
||||
AgentToolCallOutput,
|
||||
AgentToolKind,
|
||||
AgentToolStatus,
|
||||
AgentPlanEntry,
|
||||
NewAgentChatMessage,
|
||||
PermissionDecision,
|
||||
PermissionPrompt,
|
||||
SessionEvent,
|
||||
SessionUpdate,
|
||||
} from "./types";
|
||||
export type { BackendDescriptor, InstallState } from "./descriptor";
|
||||
export { MethodUnsupportedError, JSONRPC_METHOD_NOT_FOUND } from "./errors";
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue