feat: 实现 ACP session/delete + message-id 两个 UNSTABLE RFD

session/delete(rfds/session-delete.mdx):
- sessionCapabilities.delete: {} 能力广告(类型增强写入,SDK 0.19.0 早于该 RFD)
- extMethod 钩子路由 session/delete → unstable_deleteSession
- 硬删除 .jsonl 文件,ENOENT 视为成功(幂等)
- 未知方法抛 RequestError.methodNotFound(JSON-RPC -32601)

message-id(rfds/message-id.mdx):
- agent_message_chunk / user_message_chunk / agent_thought_chunk 携带 messageId
- forwardSessionUpdates 维护 currentAgentMessageId,lazy 生成 UUID
- streaming text/thinking chunks 与最终 assistant message 共享同一 ID
- replayHistoryMessages per-message 生成 UUID
- PromptRequest.messageId → PromptResponse.userMessageId 回显
- tool_call / plan / subagent 不带 messageId(spec 仅规定 chunk 类型)

测试:ACP service 从 176 → 191 (+15)
- bridge.test.ts: +9 个 message-id 测试
- agent.test.ts: +6 个 session/delete + userMessageId 测试
- 总测试 5851 → 5866,全通过

审计文档:新增附录 A.2 记录两个 UNSTABLE RFD 实现状态

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
This commit is contained in:
claude-code-best
2026-06-19 17:13:24 +08:00
parent cac23e62cc
commit 0103f45109
7 changed files with 560 additions and 31 deletions

View File

@@ -14,39 +14,42 @@
* `./index.js` imports those side-effect modules so the prototype is fully
* populated before any AcpAgent instance is constructed.
*/
import type {
Agent,
AgentSideConnection,
InitializeRequest,
InitializeResponse,
AuthenticateRequest,
AuthenticateResponse,
NewSessionRequest,
NewSessionResponse,
PromptRequest,
PromptResponse,
CancelNotification,
LoadSessionRequest,
LoadSessionResponse,
ListSessionsRequest,
ListSessionsResponse,
ResumeSessionRequest,
ResumeSessionResponse,
ForkSessionRequest,
ForkSessionResponse,
CloseSessionRequest,
CloseSessionResponse,
SetSessionModeRequest,
SetSessionModeResponse,
SetSessionModelRequest,
SetSessionModelResponse,
SetSessionConfigOptionRequest,
SetSessionConfigOptionResponse,
ClientCapabilities,
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 } from '../../../utils/sessionStoragePortable.js'
import type { AcpSession } from './sessionTypes.js'
// ── Agent class ───────────────────────────────────────────────────
@@ -123,6 +126,11 @@ export class AcpAgent implements Agent {
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> }),
},
},
}
@@ -236,6 +244,51 @@ export class AcpAgent implements Agent {
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> {

View File

@@ -43,6 +43,12 @@ async function prompt(
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)
@@ -134,6 +140,7 @@ async function prompt(
return {
stopReason,
usage: usagePayload,
...(userMessageId ? { userMessageId } : {}),
_meta: {
claudeCode: {
usage: usagePayload,
@@ -141,7 +148,10 @@ async function prompt(
},
}
}
return { stopReason }
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