Skip to content

feat: name the assistant message a prompt ended on - #1047

Closed
taras wants to merge 2 commits into
agentclientprotocol:mainfrom
taras:feat/prompt-response-assistant-uuid
Closed

taras wants to merge 2 commits into
agentclientprotocol:mainfrom
taras:feat/prompt-response-assistant-uuid

Conversation

@taras

@taras taras commented Aug 27, 2026

Copy link
Copy Markdown

The problem

Query.resumeSessionAt keys on an SDKAssistantMessage.uuid. A client that kept the uuid a prompt ended on could re-enter that conversation at exactly that point — but a prompt response has no way to say which message it finished at, so the only thing a client can ask for is wherever the session has since got to.

The adapter sees every one of those uuids go past, and already records a messageId → uuid table for rewind. The turn-boundary uuid just never reaches the caller.

The change

A settled prompt now carries it on _meta.claudeCode.assistantMessageUuid:

{
  "stopReason": "end_turn",
  "usage": { /* unchanged */ },
  "_meta": {
    "claudeCode": { "assistantMessageUuid": "0f9c…" }
  }
}

Why it is turn-owned

latestAssistantUuid lives on the Turn and is assigned where the consumer attributes the message — beside the usage snapshot that is already turn-scoped by the same two conditions — rather than being read back at settle time.

A session-global "last assistant message" would hand a turn whatever the session said most recently, which after an echo hand-off, a held-open turn waiting on subagents, or a steered second cycle is another turn's answer.

Subagent messages (parent_tool_use_id !== null) are excluded for the same reason they are excluded from the usage snapshot: they are not this turn's own answer, and resuming from one would re-enter the conversation inside a tool call.

Which identity

The SDK uuid, not the Anthropic API message id. Those are two different identities on one message, and the msg_… id is already what the ACP messageId carries — reporting it here would hand a client something resumeSessionAt cannot use. And not the prompt's own uuid, which names the question rather than the answer.

Compatibility

Merged rather than assigned: claudeCode is a namespace this adapter already writes into, and a failure lane may have put its own metadata on the response before it reaches the settle path. Everything already there survives, and a client that ignores the key sees no difference.

A cancelled turn carries nothing — it was interrupted rather than answered — and a turn that produced no top-level assistant message names nothing rather than reporting an empty value.

Tests

src/tests/checkpoint-metadata.test.ts compares the uuid the SDK emitted with the uuid the response returned:

  • the exact emitted uuid is returned
  • the last assistant message wins when a turn produced several
  • subagent messages are ignored
  • it is the SDK uuid, not the msg_… id
  • a turn that said nothing names nothing
  • interleaved sessions each get their own uuid — which is what a session-global read would cross
npm run test:run
npm run build

The new file passes and the build is clean. Reverting only src/acp-agent.ts fails 5 of the 6 new tests, so they are not vacuous. Three unrelated pre-existing failures in providers.test.ts and settings.test.ts are present with and without this change (10 failures unpatched, 3 patched — this touches neither).

Context

This came out of building durable Agent-session continuity in executable.md, where a workflow run retains which provider turn each prompt landed on so a later run can resume from an exact point. That consumer treats the value as opaque and never infers one — a prompt it cannot name stays unnamed — so the identity has to come from the adapter or not at all.

A matching change for @agentclientprotocol/codex-acp is proposed alongside this one.

`Query.resumeSessionAt` keys on an `SDKAssistantMessage.uuid`, so a client that
keeps the uuid a prompt ended on can later re-enter that conversation at exactly
that point. The adapter sees every one of those uuids go past and already
records a `messageId` -> uuid table for rewind, but a prompt's own response
carried no way to say which message it finished at.

A settled prompt now carries it on `_meta.claudeCode.assistantMessageUuid`.

The uuid is turn-owned and assigned where the consumer attributes the message,
not read back at settle time. A session-global "last assistant message" would
hand a turn whatever the session said most recently, which after a hand-off, a
held-open turn, or a steered second cycle is another turn's answer. Subagent
messages are excluded for the same reason they are excluded from the usage
snapshot beside them: they are not this turn's own answer, and resuming from one
would re-enter the conversation inside a tool call.

It is the SDK uuid rather than the Anthropic API message id — those are two
different identities on one message, and the `msg_...` id is what the ACP
`messageId` already carries — and rather than the prompt's own uuid, which names
the question and not the answer.

Merged into whatever the response already carries, so an existing `claudeCode`
namespace and any failure metadata survive. A cancelled turn carries none: it
was interrupted rather than answered, and a turn that produced no top-level
assistant message names nothing.
A client that defers creating durable session state until a conversation really
exists has nothing to wait for. A session exists SDK-side before any prompt has
been taken, and everything a turn then produces — output, an idle, a result, a
settled prompt response — says the turn is under way or over, not that the SDK
accepted it. A client that read any of those as acceptance would retain a
conversation that may never have started.

The adapter already sees the dispatch. Two facts report it: the
`command_lifecycle` "started" frame on CLIs that emit one, and the replayed echo
of the prompt's own uuid everywhere else. Either may arrive first, or both.

A queued prompt the SDK took now publishes one `session_info_update` carrying
`_meta["executablemd.session-materialization/v1"] = {state: "accepted"}`. It
carries no session information of its own — no title, no updatedAt — so a client
reading it as session info reads nothing, and one waiting for acceptance reads
exactly that.

Exactly once per prompt, latched on the turn rather than the session: the two
dispatch facts describe the same dispatch, and a session-scoped latch would
publish for the first prompt of a session and never again. A local-only slash
command is never dispatched into a turn and publishes nothing. Nothing is
published from session initialization, output, idle, a result or settlement.

Three suites read every `session_info_update` a prompt produces and now see one
that carries no title; each is narrowed to the updates it is actually about
rather than to the update kind.
@benbrandt

Copy link
Copy Markdown
Member

covered in #1046

@benbrandt benbrandt closed this Oct 26, 2026
@taras

taras commented Oct 26, 2026

Copy link
Copy Markdown
Author

Thank you 🙏

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants