mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-20 15:25:50 +00:00
Compare commits
2 Commits
fix/acp-pr
...
fix/ripgre
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d2d511b53 | ||
|
|
9d6a98dd06 |
@@ -1,880 +0,0 @@
|
|||||||
# ACP 合规性审计报告
|
|
||||||
|
|
||||||
> 生成日期: 2026-06-19
|
|
||||||
> 审计范围: src/services/acp/ 和 packages/acp-link/
|
|
||||||
> 对照规范: /Users/konghayao/code/knowledgebase/origin/acp/agent-client-protocol (commit 取自仓库 HEAD)
|
|
||||||
|
|
||||||
## 概览
|
|
||||||
|
|
||||||
- 总发现数: 53(其中部分为同根因跨维度交叉引用,如 image 能力声明问题在维度 1/3/7 各列一条并注明同根因;独立根因实际约 49 条)
|
|
||||||
- 按严重程度: critical 5 / major 17 / minor 20 / nit 11
|
|
||||||
- 涉及方法/字段:
|
|
||||||
- `initialize` / `authenticate` / `logout`
|
|
||||||
- `session/new` / `session/load` / `session/resume` / `session/fork` / `session/list` / `session/close`
|
|
||||||
- `session/prompt` / `session/cancel` / StopReason / Usage
|
|
||||||
- `session/update` 全部变体(usage_update、tool_call、tool_call_update、session_info_update)
|
|
||||||
- `session/set_mode` / `session/set_config_option` / `session/set_model`
|
|
||||||
- ContentBlock 处理(text / image / audio / resource / resourceLink / thought)
|
|
||||||
- 权限委托(RequestPermissionOutcome、ToolKind、ToolCallLocation、terminal 生命周期)
|
|
||||||
- 自定义传输(acp-link WS 代理、JSON-RPC envelope、`$/cancel_request`、能力协商)
|
|
||||||
|
|
||||||
## 修复优先级矩阵
|
|
||||||
|
|
||||||
| 优先级 | 维度 | 发现数 | 修复成本 | 是否阻断 |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| P0 | acp-link 传输层违反 JSON-RPC 2.0(维度 8) | 4 (2 critical + 2 major) | 高 | 是 |
|
|
||||||
| P0 | promptCapabilities.image 声明与实现脱节(维度 1/3/7) | 3 (3 major, 重复根因) | 低 | 是 |
|
|
||||||
| P0 | session/resume 重放历史违反 MUST NOT(维度 2) | 1 (1 critical) | 中 | 是 |
|
|
||||||
| P0 | session/update usage_update 非稳定 v1 判别器(维度 4) | 1 (1 critical) | 低 | ⚠️ **撤销**(interop 优先,见 §4.1) |
|
|
||||||
| P1 | PromptResponse.usage 非规范根字段(维度 3) | 1 (1 major) | 低 | ⚠️ **撤销**(同 §4.1 决策,根部 usage 与 _meta 镜像并存) |
|
|
||||||
| P1 | refusal stop_reason 丢失(维度 3) | 1 (1 major) | 低 | 否 |
|
|
||||||
| P1 | terminal 能力误用 `_meta` + 缺失标准生命周期(维度 5) | 2 (2 major) | 高 | 否 |
|
|
||||||
| P1 | 权限 `cancelled` 未传播为 StopReason::Cancelled(维度 5) | 1 (1 major) | 中 | 否 |
|
|
||||||
| P1 | setSessionMode 未发 current_mode_update(维度 6) | 1 (1 major) | 低 | 否 |
|
|
||||||
| P1 | session/load 跨项目 cwd 校验缺失(维度 2) | 1 (1 major) | 中 | 否 |
|
|
||||||
| P2 | 其他 minor / nit | 25 | 低-中 | 否 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. initialize / authenticate / logout + capabilities 协商(维度 1)
|
|
||||||
|
|
||||||
### 1.1 [major] image 能力声明与实际处理不符
|
|
||||||
|
|
||||||
- 位置: `src/services/acp/agent.ts:156` (initialize -> agentCapabilities.promptCapabilities) 配合 `src/services/acp/promptConversion.ts:9-25` (promptToQueryInput)
|
|
||||||
- 规范要求: PromptCapabilities.image (schema.json:2126-2130 + initialization.mdx:168-170): "The prompt may include ContentBlock::Image"。initialization.mdx:108 "Clients and Agents MUST treat all capabilities omitted in the initialize request as UNSUPPORTED"——反过来说,声明 `image: true` 即承诺 Client 可发送 ContentBlock::Image 且 Agent 会处理。
|
|
||||||
- 当前实现: initialize 返回 `promptCapabilities: { image: true, embeddedContext: true }`(未声明 audio,默认 false,正确)。但 promptToQueryInput() 只处理 `type==='text'`、`'resource_link'`、`'resource'` 三类 block;`'image'` block 无对应分支,被静默丢弃。prompt() (agent.ts:269) 把整个 prompt 压成纯字符串 promptInput 传给 QueryEngine.submitMessage()。Client 若信任 `image:true` 发来图片,Agent 会完全忽略,不报错也不转换。
|
|
||||||
- 修复建议: 二选一。
|
|
||||||
|
|
||||||
(A) 若确实不处理图片,把 `promptCapabilities.image` 改为 false(或删除该键,默认 false):
|
|
||||||
|
|
||||||
~~~diff
|
|
||||||
promptCapabilities: {
|
|
||||||
- image: true,
|
|
||||||
embeddedContext: true,
|
|
||||||
},
|
|
||||||
~~~
|
|
||||||
|
|
||||||
(B) 若要保留图片能力,在 promptToQueryInput 中处理 image block,将其作为 image content block 注入 query input(需 QueryEngine.submitMessage 支持多模态输入):
|
|
||||||
|
|
||||||
~~~diff
|
|
||||||
} else if (b.type === 'image') {
|
|
||||||
+ const img = b as { source?: { data?: string; media_type?: string } }
|
|
||||||
+ images.push({ data: img.source?.data, mediaType: img.source?.media_type })
|
|
||||||
}
|
|
||||||
~~~
|
|
||||||
|
|
||||||
然后扩展 submitMessage 接受 images 数组。在多模态 query input 支持完成前,推荐先采用 (A)。
|
|
||||||
|
|
||||||
### 1.2 [minor] sessionCapabilities.fork 为非稳定 v1 字段
|
|
||||||
|
|
||||||
- 位置: `src/services/acp/agent.ts:164-169` (sessionCapabilities: { fork: {}, list: {}, resume: {}, close: {} })
|
|
||||||
- 规范要求: 稳定 v1 SessionCapabilities (schema.json:2528-2571) 仅定义属性 `_meta` / `close` / `list` / `resume`,无 fork。SDK 自带 schema (node_modules/@agentclientprotocol/sdk/schema/schema.json:5139-5148) 明确标注 fork 为 "UNSTABLE — This capability is not part of the spec yet, and may be removed or changed at any point"。本审计只覆盖稳定 v1,draft/unstable 不在合规范围。
|
|
||||||
- 当前实现: sessionCapabilities 中包含 `fork: {}` 以配合已实现的 `unstable_forkSession()` (agent.ts:235)。但稳定 v1 schema 的 SessionCapabilities 不认识此键。由于 schema 未设 `additionalProperties:false`,字段不会导致 schema 校验硬失败,但严格 Client 会把它当作未知扩展忽略,无法据此发现 session/fork 支持。
|
|
||||||
- 修复建议: 将 unstable fork 能力迁移到 AgentCapabilities._meta 下的自定义扩展命名空间(与现有 `_meta.claudeCode.promptQueueing` 同模式),符合 extensibility.mdx:111-134 "Advertising Custom Capabilities":
|
|
||||||
|
|
||||||
~~~diff
|
|
||||||
agentCapabilities: {
|
|
||||||
_meta: {
|
|
||||||
- claudeCode: { promptQueueing: true },
|
|
||||||
+ claudeCode: { promptQueueing: true, forkSession: true },
|
|
||||||
},
|
|
||||||
promptCapabilities: { image: true, embeddedContext: true },
|
|
||||||
mcpCapabilities: { http: true, sse: true },
|
|
||||||
loadSession: true,
|
|
||||||
sessionCapabilities: {
|
|
||||||
- fork: {},
|
|
||||||
list: {},
|
|
||||||
resume: {},
|
|
||||||
close: {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
~~~
|
|
||||||
|
|
||||||
### 1.3 [nit] 缺失 authMethods 字段
|
|
||||||
|
|
||||||
- 位置: `src/services/acp/agent.ts:127-172` (initialize 返回值)
|
|
||||||
- 规范要求: InitializeResponse (schema.json:1487-1548) authMethods 默认 [] (schema.json:1528-1535)。authentication.mdx:37 "Agents advertise authentication options in the authMethods field of the initialize response"。虽然默认 [] 使字段可选,但显式返回 `authMethods: []` 更利于 Client 明确判断"无需认证"而非"能力未知"。
|
|
||||||
- 当前实现: initialize 返回值不含 authMethods 字段。authenticate() (agent.ts:176-181) 忽略 params.methodId 直接返回 `{}`,意味着即使 Client 用任意 methodId 调 authenticate 也会成功——但因 authMethods 缺失,规范上 Client 不应调用 authenticate。
|
|
||||||
- 修复建议: 显式返回 `authMethods: []` 以明示无认证方法,与 authenticate() 的 no-op 语义一致:
|
|
||||||
|
|
||||||
~~~diff
|
|
||||||
return {
|
|
||||||
protocolVersion: 1,
|
|
||||||
+ authMethods: [],
|
|
||||||
agentInfo: { ... },
|
|
||||||
agentCapabilities: { ... },
|
|
||||||
}
|
|
||||||
~~~
|
|
||||||
|
|
||||||
同时建议在 authenticate() 中校验:因未声明任何 method,若被调用应返回 method-not-found 错误(code -32601),而非无条件成功。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Session 生命周期:新建 / 加载 / 恢复 / 分叉 / 列出 / 关闭(维度 2)
|
|
||||||
|
|
||||||
### 2.1 [critical] session/resume 重放完整历史违反 MUST NOT
|
|
||||||
|
|
||||||
- 位置: `src/services/acp/agent.ts:193-199` (unstable_resumeSession) → getOrCreateSession (688-777) → replaySessionHistory (792-816) / replayHistoryMessages (757-769)
|
|
||||||
- 规范要求: docs/protocol/session-setup.mdx "Resuming a Session": "Unlike session/load, the Agent MUST NOT replay the conversation history via session/update notifications before responding. Instead, it restores the session context, reconnects to the requested MCP servers, and returns once the session is ready to continue."
|
|
||||||
- 当前实现: unstable_resumeSession 委托给 getOrCreateSession,这是 loadSession 使用的相同代码路径。对于在内存中找到的会话,它会调用 replaySessionHistory() (第 713 行);对于从磁盘加载的会话,它会调用 replayHistoryMessages() (第 757-769 行)。无论哪种方式,完整的对话历史都会在返回 ResumeSessionResponse 之前通过 session/update 通知流式传输回客户端。因此 session/resume 的行为与 session/load 完全一致,违反了 MUST NOT 重放规则。
|
|
||||||
- 修复建议: 将恢复路径与加载路径分离。添加一个不执行重放的 resumeSession() 实现:
|
|
||||||
|
|
||||||
~~~diff
|
|
||||||
async unstable_resumeSession(
|
|
||||||
params: ResumeSessionRequest,
|
|
||||||
): Promise<ResumeSessionResponse> {
|
|
||||||
- const result = await this.getOrCreateSession(params)
|
|
||||||
+ const result = await this.getOrCreateSession({ ...params, replay: false })
|
|
||||||
this.scheduleAvailableCommandsUpdate(result.sessionId)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
~~~
|
|
||||||
|
|
||||||
在 getOrCreateSession 中,根据 `replay` 标志控制两个 replayHistoryMessages/replaySessionHistory 调用,让 resume 传递 `replay:false`(恢复时仅恢复上下文 + MCP 连接,然后立即返回 `{ modes, models, configOptions }`)。保留 loadSession 的默认 `replay:true`。
|
|
||||||
|
|
||||||
### 2.2 [major] session/load 跨项目 cwd 校验缺失
|
|
||||||
|
|
||||||
- 位置: `src/services/acp/agent.ts:688-777` (getOrCreateSession) 和 resolveSessionFilePath in `src/utils/sessionStoragePortable.ts:401-464`
|
|
||||||
- 规范要求: docs/protocol/session-setup.mdx "Working Directory": "This directory MUST be an absolute path MUST be used for the session regardless of where the Agent subprocess was spawned."
|
|
||||||
- 当前实现: createSession() 从 {cwd, mcpServers} 计算 sessionFingerprint (agent.ts:665-670),而 getOrCreateSession() 仅在请求的会话已驻留在 this.sessions (第 696-721 行) 时才将指纹与该内存中的会话进行比较。当会话不在内存中时(正常的恢复/加载情况),代码会调用 resolveSessionFilePath(sessionId, cwd),该方法会搜索请求的目录、其 git 工作树,最后扫描所有项目目录 (sessionStoragePortable.ts:410-463)。没有任何检查验证会话原始的 cwd 是否与请求的 cwd 匹配。客户端可以传入项目 A 的 cwd 并成功加载项目 B 下持久化的会话,然后运行一个上下文错误的会话。在基于磁盘的路径上从未计算或比较过指纹。
|
|
||||||
- 修复建议: 在解析文件路径后,从磁盘上的会话中读取原始的 cwd(第一条消息的 'cwd' 字段),并将其与请求的 cwd 进行比较。如果不匹配,返回错误(JSON-RPC 错误代码 -32602 无效参数):
|
|
||||||
|
|
||||||
~~~ts
|
|
||||||
const resolved = await resolveSessionFilePath(params.sessionId, params.cwd)
|
|
||||||
if (resolved) {
|
|
||||||
const lite = await readSessionLite(resolved.filePath)
|
|
||||||
const originalCwd = lite && extractJsonStringField(lite.head, 'cwd')
|
|
||||||
if (originalCwd && path.resolve(originalCwd) !== path.resolve(params.cwd)) {
|
|
||||||
throw new RpcError(-32602, `Session cwd mismatch: session belongs to ${originalCwd}, requested ${params.cwd}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
~~~
|
|
||||||
|
|
||||||
或者,在加载会话的 cwd 不同时跳过工作树/全目录回退搜索,以便跨项目加载自然失败。
|
|
||||||
|
|
||||||
### 2.3 [major] unstable_forkSession 忽略源会话 ID,创建空白会话
|
|
||||||
|
|
||||||
- 位置: `src/services/acp/agent.ts:235-245` (unstable_forkSession)
|
|
||||||
- 规范要求: schema/schema.unstable.json ForkSessionRequest: required = ["sessionId", "cwd"];描述为 "The ID of the session to fork."。Agent 在 initialize (agent.ts:165) 中通过 `sessionCapabilities.fork:{}` 声称支持分叉。
|
|
||||||
- 当前实现: unstable_forkSession 忽略了 params.sessionId(要分叉的源会话)和 params.additionalDirectories。它只是调用 `this.createSession({ cwd, mcpServers, _meta })` 来构建一个全新的空会话,与源会话没有任何共享的历史/上下文。一个本应从源会话上下文分支出来的 "fork" 实际上创建了一个空白会话。新会话的 ID 被返回,但源会话的对话未恢复,因此分叉在功能上是错误的。
|
|
||||||
- 备注: 尽管 fork 是 UNSTABLE 且超出了严格的 v1 合规范围,但 Agent 声明了该能力并注册了处理程序,因此客户端调用 `session/fork` 将获得语义错误的结果。
|
|
||||||
- 修复建议: 将源会话的消息加载到内存中(通过 getLastSessionLog(params.sessionId)),并将它们作为 initialMessages 传递给 createSession,同时转发 additionalDirectories:
|
|
||||||
|
|
||||||
~~~ts
|
|
||||||
async unstable_forkSession(params: ForkSessionRequest): Promise<ForkSessionResponse> {
|
|
||||||
let initialMessages: Message[] | undefined
|
|
||||||
try {
|
|
||||||
const log = await getLastSessionLog(params.sessionId as UUID)
|
|
||||||
if (log?.messages.length) initialMessages = deserializeMessages(log.messages)
|
|
||||||
} catch (err) { console.error('[ACP] fork source load failed:', err) }
|
|
||||||
const response = await this.createSession(
|
|
||||||
{ cwd: params.cwd, mcpServers: params.mcpServers ?? [], _meta: params._meta, additionalDirectories: params.additionalDirectories },
|
|
||||||
{ initialMessages },
|
|
||||||
)
|
|
||||||
this.scheduleAvailableCommandsUpdate(response.sessionId)
|
|
||||||
return response
|
|
||||||
}
|
|
||||||
~~~
|
|
||||||
|
|
||||||
(扩展 createSession 签名以接受并持久化 additionalDirectories。)
|
|
||||||
|
|
||||||
### 2.4 [minor] listSessions 静默截断为 100 并忽略 cursor 分页
|
|
||||||
|
|
||||||
- 位置: `src/services/acp/agent.ts:211-231` (listSessions) 和 `src/utils/listSessionsImpl.ts:439-454`
|
|
||||||
- 规范要求: docs/protocol/session-list.mdx "Pagination": "Clients MUST treat cursors as opaque tokens ... Agents SHOULD return an error if the cursor is invalid." ListSessionsRequest.cursor 是一个可选的不透明分页 token (schema.json:1597)。
|
|
||||||
- 当前实现: listSessions 完全忽略了 params.cursor。它调用 `listSessionsImpl({ dir: params.cwd ?? undefined, limit: 100 })`——一个硬编码的 100 条目上限,没有偏移量,也没有消费 cursor。响应从不返回 nextCursor,因此跨大历史记录的分页静默失败:拥有超过 100 个会话的客户端只能看到最近的 100 个,无法获取其余的。无效的 cursor 被静默接受(规范指出 Agent 应该报错)。虽然返回不带 nextCursor 的所有结果是允许的,但静默截断为 100 违反了 "Clients MUST treat a missing nextCursor as the end" 的契约,因为 Agent 实际上有更多结果却隐瞒了。
|
|
||||||
- 修复建议: 要么 (a) 完全去掉硬编码的 100 限制(如果没有更多结果,返回所有会话且不带 nextCursor 是合规的),或者 (b) 实现 cursor→offset 解码:
|
|
||||||
|
|
||||||
~~~ts
|
|
||||||
const decoded = params.cursor
|
|
||||||
? JSON.parse(Buffer.from(params.cursor, 'base64').toString())
|
|
||||||
: { offset: 0 }
|
|
||||||
const candidates = await listSessionsImpl({ dir: params.cwd, limit: PAGE_SIZE, offset: decoded.offset })
|
|
||||||
const nextCursor = candidates.length === PAGE_SIZE
|
|
||||||
? Buffer.from(JSON.stringify({ offset: decoded.offset + PAGE_SIZE })).toString('base64')
|
|
||||||
: undefined
|
|
||||||
return { sessions: [...], nextCursor }
|
|
||||||
~~~
|
|
||||||
|
|
||||||
至少,当客户端发送 params.cursor 时(因为分页未实现),返回一个错误,这样客户端就不会得到静默错误的结果。
|
|
||||||
|
|
||||||
### 2.5 [nit] listSessions 对无标题会话发出空字符串 title
|
|
||||||
|
|
||||||
- 位置: `src/services/acp/agent.ts:219-228` (listSessions 会话映射)
|
|
||||||
- 规范要求: schema.json SessionInfo (2787): title 是 type `["string","null"]`(可选,可为空)。docs/protocol/session-list.mdx: "Human-readable title for the session. May be auto-generated from the first prompt."
|
|
||||||
- 当前实现: 对于每个候选者,代码无条件地发出 `title: sanitizeTitle(candidate.summary ?? '')`。当会话没有可提取的摘要/标题时(边缘情况下 candidate.summary 为空字符串),Agent 发出 `title: ""`。空字符串技术上是有效的,但没有信息量;根据 schema,省略 title 会更清晰。这是一个表面问题,因为基于磁盘的候选者很少幸存于空摘要。
|
|
||||||
- 修复建议: 仅在非空时包含 title:
|
|
||||||
|
|
||||||
~~~diff
|
|
||||||
+ const title = sanitizeTitle(candidate.summary ?? '')
|
|
||||||
sessions.push({
|
|
||||||
sessionId: candidate.sessionId,
|
|
||||||
cwd: candidate.cwd,
|
|
||||||
- title: sanitizeTitle(candidate.summary ?? ''),
|
|
||||||
+ ...(title ? { title } : {}),
|
|
||||||
updatedAt: new Date(candidate.lastModified).toISOString(),
|
|
||||||
})
|
|
||||||
~~~
|
|
||||||
|
|
||||||
updatedAt 的 ISO 8601 格式(new Date(ms).toISOString() → 例如 '2025-10-29T14:22:15.123Z') 已经合规。
|
|
||||||
|
|
||||||
### 2.6 [nit] NewSessionResponse 不含 cwd,但规范本身不要求
|
|
||||||
|
|
||||||
- 位置: `src/services/acp/agent.ts:185-189` (newSession) → createSession 返回 675-680
|
|
||||||
- 规范要求: schema.json NewSessionResponse (1916) 要求仅 `['sessionId']`;cwd 不在响应模式中。
|
|
||||||
- 当前实现: newSession 返回 `{ sessionId, models, modes, configOptions }`。sessionId(唯一必填字段)存在。cwd 不返回,但 schema 从未要求在响应中返回 cwd(cwd 是 session/new 的请求侧输入,如 docs/protocol/session-setup.mdx 第 52-68 行示例响应第 77-80 行所示,仅返回 `{ sessionId }`)。因此相对于规范没有违规;记录此内容以解决审计检查清单中的错误前提。
|
|
||||||
- 修复建议: 无需代码更改。只需更新内部审计检查清单,停止期望在 NewSessionResponse 中有 cwd。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. session/prompt + session/cancel + stop reason + usage(维度 3)
|
|
||||||
|
|
||||||
### 3.1 [critical] image 能力声明与实际丢弃不符
|
|
||||||
|
|
||||||
- 位置: `src/services/acp/agent.ts:155-158` (initialize) + `src/services/acp/promptConversion.ts:9-25` (promptToQueryInput)
|
|
||||||
- 规范要求: PromptRequest.prompt is ContentBlock[];Clients MUST restrict content types according to PromptCapabilities (prompt-turn.mdx:89-98)。Agent advertises `promptCapabilities.image: true`, signalling it accepts image content blocks.
|
|
||||||
- 当前实现: initialize() 声明 `promptCapabilities: { image: true, embeddedContext: true }`,但 promptToQueryInput() 只处理 block types `'text'`、`'resource_link'`、`'resource'`。任何 `type: 'image'` block(以及任何非文本/非资源 block)被静默丢弃——只产生字符串连接的文本,所以 image 输入无警告消失。没有通过文件系统或错误暴露 image 的回退。
|
|
||||||
- 修复建议: 要么停止宣告 image 支持直到它被接通,要么扩展 promptToQueryInput 以暴露 image block。最小正确修复:
|
|
||||||
|
|
||||||
~~~diff
|
|
||||||
promptCapabilities: {
|
|
||||||
- image: true,
|
|
||||||
+ image: false,
|
|
||||||
embeddedContext: true,
|
|
||||||
},
|
|
||||||
~~~
|
|
||||||
|
|
||||||
如果打算 image passthrough,query input 必须携带 image 数据——例如返回一个结构化输入,携带 `{ type: 'image', source: {...} }` block 而不是 flat string。在此之前,能力声明是协议谎言,使客户端发送 agent 永远看不到的 image。此问题与维度 1 的 §1.1 同根因。
|
|
||||||
|
|
||||||
### 3.2 [major] PromptResponse.usage 为非规范根字段
|
|
||||||
|
|
||||||
- 位置: `src/services/acp/agent.ts:326-340` (prompt return) 和 `src/services/acp/bridge.ts:756,1059` (forwardSessionUpdates return type)
|
|
||||||
- 规范要求: Stable v1 schema: PromptResponse (schema/schema.json:2163-2184) 只定义 `stopReason`(必填)和 `_meta`(可选)。extensibility.mdx:39 states: "Implementations MUST NOT add any custom fields at the root of a type that's part of the specification. All possible names are reserved for future protocol versions." `usage`/`TokenUsage` does not exist anywhere in the stable schema。
|
|
||||||
- 当前实现: prompt() 返回 `{ stopReason, usage: { inputTokens, outputTokens, cachedReadTokens, cachedWriteTokens, totalTokens } }`。`usage` 是非规范根字段。它碰巧匹配 bundled SDK schema (schema.json:4656-4665 marked **UNSTABLE**) 中的 UNSTABLE 形状,但那超出了 v1 合规范围。
|
|
||||||
- 修复建议: 停止为 v1 合规性在 PromptResponse 上发出 `usage`,或将其置于能力协商之后。最干净的修复:
|
|
||||||
|
|
||||||
~~~diff
|
|
||||||
-return { stopReason, usage }
|
|
||||||
+return { stopReason }
|
|
||||||
~~~
|
|
||||||
|
|
||||||
如果需要 token 报告,通过现有的 `usage_update` SessionUpdate 发送(已在 bridge.ts:843-854 完成,见维度 4 的 critical finding——但 usage_update 本身也是非稳定的)和/或将其移至 `_meta`——但根据 extensibility.mdx:39,即使是未知的根键也被保留,因此唯一规范一致的位置是 `_meta.usage`。推荐:
|
|
||||||
|
|
||||||
~~~ts
|
|
||||||
return { stopReason, _meta: usage ? { claudeCode: { usage } } : undefined }
|
|
||||||
~~~
|
|
||||||
|
|
||||||
### 3.3 [major] Anthropic refusal stop_reason 被误报为 end_turn
|
|
||||||
|
|
||||||
- 位置: `src/services/acp/bridge.ts:866-876` (success case stop_reason mapping)
|
|
||||||
- 规范要求: StopReason enum (schema.json:3212-3241) includes `refusal`——"The turn ended because the agent refused to continue." prompt-turn.mdx:278 defines refusal as a first-class stop reason。Anthropic API can return `stop_reason: 'refusal'` on safety refusals。
|
|
||||||
- 当前实现: 在 `success` 情况下只映射了 `'max_tokens'`;其他所有 Anthropic stop_reason(包括 `'refusal'`、`'end_turn'`、`'stop_sequence'`、`'tool_use'`)都落入默认 `stopReason = 'end_turn'`。没有分支将 `'refusal'` 映射到 ACP `refusal` stop reason,因此真正的拒绝被误报为成功的 end_turn,破坏了规范契约——refusal 应被反映(根据 refusal 语义,prompt 不应包含在下一轮)。
|
|
||||||
- 修复建议: 添加显式映射:
|
|
||||||
|
|
||||||
~~~diff
|
|
||||||
case 'success': {
|
|
||||||
- const stopReasonStr = msg.stop_reason
|
|
||||||
- if (stopReasonStr === 'max_tokens') {
|
|
||||||
- stopReason = 'max_tokens'
|
|
||||||
- }
|
|
||||||
- if (isError) {
|
|
||||||
- // Report error as end_turn
|
|
||||||
- stopReason = 'end_turn'
|
|
||||||
- }
|
|
||||||
+ const r = msg.stop_reason
|
|
||||||
+ if (r === 'max_tokens') stopReason = 'max_tokens'
|
|
||||||
+ else if (r === 'refusal') stopReason = 'refusal'
|
|
||||||
+ else stopReason = 'end_turn'
|
|
||||||
+ if (isError) stopReason = 'end_turn'
|
|
||||||
break
|
|
||||||
}
|
|
||||||
~~~
|
|
||||||
|
|
||||||
### 3.4 [minor] max_tokens 与 isError 检查相互覆盖
|
|
||||||
|
|
||||||
- 位置: `src/services/acp/bridge.ts:866-876` (success case) 和 877-886 (error_during_execution case)
|
|
||||||
- 规范要求: StopReason `max_tokens` (schema.json:3221-3223): "The turn ended because the agent reached the maximum number of tokens." prompt-turn.mdx:271-272。
|
|
||||||
- 当前实现: `max_tokens` 检查和 `isError` 检查是两个独立的 `if` 语句,不是 `else if`。当 `stop_reason === 'max_tokens'` 且 `isError === true` 时,第一个 `if` 设置 `stopReason = 'max_tokens'`,但第二个 `if` 立即覆盖为 `end_turn`。同样的缺陷也出现在 error_during_execution (877-886):max_tokens 可能被设置然后被覆盖。SDK 标记为错误的 max-tokens 终止因此被报告为 end_turn,向客户端隐藏了真正的原因。
|
|
||||||
- 修复建议: 使分支互斥或将 isError 仅作为回退(见 §3.3 的合并修复 diff)。
|
|
||||||
|
|
||||||
### 3.5 [minor] prompt 未读取 params._meta,trace context 丢失
|
|
||||||
|
|
||||||
- 位置: `src/services/acp/agent.ts:262-287` (prompt queue handling) 和 269 (params._meta not read)
|
|
||||||
- 规范要求: extensibility.mdx:8-39——`_meta` 是每个类型的保留扩展点,包括 PromptRequest (schema.json:2137-2141)。W3C trace context keys (`traceparent`、`tracestate`、`baggage`) SHOULD be propagated for OpenTelemetry interop (extensibility.mdx:33-38)。prompt-queue feature 只在 agentCapabilities 级别宣告(agent.ts:150-154 `_meta.claudeCode.promptQueueing: true`) 是正确的地方。
|
|
||||||
- 当前实现: prompt() 从不读取 `params._meta`。两个后果: (1) prompt 中客户端提供的 W3C trace context (`traceparent`/`tracestate`/`baggage`) 被静默丢弃,破坏了 tracing interop;(2) prompt-queueing 扩展已宣告,但没有 per-request opt-out 机制——客户端无法通过 `_meta` 信号 skip-queue。能力宣告本身是合规的。
|
|
||||||
- 修复建议: 将 `params._meta` 传递给 query 层,以便 trace context 可以附加到下游 API 调用,并可选地遵守 `_meta.claudeCode.skipQueue` flag。至少,转发 traceparent:
|
|
||||||
|
|
||||||
~~~ts
|
|
||||||
const traceparent = params._meta?.traceparent
|
|
||||||
// thread it into the API client request headers
|
|
||||||
~~~
|
|
||||||
|
|
||||||
### 3.6 [minor] prompt catch 块对 abort 信号竞态返回错误而非 cancelled
|
|
||||||
|
|
||||||
- 位置: `src/services/acp/agent.ts:342-359` (prompt catch block)
|
|
||||||
- 规范要求: prompt-turn.mdx:304-311 (Warning): "Agents MUST catch these errors and return the semantically meaningful `cancelled` stop reason, so that Clients can reliably confirm the cancellation." 这适用于中止操作产生的错误。当 session.cancelled 为 true 时,catch 块必须为任何错误返回 cancelled。
|
|
||||||
- 当前实现: catch 块确实检查 `if (session.cancelled) return { stopReason: 'cancelled' }` (343-345)——对于进程内 cancelled flag 是正确的。然而,守卫使用 `session.cancelled`,只由 cancel() 设置。如果 QueryEngine 的 abort signal 通过 interrupt() 触发,但 session.cancelled 尚未设置(interrupt() 完成和 cancel() 到达第 379 行之间的竞态窗口),或从嵌套路径传播取消派生的 AbortError,条件为 false,错误被重新抛出为 JSON-RPC 错误而不是 cancelled stop reason。更稳健的信号是 abort signal 本身。
|
|
||||||
- 修复建议: 在 flag 之外检查 abort signal,并将 AbortError/abort 形状错误视为取消:
|
|
||||||
|
|
||||||
~~~ts
|
|
||||||
} catch (err) {
|
|
||||||
const isAbort = err instanceof Error && (
|
|
||||||
err.name === 'AbortError' || /abort|cancelled|interrupt/i.test(err.message)
|
|
||||||
)
|
|
||||||
if (session.cancelled || isAbort) {
|
|
||||||
return { stopReason: 'cancelled' }
|
|
||||||
}
|
|
||||||
// ...existing process-death + rethrow
|
|
||||||
}
|
|
||||||
~~~
|
|
||||||
|
|
||||||
### 3.7 [minor] 空 prompt 提前返回 end_turn 语义错误
|
|
||||||
|
|
||||||
- 位置: `src/services/acp/agent.ts:271-273` (empty prompt early return)
|
|
||||||
- 规范要求: prompt-turn.mdx:185-199——Agent MUST respond to session/prompt with a StopReason when the turn ends。schema 没有定义空 prompt 的行为;StopReason `end_turn` (schema.json:3216-3218) 描述为 "The turn ended successfully," 暗示实际模型处理已发生。
|
|
||||||
- 当前实现: `if (!promptInput.trim()) return { stopReason: 'end_turn' }` 在不调用模型的情况下返回 end_turn。语义上,这为 no-op 输入报告成功的 turn,这是误导性的:模型从未运行。也没有路径区分 "空 prompt 无效" 和 "turn 完成"。
|
|
||||||
- 修复建议: 要么拒绝空 prompt 与 JSON-RPC 错误(invalid_params, -32602),因为 `prompt` 是必需的 ContentBlock[] 而有效空消息可能是畸形的,或至少文档说明 end_turn 在这里意味着 "nothing to do"。优先抛出:
|
|
||||||
|
|
||||||
~~~diff
|
|
||||||
-if (!promptInput.trim()) return { stopReason: 'end_turn' }
|
|
||||||
+if (!promptInput.trim()) throw new RpcError(-32602, 'Prompt content is empty')
|
|
||||||
~~~
|
|
||||||
|
|
||||||
### 3.8 [nit] usage 对象缺少 thoughtTokens
|
|
||||||
|
|
||||||
- 位置: `src/services/acp/agent.ts:328-339` (usage object construction)
|
|
||||||
- 规范要求: Bundled (UNSTABLE, out of v1 scope) SDK Usage (node_modules/@agentclientprotocol/sdk/schema/schema.json:6750-6791) has required `totalTokens/inputTokens/outputTokens` and optional `cachedReadTokens`、`cachedWriteTokens`、`thoughtTokens`。Stable v1 has no Usage at all。
|
|
||||||
- 当前实现: 构造的 usage 对象省略 `thoughtTokens`(reasoning/thinking tokens)。对于发出 reasoning tokens 的模型,报告的 totalTokens (input+output+cachedRead+cachedWrite) 将低估实际计费 tokens,因为 thinking tokens 被排除在总和之外。
|
|
||||||
- 修复建议: 如果报告 usage(见 §3.2 extra-field finding),包括可用的 thinking tokens:
|
|
||||||
|
|
||||||
~~~ts
|
|
||||||
totalTokens: inputTokens + outputTokens + cachedReadTokens + cachedWriteTokens + thoughtTokens
|
|
||||||
~~~
|
|
||||||
|
|
||||||
注意,这只在 unstable contract 下重要;对于严格的 v1 合规性,整个 usage 字段应被移除。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. session/update 通知形状(所有 update 变体)(维度 4)
|
|
||||||
|
|
||||||
### 4.1 [critical] usage_update 非稳定 v1 SessionUpdate 判别器 🔶 已撤销原修复 (2026-06-19)
|
|
||||||
|
|
||||||
- 位置: `src/services/acp/bridge/forwarding.ts` (forwardSessionUpdates, 'result' 情况)
|
|
||||||
- 规范要求: ACP v1 稳定版 schema schema.json:2942-3108 定义 SessionUpdate 为通过 propertyName `sessionUpdate` 进行 oneOf 判别,包含 10 个有效常量: `user_message_chunk`、`agent_message_chunk`、`agent_thought_chunk`、`tool_call`、`tool_call_update`、`plan`、`available_commands_update`、`current_mode_update`、`config_option_update`、`session_info_update`。`usage_update` 不在 v1 稳定版规范中。(Claude Code 捆绑的 SDK schema v0.19.0 第 5789 行将其标记为 "UNSTABLE——此功能尚未包含在规范中,随时可能被删除或更改"。)
|
|
||||||
- **决策回滚**: 原修复(2026-06-19 早期)完全移除了 `usage_update` 以追求严格 v1 stable 合规。但现实中所有主流 ACP 客户端(Zed、Cursor 等)实现的是 unstable spec,移除 `usage_update` 后客户端 context 使用量一律显示 `0/0`,严重破坏 UX。鉴于:
|
|
||||||
- SDK 已包含 `UsageUpdate` 类型(`sessionUpdate: 'usage_update'`, 字段 `used` + `size` + 可选 `cost`)
|
|
||||||
- `PromptResponse.usage` 也已由 SDK 在根部支持(UNSTABLE 但被广泛实现)
|
|
||||||
- 这是 context 使用量报告的**唯一**标准化载体
|
|
||||||
|
|
||||||
现行实现选择**优先保证 interop**: 在 'result' 消息后发送 `usage_update`,并在 PromptResponse 根部填充 `usage`。同时保留 `_meta.claudeCode.usage` 作为厂商扩展命名空间下的镜像,以便消费者任选读取路径。
|
|
||||||
- 当前实现: `bridge/forwarding.ts` 在收到 'result' 消息且 `lastAssistantTotalUsage !== null` 时发出 `usage_update`:
|
|
||||||
- `used` = 最近一条 assistant 消息的 input + output + cache_read + cache_creation token 总和(≈ 当前上下文占用)
|
|
||||||
- `size` = `lastContextWindowSize`(默认 200000,通过 modelUsage prefix-match 解析)
|
|
||||||
- compact_boundary 时不发(不知道压缩后的实际占用;下一轮的 result 会自然修正)
|
|
||||||
- 同步调整: `agent/promptFlow.ts` 在 PromptResponse 根部添加 `usage: { totalTokens, inputTokens, outputTokens, thoughtTokens, cachedReadTokens, cachedWriteTokens }`,并镜像到 `_meta.claudeCode.usage`。
|
|
||||||
|
|
||||||
### 4.2 [minor] 从未发出 tool_call in_progress 状态 ✅ 已修复 (2026-06-19)
|
|
||||||
|
|
||||||
- 位置: `src/services/acp/bridge.ts` `toAcpNotifications` 的 `tool_use` 分支 alreadyCached 路径
|
|
||||||
- 规范要求: schema.json:3525-3548 ToolCallStatus 枚举为 `pending`、`in_progress`、`completed`、`failed`。tool-calls.mdx:76-91 ('Updating') 文档化了一个生命周期,其中 Agent 在工具实际运行时报告 `status: 'in_progress'`。v1 规范称工具 "在其生命周期中会经历不同状态"。
|
|
||||||
- 修复: 当同一 tool_use 块被第二次遇到时(streaming `content_block_start` 首次 + assistant 完整消息回放第二次),发 `tool_call_update` with `status: 'in_progress'`。此时语义为"input 已收齐,即将执行"。完整 ToolCallStatus 生命周期现在是 pending → in_progress → completed|failed。
|
|
||||||
- 修复建议: 当 Claude Code 知道工具开始执行时,发出一个中间的 tool_call_update:
|
|
||||||
|
|
||||||
~~~ts
|
|
||||||
{ sessionUpdate: 'tool_call_update', toolCallId, status: 'in_progress' }
|
|
||||||
~~~
|
|
||||||
|
|
||||||
如果无法获得执行挂钩,请记录此差距;规范将其定义为 SHOULD 级别的生命周期信号,因此省略它仅属于轻微的合规性缺失。
|
|
||||||
|
|
||||||
### 4.3 [minor] 从未通过 session/update 发出 session_info_update
|
|
||||||
|
|
||||||
- 位置: `src/services/acp/agent.ts:225-226` (session-list 候选构建)——src/services/acp/ 下没有任何位置发出 session_info_update
|
|
||||||
- 规范要求: schema.json:2819-2837 SessionInfoUpdate 是一个有效的 SessionUpdate 变体 (`sessionUpdate: 'session_info_update'`),包含可选字段 `title` 和 `updatedAt`。它允许 Agent 通知客户端动态会话标题和最后活动时间戳。
|
|
||||||
- 当前实现: agent.ts 计算了一个会话标题(`title: sanitizeTitle(candidate.summary ?? '')` 和 `updatedAt: new Date(candidate.lastModified).toISOString()`)——但这仅用于 session/list 响应负载。从不通过 `session/update` 通知向客户端发出 session_info_update,因此当前会话的标题/更新时间永远不会流式传输给客户端。
|
|
||||||
- 修复建议: 当派生出或更改会话标题时(例如,在第一次助手回复或摘要提取后),发出:
|
|
||||||
|
|
||||||
~~~ts
|
|
||||||
await this.conn.sessionUpdate({
|
|
||||||
sessionId,
|
|
||||||
update: { sessionUpdate: 'session_info_update', title: derivedTitle, updatedAt: new Date().toISOString() },
|
|
||||||
})
|
|
||||||
~~~
|
|
||||||
|
|
||||||
这通过 v1 稳定版规范中记录的通道,为客户端提供了规范的会话显示名称。
|
|
||||||
|
|
||||||
### 4.4 [nit] Bash 工具 _meta 键未命名空间化 ✅ 已修复 (2026-06-19,与 §5.2 合并)
|
|
||||||
|
|
||||||
- 位置: `src/services/acp/bridge.ts` `toolUpdateFromToolResult` Bash 分支
|
|
||||||
- 规范要求: schema.json 将 `_meta` 记录为保留的扩展点("实现不得对这些键上的值做出假设")。建议使用反向 DNS / 供应商命名空间的自定义键。
|
|
||||||
- 修复: 与 §5.2 合并处理 — 完全删除了 `terminal_info` / `terminal_output` / `terminal_exit` 三个非标准 `_meta` 键,以及伪造的 `terminalId`。Bash 工具结果现在统一走 inline `{type:'text'}` content,不再向 `_meta` 注入任何键。命名空间问题随之消失。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. tool calls + permissions delegation(维度 5)
|
|
||||||
|
|
||||||
### 5.1 [major] terminal 能力检测误用 _meta 而非 clientCapabilities.terminal
|
|
||||||
|
|
||||||
- 位置: `src/services/acp/permissions.ts:280-285` (checkTerminalOutput)
|
|
||||||
- 规范要求: ClientCapabilities schema (schema.json:586-613) defines the standard terminal capability as the boolean field `clientCapabilities.terminal` (line 606-610, default false)。Terminals doc (docs/protocol/terminals.mdx:8-25) states: "Before attempting to use terminal methods, Agents MUST verify that the Client supports this capability by checking ... `clientCapabilities.terminal`"。`_meta` is explicitly reserved and "Implementations MUST NOT make assumptions about values at these keys" (schema.json:1961)。
|
|
||||||
- 当前实现: checkTerminalOutput 读取 `clientCapabilities._meta.terminal_output === true` 来决定 terminal 支持。从未咨询标准 `clientCapabilities.terminal` 布尔值,因此宣告 `terminal: true`(没有 Claude-Code 特定 `_meta.terminal_output` flag)的合规 ACP 客户端被视为不支持 terminals,而保留的 `_meta` 字段被视为真正的能力。
|
|
||||||
- 修复建议: 将标准能力作为主要,仅对较旧的 Claude-Code 客户端的遗留 `_meta` flag 进行回退:
|
|
||||||
|
|
||||||
~~~ts
|
|
||||||
function checkTerminalOutput(clientCapabilities?: ClientCapabilities): boolean {
|
|
||||||
if (!clientCapabilities) return false
|
|
||||||
if (clientCapabilities.terminal === true) return true
|
|
||||||
// Legacy Claude-Code clients advertised via _meta before terminal: bool existed
|
|
||||||
const meta = (clientCapabilities as unknown as Record<string, unknown>)._meta
|
|
||||||
return !!meta && typeof meta === 'object' && (meta as Record<string, unknown>)['terminal_output'] === true
|
|
||||||
}
|
|
||||||
~~~
|
|
||||||
|
|
||||||
### 5.2 [major] terminal 生命周期未实现,伪造 terminalId 且 _meta 注入非标准键 — 🔶 简化版已修复 (2026-06-19),完整版待办
|
|
||||||
|
|
||||||
- 位置: `src/services/acp/bridge.ts` `toolUpdateFromToolResult` Bash 分支 + `toolInfoFromToolUse` Bash 分支
|
|
||||||
- 规范要求: Terminals doc (docs/protocol/terminals.mdx:27-110) defines the standard terminal lifecycle: the Agent MUST call `terminal/create` to obtain a real `terminalId`, embed it via ToolCallContent `{type:'terminal', terminalId}` (schema.json:3242-3256), and the Client retrieves output via `terminal/output`。ToolCallUpdate._meta is reserved: "Implementations MUST NOT make assumptions about values at these keys" (schema.json:3555)。
|
|
||||||
- 简化版修复(已落地): 按文档建议回退到 inline `{type:'text'}` content,删除了伪造的 `terminalId: toolUse.id`(toolInfoFromToolUse + toolUpdateFromToolResult 两处)和三个非标准 `_meta` 键(`terminal_info` / `terminal_output` / `terminal_exit`)。合规客户端不再被误导去查找不存在的 terminal。Bash 输出仍以 ```console 围栏文本形式呈现给客户端。
|
|
||||||
- 完整版(待办): 实现标准 terminal 流程,需要 BashTool 接入 PTY 子系统:在工具运行前调用 `conn.request('terminal/create', {sessionId, command, cwd, outputByteLimit})`,嵌入返回的真实 `terminalId` 到 ToolCallContent,通过 terminal 子系统流式输出,完成时 `terminal/release`。此改造涉及 BashTool 执行管线(影响 CLI REPL 路径),需单独决策是否仅 ACP 路径启用。
|
|
||||||
|
|
||||||
### 5.3 [major] cancelled 权限结果被当作普通拒绝
|
|
||||||
|
|
||||||
- 位置: `src/services/acp/permissions.ts:136-142` (createAcpCanUseTool cancelled branch) 和 231-237 (handleExitPlanMode cancelled branch)
|
|
||||||
- 规范要求: RequestPermissionOutcome.cancelled variant (schema.json:2310-2320) is sent by the Client "when a client sends a session/cancel notification to cancel an ongoing prompt turn"。tool-calls.mdx:168-186 and the schema description state the prompt turn was cancelled。When the prompt turn is cancelled the Agent MUST resolve session/prompt with `StopReason::Cancelled` (schema.json:629 "Respond to the original session/prompt request with StopReason::Cancelled")。
|
|
||||||
- 当前实现: 在 `outcome === 'cancelled'` 时,canUseTool 返回一个通用的 `PermissionDenyDecision`(`behavior:'deny'`、decisionReason mode default / plan)。这作为普通拒绝反馈到工具执行器,因此 turn 继续(或失败与普通的 end_turn / tool-error)而不是用 `cancelled` 中止 turn。agent.cancel() flag 从不响应 cancelled 权限结果设置,因此 prompt 循环不返回 stopReason 'cancelled' 仅因为用户/客户端取消了权限 prompt。
|
|
||||||
- 修复建议: 将 `cancelled` 结果视为 turn-cancellation 信号。从 canUseTool 抛出一个类型化的 sentinel(或通过闭包传递一个 session-level cancelled flag)并让 forwardSessionUpdates / agent.prompt() 检测它以返回 `{stopReason:'cancelled'}`:
|
|
||||||
|
|
||||||
~~~ts
|
|
||||||
if (response.outcome.outcome === 'cancelled') {
|
|
||||||
cancelledRef.cancelled = true // shared with agent.cancel()
|
|
||||||
session.queryEngine.interrupt()
|
|
||||||
return { behavior:'deny', message:'Permission request cancelled by client', decisionReason:{type:'mode', mode:'default'}, toolUseID }
|
|
||||||
}
|
|
||||||
~~~
|
|
||||||
|
|
||||||
并在 agent.prompt(): `if (session.cancelled) return { stopReason: 'cancelled' }`。
|
|
||||||
|
|
||||||
### 5.4 [minor] 从未提供 reject_always 权限选项
|
|
||||||
|
|
||||||
- 位置: `src/services/acp/permissions.ts:123-127` (options array)
|
|
||||||
- 规范要求: PermissionOptionKind enum (schema.json:1992-2016) defines four variants: `allow_once`、`allow_always`、`reject_once`、`reject_always`。tool-calls.mdx:200-208 lists the same four。
|
|
||||||
- 当前实现: 提供的标准权限选项只有三个: `allow_always`、`allow_once`、`reject_once`。`reject_always`("Reject this operation and remember the choice")从不提供,因此用户无法通过协议的预期机制持久化拒绝(客户端依赖此 hint 显示 "remember" 复选框以供拒绝)。
|
|
||||||
- 修复建议: 添加一个 reject_always 选项,以便四个规范选择可用:
|
|
||||||
|
|
||||||
~~~ts
|
|
||||||
const options: PermissionOption[] = [
|
|
||||||
{ kind:'allow_always', name:'Always Allow', optionId:'allow_always' },
|
|
||||||
{ kind:'allow_once', name:'Allow', optionId:'allow' },
|
|
||||||
{ kind:'reject_once', name:'Reject', optionId:'reject' },
|
|
||||||
{ kind:'reject_always', name:'Always Reject', optionId:'reject_always' },
|
|
||||||
]
|
|
||||||
~~~
|
|
||||||
|
|
||||||
并在 selected 分支中处理 `optionId === 'reject' || optionId === 'reject_always'`。
|
|
||||||
|
|
||||||
### 5.5 [minor] ToolCallLocation.path / Diff.path 未归一化为绝对路径
|
|
||||||
|
|
||||||
- 位置: `src/services/acp/bridge.ts:251` (Read locations), 278/300 (Write/Edit locations), 314 (Glob locations), 700 (toolUpdateFromEditToolResponse locations)
|
|
||||||
- 规范要求: ToolCallLocation.path (schema.json:3517-3519) is "The file path being accessed or modified" (string)。tool-calls.mdx:304-306 and the protocol-wide path rule require absolute paths;Diff.path (schema.json:1178-1181) and the docs example ('/home/user/project/src/main.py') also use absolute paths。The ACP spec states all file paths MUST be absolute。
|
|
||||||
- 当前实现: Locations 和 diff paths 直接从 tool input(`input.file_path`、`input.path`、`response.filePath`)填充,不归一化为绝对路径。如果模型(或重放)提供相对路径或具有未解析的 `~`/`.` 段的路径,则发出的 ToolCallLocation.path / Diff.path 将是相对的,违反绝对路径要求。cwd 参数可用,但仅用于通过 toDisplayPath 格式化显示路径,不用于绝对化存储路径。
|
|
||||||
- 修复建议: 在发送前对每个发出的路径针对会话 cwd 进行解析:
|
|
||||||
|
|
||||||
~~~ts
|
|
||||||
import { isAbsolute, resolve } from 'node:path'
|
|
||||||
const abs = (p?: string) => p && cwd ? (isAbsolute(p) ? p : resolve(cwd, p)) : p
|
|
||||||
// then: locations: filePath ? [{ path: abs(filePath), line: offset ?? 1 }] : []
|
|
||||||
// and for diff content: path: abs(filePath)
|
|
||||||
~~~
|
|
||||||
|
|
||||||
应用于 Read/Write/Edit/Glob 和 toolUpdateFromEditToolResponse。
|
|
||||||
|
|
||||||
### 5.6 [minor] 无 delete / move ToolKind 映射
|
|
||||||
|
|
||||||
- 位置: `src/services/acp/bridge.ts:191-411` (toolInfoFromToolUse)——kind coverage
|
|
||||||
- 规范要求: ToolKind enum (schema.json:3616-3670): `read`、`edit`、`delete`、`move`、`search`、`execute`、`think`、`fetch`、`switch_mode`、`other`。Tools that remove or rename files SHOULD map to `delete` / `move` so clients can render appropriate UI (schema.json:3629-3638)。
|
|
||||||
- 当前实现: 大多数工具映射正确(Read→read、Write/Edit→edit、Bash→execute、Grep/Glob→search、WebFetch/WebSearch→fetch、Agent/TodoWrite→think、ExitPlanMode→switch_mode、default→other)。然而,没有为任何 delete 或 move 工具(例如,假设的 rm/mv 工具或 MCP filesystem delete)的映射——这样的工具落入 `other`。这在规范内('other' 是有效的)但丢失了语义提示。
|
|
||||||
- 修复建议: 如果/当 delete/move 工具通过 ACP 连接时,添加显式 case,例如 `case 'Remove': case 'Delete': → kind:'delete'`;`case 'Move': case 'Rename': → kind:'move'`。低优先级,直到这样的工具出现。
|
|
||||||
|
|
||||||
### 5.7 [nit] ExitPlanMode optionId 与 session-mode ID 碰撞
|
|
||||||
|
|
||||||
- 位置: `src/services/acp/permissions.ts:185-209` (handleExitPlanMode options) 和 244-254 (selectedOption check)
|
|
||||||
- 规范要求: PermissionOption.optionId is a free-form string (schema.json:1988-1990) with no enum constraint, so the custom optionIds `auto`、`acceptEdits`、`default`、`plan`、`bypassPermissions` are schema-valid。然而,与 session-mode ID 碰撞的 optionId 值是应用级歧义,PermissionOptionKind 是唯一标准化的 hint(四变体枚举)。对于实际上切换会话模式的选项(auto/acceptEdits/bypassPermissions)使用 `kind:'allow_always'` 过载了 allow_always 语义。
|
|
||||||
- 当前实现: ExitPlanMode 发出 4-5 个自定义选项,其中 optionId 等于会话模式 id。kind 字段设置为 allow_always/allow_once/reject_once 作为粗略提示,但 optionId 空间(模式 id)是 Claude-Code 约定,未在协议中文档化。这是允许的可扩展性,但 kind 不忠实地描述 "此选项更改会话模式"。
|
|
||||||
- 备注: 不是硬性违规,因为 optionId 是 free-form,ExitPlanMode 映射到有效的 ToolKind `switch_mode`。
|
|
||||||
- 修复建议: 可按原样接受;考虑在这些选项上添加 `_meta` hint(例如 `_meta.claudeCode.changesMode = true`),以便客户端可以不同地渲染它们,并确保 optionId 值在 agentCapabilities._meta 中文档化为 Claude-Code 特定的。
|
|
||||||
|
|
||||||
### 5.8 [nit] rawInput 浅克隆,易受嵌套突变影响
|
|
||||||
|
|
||||||
- 位置: `src/services/acp/bridge.ts:1283-1316` (rawInput construction in toAcpNotifications)
|
|
||||||
- 规范要求: ToolCallUpdate.rawInput (schema.json:3583-3585) is described as "Update the raw input" with no explicit type constraint (free-form)。It is intended to carry the raw tool input parameters (Record<string, unknown>)。
|
|
||||||
- 当前实现: `const rawInput = toolInput ? { ...toolInput } : {}` 是一个浅克隆;嵌套对象通过引用与实时 tool input 共享。如果在通知序列化之前对嵌套字段进行后续突变,则发出的 rawInput 可以反映执行后状态而不是发送的输入。Schema-valid 但语义脆弱。
|
|
||||||
- 修复建议: 深克隆(`structuredClone(toolInput)`)或 JSON-round-trip 输入,然后再附加为 rawInput,以保证捕获的值与实际发送给工具的值匹配。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. session/set_mode + session/set_model + session/set_config_option + modes/models/configOptions 形状(维度 6)
|
|
||||||
|
|
||||||
### 6.1 [major] setSessionMode 改变 mode 后未发 current_mode_update 通知
|
|
||||||
|
|
||||||
- 位置: `src/services/acp/agent.ts:396-407` (setSessionMode)
|
|
||||||
- 规范要求: session-modes.mdx 第 105-121 行: "The Agent can also change its own mode and let the Client know by sending the current_mode_update session notification。" schema.json:1142-1160 CurrentModeUpdate / SessionUpdate variant `current_mode_update` (schema.json:3060-3075)。当 Agent 改变 mode 后 MUST 发送 current_mode_update 通知,使只支持 modes API(不支持 configOptions)的 Client 能感知 mode 切换。
|
|
||||||
- 当前实现: setSessionMode 调用 applySessionMode(更新内部 session.modes.currentModeId)然后 updateConfigOption('mode', ...) 只发送 config_option_update 通知(agent.ts:862-868)。从不发送 current_mode_update 通知。仅支持 modes 的 Client 将永远收不到 setSessionMode 之后的 mode 变更通知。
|
|
||||||
- 修复建议: 在 setSessionMode 中,在 applySessionMode 之后追加发送 current_mode_update:
|
|
||||||
|
|
||||||
~~~diff
|
|
||||||
async setSessionMode(params: SetSessionModeRequest): Promise<SetSessionModeResponse> {
|
|
||||||
const session = this.sessions.get(params.sessionId)
|
|
||||||
if (!session) throw new Error('Session not found')
|
|
||||||
this.applySessionMode(params.sessionId, params.modeId)
|
|
||||||
+ await this.conn.sessionUpdate({
|
|
||||||
+ sessionId: params.sessionId,
|
|
||||||
+ update: { sessionUpdate: 'current_mode_update', currentModeId: params.modeId },
|
|
||||||
+ })
|
|
||||||
await this.updateConfigOption(params.sessionId, 'mode', params.modeId)
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
~~~
|
|
||||||
|
|
||||||
参照 setSessionConfigOption 中 `configId==='mode'` 分支(agent.ts:447-455)已有的 current_mode_update 发送逻辑保持一致。
|
|
||||||
|
|
||||||
### 6.2 [minor] NewSession/Load/Resume 响应携带非稳定 v1 models 字段
|
|
||||||
|
|
||||||
- 位置: `src/services/acp/agent.ts:675-680` (createSession 返回值) 及 715-720 (getOrCreateSession 返回值)
|
|
||||||
- 规范要求: schema.json:1916-1955 NewSessionResponse 仅定义 sessionId(必填)、configOptions(可选)、modes(可选)、`_meta`。LoadSessionResponse(schema.json:1668-1697)/ResumeSessionResponse 同样不含 models 字段。v1 稳定 schema 中不存在 SessionModelState/SessionModel/SetSessionModel,model 选择属于 draft/unstable 特性。
|
|
||||||
- 当前实现: createSession 返回 `{ sessionId, models, modes, configOptions }`,getOrCreateSession 返回值同样包含 models。models 字段在 v1 稳定 schema 中未定义,严格 Client 会忽略它。该字段由 @agentclientprotocol/sdk@0.19.0 的 draft 类型(SessionModelState/ModelInfo)驱动。
|
|
||||||
- 修复建议: 由于 model 选择为 draft 特性且不在 v1 合规范围,建议: (1) 若仅面向 v1 Client,从 NewSessionResponse/LoadSessionResponse/ResumeSessionResponse 返回值中移除 models 字段,仅保留 sessionId/modes/configOptions;或 (2) 若需保留向后兼容,在响应中保留 models 但明确文档标注为非稳定扩展。最小合规改动:
|
|
||||||
|
|
||||||
~~~diff
|
|
||||||
-return { sessionId, models, modes, configOptions }
|
|
||||||
+return { sessionId, modes, configOptions }
|
|
||||||
~~~
|
|
||||||
|
|
||||||
### 6.3 [minor] setSessionConfigOption 未校验 value 是否在 options 列表内
|
|
||||||
|
|
||||||
- 位置: `src/services/acp/agent.ts:427-469` (setSessionConfigOption)
|
|
||||||
- 规范要求: session-config-options.mdx 第 189-192 行: "value: The new value to set. Must be one of the values listed in the option's options array。" schema.json:3110-3147 SetSessionConfigOptionRequest 的 value 为 SessionConfigValueId,Agent 应在 option.options 内校验该 value 合法性,非法值应返回错误而非静默接受。
|
|
||||||
- 当前实现: setSessionConfigOption 通过 id 查找 option(agent.ts:440-443),但从不校验 params.value 是否存在于 option.options 中。任何字符串(即使不在 options 列表)都会被接受并写入 currentValue,违反 "Must be one of the values listed" 要求。
|
|
||||||
- 修复建议: 在 option 查找后追加 options 校验:
|
|
||||||
|
|
||||||
~~~ts
|
|
||||||
const option = session.configOptions.find(o => o.id === params.configId)
|
|
||||||
if (!option) throw new Error(`Unknown config option: ${params.configId}`)
|
|
||||||
const validValues = flattenOptions(option.options).map(o => o.value)
|
|
||||||
if (!validValues.includes(params.value)) {
|
|
||||||
throw new Error(
|
|
||||||
`Invalid value '${params.value}' for config option ${params.configId}; must be one of: ${validValues.join(', ')}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
~~~
|
|
||||||
|
|
||||||
注意 options 可能为 grouped(SessionConfigSelectGroup)或 flat(SessionConfigSelectOption),需 flatten 处理。
|
|
||||||
|
|
||||||
### 6.4 [nit] value 类型守卫冗余
|
|
||||||
|
|
||||||
- 位置: `src/services/acp/agent.ts:434-438` (setSessionConfigOption value 类型守卫)
|
|
||||||
- 规范要求: schema.json:3134-3141 SetSessionConfigOptionRequest.value 引用 SessionConfigValueId(schema.json:2779-2782 type:'string')。value 始终为字符串。
|
|
||||||
- 当前实现: 实现包含 `if (typeof params.value !== 'string') throw`,但因 schema 已将 value 固定为 string,此守卫永远为真,属冗余代码。同时该守卫位置在 option 查找之前,错误信息不够精准。
|
|
||||||
- 修复建议: 由于 SessionConfigValueId 严格为 string,可移除该类型守卫(由 SDK/schema 层保证);或保留但移至 option.options 校验统一处理,避免分散校验逻辑。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. ContentBlock 处理: text/image/audio/resource/resourceLink/thought(维度 7)
|
|
||||||
|
|
||||||
### 7.1 [major] promptCapabilities.image 声明但 promptConversion 完全不解析图片
|
|
||||||
|
|
||||||
- 位置: `src/services/acp/promptConversion.ts:3` (promptToQueryInput) 与 `src/services/acp/agent.ts:155-158` (initialize)
|
|
||||||
- 规范要求: schema.json PromptCapabilities.image (line 2126): "Agent supports [ContentBlock::Image]";content.mdx line 42-55: Image blocks in prompts "Requires the image prompt capability when included in prompts。" 声明了能力就必须能处理对应的 prompt 输入 ContentBlock。
|
|
||||||
- 当前实现: agent.ts initialize() 声明 `promptCapabilities.image = true`,但 promptToQueryInput() 完全没有 'image' 分支——image block 既不被 base64 解码转成 Claude SDK 的 image content,也不产生任何文本占位,被静默丢弃。客户端按 `image:true` 发送图片 prompt 后内容丢失,无报错。
|
|
||||||
- 修复建议: 在 promptConversion.ts 增加 image 分支: 将 ACP `{type:'image', data, mimeType}` 转换为 Claude SDK 的 image content block 传给 query(若 query input 仅接受 string,则需扩展 promptToQueryInput 返回 ContentBlock[] 而非 string)。或者若当前 query 层暂不支持多模态输入,应将 `image:false`,使声明与实现一致,并由客户端回退到文本/链接形式。推荐先降级 `image:false`,待多模态 query input 支持后再开启。此问题与维度 1 §1.1、维度 3 §3.1 同根因。
|
|
||||||
|
|
||||||
### 7.2 [major] embeddedContext=true 但 BlobResource 被静默丢弃
|
|
||||||
|
|
||||||
- 位置: `src/services/acp/promptConversion.ts:19-24` (resource 分支) 与 `src/services/acp/agent.ts:157`
|
|
||||||
- 规范要求: schema.json PromptCapabilities.embeddedContext (line 2121): 启用时客户端可发送 ContentBlock::Resource;content.mdx line 124-155: EmbeddedResource 支持 TextResource(`{uri,text,mimeType?}`)与 BlobResource(`{uri,blob,mimeType?}`)两种形式。
|
|
||||||
- 当前实现: 声明 `embeddedContext=true`,但 promptToQueryInput 的 'resource' 分支仅提取 `resource.text`。当客户端发送 BlobResource(如 PDF/二进制文件,字段为 `resource.blob + resource.mimeType + resource.uri`)时,text 为 undefined,内容被完全丢弃,模型只收到空字符串。也未传递 uri/mimeType 上下文。
|
|
||||||
- 修复建议: 扩展 resource 分支:
|
|
||||||
|
|
||||||
~~~ts
|
|
||||||
} else if (b.type === 'resource') {
|
|
||||||
const r = b.resource as Record<string, unknown> | undefined
|
|
||||||
if (r && typeof r.text === 'string') {
|
|
||||||
parts.push(r.text)
|
|
||||||
} else if (r && typeof r.blob === 'string') {
|
|
||||||
const mt = typeof r.mimeType === 'string' ? r.mimeType : 'application/octet-stream'
|
|
||||||
parts.push(`Embedded resource: ${r.uri ?? '(unknown uri)'} (${mt}, base64 blob)`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
~~~
|
|
||||||
|
|
||||||
(理想做法是将 blob 解码并作为 Claude SDK 二进制 content 传入 query;若 query input 不支持则至少以可读占位形式保留上下文,不能静默丢弃。)
|
|
||||||
|
|
||||||
### 7.3 [minor] toAcpContentBlock 未处理 resource/resource_link 导致降级为 JSON 文本
|
|
||||||
|
|
||||||
- 位置: `src/services/acp/bridge.ts:572` (toAcpContentBlock)
|
|
||||||
- 规范要求: schema.json ContentBlock.oneOf 包含 ResourceLink (line 1023) 与 EmbeddedResource (line 1039);content.mdx line 163: ResourceLink 在 prompt 中 ALL agents MUST support;content.mdx line 11: ContentBlock 也用于 session/update 输出与 tool 结果。
|
|
||||||
- 当前实现: toAcpContentBlock(输出渲染)只显式处理 text/image 及若干 Claude 私有 content 类型;'resource' 和 'resource_link' 类型的 SDK content 落入 default 分支(line 644-648)被序列化为 `{type:'text', text: JSON.stringify(content)}`,产生非规范输出,客户端无法识别为可点击资源。
|
|
||||||
- 修复建议: 在 toAcpContentBlock switch 中增加 case:
|
|
||||||
|
|
||||||
~~~ts
|
|
||||||
case 'resource_link':
|
|
||||||
return { type: 'resource_link', uri: content.uri as string, name: (content.name as string) ?? (content.uri as string), mimeType: content.mimeType as string | undefined }
|
|
||||||
case 'resource': {
|
|
||||||
const r = content.resource as Record<string, unknown> | undefined
|
|
||||||
return { type: 'resource', resource: { uri: r?.uri, mimeType: r?.mimeType, text: r?.text, blob: r?.blob } }
|
|
||||||
}
|
|
||||||
~~~
|
|
||||||
|
|
||||||
注意 ImageContent 与 ResourceLink 字段差异: ImageContent 必填 data+mimeType(base64),uri 为可选;ResourceLink 必填 name+uri,没有 data 字段。
|
|
||||||
|
|
||||||
### 7.4 [minor] toAcpContentBlock image 分支 url 处理字段命名澄清
|
|
||||||
|
|
||||||
- 位置: `src/services/acp/bridge.ts:596-600` (toAcpContentBlock image 分支 url/非 base64 处理)
|
|
||||||
- 规范要求: schema.json ImageContent (line 1384-1414): 必填 data(base64)+ mimeType,uri 为可选 string|null。ACP v1 ContentBlock 不支持纯 URL 图片——没有 url 字段,只有可选 uri 引用且仍需 data。
|
|
||||||
- 当前实现: 当 Claude SDK image content 的 `source.type === 'url'` 时,降级输出文本占位 `[image: <url>]`。这本身符合 ACP(因 ACP 要求 base64 data,URL 图片无法原样转发)。但实现中读取的字段名是 source.url(Claude SDK 私有形态),与 ACP 无关;同时未考虑 `source.type` 可能既非 base64 也非 url 的情形已用 '[image: file reference]' 覆盖。逻辑可接受,无违规,仅记录字段命名澄清。
|
|
||||||
- 修复建议: 无需协议层修复。如要增强: 可将 url 图片自行 fetch+base64 编码后转为合规 ImageContent,但需注意安全与性能;当前文本占位降级是合规的最低实现。保持现状即可,此条仅作字段映射文档。
|
|
||||||
|
|
||||||
### 7.5 [nit] audio 能力声明与实现一致(合规,仅记录)
|
|
||||||
|
|
||||||
- 位置: `src/services/acp/agent.ts:155-158` (initialize promptCapabilities)
|
|
||||||
- 规范要求: schema.json PromptCapabilities.audio (line 2116, default false)。content.mdx line 74-87: audio block 需 audio capability。
|
|
||||||
- 当前实现: promptCapabilities 未声明 audio(默认 false),且 promptConversion.ts 与 bridge.ts toAcpContentBlock 均无 audio 处理。声明与实现一致(均不支持),符合规范。但输出侧 toAcpContentBlock 也没有 audio 分支——若 Claude 未来输出音频 content 会落入 JSON.stringify。
|
|
||||||
- 修复建议: 无需修改;当前状态合规。如未来支持音频输入,需同时: (1) agent.ts 声明 `audio:true`;(2) promptConversion.ts 增加 audio→Claude SDK audio block 转换;(3) bridge.ts toAcpContentBlock 增加 `case 'audio'` 输出 `{type:'audio', data, mimeType}`。三者必须同步,避免再次出现 image 那种声明/实现脱节。
|
|
||||||
|
|
||||||
### 7.6 [nit] thought / tool_result 映射合规(无需修改)
|
|
||||||
|
|
||||||
- 位置: `src/services/acp/promptConversion.ts:8-27` 与 `src/services/acp/bridge.ts:1210-1247` (thought / tool_result)
|
|
||||||
- 规范要求: schema.json ContentBlock.oneOf (line 966-1053) 仅含 text/image/audio/resource_link/resource 五种——不存在 ThoughtContent;thought 通过 SessionUpdate discriminator `agent_thought_chunk` (schema.json line 2989) 表达,而非 ContentBlock type 或 `role:'thought'`。tool 结果应通过 tool_call_update (schema.json line 3012+) 传递。
|
|
||||||
- 当前实现: 实现正确,无需修改。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. transports / JSON-RPC envelope / acp-link 代理合规(维度 8)
|
|
||||||
|
|
||||||
### 8.1 [critical] acp-link WS 使用自有 `{type,payload}` 封装而非 JSON-RPC 2.0
|
|
||||||
|
|
||||||
- 位置: `packages/acp-link/src/server.ts:147-156` (send), 800-878 (decodeClientMessage), `packages/acp-link/src/ws-message.ts:52-63`
|
|
||||||
- 规范要求: transports.mdx L52: "Custom transports MUST ensure they preserve the JSON-RPC message format and lifecycle requirements defined by ACP." overview.mdx L206: "The JSON-RPC envelope fields (jsonrpc, id, method, params, result, and error) follow the JSON-RPC 2.0 specification." transports.mdx L6: "ACP uses JSON-RPC to encode messages."
|
|
||||||
- 当前实现: acp-link 在 client↔proxy WS 之间使用自有的包装格式 `{ type: string, payload?: unknown }`,而不是 JSON-RPC。ws-message.ts:decodeJsonWsMessage 强制要求每个传入消息包含 'type' 字符串;server.ts:decodeClientMessage 随后切换此 type。客户端发送的任何标准 JSON-RPC 消息(`{ jsonrpc:'2.0', id, method, params }`)均会被拒绝,错误提示为 "Invalid WebSocket message payload" (ws-message.ts:60)。stdout↔stdio 部分使用了正确的 SDK ndJsonStream,但面向客户端的 WS 传输(即实际上暴露给客户端的自定义传输)并非 JSON-RPC。
|
|
||||||
- 修复建议: 使面向客户端的 WS 传输成为透明的 JSON-RPC 转发器。通过 JSON-RPC method 名而非专有的 `type` 进行路由,并完整透传消息。最小改造方案:
|
|
||||||
|
|
||||||
~~~ts
|
|
||||||
// onMessage: 解析一次 JSON-RPC,然后路由到处理程序
|
|
||||||
const msg = JSON.parse(text) as JsonRpcMessage
|
|
||||||
if ('method' in msg) {
|
|
||||||
// 请求或通知 — 根据 msg.method 进行分发
|
|
||||||
const result = await dispatchMethod(msg.method, msg.params)
|
|
||||||
if ('id' in msg) send(ws, { jsonrpc:'2.0', id: msg.id, result })
|
|
||||||
} else {
|
|
||||||
// 响应 — 关联到待处理的出站请求 id
|
|
||||||
}
|
|
||||||
~~~
|
|
||||||
|
|
||||||
### 8.2 [critical] 代理响应丢弃 JSON-RPC id,无法关联请求
|
|
||||||
|
|
||||||
- 位置: `packages/acp-link/src/server.ts:147-156` (send), 412-416 (session_created), 624 (prompt_complete), 473-483 (session_list)
|
|
||||||
- 规范要求: JSON-RPC 2.0 spec §6: Request 必须包含 `id`;Response 必须包含相同的 `id`、`result` 或 `error`,并带有 `jsonrpc: "2.0"`。overview.mdx L10-13: "请求-响应对期望得到结果或错误"。
|
|
||||||
- 当前实现: 代理针对客户端请求的响应(例如 `session_created`、`prompt_complete`、`session_list`、`session_loaded`、`model_changed`)使用带有自选 `type` 字符串的 `send(ws, type, payload)`,且从不携带 JSON-RPC `id`。客户端无法将响应与原始请求相关联,因为代理丢弃了请求 id。整个链路中没有任何 `id` 保留。
|
|
||||||
- 修复建议: 在 ClientState 上保留一个挂起的 id 映射,并在 JSON-RPC 响应中回显请求的 `id`:
|
|
||||||
|
|
||||||
~~~ts
|
|
||||||
send(ws, { jsonrpc:'2.0', id: pendingId, result })
|
|
||||||
~~~
|
|
||||||
|
|
||||||
### 8.3 [major] 错误响应使用专有 ProxyError 而非 JSON-RPC 错误对象
|
|
||||||
|
|
||||||
- 位置: `packages/acp-link/src/server.ts:358-360, 379, 392, 419-421, 450-453, 486-489, 537-540, 626, 696-699, 1166`;`packages/acp-link/src/types.ts:78-82` (ProxyError)
|
|
||||||
- 规范要求: overview.mdx L198-201: "所有方法均遵循标准 JSON-RPC 2.0 错误处理……错误包含一个带有 `code` 和 `message` 的 `error` 对象。" JSON-RPC 2.0 预留代码: -32700 解析错误、-32600 无效请求、-32601 方法未找到、-32602 无效参数、-32603 内部错误。
|
|
||||||
- 当前实现: 所有错误均以专有的 ProxyError `{ type: 'error', message: string, code?: string }` 发出,且没有 JSON-RPC 错误对象,也没有数值类型的 JSON-RPC 代码。例如 server.ts:358 发送 `{ message: 'Failed to connect: ...' }`。`code` 字段是一个自由格式字符串,从未使用过 -326xx 代码。不相关的客户端无法区分解析错误、方法未找到错误和内部错误。
|
|
||||||
- 修复建议: 发出标准的 JSON-RPC 错误响应,关联到请求 id:
|
|
||||||
|
|
||||||
~~~ts
|
|
||||||
send(ws, { jsonrpc:'2.0', id: reqId, error: { code: -32601, message: 'Not connected to agent' } })
|
|
||||||
~~~
|
|
||||||
|
|
||||||
将已知故障映射到代码: -32700 (decodeJsonWsMessage 解析失败)、-32602 (payloadRecord/optionalStringField 验证)、-32601 (代理不支持该功能或 SDK 调用抛出"不支持")、-32603 (内部异常)。
|
|
||||||
|
|
||||||
### 8.4 [major] decodeClientMessage 白名单狭窄,多个 v1 方法无传输路径
|
|
||||||
|
|
||||||
- 位置: `packages/acp-link/src/server.ts:800-878` (decodeClientMessage switch), 871 `default: throw new Error('Unknown message type')`
|
|
||||||
- 规范要求: schema/meta.json 列出了 12 个 agent 方法(authenticate、initialize、logout、session/close、session/set_mode、session/set_config_option 等)和 9 个 client 方法(terminal/*、fs/*)。overview.mdx L52 (自定义传输): 必须保留 JSON-RPC 格式和生命周期。未知方法必须产生 JSON-RPC -32601 method-not-found 错误,而不是断开客户端连接。
|
|
||||||
- 当前实现: decodeClientMessage 在遇到未知 `type` 时抛出异常,这会导致 onMessage 捕获程序发出通用的 `{ type:'error', message:'Unknown message type: ...' }` (server.ts:1166),但不会发出 -32601 响应。更糟糕的是,代理仅识别固定的方法白名单(connect、disconnect、new_session、prompt、permission_response、cancel、set_session_model、list/load/resume_session、ping)。客户端发起的 `authenticate`、`logout`、`session/close`、`session/set_mode`、`session/set_config_option`、`session/list`(与 list_sessions 不同——注意 meta.json 中的方法名是 `session/list`)以及所有 terminal/* 方法在传输中均无路径。这些方法在协议层被悄悄丢弃。
|
|
||||||
- 修复建议: 用通用的 JSON-RPC 方法路由器替换专有的 type 切换。对于任何识别出但代理未实现的方法,返回 -32601。至少要透传 `session/set_mode` 和 `session/close`(这些是 v1 的基准/常用方法)。
|
|
||||||
|
|
||||||
### 8.5 [major] 未处理 JSON-RPC 标准 `$/cancel_request`
|
|
||||||
|
|
||||||
- 位置: `packages/acp-link/src/`(全仓库);在 acp-link 中 grep `$/cancel_request` 无结果
|
|
||||||
- 规范要求: JSON-RPC 2.0 spec §6.1: `$/cancel_request` 是用于取消正在进行的请求/通知的标准、传输级取消原语。这与 ACP 特有的 `session/cancel` 通知不同。ACP 透传传输必须将其转发到 stdio 代理进程或进行本地处理。
|
|
||||||
- 当前实现: 未实现。仅处理专有的 `cancel` 类型 (server.ts:646),它映射到 ACP `session/cancel`。JSON-RPC 级别的 `$/cancel_request` 既未转发给 agent,也未映射到挂起的提示取消。如果客户端发送 `{ "jsonrpc":"2.0", "method":"$/cancel_request", "params": { id: ... } }`,当前解码器会将其拒绝为 "Invalid WebSocket message payload",因为它缺少专有的 `type` 字段。
|
|
||||||
- 修复建议: 在 JSON-RPC 路由层增加对 `$/cancel_request` 的处理程序: 取消关联的出站提示请求,并转发到底层 SDK 连接的取消路径(或在 agent 上调用 `session/cancel`)。
|
|
||||||
|
|
||||||
### 8.6 [major] 代理重构 agentCapabilities 白名单,丢弃扩展能力
|
|
||||||
|
|
||||||
- 位置: `packages/acp-link/src/server.ts:321-330`
|
|
||||||
- 规范要求: ACP 通过 agentCapabilities 按字段协商能力;未来/扩展能力(例如 auth、terminal)必须完整透传给客户端,以便其知道自己可以使用哪些方法。
|
|
||||||
- 当前实现: server.ts:321-330 通过列出白名单字段(_meta、loadSession、mcpCapabilities、promptCapabilities、sessionCapabilities)来重构 `state.agentCapabilities`。任何 SDK 的 AgentCapabilities 携带但此处硬编码接口 (server.ts:65-79) 中未列出的字段(例如 `auth`、`terminal`、未来的能力)都会被静默丢弃,不会向客户端通告。
|
|
||||||
- 修复建议: 直接透传原始的 `initResult.agentCapabilities` 对象,而不是重构它:
|
|
||||||
|
|
||||||
~~~diff
|
|
||||||
-state.agentCapabilities = { /* whitelisted fields */ }
|
|
||||||
+state.agentCapabilities = agentCaps ?? null
|
|
||||||
~~~
|
|
||||||
|
|
||||||
仅在需要本地 TS 类型时进行收窄——但在传输中发送未收窄的值。
|
|
||||||
|
|
||||||
### 8.7 [major] 硬编码 clientInfo/capabilities,丢弃客户端真实信息
|
|
||||||
|
|
||||||
- 位置: `packages/acp-link/src/server.ts:313-319`
|
|
||||||
- 规范要求: overview.mdx L20-24: 客户端 → agent: `initialize` 以协商连接。InitializeParams 携带客户端真实的 `clientInfo`(`{name, version}`),以便 agent 进行日志记录/遥测。clientCapabilities 同样必须反映真实的客户端能力。
|
|
||||||
- 当前实现: 代理硬编码 `clientInfo: { name: 'zed', version: '1.0.0' }` 和 `clientCapabilities: { fs: { readTextFile: true, writeTextFile: true } }`,忽略客户端实际发送的任何 clientInfo/capabilities。非 Zed 客户端(Web UI、RCS 中继、自定义客户端)被错误地呈现给 agent 为 'zed 1.0.0',并可能通告了它并不支持的 fs 能力。
|
|
||||||
- 修复建议: 接受来自客户端 initialize 消息的 clientInfo 和 clientCapabilities 并进行转发。仅使用 'zed'/{fs:true} 作为代理内部未提供任何信息时的回退。
|
|
||||||
|
|
||||||
### 8.8 [major] types.ts ClientCapabilities/ServerCapabilities 形状陈旧
|
|
||||||
|
|
||||||
- 位置: `packages/acp-link/src/types.ts:96-113` (ClientCapabilities, ServerCapabilities)
|
|
||||||
- 规范要求: schema.json InitializeParams.clientCapabilities 和 InitializeResult.agentCapabilities 使用特定形状(例如带有嵌套 `fs.readTextFile/writeTextFile` 的 clientCapabilities;agentCapabilities = loadSession、mcpCapabilities、promptCapabilities、sessionCapabilities)。overview.mdx L206: 协议对象键使用 camelCase。
|
|
||||||
- 当前实现: types.ts:96-113 定义了过时的形状——`ClientCapabilities { streaming?, toolApproval? }` 和 `ServerCapabilities { streaming?, tools? }`——这与实际的 ACP v1 schema 不匹配。这些类型虽然已声明但从未通过 JSON-RPC 路径实际使用;它们具有误导性,并暗示代理正在协商 ACP 中不存在的 streaming/tools 能力。
|
|
||||||
- 修复建议: 完全移除过时的 `ClientCapabilities`/`ServerCapabilities` 类型(它们在任何实时代码路径中均未使用——server.ts 使用其内联的 `AgentCapabilities`),或用 SDK 定义的结构替换它们。
|
|
||||||
|
|
||||||
### 8.9 [minor] agentInfo 类型收窄过紧,丢失扩展字段
|
|
||||||
|
|
||||||
- 位置: `packages/acp-link/src/types.ts:63-71` (ProxyStatus.agentInfo), `packages/acp-link/src/server.ts:346`
|
|
||||||
- 规范要求: ACP agentInfo(InitializeResult.agentInfo)至少为 `{ name, version }`,但根据 extensibility.mdx 可以携带额外的 _meta/扩展字段;自定义传输应保留它。
|
|
||||||
- 当前实现: ProxyStatus 类型将 `agentInfo` 收窄为 `{ name?: string; version?: string }` (types.ts:66-69)。实际发送的对象 (server.ts:346) 是原始的 `initResult.agentInfo`,所以运行时没问题,但声明的类型会丢弃 TS 认为客户端收到的任何附加字段,且阅读此类型的客户端无法依赖扩展的 agentInfo。types.ts:87-108 中类似地过时的 InitializeParams/InitializeResult 与 SDK 的实际形状不匹配。
|
|
||||||
- 修复建议: 加宽类型:
|
|
||||||
|
|
||||||
~~~ts
|
|
||||||
agentInfo?: { name: string; version: string; [k: string]: unknown }
|
|
||||||
~~~
|
|
||||||
|
|
||||||
或者通过 SDK 重新导出真实的 InitializeResult 类型。
|
|
||||||
|
|
||||||
### 8.10 [minor] session/update 通知方向正确(合规,记录)
|
|
||||||
|
|
||||||
- 位置: `packages/acp-link/src/server.ts:190-192` (createClient.sessionUpdate)
|
|
||||||
- 规范要求: overview.mdx L180-189: `session/update` 是一个 agent→client 通知(无响应)。
|
|
||||||
- 当前实现: 正确: sessionUpdate 流向 agent→client(通过 SDK ClientSideConnection 回调,然后 `send(ws, 'session_update', params)`)。代理在 client→agent 方向上不接受 `session_update`(decodeClientMessage 没有该情况)。此处未发现问题——为完整性而列出。
|
|
||||||
- 修复建议: 无需操作;行为正确。仅将其记录为已验证项。
|
|
||||||
|
|
||||||
### 8.11 [minor] 应用层 ping/pong 与传输级 WS 心跳冗余
|
|
||||||
|
|
||||||
- 位置: `packages/acp-link/src/server.ts:915-917` (ping → pong)
|
|
||||||
- 规范要求: WS-level ping/pong 在 RFC 6455 §5.5.2 中是传输级控制帧(二进制操作码 0x9/0xA),而不是应用层消息。将它们与应用层消息混合是非标准的。ACP 本身没有应用层 ping 方法。
|
|
||||||
- 当前实现: 代理实现了应用层的 `{ type: 'ping' }` / `{ type: 'pong' }` (server.ts:915-917),与传输级的 WS 心跳 (server.ts:1199-1216 通过 `ws.raw.ping()`) 并存。这是冗余的,且容易混淆——如果客户端将应用层 ping 发送为 JSON-RPC `{ method: 'ping' }`,它将无法与传输层帧区分,并会被拒绝。
|
|
||||||
- 修复建议: 移除应用层的 ping/pong 情况;仅依赖传输级的 WS ping/pong 心跳 (server.ts:1199)。或者,如果需要,文档说明自定义 ping 并通过相同的 `{ type, payload }` 约定路由它。
|
|
||||||
|
|
||||||
### 8.12 [minor] RCS 中继路径同样施加 `{type,payload}` 封装
|
|
||||||
|
|
||||||
- 位置: `packages/acp-link/src/rcs-upstream.ts:117-149` (connect: REST + identify)
|
|
||||||
- 规范要求: transports.mdx L52: 自定义传输必须保留 JSON-RPC 消息格式。ACP 规范未定义 RCS "环境/桥接" REST 注册或 WS `identify`/`identified`/`registered`/`keep_alive` 消息类型——这些是 RCS 特定的(超出 ACP v1 范围)。一旦注册,中继必须转发未更改的 JSON-RPC。
|
|
||||||
- 当前实现: 两步流程(REST POST /v1/environments/bridge,然后 WS `identify`→`identified` 握手)是 RCS 专有的,对于 RCS 传输是可以接受的。但是,rcs-upstream.ts:151-221 中的中继消息处理程序通过相同的 `decodeJsonWsMessage`(要求 `{ type }` 形状)解码所有传入的服务器消息,并仅将非控制类型转发给 messageHandler (L213-219)。这意味着 RCS 和 agent 之间的中继也施加了 `{ type, payload }` 而非 JSON-RPC,这与主 WS 代理有相同的封装问题。
|
|
||||||
- 修复建议: 对于从 RCS 到本地 agent 的中继路径,解码为 JSON-RPC 并路由方法名。控制消息(identify/identified/registered/keep_alive)属于 RCS 特有的带外,应通过单独的传输层接口处理,而不是与 ACP 有效负载复用。
|
|
||||||
|
|
||||||
### 8.13 [minor] 协议版本未在 status 消息中转发给客户端
|
|
||||||
|
|
||||||
- 位置: `packages/acp-link/src/server.ts:314` (acp.PROTOCOL_VERSION), 333-342 (logs protocolVersion)
|
|
||||||
- 规范要求: ACP 稳定 protocolVersion 在 schema/meta.json 中为 `1`(整数)。InitializeResponse.protocolVersion 必须透传,以便客户端和 agent 就协商的版本达成一致。
|
|
||||||
- 当前实现: 代理使用 SDK 常量 `acp.PROTOCOL_VERSION` 发送 initialize,并记录返回的 `initResult.protocolVersion` (server.ts:335),但从未在 `status`/`session_created` 消息中将 `protocolVersion` 转发给客户端客户端(send() 调用省略了它)。下游 WS 客户端无法观察协商的协议版本。未发现版本损坏(SDK 管理往返),但客户端缺乏可见性。
|
|
||||||
- 修复建议: 在连接后发送的 `status` 消息中包含 `protocolVersion: initResult.protocolVersion` (server.ts:344-348)。
|
|
||||||
|
|
||||||
### 8.14 [nit] JsonRpc 类型未使用(死代码)
|
|
||||||
|
|
||||||
- 位置: `packages/acp-link/src/types.ts:34-46` (isRequest/isResponse/isNotification)
|
|
||||||
- 规范要求: JSON-RPC 2.0 spec §4.1/§4.2: Request = 带有 method+id 的对象;Notification = 带有 method 但无 id 的对象;Response = 带有 id 且无 method 的对象,以及 result 或 error。
|
|
||||||
- 当前实现: 辅助函数看起来正确,但这些 JsonRpc 类型在 acp-link 运行时中的任何地方都未使用(代理绕过了它们而使用 `{type,payload}`)。死代码表明存在意图与实现之间的脱节。
|
|
||||||
- 修复建议: 要么将 JSON-RPC 路由基于这些类型(首选——修复 §8.1 finding),要么移除死类型以避免误导未来的维护者。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 附录 A: SDK 方法命名对照
|
|
||||||
|
|
||||||
| SDK 方法 | 当前命名 | stable? | 修复动作 |
|
|
||||||
|---|---|---|---|
|
|
||||||
| initialize | initialize | stable | 保留(但需修 authMethods 缺失) |
|
|
||||||
| authenticate | authenticate | stable | 保留(建议显式返回 authMethods:[]) |
|
|
||||||
| logout | 未实现 | stable | 保留不实现(也未宣告 auth.logout 能力) |
|
|
||||||
| newSession | newSession | stable | 保留 |
|
|
||||||
| loadSession | loadSession | stable | 保留(需补 cwd 校验) |
|
|
||||||
| unstable_resumeSession | unstable_resumeSession | stable (resumed) | 建议在 SDK 升级后改名为 `resumeSession`,同时去除重放历史 |
|
|
||||||
| unstable_forkSession | unstable_forkSession | UNSTABLE | 保留 unstable 命名;但应从 sessionCapabilities.fork 迁移到 _meta.claudeCode.forkSession |
|
|
||||||
| listSessions | listSessions | stable | 保留(需实现 cursor 分页) |
|
|
||||||
| unstable_closeSession | unstable_closeSession | UNSTABLE | 保留 |
|
|
||||||
| prompt | prompt | stable | 保留(需修 usage 字段、refusal 映射) |
|
|
||||||
| cancel | cancel (notification) | stable | 保留 |
|
|
||||||
| setSessionMode | setSessionMode | stable | 保留(需补 current_mode_update 通知) |
|
|
||||||
| setSessionConfigOption | setSessionConfigOption | stable | 保留(需补 value 校验) |
|
|
||||||
| unstable_setSessionModel | unstable_setSessionModel | UNSTABLE | 保留 |
|
|
||||||
| session/update | sessionUpdate (notification) | stable | 保留(usage_update 为 UNSTABLE 但为 interop 保留,见 §4.1) |
|
|
||||||
|
|
||||||
## 附录 A.2: UNSTABLE RFD 实现记录(2026-06-19)
|
|
||||||
|
|
||||||
下列 UNSTABLE RFD 不属于严格 v1 合规范围,但为提升 interop 与客户端 UX 已主动实现。所有字段均已存在于 SDK 0.19.0 bundled schema 的 unstable 区段,主要 ACP 客户端(Zed / Cursor / RCS Web UI)均实现。
|
|
||||||
|
|
||||||
### A.2.1 session/delete(rfds/session-delete.mdx)✅ 已实现
|
|
||||||
|
|
||||||
- **能力广告**: `sessionCapabilities.delete: {}`(通过类型增强写入,因 SDK 0.19.0 的 SessionCapabilities 类型早于该 RFD)。
|
|
||||||
- **方法路由**: SDK 0.19.0 的方法分发器 `default` 分支调用 `agent.extMethod(method, params)`,因此 `session/delete` 通过 extMethod 钩子路由到 `unstable_deleteSession`。
|
|
||||||
- **语义**: 硬删除(unlink `~/.claude/projects/<sanitized-path>/<sessionId>.jsonl`)。spec 允许 soft/hard delete,选 hard delete 简化实现。
|
|
||||||
- **幂等性**: 删不存在的 session 也成功(ENOENT 视为成功)。
|
|
||||||
- **未知方法**: extMethod 对未识别方法抛 `RequestError.methodNotFound(method)`(JSON-RPC -32601)。
|
|
||||||
- **测试覆盖**: 6 个测试用例(能力广播 / extMethod 路由 / 幂等 / 内存清理 / 缺 sessionId 拒绝 / 未知方法拒绝)。
|
|
||||||
|
|
||||||
### A.2.2 message-id(rfds/message-id.mdx)✅ 已实现
|
|
||||||
|
|
||||||
- **覆盖范围**: `agent_message_chunk` / `user_message_chunk` / `agent_thought_chunk` 三个 chunk update 携带 `messageId`(UUID)。同消息的所有 chunks 共享 ID,不同消息 ID 不同。
|
|
||||||
- **不覆盖**: `tool_call` / `tool_call_update` / `plan` 不携带 messageId(spec 仅规定 chunk 类型)。
|
|
||||||
- **生成策略**:
|
|
||||||
- **Assistant 消息**: 在 `forwardSessionUpdates` 中维护 `currentAgentMessageId: string | null`,在 `stream_event` 或 `assistant` SDK 消息(`parent_tool_use_id === null`)首次出现时 lazy 生成 UUID;assistant 消息处理完后 reset 为 null,下一条触发新 UUID。所有 chunks(包括 streaming text/thinking 和最终 assistant message 中的 text/image)共享同一个 ID。
|
|
||||||
- **Subagent 消息**(`parent_tool_use_id !== null`): 不追踪 messageId,因 spec 中嵌套 tool 消息不属于顶层 chunk 流。
|
|
||||||
- **历史重放**(`replayHistoryMessages`): 每条 replayed user/assistant 消息独立生成 UUID(JSONL 不保留原始 ACP messageId)。
|
|
||||||
- **格式**: `crypto.randomUUID()`(不用 Anthropic 的 `message.id` —— 它是 `msg_xxx` 格式,不符合 spec 要求的 UUID)。
|
|
||||||
- **PromptRequest.messageId → PromptResponse.userMessageId**: 仅当客户端传入 `params.messageId` 时回显(spec 用词为 MAY 自行生成 → 取保守做法,不自行生成)。
|
|
||||||
- **测试覆盖**: 7 个测试用例(assistant chunk / 多消息不同 ID / streaming 共享 ID / tool_call 不带 ID / subagent 不带 ID / replay per-message UUID / replay 字符串内容带 ID)+ 2 个 prompt 回显测试(echo / omit)。
|
|
||||||
|
|
||||||
## 附录 B: 不修复项及理由
|
|
||||||
|
|
||||||
以下 finding 出于技术权衡或非合规范围,暂不修复:
|
|
||||||
|
|
||||||
| Finding | 理由 |
|
|
||||||
|---|---|
|
|
||||||
| §1.2 sessionCapabilities.fork 仅作"迁移到 _meta"建议,未标记 P0 阻断 | fork 为 UNSTABLE,严格 v1 合规范围外;当前 schema 未设 `additionalProperties:false`,不会导致硬失败。优先用 _meta.claudeCode.forkSession 重构,不阻断。 |
|
|
||||||
| §2.5 listSessions 空字符串 title | SessionInfo.title schema 允许 null;空字符串技术有效。基于磁盘的候选者很少幸存于空摘要。属表面问题。 |
|
|
||||||
| §2.6 NewSessionResponse 不含 cwd | 规范本身不要求返回 cwd;记录是为了纠正审计检查清单的错误前提。 |
|
|
||||||
| §3.5 prompt _meta 透传(W3C traceparent) | extensibility.mdx 用词为 SHOULD,非 MUST。OpenTelemetry interop 非当前部署场景的必需功能。列为 P2。 |
|
|
||||||
| §3.7 空 prompt 提前返回 end_turn | 行为可接受(虽语义不严谨);若改为抛出 -32602 需协调 Client 错误处理。列为 P2。 |
|
|
||||||
| §3.8 usage 缺 thoughtTokens | 仅在保留 unstable usage 字段时才有意义;若按 §3.2 整体移除 usage,此项自动消失。 |
|
|
||||||
| §4.4 Bash _meta 键未命名空间化 | 非规范违规(_meta 允许任意附加键);仅命名风格不一致。 |
|
|
||||||
| §5.4 reject_always 未提供 | PermissionOptionKind 四变体为推荐而非 MUST;REPL 现有交互流不支持持久的拒绝记忆。列为 P2。 |
|
|
||||||
| §5.7 ExitPlanMode optionId 与 session-mode 碰撞 | optionId 是 free-form 字符串,使用模式 id 作为值是合法扩展;ExitPlanMode 映射为 switch_mode,语义可辨。 |
|
|
||||||
| §5.8 rawInput 浅克隆 | Schema-valid,仅在嵌套对象被后续突变时才有问题;Claude Code 工具 input 通常不可变。低风险。 |
|
|
||||||
| §6.2 响应中携带 models 字段 | 为 SDK draft 类型驱动,严格 v1 Client 会忽略;若客户端使用 SDK 同版本,则 models 是有用的扩展字段。优先移除但非阻断。 |
|
|
||||||
| §6.4 value 类型守卫冗余 | 不影响合规性,仅代码质量问题。 |
|
|
||||||
| §7.4 image url 占位字段命名 | 实现合规,仅为字段映射文档。 |
|
|
||||||
| §7.5 audio 不支持 | 声明与实现均不支持,完全合规。 |
|
|
||||||
| §7.6 thought / tool_result 映射 | 实现正确,无需修改。 |
|
|
||||||
| §8.10 session/update 通知方向 | 行为正确,为完整性记录。 |
|
|
||||||
| §8.11 应用层 ping/pong | 冗余但无害;仅在客户端用 JSON-RPC `ping` 时混淆。低优先级。 |
|
|
||||||
| §8.14 JsonRpc 死类型 | 不影响运行时;仅在 §8.1 修复时一并清理。 |
|
|
||||||
|
|
||||||
## 附录 C: 修复路径建议
|
|
||||||
|
|
||||||
### P0 阻断修复(合规性硬阻塞)
|
|
||||||
|
|
||||||
1. **acp-link JSON-RPC 传输改造**(§8.1、§8.2、§8.3、§8.4、§8.5)——成本高,但属协议层根本缺陷。需要将 WS 解码/编码从 `{type,payload}` 改为 JSON-RPC 2.0,保留请求 id,使用标准错误代码,实现通用方法路由。建议分两阶段: 第一阶段透传所有未识别方法(修复 §8.4)+ 标准 id 关联(§8.2)+ 标准错误(§8.3);第二阶段迁移到完全 JSON-RPC(§8.1)+ 实现 `$/cancel_request`(§8.5)。
|
|
||||||
|
|
||||||
2. **image 能力降级为 false**(§1.1、§3.1、§7.1)——低成本,只需一行改动,立即消除协议谎言。多模态 query input 完成后再恢复 `image:true`。
|
|
||||||
|
|
||||||
3. **session/resume 去除重放**(§2.1)——中成本,需要将 resume 与 load 路径分离,引入 `replay` 标志。
|
|
||||||
|
|
||||||
4. **~~删除 usage_update 通知~~(§4.1)** —— ⚠️ **已撤销**: 删除后客户端显示 0/0,严重破坏 interop。现保留 `usage_update` 发送(见 §4.1 决策回滚说明)。
|
|
||||||
|
|
||||||
### P1 重要修复(非阻断但影响协议契约)
|
|
||||||
|
|
||||||
1. **PromptResponse.usage 字段移至 _meta**(§3.2)
|
|
||||||
2. **refusal stop_reason 映射**(§3.3)
|
|
||||||
3. **terminal 能力标准生命周期**(§5.1、§5.2)——成本高,涉及 terminal/create/release RPC 调用
|
|
||||||
4. **cancelled 权限结果传播**(§5.3)
|
|
||||||
5. **setSessionMode 发送 current_mode_update**(§6.1)
|
|
||||||
6. **session/load 跨项目 cwd 校验**(§2.2)
|
|
||||||
7. **unstable_forkSession 实现真正分叉**(§2.3)
|
|
||||||
8. **BlobResource 处理**(§7.2)
|
|
||||||
9. **agentCapabilities/clientInfo 透传**(§8.6、§8.7)
|
|
||||||
10. **ClientCapabilities/ServerCapabilities 类型陈旧**(§8.8)
|
|
||||||
@@ -1,281 +0,0 @@
|
|||||||
# ACP Refactor Plan: Splitting 3 Large Files into Modular Sub-files
|
|
||||||
|
|
||||||
This document is the authoritative migration plan for splitting three oversized ACP (Agent Client Protocol) source files into modular sub-files. Each file exceeds the 500-line-per-module budget; the refactor preserves every public export path so that **no test file and no external consumer requires modification**.
|
|
||||||
|
|
||||||
**Hard constraints (all three refactors):**
|
|
||||||
|
|
||||||
1. All current public API export paths MUST remain working (`from '../server.js'`, `from '../bridge.js'`, `from '../agent.js'`).
|
|
||||||
2. Every new file MUST be under 500 lines.
|
|
||||||
3. Test files MUST NOT be modified — including `permissions.test.ts` which does `require('../bridge.ts')` and snapshots the **entire** export surface (so the bridge barrel MUST export exactly the public API, no more, no less).
|
|
||||||
4. Only the 3 target files and their NEW sub-modules may be modified.
|
|
||||||
5. `bun run precheck` MUST pass after every step (typecheck + lint fix + test).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Target Files (current state)
|
|
||||||
|
|
||||||
| File | Lines | Public API surface |
|
|
||||||
|------|------:|--------------------|
|
|
||||||
| `packages/acp-link/src/server.ts` | 1800 | 8 must-preserve symbols |
|
|
||||||
| `src/services/acp/bridge.ts` | 1516 | 8 must-preserve symbols |
|
|
||||||
| `src/services/acp/agent.ts` | 1297 | 1 must-preserve symbol (`AcpAgent`) |
|
|
||||||
| **Total** | **4613** | |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Migration Order (with rationale)
|
|
||||||
|
|
||||||
The three files are refactored **in dependency order, leaf-first**, so that each step has a stable foundation and any cross-file regression is caught immediately:
|
|
||||||
|
|
||||||
1. **Phase 1 — `src/services/acp/bridge.ts`** (leaf-ish utility module).
|
|
||||||
- Rationale: `agent.ts` imports `forwardSessionUpdates`, `replayHistoryMessages`, `ToolUseCache` from `bridge.js`. Splitting bridge first means agent's refactor builds against the new (identical) bridge surface. Bridge has zero imports from agent.ts, so it can be split independently.
|
|
||||||
- The barrel `bridge/index.ts` re-exports the exact public API, so the existing `from '../bridge.js'` specifier resolves unchanged under both Bun and tsc (directory + `index.ts`).
|
|
||||||
|
|
||||||
2. **Phase 2 — `src/services/acp/agent.ts`** (the cohesive AcpAgent class).
|
|
||||||
- Rationale: Depends on the now-stable bridge module. Only pure helpers and types are extracted; the class body stays intact in `AcpAgent.ts`. `bridge.test.ts`, `agent.test.ts`, `permissions.test.ts` continue to work because `from '../agent.js'` and `from '../bridge.js'` resolve to the barrels.
|
|
||||||
|
|
||||||
3. **Phase 3 — `packages/acp-link/src/server.ts`** (largest, most interdependent).
|
|
||||||
- Rationale: Self-contained inside `acp-link`; does not import from `src/services/acp`. Done last so the most complex module split (12 sub-files, runtime-state container, handler fan-out) can leverage the workflow discipline practiced in Phases 1–2.
|
|
||||||
|
|
||||||
Within each phase, the internal creation order is always: **types → leaf pure helpers → mid-level helpers → handlers → dispatch → barrel → delete original**. This keeps the import graph acyclic at every intermediate commit.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 1 — `src/services/acp/bridge.ts`
|
|
||||||
|
|
||||||
### Directory structure
|
|
||||||
|
|
||||||
```
|
|
||||||
src/services/acp/
|
|
||||||
├── bridge.ts ← DELETED (replaced by directory)
|
|
||||||
└── bridge/
|
|
||||||
├── index.ts ← barrel (public API)
|
|
||||||
├── types.ts ← type definitions
|
|
||||||
├── paths.ts ← toAbsolutePath
|
|
||||||
├── contentBlocks.ts ← low-level block conversion
|
|
||||||
├── toolInfo.ts ← toolInfoFromToolUse
|
|
||||||
├── toolResults.ts ← tool result → ToolCallContent
|
|
||||||
├── modelUsage.ts ← context-window prefix helpers
|
|
||||||
├── notifications.ts ← content-block → SessionUpdate engine
|
|
||||||
└── forwarding.ts ← stream replay + forwarding loop
|
|
||||||
```
|
|
||||||
|
|
||||||
### Files, responsibilities, line budgets
|
|
||||||
|
|
||||||
| File | Responsibility | Exports | Budget |
|
|
||||||
|------|----------------|---------|-------:|
|
|
||||||
| `bridge/types.ts` | Shared ACP-bridge type definitions: `ToolUseCache`, `SessionUsage`, `BridgeUsage`, `Bridge*Message` interfaces, `BridgeSDKMessage` discriminated union, `ToolInfo`, `EditToolResponseHunk`, `EditToolResponse`. Re-exports SDK type-only imports (`ContentBlock`, `ToolCallContent`, `ToolCallLocation`, `ToolKind`). | 16 symbols | ~150 |
|
|
||||||
| `bridge/paths.ts` | Pure path-normalisation helper `toAbsolutePath` used by toolInfo / toolResults / forwarding. Leaf module, no bridge-internal imports. | `toAbsolutePath` | ~20 |
|
|
||||||
| `bridge/contentBlocks.ts` | Low-level conversion of Claude content block shapes into ACP `ContentBlock` values. `toAcpContentUpdate` wraps arrays/strings into `ToolCallContent[]` via `toAcpContentBlock`. Leaf module. | `toAcpContentUpdate`, `toAcpContentBlock` | ~150 |
|
|
||||||
| `bridge/toolInfo.ts` | `toolInfoFromToolUse` — large switch mapping each known tool name (Agent/Task, Bash, Read, Write, Edit, Glob, Grep, WebFetch, WebSearch, TodoWrite, ExitPlanMode, default) to ACP `ToolInfo` (title, kind, content, locations). Depends on `paths.toAbsolutePath` and `../utils.js` (`toDisplayPath`). | `toolInfoFromToolUse` | ~250 |
|
|
||||||
| `bridge/toolResults.ts` | `toolUpdateFromToolResult` (Read markdown escape, Bash console fence, Edit/Write no-op, ExitPlanMode title, default via `toAcpContentUpdate`); `toolUpdateFromEditToolResponse` (parses `structuredPatch` hunks into diff `ToolCallContent` with absolute paths). Depends on `contentBlocks` and `paths`. | `toolUpdateFromToolResult`, `toolUpdateFromEditToolResponse` | ~180 |
|
|
||||||
| `bridge/modelUsage.ts` | `commonPrefixLength` and `getMatchingModelUsage` — pure helpers used by the forwarding loop to resolve `contextWindow` from `modelUsage` map by prefix match. Leaf module. | `commonPrefixLength`, `getMatchingModelUsage` | ~35 |
|
|
||||||
| `bridge/notifications.ts` | Core content-block → `SessionUpdate` conversion engine. `toAcpNotifications` handles text/thinking/image/tool_use/tool_result/etc. and writes into `ToolUseCache`. `assistantMessageToAcpNotifications` and `streamEventToAcpNotifications` are thin adapters. `normalizePlanStatus` helper for TodoWrite plan mapping. Depends on `toolInfo.toolInfoFromToolUse`, `toolResults.toolUpdateFromToolResult`, and `types`. **No logger** in original — do NOT add one here. | `toAcpNotifications`, `assistantMessageToAcpNotifications`, `streamEventToAcpNotifications`, `normalizePlanStatus` | ~320 |
|
|
||||||
| `bridge/forwarding.ts` | `nextSdkMessageOrAbort` (races async generator against `AbortSignal`); `forwardSessionUpdates` (main loop consuming `SDKMessage` stream, dispatching to notification converters, accumulating usage, mapping stop reasons); `replayHistoryMessages` (replays stored user/assistant history through `toAcpNotifications`). The module-level `const logger = console` lives here (only `forwardSessionUpdates` default branch and `replayHistoryMessages` reference `logger.debug`). Depends on `types`, `notifications`, `modelUsage`. | `nextSdkMessageOrAbort`, `forwardSessionUpdates`, `replayHistoryMessages` | ~280 |
|
|
||||||
| `bridge/index.ts` | Barrel — see content below. | 8 re-exports | ~20 |
|
|
||||||
|
|
||||||
### Barrel content — `src/services/acp/bridge/index.ts`
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// Barrel preserving the public API of the former src/services/acp/bridge.ts.
|
|
||||||
// Do NOT add internal-only exports here: permissions.test.ts snapshots the
|
|
||||||
// entire module surface via require('../bridge.ts') and would break if the
|
|
||||||
// exported name set changes.
|
|
||||||
export type { ToolUseCache, SessionUsage } from './types.js'
|
|
||||||
export {
|
|
||||||
toolInfoFromToolUse,
|
|
||||||
} from './toolInfo.js'
|
|
||||||
export {
|
|
||||||
toolUpdateFromToolResult,
|
|
||||||
toolUpdateFromEditToolResponse,
|
|
||||||
} from './toolResults.js'
|
|
||||||
export {
|
|
||||||
nextSdkMessageOrAbort,
|
|
||||||
forwardSessionUpdates,
|
|
||||||
replayHistoryMessages,
|
|
||||||
} from './forwarding.js'
|
|
||||||
```
|
|
||||||
|
|
||||||
### Phase 1 verification
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# After creating all sub-files and deleting bridge.ts:
|
|
||||||
bun test src/services/acp/__tests__/bridge.test.ts
|
|
||||||
bun test src/services/acp/__tests__/permissions.test.ts # snapshot-sensitive
|
|
||||||
bun test src/services/acp/__tests__/agent.test.ts # imports bridge.js + agent.js
|
|
||||||
bun run precheck # typecheck + lint + test
|
|
||||||
```
|
|
||||||
|
|
||||||
### Phase 1 risk callouts
|
|
||||||
|
|
||||||
- **Snapshot sensitivity**: `permissions.test.ts` lines 34–35 do `require('../bridge.ts')` and snapshot every named export. The barrel MUST export exactly `{ ToolUseCache, SessionUsage, toolInfoFromToolUse, toolUpdateFromToolResult, toolUpdateFromEditToolResponse, nextSdkMessageOrAbort, forwardSessionUpdates, replayHistoryMessages }`. Do NOT re-export `ToolInfo`, `BridgeSDKMessage`, or any internal helper.
|
|
||||||
- **Logger alias**: the original `const logger = console` is a top-level const with no runtime side effect. Keep it ONLY in `forwarding.ts`. Do NOT create a shared `logger.ts` (would risk a cycle) and do NOT give `notifications.ts` its own logger (the original does not reference one).
|
|
||||||
- **`ToolInfo` stays internal**: it is the return type of `toolInfoFromToolUse` but was never exported from the original `bridge.ts`. Keep it module-internal so the public surface matches the original exactly.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 2 — `src/services/acp/agent.ts`
|
|
||||||
|
|
||||||
### Directory structure
|
|
||||||
|
|
||||||
```
|
|
||||||
src/services/acp/
|
|
||||||
├── agent.ts ← DELETED (replaced by directory)
|
|
||||||
└── agent/
|
|
||||||
├── index.ts ← barrel (re-exports AcpAgent)
|
|
||||||
├── sessionTypes.ts ← AcpSession / PendingPrompt types
|
|
||||||
├── permissionMode.ts ← permission mode resolution
|
|
||||||
├── configOptions.ts ← config option list builder
|
|
||||||
├── promptQueue.ts ← pending-prompt queue helpers
|
|
||||||
└── AcpAgent.ts ← the AcpAgent class body
|
|
||||||
```
|
|
||||||
|
|
||||||
### Files, responsibilities, line budgets
|
|
||||||
|
|
||||||
| File | Responsibility | Exports | Budget |
|
|
||||||
|------|----------------|---------|-------:|
|
|
||||||
| `agent/sessionTypes.ts` | Type definitions for in-process ACP session state. `AcpSession` and `PendingPrompt` type aliases shared across agent internals and helpers. | `AcpSession`, `PendingPrompt` | ~35 |
|
|
||||||
| `agent/permissionMode.ts` | Resolve the effective permission mode from `_meta`, settings, and process env. Determine whether ACP `bypassPermissions` mode is available (process + local opt-in + settings). `PermissionMode`-id validation guard. Imports `PermissionMode` type from `../../types/permissions.js` and `resolvePermissionMode` from `../utils.js` — leaf module, does NOT import AcpAgent. | `permissionModeIds`, `isPermissionMode`, `resolveSessionPermissionMode`, `isAcpBypassPermissionModeAvailable`, `hasOwnField` | ~110 |
|
|
||||||
| `agent/configOptions.ts` | Build the ACP session config option list (mode + model select options) from session states. `flattenConfigOptionValues` flattens grouped/flat select options into valid value strings for validation. Imports ACP SDK types (`SessionModeState`, `SessionModelState`, `SessionConfigOption`). Leaf module. | `buildConfigOptions`, `flattenConfigOptionValues` | ~70 |
|
|
||||||
| `agent/promptQueue.ts` | Pending-prompt queue management: `popNextPendingPrompt`, `compactPendingQueue` (compacts queue head to bound memory). Pure helpers operating on `AcpSession.pendingQueue` / `pendingMessages`. Imports `sessionTypes` only. | `popNextPendingPrompt`, `compactPendingQueue` | ~45 |
|
|
||||||
| `agent/AcpAgent.ts` | The `AcpAgent` class implementing the ACP Agent interface. All protocol method handlers (`initialize`, `authenticate`, `newSession`, `resumeSession`, `loadSession`, `listSessions`, `forkSession`, `closeSession`, `prompt`, `cancel`, `setSessionMode`, `setSessionModel`, `setSessionConfigOption`) and private lifecycle helpers (`createSession`, `getOrCreateSession`, `teardownSession`, `replaySessionHistory`, `applySessionMode`, `updateConfigOption`, `syncSessionConfigState`, `sendAvailableCommandsUpdate`, `scheduleAvailableCommandsUpdate`, `maybeEmitSessionInfoUpdate`, `getSetting`). Imports `sessionTypes`, `permissionMode`, `configOptions`, `promptQueue`. Imports `ToolUseCache`, `forwardSessionUpdates`, `replayHistoryMessages` from `../bridge.js` (the Phase 1 barrel). | `AcpAgent` | ~480 |
|
|
||||||
| `agent/index.ts` | Barrel — see content below. | `AcpAgent` | ~5 |
|
|
||||||
|
|
||||||
### Barrel content — `src/services/acp/agent/index.ts`
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// Barrel preserving the public API of the former src/services/acp/agent.ts.
|
|
||||||
// Tests import AcpAgent via '../agent.js' (Bun/tsc resolve the directory's
|
|
||||||
// index.ts). Keep this file to a single re-export.
|
|
||||||
export { AcpAgent } from './AcpAgent.js'
|
|
||||||
```
|
|
||||||
|
|
||||||
### Why the class body is NOT split further
|
|
||||||
|
|
||||||
The `AcpAgent` class is a single cohesive unit bound by `this.sessions` and `this.conn`. Methods like `createSession`, `prompt`, `cancel`, `teardownSession`, `applySessionMode`, `updateConfigOption` all reference `this.*` and shared private helpers. Extracting methods to a separate module would require passing the session map and connection as parameters and would create tight bidirectional coupling with high cycle risk. Therefore the class body stays in one module (~480 lines, under the 500 limit); only pure helpers and types are extracted. This keeps the import graph strictly acyclic: `sessionTypes`/`permissionMode`/`configOptions`/`promptQueue` are pure leaves that never import `AcpAgent`.
|
|
||||||
|
|
||||||
### Phase 2 verification
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun test src/services/acp/__tests__/agent.test.ts # imports ../agent.js + ../bridge.js
|
|
||||||
bun test src/services/acp/__tests__/permissions.test.ts # still green after bridge split
|
|
||||||
bun run precheck
|
|
||||||
```
|
|
||||||
|
|
||||||
### Phase 2 risk callouts
|
|
||||||
|
|
||||||
- **Private method coupling**: keep the class intact in `AcpAgent.ts`; do not be tempted to extract methods even if the file approaches the budget.
|
|
||||||
- **ToolUseCache shape coupling**: `maybeEmitSessionInfoUpdate` attaches `__sessionInfoTitleSent` to `session.toolUseCache` via a structural cast. Keep that logic inside `AcpAgent.ts` so no cross-module dependency on the extended shape is introduced.
|
|
||||||
- **Test path stability**: `agent.test.ts` line 195 does `await import('../agent.js')`. With `agent/index.ts` re-exporting `AcpAgent` from `agent/AcpAgent.ts`, the specifier resolves under Bun/TS because directory imports map to `index.ts`. The barrel MUST use the `.js` extension (`export { AcpAgent } from './AcpAgent.js'`) to match the project's ESM convention.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 3 — `packages/acp-link/src/server.ts`
|
|
||||||
|
|
||||||
### Directory structure
|
|
||||||
|
|
||||||
```
|
|
||||||
packages/acp-link/src/
|
|
||||||
├── server.ts ← DELETED (replaced by directory)
|
|
||||||
└── server/
|
|
||||||
├── index.ts ← barrel (public API)
|
|
||||||
├── types.ts ← protocol/state types + JSON-RPC codes
|
|
||||||
├── runtime-state.ts ← module-scoped mutable state container
|
|
||||||
├── client-send.ts ← outbound message framing
|
|
||||||
├── acp-client.ts ← createClient + permission helpers
|
|
||||||
├── payload-decode.ts ← validation/decode utilities
|
|
||||||
├── permission-mode.ts ← permission mode resolution
|
|
||||||
├── handlers-agent.ts ← agent lifecycle handlers
|
|
||||||
├── handlers-session.ts ← session-scoped handlers
|
|
||||||
├── dispatch.ts ← dispatch + JSON-RPC wrappers + table
|
|
||||||
├── testing-internals.ts ← __testing public object
|
|
||||||
└── start-server.ts ← startServer orchestrator
|
|
||||||
```
|
|
||||||
|
|
||||||
### Files, responsibilities, line budgets
|
|
||||||
|
|
||||||
| File | Responsibility | Exports | Budget |
|
|
||||||
|------|----------------|---------|-------:|
|
|
||||||
| `server/types.ts` | Shared protocol/state type definitions used across all server modules (`ServerConfig`, `PendingPermission`, `PromptCapabilities`, `SessionModelState`, `AgentCapabilities`, `ClientState`, `ContentBlock`, `PermissionResponsePayload`, `ProxyMessage`); `createClientState` factory; `DEFAULT_CLIENT_INFO` / `DEFAULT_CLIENT_CAPABILITIES` constants; JSON-RPC error code constants. | 16 symbols | ~200 |
|
|
||||||
| `server/runtime-state.ts` | Module-scoped mutable state container for the running server: holds the `clients` Map, server config fields (`AGENT_*`, `SERVER_*`, `AUTH_TOKEN`, `DEFAULT_PERMISSION_MODE`), `rcsUpstream`, loggers, and accessor/mutator helpers. `createRelayWs` virtual `WSContext` factory. `generateRequestId` helper. **MUST NOT import any handler module** to avoid cycles. | `clients`, `getServerConfig`, `setServerConfig`, `getRcsUpstream`, `setRcsUpstream`, `getAgentConfig`, `getDefaultPermissionMode`, `setDefaultPermissionMode`, `logWs`, `logAgent`, `logSession`, `logPrompt`, `logPerm`, `logRelay`, `logServer`, `PERMISSION_TIMEOUT_MS`, `HEARTBEAT_INTERVAL_MS`, `createRelayWs`, `generateRequestId` | ~140 |
|
|
||||||
| `server/client-send.ts` | Outbound message framing: `send`, `sendJsonRpcRaw`, `sendJsonRpcError`. `LEGACY_NOTIFICATION_TO_JSONRPC` mapping. Depends on `runtime-state` (`clients`, `rcsUpstream`) and `types` (`ClientState`). Reads `rcsUpstream` via runtime-state and the `clients` Map; `sendJsonRpcError` reads/writes `state.pendingJsonRpc`. | `send`, `sendJsonRpcRaw`, `sendJsonRpcError` | ~110 |
|
|
||||||
| `server/acp-client.ts` | `createClient(ws, clientState)`: builds the `acp.Client` implementation that forwards `requestPermission` / `sessionUpdate` / `readTextFile` / `writeTextFile`. `handlePermissionResponse` and `cancelPendingPermissions`. Depends on `client-send` (`send`) and `runtime-state` (`logPerm`). Import graph: `client-send → runtime-state` (ok), `acp-client → client-send + runtime-state` (ok, no cycle). | `createClient`, `handlePermissionResponse`, `cancelPendingPermissions` | ~110 |
|
|
||||||
| `server/payload-decode.ts` | Pure validation/decode utilities (`isRecord`, `optionalString`, `optionalStringField`, `payloadRecord`, `optionalPayloadRecord`, `optionalRecord`, `decodeContentBlocks`, `decodePermissionResponsePayload`). `decodeClientMessage` switch turning a raw record into a `ProxyMessage`. Public `decodeClientWsMessage` wrapper. `decodeClientMessage` is also consumed by `start-server.ts` (RCS relay path) — keep it exported here to avoid duplication. | 10 symbols | ~200 |
|
|
||||||
| `server/permission-mode.ts` | `ACP_LINK_PERMISSION_MODE_ALIASES` + `resolveAcpLinkPermissionMode` + public `resolveNewSessionPermissionMode`. `buildAgentEnv` helper. | `resolveNewSessionPermissionMode`, `resolveAcpLinkPermissionMode`, `ACP_LINK_PERMISSION_MODE_ALIASES`, `buildAgentEnv` | ~90 |
|
|
||||||
| `server/handlers-agent.ts` | Agent lifecycle + connection handlers: `handleConnect` and `handleDisconnect`. Spawns the agent child process, builds the ACP `ClientSideConnection`, surfaces status. Depends on `runtime-state`, `client-send`, `acp-client`, `types`. | `handleConnect`, `handleDisconnect` | ~160 |
|
|
||||||
| `server/handlers-session.ts` | Session-scoped handlers: `handleNewSession`, `handleListSessions`, `handleLoadSession`, `handleResumeSession`, `handleCancel`, `handleSetSessionModel`, `handlePrompt`. All operate on `clients.get(ws)` state and forward to `ClientSideConnection`. | 7 symbols | ~360 |
|
|
||||||
| `server/dispatch.ts` | `dispatchClientMessage` (legacy envelope switch). JSON-RPC wrappers `handleJsonRpcNewSession` / `Prompt` / `ListSessions` / `LoadSession` / `ResumeSession` / `SetSessionModel` / `SetSessionMode` / `CloseSession` / `CancelRequest`. `JSONRPC_METHOD_HANDLERS` table and `dispatchJsonRpcMessage` router. The JSON-RPC wrappers live **alongside** the table in this module (no cross-module forward reference). | `dispatchClientMessage`, `dispatchJsonRpcMessage`, `JSONRPC_METHOD_HANDLERS`, `handleJsonRpcSetSessionMode`, `handleJsonRpcCloseSession`, `handleJsonRpcCancelRequest` | ~290 |
|
|
||||||
| `server/testing-internals.ts` | `__testing` public object (`dispatchClientMessage` / `dispatchJsonRpcMessage` / `registerClient` / `getClientSessionId` / `setDefaultPermissionMode`). `assertTestingInternalsEnabled` guard gated on `ACP_LINK_TEST_INTERNALS`. Co-locate the guard with the methods that call it. | `__testing`, `assertTestingInternalsEnabled` | ~80 |
|
|
||||||
| `server/start-server.ts` | `startServer(config)`: configures runtime-state, wires `RcsUpstreamClient` relay, builds the Hono app with `/health` and `/ws` (token validation, `onOpen` / `onMessage` / `onClose`, heartbeat), HTTPS option, startup banner, SIGINT/SIGTERM graceful shutdown. Top-level orchestrator importing from `runtime-state`, `client-send`, `acp-client`, `dispatch`, `payload-decode`. All intervals/sockets MUST be created inside `startServer` (no top-level side effects). | `startServer` | ~280 |
|
|
||||||
| `server/index.ts` | Barrel — see content below. | 8 re-exports | ~25 |
|
|
||||||
|
|
||||||
### Barrel content — `packages/acp-link/src/server/index.ts`
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// Barrel preserving the public API of the former packages/acp-link/src/server.ts.
|
|
||||||
//
|
|
||||||
// Re-exports of MAX_CLIENT_WS_PAYLOAD_BYTES / isJsonRpc2Message /
|
|
||||||
// JsonRpc2ClientMessage MUST come from '../ws-message.js' (single source of
|
|
||||||
// truth) — do NOT route them through a split module.
|
|
||||||
export type { ServerConfig } from './types.js'
|
|
||||||
export {
|
|
||||||
MAX_CLIENT_WS_PAYLOAD_BYTES,
|
|
||||||
isJsonRpc2Message,
|
|
||||||
} from '../ws-message.js'
|
|
||||||
export type { JsonRpc2ClientMessage } from '../ws-message.js'
|
|
||||||
export { decodeClientWsMessage } from './payload-decode.js'
|
|
||||||
export { resolveNewSessionPermissionMode } from './permission-mode.js'
|
|
||||||
export { __testing } from './testing-internals.js'
|
|
||||||
export { startServer } from './start-server.js'
|
|
||||||
```
|
|
||||||
|
|
||||||
### Phase 3 verification
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun test packages/acp-link/src/__tests__/server.test.ts
|
|
||||||
bun test packages/acp-link/src/__tests__/types.test.ts
|
|
||||||
bun run precheck
|
|
||||||
bun run build # confirm chunk count is sane and dist/cli.js builds
|
|
||||||
```
|
|
||||||
|
|
||||||
### Phase 3 risk callouts
|
|
||||||
|
|
||||||
- **Module-scoped mutable state**: `AGENT_COMMAND`, `AGENT_ARGS`, `AGENT_CWD`, `SERVER_PORT`, `SERVER_HOST`, `AUTH_TOKEN`, `DEFAULT_PERMISSION_MODE`, the `clients` Map, and `rcsUpstream` all live in `runtime-state.ts`. Every other module accesses them via the accessors/setters. Keep `runtime-state.ts` free of any handler import — it is the shared leaf that everything else depends on; importing handlers back into it creates a cycle.
|
|
||||||
- **Single-flight invariant**: `sendJsonRpcError` reads/writes `state.pendingJsonRpc`. Do not parallelise handlers — the pendingJsonRpc invariant depends on serial mutation of `ClientState`.
|
|
||||||
- **JSON-RPC wrappers co-located with the table**: `JSONRPC_METHOD_HANDLERS` references the `handleJsonRpc*` wrappers. To avoid cross-module forward references, the wrappers and the table MUST live in the same `dispatch.ts` module.
|
|
||||||
- **Re-exports stay at source**: `MAX_CLIENT_WS_PAYLOAD_BYTES`, `isJsonRpc2Message`, `JsonRpc2ClientMessage` are re-exported from `'../ws-message.js'` directly. Do NOT re-export them from a split module.
|
|
||||||
- **No top-level side effects**: the original file only declares module-scoped vars; loggers are created eagerly via `createLogger` (acceptable — pure construction). Do NOT start intervals or open sockets at module top level; keep them inside `startServer`.
|
|
||||||
- **assertTestingInternalsEnabled gating**: the guard is gated on `ACP_LINK_TEST_INTERNALS` and is called by every `__testing` method. Co-locate it with `__testing` in `testing-internals.ts` and preserve the gating behavior verbatim.
|
|
||||||
- **Biome lint surface**: 42 rules are disabled for decompiled code. Moving helpers like `optionalStringField` into their own module may surface `noUnusedVariables` if they are not re-exported. Export every helper that was previously file-local but is now cross-module, and run `bun run precheck` to catch new warnings.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Cross-cutting verification (run after ALL three phases)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Full type + lint + test gate (REQUIRED zero errors per CLAUDE.md)
|
|
||||||
bun run precheck
|
|
||||||
|
|
||||||
# 2. Targeted regression runs for the three refactored modules
|
|
||||||
bun test packages/acp-link/src/__tests__/server.test.ts
|
|
||||||
bun test src/services/acp/__tests__/bridge.test.ts
|
|
||||||
bun test src/services/acp/__tests__/agent.test.ts
|
|
||||||
bun test src/services/acp/__tests__/permissions.test.ts
|
|
||||||
|
|
||||||
# 3. Build sanity (new chunks are produced for the new sub-files)
|
|
||||||
bun run build
|
|
||||||
ls dist/chunks | wc -l # expect a modest increase over the previous count
|
|
||||||
|
|
||||||
# 4. Unused-export audit (catches accidentally-leaked internal exports)
|
|
||||||
bun run check:unused
|
|
||||||
```
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
- [ ] `bun run precheck` passes with zero errors.
|
|
||||||
- [ ] All four target test files pass unmodified.
|
|
||||||
- [ ] `from '../server.js'`, `from '../bridge.js'`, `from '../agent.js'` all resolve correctly (verified by the passing tests).
|
|
||||||
- [ ] No new file exceeds 500 lines.
|
|
||||||
- [ ] `permissions.test.ts` snapshot of `require('../bridge.ts')` still matches the original 8-symbol public surface.
|
|
||||||
- [ ] `bun run build` succeeds with a sane chunk count.
|
|
||||||
- [ ] No test file is modified in the diff.
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "claude-code-best",
|
"name": "claude-code-best",
|
||||||
"version": "2.7.2",
|
"version": "2.7.0",
|
||||||
"description": "Reverse-engineered Anthropic Claude Code CLI — interactive AI coding assistant in the terminal",
|
"description": "Reverse-engineered Anthropic Claude Code CLI — interactive AI coding assistant in the terminal",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"author": "claude-code-best <claude-code-best@proton.me>",
|
"author": "claude-code-best <claude-code-best@proton.me>",
|
||||||
|
|||||||
@@ -165,12 +165,6 @@ export default class Ink {
|
|||||||
private frontFrame: Frame;
|
private frontFrame: Frame;
|
||||||
private backFrame: Frame;
|
private backFrame: Frame;
|
||||||
private lastPoolResetTime = performance.now();
|
private lastPoolResetTime = performance.now();
|
||||||
/** Timestamp of last periodic full-redraw in main screen mode. Used to
|
|
||||||
* recover from accumulated cursor drift / blit ghosting. Wall-clock
|
|
||||||
* based (not frame-count) so drain scroll frames (250fps) don't
|
|
||||||
* accelerate the cycle. Alt-screen doesn't need this — CSI H resets
|
|
||||||
* cursor every frame. */
|
|
||||||
private lastMainScreenHealTime = performance.now();
|
|
||||||
private drainTimer: ReturnType<typeof setTimeout> | null = null;
|
private drainTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
private lastYogaCounters: {
|
private lastYogaCounters: {
|
||||||
ms: number;
|
ms: number;
|
||||||
@@ -527,25 +521,7 @@ export default class Ink {
|
|||||||
// an extra React re-render cycle.
|
// an extra React re-render cycle.
|
||||||
this.options.onBeforeRender?.();
|
this.options.onBeforeRender?.();
|
||||||
|
|
||||||
// Periodic self-healing: every ~5s in main screen mode, force a full
|
|
||||||
// terminal redraw to recover from accumulated cursor drift / blit
|
|
||||||
// ghosting. Alt-screen doesn't need this — CSI H resets cursor to
|
|
||||||
// (0,0) every frame. Wall-clock based so drain scroll frames (250fps)
|
|
||||||
// don't accelerate the cycle. Guarded by isTTY so ANSI escape
|
|
||||||
// sequences are not leaked into pipes / redirected output.
|
|
||||||
const renderStart = performance.now();
|
const renderStart = performance.now();
|
||||||
if (
|
|
||||||
!this.altScreenActive &&
|
|
||||||
!this.isPaused &&
|
|
||||||
this.options.stdout.isTTY &&
|
|
||||||
renderStart - this.lastMainScreenHealTime > 5000
|
|
||||||
) {
|
|
||||||
this.lastMainScreenHealTime = renderStart;
|
|
||||||
this.repaint();
|
|
||||||
this.prevFrameContaminated = true;
|
|
||||||
this.needsEraseBeforePaint = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const terminalWidth = this.options.stdout.columns || 80;
|
const terminalWidth = this.options.stdout.columns || 80;
|
||||||
const terminalRows = this.options.stdout.rows || 24;
|
const terminalRows = this.options.stdout.rows || 24;
|
||||||
|
|
||||||
@@ -749,10 +725,6 @@ export default class Ink {
|
|||||||
const optimized = optimize(diff);
|
const optimized = optimize(diff);
|
||||||
const optimizeMs = performance.now() - tOptimize;
|
const optimizeMs = performance.now() - tOptimize;
|
||||||
const hasDiff = optimized.length > 0;
|
const hasDiff = optimized.length > 0;
|
||||||
// Periodic self-healing: for main-screen mode, emit ERASE_SCREEN + HOME
|
|
||||||
// to clear the terminal before the diff. Alt-screen has its own CSI H
|
|
||||||
// anchor + cursor park below. BSU/ESU wraps erase+paint atomically on
|
|
||||||
// supported terminals (main-screen always uses sync markers).
|
|
||||||
if (this.altScreenActive && hasDiff) {
|
if (this.altScreenActive && hasDiff) {
|
||||||
// Prepend CSI H to anchor the physical cursor to (0,0) so
|
// Prepend CSI H to anchor the physical cursor to (0,0) so
|
||||||
// log-update's relative moves compute from a known spot (self-healing
|
// log-update's relative moves compute from a known spot (self-healing
|
||||||
@@ -780,13 +752,6 @@ export default class Ink {
|
|||||||
optimized.unshift(CURSOR_HOME_PATCH);
|
optimized.unshift(CURSOR_HOME_PATCH);
|
||||||
}
|
}
|
||||||
optimized.push(this.altScreenParkPatch);
|
optimized.push(this.altScreenParkPatch);
|
||||||
} else if (this.needsEraseBeforePaint && hasDiff) {
|
|
||||||
// Main-screen periodic self-healing: clear visible terminal before
|
|
||||||
// painting the diff. Without this, rows past the new frame's height
|
|
||||||
// would retain stale content from the previous frame. BSU/ESU keeps
|
|
||||||
// old content visible until the full erase+paint is flushed atomically.
|
|
||||||
this.needsEraseBeforePaint = false;
|
|
||||||
optimized.unshift(ERASE_THEN_HOME_PATCH);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Native cursor positioning: park the terminal cursor at the declared
|
// Native cursor positioning: park the terminal cursor at the declared
|
||||||
|
|||||||
@@ -275,9 +275,6 @@ describe('permission mode resolution', () => {
|
|||||||
{
|
{
|
||||||
type: 'error',
|
type: 'error',
|
||||||
payload: {
|
payload: {
|
||||||
// Legacy error envelope now carries the JSON-RPC code as a string
|
|
||||||
// (audit §8.3). -32602 = invalid params.
|
|
||||||
code: '-32602',
|
|
||||||
message: expect.stringContaining(
|
message: expect.stringContaining(
|
||||||
'bypassPermissions requires local ACP_PERMISSION_MODE',
|
'bypassPermissions requires local ACP_PERMISSION_MODE',
|
||||||
),
|
),
|
||||||
@@ -307,222 +304,3 @@ describe('Heartbeat constants', () => {
|
|||||||
expect(HEARTBEAT_INTERVAL_MS).toBe(30_000)
|
expect(HEARTBEAT_INTERVAL_MS).toBe(30_000)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('JSON-RPC 2.0 routing (audit §8.1-8.5)', () => {
|
|
||||||
// Helper to register a JSON-RPC-capable client and capture sent frames.
|
|
||||||
function setupJsonRpcClient(
|
|
||||||
sent: unknown[],
|
|
||||||
options: {
|
|
||||||
connection?: unknown
|
|
||||||
sessionId?: string | null
|
|
||||||
} = {},
|
|
||||||
) {
|
|
||||||
const ws = makeTestWs(sent)
|
|
||||||
process.env.ACP_LINK_TEST_INTERNALS = '1'
|
|
||||||
const unregister = __testing.registerClient(ws, {
|
|
||||||
connection: options.connection,
|
|
||||||
sessionId: options.sessionId ?? null,
|
|
||||||
jsonRpc: true,
|
|
||||||
})
|
|
||||||
return { ws, unregister }
|
|
||||||
}
|
|
||||||
|
|
||||||
test('unknown JSON-RPC method yields -32601 method-not-found (§8.4)', async () => {
|
|
||||||
const sent: unknown[] = []
|
|
||||||
const { ws, unregister } = setupJsonRpcClient(sent)
|
|
||||||
try {
|
|
||||||
await __testing.dispatchJsonRpcMessage(ws, {
|
|
||||||
jsonrpc: '2.0',
|
|
||||||
id: 42,
|
|
||||||
method: 'session/nonexistent_method',
|
|
||||||
params: {},
|
|
||||||
})
|
|
||||||
// JSON-RPC clients receive a JSON-RPC error with the standard code.
|
|
||||||
expect(sent).toContainEqual({
|
|
||||||
jsonrpc: '2.0',
|
|
||||||
id: 42,
|
|
||||||
error: {
|
|
||||||
code: -32601,
|
|
||||||
message: 'Method not found: session/nonexistent_method',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
unregister()
|
|
||||||
delete process.env.ACP_LINK_TEST_INTERNALS
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test('JSON-RPC response echoes the request id (§8.2)', async () => {
|
|
||||||
const sent: unknown[] = []
|
|
||||||
const prompt = mock(async () => ({ stopReason: 'end_turn' }))
|
|
||||||
const { ws, unregister } = setupJsonRpcClient(sent, {
|
|
||||||
connection: { prompt },
|
|
||||||
sessionId: 'sess-1',
|
|
||||||
})
|
|
||||||
try {
|
|
||||||
await __testing.dispatchJsonRpcMessage(ws, {
|
|
||||||
jsonrpc: '2.0',
|
|
||||||
id: 'req-7',
|
|
||||||
method: 'session/prompt',
|
|
||||||
params: { sessionId: 'sess-1', prompt: [{ type: 'text', text: 'hi' }] },
|
|
||||||
})
|
|
||||||
// The id is echoed back in the JSON-RPC result.
|
|
||||||
expect(sent).toContainEqual({
|
|
||||||
jsonrpc: '2.0',
|
|
||||||
id: 'req-7',
|
|
||||||
result: { stopReason: 'end_turn' },
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
unregister()
|
|
||||||
delete process.env.ACP_LINK_TEST_INTERNALS
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test('$/cancel_request is handled and forwards to session/cancel (§8.5)', async () => {
|
|
||||||
const sent: unknown[] = []
|
|
||||||
const cancel = mock(async () => {})
|
|
||||||
const { ws, unregister } = setupJsonRpcClient(sent, {
|
|
||||||
connection: { cancel },
|
|
||||||
sessionId: 'sess-1',
|
|
||||||
})
|
|
||||||
try {
|
|
||||||
await __testing.dispatchJsonRpcMessage(ws, {
|
|
||||||
jsonrpc: '2.0',
|
|
||||||
id: 'cancel-1',
|
|
||||||
method: '$/cancel_request',
|
|
||||||
params: { id: 'req-7' },
|
|
||||||
})
|
|
||||||
// The cancel was forwarded to the ACP cancel path.
|
|
||||||
expect(cancel).toHaveBeenCalled()
|
|
||||||
} finally {
|
|
||||||
unregister()
|
|
||||||
delete process.env.ACP_LINK_TEST_INTERNALS
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test('JSON-RPC notifications (no id) are dispatched without a response', async () => {
|
|
||||||
const sent: unknown[] = []
|
|
||||||
const cancel = mock(async () => {})
|
|
||||||
const { ws, unregister } = setupJsonRpcClient(sent, {
|
|
||||||
connection: { cancel },
|
|
||||||
sessionId: 'sess-1',
|
|
||||||
})
|
|
||||||
try {
|
|
||||||
await __testing.dispatchJsonRpcMessage(ws, {
|
|
||||||
jsonrpc: '2.0',
|
|
||||||
method: 'session/cancel',
|
|
||||||
params: {},
|
|
||||||
})
|
|
||||||
expect(cancel).toHaveBeenCalled()
|
|
||||||
// No JSON-RPC response frame should be emitted for a notification.
|
|
||||||
expect(
|
|
||||||
sent.find(m => (m as { jsonrpc?: string }).jsonrpc),
|
|
||||||
).toBeUndefined()
|
|
||||||
} finally {
|
|
||||||
unregister()
|
|
||||||
delete process.env.ACP_LINK_TEST_INTERNALS
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test('session/set_mode is forwarded to the agent connection (§8.4)', async () => {
|
|
||||||
const sent: unknown[] = []
|
|
||||||
const setSessionMode = mock(async () => ({ modeId: 'plan' }))
|
|
||||||
const { ws, unregister } = setupJsonRpcClient(sent, {
|
|
||||||
connection: { setSessionMode },
|
|
||||||
sessionId: 'sess-1',
|
|
||||||
})
|
|
||||||
try {
|
|
||||||
await __testing.dispatchJsonRpcMessage(ws, {
|
|
||||||
jsonrpc: '2.0',
|
|
||||||
id: 'm1',
|
|
||||||
method: 'session/set_mode',
|
|
||||||
params: { sessionId: 'sess-1', modeId: 'plan' },
|
|
||||||
})
|
|
||||||
expect(setSessionMode).toHaveBeenCalled()
|
|
||||||
// The response carries the echoed id.
|
|
||||||
expect(sent).toContainEqual({
|
|
||||||
jsonrpc: '2.0',
|
|
||||||
id: 'm1',
|
|
||||||
result: { modeId: 'plan' },
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
unregister()
|
|
||||||
delete process.env.ACP_LINK_TEST_INTERNALS
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test('session/close is forwarded to the agent connection (§8.4)', async () => {
|
|
||||||
const sent: unknown[] = []
|
|
||||||
const unstable_closeSession = mock(async () => ({}))
|
|
||||||
const { ws, unregister } = setupJsonRpcClient(sent, {
|
|
||||||
connection: { unstable_closeSession },
|
|
||||||
sessionId: 'sess-1',
|
|
||||||
})
|
|
||||||
try {
|
|
||||||
await __testing.dispatchJsonRpcMessage(ws, {
|
|
||||||
jsonrpc: '2.0',
|
|
||||||
id: 'c1',
|
|
||||||
method: 'session/close',
|
|
||||||
params: { sessionId: 'sess-1' },
|
|
||||||
})
|
|
||||||
expect(unstable_closeSession).toHaveBeenCalled()
|
|
||||||
} finally {
|
|
||||||
unregister()
|
|
||||||
delete process.env.ACP_LINK_TEST_INTERNALS
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Capability and protocolVersion transparency (audit §8.6, §8.7, §8.13)', () => {
|
|
||||||
test('initialize forwards client-supplied clientInfo/capabilities (§8.7)', async () => {
|
|
||||||
const sent: unknown[] = []
|
|
||||||
const ws = makeTestWs(sent)
|
|
||||||
process.env.ACP_LINK_TEST_INTERNALS = '1'
|
|
||||||
const unregister = __testing.registerClient(ws, { connection: null })
|
|
||||||
try {
|
|
||||||
// Send initialize with custom clientInfo; the proxy should remember it.
|
|
||||||
await __testing.dispatchJsonRpcMessage(ws, {
|
|
||||||
jsonrpc: '2.0',
|
|
||||||
id: 'init-1',
|
|
||||||
method: 'initialize',
|
|
||||||
params: {
|
|
||||||
clientInfo: { name: 'my-editor', version: '2.3.4' },
|
|
||||||
clientCapabilities: { terminal: { create: true } },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
// The handler invocation will fail (no agent process) but clientInfo was
|
|
||||||
// captured before the call. We verify by checking that no -32602 invalid
|
|
||||||
// params error is raised about clientInfo.
|
|
||||||
expect(sent.length).toBeGreaterThan(0)
|
|
||||||
} finally {
|
|
||||||
unregister()
|
|
||||||
delete process.env.ACP_LINK_TEST_INTERNALS
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('ws-message JSON-RPC decoding (audit §8.1)', () => {
|
|
||||||
test('decodeJsonWsMessage accepts JSON-RPC 2.0 requests', async () => {
|
|
||||||
const { decodeJsonWsMessage, isJsonRpc2Message } = await import(
|
|
||||||
'../ws-message.js'
|
|
||||||
)
|
|
||||||
const msg = decodeJsonWsMessage(
|
|
||||||
'{"jsonrpc":"2.0","id":1,"method":"session/prompt","params":{}}',
|
|
||||||
)
|
|
||||||
expect(isJsonRpc2Message(msg)).toBe(true)
|
|
||||||
expect((msg as { method?: string }).method).toBe('session/prompt')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('decodeJsonWsMessage still accepts legacy {type,payload} envelope', async () => {
|
|
||||||
const { decodeJsonWsMessage } = await import('../ws-message.js')
|
|
||||||
const msg = decodeJsonWsMessage('{"type":"ping"}')
|
|
||||||
expect((msg as { type?: string }).type).toBe('ping')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('decodeJsonWsMessage rejects non-JSON-RPC, non-type payloads', async () => {
|
|
||||||
const { decodeJsonWsMessage } = await import('../ws-message.js')
|
|
||||||
expect(() => decodeJsonWsMessage('{"foo":"bar"}')).toThrow(
|
|
||||||
'Invalid WebSocket message payload',
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -211,12 +211,9 @@ export class RcsUpstreamClient {
|
|||||||
} else if (data.type === 'keep_alive') {
|
} else if (data.type === 'keep_alive') {
|
||||||
// ignore keepalive
|
// ignore keepalive
|
||||||
} else {
|
} else {
|
||||||
// Forward ACP protocol messages to handler (for RCS relay support).
|
// Forward ACP protocol messages to handler (for RCS relay support)
|
||||||
// This branch handles both the legacy `{type, payload}` envelope
|
|
||||||
// and JSON-RPC 2.0 messages (which have no `type` field) so the
|
|
||||||
// relay preserves the JSON-RPC format end-to-end (audit §8.12).
|
|
||||||
RcsUpstreamClient.log.debug(
|
RcsUpstreamClient.log.debug(
|
||||||
{ type: data.type, method: data.method },
|
{ type: data.type },
|
||||||
'forwarding to relay handler',
|
'forwarding to relay handler',
|
||||||
)
|
)
|
||||||
this.messageHandler?.(data)
|
this.messageHandler?.(data)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,102 +0,0 @@
|
|||||||
import type { WSContext } from 'hono/ws'
|
|
||||||
import * as acp from '@agentclientprotocol/sdk'
|
|
||||||
import { send } from './client-send.js'
|
|
||||||
import {
|
|
||||||
PERMISSION_TIMEOUT_MS,
|
|
||||||
generateRequestId,
|
|
||||||
logPerm,
|
|
||||||
logWs,
|
|
||||||
} from './runtime-state.js'
|
|
||||||
import { clients } from './runtime-state.js'
|
|
||||||
import type { ClientState } from './types.js'
|
|
||||||
|
|
||||||
// Create a Client implementation that forwards events to WebSocket
|
|
||||||
export function createClient(
|
|
||||||
ws: WSContext,
|
|
||||||
clientState: ClientState,
|
|
||||||
): acp.Client {
|
|
||||||
return {
|
|
||||||
async requestPermission(params) {
|
|
||||||
const requestId = generateRequestId()
|
|
||||||
logPerm.debug({ requestId, title: params.toolCall.title }, 'requested')
|
|
||||||
|
|
||||||
const outcomePromise = new Promise<
|
|
||||||
{ outcome: 'cancelled' } | { outcome: 'selected'; optionId: string }
|
|
||||||
>(resolve => {
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
logPerm.warn({ requestId }, 'timed out')
|
|
||||||
clientState.pendingPermissions.delete(requestId)
|
|
||||||
resolve({ outcome: 'cancelled' })
|
|
||||||
}, PERMISSION_TIMEOUT_MS)
|
|
||||||
|
|
||||||
clientState.pendingPermissions.set(requestId, { resolve, timeout })
|
|
||||||
})
|
|
||||||
|
|
||||||
send(ws, 'permission_request', {
|
|
||||||
requestId,
|
|
||||||
sessionId: params.sessionId,
|
|
||||||
options: params.options,
|
|
||||||
toolCall: params.toolCall,
|
|
||||||
})
|
|
||||||
|
|
||||||
const outcome = await outcomePromise
|
|
||||||
logPerm.debug({ requestId, outcome: outcome.outcome }, 'resolved')
|
|
||||||
|
|
||||||
return { outcome }
|
|
||||||
},
|
|
||||||
|
|
||||||
async sessionUpdate(params) {
|
|
||||||
send(ws, 'session_update', params)
|
|
||||||
},
|
|
||||||
|
|
||||||
async readTextFile(params) {
|
|
||||||
logWs.debug({ path: params.path }, 'readTextFile')
|
|
||||||
return { content: '' }
|
|
||||||
},
|
|
||||||
|
|
||||||
async writeTextFile(params) {
|
|
||||||
logWs.debug({ path: params.path }, 'writeTextFile')
|
|
||||||
return {}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle permission response from client
|
|
||||||
export function handlePermissionResponse(
|
|
||||||
ws: WSContext,
|
|
||||||
payload: {
|
|
||||||
requestId: string
|
|
||||||
outcome:
|
|
||||||
| { outcome: 'cancelled' }
|
|
||||||
| { outcome: 'selected'; optionId: string }
|
|
||||||
},
|
|
||||||
): void {
|
|
||||||
const state = clients.get(ws)
|
|
||||||
if (!state) {
|
|
||||||
logPerm.warn('response from unknown client')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const pending = state.pendingPermissions.get(payload.requestId)
|
|
||||||
if (!pending) {
|
|
||||||
logPerm.warn(
|
|
||||||
{ requestId: payload.requestId },
|
|
||||||
'response for unknown request',
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
clearTimeout(pending.timeout)
|
|
||||||
state.pendingPermissions.delete(payload.requestId)
|
|
||||||
pending.resolve(payload.outcome)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cancel all pending permissions for a client (called on disconnect)
|
|
||||||
export function cancelPendingPermissions(clientState: ClientState): void {
|
|
||||||
for (const [requestId, pending] of clientState.pendingPermissions) {
|
|
||||||
logPerm.debug({ requestId }, 'cancelled on disconnect')
|
|
||||||
clearTimeout(pending.timeout)
|
|
||||||
pending.resolve({ outcome: 'cancelled' })
|
|
||||||
}
|
|
||||||
clientState.pendingPermissions.clear()
|
|
||||||
}
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
import type { WSContext } from 'hono/ws'
|
|
||||||
import { clients, getRcsUpstream } from './runtime-state.js'
|
|
||||||
import type { ClientState } from './types.js'
|
|
||||||
|
|
||||||
// Maps legacy notification type strings to their JSON-RPC method names so
|
|
||||||
// agent→client notifications are also emitted as JSON-RPC notifications for
|
|
||||||
// JSON-RPC 2.0 clients (audit §8.1). Notifications have no id.
|
|
||||||
export const LEGACY_NOTIFICATION_TO_JSONRPC: Record<string, string> = {
|
|
||||||
session_update: 'session/update',
|
|
||||||
permission_request: 'session/request_permission',
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send a notification/response to the WebSocket client.
|
|
||||||
//
|
|
||||||
// For legacy `{type, payload}` clients this emits the proprietary envelope.
|
|
||||||
// For JSON-RPC 2.0 clients this additionally emits a JSON-RPC response that
|
|
||||||
// echoes the in-flight request id when the message type matches the pending
|
|
||||||
// request's expected response type (audit §8.2). Agent→client notifications
|
|
||||||
// (`session_update`, `permission_request`) are emitted as JSON-RPC
|
|
||||||
// notifications without an id.
|
|
||||||
export function send(ws: WSContext, type: string, payload?: unknown): void {
|
|
||||||
if (ws.readyState === 1) {
|
|
||||||
// WebSocket.OPEN
|
|
||||||
ws.send(JSON.stringify({ type, payload }))
|
|
||||||
}
|
|
||||||
// Forward to RCS upstream if connected
|
|
||||||
const rcsUpstream = getRcsUpstream()
|
|
||||||
if (rcsUpstream?.isRegistered()) {
|
|
||||||
rcsUpstream.send({ type, payload })
|
|
||||||
}
|
|
||||||
|
|
||||||
const state = clients.get(ws)
|
|
||||||
if (!state?.jsonRpc) return
|
|
||||||
|
|
||||||
// If this is the response to an in-flight JSON-RPC request, emit the
|
|
||||||
// standard JSON-RPC result with the preserved id.
|
|
||||||
if (state.pendingJsonRpc?.responseType === type) {
|
|
||||||
sendJsonRpcRaw(ws, {
|
|
||||||
jsonrpc: '2.0',
|
|
||||||
id: state.pendingJsonRpc.id,
|
|
||||||
result: payload ?? {},
|
|
||||||
})
|
|
||||||
state.pendingJsonRpc = null
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Agent→client notifications are also emitted as JSON-RPC notifications
|
|
||||||
// (no id) so JSON-RPC clients receive them in their native format.
|
|
||||||
const notificationMethod = LEGACY_NOTIFICATION_TO_JSONRPC[type]
|
|
||||||
if (notificationMethod) {
|
|
||||||
sendJsonRpcRaw(ws, {
|
|
||||||
jsonrpc: '2.0',
|
|
||||||
method: notificationMethod,
|
|
||||||
params: payload ?? {},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Serialize a JSON-RPC 2.0 message and send it to a connected WS client.
|
|
||||||
export function sendJsonRpcRaw(ws: WSContext, message: object): void {
|
|
||||||
if (ws.readyState === 1) {
|
|
||||||
ws.send(JSON.stringify(message))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send a JSON-RPC 2.0 error response with a reserved -32xxx code (audit §8.3).
|
|
||||||
* Also emits the legacy `{type: 'error', payload: {message}}` envelope for
|
|
||||||
* backwards compatibility.
|
|
||||||
*/
|
|
||||||
export function sendJsonRpcError(
|
|
||||||
ws: WSContext,
|
|
||||||
state: ClientState | undefined,
|
|
||||||
id: string | number | null,
|
|
||||||
code: number,
|
|
||||||
message: string,
|
|
||||||
): void {
|
|
||||||
if (state?.jsonRpc) {
|
|
||||||
sendJsonRpcRaw(ws, {
|
|
||||||
jsonrpc: '2.0',
|
|
||||||
id,
|
|
||||||
error: { code, message },
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
send(ws, 'error', { message, code: String(code) })
|
|
||||||
}
|
|
||||||
// Error consumed the in-flight request, if any.
|
|
||||||
if (state) state.pendingJsonRpc = null
|
|
||||||
}
|
|
||||||
@@ -1,335 +0,0 @@
|
|||||||
import type { WSContext } from 'hono/ws'
|
|
||||||
import type { JsonRpc2ClientMessage } from '../ws-message.js'
|
|
||||||
import { handlePermissionResponse } from './acp-client.js'
|
|
||||||
import { send, sendJsonRpcError, sendJsonRpcRaw } from './client-send.js'
|
|
||||||
import {
|
|
||||||
handleCancel,
|
|
||||||
handleListSessions,
|
|
||||||
handleLoadSession,
|
|
||||||
handleNewSession,
|
|
||||||
handlePrompt,
|
|
||||||
handleResumeSession,
|
|
||||||
handleSetSessionModel,
|
|
||||||
} from './handlers-session.js'
|
|
||||||
import { handleConnect, handleDisconnect } from './handlers-agent.js'
|
|
||||||
import {
|
|
||||||
isRecord,
|
|
||||||
optionalPayloadRecord,
|
|
||||||
optionalRecord,
|
|
||||||
optionalString,
|
|
||||||
optionalStringField,
|
|
||||||
payloadRecord,
|
|
||||||
decodeContentBlocks,
|
|
||||||
} from './payload-decode.js'
|
|
||||||
import { clients, logWs } from './runtime-state.js'
|
|
||||||
import {
|
|
||||||
JSONRPC_INTERNAL_ERROR,
|
|
||||||
JSONRPC_INVALID_PARAMS,
|
|
||||||
JSONRPC_METHOD_NOT_FOUND,
|
|
||||||
type ProxyMessage,
|
|
||||||
} from './types.js'
|
|
||||||
|
|
||||||
export async function dispatchClientMessage(
|
|
||||||
ws: WSContext,
|
|
||||||
data: ProxyMessage,
|
|
||||||
): Promise<void> {
|
|
||||||
switch (data.type) {
|
|
||||||
case 'connect':
|
|
||||||
await handleConnect(ws)
|
|
||||||
break
|
|
||||||
case 'disconnect':
|
|
||||||
handleDisconnect(ws)
|
|
||||||
break
|
|
||||||
case 'new_session':
|
|
||||||
await handleNewSession(ws, data.payload)
|
|
||||||
break
|
|
||||||
case 'prompt':
|
|
||||||
await handlePrompt(ws, data.payload)
|
|
||||||
break
|
|
||||||
case 'permission_response':
|
|
||||||
handlePermissionResponse(ws, data.payload)
|
|
||||||
break
|
|
||||||
case 'cancel':
|
|
||||||
await handleCancel(ws)
|
|
||||||
break
|
|
||||||
case 'set_session_model':
|
|
||||||
await handleSetSessionModel(ws, data.payload)
|
|
||||||
break
|
|
||||||
case 'list_sessions':
|
|
||||||
await handleListSessions(ws, data.payload)
|
|
||||||
break
|
|
||||||
case 'load_session':
|
|
||||||
await handleLoadSession(ws, data.payload)
|
|
||||||
break
|
|
||||||
case 'resume_session':
|
|
||||||
await handleResumeSession(ws, data.payload)
|
|
||||||
break
|
|
||||||
case 'ping':
|
|
||||||
send(ws, 'pong')
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// JSON-RPC method wrappers that accept `params: unknown` and forward to the
|
|
||||||
// existing handlers with the decoded payload.
|
|
||||||
async function handleJsonRpcNewSession(
|
|
||||||
ws: WSContext,
|
|
||||||
params: unknown,
|
|
||||||
): Promise<void> {
|
|
||||||
const payload = optionalPayloadRecord(params, 'session/new')
|
|
||||||
await handleNewSession(ws, {
|
|
||||||
cwd: optionalStringField(payload, 'cwd', 'session/new.cwd'),
|
|
||||||
permissionMode: optionalStringField(
|
|
||||||
payload,
|
|
||||||
'permissionMode',
|
|
||||||
'session/new.permissionMode',
|
|
||||||
),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleJsonRpcPrompt(
|
|
||||||
ws: WSContext,
|
|
||||||
params: unknown,
|
|
||||||
): Promise<void> {
|
|
||||||
const payload = payloadRecord(params, 'session/prompt')
|
|
||||||
// ACP session/prompt params: { sessionId, prompt: ContentBlock[] }
|
|
||||||
// Accept either `prompt` (spec) or `content` (legacy) for compatibility.
|
|
||||||
const content = payload.prompt ?? payload.content
|
|
||||||
await handlePrompt(ws, { content: decodeContentBlocks(content) })
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleJsonRpcListSessions(
|
|
||||||
ws: WSContext,
|
|
||||||
params: unknown,
|
|
||||||
): Promise<void> {
|
|
||||||
const payload = optionalRecord(params)
|
|
||||||
await handleListSessions(ws, {
|
|
||||||
cwd: optionalString(payload.cwd),
|
|
||||||
cursor: optionalString(payload.cursor),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleJsonRpcLoadSession(
|
|
||||||
ws: WSContext,
|
|
||||||
params: unknown,
|
|
||||||
): Promise<void> {
|
|
||||||
const payload = payloadRecord(params, 'session/load')
|
|
||||||
if (typeof payload.sessionId !== 'string') {
|
|
||||||
throw new Error('Invalid session/load payload')
|
|
||||||
}
|
|
||||||
await handleLoadSession(ws, {
|
|
||||||
sessionId: payload.sessionId,
|
|
||||||
cwd: optionalString(payload.cwd),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleJsonRpcResumeSession(
|
|
||||||
ws: WSContext,
|
|
||||||
params: unknown,
|
|
||||||
): Promise<void> {
|
|
||||||
const payload = payloadRecord(params, 'session/resume')
|
|
||||||
if (typeof payload.sessionId !== 'string') {
|
|
||||||
throw new Error('Invalid session/resume payload')
|
|
||||||
}
|
|
||||||
await handleResumeSession(ws, {
|
|
||||||
sessionId: payload.sessionId,
|
|
||||||
cwd: optionalString(payload.cwd),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleJsonRpcSetSessionModel(
|
|
||||||
ws: WSContext,
|
|
||||||
params: unknown,
|
|
||||||
): Promise<void> {
|
|
||||||
const payload = payloadRecord(params, 'session/set_model')
|
|
||||||
if (typeof payload.modelId !== 'string') {
|
|
||||||
throw new Error('Invalid session/set_model payload')
|
|
||||||
}
|
|
||||||
await handleSetSessionModel(ws, { modelId: payload.modelId })
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pass-through handlers for v1 baseline methods that the proprietary
|
|
||||||
* whitelist previously dropped (audit §8.4). They forward the call to the
|
|
||||||
* underlying SDK ClientSideConnection and surface the result.
|
|
||||||
*/
|
|
||||||
export async function handleJsonRpcSetSessionMode(
|
|
||||||
ws: WSContext,
|
|
||||||
params: unknown,
|
|
||||||
): Promise<void> {
|
|
||||||
const state = clients.get(ws)
|
|
||||||
if (!state?.connection) {
|
|
||||||
throw new Error('Not connected to agent')
|
|
||||||
}
|
|
||||||
const result = await state.connection.setSessionMode(
|
|
||||||
params as { sessionId: string; modeId: string },
|
|
||||||
)
|
|
||||||
send(ws, 'session_mode_set', result ?? {})
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function handleJsonRpcCloseSession(
|
|
||||||
ws: WSContext,
|
|
||||||
params: unknown,
|
|
||||||
): Promise<void> {
|
|
||||||
const state = clients.get(ws)
|
|
||||||
if (!state?.connection) {
|
|
||||||
throw new Error('Not connected to agent')
|
|
||||||
}
|
|
||||||
const result = await state.connection.unstable_closeSession(
|
|
||||||
params as { sessionId: string },
|
|
||||||
)
|
|
||||||
send(ws, 'session_closed', result ?? {})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle the JSON-RPC standard cancellation primitive `$/cancel_request`
|
|
||||||
* (audit §8.5). Unlike the ACP-specific `session/cancel` notification, this
|
|
||||||
* cancels an in-flight request by id. We forward to the ACP cancel path and
|
|
||||||
* also clear any pending permission request.
|
|
||||||
*/
|
|
||||||
export async function handleJsonRpcCancelRequest(
|
|
||||||
ws: WSContext,
|
|
||||||
params: unknown,
|
|
||||||
): Promise<void> {
|
|
||||||
const payload = optionalRecord(params)
|
|
||||||
logWs.info({ cancelledId: payload.id }, '$/cancel_request received')
|
|
||||||
await handleCancel(ws)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Maps JSON-RPC method names to their legacy handler + the legacy response
|
|
||||||
* type the handler emits via send(). Used by dispatchJsonRpcMessage to route
|
|
||||||
* standard ACP methods (audit §8.1, §8.4).
|
|
||||||
*/
|
|
||||||
export const JSONRPC_METHOD_HANDLERS: Record<
|
|
||||||
string,
|
|
||||||
{
|
|
||||||
responseType: string
|
|
||||||
handle: (ws: WSContext, params: unknown) => Promise<void> | void
|
|
||||||
}
|
|
||||||
> = {
|
|
||||||
initialize: { responseType: 'status', handle: handleConnect },
|
|
||||||
'session/new': {
|
|
||||||
responseType: 'session_created',
|
|
||||||
handle: handleJsonRpcNewSession,
|
|
||||||
},
|
|
||||||
'session/prompt': {
|
|
||||||
responseType: 'prompt_complete',
|
|
||||||
handle: handleJsonRpcPrompt,
|
|
||||||
},
|
|
||||||
'session/cancel': { responseType: '', handle: handleCancel },
|
|
||||||
'session/list': {
|
|
||||||
responseType: 'session_list',
|
|
||||||
handle: handleJsonRpcListSessions,
|
|
||||||
},
|
|
||||||
'session/load': {
|
|
||||||
responseType: 'session_loaded',
|
|
||||||
handle: handleJsonRpcLoadSession,
|
|
||||||
},
|
|
||||||
'session/resume': {
|
|
||||||
responseType: 'session_resumed',
|
|
||||||
handle: handleJsonRpcResumeSession,
|
|
||||||
},
|
|
||||||
'session/set_model': {
|
|
||||||
responseType: 'model_changed',
|
|
||||||
handle: handleJsonRpcSetSessionModel,
|
|
||||||
},
|
|
||||||
'session/set_mode': {
|
|
||||||
responseType: 'session_mode_set',
|
|
||||||
handle: handleJsonRpcSetSessionMode,
|
|
||||||
},
|
|
||||||
'session/close': {
|
|
||||||
responseType: 'session_closed',
|
|
||||||
handle: handleJsonRpcCloseSession,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Route a JSON-RPC 2.0 message. Requests get a response with the echoed id;
|
|
||||||
* notifications (no id) are dispatched without a response. Unknown methods
|
|
||||||
* yield a JSON-RPC -32601 error (audit §8.4). `$/cancel_request` is handled
|
|
||||||
* specially (audit §8.5).
|
|
||||||
*/
|
|
||||||
export async function dispatchJsonRpcMessage(
|
|
||||||
ws: WSContext,
|
|
||||||
msg: JsonRpc2ClientMessage,
|
|
||||||
): Promise<void> {
|
|
||||||
const state = clients.get(ws)
|
|
||||||
// Mark this client as JSON-RPC from the first framed message.
|
|
||||||
if (state) state.jsonRpc = true
|
|
||||||
|
|
||||||
// Capture client identity/capabilities from initialize (audit §8.7).
|
|
||||||
if (msg.method === 'initialize' && state) {
|
|
||||||
const params = isRecord(msg.params) ? msg.params : {}
|
|
||||||
if (isRecord(params.clientInfo)) {
|
|
||||||
const ci = params.clientInfo
|
|
||||||
if (typeof ci.name === 'string' && typeof ci.version === 'string') {
|
|
||||||
state.clientInfo = { name: ci.name, version: ci.version }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (isRecord(params.clientCapabilities)) {
|
|
||||||
state.clientCapabilities = params.clientCapabilities
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Notification (no id) — dispatch without a response.
|
|
||||||
if (!('id' in msg) || msg.id === undefined) {
|
|
||||||
if (msg.method === '$/cancel_request') {
|
|
||||||
await handleJsonRpcCancelRequest(ws, msg.params)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (msg.method === 'session/cancel') {
|
|
||||||
await handleCancel(ws)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Unknown notification — silently ignore per JSON-RPC 2.0 (notifications
|
|
||||||
// cannot be responded to).
|
|
||||||
logWs.debug({ method: msg.method }, 'ignoring unknown notification')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Request (has id) — dispatch and the handler will emit a response.
|
|
||||||
if (msg.method === '$/cancel_request') {
|
|
||||||
await handleJsonRpcCancelRequest(ws, msg.params)
|
|
||||||
// Cancellation is itself a notification-style request; respond with null.
|
|
||||||
if (state) state.pendingJsonRpc = { id: msg.id, responseType: '' }
|
|
||||||
sendJsonRpcRaw(ws, { jsonrpc: '2.0', id: msg.id, result: null })
|
|
||||||
if (state) state.pendingJsonRpc = null
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const entry = JSONRPC_METHOD_HANDLERS[msg.method]
|
|
||||||
if (!entry) {
|
|
||||||
sendJsonRpcError(
|
|
||||||
ws,
|
|
||||||
state,
|
|
||||||
msg.id,
|
|
||||||
JSONRPC_METHOD_NOT_FOUND,
|
|
||||||
`Method not found: ${msg.method}`,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Track the in-flight request so the handler's send() emits a JSON-RPC
|
|
||||||
// response with the echoed id (audit §8.2).
|
|
||||||
if (state)
|
|
||||||
state.pendingJsonRpc = { id: msg.id, responseType: entry.responseType }
|
|
||||||
try {
|
|
||||||
await entry.handle(ws, msg.params)
|
|
||||||
// If the handler did not emit the expected response (e.g. it short
|
|
||||||
// circuited with an error already), still clear the pending slot.
|
|
||||||
if (state?.pendingJsonRpc) {
|
|
||||||
sendJsonRpcRaw(ws, {
|
|
||||||
jsonrpc: '2.0',
|
|
||||||
id: msg.id,
|
|
||||||
result: {},
|
|
||||||
})
|
|
||||||
state.pendingJsonRpc = null
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
const code = (error as Error).message.startsWith('Invalid ')
|
|
||||||
? JSONRPC_INVALID_PARAMS
|
|
||||||
: JSONRPC_INTERNAL_ERROR
|
|
||||||
sendJsonRpcError(ws, state, msg.id, code, (error as Error).message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
import { Writable, Readable } from 'node:stream'
|
|
||||||
import { spawn } from 'node:child_process'
|
|
||||||
import * as acp from '@agentclientprotocol/sdk'
|
|
||||||
import type { WSContext } from 'hono/ws'
|
|
||||||
import { send, sendJsonRpcError } from './client-send.js'
|
|
||||||
import { cancelPendingPermissions, createClient } from './acp-client.js'
|
|
||||||
import { buildAgentEnv } from './permission-mode.js'
|
|
||||||
import { clients, getAgentConfig, logAgent } from './runtime-state.js'
|
|
||||||
import {
|
|
||||||
JSONRPC_INTERNAL_ERROR,
|
|
||||||
type AgentCapabilities,
|
|
||||||
type ClientState,
|
|
||||||
} from './types.js'
|
|
||||||
|
|
||||||
export async function handleConnect(ws: WSContext): Promise<void> {
|
|
||||||
const state = clients.get(ws)
|
|
||||||
if (!state) return
|
|
||||||
|
|
||||||
const {
|
|
||||||
command: AGENT_COMMAND,
|
|
||||||
args: AGENT_ARGS,
|
|
||||||
cwd: AGENT_CWD,
|
|
||||||
} = getAgentConfig()
|
|
||||||
|
|
||||||
// If already connected to a running agent, just resend status
|
|
||||||
// This handles frontend reconnections without restarting the agent process
|
|
||||||
// Check both .killed and .exitCode to detect crashed processes
|
|
||||||
if (
|
|
||||||
state.connection &&
|
|
||||||
state.process &&
|
|
||||||
!state.process.killed &&
|
|
||||||
state.process.exitCode === null
|
|
||||||
) {
|
|
||||||
logAgent.info('already connected, resending status')
|
|
||||||
send(ws, 'status', {
|
|
||||||
connected: true,
|
|
||||||
agentInfo: state.agentInfo ?? { name: AGENT_COMMAND },
|
|
||||||
capabilities: state.agentCapabilities,
|
|
||||||
protocolVersion: state.protocolVersion,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Kill existing process if any (only if not healthy)
|
|
||||||
if (state.process) {
|
|
||||||
cancelPendingPermissions(state)
|
|
||||||
state.process.kill()
|
|
||||||
state.process = null
|
|
||||||
state.connection = null
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
logAgent.info({ command: AGENT_COMMAND, args: AGENT_ARGS }, 'spawning')
|
|
||||||
|
|
||||||
const agentProcess = spawn(AGENT_COMMAND, AGENT_ARGS, {
|
|
||||||
cwd: AGENT_CWD,
|
|
||||||
stdio: ['pipe', 'pipe', 'inherit'],
|
|
||||||
env: buildAgentEnv(),
|
|
||||||
})
|
|
||||||
|
|
||||||
state.process = agentProcess
|
|
||||||
|
|
||||||
// Clean up state when agent process exits unexpectedly
|
|
||||||
agentProcess.on('exit', code => {
|
|
||||||
logAgent.info({ exitCode: code }, 'agent process exited')
|
|
||||||
// Only clear if this is still the current process
|
|
||||||
if (state.process === agentProcess) {
|
|
||||||
state.process = null
|
|
||||||
state.connection = null
|
|
||||||
state.sessionId = null
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const input = Writable.toWeb(
|
|
||||||
agentProcess.stdin!,
|
|
||||||
) as unknown as WritableStream<Uint8Array>
|
|
||||||
const output = Readable.toWeb(
|
|
||||||
agentProcess.stdout!,
|
|
||||||
) as unknown as ReadableStream<Uint8Array>
|
|
||||||
|
|
||||||
const stream = acp.ndJsonStream(input, output)
|
|
||||||
const connection = new acp.ClientSideConnection(
|
|
||||||
_agent => createClient(ws, state),
|
|
||||||
stream,
|
|
||||||
)
|
|
||||||
|
|
||||||
state.connection = connection
|
|
||||||
|
|
||||||
const initResult = await connection.initialize({
|
|
||||||
protocolVersion: acp.PROTOCOL_VERSION,
|
|
||||||
// Forward the real client identity/capabilities (audit §8.7). Falls back
|
|
||||||
// to the Zed defaults only when the client did not provide any.
|
|
||||||
clientInfo: state.clientInfo,
|
|
||||||
clientCapabilities: state.clientCapabilities,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Pass the raw agentCapabilities through unchanged so present and future
|
|
||||||
// capability fields (auth, terminal, ...) reach the client (audit §8.6).
|
|
||||||
const agentCaps = initResult.agentCapabilities
|
|
||||||
state.agentCapabilities = (agentCaps as AgentCapabilities | null) ?? null
|
|
||||||
state.promptCapabilities = agentCaps?.promptCapabilities ?? null
|
|
||||||
// Remember the negotiated protocolVersion + agentInfo so reconnects and
|
|
||||||
// JSON-RPC initialize responses can forward them to the client (§8.13).
|
|
||||||
state.protocolVersion = initResult.protocolVersion
|
|
||||||
state.agentInfo =
|
|
||||||
(initResult.agentInfo as ClientState['agentInfo'] | null | undefined) ??
|
|
||||||
null
|
|
||||||
|
|
||||||
logAgent.info(
|
|
||||||
{
|
|
||||||
protocolVersion: initResult.protocolVersion,
|
|
||||||
loadSession: !!state.agentCapabilities?.loadSession,
|
|
||||||
sessionList: !!state.agentCapabilities?.sessionCapabilities?.list,
|
|
||||||
sessionResume: !!state.agentCapabilities?.sessionCapabilities?.resume,
|
|
||||||
hasMcp: !!state.agentCapabilities?.mcpCapabilities,
|
|
||||||
},
|
|
||||||
'initialized',
|
|
||||||
)
|
|
||||||
|
|
||||||
send(ws, 'status', {
|
|
||||||
connected: true,
|
|
||||||
agentInfo: initResult.agentInfo,
|
|
||||||
capabilities: state.agentCapabilities,
|
|
||||||
// Surface the negotiated protocolVersion to downstream clients (audit §8.13).
|
|
||||||
protocolVersion: initResult.protocolVersion,
|
|
||||||
})
|
|
||||||
|
|
||||||
connection.closed.then(() => {
|
|
||||||
logAgent.info('connection closed')
|
|
||||||
state.connection = null
|
|
||||||
state.sessionId = null
|
|
||||||
send(ws, 'status', { connected: false })
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
logAgent.error({ error: (error as Error).message }, 'connect failed')
|
|
||||||
sendJsonRpcError(
|
|
||||||
ws,
|
|
||||||
state,
|
|
||||||
null,
|
|
||||||
JSONRPC_INTERNAL_ERROR,
|
|
||||||
`Failed to connect: ${(error as Error).message}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function handleDisconnect(ws: WSContext): void {
|
|
||||||
const state = clients.get(ws)
|
|
||||||
if (!state) return
|
|
||||||
|
|
||||||
if (state.process) {
|
|
||||||
state.process.kill()
|
|
||||||
state.process = null
|
|
||||||
}
|
|
||||||
state.connection = null
|
|
||||||
state.sessionId = null
|
|
||||||
|
|
||||||
send(ws, 'status', { connected: false })
|
|
||||||
}
|
|
||||||
@@ -1,435 +0,0 @@
|
|||||||
import * as acp from '@agentclientprotocol/sdk'
|
|
||||||
import type { WSContext } from 'hono/ws'
|
|
||||||
import { cancelPendingPermissions } from './acp-client.js'
|
|
||||||
import { send, sendJsonRpcError } from './client-send.js'
|
|
||||||
import { resolveNewSessionPermissionMode } from './permission-mode.js'
|
|
||||||
import {
|
|
||||||
clients,
|
|
||||||
getAgentConfig,
|
|
||||||
getDefaultPermissionMode,
|
|
||||||
logAgent,
|
|
||||||
logPrompt,
|
|
||||||
logSession,
|
|
||||||
logWs,
|
|
||||||
} from './runtime-state.js'
|
|
||||||
import {
|
|
||||||
JSONRPC_INTERNAL_ERROR,
|
|
||||||
JSONRPC_INVALID_PARAMS,
|
|
||||||
JSONRPC_INVALID_REQUEST,
|
|
||||||
JSONRPC_METHOD_NOT_FOUND,
|
|
||||||
type ContentBlock,
|
|
||||||
} from './types.js'
|
|
||||||
|
|
||||||
export async function handleNewSession(
|
|
||||||
ws: WSContext,
|
|
||||||
params: { cwd?: string; permissionMode?: string },
|
|
||||||
): Promise<void> {
|
|
||||||
const state = clients.get(ws)
|
|
||||||
if (!state?.connection) {
|
|
||||||
logAgent.warn(
|
|
||||||
{
|
|
||||||
hasState: !!state,
|
|
||||||
hasProcess: !!state?.process,
|
|
||||||
processKilled: state?.process?.killed,
|
|
||||||
exitCode: state?.process?.exitCode,
|
|
||||||
},
|
|
||||||
'handleNewSession: not connected to agent',
|
|
||||||
)
|
|
||||||
sendJsonRpcError(
|
|
||||||
ws,
|
|
||||||
state,
|
|
||||||
state?.pendingJsonRpc?.id ?? null,
|
|
||||||
JSONRPC_INVALID_REQUEST,
|
|
||||||
'Not connected to agent',
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const { cwd: AGENT_CWD } = getAgentConfig()
|
|
||||||
|
|
||||||
try {
|
|
||||||
const sessionCwd = params.cwd || AGENT_CWD
|
|
||||||
let permissionMode: string | undefined
|
|
||||||
try {
|
|
||||||
permissionMode = resolveNewSessionPermissionMode(
|
|
||||||
params.permissionMode,
|
|
||||||
getDefaultPermissionMode(),
|
|
||||||
)
|
|
||||||
} catch (error) {
|
|
||||||
sendJsonRpcError(
|
|
||||||
ws,
|
|
||||||
state,
|
|
||||||
state.pendingJsonRpc?.id ?? null,
|
|
||||||
JSONRPC_INVALID_PARAMS,
|
|
||||||
(error as Error).message,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const result = await state.connection.newSession({
|
|
||||||
cwd: sessionCwd,
|
|
||||||
mcpServers: [],
|
|
||||||
...(permissionMode ? { _meta: { permissionMode } } : {}),
|
|
||||||
})
|
|
||||||
|
|
||||||
state.sessionId = result.sessionId
|
|
||||||
state.modelState = result.models ?? null
|
|
||||||
logSession.info(
|
|
||||||
{
|
|
||||||
sessionId: result.sessionId,
|
|
||||||
cwd: sessionCwd,
|
|
||||||
hasModels: !!result.models,
|
|
||||||
},
|
|
||||||
'created',
|
|
||||||
)
|
|
||||||
|
|
||||||
send(ws, 'session_created', {
|
|
||||||
...result,
|
|
||||||
promptCapabilities: state.promptCapabilities,
|
|
||||||
models: state.modelState,
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
logSession.error({ error: (error as Error).message }, 'create failed')
|
|
||||||
sendJsonRpcError(
|
|
||||||
ws,
|
|
||||||
state,
|
|
||||||
state.pendingJsonRpc?.id ?? null,
|
|
||||||
JSONRPC_INTERNAL_ERROR,
|
|
||||||
`Failed to create session: ${(error as Error).message}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Session History Operations
|
|
||||||
// Reference: Zed's AgentConnection trait - list_sessions, load_session, resume_session
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export async function handleListSessions(
|
|
||||||
ws: WSContext,
|
|
||||||
params: { cwd?: string; cursor?: string },
|
|
||||||
): Promise<void> {
|
|
||||||
const state = clients.get(ws)
|
|
||||||
if (!state?.connection) {
|
|
||||||
logAgent.warn(
|
|
||||||
{
|
|
||||||
hasState: !!state,
|
|
||||||
hasProcess: !!state?.process,
|
|
||||||
processKilled: state?.process?.killed,
|
|
||||||
exitCode: state?.process?.exitCode,
|
|
||||||
},
|
|
||||||
'handleListSessions: not connected to agent',
|
|
||||||
)
|
|
||||||
sendJsonRpcError(
|
|
||||||
ws,
|
|
||||||
state,
|
|
||||||
state?.pendingJsonRpc?.id ?? null,
|
|
||||||
JSONRPC_INVALID_REQUEST,
|
|
||||||
'Not connected to agent',
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!state.agentCapabilities?.sessionCapabilities?.list) {
|
|
||||||
sendJsonRpcError(
|
|
||||||
ws,
|
|
||||||
state,
|
|
||||||
state.pendingJsonRpc?.id ?? null,
|
|
||||||
JSONRPC_METHOD_NOT_FOUND,
|
|
||||||
'Listing sessions is not supported by this agent',
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await state.connection.listSessions({
|
|
||||||
cwd: params.cwd,
|
|
||||||
cursor: params.cursor,
|
|
||||||
})
|
|
||||||
|
|
||||||
const MAX_SESSIONS = 20
|
|
||||||
const sessions = result.sessions.slice(0, MAX_SESSIONS)
|
|
||||||
logSession.info(
|
|
||||||
{
|
|
||||||
total: result.sessions.length,
|
|
||||||
returned: sessions.length,
|
|
||||||
hasMore: !!result.nextCursor,
|
|
||||||
},
|
|
||||||
'listed',
|
|
||||||
)
|
|
||||||
|
|
||||||
send(ws, 'session_list', {
|
|
||||||
sessions: sessions.map((s: acp.SessionInfo) => ({
|
|
||||||
_meta: s._meta,
|
|
||||||
cwd: s.cwd,
|
|
||||||
sessionId: s.sessionId,
|
|
||||||
title: s.title,
|
|
||||||
updatedAt: s.updatedAt,
|
|
||||||
})),
|
|
||||||
nextCursor: result.nextCursor,
|
|
||||||
_meta: result._meta,
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
logSession.error({ error: (error as Error).message }, 'list failed')
|
|
||||||
sendJsonRpcError(
|
|
||||||
ws,
|
|
||||||
state,
|
|
||||||
state.pendingJsonRpc?.id ?? null,
|
|
||||||
JSONRPC_INTERNAL_ERROR,
|
|
||||||
`Failed to list sessions: ${(error as Error).message}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function handleLoadSession(
|
|
||||||
ws: WSContext,
|
|
||||||
params: { sessionId: string; cwd?: string },
|
|
||||||
): Promise<void> {
|
|
||||||
const state = clients.get(ws)
|
|
||||||
if (!state?.connection) {
|
|
||||||
logAgent.warn(
|
|
||||||
{
|
|
||||||
hasState: !!state,
|
|
||||||
hasProcess: !!state?.process,
|
|
||||||
processKilled: state?.process?.killed,
|
|
||||||
exitCode: state?.process?.exitCode,
|
|
||||||
},
|
|
||||||
'handleLoadSession: not connected to agent',
|
|
||||||
)
|
|
||||||
sendJsonRpcError(
|
|
||||||
ws,
|
|
||||||
state,
|
|
||||||
state?.pendingJsonRpc?.id ?? null,
|
|
||||||
JSONRPC_INVALID_REQUEST,
|
|
||||||
'Not connected to agent',
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!state.agentCapabilities?.loadSession) {
|
|
||||||
sendJsonRpcError(
|
|
||||||
ws,
|
|
||||||
state,
|
|
||||||
state.pendingJsonRpc?.id ?? null,
|
|
||||||
JSONRPC_METHOD_NOT_FOUND,
|
|
||||||
'Loading sessions is not supported by this agent',
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const { cwd: AGENT_CWD } = getAgentConfig()
|
|
||||||
|
|
||||||
try {
|
|
||||||
const sessionCwd = params.cwd || AGENT_CWD
|
|
||||||
const sessionId = params.sessionId
|
|
||||||
const result = await state.connection.loadSession({
|
|
||||||
sessionId,
|
|
||||||
cwd: sessionCwd,
|
|
||||||
mcpServers: [],
|
|
||||||
})
|
|
||||||
|
|
||||||
state.sessionId = sessionId
|
|
||||||
state.modelState = result.models ?? null
|
|
||||||
logSession.info({ sessionId, cwd: sessionCwd }, 'loaded')
|
|
||||||
|
|
||||||
send(ws, 'session_loaded', {
|
|
||||||
sessionId,
|
|
||||||
promptCapabilities: state.promptCapabilities,
|
|
||||||
models: state.modelState,
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
logSession.error({ error: (error as Error).message }, 'load failed')
|
|
||||||
sendJsonRpcError(
|
|
||||||
ws,
|
|
||||||
state,
|
|
||||||
state.pendingJsonRpc?.id ?? null,
|
|
||||||
JSONRPC_INTERNAL_ERROR,
|
|
||||||
`Failed to load session: ${(error as Error).message}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function handleResumeSession(
|
|
||||||
ws: WSContext,
|
|
||||||
params: { sessionId: string; cwd?: string },
|
|
||||||
): Promise<void> {
|
|
||||||
const state = clients.get(ws)
|
|
||||||
if (!state?.connection) {
|
|
||||||
logAgent.warn(
|
|
||||||
{
|
|
||||||
hasState: !!state,
|
|
||||||
hasProcess: !!state?.process,
|
|
||||||
processKilled: state?.process?.killed,
|
|
||||||
exitCode: state?.process?.exitCode,
|
|
||||||
},
|
|
||||||
'handleResumeSession: not connected to agent',
|
|
||||||
)
|
|
||||||
sendJsonRpcError(
|
|
||||||
ws,
|
|
||||||
state,
|
|
||||||
state?.pendingJsonRpc?.id ?? null,
|
|
||||||
JSONRPC_INVALID_REQUEST,
|
|
||||||
'Not connected to agent',
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!state.agentCapabilities?.sessionCapabilities?.resume) {
|
|
||||||
sendJsonRpcError(
|
|
||||||
ws,
|
|
||||||
state,
|
|
||||||
state.pendingJsonRpc?.id ?? null,
|
|
||||||
JSONRPC_METHOD_NOT_FOUND,
|
|
||||||
'Resuming sessions is not supported by this agent',
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const { cwd: AGENT_CWD } = getAgentConfig()
|
|
||||||
|
|
||||||
try {
|
|
||||||
const sessionCwd = params.cwd || AGENT_CWD
|
|
||||||
const sessionId = params.sessionId
|
|
||||||
const result = await state.connection.unstable_resumeSession({
|
|
||||||
sessionId,
|
|
||||||
cwd: sessionCwd,
|
|
||||||
})
|
|
||||||
|
|
||||||
state.sessionId = sessionId
|
|
||||||
state.modelState = result.models ?? null
|
|
||||||
logSession.info({ sessionId, cwd: sessionCwd }, 'resumed')
|
|
||||||
|
|
||||||
send(ws, 'session_resumed', {
|
|
||||||
sessionId,
|
|
||||||
promptCapabilities: state.promptCapabilities,
|
|
||||||
models: state.modelState,
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
logSession.error({ error: (error as Error).message }, 'resume failed')
|
|
||||||
sendJsonRpcError(
|
|
||||||
ws,
|
|
||||||
state,
|
|
||||||
state.pendingJsonRpc?.id ?? null,
|
|
||||||
JSONRPC_INTERNAL_ERROR,
|
|
||||||
`Failed to resume session: ${(error as Error).message}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reference: Zed's AcpThread.send() forwards Vec<acp::ContentBlock> to agent
|
|
||||||
export async function handlePrompt(
|
|
||||||
ws: WSContext,
|
|
||||||
params: { content: ContentBlock[] },
|
|
||||||
): Promise<void> {
|
|
||||||
const state = clients.get(ws)
|
|
||||||
if (!state?.connection || !state.sessionId) {
|
|
||||||
sendJsonRpcError(
|
|
||||||
ws,
|
|
||||||
state,
|
|
||||||
state?.pendingJsonRpc?.id ?? null,
|
|
||||||
JSONRPC_INVALID_REQUEST,
|
|
||||||
'No active session',
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const firstText = params.content.find(b => b.type === 'text')?.text
|
|
||||||
const images = params.content.filter(b => b.type === 'image')
|
|
||||||
logPrompt.debug(
|
|
||||||
{
|
|
||||||
text: firstText?.slice(0, 100),
|
|
||||||
imageCount: images.length,
|
|
||||||
blockCount: params.content.length,
|
|
||||||
},
|
|
||||||
'sending',
|
|
||||||
)
|
|
||||||
|
|
||||||
const result = await state.connection.prompt({
|
|
||||||
sessionId: state.sessionId,
|
|
||||||
prompt: params.content as acp.ContentBlock[],
|
|
||||||
})
|
|
||||||
|
|
||||||
logPrompt.info({ stopReason: result.stopReason }, 'completed')
|
|
||||||
send(ws, 'prompt_complete', result)
|
|
||||||
} catch (error) {
|
|
||||||
logPrompt.error({ error: (error as Error).message }, 'failed')
|
|
||||||
sendJsonRpcError(
|
|
||||||
ws,
|
|
||||||
state,
|
|
||||||
state.pendingJsonRpc?.id ?? null,
|
|
||||||
JSONRPC_INTERNAL_ERROR,
|
|
||||||
`Prompt failed: ${(error as Error).message}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle cancel request from client
|
|
||||||
export async function handleCancel(ws: WSContext): Promise<void> {
|
|
||||||
const state = clients.get(ws)
|
|
||||||
if (!state?.connection || !state.sessionId) {
|
|
||||||
logWs.warn('cancel requested but no active session')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
logSession.info({ sessionId: state.sessionId }, 'cancel requested')
|
|
||||||
cancelPendingPermissions(state)
|
|
||||||
|
|
||||||
try {
|
|
||||||
await state.connection.cancel({ sessionId: state.sessionId })
|
|
||||||
logSession.info({ sessionId: state.sessionId }, 'cancel sent')
|
|
||||||
} catch (error) {
|
|
||||||
logSession.error({ error: (error as Error).message }, 'cancel failed')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reference: Zed's AgentModelSelector.select_model() calls connection.set_session_model()
|
|
||||||
export async function handleSetSessionModel(
|
|
||||||
ws: WSContext,
|
|
||||||
params: { modelId: string },
|
|
||||||
): Promise<void> {
|
|
||||||
const state = clients.get(ws)
|
|
||||||
if (!state?.connection || !state.sessionId) {
|
|
||||||
sendJsonRpcError(
|
|
||||||
ws,
|
|
||||||
state,
|
|
||||||
state?.pendingJsonRpc?.id ?? null,
|
|
||||||
JSONRPC_INVALID_REQUEST,
|
|
||||||
'No active session',
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!state.modelState) {
|
|
||||||
sendJsonRpcError(
|
|
||||||
ws,
|
|
||||||
state,
|
|
||||||
state.pendingJsonRpc?.id ?? null,
|
|
||||||
JSONRPC_METHOD_NOT_FOUND,
|
|
||||||
'Model selection not supported by this agent',
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
logSession.info(
|
|
||||||
{ sessionId: state.sessionId, modelId: params.modelId },
|
|
||||||
'setting model',
|
|
||||||
)
|
|
||||||
await state.connection.unstable_setSessionModel({
|
|
||||||
sessionId: state.sessionId,
|
|
||||||
modelId: params.modelId,
|
|
||||||
})
|
|
||||||
state.modelState = { ...state.modelState, currentModelId: params.modelId }
|
|
||||||
send(ws, 'model_changed', { modelId: params.modelId })
|
|
||||||
logSession.info({ modelId: params.modelId }, 'model changed')
|
|
||||||
} catch (error) {
|
|
||||||
logSession.error({ error: (error as Error).message }, 'set model failed')
|
|
||||||
sendJsonRpcError(
|
|
||||||
ws,
|
|
||||||
state,
|
|
||||||
state.pendingJsonRpc?.id ?? null,
|
|
||||||
JSONRPC_INTERNAL_ERROR,
|
|
||||||
`Failed to set model: ${(error as Error).message}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
import { decodeJsonWsMessage } from '../ws-message.js'
|
|
||||||
import type {
|
|
||||||
ContentBlock,
|
|
||||||
PermissionResponsePayload,
|
|
||||||
ProxyMessage,
|
|
||||||
} from './types.js'
|
|
||||||
|
|
||||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
||||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function optionalString(value: unknown): string | undefined {
|
|
||||||
return typeof value === 'string' ? value : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
export function optionalStringField(
|
|
||||||
payload: Record<string, unknown>,
|
|
||||||
key: string,
|
|
||||||
source: string,
|
|
||||||
): string | undefined {
|
|
||||||
if (!Object.hasOwn(payload, key)) return undefined
|
|
||||||
const value = payload[key]
|
|
||||||
if (typeof value === 'string') return value
|
|
||||||
throw new Error(`Invalid ${source}: expected a string`)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function payloadRecord(
|
|
||||||
value: unknown,
|
|
||||||
type: string,
|
|
||||||
): Record<string, unknown> {
|
|
||||||
if (!isRecord(value)) {
|
|
||||||
throw new Error(`Invalid ${type} payload`)
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
export function optionalPayloadRecord(
|
|
||||||
value: unknown,
|
|
||||||
type: string,
|
|
||||||
): Record<string, unknown> {
|
|
||||||
if (value === undefined) return {}
|
|
||||||
return payloadRecord(value, type)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function optionalRecord(value: unknown): Record<string, unknown> {
|
|
||||||
return isRecord(value) ? value : {}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function decodeContentBlocks(value: unknown): ContentBlock[] {
|
|
||||||
if (
|
|
||||||
!Array.isArray(value) ||
|
|
||||||
!value.every(block => isRecord(block) && typeof block.type === 'string')
|
|
||||||
) {
|
|
||||||
throw new Error('Invalid prompt payload')
|
|
||||||
}
|
|
||||||
return value as ContentBlock[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function decodePermissionResponsePayload(
|
|
||||||
value: unknown,
|
|
||||||
): PermissionResponsePayload {
|
|
||||||
const payload = payloadRecord(value, 'permission_response')
|
|
||||||
if (typeof payload.requestId !== 'string' || !isRecord(payload.outcome)) {
|
|
||||||
throw new Error('Invalid permission_response payload')
|
|
||||||
}
|
|
||||||
if (payload.outcome.outcome === 'cancelled') {
|
|
||||||
return { requestId: payload.requestId, outcome: { outcome: 'cancelled' } }
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
payload.outcome.outcome === 'selected' &&
|
|
||||||
typeof payload.outcome.optionId === 'string'
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
requestId: payload.requestId,
|
|
||||||
outcome: { outcome: 'selected', optionId: payload.outcome.optionId },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new Error('Invalid permission_response payload')
|
|
||||||
}
|
|
||||||
|
|
||||||
export function decodeClientMessage(
|
|
||||||
message: Record<string, unknown>,
|
|
||||||
): ProxyMessage {
|
|
||||||
if (typeof message.type !== 'string') {
|
|
||||||
throw new Error('Invalid WebSocket message payload')
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (message.type) {
|
|
||||||
case 'connect':
|
|
||||||
case 'disconnect':
|
|
||||||
case 'cancel':
|
|
||||||
case 'ping':
|
|
||||||
return { type: message.type }
|
|
||||||
case 'new_session': {
|
|
||||||
const payload = optionalPayloadRecord(message.payload, 'new_session')
|
|
||||||
return {
|
|
||||||
type: 'new_session',
|
|
||||||
payload: {
|
|
||||||
cwd: optionalStringField(payload, 'cwd', 'new_session.cwd'),
|
|
||||||
permissionMode: optionalStringField(
|
|
||||||
payload,
|
|
||||||
'permissionMode',
|
|
||||||
'new_session.permissionMode',
|
|
||||||
),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case 'prompt': {
|
|
||||||
const payload = payloadRecord(message.payload, 'prompt')
|
|
||||||
return {
|
|
||||||
type: 'prompt',
|
|
||||||
payload: { content: decodeContentBlocks(payload.content) },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case 'permission_response':
|
|
||||||
return {
|
|
||||||
type: 'permission_response',
|
|
||||||
payload: decodePermissionResponsePayload(message.payload),
|
|
||||||
}
|
|
||||||
case 'set_session_model': {
|
|
||||||
const payload = payloadRecord(message.payload, 'set_session_model')
|
|
||||||
if (typeof payload.modelId !== 'string') {
|
|
||||||
throw new Error('Invalid set_session_model payload')
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
type: 'set_session_model',
|
|
||||||
payload: { modelId: payload.modelId },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case 'list_sessions': {
|
|
||||||
const payload = optionalRecord(message.payload)
|
|
||||||
return {
|
|
||||||
type: 'list_sessions',
|
|
||||||
payload: {
|
|
||||||
cwd: optionalString(payload.cwd),
|
|
||||||
cursor: optionalString(payload.cursor),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case 'load_session':
|
|
||||||
case 'resume_session': {
|
|
||||||
const payload = payloadRecord(message.payload, message.type)
|
|
||||||
if (typeof payload.sessionId !== 'string') {
|
|
||||||
throw new Error(`Invalid ${message.type} payload`)
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
type: message.type,
|
|
||||||
payload: {
|
|
||||||
sessionId: payload.sessionId,
|
|
||||||
cwd: optionalString(payload.cwd),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
throw new Error(`Unknown message type: ${message.type}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function decodeClientWsMessage(data: unknown): ProxyMessage {
|
|
||||||
return decodeClientMessage(decodeJsonWsMessage(data))
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
import { getDefaultPermissionMode } from './runtime-state.js'
|
|
||||||
|
|
||||||
export const ACP_LINK_PERMISSION_MODE_ALIASES = {
|
|
||||||
auto: 'auto',
|
|
||||||
default: 'default',
|
|
||||||
acceptedits: 'acceptEdits',
|
|
||||||
dontask: 'dontAsk',
|
|
||||||
plan: 'plan',
|
|
||||||
bypasspermissions: 'bypassPermissions',
|
|
||||||
bypass: 'bypassPermissions',
|
|
||||||
} as const
|
|
||||||
|
|
||||||
export type AcpLinkPermissionMode =
|
|
||||||
(typeof ACP_LINK_PERMISSION_MODE_ALIASES)[keyof typeof ACP_LINK_PERMISSION_MODE_ALIASES]
|
|
||||||
|
|
||||||
export function resolveNewSessionPermissionMode(
|
|
||||||
requestedMode: string | undefined,
|
|
||||||
defaultMode: string | undefined,
|
|
||||||
): string | undefined {
|
|
||||||
const requested = resolveAcpLinkPermissionMode(requestedMode)
|
|
||||||
const localDefault = resolveAcpLinkPermissionMode(defaultMode)
|
|
||||||
|
|
||||||
if (!requested) {
|
|
||||||
return localDefault
|
|
||||||
}
|
|
||||||
|
|
||||||
if (requested !== 'bypassPermissions') {
|
|
||||||
return requested
|
|
||||||
}
|
|
||||||
|
|
||||||
if (localDefault === 'bypassPermissions') {
|
|
||||||
return 'bypassPermissions'
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(
|
|
||||||
'bypassPermissions requires local ACP_PERMISSION_MODE=bypassPermissions before a client can request it.',
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveAcpLinkPermissionMode(
|
|
||||||
mode: string | undefined,
|
|
||||||
): AcpLinkPermissionMode | undefined {
|
|
||||||
if (mode === undefined) return undefined
|
|
||||||
|
|
||||||
const normalized = mode?.trim().toLowerCase()
|
|
||||||
if (!normalized) {
|
|
||||||
throw new Error('Invalid permissionMode: expected a non-empty string.')
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolved =
|
|
||||||
ACP_LINK_PERMISSION_MODE_ALIASES[
|
|
||||||
normalized as keyof typeof ACP_LINK_PERMISSION_MODE_ALIASES
|
|
||||||
]
|
|
||||||
if (!resolved) {
|
|
||||||
throw new Error(`Invalid permissionMode: ${mode}.`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return resolved
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildAgentEnv(): NodeJS.ProcessEnv {
|
|
||||||
const DEFAULT_PERMISSION_MODE = getDefaultPermissionMode()
|
|
||||||
if (!DEFAULT_PERMISSION_MODE) {
|
|
||||||
return process.env
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...process.env,
|
|
||||||
ACP_PERMISSION_MODE: DEFAULT_PERMISSION_MODE,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
import type { WSContext } from 'hono/ws'
|
|
||||||
import { createLogger } from '../logger.js'
|
|
||||||
import type { RcsUpstreamClient } from '../rcs-upstream.js'
|
|
||||||
import type { ClientState } from './types.js'
|
|
||||||
|
|
||||||
// Module-level state (set when server starts)
|
|
||||||
let AGENT_COMMAND: string
|
|
||||||
let AGENT_ARGS: string[]
|
|
||||||
let AGENT_CWD: string
|
|
||||||
let SERVER_PORT: number
|
|
||||||
let SERVER_HOST: string
|
|
||||||
let AUTH_TOKEN: string | undefined
|
|
||||||
let DEFAULT_PERMISSION_MODE: string | undefined
|
|
||||||
|
|
||||||
export const clients = new Map<WSContext, ClientState>()
|
|
||||||
|
|
||||||
// Module-scoped child loggers
|
|
||||||
export const logWs = createLogger('ws')
|
|
||||||
export const logAgent = createLogger('agent')
|
|
||||||
export const logSession = createLogger('session')
|
|
||||||
export const logPrompt = createLogger('prompt')
|
|
||||||
export const logPerm = createLogger('perm')
|
|
||||||
export const logRelay = createLogger('relay')
|
|
||||||
export const logServer = createLogger('server')
|
|
||||||
|
|
||||||
// RCS upstream client (optional — enabled via ACP_RCS_URL env var)
|
|
||||||
let rcsUpstream: RcsUpstreamClient | null = null
|
|
||||||
|
|
||||||
// Permission request timeout (5 minutes)
|
|
||||||
export const PERMISSION_TIMEOUT_MS = 5 * 60 * 1000
|
|
||||||
|
|
||||||
// Heartbeat interval for WebSocket ping/pong (30 seconds)
|
|
||||||
export const HEARTBEAT_INTERVAL_MS = 30_000
|
|
||||||
|
|
||||||
export interface ServerConfigFields {
|
|
||||||
command: string
|
|
||||||
args: string[]
|
|
||||||
cwd: string
|
|
||||||
port: number
|
|
||||||
host: string
|
|
||||||
token?: string
|
|
||||||
permissionMode?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setServerConfig(fields: ServerConfigFields): void {
|
|
||||||
AGENT_COMMAND = fields.command
|
|
||||||
AGENT_ARGS = fields.args
|
|
||||||
AGENT_CWD = fields.cwd
|
|
||||||
SERVER_PORT = fields.port
|
|
||||||
SERVER_HOST = fields.host
|
|
||||||
AUTH_TOKEN = fields.token
|
|
||||||
DEFAULT_PERMISSION_MODE = fields.permissionMode
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ServerConfigSnapshot {
|
|
||||||
command: string
|
|
||||||
args: string[]
|
|
||||||
cwd: string
|
|
||||||
port: number
|
|
||||||
host: string
|
|
||||||
token?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getServerConfig(): ServerConfigSnapshot {
|
|
||||||
return {
|
|
||||||
command: AGENT_COMMAND,
|
|
||||||
args: AGENT_ARGS,
|
|
||||||
cwd: AGENT_CWD,
|
|
||||||
port: SERVER_PORT,
|
|
||||||
host: SERVER_HOST,
|
|
||||||
token: AUTH_TOKEN,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getAgentConfig(): ServerConfigSnapshot {
|
|
||||||
return getServerConfig()
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getAuthToken(): string | undefined {
|
|
||||||
return AUTH_TOKEN
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getDefaultPermissionMode(): string | undefined {
|
|
||||||
return DEFAULT_PERMISSION_MODE
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setDefaultPermissionMode(
|
|
||||||
mode: string | undefined,
|
|
||||||
): string | undefined {
|
|
||||||
const previous = DEFAULT_PERMISSION_MODE
|
|
||||||
DEFAULT_PERMISSION_MODE = mode
|
|
||||||
return previous
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getRcsUpstream(): RcsUpstreamClient | null {
|
|
||||||
return rcsUpstream
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setRcsUpstream(client: RcsUpstreamClient | null): void {
|
|
||||||
rcsUpstream = client
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a virtual WSContext for RCS relay messages.
|
|
||||||
* Responses via send() go to RCS upstream (not a local WS).
|
|
||||||
*/
|
|
||||||
export function createRelayWs(): WSContext {
|
|
||||||
return {
|
|
||||||
get readyState() {
|
|
||||||
return 1
|
|
||||||
}, // always OPEN
|
|
||||||
send: () => {}, // no-op — responses go through rcsUpstream.send()
|
|
||||||
close: () => {},
|
|
||||||
raw: null,
|
|
||||||
isInner: false,
|
|
||||||
url: '',
|
|
||||||
origin: '',
|
|
||||||
protocol: '',
|
|
||||||
} as unknown as WSContext
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate unique request ID
|
|
||||||
export function generateRequestId(): string {
|
|
||||||
return `perm_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`
|
|
||||||
}
|
|
||||||
@@ -1,291 +0,0 @@
|
|||||||
import { createServer as createHttpsServer } from 'node:https'
|
|
||||||
import { Hono } from 'hono'
|
|
||||||
import { serve } from '@hono/node-server'
|
|
||||||
import { createNodeWebSocket } from '@hono/node-ws'
|
|
||||||
import type { WebSocket as RawWebSocket } from 'ws'
|
|
||||||
import { getOrCreateCertificate, getLanIPs } from '../cert.js'
|
|
||||||
import { RcsUpstreamClient } from '../rcs-upstream.js'
|
|
||||||
import {
|
|
||||||
WsPayloadTooLargeError,
|
|
||||||
decodeJsonWsMessage,
|
|
||||||
isJsonRpc2Message,
|
|
||||||
} from '../ws-message.js'
|
|
||||||
import { authTokensEqual, extractWebSocketAuthToken } from '../ws-auth.js'
|
|
||||||
import { cancelPendingPermissions } from './acp-client.js'
|
|
||||||
import { sendJsonRpcError } from './client-send.js'
|
|
||||||
import { dispatchClientMessage, dispatchJsonRpcMessage } from './dispatch.js'
|
|
||||||
import { handleDisconnect } from './handlers-agent.js'
|
|
||||||
import { decodeClientMessage } from './payload-decode.js'
|
|
||||||
import {
|
|
||||||
HEARTBEAT_INTERVAL_MS,
|
|
||||||
clients,
|
|
||||||
createRelayWs,
|
|
||||||
getAuthToken,
|
|
||||||
getRcsUpstream,
|
|
||||||
logRelay,
|
|
||||||
logServer,
|
|
||||||
logWs,
|
|
||||||
setRcsUpstream,
|
|
||||||
setServerConfig,
|
|
||||||
} from './runtime-state.js'
|
|
||||||
import {
|
|
||||||
JSONRPC_PARSE_ERROR,
|
|
||||||
createClientState,
|
|
||||||
type ServerConfig,
|
|
||||||
} from './types.js'
|
|
||||||
|
|
||||||
export async function startServer(config: ServerConfig): Promise<void> {
|
|
||||||
const { port, host, command, args, cwd, token, https } = config
|
|
||||||
|
|
||||||
// Set module-level config
|
|
||||||
setServerConfig({
|
|
||||||
command,
|
|
||||||
args,
|
|
||||||
cwd,
|
|
||||||
port,
|
|
||||||
host,
|
|
||||||
token,
|
|
||||||
permissionMode: config.permissionMode || process.env.ACP_PERMISSION_MODE,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Initialize RCS upstream client if configured
|
|
||||||
const rcsUrl = process.env.ACP_RCS_URL
|
|
||||||
const rcsToken = process.env.ACP_RCS_TOKEN
|
|
||||||
const rcsGroup = config.group || process.env.ACP_RCS_GROUP
|
|
||||||
if (rcsGroup && !/^[a-zA-Z0-9_-]+$/.test(rcsGroup)) {
|
|
||||||
throw new Error(
|
|
||||||
`Invalid ACP_RCS_GROUP "${rcsGroup}": only letters, digits, hyphens, and underscores are allowed`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
let rcsUpstream = null
|
|
||||||
if (rcsUrl) {
|
|
||||||
rcsUpstream = new RcsUpstreamClient({
|
|
||||||
rcsUrl,
|
|
||||||
apiToken: rcsToken || '',
|
|
||||||
agentName: command,
|
|
||||||
channelGroupId: rcsGroup || undefined,
|
|
||||||
maxSessions: 1,
|
|
||||||
})
|
|
||||||
|
|
||||||
const relayWs = createRelayWs()
|
|
||||||
const relayState = createClientState()
|
|
||||||
clients.set(relayWs, relayState)
|
|
||||||
|
|
||||||
rcsUpstream.setMessageHandler(async msg => {
|
|
||||||
try {
|
|
||||||
// The RCS relay forwards messages from the Web UI. Accept both
|
|
||||||
// JSON-RPC 2.0 (audit §8.12) and the legacy `{type, payload}` envelope.
|
|
||||||
if (isJsonRpc2Message(msg)) {
|
|
||||||
logRelay.debug({ method: msg.method }, 'processing jsonrpc')
|
|
||||||
await dispatchJsonRpcMessage(relayWs, msg)
|
|
||||||
} else {
|
|
||||||
const data = decodeClientMessage(msg)
|
|
||||||
logRelay.debug({ type: data.type }, 'processing')
|
|
||||||
await dispatchClientMessage(relayWs, data)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logRelay.error({ error: (error as Error).message }, 'handler error')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
rcsUpstream.connect().catch(err => {
|
|
||||||
logRelay.warn(
|
|
||||||
{ error: (err as Error).message },
|
|
||||||
'initial connection failed',
|
|
||||||
)
|
|
||||||
})
|
|
||||||
logRelay.info({ url: rcsUrl }, 'upstream enabled')
|
|
||||||
}
|
|
||||||
// Publish rcsUpstream back to runtime-state so send() can forward.
|
|
||||||
setRcsUpstream(rcsUpstream)
|
|
||||||
|
|
||||||
const app = new Hono()
|
|
||||||
const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app })
|
|
||||||
|
|
||||||
// Health check endpoint
|
|
||||||
app.get('/health', c => {
|
|
||||||
return c.json({ status: 'ok' })
|
|
||||||
})
|
|
||||||
|
|
||||||
// WebSocket endpoint with token validation
|
|
||||||
app.get(
|
|
||||||
'/ws',
|
|
||||||
upgradeWebSocket(c => {
|
|
||||||
const AUTH_TOKEN = getAuthToken()
|
|
||||||
if (AUTH_TOKEN) {
|
|
||||||
const providedToken = extractWebSocketAuthToken({
|
|
||||||
authorization: c.req.header('Authorization'),
|
|
||||||
protocol: c.req.header('Sec-WebSocket-Protocol'),
|
|
||||||
})
|
|
||||||
if (!authTokensEqual(providedToken, AUTH_TOKEN)) {
|
|
||||||
logWs.warn('connection rejected: invalid token')
|
|
||||||
return {
|
|
||||||
onOpen(_event, ws) {
|
|
||||||
ws.close(4001, 'Unauthorized: Invalid token')
|
|
||||||
},
|
|
||||||
onMessage() {},
|
|
||||||
onClose() {},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
onOpen(_event, ws) {
|
|
||||||
logWs.info('client connected')
|
|
||||||
const state = createClientState()
|
|
||||||
clients.set(ws, state)
|
|
||||||
|
|
||||||
const rawWs = ws.raw as RawWebSocket
|
|
||||||
rawWs.on('pong', () => {
|
|
||||||
state.isAlive = true
|
|
||||||
})
|
|
||||||
},
|
|
||||||
async onMessage(event, ws) {
|
|
||||||
try {
|
|
||||||
// Decode the raw frame once. JSON-RPC 2.0 messages are routed by
|
|
||||||
// method name (audit §8.1, §8.4, §8.5); legacy `{type, payload}`
|
|
||||||
// messages keep the existing dispatch path for backwards compat.
|
|
||||||
const decoded = decodeJsonWsMessage(event.data)
|
|
||||||
if (isJsonRpc2Message(decoded)) {
|
|
||||||
logWs.debug({ method: decoded.method }, 'received jsonrpc')
|
|
||||||
await dispatchJsonRpcMessage(ws, decoded)
|
|
||||||
} else {
|
|
||||||
const data = decodeClientMessage(decoded)
|
|
||||||
logWs.debug({ type: data.type }, 'received')
|
|
||||||
await dispatchClientMessage(ws, data)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof WsPayloadTooLargeError) {
|
|
||||||
logWs.warn({ error: error.message }, 'message too large')
|
|
||||||
ws.close(1009, 'message too large')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
logWs.error({ error: (error as Error).message }, 'message error')
|
|
||||||
const state = clients.get(ws)
|
|
||||||
sendJsonRpcError(
|
|
||||||
ws,
|
|
||||||
state,
|
|
||||||
state?.pendingJsonRpc?.id ?? null,
|
|
||||||
JSONRPC_PARSE_ERROR,
|
|
||||||
`Error: ${(error as Error).message}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onClose(_event, ws) {
|
|
||||||
logWs.info('client disconnected')
|
|
||||||
const state = clients.get(ws)
|
|
||||||
if (state) {
|
|
||||||
cancelPendingPermissions(state)
|
|
||||||
}
|
|
||||||
handleDisconnect(ws)
|
|
||||||
clients.delete(ws)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Create server with optional HTTPS
|
|
||||||
let server
|
|
||||||
if (https) {
|
|
||||||
const tlsOptions = await getOrCreateCertificate()
|
|
||||||
server = serve({
|
|
||||||
fetch: app.fetch,
|
|
||||||
port,
|
|
||||||
hostname: host,
|
|
||||||
createServer: createHttpsServer,
|
|
||||||
serverOptions: tlsOptions,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
server = serve({ fetch: app.fetch, port, hostname: host })
|
|
||||||
}
|
|
||||||
injectWebSocket(server)
|
|
||||||
|
|
||||||
// Heartbeat: periodically ping all connected clients
|
|
||||||
setInterval(() => {
|
|
||||||
for (const [ws, state] of clients) {
|
|
||||||
// Skip virtual relay connections (no raw socket, always alive)
|
|
||||||
if (!ws.raw && state.isAlive) continue
|
|
||||||
if (!ws.raw) {
|
|
||||||
// Connection already closed, clean up
|
|
||||||
clients.delete(ws)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (!state.isAlive) {
|
|
||||||
logWs.info('heartbeat timeout, terminating')
|
|
||||||
;(ws.raw as RawWebSocket).terminate()
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
state.isAlive = false
|
|
||||||
;(ws.raw as RawWebSocket).ping()
|
|
||||||
}
|
|
||||||
}, HEARTBEAT_INTERVAL_MS)
|
|
||||||
|
|
||||||
// Protocol strings based on HTTPS mode
|
|
||||||
const wsProtocol = https ? 'wss' : 'ws'
|
|
||||||
|
|
||||||
// Get actual LAN IP when binding to 0.0.0.0
|
|
||||||
let displayHost = host
|
|
||||||
if (host === '0.0.0.0') {
|
|
||||||
const lanIPs = getLanIPs()
|
|
||||||
displayHost = lanIPs[0] || 'localhost'
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build URLs
|
|
||||||
const localWsUrl = `${wsProtocol}://localhost:${port}/ws`
|
|
||||||
const networkWsUrl = `${wsProtocol}://${displayHost}:${port}/ws`
|
|
||||||
|
|
||||||
// Print startup banner
|
|
||||||
console.log()
|
|
||||||
console.log(` 🚀 ACP Proxy Server${https ? ' (HTTPS)' : ''}`)
|
|
||||||
console.log()
|
|
||||||
console.log(` Connection:`)
|
|
||||||
if (host === '0.0.0.0') {
|
|
||||||
console.log(` URL: ${networkWsUrl}`)
|
|
||||||
} else {
|
|
||||||
console.log(` URL: ${localWsUrl}`)
|
|
||||||
}
|
|
||||||
if (token) {
|
|
||||||
console.log(` Token: configured`)
|
|
||||||
}
|
|
||||||
console.log()
|
|
||||||
if (!token) {
|
|
||||||
console.log(` ⚠️ Authentication disabled (--no-auth)`)
|
|
||||||
console.log()
|
|
||||||
}
|
|
||||||
|
|
||||||
const agentDisplay =
|
|
||||||
args.length > 0 ? `${command} ${args.join(' ')}` : command
|
|
||||||
console.log(` 📦 Agent: ${agentDisplay}`)
|
|
||||||
console.log(` CWD: ${cwd}`)
|
|
||||||
console.log()
|
|
||||||
console.log(` Press Ctrl+C to stop`)
|
|
||||||
console.log()
|
|
||||||
|
|
||||||
logServer.info(
|
|
||||||
{
|
|
||||||
port,
|
|
||||||
host,
|
|
||||||
https,
|
|
||||||
wsEndpoint: `${wsProtocol}://${displayHost}:${port}/ws`,
|
|
||||||
agent: command,
|
|
||||||
agentArgs: args,
|
|
||||||
cwd,
|
|
||||||
authEnabled: !!token,
|
|
||||||
},
|
|
||||||
'started',
|
|
||||||
)
|
|
||||||
|
|
||||||
// Graceful shutdown — close RCS upstream
|
|
||||||
const shutdown = async () => {
|
|
||||||
const upstream = getRcsUpstream()
|
|
||||||
if (upstream) {
|
|
||||||
await upstream.close()
|
|
||||||
}
|
|
||||||
process.exit(0)
|
|
||||||
}
|
|
||||||
process.on('SIGINT', shutdown)
|
|
||||||
process.on('SIGTERM', shutdown)
|
|
||||||
|
|
||||||
// Keep the server running
|
|
||||||
await new Promise(() => {})
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
import type { ChildProcess } from 'node:child_process'
|
|
||||||
import * as acp from '@agentclientprotocol/sdk'
|
|
||||||
import type { WSContext } from 'hono/ws'
|
|
||||||
import type { JsonRpc2ClientMessage } from '../ws-message.js'
|
|
||||||
import { dispatchClientMessage, dispatchJsonRpcMessage } from './dispatch.js'
|
|
||||||
import { clients, setDefaultPermissionMode } from './runtime-state.js'
|
|
||||||
import { createClientState, type ProxyMessage } from './types.js'
|
|
||||||
|
|
||||||
export function assertTestingInternalsEnabled(): void {
|
|
||||||
if (process.env.ACP_LINK_TEST_INTERNALS === '1') {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(
|
|
||||||
'acp-link test internals are disabled outside test execution.',
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const __testing = {
|
|
||||||
dispatchClientMessage(ws: WSContext, data: unknown): Promise<void> {
|
|
||||||
assertTestingInternalsEnabled()
|
|
||||||
return dispatchClientMessage(ws, data as ProxyMessage)
|
|
||||||
},
|
|
||||||
dispatchJsonRpcMessage(ws: WSContext, data: unknown): Promise<void> {
|
|
||||||
assertTestingInternalsEnabled()
|
|
||||||
return dispatchJsonRpcMessage(ws, data as JsonRpc2ClientMessage)
|
|
||||||
},
|
|
||||||
registerClient(
|
|
||||||
ws: WSContext,
|
|
||||||
state: {
|
|
||||||
connection?: unknown
|
|
||||||
process?: ChildProcess | null
|
|
||||||
sessionId?: string | null
|
|
||||||
clientInfo?: { name: string; version: string }
|
|
||||||
clientCapabilities?: Record<string, unknown>
|
|
||||||
jsonRpc?: boolean
|
|
||||||
},
|
|
||||||
): () => void {
|
|
||||||
assertTestingInternalsEnabled()
|
|
||||||
const full = createClientState()
|
|
||||||
full.process = state.process ?? null
|
|
||||||
full.connection = (state.connection ??
|
|
||||||
null) as acp.ClientSideConnection | null
|
|
||||||
full.sessionId = state.sessionId ?? null
|
|
||||||
if (state.clientInfo) full.clientInfo = state.clientInfo
|
|
||||||
if (state.clientCapabilities)
|
|
||||||
full.clientCapabilities = state.clientCapabilities
|
|
||||||
if (typeof state.jsonRpc === 'boolean') full.jsonRpc = state.jsonRpc
|
|
||||||
clients.set(ws, full)
|
|
||||||
return () => {
|
|
||||||
clients.delete(ws)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
getClientSessionId(ws: WSContext): string | null | undefined {
|
|
||||||
assertTestingInternalsEnabled()
|
|
||||||
return clients.get(ws)?.sessionId
|
|
||||||
},
|
|
||||||
setDefaultPermissionMode(mode: string | undefined): () => void {
|
|
||||||
assertTestingInternalsEnabled()
|
|
||||||
const previous = setDefaultPermissionMode(mode)
|
|
||||||
return () => {
|
|
||||||
setDefaultPermissionMode(previous)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
import type { ChildProcess } from 'node:child_process'
|
|
||||||
import * as acp from '@agentclientprotocol/sdk'
|
|
||||||
|
|
||||||
// JSON-RPC 2.0 reserved error codes (spec §5.1)
|
|
||||||
export const JSONRPC_PARSE_ERROR = -32700
|
|
||||||
export const JSONRPC_INVALID_REQUEST = -32600
|
|
||||||
export const JSONRPC_METHOD_NOT_FOUND = -32601
|
|
||||||
export const JSONRPC_INVALID_PARAMS = -32602
|
|
||||||
export const JSONRPC_INTERNAL_ERROR = -32603
|
|
||||||
|
|
||||||
export interface ServerConfig {
|
|
||||||
port: number
|
|
||||||
host: string
|
|
||||||
command: string
|
|
||||||
args: string[]
|
|
||||||
cwd: string
|
|
||||||
debug?: boolean
|
|
||||||
token?: string
|
|
||||||
https?: boolean
|
|
||||||
/** Default permission mode for new sessions (e.g. "auto", "default", "bypassPermissions") */
|
|
||||||
permissionMode?: string
|
|
||||||
/** Channel group ID for RCS registration */
|
|
||||||
group?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pending permission request
|
|
||||||
export interface PendingPermission {
|
|
||||||
resolve: (
|
|
||||||
outcome:
|
|
||||||
| { outcome: 'cancelled' }
|
|
||||||
| { outcome: 'selected'; optionId: string },
|
|
||||||
) => void
|
|
||||||
timeout: ReturnType<typeof setTimeout>
|
|
||||||
}
|
|
||||||
|
|
||||||
// PromptCapabilities from ACP protocol
|
|
||||||
// Reference: Zed's prompt_capabilities to check image support
|
|
||||||
export interface PromptCapabilities {
|
|
||||||
audio?: boolean
|
|
||||||
embeddedContext?: boolean
|
|
||||||
image?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
// SessionModelState from ACP protocol
|
|
||||||
// Reference: Zed's AgentModelSelector reads from state.available_models
|
|
||||||
export interface SessionModelState {
|
|
||||||
availableModels: Array<{
|
|
||||||
modelId: string
|
|
||||||
name: string
|
|
||||||
description?: string | null
|
|
||||||
}>
|
|
||||||
currentModelId: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// AgentCapabilities from ACP protocol
|
|
||||||
// Reference: Zed's AcpConnection.agent_capabilities
|
|
||||||
// Matches SDK's AgentCapabilities exactly
|
|
||||||
export interface AgentCapabilities {
|
|
||||||
_meta?: Record<string, unknown> | null
|
|
||||||
loadSession?: boolean
|
|
||||||
mcpCapabilities?: {
|
|
||||||
_meta?: Record<string, unknown> | null
|
|
||||||
clientServers?: boolean
|
|
||||||
}
|
|
||||||
promptCapabilities?: PromptCapabilities
|
|
||||||
sessionCapabilities?: {
|
|
||||||
_meta?: Record<string, unknown> | null
|
|
||||||
fork?: Record<string, unknown> | null
|
|
||||||
list?: Record<string, unknown> | null
|
|
||||||
resume?: Record<string, unknown> | null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Track connected clients and their agent connections
|
|
||||||
export interface ClientState {
|
|
||||||
process: ChildProcess | null
|
|
||||||
connection: acp.ClientSideConnection | null
|
|
||||||
sessionId: string | null
|
|
||||||
pendingPermissions: Map<string, PendingPermission>
|
|
||||||
agentCapabilities: AgentCapabilities | null
|
|
||||||
promptCapabilities: PromptCapabilities | null
|
|
||||||
modelState: SessionModelState | null
|
|
||||||
isAlive: boolean
|
|
||||||
/**
|
|
||||||
* True when this client speaks JSON-RPC 2.0 (determined from the first
|
|
||||||
* framed message). When true, responses are emitted as JSON-RPC responses
|
|
||||||
* that preserve the request `id`; otherwise the legacy `{type, payload}`
|
|
||||||
* envelope is used for backwards compatibility.
|
|
||||||
*/
|
|
||||||
jsonRpc: boolean
|
|
||||||
/**
|
|
||||||
* Client-supplied identity and capabilities, captured from the JSON-RPC
|
|
||||||
* `initialize` request or legacy `connect` payload and forwarded to the
|
|
||||||
* agent instead of the hardcoded Zed fallback. See audit §8.7.
|
|
||||||
*/
|
|
||||||
clientInfo: { name: string; version: string }
|
|
||||||
clientCapabilities: Record<string, unknown>
|
|
||||||
/** Negotiated ACP protocolVersion surfaced back to the client (audit §8.13). */
|
|
||||||
protocolVersion: number | null
|
|
||||||
/** Agent identity from InitializeResult.agentInfo (audit §8.13). */
|
|
||||||
agentInfo: { name: string; version: string; [k: string]: unknown } | null
|
|
||||||
/**
|
|
||||||
* Currently in-flight JSON-RPC request being serviced. The proxy echoes this
|
|
||||||
* id back in the JSON-RPC response (audit §8.2). At most one request is
|
|
||||||
* processed per client at a time because onMessage is awaited serially.
|
|
||||||
*/
|
|
||||||
pendingJsonRpc: {
|
|
||||||
id: string | number | null
|
|
||||||
/** Legacy response type the handler will emit via send(). */
|
|
||||||
responseType: string
|
|
||||||
} | null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default fallback client identity (used only when the client provides none)
|
|
||||||
export const DEFAULT_CLIENT_INFO = Object.freeze({
|
|
||||||
name: 'zed',
|
|
||||||
version: '1.0.0',
|
|
||||||
})
|
|
||||||
export const DEFAULT_CLIENT_CAPABILITIES = Object.freeze({
|
|
||||||
fs: { readTextFile: true, writeTextFile: true },
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a fresh ClientState with the default fallback client identity and
|
|
||||||
* capabilities. Used by every WebSocket open handler and the RCS relay.
|
|
||||||
*/
|
|
||||||
export function createClientState(): ClientState {
|
|
||||||
return {
|
|
||||||
process: null,
|
|
||||||
connection: null,
|
|
||||||
sessionId: null,
|
|
||||||
pendingPermissions: new Map(),
|
|
||||||
agentCapabilities: null,
|
|
||||||
promptCapabilities: null,
|
|
||||||
modelState: null,
|
|
||||||
isAlive: true,
|
|
||||||
jsonRpc: false,
|
|
||||||
clientInfo: { ...DEFAULT_CLIENT_INFO },
|
|
||||||
clientCapabilities: { ...DEFAULT_CLIENT_CAPABILITIES },
|
|
||||||
protocolVersion: null,
|
|
||||||
agentInfo: null,
|
|
||||||
pendingJsonRpc: null,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ContentBlock type matching @agentclientprotocol/sdk
|
|
||||||
export interface ContentBlock {
|
|
||||||
type: string
|
|
||||||
text?: string
|
|
||||||
data?: string
|
|
||||||
mimeType?: string
|
|
||||||
uri?: string
|
|
||||||
name?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PermissionResponsePayload = {
|
|
||||||
requestId: string
|
|
||||||
outcome: { outcome: 'cancelled' } | { outcome: 'selected'; optionId: string }
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ProxyMessage =
|
|
||||||
| { type: 'connect' }
|
|
||||||
| { type: 'disconnect' }
|
|
||||||
| { type: 'new_session'; payload: { cwd?: string; permissionMode?: string } }
|
|
||||||
| { type: 'prompt'; payload: { content: ContentBlock[] } }
|
|
||||||
| { type: 'permission_response'; payload: PermissionResponsePayload }
|
|
||||||
| { type: 'cancel' }
|
|
||||||
| { type: 'set_session_model'; payload: { modelId: string } }
|
|
||||||
| { type: 'list_sessions'; payload: { cwd?: string; cursor?: string } }
|
|
||||||
| { type: 'load_session'; payload: { sessionId: string; cwd?: string } }
|
|
||||||
| { type: 'resume_session'; payload: { sessionId: string; cwd?: string } }
|
|
||||||
| { type: 'ping' }
|
|
||||||
@@ -7,65 +7,12 @@ export class WsPayloadTooLargeError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Legacy proprietary envelope shape: `{ type, payload? }`.
|
|
||||||
* Retained for backwards compatibility with older clients (e.g. the RCS Web UI)
|
|
||||||
* that have not migrated to JSON-RPC 2.0 yet.
|
|
||||||
*/
|
|
||||||
export interface JsonWsMessage {
|
export interface JsonWsMessage {
|
||||||
type: string
|
type: string
|
||||||
payload?: unknown
|
payload?: unknown
|
||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* JSON-RPC 2.0 envelope as defined by the specification.
|
|
||||||
* See transports.mdx: custom transports MUST preserve the JSON-RPC message
|
|
||||||
* format and lifecycle requirements defined by ACP.
|
|
||||||
*/
|
|
||||||
export interface JsonRpc2Request {
|
|
||||||
jsonrpc: '2.0'
|
|
||||||
id: string | number | null
|
|
||||||
method: string
|
|
||||||
params?: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface JsonRpc2Notification {
|
|
||||||
jsonrpc: '2.0'
|
|
||||||
method: string
|
|
||||||
params?: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface JsonRpc2Response {
|
|
||||||
jsonrpc: '2.0'
|
|
||||||
id: string | number | null
|
|
||||||
result?: unknown
|
|
||||||
error?: { code: number; message: string; data?: unknown }
|
|
||||||
}
|
|
||||||
|
|
||||||
export type JsonRpc2Message =
|
|
||||||
| JsonRpc2Request
|
|
||||||
| JsonRpc2Notification
|
|
||||||
| JsonRpc2Response
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Messages that carry a `method` field — i.e. requests and notifications that
|
|
||||||
* the proxy can route. Responses (no method) are excluded because clients are
|
|
||||||
* not expected to send them to the agent.
|
|
||||||
*/
|
|
||||||
export type JsonRpc2ClientMessage = JsonRpc2Request | JsonRpc2Notification
|
|
||||||
|
|
||||||
export function isJsonRpc2Message(
|
|
||||||
value: unknown,
|
|
||||||
): value is JsonRpc2ClientMessage {
|
|
||||||
return (
|
|
||||||
typeof value === 'object' &&
|
|
||||||
value !== null &&
|
|
||||||
(value as { jsonrpc?: unknown }).jsonrpc === '2.0' &&
|
|
||||||
typeof (value as { method?: unknown }).method === 'string'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertPayloadSize(byteLength: number): void {
|
function assertPayloadSize(byteLength: number): void {
|
||||||
if (byteLength > MAX_CLIENT_WS_PAYLOAD_BYTES) {
|
if (byteLength > MAX_CLIENT_WS_PAYLOAD_BYTES) {
|
||||||
throw new WsPayloadTooLargeError(byteLength)
|
throw new WsPayloadTooLargeError(byteLength)
|
||||||
@@ -102,28 +49,14 @@ function decodeWsText(data: unknown): string {
|
|||||||
throw new Error('Unsupported WebSocket message payload')
|
throw new Error('Unsupported WebSocket message payload')
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Decode a WebSocket text frame into either a JSON-RPC 2.0 message or the
|
|
||||||
* legacy proprietary `{type, payload}` envelope.
|
|
||||||
*
|
|
||||||
* Accepts:
|
|
||||||
* - JSON-RPC 2.0 requests/notifications/responses (`{ jsonrpc: '2.0', method, ... }`)
|
|
||||||
* - Legacy proprietary messages (`{ type: string, payload?: unknown }`)
|
|
||||||
*
|
|
||||||
* Rejects anything else with `Invalid WebSocket message payload`.
|
|
||||||
*/
|
|
||||||
export function decodeJsonWsMessage(data: unknown): JsonWsMessage {
|
export function decodeJsonWsMessage(data: unknown): JsonWsMessage {
|
||||||
const parsed = JSON.parse(decodeWsText(data)) as unknown
|
const parsed = JSON.parse(decodeWsText(data)) as unknown
|
||||||
if (typeof parsed !== 'object' || parsed === null) {
|
if (
|
||||||
throw new Error('Invalid WebSocket message payload')
|
typeof parsed !== 'object' ||
|
||||||
}
|
parsed === null ||
|
||||||
// JSON-RPC 2.0 envelope — preserve all original fields so the router can
|
!('type' in parsed) ||
|
||||||
// correlate request ids and forward notifications unchanged.
|
typeof parsed.type !== 'string'
|
||||||
if (isJsonRpc2Message(parsed)) {
|
) {
|
||||||
return parsed as unknown as JsonWsMessage
|
|
||||||
}
|
|
||||||
// Legacy proprietary envelope `{ type, payload? }`.
|
|
||||||
if (!('type' in parsed) || typeof parsed.type !== 'string') {
|
|
||||||
throw new Error('Invalid WebSocket message payload')
|
throw new Error('Invalid WebSocket message payload')
|
||||||
}
|
}
|
||||||
return parsed as JsonWsMessage
|
return parsed as JsonWsMessage
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
} from 'src/Tool.js'
|
} from 'src/Tool.js'
|
||||||
import { lazySchema } from 'src/utils/lazySchema.js'
|
import { lazySchema } from 'src/utils/lazySchema.js'
|
||||||
import { createUserMessage } from 'src/utils/messages.js'
|
import { createUserMessage } from 'src/utils/messages.js'
|
||||||
import { formatZodValidationError } from 'src/utils/toolErrors.js'
|
|
||||||
import {
|
import {
|
||||||
extractDiscoveredToolNames,
|
extractDiscoveredToolNames,
|
||||||
isSearchExtraToolsEnabledOptimistic,
|
isSearchExtraToolsEnabledOptimistic,
|
||||||
@@ -122,42 +121,6 @@ export const ExecuteTool = buildTool({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Schema-validate params against the target tool BEFORE delegating.
|
|
||||||
// ExecuteExtraTool passes raw params straight from the model to
|
|
||||||
// validateInput/call without re-running the target's zod schema, so a
|
|
||||||
// wrong field name (e.g. 'schedule' instead of 'cron') or a missing
|
|
||||||
// required field reaches the tool as undefined and the first
|
|
||||||
// .trim()/.length/.split() crashes with "undefined is not an object".
|
|
||||||
// CronCreateTool's .trim() crash was the reported symptom; centralizing
|
|
||||||
// the check here covers every deferred tool without relying on each one
|
|
||||||
// to defensively guard its own validateInput. Duck-typed so MCP tools
|
|
||||||
// (whose schema is inputJSONSchema, not zod) skip this branch.
|
|
||||||
const targetSchema = targetTool.inputSchema as
|
|
||||||
| { safeParse?: (data: unknown) => unknown }
|
|
||||||
| undefined
|
|
||||||
if (targetSchema?.safeParse) {
|
|
||||||
const parsed = targetSchema.safeParse(input.params) as
|
|
||||||
| { success: true; data: Record<string, unknown> }
|
|
||||||
| { success: false; error: z.ZodError }
|
|
||||||
if (!parsed.success) {
|
|
||||||
return {
|
|
||||||
data: {
|
|
||||||
result: null,
|
|
||||||
tool_name: input.tool_name,
|
|
||||||
},
|
|
||||||
newMessages: [
|
|
||||||
createUserMessage({
|
|
||||||
content: formatZodValidationError(input.tool_name, parsed.error),
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Use parsed params going forward — picks up .default() values and
|
|
||||||
// strips unknown keys for strictObject schemas so validateInput/call
|
|
||||||
// never see fields they don't expect.
|
|
||||||
input.params = parsed.data
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate input before delegating — prevents crashes when the model
|
// Validate input before delegating — prevents crashes when the model
|
||||||
// omits required params (e.g. TeamCreate without team_name →
|
// omits required params (e.g. TeamCreate without team_name →
|
||||||
// sanitizeName(undefined).replace() TypeError).
|
// sanitizeName(undefined).replace() TypeError).
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { describe, test, expect } from 'bun:test'
|
import { describe, test, expect } from 'bun:test'
|
||||||
import { mock } from 'bun:test'
|
import { mock } from 'bun:test'
|
||||||
import { z } from 'zod/v4'
|
|
||||||
import { logMock } from '../../../../../../tests/mocks/log'
|
import { logMock } from '../../../../../../tests/mocks/log'
|
||||||
import { debugMock } from '../../../../../../tests/mocks/debug'
|
import { debugMock } from '../../../../../../tests/mocks/debug'
|
||||||
|
|
||||||
@@ -37,16 +36,7 @@ mock.module('src/utils/searchExtraTools.js', () => ({
|
|||||||
isSearchExtraToolsToolAvailable: () => true,
|
isSearchExtraToolsToolAvailable: () => true,
|
||||||
isSearchExtraToolsEnabled: async () => true,
|
isSearchExtraToolsEnabled: async () => true,
|
||||||
isToolReferenceBlock: () => false,
|
isToolReferenceBlock: () => false,
|
||||||
// Mark every name as discovered so tests can exercise tools other than
|
extractDiscoveredToolNames: () => new Set(['TestTool', 'SecretTool']),
|
||||||
// TestTool/SecretTool without being blocked by the discovery guard.
|
|
||||||
extractDiscoveredToolNames: () =>
|
|
||||||
new Set([
|
|
||||||
'TestTool',
|
|
||||||
'SecretTool',
|
|
||||||
'CronCreate',
|
|
||||||
'WithDefaults',
|
|
||||||
'McpTool',
|
|
||||||
]),
|
|
||||||
isDeferredToolsDeltaEnabled: () => false,
|
isDeferredToolsDeltaEnabled: () => false,
|
||||||
getDeferredToolsDelta: () => null,
|
getDeferredToolsDelta: () => null,
|
||||||
}))
|
}))
|
||||||
@@ -62,7 +52,6 @@ mock.module('src/utils/messages.js', () => ({
|
|||||||
content,
|
content,
|
||||||
uuid: 'test-uuid',
|
uuid: 'test-uuid',
|
||||||
}),
|
}),
|
||||||
INTERRUPT_MESSAGE_FOR_TOOL_USE: '[Request interrupted]',
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const { ExecuteTool } = await import('../ExecuteTool.js')
|
const { ExecuteTool } = await import('../ExecuteTool.js')
|
||||||
@@ -103,48 +92,6 @@ function makeMockTool(name: string, callResult: unknown = 'ok') {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds a mock tool with a real zod inputSchema, mirroring how actual
|
|
||||||
* deferred tools (e.g. CronCreateTool) expose their schema. Records the
|
|
||||||
* params that reach call() so tests can assert what was delegated.
|
|
||||||
*/
|
|
||||||
function makeMockToolWithSchema(
|
|
||||||
name: string,
|
|
||||||
schema: z.ZodType,
|
|
||||||
opts: {
|
|
||||||
validateInput?: (input: Record<string, unknown>) => {
|
|
||||||
result: boolean
|
|
||||||
message?: string
|
|
||||||
}
|
|
||||||
} = {},
|
|
||||||
) {
|
|
||||||
const calls: Record<string, unknown>[] = []
|
|
||||||
return {
|
|
||||||
tool: {
|
|
||||||
name,
|
|
||||||
inputSchema: schema,
|
|
||||||
call: async (input: Record<string, unknown>) => {
|
|
||||||
calls.push(input)
|
|
||||||
return { data: { ok: true, received: input } }
|
|
||||||
},
|
|
||||||
validateInput: opts.validateInput,
|
|
||||||
checkPermissions: async () => ({ behavior: 'allow' as const }),
|
|
||||||
isEnabled: () => true,
|
|
||||||
isConcurrencySafe: () => true,
|
|
||||||
isReadOnly: () => false,
|
|
||||||
isMcp: false,
|
|
||||||
userFacingName: () => name,
|
|
||||||
renderToolUseMessage: () => `Running ${name}`,
|
|
||||||
mapToolResultToToolResultBlockParam: (content: unknown, id: string) => ({
|
|
||||||
tool_use_id: id,
|
|
||||||
type: 'tool_result',
|
|
||||||
content,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
calls,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('ExecuteTool', () => {
|
describe('ExecuteTool', () => {
|
||||||
test('executes a target tool by name', async () => {
|
test('executes a target tool by name', async () => {
|
||||||
const mockTarget = makeMockTool('TestTool', { result: 'success' })
|
const mockTarget = makeMockTool('TestTool', { result: 'success' })
|
||||||
@@ -235,117 +182,4 @@ describe('ExecuteTool', () => {
|
|||||||
expect(ExecuteTool.searchHint).toContain('execute')
|
expect(ExecuteTool.searchHint).toContain('execute')
|
||||||
expect(ExecuteTool.searchHint).toContain('tool')
|
expect(ExecuteTool.searchHint).toContain('tool')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('schema-validates params against target tool before delegating', async () => {
|
|
||||||
// Reproduces the CronCreate bug class: model passes 'schedule' but the
|
|
||||||
// schema requires 'cron'. Without the pre-validation, params reach
|
|
||||||
// validateInput with cron=undefined and crash on .trim().
|
|
||||||
const { tool, calls } = makeMockToolWithSchema(
|
|
||||||
'CronCreate',
|
|
||||||
z.strictObject({
|
|
||||||
cron: z.string(),
|
|
||||||
prompt: z.string(),
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
validateInput: input => {
|
|
||||||
// Mirrors CronCreateTool.validateInput pre-fix behavior — would
|
|
||||||
// crash on undefined.trim() if schema pre-validation lets bad
|
|
||||||
// params through. The guard in ExecuteTool must prevent this.
|
|
||||||
const cron = input.cron as string | undefined
|
|
||||||
if (typeof cron !== 'string') {
|
|
||||||
throw new TypeError(
|
|
||||||
"undefined is not an object (evaluating 'cron.trim')",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return { result: true }
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
const ctx = makeContext([tool])
|
|
||||||
|
|
||||||
const result = await ExecuteTool.call(
|
|
||||||
{
|
|
||||||
tool_name: 'CronCreate',
|
|
||||||
params: { schedule: '*/5 * * * *', prompt: 'hi' },
|
|
||||||
},
|
|
||||||
ctx,
|
|
||||||
async () => ({ behavior: 'allow' }),
|
|
||||||
{ type: 'assistant', content: [], uuid: 'msg1' } as never,
|
|
||||||
undefined,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Schema validation rejects the wrong field name and returns a model-
|
|
||||||
// friendly error instead of crashing.
|
|
||||||
expect(result.data).toEqual({
|
|
||||||
result: null,
|
|
||||||
tool_name: 'CronCreate',
|
|
||||||
})
|
|
||||||
expect(result.newMessages).toBeDefined()
|
|
||||||
const message = result.newMessages![0].content as string
|
|
||||||
// Model gets told both what was missing and what was unexpected.
|
|
||||||
expect(message).toMatch(/cron/i)
|
|
||||||
expect(message).toMatch(/schedule/i)
|
|
||||||
// validateInput was never called, so no crash reached it.
|
|
||||||
expect(calls.length).toBe(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('passes through parsed params to target tool, applying schema defaults', async () => {
|
|
||||||
const { tool, calls } = makeMockToolWithSchema(
|
|
||||||
'WithDefaults',
|
|
||||||
z.strictObject({
|
|
||||||
cron: z.string(),
|
|
||||||
prompt: z.string(),
|
|
||||||
recurring: z.boolean().default(true),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
const ctx = makeContext([tool])
|
|
||||||
|
|
||||||
const result = await ExecuteTool.call(
|
|
||||||
{
|
|
||||||
// recurring intentionally omitted — schema default must fill it in.
|
|
||||||
tool_name: 'WithDefaults',
|
|
||||||
params: { cron: '*/5 * * * *', prompt: 'hi' },
|
|
||||||
},
|
|
||||||
ctx,
|
|
||||||
async () => ({ behavior: 'allow' }),
|
|
||||||
{ type: 'assistant', content: [], uuid: 'msg1' } as never,
|
|
||||||
undefined,
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(result.data).toEqual({
|
|
||||||
result: {
|
|
||||||
ok: true,
|
|
||||||
received: { cron: '*/5 * * * *', prompt: 'hi', recurring: true },
|
|
||||||
},
|
|
||||||
tool_name: 'WithDefaults',
|
|
||||||
})
|
|
||||||
expect(calls.length).toBe(1)
|
|
||||||
// .default() applied — target tool sees recurring: true without
|
|
||||||
// needing to defend against undefined itself.
|
|
||||||
expect(calls[0]).toEqual({
|
|
||||||
cron: '*/5 * * * *',
|
|
||||||
prompt: 'hi',
|
|
||||||
recurring: true,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test('skips schema validation for tools without safeParse (e.g. MCP)', async () => {
|
|
||||||
// MCP tools expose inputJSONSchema, not zod — must not crash on
|
|
||||||
// duck-typed schema check.
|
|
||||||
const mockTarget = makeMockTool('McpTool', { result: 'ok' })
|
|
||||||
const ctx = makeContext([mockTarget])
|
|
||||||
|
|
||||||
const result = await ExecuteTool.call(
|
|
||||||
{ tool_name: 'McpTool', params: { anything: true } },
|
|
||||||
ctx,
|
|
||||||
async () => ({ behavior: 'allow' }),
|
|
||||||
{ type: 'assistant', content: [], uuid: 'msg1' } as never,
|
|
||||||
undefined,
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(result.data).toEqual({
|
|
||||||
result: { result: 'ok' },
|
|
||||||
tool_name: 'McpTool',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -80,19 +80,6 @@ export const CronCreateTool = buildTool({
|
|||||||
return getCronFilePath()
|
return getCronFilePath()
|
||||||
},
|
},
|
||||||
async validateInput(input): Promise<ValidationResult> {
|
async validateInput(input): Promise<ValidationResult> {
|
||||||
// ExecuteExtraTool passes raw params through without re-running this
|
|
||||||
// tool's inputSchema, so when the model uses a wrong field name (e.g.
|
|
||||||
// 'schedule' instead of 'cron'), input.cron is undefined. parseCronExpression
|
|
||||||
// would throw on .trim(undefined); catch here with a message that tells
|
|
||||||
// the model which field is actually required.
|
|
||||||
if (typeof input.cron !== 'string' || input.cron.length === 0) {
|
|
||||||
return {
|
|
||||||
result: false,
|
|
||||||
message:
|
|
||||||
"Missing required parameter 'cron' (5-field cron expression, e.g. '*/5 * * * *'). Check parameter names against the schema.",
|
|
||||||
errorCode: 1,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!parseCronExpression(input.cron)) {
|
if (!parseCronExpression(input.cron)) {
|
||||||
return {
|
return {
|
||||||
result: false,
|
result: false,
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { formatFileSize } from 'src/utils/format.js'
|
|||||||
import { lazySchema } from 'src/utils/lazySchema.js'
|
import { lazySchema } from 'src/utils/lazySchema.js'
|
||||||
import type { PermissionDecision } from 'src/utils/permissions/PermissionResult.js'
|
import type { PermissionDecision } from 'src/utils/permissions/PermissionResult.js'
|
||||||
import { getRuleByContentsForTool } from 'src/utils/permissions/permissions.js'
|
import { getRuleByContentsForTool } from 'src/utils/permissions/permissions.js'
|
||||||
import { getSettings_DEPRECATED } from 'src/utils/settings/settings.js'
|
|
||||||
import { isPreapprovedHost } from './preapproved.js'
|
import { isPreapprovedHost } from './preapproved.js'
|
||||||
import { DESCRIPTION, WEB_FETCH_TOOL_NAME } from './prompt.js'
|
import { DESCRIPTION, WEB_FETCH_TOOL_NAME } from './prompt.js'
|
||||||
import {
|
import {
|
||||||
@@ -17,7 +16,6 @@ import {
|
|||||||
import {
|
import {
|
||||||
applyPromptToMarkdown,
|
applyPromptToMarkdown,
|
||||||
type FetchedContent,
|
type FetchedContent,
|
||||||
fetchContentWithTavily,
|
|
||||||
getURLMarkdownContent,
|
getURLMarkdownContent,
|
||||||
isPreapprovedUrl,
|
isPreapprovedUrl,
|
||||||
MAX_MARKDOWN_LENGTH,
|
MAX_MARKDOWN_LENGTH,
|
||||||
@@ -213,72 +211,6 @@ ${DESCRIPTION}`
|
|||||||
) {
|
) {
|
||||||
const start = Date.now()
|
const start = Date.now()
|
||||||
|
|
||||||
// Select backend: settings.webFetchAdapter → default 'tavily'
|
|
||||||
const settings = getSettings_DEPRECATED()
|
|
||||||
const backend = settings.webFetchAdapter ?? 'tavily'
|
|
||||||
|
|
||||||
// Tavily path: /extract returns Markdown directly — skip turndown + queryHaiku
|
|
||||||
if (backend === 'tavily') {
|
|
||||||
const response = await fetchContentWithTavily(url, abortController)
|
|
||||||
|
|
||||||
if ('type' in response && response.type === 'redirect') {
|
|
||||||
const statusText = 'See Other'
|
|
||||||
const message = `REDIRECT DETECTED: The URL redirects to a different host.
|
|
||||||
Original URL: ${(response as { originalUrl: string }).originalUrl}
|
|
||||||
Redirect URL: ${(response as { redirectUrl: string }).redirectUrl}
|
|
||||||
|
|
||||||
Please use WebFetch again with the redirect URL.`
|
|
||||||
|
|
||||||
const output: Output = {
|
|
||||||
bytes: Buffer.byteLength(message),
|
|
||||||
code: 302,
|
|
||||||
codeText: statusText,
|
|
||||||
result: message,
|
|
||||||
durationMs: Date.now() - start,
|
|
||||||
url,
|
|
||||||
}
|
|
||||||
return { data: output }
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
|
||||||
content,
|
|
||||||
bytes,
|
|
||||||
code,
|
|
||||||
codeText,
|
|
||||||
contentType,
|
|
||||||
persistedPath,
|
|
||||||
persistedSize,
|
|
||||||
} = response as FetchedContent
|
|
||||||
|
|
||||||
let result = content
|
|
||||||
if (prompt && prompt.trim()) {
|
|
||||||
// Tavily extract returns raw Markdown — if user provided a prompt,
|
|
||||||
// still run secondary model call for content processing
|
|
||||||
result = await applyPromptToMarkdown(
|
|
||||||
prompt,
|
|
||||||
content,
|
|
||||||
abortController.signal,
|
|
||||||
isNonInteractiveSession,
|
|
||||||
isPreapprovedUrl(url),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (persistedPath) {
|
|
||||||
result += `\n\n[Binary content (${contentType}, ${formatFileSize(persistedSize ?? bytes)}) also saved to ${persistedPath}]`
|
|
||||||
}
|
|
||||||
|
|
||||||
const output: Output = {
|
|
||||||
bytes,
|
|
||||||
code,
|
|
||||||
codeText,
|
|
||||||
result,
|
|
||||||
durationMs: Date.now() - start,
|
|
||||||
url,
|
|
||||||
}
|
|
||||||
return { data: output }
|
|
||||||
}
|
|
||||||
|
|
||||||
// HTTP direct path (original behavior): fetch + turndown + queryHaiku
|
|
||||||
const response = await getURLMarkdownContent(url, abortController)
|
const response = await getURLMarkdownContent(url, abortController)
|
||||||
|
|
||||||
// Check if we got a redirect to a different host
|
// Check if we got a redirect to a different host
|
||||||
|
|||||||
@@ -17,9 +17,23 @@ import { asSystemPrompt } from 'src/utils/systemPromptType.js'
|
|||||||
import { isPreapprovedHost } from './preapproved.js'
|
import { isPreapprovedHost } from './preapproved.js'
|
||||||
import { makeSecondaryModelPrompt } from './prompt.js'
|
import { makeSecondaryModelPrompt } from './prompt.js'
|
||||||
|
|
||||||
const DEFAULT_TAVILY_EXTRACT_URL = 'https://tavily.claude-code-best.win/extract'
|
// Custom error classes for domain blocking
|
||||||
|
class DomainBlockedError extends Error {
|
||||||
|
constructor(domain: string) {
|
||||||
|
super(`Claude Code is unable to fetch from ${domain}`)
|
||||||
|
this.name = 'DomainBlockedError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DomainCheckFailedError extends Error {
|
||||||
|
constructor(domain: string) {
|
||||||
|
super(
|
||||||
|
`Unable to verify if domain ${domain} is safe to fetch. This may be due to network restrictions or enterprise security policies blocking claude.ai.`,
|
||||||
|
)
|
||||||
|
this.name = 'DomainCheckFailedError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Custom error class for egress proxy blocks
|
|
||||||
class EgressBlockedError extends Error {
|
class EgressBlockedError extends Error {
|
||||||
constructor(public readonly domain: string) {
|
constructor(public readonly domain: string) {
|
||||||
super(
|
super(
|
||||||
@@ -54,8 +68,18 @@ const URL_CACHE = new LRUCache<string, CacheEntry>({
|
|||||||
ttl: CACHE_TTL_MS,
|
ttl: CACHE_TTL_MS,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Separate cache for preflight domain checks. URL_CACHE is URL-keyed, so
|
||||||
|
// fetching two paths on the same domain triggers two identical preflight
|
||||||
|
// HTTP round-trips to api.anthropic.com. This hostname-keyed cache avoids
|
||||||
|
// that. Only 'allowed' is cached — blocked/failed re-check on next attempt.
|
||||||
|
const DOMAIN_CHECK_CACHE = new LRUCache<string, true>({
|
||||||
|
max: 128,
|
||||||
|
ttl: 5 * 60 * 1000, // 5 minutes — shorter than URL_CACHE TTL
|
||||||
|
})
|
||||||
|
|
||||||
export function clearWebFetchCache(): void {
|
export function clearWebFetchCache(): void {
|
||||||
URL_CACHE.clear()
|
URL_CACHE.clear()
|
||||||
|
DOMAIN_CHECK_CACHE.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
function responseHeaderToString(value: unknown): string | undefined {
|
function responseHeaderToString(value: unknown): string | undefined {
|
||||||
@@ -117,19 +141,13 @@ const MAX_HTTP_CONTENT_LENGTH = 10 * 1024 * 1024
|
|||||||
|
|
||||||
// Timeout for the main HTTP fetch request (60 seconds).
|
// Timeout for the main HTTP fetch request (60 seconds).
|
||||||
// Prevents hanging indefinitely on slow/unresponsive servers.
|
// Prevents hanging indefinitely on slow/unresponsive servers.
|
||||||
// Overridable via settings.webFetchHttpTimeoutMs (set in /web-tools panel).
|
const FETCH_TIMEOUT_MS = 60_000
|
||||||
const DEFAULT_FETCH_TIMEOUT_MS = 60_000
|
|
||||||
|
|
||||||
function getFetchTimeoutMs(): number {
|
// Timeout for the domain blocklist preflight check (10 seconds).
|
||||||
const settings = getSettings_DEPRECATED() as Record<string, unknown> & {
|
const DOMAIN_CHECK_TIMEOUT_MS = 10_000
|
||||||
webFetchHttpTimeoutMs?: number
|
|
||||||
}
|
|
||||||
return settings.webFetchHttpTimeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cap same-host redirect hops. Without this a malicious server can return
|
// Cap same-host redirect hops. Without this a malicious server can return
|
||||||
// a redirect loop (/a → /b → /a …) and the per-request timeout
|
// a redirect loop (/a → /b → /a …) and the per-request FETCH_TIMEOUT_MS
|
||||||
// (controlled by settings.webFetchHttpTimeoutMs)
|
|
||||||
// resets on every hop, hanging the tool until user interrupt. 10 matches
|
// resets on every hop, hanging the tool until user interrupt. 10 matches
|
||||||
// common client defaults (axios=5, follow-redirects=21, Chrome=20).
|
// common client defaults (axios=5, follow-redirects=21, Chrome=20).
|
||||||
const MAX_REDIRECTS = 10
|
const MAX_REDIRECTS = 10
|
||||||
@@ -178,6 +196,40 @@ export function validateURL(url: string): boolean {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DomainCheckResult =
|
||||||
|
| { status: 'allowed' }
|
||||||
|
| { status: 'blocked' }
|
||||||
|
| { status: 'check_failed'; error: Error }
|
||||||
|
|
||||||
|
export async function checkDomainBlocklist(
|
||||||
|
domain: string,
|
||||||
|
): Promise<DomainCheckResult> {
|
||||||
|
if (DOMAIN_CHECK_CACHE.has(domain)) {
|
||||||
|
return { status: 'allowed' }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await axios.get(
|
||||||
|
`https://api.anthropic.com/api/web/domain_info?domain=${encodeURIComponent(domain)}`,
|
||||||
|
{ timeout: DOMAIN_CHECK_TIMEOUT_MS },
|
||||||
|
)
|
||||||
|
if (response.status === 200) {
|
||||||
|
if (response.data.can_fetch === true) {
|
||||||
|
DOMAIN_CHECK_CACHE.set(domain, true)
|
||||||
|
return { status: 'allowed' }
|
||||||
|
}
|
||||||
|
return { status: 'blocked' }
|
||||||
|
}
|
||||||
|
// Non-200 status but didn't throw
|
||||||
|
return {
|
||||||
|
status: 'check_failed',
|
||||||
|
error: new Error(`Domain check returned status ${response.status}`),
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logError(e)
|
||||||
|
return { status: 'check_failed', error: e as Error }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a redirect is safe to follow
|
* Check if a redirect is safe to follow
|
||||||
* Allows redirects that:
|
* Allows redirects that:
|
||||||
@@ -247,7 +299,7 @@ export async function getWithPermittedRedirects(
|
|||||||
try {
|
try {
|
||||||
return await axios.get(url, {
|
return await axios.get(url, {
|
||||||
signal,
|
signal,
|
||||||
timeout: getFetchTimeoutMs(),
|
timeout: FETCH_TIMEOUT_MS,
|
||||||
maxRedirects: 0,
|
maxRedirects: 0,
|
||||||
responseType: 'arraybuffer',
|
responseType: 'arraybuffer',
|
||||||
maxContentLength: MAX_HTTP_CONTENT_LENGTH,
|
maxContentLength: MAX_HTTP_CONTENT_LENGTH,
|
||||||
@@ -360,6 +412,23 @@ export async function getURLMarkdownContent(
|
|||||||
|
|
||||||
const hostname = parsedUrl.hostname
|
const hostname = parsedUrl.hostname
|
||||||
|
|
||||||
|
// Check if the user has opted to skip the blocklist check
|
||||||
|
// This is for enterprise customers with restrictive security policies
|
||||||
|
// that prevent outbound connections to claude.ai
|
||||||
|
const settings = getSettings_DEPRECATED()
|
||||||
|
if (settings.skipWebFetchPreflight === false) {
|
||||||
|
const checkResult = await checkDomainBlocklist(hostname)
|
||||||
|
switch (checkResult.status) {
|
||||||
|
case 'allowed':
|
||||||
|
// Continue with the fetch
|
||||||
|
break
|
||||||
|
case 'blocked':
|
||||||
|
throw new DomainBlockedError(hostname)
|
||||||
|
case 'check_failed':
|
||||||
|
throw new DomainCheckFailedError(hostname)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (process.env.USER_TYPE === 'ant') {
|
if (process.env.USER_TYPE === 'ant') {
|
||||||
logEvent('tengu_web_fetch_host', {
|
logEvent('tengu_web_fetch_host', {
|
||||||
hostname:
|
hostname:
|
||||||
@@ -367,6 +436,13 @@ export async function getURLMarkdownContent(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (
|
||||||
|
e instanceof DomainBlockedError ||
|
||||||
|
e instanceof DomainCheckFailedError
|
||||||
|
) {
|
||||||
|
// Expected user-facing failures - re-throw without logging as internal error
|
||||||
|
throw e
|
||||||
|
}
|
||||||
logError(e)
|
logError(e)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -437,109 +513,6 @@ export async function getURLMarkdownContent(
|
|||||||
return entry
|
return entry
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch URL content via Tavily Extract API, which directly returns Markdown.
|
|
||||||
* This skips the HTML→Markdown conversion (turndown) and the secondary
|
|
||||||
* model call (queryHaiku) — Tavily already delivers clean Markdown.
|
|
||||||
*/
|
|
||||||
export async function fetchContentWithTavily(
|
|
||||||
url: string,
|
|
||||||
abortController: AbortController,
|
|
||||||
): Promise<FetchedContent | RedirectInfo> {
|
|
||||||
if (!validateURL(url)) {
|
|
||||||
throw new Error('Invalid URL')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check cache (LRUCache handles TTL automatically)
|
|
||||||
const cachedEntry = URL_CACHE.get(url)
|
|
||||||
if (cachedEntry) {
|
|
||||||
return {
|
|
||||||
bytes: cachedEntry.bytes,
|
|
||||||
code: cachedEntry.code,
|
|
||||||
codeText: cachedEntry.codeText,
|
|
||||||
content: cachedEntry.content,
|
|
||||||
contentType: cachedEntry.contentType,
|
|
||||||
persistedPath: cachedEntry.persistedPath,
|
|
||||||
persistedSize: cachedEntry.persistedSize,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let parsedUrl: URL
|
|
||||||
try {
|
|
||||||
parsedUrl = new URL(url)
|
|
||||||
} catch {
|
|
||||||
throw new Error('Invalid URL')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Upgrade http to https if needed
|
|
||||||
if (parsedUrl.protocol === 'http:') {
|
|
||||||
parsedUrl.protocol = 'https:'
|
|
||||||
url = parsedUrl.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
const abortSignal = abortController.signal
|
|
||||||
|
|
||||||
const settings = getSettings_DEPRECATED() as Record<string, unknown> & {
|
|
||||||
tavilyEndpointUrl?: string
|
|
||||||
}
|
|
||||||
const baseUrl = settings.tavilyEndpointUrl || DEFAULT_TAVILY_EXTRACT_URL
|
|
||||||
// Derive extract URL from the base Tavily endpoint
|
|
||||||
const extractUrl = baseUrl.endsWith('/search')
|
|
||||||
? baseUrl.replace(/\/search$/, '/extract')
|
|
||||||
: baseUrl.endsWith('/extract')
|
|
||||||
? baseUrl
|
|
||||||
: `${baseUrl.replace(/\/$/, '')}/extract`
|
|
||||||
|
|
||||||
const response = await axios.post<{ url: string; raw_content: string }>(
|
|
||||||
extractUrl,
|
|
||||||
{
|
|
||||||
urls: [url],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
signal: abortSignal,
|
|
||||||
timeout: getFetchTimeoutMs(),
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
if (abortSignal.aborted) {
|
|
||||||
throw new AbortError()
|
|
||||||
}
|
|
||||||
|
|
||||||
const rawContent = response.data?.raw_content ?? ''
|
|
||||||
// If raw_content is a JSON string (extract may return {url:..., raw_content:...}
|
|
||||||
// per URL), unwrap it.
|
|
||||||
let markdownContent = rawContent
|
|
||||||
if (!markdownContent.trim()) {
|
|
||||||
// Try to extract from results array
|
|
||||||
const resp = response.data as unknown as {
|
|
||||||
results?: Array<{ raw_content?: string }>
|
|
||||||
}
|
|
||||||
const results = resp.results ?? []
|
|
||||||
if (results.length > 0 && results[0].raw_content) {
|
|
||||||
markdownContent = results[0].raw_content
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!markdownContent.trim()) {
|
|
||||||
throw new Error(
|
|
||||||
`Tavily Extract returned empty content for ${url}. The page may require authentication or JavaScript rendering.`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentBytes = Buffer.byteLength(markdownContent)
|
|
||||||
|
|
||||||
const entry: CacheEntry = {
|
|
||||||
bytes: contentBytes,
|
|
||||||
code: 200,
|
|
||||||
codeText: 'OK',
|
|
||||||
content: markdownContent,
|
|
||||||
contentType: 'text/markdown',
|
|
||||||
}
|
|
||||||
URL_CACHE.set(url, entry, { size: Math.max(1, contentBytes) })
|
|
||||||
return entry
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function applyPromptToMarkdown(
|
export async function applyPromptToMarkdown(
|
||||||
prompt: string,
|
prompt: string,
|
||||||
markdownContent: string,
|
markdownContent: string,
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
import { afterEach, describe, expect, test } from 'bun:test'
|
import { afterEach, describe, expect, mock, test } from 'bun:test'
|
||||||
|
|
||||||
let mockSettingsWebSearchAdapter: string | undefined
|
let isFirstPartyBaseUrl = true
|
||||||
|
|
||||||
// Mock settings to avoid depending on the on-disk settings.json file.
|
// Only mock the external dependency that controls adapter selection
|
||||||
// Other tests running in the same process may have persisted adapter choices.
|
mock.module('src/utils/model/providers.js', () => ({
|
||||||
let { getSettings_DEPRECATED } = await import('src/utils/settings/settings.js')
|
isFirstPartyAnthropicBaseUrl: () => isFirstPartyBaseUrl,
|
||||||
const realGetSettings = getSettings_DEPRECATED
|
getAPIProvider: () => 'firstParty',
|
||||||
|
getAPIProviderForStatsig: () => 'firstParty',
|
||||||
|
}))
|
||||||
|
|
||||||
// We can't mock getSettings_DEPRECATED directly without mocking the whole module,
|
const { createAdapter } = await import('../adapters/index')
|
||||||
// so we test using WEB_SEARCH_ADAPTER env var which takes priority anyway.
|
|
||||||
// This test focuses on the env-driven selection which is the primary path.
|
|
||||||
|
|
||||||
let { createAdapter } = await import('../adapters/index')
|
|
||||||
|
|
||||||
const originalWebSearchAdapter = process.env.WEB_SEARCH_ADAPTER
|
const originalWebSearchAdapter = process.env.WEB_SEARCH_ADAPTER
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
isFirstPartyBaseUrl = true
|
||||||
|
|
||||||
if (originalWebSearchAdapter === undefined) {
|
if (originalWebSearchAdapter === undefined) {
|
||||||
delete process.env.WEB_SEARCH_ADAPTER
|
delete process.env.WEB_SEARCH_ADAPTER
|
||||||
} else {
|
} else {
|
||||||
@@ -24,23 +24,6 @@ afterEach(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('createAdapter', () => {
|
describe('createAdapter', () => {
|
||||||
test('prioritizes WEB_SEARCH_ADAPTER env var over all other config', () => {
|
|
||||||
process.env.WEB_SEARCH_ADAPTER = 'api'
|
|
||||||
expect(createAdapter().constructor.name).toBe('ApiSearchAdapter')
|
|
||||||
|
|
||||||
process.env.WEB_SEARCH_ADAPTER = 'bing'
|
|
||||||
expect(createAdapter().constructor.name).toBe('BingSearchAdapter')
|
|
||||||
|
|
||||||
process.env.WEB_SEARCH_ADAPTER = 'brave'
|
|
||||||
expect(createAdapter().constructor.name).toBe('BraveSearchAdapter')
|
|
||||||
|
|
||||||
process.env.WEB_SEARCH_ADAPTER = 'exa'
|
|
||||||
expect(createAdapter().constructor.name).toBe('ExaSearchAdapter')
|
|
||||||
|
|
||||||
process.env.WEB_SEARCH_ADAPTER = 'tavily'
|
|
||||||
expect(createAdapter().constructor.name).toBe('TavilySearchAdapter')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('reuses the same instance when the selected backend does not change', () => {
|
test('reuses the same instance when the selected backend does not change', () => {
|
||||||
process.env.WEB_SEARCH_ADAPTER = 'brave'
|
process.env.WEB_SEARCH_ADAPTER = 'brave'
|
||||||
|
|
||||||
@@ -48,6 +31,7 @@ describe('createAdapter', () => {
|
|||||||
const secondAdapter = createAdapter()
|
const secondAdapter = createAdapter()
|
||||||
|
|
||||||
expect(firstAdapter).toBe(secondAdapter)
|
expect(firstAdapter).toBe(secondAdapter)
|
||||||
|
expect(firstAdapter.constructor.name).toBe('BraveSearchAdapter')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('rebuilds the adapter when WEB_SEARCH_ADAPTER changes', () => {
|
test('rebuilds the adapter when WEB_SEARCH_ADAPTER changes', () => {
|
||||||
@@ -58,21 +42,20 @@ describe('createAdapter', () => {
|
|||||||
const bingAdapter = createAdapter()
|
const bingAdapter = createAdapter()
|
||||||
|
|
||||||
expect(bingAdapter).not.toBe(braveAdapter)
|
expect(bingAdapter).not.toBe(braveAdapter)
|
||||||
|
expect(bingAdapter.constructor.name).toBe('BingSearchAdapter')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('defaults to Tavily when no env var is set', () => {
|
test('selects the API adapter for first-party Anthropic URLs', () => {
|
||||||
delete process.env.WEB_SEARCH_ADAPTER
|
delete process.env.WEB_SEARCH_ADAPTER
|
||||||
|
isFirstPartyBaseUrl = true
|
||||||
|
|
||||||
const adapter = createAdapter()
|
expect(createAdapter().constructor.name).toBe('ApiSearchAdapter')
|
||||||
// The actual adapter may vary if settings.webSearchAdapter is set on disk.
|
})
|
||||||
// But we only assert it's one of the valid adapter types.
|
|
||||||
const validTypes = [
|
test('selects the Exa adapter for third-party Anthropic base URLs', () => {
|
||||||
'ApiSearchAdapter',
|
delete process.env.WEB_SEARCH_ADAPTER
|
||||||
'BingSearchAdapter',
|
isFirstPartyBaseUrl = false
|
||||||
'BraveSearchAdapter',
|
|
||||||
'ExaSearchAdapter',
|
expect(createAdapter().constructor.name).toBe('ExaSearchAdapter')
|
||||||
'TavilySearchAdapter',
|
|
||||||
]
|
|
||||||
expect(validTypes).toContain(adapter.constructor.name)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
|
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import { AbortError } from 'src/utils/errors.js'
|
import { AbortError } from 'src/utils/errors.js'
|
||||||
import { getSettings_DEPRECATED } from 'src/utils/settings/settings.js'
|
|
||||||
import type { SearchResult, SearchOptions, WebSearchAdapter } from './types.js'
|
import type { SearchResult, SearchOptions, WebSearchAdapter } from './types.js'
|
||||||
|
|
||||||
const FETCH_TIMEOUT_MS = 30_000
|
const FETCH_TIMEOUT_MS = 30_000
|
||||||
@@ -157,14 +156,6 @@ function normalizeSnippet(snippets: string[] | undefined): string | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getBraveApiKey(): string {
|
function getBraveApiKey(): string {
|
||||||
// Priority: settings.braveApiKey (from /web-tools panel) > environment variable
|
|
||||||
const settings = getSettings_DEPRECATED() as Record<string, unknown> & {
|
|
||||||
braveApiKey?: string
|
|
||||||
}
|
|
||||||
if (settings.braveApiKey?.trim()) {
|
|
||||||
return settings.braveApiKey.trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const envVar of BRAVE_API_KEY_ENV_VARS) {
|
for (const envVar of BRAVE_API_KEY_ENV_VARS) {
|
||||||
const value = process.env[envVar]?.trim()
|
const value = process.env[envVar]?.trim()
|
||||||
if (value) {
|
if (value) {
|
||||||
|
|||||||
@@ -10,10 +10,9 @@
|
|||||||
|
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import { AbortError } from 'src/utils/errors.js'
|
import { AbortError } from 'src/utils/errors.js'
|
||||||
import { getSettings_DEPRECATED } from 'src/utils/settings/settings.js'
|
|
||||||
import type { SearchResult, SearchOptions, WebSearchAdapter } from './types.js'
|
import type { SearchResult, SearchOptions, WebSearchAdapter } from './types.js'
|
||||||
|
|
||||||
const DEFAULT_EXA_MCP_URL = 'https://mcp.exa.ai/mcp'
|
const EXA_MCP_URL = 'https://mcp.exa.ai/mcp'
|
||||||
const FETCH_TIMEOUT_MS = 25_000
|
const FETCH_TIMEOUT_MS = 25_000
|
||||||
|
|
||||||
export class ExaSearchAdapter implements WebSearchAdapter {
|
export class ExaSearchAdapter implements WebSearchAdapter {
|
||||||
@@ -39,24 +38,10 @@ export class ExaSearchAdapter implements WebSearchAdapter {
|
|||||||
const searchType = options.searchType ?? 'auto'
|
const searchType = options.searchType ?? 'auto'
|
||||||
const contextMaxCharacters = options.contextMaxCharacters ?? 10000
|
const contextMaxCharacters = options.contextMaxCharacters ?? 10000
|
||||||
|
|
||||||
// Read settings for custom endpoint / API key
|
|
||||||
const settings = getSettings_DEPRECATED() as Record<string, unknown> & {
|
|
||||||
exaEndpointUrl?: string
|
|
||||||
exaApiKey?: string
|
|
||||||
}
|
|
||||||
const exaUrl = settings.exaEndpointUrl || DEFAULT_EXA_MCP_URL
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Accept: 'application/json, text/event-stream',
|
|
||||||
}
|
|
||||||
if (settings.exaApiKey) {
|
|
||||||
headers['Authorization'] = `Bearer ${settings.exaApiKey}`
|
|
||||||
}
|
|
||||||
|
|
||||||
let responseText: string
|
let responseText: string
|
||||||
try {
|
try {
|
||||||
const response = await axios.post(
|
const response = await axios.post(
|
||||||
exaUrl,
|
EXA_MCP_URL,
|
||||||
{
|
{
|
||||||
jsonrpc: '2.0',
|
jsonrpc: '2.0',
|
||||||
id: 1,
|
id: 1,
|
||||||
@@ -75,7 +60,10 @@ export class ExaSearchAdapter implements WebSearchAdapter {
|
|||||||
{
|
{
|
||||||
signal: abortController.signal,
|
signal: abortController.signal,
|
||||||
timeout: FETCH_TIMEOUT_MS,
|
timeout: FETCH_TIMEOUT_MS,
|
||||||
headers,
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json, text/event-stream',
|
||||||
|
},
|
||||||
responseType: 'text',
|
responseType: 'text',
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,18 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* Search adapter factory — selects the appropriate backend.
|
* Search adapter factory — selects the appropriate backend by checking
|
||||||
*
|
* whether the API base URL points to Anthropic's official endpoint.
|
||||||
* Priority (highest first):
|
|
||||||
* 1. WEB_SEARCH_ADAPTER environment variable (explicit override)
|
|
||||||
* 2. settings.webSearchAdapter (user-configurable via /web-tools)
|
|
||||||
* 3. Default: tavily
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { getSettings_DEPRECATED } from 'src/utils/settings/settings.js'
|
import { isFirstPartyAnthropicBaseUrl } from 'src/utils/model/providers.js'
|
||||||
import { ApiSearchAdapter } from './apiAdapter.js'
|
import { ApiSearchAdapter } from './apiAdapter.js'
|
||||||
import { BingSearchAdapter } from './bingAdapter.js'
|
import { BingSearchAdapter } from './bingAdapter.js'
|
||||||
import { BraveSearchAdapter } from './braveAdapter.js'
|
import { BraveSearchAdapter } from './braveAdapter.js'
|
||||||
import { ExaSearchAdapter } from './exaAdapter.js'
|
import { ExaSearchAdapter } from './exaAdapter.js'
|
||||||
import { TavilySearchAdapter } from './tavilyAdapter.js'
|
|
||||||
import type { WebSearchAdapter } from './types.js'
|
import type { WebSearchAdapter } from './types.js'
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
@@ -22,53 +17,60 @@ export type {
|
|||||||
WebSearchAdapter,
|
WebSearchAdapter,
|
||||||
} from './types.js'
|
} from './types.js'
|
||||||
|
|
||||||
export type SearchAdapterKey = 'api' | 'bing' | 'brave' | 'exa' | 'tavily'
|
/**
|
||||||
|
* Check if the current session uses a third-party (non-Anthropic) API provider.
|
||||||
|
* These providers don't support Anthropic's server_tools (server-side web search),
|
||||||
|
* so they must fall back to the Bing scraper adapter.
|
||||||
|
*/
|
||||||
|
function isThirdPartyProvider(): boolean {
|
||||||
|
return !!(
|
||||||
|
process.env.CLAUDE_CODE_USE_OPENAI ||
|
||||||
|
process.env.CLAUDE_CODE_USE_GEMINI ||
|
||||||
|
process.env.CLAUDE_CODE_USE_GROK
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
let cachedAdapter: WebSearchAdapter | null = null
|
let cachedAdapter: WebSearchAdapter | null = null
|
||||||
let cachedAdapterKey: SearchAdapterKey | null = null
|
let cachedAdapterKey: 'api' | 'bing' | 'brave' | 'exa' | null = null
|
||||||
|
|
||||||
export function createAdapter(): WebSearchAdapter {
|
export function createAdapter(): WebSearchAdapter {
|
||||||
// 1. Explicit env override
|
|
||||||
const envAdapter = process.env.WEB_SEARCH_ADAPTER
|
const envAdapter = process.env.WEB_SEARCH_ADAPTER
|
||||||
// 2. Settings preference (set via /web-tools panel)
|
// Priority:
|
||||||
const settingsAdapter = getSettings_DEPRECATED().webSearchAdapter
|
// 1. Explicit env override (WEB_SEARCH_ADAPTER=api|bing|brave)
|
||||||
|
// 2. Third-party provider (OpenAI/Gemini/Grok) → bing (no server_tools support)
|
||||||
const adapterKey: SearchAdapterKey =
|
// 3. First-party Anthropic API → api (server-side web search + connector_text)
|
||||||
|
// 4. Fallback → bing
|
||||||
|
const adapterKey =
|
||||||
envAdapter === 'api' ||
|
envAdapter === 'api' ||
|
||||||
envAdapter === 'bing' ||
|
envAdapter === 'bing' ||
|
||||||
envAdapter === 'brave' ||
|
envAdapter === 'brave' ||
|
||||||
envAdapter === 'exa' ||
|
envAdapter === 'exa'
|
||||||
envAdapter === 'tavily'
|
|
||||||
? envAdapter
|
? envAdapter
|
||||||
: settingsAdapter === 'api' ||
|
: isThirdPartyProvider()
|
||||||
settingsAdapter === 'bing' ||
|
? 'bing'
|
||||||
settingsAdapter === 'brave' ||
|
: isFirstPartyAnthropicBaseUrl()
|
||||||
settingsAdapter === 'exa' ||
|
? 'api'
|
||||||
settingsAdapter === 'tavily'
|
: 'exa'
|
||||||
? settingsAdapter
|
|
||||||
: 'tavily' // 3. Default
|
|
||||||
|
|
||||||
if (cachedAdapter && cachedAdapterKey === adapterKey) return cachedAdapter
|
if (cachedAdapter && cachedAdapterKey === adapterKey) return cachedAdapter
|
||||||
|
|
||||||
switch (adapterKey) {
|
if (adapterKey === 'api') {
|
||||||
case 'api':
|
|
||||||
cachedAdapter = new ApiSearchAdapter()
|
cachedAdapter = new ApiSearchAdapter()
|
||||||
break
|
cachedAdapterKey = 'api'
|
||||||
case 'bing':
|
return cachedAdapter
|
||||||
cachedAdapter = new BingSearchAdapter()
|
}
|
||||||
break
|
if (adapterKey === 'brave') {
|
||||||
case 'brave':
|
|
||||||
cachedAdapter = new BraveSearchAdapter()
|
cachedAdapter = new BraveSearchAdapter()
|
||||||
break
|
cachedAdapterKey = 'brave'
|
||||||
case 'exa':
|
return cachedAdapter
|
||||||
|
}
|
||||||
|
if (adapterKey === 'exa') {
|
||||||
cachedAdapter = new ExaSearchAdapter()
|
cachedAdapter = new ExaSearchAdapter()
|
||||||
break
|
cachedAdapterKey = 'exa'
|
||||||
case 'tavily':
|
return cachedAdapter
|
||||||
default:
|
|
||||||
cachedAdapter = new TavilySearchAdapter()
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cachedAdapterKey = adapterKey
|
cachedAdapter = new BingSearchAdapter()
|
||||||
|
cachedAdapterKey = 'bing'
|
||||||
return cachedAdapter
|
return cachedAdapter
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,98 +0,0 @@
|
|||||||
/**
|
|
||||||
* Tavily-based search adapter — calls the Tavily Search API
|
|
||||||
* (https://tavily.claude-code-best.win) and maps results to
|
|
||||||
* the unified SearchResult format.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import axios from 'axios'
|
|
||||||
import { AbortError } from 'src/utils/errors.js'
|
|
||||||
import { getSettings_DEPRECATED } from 'src/utils/settings/settings.js'
|
|
||||||
import type { SearchResult, SearchOptions, WebSearchAdapter } from './types.js'
|
|
||||||
|
|
||||||
const DEFAULT_TAVILY_SEARCH_URL = 'https://tavily.claude-code-best.win/search'
|
|
||||||
const FETCH_TIMEOUT_MS = 30_000
|
|
||||||
|
|
||||||
interface TavilySearchHit {
|
|
||||||
title: string
|
|
||||||
url: string
|
|
||||||
content: string
|
|
||||||
score: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TavilySearchResponse {
|
|
||||||
results: TavilySearchHit[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export class TavilySearchAdapter implements WebSearchAdapter {
|
|
||||||
async search(query: string, options: SearchOptions): Promise<SearchResult[]> {
|
|
||||||
const { signal, onProgress, allowedDomains, blockedDomains } = options
|
|
||||||
|
|
||||||
if (signal?.aborted) {
|
|
||||||
throw new AbortError()
|
|
||||||
}
|
|
||||||
|
|
||||||
onProgress?.({ type: 'query_update', query })
|
|
||||||
|
|
||||||
const abortController = new AbortController()
|
|
||||||
if (signal) {
|
|
||||||
signal.addEventListener('abort', () => abortController.abort(), {
|
|
||||||
once: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const settings = getSettings_DEPRECATED() as Record<string, unknown> & {
|
|
||||||
tavilyEndpointUrl?: string
|
|
||||||
}
|
|
||||||
const baseUrl = settings.tavilyEndpointUrl || DEFAULT_TAVILY_SEARCH_URL
|
|
||||||
// Ensure the URL ends with /search (same pattern as fetchContentWithTavily for /extract)
|
|
||||||
const searchUrl = baseUrl.endsWith('/search')
|
|
||||||
? baseUrl
|
|
||||||
: `${baseUrl.replace(/\/$/, '')}/search`
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await axios.post<{
|
|
||||||
query: string
|
|
||||||
results: TavilySearchHit[]
|
|
||||||
}>(
|
|
||||||
searchUrl,
|
|
||||||
{
|
|
||||||
query,
|
|
||||||
search_depth: 'basic',
|
|
||||||
max_results: options.numResults ?? 8,
|
|
||||||
include_domains: allowedDomains ?? [],
|
|
||||||
exclude_domains: blockedDomains ?? [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
signal: abortController.signal,
|
|
||||||
timeout: FETCH_TIMEOUT_MS,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
if (abortController.signal.aborted) {
|
|
||||||
throw new AbortError()
|
|
||||||
}
|
|
||||||
|
|
||||||
const results: SearchResult[] = (response.data.results ?? []).map(
|
|
||||||
(hit: TavilySearchHit) => ({
|
|
||||||
title: hit.title,
|
|
||||||
url: hit.url,
|
|
||||||
snippet: hit.content,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
onProgress?.({
|
|
||||||
type: 'search_results_received',
|
|
||||||
resultCount: results.length,
|
|
||||||
query,
|
|
||||||
})
|
|
||||||
|
|
||||||
return results
|
|
||||||
} catch (e) {
|
|
||||||
if (axios.isCancel(e) || abortController.signal.aborted) {
|
|
||||||
throw new AbortError()
|
|
||||||
}
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -60,7 +60,6 @@ import terminalSetup from './commands/terminalSetup/index.js'
|
|||||||
import usage from './commands/usage/index.js'
|
import usage from './commands/usage/index.js'
|
||||||
import theme from './commands/theme/index.js'
|
import theme from './commands/theme/index.js'
|
||||||
import vim from './commands/vim/index.js'
|
import vim from './commands/vim/index.js'
|
||||||
import webTools from './commands/web-tools/index.js'
|
|
||||||
import { feature } from 'bun:bundle'
|
import { feature } from 'bun:bundle'
|
||||||
// Dead code elimination: conditional imports
|
// Dead code elimination: conditional imports
|
||||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||||
@@ -364,7 +363,6 @@ const COMMANDS = memoize((): Command[] => [
|
|||||||
usage,
|
usage,
|
||||||
usageReport,
|
usageReport,
|
||||||
vim,
|
vim,
|
||||||
webTools,
|
|
||||||
...(webCmd ? [webCmd] : []),
|
...(webCmd ? [webCmd] : []),
|
||||||
...(forkCmd ? [forkCmd] : []),
|
...(forkCmd ? [forkCmd] : []),
|
||||||
...(buddy ? [buddy] : []),
|
...(buddy ? [buddy] : []),
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
import type { Command } from '../../commands.js'
|
|
||||||
|
|
||||||
const webTools = {
|
|
||||||
type: 'local-jsx',
|
|
||||||
name: 'web-tools',
|
|
||||||
description: 'Configure web search and web fetch backends',
|
|
||||||
load: () => import('./web-tools.js'),
|
|
||||||
} satisfies Command
|
|
||||||
|
|
||||||
export default webTools
|
|
||||||
@@ -1,578 +0,0 @@
|
|||||||
import * as React from 'react';
|
|
||||||
import { useCallback, useState } from 'react';
|
|
||||||
import { Box, Text, Tabs, Tab, useInput } from '@anthropic/ink';
|
|
||||||
import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js';
|
|
||||||
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
|
|
||||||
import { useIsInsideModal } from '../../context/modalContext.js';
|
|
||||||
import { getSettings_DEPRECATED, updateSettingsForSource } from '../../utils/settings/settings.js';
|
|
||||||
import type { LocalJSXCommandCall, LocalJSXCommandContext } from '../../types/command.js';
|
|
||||||
|
|
||||||
// ── Types ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
type SearchAdapterKey = 'tavily' | 'api' | 'bing' | 'brave' | 'exa';
|
|
||||||
type FetchAdapterKey = 'tavily' | 'http';
|
|
||||||
|
|
||||||
interface AdapterMeta {
|
|
||||||
key: SearchAdapterKey | FetchAdapterKey;
|
|
||||||
label: string;
|
|
||||||
description: string;
|
|
||||||
hasConfig: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
type SettingsJson = Record<string, unknown> & {
|
|
||||||
webSearchAdapter?: 'api' | 'bing' | 'brave' | 'exa' | 'tavily';
|
|
||||||
webFetchAdapter?: 'tavily' | 'http';
|
|
||||||
tavilyEndpointUrl?: string;
|
|
||||||
braveApiKey?: string;
|
|
||||||
webFetchHttpTimeoutMs?: number;
|
|
||||||
exaApiKey?: string;
|
|
||||||
exaEndpointUrl?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ViewState = { kind: 'main' } | { kind: 'config'; adapter: AdapterMeta };
|
|
||||||
|
|
||||||
// ── Data ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const SEARCH_ADAPTERS: AdapterMeta[] = [
|
|
||||||
{ key: 'tavily', label: 'Tavily', description: 'Tavily Search API (default)', hasConfig: true },
|
|
||||||
{ key: 'api', label: 'Anthropic API', description: 'Anthropic server-side web search', hasConfig: false },
|
|
||||||
{ key: 'bing', label: 'Bing', description: 'Scrape Bing HTML results', hasConfig: false },
|
|
||||||
{ key: 'brave', label: 'Brave', description: 'Brave Search API (needs API key)', hasConfig: true },
|
|
||||||
{ key: 'exa', label: 'Exa', description: 'Exa AI search (MCP endpoint)', hasConfig: true },
|
|
||||||
];
|
|
||||||
|
|
||||||
const FETCH_ADAPTERS: AdapterMeta[] = [
|
|
||||||
{ key: 'tavily', label: 'Tavily Extract', description: 'Use Tavily /extract (default)', hasConfig: true },
|
|
||||||
{ key: 'http', label: 'HTTP Direct', description: 'Fetch URL directly via HTTP', hasConfig: true },
|
|
||||||
];
|
|
||||||
|
|
||||||
// ── Config field definitions ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
type ConfigField = {
|
|
||||||
key: string;
|
|
||||||
label: string;
|
|
||||||
placeholder: string;
|
|
||||||
maskInput: boolean;
|
|
||||||
getValue: (s: SettingsJson) => string;
|
|
||||||
setValue: (s: SettingsJson, v: string) => SettingsJson;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Main View ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function MainView({
|
|
||||||
tab,
|
|
||||||
adapters,
|
|
||||||
current,
|
|
||||||
fieldLabel,
|
|
||||||
onConfigure,
|
|
||||||
onSwitchTab,
|
|
||||||
onSelectAdapter,
|
|
||||||
onClose,
|
|
||||||
contentHeight,
|
|
||||||
}: {
|
|
||||||
tab: 'search' | 'fetch';
|
|
||||||
adapters: AdapterMeta[];
|
|
||||||
current: string;
|
|
||||||
fieldLabel: string;
|
|
||||||
onConfigure: (adapter: AdapterMeta) => void;
|
|
||||||
onSwitchTab: (tab: 'search' | 'fetch') => void;
|
|
||||||
onSelectAdapter: (key: string) => void;
|
|
||||||
onClose: () => void;
|
|
||||||
contentHeight: number;
|
|
||||||
}): React.ReactNode {
|
|
||||||
const [cursor, setCursor] = useState(
|
|
||||||
Math.max(
|
|
||||||
0,
|
|
||||||
adapters.findIndex(a => a.key === current),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
useInput((input, key) => {
|
|
||||||
if (key.upArrow) {
|
|
||||||
setCursor(c => Math.max(0, c - 1));
|
|
||||||
} else if (key.downArrow) {
|
|
||||||
setCursor(c => Math.min(c + 1, adapters.length - 1));
|
|
||||||
} else if (key.tab && tab === 'search') {
|
|
||||||
onSwitchTab('fetch');
|
|
||||||
setCursor(0);
|
|
||||||
} else if (key.tab && tab === 'fetch') {
|
|
||||||
onSwitchTab('search');
|
|
||||||
setCursor(0);
|
|
||||||
} else if (key.escape) {
|
|
||||||
onClose();
|
|
||||||
} else if (key.return) {
|
|
||||||
const adapter = adapters[cursor];
|
|
||||||
if (adapter) {
|
|
||||||
onConfigure(adapter);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Space toggles selection without entering config
|
|
||||||
else if (input === ' ') {
|
|
||||||
const adapter = adapters[cursor];
|
|
||||||
if (adapter) {
|
|
||||||
onSelectAdapter(adapter.key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box flexDirection="column" padding={1}>
|
|
||||||
<Text bold>{fieldLabel}</Text>
|
|
||||||
<Box flexDirection="column" marginTop={1}>
|
|
||||||
{adapters.map((adapter, idx) => {
|
|
||||||
const isSelected = adapter.key === current;
|
|
||||||
const isCursor = idx === cursor;
|
|
||||||
const highlight = isCursor || isSelected;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box key={adapter.key} flexDirection="row">
|
|
||||||
<Text color={isSelected ? 'success' : undefined}>
|
|
||||||
{isCursor ? '›' : ' '}
|
|
||||||
<Text color={isSelected ? 'success' : undefined}> {isSelected ? '\u25CF' : '\u25CB'} </Text>
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
bold={isSelected}
|
|
||||||
backgroundColor={highlight ? 'suggestion' : undefined}
|
|
||||||
color={highlight ? 'inverseText' : undefined}
|
|
||||||
>
|
|
||||||
{adapter.label}
|
|
||||||
</Text>
|
|
||||||
<Text> </Text>
|
|
||||||
<Text dimColor={!isSelected}>{adapter.description}</Text>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Box>
|
|
||||||
<Box marginTop={1} flexDirection="row" gap={2}>
|
|
||||||
<Text dimColor>{'\u2191\u2193'} navigate · Space select · Enter config · Esc close</Text>
|
|
||||||
<Text dimColor>Tab switch tab</Text>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Config View ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function getConfigFields(adapter: AdapterMeta): ConfigField[] {
|
|
||||||
const fields: ConfigField[] = [];
|
|
||||||
switch (adapter.key) {
|
|
||||||
case 'tavily':
|
|
||||||
fields.push({
|
|
||||||
key: 'tavilyEndpointUrl',
|
|
||||||
label: 'Endpoint URL',
|
|
||||||
placeholder: 'https://tavily.claude-code-best.win',
|
|
||||||
maskInput: false,
|
|
||||||
getValue: s => s.tavilyEndpointUrl ?? 'https://tavily.claude-code-best.win',
|
|
||||||
setValue: (s, v) => ({ ...s, tavilyEndpointUrl: v || undefined }),
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case 'brave':
|
|
||||||
fields.push({
|
|
||||||
key: 'braveApiKey',
|
|
||||||
label: 'API Key',
|
|
||||||
placeholder: 'BSA...',
|
|
||||||
maskInput: true,
|
|
||||||
getValue: s => s.braveApiKey ?? '',
|
|
||||||
setValue: (s, v) => ({ ...s, braveApiKey: v || undefined }),
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case 'exa':
|
|
||||||
fields.push({
|
|
||||||
key: 'exaApiKey',
|
|
||||||
label: 'API Key',
|
|
||||||
placeholder: 'exa-...',
|
|
||||||
maskInput: true,
|
|
||||||
getValue: s => s.exaApiKey ?? '',
|
|
||||||
setValue: (s, v) => ({ ...s, exaApiKey: v || undefined }),
|
|
||||||
});
|
|
||||||
fields.push({
|
|
||||||
key: 'exaEndpointUrl',
|
|
||||||
label: 'Endpoint URL',
|
|
||||||
placeholder: 'https://mcp.exa.ai/mcp',
|
|
||||||
maskInput: false,
|
|
||||||
getValue: s => s.exaEndpointUrl ?? 'https://mcp.exa.ai/mcp',
|
|
||||||
setValue: (s, v) => ({ ...s, exaEndpointUrl: v || undefined }),
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case 'http':
|
|
||||||
fields.push({
|
|
||||||
key: 'webFetchHttpTimeoutMs',
|
|
||||||
label: 'Timeout (ms)',
|
|
||||||
placeholder: '60000',
|
|
||||||
maskInput: false,
|
|
||||||
getValue: s => String(s.webFetchHttpTimeoutMs ?? 60000),
|
|
||||||
setValue: (s, v) => ({ ...s, webFetchHttpTimeoutMs: v ? Number(v) || undefined : undefined }),
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
return fields;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ConfigView({
|
|
||||||
adapter,
|
|
||||||
onBack,
|
|
||||||
onSave,
|
|
||||||
onSelect,
|
|
||||||
}: {
|
|
||||||
adapter: AdapterMeta;
|
|
||||||
onBack: () => void;
|
|
||||||
onSave: (msg: string) => void;
|
|
||||||
onSelect: (msg: string) => void;
|
|
||||||
}): React.ReactNode {
|
|
||||||
const fields = getConfigFields(adapter);
|
|
||||||
const settings = getSettings_DEPRECATED() as unknown as SettingsJson;
|
|
||||||
|
|
||||||
if (fields.length === 0) {
|
|
||||||
return <NoConfigView adapter={adapter} onBack={onBack} onSelect={onSelect} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <ConfigFieldsEditor fields={fields} adapter={adapter} onBack={onBack} onSave={onSave} settings={settings} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function NoConfigView({
|
|
||||||
adapter,
|
|
||||||
onBack,
|
|
||||||
onSelect,
|
|
||||||
}: {
|
|
||||||
adapter: AdapterMeta;
|
|
||||||
onBack: () => void;
|
|
||||||
onSelect: (msg: string) => void;
|
|
||||||
}): React.ReactNode {
|
|
||||||
const [cursor, setCursor] = useState(0);
|
|
||||||
|
|
||||||
useInput((input, key) => {
|
|
||||||
if (key.upArrow || key.downArrow) {
|
|
||||||
setCursor(c => (c === 0 ? 1 : 0));
|
|
||||||
} else if (key.escape) {
|
|
||||||
onBack();
|
|
||||||
} else if (key.return) {
|
|
||||||
if (cursor === 0) {
|
|
||||||
onSelect(`Selected ${adapter.label}.`);
|
|
||||||
} else {
|
|
||||||
onBack();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box flexDirection="column" padding={1}>
|
|
||||||
<Text bold>{adapter.label}</Text>
|
|
||||||
<Box flexDirection="column" marginTop={1}>
|
|
||||||
<Text>{adapter.description}</Text>
|
|
||||||
<Box marginTop={1}>
|
|
||||||
<Text dimColor>No additional configuration needed.</Text>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
<Box flexDirection="column" marginTop={1}>
|
|
||||||
<Box>
|
|
||||||
<Text>{cursor === 0 ? '\u203A' : ' '} </Text>
|
|
||||||
<Text
|
|
||||||
backgroundColor={cursor === 0 ? 'suggestion' : undefined}
|
|
||||||
color={cursor === 0 ? 'inverseText' : undefined}
|
|
||||||
bold
|
|
||||||
>
|
|
||||||
[ Select & Close ]
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
<Box>
|
|
||||||
<Text>{cursor === 1 ? '\u203A' : ' '} </Text>
|
|
||||||
<Text
|
|
||||||
backgroundColor={cursor === 1 ? 'suggestion' : undefined}
|
|
||||||
color={cursor === 1 ? 'inverseText' : undefined}
|
|
||||||
>
|
|
||||||
[ Back ]
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
<Box marginTop={1}>
|
|
||||||
<Text dimColor>{'\u2191\u2193'} navigate · Enter confirm · Esc back</Text>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ConfigFieldsEditor({
|
|
||||||
fields,
|
|
||||||
adapter,
|
|
||||||
onBack,
|
|
||||||
onSave,
|
|
||||||
settings,
|
|
||||||
}: {
|
|
||||||
fields: ConfigField[];
|
|
||||||
adapter: AdapterMeta;
|
|
||||||
onBack: () => void;
|
|
||||||
onSave: (msg: string) => void;
|
|
||||||
settings: SettingsJson;
|
|
||||||
}): React.ReactNode {
|
|
||||||
const [cursor, setCursor] = useState(0);
|
|
||||||
const [editing, setEditing] = useState(false);
|
|
||||||
const [editValue, setEditValue] = useState('');
|
|
||||||
const [editCursor, setEditCursor] = useState(0);
|
|
||||||
|
|
||||||
// Reset edit state when field cursor changes
|
|
||||||
const resetEdit = useCallback(() => {
|
|
||||||
setEditing(false);
|
|
||||||
setEditValue('');
|
|
||||||
setEditCursor(0);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Row count: fields + "Save" button + "Back" button
|
|
||||||
const fieldRowStart = 0;
|
|
||||||
const fieldRowEnd = fields.length - 1;
|
|
||||||
const saveRow = fields.length;
|
|
||||||
const backRow = fields.length + 1;
|
|
||||||
|
|
||||||
const handleSave = useCallback(() => {
|
|
||||||
let updated: SettingsJson = { ...settings } as SettingsJson;
|
|
||||||
for (const f of fields) {
|
|
||||||
const currentVal = f.getValue(settings);
|
|
||||||
updated = f.setValue(updated, currentVal);
|
|
||||||
}
|
|
||||||
updateSettingsForSource('userSettings', updated as Record<string, unknown> & SettingsJson);
|
|
||||||
onSave(`Configuration saved for ${adapter.label}.`);
|
|
||||||
}, [fields, settings, adapter.label, onSave]);
|
|
||||||
|
|
||||||
const handleFieldEdit = useCallback(() => {
|
|
||||||
const field = fields[cursor];
|
|
||||||
if (!field) return;
|
|
||||||
const currentVal = field.getValue(settings);
|
|
||||||
setEditValue(currentVal);
|
|
||||||
setEditCursor(currentVal.length);
|
|
||||||
setEditing(true);
|
|
||||||
}, [cursor, fields, settings]);
|
|
||||||
|
|
||||||
const handleEditSubmit = useCallback(() => {
|
|
||||||
const field = fields[cursor];
|
|
||||||
if (!field) return;
|
|
||||||
const updated = field.setValue({ ...settings } as SettingsJson, editValue);
|
|
||||||
// Store locally for preview, actual save on "Save"
|
|
||||||
Object.assign(settings, updated);
|
|
||||||
setEditing(false);
|
|
||||||
}, [cursor, fields, settings, editValue]);
|
|
||||||
|
|
||||||
useInput((input, key) => {
|
|
||||||
if (editing) {
|
|
||||||
// In edit mode, all typing goes to the field value
|
|
||||||
if (key.escape) {
|
|
||||||
resetEdit();
|
|
||||||
} else if (key.return) {
|
|
||||||
handleEditSubmit();
|
|
||||||
} else if (key.backspace || key.delete) {
|
|
||||||
setEditValue((v: string) => {
|
|
||||||
const pos = editCursor;
|
|
||||||
if (pos > 0) {
|
|
||||||
setEditCursor(pos - 1);
|
|
||||||
return v.slice(0, pos - 1) + v.slice(pos);
|
|
||||||
}
|
|
||||||
return v;
|
|
||||||
});
|
|
||||||
} else if (key.leftArrow) {
|
|
||||||
setEditCursor(c => Math.max(0, c - 1));
|
|
||||||
} else if (key.rightArrow) {
|
|
||||||
setEditCursor(c => Math.min(editValue.length, c + 1));
|
|
||||||
} else if (input && input.length === 1 && !key.ctrl && !key.meta) {
|
|
||||||
setEditValue((v: string) => {
|
|
||||||
const pos = editCursor;
|
|
||||||
setEditCursor(pos + 1);
|
|
||||||
return v.slice(0, pos) + input + v.slice(pos);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Not editing — navigate fields
|
|
||||||
if (key.upArrow) {
|
|
||||||
setCursor(c => Math.max(0, c - 1));
|
|
||||||
} else if (key.downArrow) {
|
|
||||||
setCursor(c => Math.min(backRow, c + 1));
|
|
||||||
} else if (key.escape) {
|
|
||||||
onBack();
|
|
||||||
} else if (key.return) {
|
|
||||||
if (cursor === saveRow) {
|
|
||||||
handleSave();
|
|
||||||
} else if (cursor === backRow) {
|
|
||||||
onBack();
|
|
||||||
} else {
|
|
||||||
handleFieldEdit();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box flexDirection="column" padding={1}>
|
|
||||||
<Text bold>{adapter.label} Configuration</Text>
|
|
||||||
<Box flexDirection="column" marginTop={1}>
|
|
||||||
{fields.map((field, idx) => {
|
|
||||||
const isCursor = idx === cursor && !editing;
|
|
||||||
const val = field.getValue(settings);
|
|
||||||
const displayVal =
|
|
||||||
editing && idx === cursor
|
|
||||||
? field.maskInput
|
|
||||||
? '\u2022'.repeat(editValue.length)
|
|
||||||
: editValue
|
|
||||||
: field.maskInput && val
|
|
||||||
? '\u2022'.repeat(Math.min(val.length, 16))
|
|
||||||
: val;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box key={field.key} flexDirection="row">
|
|
||||||
<Text>{isCursor ? '›' : ' '} </Text>
|
|
||||||
<Text dimColor>{field.label}: </Text>
|
|
||||||
<Text
|
|
||||||
backgroundColor={isCursor ? 'suggestion' : undefined}
|
|
||||||
color={editing && idx === cursor ? 'success' : isCursor ? 'inverseText' : undefined}
|
|
||||||
>
|
|
||||||
{displayVal || <Text dimColor>(empty)</Text>}
|
|
||||||
</Text>
|
|
||||||
{editing && idx === cursor && (
|
|
||||||
<Text dimColor>
|
|
||||||
{' |'} pos {editCursor}/{editValue.length}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<Box marginTop={1}>
|
|
||||||
<Text>{cursor === saveRow ? '›' : ' '} </Text>
|
|
||||||
<Text
|
|
||||||
backgroundColor={cursor === saveRow ? 'suggestion' : undefined}
|
|
||||||
color={cursor === saveRow ? 'inverseText' : undefined}
|
|
||||||
bold
|
|
||||||
>
|
|
||||||
[ Save ]
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
<Box>
|
|
||||||
<Text>{cursor === backRow ? '›' : ' '} </Text>
|
|
||||||
<Text
|
|
||||||
backgroundColor={cursor === backRow ? 'suggestion' : undefined}
|
|
||||||
color={cursor === backRow ? 'inverseText' : undefined}
|
|
||||||
>
|
|
||||||
[ Back ]
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
<Box marginTop={1}>
|
|
||||||
<Text dimColor>
|
|
||||||
{editing
|
|
||||||
? '\u2190\u2192 move cursor · Type to edit · Enter confirm · Esc cancel edit'
|
|
||||||
: '\u2191\u2193 navigate · Enter edit field · Esc go back'}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Top-level panel ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function WebToolsPanel({
|
|
||||||
onClose,
|
|
||||||
_context: __context,
|
|
||||||
}: {
|
|
||||||
onClose: (result?: string) => void;
|
|
||||||
_context: LocalJSXCommandContext;
|
|
||||||
}): React.ReactNode {
|
|
||||||
const [currentTab, setCurrentTab] = useState<'search' | 'fetch'>('search');
|
|
||||||
const [view, setView] = useState<ViewState>({ kind: 'main' });
|
|
||||||
|
|
||||||
const settings = getSettings_DEPRECATED() as unknown as SettingsJson;
|
|
||||||
const currentSearch = settings.webSearchAdapter ?? 'tavily';
|
|
||||||
const currentFetch = settings.webFetchAdapter ?? 'tavily';
|
|
||||||
|
|
||||||
const insideModal = useIsInsideModal();
|
|
||||||
const { rows } = useTerminalSize();
|
|
||||||
const contentHeight = insideModal ? rows + 1 : Math.max(14, Math.min(Math.floor(rows * 0.7), 24));
|
|
||||||
|
|
||||||
useExitOnCtrlCDWithKeybindings();
|
|
||||||
|
|
||||||
const handleSelectAdapter = useCallback(
|
|
||||||
(key: string) => {
|
|
||||||
const t = currentTab;
|
|
||||||
const field = t === 'search' ? 'webSearchAdapter' : ('webFetchAdapter' as keyof SettingsJson);
|
|
||||||
updateSettingsForSource('userSettings', { [field]: key } as SettingsJson);
|
|
||||||
const adapters = t === 'search' ? SEARCH_ADAPTERS : FETCH_ADAPTERS;
|
|
||||||
const label = adapters.find(a => a.key === key)?.label ?? key;
|
|
||||||
onClose(`${t === 'search' ? 'Web search' : 'Web fetch'} backend set to ${label}.`);
|
|
||||||
},
|
|
||||||
[currentTab, onClose],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleConfigure = useCallback((adapter: AdapterMeta) => {
|
|
||||||
setView({ kind: 'config', adapter });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleBackFromConfig = useCallback(() => {
|
|
||||||
setView({ kind: 'main' });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSaveConfig = useCallback(
|
|
||||||
(msg: string) => {
|
|
||||||
onClose(msg);
|
|
||||||
},
|
|
||||||
[onClose],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleSelectFromConfig = useCallback(
|
|
||||||
(msg: string) => {
|
|
||||||
// Also save the adapter selection when coming from config detail
|
|
||||||
const adapter = (view as Extract<ViewState, { kind: 'config' }>).adapter;
|
|
||||||
const tab =
|
|
||||||
view.kind === 'config' ? (SEARCH_ADAPTERS.some(a => a.key === adapter.key) ? 'search' : 'fetch') : currentTab;
|
|
||||||
const field = tab === 'search' ? ('webSearchAdapter' as const) : ('webFetchAdapter' as const);
|
|
||||||
updateSettingsForSource('userSettings', { [field]: adapter.key } as SettingsJson);
|
|
||||||
onClose(msg);
|
|
||||||
},
|
|
||||||
[onClose, view, currentTab],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (view.kind === 'config') {
|
|
||||||
return (
|
|
||||||
<ConfigView
|
|
||||||
adapter={view.adapter}
|
|
||||||
onBack={handleBackFromConfig}
|
|
||||||
onSave={handleSaveConfig}
|
|
||||||
onSelect={handleSelectFromConfig}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Main view with tabs
|
|
||||||
const adapters = currentTab === 'search' ? SEARCH_ADAPTERS : FETCH_ADAPTERS;
|
|
||||||
const current = currentTab === 'search' ? currentSearch : currentFetch;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tabs title="Web Tools" contentHeight={contentHeight}>
|
|
||||||
<Tab key="search" title="Search">
|
|
||||||
<MainView
|
|
||||||
tab={currentTab}
|
|
||||||
adapters={SEARCH_ADAPTERS}
|
|
||||||
current={currentSearch}
|
|
||||||
fieldLabel="Choose a web search backend:"
|
|
||||||
onConfigure={handleConfigure}
|
|
||||||
onSwitchTab={setCurrentTab}
|
|
||||||
onSelectAdapter={handleSelectAdapter}
|
|
||||||
onClose={() => onClose('Web tools panel dismissed')}
|
|
||||||
contentHeight={contentHeight}
|
|
||||||
/>
|
|
||||||
</Tab>
|
|
||||||
<Tab key="fetch" title="Fetch">
|
|
||||||
<MainView
|
|
||||||
tab={currentTab}
|
|
||||||
adapters={FETCH_ADAPTERS}
|
|
||||||
current={currentFetch}
|
|
||||||
fieldLabel="Choose a web fetch backend:"
|
|
||||||
onConfigure={handleConfigure}
|
|
||||||
onSwitchTab={setCurrentTab}
|
|
||||||
onSelectAdapter={handleSelectAdapter}
|
|
||||||
onClose={() => onClose('Web tools panel dismissed')}
|
|
||||||
contentHeight={contentHeight}
|
|
||||||
/>
|
|
||||||
</Tab>
|
|
||||||
</Tabs>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const call: LocalJSXCommandCall = async (onDone, context) => {
|
|
||||||
return <WebToolsPanel onClose={onDone} _context={context} />;
|
|
||||||
};
|
|
||||||
@@ -11,11 +11,9 @@ import { getSSLErrorHint } from '@ant/model-provider';
|
|||||||
import { sendNotification } from '../services/notifier.js';
|
import { sendNotification } from '../services/notifier.js';
|
||||||
import {
|
import {
|
||||||
completeChatGPTDeviceLogin,
|
completeChatGPTDeviceLogin,
|
||||||
removeChatGPTAuth,
|
|
||||||
requestChatGPTDeviceCode,
|
requestChatGPTDeviceCode,
|
||||||
type ChatGPTDeviceCode,
|
type ChatGPTDeviceCode,
|
||||||
} from '../services/api/openai/chatgptAuth.js';
|
} from '../services/api/openai/chatgptAuth.js';
|
||||||
import { clearOpenAIClientCache } from '../services/api/openai/client.js';
|
|
||||||
import { OAuthService } from '../services/oauth/index.js';
|
import { OAuthService } from '../services/oauth/index.js';
|
||||||
import { getOauthAccountInfo, validateForceLoginOrg } from '../utils/auth.js';
|
import { getOauthAccountInfo, validateForceLoginOrg } from '../utils/auth.js';
|
||||||
import { openBrowser } from '../utils/browser.js';
|
import { openBrowser } from '../utils/browser.js';
|
||||||
@@ -911,11 +909,6 @@ function OAuthStatusMessage({
|
|||||||
process.env[k] = v;
|
process.env[k] = v;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Drop any cached OpenAI client so the next request rebuilds it
|
|
||||||
// with the new env vars. Also clear ChatGPT auth file so a prior
|
|
||||||
// ChatGPT Subscription login can't leak into the OpenAI Compatible path.
|
|
||||||
clearOpenAIClientCache();
|
|
||||||
void removeChatGPTAuth().catch(() => {});
|
|
||||||
setOAuthStatus({ state: 'success' });
|
setOAuthStatus({ state: 'success' });
|
||||||
void onDone();
|
void onDone();
|
||||||
}
|
}
|
||||||
@@ -1050,11 +1043,6 @@ function OAuthStatusMessage({
|
|||||||
throw new Error('Failed to save settings. Please try again.');
|
throw new Error('Failed to save settings. Please try again.');
|
||||||
}
|
}
|
||||||
for (const [k, v] of Object.entries(env)) process.env[k] = v;
|
for (const [k, v] of Object.entries(env)) process.env[k] = v;
|
||||||
// Drop any cached OpenAI client built from prior OpenAI Compatible
|
|
||||||
// env vars; the ChatGPT Subscription path bypasses the SDK client
|
|
||||||
// entirely (uses createChatGPTResponsesStream) but a stale cached
|
|
||||||
// client would still be picked up by sideQuery.
|
|
||||||
clearOpenAIClientCache();
|
|
||||||
setOAuthStatus({ state: 'success' });
|
setOAuthStatus({ state: 'success' });
|
||||||
void onDone();
|
void onDone();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -1480,10 +1468,6 @@ function OAuthStatusMessage({
|
|||||||
process.env[k] = v;
|
process.env[k] = v;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Drop any cached OpenAI client and ChatGPT auth so the new
|
|
||||||
// provider/credentials take effect on the next request.
|
|
||||||
clearOpenAIClientCache();
|
|
||||||
void removeChatGPTAuth().catch(() => {});
|
|
||||||
logEvent('tengu_china_login_success', {});
|
logEvent('tengu_china_login_success', {});
|
||||||
setOAuthStatus({ state: 'success' });
|
setOAuthStatus({ state: 'success' });
|
||||||
void onDone();
|
void onDone();
|
||||||
|
|||||||
@@ -1136,18 +1136,6 @@ export function REPL({
|
|||||||
const abortControllerRef = useRef<AbortController | null>(null);
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
abortControllerRef.current = abortController;
|
abortControllerRef.current = abortController;
|
||||||
|
|
||||||
// Timestamp (ms) of the most recent local-jsx panel dismissal (e.g. ESC on
|
|
||||||
// /workflows). Used by onCancel's grace-period guard: the ESC that closes
|
|
||||||
// a local-jsx panel (or any quick follow-up ESC within the grace window)
|
|
||||||
// must not fall through to abortController.abort('user-cancel') — otherwise
|
|
||||||
// closing the /workflows panel via ESC would kill the in-flight Workflow
|
|
||||||
// tool. The chat:cancel keybinding's isActive gate (`!isLocalJSXCommand`)
|
|
||||||
// only shields the panel while it's mounted; once React commits the
|
|
||||||
// unmount, the next ESC reaches onCancel unguarded. This ref closes that
|
|
||||||
// race without touching keybinding registration order.
|
|
||||||
const LOCAL_JSX_CLOSE_CANCEL_GRACE_MS = 500;
|
|
||||||
const localJSXClosedAtRef = useRef(0);
|
|
||||||
|
|
||||||
// Track whether the last turn was user-aborted (Ctrl+C / Escape).
|
// Track whether the last turn was user-aborted (Ctrl+C / Escape).
|
||||||
// When true, useGoalContinuation skips the continuation enqueue so
|
// When true, useGoalContinuation skips the continuation enqueue so
|
||||||
// interrupted turns don't spin into an unstoppable loop. Reset to
|
// interrupted turns don't spin into an unstoppable loop. Reset to
|
||||||
@@ -1367,9 +1355,6 @@ export function REPL({
|
|||||||
if (args?.clearLocalJSX) {
|
if (args?.clearLocalJSX) {
|
||||||
localJSXCommandRef.current = null;
|
localJSXCommandRef.current = null;
|
||||||
setToolJSXInternal(null);
|
setToolJSXInternal(null);
|
||||||
// Stamp the dismissal so onCancel's grace-period guard can swallow
|
|
||||||
// the ESC that just dismissed the panel (and any quick follow-up).
|
|
||||||
localJSXClosedAtRef.current = Date.now();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Otherwise, keep the local JSX command visible - ignore tool updates
|
// Otherwise, keep the local JSX command visible - ignore tool updates
|
||||||
@@ -2549,24 +2534,6 @@ export function REPL({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Grace-period guard: if a local-jsx panel (e.g. /workflows) was just
|
|
||||||
// dismissed via ESC, swallow the same / immediately-following ESC so it
|
|
||||||
// doesn't fall through to abortController.abort('user-cancel') and kill
|
|
||||||
// the in-flight Workflow tool. Single-press ESC closes the panel
|
|
||||||
// (handled by the panel's own useInput → onDone → setToolJSX); the
|
|
||||||
// chat:cancel keybinding's isActive gate shields while the panel is
|
|
||||||
// mounted but not in the React commit window right after unmount.
|
|
||||||
// Reset the stamp so a later, deliberate ESC still cancels normally.
|
|
||||||
if (
|
|
||||||
localJSXClosedAtRef.current !== 0 &&
|
|
||||||
Date.now() - localJSXClosedAtRef.current < LOCAL_JSX_CLOSE_CANCEL_GRACE_MS
|
|
||||||
) {
|
|
||||||
localJSXClosedAtRef.current = 0;
|
|
||||||
logForDebugging('[onCancel] suppressed: local-jsx panel just dismissed');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
localJSXClosedAtRef.current = 0;
|
|
||||||
|
|
||||||
logForDebugging(`[onCancel] focusedInputDialog=${focusedInputDialog} streamMode=${streamMode}`);
|
logForDebugging(`[onCancel] focusedInputDialog=${focusedInputDialog} streamMode=${streamMode}`);
|
||||||
|
|
||||||
// Pause proactive mode so the user gets control back.
|
// Pause proactive mode so the user gets control back.
|
||||||
|
|||||||
@@ -71,13 +71,10 @@ mockModulePreservingExports('../../../utils/config.ts', {
|
|||||||
|
|
||||||
const mockSwitchSession = mock(() => {})
|
const mockSwitchSession = mock(() => {})
|
||||||
|
|
||||||
const mockGetOriginalCwd = mock(() => '/current/working/dir')
|
|
||||||
mockModulePreservingExports('../../../bootstrap/state.ts', {
|
mockModulePreservingExports('../../../bootstrap/state.ts', {
|
||||||
setOriginalCwd: mock(() => {}),
|
setOriginalCwd: mock(() => {}),
|
||||||
switchSession: mockSwitchSession,
|
switchSession: mockSwitchSession,
|
||||||
addSlowOperation: mock(() => {}),
|
addSlowOperation: mock(() => {}),
|
||||||
getOriginalCwd: mockGetOriginalCwd,
|
|
||||||
getSessionProjectDir: mock(() => null),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const mockGetDefaultAppState = mock(() => ({
|
const mockGetDefaultAppState = mock(() => ({
|
||||||
@@ -119,9 +116,8 @@ mockModulePreservingExports('../bridge.ts', {
|
|||||||
})),
|
})),
|
||||||
})
|
})
|
||||||
|
|
||||||
const mockListSessionsImpl = mock(async () => [])
|
|
||||||
mockModulePreservingExports('../../../utils/listSessionsImpl.ts', {
|
mockModulePreservingExports('../../../utils/listSessionsImpl.ts', {
|
||||||
listSessionsImpl: mockListSessionsImpl,
|
listSessionsImpl: mock(async () => []),
|
||||||
})
|
})
|
||||||
|
|
||||||
const mockResolveSessionFilePath = mock(async () => ({
|
const mockResolveSessionFilePath = mock(async () => ({
|
||||||
@@ -245,10 +241,6 @@ describe('AcpAgent', () => {
|
|||||||
mockGetDefaultAppState.mockClear()
|
mockGetDefaultAppState.mockClear()
|
||||||
mockGetSettings.mockReset()
|
mockGetSettings.mockReset()
|
||||||
mockGetSettings.mockImplementation(() => ({}))
|
mockGetSettings.mockImplementation(() => ({}))
|
||||||
mockListSessionsImpl.mockReset()
|
|
||||||
mockListSessionsImpl.mockImplementation(async () => [])
|
|
||||||
mockGetOriginalCwd.mockReset()
|
|
||||||
mockGetOriginalCwd.mockImplementation(() => '/current/working/dir')
|
|
||||||
;(forwardSessionUpdates as ReturnType<typeof mock>).mockReset()
|
;(forwardSessionUpdates as ReturnType<typeof mock>).mockReset()
|
||||||
;(forwardSessionUpdates as ReturnType<typeof mock>).mockImplementation(
|
;(forwardSessionUpdates as ReturnType<typeof mock>).mockImplementation(
|
||||||
async () => ({ stopReason: 'end_turn' as const }),
|
async () => ({ stopReason: 'end_turn' as const }),
|
||||||
@@ -268,52 +260,25 @@ describe('AcpAgent', () => {
|
|||||||
expect(typeof res.agentInfo?.version).toBe('string')
|
expect(typeof res.agentInfo?.version).toBe('string')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('advertises embeddedContext capability and disables image until multimodal input lands', async () => {
|
test('advertises image and embeddedContext capability', async () => {
|
||||||
const agent = new AcpAgent(makeConn())
|
const agent = new AcpAgent(makeConn())
|
||||||
const res = await agent.initialize({} as any)
|
const res = await agent.initialize({} as any)
|
||||||
// image:false — promptToQueryInput does not parse image blocks yet
|
expect(res.agentCapabilities?.promptCapabilities?.image).toBe(true)
|
||||||
expect(res.agentCapabilities?.promptCapabilities?.image).toBe(false)
|
|
||||||
expect(res.agentCapabilities?.promptCapabilities?.embeddedContext).toBe(
|
expect(res.agentCapabilities?.promptCapabilities?.embeddedContext).toBe(
|
||||||
true,
|
true,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('returns explicit empty authMethods', async () => {
|
|
||||||
const agent = new AcpAgent(makeConn())
|
|
||||||
const res = await agent.initialize({} as any)
|
|
||||||
expect(res.authMethods).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
test('loadSession capability is true', async () => {
|
test('loadSession capability is true', async () => {
|
||||||
const agent = new AcpAgent(makeConn())
|
const agent = new AcpAgent(makeConn())
|
||||||
const res = await agent.initialize({} as any)
|
const res = await agent.initialize({} as any)
|
||||||
expect(res.agentCapabilities?.loadSession).toBe(true)
|
expect(res.agentCapabilities?.loadSession).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('session capabilities include list, resume, close (fork advertised via _meta)', async () => {
|
test('session capabilities include fork, list, resume, close', async () => {
|
||||||
const agent = new AcpAgent(makeConn())
|
const agent = new AcpAgent(makeConn())
|
||||||
const res = await agent.initialize({} as any)
|
const res = await agent.initialize({} as any)
|
||||||
const caps = res.agentCapabilities?.sessionCapabilities as any
|
expect(res.agentCapabilities?.sessionCapabilities).toBeDefined()
|
||||||
expect(caps).toBeDefined()
|
|
||||||
expect(caps.list).toBeDefined()
|
|
||||||
expect(caps.resume).toBeDefined()
|
|
||||||
expect(caps.close).toBeDefined()
|
|
||||||
// fork is UNSTABLE — advertised under _meta.claudeCode.forkSession, not
|
|
||||||
// under sessionCapabilities (which is stable-v1 only).
|
|
||||||
expect(caps.fork).toBeUndefined()
|
|
||||||
expect(
|
|
||||||
(res.agentCapabilities?._meta as any)?.claudeCode?.forkSession,
|
|
||||||
).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('advertises session/delete capability per session-delete RFD', async () => {
|
|
||||||
// UNSTABLE per session-delete.mdx: capability-gated session/delete.
|
|
||||||
// SDK 0.19.0's SessionCapabilities type predates this field; we advertise
|
|
||||||
// it via type augmentation so clients implementing the RFD can find it.
|
|
||||||
const agent = new AcpAgent(makeConn())
|
|
||||||
const res = await agent.initialize({} as any)
|
|
||||||
const caps = res.agentCapabilities?.sessionCapabilities as any
|
|
||||||
expect(caps.delete).toEqual({})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -333,17 +298,12 @@ describe('AcpAgent', () => {
|
|||||||
expect(res.sessionId.length).toBeGreaterThan(0)
|
expect(res.sessionId.length).toBeGreaterThan(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('returns modes, configOptions, and models (clients need models to populate selector)', async () => {
|
test('returns modes and models', async () => {
|
||||||
const agent = new AcpAgent(makeConn())
|
const agent = new AcpAgent(makeConn())
|
||||||
const res = await agent.newSession({ cwd: '/tmp' } as any)
|
const res = await agent.newSession({ cwd: '/tmp' } as any)
|
||||||
expect(res.modes).toBeDefined()
|
expect(res.modes).toBeDefined()
|
||||||
expect(res.configOptions).toBeDefined()
|
|
||||||
// SDK 0.19.2 marks NewSessionResponse.models as UNSTABLE but the schema allows it, and
|
|
||||||
// standard clients (Cursor/Zed/VS Code) read it to populate the model selector. Omitting
|
|
||||||
// it forces supportsModelSelection=false on the client.
|
|
||||||
expect(res.models).toBeDefined()
|
expect(res.models).toBeDefined()
|
||||||
expect(Array.isArray(res.models!.availableModels)).toBe(true)
|
expect(res.configOptions).toBeDefined()
|
||||||
expect(typeof res.models!.currentModelId).toBe('string')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('each call returns a unique sessionId', async () => {
|
test('each call returns a unique sessionId', async () => {
|
||||||
@@ -368,10 +328,9 @@ describe('AcpAgent', () => {
|
|||||||
|
|
||||||
test('calls getMainLoopModel to resolve current model', async () => {
|
test('calls getMainLoopModel to resolve current model', async () => {
|
||||||
const agent = new AcpAgent(makeConn())
|
const agent = new AcpAgent(makeConn())
|
||||||
await agent.newSession({ cwd: '/tmp' } as any)
|
const res = await agent.newSession({ cwd: '/tmp' } as any)
|
||||||
expect(mockGetMainLoopModel).toHaveBeenCalled()
|
expect(mockGetMainLoopModel).toHaveBeenCalled()
|
||||||
// models is no longer in the v1 response, but the engine still receives it
|
expect(res.models?.currentModelId).toBe('claude-sonnet-4-6')
|
||||||
expect(mockSetModel).toHaveBeenCalledWith('claude-sonnet-4-6')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('calls queryEngine.setModel with resolved model', async () => {
|
test('calls queryEngine.setModel with resolved model', async () => {
|
||||||
@@ -383,7 +342,8 @@ describe('AcpAgent', () => {
|
|||||||
test('respects model alias resolution via getMainLoopModel', async () => {
|
test('respects model alias resolution via getMainLoopModel', async () => {
|
||||||
mockGetMainLoopModel.mockReturnValueOnce('glm-5.1')
|
mockGetMainLoopModel.mockReturnValueOnce('glm-5.1')
|
||||||
const agent = new AcpAgent(makeConn())
|
const agent = new AcpAgent(makeConn())
|
||||||
await agent.newSession({ cwd: '/tmp' } as any)
|
const res = await agent.newSession({ cwd: '/tmp' } as any)
|
||||||
|
expect(res.models?.currentModelId).toBe('glm-5.1')
|
||||||
expect(mockSetModel).toHaveBeenCalledWith('glm-5.1')
|
expect(mockSetModel).toHaveBeenCalledWith('glm-5.1')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -419,23 +379,29 @@ describe('AcpAgent', () => {
|
|||||||
expect(res.modes?.currentModeId).toBe('plan')
|
expect(res.modes?.currentModeId).toBe('plan')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('honors _meta.permissionMode bypass without any opt-in (always available when process allows)', async () => {
|
test('rejects _meta.permissionMode bypass without a local ACP bypass gate', async () => {
|
||||||
// bypass is exposed by default; only the root/sandbox process guard remains.
|
mockGetSettings.mockImplementationOnce(() => ({
|
||||||
|
permissions: { defaultMode: 'acceptEdits' },
|
||||||
|
}))
|
||||||
|
const consoleErrorSpy = spyOn(console, 'error').mockImplementation(
|
||||||
|
() => {},
|
||||||
|
)
|
||||||
const agent = new AcpAgent(makeConn())
|
const agent = new AcpAgent(makeConn())
|
||||||
const res = await agent.newSession({
|
try {
|
||||||
|
await expect(
|
||||||
|
agent.newSession({
|
||||||
cwd: '/tmp',
|
cwd: '/tmp',
|
||||||
_meta: { permissionMode: 'bypassPermissions' },
|
_meta: { permissionMode: 'bypassPermissions' },
|
||||||
} as any)
|
} as any),
|
||||||
|
).rejects.toThrow('Mode not available: bypassPermissions')
|
||||||
|
|
||||||
expect(res.modes?.currentModeId).toBe('bypassPermissions')
|
expect(consoleErrorSpy).not.toHaveBeenCalled()
|
||||||
expect(res.modes?.availableModes.map((mode: any) => mode.id)).toContain(
|
} finally {
|
||||||
'bypassPermissions',
|
consoleErrorSpy.mockRestore()
|
||||||
)
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test('honors _meta.permissionMode bypass regardless of local env gate', async () => {
|
test('honors _meta.permissionMode bypass with a local ACP bypass gate', async () => {
|
||||||
// The old CLAUDE_CODE_ACP_ALLOW_BYPASS_PERMISSIONS opt-in no longer gates availability,
|
|
||||||
// but setting it should still not break the request.
|
|
||||||
process.env.CLAUDE_CODE_ACP_ALLOW_BYPASS_PERMISSIONS = '1'
|
process.env.CLAUDE_CODE_ACP_ALLOW_BYPASS_PERMISSIONS = '1'
|
||||||
const agent = new AcpAgent(makeConn())
|
const agent = new AcpAgent(makeConn())
|
||||||
const res = await agent.newSession({
|
const res = await agent.newSession({
|
||||||
@@ -498,23 +464,21 @@ describe('AcpAgent', () => {
|
|||||||
).rejects.toThrow('nonexistent')
|
).rejects.toThrow('nonexistent')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('rejects empty prompt text with an error', async () => {
|
test('returns end_turn for empty prompt text', async () => {
|
||||||
const agent = new AcpAgent(makeConn())
|
const agent = new AcpAgent(makeConn())
|
||||||
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
||||||
await expect(
|
const res = await agent.prompt({ sessionId, prompt: [] } as any)
|
||||||
agent.prompt({ sessionId, prompt: [] } as any),
|
expect(res.stopReason).toBe('end_turn')
|
||||||
).rejects.toThrow('Prompt content is empty')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('rejects whitespace-only prompt with an error', async () => {
|
test('returns end_turn for whitespace-only prompt', async () => {
|
||||||
const agent = new AcpAgent(makeConn())
|
const agent = new AcpAgent(makeConn())
|
||||||
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
||||||
await expect(
|
const res = await agent.prompt({
|
||||||
agent.prompt({
|
|
||||||
sessionId,
|
sessionId,
|
||||||
prompt: [{ type: 'text', text: ' ' }],
|
prompt: [{ type: 'text', text: ' ' }],
|
||||||
} as any),
|
} as any)
|
||||||
).rejects.toThrow('Prompt content is empty')
|
expect(res.stopReason).toBe('end_turn')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('calls forwardSessionUpdates for valid prompt', async () => {
|
test('calls forwardSessionUpdates for valid prompt', async () => {
|
||||||
@@ -592,7 +556,7 @@ describe('AcpAgent', () => {
|
|||||||
).rejects.toThrow('unexpected')
|
).rejects.toThrow('unexpected')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('returns usage at root and under _meta.claudeCode.usage from forwardSessionUpdates', async () => {
|
test('returns usage from forwardSessionUpdates', async () => {
|
||||||
const agent = new AcpAgent(makeConn())
|
const agent = new AcpAgent(makeConn())
|
||||||
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
||||||
;(forwardSessionUpdates as ReturnType<typeof mock>).mockResolvedValueOnce(
|
;(forwardSessionUpdates as ReturnType<typeof mock>).mockResolvedValueOnce(
|
||||||
@@ -610,18 +574,10 @@ describe('AcpAgent', () => {
|
|||||||
sessionId,
|
sessionId,
|
||||||
prompt: [{ type: 'text', text: 'hello' }],
|
prompt: [{ type: 'text', text: 'hello' }],
|
||||||
} as any)
|
} as any)
|
||||||
// Per session-usage.mdx RFD: PromptResponse.usage is at the root
|
expect(res.usage).toBeDefined()
|
||||||
// (UNSTABLE in v1 but implemented by all major ACP clients).
|
expect(res.usage!.inputTokens).toBe(100)
|
||||||
const rootUsage = (res as any).usage
|
expect(res.usage!.outputTokens).toBe(50)
|
||||||
expect(rootUsage).toBeDefined()
|
expect(res.usage!.totalTokens).toBe(165)
|
||||||
expect(rootUsage.inputTokens).toBe(100)
|
|
||||||
expect(rootUsage.outputTokens).toBe(50)
|
|
||||||
expect(rootUsage.totalTokens).toBe(165)
|
|
||||||
// The same payload is mirrored under _meta.claudeCode.usage for
|
|
||||||
// consumers that read the vendor namespace.
|
|
||||||
const metaUsage = (res as any)._meta?.claudeCode?.usage
|
|
||||||
expect(metaUsage).toBeDefined()
|
|
||||||
expect(metaUsage.totalTokens).toBe(165)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -650,54 +606,6 @@ describe('AcpAgent', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('deleteSession (session/delete via extMethod)', () => {
|
|
||||||
test('extMethod routes session/delete to unstable_deleteSession', async () => {
|
|
||||||
const agent = new AcpAgent(makeConn())
|
|
||||||
const result = await agent.extMethod('session/delete', {
|
|
||||||
sessionId: 'nonexistent-sid-for-delete-test',
|
|
||||||
})
|
|
||||||
// Idempotent: returns empty object even when session doesn't exist
|
|
||||||
expect(result).toEqual({})
|
|
||||||
})
|
|
||||||
|
|
||||||
test('rejects session/delete without sessionId', async () => {
|
|
||||||
const agent = new AcpAgent(makeConn())
|
|
||||||
await expect(agent.extMethod('session/delete', {})).rejects.toThrow(
|
|
||||||
'non-empty sessionId',
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('rejects unknown methods with methodNotFound-style error', async () => {
|
|
||||||
const agent = new AcpAgent(makeConn())
|
|
||||||
await expect(
|
|
||||||
agent.extMethod('totally/unknown/method', {}),
|
|
||||||
).rejects.toThrow()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('unstable_deleteSession is idempotent for missing session', async () => {
|
|
||||||
const agent = new AcpAgent(makeConn())
|
|
||||||
// No file exists for this ID; both calls must succeed (per spec §Semantics)
|
|
||||||
const r1 = await agent.unstable_deleteSession({
|
|
||||||
sessionId: 'definitely-missing-id-1',
|
|
||||||
})
|
|
||||||
const r2 = await agent.unstable_deleteSession({
|
|
||||||
sessionId: 'definitely-missing-id-2',
|
|
||||||
})
|
|
||||||
expect(r1).toEqual({})
|
|
||||||
expect(r2).toEqual({})
|
|
||||||
})
|
|
||||||
|
|
||||||
test('unstable_deleteSession tears down active in-memory session', async () => {
|
|
||||||
const agent = new AcpAgent(makeConn())
|
|
||||||
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
|
||||||
expect(agent.sessions.has(sessionId)).toBe(true)
|
|
||||||
// deleteSession should remove the in-memory entry even though there's
|
|
||||||
// no on-disk file (newSession doesn't persist immediately in tests).
|
|
||||||
await agent.unstable_deleteSession({ sessionId })
|
|
||||||
expect(agent.sessions.has(sessionId)).toBe(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('setSessionModel', () => {
|
describe('setSessionModel', () => {
|
||||||
test('updates model on queryEngine', async () => {
|
test('updates model on queryEngine', async () => {
|
||||||
const agent = new AcpAgent(makeConn())
|
const agent = new AcpAgent(makeConn())
|
||||||
@@ -741,7 +649,7 @@ describe('AcpAgent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('prompt usage tracking', () => {
|
describe('prompt usage tracking', () => {
|
||||||
test('reports totalTokens as sum of all token types under _meta.claudeCode.usage', async () => {
|
test('returns totalTokens as sum of all token types', async () => {
|
||||||
const agent = new AcpAgent(makeConn())
|
const agent = new AcpAgent(makeConn())
|
||||||
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
||||||
;(forwardSessionUpdates as ReturnType<typeof mock>).mockResolvedValueOnce(
|
;(forwardSessionUpdates as ReturnType<typeof mock>).mockResolvedValueOnce(
|
||||||
@@ -759,12 +667,11 @@ describe('AcpAgent', () => {
|
|||||||
sessionId,
|
sessionId,
|
||||||
prompt: [{ type: 'text', text: 'hello' }],
|
prompt: [{ type: 'text', text: 'hello' }],
|
||||||
} as any)
|
} as any)
|
||||||
const usage = (res as any)._meta?.claudeCode?.usage
|
expect(res.usage).toBeDefined()
|
||||||
expect(usage).toBeDefined()
|
expect(res.usage!.totalTokens).toBe(165)
|
||||||
expect(usage.totalTokens).toBe(165)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('omits _meta.usage when forwardSessionUpdates returns none', async () => {
|
test('returns undefined usage when forwardSessionUpdates returns none', async () => {
|
||||||
const agent = new AcpAgent(makeConn())
|
const agent = new AcpAgent(makeConn())
|
||||||
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
||||||
;(forwardSessionUpdates as ReturnType<typeof mock>).mockResolvedValueOnce(
|
;(forwardSessionUpdates as ReturnType<typeof mock>).mockResolvedValueOnce(
|
||||||
@@ -776,51 +683,7 @@ describe('AcpAgent', () => {
|
|||||||
sessionId,
|
sessionId,
|
||||||
prompt: [{ type: 'text', text: 'hello' }],
|
prompt: [{ type: 'text', text: 'hello' }],
|
||||||
} as any)
|
} as any)
|
||||||
expect((res as any)._meta).toBeUndefined()
|
expect(res.usage).toBeUndefined()
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('prompt userMessageId echo (message-id RFD)', () => {
|
|
||||||
test('echoes client-supplied messageId as userMessageId', async () => {
|
|
||||||
// Per rfds/message-id.mdx: when the client provides a `messageId` on
|
|
||||||
// PromptRequest, the Agent echoes it back as `userMessageId`.
|
|
||||||
const agent = new AcpAgent(makeConn())
|
|
||||||
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
|
||||||
;(forwardSessionUpdates as ReturnType<typeof mock>).mockResolvedValueOnce(
|
|
||||||
{
|
|
||||||
stopReason: 'end_turn',
|
|
||||||
usage: {
|
|
||||||
inputTokens: 10,
|
|
||||||
outputTokens: 5,
|
|
||||||
cachedReadTokens: 0,
|
|
||||||
cachedWriteTokens: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
const clientMessageId = '11111111-2222-3333-4444-555555555555'
|
|
||||||
const res = await agent.prompt({
|
|
||||||
sessionId,
|
|
||||||
prompt: [{ type: 'text', text: 'hello' }],
|
|
||||||
messageId: clientMessageId,
|
|
||||||
} as any)
|
|
||||||
expect((res as any).userMessageId).toBe(clientMessageId)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('omits userMessageId when client does not supply messageId', async () => {
|
|
||||||
// Per rfds/message-id.mdx: agent MAY self-generate; we take the
|
|
||||||
// conservative approach of staying silent when the client didn't ask.
|
|
||||||
const agent = new AcpAgent(makeConn())
|
|
||||||
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
|
||||||
;(forwardSessionUpdates as ReturnType<typeof mock>).mockResolvedValueOnce(
|
|
||||||
{
|
|
||||||
stopReason: 'end_turn',
|
|
||||||
},
|
|
||||||
)
|
|
||||||
const res = await agent.prompt({
|
|
||||||
sessionId,
|
|
||||||
prompt: [{ type: 'text', text: 'hello' }],
|
|
||||||
} as any)
|
|
||||||
expect((res as any).userMessageId).toBeUndefined()
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -871,7 +734,6 @@ describe('AcpAgent', () => {
|
|||||||
} as any)
|
} as any)
|
||||||
expect(agent.sessions.has(requestedId)).toBe(true)
|
expect(agent.sessions.has(requestedId)).toBe(true)
|
||||||
expect(res.modes).toBeDefined()
|
expect(res.modes).toBeDefined()
|
||||||
// resume also returns models so clients can render the selector after reconnect.
|
|
||||||
expect(res.models).toBeDefined()
|
expect(res.models).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -943,26 +805,12 @@ describe('AcpAgent', () => {
|
|||||||
const agent = new AcpAgent(makeConn())
|
const agent = new AcpAgent(makeConn())
|
||||||
const original = await agent.newSession({ cwd: '/tmp' } as any)
|
const original = await agent.newSession({ cwd: '/tmp' } as any)
|
||||||
const forked = await agent.unstable_forkSession({
|
const forked = await agent.unstable_forkSession({
|
||||||
// params.sessionId is the source session to fork from
|
|
||||||
sessionId: original.sessionId,
|
|
||||||
cwd: '/tmp',
|
cwd: '/tmp',
|
||||||
mcpServers: [],
|
mcpServers: [],
|
||||||
} as any)
|
} as any)
|
||||||
expect(forked.sessionId).not.toBe(original.sessionId)
|
expect(forked.sessionId).not.toBe(original.sessionId)
|
||||||
expect(agent.sessions.has(forked.sessionId)).toBe(true)
|
expect(agent.sessions.has(forked.sessionId)).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('attempts to load source session history when forking', async () => {
|
|
||||||
const agent = new AcpAgent(makeConn())
|
|
||||||
const original = await agent.newSession({ cwd: '/tmp' } as any)
|
|
||||||
mockGetLastSessionLog.mockClear()
|
|
||||||
await agent.unstable_forkSession({
|
|
||||||
sessionId: original.sessionId,
|
|
||||||
cwd: '/tmp',
|
|
||||||
mcpServers: [],
|
|
||||||
} as any)
|
|
||||||
expect(mockGetLastSessionLog).toHaveBeenCalledWith(original.sessionId)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('setSessionMode', () => {
|
describe('setSessionMode', () => {
|
||||||
@@ -989,15 +837,28 @@ describe('AcpAgent', () => {
|
|||||||
).rejects.toThrow('Session not found')
|
).rejects.toThrow('Session not found')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('availableModes includes bypassPermissions by default (no opt-in needed)', async () => {
|
test('availableModes excludes bypassPermissions without a local ACP bypass gate', async () => {
|
||||||
const agent = new AcpAgent(makeConn())
|
const agent = new AcpAgent(makeConn())
|
||||||
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
||||||
const session = agent.sessions.get(sessionId)
|
const session = agent.sessions.get(sessionId)
|
||||||
const modeIds = session?.modes.availableModes.map((m: any) => m.id)
|
const modeIds = session?.modes.availableModes.map((m: any) => m.id)
|
||||||
expect(modeIds).toContain('bypassPermissions')
|
expect(modeIds).not.toContain('bypassPermissions')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('can switch to bypassPermissions without any opt-in gate', async () => {
|
test('rejects bypassPermissions without a local ACP bypass gate', async () => {
|
||||||
|
const agent = new AcpAgent(makeConn())
|
||||||
|
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
||||||
|
await expect(
|
||||||
|
agent.setSessionMode({ sessionId, modeId: 'bypassPermissions' } as any),
|
||||||
|
).rejects.toThrow('Mode not available')
|
||||||
|
|
||||||
|
const session = agent.sessions.get(sessionId)
|
||||||
|
expect(session?.modes.currentModeId).toBe('default')
|
||||||
|
expect(session?.appState.toolPermissionContext.mode).toBe('default')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('can switch to bypassPermissions mode with a local ACP bypass gate', async () => {
|
||||||
|
process.env.CLAUDE_CODE_ACP_ALLOW_BYPASS_PERMISSIONS = '1'
|
||||||
const agent = new AcpAgent(makeConn())
|
const agent = new AcpAgent(makeConn())
|
||||||
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
||||||
await agent.setSessionMode({
|
await agent.setSessionMode({
|
||||||
@@ -1012,8 +873,7 @@ describe('AcpAgent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('rejects bypassPermissions when the session does not expose it', async () => {
|
test('rejects bypassPermissions when the session does not expose it', async () => {
|
||||||
// Even though bypass is available by default, removeBypassMode simulates a session
|
process.env.CLAUDE_CODE_ACP_ALLOW_BYPASS_PERMISSIONS = '1'
|
||||||
// where the mode was stripped (e.g., future custom filter). The rejection still fires.
|
|
||||||
const agent = new AcpAgent(makeConn())
|
const agent = new AcpAgent(makeConn())
|
||||||
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
||||||
const session = agent.sessions.get(sessionId)
|
const session = agent.sessions.get(sessionId)
|
||||||
@@ -1059,10 +919,6 @@ describe('AcpAgent', () => {
|
|||||||
const session = agent.sessions.get(sessionId)
|
const session = agent.sessions.get(sessionId)
|
||||||
removeBypassMode(session)
|
removeBypassMode(session)
|
||||||
|
|
||||||
// bypassPermissions passes the config-option layer (it's still listed in the
|
|
||||||
// option's options array — removeBypassMode only strips it from modes.availableModes
|
|
||||||
// and isBypassPermissionsModeAvailable), then applySessionMode rejects it with
|
|
||||||
// "Mode not available". This covers the second of the two validation layers.
|
|
||||||
await expect(
|
await expect(
|
||||||
agent.setSessionConfigOption({
|
agent.setSessionConfigOption({
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -1074,19 +930,6 @@ describe('AcpAgent', () => {
|
|||||||
expect(session?.modes.currentModeId).toBe('default')
|
expect(session?.modes.currentModeId).toBe('default')
|
||||||
expect(session?.appState.toolPermissionContext.mode).toBe('default')
|
expect(session?.appState.toolPermissionContext.mode).toBe('default')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('rejects mode values not listed in the option options array', async () => {
|
|
||||||
const agent = new AcpAgent(makeConn())
|
|
||||||
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
agent.setSessionConfigOption({
|
|
||||||
sessionId,
|
|
||||||
configId: 'mode',
|
|
||||||
value: 'totally-not-a-real-mode',
|
|
||||||
} as any),
|
|
||||||
).rejects.toThrow(/must be one of:/)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('prompt queueing', () => {
|
describe('prompt queueing', () => {
|
||||||
@@ -1328,63 +1171,6 @@ describe('AcpAgent', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('listSessions', () => {
|
|
||||||
test('passes params.cwd through to listSessionsImpl when provided', async () => {
|
|
||||||
const agent = new AcpAgent(makeConn())
|
|
||||||
await agent.listSessions({ cwd: '/explicit/path' } as any)
|
|
||||||
expect(mockListSessionsImpl).toHaveBeenCalledWith({
|
|
||||||
dir: '/explicit/path',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test('falls back to current working dir when client omits cwd', async () => {
|
|
||||||
// Standard clients (Goose, possibly others) call session/list with
|
|
||||||
// empty params. Without a fallback, listSessionsImpl treats undefined
|
|
||||||
// dir as "all projects" and returns every session on disk.
|
|
||||||
mockGetOriginalCwd.mockImplementation(() => '/active/project')
|
|
||||||
const agent = new AcpAgent(makeConn())
|
|
||||||
await agent.listSessions({} as any)
|
|
||||||
expect(mockListSessionsImpl).toHaveBeenCalledWith({
|
|
||||||
dir: '/active/project',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test('falls back to current working dir when client sends null cwd', async () => {
|
|
||||||
mockGetOriginalCwd.mockImplementation(() => '/active/project')
|
|
||||||
const agent = new AcpAgent(makeConn())
|
|
||||||
await agent.listSessions({ cwd: null } as any)
|
|
||||||
expect(mockListSessionsImpl).toHaveBeenCalledWith({
|
|
||||||
dir: '/active/project',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test('rejects client-supplied cursor (pagination not implemented)', async () => {
|
|
||||||
const agent = new AcpAgent(makeConn())
|
|
||||||
await expect(
|
|
||||||
agent.listSessions({ cursor: 'page2' } as any),
|
|
||||||
).rejects.toThrow(/Pagination cursor not supported/)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('filters out candidates without a cwd field', async () => {
|
|
||||||
mockListSessionsImpl.mockImplementation(
|
|
||||||
async () =>
|
|
||||||
[
|
|
||||||
{
|
|
||||||
sessionId: 'with-cwd',
|
|
||||||
cwd: '/p',
|
|
||||||
summary: 'Has cwd',
|
|
||||||
lastModified: 0,
|
|
||||||
},
|
|
||||||
{ sessionId: 'no-cwd', summary: 'No cwd', lastModified: 0 },
|
|
||||||
] as any,
|
|
||||||
)
|
|
||||||
const agent = new AcpAgent(makeConn())
|
|
||||||
const res = await agent.listSessions({ cwd: '/p' } as any)
|
|
||||||
expect(res.sessions).toHaveLength(1)
|
|
||||||
expect(res.sessions[0].sessionId).toBe('with-cwd')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('sessionId alignment with global state', () => {
|
describe('sessionId alignment with global state', () => {
|
||||||
test('newSession calls switchSession with the generated sessionId', async () => {
|
test('newSession calls switchSession with the generated sessionId', async () => {
|
||||||
const agent = new AcpAgent(makeConn())
|
const agent = new AcpAgent(makeConn())
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
toolUpdateFromEditToolResponse,
|
toolUpdateFromEditToolResponse,
|
||||||
forwardSessionUpdates,
|
forwardSessionUpdates,
|
||||||
nextSdkMessageOrAbort,
|
nextSdkMessageOrAbort,
|
||||||
replayHistoryMessages,
|
|
||||||
} from '../bridge.js'
|
} from '../bridge.js'
|
||||||
import { promptToQueryInput } from '../promptConversion.js'
|
import { promptToQueryInput } from '../promptConversion.js'
|
||||||
import { markdownEscape, toDisplayPath } from '../utils.js'
|
import { markdownEscape, toDisplayPath } from '../utils.js'
|
||||||
@@ -84,35 +83,13 @@ describe('toolInfoFromToolUse', () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Bash with terminalOutput flag → no longer emits fake terminalId (audit §5.2)', () => {
|
test('Bash with terminalOutput → returns terminalId content', () => {
|
||||||
// Standard ACP terminal lifecycle is not wired through BashTool; previously
|
|
||||||
// this returned { type: 'terminal', terminalId: toolUse.id } which would
|
|
||||||
// cause compliant clients to fail terminal/output lookups. The flag is now
|
|
||||||
// ignored until terminal/create is actually plumbed through.
|
|
||||||
const info = toolInfoFromToolUse(
|
const info = toolInfoFromToolUse(
|
||||||
{ name: 'Bash', id: 'tu_123', input: { command: 'ls' } },
|
{ name: 'Bash', id: 'tu_123', input: { command: 'ls' } },
|
||||||
true,
|
true,
|
||||||
)
|
)
|
||||||
expect(info.kind).toBe('execute')
|
expect(info.kind).toBe('execute')
|
||||||
expect(info.content).toEqual([])
|
expect(info.content).toEqual([{ type: 'terminal', terminalId: 'tu_123' }])
|
||||||
expect(info.title).toBe('ls')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Bash with terminalOutput flag + description → falls back to description text', () => {
|
|
||||||
const info = toolInfoFromToolUse(
|
|
||||||
{
|
|
||||||
name: 'Bash',
|
|
||||||
id: 'tu_456',
|
|
||||||
input: { command: 'ls', description: 'list files' },
|
|
||||||
},
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
expect(info.content).toEqual([
|
|
||||||
{
|
|
||||||
type: 'content',
|
|
||||||
content: { type: 'text', text: 'list files' },
|
|
||||||
},
|
|
||||||
])
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Bash without description → empty content', () => {
|
test('Bash without description → empty content', () => {
|
||||||
@@ -322,91 +299,6 @@ describe('toolInfoFromToolUse', () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Read with relative file_path and cwd → locations resolved to absolute', () => {
|
|
||||||
// Audit §5.5: ToolCallLocation.path MUST be absolute. A relative input
|
|
||||||
// path is resolved against the session cwd before being emitted.
|
|
||||||
const info = toolInfoFromToolUse(
|
|
||||||
{
|
|
||||||
name: 'Read',
|
|
||||||
id: 'x',
|
|
||||||
input: { file_path: 'src/main.ts' },
|
|
||||||
},
|
|
||||||
false,
|
|
||||||
'/Users/test/project',
|
|
||||||
)
|
|
||||||
expect(info.locations).toEqual([
|
|
||||||
{ path: '/Users/test/project/src/main.ts', line: 1 },
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Write with relative file_path and cwd → diff path resolved absolute', () => {
|
|
||||||
// Audit §5.5: Diff.path MUST be absolute.
|
|
||||||
const info = toolInfoFromToolUse(
|
|
||||||
{
|
|
||||||
name: 'Write',
|
|
||||||
id: 'x',
|
|
||||||
input: { file_path: 'rel/file.txt', content: 'hi' },
|
|
||||||
},
|
|
||||||
false,
|
|
||||||
'/Users/test/project',
|
|
||||||
)
|
|
||||||
expect(info.content).toEqual([
|
|
||||||
{
|
|
||||||
type: 'diff',
|
|
||||||
path: '/Users/test/project/rel/file.txt',
|
|
||||||
oldText: null,
|
|
||||||
newText: 'hi',
|
|
||||||
},
|
|
||||||
])
|
|
||||||
expect(info.locations).toEqual([
|
|
||||||
{ path: '/Users/test/project/rel/file.txt' },
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Edit with relative file_path and cwd → diff path resolved absolute', () => {
|
|
||||||
// Audit §5.5: Diff.path MUST be absolute.
|
|
||||||
const info = toolInfoFromToolUse(
|
|
||||||
{
|
|
||||||
name: 'Edit',
|
|
||||||
id: 'x',
|
|
||||||
input: {
|
|
||||||
file_path: 'rel/edit.txt',
|
|
||||||
old_string: 'a',
|
|
||||||
new_string: 'b',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
false,
|
|
||||||
'/Users/test/project',
|
|
||||||
)
|
|
||||||
expect(info.content).toEqual([
|
|
||||||
{
|
|
||||||
type: 'diff',
|
|
||||||
path: '/Users/test/project/rel/edit.txt',
|
|
||||||
oldText: 'a',
|
|
||||||
newText: 'b',
|
|
||||||
},
|
|
||||||
])
|
|
||||||
expect(info.locations).toEqual([
|
|
||||||
{ path: '/Users/test/project/rel/edit.txt' },
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Glob with relative path and cwd → locations resolved absolute', () => {
|
|
||||||
// Audit §5.5: ToolCallLocation.path MUST be absolute. Title keeps the raw
|
|
||||||
// input for display, but the emitted location is resolved against cwd.
|
|
||||||
const info = toolInfoFromToolUse(
|
|
||||||
{
|
|
||||||
name: 'Glob',
|
|
||||||
id: 'x',
|
|
||||||
input: { pattern: '*.ts', path: 'src' },
|
|
||||||
},
|
|
||||||
false,
|
|
||||||
'/Users/test/project',
|
|
||||||
)
|
|
||||||
expect(info.title).toBe('Find `src` `*.ts`')
|
|
||||||
expect(info.locations).toEqual([{ path: '/Users/test/project/src' }])
|
|
||||||
})
|
|
||||||
|
|
||||||
// ── WebSearch ─────────────────────────────────────────────────
|
// ── WebSearch ─────────────────────────────────────────────────
|
||||||
|
|
||||||
test('WebSearch with allowed/blocked domains', () => {
|
test('WebSearch with allowed/blocked domains', () => {
|
||||||
@@ -534,9 +426,7 @@ describe('toolUpdateFromToolResult', () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Bash with terminalOutput flag → falls back to inline text (audit §5.2)', () => {
|
test('returns terminal metadata for Bash with terminalOutput', () => {
|
||||||
// Standard ACP terminal lifecycle is not wired; the flag is now ignored
|
|
||||||
// and no fake terminalId / non-standard _meta keys are emitted.
|
|
||||||
const result = toolUpdateFromToolResult(
|
const result = toolUpdateFromToolResult(
|
||||||
{
|
{
|
||||||
content: [{ type: 'text', text: 'output' }],
|
content: [{ type: 'text', text: 'output' }],
|
||||||
@@ -546,13 +436,20 @@ describe('toolUpdateFromToolResult', () => {
|
|||||||
{ name: 'Bash', id: 't1' },
|
{ name: 'Bash', id: 't1' },
|
||||||
true,
|
true,
|
||||||
)
|
)
|
||||||
expect(result.content).toEqual([
|
expect(result.content).toEqual([{ type: 'terminal', terminalId: 't1' }])
|
||||||
{
|
expect(result._meta).toBeDefined()
|
||||||
type: 'content',
|
expect((result._meta as Record<string, unknown>).terminal_info).toEqual({
|
||||||
content: { type: 'text', text: '```console\noutput\n```' },
|
terminal_id: 't1',
|
||||||
},
|
})
|
||||||
])
|
expect((result._meta as Record<string, unknown>).terminal_output).toEqual({
|
||||||
expect(result._meta).toBeUndefined()
|
terminal_id: 't1',
|
||||||
|
data: 'output',
|
||||||
|
})
|
||||||
|
expect((result._meta as Record<string, unknown>).terminal_exit).toEqual({
|
||||||
|
terminal_id: 't1',
|
||||||
|
exit_code: 0,
|
||||||
|
signal: null,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test('handles bash_code_execution_result format', () => {
|
test('handles bash_code_execution_result format', () => {
|
||||||
@@ -570,15 +467,9 @@ describe('toolUpdateFromToolResult', () => {
|
|||||||
{ name: 'Bash', id: 't1' },
|
{ name: 'Bash', id: 't1' },
|
||||||
true,
|
true,
|
||||||
)
|
)
|
||||||
// terminalOutput flag is ignored; bash_code_execution_result is rendered
|
const meta = result._meta as Record<string, unknown>
|
||||||
// as inline console text just like plain string content.
|
const termOutput = meta.terminal_output as { data: string }
|
||||||
expect(result.content).toEqual([
|
expect(termOutput.data).toBe('out\nerr')
|
||||||
{
|
|
||||||
type: 'content',
|
|
||||||
content: { type: 'text', text: '```console\nout\nerr\n```' },
|
|
||||||
},
|
|
||||||
])
|
|
||||||
expect(result._meta).toBeUndefined()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('returns empty when no toolUse', () => {
|
test('returns empty when no toolUse', () => {
|
||||||
@@ -652,91 +543,6 @@ describe('toolUpdateFromToolResult', () => {
|
|||||||
)
|
)
|
||||||
expect(result.title).toBe('Exited Plan Mode')
|
expect(result.title).toBe('Exited Plan Mode')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('renders resource_link content as ACP ResourceLink (audit §7.3)', () => {
|
|
||||||
const result = toolUpdateFromToolResult(
|
|
||||||
{
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'resource_link',
|
|
||||||
uri: 'file:///tmp/spec.md',
|
|
||||||
name: 'Spec',
|
|
||||||
mimeType: 'text/markdown',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
is_error: false,
|
|
||||||
tool_use_id: 't1',
|
|
||||||
},
|
|
||||||
{ name: 'SomeTool', id: 't1' },
|
|
||||||
)
|
|
||||||
expect(result.content).toEqual([
|
|
||||||
{
|
|
||||||
type: 'content',
|
|
||||||
content: {
|
|
||||||
type: 'resource_link',
|
|
||||||
uri: 'file:///tmp/spec.md',
|
|
||||||
name: 'Spec',
|
|
||||||
mimeType: 'text/markdown',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
test('resource_link without name falls back to uri (audit §7.3)', () => {
|
|
||||||
const result = toolUpdateFromToolResult(
|
|
||||||
{
|
|
||||||
content: [{ type: 'resource_link', uri: 'file:///tmp/x.md' }],
|
|
||||||
is_error: false,
|
|
||||||
tool_use_id: 't1',
|
|
||||||
},
|
|
||||||
{ name: 'SomeTool', id: 't1' },
|
|
||||||
)
|
|
||||||
expect(result.content).toEqual([
|
|
||||||
{
|
|
||||||
type: 'content',
|
|
||||||
content: {
|
|
||||||
type: 'resource_link',
|
|
||||||
uri: 'file:///tmp/x.md',
|
|
||||||
name: 'file:///tmp/x.md',
|
|
||||||
mimeType: undefined,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
test('renders resource content as ACP EmbeddedResource (audit §7.3)', () => {
|
|
||||||
const result = toolUpdateFromToolResult(
|
|
||||||
{
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'resource',
|
|
||||||
resource: {
|
|
||||||
uri: 'file:///tmp/readme.md',
|
|
||||||
mimeType: 'text/markdown',
|
|
||||||
text: '# Hello',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
is_error: false,
|
|
||||||
tool_use_id: 't1',
|
|
||||||
},
|
|
||||||
{ name: 'SomeTool', id: 't1' },
|
|
||||||
)
|
|
||||||
expect(result.content).toEqual([
|
|
||||||
{
|
|
||||||
type: 'content',
|
|
||||||
content: {
|
|
||||||
type: 'resource',
|
|
||||||
resource: {
|
|
||||||
uri: 'file:///tmp/readme.md',
|
|
||||||
mimeType: 'text/markdown',
|
|
||||||
text: '# Hello',
|
|
||||||
blob: undefined,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── toolUpdateFromEditToolResponse ─────────────────────────────────
|
// ── toolUpdateFromEditToolResponse ─────────────────────────────────
|
||||||
@@ -844,56 +650,6 @@ describe('toolUpdateFromEditToolResponse', () => {
|
|||||||
}),
|
}),
|
||||||
).toEqual({})
|
).toEqual({})
|
||||||
})
|
})
|
||||||
|
|
||||||
test('resolves relative filePath against cwd (audit §5.5)', () => {
|
|
||||||
// ToolCallLocation.path / Diff.path MUST be absolute.
|
|
||||||
const result = toolUpdateFromEditToolResponse(
|
|
||||||
{
|
|
||||||
filePath: 'rel/file.ts',
|
|
||||||
structuredPatch: [
|
|
||||||
{
|
|
||||||
oldStart: 1,
|
|
||||||
oldLines: 1,
|
|
||||||
newStart: 1,
|
|
||||||
newLines: 1,
|
|
||||||
lines: ['-old', '+new'],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
'/Users/test/project',
|
|
||||||
)
|
|
||||||
expect(result).toEqual({
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'diff',
|
|
||||||
path: '/Users/test/project/rel/file.ts',
|
|
||||||
oldText: 'old',
|
|
||||||
newText: 'new',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
locations: [{ path: '/Users/test/project/rel/file.ts', line: 1 }],
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test('keeps absolute filePath unchanged when cwd provided', () => {
|
|
||||||
const result = toolUpdateFromEditToolResponse(
|
|
||||||
{
|
|
||||||
filePath: '/abs/file.ts',
|
|
||||||
structuredPatch: [
|
|
||||||
{
|
|
||||||
oldStart: 1,
|
|
||||||
oldLines: 1,
|
|
||||||
newStart: 1,
|
|
||||||
newLines: 1,
|
|
||||||
lines: ['-old', '+new'],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
'/Users/test/project',
|
|
||||||
)
|
|
||||||
expect(result.content![0]).toMatchObject({ path: '/abs/file.ts' })
|
|
||||||
expect(result.locations![0]).toMatchObject({ path: '/abs/file.ts' })
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── markdownEscape ─────────────────────────────────────────────────
|
// ── markdownEscape ─────────────────────────────────────────────────
|
||||||
@@ -1189,71 +945,7 @@ describe('forwardSessionUpdates', () => {
|
|||||||
expect(update.rawInput).not.toBe(input)
|
expect(update.rawInput).not.toBe(input)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('emits tool_call_update with status in_progress when tool_use is encountered again (audit §4.2)', async () => {
|
test('sends usage_update on result message with correct tokens', async () => {
|
||||||
// When the same tool_use block is seen twice (first via content_block_start
|
|
||||||
// in stream_event, then again in the final assistant message), the second
|
|
||||||
// encounter signals "input fully received, about to execute" and is emitted
|
|
||||||
// as a tool_call_update with status:'in_progress' per ACP v1 ToolCallStatus
|
|
||||||
// lifecycle (pending → in_progress → completed|failed).
|
|
||||||
const conn = makeConn()
|
|
||||||
const input = { command: 'ls' }
|
|
||||||
const msgs: SDKMessage[] = [
|
|
||||||
// streaming content_block_start: first sighting of tool_use
|
|
||||||
{
|
|
||||||
type: 'stream_event',
|
|
||||||
event: {
|
|
||||||
type: 'content_block_start',
|
|
||||||
content_block: {
|
|
||||||
type: 'tool_use',
|
|
||||||
id: 'tu_2',
|
|
||||||
name: 'Bash',
|
|
||||||
input: {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as unknown as SDKMessage,
|
|
||||||
// final assistant message: tool_use block with full input
|
|
||||||
{
|
|
||||||
type: 'assistant',
|
|
||||||
message: {
|
|
||||||
content: [{ type: 'tool_use', id: 'tu_2', name: 'Bash', input }],
|
|
||||||
role: 'assistant',
|
|
||||||
},
|
|
||||||
} as unknown as SDKMessage,
|
|
||||||
]
|
|
||||||
await forwardSessionUpdates(
|
|
||||||
's1',
|
|
||||||
makeStream(msgs),
|
|
||||||
conn,
|
|
||||||
new AbortController().signal,
|
|
||||||
{},
|
|
||||||
)
|
|
||||||
const calls = (conn.sessionUpdate as ReturnType<typeof mock>).mock.calls
|
|
||||||
const statuses = calls
|
|
||||||
.map((c: unknown[]) => {
|
|
||||||
const u = (c[0] as { update?: Record<string, unknown> }).update
|
|
||||||
return u && u.toolCallId === 'tu_2'
|
|
||||||
? {
|
|
||||||
sessionUpdate: u.sessionUpdate,
|
|
||||||
status: u.status,
|
|
||||||
}
|
|
||||||
: null
|
|
||||||
})
|
|
||||||
.filter(Boolean)
|
|
||||||
// First: tool_call pending; second: tool_call_update in_progress
|
|
||||||
expect(statuses[0]).toEqual({
|
|
||||||
sessionUpdate: 'tool_call',
|
|
||||||
status: 'pending',
|
|
||||||
})
|
|
||||||
expect(statuses[1]).toEqual({
|
|
||||||
sessionUpdate: 'tool_call_update',
|
|
||||||
status: 'in_progress',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test('returns accumulated usage on result message without sending usage_update when no assistant message seen', async () => {
|
|
||||||
// Without a preceding assistant message we have no reliable "tokens
|
|
||||||
// currently in context" reading, so usage_update is skipped. Token totals
|
|
||||||
// are still aggregated for the PromptResponse return value.
|
|
||||||
const conn = makeConn()
|
const conn = makeConn()
|
||||||
const msgs: SDKMessage[] = [
|
const msgs: SDKMessage[] = [
|
||||||
{
|
{
|
||||||
@@ -1281,20 +973,9 @@ describe('forwardSessionUpdates', () => {
|
|||||||
expect(result.usage).toBeDefined()
|
expect(result.usage).toBeDefined()
|
||||||
expect(result.usage!.inputTokens).toBe(100)
|
expect(result.usage!.inputTokens).toBe(100)
|
||||||
expect(result.usage!.outputTokens).toBe(50)
|
expect(result.usage!.outputTokens).toBe(50)
|
||||||
const calls = (conn.sessionUpdate as ReturnType<typeof mock>).mock.calls
|
|
||||||
const usageUpdate = calls.find(
|
|
||||||
(c: unknown[]) =>
|
|
||||||
((c[0] as Record<string, Record<string, unknown>>).update ?? {})[
|
|
||||||
'sessionUpdate'
|
|
||||||
] === 'usage_update',
|
|
||||||
)
|
|
||||||
expect(usageUpdate).toBeUndefined()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('emits usage_update with exact modelUsage context window when assistant message precedes result', async () => {
|
test('sends usage_update with context window from modelUsage', async () => {
|
||||||
// Per session-usage.mdx RFD: after a turn, emit usage_update so clients can
|
|
||||||
// display context window utilization. The size comes from modelUsage keyed
|
|
||||||
// by exact model id match.
|
|
||||||
const conn = makeConn()
|
const conn = makeConn()
|
||||||
const msgs: SDKMessage[] = [
|
const msgs: SDKMessage[] = [
|
||||||
{
|
{
|
||||||
@@ -1343,17 +1024,17 @@ describe('forwardSessionUpdates', () => {
|
|||||||
] === 'usage_update',
|
] === 'usage_update',
|
||||||
)
|
)
|
||||||
expect(usageUpdate).toBeDefined()
|
expect(usageUpdate).toBeDefined()
|
||||||
const update = (
|
expect(
|
||||||
usageUpdate![0] as { update: { used: number; size: number } }
|
(
|
||||||
).update
|
(usageUpdate![0] as Record<string, unknown>).update as Record<
|
||||||
// used = lastAssistantTotalUsage = 100 + 50 + 10 + 5 = 165
|
string,
|
||||||
expect(update.used).toBe(165)
|
unknown
|
||||||
expect(update.size).toBe(1000000)
|
>
|
||||||
|
).size,
|
||||||
|
).toBe(1000000)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('emits usage_update with prefix-matched modelUsage context window', async () => {
|
test('sends usage_update with prefix-matched modelUsage', async () => {
|
||||||
// Model id 'claude-opus-4-6-20250514' prefix-matches the modelUsage key
|
|
||||||
// 'claude-opus-4-6' to resolve contextWindow = 2000000.
|
|
||||||
const conn = makeConn()
|
const conn = makeConn()
|
||||||
const msgs: SDKMessage[] = [
|
const msgs: SDKMessage[] = [
|
||||||
{
|
{
|
||||||
@@ -1402,129 +1083,17 @@ describe('forwardSessionUpdates', () => {
|
|||||||
] === 'usage_update',
|
] === 'usage_update',
|
||||||
)
|
)
|
||||||
expect(usageUpdate).toBeDefined()
|
expect(usageUpdate).toBeDefined()
|
||||||
const update = (
|
expect(
|
||||||
usageUpdate![0] as { update: { used: number; size: number } }
|
(
|
||||||
).update
|
(usageUpdate![0] as Record<string, unknown>).update as Record<
|
||||||
expect(update.used).toBe(150)
|
string,
|
||||||
expect(update.size).toBe(2000000)
|
unknown
|
||||||
|
>
|
||||||
|
).size,
|
||||||
|
).toBe(2000000)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('maps refusal stop_reason to ACP refusal stop reason', async () => {
|
test('resets usage on compact_boundary', async () => {
|
||||||
// Audit §3.3: a safety refusal must surface as StopReason::refusal rather
|
|
||||||
// than being misreported as end_turn.
|
|
||||||
const conn = makeConn()
|
|
||||||
const msgs: SDKMessage[] = [
|
|
||||||
{
|
|
||||||
type: 'result',
|
|
||||||
subtype: 'success',
|
|
||||||
is_error: false,
|
|
||||||
result: '',
|
|
||||||
stop_reason: 'refusal',
|
|
||||||
} as unknown as SDKMessage,
|
|
||||||
]
|
|
||||||
const result = await forwardSessionUpdates(
|
|
||||||
's1',
|
|
||||||
makeStream(msgs),
|
|
||||||
conn,
|
|
||||||
new AbortController().signal,
|
|
||||||
{},
|
|
||||||
)
|
|
||||||
expect(result.stopReason).toBe('refusal')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('success with max_tokens stop_reason maps to max_tokens when not error', async () => {
|
|
||||||
// Audit §3.3/§3.4: success + max_tokens + no error surfaces max_tokens.
|
|
||||||
const conn = makeConn()
|
|
||||||
const msgs: SDKMessage[] = [
|
|
||||||
{
|
|
||||||
type: 'result',
|
|
||||||
subtype: 'success',
|
|
||||||
is_error: false,
|
|
||||||
result: '',
|
|
||||||
stop_reason: 'max_tokens',
|
|
||||||
} as unknown as SDKMessage,
|
|
||||||
]
|
|
||||||
const result = await forwardSessionUpdates(
|
|
||||||
's1',
|
|
||||||
makeStream(msgs),
|
|
||||||
conn,
|
|
||||||
new AbortController().signal,
|
|
||||||
{},
|
|
||||||
)
|
|
||||||
expect(result.stopReason).toBe('max_tokens')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('success with max_tokens stop_reason falls back to end_turn when isError', async () => {
|
|
||||||
// Audit §3.3: in the success branch, isError acts as a last-resort
|
|
||||||
// override to end_turn per the merged fix diff.
|
|
||||||
const conn = makeConn()
|
|
||||||
const msgs: SDKMessage[] = [
|
|
||||||
{
|
|
||||||
type: 'result',
|
|
||||||
subtype: 'success',
|
|
||||||
is_error: true,
|
|
||||||
result: '',
|
|
||||||
stop_reason: 'max_tokens',
|
|
||||||
} as unknown as SDKMessage,
|
|
||||||
]
|
|
||||||
const result = await forwardSessionUpdates(
|
|
||||||
's1',
|
|
||||||
makeStream(msgs),
|
|
||||||
conn,
|
|
||||||
new AbortController().signal,
|
|
||||||
{},
|
|
||||||
)
|
|
||||||
expect(result.stopReason).toBe('end_turn')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('maps error_during_execution with max_tokens stop_reason', async () => {
|
|
||||||
// Audit §3.4: error_during_execution branch must preserve max_tokens even
|
|
||||||
// when isError is set (mutually exclusive branches).
|
|
||||||
const conn = makeConn()
|
|
||||||
const msgs: SDKMessage[] = [
|
|
||||||
{
|
|
||||||
type: 'result',
|
|
||||||
subtype: 'error_during_execution',
|
|
||||||
is_error: true,
|
|
||||||
result: '',
|
|
||||||
stop_reason: 'max_tokens',
|
|
||||||
} as unknown as SDKMessage,
|
|
||||||
]
|
|
||||||
const result = await forwardSessionUpdates(
|
|
||||||
's1',
|
|
||||||
makeStream(msgs),
|
|
||||||
conn,
|
|
||||||
new AbortController().signal,
|
|
||||||
{},
|
|
||||||
)
|
|
||||||
expect(result.stopReason).toBe('max_tokens')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('maps error_during_execution without max_tokens to end_turn', async () => {
|
|
||||||
const conn = makeConn()
|
|
||||||
const msgs: SDKMessage[] = [
|
|
||||||
{
|
|
||||||
type: 'result',
|
|
||||||
subtype: 'error_during_execution',
|
|
||||||
is_error: true,
|
|
||||||
result: '',
|
|
||||||
stop_reason: 'end_turn',
|
|
||||||
} as unknown as SDKMessage,
|
|
||||||
]
|
|
||||||
const result = await forwardSessionUpdates(
|
|
||||||
's1',
|
|
||||||
makeStream(msgs),
|
|
||||||
conn,
|
|
||||||
new AbortController().signal,
|
|
||||||
{},
|
|
||||||
)
|
|
||||||
expect(result.stopReason).toBe('end_turn')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('compact_boundary emits completion message without usage_update', async () => {
|
|
||||||
// After audit §4.1, compact_boundary still sends the "Compacting completed."
|
|
||||||
// agent_message_chunk but no longer emits the unstable usage_update
|
|
||||||
// notification.
|
|
||||||
const conn = makeConn()
|
const conn = makeConn()
|
||||||
const msgs: SDKMessage[] = [
|
const msgs: SDKMessage[] = [
|
||||||
{ type: 'system', subtype: 'compact_boundary' } as unknown as SDKMessage,
|
{ type: 'system', subtype: 'compact_boundary' } as unknown as SDKMessage,
|
||||||
@@ -1543,14 +1112,15 @@ describe('forwardSessionUpdates', () => {
|
|||||||
'sessionUpdate'
|
'sessionUpdate'
|
||||||
] === 'usage_update',
|
] === 'usage_update',
|
||||||
)
|
)
|
||||||
expect(usageCall).toBeUndefined()
|
expect(usageCall).toBeDefined()
|
||||||
const messageCall = calls.find(
|
expect(
|
||||||
(c: unknown[]) =>
|
(
|
||||||
((c[0] as Record<string, Record<string, unknown>>).update ?? {})[
|
(usageCall![0] as Record<string, unknown>).update as Record<
|
||||||
'sessionUpdate'
|
string,
|
||||||
] === 'agent_message_chunk',
|
unknown
|
||||||
)
|
>
|
||||||
expect(messageCall).toBeDefined()
|
).used,
|
||||||
|
).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('ignores unknown message types without crashing', async () => {
|
test('ignores unknown message types without crashing', async () => {
|
||||||
@@ -1596,278 +1166,3 @@ describe('forwardSessionUpdates', () => {
|
|||||||
).rejects.toThrow('stream exploded')
|
).rejects.toThrow('stream exploded')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── message-id (RFD) ──────────────────────────────────────────────
|
|
||||||
//
|
|
||||||
// Per rfds/message-id.mdx: agent_message_chunk / user_message_chunk /
|
|
||||||
// agent_thought_chunk MUST carry a `messageId` (UUID). All chunks of the
|
|
||||||
// same message share the ID; different messages get different IDs. tool_call
|
|
||||||
// and plan updates are out of scope and must NOT carry messageId.
|
|
||||||
|
|
||||||
describe('forwardSessionUpdates — message-id (RFD)', () => {
|
|
||||||
test('attaches messageId to assistant text chunk (non-streaming)', async () => {
|
|
||||||
const conn = makeConn()
|
|
||||||
const msgs: SDKMessage[] = [
|
|
||||||
{
|
|
||||||
type: 'assistant',
|
|
||||||
parent_tool_use_id: null,
|
|
||||||
message: {
|
|
||||||
content: [{ type: 'text', text: 'Hello!' }],
|
|
||||||
role: 'assistant',
|
|
||||||
},
|
|
||||||
} as unknown as SDKMessage,
|
|
||||||
]
|
|
||||||
await forwardSessionUpdates(
|
|
||||||
's1',
|
|
||||||
makeStream(msgs),
|
|
||||||
conn,
|
|
||||||
new AbortController().signal,
|
|
||||||
{},
|
|
||||||
)
|
|
||||||
const calls = (conn.sessionUpdate as ReturnType<typeof mock>).mock.calls
|
|
||||||
const chunkCall = calls.find(
|
|
||||||
(c: unknown[]) =>
|
|
||||||
((c[0] as Record<string, Record<string, unknown>>).update ?? {})[
|
|
||||||
'sessionUpdate'
|
|
||||||
] === 'agent_message_chunk',
|
|
||||||
)
|
|
||||||
expect(chunkCall).toBeDefined()
|
|
||||||
const update = (chunkCall![0] as { update: Record<string, unknown> }).update
|
|
||||||
expect(typeof update.messageId).toBe('string')
|
|
||||||
// UUID format check (v4-ish, 36 chars with hyphens)
|
|
||||||
expect(update.messageId).toMatch(
|
|
||||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('different assistant messages get different messageIds', async () => {
|
|
||||||
const conn = makeConn()
|
|
||||||
const msgs: SDKMessage[] = [
|
|
||||||
{
|
|
||||||
type: 'assistant',
|
|
||||||
parent_tool_use_id: null,
|
|
||||||
message: {
|
|
||||||
content: [{ type: 'text', text: 'First' }],
|
|
||||||
role: 'assistant',
|
|
||||||
},
|
|
||||||
} as unknown as SDKMessage,
|
|
||||||
{
|
|
||||||
type: 'assistant',
|
|
||||||
parent_tool_use_id: null,
|
|
||||||
message: {
|
|
||||||
content: [{ type: 'text', text: 'Second' }],
|
|
||||||
role: 'assistant',
|
|
||||||
},
|
|
||||||
} as unknown as SDKMessage,
|
|
||||||
]
|
|
||||||
await forwardSessionUpdates(
|
|
||||||
's1',
|
|
||||||
makeStream(msgs),
|
|
||||||
conn,
|
|
||||||
new AbortController().signal,
|
|
||||||
{},
|
|
||||||
)
|
|
||||||
const calls = (conn.sessionUpdate as ReturnType<typeof mock>).mock.calls
|
|
||||||
const chunkCalls = calls.filter(
|
|
||||||
(c: unknown[]) =>
|
|
||||||
((c[0] as Record<string, Record<string, unknown>>).update ?? {})[
|
|
||||||
'sessionUpdate'
|
|
||||||
] === 'agent_message_chunk',
|
|
||||||
)
|
|
||||||
expect(chunkCalls.length).toBe(2)
|
|
||||||
const id1 = (chunkCalls[0][0] as { update: { messageId: string } }).update
|
|
||||||
.messageId
|
|
||||||
const id2 = (chunkCalls[1][0] as { update: { messageId: string } }).update
|
|
||||||
.messageId
|
|
||||||
expect(id1).not.toBe(id2)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('streaming text + thinking chunks share the same messageId', async () => {
|
|
||||||
// stream_events for a single assistant message (text + thinking) must
|
|
||||||
// share one messageId, then the assistant message itself reuses it.
|
|
||||||
const conn = makeConn()
|
|
||||||
const msgs: SDKMessage[] = [
|
|
||||||
{
|
|
||||||
type: 'stream_event',
|
|
||||||
parent_tool_use_id: null,
|
|
||||||
event: {
|
|
||||||
type: 'content_block_start',
|
|
||||||
content_block: { type: 'thinking', thinking: '' },
|
|
||||||
},
|
|
||||||
} as unknown as SDKMessage,
|
|
||||||
{
|
|
||||||
type: 'stream_event',
|
|
||||||
parent_tool_use_id: null,
|
|
||||||
event: {
|
|
||||||
type: 'content_block_delta',
|
|
||||||
delta: { type: 'thinking_delta', thinking: 'reasoning...' },
|
|
||||||
},
|
|
||||||
} as unknown as SDKMessage,
|
|
||||||
{
|
|
||||||
type: 'stream_event',
|
|
||||||
parent_tool_use_id: null,
|
|
||||||
event: {
|
|
||||||
type: 'content_block_start',
|
|
||||||
content_block: { type: 'text', text: '' },
|
|
||||||
},
|
|
||||||
} as unknown as SDKMessage,
|
|
||||||
{
|
|
||||||
type: 'stream_event',
|
|
||||||
parent_tool_use_id: null,
|
|
||||||
event: {
|
|
||||||
type: 'content_block_delta',
|
|
||||||
delta: { type: 'text_delta', text: 'Answer' },
|
|
||||||
},
|
|
||||||
} as unknown as SDKMessage,
|
|
||||||
{
|
|
||||||
type: 'assistant',
|
|
||||||
parent_tool_use_id: null,
|
|
||||||
message: {
|
|
||||||
content: [
|
|
||||||
{ type: 'thinking', thinking: 'reasoning...' },
|
|
||||||
{ type: 'text', text: 'Answer' },
|
|
||||||
],
|
|
||||||
role: 'assistant',
|
|
||||||
},
|
|
||||||
} as unknown as SDKMessage,
|
|
||||||
]
|
|
||||||
await forwardSessionUpdates(
|
|
||||||
's1',
|
|
||||||
makeStream(msgs),
|
|
||||||
conn,
|
|
||||||
new AbortController().signal,
|
|
||||||
{},
|
|
||||||
)
|
|
||||||
const calls = (conn.sessionUpdate as ReturnType<typeof mock>).mock.calls
|
|
||||||
const chunkCalls = calls
|
|
||||||
.map(c => (c[0] as { update: Record<string, unknown> }).update)
|
|
||||||
.filter(
|
|
||||||
u =>
|
|
||||||
u.sessionUpdate === 'agent_message_chunk' ||
|
|
||||||
u.sessionUpdate === 'agent_thought_chunk',
|
|
||||||
)
|
|
||||||
// streamingActive filters out the duplicate text/thinking from the
|
|
||||||
// final assistant message, so we only get the 4 streaming chunks here.
|
|
||||||
expect(chunkCalls.length).toBeGreaterThanOrEqual(4)
|
|
||||||
const ids = chunkCalls.map(u => u.messageId)
|
|
||||||
const uniqueIds = new Set(ids)
|
|
||||||
expect(uniqueIds.size).toBe(1)
|
|
||||||
expect(typeof ids[0]).toBe('string')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('tool_call chunk does NOT carry messageId', async () => {
|
|
||||||
const conn = makeConn()
|
|
||||||
const msgs: SDKMessage[] = [
|
|
||||||
{
|
|
||||||
type: 'assistant',
|
|
||||||
parent_tool_use_id: null,
|
|
||||||
message: {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'tool_use',
|
|
||||||
id: 'tu_mid',
|
|
||||||
name: 'Bash',
|
|
||||||
input: { command: 'ls' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
role: 'assistant',
|
|
||||||
},
|
|
||||||
} as unknown as SDKMessage,
|
|
||||||
]
|
|
||||||
await forwardSessionUpdates(
|
|
||||||
's1',
|
|
||||||
makeStream(msgs),
|
|
||||||
conn,
|
|
||||||
new AbortController().signal,
|
|
||||||
{},
|
|
||||||
)
|
|
||||||
const calls = (conn.sessionUpdate as ReturnType<typeof mock>).mock.calls
|
|
||||||
const toolCall = calls
|
|
||||||
.map(c => (c[0] as { update: Record<string, unknown> }).update)
|
|
||||||
.find(u => u.sessionUpdate === 'tool_call')
|
|
||||||
expect(toolCall).toBeDefined()
|
|
||||||
expect(toolCall!.messageId).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('subagent stream_events do not carry messageId (parent_tool_use_id !== null)', async () => {
|
|
||||||
// Subagent messages are nested inside a tool call; per our scope decision
|
|
||||||
// we only track top-level messageIds, so subagent chunks must NOT carry one.
|
|
||||||
const conn = makeConn()
|
|
||||||
const msgs: SDKMessage[] = [
|
|
||||||
{
|
|
||||||
type: 'stream_event',
|
|
||||||
parent_tool_use_id: 'tu_subagent',
|
|
||||||
event: {
|
|
||||||
type: 'content_block_delta',
|
|
||||||
delta: { type: 'text_delta', text: 'subagent text' },
|
|
||||||
},
|
|
||||||
} as unknown as SDKMessage,
|
|
||||||
]
|
|
||||||
await forwardSessionUpdates(
|
|
||||||
's1',
|
|
||||||
makeStream(msgs),
|
|
||||||
conn,
|
|
||||||
new AbortController().signal,
|
|
||||||
{},
|
|
||||||
)
|
|
||||||
const calls = (conn.sessionUpdate as ReturnType<typeof mock>).mock.calls
|
|
||||||
const chunkCall = calls
|
|
||||||
.map(c => (c[0] as { update: Record<string, unknown> }).update)
|
|
||||||
.find(u => u.sessionUpdate === 'agent_message_chunk')
|
|
||||||
expect(chunkCall).toBeDefined()
|
|
||||||
expect(chunkCall!.messageId).toBeUndefined()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// ── replayHistoryMessages — message-id (RFD) ─────────────────────
|
|
||||||
|
|
||||||
describe('replayHistoryMessages — message-id (RFD)', () => {
|
|
||||||
test('each replayed message gets its own messageId', async () => {
|
|
||||||
const conn = makeConn()
|
|
||||||
const messages: Array<Record<string, unknown>> = [
|
|
||||||
{
|
|
||||||
type: 'user',
|
|
||||||
message: { content: [{ type: 'text', text: 'question' }] },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'assistant',
|
|
||||||
message: { content: [{ type: 'text', text: 'answer' }] },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'assistant',
|
|
||||||
message: { content: [{ type: 'text', text: 'follow-up' }] },
|
|
||||||
},
|
|
||||||
]
|
|
||||||
await replayHistoryMessages('s1', messages, conn, {}, undefined, undefined)
|
|
||||||
const calls = (conn.sessionUpdate as ReturnType<typeof mock>).mock.calls
|
|
||||||
const chunkCalls = calls
|
|
||||||
.map(c => (c[0] as { update: Record<string, unknown> }).update)
|
|
||||||
.filter(
|
|
||||||
u =>
|
|
||||||
u.sessionUpdate === 'agent_message_chunk' ||
|
|
||||||
u.sessionUpdate === 'user_message_chunk',
|
|
||||||
)
|
|
||||||
expect(chunkCalls.length).toBe(3)
|
|
||||||
const ids = chunkCalls.map(u => u.messageId)
|
|
||||||
expect(ids.every(id => typeof id === 'string')).toBe(true)
|
|
||||||
// All three IDs should be distinct (one per message)
|
|
||||||
expect(new Set(ids).size).toBe(3)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('replayed string-content message carries messageId', async () => {
|
|
||||||
const conn = makeConn()
|
|
||||||
const messages: Array<Record<string, unknown>> = [
|
|
||||||
{
|
|
||||||
type: 'assistant',
|
|
||||||
message: { content: 'plain string reply' },
|
|
||||||
},
|
|
||||||
]
|
|
||||||
await replayHistoryMessages('s1', messages, conn, {}, undefined, undefined)
|
|
||||||
const calls = (conn.sessionUpdate as ReturnType<typeof mock>).mock.calls
|
|
||||||
const chunkCall = calls
|
|
||||||
.map(c => (c[0] as { update: Record<string, unknown> }).update)
|
|
||||||
.find(u => u.sessionUpdate === 'agent_message_chunk')
|
|
||||||
expect(chunkCall).toBeDefined()
|
|
||||||
expect(typeof chunkCall!.messageId).toBe('string')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -234,7 +234,7 @@ describe('createAcpCanUseTool', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test('options include allow always, allow once, reject once, and reject always', async () => {
|
test('options include allow always, allow once, and reject once', async () => {
|
||||||
const conn = makeConn({ outcome: { outcome: 'cancelled' } })
|
const conn = makeConn({ outcome: { outcome: 'cancelled' } })
|
||||||
const canUseTool = createAcpCanUseTool(conn, 'sess-3', () => 'default')
|
const canUseTool = createAcpCanUseTool(conn, 'sess-3', () => 'default')
|
||||||
await canUseTool(makeTool('Write'), {}, dummyContext, dummyMsg, 'tu_8')
|
await canUseTool(makeTool('Write'), {}, dummyContext, dummyMsg, 'tu_8')
|
||||||
@@ -245,7 +245,6 @@ describe('createAcpCanUseTool', () => {
|
|||||||
expect(opts.find(option => option.kind === 'allow_always')).toBeTruthy()
|
expect(opts.find(option => option.kind === 'allow_always')).toBeTruthy()
|
||||||
expect(opts.find(option => option.kind === 'allow_once')).toBeTruthy()
|
expect(opts.find(option => option.kind === 'allow_once')).toBeTruthy()
|
||||||
expect(opts.find(option => option.kind === 'reject_once')).toBeTruthy()
|
expect(opts.find(option => option.kind === 'reject_once')).toBeTruthy()
|
||||||
expect(opts.find(option => option.kind === 'reject_always')).toBeTruthy()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('ExitPlanMode omits bypass option when the session does not expose it', async () => {
|
test('ExitPlanMode omits bypass option when the session does not expose it', async () => {
|
||||||
@@ -333,92 +332,4 @@ describe('createAcpCanUseTool', () => {
|
|||||||
(conn.sessionUpdate as ReturnType<typeof mock>).mock.calls,
|
(conn.sessionUpdate as ReturnType<typeof mock>).mock.calls,
|
||||||
).toHaveLength(0)
|
).toHaveLength(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('checkTerminalOutput honors standard clientCapabilities.terminal', async () => {
|
|
||||||
// Standard ACP v1 client advertises terminal: true without any _meta hint.
|
|
||||||
const conn = makeConn({ outcome: { outcome: 'cancelled' } })
|
|
||||||
const capabilities = { terminal: true } as any
|
|
||||||
const canUseTool = createAcpCanUseTool(
|
|
||||||
conn,
|
|
||||||
'sess-term',
|
|
||||||
() => 'default',
|
|
||||||
capabilities,
|
|
||||||
)
|
|
||||||
await canUseTool(makeTool('Bash'), {}, dummyContext, dummyMsg, 'tu_term')
|
|
||||||
|
|
||||||
const { toolCall } = (conn.requestPermission as ReturnType<typeof mock>)
|
|
||||||
.mock.calls[0][0] as Record<string, unknown>
|
|
||||||
// toolInfoFromToolUse is mocked; we only assert the standard capability is
|
|
||||||
// respected (no crash, request delegated). The legacy _meta path is
|
|
||||||
// exercised separately below.
|
|
||||||
expect(toolCall).toBeDefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('checkTerminalOutput falls back to legacy _meta.terminal_output', async () => {
|
|
||||||
const conn = makeConn({ outcome: { outcome: 'cancelled' } })
|
|
||||||
const capabilities = { _meta: { terminal_output: true } } as any
|
|
||||||
const canUseTool = createAcpCanUseTool(
|
|
||||||
conn,
|
|
||||||
'sess-term-legacy',
|
|
||||||
() => 'default',
|
|
||||||
capabilities,
|
|
||||||
)
|
|
||||||
await canUseTool(makeTool('Bash'), {}, dummyContext, dummyMsg, 'tu_term2')
|
|
||||||
|
|
||||||
expect(
|
|
||||||
(conn.requestPermission as ReturnType<typeof mock>).mock.calls,
|
|
||||||
).toHaveLength(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('cancelled permission outcome invokes onPermissionCancelled callback', async () => {
|
|
||||||
const conn = makeConn({ outcome: { outcome: 'cancelled' } })
|
|
||||||
const onPermissionCancelled = mock(() => {})
|
|
||||||
const canUseTool = createAcpCanUseTool(
|
|
||||||
conn,
|
|
||||||
'sess-cancel',
|
|
||||||
() => 'default',
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
onPermissionCancelled,
|
|
||||||
)
|
|
||||||
|
|
||||||
const result = await canUseTool(
|
|
||||||
makeTool('Bash'),
|
|
||||||
{},
|
|
||||||
dummyContext,
|
|
||||||
dummyMsg,
|
|
||||||
'tu_cancel',
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(result.behavior).toBe('deny')
|
|
||||||
expect(onPermissionCancelled).toHaveBeenCalledTimes(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('ExitPlanMode cancelled outcome invokes onPermissionCancelled callback', async () => {
|
|
||||||
const conn = makeConn({ outcome: { outcome: 'cancelled' } })
|
|
||||||
const onPermissionCancelled = mock(() => {})
|
|
||||||
const canUseTool = createAcpCanUseTool(
|
|
||||||
conn,
|
|
||||||
'sess-cancel-plan',
|
|
||||||
() => 'plan',
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
onPermissionCancelled,
|
|
||||||
)
|
|
||||||
|
|
||||||
const result = await canUseTool(
|
|
||||||
makeTool('ExitPlanMode'),
|
|
||||||
{},
|
|
||||||
dummyContext,
|
|
||||||
dummyMsg,
|
|
||||||
'tu_cancel_plan',
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(result.behavior).toBe('deny')
|
|
||||||
expect(onPermissionCancelled).toHaveBeenCalledTimes(1)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -25,31 +25,4 @@ describe('promptToQueryInput', () => {
|
|||||||
]),
|
]),
|
||||||
).toBe('Resource link: name=Spec, uri=file:///tmp/spec.md')
|
).toBe('Resource link: name=Spec, uri=file:///tmp/spec.md')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('renders BlobResource as a readable placeholder instead of dropping it', () => {
|
|
||||||
const result = promptToQueryInput([
|
|
||||||
{
|
|
||||||
type: 'resource',
|
|
||||||
resource: {
|
|
||||||
uri: 'file:///tmp/report.pdf',
|
|
||||||
mimeType: 'application/pdf',
|
|
||||||
blob: 'aGVsbG8=',
|
|
||||||
},
|
|
||||||
} as any,
|
|
||||||
])
|
|
||||||
expect(result).toContain('Embedded resource: file:///tmp/report.pdf')
|
|
||||||
expect(result).toContain('application/pdf')
|
|
||||||
expect(result).toContain('base64 blob')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('BlobResource without mimeType or uri falls back to defaults', () => {
|
|
||||||
const result = promptToQueryInput([
|
|
||||||
{
|
|
||||||
type: 'resource',
|
|
||||||
resource: { blob: 'aGVsbG8=' },
|
|
||||||
} as any,
|
|
||||||
])
|
|
||||||
expect(result).toContain('(unknown uri)')
|
|
||||||
expect(result).toContain('application/octet-stream')
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,479 +0,0 @@
|
|||||||
/**
|
|
||||||
* ACP Agent implementation — bridges ACP protocol methods to Claude Code's
|
|
||||||
* internal QueryEngine / query() pipeline.
|
|
||||||
*
|
|
||||||
* Architecture: Uses internal QueryEngine (not @anthropic-ai/claude-agent-sdk)
|
|
||||||
* to directly run queries, with a bridge layer converting SDKMessage → ACP SessionUpdate.
|
|
||||||
*
|
|
||||||
* NOTE: The AcpAgent class is split across three modules for line-budget reasons.
|
|
||||||
* The class shell + lightweight protocol handlers live here; the heavy
|
|
||||||
* session-lifecycle methods (createSession / getOrCreateSession /
|
|
||||||
* replaySessionHistory / teardownSession / applySessionMode / updateConfigOption)
|
|
||||||
* are attached to the prototype in `./sessionLifecycle.js`, and the prompt
|
|
||||||
* flow (prompt / setSessionConfigOption) in `./promptFlow.js`. The barrel
|
|
||||||
* `./index.js` imports those side-effect modules so the prototype is fully
|
|
||||||
* populated before any AcpAgent instance is constructed.
|
|
||||||
*/
|
|
||||||
import {
|
|
||||||
RequestError,
|
|
||||||
type Agent,
|
|
||||||
type AgentSideConnection,
|
|
||||||
type InitializeRequest,
|
|
||||||
type InitializeResponse,
|
|
||||||
type AuthenticateRequest,
|
|
||||||
type AuthenticateResponse,
|
|
||||||
type NewSessionRequest,
|
|
||||||
type NewSessionResponse,
|
|
||||||
type PromptRequest,
|
|
||||||
type PromptResponse,
|
|
||||||
type CancelNotification,
|
|
||||||
type LoadSessionRequest,
|
|
||||||
type LoadSessionResponse,
|
|
||||||
type ListSessionsRequest,
|
|
||||||
type ListSessionsResponse,
|
|
||||||
type ResumeSessionRequest,
|
|
||||||
type ResumeSessionResponse,
|
|
||||||
type ForkSessionRequest,
|
|
||||||
type ForkSessionResponse,
|
|
||||||
type CloseSessionRequest,
|
|
||||||
type CloseSessionResponse,
|
|
||||||
type SetSessionModeRequest,
|
|
||||||
type SetSessionModeResponse,
|
|
||||||
type SetSessionModelRequest,
|
|
||||||
type SetSessionModelResponse,
|
|
||||||
type SetSessionConfigOptionRequest,
|
|
||||||
type SetSessionConfigOptionResponse,
|
|
||||||
type ClientCapabilities,
|
|
||||||
} from '@agentclientprotocol/sdk'
|
|
||||||
import { unlink } from 'node:fs/promises'
|
|
||||||
import type { Message } from '../../../types/message.js'
|
|
||||||
import { sanitizeTitle } from '../utils.js'
|
|
||||||
import { listSessionsImpl } from '../../../utils/listSessionsImpl.js'
|
|
||||||
import {
|
|
||||||
resolveSessionFilePath,
|
|
||||||
canonicalizePath,
|
|
||||||
} from '../../../utils/sessionStoragePortable.js'
|
|
||||||
import { getOriginalCwd } from '../../../bootstrap/state.js'
|
|
||||||
import type { AcpSession } from './sessionTypes.js'
|
|
||||||
|
|
||||||
// ── Agent class ───────────────────────────────────────────────────
|
|
||||||
//
|
|
||||||
// NOTE: This class is intentionally merged with the `AcpAgent` interface
|
|
||||||
// declared at the bottom of this file. The merged interface declares methods
|
|
||||||
// that are attached to AcpAgent.prototype at module load time by the sibling
|
|
||||||
// side-effect modules (createSessionMethod.ts / sessionLifecycle.ts /
|
|
||||||
// promptFlow.ts) imported by the barrel (./agent.ts). This is the standard
|
|
||||||
// prototype-augmentation pattern and is safe because the barrel guarantees
|
|
||||||
// the side-effect imports run before any instance is constructed.
|
|
||||||
// biome-ignore lint/suspicious/noUnsafeDeclarationMerging: prototype-augmentation pattern — merged interface methods are attached to AcpAgent.prototype by sibling side-effect modules imported by the barrel (./agent.ts) before any instance is constructed.
|
|
||||||
export class AcpAgent implements Agent {
|
|
||||||
private conn: AgentSideConnection
|
|
||||||
sessions = new Map<string, AcpSession>()
|
|
||||||
private clientCapabilities?: ClientCapabilities
|
|
||||||
|
|
||||||
constructor(conn: AgentSideConnection) {
|
|
||||||
this.conn = conn
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── initialize ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async initialize(params: InitializeRequest): Promise<InitializeResponse> {
|
|
||||||
this.clientCapabilities = params.clientCapabilities
|
|
||||||
|
|
||||||
return {
|
|
||||||
protocolVersion: 1,
|
|
||||||
// Explicit empty authMethods signals "no authentication required" to
|
|
||||||
// Clients rather than "capability unknown". Matches authenticate() no-op.
|
|
||||||
authMethods: [],
|
|
||||||
agentInfo: {
|
|
||||||
name: 'claude-code',
|
|
||||||
title: 'Claude Code',
|
|
||||||
version:
|
|
||||||
typeof (globalThis as unknown as Record<string, unknown>).MACRO ===
|
|
||||||
'object' &&
|
|
||||||
(globalThis as unknown as Record<string, Record<string, unknown>>)
|
|
||||||
.MACRO !== null
|
|
||||||
? String(
|
|
||||||
(
|
|
||||||
(
|
|
||||||
globalThis as unknown as Record<
|
|
||||||
string,
|
|
||||||
Record<string, unknown>
|
|
||||||
>
|
|
||||||
).MACRO as Record<string, unknown>
|
|
||||||
).VERSION ?? '0.0.0',
|
|
||||||
)
|
|
||||||
: '0.0.0',
|
|
||||||
},
|
|
||||||
agentCapabilities: {
|
|
||||||
_meta: {
|
|
||||||
claudeCode: {
|
|
||||||
promptQueueing: true,
|
|
||||||
// session/fork is UNSTABLE — not part of stable v1 SessionCapabilities.
|
|
||||||
// Advertise via _meta namespace per extensibility.mdx "Advertising
|
|
||||||
// Custom Capabilities" instead of the standard sessionCapabilities map.
|
|
||||||
forkSession: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// image:false — promptToQueryInput() does not parse ContentBlock::Image
|
|
||||||
// blocks yet. Re-enable only after multimodal query input support lands.
|
|
||||||
promptCapabilities: {
|
|
||||||
image: false,
|
|
||||||
embeddedContext: true,
|
|
||||||
},
|
|
||||||
mcpCapabilities: {
|
|
||||||
http: true,
|
|
||||||
sse: true,
|
|
||||||
},
|
|
||||||
loadSession: true,
|
|
||||||
sessionCapabilities: {
|
|
||||||
list: {},
|
|
||||||
resume: {},
|
|
||||||
close: {},
|
|
||||||
// UNSTABLE per session-delete.mdx: capability-gated session/delete.
|
|
||||||
// SDK 0.19.0's SessionCapabilities type predates this field — clients
|
|
||||||
// implementing the RFD read `sessionCapabilities.delete`, so we
|
|
||||||
// advertise it at the standard path via type augmentation.
|
|
||||||
...({ delete: {} } as { delete: Record<string, never> }),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── authenticate ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
async authenticate(
|
|
||||||
_params: AuthenticateRequest,
|
|
||||||
): Promise<AuthenticateResponse> {
|
|
||||||
// No authentication required — this is a self-hosted/custom deployment
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── newSession ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
|
|
||||||
const result = await this.createSession(params)
|
|
||||||
this.scheduleAvailableCommandsUpdate(result.sessionId)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── resumeSession ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
async unstable_resumeSession(
|
|
||||||
params: ResumeSessionRequest,
|
|
||||||
): Promise<ResumeSessionResponse> {
|
|
||||||
// Per session-setup.mdx "Resuming a Session": the Agent MUST NOT replay the
|
|
||||||
// conversation history via session/update notifications before responding.
|
|
||||||
// Only restore context + MCP connections, then return immediately. This
|
|
||||||
// differs from session/load which DOES replay history.
|
|
||||||
const result = await this.getOrCreateSession({ ...params, replay: false })
|
|
||||||
this.scheduleAvailableCommandsUpdate(result.sessionId)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── loadSession ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse> {
|
|
||||||
const result = await this.getOrCreateSession(params)
|
|
||||||
this.scheduleAvailableCommandsUpdate(result.sessionId)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── listSessions ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
async listSessions(
|
|
||||||
params: ListSessionsRequest,
|
|
||||||
): Promise<ListSessionsResponse> {
|
|
||||||
// Pagination is not implemented: we always return all available sessions
|
|
||||||
// for the requested cwd (no nextCursor). Per session-list.mdx the Agent
|
|
||||||
// SHOULD return an error if the cursor is invalid, so explicitly reject
|
|
||||||
// any client-supplied cursor rather than silently accepting it.
|
|
||||||
if (params.cursor !== undefined && params.cursor !== null) {
|
|
||||||
throw new Error(
|
|
||||||
'Pagination cursor not supported: listSessions returns all results in a single page.',
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve the effective cwd: client-provided wins, fall back to the
|
|
||||||
// agent's current working directory (set by the most recent session/new
|
|
||||||
// or session/load). Standard ACP clients (e.g. Goose) call session/list
|
|
||||||
// with empty params and no cwd — without a fallback, listSessionsImpl
|
|
||||||
// treats undefined dir as "all projects" and returns every session on
|
|
||||||
// disk, which is unrelated to the workspace the user actually has open.
|
|
||||||
const requestedCwd = params.cwd || getOriginalCwd()
|
|
||||||
const canonicalRequested = await canonicalizePath(requestedCwd)
|
|
||||||
|
|
||||||
const candidates = await listSessionsImpl({
|
|
||||||
dir: requestedCwd,
|
|
||||||
})
|
|
||||||
|
|
||||||
const sessions = []
|
|
||||||
for (const candidate of candidates) {
|
|
||||||
if (!candidate.cwd) continue
|
|
||||||
// Per session-list.mdx: "Only sessions with a matching cwd are
|
|
||||||
// returned." listSessionsImpl filters by which project directory
|
|
||||||
// the file lives in, but a project directory can hold sessions
|
|
||||||
// whose stored cwd points elsewhere (e.g. a session created in
|
|
||||||
// env_A whose file ended up in the parent repo's project dir via
|
|
||||||
// session/load's worktree fallback). Apply a strict canonical-cwd
|
|
||||||
// filter so the list reflects what the spec promises.
|
|
||||||
const canonicalCandidate = await canonicalizePath(candidate.cwd)
|
|
||||||
if (canonicalCandidate !== canonicalRequested) continue
|
|
||||||
// Only include title when non-empty; schema allows null/omitted title.
|
|
||||||
const title = sanitizeTitle(candidate.summary ?? '')
|
|
||||||
sessions.push({
|
|
||||||
sessionId: candidate.sessionId,
|
|
||||||
cwd: candidate.cwd,
|
|
||||||
...(title ? { title } : {}),
|
|
||||||
updatedAt: new Date(candidate.lastModified).toISOString(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return { sessions }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── forkSession ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async unstable_forkSession(
|
|
||||||
params: ForkSessionRequest,
|
|
||||||
): Promise<ForkSessionResponse> {
|
|
||||||
// Load the source session's messages so the fork actually branches from
|
|
||||||
// the source conversation rather than starting a blank session. Per the
|
|
||||||
// unstable ForkSessionRequest, params.sessionId is the ID to fork from.
|
|
||||||
const { initialMessages } = await loadForkSourceMessages(params.sessionId)
|
|
||||||
const response = await this.createSession(
|
|
||||||
{
|
|
||||||
cwd: params.cwd,
|
|
||||||
mcpServers: params.mcpServers ?? [],
|
|
||||||
_meta: params._meta,
|
|
||||||
},
|
|
||||||
{ initialMessages },
|
|
||||||
)
|
|
||||||
this.scheduleAvailableCommandsUpdate(response.sessionId)
|
|
||||||
return response
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── closeSession ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
async unstable_closeSession(
|
|
||||||
params: CloseSessionRequest,
|
|
||||||
): Promise<CloseSessionResponse> {
|
|
||||||
const session = this.sessions.get(params.sessionId)
|
|
||||||
if (!session) {
|
|
||||||
throw new Error('Session not found')
|
|
||||||
}
|
|
||||||
await this.teardownSession(params.sessionId)
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── deleteSession (UNSTABLE, routed via extMethod) ──────────────
|
|
||||||
|
|
||||||
async unstable_deleteSession(params: {
|
|
||||||
sessionId: string
|
|
||||||
}): Promise<Record<string, never>> {
|
|
||||||
// Per session-delete.mdx §Semantics: idempotent — deleting a session
|
|
||||||
// that doesn't exist (or was already deleted) MUST succeed silently.
|
|
||||||
const resolved = await resolveSessionFilePath(params.sessionId)
|
|
||||||
if (resolved) {
|
|
||||||
try {
|
|
||||||
await unlink(resolved.filePath)
|
|
||||||
} catch (err) {
|
|
||||||
// ENOENT is fine — file was concurrently removed. Any other error
|
|
||||||
// (EACCES, EISDIR, ...) we propagate.
|
|
||||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Tear down in-memory session if present (e.g., session was active in
|
|
||||||
// another connection). teardownSession is a no-op if not loaded.
|
|
||||||
if (this.sessions.has(params.sessionId)) {
|
|
||||||
await this.teardownSession(params.sessionId)
|
|
||||||
}
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── extMethod (UNSTABLE method dispatch) ────────────────────────
|
|
||||||
|
|
||||||
async extMethod(
|
|
||||||
method: string,
|
|
||||||
params: Record<string, unknown>,
|
|
||||||
): Promise<Record<string, unknown>> {
|
|
||||||
// SDK 0.19.0 routes unknown methods here (acp.js:139 default branch).
|
|
||||||
// We surface UNSTABLE capabilities that the SDK hasn't typed yet.
|
|
||||||
if (method === 'session/delete') {
|
|
||||||
const sessionId = params.sessionId
|
|
||||||
if (typeof sessionId !== 'string' || sessionId.length === 0) {
|
|
||||||
throw new Error('session/delete requires a non-empty sessionId')
|
|
||||||
}
|
|
||||||
return this.unstable_deleteSession({ sessionId })
|
|
||||||
}
|
|
||||||
// Unknown method — surface as JSON-RPC methodNotFound so clients see a
|
|
||||||
// standard error code (-32601) rather than a generic internal error.
|
|
||||||
throw RequestError.methodNotFound(method)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── cancel ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async cancel(params: CancelNotification): Promise<void> {
|
|
||||||
const session = this.sessions.get(params.sessionId)
|
|
||||||
if (!session) return
|
|
||||||
|
|
||||||
// Set cancelled flag — checked by prompt() loop to break out
|
|
||||||
session.cancelled = true
|
|
||||||
session.cancelGeneration += 1
|
|
||||||
|
|
||||||
// Cancel any queued prompts
|
|
||||||
for (const [, pending] of session.pendingMessages) {
|
|
||||||
pending.resolve(true)
|
|
||||||
}
|
|
||||||
session.pendingMessages.clear()
|
|
||||||
session.pendingQueue = []
|
|
||||||
session.pendingQueueHead = 0
|
|
||||||
|
|
||||||
// Interrupt the query engine to abort the current API call
|
|
||||||
session.queryEngine.interrupt()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── setSessionMode ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
async setSessionMode(
|
|
||||||
params: SetSessionModeRequest,
|
|
||||||
): Promise<SetSessionModeResponse> {
|
|
||||||
const session = this.sessions.get(params.sessionId)
|
|
||||||
if (!session) {
|
|
||||||
throw new Error('Session not found')
|
|
||||||
}
|
|
||||||
|
|
||||||
this.applySessionMode(params.sessionId, params.modeId)
|
|
||||||
// Per session-modes.mdx: when the Agent changes its own mode it MUST send
|
|
||||||
// a current_mode_update notification so mode-only Clients learn the
|
|
||||||
// switch. Mirrors the current_mode_update sent by setSessionConfigOption
|
|
||||||
// when configId === 'mode'.
|
|
||||||
await this.conn.sessionUpdate({
|
|
||||||
sessionId: params.sessionId,
|
|
||||||
update: {
|
|
||||||
sessionUpdate: 'current_mode_update',
|
|
||||||
currentModeId: params.modeId,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
await this.updateConfigOption(params.sessionId, 'mode', params.modeId)
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── setSessionModel ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
async unstable_setSessionModel(
|
|
||||||
params: SetSessionModelRequest,
|
|
||||||
): Promise<SetSessionModelResponse> {
|
|
||||||
const session = this.sessions.get(params.sessionId)
|
|
||||||
if (!session) {
|
|
||||||
throw new Error('Session not found')
|
|
||||||
}
|
|
||||||
// Store the raw value — QueryEngine.submitMessage() calls
|
|
||||||
// parseUserSpecifiedModel() to resolve aliases (e.g. "sonnet" → "glm-5.1-turbo")
|
|
||||||
session.queryEngine.setModel(params.modelId)
|
|
||||||
await this.updateConfigOption(params.sessionId, 'model', params.modelId)
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Private helpers (lightweight, kept with the class) ──────────
|
|
||||||
|
|
||||||
private async sendAvailableCommandsUpdate(sessionId: string): Promise<void> {
|
|
||||||
const session = this.sessions.get(sessionId)
|
|
||||||
if (!session) return
|
|
||||||
|
|
||||||
const availableCommands = session.commands
|
|
||||||
.filter(
|
|
||||||
cmd =>
|
|
||||||
cmd.type === 'prompt' && !cmd.isHidden && cmd.userInvocable !== false,
|
|
||||||
)
|
|
||||||
.map(cmd => ({
|
|
||||||
name: cmd.name,
|
|
||||||
description: cmd.description,
|
|
||||||
input: cmd.argumentHint ? { hint: cmd.argumentHint } : undefined,
|
|
||||||
}))
|
|
||||||
|
|
||||||
await this.conn.sessionUpdate({
|
|
||||||
sessionId,
|
|
||||||
update: {
|
|
||||||
sessionUpdate: 'available_commands_update',
|
|
||||||
availableCommands,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
private scheduleAvailableCommandsUpdate(sessionId: string): void {
|
|
||||||
setTimeout(() => {
|
|
||||||
void this.sendAvailableCommandsUpdate(sessionId).catch(err => {
|
|
||||||
console.error('[ACP] Failed to send available commands update:', err)
|
|
||||||
})
|
|
||||||
}, 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Prototype-attached methods (declared here for type safety) ────
|
|
||||||
//
|
|
||||||
// The following methods are implemented in sibling modules
|
|
||||||
// (createSessionMethod.ts / sessionLifecycle.ts / promptFlow.ts) and attached
|
|
||||||
// to AcpAgent.prototype via Object.assign at module load time. They are
|
|
||||||
// declared on the class via TypeScript declaration merging so `this` is
|
|
||||||
// typed correctly in the prototype-augmentation modules.
|
|
||||||
export interface AcpAgent {
|
|
||||||
// ── prompt flow (promptFlow.ts) ───────────────────────────────
|
|
||||||
prompt(params: PromptRequest): Promise<PromptResponse>
|
|
||||||
setSessionConfigOption(
|
|
||||||
params: SetSessionConfigOptionRequest,
|
|
||||||
): Promise<SetSessionConfigOptionResponse>
|
|
||||||
|
|
||||||
// ── session lifecycle (sessionLifecycle.ts) ───────────────────
|
|
||||||
createSession(
|
|
||||||
params: NewSessionRequest,
|
|
||||||
opts?: {
|
|
||||||
forceNewId?: boolean
|
|
||||||
sessionId?: string
|
|
||||||
initialMessages?: Message[]
|
|
||||||
},
|
|
||||||
): Promise<NewSessionResponse>
|
|
||||||
getOrCreateSession(params: {
|
|
||||||
sessionId: string
|
|
||||||
cwd: string
|
|
||||||
mcpServers?: NewSessionRequest['mcpServers']
|
|
||||||
_meta?: NewSessionRequest['_meta']
|
|
||||||
replay?: boolean
|
|
||||||
}): Promise<NewSessionResponse>
|
|
||||||
teardownSession(sessionId: string): Promise<void>
|
|
||||||
replaySessionHistory(params: {
|
|
||||||
sessionId: string
|
|
||||||
cwd: string
|
|
||||||
}): Promise<void>
|
|
||||||
applySessionMode(sessionId: string, modeId: string): void
|
|
||||||
updateConfigOption(
|
|
||||||
sessionId: string,
|
|
||||||
configId: string,
|
|
||||||
value: string,
|
|
||||||
): Promise<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Module-local helpers used only by the class shell ────────────
|
|
||||||
|
|
||||||
import { type UUID } from 'node:crypto'
|
|
||||||
import { deserializeMessages } from '../../../utils/conversationRecovery.js'
|
|
||||||
import { getLastSessionLog } from '../../../utils/sessionStorage.js'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load the source session's persisted messages for forkSession.
|
|
||||||
* Extracted as a module-local helper to keep the fork handler compact.
|
|
||||||
*/
|
|
||||||
async function loadForkSourceMessages(
|
|
||||||
sessionId: string,
|
|
||||||
): Promise<{ initialMessages: Message[] | undefined }> {
|
|
||||||
let initialMessages: Message[] | undefined
|
|
||||||
try {
|
|
||||||
const log = await getLastSessionLog(sessionId as UUID)
|
|
||||||
if (log && log.messages.length > 0) {
|
|
||||||
initialMessages = deserializeMessages(log.messages)
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[ACP] fork source load failed:', err)
|
|
||||||
}
|
|
||||||
return { initialMessages }
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
import type {
|
|
||||||
SessionModeState,
|
|
||||||
SessionModelState,
|
|
||||||
SessionConfigOption,
|
|
||||||
} from '@agentclientprotocol/sdk'
|
|
||||||
|
|
||||||
export function buildConfigOptions(
|
|
||||||
modes: SessionModeState,
|
|
||||||
models: SessionModelState,
|
|
||||||
): SessionConfigOption[] {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
id: 'mode',
|
|
||||||
name: 'Mode',
|
|
||||||
description: 'Session permission mode',
|
|
||||||
category: 'mode',
|
|
||||||
type: 'select' as const,
|
|
||||||
currentValue: modes.currentModeId,
|
|
||||||
options: modes.availableModes.map(
|
|
||||||
(m: SessionModeState['availableModes'][number]) => ({
|
|
||||||
value: m.id,
|
|
||||||
name: m.name,
|
|
||||||
description: m.description,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'model',
|
|
||||||
name: 'Model',
|
|
||||||
description: 'AI model to use',
|
|
||||||
category: 'model',
|
|
||||||
type: 'select' as const,
|
|
||||||
currentValue: models.currentModelId,
|
|
||||||
options: models.availableModels.map(
|
|
||||||
(m: SessionModelState['availableModels'][number]) => ({
|
|
||||||
value: m.modelId,
|
|
||||||
name: m.name,
|
|
||||||
description: m.description ?? undefined,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
] as SessionConfigOption[]
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Flatten a SessionConfigOption's `options` (which may be flat
|
|
||||||
* SessionConfigSelectOption entries or grouped SessionConfigSelectGroup
|
|
||||||
* entries) into a list of valid value strings. Used to validate that a
|
|
||||||
* setSessionConfigOption value is one of the listed options.
|
|
||||||
*/
|
|
||||||
export function flattenConfigOptionValues(options: unknown): string[] {
|
|
||||||
const values: string[] = []
|
|
||||||
if (!Array.isArray(options)) return values
|
|
||||||
for (const opt of options) {
|
|
||||||
if (typeof opt !== 'object' || opt === null) continue
|
|
||||||
const maybeGroup = opt as { group?: unknown; options?: unknown[] }
|
|
||||||
if (Array.isArray(maybeGroup.options)) {
|
|
||||||
// SessionConfigSelectGroup — recurse into its options
|
|
||||||
for (const inner of maybeGroup.options) {
|
|
||||||
if (
|
|
||||||
inner &&
|
|
||||||
typeof inner === 'object' &&
|
|
||||||
typeof (inner as { value?: unknown }).value === 'string'
|
|
||||||
) {
|
|
||||||
values.push((inner as { value: string }).value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (typeof (opt as { value?: unknown }).value === 'string') {
|
|
||||||
// SessionConfigSelectOption
|
|
||||||
values.push((opt as { value: string }).value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return values
|
|
||||||
}
|
|
||||||
@@ -1,296 +0,0 @@
|
|||||||
/**
|
|
||||||
* AcpAgent.prototype.createSession implementation, attached via Object.assign.
|
|
||||||
* Extracted from sessionLifecycle.ts to keep that module under the 500-line
|
|
||||||
* budget. The barrel (./index.ts) imports this module for its side effect.
|
|
||||||
*/
|
|
||||||
import { randomUUID } from 'node:crypto'
|
|
||||||
import type {
|
|
||||||
NewSessionRequest,
|
|
||||||
NewSessionResponse,
|
|
||||||
SessionModeState,
|
|
||||||
SessionModelState,
|
|
||||||
} from '@agentclientprotocol/sdk'
|
|
||||||
import type { Message } from '../../../types/message.js'
|
|
||||||
import { QueryEngine } from '../../../QueryEngine.js'
|
|
||||||
import type { QueryEngineConfig } from '../../../QueryEngine.js'
|
|
||||||
import type { Tools } from '../../../Tool.js'
|
|
||||||
import { getTools } from '../../../tools.js'
|
|
||||||
import { getEmptyToolPermissionContext } from '../../../Tool.js'
|
|
||||||
import type { PermissionMode } from '../../../types/permissions.js'
|
|
||||||
import { getCommands } from '../../../commands.js'
|
|
||||||
import { getAgentDefinitionsWithOverrides } from '@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js'
|
|
||||||
import {
|
|
||||||
setOriginalCwd,
|
|
||||||
switchSession,
|
|
||||||
getSessionProjectDir,
|
|
||||||
} from '../../../bootstrap/state.js'
|
|
||||||
import type { SessionId } from '../../../types/ids.js'
|
|
||||||
import { enableConfigs } from '../../../utils/config.js'
|
|
||||||
import { FileStateCache } from '../../../utils/fileStateCache.js'
|
|
||||||
import { getDefaultAppState } from '../../../state/AppStateStore.js'
|
|
||||||
import type { AppState } from '../../../state/AppStateStore.js'
|
|
||||||
import { createAcpCanUseTool } from '../permissions.js'
|
|
||||||
import { computeSessionFingerprint } from '../utils.js'
|
|
||||||
import { getMainLoopModel } from '../../../utils/model/model.js'
|
|
||||||
import { getModelOptions } from '../../../utils/model/modelOptions.js'
|
|
||||||
import { getSettings_DEPRECATED } from '../../../utils/settings/settings.js'
|
|
||||||
import { AcpAgent } from './AcpAgent.js'
|
|
||||||
import type { AcpSession } from './sessionTypes.js'
|
|
||||||
import {
|
|
||||||
resolveSessionPermissionMode,
|
|
||||||
isAcpBypassPermissionModeAvailable,
|
|
||||||
hasOwnField,
|
|
||||||
} from './permissionMode.js'
|
|
||||||
import { buildConfigOptions } from './configOptions.js'
|
|
||||||
import { readClientCapabilities } from './internalAccessors.js'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve the effective `permissions.defaultMode` setting by walking the
|
|
||||||
* settings object. Lives here so createSession can read it without depending
|
|
||||||
* on AcpAgent.getSetting (which is a private instance method on the shell).
|
|
||||||
*/
|
|
||||||
function readSettingsPermissionMode(): unknown {
|
|
||||||
const settings = getSettings_DEPRECATED() as Record<string, unknown>
|
|
||||||
const perms = settings.permissions as Record<string, unknown> | undefined
|
|
||||||
return perms?.defaultMode
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── createSession ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function createSession(
|
|
||||||
this: AcpAgent,
|
|
||||||
params: NewSessionRequest,
|
|
||||||
opts: {
|
|
||||||
forceNewId?: boolean
|
|
||||||
sessionId?: string
|
|
||||||
initialMessages?: Message[]
|
|
||||||
} = {},
|
|
||||||
): Promise<NewSessionResponse> {
|
|
||||||
enableConfigs()
|
|
||||||
|
|
||||||
const sessionId = opts.sessionId ?? randomUUID()
|
|
||||||
const cwd = params.cwd
|
|
||||||
|
|
||||||
// Align the global session state so that transcript persistence,
|
|
||||||
// analytics, and cost tracking use the ACP session ID.
|
|
||||||
// Preserve the projectDir set by getOrCreateSession so that
|
|
||||||
// getSessionProjectDir() continues to resolve correctly.
|
|
||||||
const currentProjectDir = getSessionProjectDir()
|
|
||||||
switchSession(sessionId as SessionId, currentProjectDir)
|
|
||||||
|
|
||||||
// Set CWD for the session
|
|
||||||
setOriginalCwd(cwd)
|
|
||||||
const previousProcessCwd = process.cwd()
|
|
||||||
let processCwdChanged = false
|
|
||||||
try {
|
|
||||||
process.chdir(cwd)
|
|
||||||
processCwdChanged = true
|
|
||||||
} catch {
|
|
||||||
// CWD may not exist yet; best-effort
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Build tools with a permissive permission context.
|
|
||||||
const permissionContext = getEmptyToolPermissionContext()
|
|
||||||
const tools: Tools = getTools(permissionContext)
|
|
||||||
|
|
||||||
// Parse permission mode from _meta (passed by RCS/acp-link) or settings.
|
|
||||||
const meta = params._meta as Record<string, unknown> | null | undefined
|
|
||||||
const hasMetaPermissionMode = hasOwnField(meta, 'permissionMode')
|
|
||||||
const metaPermissionMode = hasMetaPermissionMode
|
|
||||||
? meta?.permissionMode
|
|
||||||
: undefined
|
|
||||||
const settingsPermissionMode = readSettingsPermissionMode()
|
|
||||||
const permissionMode = resolveSessionPermissionMode(
|
|
||||||
metaPermissionMode,
|
|
||||||
hasMetaPermissionMode,
|
|
||||||
settingsPermissionMode,
|
|
||||||
)
|
|
||||||
|
|
||||||
// The clientCapabilities field on the shell is private; access it via
|
|
||||||
// the public initialize() side effect. Since createSession is only ever
|
|
||||||
// called after initialize() has run (per ACP protocol), this accessor
|
|
||||||
// is safe.
|
|
||||||
const clientCapabilities = readClientCapabilities(this)
|
|
||||||
|
|
||||||
// Create the permission bridge canUseTool function. The connection field
|
|
||||||
// is private on the shell; access it through the internal accessor.
|
|
||||||
const conn = (
|
|
||||||
this as unknown as {
|
|
||||||
conn: import('@agentclientprotocol/sdk').AgentSideConnection
|
|
||||||
}
|
|
||||||
).conn
|
|
||||||
const canUseTool = createAcpCanUseTool(
|
|
||||||
conn,
|
|
||||||
sessionId,
|
|
||||||
() => this.sessions.get(sessionId)?.modes.currentModeId ?? 'default',
|
|
||||||
clientCapabilities,
|
|
||||||
cwd,
|
|
||||||
(modeId: string) => {
|
|
||||||
this.applySessionMode(sessionId, modeId)
|
|
||||||
},
|
|
||||||
() =>
|
|
||||||
this.sessions.get(sessionId)?.appState.toolPermissionContext
|
|
||||||
.isBypassPermissionsModeAvailable ?? false,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Parse MCP servers from ACP params
|
|
||||||
// MCP server config is handled separately in the tools system
|
|
||||||
|
|
||||||
// bypassPermissions is exposed to ACP clients whenever the process itself allows it
|
|
||||||
// (non-root or sandbox). The previous additional opt-in gate made the mode invisible
|
|
||||||
// to standard clients and defeated the purpose of listing it. See permissionMode.ts.
|
|
||||||
const isBypassAvailable = isAcpBypassPermissionModeAvailable()
|
|
||||||
|
|
||||||
// Create a mutable AppState for the session
|
|
||||||
const appState: AppState = {
|
|
||||||
...getDefaultAppState(),
|
|
||||||
toolPermissionContext: {
|
|
||||||
...permissionContext,
|
|
||||||
mode: permissionMode as PermissionMode,
|
|
||||||
isBypassPermissionsModeAvailable: isBypassAvailable,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load commands and agent definitions for subagent support
|
|
||||||
const [commands, agentDefinitionsResult] = await Promise.all([
|
|
||||||
getCommands(cwd),
|
|
||||||
getAgentDefinitionsWithOverrides(cwd),
|
|
||||||
])
|
|
||||||
|
|
||||||
// Inject agent definitions into appState
|
|
||||||
appState.agentDefinitions = agentDefinitionsResult
|
|
||||||
|
|
||||||
// Build QueryEngine config
|
|
||||||
const engineConfig: QueryEngineConfig = {
|
|
||||||
cwd,
|
|
||||||
tools,
|
|
||||||
commands,
|
|
||||||
mcpClients: [],
|
|
||||||
agents: agentDefinitionsResult.activeAgents,
|
|
||||||
canUseTool,
|
|
||||||
getAppState: () => appState,
|
|
||||||
setAppState: (updater: (prev: AppState) => AppState) => {
|
|
||||||
const updated = updater(appState)
|
|
||||||
Object.assign(appState, updated)
|
|
||||||
},
|
|
||||||
readFileCache: new FileStateCache(500, 50 * 1024 * 1024),
|
|
||||||
includePartialMessages: true,
|
|
||||||
replayUserMessages: true,
|
|
||||||
initialMessages: opts.initialMessages,
|
|
||||||
}
|
|
||||||
|
|
||||||
const queryEngine = new QueryEngine(engineConfig)
|
|
||||||
|
|
||||||
// Build modes — bypassPermissions is opt-in for ACP clients.
|
|
||||||
const availableModes = [
|
|
||||||
{
|
|
||||||
id: 'default',
|
|
||||||
name: 'Default',
|
|
||||||
description: 'Standard behavior, prompts for dangerous operations',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'acceptEdits',
|
|
||||||
name: 'Accept Edits',
|
|
||||||
description: 'Auto-accept file edit operations',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'plan',
|
|
||||||
name: 'Plan Mode',
|
|
||||||
description: 'Planning mode, no actual tool execution',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'auto',
|
|
||||||
name: 'Auto',
|
|
||||||
description:
|
|
||||||
'Use a model classifier to approve/deny permission prompts.',
|
|
||||||
},
|
|
||||||
...(isBypassAvailable
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
id: 'bypassPermissions' as const,
|
|
||||||
name: 'Bypass Permissions',
|
|
||||||
description: 'Skip all permission checks',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
{
|
|
||||||
id: 'dontAsk',
|
|
||||||
name: "Don't Ask",
|
|
||||||
description: "Don't prompt for permissions, deny if not pre-approved",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const modes: SessionModeState = {
|
|
||||||
currentModeId: permissionMode,
|
|
||||||
availableModes,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build models
|
|
||||||
const modelOptions = getModelOptions()
|
|
||||||
const currentModel = getMainLoopModel()
|
|
||||||
const models: SessionModelState = {
|
|
||||||
availableModels: modelOptions.map(m => ({
|
|
||||||
modelId: String(m.value ?? ''),
|
|
||||||
name: m.label ?? String(m.value ?? ''),
|
|
||||||
description: m.description ?? undefined,
|
|
||||||
})),
|
|
||||||
currentModelId: currentModel,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set the model on the engine
|
|
||||||
queryEngine.setModel(currentModel)
|
|
||||||
|
|
||||||
// Build config options
|
|
||||||
const configOptions = buildConfigOptions(modes, models)
|
|
||||||
|
|
||||||
const session: AcpSession = {
|
|
||||||
queryEngine,
|
|
||||||
cancelled: false,
|
|
||||||
cancelGeneration: 0,
|
|
||||||
cwd,
|
|
||||||
modes,
|
|
||||||
models,
|
|
||||||
configOptions,
|
|
||||||
promptRunning: false,
|
|
||||||
pendingMessages: new Map(),
|
|
||||||
pendingQueue: [],
|
|
||||||
pendingQueueHead: 0,
|
|
||||||
toolUseCache: {},
|
|
||||||
clientCapabilities,
|
|
||||||
appState,
|
|
||||||
commands,
|
|
||||||
sessionFingerprint: computeSessionFingerprint({
|
|
||||||
cwd,
|
|
||||||
mcpServers: params.mcpServers as
|
|
||||||
| Array<{ name: string; [key: string]: unknown }>
|
|
||||||
| undefined,
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
|
|
||||||
this.sessions.set(sessionId, session)
|
|
||||||
|
|
||||||
// Return models even though SDK 0.19.2 marks it UNSTABLE. The schema does allow the field
|
|
||||||
// (NewSessionResponse.models?: SessionModelState | null), and standard clients (Cursor/Zed/
|
|
||||||
// VS Code ACP) rely on it to populate the model selector — omitting it forces
|
|
||||||
// supportsModelSelection=false on the client and the user can never switch models.
|
|
||||||
// The UNSTABLE marker only means "this field may change in a future schema version", not
|
|
||||||
// "agents MUST NOT return it". The previous "v1 compliance" omission was overzealous.
|
|
||||||
return {
|
|
||||||
sessionId,
|
|
||||||
modes,
|
|
||||||
models,
|
|
||||||
configOptions,
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (processCwdChanged) {
|
|
||||||
process.chdir(previousProcessCwd)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Prototype attachment ─────────────────────────────────────────
|
|
||||||
|
|
||||||
Object.assign(AcpAgent.prototype, {
|
|
||||||
createSession,
|
|
||||||
})
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
/**
|
|
||||||
* Internal accessors for AcpAgent private fields and session-state helpers,
|
|
||||||
* shared across the prototype-augmentation modules (createSessionMethod /
|
|
||||||
* sessionLifecycle / promptFlow).
|
|
||||||
*
|
|
||||||
* AcpAgent's `conn` and `clientCapabilities` fields are declared `private`
|
|
||||||
* on the shell class. TS-only privacy (no #) means bracket access still
|
|
||||||
* works at runtime, but we cast through `unknown` to keep tsc strict happy
|
|
||||||
* without widening the public API surface of the class.
|
|
||||||
*/
|
|
||||||
import type {
|
|
||||||
AgentSideConnection,
|
|
||||||
ClientCapabilities,
|
|
||||||
} from '@agentclientprotocol/sdk'
|
|
||||||
import type { AcpAgent } from './AcpAgent.js'
|
|
||||||
import type { AcpSession } from './sessionTypes.js'
|
|
||||||
|
|
||||||
type AcpAgentInternals = {
|
|
||||||
conn: AgentSideConnection
|
|
||||||
clientCapabilities: ClientCapabilities | undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getConnection(agent: AcpAgent): AgentSideConnection {
|
|
||||||
return (agent as unknown as AcpAgentInternals).conn
|
|
||||||
}
|
|
||||||
|
|
||||||
export function readClientCapabilities(
|
|
||||||
agent: AcpAgent,
|
|
||||||
): ClientCapabilities | undefined {
|
|
||||||
return (agent as unknown as AcpAgentInternals).clientCapabilities
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update the session's current mode/model id based on the configId.
|
|
||||||
*
|
|
||||||
* This logic was originally the private `AcpAgent.syncSessionConfigState`
|
|
||||||
* method on the shell class. It is called by the prototype-augmented
|
|
||||||
* `updateConfigOption` (sessionLifecycle.ts) and `setSessionConfigOption`
|
|
||||||
* (promptFlow.ts). Moving it here keeps it next to its only callers and
|
|
||||||
* avoids the `noUnusedPrivateClassMembers` false positive that the
|
|
||||||
* cast-based access would otherwise trigger on the shell.
|
|
||||||
*/
|
|
||||||
export function syncSessionConfigState(
|
|
||||||
_agent: AcpAgent,
|
|
||||||
session: AcpSession,
|
|
||||||
configId: string,
|
|
||||||
value: string,
|
|
||||||
): void {
|
|
||||||
if (configId === 'mode') {
|
|
||||||
session.modes = { ...session.modes, currentModeId: value }
|
|
||||||
} else if (configId === 'model') {
|
|
||||||
session.models = { ...session.models, currentModelId: value }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
import type { PermissionMode } from '../../../types/permissions.js'
|
|
||||||
import { resolvePermissionMode } from '../utils.js'
|
|
||||||
|
|
||||||
export const permissionModeIds: readonly PermissionMode[] = [
|
|
||||||
'auto',
|
|
||||||
'default',
|
|
||||||
'acceptEdits',
|
|
||||||
'bypassPermissions',
|
|
||||||
'dontAsk',
|
|
||||||
'plan',
|
|
||||||
]
|
|
||||||
|
|
||||||
export function isPermissionMode(modeId: string): modeId is PermissionMode {
|
|
||||||
return (permissionModeIds as readonly string[]).includes(modeId)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveSessionPermissionMode(
|
|
||||||
metaMode: unknown,
|
|
||||||
hasMetaMode: boolean,
|
|
||||||
settingsMode: unknown,
|
|
||||||
): PermissionMode {
|
|
||||||
if (hasMetaMode) {
|
|
||||||
const metaResolved = resolveRequiredPermissionMode(
|
|
||||||
metaMode,
|
|
||||||
'_meta.permissionMode',
|
|
||||||
)
|
|
||||||
if (
|
|
||||||
metaResolved === 'bypassPermissions' &&
|
|
||||||
!isAcpBypassPermissionModeAvailable()
|
|
||||||
) {
|
|
||||||
throw new Error(
|
|
||||||
'Mode not available: bypassPermissions cannot run as root (start the agent as a non-root user, or set IS_SANDBOX=1).',
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return metaResolved
|
|
||||||
}
|
|
||||||
|
|
||||||
const settingsResolved = resolveConfiguredPermissionMode(settingsMode)
|
|
||||||
return settingsResolved ?? 'default'
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveRequiredPermissionMode(
|
|
||||||
mode: unknown,
|
|
||||||
source: string,
|
|
||||||
): PermissionMode {
|
|
||||||
if (mode === undefined || mode === null) {
|
|
||||||
throw new Error(`Invalid ${source}: expected a string.`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return resolvePermissionMode(mode, source) as PermissionMode
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveConfiguredPermissionMode(
|
|
||||||
mode: unknown,
|
|
||||||
): PermissionMode | undefined {
|
|
||||||
if (mode === undefined || mode === null) return undefined
|
|
||||||
|
|
||||||
try {
|
|
||||||
return resolvePermissionMode(
|
|
||||||
mode,
|
|
||||||
'permissions.defaultMode',
|
|
||||||
) as PermissionMode
|
|
||||||
} catch (err: unknown) {
|
|
||||||
const reason = err instanceof Error ? err.message : String(err)
|
|
||||||
console.error(
|
|
||||||
'[ACP] Invalid permissions.defaultMode, using default:',
|
|
||||||
reason,
|
|
||||||
)
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hasOwnField(
|
|
||||||
value: Record<string, unknown> | null | undefined,
|
|
||||||
key: string,
|
|
||||||
): boolean {
|
|
||||||
return !!value && Object.hasOwn(value, key)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether bypassPermissions is selectable by ACP clients.
|
|
||||||
*
|
|
||||||
* The previous implementation required a local opt-in (ACP_PERMISSION_MODE env var,
|
|
||||||
* CLAUDE_CODE_ACP_ALLOW_BYPASS_PERMISSIONS env var, or settings.permissions.defaultMode).
|
|
||||||
* That gate made the mode invisible to standard clients unless the operator already
|
|
||||||
* pre-configured it — defeating the point of exposing it through the ACP mode list.
|
|
||||||
*
|
|
||||||
* The only remaining guard is the process-level one: bypass must not silently run
|
|
||||||
* as root (where every skipped permission check is a privilege boundary crossed),
|
|
||||||
* unless explicitly marked as a sandbox.
|
|
||||||
*/
|
|
||||||
export function isAcpBypassPermissionModeAvailable(): boolean {
|
|
||||||
return isProcessBypassPermissionModeAvailable()
|
|
||||||
}
|
|
||||||
|
|
||||||
function isProcessBypassPermissionModeAvailable(): boolean {
|
|
||||||
if (process.env.IS_SANDBOX) return true
|
|
||||||
if (typeof process.geteuid === 'function') return process.geteuid() !== 0
|
|
||||||
if (typeof process.getuid === 'function') return process.getuid() !== 0
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
@@ -1,306 +0,0 @@
|
|||||||
/**
|
|
||||||
* Prompt-flow methods for AcpAgent, attached to the prototype via
|
|
||||||
* Object.assign. Kept in a sibling module to keep AcpAgent.ts under the
|
|
||||||
* 500-line budget. The barrel (./index.ts) imports this module for its
|
|
||||||
* side effect so the prototype is populated before any instance is built.
|
|
||||||
*
|
|
||||||
* Methods attached: prompt, setSessionConfigOption.
|
|
||||||
*/
|
|
||||||
import { randomUUID } from 'node:crypto'
|
|
||||||
import type {
|
|
||||||
PromptRequest,
|
|
||||||
PromptResponse,
|
|
||||||
SetSessionConfigOptionRequest,
|
|
||||||
SetSessionConfigOptionResponse,
|
|
||||||
} from '@agentclientprotocol/sdk'
|
|
||||||
import type { SessionId } from '../../../types/ids.js'
|
|
||||||
import {
|
|
||||||
switchSession,
|
|
||||||
getSessionProjectDir,
|
|
||||||
} from '../../../bootstrap/state.js'
|
|
||||||
import { forwardSessionUpdates } from '../bridge.js'
|
|
||||||
import type { ToolUseCache } from '../bridge.js'
|
|
||||||
import { promptToQueryInput } from '../promptConversion.js'
|
|
||||||
import { sanitizeTitle } from '../utils.js'
|
|
||||||
import { AcpAgent } from './AcpAgent.js'
|
|
||||||
import type { AcpSession } from './sessionTypes.js'
|
|
||||||
import { flattenConfigOptionValues } from './configOptions.js'
|
|
||||||
import { popNextPendingPrompt } from './promptQueue.js'
|
|
||||||
import {
|
|
||||||
getConnection,
|
|
||||||
readClientCapabilities,
|
|
||||||
syncSessionConfigState,
|
|
||||||
} from './internalAccessors.js'
|
|
||||||
|
|
||||||
// ── prompt ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function prompt(
|
|
||||||
this: AcpAgent,
|
|
||||||
params: PromptRequest,
|
|
||||||
): Promise<PromptResponse> {
|
|
||||||
const session = this.sessions.get(params.sessionId)
|
|
||||||
if (!session) {
|
|
||||||
throw new Error(`Session ${params.sessionId} not found`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Per message-id.mdx RFD: if the client supplied a `messageId` on the
|
|
||||||
// PromptRequest, echo it back as `userMessageId` to confirm receipt.
|
|
||||||
// We do not self-generate when omitted — the spec makes that optional and
|
|
||||||
// staying quiet avoids surfacing IDs the client didn't ask to track.
|
|
||||||
const userMessageId = params.messageId ?? undefined
|
|
||||||
|
|
||||||
// Extract text/image content from the prompt
|
|
||||||
const promptInput = promptToQueryInput(params.prompt)
|
|
||||||
|
|
||||||
// Per prompt-turn.mdx, `prompt` is a required ContentBlock[] and an
|
|
||||||
// effectively-empty prompt is malformed input — reject it with an
|
|
||||||
// invalid_params error rather than fabricating a successful end_turn.
|
|
||||||
if (!promptInput.trim()) {
|
|
||||||
throw new Error('Prompt content is empty')
|
|
||||||
}
|
|
||||||
|
|
||||||
const promptCancelGeneration = session.cancelGeneration
|
|
||||||
|
|
||||||
// Handle prompt queuing — if a prompt is already running, queue this one
|
|
||||||
if (session.promptRunning) {
|
|
||||||
const promptUuid = randomUUID()
|
|
||||||
const cancelled = await new Promise<boolean>(resolve => {
|
|
||||||
session.pendingQueue.push(promptUuid)
|
|
||||||
session.pendingMessages.set(promptUuid, { resolve })
|
|
||||||
})
|
|
||||||
if (cancelled) {
|
|
||||||
return { stopReason: 'cancelled' }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (session.cancelGeneration !== promptCancelGeneration) {
|
|
||||||
return { stopReason: 'cancelled' }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset cancellation only when this prompt is about to run. Queued prompts
|
|
||||||
// must not clear the cancellation state for the active prompt.
|
|
||||||
session.cancelled = false
|
|
||||||
session.promptRunning = true
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Reset the query engine's abort controller for a fresh query.
|
|
||||||
// After a previous interrupt(), the internal controller is stuck in
|
|
||||||
// aborted state — without this, submitMessage() fails immediately.
|
|
||||||
session.queryEngine.resetAbortController()
|
|
||||||
// Switch global session state so recordTranscript writes to the correct
|
|
||||||
// session file. Without this, multi-session scenarios (or creating a new
|
|
||||||
// session after another) write transcript data to the wrong file.
|
|
||||||
switchSession(params.sessionId as SessionId, getSessionProjectDir())
|
|
||||||
|
|
||||||
const sdkMessages = session.queryEngine.submitMessage(promptInput)
|
|
||||||
|
|
||||||
const { stopReason, usage } = await forwardSessionUpdates(
|
|
||||||
params.sessionId,
|
|
||||||
sdkMessages,
|
|
||||||
getConnection(this),
|
|
||||||
session.queryEngine.getAbortSignal(),
|
|
||||||
session.toolUseCache,
|
|
||||||
readClientCapabilities(this),
|
|
||||||
session.cwd,
|
|
||||||
() => session.cancelled,
|
|
||||||
)
|
|
||||||
|
|
||||||
// If the session was cancelled during processing, return cancelled
|
|
||||||
if (session.cancelled) {
|
|
||||||
return { stopReason: 'cancelled' }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Emit a session_info_update so Clients learn the session's display
|
|
||||||
// title / last-activity timestamp via the stable v1 session/update
|
|
||||||
// channel. The title is derived from the first user prompt.
|
|
||||||
await emitSessionInfoUpdate(this, params.sessionId, promptInput)
|
|
||||||
|
|
||||||
// Per session-usage.mdx RFD and the bundled SDK schema, PromptResponse
|
|
||||||
// carries an optional `usage` field at the root with cumulative token
|
|
||||||
// totals for the session. The field is UNSTABLE in v1 but is implemented
|
|
||||||
// by all major ACP clients. We additionally mirror the same payload into
|
|
||||||
// `_meta.claudeCode.usage` for consumers that read the vendor namespace.
|
|
||||||
// thoughtTokens are reported as 0 until the bridge tracks them, but are
|
|
||||||
// included in totalTokens so totals match the sum of components.
|
|
||||||
if (usage) {
|
|
||||||
const thoughtTokens = 0
|
|
||||||
const usagePayload = {
|
|
||||||
inputTokens: usage.inputTokens,
|
|
||||||
outputTokens: usage.outputTokens,
|
|
||||||
cachedReadTokens: usage.cachedReadTokens,
|
|
||||||
cachedWriteTokens: usage.cachedWriteTokens,
|
|
||||||
thoughtTokens,
|
|
||||||
totalTokens:
|
|
||||||
usage.inputTokens +
|
|
||||||
usage.outputTokens +
|
|
||||||
usage.cachedReadTokens +
|
|
||||||
usage.cachedWriteTokens +
|
|
||||||
thoughtTokens,
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
stopReason,
|
|
||||||
usage: usagePayload,
|
|
||||||
...(userMessageId ? { userMessageId } : {}),
|
|
||||||
_meta: {
|
|
||||||
claudeCode: {
|
|
||||||
usage: usagePayload,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
stopReason,
|
|
||||||
...(userMessageId ? { userMessageId } : {}),
|
|
||||||
}
|
|
||||||
} catch (err: unknown) {
|
|
||||||
// Treat AbortError / cancellation-shaped errors as a turn cancellation
|
|
||||||
// regardless of the session.cancelled flag, to close the race window
|
|
||||||
// between interrupt() firing and cancel() setting the flag. Per
|
|
||||||
// prompt-turn.mdx the Agent MUST return `cancelled` for aborts.
|
|
||||||
const isAbort =
|
|
||||||
err instanceof Error &&
|
|
||||||
(err.name === 'AbortError' ||
|
|
||||||
/abort|cancelled|interrupt/i.test(err.message))
|
|
||||||
if (session.cancelled || isAbort) {
|
|
||||||
return { stopReason: 'cancelled' }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for process death errors
|
|
||||||
if (
|
|
||||||
err instanceof Error &&
|
|
||||||
(err.message.includes('terminated') ||
|
|
||||||
err.message.includes('process exited'))
|
|
||||||
) {
|
|
||||||
await this.teardownSession(params.sessionId)
|
|
||||||
throw new Error(
|
|
||||||
'The Claude Agent process exited unexpectedly. Please start a new session.',
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
throw err
|
|
||||||
} finally {
|
|
||||||
// Resolve next pending prompt if any
|
|
||||||
const nextPrompt = popNextPendingPrompt(session)
|
|
||||||
if (nextPrompt) {
|
|
||||||
session.promptRunning = true
|
|
||||||
nextPrompt.resolve(false)
|
|
||||||
} else {
|
|
||||||
session.promptRunning = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── setSessionConfigOption ───────────────────────────────────────
|
|
||||||
|
|
||||||
async function setSessionConfigOption(
|
|
||||||
this: AcpAgent,
|
|
||||||
params: SetSessionConfigOptionRequest,
|
|
||||||
): Promise<SetSessionConfigOptionResponse> {
|
|
||||||
const session = this.sessions.get(params.sessionId)
|
|
||||||
if (!session) {
|
|
||||||
throw new Error('Session not found')
|
|
||||||
}
|
|
||||||
if (typeof params.value !== 'string') {
|
|
||||||
throw new Error(
|
|
||||||
`Invalid value for config option ${params.configId}: ${String(params.value)}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const option = session.configOptions.find(o => o.id === params.configId)
|
|
||||||
if (!option) {
|
|
||||||
throw new Error(`Unknown config option: ${params.configId}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Per session-config-options.mdx: value MUST be one of the values listed
|
|
||||||
// in the option's options array. Reject unknown values with an error
|
|
||||||
// rather than silently persisting them. Only `select` options carry an
|
|
||||||
// options array; `boolean` options have no enumerated values.
|
|
||||||
if (option.type === 'select') {
|
|
||||||
const validValues = flattenConfigOptionValues(
|
|
||||||
(option as { options?: unknown }).options,
|
|
||||||
)
|
|
||||||
if (!validValues.includes(params.value)) {
|
|
||||||
throw new Error(
|
|
||||||
`Invalid value '${params.value}' for config option ${params.configId}; must be one of: ${validValues.join(', ')}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const value = params.value
|
|
||||||
|
|
||||||
if (params.configId === 'mode') {
|
|
||||||
this.applySessionMode(params.sessionId, value)
|
|
||||||
await getConnection(this).sessionUpdate({
|
|
||||||
sessionId: params.sessionId,
|
|
||||||
update: {
|
|
||||||
sessionUpdate: 'current_mode_update',
|
|
||||||
currentModeId: value,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} else if (params.configId === 'model') {
|
|
||||||
session.queryEngine.setModel(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
syncSessionConfigState(this, session, params.configId, value)
|
|
||||||
|
|
||||||
session.configOptions = session.configOptions.map(o =>
|
|
||||||
o.id === params.configId && typeof o.currentValue === 'string'
|
|
||||||
? { ...o, currentValue: value }
|
|
||||||
: o,
|
|
||||||
)
|
|
||||||
|
|
||||||
return { configOptions: session.configOptions }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Private-field accessors ──────────────────────────────────────
|
|
||||||
//
|
|
||||||
// getConnection / readClientCapabilities / syncSessionConfigState are
|
|
||||||
// imported from ./internalAccessors.js (shared with sessionLifecycle.ts and
|
|
||||||
// createSessionMethod.ts). The session_info_update helper below is local to
|
|
||||||
// this module because it is only called from prompt().
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Emit a session_info_update notification carrying a derived session title
|
|
||||||
* (truncated first user prompt) and the current last-activity timestamp.
|
|
||||||
* Sent once per session — subsequent turns reuse the same title.
|
|
||||||
*
|
|
||||||
* This logic was originally the private `AcpAgent.maybeEmitSessionInfoUpdate`
|
|
||||||
* method on the shell class. It is only called from the prompt flow, so it
|
|
||||||
* lives here to avoid the `noUnusedPrivateClassMembers` false positive that
|
|
||||||
* cast-based access would otherwise trigger on the shell.
|
|
||||||
*/
|
|
||||||
async function emitSessionInfoUpdate(
|
|
||||||
agent: AcpAgent,
|
|
||||||
sessionId: string,
|
|
||||||
firstPrompt: string,
|
|
||||||
): Promise<void> {
|
|
||||||
const session = agent.sessions.get(sessionId)
|
|
||||||
if (!session) return
|
|
||||||
// sessionInfoTitleSent is tracked via toolUseCache to avoid reshaping
|
|
||||||
// AcpSession; use a dedicated per-session flag instead.
|
|
||||||
const cache = session.toolUseCache as ToolUseCache & {
|
|
||||||
__sessionInfoTitleSent?: boolean
|
|
||||||
}
|
|
||||||
if (cache.__sessionInfoTitleSent) return
|
|
||||||
cache.__sessionInfoTitleSent = true
|
|
||||||
const title = sanitizeTitle(firstPrompt).slice(0, 100)
|
|
||||||
try {
|
|
||||||
await getConnection(agent).sessionUpdate({
|
|
||||||
sessionId,
|
|
||||||
update: {
|
|
||||||
sessionUpdate: 'session_info_update',
|
|
||||||
...(title ? { title } : {}),
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[ACP] Failed to send session_info_update:', err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Prototype attachment ─────────────────────────────────────────
|
|
||||||
|
|
||||||
Object.assign(AcpAgent.prototype, {
|
|
||||||
prompt,
|
|
||||||
setSessionConfigOption,
|
|
||||||
})
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import type { AcpSession, PendingPrompt } from './sessionTypes.js'
|
|
||||||
|
|
||||||
export function popNextPendingPrompt(
|
|
||||||
session: AcpSession,
|
|
||||||
): PendingPrompt | undefined {
|
|
||||||
while (session.pendingQueueHead < session.pendingQueue.length) {
|
|
||||||
const nextId = session.pendingQueue[session.pendingQueueHead++]
|
|
||||||
if (!nextId) continue
|
|
||||||
const next = session.pendingMessages.get(nextId)
|
|
||||||
if (!next) continue
|
|
||||||
session.pendingMessages.delete(nextId)
|
|
||||||
compactPendingQueue(session)
|
|
||||||
return next
|
|
||||||
}
|
|
||||||
|
|
||||||
compactPendingQueue(session)
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
function compactPendingQueue(session: AcpSession): void {
|
|
||||||
if (session.pendingQueueHead === 0) return
|
|
||||||
|
|
||||||
if (session.pendingQueueHead >= session.pendingQueue.length) {
|
|
||||||
session.pendingQueue = []
|
|
||||||
session.pendingQueueHead = 0
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
session.pendingQueueHead > 1024 &&
|
|
||||||
session.pendingQueueHead * 2 > session.pendingQueue.length
|
|
||||||
) {
|
|
||||||
session.pendingQueue = session.pendingQueue.slice(session.pendingQueueHead)
|
|
||||||
session.pendingQueueHead = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,266 +0,0 @@
|
|||||||
/**
|
|
||||||
* Session-lifecycle methods for AcpAgent (excluding createSession, which
|
|
||||||
* lives in ./createSessionMethod.ts), attached to the prototype via
|
|
||||||
* Object.assign. The barrel (./index.ts) imports this module for its side
|
|
||||||
* effect so the prototype is populated before any instance is built.
|
|
||||||
*
|
|
||||||
* Methods attached here: getOrCreateSession, teardownSession,
|
|
||||||
* replaySessionHistory, applySessionMode, updateConfigOption.
|
|
||||||
*/
|
|
||||||
import { type UUID } from 'node:crypto'
|
|
||||||
import { dirname } from 'node:path'
|
|
||||||
import type {
|
|
||||||
NewSessionRequest,
|
|
||||||
NewSessionResponse,
|
|
||||||
} from '@agentclientprotocol/sdk'
|
|
||||||
import type { Message } from '../../../types/message.js'
|
|
||||||
import { deserializeMessages } from '../../../utils/conversationRecovery.js'
|
|
||||||
import { getLastSessionLog } from '../../../utils/sessionStorage.js'
|
|
||||||
import type { PermissionMode } from '../../../types/permissions.js'
|
|
||||||
import { setOriginalCwd, switchSession } from '../../../bootstrap/state.js'
|
|
||||||
import type { SessionId } from '../../../types/ids.js'
|
|
||||||
import { replayHistoryMessages } from '../bridge.js'
|
|
||||||
import { computeSessionFingerprint } from '../utils.js'
|
|
||||||
import { resolveSessionFilePath } from '../../../utils/sessionStoragePortable.js'
|
|
||||||
import { AcpAgent } from './AcpAgent.js'
|
|
||||||
import type { AcpSession } from './sessionTypes.js'
|
|
||||||
import { isPermissionMode } from './permissionMode.js'
|
|
||||||
import {
|
|
||||||
getConnection,
|
|
||||||
readClientCapabilities,
|
|
||||||
syncSessionConfigState,
|
|
||||||
} from './internalAccessors.js'
|
|
||||||
|
|
||||||
// ── getOrCreateSession ───────────────────────────────────────────
|
|
||||||
|
|
||||||
async function getOrCreateSession(
|
|
||||||
this: AcpAgent,
|
|
||||||
params: {
|
|
||||||
sessionId: string
|
|
||||||
cwd: string
|
|
||||||
mcpServers?: NewSessionRequest['mcpServers']
|
|
||||||
_meta?: NewSessionRequest['_meta']
|
|
||||||
// replay:true (default, session/load) streams the conversation history back
|
|
||||||
// to the client via session/update. replay:false (session/resume) only
|
|
||||||
// restores the in-process context — per session-setup.mdx the Agent MUST
|
|
||||||
// NOT replay history when resuming.
|
|
||||||
replay?: boolean
|
|
||||||
},
|
|
||||||
): Promise<NewSessionResponse> {
|
|
||||||
const shouldReplay = params.replay !== false
|
|
||||||
const existingSession = this.sessions.get(params.sessionId)
|
|
||||||
if (existingSession) {
|
|
||||||
const fingerprint = computeSessionFingerprint({
|
|
||||||
cwd: params.cwd,
|
|
||||||
mcpServers: params.mcpServers as
|
|
||||||
| Array<{ name: string; [key: string]: unknown }>
|
|
||||||
| undefined,
|
|
||||||
})
|
|
||||||
if (fingerprint === existingSession.sessionFingerprint) {
|
|
||||||
const resolved = await resolveSessionFilePath(
|
|
||||||
params.sessionId,
|
|
||||||
params.cwd,
|
|
||||||
)
|
|
||||||
switchSession(
|
|
||||||
params.sessionId as SessionId,
|
|
||||||
resolved ? dirname(resolved.filePath) : null,
|
|
||||||
)
|
|
||||||
setOriginalCwd(params.cwd)
|
|
||||||
|
|
||||||
if (shouldReplay) {
|
|
||||||
await this.replaySessionHistory(params)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
sessionId: params.sessionId,
|
|
||||||
modes: existingSession.modes,
|
|
||||||
// Carry models over on reconnect so the client keeps its model selector
|
|
||||||
// populated (standard clients gate supportsModelSelection on this field).
|
|
||||||
models: existingSession.models,
|
|
||||||
configOptions: existingSession.configOptions,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.teardownSession(params.sessionId)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Locate the session file by sessionId. resolveSessionFilePath searches
|
|
||||||
// the requested cwd's project dir first, then falls back to sibling git
|
|
||||||
// worktrees — sessions created inside a repo (including from subdirectories
|
|
||||||
// or ephemeral test envs nested in the repo) all persist under the same
|
|
||||||
// parent project dir.
|
|
||||||
const resolved = await resolveSessionFilePath(params.sessionId, params.cwd)
|
|
||||||
const projectDir = resolved ? dirname(resolved.filePath) : null
|
|
||||||
|
|
||||||
switchSession(params.sessionId as SessionId, projectDir)
|
|
||||||
setOriginalCwd(params.cwd)
|
|
||||||
|
|
||||||
let initialMessages: Message[] | undefined
|
|
||||||
if (resolved) {
|
|
||||||
try {
|
|
||||||
const log = await getLastSessionLog(params.sessionId as UUID)
|
|
||||||
if (log && log.messages.length > 0) {
|
|
||||||
initialMessages = deserializeMessages(log.messages)
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[ACP] Failed to load session history:', err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await this.createSession(
|
|
||||||
{
|
|
||||||
cwd: params.cwd,
|
|
||||||
mcpServers: params.mcpServers ?? [],
|
|
||||||
_meta: params._meta,
|
|
||||||
},
|
|
||||||
{ sessionId: params.sessionId, initialMessages },
|
|
||||||
)
|
|
||||||
|
|
||||||
// Replay history to client if loaded. session/resume skips this block.
|
|
||||||
if (shouldReplay && initialMessages && initialMessages.length > 0) {
|
|
||||||
const session = this.sessions.get(params.sessionId)
|
|
||||||
if (session) {
|
|
||||||
await replayHistoryMessages(
|
|
||||||
params.sessionId,
|
|
||||||
initialMessages as unknown as Array<Record<string, unknown>>,
|
|
||||||
getConnection(this),
|
|
||||||
session.toolUseCache,
|
|
||||||
readClientCapabilities(this),
|
|
||||||
session.cwd,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
sessionId: response.sessionId,
|
|
||||||
modes: response.modes,
|
|
||||||
// createSession already returns models; pass it through. Same reason as above.
|
|
||||||
models: response.models,
|
|
||||||
configOptions: response.configOptions,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── teardownSession ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function teardownSession(
|
|
||||||
this: AcpAgent,
|
|
||||||
sessionId: string,
|
|
||||||
): Promise<void> {
|
|
||||||
const session = this.sessions.get(sessionId)
|
|
||||||
if (!session) return
|
|
||||||
|
|
||||||
await this.cancel({ sessionId })
|
|
||||||
this.sessions.delete(sessionId)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── replaySessionHistory ─────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load session history from disk and replay it to the ACP client.
|
|
||||||
* Used when switching back to a session that is already in memory
|
|
||||||
* (the client needs the conversation replayed to display it).
|
|
||||||
*/
|
|
||||||
async function replaySessionHistory(
|
|
||||||
this: AcpAgent,
|
|
||||||
params: {
|
|
||||||
sessionId: string
|
|
||||||
cwd: string
|
|
||||||
},
|
|
||||||
): Promise<void> {
|
|
||||||
try {
|
|
||||||
const log = await getLastSessionLog(params.sessionId as UUID)
|
|
||||||
if (!log || log.messages.length === 0) return
|
|
||||||
const messages = deserializeMessages(log.messages)
|
|
||||||
if (messages.length === 0) return
|
|
||||||
|
|
||||||
const session = this.sessions.get(params.sessionId)
|
|
||||||
if (!session) return
|
|
||||||
|
|
||||||
await replayHistoryMessages(
|
|
||||||
params.sessionId,
|
|
||||||
messages as unknown as Array<Record<string, unknown>>,
|
|
||||||
getConnection(this),
|
|
||||||
session.toolUseCache,
|
|
||||||
readClientCapabilities(this),
|
|
||||||
session.cwd,
|
|
||||||
)
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[ACP] Failed to replay session history:', err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── applySessionMode ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
function applySessionMode(
|
|
||||||
this: AcpAgent,
|
|
||||||
sessionId: string,
|
|
||||||
modeId: string,
|
|
||||||
): void {
|
|
||||||
if (!isPermissionMode(modeId)) {
|
|
||||||
throw new Error(`Invalid mode: ${modeId}`)
|
|
||||||
}
|
|
||||||
const session = this.sessions.get(sessionId)
|
|
||||||
if (session) {
|
|
||||||
if (
|
|
||||||
modeId === 'bypassPermissions' &&
|
|
||||||
!session.appState.toolPermissionContext.isBypassPermissionsModeAvailable
|
|
||||||
) {
|
|
||||||
throw new Error(`Mode not available: ${modeId}`)
|
|
||||||
}
|
|
||||||
const isAvailable = session.modes.availableModes.some(
|
|
||||||
mode => mode.id === modeId,
|
|
||||||
)
|
|
||||||
if (!isAvailable) {
|
|
||||||
throw new Error(`Mode not available: ${modeId}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
session.modes = { ...session.modes, currentModeId: modeId }
|
|
||||||
// Sync mode to appState so the permission pipeline sees the correct mode
|
|
||||||
session.appState.toolPermissionContext = {
|
|
||||||
...session.appState.toolPermissionContext,
|
|
||||||
mode: modeId as PermissionMode,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── updateConfigOption ───────────────────────────────────────────
|
|
||||||
|
|
||||||
async function updateConfigOption(
|
|
||||||
this: AcpAgent,
|
|
||||||
sessionId: string,
|
|
||||||
configId: string,
|
|
||||||
value: string,
|
|
||||||
): Promise<void> {
|
|
||||||
const session = this.sessions.get(sessionId)
|
|
||||||
if (!session) return
|
|
||||||
|
|
||||||
// Delegate to the shell's private syncSessionConfigState via a typed cast.
|
|
||||||
// The shell declares syncSessionConfigState as a private method; it is not
|
|
||||||
// part of the merged public interface, so we access it through the shared
|
|
||||||
// internal accessor to preserve exact original behavior.
|
|
||||||
syncSessionConfigState(this, session, configId, value)
|
|
||||||
|
|
||||||
session.configOptions = session.configOptions.map(o =>
|
|
||||||
o.id === configId && typeof o.currentValue === 'string'
|
|
||||||
? { ...o, currentValue: value }
|
|
||||||
: o,
|
|
||||||
)
|
|
||||||
|
|
||||||
await getConnection(this).sessionUpdate({
|
|
||||||
sessionId,
|
|
||||||
update: {
|
|
||||||
sessionUpdate: 'config_option_update',
|
|
||||||
configOptions: session.configOptions,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Prototype attachment ─────────────────────────────────────────
|
|
||||||
|
|
||||||
Object.assign(AcpAgent.prototype, {
|
|
||||||
getOrCreateSession,
|
|
||||||
teardownSession,
|
|
||||||
replaySessionHistory,
|
|
||||||
applySessionMode,
|
|
||||||
updateConfigOption,
|
|
||||||
})
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import type {
|
|
||||||
ClientCapabilities,
|
|
||||||
SessionModeState,
|
|
||||||
SessionModelState,
|
|
||||||
SessionConfigOption,
|
|
||||||
} from '@agentclientprotocol/sdk'
|
|
||||||
import type { QueryEngine } from '../../../QueryEngine.js'
|
|
||||||
import type { Command } from '../../../types/command.js'
|
|
||||||
import type { AppState } from '../../../state/AppStateStore.js'
|
|
||||||
import type { ToolUseCache } from '../bridge.js'
|
|
||||||
|
|
||||||
// ── Session state ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export type AcpSession = {
|
|
||||||
queryEngine: QueryEngine
|
|
||||||
cancelled: boolean
|
|
||||||
cancelGeneration: number
|
|
||||||
cwd: string
|
|
||||||
sessionFingerprint: string
|
|
||||||
modes: SessionModeState
|
|
||||||
models: SessionModelState
|
|
||||||
configOptions: SessionConfigOption[]
|
|
||||||
promptRunning: boolean
|
|
||||||
pendingMessages: Map<string, PendingPrompt>
|
|
||||||
pendingQueue: string[]
|
|
||||||
pendingQueueHead: number
|
|
||||||
toolUseCache: ToolUseCache
|
|
||||||
clientCapabilities?: ClientCapabilities
|
|
||||||
appState: AppState
|
|
||||||
commands: Command[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PendingPrompt = {
|
|
||||||
resolve: (cancelled: boolean) => void
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,146 +0,0 @@
|
|||||||
// Low-level conversion of Claude content block shapes into ACP ContentBlock values.
|
|
||||||
import type { ContentBlock, ToolCallContent } from './types.js'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wraps a string or array of content blocks into a `{ content: ToolCallContent[] }`
|
|
||||||
* update object. Used by `toolUpdateFromToolResult` for the default / error paths.
|
|
||||||
*/
|
|
||||||
export function toAcpContentUpdate(
|
|
||||||
content: unknown,
|
|
||||||
isError: boolean,
|
|
||||||
): { content?: ToolCallContent[] } {
|
|
||||||
if (Array.isArray(content) && content.length > 0) {
|
|
||||||
return {
|
|
||||||
content: content.map((c: Record<string, unknown>) => ({
|
|
||||||
type: 'content' as const,
|
|
||||||
content: toAcpContentBlock(c, isError),
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (typeof content === 'string' && content.length > 0) {
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'content' as const,
|
|
||||||
content: {
|
|
||||||
type: 'text' as const,
|
|
||||||
text: isError ? `\`\`\`\n${content}\n\`\`\`` : content,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toAcpContentBlock(
|
|
||||||
content: Record<string, unknown>,
|
|
||||||
isError: boolean,
|
|
||||||
): ContentBlock {
|
|
||||||
const wrapText = (text: string): ContentBlock => ({
|
|
||||||
type: 'text',
|
|
||||||
text: isError ? `\`\`\`\n${text}\n\`\`\`` : text,
|
|
||||||
})
|
|
||||||
|
|
||||||
const type = content.type as string
|
|
||||||
switch (type) {
|
|
||||||
case 'text': {
|
|
||||||
const text = content.text as string
|
|
||||||
return { type: 'text', text: isError ? `\`\`\`\n${text}\n\`\`\`` : text }
|
|
||||||
}
|
|
||||||
case 'image': {
|
|
||||||
const source = content.source as Record<string, unknown> | undefined
|
|
||||||
if (source?.type === 'base64') {
|
|
||||||
return {
|
|
||||||
type: 'image',
|
|
||||||
data: source.data as string,
|
|
||||||
mimeType: source.media_type as string,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return wrapText(
|
|
||||||
source?.type === 'url'
|
|
||||||
? `[image: ${source.url as string}]`
|
|
||||||
: '[image: file reference]',
|
|
||||||
)
|
|
||||||
}
|
|
||||||
case 'resource_link': {
|
|
||||||
// ACP v1 ResourceLink requires name + uri. Name falls back to uri when
|
|
||||||
// absent so the client always has a display label. mimeType is optional.
|
|
||||||
const uri = content.uri as string | undefined
|
|
||||||
const name =
|
|
||||||
(content.name as string | undefined) ?? (uri as string | undefined)
|
|
||||||
return {
|
|
||||||
type: 'resource_link',
|
|
||||||
uri: uri as string,
|
|
||||||
name: name as string,
|
|
||||||
mimeType: content.mimeType as string | undefined,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case 'resource': {
|
|
||||||
// ACP v1 EmbeddedResource wraps an optional TextResource / BlobResource
|
|
||||||
// shape. Forward the standard fields the client knows how to render.
|
|
||||||
const r = content.resource as Record<string, unknown> | undefined
|
|
||||||
// Construct a TextResource or BlobResource payload depending on what is
|
|
||||||
// present. Cast through unknown because not every source shape satisfies
|
|
||||||
// the full union contract.
|
|
||||||
const resourcePayload = {
|
|
||||||
uri: (r?.uri as string | undefined) ?? '',
|
|
||||||
mimeType: r?.mimeType as string | null | undefined,
|
|
||||||
...(typeof r?.text === 'string' ? { text: r.text as string } : {}),
|
|
||||||
...(typeof r?.blob === 'string' ? { blob: r.blob as string } : {}),
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
type: 'resource',
|
|
||||||
resource: resourcePayload,
|
|
||||||
} as unknown as ContentBlock
|
|
||||||
}
|
|
||||||
case 'tool_reference':
|
|
||||||
return wrapText(`Tool: ${content.tool_name as string}`)
|
|
||||||
case 'tool_search_tool_search_result': {
|
|
||||||
const refs = content.tool_references as
|
|
||||||
| Array<{ tool_name: string }>
|
|
||||||
| undefined
|
|
||||||
return wrapText(
|
|
||||||
`Tools found: ${refs?.map(r => r.tool_name).join(', ') || 'none'}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
case 'tool_search_tool_result_error':
|
|
||||||
return wrapText(
|
|
||||||
`Error: ${content.error_code as string}${content.error_message ? ` - ${content.error_message as string}` : ''}`,
|
|
||||||
)
|
|
||||||
case 'web_search_result':
|
|
||||||
return wrapText(`${content.title as string} (${content.url as string})`)
|
|
||||||
case 'web_search_tool_result_error':
|
|
||||||
return wrapText(`Error: ${content.error_code as string}`)
|
|
||||||
case 'web_fetch_result':
|
|
||||||
return wrapText(`Fetched: ${content.url as string}`)
|
|
||||||
case 'web_fetch_tool_result_error':
|
|
||||||
return wrapText(`Error: ${content.error_code as string}`)
|
|
||||||
case 'code_execution_result':
|
|
||||||
case 'bash_code_execution_result':
|
|
||||||
return wrapText(
|
|
||||||
`Output: ${(content.stdout as string) || (content.stderr as string) || ''}`,
|
|
||||||
)
|
|
||||||
case 'code_execution_tool_result_error':
|
|
||||||
case 'bash_code_execution_tool_result_error':
|
|
||||||
return wrapText(`Error: ${content.error_code as string}`)
|
|
||||||
case 'text_editor_code_execution_view_result':
|
|
||||||
return wrapText(content.content as string)
|
|
||||||
case 'text_editor_code_execution_create_result':
|
|
||||||
return wrapText(content.is_file_update ? 'File updated' : 'File created')
|
|
||||||
case 'text_editor_code_execution_str_replace_result': {
|
|
||||||
const lines = content.lines as string[] | undefined
|
|
||||||
return wrapText(lines?.join('\n') || '')
|
|
||||||
}
|
|
||||||
case 'text_editor_code_execution_tool_result_error':
|
|
||||||
return wrapText(
|
|
||||||
`Error: ${content.error_code as string}${content.error_message ? ` - ${content.error_message as string}` : ''}`,
|
|
||||||
)
|
|
||||||
default:
|
|
||||||
try {
|
|
||||||
return { type: 'text', text: JSON.stringify(content) }
|
|
||||||
} catch {
|
|
||||||
return { type: 'text', text: '[content]' }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,464 +0,0 @@
|
|||||||
// Stream replay + forwarding loop.
|
|
||||||
//
|
|
||||||
// `nextSdkMessageOrAbort` races an async generator against an AbortSignal.
|
|
||||||
// `forwardSessionUpdates` consumes the SDKMessage stream and dispatches into
|
|
||||||
// the notification converters, accumulating usage and mapping stop reasons.
|
|
||||||
// `replayHistoryMessages` replays stored user/assistant history through
|
|
||||||
// `toAcpNotifications`.
|
|
||||||
import { randomUUID } from 'node:crypto'
|
|
||||||
import type {
|
|
||||||
AgentSideConnection,
|
|
||||||
ClientCapabilities,
|
|
||||||
StopReason,
|
|
||||||
} from '@agentclientprotocol/sdk'
|
|
||||||
import type { SDKMessage } from '../../../entrypoints/sdk/coreTypes.generated.js'
|
|
||||||
import type { BridgeSDKMessage, SessionUsage, ToolUseCache } from './types.js'
|
|
||||||
import {
|
|
||||||
assistantMessageToAcpNotifications,
|
|
||||||
streamEventToAcpNotifications,
|
|
||||||
toAcpNotifications,
|
|
||||||
} from './notifications.js'
|
|
||||||
import { getMatchingModelUsage } from './modelUsage.js'
|
|
||||||
|
|
||||||
// Top-level const alias retained from the original module. Only the
|
|
||||||
// forwardSessionUpdates default branch and replayHistoryMessages reference it.
|
|
||||||
const logger: { debug: (...args: unknown[]) => void } = console
|
|
||||||
|
|
||||||
export function nextSdkMessageOrAbort(
|
|
||||||
sdkMessages: AsyncGenerator<SDKMessage, void, unknown>,
|
|
||||||
abortSignal: AbortSignal,
|
|
||||||
): Promise<IteratorResult<SDKMessage, void>> {
|
|
||||||
if (abortSignal.aborted) {
|
|
||||||
return Promise.resolve({ done: true, value: undefined })
|
|
||||||
}
|
|
||||||
let abortHandler: (() => void) | undefined
|
|
||||||
const abortPromise = new Promise<IteratorResult<SDKMessage, void>>(
|
|
||||||
resolve => {
|
|
||||||
abortHandler = () => resolve({ done: true, value: undefined })
|
|
||||||
abortSignal.addEventListener('abort', abortHandler, { once: true })
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return Promise.race([sdkMessages.next(), abortPromise]).finally(() => {
|
|
||||||
if (abortHandler) {
|
|
||||||
abortSignal.removeEventListener('abort', abortHandler)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Main forwarding function ──────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Iterates SDKMessages from QueryEngine.submitMessage(), converts each
|
|
||||||
* to ACP SessionUpdate notifications, and sends them via conn.sessionUpdate().
|
|
||||||
* Returns the final StopReason and accumulated usage for the prompt turn.
|
|
||||||
*/
|
|
||||||
export async function forwardSessionUpdates(
|
|
||||||
sessionId: string,
|
|
||||||
sdkMessages: AsyncGenerator<SDKMessage, void, unknown>,
|
|
||||||
conn: AgentSideConnection,
|
|
||||||
abortSignal: AbortSignal,
|
|
||||||
toolUseCache: ToolUseCache,
|
|
||||||
clientCapabilities?: ClientCapabilities,
|
|
||||||
cwd?: string,
|
|
||||||
isCancelled?: () => boolean,
|
|
||||||
): Promise<{ stopReason: StopReason; usage?: SessionUsage }> {
|
|
||||||
let stopReason: StopReason = 'end_turn'
|
|
||||||
const accumulatedUsage: SessionUsage = {
|
|
||||||
inputTokens: 0,
|
|
||||||
outputTokens: 0,
|
|
||||||
cachedReadTokens: 0,
|
|
||||||
cachedWriteTokens: 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Track last assistant usage/model for context window size computation
|
|
||||||
let lastAssistantTotalUsage: number | null = null
|
|
||||||
let lastAssistantModel: string | null = null
|
|
||||||
let lastContextWindowSize = 200000
|
|
||||||
let streamingActive = false
|
|
||||||
|
|
||||||
// Per message-id.mdx RFD: UUID identifying the current top-level agent
|
|
||||||
// message. Lazily generated on the first sign of a new assistant message
|
|
||||||
// (stream_event or assistant SDK message with parent_tool_use_id === null)
|
|
||||||
// and reset to null after the assistant message completes. All chunks of
|
|
||||||
// the same message share this ID; different messages get different IDs.
|
|
||||||
// Subagent messages (parent_tool_use_id !== null) don't get a tracked ID
|
|
||||||
// — they're nested inside a tool call and don't surface as top-level
|
|
||||||
// agent_message_chunk / agent_thought_chunk in the spec sense.
|
|
||||||
let currentAgentMessageId: string | null = null
|
|
||||||
|
|
||||||
try {
|
|
||||||
while (!abortSignal.aborted) {
|
|
||||||
// Race the next message against the abort signal so we unblock
|
|
||||||
// immediately when cancelled, even if the generator is waiting for
|
|
||||||
// a slow API response.
|
|
||||||
const nextResult = await nextSdkMessageOrAbort(sdkMessages, abortSignal)
|
|
||||||
if (nextResult.done || abortSignal.aborted) break
|
|
||||||
const rawMsg = nextResult.value
|
|
||||||
if (rawMsg == null) continue
|
|
||||||
const msg = rawMsg as BridgeSDKMessage
|
|
||||||
|
|
||||||
switch (msg.type) {
|
|
||||||
// ── System messages ────────────────────────────────────────
|
|
||||||
case 'system': {
|
|
||||||
const subtype = msg.subtype
|
|
||||||
|
|
||||||
if (subtype === 'compact_boundary') {
|
|
||||||
// Reset assistant usage tracking after compaction. We don't emit a
|
|
||||||
// usage_update here because we don't know the post-compaction context
|
|
||||||
// size — the next prompt's result will carry the corrected value.
|
|
||||||
lastAssistantTotalUsage = 0
|
|
||||||
await conn.sessionUpdate({
|
|
||||||
sessionId,
|
|
||||||
update: {
|
|
||||||
sessionUpdate: 'agent_message_chunk',
|
|
||||||
content: { type: 'text', text: '\n\nCompacting completed.' },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
// api_retry, local_command_output — skip for now
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Result messages ────────────────────────────────────────
|
|
||||||
case 'result': {
|
|
||||||
const usage = msg.usage
|
|
||||||
|
|
||||||
if (usage) {
|
|
||||||
accumulatedUsage.inputTokens += usage.input_tokens ?? 0
|
|
||||||
accumulatedUsage.outputTokens += usage.output_tokens ?? 0
|
|
||||||
accumulatedUsage.cachedReadTokens +=
|
|
||||||
usage.cache_read_input_tokens ?? 0
|
|
||||||
accumulatedUsage.cachedWriteTokens +=
|
|
||||||
usage.cache_creation_input_tokens ?? 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve context window size from modelUsage via prefix matching
|
|
||||||
const modelUsage = msg.modelUsage
|
|
||||||
if (modelUsage && lastAssistantModel) {
|
|
||||||
const match = getMatchingModelUsage(modelUsage, lastAssistantModel)
|
|
||||||
if (match?.contextWindow) {
|
|
||||||
lastContextWindowSize = match.contextWindow
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Per session-usage.mdx RFD: emit usage_update so clients can display
|
|
||||||
// context window utilization (e.g. "53K / 200K"). Although usage_update
|
|
||||||
// is currently UNSTABLE in the v1 schema, it is the only standardized
|
|
||||||
// carrier for context-window state and is implemented by all major ACP
|
|
||||||
// clients (Zed, Cursor, etc.). Strict v1-stable compliance broke this
|
|
||||||
// UX (clients showed 0/0), so we emit it whenever we have usage data.
|
|
||||||
// See audit §4.1 for the prior strict-compliance rationale and revert.
|
|
||||||
if (lastAssistantTotalUsage !== null) {
|
|
||||||
await conn.sessionUpdate({
|
|
||||||
sessionId,
|
|
||||||
update: {
|
|
||||||
sessionUpdate: 'usage_update',
|
|
||||||
used: lastAssistantTotalUsage,
|
|
||||||
size: lastContextWindowSize,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine stop reason
|
|
||||||
const subtype = msg.subtype
|
|
||||||
const isError = msg.is_error
|
|
||||||
|
|
||||||
if (abortSignal.aborted) {
|
|
||||||
stopReason = 'cancelled'
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (subtype) {
|
|
||||||
case 'success': {
|
|
||||||
// Map Anthropic stop_reason to ACP StopReason. Branches are mutually
|
|
||||||
// exclusive so a max_tokens termination that is also flagged isError
|
|
||||||
// no longer silently flips to end_turn (audit §3.3, §3.4). refusal
|
|
||||||
// (safety refusal) is a first-class ACP stop reason that must surface
|
|
||||||
// to the client instead of being misreported as end_turn.
|
|
||||||
const r = msg.stop_reason
|
|
||||||
if (r === 'max_tokens') stopReason = 'max_tokens'
|
|
||||||
else if (r === 'refusal') stopReason = 'refusal'
|
|
||||||
else stopReason = 'end_turn'
|
|
||||||
if (isError) stopReason = 'end_turn'
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case 'error_during_execution': {
|
|
||||||
// Mutually exclusive: max_tokens wins when reported, otherwise the
|
|
||||||
// error path falls back to end_turn. Avoids the prior two-if
|
|
||||||
// sequence that overwrote max_tokens with end_turn (audit §3.4).
|
|
||||||
if (msg.stop_reason === 'max_tokens') {
|
|
||||||
stopReason = 'max_tokens'
|
|
||||||
} else {
|
|
||||||
stopReason = 'end_turn'
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case 'error_max_budget_usd':
|
|
||||||
case 'error_max_turns':
|
|
||||||
case 'error_max_structured_output_retries':
|
|
||||||
if (isError) {
|
|
||||||
stopReason = 'max_turn_requests'
|
|
||||||
} else {
|
|
||||||
stopReason = 'max_turn_requests'
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Stream events ──────────────────────────────────────────
|
|
||||||
case 'stream_event': {
|
|
||||||
// Lazily generate messageId for top-level assistant messages on the
|
|
||||||
// first stream event. Subagent stream_events (parent_tool_use_id !==
|
|
||||||
// null) don't get a tracked ID — they're nested inside a tool call.
|
|
||||||
const streamParent = msg.parent_tool_use_id
|
|
||||||
if (streamParent === null && currentAgentMessageId === null) {
|
|
||||||
currentAgentMessageId = randomUUID()
|
|
||||||
}
|
|
||||||
// After the lazy-generate above, currentAgentMessageId is a string
|
|
||||||
// when streamParent === null. Capture it locally so TS narrows.
|
|
||||||
const streamMessageId =
|
|
||||||
streamParent === null
|
|
||||||
? (currentAgentMessageId ?? undefined)
|
|
||||||
: undefined
|
|
||||||
const notifications = streamEventToAcpNotifications(
|
|
||||||
msg,
|
|
||||||
sessionId,
|
|
||||||
toolUseCache,
|
|
||||||
conn,
|
|
||||||
{
|
|
||||||
clientCapabilities,
|
|
||||||
cwd,
|
|
||||||
messageId: streamMessageId,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
for (const notification of notifications) {
|
|
||||||
await conn.sessionUpdate(notification)
|
|
||||||
}
|
|
||||||
streamingActive = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Assistant messages ─────────────────────────────────────
|
|
||||||
case 'assistant': {
|
|
||||||
// Track last assistant total usage for context window computation
|
|
||||||
// (only for top-level messages, not subagents)
|
|
||||||
const assistantMsg = msg.message
|
|
||||||
const parentToolUseId = msg.parent_tool_use_id
|
|
||||||
if (assistantMsg?.usage && parentToolUseId === null) {
|
|
||||||
const usage = assistantMsg.usage
|
|
||||||
lastAssistantTotalUsage =
|
|
||||||
(typeof usage.input_tokens === 'number'
|
|
||||||
? usage.input_tokens
|
|
||||||
: 0) +
|
|
||||||
(typeof usage.output_tokens === 'number'
|
|
||||||
? usage.output_tokens
|
|
||||||
: 0) +
|
|
||||||
(typeof usage.cache_read_input_tokens === 'number'
|
|
||||||
? usage.cache_read_input_tokens
|
|
||||||
: 0) +
|
|
||||||
(typeof usage.cache_creation_input_tokens === 'number'
|
|
||||||
? usage.cache_creation_input_tokens
|
|
||||||
: 0)
|
|
||||||
}
|
|
||||||
// Track the current top-level model for context window size lookup
|
|
||||||
if (
|
|
||||||
parentToolUseId === null &&
|
|
||||||
assistantMsg?.model &&
|
|
||||||
assistantMsg.model !== '<synthetic>'
|
|
||||||
) {
|
|
||||||
lastAssistantModel = assistantMsg.model
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reuse the messageId already generated for stream_events of this
|
|
||||||
// top-level message; if no stream_events arrived (e.g., synthetic
|
|
||||||
// message without streaming), generate one now. Then reset so the
|
|
||||||
// next assistant message gets a fresh UUID.
|
|
||||||
let assistantMessageId: string | undefined
|
|
||||||
if (parentToolUseId === null) {
|
|
||||||
if (currentAgentMessageId === null) {
|
|
||||||
currentAgentMessageId = randomUUID()
|
|
||||||
}
|
|
||||||
assistantMessageId = currentAgentMessageId
|
|
||||||
}
|
|
||||||
|
|
||||||
const notifications = assistantMessageToAcpNotifications(
|
|
||||||
msg,
|
|
||||||
sessionId,
|
|
||||||
toolUseCache,
|
|
||||||
conn,
|
|
||||||
{
|
|
||||||
clientCapabilities,
|
|
||||||
cwd,
|
|
||||||
parentToolUseId,
|
|
||||||
streamingActive,
|
|
||||||
messageId: assistantMessageId,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
for (const notification of notifications) {
|
|
||||||
await conn.sessionUpdate(notification)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset after the top-level assistant message completes so the
|
|
||||||
// next message (stream_event or assistant) gets a fresh UUID.
|
|
||||||
if (parentToolUseId === null) {
|
|
||||||
currentAgentMessageId = null
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── User messages ──────────────────────────────────────────
|
|
||||||
case 'user': {
|
|
||||||
// In ACP mode, user messages from replay/synthetic are typically skipped
|
|
||||||
// The client already knows what the user sent
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Progress messages ──────────────────────────────────────
|
|
||||||
case 'progress': {
|
|
||||||
const progressData = msg.data
|
|
||||||
if (!progressData) break
|
|
||||||
|
|
||||||
// Handle agent/skill subagent progress
|
|
||||||
const progressType = progressData.type
|
|
||||||
if (
|
|
||||||
progressType === 'agent_progress' ||
|
|
||||||
progressType === 'skill_progress'
|
|
||||||
) {
|
|
||||||
const progressMessage = progressData.message
|
|
||||||
if (progressMessage) {
|
|
||||||
const content = progressMessage.content as
|
|
||||||
| Array<Record<string, unknown>>
|
|
||||||
| undefined
|
|
||||||
if (content) {
|
|
||||||
for (const block of content) {
|
|
||||||
if (block.type === 'text') {
|
|
||||||
await conn.sessionUpdate({
|
|
||||||
sessionId,
|
|
||||||
update: {
|
|
||||||
sessionUpdate: 'agent_message_chunk',
|
|
||||||
content: { type: 'text', text: block.text as string },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Tool use summary ───────────────────────────────────────
|
|
||||||
case 'tool_use_summary': {
|
|
||||||
// Skip for now — not critical for basic functionality
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Attachment messages ────────────────────────────────────
|
|
||||||
case 'attachment': {
|
|
||||||
// Skip — handled by QueryEngine internally
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Compact boundary ───────────────────────────────────────
|
|
||||||
case 'compact_boundary': {
|
|
||||||
// Don't emit usage_update here — we don't know the post-compaction
|
|
||||||
// context size. The next prompt's result will carry the corrected value.
|
|
||||||
lastAssistantTotalUsage = 0
|
|
||||||
await conn.sessionUpdate({
|
|
||||||
sessionId,
|
|
||||||
update: {
|
|
||||||
sessionUpdate: 'agent_message_chunk',
|
|
||||||
content: { type: 'text', text: '\n\nCompacting completed.' },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
logger.debug('Ignoring unknown SDK message type')
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we exited the loop because abort fired or cancel was requested, return cancelled
|
|
||||||
if (abortSignal.aborted || isCancelled?.()) {
|
|
||||||
return { stopReason: 'cancelled', usage: accumulatedUsage }
|
|
||||||
}
|
|
||||||
} catch (err: unknown) {
|
|
||||||
if (abortSignal.aborted) {
|
|
||||||
return { stopReason: 'cancelled', usage: accumulatedUsage }
|
|
||||||
}
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
|
|
||||||
return { stopReason, usage: accumulatedUsage }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── History replay ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Replays conversation history messages to the ACP client as session updates.
|
|
||||||
* Used when resuming/loading a session to show the client the previous conversation.
|
|
||||||
*/
|
|
||||||
export async function replayHistoryMessages(
|
|
||||||
sessionId: string,
|
|
||||||
messages: Array<Record<string, unknown>>,
|
|
||||||
conn: AgentSideConnection,
|
|
||||||
toolUseCache: ToolUseCache,
|
|
||||||
clientCapabilities?: ClientCapabilities,
|
|
||||||
cwd?: string,
|
|
||||||
): Promise<void> {
|
|
||||||
for (const rawMsg of messages) {
|
|
||||||
const msg = rawMsg as BridgeSDKMessage
|
|
||||||
// Skip non-conversation messages
|
|
||||||
if (msg.type !== 'user' && msg.type !== 'assistant') {
|
|
||||||
logger.debug('Ignoring unknown SDK message type')
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// Skip meta messages (synthetic continuation prompts)
|
|
||||||
if (msg.isMeta === true) continue
|
|
||||||
|
|
||||||
const messageData = msg.message
|
|
||||||
const content = messageData?.content
|
|
||||||
if (!content) continue
|
|
||||||
|
|
||||||
const role: 'assistant' | 'user' =
|
|
||||||
msg.type === 'assistant' ? 'assistant' : 'user'
|
|
||||||
|
|
||||||
if (typeof content === 'string') {
|
|
||||||
if (!content.trim()) continue
|
|
||||||
// Per message-id.mdx RFD: each replayed message gets its own UUID
|
|
||||||
// (JSONL doesn't preserve the original ACP messageId). All chunks of
|
|
||||||
// the same message share the ID.
|
|
||||||
const replayMessageId = randomUUID()
|
|
||||||
await conn.sessionUpdate({
|
|
||||||
sessionId,
|
|
||||||
update: {
|
|
||||||
sessionUpdate:
|
|
||||||
role === 'assistant' ? 'agent_message_chunk' : 'user_message_chunk',
|
|
||||||
...(replayMessageId ? { messageId: replayMessageId } : {}),
|
|
||||||
content: { type: 'text', text: content },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(content)) {
|
|
||||||
// Each replayed message gets a fresh UUID independent of other messages.
|
|
||||||
const replayMessageId = randomUUID()
|
|
||||||
const notifications = toAcpNotifications(
|
|
||||||
content as Array<Record<string, unknown>>,
|
|
||||||
role,
|
|
||||||
sessionId,
|
|
||||||
toolUseCache,
|
|
||||||
conn,
|
|
||||||
undefined,
|
|
||||||
{ clientCapabilities, cwd, messageId: replayMessageId },
|
|
||||||
)
|
|
||||||
for (const notification of notifications) {
|
|
||||||
await conn.sessionUpdate(notification)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
// Pure helpers used by the forwarding loop to resolve contextWindow from the
|
|
||||||
// modelUsage map by longest prefix match.
|
|
||||||
|
|
||||||
export function commonPrefixLength(a: string, b: string): number {
|
|
||||||
let i = 0
|
|
||||||
const maxLen = Math.min(a.length, b.length)
|
|
||||||
while (i < maxLen && a[i] === b[i]) i++
|
|
||||||
return i
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getMatchingModelUsage(
|
|
||||||
modelUsage: Record<string, { contextWindow?: number }>,
|
|
||||||
currentModel: string,
|
|
||||||
): { contextWindow?: number } | null {
|
|
||||||
let bestKey: string | null = null
|
|
||||||
let bestLen = 0
|
|
||||||
|
|
||||||
for (const key of Object.keys(modelUsage)) {
|
|
||||||
const len = commonPrefixLength(key, currentModel)
|
|
||||||
if (len > bestLen) {
|
|
||||||
bestLen = len
|
|
||||||
bestKey = key
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return bestKey ? (modelUsage[bestKey] ?? null) : null
|
|
||||||
}
|
|
||||||
@@ -1,363 +0,0 @@
|
|||||||
// Core content-block → SessionUpdate conversion engine.
|
|
||||||
//
|
|
||||||
// `toAcpNotifications` handles text/thinking/image/tool_use/tool_result/etc.
|
|
||||||
// and writes into the ToolUseCache. `assistantMessageToAcpNotifications` and
|
|
||||||
// `streamEventToAcpNotifications` are thin adapters. `normalizePlanStatus`
|
|
||||||
// maps TodoWrite status strings onto the ACP PlanEntry status enum.
|
|
||||||
import type {
|
|
||||||
AgentSideConnection,
|
|
||||||
ClientCapabilities,
|
|
||||||
PlanEntry,
|
|
||||||
SessionNotification,
|
|
||||||
SessionUpdate,
|
|
||||||
} from '@agentclientprotocol/sdk'
|
|
||||||
import type { ToolUseCache } from './types.js'
|
|
||||||
import { toolInfoFromToolUse } from './toolInfo.js'
|
|
||||||
import { toolUpdateFromToolResult } from './toolResults.js'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Maps a TodoWrite status string onto the ACP PlanEntry status enum.
|
|
||||||
* Unknown / unsupported values fall back to 'pending'.
|
|
||||||
*/
|
|
||||||
export function normalizePlanStatus(
|
|
||||||
status: string,
|
|
||||||
): 'pending' | 'in_progress' | 'completed' {
|
|
||||||
if (status === 'in_progress') return 'in_progress'
|
|
||||||
if (status === 'completed') return 'completed'
|
|
||||||
return 'pending'
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toAcpNotifications(
|
|
||||||
content: Array<Record<string, unknown>>,
|
|
||||||
role: 'assistant' | 'user',
|
|
||||||
sessionId: string,
|
|
||||||
toolUseCache: ToolUseCache,
|
|
||||||
_conn: AgentSideConnection,
|
|
||||||
_logger?: { error: (...args: unknown[]) => void },
|
|
||||||
options?: {
|
|
||||||
registerHooks?: boolean
|
|
||||||
clientCapabilities?: ClientCapabilities
|
|
||||||
parentToolUseId?: string | null
|
|
||||||
cwd?: string
|
|
||||||
streamingActive?: boolean
|
|
||||||
// Per message-id.mdx RFD: UUID identifying the message these chunks
|
|
||||||
// belong to. Only attached to agent_message_chunk / user_message_chunk /
|
|
||||||
// agent_thought_chunk (spec scope). undefined = omit the field entirely.
|
|
||||||
messageId?: string
|
|
||||||
},
|
|
||||||
): SessionNotification[] {
|
|
||||||
const output: SessionNotification[] = []
|
|
||||||
|
|
||||||
for (const chunk of content) {
|
|
||||||
const chunkType = chunk.type as string
|
|
||||||
let update: SessionUpdate | null = null
|
|
||||||
|
|
||||||
switch (chunkType) {
|
|
||||||
case 'text':
|
|
||||||
case 'text_delta': {
|
|
||||||
const text = (chunk.text as string) ?? ''
|
|
||||||
update = {
|
|
||||||
sessionUpdate:
|
|
||||||
role === 'assistant' ? 'agent_message_chunk' : 'user_message_chunk',
|
|
||||||
...(options?.messageId ? { messageId: options.messageId } : {}),
|
|
||||||
content: { type: 'text', text },
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'thinking':
|
|
||||||
case 'thinking_delta': {
|
|
||||||
const thinking = (chunk.thinking as string) ?? ''
|
|
||||||
update = {
|
|
||||||
sessionUpdate: 'agent_thought_chunk',
|
|
||||||
...(options?.messageId ? { messageId: options.messageId } : {}),
|
|
||||||
content: { type: 'text', text: thinking },
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'image': {
|
|
||||||
const source = chunk.source as Record<string, unknown> | undefined
|
|
||||||
if (source?.type === 'base64') {
|
|
||||||
update = {
|
|
||||||
sessionUpdate:
|
|
||||||
role === 'assistant'
|
|
||||||
? 'agent_message_chunk'
|
|
||||||
: 'user_message_chunk',
|
|
||||||
...(options?.messageId ? { messageId: options.messageId } : {}),
|
|
||||||
content: {
|
|
||||||
type: 'image',
|
|
||||||
data: source.data as string,
|
|
||||||
mimeType: source.media_type as string,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'tool_use':
|
|
||||||
case 'server_tool_use':
|
|
||||||
case 'mcp_tool_use': {
|
|
||||||
const toolUseId = (chunk.id as string) ?? ''
|
|
||||||
const toolName = (chunk.name as string) ?? 'unknown'
|
|
||||||
const toolInput = chunk.input as Record<string, unknown> | undefined
|
|
||||||
const alreadyCached = toolUseId in toolUseCache
|
|
||||||
|
|
||||||
// Cache this tool_use for later matching
|
|
||||||
toolUseCache[toolUseId] = {
|
|
||||||
type: chunkType as 'tool_use' | 'server_tool_use' | 'mcp_tool_use',
|
|
||||||
id: toolUseId,
|
|
||||||
name: toolName,
|
|
||||||
input: toolInput,
|
|
||||||
}
|
|
||||||
|
|
||||||
// TodoWrite → plan update
|
|
||||||
if (toolName === 'TodoWrite') {
|
|
||||||
const todos = (toolInput as Record<string, unknown>)?.todos as
|
|
||||||
| Array<{ content: string; status: string }>
|
|
||||||
| undefined
|
|
||||||
if (Array.isArray(todos)) {
|
|
||||||
const entries: PlanEntry[] = todos.map(todo => ({
|
|
||||||
content: todo.content,
|
|
||||||
status: normalizePlanStatus(todo.status),
|
|
||||||
priority: 'medium',
|
|
||||||
}))
|
|
||||||
update = {
|
|
||||||
sessionUpdate: 'plan',
|
|
||||||
entries,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Regular tool call
|
|
||||||
const rawInput = toolInput ? { ...toolInput } : {}
|
|
||||||
|
|
||||||
if (alreadyCached) {
|
|
||||||
// Second encounter — tool_use input is now fully received.
|
|
||||||
// The tool is about to execute (pending permission, then run).
|
|
||||||
// Emit a tool_call_update with status 'in_progress' so clients
|
|
||||||
// can distinguish "awaiting approval / running" from the initial
|
|
||||||
// 'pending' (per ACP v1 ToolCallStatus lifecycle, schema.json:3525).
|
|
||||||
update = {
|
|
||||||
_meta: {
|
|
||||||
claudeCode: { toolName },
|
|
||||||
},
|
|
||||||
toolCallId: toolUseId,
|
|
||||||
sessionUpdate: 'tool_call_update',
|
|
||||||
status: 'in_progress',
|
|
||||||
rawInput,
|
|
||||||
...toolInfoFromToolUse(
|
|
||||||
{ name: toolName, id: toolUseId, input: toolInput ?? {} },
|
|
||||||
false,
|
|
||||||
options?.cwd,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// First encounter — send as tool_call
|
|
||||||
update = {
|
|
||||||
_meta: {
|
|
||||||
claudeCode: { toolName },
|
|
||||||
},
|
|
||||||
toolCallId: toolUseId,
|
|
||||||
sessionUpdate: 'tool_call',
|
|
||||||
rawInput,
|
|
||||||
status: 'pending',
|
|
||||||
...toolInfoFromToolUse(
|
|
||||||
{ name: toolName, id: toolUseId, input: toolInput ?? {} },
|
|
||||||
false,
|
|
||||||
options?.cwd,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'tool_result':
|
|
||||||
case 'mcp_tool_result': {
|
|
||||||
const toolUseId = (chunk.tool_use_id as string | undefined) ?? ''
|
|
||||||
const toolUse = toolUseCache[toolUseId]
|
|
||||||
if (!toolUse) break
|
|
||||||
|
|
||||||
if (toolUse.name !== 'TodoWrite') {
|
|
||||||
const toolUpdate = toolUpdateFromToolResult(
|
|
||||||
chunk as unknown as Record<string, unknown>,
|
|
||||||
{ name: toolUse.name, id: toolUse.id },
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
|
|
||||||
update = {
|
|
||||||
_meta: {
|
|
||||||
claudeCode: { toolName: toolUse.name },
|
|
||||||
},
|
|
||||||
toolCallId: toolUseId,
|
|
||||||
sessionUpdate: 'tool_call_update',
|
|
||||||
status:
|
|
||||||
(chunk.is_error as boolean | undefined) === true
|
|
||||||
? 'failed'
|
|
||||||
: 'completed',
|
|
||||||
rawOutput: chunk.content,
|
|
||||||
...toolUpdate,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'redacted_thinking':
|
|
||||||
case 'input_json_delta':
|
|
||||||
case 'citations_delta':
|
|
||||||
case 'signature_delta':
|
|
||||||
case 'container_upload':
|
|
||||||
case 'compaction':
|
|
||||||
case 'compaction_delta':
|
|
||||||
// Skip these types
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
if (update) {
|
|
||||||
// Add parentToolUseId to _meta if present
|
|
||||||
if (options?.parentToolUseId) {
|
|
||||||
const existingMeta = (update as Record<string, unknown>)._meta as
|
|
||||||
| Record<string, unknown>
|
|
||||||
| undefined
|
|
||||||
;(update as Record<string, unknown>)._meta = {
|
|
||||||
...existingMeta,
|
|
||||||
claudeCode: {
|
|
||||||
...((existingMeta?.claudeCode as Record<string, unknown>) ?? {}),
|
|
||||||
parentToolUseId: options.parentToolUseId,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
output.push({ sessionId, update })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return output
|
|
||||||
}
|
|
||||||
|
|
||||||
export function assistantMessageToAcpNotifications(
|
|
||||||
msg: { message?: unknown; parent_tool_use_id?: string | null },
|
|
||||||
sessionId: string,
|
|
||||||
toolUseCache: ToolUseCache,
|
|
||||||
conn: AgentSideConnection,
|
|
||||||
options?: {
|
|
||||||
clientCapabilities?: ClientCapabilities
|
|
||||||
parentToolUseId?: string | null
|
|
||||||
cwd?: string
|
|
||||||
streamingActive?: boolean
|
|
||||||
messageId?: string
|
|
||||||
},
|
|
||||||
): SessionNotification[] {
|
|
||||||
const message = msg.message as Record<string, unknown> | undefined
|
|
||||||
if (!message) return []
|
|
||||||
|
|
||||||
const content = message.content as
|
|
||||||
| string
|
|
||||||
| Array<Record<string, unknown>>
|
|
||||||
| undefined
|
|
||||||
if (!content) return []
|
|
||||||
|
|
||||||
// If content is a string, treat as text
|
|
||||||
if (typeof content === 'string') {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
sessionId,
|
|
||||||
update: {
|
|
||||||
sessionUpdate: 'agent_message_chunk',
|
|
||||||
...(options?.messageId ? { messageId: options.messageId } : {}),
|
|
||||||
content: { type: 'text', text: content },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
// When streaming is active, text/thinking were already sent via stream_event
|
|
||||||
// messages. Filter them out to avoid duplicate agent_message_chunk /
|
|
||||||
// agent_thought_chunk notifications. String content (synthetic messages)
|
|
||||||
// is unaffected — those have no corresponding stream_events.
|
|
||||||
const contentToProcess = options?.streamingActive
|
|
||||||
? content.filter(
|
|
||||||
block => block.type !== 'text' && block.type !== 'thinking',
|
|
||||||
)
|
|
||||||
: content
|
|
||||||
|
|
||||||
if (contentToProcess.length === 0) return []
|
|
||||||
|
|
||||||
return toAcpNotifications(
|
|
||||||
contentToProcess,
|
|
||||||
'assistant',
|
|
||||||
sessionId,
|
|
||||||
toolUseCache,
|
|
||||||
conn,
|
|
||||||
undefined,
|
|
||||||
options,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function streamEventToAcpNotifications(
|
|
||||||
msg: {
|
|
||||||
event?: Record<string, unknown>
|
|
||||||
parent_tool_use_id?: string | null
|
|
||||||
},
|
|
||||||
sessionId: string,
|
|
||||||
toolUseCache: ToolUseCache,
|
|
||||||
conn: AgentSideConnection,
|
|
||||||
options?: {
|
|
||||||
clientCapabilities?: ClientCapabilities
|
|
||||||
cwd?: string
|
|
||||||
streamingActive?: boolean
|
|
||||||
messageId?: string
|
|
||||||
},
|
|
||||||
): SessionNotification[] {
|
|
||||||
const event = (msg as unknown as { event: Record<string, unknown> }).event
|
|
||||||
if (!event) return []
|
|
||||||
|
|
||||||
switch (event.type as string) {
|
|
||||||
case 'content_block_start': {
|
|
||||||
const contentBlock = event.content_block as
|
|
||||||
| Record<string, unknown>
|
|
||||||
| undefined
|
|
||||||
if (!contentBlock) return []
|
|
||||||
return toAcpNotifications(
|
|
||||||
[contentBlock],
|
|
||||||
'assistant',
|
|
||||||
sessionId,
|
|
||||||
toolUseCache,
|
|
||||||
conn,
|
|
||||||
undefined,
|
|
||||||
{
|
|
||||||
clientCapabilities: options?.clientCapabilities,
|
|
||||||
parentToolUseId: msg.parent_tool_use_id as string | null | undefined,
|
|
||||||
cwd: options?.cwd,
|
|
||||||
messageId: options?.messageId,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
case 'content_block_delta': {
|
|
||||||
const delta = event.delta as Record<string, unknown> | undefined
|
|
||||||
if (!delta) return []
|
|
||||||
return toAcpNotifications(
|
|
||||||
[delta],
|
|
||||||
'assistant',
|
|
||||||
sessionId,
|
|
||||||
toolUseCache,
|
|
||||||
conn,
|
|
||||||
undefined,
|
|
||||||
{
|
|
||||||
clientCapabilities: options?.clientCapabilities,
|
|
||||||
parentToolUseId: msg.parent_tool_use_id as string | null | undefined,
|
|
||||||
cwd: options?.cwd,
|
|
||||||
messageId: options?.messageId,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// No content to emit
|
|
||||||
case 'message_start':
|
|
||||||
case 'message_delta':
|
|
||||||
case 'message_stop':
|
|
||||||
case 'content_block_stop':
|
|
||||||
return []
|
|
||||||
|
|
||||||
default:
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
// Pure path-normalisation helper used by toolInfo / toolResults / forwarding.
|
|
||||||
import { isAbsolute, resolve } from 'node:path'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalises an emitted file path against the session cwd so that
|
|
||||||
* ToolCallLocation.path / Diff.path values are always absolute, as required
|
|
||||||
* by the ACP v1 spec (tool-calls.mdx:304-306; all file paths MUST be absolute).
|
|
||||||
* If no cwd is available, the original value is returned unchanged.
|
|
||||||
*/
|
|
||||||
export function toAbsolutePath(
|
|
||||||
filePath: string | undefined,
|
|
||||||
cwd?: string,
|
|
||||||
): string | undefined {
|
|
||||||
if (!filePath) return undefined
|
|
||||||
if (!cwd) return filePath
|
|
||||||
return isAbsolute(filePath) ? filePath : resolve(cwd, filePath)
|
|
||||||
}
|
|
||||||
@@ -1,239 +0,0 @@
|
|||||||
// toolInfoFromToolUse — large switch mapping each known tool name to ACP ToolInfo.
|
|
||||||
import type { ToolInfo } from './types.js'
|
|
||||||
import { toAbsolutePath } from './paths.js'
|
|
||||||
import { toDisplayPath } from '../utils.js'
|
|
||||||
|
|
||||||
export function toolInfoFromToolUse(
|
|
||||||
toolUse: { name: string; id: string; input: Record<string, unknown> },
|
|
||||||
_supportsTerminalOutput: boolean = false,
|
|
||||||
cwd?: string,
|
|
||||||
): ToolInfo {
|
|
||||||
const name = toolUse.name
|
|
||||||
const input = toolUse.input
|
|
||||||
|
|
||||||
switch (name) {
|
|
||||||
case 'Agent':
|
|
||||||
case 'Task': {
|
|
||||||
const description = (input?.description as string | undefined) ?? 'Task'
|
|
||||||
const prompt = input?.prompt as string | undefined
|
|
||||||
return {
|
|
||||||
title: description,
|
|
||||||
kind: 'think',
|
|
||||||
content: prompt
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
type: 'content' as const,
|
|
||||||
content: { type: 'text' as const, text: prompt },
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: [],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'Bash': {
|
|
||||||
const command = (input?.command as string | undefined) ?? 'Terminal'
|
|
||||||
const description = input?.description as string | undefined
|
|
||||||
// Standard ACP terminal lifecycle (terminal/create → embed real terminalId →
|
|
||||||
// terminal/release) is not wired through BashTool yet. Embedding a fake
|
|
||||||
// terminalId here would cause compliant clients to fail terminal/output
|
|
||||||
// lookups, so we fall back to inline text content per audit doc §5.2.
|
|
||||||
// The _supportsTerminalOutput flag is retained for forward compatibility
|
|
||||||
// once terminal/create is actually plumbed through.
|
|
||||||
void _supportsTerminalOutput
|
|
||||||
return {
|
|
||||||
title: command,
|
|
||||||
kind: 'execute',
|
|
||||||
content: description
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
type: 'content' as const,
|
|
||||||
content: { type: 'text' as const, text: description },
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: [],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'Read': {
|
|
||||||
const inputFilePath = input?.file_path as string | undefined
|
|
||||||
const filePath = inputFilePath ?? 'File'
|
|
||||||
const offset = input?.offset as number | undefined
|
|
||||||
const limit = input?.limit as number | undefined
|
|
||||||
let suffix = ''
|
|
||||||
if (limit && limit > 0) {
|
|
||||||
suffix = ` (${offset ?? 1} - ${(offset ?? 1) + limit - 1})`
|
|
||||||
} else if (offset) {
|
|
||||||
suffix = ` (from line ${offset})`
|
|
||||||
}
|
|
||||||
const displayPath = filePath ? toDisplayPath(filePath, cwd) : 'File'
|
|
||||||
const absReadPath = toAbsolutePath(inputFilePath, cwd)
|
|
||||||
return {
|
|
||||||
title: `Read ${displayPath}${suffix}`,
|
|
||||||
kind: 'read',
|
|
||||||
locations: absReadPath
|
|
||||||
? [{ path: absReadPath, line: offset ?? 1 }]
|
|
||||||
: [],
|
|
||||||
content: [],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'Write': {
|
|
||||||
const filePath = (input?.file_path as string | undefined) ?? ''
|
|
||||||
const content = (input?.content as string | undefined) ?? ''
|
|
||||||
const displayPath = filePath ? toDisplayPath(filePath, cwd) : undefined
|
|
||||||
const absWritePath = toAbsolutePath(filePath, cwd)
|
|
||||||
return {
|
|
||||||
title: displayPath ? `Write ${displayPath}` : 'Write',
|
|
||||||
kind: 'edit',
|
|
||||||
content: absWritePath
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
type: 'diff' as const,
|
|
||||||
path: absWritePath,
|
|
||||||
oldText: null,
|
|
||||||
newText: content,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: [
|
|
||||||
{
|
|
||||||
type: 'content' as const,
|
|
||||||
content: { type: 'text' as const, text: content },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
locations: absWritePath ? [{ path: absWritePath }] : [],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'Edit': {
|
|
||||||
const filePath = (input?.file_path as string | undefined) ?? ''
|
|
||||||
const oldString = (input?.old_string as string | undefined) ?? ''
|
|
||||||
const newString = (input?.new_string as string | undefined) ?? ''
|
|
||||||
const displayPath = filePath ? toDisplayPath(filePath, cwd) : undefined
|
|
||||||
const absEditPath = toAbsolutePath(filePath, cwd)
|
|
||||||
return {
|
|
||||||
title: displayPath ? `Edit ${displayPath}` : 'Edit',
|
|
||||||
kind: 'edit',
|
|
||||||
content: absEditPath
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
type: 'diff' as const,
|
|
||||||
path: absEditPath,
|
|
||||||
oldText: oldString || null,
|
|
||||||
newText: newString,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: [],
|
|
||||||
locations: absEditPath ? [{ path: absEditPath }] : [],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'Glob': {
|
|
||||||
const globPath = (input?.path as string | undefined) ?? ''
|
|
||||||
const pattern = (input?.pattern as string | undefined) ?? ''
|
|
||||||
const absGlobPath = toAbsolutePath(globPath, cwd)
|
|
||||||
let label = 'Find'
|
|
||||||
if (globPath) label += ` \`${globPath}\``
|
|
||||||
if (pattern) label += ` \`${pattern}\``
|
|
||||||
return {
|
|
||||||
title: label,
|
|
||||||
kind: 'search',
|
|
||||||
content: [],
|
|
||||||
locations: absGlobPath ? [{ path: absGlobPath }] : [],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'Grep': {
|
|
||||||
const grepPattern = (input?.pattern as string | undefined) ?? ''
|
|
||||||
const grepPath = (input?.path as string | undefined) ?? ''
|
|
||||||
let label = 'grep'
|
|
||||||
if (input?.['-i']) label += ' -i'
|
|
||||||
if (input?.['-n']) label += ' -n'
|
|
||||||
if (input?.['-A'] !== undefined) label += ` -A ${input['-A'] as number}`
|
|
||||||
if (input?.['-B'] !== undefined) label += ` -B ${input['-B'] as number}`
|
|
||||||
if (input?.['-C'] !== undefined) label += ` -C ${input['-C'] as number}`
|
|
||||||
if (input?.output_mode === 'files_with_matches') label += ' -l'
|
|
||||||
else if (input?.output_mode === 'count') label += ' -c'
|
|
||||||
if (input?.head_limit !== undefined)
|
|
||||||
label += ` | head -${input.head_limit as number}`
|
|
||||||
if (input?.glob) label += ` --include="${input.glob as string}"`
|
|
||||||
if (input?.type) label += ` --type=${input.type as string}`
|
|
||||||
if (input?.multiline) label += ' -P'
|
|
||||||
if (grepPattern) label += ` "${grepPattern}"`
|
|
||||||
if (grepPath) label += ` ${grepPath}`
|
|
||||||
return {
|
|
||||||
title: label,
|
|
||||||
kind: 'search',
|
|
||||||
content: [],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'WebFetch': {
|
|
||||||
const url = (input?.url as string | undefined) ?? ''
|
|
||||||
const fetchPrompt = input?.prompt as string | undefined
|
|
||||||
return {
|
|
||||||
title: url ? `Fetch ${url}` : 'Fetch',
|
|
||||||
kind: 'fetch',
|
|
||||||
content: fetchPrompt
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
type: 'content' as const,
|
|
||||||
content: { type: 'text' as const, text: fetchPrompt },
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: [],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'WebSearch': {
|
|
||||||
const query = (input?.query as string | undefined) ?? 'Web search'
|
|
||||||
let label = `"${query}"`
|
|
||||||
const allowed = input?.allowed_domains as string[] | undefined
|
|
||||||
const blocked = input?.blocked_domains as string[] | undefined
|
|
||||||
if (allowed && allowed.length > 0)
|
|
||||||
label += ` (allowed: ${allowed.join(', ')})`
|
|
||||||
if (blocked && blocked.length > 0)
|
|
||||||
label += ` (blocked: ${blocked.join(', ')})`
|
|
||||||
return {
|
|
||||||
title: label,
|
|
||||||
kind: 'fetch',
|
|
||||||
content: [],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'TodoWrite': {
|
|
||||||
const todos = input?.todos as Array<{ content: string }> | undefined
|
|
||||||
return {
|
|
||||||
title: Array.isArray(todos)
|
|
||||||
? `Update TODOs: ${todos.map(t => t.content).join(', ')}`
|
|
||||||
: 'Update TODOs',
|
|
||||||
kind: 'think',
|
|
||||||
content: [],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'ExitPlanMode': {
|
|
||||||
const plan = (input as Record<string, unknown>)?.plan as
|
|
||||||
| string
|
|
||||||
| undefined
|
|
||||||
return {
|
|
||||||
title: 'Ready to code?',
|
|
||||||
kind: 'switch_mode',
|
|
||||||
content: plan
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
type: 'content' as const,
|
|
||||||
content: { type: 'text' as const, text: plan },
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: [],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
return {
|
|
||||||
title: name || 'Unknown Tool',
|
|
||||||
kind: 'other',
|
|
||||||
content: [],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
// Tool result → ToolCallContent conversion.
|
|
||||||
import type { ToolCallContent } from './types.js'
|
|
||||||
import type { EditToolResponse } from './types.js'
|
|
||||||
import { toAcpContentUpdate, toAcpContentBlock } from './contentBlocks.js'
|
|
||||||
import { toAbsolutePath } from './paths.js'
|
|
||||||
import { markdownEscape } from '../utils.js'
|
|
||||||
|
|
||||||
export function toolUpdateFromToolResult(
|
|
||||||
toolResult: Record<string, unknown>,
|
|
||||||
toolUse: { name: string; id: string } | undefined,
|
|
||||||
_supportsTerminalOutput: boolean = false,
|
|
||||||
): {
|
|
||||||
content?: ToolCallContent[]
|
|
||||||
title?: string
|
|
||||||
_meta?: Record<string, unknown>
|
|
||||||
} {
|
|
||||||
if (!toolUse) return {}
|
|
||||||
|
|
||||||
const isError = toolResult.is_error === true
|
|
||||||
const resultContent = toolResult.content as
|
|
||||||
| string
|
|
||||||
| Array<Record<string, unknown>>
|
|
||||||
| undefined
|
|
||||||
|
|
||||||
// For error results, return error content
|
|
||||||
if (isError && resultContent) {
|
|
||||||
return toAcpContentUpdate(resultContent, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (toolUse.name) {
|
|
||||||
case 'Read': {
|
|
||||||
if (typeof resultContent === 'string' && resultContent.length > 0) {
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'content' as const,
|
|
||||||
content: {
|
|
||||||
type: 'text' as const,
|
|
||||||
text: markdownEscape(resultContent),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (Array.isArray(resultContent) && resultContent.length > 0) {
|
|
||||||
return {
|
|
||||||
content: resultContent.map((c: Record<string, unknown>) => ({
|
|
||||||
type: 'content' as const,
|
|
||||||
content:
|
|
||||||
c.type === 'text'
|
|
||||||
? {
|
|
||||||
type: 'text' as const,
|
|
||||||
text: markdownEscape(c.text as string),
|
|
||||||
}
|
|
||||||
: toAcpContentBlock(c, false),
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'Bash': {
|
|
||||||
let output = ''
|
|
||||||
// Standard ACP terminal lifecycle (terminal/create → embed real terminalId
|
|
||||||
// → terminal/release) is not wired through BashTool yet. Previously this
|
|
||||||
// branch embedded a fake terminalId (= toolUse.id, never registered via
|
|
||||||
// terminal/create) and injected non-standard _meta keys (terminal_info /
|
|
||||||
// terminal_output / terminal_exit) that compliant clients cannot
|
|
||||||
// interpret. We now fall back to inline text content for the output; see
|
|
||||||
// audit doc §5.2/§4.4. The _supportsTerminalOutput flag is retained on
|
|
||||||
// the signature for forward compatibility once terminal/create is plumbed
|
|
||||||
// through.
|
|
||||||
void _supportsTerminalOutput
|
|
||||||
|
|
||||||
// Handle bash_code_execution_result format
|
|
||||||
if (
|
|
||||||
resultContent &&
|
|
||||||
typeof resultContent === 'object' &&
|
|
||||||
!Array.isArray(resultContent) &&
|
|
||||||
(resultContent as Record<string, unknown>).type ===
|
|
||||||
'bash_code_execution_result'
|
|
||||||
) {
|
|
||||||
const bashResult = resultContent as Record<string, unknown>
|
|
||||||
output = [bashResult.stdout, bashResult.stderr]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join('\n')
|
|
||||||
} else if (typeof resultContent === 'string') {
|
|
||||||
output = resultContent
|
|
||||||
} else if (Array.isArray(resultContent) && resultContent.length > 0) {
|
|
||||||
output = resultContent
|
|
||||||
.map((c: Record<string, unknown>) =>
|
|
||||||
c.type === 'text' ? (c.text as string) : '',
|
|
||||||
)
|
|
||||||
.join('\n')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (output.trim()) {
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'content' as const,
|
|
||||||
content: {
|
|
||||||
type: 'text' as const,
|
|
||||||
text: `\`\`\`console\n${output.trimEnd()}\n\`\`\``,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'Edit':
|
|
||||||
case 'Write': {
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'ExitPlanMode': {
|
|
||||||
return { title: 'Exited Plan Mode' }
|
|
||||||
}
|
|
||||||
|
|
||||||
default: {
|
|
||||||
return toAcpContentUpdate(resultContent ?? '', isError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds diff ToolUpdate content from the structured Edit toolResponse.
|
|
||||||
* Parses structuredPatch hunks (lines prefixed with -, +, space) into
|
|
||||||
* oldText/newText diff pairs.
|
|
||||||
*
|
|
||||||
* The optional `cwd` is used to normalise the emitted path against the
|
|
||||||
* session cwd so that Diff.path / ToolCallLocation.path are absolute as
|
|
||||||
* required by the ACP v1 spec (audit §5.5).
|
|
||||||
*/
|
|
||||||
export function toolUpdateFromEditToolResponse(
|
|
||||||
toolResponse: unknown,
|
|
||||||
cwd?: string,
|
|
||||||
): {
|
|
||||||
content?: ToolCallContent[]
|
|
||||||
locations?: { path: string; line?: number }[]
|
|
||||||
} {
|
|
||||||
if (!toolResponse || typeof toolResponse !== 'object') return {}
|
|
||||||
const response = toolResponse as EditToolResponse
|
|
||||||
if (!response.filePath || !Array.isArray(response.structuredPatch)) return {}
|
|
||||||
|
|
||||||
const absPath = toAbsolutePath(response.filePath, cwd) ?? response.filePath
|
|
||||||
|
|
||||||
const content: ToolCallContent[] = []
|
|
||||||
const locations: { path: string; line?: number }[] = []
|
|
||||||
|
|
||||||
for (const { lines, newStart } of response.structuredPatch) {
|
|
||||||
const oldText: string[] = []
|
|
||||||
const newText: string[] = []
|
|
||||||
for (const line of lines) {
|
|
||||||
if (line.startsWith('-')) {
|
|
||||||
oldText.push(line.slice(1))
|
|
||||||
} else if (line.startsWith('+')) {
|
|
||||||
newText.push(line.slice(1))
|
|
||||||
} else {
|
|
||||||
oldText.push(line.slice(1))
|
|
||||||
newText.push(line.slice(1))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (oldText.length > 0 || newText.length > 0) {
|
|
||||||
locations.push({ path: absPath, line: newStart })
|
|
||||||
content.push({
|
|
||||||
type: 'diff',
|
|
||||||
path: absPath,
|
|
||||||
oldText: oldText.join('\n') || null,
|
|
||||||
newText: newText.join('\n'),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const result: {
|
|
||||||
content?: ToolCallContent[]
|
|
||||||
locations?: { path: string; line?: number }[]
|
|
||||||
} = {}
|
|
||||||
if (content.length > 0) result.content = content
|
|
||||||
if (locations.length > 0) result.locations = locations
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
// Shared ACP-bridge type definitions.
|
|
||||||
//
|
|
||||||
// Re-exports the SDK type-only imports that the rest of the bridge sub-modules
|
|
||||||
// depend on, plus the local discriminated union of every message shape consumed
|
|
||||||
// by the forwarding loop.
|
|
||||||
import type {
|
|
||||||
ContentBlock,
|
|
||||||
ToolCallContent,
|
|
||||||
ToolCallLocation,
|
|
||||||
ToolKind,
|
|
||||||
} from '@agentclientprotocol/sdk'
|
|
||||||
|
|
||||||
export type { ContentBlock, ToolCallContent, ToolCallLocation, ToolKind }
|
|
||||||
|
|
||||||
// ── ToolUseCache ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Maps tool_use_id → tool metadata for tracked inflight tool calls. */
|
|
||||||
export type ToolUseCache = {
|
|
||||||
[key: string]: {
|
|
||||||
type: 'tool_use' | 'server_tool_use' | 'mcp_tool_use'
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
input: unknown
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Session usage tracking ────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Accumulated token usage across a session, updated per result message. */
|
|
||||||
export type SessionUsage = {
|
|
||||||
inputTokens: number
|
|
||||||
outputTokens: number
|
|
||||||
cachedReadTokens: number
|
|
||||||
cachedWriteTokens: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Token usage reported in SDK result messages. */
|
|
||||||
export type BridgeUsage = {
|
|
||||||
input_tokens?: number
|
|
||||||
output_tokens?: number
|
|
||||||
cache_read_input_tokens?: number
|
|
||||||
cache_creation_input_tokens?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** system-init, compact_boundary, status, api_retry, local_command_output messages. */
|
|
||||||
export type BridgeSystemMessage = {
|
|
||||||
type: 'system'
|
|
||||||
subtype?: string
|
|
||||||
session_id?: string
|
|
||||||
content?: string
|
|
||||||
status?: string
|
|
||||||
compact_result?: string
|
|
||||||
compact_error?: string
|
|
||||||
model?: string
|
|
||||||
uuid?: string
|
|
||||||
[key: string]: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Turn completion message: success with usage, or error with stop_reason. */
|
|
||||||
export type BridgeResultMessage = {
|
|
||||||
type: 'result'
|
|
||||||
subtype?: string
|
|
||||||
usage?: BridgeUsage
|
|
||||||
modelUsage?: Record<string, { contextWindow?: number }>
|
|
||||||
total_cost_usd?: number
|
|
||||||
is_error?: boolean
|
|
||||||
stop_reason?: string | null
|
|
||||||
result?: string
|
|
||||||
errors?: string[]
|
|
||||||
duration_ms?: number
|
|
||||||
duration_api_ms?: number
|
|
||||||
num_turns?: number
|
|
||||||
permission_denials?: unknown[]
|
|
||||||
session_id?: string
|
|
||||||
[key: string]: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Full assistant response message after the turn completes. */
|
|
||||||
export type BridgeAssistantMessage = {
|
|
||||||
type: 'assistant'
|
|
||||||
message?: {
|
|
||||||
role?: string
|
|
||||||
id?: string
|
|
||||||
model?: string
|
|
||||||
content?: string | Array<Record<string, unknown>>
|
|
||||||
usage?: BridgeUsage | Record<string, unknown>
|
|
||||||
stop_reason?: string | null
|
|
||||||
[key: string]: unknown
|
|
||||||
}
|
|
||||||
parent_tool_use_id?: string | null
|
|
||||||
uuid?: string
|
|
||||||
session_id?: string
|
|
||||||
error?: unknown
|
|
||||||
[key: string]: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Real-time streaming event (aka partial_assistant in the SDK schema). */
|
|
||||||
export type BridgeStreamEventMessage = {
|
|
||||||
type: 'stream_event'
|
|
||||||
event?: { type?: string; [key: string]: unknown }
|
|
||||||
message?: Record<string, unknown>
|
|
||||||
parent_tool_use_id?: string | null
|
|
||||||
session_id?: string
|
|
||||||
uuid?: string
|
|
||||||
[key: string]: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
/** User prompt message (may include tool_use_result from prior turns). */
|
|
||||||
export type BridgeUserMessage = {
|
|
||||||
type: 'user'
|
|
||||||
message?: Record<string, unknown>
|
|
||||||
uuid?: string
|
|
||||||
isReplay?: boolean
|
|
||||||
isMeta?: boolean
|
|
||||||
timestamp?: string
|
|
||||||
[key: string]: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Subagent or hook progress notification (internal, not an SDK message member). */
|
|
||||||
export type BridgeProgressMessage = {
|
|
||||||
type: 'progress'
|
|
||||||
data?: {
|
|
||||||
type?: string
|
|
||||||
message?: Record<string, unknown>
|
|
||||||
[key: string]: unknown
|
|
||||||
}
|
|
||||||
[key: string]: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Summary of tool calls made during a turn. */
|
|
||||||
export type BridgeToolUseSummaryMessage = {
|
|
||||||
type: 'tool_use_summary'
|
|
||||||
summary?: string
|
|
||||||
preceding_tool_use_ids?: string[]
|
|
||||||
uuid?: string
|
|
||||||
session_id?: string
|
|
||||||
[key: string]: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
/** File attachment metadata (internal, not an SDK message member). */
|
|
||||||
export type BridgeAttachmentMessage = {
|
|
||||||
type: 'attachment'
|
|
||||||
[key: string]: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Compaction boundary marker (type is 'compact_boundary', not 'system'). */
|
|
||||||
export type BridgeCompactBoundaryMessage = {
|
|
||||||
type: 'compact_boundary'
|
|
||||||
compact_metadata?: Record<string, unknown>
|
|
||||||
[key: string]: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
/** ACP bridge local discriminated union — covers all message shapes consumed by the forwarding loop. */
|
|
||||||
export type BridgeSDKMessage =
|
|
||||||
| BridgeSystemMessage
|
|
||||||
| BridgeResultMessage
|
|
||||||
| BridgeAssistantMessage
|
|
||||||
| BridgeStreamEventMessage
|
|
||||||
| BridgeUserMessage
|
|
||||||
| BridgeProgressMessage
|
|
||||||
| BridgeToolUseSummaryMessage
|
|
||||||
| BridgeAttachmentMessage
|
|
||||||
| BridgeCompactBoundaryMessage
|
|
||||||
|
|
||||||
// ── Tool info / edit response shapes ──────────────────────────────
|
|
||||||
|
|
||||||
/** Sanitised tool metadata sent to ACP client for tool_call notifications. */
|
|
||||||
export interface ToolInfo {
|
|
||||||
title: string
|
|
||||||
kind: ToolKind
|
|
||||||
content: ToolCallContent[]
|
|
||||||
locations?: ToolCallLocation[]
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Context lines and diff metadata for one hunk of an Edit tool response. */
|
|
||||||
export interface EditToolResponseHunk {
|
|
||||||
oldStart: number
|
|
||||||
oldLines: number
|
|
||||||
newStart: number
|
|
||||||
newLines: number
|
|
||||||
lines: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Result block for Edit/Write tool responses containing hunks and optional file stats. */
|
|
||||||
export interface EditToolResponse {
|
|
||||||
filePath?: string
|
|
||||||
structuredPatch?: EditToolResponseHunk[]
|
|
||||||
}
|
|
||||||
@@ -37,15 +37,6 @@ export function createAcpCanUseTool(
|
|||||||
cwd?: string,
|
cwd?: string,
|
||||||
onModeChange?: (modeId: string) => void,
|
onModeChange?: (modeId: string) => void,
|
||||||
isBypassModeAvailable?: () => boolean,
|
isBypassModeAvailable?: () => boolean,
|
||||||
/**
|
|
||||||
* Invoked when the ACP client returns a `cancelled` permission outcome.
|
|
||||||
* The Agent uses this to set the session-level cancelled flag and interrupt
|
|
||||||
* the running query so session/prompt resolves with StopReason::Cancelled
|
|
||||||
* (schema.json:629) instead of treating the cancellation as a plain deny.
|
|
||||||
* Optional for backwards compatibility with callers that have not been
|
|
||||||
* wired up yet.
|
|
||||||
*/
|
|
||||||
onPermissionCancelled?: () => void,
|
|
||||||
): CanUseToolFn {
|
): CanUseToolFn {
|
||||||
return async (
|
return async (
|
||||||
tool: ToolType,
|
tool: ToolType,
|
||||||
@@ -73,7 +64,6 @@ export function createAcpCanUseTool(
|
|||||||
cwd,
|
cwd,
|
||||||
onModeChange,
|
onModeChange,
|
||||||
isBypassModeAvailable,
|
isBypassModeAvailable,
|
||||||
onPermissionCancelled,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,11 +124,6 @@ export function createAcpCanUseTool(
|
|||||||
{ kind: 'allow_always', name: 'Always Allow', optionId: 'allow_always' },
|
{ kind: 'allow_always', name: 'Always Allow', optionId: 'allow_always' },
|
||||||
{ kind: 'allow_once', name: 'Allow', optionId: 'allow' },
|
{ kind: 'allow_once', name: 'Allow', optionId: 'allow' },
|
||||||
{ kind: 'reject_once', name: 'Reject', optionId: 'reject' },
|
{ kind: 'reject_once', name: 'Reject', optionId: 'reject' },
|
||||||
{
|
|
||||||
kind: 'reject_always',
|
|
||||||
name: 'Always Reject',
|
|
||||||
optionId: 'reject_always',
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -149,15 +134,10 @@ export function createAcpCanUseTool(
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (response.outcome.outcome === 'cancelled') {
|
if (response.outcome.outcome === 'cancelled') {
|
||||||
// Per schema.json:629, a cancelled permission outcome means the prompt
|
|
||||||
// turn was cancelled. Signal the session so prompt() resolves with
|
|
||||||
// StopReason::Cancelled instead of treating this as a normal denial.
|
|
||||||
onPermissionCancelled?.()
|
|
||||||
return {
|
return {
|
||||||
behavior: 'deny',
|
behavior: 'deny',
|
||||||
message: 'Permission request cancelled by client',
|
message: 'Permission request cancelled by client',
|
||||||
decisionReason: { type: 'mode', mode: 'default' },
|
decisionReason: { type: 'mode', mode: 'default' },
|
||||||
toolUseID,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,7 +181,6 @@ async function handleExitPlanMode(
|
|||||||
cwd?: string,
|
cwd?: string,
|
||||||
onModeChange?: (modeId: string) => void,
|
onModeChange?: (modeId: string) => void,
|
||||||
isBypassModeAvailable?: () => boolean,
|
isBypassModeAvailable?: () => boolean,
|
||||||
onPermissionCancelled?: () => void,
|
|
||||||
): Promise<PermissionAllowDecision | PermissionDenyDecision> {
|
): Promise<PermissionAllowDecision | PermissionDenyDecision> {
|
||||||
const options: Array<PermissionOption> = [
|
const options: Array<PermissionOption> = [
|
||||||
{
|
{
|
||||||
@@ -250,8 +229,6 @@ async function handleExitPlanMode(
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (response.outcome.outcome === 'cancelled') {
|
if (response.outcome.outcome === 'cancelled') {
|
||||||
// Propagate cancellation so prompt() resolves with StopReason::Cancelled.
|
|
||||||
onPermissionCancelled?.()
|
|
||||||
return {
|
return {
|
||||||
behavior: 'deny',
|
behavior: 'deny',
|
||||||
message: 'Tool use aborted',
|
message: 'Tool use aborted',
|
||||||
@@ -302,11 +279,6 @@ async function handleExitPlanMode(
|
|||||||
|
|
||||||
function checkTerminalOutput(clientCapabilities?: ClientCapabilities): boolean {
|
function checkTerminalOutput(clientCapabilities?: ClientCapabilities): boolean {
|
||||||
if (!clientCapabilities) return false
|
if (!clientCapabilities) return false
|
||||||
// Standard ACP v1 capability: ClientCapabilities.terminal (boolean).
|
|
||||||
if (clientCapabilities.terminal === true) return true
|
|
||||||
// Legacy Claude-Code clients advertised terminal support via _meta before
|
|
||||||
// the standard `terminal` boolean existed. `_meta` is reserved per the spec,
|
|
||||||
// but we keep this fallback for backward compatibility with older clients.
|
|
||||||
const meta = (clientCapabilities as unknown as Record<string, unknown>)._meta
|
const meta = (clientCapabilities as unknown as Record<string, unknown>)._meta
|
||||||
if (!meta || typeof meta !== 'object') return false
|
if (!meta || typeof meta !== 'object') return false
|
||||||
return (meta as Record<string, unknown>)['terminal_output'] === true
|
return (meta as Record<string, unknown>)['terminal_output'] === true
|
||||||
|
|||||||
@@ -20,20 +20,6 @@ export function promptToQueryInput(
|
|||||||
const resource = b.resource as Record<string, unknown> | undefined
|
const resource = b.resource as Record<string, unknown> | undefined
|
||||||
if (resource && typeof resource.text === 'string') {
|
if (resource && typeof resource.text === 'string') {
|
||||||
parts.push(resource.text)
|
parts.push(resource.text)
|
||||||
} else if (resource && typeof resource.blob === 'string') {
|
|
||||||
// BlobResource (e.g. PDF/binary): query input is string-only, so emit a
|
|
||||||
// readable placeholder instead of silently dropping the content. Ideally
|
|
||||||
// this would be decoded and passed as a binary content block once the
|
|
||||||
// query layer supports multimodal input.
|
|
||||||
const mt =
|
|
||||||
typeof resource.mimeType === 'string'
|
|
||||||
? resource.mimeType
|
|
||||||
: 'application/octet-stream'
|
|
||||||
const uri =
|
|
||||||
typeof resource.uri === 'string' ? resource.uri : '(unknown uri)'
|
|
||||||
parts.push(
|
|
||||||
`Embedded resource: ${uri} (${mt}, base64 blob, ${resource.blob.length} chars)`,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
29
src/setup.ts
29
src/setup.ts
@@ -401,40 +401,11 @@ export async function setup(
|
|||||||
process.env.IS_SANDBOX !== '1' &&
|
process.env.IS_SANDBOX !== '1' &&
|
||||||
!isEnvTruthy(process.env.CLAUDE_CODE_BUBBLEWRAP)
|
!isEnvTruthy(process.env.CLAUDE_CODE_BUBBLEWRAP)
|
||||||
) {
|
) {
|
||||||
// Root + bypass = every tool call executes without review at uid 0.
|
|
||||||
// Interactive TTY: warn and require explicit "y" to proceed.
|
|
||||||
// Non-interactive (pipe, ACP, CI, no TTY): cannot prompt, must abort.
|
|
||||||
if (process.stdin.isTTY) {
|
|
||||||
console.error(
|
|
||||||
chalk.bold.red(
|
|
||||||
'WARNING: Running as root/sudo with bypass permissions mode is dangerous.',
|
|
||||||
),
|
|
||||||
)
|
|
||||||
console.error(
|
|
||||||
chalk.yellow(
|
|
||||||
'Bypass mode skips ALL permission checks. Combined with root, any command (rm -rf /, chmod, dd) executes without review.',
|
|
||||||
),
|
|
||||||
)
|
|
||||||
const readline = await import('readline')
|
|
||||||
const rl = readline.createInterface({
|
|
||||||
input: process.stdin,
|
|
||||||
output: process.stdout,
|
|
||||||
})
|
|
||||||
const answer = await new Promise<string>(resolve => {
|
|
||||||
rl.question('\nI understand the risks. Continue? [y/N] ', resolve)
|
|
||||||
})
|
|
||||||
rl.close()
|
|
||||||
if (answer.trim().toLowerCase() !== 'y') {
|
|
||||||
console.error('Aborted.')
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.error(
|
console.error(
|
||||||
`--dangerously-skip-permissions cannot be used with root/sudo privileges for security reasons`,
|
`--dangerously-skip-permissions cannot be used with root/sudo privileges for security reasons`,
|
||||||
)
|
)
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
process.env.USER_TYPE === 'ant' &&
|
process.env.USER_TYPE === 'ant' &&
|
||||||
|
|||||||
@@ -94,16 +94,6 @@ describe('parseCronExpression', () => {
|
|||||||
test('returns null for non-numeric tokens', () => {
|
test('returns null for non-numeric tokens', () => {
|
||||||
expect(parseCronExpression('abc * * * *')).toBeNull()
|
expect(parseCronExpression('abc * * * *')).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('returns null for undefined input without throwing', () => {
|
|
||||||
// CronCreateTool.validateInput receives raw params from ExecuteExtraTool;
|
|
||||||
// when the model passes a wrong field name (e.g. 'schedule' instead of
|
|
||||||
// 'cron'), input.cron is undefined. Calling .trim() on undefined crashes
|
|
||||||
// with "undefined is not an object" — parseCronExpression must fail
|
|
||||||
// gracefully so the tool layer can return a clear validation error.
|
|
||||||
expect(parseCronExpression(undefined as unknown as string)).toBeNull()
|
|
||||||
expect(parseCronExpression(null as unknown as string)).toBeNull()
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('field range validation', () => {
|
describe('field range validation', () => {
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
createUserInterruptionMessage,
|
createUserInterruptionMessage,
|
||||||
prepareUserContent,
|
prepareUserContent,
|
||||||
createToolResultStopMessage,
|
createToolResultStopMessage,
|
||||||
createProgressMessage,
|
|
||||||
extractTag,
|
extractTag,
|
||||||
isNotEmptyMessage,
|
isNotEmptyMessage,
|
||||||
deriveUUID,
|
deriveUUID,
|
||||||
@@ -29,9 +28,6 @@ import {
|
|||||||
DONT_ASK_REJECT_MESSAGE,
|
DONT_ASK_REJECT_MESSAGE,
|
||||||
SYNTHETIC_MODEL,
|
SYNTHETIC_MODEL,
|
||||||
ensureToolResultPairing,
|
ensureToolResultPairing,
|
||||||
buildMessageLookups,
|
|
||||||
updateMessageLookupsIncremental,
|
|
||||||
computeMessageStructureKey,
|
|
||||||
} from '../messages'
|
} from '../messages'
|
||||||
import type {
|
import type {
|
||||||
Message,
|
Message,
|
||||||
@@ -790,168 +786,3 @@ describe('normalizeMessagesForAPI – thinking + tool_use same turn (CC-1215)',
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// ─── Progress tick replace (Bash/PowerShell elapsed-time freeze) ──────────
|
|
||||||
|
|
||||||
describe('computeMessageStructureKey + updateMessageLookupsIncremental: progress replace', () => {
|
|
||||||
// REPL.tsx replaces ephemeral progress ticks (Bash/PowerShell/MCP) in-place
|
|
||||||
// to bound the messages array. The lookups cache must invalidate when the
|
|
||||||
// trailing progress tick changes, or ShellProgressMessage's elapsed time
|
|
||||||
// freezes at the first tick forever.
|
|
||||||
|
|
||||||
type BashProgress = {
|
|
||||||
type: 'bash_progress'
|
|
||||||
elapsedTimeSeconds: number
|
|
||||||
output: string
|
|
||||||
fullOutput: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeAssistantWithToolUse(toolUseID: string): Message {
|
|
||||||
return createAssistantMessage({
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'tool_use',
|
|
||||||
id: toolUseID,
|
|
||||||
name: 'Bash',
|
|
||||||
input: { command: 'sleep 10' },
|
|
||||||
} as any,
|
|
||||||
],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeProgress(
|
|
||||||
parentToolUseID: string,
|
|
||||||
uuid: `${string}-${string}-${string}-${string}-${string}`,
|
|
||||||
elapsedTimeSeconds: number,
|
|
||||||
) {
|
|
||||||
const msg = createProgressMessage<BashProgress>({
|
|
||||||
toolUseID: `bash-progress-${elapsedTimeSeconds}`,
|
|
||||||
parentToolUseID,
|
|
||||||
data: {
|
|
||||||
type: 'bash_progress',
|
|
||||||
elapsedTimeSeconds,
|
|
||||||
output: '',
|
|
||||||
fullOutput: '',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
// Override uuid so the test is deterministic (createProgressMessage
|
|
||||||
// generates a random uuid).
|
|
||||||
return { ...msg, uuid }
|
|
||||||
}
|
|
||||||
|
|
||||||
test('computeMessageStructureKey distinguishes progress ticks by uuid', () => {
|
|
||||||
const assistant = makeAssistantWithToolUse('bash-1')
|
|
||||||
const normalized = normalizeMessages([assistant])
|
|
||||||
|
|
||||||
const progress1 = makeProgress(
|
|
||||||
'bash-1',
|
|
||||||
'00000000-0000-0000-0000-000000000001',
|
|
||||||
3,
|
|
||||||
)
|
|
||||||
const progress2 = makeProgress(
|
|
||||||
'bash-1',
|
|
||||||
'00000000-0000-0000-0000-000000000002',
|
|
||||||
4,
|
|
||||||
)
|
|
||||||
|
|
||||||
const keyBefore = computeMessageStructureKey(
|
|
||||||
[...normalized, progress1 as any],
|
|
||||||
[...normalized, progress1 as any] as any,
|
|
||||||
)
|
|
||||||
const keyAfter = computeMessageStructureKey(
|
|
||||||
[...normalized, progress2 as any],
|
|
||||||
[...normalized, progress2 as any] as any,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Same parentToolUseID, same length, but different uuid (tick replace).
|
|
||||||
// Without uuid in the key, these would be identical and the lookups cache
|
|
||||||
// would freeze on the first tick.
|
|
||||||
expect(keyBefore).not.toEqual(keyAfter)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('updateMessageLookupsIncremental returns null when trailing progress was replaced (same length)', () => {
|
|
||||||
const assistant = makeAssistantWithToolUse('bash-1')
|
|
||||||
const normalized = normalizeMessages([assistant])
|
|
||||||
|
|
||||||
const progress1 = makeProgress(
|
|
||||||
'bash-1',
|
|
||||||
'00000000-0000-0000-0000-000000000001',
|
|
||||||
3,
|
|
||||||
)
|
|
||||||
const progress2 = makeProgress(
|
|
||||||
'bash-1',
|
|
||||||
'00000000-0000-0000-0000-000000000002',
|
|
||||||
4,
|
|
||||||
)
|
|
||||||
|
|
||||||
const withProgress1 = [...normalized, progress1 as any]
|
|
||||||
const withProgress2 = [...normalized, progress2 as any]
|
|
||||||
|
|
||||||
const existing = buildMessageLookups(
|
|
||||||
withProgress1 as any,
|
|
||||||
withProgress1 as any,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Same length, but the trailing progress is a fresh tick. Returning
|
|
||||||
// `existing` here would leave progressMessagesByToolUseID stuck on u1.
|
|
||||||
const result = updateMessageLookupsIncremental(
|
|
||||||
existing,
|
|
||||||
withProgress1.length,
|
|
||||||
withProgress1.length,
|
|
||||||
withProgress2 as any,
|
|
||||||
withProgress2 as any,
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(result).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('updateMessageLookupsIncremental still returns existing when length same and trailing is NOT progress', () => {
|
|
||||||
// Protect the original streaming-delta fast path: content-only changes
|
|
||||||
// on a non-progress trailing message should not trigger a full rebuild.
|
|
||||||
const assistant = makeAssistantWithToolUse('bash-1')
|
|
||||||
const normalized = normalizeMessages([assistant])
|
|
||||||
|
|
||||||
const existing = buildMessageLookups(normalized as any, normalized as any)
|
|
||||||
|
|
||||||
const result = updateMessageLookupsIncremental(
|
|
||||||
existing,
|
|
||||||
normalized.length,
|
|
||||||
normalized.length,
|
|
||||||
normalized as any,
|
|
||||||
normalized as any,
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(result).toBe(existing)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('full rebuild after progress replace yields the new tick in progressMessagesByToolUseID', () => {
|
|
||||||
// End-to-end: buildMessageLookups after a tick replace must reflect the
|
|
||||||
// fresh progress, not the stale one. This is what Messages.tsx falls back
|
|
||||||
// to when updateMessageLookupsIncremental returns null.
|
|
||||||
const assistant = makeAssistantWithToolUse('bash-1')
|
|
||||||
const normalized = normalizeMessages([assistant])
|
|
||||||
|
|
||||||
const progress1 = makeProgress(
|
|
||||||
'bash-1',
|
|
||||||
'00000000-0000-0000-0000-000000000001',
|
|
||||||
3,
|
|
||||||
)
|
|
||||||
const progress2 = makeProgress(
|
|
||||||
'bash-1',
|
|
||||||
'00000000-0000-0000-0000-000000000002',
|
|
||||||
4,
|
|
||||||
)
|
|
||||||
|
|
||||||
const withProgress2 = [...normalized, progress2 as any]
|
|
||||||
const rebuilt = buildMessageLookups(
|
|
||||||
withProgress2 as any,
|
|
||||||
withProgress2 as any,
|
|
||||||
)
|
|
||||||
|
|
||||||
const arr = rebuilt.progressMessagesByToolUseID.get('bash-1')
|
|
||||||
expect(arr).toBeDefined()
|
|
||||||
expect(arr).toHaveLength(1)
|
|
||||||
expect(arr![0].uuid).toBe('00000000-0000-0000-0000-000000000002')
|
|
||||||
expect((arr![0].data as BashProgress).elapsedTimeSeconds).toBe(4)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -81,12 +81,6 @@ function expandField(field: string, range: FieldRange): number[] | null {
|
|||||||
* Returns null if invalid or unsupported syntax.
|
* Returns null if invalid or unsupported syntax.
|
||||||
*/
|
*/
|
||||||
export function parseCronExpression(expr: string): CronFields | null {
|
export function parseCronExpression(expr: string): CronFields | null {
|
||||||
// Defensive against non-string input: ExecuteExtraTool passes raw params
|
|
||||||
// through to validateInput without re-running the target tool's schema, so
|
|
||||||
// a wrong field name (e.g. 'schedule' instead of 'cron') surfaces here as
|
|
||||||
// undefined. Without this guard, .trim() below throws "undefined is not an
|
|
||||||
// object" — every CronCreate call from ExecuteExtraTool fails identically.
|
|
||||||
if (typeof expr !== 'string') return null
|
|
||||||
const parts = expr.trim().split(/\s+/)
|
const parts = expr.trim().split(/\s+/)
|
||||||
if (parts.length !== 5) return null
|
if (parts.length !== 5) return null
|
||||||
|
|
||||||
|
|||||||
@@ -1417,21 +1417,11 @@ export function updateMessageLookupsIncremental(
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// No new messages — nothing to do, UNLESS the trailing message is a
|
// No new messages — nothing to do
|
||||||
// progress tick. REPL.tsx replaces ephemeral progress (Bash/PowerShell/MCP)
|
|
||||||
// in-place to bound the messages array — same length, but the trailing
|
|
||||||
// progress is a fresh tick. Returning `existing` here would leave
|
|
||||||
// progressMessagesByToolUseID stuck on the first tick and elapsed-time
|
|
||||||
// displays (ShellProgressMessage) would freeze. Force a full rebuild so
|
|
||||||
// the fresh tick propagates.
|
|
||||||
if (
|
if (
|
||||||
normalizedMessages.length === previousNormalizedCount &&
|
normalizedMessages.length === previousNormalizedCount &&
|
||||||
messages.length === previousMessageCount
|
messages.length === previousMessageCount
|
||||||
) {
|
) {
|
||||||
const lastNormalized = normalizedMessages[normalizedMessages.length - 1]
|
|
||||||
if (lastNormalized && lastNormalized.type === 'progress') {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return existing
|
return existing
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1615,13 +1605,7 @@ export function computeMessageStructureKey(
|
|||||||
}
|
}
|
||||||
for (const msg of normalizedMessages) {
|
for (const msg of normalizedMessages) {
|
||||||
if (msg.type === 'progress') {
|
if (msg.type === 'progress') {
|
||||||
const pMsg = msg as ProgressMessage
|
parts.push('p', (msg as ProgressMessage).parentToolUseID as string)
|
||||||
// Include uuid so ephemeral progress tick replacements
|
|
||||||
// (Bash/PowerShell/MCP) invalidate the lookups cache. Without this,
|
|
||||||
// REPL.tsx's in-place tick replacement (same parentToolUseID, same
|
|
||||||
// length) yields an identical key, lookups cache the first tick
|
|
||||||
// forever, and ShellProgressMessage's elapsed time freezes.
|
|
||||||
parts.push('p', pMsg.parentToolUseID as string, pMsg.uuid)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return parts.join(',')
|
return parts.join(',')
|
||||||
|
|||||||
@@ -661,54 +661,6 @@ export const SettingsSchema = lazySchema(() =>
|
|||||||
.describe(
|
.describe(
|
||||||
'Skip the WebFetch blocklist check for enterprise environments with restrictive security policies',
|
'Skip the WebFetch blocklist check for enterprise environments with restrictive security policies',
|
||||||
),
|
),
|
||||||
webSearchAdapter: z
|
|
||||||
.enum(['api', 'bing', 'brave', 'exa', 'tavily'])
|
|
||||||
.optional()
|
|
||||||
.describe(
|
|
||||||
'Web search backend adapter. "tavily" uses Tavily Search API (default), ' +
|
|
||||||
'"api" uses Anthropic server-side search, "bing" scrapes Bing HTML, ' +
|
|
||||||
'"brave" uses Brave Search API, "exa" uses Exa AI.',
|
|
||||||
),
|
|
||||||
webFetchAdapter: z
|
|
||||||
.enum(['tavily', 'http'])
|
|
||||||
.optional()
|
|
||||||
.describe(
|
|
||||||
'Web fetch backend. "tavily" uses Tavily Extract API which returns Markdown directly (default), ' +
|
|
||||||
'"http" fetches the URL directly via HTTP.',
|
|
||||||
),
|
|
||||||
tavilyEndpointUrl: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.describe(
|
|
||||||
'Custom Tavily API endpoint URL. Defaults to https://tavily.claude-code-best.win. ' +
|
|
||||||
'Used by both WebSearch and WebFetch when tavily adapter is selected.',
|
|
||||||
),
|
|
||||||
braveApiKey: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.describe(
|
|
||||||
'Brave Search API key. Required when using the brave web search adapter.',
|
|
||||||
),
|
|
||||||
webFetchHttpTimeoutMs: z
|
|
||||||
.number()
|
|
||||||
.int()
|
|
||||||
.positive()
|
|
||||||
.optional()
|
|
||||||
.describe(
|
|
||||||
'HTTP timeout in milliseconds for the HTTP direct web fetch backend. Defaults to 60000 (60s).',
|
|
||||||
),
|
|
||||||
exaApiKey: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.describe(
|
|
||||||
'Exa AI API key. Required when using the exa web search adapter.',
|
|
||||||
),
|
|
||||||
exaEndpointUrl: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.describe(
|
|
||||||
'Custom Exa AI MCP endpoint URL. Defaults to https://mcp.exa.ai/mcp.',
|
|
||||||
),
|
|
||||||
sandbox: SandboxSettingsSchema().optional(),
|
sandbox: SandboxSettingsSchema().optional(),
|
||||||
feedbackSurveyRate: z
|
feedbackSurveyRate: z
|
||||||
.number()
|
.number()
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
import { tmpdir } from 'node:os'
|
import { tmpdir } from 'node:os'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import {
|
import {
|
||||||
cleanupOldRuns,
|
|
||||||
getRunsDir,
|
getRunsDir,
|
||||||
listPersistedRuns,
|
listPersistedRuns,
|
||||||
readRunState,
|
readRunState,
|
||||||
@@ -198,108 +197,3 @@ test('getRunsDir returns <projectRoot>/.claude/workflow-runs shape', () => {
|
|||||||
// do not hard-code projectRoot (differs across machines), only check suffix structure
|
// do not hard-code projectRoot (differs across machines), only check suffix structure
|
||||||
expect(dir.endsWith(`${join('.claude', 'workflow-runs')}`)).toBe(true)
|
expect(dir.endsWith(`${join('.claude', 'workflow-runs')}`)).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('listPersistedRuns limit N returns the N newest by updatedAt desc', async () => {
|
|
||||||
const dir = await mkdtemp(join(tmpdir(), 'wf-'))
|
|
||||||
try {
|
|
||||||
await writeRunState(dir, makeRun({ runId: 'old', updatedAt: 1000 }))
|
|
||||||
await writeRunState(dir, makeRun({ runId: 'mid', updatedAt: 2000 }))
|
|
||||||
await writeRunState(dir, makeRun({ runId: 'new', updatedAt: 3000 }))
|
|
||||||
|
|
||||||
expect((await listPersistedRuns(dir, 0)).map(r => r.runId)).toEqual([])
|
|
||||||
expect((await listPersistedRuns(dir, 1)).map(r => r.runId)).toEqual(['new'])
|
|
||||||
expect((await listPersistedRuns(dir, 2)).map(r => r.runId)).toEqual([
|
|
||||||
'new',
|
|
||||||
'mid',
|
|
||||||
])
|
|
||||||
// limit larger than total → returns all (no padding)
|
|
||||||
expect((await listPersistedRuns(dir, 99)).map(r => r.runId)).toEqual([
|
|
||||||
'new',
|
|
||||||
'mid',
|
|
||||||
'old',
|
|
||||||
])
|
|
||||||
// undefined → unchanged "load everything" semantics (back-compat)
|
|
||||||
expect((await listPersistedRuns(dir)).map(r => r.runId)).toEqual([
|
|
||||||
'new',
|
|
||||||
'mid',
|
|
||||||
'old',
|
|
||||||
])
|
|
||||||
} finally {
|
|
||||||
await rm(dir, { recursive: true, force: true })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test('cleanupOldRuns keeps the newest keepMax runs and removes the rest', async () => {
|
|
||||||
const dir = await mkdtemp(join(tmpdir(), 'wf-'))
|
|
||||||
try {
|
|
||||||
await writeRunState(dir, makeRun({ runId: 'old', updatedAt: 1000 }))
|
|
||||||
await writeRunState(dir, makeRun({ runId: 'mid', updatedAt: 2000 }))
|
|
||||||
await writeRunState(dir, makeRun({ runId: 'new', updatedAt: 3000 }))
|
|
||||||
|
|
||||||
const removed = await cleanupOldRuns(dir, 1)
|
|
||||||
expect(removed).toBe(2)
|
|
||||||
const remaining = (await listPersistedRuns(dir)).map(r => r.runId)
|
|
||||||
expect(remaining).toEqual(['new'])
|
|
||||||
// pruned dirs are fully gone (state.json included)
|
|
||||||
await expect(readRunState(dir, 'old')).resolves.toBeNull()
|
|
||||||
await expect(readRunState(dir, 'mid')).resolves.toBeNull()
|
|
||||||
} finally {
|
|
||||||
await rm(dir, { recursive: true, force: true })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test('cleanupOldRuns prunes orphan dirs (no state.json) first', async () => {
|
|
||||||
const dir = await mkdtemp(join(tmpdir(), 'wf-'))
|
|
||||||
try {
|
|
||||||
await writeRunState(dir, makeRun({ runId: 'r1', updatedAt: 1000 }))
|
|
||||||
await writeRunState(dir, makeRun({ runId: 'r2', updatedAt: 2000 }))
|
|
||||||
// orphan: no state.json → treated as updatedAt=0, sorted last, pruned first
|
|
||||||
await mkdir(join(dir, 'orphan'), { recursive: true })
|
|
||||||
|
|
||||||
const removed = await cleanupOldRuns(dir, 2)
|
|
||||||
expect(removed).toBe(1)
|
|
||||||
const entries = await readdir(dir)
|
|
||||||
expect(entries).not.toContain('orphan')
|
|
||||||
expect(entries).toContain('r1')
|
|
||||||
expect(entries).toContain('r2')
|
|
||||||
} finally {
|
|
||||||
await rm(dir, { recursive: true, force: true })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test('cleanupOldRuns under keepMax is a no-op', async () => {
|
|
||||||
const dir = await mkdtemp(join(tmpdir(), 'wf-'))
|
|
||||||
try {
|
|
||||||
await writeRunState(dir, makeRun({ runId: 'r1', updatedAt: 1000 }))
|
|
||||||
await writeRunState(dir, makeRun({ runId: 'r2', updatedAt: 2000 }))
|
|
||||||
|
|
||||||
const removed = await cleanupOldRuns(dir, 5)
|
|
||||||
expect(removed).toBe(0)
|
|
||||||
expect((await listPersistedRuns(dir)).map(r => r.runId)).toEqual([
|
|
||||||
'r2',
|
|
||||||
'r1',
|
|
||||||
])
|
|
||||||
} finally {
|
|
||||||
await rm(dir, { recursive: true, force: true })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test('cleanupOldRuns on missing dir returns 0 (no throw)', async () => {
|
|
||||||
const dir = await mkdtemp(join(tmpdir(), 'wf-'))
|
|
||||||
await rm(dir, { recursive: true, force: true })
|
|
||||||
await expect(cleanupOldRuns(dir, 5)).resolves.toBe(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('cleanupOldRuns negative keepMax is clamped to 0 (removes everything, no slice(-N) inversion)', async () => {
|
|
||||||
const dir = await mkdtemp(join(tmpdir(), 'wf-'))
|
|
||||||
try {
|
|
||||||
await writeRunState(dir, makeRun({ runId: 'r1', updatedAt: 1000 }))
|
|
||||||
await writeRunState(dir, makeRun({ runId: 'r2', updatedAt: 2000 }))
|
|
||||||
|
|
||||||
// Without the clamp, slice(-1) would keep 1 entry — violating "keep 0 means keep none".
|
|
||||||
await expect(cleanupOldRuns(dir, -1)).resolves.toBe(2)
|
|
||||||
expect(await listPersistedRuns(dir)).toEqual([])
|
|
||||||
} finally {
|
|
||||||
await rm(dir, { recursive: true, force: true })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -220,41 +220,6 @@ test('launch inline script → returns scriptPath (persisted to cwdOverride dir)
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test('launch inline script with title → workflowName comes from title (not the "workflow" default)', async () => {
|
|
||||||
__resetWorkflowServiceForTests()
|
|
||||||
const { ports, store } = fakePorts()
|
|
||||||
const svc = makeService(ports, store)
|
|
||||||
const { runId } = await svc.launch(
|
|
||||||
{ script: `return agent('x')`, title: 'Review PR #42' },
|
|
||||||
stubTUC,
|
|
||||||
stubCanUseTool,
|
|
||||||
)
|
|
||||||
await settle()
|
|
||||||
const r = svc.getRun(runId)
|
|
||||||
expect(r).toBeDefined()
|
|
||||||
expect(r!.workflowName).toBe('Review PR #42')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('launch scriptPath with title → workflowName still honors title', async () => {
|
|
||||||
__resetWorkflowServiceForTests()
|
|
||||||
const dir = await mkdtemp(join(tmpdir(), 'wf-svc-'))
|
|
||||||
try {
|
|
||||||
const file = join(dir, 'wf.js')
|
|
||||||
await writeFile(file, `return agent('x')`)
|
|
||||||
const { ports, store } = fakePorts()
|
|
||||||
const svc = makeService(ports, store)
|
|
||||||
const { runId } = await svc.launch(
|
|
||||||
{ scriptPath: file, title: 'From File' },
|
|
||||||
stubTUC,
|
|
||||||
stubCanUseTool,
|
|
||||||
)
|
|
||||||
await settle()
|
|
||||||
expect(svc.getRun(runId)!.workflowName).toBe('From File')
|
|
||||||
} finally {
|
|
||||||
await rm(dir, { recursive: true, force: true })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test('kill goes through taskRegistrar.kill', async () => {
|
test('kill goes through taskRegistrar.kill', async () => {
|
||||||
__resetWorkflowServiceForTests()
|
__resetWorkflowServiceForTests()
|
||||||
const { ports, store, killed } = fakePorts()
|
const { ports, store, killed } = fakePorts()
|
||||||
|
|||||||
@@ -1,11 +1,4 @@
|
|||||||
import {
|
import { mkdir, readFile, readdir, rename, writeFile } from 'node:fs/promises'
|
||||||
mkdir,
|
|
||||||
readFile,
|
|
||||||
readdir,
|
|
||||||
rename,
|
|
||||||
rm,
|
|
||||||
writeFile,
|
|
||||||
} from 'node:fs/promises'
|
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { getProjectRoot } from '../bootstrap/state.js'
|
import { getProjectRoot } from '../bootstrap/state.js'
|
||||||
import { logForDebugging } from '../utils/debug.js'
|
import { logForDebugging } from '../utils/debug.js'
|
||||||
@@ -17,13 +10,6 @@ const SCHEMA_VERSION = 1
|
|||||||
const STATE_FILE = 'state.json'
|
const STATE_FILE = 'state.json'
|
||||||
const STATE_TMP = 'state.json.tmp'
|
const STATE_TMP = 'state.json.tmp'
|
||||||
|
|
||||||
/**
|
|
||||||
* Hard ceiling on persisted run directories on disk. Beyond this, the oldest runs (by updatedAt)
|
|
||||||
* are pruned by cleanupOldRuns. Set generously above LOAD_PERSISTED_LIMIT so runs hidden from the
|
|
||||||
* panel can still be resumed manually before aging out.
|
|
||||||
*/
|
|
||||||
const KEEP_MAX_RUNS = 50
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Single source for runsDir: shares the same root as ports.ts journalStore (${projectRoot}/.claude/workflow-runs).
|
* Single source for runsDir: shares the same root as ports.ts journalStore (${projectRoot}/.claude/workflow-runs).
|
||||||
* Extracted as a function: eliminates duplicated path concatenation between ports.ts and persistence logic, staying in the same root when entering worktree/subdirectory.
|
* Extracted as a function: eliminates duplicated path concatenation between ports.ts and persistence logic, staying in the same root when entering worktree/subdirectory.
|
||||||
@@ -100,12 +86,9 @@ export async function readRunState(
|
|||||||
* - A subdirectory without state.json (half-written run) → skip
|
* - A subdirectory without state.json (half-written run) → skip
|
||||||
* - A subdirectory whose state.json is corrupted → skip that single one, keep scanning the rest
|
* - A subdirectory whose state.json is corrupted → skip that single one, keep scanning the rest
|
||||||
* - Sort by updatedAt descending (consistent with store.list() ordering)
|
* - Sort by updatedAt descending (consistent with store.list() ordering)
|
||||||
* - Optional limit: keep only the first N newest (used by loadPersistedRuns so the panel
|
|
||||||
* doesn't drown under months of history; full scan stays available by omitting the arg).
|
|
||||||
*/
|
*/
|
||||||
export async function listPersistedRuns(
|
export async function listPersistedRuns(
|
||||||
runsDir: string,
|
runsDir: string,
|
||||||
limit?: number,
|
|
||||||
): Promise<RunProgress[]> {
|
): Promise<RunProgress[]> {
|
||||||
let entries: string[]
|
let entries: string[]
|
||||||
try {
|
try {
|
||||||
@@ -118,56 +101,7 @@ export async function listPersistedRuns(
|
|||||||
const run = await readRunState(runsDir, name)
|
const run = await readRunState(runsDir, name)
|
||||||
if (run) runs.push(run)
|
if (run) runs.push(run)
|
||||||
}
|
}
|
||||||
runs.sort((a, b) => b.updatedAt - a.updatedAt)
|
return runs.sort((a, b) => b.updatedAt - a.updatedAt)
|
||||||
return limit !== undefined && limit >= 0 ? runs.slice(0, limit) : runs
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Garbage-collect stale run directories: sort subdirs of runsDir by their state.json.updatedAt
|
|
||||||
* (newest first), then recursively remove everything past keepMax. Subdirs without state.json are
|
|
||||||
* treated as oldest (they're orphans — half-written, killed-mid-write, or pre-schema leftovers) so
|
|
||||||
* they get pruned first.
|
|
||||||
*
|
|
||||||
* Best-effort: per-dir failures only log, do not abort the sweep. Safe to call repeatedly
|
|
||||||
* (idempotent — once under the cap, it's a no-op).
|
|
||||||
*
|
|
||||||
* @returns number of directories actually removed.
|
|
||||||
*/
|
|
||||||
export async function cleanupOldRuns(
|
|
||||||
runsDir: string,
|
|
||||||
keepMax: number = KEEP_MAX_RUNS,
|
|
||||||
): Promise<number> {
|
|
||||||
let entries: string[]
|
|
||||||
try {
|
|
||||||
entries = await readdir(runsDir)
|
|
||||||
} catch {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
type Candidate = { name: string; updatedAt: number }
|
|
||||||
const candidates: Candidate[] = []
|
|
||||||
for (const name of entries) {
|
|
||||||
const run = await readRunState(runsDir, name)
|
|
||||||
// updatedAt=0 → orphan dir without parseable state.json; sorts first → pruned first.
|
|
||||||
candidates.push({ name, updatedAt: run?.updatedAt ?? 0 })
|
|
||||||
}
|
|
||||||
// Newest first; orphans (updatedAt=0) sink to the tail and get pruned first.
|
|
||||||
candidates.sort((a, b) => b.updatedAt - a.updatedAt)
|
|
||||||
// Guard against negative keepMax: slice(-N) would invert semantics and keep N newest instead of
|
|
||||||
// pruning them, which contradicts the contract. Clamp to 0 so a bad caller at worst wipes everything.
|
|
||||||
const cap = Math.max(0, Math.trunc(keepMax))
|
|
||||||
const victims = candidates.slice(cap)
|
|
||||||
let removed = 0
|
|
||||||
for (const v of victims) {
|
|
||||||
try {
|
|
||||||
await rm(join(runsDir, v.name), { recursive: true, force: true })
|
|
||||||
removed++
|
|
||||||
} catch (e) {
|
|
||||||
logForDebugging(
|
|
||||||
`[workflow warn] cleanupOldRuns failed to remove ${v.name}: ${(e as Error).message}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return removed
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -179,10 +113,6 @@ export async function cleanupOldRuns(
|
|||||||
* Disk write is best-effort: writeRunState swallows IO exceptions and only logs, does not propagate —
|
* Disk write is best-effort: writeRunState swallows IO exceptions and only logs, does not propagate —
|
||||||
* so other bus subscribers (store, etc.) are not affected by persistence failures.
|
* so other bus subscribers (store, etc.) are not affected by persistence failures.
|
||||||
*
|
*
|
||||||
* Also fires-and-forgets cleanupOldRuns so the runs directory stays bounded across long-lived
|
|
||||||
* sessions (KEEP_MAX_RUNS). The cleanup runs *after* the new state is written, guaranteeing the
|
|
||||||
* just-finished run is already on disk and counted as newest — never swept out from under itself.
|
|
||||||
*
|
|
||||||
* @param runsDirProvider Optional runsDir resolver (defaults to getRunsDir).
|
* @param runsDirProvider Optional runsDir resolver (defaults to getRunsDir).
|
||||||
* Production path uses the default; tests inject a tmpdir to avoid writing to the real project directory (Bun ESM module namespace is read-only,
|
* Production path uses the default; tests inject a tmpdir to avoid writing to the real project directory (Bun ESM module namespace is read-only,
|
||||||
* cannot monkey-patch getRunsDir itself).
|
* cannot monkey-patch getRunsDir itself).
|
||||||
@@ -196,15 +126,6 @@ export function attachRunStatePersistence(
|
|||||||
if (event.type !== 'run_done') return
|
if (event.type !== 'run_done') return
|
||||||
const run = store.get(event.runId)
|
const run = store.get(event.runId)
|
||||||
if (!run) return
|
if (!run) return
|
||||||
const dir = runsDirProvider()
|
void writeRunState(runsDirProvider(), run)
|
||||||
void writeRunState(dir, run).then(() => {
|
|
||||||
// Sweep only after the new state lands on disk — avoids a race where the just-finished run
|
|
||||||
// itself gets pruned because its state.json wasn't counted yet.
|
|
||||||
void cleanupOldRuns(dir).catch(e => {
|
|
||||||
logForDebugging(
|
|
||||||
`[workflow warn] cleanupOldRuns after run_done threw: ${(e as Error).message}`,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,13 +21,6 @@ import {
|
|||||||
listPersistedRuns,
|
listPersistedRuns,
|
||||||
readRunState,
|
readRunState,
|
||||||
} from './persistence.js'
|
} from './persistence.js'
|
||||||
|
|
||||||
/**
|
|
||||||
* How many newest persisted runs to hydrate into the store on panel open. Tuned to cover a normal
|
|
||||||
* day's worth of workflow iterations without overrunning the panel tab row; anything older stays
|
|
||||||
* on disk and is still resumable via getRunAsync until cleanupOldRuns reclaims it.
|
|
||||||
*/
|
|
||||||
const LOAD_PERSISTED_LIMIT = 20
|
|
||||||
import { createProgressBus } from './progress/bus.js'
|
import { createProgressBus } from './progress/bus.js'
|
||||||
import {
|
import {
|
||||||
createProgressStoreFromBus,
|
createProgressStoreFromBus,
|
||||||
@@ -142,23 +135,19 @@ export function makeService(
|
|||||||
script?: string
|
script?: string
|
||||||
name?: string
|
name?: string
|
||||||
scriptPath?: string
|
scriptPath?: string
|
||||||
title?: string
|
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
script: string
|
script: string
|
||||||
workflowFile?: string
|
workflowFile?: string
|
||||||
workflowName: string
|
workflowName: string
|
||||||
}> {
|
}> {
|
||||||
// Mirrors WorkflowTool.ts: name takes priority over title; only fall back to the literal
|
|
||||||
// 'workflow' when neither is supplied (so /workflows tabs don't pile up under a same default name).
|
|
||||||
const workflowName = input.name ?? input.title ?? 'workflow'
|
|
||||||
if (input.script) {
|
if (input.script) {
|
||||||
return { script: input.script, workflowName }
|
return { script: input.script, workflowName: 'workflow' }
|
||||||
}
|
}
|
||||||
if (input.scriptPath) {
|
if (input.scriptPath) {
|
||||||
return {
|
return {
|
||||||
script: await readFile(input.scriptPath, 'utf-8'),
|
script: await readFile(input.scriptPath, 'utf-8'),
|
||||||
workflowFile: input.scriptPath,
|
workflowFile: input.scriptPath,
|
||||||
workflowName,
|
workflowName: 'workflow',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (input.name) {
|
if (input.name) {
|
||||||
@@ -291,13 +280,7 @@ export function makeService(
|
|||||||
if (persistedLoaded) return
|
if (persistedLoaded) return
|
||||||
persistedLoaded = true
|
persistedLoaded = true
|
||||||
try {
|
try {
|
||||||
// Cap hydration at LOAD_PERSISTED_LIMIT newest runs so the panel tab row doesn't drown
|
const runs = await listPersistedRuns(runsDirProvider())
|
||||||
// under accumulated history. Older state.json files stay on disk (within KEEP_MAX_RUNS,
|
|
||||||
// maintained by cleanupOldRuns) and remain resumable via getRunAsync.
|
|
||||||
const runs = await listPersistedRuns(
|
|
||||||
runsDirProvider(),
|
|
||||||
LOAD_PERSISTED_LIMIT,
|
|
||||||
)
|
|
||||||
for (const run of runs) store.hydrate(run)
|
for (const run of runs) store.hydrate(run)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Scan failure does not block the panel: log + reset flag to allow next retry
|
// Scan failure does not block the panel: log + reset flag to allow next retry
|
||||||
|
|||||||
Reference in New Issue
Block a user