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

@@ -297,6 +297,16 @@ describe('AcpAgent', () => {
(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({})
})
})
describe('authenticate', () => {
@@ -634,6 +644,54 @@ 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', () => {
test('updates model on queryEngine', async () => {
const agent = new AcpAgent(makeConn())
@@ -716,6 +774,50 @@ describe('AcpAgent', () => {
})
})
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()
})
})
describe('prompt error handling', () => {
test('returns cancelled when session was cancelled during prompt', async () => {
const agent = new AcpAgent(makeConn())

View File

@@ -5,6 +5,7 @@ import {
toolUpdateFromEditToolResponse,
forwardSessionUpdates,
nextSdkMessageOrAbort,
replayHistoryMessages,
} from '../bridge.js'
import { promptToQueryInput } from '../promptConversion.js'
import { markdownEscape, toDisplayPath } from '../utils.js'
@@ -1595,3 +1596,278 @@ describe('forwardSessionUpdates', () => {
).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')
})
})