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

@@ -804,6 +804,31 @@
| 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/deleterfds/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-idrfds/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` 不携带 messageIdspec 仅规定 chunk 类型)。
- **生成策略**:
- **Assistant 消息**: 在 `forwardSessionUpdates` 中维护 `currentAgentMessageId: string | null`,在 `stream_event``assistant` SDK 消息(`parent_tool_use_id === null`)首次出现时 lazy 生成 UUIDassistant 消息处理完后 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 消息独立生成 UUIDJSONL 不保留原始 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 出于技术权衡或非合规范围,暂不修复:

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')
})
})

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

View File

@@ -5,6 +5,7 @@
// 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,
@@ -75,6 +76,16 @@ export async function forwardSessionUpdates(
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
@@ -197,6 +208,19 @@ export async function forwardSessionUpdates(
// ── 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,
@@ -205,6 +229,7 @@ export async function forwardSessionUpdates(
{
clientCapabilities,
cwd,
messageId: streamMessageId,
},
)
for (const notification of notifications) {
@@ -245,6 +270,18 @@ export async function forwardSessionUpdates(
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,
@@ -255,11 +292,18 @@ export async function forwardSessionUpdates(
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
}
@@ -384,11 +428,16 @@ export async function replayHistoryMessages(
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 },
},
})
@@ -396,6 +445,8 @@ export async function replayHistoryMessages(
}
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,
@@ -403,7 +454,7 @@ export async function replayHistoryMessages(
toolUseCache,
conn,
undefined,
{ clientCapabilities, cwd },
{ clientCapabilities, cwd, messageId: replayMessageId },
)
for (const notification of notifications) {
await conn.sessionUpdate(notification)

View File

@@ -40,6 +40,10 @@ export function toAcpNotifications(
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[] = []
@@ -55,6 +59,7 @@ export function toAcpNotifications(
update = {
sessionUpdate:
role === 'assistant' ? 'agent_message_chunk' : 'user_message_chunk',
...(options?.messageId ? { messageId: options.messageId } : {}),
content: { type: 'text', text },
}
break
@@ -65,6 +70,7 @@ export function toAcpNotifications(
const thinking = (chunk.thinking as string) ?? ''
update = {
sessionUpdate: 'agent_thought_chunk',
...(options?.messageId ? { messageId: options.messageId } : {}),
content: { type: 'text', text: thinking },
}
break
@@ -78,6 +84,7 @@ export function toAcpNotifications(
role === 'assistant'
? 'agent_message_chunk'
: 'user_message_chunk',
...(options?.messageId ? { messageId: options.messageId } : {}),
content: {
type: 'image',
data: source.data as string,
@@ -237,6 +244,7 @@ export function assistantMessageToAcpNotifications(
parentToolUseId?: string | null
cwd?: string
streamingActive?: boolean
messageId?: string
},
): SessionNotification[] {
const message = msg.message as Record<string, unknown> | undefined
@@ -255,6 +263,7 @@ export function assistantMessageToAcpNotifications(
sessionId,
update: {
sessionUpdate: 'agent_message_chunk',
...(options?.messageId ? { messageId: options.messageId } : {}),
content: { type: 'text', text: content },
},
},
@@ -296,6 +305,7 @@ export function streamEventToAcpNotifications(
clientCapabilities?: ClientCapabilities
cwd?: string
streamingActive?: boolean
messageId?: string
},
): SessionNotification[] {
const event = (msg as unknown as { event: Record<string, unknown> }).event
@@ -318,6 +328,7 @@ export function streamEventToAcpNotifications(
clientCapabilities: options?.clientCapabilities,
parentToolUseId: msg.parent_tool_use_id as string | null | undefined,
cwd: options?.cwd,
messageId: options?.messageId,
},
)
}
@@ -335,6 +346,7 @@ export function streamEventToAcpNotifications(
clientCapabilities: options?.clientCapabilities,
parentToolUseId: msg.parent_tool_use_id as string | null | undefined,
cwd: options?.cwd,
messageId: options?.messageId,
},
)
}