diff --git a/apps/mobile/src/components/conversation/timeline-item.tsx b/apps/mobile/src/components/conversation/timeline-item.tsx index 045d8a4e5..134b06040 100644 --- a/apps/mobile/src/components/conversation/timeline-item.tsx +++ b/apps/mobile/src/components/conversation/timeline-item.tsx @@ -140,6 +140,12 @@ export function TimelineItem({ ) : null} ); + case 'history-unavailable': + return ( + + {t('historyUnavailable')} + + ); default: return null; } diff --git a/apps/mobile/src/runtime/__tests__/use-seeded-conversation.test.ts b/apps/mobile/src/runtime/__tests__/use-seeded-conversation.test.ts index c9024b682..55c47f2f3 100644 --- a/apps/mobile/src/runtime/__tests__/use-seeded-conversation.test.ts +++ b/apps/mobile/src/runtime/__tests__/use-seeded-conversation.test.ts @@ -5,15 +5,19 @@ import type { MessageId, SessionId, SessionInfo, + TurnId, WirePayload, } from '@linkcode/schema'; +import { userRowMessageId } from '@linkcode/schema'; import { useSeededConversation } from '@mobile/runtime/use-seeded-conversation'; import { renderHook, waitFor } from '@testing-library/react'; import { expect, it } from 'vitest'; +import type { ControlledTransport } from './client-test-helpers'; import { clientWrapper, connectClient } from './client-test-helpers'; const SESSION = 'session-1' as SessionId; const HISTORY = 'history-1' as AgentHistoryId; +const TURN = 'turn-1' as TurnId; const SESSION_INFO: SessionInfo = { sessionId: SESSION, @@ -25,7 +29,7 @@ const SESSION_INFO: SessionInfo = { historyId: HISTORY, }; -/** Same session with no `historyId`, so the seed read short-circuits and only attach runs. */ +/** Same session with no `historyId`: without a turn graph there is nothing left to read. */ const NO_HISTORY: SessionInfo = { ...SESSION_INFO, historyId: undefined }; function kinds(sent: readonly WirePayload[]): string[] { @@ -40,6 +44,21 @@ async function mountSeeded(sessionId: SessionId | null, session: SessionInfo | n return { transport, client, view }; } +/** Answer the newest `conversation.read` as the daemon would for a session without turn rows. */ +async function answerEmptyGraph(transport: ControlledTransport): Promise { + await waitFor(() => expect(kinds(transport.sent)).toContain('conversation.read')); + const read = transport.sent.findLast((payload) => payload.kind === 'conversation.read'); + if (read?.kind !== 'conversation.read') throw new Error('no conversation.read'); + transport.receive({ + kind: 'conversation.read.result', + replyTo: read.clientReqId, + sessionId: SESSION, + graphRevision: 0, + watermark: { epoch: 0, seq: 0 }, + events: [], + }); +} + it('announces the route session before the session list resolves', async () => { // The screen has the id from the route immediately but `SessionInfo` only a round-trip later. // Waiting for it would let the connection sit in `attached` scope with nothing announced, and @@ -47,21 +66,30 @@ it('announces the route session before the session list resolves', async () => { const { transport, client } = await mountSeeded(SESSION, null); expect(transport.sent).toContainEqual({ kind: 'session.attach', sessionId: SESSION }); + expect(kinds(transport.sent)).not.toContain('conversation.read'); expect(kinds(transport.sent)).not.toContain('history.read'); client.dispose(); }); -it('announces the session before reading its history', async () => { +it('announces the session, reads its turn graph, and falls back to the transcript', async () => { const { transport, client } = await mountSeeded(SESSION, SESSION_INFO); - await waitFor(() => expect(kinds(transport.sent)).toContain('history.read')); // Attaching first is what asks the daemon to re-broadcast the buffered per-session state; the - // read then walks the transcript. Reversed, the re-broadcast would land after the seed sampled - // its cut. + // read then follows. Reversed, the re-broadcast would land after the seed sampled its cut. + await answerEmptyGraph(transport); + await waitFor(() => expect(kinds(transport.sent)).toContain('history.read')); expect(kinds(transport.sent).indexOf('session.attach')).toBeLessThan( - kinds(transport.sent).indexOf('history.read'), + kinds(transport.sent).indexOf('conversation.read'), ); - expect(transport.sent).toContainEqual({ kind: 'session.attach', sessionId: SESSION }); + client.dispose(); +}); + +it('reads nothing further for a graph-less session without a transcript', async () => { + const { transport, client } = await mountSeeded(SESSION, NO_HISTORY); + + await answerEmptyGraph(transport); + await waitFor(() => expect(kinds(transport.sent)).toContain('conversation.read')); + expect(kinds(transport.sent)).not.toContain('history.read'); client.dispose(); }); @@ -87,8 +115,55 @@ it('announces nothing when there is no session to observe', async () => { client.dispose(); }); +it('seeds from the turn-graph projection and re-reads when the store asks to', async () => { + const { transport, client, view } = await mountSeeded(SESSION, SESSION_INFO); + await waitFor(() => expect(kinds(transport.sent)).toContain('conversation.read')); + const read = transport.sent.find((payload) => payload.kind === 'conversation.read'); + if (read?.kind !== 'conversation.read') throw new Error('no conversation.read'); + transport.receive({ + kind: 'conversation.read.result', + replyTo: read.clientReqId, + sessionId: SESSION, + graphRevision: 1, + leafTurnId: TURN, + watermark: { epoch: 1, seq: 1 }, + events: [ + { + turnId: TURN, + event: { + type: 'user-message', + messageId: userRowMessageId(TURN), + content: [{ type: 'text', text: 'hi' }], + }, + }, + ], + }); + + await waitFor(() => + expect(view.result.current.items).toContainEqual( + expect.objectContaining({ kind: 'message', role: 'user', id: userRowMessageId(TURN) }), + ), + ); + // A projection covers the transcript; the legacy read never runs. + expect(kinds(transport.sent)).not.toContain('history.read'); + + // A relaunch (new epoch) must be re-read: the hook answers the store's request with a fresh read. + transport.receive({ + kind: 'agent.event', + sessionId: SESSION, + epoch: 2, + seq: 1, + event: { type: 'status', status: 'running' }, + }); + await waitFor(() => + expect(kinds(transport.sent).filter((kind) => kind === 'conversation.read')).toHaveLength(2), + ); + client.dispose(); +}); + it('keeps an ask the re-broadcast delivered before the seed cut', async () => { const { transport, client, view } = await mountSeeded(SESSION, SESSION_INFO); + await answerEmptyGraph(transport); await waitFor(() => expect(kinds(transport.sent)).toContain('history.read')); // What `session.attach` buys: an ask raised while the thread was closed. It arrives before the diff --git a/apps/mobile/src/runtime/use-seeded-conversation.ts b/apps/mobile/src/runtime/use-seeded-conversation.ts index 60f4068f2..9671956a2 100644 --- a/apps/mobile/src/runtime/use-seeded-conversation.ts +++ b/apps/mobile/src/runtime/use-seeded-conversation.ts @@ -1,25 +1,30 @@ -import type { Conversation, ConversationSeed, ConversationSeedEvent } from '@linkcode/client-core'; -import { useConversation, useLinkCodeClient } from '@linkcode/client-core'; +import type { + Conversation, + ConversationProjectionSeed, + ConversationSeed, +} from '@linkcode/client-core'; +import { readConversationSeed, useConversation, useLinkCodeClient } from '@linkcode/client-core'; import type { SessionId, SessionInfo } from '@linkcode/schema'; import { noop } from 'foxact/noop'; import { useEffect } from 'foxact/use-abortable-effect'; -import { useState } from 'react'; +import { useReducer, useState } from 'react'; -/** Upper bound on cursor pages one seed read follows, so a buggy cursor can't loop forever. */ -const MAX_SEED_PAGES = 20; +type Seed = ConversationProjectionSeed | ConversationSeed; /** - * The session's conversation view-model seeded from provider history — the live `agent.event` - * subscription only covers this connection, so a cold-opened session replays its past from - * `history.read` (same read walk as workbench's useSeededConversation, without the SWR cache). - * A failed read degrades to live-only; the seed is keyed by session so it never bleeds across. + * The session's conversation view-model seeded from the daemon: the turn-graph projection where the + * host serves one, the provider transcript otherwise — the live `agent.event` subscription only + * covers this connection (same read as workbench's useSeededConversation, without the SWR cache). + * A failed read degrades to live-only; the seed is keyed by session so it never bleeds across, and + * a projection store's resync request re-runs the read. */ export function useSeededConversation( sessionId: SessionId | null, session: SessionInfo | null, ): Conversation { const client = useLinkCodeClient(); - const [seeded, setSeeded] = useState<{ for: SessionId; seed: ConversationSeed } | null>(null); + const [seeded, setSeeded] = useState<{ for: SessionId; seed: Seed } | null>(null); + const [readGeneration, requestReread] = useReducer((n: number) => n + 1, 0); const agentKind = session?.kind; const cwd = session?.cwd; @@ -30,7 +35,7 @@ export function useSeededConversation( // The attach replay would not recover it — it carries control state only, and an in-flight // reply's chunks are not in `history.read` yet either, so the turn would render truncated. // Announcing before the seed read is also what keeps a re-broadcast ask: it lands inside the - // seed's `uptoSeq` cut, which only drops what the transcript verifiably covers (CODE-35). + // seed's cut, which only drops what the read verifiably covers (CODE-35). useEffect(() => { if (!sessionId) return; client.attachSession(sessionId); @@ -39,34 +44,19 @@ export function useSeededConversation( useEffect( (signal) => { - if (!agentKind || !historyId || !sessionId) return; + if (!agentKind || cwd === undefined || !sessionId) return; void (async () => { - const events: ConversationSeedEvent[] = []; - let cursor: string | undefined; - for (let page = 0; page < MAX_SEED_PAGES; page += 1) { - // eslint-disable-next-line no-await-in-loop -- cursor pagination: each page's cursor comes from the previous reply - const result = await client.readHistory(agentKind, { - historyId, - cwd, - cursor, - forceRefresh: page === 0, - }); - for (let i = 0, len = result.events.length; i < len; i++) { - const entry = result.events[i]; - events.push({ event: entry.event, ts: entry.ts }); - } - cursor = result.cursor; - if (cursor === undefined) break; - } - if (signal.aborted) return; - setSeeded({ - for: sessionId, - seed: { events, uptoSeq: client.eventSeq(sessionId) }, - }); + const seed = await readConversationSeed(client, { sessionId, agentKind, cwd, historyId }); + if (seed === undefined || signal.aborted) return; + setSeeded({ for: sessionId, seed }); })().catch(noop); }, - [agentKind, client, cwd, historyId, sessionId], + [agentKind, client, cwd, historyId, sessionId, readGeneration], ); - return useConversation(sessionId, seeded?.for === sessionId ? seeded.seed : undefined); + return useConversation( + sessionId, + seeded?.for === sessionId ? seeded.seed : undefined, + requestReread, + ); } diff --git a/packages/client/core/AGENTS.md b/packages/client/core/AGENTS.md new file mode 100644 index 000000000..d2759091a --- /dev/null +++ b/packages/client/core/AGENTS.md @@ -0,0 +1,45 @@ +# packages/client/core — the cross-platform data-plane client + +`LinkCodeClient` (session semantics over any `Transport`), the per-session `EventBuffer`, the +conversation view-model builder, and the conversation store shared by desktop, webview, and mobile. +Framework-agnostic except `react.tsx` (the `useSyncExternalStore` hooks). + +## Conversation seeding — two merge paths + +`createConversationStore` folds a seed, then the live `agent.event` buffer. Which seed a surface +reads is decided once, in `readConversationSeed` (`conversation-read.ts`), and both paths must keep +working until the compatibility floor passes v80: + +- **Projection path** (`ConversationProjectionSeed`): the host advertises the turn graph + (`client.supportsConversationGraph`, wire ≥ `CONVERSATION_GRAPH_WIRE_VERSION`) **and** the session + has turn rows (`conversation.read` names a `leafTurnId`). Pages are walked as one snapshot; a + `graphRevision`/leaf change between pages or the daemon's typed `conflict` restarts the walk. + Merge rule: drop live events at or below the final page's `(epoch, seq)` watermark, older epochs + included; fold everything above. Nothing is matched by content — the daemon mints one identity per + user row (`userRowMessageId`, `msg-`) for the live echo and the read alike. +- **History path** (`ConversationSeed`): ≤v79 hosts and sessions without turn rows (pre-existing + sessions until the single-lineage migration). `history.read` transcript + the connection's receive + cut (`uptoSeq`); user rows are matched by content because provider and host ids never converge. + Retires with the floor bump. + +Rules the projection store enforces — keep them when touching it: + +- **Interactive events are never dropped on the watermark** (`permission-*`, `question-*`, + `prompt-response-status`): their state lives in the daemon's interaction registry, a read may + predate them, and the builder folds repeats idempotently. Unstamped frames (the dev mock, an old + host) always fold. +- **A skip in the daemon position asks for one re-read** through `onResync`: an epoch jump (a + relaunch, a daemon restart), a same-epoch sequence gap (frames missed while detached), or a + `conversation.graph.changed` revision past the read whose new leaf's row never arrived live (an + edit or rewrite from any device, a stale read). The request fires at most once per store, via a + microtask so it never runs inside a render; folding continues meanwhile. A persisted seed carries + no watermark, supersedes nothing, and takes its baseline from the first stamped event. +- **A stamped repeat stays in the `EventBuffer`** (attach replays resolved asks): dropping it would + read as a gap. Only unstamped repeats are deduped. +- **The echo's attachment blocks win** for the row sharing its identity: durable prompts are + text-only until attachment refs land, so the store overlays the buffered echo's content on a read + row with the same id. Remove this with the attachment store. +- Live user echoes carry no envelope `turnId` (they precede turn tracking); never bucket by it. + +`history-unavailable` read items become `ConversationItem`s of that kind under the current turn: +the prompt-only fallback for a lost, compacted, or never-recorded transcript. diff --git a/packages/client/core/CLAUDE.md b/packages/client/core/CLAUDE.md new file mode 100644 index 000000000..43c994c2d --- /dev/null +++ b/packages/client/core/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/packages/client/core/src/__tests__/event-buffer.test.ts b/packages/client/core/src/__tests__/event-buffer.test.ts index b42c492bf..77c7651d3 100644 --- a/packages/client/core/src/__tests__/event-buffer.test.ts +++ b/packages/client/core/src/__tests__/event-buffer.test.ts @@ -25,6 +25,19 @@ describe('EventBuffer', () => { expect(listener).toHaveBeenCalledOnce(); }); + it('retains a stamped repeat so the daemon sequence stays contiguous', () => { + const buffer = new EventBuffer(); + + buffer.ingest(SESSION_ID, RESOLUTION, { epoch: 1, seq: 1 }); + buffer.ingest(SESSION_ID, RESOLUTION, { epoch: 1, seq: 2 }); + + // Each stamped frame is a distinct daemon position; dropping one would read as a gap. + expect(buffer.snapshot(SESSION_ID).map(({ position }) => position)).toEqual([ + { epoch: 1, seq: 1 }, + { epoch: 1, seq: 2 }, + ]); + }); + it('drops the buffered suffix at a conversation rewind without dropping subscribers', () => { const buffer = new EventBuffer(); const listener = vi.fn(); diff --git a/packages/client/core/src/client.ts b/packages/client/core/src/client.ts index af44ac1ef..b6fc026bb 100644 --- a/packages/client/core/src/client.ts +++ b/packages/client/core/src/client.ts @@ -2,7 +2,6 @@ import type { AccountModel, AccountSecret, Accounts, - AgentEvent, AgentHistoryBranchCursor, AgentHistoryId, AgentHistoryListResult, @@ -75,7 +74,11 @@ import type { WorkspaceRecord, WorkspaceScript, } from '@linkcode/schema'; -import { MIN_COMPATIBLE_WIRE_VERSION, WIRE_PROTOCOL_VERSION } from '@linkcode/schema'; +import { + CONVERSATION_GRAPH_WIRE_VERSION, + MIN_COMPATIBLE_WIRE_VERSION, + WIRE_PROTOCOL_VERSION, +} from '@linkcode/schema'; import type { Transport, Unsubscribe } from '@linkcode/transport'; import { createWireMessage } from '@linkcode/transport'; import { extractErrorMessage, isErrorLikeObject } from 'foxts/extract-error-message'; @@ -84,12 +87,20 @@ import type { AgentLoginHandlers } from './client/agent-login-channel'; import { AgentLoginChannel } from './client/agent-login-channel'; import type { BrowserCommandExecutor } from './client/browser-host-channel'; import { BrowserHostChannel } from './client/browser-host-channel'; -import type { HistoryListClientOptions, HistoryReadClientOptions } from './client/control-channel'; +import type { + ConversationReadClientOptions, + HistoryListClientOptions, + HistoryReadClientOptions, +} from './client/control-channel'; import { ControlChannel } from './client/control-channel'; +import type { ConversationGraphChange } from './client/conversation-graph-changes'; +import { ConversationGraphChanges } from './client/conversation-graph-changes'; import type { SequencedAgentEvent } from './client/event-buffer'; import { EventBuffer } from './client/event-buffer'; import { LoopLogBuffer } from './client/loop-log-buffer'; import type { + ConversationGraphSnapshot, + ConversationReadPage, PluginList, PluginMutation, RandomUUID, @@ -101,11 +112,23 @@ import { TerminalChannel } from './client/terminal-channel'; export type { AgentLoginHandlers, AgentLoginSettled } from './client/agent-login-channel'; export type { BrowserCommandExecutor } from './client/browser-host-channel'; -export type { HistoryListClientOptions, HistoryReadClientOptions } from './client/control-channel'; -export type { SequencedAgentEvent } from './client/event-buffer'; -export type { PluginList, PluginMutation, SessionStartResult } from './client/pending-registry'; +export type { + ConversationReadClientOptions, + HistoryListClientOptions, + HistoryReadClientOptions, +} from './client/control-channel'; +export type { ConversationGraphChange } from './client/conversation-graph-changes'; +export type { AgentEventEnvelope, SequencedAgentEvent } from './client/event-buffer'; +export type { + ConversationGraphSnapshot, + ConversationReadPage, + PluginList, + PluginMutation, + SessionStartResult, +} from './client/pending-registry'; -type EventCb = (event: AgentEvent, seq: number) => void; +type EventCb = (entry: SequencedAgentEvent) => void; +type GraphChangeCb = (change: ConversationGraphChange) => void; type TerminalOutputCb = (data: string) => void; type TerminalEventCb = (event: TerminalReplayEvent) => void; type ScriptStatusCb = (cwd: string, script: WorkspaceScript) => void; @@ -218,6 +241,7 @@ export class LinkCodeClient { private readonly pending: PendingRegistry; private readonly control: ControlChannel; private readonly events = new EventBuffer(); + private readonly graphChanges = new ConversationGraphChanges(); private readonly terminals: TerminalChannel; private readonly browserHost: BrowserHostChannel; private readonly agentLogin: AgentLoginChannel; @@ -296,6 +320,12 @@ export class LinkCodeClient { return this.peerWire?.version ?? null; } + /** Whether the host serves the turn graph (`conversation.read`/`graph.get`) and stamps + * `agent.event` with `(epoch, seq)` — the gate for the projection merge path. */ + get supportsConversationGraph(): boolean { + return this.peerWire !== null && this.peerWire.version >= CONVERSATION_GRAPH_WIRE_VERSION; + } + private async handshake(): Promise { let settled = false; let cancelTimer: () => void = noop; @@ -401,6 +431,30 @@ export class LinkCodeClient { case 'history.read.result': this.pending.resolve('historyRead', p.replyTo, p.result); break; + case 'conversation.graph.result': + this.pending.resolve('conversationGraph', p.replyTo, { + sessionId: p.sessionId, + graphRevision: p.graphRevision, + ...(p.activeLeafTurnId !== undefined && { activeLeafTurnId: p.activeLeafTurnId }), + turns: p.turns, + }); + break; + case 'conversation.read.result': + this.pending.resolve('conversationRead', p.replyTo, { + sessionId: p.sessionId, + graphRevision: p.graphRevision, + ...(p.leafTurnId !== undefined && { leafTurnId: p.leafTurnId }), + ...(p.watermark !== undefined && { watermark: p.watermark }), + events: p.events, + ...(p.cursor !== undefined && { cursor: p.cursor }), + }); + break; + case 'conversation.graph.changed': + this.graphChanges.note(p.sessionId, { + graphRevision: p.graphRevision, + ...(p.activeLeafTurnId !== undefined && { activeLeafTurnId: p.activeLeafTurnId }), + }); + break; case 'config.get.result': // One result carries all three; each resolve is a no-op unless a request awaits that reply id. this.pending.resolve('configGet', p.replyTo, p.providers); @@ -636,7 +690,12 @@ export class LinkCodeClient { this.pending.resolve('ack', p.replyTo, { ok: true }); break; case 'agent.event': - this.events.ingest(p.sessionId, p.event); + this.events.ingest(p.sessionId, p.event, { + runId: p.runId, + turnId: p.turnId, + epoch: p.epoch, + seq: p.seq, + }); break; case 'terminal.listed': case 'terminal.opened': @@ -709,6 +768,28 @@ export class LinkCodeClient { return this.control.readHistory(agentKind, opts); } + /** See {@link ControlChannel.getConversationGraph}. */ + getConversationGraph(sessionId: SessionId): Promise { + return this.control.getConversationGraph(sessionId); + } + + /** See {@link ControlChannel.readConversation}. */ + readConversation( + sessionId: SessionId, + opts?: ConversationReadClientOptions, + ): Promise { + return this.control.readConversation(sessionId, opts); + } + + /** The newest `conversation.graph.changed` seen for the session on this connection. */ + latestGraphChange(sessionId: SessionId): ConversationGraphChange | undefined { + return this.graphChanges.get(sessionId); + } + + subscribeGraphChanges(sessionId: SessionId, cb: GraphChangeCb): Unsubscribe { + return this.graphChanges.subscribe(sessionId, cb); + } + resumeHistory( agentKind: AgentKind, historyId: AgentHistoryId, @@ -826,6 +907,7 @@ export class LinkCodeClient { stopSession(sessionId: SessionId): Promise { return this.control.stopSession(sessionId).then((ack) => { this.events.clearSession(sessionId); + this.graphChanges.clearSession(sessionId); return ack; }); } @@ -834,6 +916,7 @@ export class LinkCodeClient { deleteSession(sessionId: SessionId): Promise { return this.control.deleteSession(sessionId).then((ack) => { this.events.clearSession(sessionId); + this.graphChanges.clearSession(sessionId); return ack; }); } @@ -1472,6 +1555,7 @@ export class LinkCodeClient { this.connectionCloseSubs.clear(); this.pending.failAll(error); this.events.clearAll(); + this.graphChanges.clearAll(); this.loopLogs.clear(); this.terminals.disposeAll(); this.agentLogin.disposeAll(); diff --git a/packages/client/core/src/client/control-channel.ts b/packages/client/core/src/client/control-channel.ts index 73458efee..c49be734c 100644 --- a/packages/client/core/src/client/control-channel.ts +++ b/packages/client/core/src/client/control-channel.ts @@ -62,6 +62,7 @@ import type { StandaloneSkill, StandaloneSkillScope, StartOptions, + TurnId, WirePayload, WorkspaceFile, WorkspaceId, @@ -72,6 +73,8 @@ import type { import type { Transport } from '@linkcode/transport'; import { createWireMessage } from '@linkcode/transport'; import type { + ConversationGraphSnapshot, + ConversationReadPage, PendingRegistry, PendingValueMap, PluginList, @@ -89,6 +92,13 @@ export type HistoryReadClientOptions = AgentHistoryReadOptions & { forceRefresh?: boolean; }; +export interface ConversationReadClientOptions { + /** Absent = the session's active leaf. */ + leafTurnId?: TurnId; + cursor?: string; + limit?: number; +} + /** * Correlated control-plane requests (sessions, history, config, git, workspaces); replies are * correlated via the shared {@link PendingRegistry} (see {@link sendCorrelated}). @@ -163,6 +173,29 @@ export class ControlChannel { })); } + /** The session's turn tree — ids, parents, ordinals, states, input summaries; no content. */ + getConversationGraph(sessionId: SessionId): Promise { + return this.sendCorrelated('conversationGraph', (clientReqId) => ({ + kind: 'conversation.graph.get', + clientReqId, + sessionId, + })); + } + + /** One page of the host-composed projection toward a leaf. Only the final page carries the live + * tail and the `(epoch, seq)` watermark; `readConversationProjection` walks the whole read. */ + readConversation( + sessionId: SessionId, + opts: ConversationReadClientOptions = {}, + ): Promise { + return this.sendCorrelated('conversationRead', (clientReqId) => ({ + kind: 'conversation.read', + clientReqId, + sessionId, + ...opts, + })); + } + resumeHistory( agentKind: AgentKind, historyId: AgentHistoryId, diff --git a/packages/client/core/src/client/conversation-graph-changes.ts b/packages/client/core/src/client/conversation-graph-changes.ts new file mode 100644 index 000000000..d25e92da0 --- /dev/null +++ b/packages/client/core/src/client/conversation-graph-changes.ts @@ -0,0 +1,52 @@ +import type { SessionId, TurnId } from '@linkcode/schema'; +import type { Unsubscribe } from '@linkcode/transport'; + +/** One `conversation.graph.changed` broadcast: the graph moved its default leaf or gained shape. */ +export interface ConversationGraphChange { + graphRevision: number; + activeLeafTurnId?: TurnId; +} + +type ChangeCb = (change: ConversationGraphChange) => void; + +/** + * Per-session register of the newest graph revision the daemon announced on this connection. Not + * a buffer: a store holding a read at an older revision only needs to know that a newer one exists + * and where its leaf is. + */ +export class ConversationGraphChanges { + private readonly latest = new Map(); + private readonly subscribers = new Map>(); + + note(sessionId: SessionId, change: ConversationGraphChange): void { + const current = this.latest.get(sessionId); + if (current !== undefined && current.graphRevision >= change.graphRevision) return; + this.latest.set(sessionId, change); + const subs = this.subscribers.get(sessionId); + if (subs) for (const cb of subs) cb(change); + } + + get(sessionId: SessionId): ConversationGraphChange | undefined { + return this.latest.get(sessionId); + } + + subscribe(sessionId: SessionId, cb: ChangeCb): Unsubscribe { + let set = this.subscribers.get(sessionId); + if (!set) { + set = new Set(); + this.subscribers.set(sessionId, set); + } + set.add(cb); + return () => set.delete(cb); + } + + clearSession(sessionId: SessionId): void { + this.latest.delete(sessionId); + this.subscribers.delete(sessionId); + } + + clearAll(): void { + this.latest.clear(); + this.subscribers.clear(); + } +} diff --git a/packages/client/core/src/client/event-buffer.ts b/packages/client/core/src/client/event-buffer.ts index c6a43813c..58e169600 100644 --- a/packages/client/core/src/client/event-buffer.ts +++ b/packages/client/core/src/client/event-buffer.ts @@ -1,6 +1,19 @@ -import type { AgentEvent, SessionId } from '@linkcode/schema'; +import type { + AgentEvent, + ConversationWatermark, + RunId, + SessionId, + TurnId, + WirePayload, +} from '@linkcode/schema'; import type { Unsubscribe } from '@linkcode/transport'; +/** The attribution and position fields of an `agent.event` frame; all absent from ≤v79 hosts. */ +export type AgentEventEnvelope = Pick< + Extract, + 'runId' | 'turnId' | 'epoch' | 'seq' +>; + /** * An event plus its connection-scoped receive sequence (1-based, monotone per connection): a * transcript snapshot taken at counter N supersedes exactly the events with seq ≤ N. @@ -11,9 +24,15 @@ export interface SequencedAgentEvent { /** Client receive time (ms epoch), stamped when the event is ingested from the live stream. * Drives relative timestamps in the UI; absent for events replayed from a history read. */ receivedAt?: number; + /** Daemon-minted `(epoch, seq)` position — the projection merge cut; absent from ≤v79 hosts. */ + position?: ConversationWatermark; + runId?: RunId; + /** The turn the daemon attributed the event to. A live user echo carries none: it is broadcast + * before its turn is tracked, so echoes are never bucketed by this field. */ + turnId?: TurnId; } -type EventCb = (event: AgentEvent, seq: number) => void; +type EventCb = (entry: SequencedAgentEvent) => void; const EMPTY_EVENTS: readonly SequencedAgentEvent[] = []; @@ -31,14 +50,28 @@ export class EventBuffer { * monotone, or a seed's `uptoSeq` sampled before the stop swallows the resumed session's events. */ private readonly seqs = new Map(); /** Terminal prompt outcomes are immutable by request ID. Attach may replay them, but retaining - * the same outcome repeatedly would grow the live buffer without changing the projection. */ + * the same outcome repeatedly would grow the live buffer without changing the projection. + * Unstamped frames only: a stamped repeat is a distinct daemon position the projection merge + * must see to keep its sequence contiguous, and the builder folds it idempotently. */ private readonly resolvedRequestIds = new Map>(); /** Record an incoming event, assigning it the session's next receive sequence number. */ - ingest(sessionId: SessionId, event: AgentEvent): SequencedAgentEvent { + ingest( + sessionId: SessionId, + event: AgentEvent, + envelope: AgentEventEnvelope = {}, + ): SequencedAgentEvent { const seq = (this.seqs.get(sessionId) ?? 0) + 1; this.seqs.set(sessionId, seq); - const sequenced: SequencedAgentEvent = { event, seq, receivedAt: Date.now() }; + const sequenced: SequencedAgentEvent = { + event, + seq, + receivedAt: Date.now(), + ...(envelope.epoch !== undefined && + envelope.seq !== undefined && { position: { epoch: envelope.epoch, seq: envelope.seq } }), + ...(envelope.runId !== undefined && { runId: envelope.runId }), + ...(envelope.turnId !== undefined && { turnId: envelope.turnId }), + }; if (event.type === 'conversation-rewind') { // The reducer also rewinds, but the receive buffer must drop the suffix or a later reseed // can fold discarded live events back over the provider's replacement transcript. @@ -46,7 +79,10 @@ export class EventBuffer { this.snapshots.delete(sessionId); this.resolvedRequestIds.delete(sessionId); } - if (event.type === 'permission-resolved' || event.type === 'question-resolved') { + if ( + sequenced.position === undefined && + (event.type === 'permission-resolved' || event.type === 'question-resolved') + ) { let resolved = this.resolvedRequestIds.get(sessionId); if (!resolved) { resolved = new Set(); @@ -60,7 +96,7 @@ export class EventBuffer { else this.events.set(sessionId, [sequenced]); this.snapshots.delete(sessionId); const subs = this.subscribers.get(sessionId); - if (subs) for (const cb of subs) cb(sequenced.event, sequenced.seq); + if (subs) for (const cb of subs) cb(sequenced); return sequenced; } @@ -75,8 +111,7 @@ export class EventBuffer { const buf = this.events.get(sessionId); if (buf) { for (let i = 0, len = buf.length; i < len; i++) { - const { event, seq } = buf[i]; - cb(event, seq); + cb(buf[i]); } } return () => set.delete(cb); diff --git a/packages/client/core/src/client/pending-registry.ts b/packages/client/core/src/client/pending-registry.ts index 673fa935c..7b88e0cee 100644 --- a/packages/client/core/src/client/pending-registry.ts +++ b/packages/client/core/src/client/pending-registry.ts @@ -76,6 +76,18 @@ export interface PluginMutation { pendingAuthApps?: string[]; } +/** `conversation.graph.result` without its correlation fields. */ +export type ConversationGraphSnapshot = Omit< + Extract, + 'kind' | 'replyTo' +>; + +/** One `conversation.read.result` page without its correlation fields. */ +export type ConversationReadPage = Omit< + Extract, + 'kind' | 'replyTo' +>; + export type RandomUUID = () => string; export function resolveRandomUUID(provider?: RandomUUID): RandomUUID { @@ -99,6 +111,8 @@ export interface PendingValueMap { import: SessionRecord; historyList: AgentHistoryListResult; historyRead: AgentHistoryReadResult; + conversationGraph: ConversationGraphSnapshot; + conversationRead: ConversationReadPage; configGet: ProvidersConfig; accountsGet: Accounts; accountModels: AccountModel[]; @@ -161,6 +175,8 @@ export class PendingRegistry { import: new Map(), historyList: new Map(), historyRead: new Map(), + conversationGraph: new Map(), + conversationRead: new Map(), configGet: new Map(), accountsGet: new Map(), accountModels: new Map(), diff --git a/packages/client/core/src/conversation-read.ts b/packages/client/core/src/conversation-read.ts new file mode 100644 index 000000000..4351246d3 --- /dev/null +++ b/packages/client/core/src/conversation-read.ts @@ -0,0 +1,158 @@ +import type { + AgentHistoryId, + AgentKind, + ConversationReadItem, + ConversationWatermark, + SessionId, + TurnId, +} from '@linkcode/schema'; +import { isErrorLikeObject } from 'foxts/extract-error-message'; +import type { LinkCodeClient } from './client'; +import type { ConversationSeed, ConversationSeedEvent } from './conversation'; + +/** + * A point-in-time projection of one lineage, read from the daemon's turn graph: host user rows, + * attributed provider events, placeholders, and the live tail — every `conversation.read` page of + * one walk, taken as a single snapshot. + */ +export interface ConversationProjectionSeed { + items: ConversationReadItem[]; + graphRevision: number; + leafTurnId: TurnId; + /** The final page's `(epoch, seq)` cut: live events at or below it are already in `items`. + * Absent on a persisted seed, which then supersedes nothing. */ + watermark?: ConversationWatermark; +} + +export interface ReadConversationOptions { + /** Absent = the session's active leaf. */ + leafTurnId?: TurnId; +} + +/** Cursor pages one walk follows before giving up on a buggy cursor. */ +const MAX_PAGES = 50; +/** Walks restarted after the graph moved underneath one before giving up. */ +const MAX_RESTARTS = 3; + +class ProjectionDriftError extends Error { + override readonly name = 'ProjectionDriftError'; +} + +/** + * Walk `conversation.read` to its final page. Resolves undefined for a session with no turn graph + * yet — pre-existing sessions keep `history.read` as their path until the single-lineage + * migration. A `graphRevision` or leaf change between pages, or the daemon's typed `conflict`, + * restarts the walk: two projections are never spliced. + */ +export async function readConversationProjection( + client: LinkCodeClient, + sessionId: SessionId, + options: ReadConversationOptions = {}, +): Promise { + for (let attempt = 0; ; attempt += 1) { + try { + // eslint-disable-next-line no-await-in-loop -- a restart depends on the previous walk's outcome + return await walk(client, sessionId, options); + } catch (error) { + if (attempt >= MAX_RESTARTS || !isProjectionDrift(error)) throw error; + } + } +} + +async function walk( + client: LinkCodeClient, + sessionId: SessionId, + options: ReadConversationOptions, +): Promise { + const items: ConversationReadItem[] = []; + let graphRevision: number | undefined; + let leafTurnId: TurnId | undefined; + let cursor: string | undefined; + for (let page = 0; page < MAX_PAGES; page += 1) { + // eslint-disable-next-line no-await-in-loop -- cursor pagination: each page's cursor comes from the previous reply + const result = await client.readConversation(sessionId, { ...options, cursor }); + if (result.leafTurnId === undefined) return undefined; + graphRevision ??= result.graphRevision; + leafTurnId ??= result.leafTurnId; + if (result.graphRevision !== graphRevision || result.leafTurnId !== leafTurnId) { + throw new ProjectionDriftError(`conversation ${sessionId} changed while paging`); + } + for (let i = 0, len = result.events.length; i < len; i++) items.push(result.events[i]); + if (result.cursor === undefined) { + return { + items, + graphRevision, + leafTurnId, + ...(result.watermark !== undefined && { watermark: result.watermark }), + }; + } + cursor = result.cursor; + } + throw new Error(`conversation.read for ${sessionId} did not end within ${MAX_PAGES} pages`); +} + +function isProjectionDrift(error: unknown): boolean { + if (error instanceof ProjectionDriftError) return true; + return isErrorLikeObject(error) && 'code' in error && error.code === 'conflict'; +} + +/** What a seed read needs to know about the session; `historyId` gates the transcript fallback. */ +export interface ConversationSeedSource { + sessionId: SessionId; + agentKind: AgentKind; + cwd: string; + historyId?: AgentHistoryId; +} + +/** Transcript pages one history read follows before giving up on a buggy cursor. */ +const MAX_HISTORY_PAGES = 20; + +/** + * The seed a conversation store should fold for a session: the turn-graph projection when the + * host serves one for this session, else the provider transcript (≤v79 hosts, and sessions with no + * turn rows yet), else nothing — the store then runs live-only. Every client surface reads through + * here so the two paths and their fallback order live in one place. + */ +export async function readConversationSeed( + client: LinkCodeClient, + source: ConversationSeedSource, +): Promise { + if (client.supportsConversationGraph) { + const projection = await readConversationProjection(client, source.sessionId); + if (projection !== undefined) return projection; + } + if (source.historyId === undefined) return undefined; + return readHistorySeed(client, source.sessionId, source.agentKind, source.cwd, source.historyId); +} + +/** + * The provider transcript as a point-in-time snapshot: pages walked to the end, the first page + * bypassing the daemon's history cache so the snapshot is current. `uptoSeq` (the live receive + * counter sampled at resolve) marks the cut: live events ≤ it are in the snapshot. + */ +async function readHistorySeed( + client: LinkCodeClient, + sessionId: SessionId, + agentKind: AgentKind, + cwd: string, + historyId: AgentHistoryId, +): Promise { + const events: ConversationSeedEvent[] = []; + let cursor: string | undefined; + for (let page = 0; page < MAX_HISTORY_PAGES; page += 1) { + // eslint-disable-next-line no-await-in-loop -- cursor pagination: each page's cursor comes from the previous reply + const result = await client.readHistory(agentKind, { + historyId, + cwd, + cursor, + forceRefresh: page === 0, + }); + for (let i = 0, len = result.events.length; i < len; i++) { + const entry = result.events[i]; + events.push({ event: entry.event, ts: entry.ts }); + } + cursor = result.cursor; + if (cursor === undefined) break; + } + return { events, uptoSeq: client.eventSeq(sessionId) }; +} diff --git a/packages/client/core/src/conversation-store.ts b/packages/client/core/src/conversation-store.ts index 9929cb1d4..3b5eae240 100644 --- a/packages/client/core/src/conversation-store.ts +++ b/packages/client/core/src/conversation-store.ts @@ -1,9 +1,11 @@ -import type { AgentEvent, SessionId } from '@linkcode/schema'; +import type { AgentEvent, ContentBlock, ConversationWatermark, SessionId } from '@linkcode/schema'; +import { compareConversationWatermarks, userRowMessageId } from '@linkcode/schema'; import type { Unsubscribe } from '@linkcode/transport'; import { noop } from 'foxact/noop'; -import type { LinkCodeClient, SequencedAgentEvent } from './client'; +import type { ConversationGraphChange, LinkCodeClient, SequencedAgentEvent } from './client'; import type { Conversation, ConversationBuilder, ConversationSeed } from './conversation'; import { createConversationBuilder } from './conversation'; +import type { ConversationProjectionSeed } from './conversation-read'; /** A `useSyncExternalStore`-shaped incremental projection of one session's conversation. * Function-typed properties (not methods): both get detached and handed to React. */ @@ -12,6 +14,15 @@ export interface ConversationStore { getSnapshot: () => Conversation; } +/** Why a projection store wants its seed re-read: the live stream can no longer be trusted to + * extend the read it was folded onto. */ +export type ConversationResyncReason = 'epoch' | 'gap' | 'graph'; + +export interface ConversationStoreOptions { + /** Called at most once per store, never during a render, when the seed must be re-read. */ + onResync?: (reason: ConversationResyncReason) => void; +} + const EMPTY_CONVERSATION: Conversation = { items: [], status: null, @@ -29,6 +40,176 @@ const EMPTY_CONVERSATION: Conversation = { pendingQuestionIds: [], }; +/** + * Project a session's conversation from a seed plus the live event buffer. A projection seed (a + * `conversation.read` walk) merges by the daemon's `(epoch, seq)` positions; a history seed (a + * `history.read` transcript, the path for ≤v79 daemons and sessions without a turn graph) merges + * by the connection's receive cut. Either way 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. + */ +export function createConversationStore( + client: LinkCodeClient, + sessionId: SessionId | null, + seed?: ConversationSeed | ConversationProjectionSeed, + options: ConversationStoreOptions = {}, +): ConversationStore { + if (!sessionId) { + return { subscribe: () => noop, getSnapshot: () => EMPTY_CONVERSATION }; + } + if (seed !== undefined && 'items' in seed) { + return createProjectionStore(client, sessionId, seed, options.onResync ?? noop); + } + return createHistoryStore(client, sessionId, seed, options.onResync ?? noop); +} + +/** Kinds the projection merge never drops on the watermark: their authoritative state lives in + * the daemon's interaction registry, a read may predate them, and the builder folds repeats + * idempotently — so the backstop that keeps a permission card renderable costs nothing. */ +const INTERACTIVE_EVENT_TYPES = new Set([ + 'permission-request', + 'question-request', + 'permission-resolved', + 'question-resolved', + 'prompt-response-status', +]); + +/** + * The projection merge: the seed's items fold first, then every live event whose position is + * above the seed's watermark. Nothing is matched by content — the daemon mints one identity per + * user row for the echo and the read alike, so re-reads converge on the rows they already hold. + * A position that skips ahead (a sequence gap, an epoch jump) or a graph revision past the read + * asks the owner to re-read once; folding continues meanwhile so streaming never stalls. + */ +function createProjectionStore( + client: LinkCodeClient, + sessionId: SessionId, + seed: ConversationProjectionSeed, + onResync: (reason: ConversationResyncReason) => void, +): ConversationStore { + const builder = createConversationBuilder(); + const userMessageIds = new Set(); + let seeded = false; + /** Highest receive seq already examined. */ + let consumedSeq = 0; + /** The newest daemon position covered or folded; a persisted seed starts with none and the first + * stamped event becomes the baseline. */ + let cursor: ConversationWatermark | null = seed.watermark ?? null; + let resyncRequested = false; + + const requestResync = (reason: ConversationResyncReason): void => { + if (resyncRequested) return; + resyncRequested = true; + // Detection runs inside getSnapshot (a render); the owner's re-read must not. + queueMicrotask(() => onResync(reason)); + }; + + const fold = (event: AgentEvent, receivedAt: number | undefined): void => { + if (event.type === 'user-message') userMessageIds.add(event.messageId); + builder.advance(event, receivedAt); + }; + + const foldSeed = (): void => { + const echoes = attachmentBearingEchoes(client.eventsSnapshot(sessionId)); + for (let i = 0, len = seed.items.length; i < len; i++) { + const item = seed.items[i]; + if (!('event' in item)) { + builder.unavailable(); + continue; + } + const { event } = item; + if (event.type !== 'user-message') { + fold(event, item.ts); + continue; + } + const content = echoes.get(event.messageId); + fold(content === undefined ? event : { ...event, content }, item.ts); + } + }; + + /** Whether a live entry extends the seed; advances the cursor and flags gaps and jumps. */ + const admit = (entry: SequencedAgentEvent): boolean => { + const { position } = entry; + if (position === undefined) return true; + if (cursor !== null) { + // Covered by the read, or an older epoch's straggler: gone either way. + if (compareConversationWatermarks(position, cursor) <= 0) { + return INTERACTIVE_EVENT_TYPES.has(entry.event.type); + } + if (position.epoch !== cursor.epoch) requestResync('epoch'); + else if (position.seq !== cursor.seq + 1) requestResync('gap'); + } + cursor = position; + return true; + }; + + const sync = (): void => { + if (!seeded) { + seeded = true; + foldSeed(); + } + if (client.eventSeq(sessionId) <= consumedSeq) return; + const events = client.eventsSnapshot(sessionId); + for (let i = firstIndexAfter(events, consumedSeq), len = events.length; i < len; i += 1) { + const entry = events[i]; + if (admit(entry)) fold(entry.event, entry.receivedAt); + } + consumedSeq = client.eventSeq(sessionId); + }; + + /** A revision past this read means a lineage moved. A plain continuation is already covered + * live — its new leaf's own user row has arrived — so only a leaf this store has never seen + * (an edit or rewrite from any device, a stale read) needs the re-read. */ + const checkGraph = (change: ConversationGraphChange | undefined): void => { + if (change === undefined || change.graphRevision <= seed.graphRevision) return; + if ( + change.activeLeafTurnId !== undefined && + userMessageIds.has(userRowMessageId(change.activeLeafTurnId)) + ) { + return; + } + requestResync('graph'); + }; + + return { + subscribe(onStoreChange) { + sync(); + checkGraph(client.latestGraphChange(sessionId)); + const unsubscribeEvents = client.subscribe(sessionId, () => { + sync(); + onStoreChange(); + }); + const unsubscribeGraph = client.subscribeGraphChanges(sessionId, (change) => { + sync(); + checkGraph(change); + }); + return () => { + unsubscribeEvents(); + unsubscribeGraph(); + }; + }, + getSnapshot() { + sync(); + return builder.snapshot(); + }, + }; +} + +/** Live user echoes carry the prompt's attachment blocks while durable rows are text-only until + * attachment refs land: for the row sharing an echo's identity, the echo's content wins. */ +function attachmentBearingEchoes( + events: readonly SequencedAgentEvent[], +): Map { + const byId = new Map(); + for (let i = 0, len = events.length; i < len; i++) { + const { event } = events[i]; + if (event.type === 'user-message' && event.content.some((block) => block.type !== 'text')) { + byId.set(event.messageId, event.content); + } + } + return byId; +} + type UserMessageEvent = Extract; interface SeedUserMessageQueue { messages: UserMessageEvent[]; @@ -100,21 +281,17 @@ function foldPreCutEvent( } /** - * 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 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. + * The transcript merge for hosts without a turn graph: the seed folds once, then `getSnapshot` + * lazily advances by unconsumed events, skipping events inside the `uptoSeq` cut that the snapshot + * verifiably covers (see {@link foldPreCutEvent}). Provider and host ids never converge here, so + * user rows are matched by content — the path retires with the compatibility floor. */ -export function createConversationStore( +function createHistoryStore( client: LinkCodeClient, - sessionId: SessionId | null, - seed?: ConversationSeed, + sessionId: SessionId, + seed: ConversationSeed | undefined, + onResync: (reason: ConversationResyncReason) => void, ): ConversationStore { - if (!sessionId) { - return { subscribe: () => noop, getSnapshot: () => EMPTY_CONVERSATION }; - } - const builder = createConversationBuilder(); const uptoSeq = seed?.uptoSeq ?? 0; // Identities the snapshot actually holds, for the per-event coverage check of the cut. @@ -152,6 +329,19 @@ export function createConversationStore( let seeded = false; /** Highest receive seq already examined (not necessarily folded — covered ones may be cut). */ let consumedSeq = 0; + let resyncRequested = false; + + const requestResync = (): void => { + if (resyncRequested) return; + resyncRequested = true; + queueMicrotask(() => onResync('graph')); + }; + + const noteGraph = (change: ConversationGraphChange | undefined): void => { + // A leaf appearing after an empty-graph / live-only read is the cutover: the owner must + // re-read so the next store is a projection. History-path sessions never see this. + if (change?.activeLeafTurnId !== undefined) requestResync(); + }; const sync = (): void => { if (!seeded) { @@ -179,7 +369,17 @@ export function createConversationStore( }; return { - subscribe: (onStoreChange) => client.subscribe(sessionId, onStoreChange), + subscribe(onStoreChange) { + noteGraph(client.latestGraphChange(sessionId)); + const unsubscribeEvents = client.subscribe(sessionId, onStoreChange); + const unsubscribeGraph = client.subscribeGraphChanges(sessionId, (change) => { + noteGraph(change); + }); + return () => { + unsubscribeEvents(); + unsubscribeGraph(); + }; + }, getSnapshot() { sync(); return builder.snapshot(); diff --git a/packages/client/core/src/conversation.ts b/packages/client/core/src/conversation.ts index 33fc4373a..e9136a9a1 100644 --- a/packages/client/core/src/conversation.ts +++ b/packages/client/core/src/conversation.ts @@ -80,6 +80,13 @@ export type ConversationItem = ( postTokens?: number; summary?: string; } + | { + /** The daemon could not project this turn's provider output (no-history harness, lost or + * compacted transcript, migrated turn): the host prompt row is all there is. */ + kind: 'history-unavailable'; + id: string; + turnId: ConversationTurnId; + } | { kind: 'plan'; id: string; @@ -186,31 +193,47 @@ export interface ConversationBuilder { * this event touches: the client receive time for live events, the provider's own event * timestamp for history-read replays (omitted when the provider recorded none). */ advance(event: AgentEvent, receivedAt?: number): void; + /** Mark the current turn's provider output as unavailable — a read's `history-unavailable` + * placeholder, rendered where that output would have been. */ + unavailable(receivedAt?: number): void; /** The current view-model. Cached between advances; every changed item is a fresh object * (copy-on-write), so React memoization over items keeps working across snapshots. */ snapshot(): Conversation; } +type ProjectionInput = + | { readonly kind: 'event'; readonly event: AgentEvent; readonly receivedAt?: number } + | { readonly kind: 'unavailable'; readonly receivedAt?: number }; + /** * Incremental form of {@link buildConversation}: advanced one event at a time (O(delta), not a full * re-reduce). Item updates are copy-on-write — previously returned snapshots are never mutated. */ export function createConversationBuilder(): ConversationBuilder { let projection = createConversationProjection(); - let entries: Array<{ event: AgentEvent; receivedAt?: number }> = []; + let entries: ProjectionInput[] = []; + + const replay = (input: ProjectionInput): void => { + if (input.kind === 'event') projection.advance(input.event, input.receivedAt); + else projection.unavailable(input.receivedAt); + }; return { advance(event, receivedAt) { if (event.type !== 'conversation-rewind') { - entries.push({ event, receivedAt }); + entries.push({ kind: 'event', event, receivedAt }); projection.advance(event, receivedAt); return; } let cut = -1; for (let index = entries.length - 1; index >= 0; index -= 1) { - const candidate = entries[index].event; - if (candidate.type === 'user-message' && candidate.messageId === event.messageId) { + const candidate = entries[index]; + if ( + candidate.kind === 'event' && + candidate.event.type === 'user-message' && + candidate.event.messageId === event.messageId + ) { cut = index; break; } @@ -218,10 +241,11 @@ export function createConversationBuilder(): ConversationBuilder { if (cut < 0) return; entries = entries.slice(0, cut); projection = createConversationProjection(); - for (let i = 0, len = entries.length; i < len; i++) { - const entry = entries[i]; - projection.advance(entry.event, entry.receivedAt); - } + for (let i = 0, len = entries.length; i < len; i++) replay(entries[i]); + }, + unavailable(receivedAt) { + entries.push({ kind: 'unavailable', receivedAt }); + projection.unavailable(receivedAt); }, snapshot: () => projection.snapshot(), }; @@ -703,6 +727,17 @@ function createConversationProjection(): ConversationBuilder { } }; + const unavailable = (receivedAt?: number): void => { + cached = null; + endActiveReasoning(undefined, receivedAt); + items.push({ + kind: 'history-unavailable', + id: genId('unavailable'), + turnId: currentTurnId, + receivedAt, + }); + }; + const snapshot = (): Conversation => { if (cached) return cached; @@ -738,7 +773,7 @@ function createConversationProjection(): ConversationBuilder { return cached; }; - return { advance, snapshot }; + return { advance, unavailable, snapshot }; } /** Build a structured Conversation from the flat, append-only agent event stream. Pure & deterministic. */ diff --git a/packages/client/core/src/index.ts b/packages/client/core/src/index.ts index 0c88d2512..0e68045ee 100644 --- a/packages/client/core/src/index.ts +++ b/packages/client/core/src/index.ts @@ -5,5 +5,6 @@ export * from './client'; export * from './connection-controller'; export * from './conversation'; +export * from './conversation-read'; export * from './conversation-store'; export * from './react'; diff --git a/packages/client/core/src/react.tsx b/packages/client/core/src/react.tsx index 76cce9669..bad22447c 100644 --- a/packages/client/core/src/react.tsx +++ b/packages/client/core/src/react.tsx @@ -8,6 +8,7 @@ import type { import { noop } from 'foxact/noop'; import { nullthrow } from 'foxact/nullthrow'; import { useEffect } from 'foxact/use-abortable-effect'; +import { useStableHandler } from 'foxact/use-stable-handler-only-when-you-know-what-you-are-doing-or-you-will-be-fired'; import { createContext, useCallback, @@ -19,6 +20,8 @@ import { } from 'react'; import type { LinkCodeClient, SequencedAgentEvent } from './client'; import type { Conversation, ConversationSeed } from './conversation'; +import type { ConversationProjectionSeed } from './conversation-read'; +import type { ConversationResyncReason } from './conversation-store'; import { createConversationStore } from './conversation-store'; const ClientContext = createContext(null); @@ -99,17 +102,20 @@ export function useSendInput(sessionId: SessionId | null): (input: AgentInput) = /** * Subscribe to a session's structured conversation view-model, optionally seeded (see - * `ConversationSeed`). Folds are O(delta) and unchanged items keep their identity, so memoized - * message components skip re-rendering during streaming. + * `ConversationSeed` / `ConversationProjectionSeed`). Folds are O(delta) and unchanged items keep + * their identity, so memoized message components skip re-rendering during streaming. A projection + * seed reports through `onResync` when it must be re-read (see `ConversationStoreOptions`). */ export function useConversation( sessionId: SessionId | null, - seed?: ConversationSeed, + seed?: ConversationSeed | ConversationProjectionSeed, + onResync?: (reason: ConversationResyncReason) => void, ): Conversation { const client = useLinkCodeClient(); + const handleResync = useStableHandler(onResync ?? noop); const store = useMemo( - () => createConversationStore(client, sessionId, seed), - [client, sessionId, seed], + () => createConversationStore(client, sessionId, seed, { onResync: handleResync }), + [client, sessionId, seed, handleResync], ); return useSyncExternalStore(store.subscribe, store.getSnapshot); } diff --git a/packages/client/core/tests/integration/control-client.test.ts b/packages/client/core/tests/integration/control-client.test.ts index 13eceba59..3c96054c5 100644 --- a/packages/client/core/tests/integration/control-client.test.ts +++ b/packages/client/core/tests/integration/control-client.test.ts @@ -309,7 +309,7 @@ describe('LinkCodeClient event buffer', () => { // A late subscriber replays the buffer with the original seqs, not renumbered ones. const seen: Array> = []; - client.subscribe(sessionId, (event, seq) => seen.push({ event, seq })); + client.subscribe(sessionId, ({ event, seq }) => seen.push({ event, seq })); expect(seen).toEqual([ { event: first, seq: 1 }, { event: second, seq: 2 }, @@ -361,7 +361,7 @@ describe('LinkCodeClient event buffer', () => { // Were the counter reset with the buffer, a pre-stop uptoSeq would swallow this event. expect(client.eventSeq(sessionId)).toBe(2); const seen: Array> = []; - client.subscribe(sessionId, (e, seq) => seen.push({ event: e, seq })); + client.subscribe(sessionId, ({ event: e, seq }) => seen.push({ event: e, seq })); expect(seen).toEqual([{ event, seq: 2 }]); client.dispose(); diff --git a/packages/client/core/tests/integration/conversation-client.test.ts b/packages/client/core/tests/integration/conversation-client.test.ts new file mode 100644 index 000000000..84890f987 --- /dev/null +++ b/packages/client/core/tests/integration/conversation-client.test.ts @@ -0,0 +1,141 @@ +import type { RunId, SessionId, TurnId } from '@linkcode/schema'; +import { WIRE_PROTOCOL_VERSION } from '@linkcode/schema'; +import { createLocalTransportPair, createWireMessage } from '@linkcode/transport'; +import { wait } from 'foxts/wait'; +import { describe, expect, it } from 'vitest'; +import type { SequencedAgentEvent } from '../../src/client'; +import { LinkCodeClient } from '../../src/client'; +import { createConnectedLocalClient } from '../support/local-client'; + +const sessionId = 'sess-conv' as SessionId; +const leafTurnId = 'turn-leaf' as TurnId; + +describe('LinkCodeClient conversation graph API', () => { + it('advertises the graph path only for hosts at or above its wire version', async () => { + const current = await createConnectedLocalClient(); + expect(current.client.supportsConversationGraph).toBe(true); + current.client.dispose(); + current.serverTransport.close(); + + const [clientTransport, serverTransport] = createLocalTransportPair(); + await serverTransport.connect(); + serverTransport.onMessage((message) => { + if (message.payload.kind === 'ping') { + serverTransport.send( + createWireMessage({ + kind: 'pong', + version: WIRE_PROTOCOL_VERSION - 1, + minCompatible: WIRE_PROTOCOL_VERSION - 4, + }), + ); + } + }); + const older = new LinkCodeClient(clientTransport); + await older.connect(); + expect(older.supportsConversationGraph).toBe(false); + older.dispose(); + serverTransport.close(); + }); + + it('keeps the daemon position and attribution on buffered events', async () => { + const { client, serverTransport } = await createConnectedLocalClient(); + const seen: SequencedAgentEvent[] = []; + client.subscribe(sessionId, (entry) => seen.push(entry)); + + serverTransport.send( + createWireMessage({ + kind: 'agent.event', + sessionId, + runId: 'run-1' as RunId, + turnId: 'turn-1' as TurnId, + epoch: 4, + seq: 7, + event: { type: 'status', status: 'running' }, + }), + ); + serverTransport.send( + createWireMessage({ + kind: 'agent.event', + sessionId, + event: { type: 'status', status: 'idle' }, + }), + ); + await wait(10); + + expect(client.eventsSnapshot(sessionId)).toMatchObject([ + { seq: 1, position: { epoch: 4, seq: 7 }, runId: 'run-1', turnId: 'turn-1' }, + { seq: 2 }, + ]); + // An unstamped frame (≤v79 host) carries no position at all. + expect(client.eventsSnapshot(sessionId)[1]).not.toHaveProperty('position'); + expect(seen.map((entry) => entry.position)).toEqual([{ epoch: 4, seq: 7 }, undefined]); + + client.dispose(); + serverTransport.close(); + }); + + it('registers the newest graph change per session and ignores stale announcements', async () => { + const { client, serverTransport } = await createConnectedLocalClient(); + const seen: number[] = []; + client.subscribeGraphChanges(sessionId, (change) => seen.push(change.graphRevision)); + + serverTransport.send( + createWireMessage({ + kind: 'conversation.graph.changed', + sessionId, + graphRevision: 2, + activeLeafTurnId: leafTurnId, + }), + ); + serverTransport.send( + createWireMessage({ kind: 'conversation.graph.changed', sessionId, graphRevision: 1 }), + ); + await wait(10); + + expect(client.latestGraphChange(sessionId)).toEqual({ + graphRevision: 2, + activeLeafTurnId: leafTurnId, + }); + expect(seen).toEqual([2]); + + client.dispose(); + serverTransport.close(); + }); + + it('resolves the turn tree', async () => { + const { client, serverTransport } = await createConnectedLocalClient(); + serverTransport.onMessage((msg) => { + const p = msg.payload; + if (p.kind !== 'conversation.graph.get') return; + serverTransport.send( + createWireMessage({ + kind: 'conversation.graph.result', + replyTo: p.clientReqId, + sessionId: p.sessionId, + graphRevision: 1, + activeLeafTurnId: leafTurnId, + turns: [ + { + turnId: leafTurnId, + sessionId, + parentTurnId: null, + siblingOrdinal: 1, + input: { type: 'shell-command', command: 'ls' }, + runId: 'run-1' as RunId, + state: 'completed', + createdAt: 1, + inputSummary: '$ ls', + }, + ], + }), + ); + }); + + const graph = await client.getConversationGraph(sessionId); + expect(graph.activeLeafTurnId).toBe(leafTurnId); + expect(graph.turns).toHaveLength(1); + + client.dispose(); + serverTransport.close(); + }); +}); diff --git a/packages/client/core/tests/integration/conversation-read.test.ts b/packages/client/core/tests/integration/conversation-read.test.ts new file mode 100644 index 000000000..9f30af869 --- /dev/null +++ b/packages/client/core/tests/integration/conversation-read.test.ts @@ -0,0 +1,158 @@ +import type { + ConversationReadItem, + MessageId, + SessionId, + TurnId, + WirePayload, +} from '@linkcode/schema'; +import { createWireMessage } from '@linkcode/transport'; +import { describe, expect, it } from 'vitest'; +import type { ConversationReadPage } from '../../src/client'; +import { readConversationProjection } from '../../src/conversation-read'; +import { createConnectedLocalClient } from '../support/local-client'; + +const sessionId = 'sess-read' as SessionId; +const leafTurnId = 'turn-leaf' as TurnId; + +type ReadRequest = Extract; + +function userRow(turnId: string, text: string): ConversationReadItem { + return { + turnId: turnId as TurnId, + event: { + type: 'user-message', + messageId: `msg-${turnId}` as MessageId, + content: [{ type: 'text', text }], + }, + }; +} + +function page( + items: ConversationReadItem[], + extra: Partial> & { + watermark?: ConversationReadPage['watermark']; + } = {}, +): ConversationReadPage { + return { + sessionId, + graphRevision: extra.graphRevision ?? 3, + ...('leafTurnId' in extra ? { leafTurnId: extra.leafTurnId } : { leafTurnId }), + ...(extra.watermark !== undefined && { watermark: extra.watermark }), + events: items, + ...(extra.cursor !== undefined && { cursor: extra.cursor }), + }; +} + +/** A daemon double that answers every `conversation.read` from `script(request, call)`. */ +async function readingHarness( + script: (request: ReadRequest, call: number) => ConversationReadPage | { conflict: true }, +) { + const { client, serverTransport } = await createConnectedLocalClient(); + const requests: ReadRequest[] = []; + serverTransport.onMessage((msg) => { + const p = msg.payload; + if (p.kind !== 'conversation.read') return; + requests.push(p); + const answer = script(p, requests.length); + serverTransport.send( + createWireMessage( + 'conflict' in answer + ? { + kind: 'request.failed', + replyTo: p.clientReqId, + code: 'conflict', + message: 'The conversation changed while paging; restart the read', + } + : { kind: 'conversation.read.result', replyTo: p.clientReqId, ...answer }, + ), + ); + }); + return { + client, + requests, + close(this: void) { + client.dispose(); + serverTransport.close(); + }, + }; +} + +describe('readConversationProjection', () => { + it('walks every page into one seed carrying only the final page’s watermark', async () => { + const { client, requests, close } = await readingHarness((request) => + request.cursor === undefined + ? page([userRow('turn-1', 'one')], { cursor: 'c1' }) + : page([userRow('turn-leaf', 'two')], { watermark: { epoch: 2, seq: 9 } }), + ); + + const seed = await readConversationProjection(client, sessionId); + + expect(seed).toEqual({ + items: [userRow('turn-1', 'one'), userRow('turn-leaf', 'two')], + graphRevision: 3, + leafTurnId, + watermark: { epoch: 2, seq: 9 }, + }); + expect(requests.map((request) => request.cursor)).toEqual([undefined, 'c1']); + close(); + }); + + it('restarts the walk when the graph revision drifts between pages', async () => { + const { client, requests, close } = await readingHarness((request, call) => { + if (request.cursor === undefined) { + return page([userRow('turn-1', 'one')], { + graphRevision: call === 1 ? 3 : 4, + cursor: 'c1', + }); + } + return page([userRow('turn-leaf', 'two')], { + graphRevision: 4, + watermark: { epoch: 2, seq: 9 }, + }); + }); + + const seed = await readConversationProjection(client, sessionId); + + // Page two moved to revision 4 under the first walk; the second walk is consistent at 4. + expect(seed?.graphRevision).toBe(4); + expect(seed?.items).toHaveLength(2); + expect(requests).toHaveLength(4); + close(); + }); + + it('restarts on the daemon’s typed conflict', async () => { + const { client, requests, close } = await readingHarness((request, call) => { + if (request.cursor === undefined) return page([userRow('turn-1', 'one')], { cursor: 'c1' }); + if (call === 2) return { conflict: true }; + return page([userRow('turn-leaf', 'two')], { watermark: { epoch: 2, seq: 9 } }); + }); + + const seed = await readConversationProjection(client, sessionId); + + expect(seed?.items).toHaveLength(2); + expect(requests).toHaveLength(4); + close(); + }); + + it('gives up after the graph keeps moving', async () => { + const { client, close } = await readingHarness((request, call) => + request.cursor === undefined + ? page([userRow('turn-1', 'one')], { graphRevision: call, cursor: 'c1' }) + : page([userRow('turn-leaf', 'two')], { graphRevision: call + 100 }), + ); + + await expect(readConversationProjection(client, sessionId)).rejects.toThrow( + 'changed while paging', + ); + close(); + }); + + it('resolves undefined for a session without a turn graph', async () => { + const { client, close } = await readingHarness(() => + page([], { leafTurnId: undefined, graphRevision: 0, watermark: { epoch: 5, seq: 0 } }), + ); + + await expect(readConversationProjection(client, sessionId)).resolves.toBeUndefined(); + close(); + }); +}); diff --git a/packages/client/core/tests/integration/conversation-store-projection.test.ts b/packages/client/core/tests/integration/conversation-store-projection.test.ts new file mode 100644 index 000000000..862001a41 --- /dev/null +++ b/packages/client/core/tests/integration/conversation-store-projection.test.ts @@ -0,0 +1,337 @@ +import type { + AgentEvent, + ContentBlock, + ConversationReadItem, + ConversationWatermark, + MessageId, + SessionId, + TurnId, +} from '@linkcode/schema'; +import { userRowMessageId } from '@linkcode/schema'; +import { createWireMessage } from '@linkcode/transport'; +import { noop } from 'foxact/noop'; +import { wait } from 'foxts/wait'; +import { describe, expect, it } from 'vitest'; +import type { ConversationProjectionSeed } from '../../src/conversation-read'; +import type { ConversationResyncReason } from '../../src/conversation-store'; +import { createConversationStore } from '../../src/conversation-store'; +import { createConnectedLocalClient } from '../support/local-client'; + +const sessionId = 'sess-projection' as SessionId; +const turn = (n: number): TurnId => `turn-${n}` as TurnId; +const IMAGE: ContentBlock = { type: 'image', data: 'cG5n', mimeType: 'image/png' }; + +function echo(n: number, text: string, extra: Partial = {}): AgentEvent { + return { + type: 'user-message', + messageId: userRowMessageId(turn(n)), + content: [{ type: 'text', text }], + ...extra, + } as AgentEvent; +} + +function chunk(messageId: string, text: string): AgentEvent { + return { + type: 'agent-message-chunk', + messageId: messageId as MessageId, + content: { type: 'text', text }, + }; +} + +function userRow(n: number, text: string): ConversationReadItem { + return { turnId: turn(n), ts: 1_700_000_000_000 + n, event: echo(n, text) }; +} + +function tailItem(event: AgentEvent, position: ConversationWatermark): ConversationReadItem { + return { ...position, event }; +} + +function seedOf( + items: ConversationReadItem[], + watermark?: ConversationWatermark, + graphRevision = 1, +): ConversationProjectionSeed { + return { + items, + graphRevision, + leafTurnId: turn(99), + ...(watermark !== undefined && { watermark }), + }; +} + +function texts(store: ReturnType): string[] { + return store.getSnapshot().items.flatMap((item) => { + if (item.kind !== 'message') return [item.kind]; + return item.blocks.flatMap((block) => (block.type === 'text' ? [block.text] : [])); + }); +} + +async function harness() { + const { client, serverTransport } = await createConnectedLocalClient(); + const resyncs: ConversationResyncReason[] = []; + return { + client, + resyncs, + send(this: void, event: AgentEvent, position?: ConversationWatermark) { + serverTransport.send( + createWireMessage({ kind: 'agent.event', sessionId, ...position, event }), + ); + }, + graphChanged(this: void, graphRevision: number, activeLeafTurnId: TurnId) { + serverTransport.send( + createWireMessage({ + kind: 'conversation.graph.changed', + sessionId, + graphRevision, + activeLeafTurnId, + }), + ); + }, + store(this: void, seed: ConversationProjectionSeed) { + return createConversationStore(client, sessionId, seed, { + onResync: (reason) => resyncs.push(reason), + }); + }, + close(this: void) { + client.dispose(); + serverTransport.close(); + }, + }; +} + +const tick = (): Promise => wait(10); + +describe('projection conversation store', () => { + it('folds the read and drops the live events it already covers', async () => { + const h = await harness(); + h.send(echo(1, 'hello'), { epoch: 1, seq: 1 }); + h.send(chunk('a1', 'Hello'), { epoch: 1, seq: 2 }); + await tick(); + + const store = h.store( + seedOf([userRow(1, 'hello'), tailItem(chunk('a1', 'Hello'), { epoch: 1, seq: 2 })], { + epoch: 1, + seq: 2, + }), + ); + expect(texts(store)).toEqual(['hello', 'Hello']); + const seeded = store.getSnapshot(); + expect(store.getSnapshot()).toBe(seeded); + + h.send(chunk('a1', ' world'), { epoch: 1, seq: 3 }); + await tick(); + expect(texts(store)).toEqual(['hello', 'Hello world']); + // Previously returned snapshots are never mutated. + expect(seeded.items).toHaveLength(2); + expect(h.resyncs).toEqual([]); + h.close(); + }); + + it('keeps interactive events below the watermark (the permission-card backstop)', async () => { + const h = await harness(); + h.send( + { + type: 'permission-request', + requestId: 'p1', + toolCall: { toolCallId: 't1', title: 'Bash' }, + options: [{ optionId: 'allow', name: 'Allow', kind: 'allow_once' }], + }, + { epoch: 1, seq: 3 }, + ); + await tick(); + + const store = h.store(seedOf([userRow(1, 'run it')], { epoch: 1, seq: 4 })); + expect(store.getSnapshot().pendingPermissionIds).toEqual(['p1']); + h.close(); + }); + + it('folds unstamped frames regardless of the watermark', async () => { + const h = await harness(); + h.send({ type: 'status', status: 'running' }); + await tick(); + + const store = h.store(seedOf([userRow(1, 'hi')], { epoch: 1, seq: 9 })); + expect(store.getSnapshot().status).toBe('running'); + await tick(); + expect(h.resyncs).toEqual([]); + h.close(); + }); + + it('drops an older epoch’s straggler and asks for one re-read on an epoch jump', async () => { + const h = await harness(); + const store = h.store(seedOf([userRow(1, 'first')], { epoch: 2, seq: 0 })); + h.send(chunk('stale', 'from the replaced adapter'), { epoch: 1, seq: 9 }); + h.send(echo(2, 'second'), { epoch: 2, seq: 1 }); + await tick(); + expect(texts(store)).toEqual(['first', 'second']); + expect(h.resyncs).toEqual([]); + + h.send(echo(3, 'after relaunch'), { epoch: 3, seq: 1 }); + h.send(chunk('a3', 'reply'), { epoch: 3, seq: 2 }); + await tick(); + // Folding continues while the owner re-reads, and the request fires once. + expect(texts(store)).toEqual(['first', 'second', 'after relaunch', 'reply']); + await tick(); + expect(h.resyncs).toEqual(['epoch']); + h.close(); + }); + + it('asks for a re-read on a sequence gap', async () => { + const h = await harness(); + const store = h.store(seedOf([userRow(1, 'first')], { epoch: 1, seq: 3 })); + h.send(chunk('a1', 'seen'), { epoch: 1, seq: 5 }); + await tick(); + expect(texts(store)).toEqual(['first', 'seen']); + await tick(); + expect(h.resyncs).toEqual(['gap']); + h.close(); + }); + + it('asks for a re-read when the graph moved past the read to a leaf it never saw', async () => { + const h = await harness(); + const store = h.store(seedOf([userRow(1, 'first')], { epoch: 1, seq: 1 }, 3)); + store.subscribe(noop); + h.graphChanged(4, turn(9)); + await tick(); + expect(h.resyncs).toEqual(['graph']); + h.close(); + }); + + it('asks a live-only store to re-read when a leaf appears', async () => { + const h = await harness(); + const store = createConversationStore(h.client, sessionId, undefined, { + onResync: (reason) => h.resyncs.push(reason), + }); + store.subscribe(noop); + h.graphChanged(1, turn(1)); + await tick(); + expect(h.resyncs).toEqual(['graph']); + h.close(); + }); + + it('treats a graph move onto a leaf whose row arrived live as a plain continuation', async () => { + const h = await harness(); + const store = h.store(seedOf([userRow(1, 'first')], { epoch: 1, seq: 1 }, 3)); + store.subscribe(noop); + h.send(echo(9, 'second'), { epoch: 1, seq: 2 }); + h.graphChanged(4, turn(9)); + await tick(); + expect(texts(store)).toEqual(['first', 'second']); + expect(h.resyncs).toEqual([]); + h.close(); + }); + + it('renders history-unavailable placeholders under their turn', async () => { + const h = await harness(); + const store = h.store( + seedOf( + [ + userRow(1, 'lost'), + { type: 'history-unavailable', turnId: turn(1) }, + userRow(2, 'kept'), + tailItem(chunk('a2', 'answer'), { epoch: 1, seq: 1 }), + ], + { epoch: 1, seq: 1 }, + ), + ); + const { items } = store.getSnapshot(); + expect(items.map((item) => item.kind)).toEqual([ + 'message', + 'history-unavailable', + 'message', + 'message', + ]); + expect(items[1].turnId).toBe(items[0].turnId); + expect(items[3].turnId).toBe(items[2].turnId); + h.close(); + }); + + it('takes the attachment blocks of the live echo that shares a row’s identity', async () => { + const h = await harness(); + h.send( + echo(1, 'describe this', { content: [{ type: 'text', text: 'describe this' }, IMAGE] }), + { + epoch: 1, + seq: 1, + }, + ); + await tick(); + + const store = h.store(seedOf([userRow(1, 'describe this')], { epoch: 1, seq: 1 })); + const [row] = store.getSnapshot().items; + expect(row.kind === 'message' && row.blocks).toEqual([ + { type: 'text', text: 'describe this' }, + IMAGE, + ]); + h.close(); + }); + + it('gives repeated echoes and re-reads one row per turn, in order', async () => { + const h = await harness(); + // The engine's double echo: bare first, then again with the cursor once the history binds. + h.send(echo(1, 'old'), { epoch: 1, seq: 1 }); + h.send(echo(1, 'old', { branchCursor: 'cursor-1' }), { epoch: 1, seq: 2 }); + h.send(echo(2, 'new'), { epoch: 1, seq: 3 }); + await tick(); + + const seed = seedOf([userRow(1, 'old'), userRow(2, 'new')], { epoch: 1, seq: 3 }); + expect(texts(h.store(seed))).toEqual(['old', 'new']); + // A reseed (focus, reconnect) folds the same rows again — never ['old', 'new', 'old']. + expect(texts(h.store(seed))).toEqual(['old', 'new']); + h.close(); + }); + + it('keeps duplicate-text prompts as distinct, stable rows across a reseed', async () => { + const h = await harness(); + const seed = seedOf([userRow(1, 'repeat'), userRow(2, 'repeat')], { epoch: 1, seq: 0 }); + const ids = (store: ReturnType): string[] => + store.getSnapshot().items.map((item) => item.id); + expect(ids(h.store(seed))).toEqual([userRowMessageId(turn(1)), userRowMessageId(turn(2))]); + expect(ids(h.store(seed))).toEqual([userRowMessageId(turn(1)), userRowMessageId(turn(2))]); + h.close(); + }); + + it('lets a persisted seed supersede nothing while the first stamped event sets the baseline', async () => { + const h = await harness(); + h.send(echo(1, 'hello'), { epoch: 1, seq: 1 }); + h.send(chunk('a1', 'reply'), { epoch: 1, seq: 2 }); + await tick(); + + // Loaded from the seed cache: same rows, no watermark — the buffered echo folds onto its row. + const store = h.store(seedOf([userRow(1, 'hello')])); + expect(texts(store)).toEqual(['hello', 'reply']); + h.send(chunk('a1', ' more'), { epoch: 1, seq: 4 }); + await tick(); + expect(texts(store)).toEqual(['hello', 'reply more']); + await tick(); + expect(h.resyncs).toEqual(['gap']); + h.close(); + }); + + it('keeps an in-flight stream whole across a mid-turn reseed', async () => { + const h = await harness(); + h.send(echo(1, 'tell a story'), { epoch: 1, seq: 1 }); + h.send(chunk('m1', 'Once '), { epoch: 1, seq: 2 }); + h.send(chunk('m1', 'upon '), { epoch: 1, seq: 3 }); + await tick(); + const before = h.store(seedOf([userRow(1, 'tell a story')], { epoch: 1, seq: 1 })); + expect(texts(before)).toEqual(['tell a story', 'Once upon ']); + + // The reseed's tail holds the chunks flushed so far; the next one arrives live. + const reseeded = h.store( + seedOf( + [ + userRow(1, 'tell a story'), + tailItem(chunk('m1', 'Once '), { epoch: 1, seq: 2 }), + tailItem(chunk('m1', 'upon '), { epoch: 1, seq: 3 }), + ], + { epoch: 1, seq: 3 }, + ), + ); + h.send(chunk('m1', 'a time.'), { epoch: 1, seq: 4 }); + await tick(); + expect(texts(reseeded)).toEqual(['tell a story', 'Once upon a time.']); + expect(h.resyncs).toEqual([]); + h.close(); + }); +}); diff --git a/packages/client/workbench/AGENTS.md b/packages/client/workbench/AGENTS.md index 6077282d6..ab0bad87b 100644 --- a/packages/client/workbench/AGENTS.md +++ b/packages/client/workbench/AGENTS.md @@ -31,7 +31,11 @@ app-specific entries (`apps/desktop`, `apps/webview`) and pure presentation (`pa endpoint, starts a fresh cache after endpoint migration, and revalidates once after a generation becomes protocol-ready; it does not own connection state. - `surface/` — the workbench feature surface: the `Workbench` component, the `WorkbenchShell*` - contract plus the default shell, and session orchestration hooks. + contract plus the default shell, and session orchestration hooks. `use-seeded-conversation.ts` + seeds the active thread through client-core's `readConversationSeed` (the turn-graph projection + where the host serves one, the provider transcript otherwise — rules in + `packages/client/core/AGENTS.md`), answers a store's resync request with SWR `mutate()`, and + persists both seed shapes through `seed-cache.ts` for the instant repaint on reopen. - `terminal/` — the daemon-backed interactive terminal: the panel container, the key-scoped session registry that retains/detaches (rather than kills) a PTY across remounts, viewer attachment containers, and the transport-backed `TerminalSession`. Only the current controller diff --git a/packages/client/workbench/src/mock/__tests__/dev-mock-conversation.test.ts b/packages/client/workbench/src/mock/__tests__/dev-mock-conversation.test.ts index 57edd48d6..620bdb569 100644 --- a/packages/client/workbench/src/mock/__tests__/dev-mock-conversation.test.ts +++ b/packages/client/workbench/src/mock/__tests__/dev-mock-conversation.test.ts @@ -81,11 +81,15 @@ describe('dev mock host conversation parity', () => { if (read.kind !== 'conversation.read.result') throw new Error('no read result'); expect(read.watermark).toBeDefined(); expect(read.cursor).toBeUndefined(); - expect(read.events).toHaveLength(2); - const [userRow, placeholder] = read.events; + // The attach replay precedes the turn in the journal; the echo is the turn's first frame. + const rowIndex = read.events.findIndex( + (item) => 'event' in item && item.event.type === 'user-message', + ); + const userRow = read.events[rowIndex]; if (!('event' in userRow) || userRow.event.type !== 'user-message') { - throw new Error('expected a user row first'); + throw new Error('expected a user row'); } + const placeholder = read.events[rowIndex + 1]; expect(userRow.event.content).toEqual([{ type: 'text', text: '$ ls' }]); // Deterministic like the daemon: a re-read converges on the same row identity. expect(userRow.event.messageId).toBe(`msg-${submitted.turnId}`); @@ -93,6 +97,80 @@ describe('dev mock host conversation parity', () => { type: 'history-unavailable', turnId: submitted.turnId, }); + // The row is the stamped echo itself, and the watermark is the session's last position. + expect(userRow).toMatchObject({ turnId: submitted.turnId, epoch: 0 }); + expect(read.watermark).toEqual({ epoch: 0, seq: userRow.seq }); + }, 15000); + + it('stamps every frame, records legacy prompts as turns, and replays them on a read', async () => { + const { sent, request } = createHost(); + const started = await request( + { kind: 'session.start', clientReqId: 'r1', opts: { kind: 'claude-code', cwd: '/mock' } }, + 'r1', + ); + if (started.kind !== 'session.started') throw new Error('session did not start'); + const sessionId = started.sessionId; + + await request( + { + kind: 'agent.input', + clientReqId: 'p1', + sessionId, + input: { type: 'prompt', content: [{ type: 'text', text: 'hello mock' }] }, + }, + 'p1', + ); + const frames = sent.filter( + (payload) => payload.kind === 'agent.event' && payload.sessionId === sessionId, + ); + // One epoch per launch, contiguous seqs: what the client's merge relies on. + expect(frames.map((frame) => frame.kind === 'agent.event' && frame.epoch)).toEqual( + frames.map(() => 0), + ); + expect(frames.map((frame) => frame.kind === 'agent.event' && frame.seq)).toEqual( + frames.map((_, index) => index + 1), + ); + + const graph = await request( + { kind: 'conversation.graph.get', clientReqId: 'g1', sessionId }, + 'g1', + ); + if (graph.kind !== 'conversation.graph.result') throw new Error('no graph result'); + expect(graph.turns).toHaveLength(1); + const [turn] = graph.turns; + expect(turn).toMatchObject({ state: 'completed', input: { type: 'prompt' } }); + expect(sent).toContainEqual( + expect.objectContaining({ + kind: 'conversation.graph.changed', + sessionId, + graphRevision: 1, + activeLeafTurnId: turn.turnId, + }), + ); + + // A re-read reproduces exactly the frames the client already folded, under the same ids. + const read = await request({ kind: 'conversation.read', clientReqId: 'c1', sessionId }, 'c1'); + if (read.kind !== 'conversation.read.result') throw new Error('no read result'); + expect(read.leafTurnId).toBe(turn.turnId); + expect(read.events.filter((item) => 'event' in item)).toHaveLength(frames.length); + expect(read.events).toContainEqual( + expect.objectContaining({ + turnId: turn.turnId, + event: expect.objectContaining({ + type: 'user-message', + messageId: `msg-${turn.turnId}`, + }), + }), + ); + expect(read.watermark).toEqual({ epoch: 0, seq: frames.length }); + + // A relaunch mints under the next epoch. + await request({ kind: 'session.stop', clientReqId: 'stop', sessionId }, 'stop'); + await request({ kind: 'session.resume', clientReqId: 'resume', sessionId }, 'resume'); + const resumed = sent.findLast( + (payload) => payload.kind === 'agent.event' && payload.sessionId === sessionId, + ); + expect(resumed).toMatchObject({ epoch: 1, seq: expect.any(Number) as number }); }, 15000); it('fails loudly on parameters it would otherwise ignore', async () => { diff --git a/packages/client/workbench/src/mock/dev-mock-host.ts b/packages/client/workbench/src/mock/dev-mock-host.ts index 6ba74995e..83a4ef5b6 100644 --- a/packages/client/workbench/src/mock/dev-mock-host.ts +++ b/packages/client/workbench/src/mock/dev-mock-host.ts @@ -49,6 +49,7 @@ import { normalizeCwdKey, SessionResourceIdSchema, textBlock, + userRowMessageId, } from '@linkcode/schema'; import type { Transport } from '@linkcode/transport'; import { createWireMessage, pong } from '@linkcode/transport'; @@ -131,7 +132,15 @@ interface MockSession extends SessionInfo { effort?: EffortLevel; /** Bumped by cancel/stop so an in-flight prompt turn knows to bail out. */ epoch: number; - /** Minimal turn tree: one lineage appended per `turn.submit` (showcase parity). */ + /** The daemon's event-plane position: `eventEpoch` bumps per resume, `eventSeq` per frame. */ + eventEpoch: number; + eventSeq: number; + /** Every stamped frame ever emitted — the mock's stand-in for provider history, so a + * `conversation.read` reproduces exactly what a client already had. */ + journal: MockJournalEntry[]; + /** The turn the next frames are attributed to; set from a turn's start until it settles. */ + runningTurnId?: TurnId; + /** Minimal turn tree: one lineage appended per turn-starting input (showcase parity). */ graphTurns: MockTurn[]; showcase?: boolean; showcaseSeeded?: boolean; @@ -145,6 +154,14 @@ interface MockTurn { content: ContentBlock[]; } +interface MockJournalEntry { + epoch: number; + seq: number; + ts: number; + turnId?: TurnId; + event: AgentEvent; +} + interface PendingPermission { sessionId: SessionId; /** The pending snapshot the ask was raised for; the response re-emits it resolved. */ @@ -404,30 +421,14 @@ export class DevMockHost { break; } const leaf = session.graphTurns.at(-1); - // Minimal parity: host user rows + the no-history placeholder, one final page. The mock - // has no provider transcripts, so this mirrors the daemon's prompt-only fallback. - const events = session.graphTurns.flatMap(({ graph, content }): ConversationReadItem[] => [ - { - turnId: graph.turnId, - runId: graph.runId, - ts: graph.createdAt, - event: { - type: 'user-message', - // Deterministic like the daemon: re-reads must converge on one row per turn. - messageId: `msg-${graph.turnId}` as MessageId, - content: structuredClone(content), - }, - }, - { type: 'history-unavailable', turnId: graph.turnId, runId: graph.runId }, - ]); this.send({ kind: 'conversation.read.result', replyTo: p.clientReqId, sessionId: p.sessionId, graphRevision: session.graphTurns.length, ...(leaf !== undefined && { leafTurnId: leaf.graph.turnId }), - watermark: { epoch: 0, seq: 0 }, - events, + watermark: { epoch: session.eventEpoch, seq: session.eventSeq }, + events: readMockProjection(session), }); break; } @@ -833,7 +834,17 @@ export class DevMockHost { } private addSession( - init: Omit & { + init: Omit< + MockSession, + | 'sessionId' + | 'origin' + | 'epoch' + | 'eventEpoch' + | 'eventSeq' + | 'journal' + | 'status' + | 'graphTurns' + > & { status: SessionStatus; origin?: SessionInfo['origin']; }, @@ -850,6 +861,9 @@ export class DevMockHost { sessionId: this.nextSessionId(), origin: origin ?? { type: 'created' }, epoch: 0, + eventEpoch: 0, + eventSeq: 0, + journal: [], graphTurns: [], }; this.sessions.set(session.sessionId, session); @@ -1162,6 +1176,9 @@ export class DevMockHost { this.sendFailure(replyTo, `Session is already running: ${sessionId}`); return; } + // A relaunch mints under a new epoch, like the daemon's run launch. + session.eventEpoch += 1; + session.eventSeq = 0; session.status = 'idle'; this.attachSession(sessionId); this.send({ kind: 'session.started', replyTo, sessionId }); @@ -1254,14 +1271,13 @@ export class DevMockHost { case 'command': this.invokeCommand(replyTo, session, input.name, input.arguments); break; - case 'shell-command': - this.emit(sessionId, { - type: 'user-message', - messageId: this.nextMessageId('mock-user'), - content: [textBlock(`$ ${input.command}`)], - }); + case 'shell-command': { + const content = [textBlock(`$ ${input.command}`)]; + const turn = this.beginTurn(session, content, input); + settleTurn(session, turn, 'completed'); this.sendSuccess(replyTo); break; + } case 'question-response': this.respondQuestion(replyTo, sessionId, input.requestId, input.outcome); break; @@ -1290,10 +1306,11 @@ export class DevMockHost { return; } - this.emit(session.sessionId, { - type: 'user-message', - messageId: this.nextMessageId('mock-user'), - content: [textBlock(`/${name}${args ? ` ${args}` : ''}`)], + const content = [textBlock(`/${name}${args ? ` ${args}` : ''}`)]; + const turn = this.beginTurn(session, content, { + type: 'command', + name, + ...(args !== undefined && { arguments: args }), }); session.status = 'running'; this.emit(session.sessionId, { type: 'status', status: 'running' }); @@ -1309,6 +1326,7 @@ export class DevMockHost { } session.status = 'idle'; this.emit(session.sessionId, { type: 'status', status: 'idle' }); + settleTurn(session, turn, 'completed'); this.sendSuccess(replyTo); } @@ -1334,36 +1352,58 @@ export class DevMockHost { return; } const content = turnSubmitContent(p.input); - this.turnSeq += 1; - const turnId = `turn-mock-${this.turnSeq.toString(36)}` as TurnId; - const parent = session.graphTurns.at(-1); - const graph: ConversationGraphTurn = { - turnId, - sessionId: p.sessionId, - parentTurnId: parent?.graph.turnId ?? null, - siblingOrdinal: 1, - input: - p.input.type === 'prompt' - ? { type: 'prompt', promptId: `prompt-mock-${this.turnSeq.toString(36)}` as PromptId } - : p.input, - runId: `run-mock-${this.turnSeq.toString(36)}` as RunId, - state: 'completed', - createdAt: Date.now(), - inputSummary: promptText(content).slice(0, 140), - }; - session.graphTurns.push({ graph, content }); - this.send({ kind: 'turn.submitted', replyTo: p.clientReqId, turnId }); + const turn = this.beginTurn(session, content, p.input.type === 'prompt' ? undefined : p.input); + this.send({ kind: 'turn.submitted', replyTo: p.clientReqId, turnId: turn.graph.turnId }); if (p.input.type === 'prompt') { - const result = await this.streamMockTurn(session, content); - if (!result.ok) graph.state = 'failed'; + const result = await this.streamMockReply(session, content); + settleTurn(session, turn, result.ok ? 'completed' : 'failed'); return; } // Command/shell turns just echo — the mock has no directive execution behind turn.submit. - this.emit(p.sessionId, { + settleTurn(session, turn, 'completed'); + } + + /** Mint the graph turn a turn-starting input persists on the daemon (legacy inputs included) + * and point the frames that follow at it. */ + private beginTurn( + session: MockSession, + content: ContentBlock[], + input?: Exclude, + ): MockTurn { + this.turnSeq += 1; + const id = this.turnSeq.toString(36); + const turnId = `turn-mock-${id}` as TurnId; + const parent = session.graphTurns.at(-1); + const turn: MockTurn = { + graph: { + turnId, + sessionId: session.sessionId, + parentTurnId: parent?.graph.turnId ?? null, + siblingOrdinal: 1, + input: input ?? { type: 'prompt', promptId: `prompt-mock-${id}` as PromptId }, + runId: `run-mock-${id}` as RunId, + state: 'running', + createdAt: Date.now(), + inputSummary: promptText(content).slice(0, 140), + }, + content, + }; + session.graphTurns.push(turn); + session.runningTurnId = turnId; + // Echo before graph.changed so a subscribed projection store sees the new leaf row and + // treats a plain send as continuation, matching the engine dispatcher. + this.emit(session.sessionId, { type: 'user-message', - messageId: this.nextMessageId('mock-user'), + messageId: userRowMessageId(turnId), content, }); + this.send({ + kind: 'conversation.graph.changed', + sessionId: session.sessionId, + graphRevision: session.graphTurns.length, + activeLeafTurnId: turnId, + }); + return turn; } private async prompt( @@ -1371,23 +1411,20 @@ export class DevMockHost { session: MockSession, content: ContentBlock[], ): Promise { - const result = await this.streamMockTurn(session, content); + const turn = this.beginTurn(session, content); + const result = await this.streamMockReply(session, content); + settleTurn(session, turn, result.ok ? 'completed' : 'failed'); if (result.ok) this.sendSuccess(replyTo); else this.sendFailure(replyTo, result.message, { reportedInConversation: true }); } - private async streamMockTurn( + private async streamMockReply( session: MockSession, content: ContentBlock[], ): Promise<{ ok: true } | { ok: false; message: string }> { const text = promptText(content); if (text && !session.title) session.title = text.slice(0, 80); session.status = 'running'; - this.emit(session.sessionId, { - type: 'user-message', - messageId: this.nextMessageId('mock-user'), - content, - }); this.emit(session.sessionId, { type: 'status', status: 'running' }); // Cancel/stop bump the session epoch; a stale epoch means this turn was cancelled and the @@ -1793,8 +1830,31 @@ export class DevMockHost { this.emit(sessionId, { type: 'tool-call', toolCall }); } + /** The mock's stamped exit: every frame takes the session's next `(epoch, seq)` position, its + * running turn, and a journal entry — wire stream ≡ journal, as on the daemon. */ private emit(sessionId: SessionId, event: AgentEvent): void { - this.send({ kind: 'agent.event', sessionId, event }); + const session = this.sessions.get(sessionId); + if (!session) { + this.send({ kind: 'agent.event', sessionId, event }); + return; + } + session.eventSeq += 1; + const entry: MockJournalEntry = { + epoch: session.eventEpoch, + seq: session.eventSeq, + ts: Date.now(), + ...(session.runningTurnId !== undefined && { turnId: session.runningTurnId }), + event, + }; + session.journal.push(entry); + this.send({ + kind: 'agent.event', + sessionId, + epoch: entry.epoch, + seq: entry.seq, + ...(entry.turnId !== undefined && { turnId: entry.turnId }), + event, + }); } private send(payload: WirePayload): void { @@ -1860,6 +1920,42 @@ function promptText(content: readonly ContentBlock[]): string { .trim(); } +function settleTurn(session: MockSession, turn: MockTurn, state: 'completed' | 'failed'): void { + turn.graph.state = state; + if (session.runningTurnId === turn.graph.turnId) session.runningTurnId = undefined; +} + +/** The journal as one final page: every stamped frame in order, plus the daemon's prompt-only + * placeholder under any turn whose frames hold nothing but its own echo. */ +function readMockProjection(session: MockSession): ConversationReadItem[] { + const withOutput = new Set(); + for (let i = 0, len = session.journal.length; i < len; i++) { + const entry = session.journal[i]; + if (entry.turnId !== undefined && entry.event.type !== 'user-message') { + withOutput.add(entry.turnId); + } + } + const items: ConversationReadItem[] = []; + for (let i = 0, len = session.journal.length; i < len; i++) { + const entry = session.journal[i]; + items.push({ + ...(entry.turnId !== undefined && { turnId: entry.turnId }), + epoch: entry.epoch, + seq: entry.seq, + ts: entry.ts, + event: entry.event, + }); + if ( + entry.turnId !== undefined && + entry.event.type === 'user-message' && + !withOutput.has(entry.turnId) + ) { + items.push({ type: 'history-unavailable', turnId: entry.turnId }); + } + } + return items; +} + function toSessionInfo(session: MockSession): SessionInfo { return { sessionId: session.sessionId, diff --git a/packages/client/workbench/src/surface/__tests__/seed-cache.test.ts b/packages/client/workbench/src/surface/__tests__/seed-cache.test.ts index 37b3024cc..656885ced 100644 --- a/packages/client/workbench/src/surface/__tests__/seed-cache.test.ts +++ b/packages/client/workbench/src/surface/__tests__/seed-cache.test.ts @@ -1,11 +1,25 @@ -import type { AgentEvent, AgentHistoryId, AgentKind, MessageId } from '@linkcode/schema'; +import type { + AgentEvent, + AgentHistoryId, + AgentKind, + MessageId, + SessionId, + TurnId, +} from '@linkcode/schema'; import { WIRE_PROTOCOL_VERSION } from '@linkcode/schema'; import { describe, expect, it } from 'vitest'; import type { SeedCacheStorage } from '../seed-cache'; -import { loadPersistedSeed, persistSeed } from '../seed-cache'; +import { + loadPersistedProjection, + loadPersistedSeed, + persistProjection, + persistSeed, +} from '../seed-cache'; const kind: AgentKind = 'claude-code'; const historyId = (value: string): AgentHistoryId => value as AgentHistoryId; +const sessionId = (value: string): SessionId => value as SessionId; +const leafTurnId = 'turn-1' as TurnId; function userText(text: string): AgentEvent { return { @@ -123,4 +137,35 @@ describe('seed cache', () => { uptoSeq: 0, }); }); + + it('round-trips a projection by session and loads it back without its watermark', () => { + const storage = fakeStorage(); + const items = [{ turnId: leafTurnId, ts: 1_700_000_000_000, event: userText('hi') }]; + persistProjection( + sessionId('s1'), + { items, graphRevision: 3, leafTurnId, watermark: { epoch: 2, seq: 5 } }, + storage, + ); + // The cut belonged to a connection that is gone: a loaded projection supersedes nothing. + expect(loadPersistedProjection(sessionId('s1'), storage)).toEqual({ + items, + graphRevision: 3, + leafTurnId, + }); + expect(loadPersistedProjection(sessionId('absent'), storage)).toBeUndefined(); + }); + + it('shares the entry cap between transcripts and projections', () => { + const storage = fakeStorage(); + persistSeed(kind, historyId('h-old'), { events: [seedEvent('a')], uptoSeq: 0 }, storage); + for (let index = 0; index < 20; index += 1) { + persistProjection( + sessionId(`s${index}`), + { items: [], graphRevision: index, leafTurnId }, + storage, + ); + } + expect(storage.map.has(`linkcode.seed.${kind}.h-old`)).toBe(false); + expect(loadPersistedProjection(sessionId('s19'), storage)?.graphRevision).toBe(19); + }); }); diff --git a/packages/client/workbench/src/surface/seed-cache.ts b/packages/client/workbench/src/surface/seed-cache.ts index a70c7dd60..8ceb2f59a 100644 --- a/packages/client/workbench/src/surface/seed-cache.ts +++ b/packages/client/workbench/src/surface/seed-cache.ts @@ -1,16 +1,23 @@ -import type { ConversationSeed } from '@linkcode/client-core'; -import type { AgentHistoryId, AgentKind } from '@linkcode/schema'; -import { AgentEventSchema, WIRE_PROTOCOL_VERSION } from '@linkcode/schema'; +import type { ConversationProjectionSeed, ConversationSeed } from '@linkcode/client-core'; +import type { AgentHistoryId, AgentKind, SessionId } from '@linkcode/schema'; +import { + AgentEventSchema, + ConversationReadItemSchema, + TurnIdSchema, + WIRE_PROTOCOL_VERSION, +} from '@linkcode/schema'; import { z } from 'zod'; /** * Best-effort persistence for conversation seeds: reopening the app paints history instantly - * while the fresh transcript read revalidates. The provider transcript stays the source of - * truth — any read/write failure degrades to a cache miss, never to an error surface. + * while the fresh read revalidates. The daemon stays the source of truth — any read/write failure + * degrades to a cache miss, never to an error surface. */ export type SeedCacheStorage = Pick; +type PersistedSeed = ConversationSeed | ConversationProjectionSeed; + /** Newest-last list of entry keys; the eviction order for the size cap and quota pressure. */ const INDEX_KEY = 'linkcode.seed-index'; const MAX_ENTRIES = 20; @@ -22,18 +29,31 @@ const PersistedSeedSchema = z.object({ events: z.array(z.object({ event: AgentEventSchema, ts: z.number().optional() })), }); +/** A projection snapshot keyed by session. Loaded without its watermark: a cached cut belongs to + * a connection that is gone, so the seed supersedes nothing and the fresh read takes over. */ +const PersistedProjectionSchema = z.object({ + v: z.literal(WIRE_PROTOCOL_VERSION), + graphRevision: z.number().int().nonnegative(), + leafTurnId: TurnIdSchema, + items: z.array(ConversationReadItemSchema), +}); + /** Parse results are memoized per storage so render-time loads don't re-parse megabyte JSON. */ -const memoByStorage = new WeakMap>(); +const memoByStorage = new WeakMap>(); function defaultStorage(): SeedCacheStorage | null { return typeof localStorage === 'undefined' ? null : localStorage; } -function entryKey(kind: AgentKind, historyId: AgentHistoryId): string { +function historyKey(kind: AgentKind, historyId: AgentHistoryId): string { return `linkcode.seed.${kind}.${historyId}`; } -function memoFor(storage: SeedCacheStorage): Map { +function projectionKey(sessionId: SessionId): string { + return `linkcode.conversation.${sessionId}`; +} + +function memoFor(storage: SeedCacheStorage): Map { let memo = memoByStorage.get(storage); if (!memo) { memo = new Map(); @@ -65,47 +85,32 @@ function evictOldest(storage: SeedCacheStorage, index: string[]): string[] { return rest; } -/** - * The last persisted snapshot for a session's transcript, or undefined on any miss (absent, stale - * wire version, unparseable). Loaded seeds carry `uptoSeq: 0` — they predate this connection. - */ -export function loadPersistedSeed( - kind: AgentKind, - historyId: AgentHistoryId, - storage: SeedCacheStorage | null = defaultStorage(), -): ConversationSeed | undefined { - if (!storage) return undefined; - const key = entryKey(kind, historyId); +/** The memoized parse of one entry, or undefined on any miss (absent, stale wire version, + * unparseable). Keys of the two entry kinds never collide, so the memo can hold both. */ +function load( + storage: SeedCacheStorage, + key: string, + parse: (raw: unknown) => T | undefined, +): T | undefined { const memo = memoFor(storage); const cached = memo.get(key); - if (cached !== undefined) return cached ?? undefined; + if (cached !== undefined) return (cached ?? undefined) as T | undefined; - let seed: ConversationSeed | null = null; + let seed: T | undefined; try { const raw = storage.getItem(key); - if (raw !== null) { - const parsed = PersistedSeedSchema.safeParse(JSON.parse(raw)); - // A stale/corrupt entry is only *recorded* as a miss: this runs during render, which must - // stay pure — no removeItem. The next persist overwrites; LRU eviction bounds the rest. - if (parsed.success) seed = { events: parsed.data.events, uptoSeq: 0 }; - } + // A stale/corrupt entry is only *recorded* as a miss: this runs during render, which must + // stay pure — no removeItem. The next persist overwrites; LRU eviction bounds the rest. + if (raw !== null) seed = parse(JSON.parse(raw)); } catch { // Unreadable storage or corrupt JSON both degrade to a cache miss. } - memo.set(key, seed); - return seed ?? undefined; + memo.set(key, seed ?? null); + return seed; } -/** Persist a freshly fetched seed, keeping at most {@link MAX_ENTRIES} snapshots (LRU by write). */ -export function persistSeed( - kind: AgentKind, - historyId: AgentHistoryId, - seed: ConversationSeed, - storage: SeedCacheStorage | null = defaultStorage(), -): void { - if (!storage) return; - const key = entryKey(kind, historyId); - const value = JSON.stringify({ v: WIRE_PROTOCOL_VERSION, events: seed.events }); +/** Persist one entry, keeping at most {@link MAX_ENTRIES} snapshots (LRU by write). */ +function persist(storage: SeedCacheStorage, key: string, value: string, seed: PersistedSeed): void { let index = readIndex(storage).filter((existing) => existing !== key); while (index.length >= MAX_ENTRIES) index = evictOldest(storage, index); @@ -121,10 +126,68 @@ export function persistSeed( } } writeIndex(storage, [...index, key]); - memoFor(storage).set(key, { events: seed.events, uptoSeq: 0 }); + memoFor(storage).set(key, seed); } catch (err) { // The cache is an optimization; failing to write it must not break the conversation surface. // eslint-disable-next-line no-console -- cache failures are non-fatal but still need a developer diagnostic. console.warn('[LinkCode] failed to persist conversation seed', err); } } + +/** The last persisted transcript snapshot for a history, loaded with `uptoSeq: 0` — it predates + * this connection. */ +export function loadPersistedSeed( + kind: AgentKind, + historyId: AgentHistoryId, + storage: SeedCacheStorage | null = defaultStorage(), +): ConversationSeed | undefined { + if (!storage) return undefined; + return load(storage, historyKey(kind, historyId), (raw) => { + const parsed = PersistedSeedSchema.safeParse(raw); + return parsed.success ? { events: parsed.data.events, uptoSeq: 0 } : undefined; + }); +} + +export function persistSeed( + kind: AgentKind, + historyId: AgentHistoryId, + seed: ConversationSeed, + storage: SeedCacheStorage | null = defaultStorage(), +): void { + if (!storage) return; + const value = JSON.stringify({ v: WIRE_PROTOCOL_VERSION, events: seed.events }); + persist(storage, historyKey(kind, historyId), value, { events: seed.events, uptoSeq: 0 }); +} + +/** The last persisted projection for a session, loaded without its watermark. */ +export function loadPersistedProjection( + sessionId: SessionId, + storage: SeedCacheStorage | null = defaultStorage(), +): ConversationProjectionSeed | undefined { + if (!storage) return undefined; + return load(storage, projectionKey(sessionId), (raw) => { + const parsed = PersistedProjectionSchema.safeParse(raw); + if (!parsed.success) return; + const { graphRevision, leafTurnId, items } = parsed.data; + return { items, graphRevision, leafTurnId }; + }); +} + +export function persistProjection( + sessionId: SessionId, + seed: ConversationProjectionSeed, + storage: SeedCacheStorage | null = defaultStorage(), +): void { + if (!storage) return; + const entry = { + graphRevision: seed.graphRevision, + leafTurnId: seed.leafTurnId, + items: seed.items, + }; + persist( + storage, + projectionKey(sessionId), + JSON.stringify({ v: WIRE_PROTOCOL_VERSION, ...entry }), + entry, + ); +} diff --git a/packages/client/workbench/src/surface/use-seeded-conversation.ts b/packages/client/workbench/src/surface/use-seeded-conversation.ts index 396248f30..11e4cd52f 100644 --- a/packages/client/workbench/src/surface/use-seeded-conversation.ts +++ b/packages/client/workbench/src/surface/use-seeded-conversation.ts @@ -1,80 +1,72 @@ -import type { Conversation, ConversationSeed, ConversationSeedEvent } from '@linkcode/client-core'; -import { useConversation } from '@linkcode/client-core'; -import type { AgentHistoryId, AgentKind, SessionId, SessionInfo } from '@linkcode/schema'; +import type { + Conversation, + ConversationProjectionSeed, + ConversationSeed, + ConversationSeedSource, +} from '@linkcode/client-core'; +import { readConversationSeed, useConversation } from '@linkcode/client-core'; +import type { SessionInfo } from '@linkcode/schema'; import type { Options, RequestResult } from '@linkcode/sdk'; import { resolveClient } from '@linkcode/sdk'; +import { noop } from 'foxact/noop'; import { useData } from '../runtime/tayori'; -import { loadPersistedSeed, persistSeed } from './seed-cache'; +import { + loadPersistedProjection, + loadPersistedSeed, + persistProjection, + persistSeed, +} from './seed-cache'; -/** Upper bound on cursor pages one seed read follows, so a buggy cursor can't loop forever. */ -const MAX_SEED_PAGES = 20; +/** SWR data is `undefined` while loading, so "nothing to seed" needs its own value. */ +type SeedData = ConversationProjectionSeed | ConversationSeed | null; /** - * Read a session's full provider transcript as a point-in-time snapshot: pages walked to the end, - * the first page bypassing the daemon's history cache so the snapshot is current. `uptoSeq` (the - * live receive counter sampled at resolve) marks the cut: live events ≤ it are in the snapshot. + * Read the seed for a session (see `readConversationSeed`) and persist it for the next reopen. + * Persisted here, not in an onSuccess hook: the fetcher owns its params, so a session switch + * mid-flight can't file the snapshot under the newly active session's key. */ -async function readConversationSeed( - options: Options<{ - agentKind: AgentKind; - cwd: string; - historyId: AgentHistoryId; - sessionId: SessionId; - }>, -): RequestResult { - const client = resolveClient(options); - const events: ConversationSeedEvent[] = []; - let cursor: string | undefined; - for (let page = 0; page < MAX_SEED_PAGES; page += 1) { - // eslint-disable-next-line no-await-in-loop -- cursor pagination: each page's cursor comes from the previous reply - const { data } = await client.readHistory(options.agentKind, { - historyId: options.historyId, - cwd: options.cwd, - cursor, - forceRefresh: page === 0, - }); - for (let i = 0, len = data.events.length; i < len; i++) { - const entry = data.events[i]; - events.push({ event: entry.event, ts: entry.ts }); - } - cursor = data.cursor; - if (cursor === undefined) break; - } - const seed: ConversationSeed = { events, uptoSeq: client.raw.eventSeq(options.sessionId) }; - // Persisted here, not in an onSuccess hook: the fetcher owns its params, so a session switch - // mid-flight can't file the snapshot under the newly active session's key. - persistSeed(options.agentKind, options.historyId, seed); +async function fetchConversationSeed( + options: Options, +): RequestResult { + const seed = await readConversationSeed(resolveClient(options).raw, options); + if (seed === undefined) return { data: null }; + if ('items' in seed) persistProjection(options.sessionId, seed); + else if (options.historyId !== undefined) persistSeed(options.agentKind, options.historyId, seed); return { data: seed }; } /** - * The active session's conversation view-model, seeded from provider history: the live - * `agent.event` subscription only covers this connection, so a cold-resumed session replays its - * past from `history.read`. The last persisted snapshot serves as `fallbackData` — reopening the - * app paints history immediately while the fresh read revalidates behind it. + * The active session's conversation view-model, seeded from the daemon: the turn-graph projection + * where the host serves one, the provider transcript otherwise (the live `agent.event` + * subscription only covers this connection). The last persisted snapshot serves as + * `fallbackData` — reopening the app paints history immediately while the fresh read revalidates + * behind it — and a projection store's resync request is answered by re-running the read. */ export function useSeededConversation( active: SessionInfo | null, onError: (err: unknown) => void, ): Conversation { - const { data: seed } = useData( - readConversationSeed, - active?.historyId + const { data: seed, mutate } = useData( + fetchConversationSeed, + active ? { + sessionId: active.sessionId, agentKind: active.kind, cwd: active.cwd, historyId: active.historyId, - sessionId: active.sessionId, } : null, { onError, - fallbackData: active?.historyId - ? loadPersistedSeed(active.kind, active.historyId) + fallbackData: active + ? (loadPersistedProjection(active.sessionId) ?? + (active.historyId ? loadPersistedSeed(active.kind, active.historyId) : undefined)) : undefined, // Never opt this into keepPreviousData: a conversation must not bleed across sessions, and // on a switch it would serve the previous transcript — forever, with no historyId yet. }, ); - return useConversation(active?.sessionId ?? null, seed); + return useConversation(active?.sessionId ?? null, seed ?? undefined, () => { + void mutate().catch(noop); + }); } diff --git a/packages/client/workbench/tests/integration/dev-mock-projection.test.ts b/packages/client/workbench/tests/integration/dev-mock-projection.test.ts new file mode 100644 index 000000000..30a3f7363 --- /dev/null +++ b/packages/client/workbench/tests/integration/dev-mock-projection.test.ts @@ -0,0 +1,84 @@ +import type { ConversationResyncReason } from '@linkcode/client-core'; +import { + createConversationStore, + LinkCodeClient, + readConversationSeed, +} from '@linkcode/client-core'; +import { userRowMessageId } from '@linkcode/schema'; +import { noop } from 'foxact/noop'; +import { nullthrow } from 'foxts/guard'; +import { wait } from 'foxts/wait'; +import { describe, expect, it } from 'vitest'; +import { createDevMockTransport } from '../../src/mock/dev-mock-transport'; + +/** The client-side seed path end to end against the mock host: what dev:mock exercises. */ +describe('dev mock projection seeding', () => { + it('seeds through the turn graph once a session has turns and re-reads on a relaunch', async () => { + const client = new LinkCodeClient(createDevMockTransport()); + await client.connect(); + expect(client.supportsConversationGraph).toBe(true); + + const sessionId = await client.startSession({ kind: 'codex', cwd: '/mock/repo' }); + client.attachSession(sessionId); + const source = { sessionId, agentKind: 'codex' as const, cwd: '/mock/repo' }; + // No turn rows and no transcript: nothing to seed, the store runs live-only. + await expect(readConversationSeed(client, source)).resolves.toBeUndefined(); + + const liveResyncs: ConversationResyncReason[] = []; + createConversationStore(client, sessionId, undefined, { + onResync: (reason) => liveResyncs.push(reason), + }).subscribe(noop); + + await client.promptText(sessionId, 'Hello mocked daemon'); + await wait(10); + expect(liveResyncs).toEqual(['graph']); + + const change = nullthrow(client.latestGraphChange(sessionId)); + const leaf = nullthrow(change.activeLeafTurnId); + + const seed = await readConversationSeed(client, source); + if (seed === undefined || !('items' in seed)) throw new Error('expected a projection seed'); + expect(seed.leafTurnId).toBe(leaf); + expect(seed.graphRevision).toBe(1); + // The watermark is the last stamped frame the client already holds. + expect(seed.watermark).toEqual(client.eventsSnapshot(sessionId).at(-1)?.position); + + const resyncs: ConversationResyncReason[] = []; + const store = createConversationStore(client, sessionId, seed, { + onResync: (reason) => resyncs.push(reason), + }); + store.subscribe(noop); + const items = store.getSnapshot().items; + // One user row under the turn identity — the live echo folded once, never twice. + expect(items.filter((item) => item.kind === 'message' && item.role === 'user')).toEqual([ + expect.objectContaining({ id: userRowMessageId(leaf) }), + ]); + expect(items.some((item) => item.kind === 'message' && item.role === 'assistant')).toBe(true); + expect(items.some((item) => item.kind === 'history-unavailable')).toBe(false); + + // A turn without output renders the prompt-only placeholder under its row. + await client.runShellCommand(sessionId, 'ls'); + await wait(10); + expect(resyncs).toEqual([]); + const reseed = await readConversationSeed(client, source); + if (reseed === undefined || !('items' in reseed)) throw new Error('expected a projection seed'); + const shellLeaf = nullthrow(client.latestGraphChange(sessionId)?.activeLeafTurnId); + const reseeded = createConversationStore(client, sessionId, reseed).getSnapshot().items; + const rowIndex = reseeded.findIndex((item) => item.id === userRowMessageId(shellLeaf)); + expect(rowIndex).toBeGreaterThan(0); + expect(reseeded[rowIndex + 1]).toMatchObject({ + kind: 'history-unavailable', + turnId: reseeded[rowIndex].turnId, + }); + + // Stop + resume relaunches under the next epoch: the earlier store asks for one re-read. + await client.stopSession(sessionId); + await client.resumeSession(sessionId); + await wait(50); + store.getSnapshot(); + await wait(10); + expect(resyncs).toEqual(['epoch']); + + client.dispose(); + }, 15000); +}); diff --git a/packages/client/workbench/tests/integration/dev-mock-transport.test.ts b/packages/client/workbench/tests/integration/dev-mock-transport.test.ts index 3febd842e..7d12744ff 100644 --- a/packages/client/workbench/tests/integration/dev-mock-transport.test.ts +++ b/packages/client/workbench/tests/integration/dev-mock-transport.test.ts @@ -23,7 +23,7 @@ async function connectedClient(): Promise { function collectEvents(client: LinkCodeClient, sessionId: SessionId): AgentEvent[] { const events: AgentEvent[] = []; - client.subscribe(sessionId, (event) => events.push(event)); + client.subscribe(sessionId, ({ event }) => events.push(event)); return events; } diff --git a/packages/foundation/schema/src/model/conversation.ts b/packages/foundation/schema/src/model/conversation.ts index e3f2ed3f1..943027223 100644 --- a/packages/foundation/schema/src/model/conversation.ts +++ b/packages/foundation/schema/src/model/conversation.ts @@ -1,6 +1,8 @@ import { z } from 'zod'; +import type { MessageId, TurnId } from './primitives'; import { AttachmentIdSchema, + MessageIdSchema, OperationIdSchema, PromptIdSchema, RunIdSchema, @@ -9,6 +11,12 @@ import { TurnIdSchema, } from './primitives'; +/** The one identity of a turn's user row: the daemon mints it for the live echo and for every + * `conversation.read`, and clients join graph turns to rows through it. */ +export function userRowMessageId(turnId: TurnId): MessageId { + return MessageIdSchema.parse(`msg-${turnId}`); +} + /** * The conversation turn tree: host-authoritative turn identity and durable user prompts. * Every turn has a parent (null = child of the session root); siblings under one parent are the diff --git a/packages/foundation/schema/src/wire/conversation.ts b/packages/foundation/schema/src/wire/conversation.ts index a33430160..053173186 100644 --- a/packages/foundation/schema/src/wire/conversation.ts +++ b/packages/foundation/schema/src/wire/conversation.ts @@ -14,6 +14,10 @@ import { } from '../model/primitives'; import { WireRequestIdSchema } from './request'; +/** The wire version that introduced the turn graph: `conversation.read`/`graph.get`, `turn.submit`, + * and the `(epoch, seq)` stamps on `agent.event`. Clients feature-detect the merge path on it. */ +export const CONVERSATION_GRAPH_WIRE_VERSION = 80 as const; + /** What a submit carries over the wire. Prompt content travels as blocks — the daemon mints the * durable `PromptRecord` (and its id) when it persists the turn intent. */ export const TurnSubmitInputSchema = z.discriminatedUnion('type', [ diff --git a/packages/foundation/schema/src/wire/index.ts b/packages/foundation/schema/src/wire/index.ts index fddcdcd2a..784aaa437 100644 --- a/packages/foundation/schema/src/wire/index.ts +++ b/packages/foundation/schema/src/wire/index.ts @@ -1,4 +1,5 @@ export { + CONVERSATION_GRAPH_WIRE_VERSION, type ConversationEvent, ConversationEventSchema, type ConversationGraphTurn, diff --git a/packages/host/engine/src/__tests__/engine-conversation-read.test.ts b/packages/host/engine/src/__tests__/engine-conversation-read.test.ts index b7a5aabbd..ab7818425 100644 --- a/packages/host/engine/src/__tests__/engine-conversation-read.test.ts +++ b/packages/host/engine/src/__tests__/engine-conversation-read.test.ts @@ -18,6 +18,7 @@ import { import { nullthrow } from 'foxts/guard'; import { describe, expect, it } from 'vitest'; import { InMemoryConversationStore } from '../conversation/conversation-store'; +import { decodeLiveBranchCursor } from '../session/live-session'; import { FakeAdapter, createSessionHarness as harness, @@ -176,6 +177,38 @@ describe('conversation.graph.get', () => { }); describe('conversation.read', () => { + it('mints user rows with the live echo’s identity and edit cursor', async () => { + const shared: SharedHistory = { events: [], failRead: false }; + const h = await startedHarness(() => new HistoryFakeAdapter(shared)); + h.adapter.emit({ type: 'session-ref', historyId: HISTORY_ID }); + shared.events = [userRow('u1', 'hello one'), assistantRow('a1', 'answer one')]; + await completeTurn(h, 's1', 'hello one'); + const echo = h.sent.find( + (payload) => payload.kind === 'agent.event' && payload.event.type === 'user-message', + ); + if (echo?.kind !== 'agent.event' || echo.event.type !== 'user-message') { + throw new Error('no live prompt echo'); + } + + await h.inject({ kind: 'conversation.read', clientReqId: 'rr', sessionId: h.sessionId }); + + const row = readResult(h.sent, 'rr').events.find( + (item) => 'event' in item && item.event.type === 'user-message', + ); + if (row === undefined || !('event' in row) || row.event.type !== 'user-message') { + throw new Error('no user row'); + } + // One identity per turn across the live view and the read: nothing to reconcile client-side. + expect(row.event.messageId).toBe(echo.event.messageId); + // Read rows stay editable through the legacy path exactly like the echo they replace. + expect(row.event.branchCursor).toBe(echo.event.branchCursor); + expect(decodeLiveBranchCursor(nullthrow(row.event.branchCursor))).toEqual({ + type: 'live', + historyId: HISTORY_ID, + turnId: row.turnId, + }); + }); + it('renders prompts and placeholders when the harness has no history', async () => { const h = await startedHarness(); await completeTurn(h, 's1', 'hello one'); diff --git a/packages/host/engine/src/__tests__/engine-schedule.test.ts b/packages/host/engine/src/__tests__/engine-schedule.test.ts index c388c649f..b56b341f7 100644 --- a/packages/host/engine/src/__tests__/engine-schedule.test.ts +++ b/packages/host/engine/src/__tests__/engine-schedule.test.ts @@ -232,6 +232,18 @@ describe('engine schedule wiring', () => { graphRevision: 1, activeLeafTurnId: turn.turnId, }); + expect( + h.sent.filter( + (payload) => payload.kind === 'agent.event' && payload.event.type === 'user-message', + ), + ).toEqual([ + expect.objectContaining({ + event: expect.objectContaining({ + type: 'user-message', + messageId: `msg-${turn.turnId}`, + }), + }), + ]); }); it('reports an unknown schedule as not found', async () => { diff --git a/packages/host/engine/src/__tests__/engine-turn-submit.test.ts b/packages/host/engine/src/__tests__/engine-turn-submit.test.ts index 100e2bf82..02a6e05f1 100644 --- a/packages/host/engine/src/__tests__/engine-turn-submit.test.ts +++ b/packages/host/engine/src/__tests__/engine-turn-submit.test.ts @@ -345,11 +345,16 @@ describe('turn.submit saga', () => { activeLeafTurnId: turnId, }), ); + // The echo carries the durable row's identity, so a later conversation.read converges on it. expect( - h.sent.some( + h.sent.filter( (payload) => payload.kind === 'agent.event' && payload.event.type === 'user-message', ), - ).toBe(true); + ).toEqual([ + expect.objectContaining({ + event: expect.objectContaining({ type: 'user-message', messageId: `msg-${turnId}` }), + }), + ]); }); it('replays a lost reply verbatim instead of duplicating a sibling', async () => { diff --git a/packages/host/engine/src/conversation/projection-service.ts b/packages/host/engine/src/conversation/projection-service.ts index 2dd8b9004..61bad2a10 100644 --- a/packages/host/engine/src/conversation/projection-service.ts +++ b/packages/host/engine/src/conversation/projection-service.ts @@ -3,11 +3,13 @@ import { boundedLimit } from '@linkcode/agent-adapter'; import type { AgentEvent, AgentHistoryEvent, + AgentHistoryId, ContentBlock, ConversationGraphTurn, ConversationReadItem, ConversationTurn, ConversationWatermark, + RunId, SessionId, SessionRecord, TurnId, @@ -15,12 +17,13 @@ import type { import { compareConversationWatermarks, MAX_ATTACHMENT_TOTAL_BASE64_LENGTH, - MessageIdSchema, TurnIdSchema, + userRowMessageId, } from '@linkcode/schema'; import { Effect } from 'effect'; import type { OperationError } from '../failure'; import { RequestError } from '../failure'; +import { encodeLiveBranchCursor } from '../session/live-session'; import type { SessionRecordRegistry } from '../session/session-record-registry'; import type { ConversationCheckpointService } from './checkpoint-service'; import type { ProviderPartition } from './lineage-attribution'; @@ -217,7 +220,9 @@ export class ConversationProjectionService { for (let i = 0, len = path.length; i < len; i++) { const turn = path[i]; const content = contents[i]; - if (content !== undefined) items.push(projectedUserRow(turn, content)); + if (content !== undefined) { + items.push(projectedUserRow(turn, content, runHistoryId(record, turn.runId))); + } if (!TERMINAL_TURN_STATES.has(turn.state)) continue; // in-flight output rides the live tail if (turn.state === 'failed') continue; // nothing durable ran; the state badge is the story const partition = attributed[partitionIndex]; @@ -446,16 +451,28 @@ function projectedItem( }; } -function projectedUserRow(turn: ConversationTurn, content: ContentBlock[]): ConversationReadItem { +function runHistoryId(record: SessionRecord, runId: RunId): AgentHistoryId | undefined { + return record.runs.find((run) => run.runId === runId)?.historyId; +} + +function projectedUserRow( + turn: ConversationTurn, + content: ContentBlock[], + historyId: AgentHistoryId | undefined, +): ConversationReadItem { return { turnId: turn.turnId, runId: turn.runId, ts: turn.createdAt, event: { type: 'user-message', - // Deterministic identity: re-reads and page overlaps converge on one row per turn. - messageId: MessageIdSchema.parse(`msg-${turn.turnId}`), + messageId: userRowMessageId(turn.turnId), content, + // The cursor the live echo carries, so legacy `history.branch` can edit a row that was read + // rather than seen live; absent until the run's adapter reports its history. + ...(historyId !== undefined && { + branchCursor: encodeLiveBranchCursor(historyId, turn.turnId), + }), }, }; } diff --git a/packages/host/engine/src/session/live-session.ts b/packages/host/engine/src/session/live-session.ts index c64ec5010..fe07b1f65 100644 --- a/packages/host/engine/src/session/live-session.ts +++ b/packages/host/engine/src/session/live-session.ts @@ -251,6 +251,6 @@ export function promptContentFingerprint(content: ContentBlock[]): string { return createHash('sha256').update(contentToText(content)).digest('base64url'); } -function encodeLiveBranchCursor(historyId: AgentHistoryId, turnId: TurnId): string { +export function encodeLiveBranchCursor(historyId: AgentHistoryId, turnId: TurnId): string { return JSON.stringify({ type: LIVE_BRANCH_CURSOR_TYPE, historyId, turnId }); } diff --git a/packages/host/engine/src/session/orchestrator.ts b/packages/host/engine/src/session/orchestrator.ts index 90fcc4c54..ebcf49251 100644 --- a/packages/host/engine/src/session/orchestrator.ts +++ b/packages/host/engine/src/session/orchestrator.ts @@ -1,5 +1,4 @@ import type { AdapterFactory, AgentAdapter, BrowserToolsetFactory } from '@linkcode/agent-adapter'; -import { nextMessageId } from '@linkcode/agent-adapter'; import type { AgentEvent, AgentHistoryCapabilities, @@ -13,6 +12,7 @@ import type { SessionInfo, SessionRecord, } from '@linkcode/schema'; +import { userRowMessageId } from '@linkcode/schema'; import type { Transport } from '@linkcode/transport'; import { createWireMessage } from '@linkcode/transport'; import { Cause, Deferred, Effect, Exit, Scope } from 'effect'; @@ -221,9 +221,15 @@ export class SessionOrchestrator { input: { type: 'prompt', blocks: promptBlocksFromContent(content) }, }); const result = yield* Effect.sync(() => { - this.events.broadcast(sessionId, session, [ - { type: 'user-message', messageId: nextMessageId(), content }, - ]); + this.events.broadcast( + sessionId, + session, + session.trackPrompt( + userRowMessageId(intent.turn.turnId), + content, + intent.turn.turnId, + ), + ); records.setTitleFromContent(sessionId, content); }).pipe( Effect.andThen( diff --git a/packages/host/engine/src/session/session-input-dispatcher.ts b/packages/host/engine/src/session/session-input-dispatcher.ts index 8dcf8ebd3..1075303a9 100644 --- a/packages/host/engine/src/session/session-input-dispatcher.ts +++ b/packages/host/engine/src/session/session-input-dispatcher.ts @@ -1,6 +1,5 @@ -import { nextMessageId } from '@linkcode/agent-adapter'; import type { AgentInput, SessionId } from '@linkcode/schema'; -import { agentCommandMatches } from '@linkcode/schema'; +import { agentCommandMatches, userRowMessageId } from '@linkcode/schema'; import { Cause, Effect, Exit } from 'effect'; import { nullthrow } from 'foxts/guard'; import type { ConversationTurnService, PersistedTurnIntent } from '../conversation/turn-service'; @@ -64,7 +63,6 @@ export class SessionInputDispatcher { return Effect.fail(error); } const { events, records, resources, turns } = this; - const promptMessageId = input.type === 'prompt' ? nextMessageId() : undefined; // Set synchronously, before the first await, so a same-tick second turn input cannot slip // past the gate above while this one is still validating; every failure exit releases it. if (startsTurn) session.turnInputActive = true; @@ -118,28 +116,34 @@ export class SessionInputDispatcher { }); } const persisted = intent; + const persistedTurnId = startsTurn + ? nullthrow(persisted, 'turn input without a persisted turn').turn.turnId + : undefined; + // The echo carries the durable row's identity: a client's live view and its later + // conversation.read converge on one row per turn instead of reconciling two ids. + const echoMessageId = + persistedTurnId === undefined ? undefined : userRowMessageId(persistedTurnId); const dispatch = Effect.gen(function* () { // Echo before awaiting send: provider events can outrun the dispatch acknowledgement. - if (promptMessageId !== undefined && input.type === 'prompt') { - const { turnId } = nullthrow(persisted, 'prompt dispatch without a persisted turn').turn; - events.broadcast( - sessionId, - session, - session.trackPrompt(promptMessageId, input.content, turnId), - ); - records.setTitleFromContent(sessionId, input.content); - } else if (input.type === 'command' || input.type === 'shell-command') { - const text = - input.type === 'command' - ? `/${input.name}${input.arguments ? ` ${input.arguments}` : ''}` - : `$ ${input.command}`; - events.broadcast(sessionId, session, [ - { - type: 'user-message', - messageId: nextMessageId(), - content: [{ type: 'text', text }], - }, - ]); + if (persistedTurnId !== undefined && echoMessageId !== undefined) { + if (input.type === 'prompt') { + events.broadcast( + sessionId, + session, + session.trackPrompt(echoMessageId, input.content, persistedTurnId), + ); + records.setTitleFromContent(sessionId, input.content); + } else if (input.type === 'command' || input.type === 'shell-command') { + const text = + input.type === 'command' + ? `/${input.name}${input.arguments ? ` ${input.arguments}` : ''}` + : `$ ${input.command}`; + events.broadcast( + sessionId, + session, + session.trackPrompt(echoMessageId, [{ type: 'text', text }], persistedTurnId), + ); + } } const responseInput = input.type === 'permission-response' || input.type === 'question-response' @@ -177,7 +181,7 @@ export class SessionInputDispatcher { session.interactions.restoreResponse(responseInput.requestId, respondingAsk), ); } - if (promptMessageId !== undefined) session.untrackPrompt(promptMessageId); + if (echoMessageId !== undefined) session.untrackPrompt(echoMessageId); if (startsTurn) events.rejectInput(sessionId, session, error.publicMessage); }), ), diff --git a/packages/presentation/i18n/src/locales/en.ts b/packages/presentation/i18n/src/locales/en.ts index 02a181740..35bba2d47 100644 --- a/packages/presentation/i18n/src/locales/en.ts +++ b/packages/presentation/i18n/src/locales/en.ts @@ -136,6 +136,7 @@ export const en = { compacting: 'Compacting context…', compacted: 'Context compacted', compactedTokens: '{pre} → {post} tokens', + historyUnavailable: 'Output for this turn is unavailable', insufficientCreditsTitle: 'LinkCode credits needed', insufficientCreditsHint: 'Top up your balance, then retry this message.', topUpCredits: 'Top up credits', @@ -1392,6 +1393,7 @@ export const en = { compacting: 'Compacting context…', compacted: 'Context compacted', compactedTokens: '{pre} → {post} tokens', + historyUnavailable: 'Output for this turn is unavailable', }, chat: { allowTitle: 'Allow "{title}"?', diff --git a/packages/presentation/i18n/src/locales/zh-cn.ts b/packages/presentation/i18n/src/locales/zh-cn.ts index 68d11d116..0f6b69285 100644 --- a/packages/presentation/i18n/src/locales/zh-cn.ts +++ b/packages/presentation/i18n/src/locales/zh-cn.ts @@ -132,6 +132,7 @@ export const zhCN = { compacting: '正在压缩上下文…', compacted: '上下文已压缩', compactedTokens: '{pre} → {post} tokens', + historyUnavailable: '这一轮的输出已不可用', insufficientCreditsTitle: '需要 LinkCode 额度', insufficientCreditsHint: '充值后即可安全重试这条消息。', topUpCredits: '充值额度', @@ -1348,6 +1349,7 @@ export const zhCN = { compacting: '正在压缩上下文…', compacted: '上下文已压缩', compactedTokens: '{pre} → {post} tokens', + historyUnavailable: '这一轮的输出已不可用', }, chat: { allowTitle: '允许「{title}」?', diff --git a/packages/presentation/ui/src/__tests__/activity-groups.test.ts b/packages/presentation/ui/src/__tests__/activity-groups.test.ts index 0f5c9b3f2..075a7412f 100644 --- a/packages/presentation/ui/src/__tests__/activity-groups.test.ts +++ b/packages/presentation/ui/src/__tests__/activity-groups.test.ts @@ -60,12 +60,14 @@ function approvalFor(toolCallId: string): ConversationItem { } function boundary( - kind: 'plan' | 'approval' | 'question' | 'error' | 'compaction', + kind: 'plan' | 'approval' | 'question' | 'error' | 'compaction' | 'history-unavailable', ): ConversationItem { const id = `boundary-${seq++}`; switch (kind) { case 'plan': return { kind, id, turnId: 'turn-0', plan: { planId: id, entries: [] } }; + case 'history-unavailable': + return { kind, id, turnId: 'turn-0' }; case 'approval': return approvalFor(`unrelated-${id}`); case 'question': diff --git a/packages/presentation/ui/src/chat/history-unavailable-marker.tsx b/packages/presentation/ui/src/chat/history-unavailable-marker.tsx new file mode 100644 index 000000000..711c3edba --- /dev/null +++ b/packages/presentation/ui/src/chat/history-unavailable-marker.tsx @@ -0,0 +1,23 @@ +import { HistoryIcon } from 'lucide-react'; +import { useTranslations } from 'use-intl'; +import { + CHAT_DISCLOSURE_TEXT_CLASS_NAME, + CHAT_DISCLOSURE_TITLE_CLASS_NAME, + ChatDisclosureIconSlot, +} from './disclosure-header'; + +/** Stands where a turn's provider output would render when the daemon could not project it: the + * prompt-only fallback for a lost, compacted, or never-recorded transcript. */ +export function HistoryUnavailableMarker(): React.ReactNode { + const t = useTranslations('workbench.conversation'); + return ( +
+ + + + + {t('historyUnavailable')} + +
+ ); +} diff --git a/packages/presentation/ui/src/chat/turn-segment-view.tsx b/packages/presentation/ui/src/chat/turn-segment-view.tsx index cfdc36b66..f5dc76ef2 100644 --- a/packages/presentation/ui/src/chat/turn-segment-view.tsx +++ b/packages/presentation/ui/src/chat/turn-segment-view.tsx @@ -10,6 +10,7 @@ import type { QuestionConversationItem } from './conversation-prompts'; import { declinedToolCall } from './conversation-prompts'; import { assistantTurnText, latestReceivedAt, turnModel } from './conversation-text'; import { ErrorMessage } from './error-message'; +import { HistoryUnavailableMarker } from './history-unavailable-marker'; import { Message, MessageContent } from './message'; import { QuestionCallItem } from './question-call-item'; import { SubagentCard } from './subagent-card'; @@ -193,6 +194,8 @@ export function TurnSegmentView({ summary={item.summary} /> ); + case 'history-unavailable': + return ; case 'approval': // Accepted / pending asks leave no receipt — the tool row (or the dock card) is the // record. A decline only materializes here when the agent never snapshotted the call. diff --git a/packages/presentation/ui/src/chat/types.ts b/packages/presentation/ui/src/chat/types.ts index 811d2719e..62b81bbb2 100644 --- a/packages/presentation/ui/src/chat/types.ts +++ b/packages/presentation/ui/src/chat/types.ts @@ -71,6 +71,9 @@ export type ConversationItem = postTokens?: number; summary?: string; }) + /** The daemon could not project this turn's provider output (no-history harness, lost or + * compacted transcript, migrated turn): the host prompt row is all there is. */ + | (ConversationItemBase & { kind: 'history-unavailable' }) | (ConversationItemBase & { kind: 'plan'; /** Turn that most recently emitted this stable plan identity. */