Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 66 additions & 37 deletions packages/client/core/src/conversation-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { AgentEvent, SessionId } from '@linkcode/schema';
import type { Unsubscribe } from '@linkcode/transport';
import { noop } from 'foxact/noop';
import type { LinkCodeClient, SequencedAgentEvent } from './client';
import type { Conversation, ConversationSeed } from './conversation';
import type { Conversation, ConversationBuilder, ConversationSeed } from './conversation';
import { createConversationBuilder } from './conversation';

/** A `useSyncExternalStore`-shaped incremental projection of one session's conversation.
Expand All @@ -29,52 +29,80 @@ const EMPTY_CONVERSATION: Conversation = {
pendingQuestionIds: [],
};

/**
* Whether the seed's transcript snapshot can be assumed to contain this event — the only license
* the `uptoSeq` cut has to drop it as "already in the snapshot". Providers flush transcripts by
* whole item, so coverage is checked per provider identity, or by counted value for user prompts
* whose host and provider ids cannot converge. A chunk of a message the snapshot never saw (the
* in-flight reply — claude-code writes the row only when the message completes) must survive a
* mid-turn reseed, or the streamed text vanishes at a chunk boundary (CODE-272). Everything
* outside the switch (interactive requests and resolutions, status, stop, errors, usage …) is
* ephemeral: it never appears in `history.read`, so cutting it would erase it outright — a pending
* permission-request would vanish and strand the turn (CODE-35).
*/
function coveredBySeed(
type UserMessageEvent = Extract<AgentEvent, { type: 'user-message' }>;
interface SeedUserMessageQueue {
messages: UserMessageEvent[];
nextIndex: number;
}

function takeSeedUserMessage(
messagesByContent: Map<string, SeedUserMessageQueue>,
content: UserMessageEvent['content'],
): UserMessageEvent | undefined {
const key = JSON.stringify(content);
const queue = messagesByContent.get(key);
if (!queue) return undefined;
const message = queue.messages[queue.nextIndex];
queue.nextIndex += 1;
if (queue.nextIndex === queue.messages.length) messagesByContent.delete(key);
return message;
}

/** Fold a pre-cut event only when the transcript snapshot does not already cover it. */
function foldPreCutEvent(
builder: ConversationBuilder,
event: AgentEvent,
receivedAt: number | undefined,
seedMessageIds: ReadonlySet<string>,
seedToolIds: ReadonlySet<string>,
seedUserMessageCounts: Map<string, number>,
): boolean {
seedUserMessages: Map<string, SeedUserMessageQueue>,
): void {
switch (event.type) {
case 'agent-message':
case 'agent-message-chunk':
case 'agent-thought':
case 'agent-thought-chunk':
return seedMessageIds.has(event.messageId);
case 'agent-thought-chunk': {
if (!seedMessageIds.has(event.messageId)) builder.advance(event, receivedAt);
break;
}
case 'user-message': {
// Host and provider ids cannot converge, so consume one matching seed row by value. Counting
// preserves repeated prompts while an unflushed queued prompt remains visible past the cut.
const key = JSON.stringify(event.content);
const remaining = seedUserMessageCounts.get(key) ?? 0;
if (remaining === 0) return false;
if (remaining === 1) seedUserMessageCounts.delete(key);
else seedUserMessageCounts.set(key, remaining - 1);
return true;
// Host and provider ids cannot converge, so consume matching seed rows by value. Some
// histories omit images; use the full live echo to enrich that seed row in place.
if (takeSeedUserMessage(seedUserMessages, event.content)) break;
if (event.content.some((block) => block.type === 'image')) {
const seedMessage = takeSeedUserMessage(
seedUserMessages,
event.content.filter((block) => block.type !== 'image'),
);
if (seedMessage) {
builder.advance({
...event,
messageId: seedMessage.messageId,
branchCursor: seedMessage.branchCursor,
});
Comment on lines +78 to +82

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This advance registers a second entries row under the seed's messageId, which breaks conversation-rewind for enriched prompts. createConversationBuilder.advance appends every event to a replay log, and the rewind handler scans that log backwards for the first matching user-message id (conversation.ts:211-219) — so the cut lands on this enriched row instead of the seed row, leaving the rewound prompt and the agent reply between them on screen.

I reproduced it: with a lossy seed the timeline after a rewind + replacement is ['provider-prompt', 'reply', 'replacement']; with a faithful seed (so the fallback never fires) the same sequence correctly yields ['replacement'].

Technical details
# Enrichment advance corrupts the `conversation-rewind` replay log

## Affected sites
- `packages/client/core/src/conversation-store.ts:78-82` — advances the live content under `seedMessage.messageId`, after the seed fold at line 158 already advanced the seed event under that same id. `entries` now holds two `user-message` rows sharing one `messageId`.
- `packages/client/core/src/conversation.ts:211-219` — the rewind scan walks `entries` from the end and cuts at the first id match, i.e. the enriched row. `entries.slice(0, cut)` therefore retains the original lossy seed row and every event that landed between the two.

## Reachability
- `packages/presentation/ui/src/chat/user-message.tsx:98``onEditPrompt(item.id, item.branchCursor, …)`. After enrichment `item.id` *is* the seed `messageId` and `item.branchCursor` is the seed cursor, so edit is enabled and keyed on the enriched identity.
- `packages/client/workbench/src/surface/workbench.tsx:346` — forwards that id as `sourceMessageId`; the engine echoes it back as `conversation-rewind` with the same `messageId`.

## Reproduction
Seed `[{ user-message 'provider-prompt', content: [text] }, { agent-message 'reply' }]`, `uptoSeq: 2`; live buffer holds the image-bearing echo `'host-prompt'` plus the same reply. Enrichment fires. Then send `conversation-rewind` for `'provider-prompt'` followed by a replacement prompt:

```
lossy seed (fallback fires):    ['provider-prompt', 'reply', 'replacement']   // wrong
faithful seed (no fallback):   ['replacement']                               // correct
```

## Required outcome
- After a rewind targeting an enriched prompt, that prompt and every later item must leave the timeline, exactly as they do when the fallback never fires.

## Suggested approach (optional)
- Enrich the seed event's `content` *before* the initial fold rather than re-advancing afterwards, so `entries` keeps one row per message identity. The live buffer is already reachable from `sync()`, which folds the seed and then walks the live events on the same call.
- Alternatively, make the rewind scan cut at the *first* matching entry instead of the last. Since a user `messageId` is a single identity, forward and backward scans differ only when one id was folded twice — precisely this case.

## Open questions for the human
- A prompt rewrite also emits `session-ref` with a new `historyId`, which re-keys `useSeededConversation` and would build a fresh store from a fresh seed. How reliably does that reseed land? It probably bounds this to a transient window rather than a permanent wrong state, but it depends on the reseed arriving and on SWR yielding a new seed identity, so I would not lean on it.

break;
}
}
builder.advance(event, receivedAt);
break;
}
case 'tool-call': {
if (!seedToolIds.has(event.toolCall.toolCallId)) builder.advance(event, receivedAt);
break;
}
case 'tool-call-content-chunk': {
if (!seedToolIds.has(event.toolCallId)) builder.advance(event, receivedAt);
break;
}
case 'tool-call':
return seedToolIds.has(event.toolCall.toolCallId);
case 'tool-call-content-chunk':
return seedToolIds.has(event.toolCallId);
default:
return false;
builder.advance(event, receivedAt);
}
}

/**
* Project a session's conversation from a transcript seed plus the live event buffer: the seed
* folds once, then `getSnapshot` lazily advances by unconsumed events, skipping events inside the
* `uptoSeq` cut that the snapshot verifiably covers (see {@link coveredBySeed}). The sync is idempotent and monotone with a stable snapshot identity
* `uptoSeq` cut that the snapshot verifiably covers (see {@link foldPreCutEvent}). The sync is idempotent and monotone with a stable snapshot identity
* between events — the `useSyncExternalStore` getSnapshot contract. A store is bound to one
* (session, seed) pair; create a fresh one when either changes.
*/
Expand All @@ -92,7 +120,7 @@ export function createConversationStore(
// Identities the snapshot actually holds, for the per-event coverage check of the cut.
const seedMessageIds = new Set<string>();
const seedToolIds = new Set<string>();
const seedUserMessageCounts = new Map<string, number>();
const seedUserMessages = new Map<string, SeedUserMessageQueue>();
if (seed) {
for (const { event } of seed.events) {
switch (event.type) {
Expand All @@ -104,7 +132,9 @@ export function createConversationStore(
break;
case 'user-message': {
const key = JSON.stringify(event.content);
seedUserMessageCounts.set(key, (seedUserMessageCounts.get(key) ?? 0) + 1);
const queue = seedUserMessages.get(key);
if (queue) queue.messages.push(event);
else seedUserMessages.set(key, { messages: [event], nextIndex: 0 });
break;
}
case 'tool-call':
Expand All @@ -131,11 +161,10 @@ export function createConversationStore(
const events = client.eventsSnapshot(sessionId);
for (let i = firstIndexAfter(events, consumedSeq); i < events.length; i += 1) {
const { event, seq, receivedAt } = events[i];
if (
seq > uptoSeq ||
!coveredBySeed(event, seedMessageIds, seedToolIds, seedUserMessageCounts)
) {
if (seq > uptoSeq) {
builder.advance(event, receivedAt);
} else {
foldPreCutEvent(builder, event, receivedAt, seedMessageIds, seedToolIds, seedUserMessages);
}
}
// Snap to the counter even when the buffer lags it (cleared by a stop): those events are
Expand Down
48 changes: 48 additions & 0 deletions packages/client/core/tests/integration/conversation-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,54 @@ describe('createConversationStore', () => {
close();
});

it('enriches a lossy seeded prompt with its live image instead of appending a duplicate', async () => {
const { client, send, close } = await harness();
const livePrompt: AgentEvent = {
type: 'user-message',
messageId: 'host-prompt' as MessageId,
content: [
{ type: 'text', text: 'describe this image' },
{ type: 'image', data: 'cG5n', mimeType: 'image/png' },
],
branchCursor: 'live-cursor',
};
const reply: AgentEvent = {
type: 'agent-message',
messageId: 'reply' as MessageId,
content: [{ type: 'text', text: 'It is a test image.' }],
};
send(livePrompt);
send(reply);
await tick();

const store = createConversationStore(client, sessionId, {
events: [
// Some provider histories retain the prompt text but omit its image blocks.
{
event: {
type: 'user-message',
messageId: 'provider-prompt' as MessageId,
content: [{ type: 'text', text: 'describe this image' }],
branchCursor: 'provider-cursor',
},
},
{ event: reply },
],
uptoSeq: 2,
});

const messages = store.getSnapshot().items.filter((item) => item.kind === 'message');
expect(messages).toHaveLength(2);
expect(messages[0]).toMatchObject({
id: 'provider-prompt',
role: 'user',
blocks: livePrompt.content,
branchCursor: 'provider-cursor',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The seed row here has no ts, so nothing in this test pins timestamp preservation even though the PR description claims it. That matters because the preservation is implicit: omitting receivedAt from the enrichment advance is the only reason the seed timestamp survives (receivedAt ?? item.receivedAt). A future refactor that threads receivedAt through would silently stamp the live receive time onto a history message with no test failing.

Giving the seed row a ts and asserting receivedAt alongside the existing fields would close that.

Suggested change
branchCursor: 'provider-cursor',
branchCursor: 'provider-cursor',
receivedAt: 1_700_000_000_000,

});
expect(messages[1]).toMatchObject({ id: 'reply', role: 'assistant' });
close();
});

it('consumes only one matching seed row for repeated prompt content', async () => {
const { client, send, close } = await harness();
send(userText('repeat', 'host-1'));
Expand Down
Loading