Skip to content

Record each message once, under one id, as the API received it - #506

Open
shellicar wants to merge 15 commits into
mainfrom
fix/remove-identity-field
Open

Record each message once, under one id, as the API received it#506
shellicar wants to merge 15 commits into
mainfrom
fix/remove-identity-field

Conversation

@shellicar

@shellicar shellicar commented Aug 10, 2026

Copy link
Copy Markdown
Owner

Summary

  • The conversation stream carries what the conversation holds, including a message that grew after it was first announced.
  • The audit file and the stream name the same message by the same id.
  • A cancelled or aborted query no longer lends its ids to the next one.
  • Resuming a conversation no longer republishes its history.
  • A tool_result is announced without a sender.

@shellicar shellicar added this to the 1.0 milestone Aug 10, 2026
@shellicar shellicar added bug Something isn't working pkg: claude-sdk-cli The new SDK-based CLI pkg: claude-sdk The agent SDK labels Aug 10, 2026
@shellicar shellicar self-assigned this Aug 10, 2026
@shellicar
shellicar marked this pull request as ready for review August 10, 2026 17:53
The conversation file rebuilds the API request, and it was also carrying an _identity
sidecar, which made the ids message state. Two things followed. A query cancelled
mid-round leaves a user-role tip, so the next message merges into that row and inherits
the dead query's ids. And the audit recorded the assistant under the API's msg.id while
the wire announced it under a separate uuid, so one message read as two.

The query, the turn and each message now mint and hold their own id, and a message is
recorded in the audit and announced on the wire from a single decision, so the two
records cannot hold different sets.
The audit wrote a round at a time, at final_message, so a query cancelled during its
tools committed a tool_result message the audit never held. That message is on the wire,
so the two records disagreed and the tip could not be read back out of the audit on
resume. Each message is now written at its own commit moment, from the same decision
that announces it.

The assistant deliberately keeps its old moment: model output cannot be regenerated, so
it is recorded the instant the response completes rather than behind a disk write.

The request delta no longer rides final_message, since the user half is recorded before
the request goes out. The v2 message schema drops `from` from its required set, matching
the 19 July correction that a tool_result has no sender to name.
It had grown four members serving four different callers, and no caller used more than
two of them. Adopting a conversation, committing a message, closing a query and reading
the tip are four seams behind one implementation, so each caller now declares only the
one it uses and cannot reach the rest.

The class is named for what it does: it commits a message to both records rather than
only announcing it.

It also caught a real gap. The boot sequence declared the dependency but never called
adopt, so a resumed conversation started with no tip.
The record was taken from the conversation array before the request went out. That array
is shaped for the API and still mutable at that point: consecutive user messages merge
into one row, and a clock stamp and a heal are applied to the tip on the way out. So the
audit and the wire held something the model never saw, and content merged in afterwards
reached neither.

A message is a message when the model receives it. The turn runner reads the delta after
every shaping and the stream processor announces it as the request goes out, so what was
sent is what is recorded, under one id, in both records.

That also settles the case this started from. A query cancelled during its tools creates
a tool_result message that is only sent on the next request, so it is not recorded yet,
and it is recorded the moment anything is sent after it.
…e it is attempted

The announcement hung off the stream processor, which is called inside the retry loop, so a
reconnect or a backed-off resend announced the same message again. It was there because that
was the event that existed, not because the flow put it there.

It is now a lifecycle listener the turn runner calls once, above the loop, immediately before
the first attempt. The retry is the transport recovering from its own failure and carries the
same message.
… a call carries it

Both records were driven off the send. Consecutive user messages merge into one row, so each
send announced that row again with everything merged into it so far, and one message that grew
read as four messages each repeating the ones before it.

The two answer different questions and belong on different triggers. The audit holds what an
API call carried, so it is written per request. The wire holds what the conversation contains,
so it is announced when the conversation is persisted, once per row. One id per row keeps them
naming the same message.
A row that grew because consecutive user messages merged was skipped on the grounds it had
already been announced. Skipping it is how what you said after the first announcement never
reached the wire.

It is announced every time the conversation is persisted, under the id that row already has.
The state of a message is its latest announcement, last-write-wins per id, so fuller content
replaces the earlier one rather than adding a second message.
The committer mints the user row's id, so the scope only ever held the assistant's. It is named
for what it holds.
A message is announced on the conversation stream each time the conversation is persisted, and
the announcement at the end of a turn was made without a sender. A consumer keeps the latest
announcement of a message, so a message you typed ended up on the wire with nobody named as
having said it.

The sender belongs to the message rather than to the moment it is announced, so it is held with
the row and reused when a later announcement is not told one.
A message arriving over the bus carries an id and the identity of whoever sent it, and two
fields existed to carry those into the turn. The id and the sender are now established when the
request is accepted, before the turn starts, so both fields were being filled in on every turn
and read nowhere.

The sender one is the misleading half: it looks like the thing that tells the record who spoke.
Startup reads the conversation's audit file to find where it left off, parsing every line. The
audit is appended without waiting for the write to finish, so a process killed mid-append leaves
a line that will not parse, and parsing it threw with nothing to catch it: the CLI failed to
launch on that conversation.

Such a line is skipped, the same as a line that carries no id.
When the CLI opens or moves to a conversation it reads the durable record to find the last
message, and states that as the conversation's position. Another program has to name that
position to be allowed to speak, so getting it wrong means nothing can talk to the conversation.

Nothing asserted it: every test replaced the reader with a stub returning nothing, and the
reader itself had no test at all.
@shellicar
shellicar force-pushed the fix/remove-identity-field branch from dc6f81b to 96f1db7 Compare September 12, 2026 10:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working pkg: claude-sdk The agent SDK pkg: claude-sdk-cli The new SDK-based CLI

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant