diff --git a/apps/desktop/src/renderer/src/shell/desktop-shell.tsx b/apps/desktop/src/renderer/src/shell/desktop-shell.tsx index a4a62df8a..4fb0b4a25 100644 --- a/apps/desktop/src/renderer/src/shell/desktop-shell.tsx +++ b/apps/desktop/src/renderer/src/shell/desktop-shell.tsx @@ -92,6 +92,7 @@ export function DesktopShell({ onOpenBilling, conversation, onEditPrompt, + lineage, respondingRequestIds, responseErrors, resourcesPanel, @@ -465,6 +466,7 @@ export function DesktopShell({ : 'unsupported' } onEditPrompt={onEditPrompt} + lineage={lineage} disabled={!active || active.status === 'stopped'} isRunning={isRunning} mentionItems={mentionItems} diff --git a/packages/client/core/AGENTS.md b/packages/client/core/AGENTS.md index bfdbcb270..d7e7a60e8 100644 --- a/packages/client/core/AGENTS.md +++ b/packages/client/core/AGENTS.md @@ -41,6 +41,20 @@ Rules the projection store enforces — keep them when touching it: hid the durable refs. Pending drafts render from the client's blob cache until submit roots the attachment. - Live user echoes carry no envelope `turnId` (they precede turn tracking); never bucket by it. +- **A parked view is frozen at its read — its content, not the session's state.** Browsing an + earlier version reads toward that `leafTurnId` (`ConversationSeedSource.leafTurnId`) with + `followLive: false`: the live stream's content belongs to the active lineage's run and must not + fold into another version, and a graph change is the owner's business (the "continued + elsewhere" chip), not a re-read. Session state (`status`, policy, model, effort, mode, + capabilities, commands, models, usage) still folds from the live buffer, latest wins, no + watermark — those events reach a client only live, so a store that skipped them would render the + composer at defaults while parked. The caller decides + `followLive` from the read the store holds — a leaf on, behind, or ahead of the host default + follows; another version is frozen — never from view state, which runs ahead of the read by a + round trip on every switch. +- **Edits and continues are explicit-parent submits** (`submitTurn(…, target)`): the daemon + validates the parent and `expectedGraphRevision` and answers typed `conflict`/`busy`; the + client never calls `history.branch` on a host that serves the graph and can fork. `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/src/client.ts b/packages/client/core/src/client.ts index 590a835c4..c0d84a6ad 100644 --- a/packages/client/core/src/client.ts +++ b/packages/client/core/src/client.ts @@ -102,6 +102,7 @@ import type { ConversationReadClientOptions, HistoryListClientOptions, HistoryReadClientOptions, + TurnSubmitTarget, } from './client/control-channel'; import { ControlChannel } from './client/control-channel'; import type { ConversationGraphChange } from './client/conversation-graph-changes'; @@ -139,6 +140,7 @@ export type { ConversationReadClientOptions, HistoryListClientOptions, HistoryReadClientOptions, + TurnSubmitTarget, } from './client/control-channel'; export type { ConversationGraphChange } from './client/conversation-graph-changes'; export type { AgentEventEnvelope, SequencedAgentEvent } from './client/event-buffer'; @@ -848,8 +850,12 @@ export class LinkCodeClient { } /** See {@link ControlChannel.submitTurn}. */ - submitTurn(sessionId: SessionId, input: TurnSubmitInput): Promise { - return this.control.submitTurn(sessionId, input); + submitTurn( + sessionId: SessionId, + input: TurnSubmitInput, + target?: TurnSubmitTarget, + ): Promise { + return this.control.submitTurn(sessionId, input, target); } /** The newest `conversation.graph.changed` seen for the session on this connection. */ diff --git a/packages/client/core/src/client/attachment-channel.ts b/packages/client/core/src/client/attachment-channel.ts index d8eadba40..499adc271 100644 --- a/packages/client/core/src/client/attachment-channel.ts +++ b/packages/client/core/src/client/attachment-channel.ts @@ -3,6 +3,8 @@ import { ATTACHMENT_UPLOAD_CHUNK_BYTES, ATTACHMENT_UPLOAD_WINDOW_CHUNKS, MAX_ATTACHMENT_BYTES, + MAX_ATTACHMENT_NAME_LENGTH, + MAX_MIME_TYPE_LENGTH, } from '@linkcode/schema'; import type { Transport } from '@linkcode/transport'; import { noop } from 'foxts/noop'; @@ -48,6 +50,17 @@ export class AttachmentChannel { ) {} beginUpload(input: AttachmentBeginInput): Promise { + // The wire schema bounds both fields; an over-long begin is dropped unanswered, never refused. + if (input.name.length > MAX_ATTACHMENT_NAME_LENGTH) { + return Promise.reject( + new Error(`Attachment name exceeds ${MAX_ATTACHMENT_NAME_LENGTH} characters`), + ); + } + if (input.mimeType !== undefined && input.mimeType.length > MAX_MIME_TYPE_LENGTH) { + return Promise.reject( + new Error(`Attachment MIME type exceeds ${MAX_MIME_TYPE_LENGTH} characters`), + ); + } return sendCorrelated(this.transport, this.pending, 'attachmentBegin', (clientReqId) => ({ kind: 'attachment.upload.begin', clientReqId, @@ -144,10 +157,13 @@ export class AttachmentChannel { let offset = 0; while (offset < first.sizeBytes) { const slice = base64ToBytes(page.data); - // A page that repeats an offset, returns nothing, or overruns the recorded size cannot be - // assembled — without this the walk never advances and zero-fills what it could not read. + // A page that repeats an offset, returns nothing, overruns the recorded size, or names another + // blob or size cannot be assembled — without this the walk never advances, zero-fills what it + // could not read, or caches spliced bytes under the first page's blob id. if ( page.offset !== offset || + page.blobId !== first.blobId || + page.sizeBytes !== first.sizeBytes || slice.byteLength === 0 || offset + slice.byteLength > bytes.byteLength ) { diff --git a/packages/client/core/src/client/control-channel.ts b/packages/client/core/src/client/control-channel.ts index 2a4d63d16..0c0d40c91 100644 --- a/packages/client/core/src/client/control-channel.ts +++ b/packages/client/core/src/client/control-channel.ts @@ -102,6 +102,13 @@ export interface ConversationReadClientOptions { limit?: number; } +/** Where an explicit-parent submit lands: `null` starts a new root lineage (editing the first + * prompt); a turn id submits a sibling under that turn (edit) or a child of a leaf (continue). */ +export interface TurnSubmitTarget { + parentTurnId: TurnId | null; + expectedGraphRevision: number; +} + /** * Correlated control-plane requests (sessions, history, config, git, workspaces); replies are * correlated via the shared {@link PendingRegistry} (see {@link sendCorrelated}). @@ -186,16 +193,26 @@ export class ControlChannel { } /** - * Plain send onto the active leaf. `parentTurnId` is omitted on purpose — explicit-parent - * submit is a later client. Idempotency is a fresh `operationId` per call. + * Without `target`, a plain send onto the active leaf — no parent, no revision, so a device + * racing a turn another device just finished never conflicts on staleness. With `target`, the + * edit/continue path: the daemon validates the parent and the graph revision. Idempotency is a + * fresh `operationId` per call. */ - submitTurn(sessionId: SessionId, input: TurnSubmitInput): Promise { + submitTurn( + sessionId: SessionId, + input: TurnSubmitInput, + target?: TurnSubmitTarget, + ): Promise { return this.sendCorrelated('turnSubmit', (clientReqId) => ({ kind: 'turn.submit', clientReqId, sessionId, operationId: OperationIdSchema.parse(`op-${clientReqId}`), input, + ...(target !== undefined && { + parentTurnId: target.parentTurnId, + expectedGraphRevision: target.expectedGraphRevision, + }), })); } diff --git a/packages/client/core/src/client/conversation-graph-changes.ts b/packages/client/core/src/client/conversation-graph-changes.ts index d25e92da0..67f8ac8df 100644 --- a/packages/client/core/src/client/conversation-graph-changes.ts +++ b/packages/client/core/src/client/conversation-graph-changes.ts @@ -1,7 +1,8 @@ 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. */ +/** One `conversation.graph.changed` broadcast: the graph moved its default leaf or gained shape + * (a new revision), or a turn reached its terminal state (the revision stands). */ export interface ConversationGraphChange { graphRevision: number; activeLeafTurnId?: TurnId; @@ -12,7 +13,8 @@ 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. + * and where its leaf is. Subscribers hear every announcement — a same-revision one carries a turn + * state they must refetch. */ export class ConversationGraphChanges { private readonly latest = new Map(); @@ -20,7 +22,7 @@ export class ConversationGraphChanges { note(sessionId: SessionId, change: ConversationGraphChange): void { const current = this.latest.get(sessionId); - if (current !== undefined && current.graphRevision >= change.graphRevision) return; + 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); diff --git a/packages/client/core/src/conversation-read.ts b/packages/client/core/src/conversation-read.ts index 4351246d3..73d75beef 100644 --- a/packages/client/core/src/conversation-read.ts +++ b/packages/client/core/src/conversation-read.ts @@ -102,6 +102,9 @@ export interface ConversationSeedSource { agentKind: AgentKind; cwd: string; historyId?: AgentHistoryId; + /** Read toward this leaf instead of the session's active one — a client browsing an earlier + * version. Only the projection path knows lineages; the transcript fallback ignores it. */ + leafTurnId?: TurnId; } /** Transcript pages one history read follows before giving up on a buggy cursor. */ @@ -118,7 +121,11 @@ export async function readConversationSeed( source: ConversationSeedSource, ): Promise { if (client.supportsConversationGraph) { - const projection = await readConversationProjection(client, source.sessionId); + const projection = await readConversationProjection( + client, + source.sessionId, + source.leafTurnId === undefined ? {} : { leafTurnId: source.leafTurnId }, + ); if (projection !== undefined) return projection; } if (source.historyId === undefined) return undefined; diff --git a/packages/client/core/src/conversation-store.ts b/packages/client/core/src/conversation-store.ts index 30e60f709..34feff2c1 100644 --- a/packages/client/core/src/conversation-store.ts +++ b/packages/client/core/src/conversation-store.ts @@ -21,6 +21,12 @@ 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; + /** `false` freezes a projection store's content at its read: a client browsing an inactive + * lineage must not fold the active run's live stream, and a graph change is the owner's business + * (the "continued elsewhere" chip), not a re-read. Session state — policy, model, effort, mode, + * capabilities, commands, usage, status — still follows: it is the session's, not a lineage's, + * and the composer renders it. Default `true`. */ + followLive?: boolean; } const EMPTY_CONVERSATION: Conversation = { @@ -58,7 +64,13 @@ export function createConversationStore( return { subscribe: () => noop, getSnapshot: () => EMPTY_CONVERSATION }; } if (seed !== undefined && 'items' in seed) { - return createProjectionStore(client, sessionId, seed, options.onResync ?? noop); + return createProjectionStore( + client, + sessionId, + seed, + options.onResync ?? noop, + options.followLive ?? true, + ); } return createHistoryStore(client, sessionId, seed, options.onResync ?? noop); } @@ -74,6 +86,21 @@ const INTERACTIVE_EVENT_TYPES = new Set([ 'prompt-response-status', ]); +/** Session state, not lineage content: the latest of each wins, so a frozen store folds them + * without a watermark — a parked composer must not fall back to defaults. */ +const SESSION_STATE_EVENT_TYPES = new Set([ + 'status', + 'current-mode-update', + 'approval-policy-update', + 'model-update', + 'effort-update', + 'available-commands-update', + 'available-models-update', + 'capabilities-update', + 'token-usage', + 'usage-report', +]); + /** * 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 @@ -86,6 +113,7 @@ function createProjectionStore( sessionId: SessionId, seed: ConversationProjectionSeed, onResync: (reason: ConversationResyncReason) => void, + followLive: boolean, ): ConversationStore { const builder = createConversationBuilder(); const userMessageIds = new Set(); @@ -145,6 +173,10 @@ function createProjectionStore( const events = client.eventsSnapshot(sessionId); for (let i = firstIndexAfter(events, consumedSeq), len = events.length; i < len; i += 1) { const entry = events[i]; + if (!followLive) { + if (SESSION_STATE_EVENT_TYPES.has(entry.event.type)) fold(entry.event, entry.receivedAt); + continue; + } if (admit(entry)) fold(entry.event, entry.receivedAt); } consumedSeq = client.eventSeq(sessionId); @@ -152,7 +184,8 @@ function createProjectionStore( /** 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. */ + * (an edit or rewrite from any device, a stale read) needs the re-read. A fork's row cannot + * pass: it relaunches under a new epoch, which `admit` flags first, and reads carry no echoes. */ const checkGraph = (change: ConversationGraphChange | undefined): void => { if (change === undefined || change.graphRevision <= seed.graphRevision) return; if ( @@ -167,11 +200,13 @@ function createProjectionStore( return { subscribe(onStoreChange) { sync(); - checkGraph(client.latestGraphChange(sessionId)); const unsubscribeEvents = client.subscribe(sessionId, () => { sync(); onStoreChange(); }); + // A frozen store keeps its session state live but leaves graph changes to its owner. + if (!followLive) return unsubscribeEvents; + checkGraph(client.latestGraphChange(sessionId)); const unsubscribeGraph = client.subscribeGraphChanges(sessionId, (change) => { sync(); checkGraph(change); diff --git a/packages/client/core/src/react.tsx b/packages/client/core/src/react.tsx index bad22447c..e112d01ab 100644 --- a/packages/client/core/src/react.tsx +++ b/packages/client/core/src/react.tsx @@ -110,12 +110,13 @@ export function useConversation( sessionId: SessionId | null, seed?: ConversationSeed | ConversationProjectionSeed, onResync?: (reason: ConversationResyncReason) => void, + followLive = true, ): Conversation { const client = useLinkCodeClient(); const handleResync = useStableHandler(onResync ?? noop); const store = useMemo( - () => createConversationStore(client, sessionId, seed, { onResync: handleResync }), - [client, sessionId, seed, handleResync], + () => createConversationStore(client, sessionId, seed, { onResync: handleResync, followLive }), + [client, sessionId, seed, handleResync, followLive], ); return useSyncExternalStore(store.subscribe, store.getSnapshot); } diff --git a/packages/client/core/tests/integration/attachment-client.test.ts b/packages/client/core/tests/integration/attachment-client.test.ts index 621187697..f17161293 100644 --- a/packages/client/core/tests/integration/attachment-client.test.ts +++ b/packages/client/core/tests/integration/attachment-client.test.ts @@ -4,6 +4,8 @@ import { AttachmentIdSchema, BlobIdSchema, MAX_ATTACHMENT_BYTES, + MAX_ATTACHMENT_NAME_LENGTH, + MAX_MIME_TYPE_LENGTH, SessionIdSchema, UploadIdSchema, } from '@linkcode/schema'; @@ -259,6 +261,39 @@ describe('LinkCodeClient attachment store API', () => { serverTransport.close(); }); + it('fails the read walk when a later page names another blob', async () => { + const { client, serverTransport } = await createConnectedLocalClient(); + const bytes = new Uint8Array(ATTACHMENT_UPLOAD_CHUNK_BYTES + 8).fill(5); + const attachmentId = AttachmentIdSchema.parse('att-5'); + const sessionId = SessionIdSchema.parse('session-1'); + + serverTransport.onMessage((message) => { + const p = message.payload; + if (p.kind !== 'attachment.read') return; + const slice = bytes.subarray(p.offset, p.offset + p.length); + serverTransport.send( + createWireMessage({ + kind: 'attachment.read.result', + replyTo: p.clientReqId, + sessionId: p.sessionId, + attachmentId: p.attachmentId, + // The second page answers from another record: its bytes must not be spliced in and + // cached under the first page's blob. + blobId: BlobIdSchema.parse(`sha256:${(p.offset === 0 ? 'f' : '0').repeat(64)}`), + offset: p.offset, + data: bytesToBase64(slice), + sizeBytes: bytes.byteLength, + eof: p.offset + slice.byteLength >= bytes.byteLength, + }), + ); + }); + + await expect(client.getAttachmentBytes(sessionId, attachmentId)).rejects.toThrow('att-5'); + + client.dispose(); + serverTransport.close(); + }); + it('aborts the upload when a chunk is rejected', async () => { const { client, serverTransport } = await createConnectedLocalClient(); const bytes = new Uint8Array(ATTACHMENT_UPLOAD_CHUNK_BYTES * 2).fill(5); @@ -343,6 +378,33 @@ describe('LinkCodeClient attachment guards', () => { serverTransport.close(); }); + it('rejects an over-long name or MIME type before any frame leaves the client', async () => { + const { client, serverTransport } = await createConnectedLocalClient(); + const seen: string[] = []; + serverTransport.onMessage((message) => { + seen.push(message.payload.kind); + }); + const bytes = new Uint8Array(4); + await expect( + client.putAttachment({ + bytes, + name: 'n'.repeat(MAX_ATTACHMENT_NAME_LENGTH + 1), + attachmentKind: 'file', + }), + ).rejects.toThrow('name exceeds'); + await expect( + client.putAttachment({ + bytes, + name: 'ok.bin', + mimeType: `text/${'x'.repeat(MAX_MIME_TYPE_LENGTH)}`, + attachmentKind: 'file', + }), + ).rejects.toThrow('MIME type exceeds'); + expect(seen).not.toContain('attachment.upload.begin'); + client.dispose(); + serverTransport.close(); + }); + it('fails typed against a peer without the attachment store instead of hanging', async () => { const [clientTransport, serverTransport] = createLocalTransportPair(); await serverTransport.connect(); diff --git a/packages/client/core/tests/integration/conversation-client.test.ts b/packages/client/core/tests/integration/conversation-client.test.ts index d56ee8e1d..d50791be1 100644 --- a/packages/client/core/tests/integration/conversation-client.test.ts +++ b/packages/client/core/tests/integration/conversation-client.test.ts @@ -1,5 +1,5 @@ import type { RunId, SessionId, TurnId } from '@linkcode/schema'; -import { AttachmentIdSchema, WIRE_PROTOCOL_VERSION } from '@linkcode/schema'; +import { AttachmentIdSchema, CONVERSATION_GRAPH_WIRE_VERSION } from '@linkcode/schema'; import { createLocalTransportPair, createWireMessage } from '@linkcode/transport'; import { wait } from 'foxts/wait'; import { describe, expect, it } from 'vitest'; @@ -25,8 +25,8 @@ describe('LinkCodeClient conversation graph API', () => { serverTransport.send( createWireMessage({ kind: 'pong', - version: WIRE_PROTOCOL_VERSION - 1, - minCompatible: WIRE_PROTOCOL_VERSION - 4, + version: CONVERSATION_GRAPH_WIRE_VERSION - 1, + minCompatible: CONVERSATION_GRAPH_WIRE_VERSION - 4, }), ); } @@ -140,6 +140,36 @@ describe('LinkCodeClient conversation graph API', () => { serverTransport.close(); }); + it('sends the parent and revision for an explicit-parent turn.submit', async () => { + const { client, serverTransport } = await createConnectedLocalClient(); + const submitted: unknown[] = []; + serverTransport.onMessage((msg) => { + const p = msg.payload; + if (p.kind !== 'turn.submit') return; + submitted.push(p); + serverTransport.send( + createWireMessage({ kind: 'turn.submitted', replyTo: p.clientReqId, turnId: leafTurnId }), + ); + }); + + await client.submitTurn( + sessionId, + { type: 'prompt', blocks: [{ type: 'text', text: 'again' }] }, + { parentTurnId: null, expectedGraphRevision: 4 }, + ); + await client.submitTurn( + sessionId, + { type: 'prompt', blocks: [{ type: 'text', text: 'onward' }] }, + { parentTurnId: leafTurnId, expectedGraphRevision: 5 }, + ); + expect(submitted).toEqual([ + expect.objectContaining({ parentTurnId: null, expectedGraphRevision: 4 }), + expect.objectContaining({ parentTurnId: leafTurnId, expectedGraphRevision: 5 }), + ]); + client.dispose(); + serverTransport.close(); + }); + it('resolves a plain-send turn.submit without parent or revision', async () => { const { client, serverTransport } = await createConnectedLocalClient(); const submitted: unknown[] = []; diff --git a/packages/client/core/tests/integration/conversation-read.test.ts b/packages/client/core/tests/integration/conversation-read.test.ts index 9f30af869..9197acc60 100644 --- a/packages/client/core/tests/integration/conversation-read.test.ts +++ b/packages/client/core/tests/integration/conversation-read.test.ts @@ -8,7 +8,7 @@ import type { import { createWireMessage } from '@linkcode/transport'; import { describe, expect, it } from 'vitest'; import type { ConversationReadPage } from '../../src/client'; -import { readConversationProjection } from '../../src/conversation-read'; +import { readConversationProjection, readConversationSeed } from '../../src/conversation-read'; import { createConnectedLocalClient } from '../support/local-client'; const sessionId = 'sess-read' as SessionId; @@ -155,4 +155,23 @@ describe('readConversationProjection', () => { await expect(readConversationProjection(client, sessionId)).resolves.toBeUndefined(); close(); }); + + it('reads toward the leaf a seed source names instead of the active one', async () => { + const parked = 'turn-old' as TurnId; + const { client, requests, close } = await readingHarness(() => + page([userRow('turn-old', 'old version')], { + leafTurnId: parked, + watermark: { epoch: 1, seq: 4 }, + }), + ); + const seed = await readConversationSeed(client, { + sessionId, + agentKind: 'codex', + cwd: '/repo', + leafTurnId: parked, + }); + expect(requests.map((request) => request.leafTurnId)).toEqual([parked]); + expect(seed !== undefined && 'items' in seed ? seed.leafTurnId : undefined).toBe(parked); + 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 index 5ec355a1a..5cb010574 100644 --- a/packages/client/core/tests/integration/conversation-store-projection.test.ts +++ b/packages/client/core/tests/integration/conversation-store-projection.test.ts @@ -102,6 +102,34 @@ async function harness() { const tick = (): Promise => wait(10); describe('projection conversation store', () => { + it('freezes a parked read’s content — live rows and graph changes never reach it — but not the session’s state', async () => { + const h = await harness(); + const store = createConversationStore( + h.client, + sessionId, + seedOf([userRow(1, 'old version')], { epoch: 1, seq: 1 }), + { onResync: (reason) => h.resyncs.push(reason), followLive: false }, + ); + const unsubscribe = store.subscribe(noop); + h.send(echo(2, 'the active lineage moves on'), { epoch: 1, seq: 2 }); + h.send(chunk('a2', 'streaming into the other version'), { epoch: 1, seq: 3 }); + h.graphChanged(7, turn(2)); + // The composer renders these while parked; they are the session's, not the lineage's. + h.send({ type: 'model-update', model: 'claude-fable-5' }, { epoch: 1, seq: 4 }); + h.send({ type: 'effort-update', effort: 'high' }, { epoch: 1, seq: 5 }); + h.send({ type: 'status', status: 'running' }, { epoch: 1, seq: 6 }); + await tick(); + + expect(texts(store)).toEqual(['old version']); + const snapshot = store.getSnapshot(); + expect(snapshot.currentModel).toBe('claude-fable-5'); + expect(snapshot.currentEffort).toBe('high'); + expect(snapshot.status).toBe('running'); + expect(h.resyncs).toEqual([]); + unsubscribe(); + h.close(); + }); + 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 }); @@ -221,6 +249,19 @@ describe('projection conversation store', () => { h.close(); }); + it('re-reads a fork whose leaf row arrived live: its relaunch’s epoch asks before the graph move', async () => { + const h = await harness(); + const store = h.store( + seedOf([userRow(1, 'first'), userRow(2, 'second')], { epoch: 1, seq: 2 }, 3), + ); + store.subscribe(noop); + h.send(echo(9, 'edited second'), { epoch: 2, seq: 1 }); + h.graphChanged(4, turn(9)); + await tick(); + expect(h.resyncs).toEqual(['epoch']); + h.close(); + }); + it('renders history-unavailable placeholders under their turn', async () => { const h = await harness(); const store = h.store( diff --git a/packages/client/workbench/AGENTS.md b/packages/client/workbench/AGENTS.md index ab0bad87b..f4d858070 100644 --- a/packages/client/workbench/AGENTS.md +++ b/packages/client/workbench/AGENTS.md @@ -35,7 +35,19 @@ app-specific entries (`apps/desktop`, `apps/webview`) and pure presentation (`pa 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. + persists both seed shapes through `seed-cache.ts` for the instant repaint on reopen. Version + browsing (`‹ 1/N ›`) is `lineage-store.ts` (non-persisted zustand: the parked leaf per session, + remembered descent per parent) + the pure helpers in `lineage.ts` + `use-conversation-graph.ts` + (the turn tree, revalidated on `conversation.graph.changed`). The seed hook keeps a stable SWR + key and reads the parked leaf at fetch time — a version switch revalidates in place instead of + flashing an empty timeline — and freezes the store (`followLive: false`) while the read it holds + was made toward a parked version the active lineage does not run through (`onActiveLineage`); a + read of the host default keeps following, and the next graph snapshot re-reads it once the tree + has moved past it (an edit from any device). A parked view's sends and `/`/`$` inputs are + explicit-parent `turn.submit`s under the version's last completed turn, an edit is a sibling + under the edited turn's parent, and a successful submit follows the host default again (the + daemon moved it before replying); the store also releases a parked view once the default runs + through its leaf. - `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 620bdb569..6b9a017dc 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 @@ -188,7 +188,8 @@ describe('dev mock host conversation parity', () => { ); expect(pagedRead.kind).toBe('request.failed'); - const parentSubmit = await request( + // An explicit-parent submit is admitted like the daemon's: a stale revision is a conflict. + const staleSubmit = await request( { kind: 'turn.submit', clientReqId: 's-parent', @@ -196,11 +197,11 @@ describe('dev mock host conversation parity', () => { operationId: OperationIdSchema.parse('op-mock-parent'), input: { type: 'shell-command', command: 'ls' }, parentTurnId: null, - expectedGraphRevision: 0, + expectedGraphRevision: 7, }, 's-parent', ); - expect(parentSubmit.kind).toBe('request.failed'); + expect(staleSubmit).toMatchObject({ kind: 'request.failed', code: 'conflict' }); }, 15000); it('fails loudly for conversation reads on unknown sessions', async () => { diff --git a/packages/client/workbench/src/mock/data/prompt.ts b/packages/client/workbench/src/mock/data/prompt.ts index 3fc0ad5aa..bda93148b 100644 --- a/packages/client/workbench/src/mock/data/prompt.ts +++ b/packages/client/workbench/src/mock/data/prompt.ts @@ -61,6 +61,10 @@ export const MOCK_USAGE_REPORT: UsageReport = { /** Prompting exactly this text forces the error path (the platform mocks' `?outcome=` analog). */ export const FAIL_PROMPT = 'fail'; +/** Prompting exactly this text is refused before dispatch: the tree gains a failed sibling, the + * default leaf stays, and the reply is the typed failure (the daemon's `resolveFailed` shape). */ +export const REFUSE_PROMPT = 'refuse'; +export const REFUSE_MESSAGE = `Mock refusal requested via the "${REFUSE_PROMPT}" prompt.`; /** Simulated round-trip on control ops so list/start loading states stay visible in dev. */ export const CONTROL_LATENCY_MS = 300; diff --git a/packages/client/workbench/src/mock/dev-mock-host.ts b/packages/client/workbench/src/mock/dev-mock-host.ts index cb5378f46..782747002 100644 --- a/packages/client/workbench/src/mock/dev-mock-host.ts +++ b/packages/client/workbench/src/mock/dev-mock-host.ts @@ -1,6 +1,7 @@ import type { Accounts, AgentEvent, + AgentHistoryCapabilities, AgentHistoryId, AgentHistorySession, AgentInput, @@ -77,6 +78,8 @@ import { FAIL_PROMPT, MOCK_REPLY, MOCK_USAGE_REPORT, + REFUSE_MESSAGE, + REFUSE_PROMPT, WORD_CHUNK_PATTERN, } from './data/prompt'; import { mockScriptDeclarations } from './data/scripts'; @@ -136,6 +139,14 @@ const MOCK_DEFAULT_EFFORTS: Readonly>> = 'grok-build': 'high', }; +/** What each adapter class declares; grok-build declares nothing. */ +const MOCK_HISTORY_CAPABILITIES: Readonly>> = { + 'claude-code': { list: true, read: true, resume: true, forkAfterTurn: true, branch: true }, + codex: { list: true, read: true, resume: true, forkAfterTurn: true, branch: true }, + opencode: { list: true, read: true, resume: true, forkAfterTurn: false, branch: true }, + pi: { list: true, read: true, resume: true, forkAfterTurn: true, branch: true }, +}; + interface MockSession extends SessionInfo { /** Host-only replay state: keep it off `session.list` so the mock crosses the schema boundary. */ model?: string; @@ -150,8 +161,11 @@ interface MockSession extends SessionInfo { 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). */ + /** Every turn ever minted, any lineage; siblings share a parent and take ordinals in order. */ graphTurns: MockTurn[]; + /** The host default view; moves on every submit, the way the daemon's does on dispatch. */ + activeLeafTurnId?: TurnId; + graphRevision: number; showcase?: boolean; showcaseSeeded?: boolean; longThread?: boolean; @@ -166,6 +180,8 @@ interface MockTurn { readContent?: ContentBlock[]; } +type MockReplyOutcome = { state: 'completed' | 'cancelled' } | { state: 'failed'; message: string }; + interface MockJournalEntry { epoch: number; seq: number; @@ -440,13 +456,14 @@ export class DevMockHost { this.sendFailure(p.clientReqId, `Unknown session: ${p.sessionId}`); break; } - const leaf = session.graphTurns.at(-1); this.send({ kind: 'conversation.graph.result', replyTo: p.clientReqId, sessionId: p.sessionId, - graphRevision: session.graphTurns.length, - ...(leaf !== undefined && { activeLeafTurnId: leaf.graph.turnId }), + graphRevision: session.graphRevision, + ...(session.activeLeafTurnId !== undefined && { + activeLeafTurnId: session.activeLeafTurnId, + }), turns: session.graphTurns.map((turn) => structuredClone(turn.graph)), }); break; @@ -459,19 +476,26 @@ export class DevMockHost { break; } // Fail loudly on parameters the mock would silently ignore. - if (p.leafTurnId !== undefined || p.cursor !== undefined || p.limit !== undefined) { + if (p.cursor !== undefined || p.limit !== undefined) { this.sendFailure(p.clientReqId, 'Dev mock host does not support read paging yet.'); break; } - const leaf = session.graphTurns.at(-1); + if ( + p.leafTurnId !== undefined && + !session.graphTurns.some((turn) => turn.graph.turnId === p.leafTurnId) + ) { + this.sendFailure(p.clientReqId, `Unknown turn: ${p.leafTurnId}`, { code: 'not_found' }); + break; + } + const leafTurnId = p.leafTurnId ?? session.activeLeafTurnId; this.send({ kind: 'conversation.read.result', replyTo: p.clientReqId, sessionId: p.sessionId, - graphRevision: session.graphTurns.length, - ...(leaf !== undefined && { leafTurnId: leaf.graph.turnId }), + graphRevision: session.graphRevision, + ...(leafTurnId !== undefined && { leafTurnId }), watermark: { epoch: session.eventEpoch, seq: session.eventSeq }, - events: readMockProjection(session), + events: readMockProjection(session, leafTurnId), }); break; } @@ -906,6 +930,8 @@ export class DevMockHost { | 'journal' | 'status' | 'graphTurns' + | 'activeLeafTurnId' + | 'graphRevision' > & { status: SessionStatus; origin?: SessionInfo['origin']; @@ -927,6 +953,7 @@ export class DevMockHost { eventSeq: 0, journal: [], graphTurns: [], + graphRevision: 0, }; this.sessions.set(session.sessionId, session); return session; @@ -1345,7 +1372,7 @@ export class DevMockHost { case 'shell-command': { const content = [textBlock(`$ ${input.command}`)]; const turn = this.beginTurn(session, content, input); - settleTurn(session, turn, 'completed'); + this.settleTurn(session, turn, 'completed'); this.sendSuccess(replyTo); break; } @@ -1397,7 +1424,7 @@ export class DevMockHost { } session.status = 'idle'; this.emit(session.sessionId, { type: 'status', status: 'idle' }); - settleTurn(session, turn, 'completed'); + this.settleTurn(session, turn, 'completed'); this.sendSuccess(replyTo); } @@ -1414,13 +1441,33 @@ export class DevMockHost { return; } if (session.status === 'running') { - this.sendFailure(p.clientReqId, `Session is busy: ${p.sessionId}`); + this.sendFailure(p.clientReqId, `Session is busy: ${p.sessionId}`, { code: 'busy' }); return; } - // Fail loudly on parameters the mock would silently ignore (plain sends only). - if (p.parentTurnId !== undefined || p.expectedGraphRevision !== undefined) { - this.sendFailure(p.clientReqId, 'Dev mock host does not support explicit-parent submits.'); - return; + // Explicit-parent submits carry the daemon's admit rules in its order: the parent must exist + // and have completed, then the revision must match. `null` starts a new root lineage. + let parentTurnId: TurnId | null | undefined; + if (p.parentTurnId !== undefined) { + if (p.parentTurnId !== null) { + const parent = session.graphTurns.find((turn) => turn.graph.turnId === p.parentTurnId); + if (parent === undefined) { + this.sendFailure(p.clientReqId, `Unknown turn: ${p.parentTurnId}`, { + code: 'not_found', + }); + return; + } + if (parent.graph.state !== 'completed') { + this.sendFailure(p.clientReqId, 'The parent turn has not completed', { + code: 'conflict', + }); + return; + } + } + if (p.expectedGraphRevision !== session.graphRevision) { + this.sendFailure(p.clientReqId, 'The conversation graph has moved', { code: 'conflict' }); + return; + } + parentTurnId = p.parentTurnId; } if (p.input.type === 'prompt') { const blocks = p.input.blocks; @@ -1447,44 +1494,95 @@ export class DevMockHost { } } const content = turnSubmitContent(p.input); - const turn = this.beginTurn(session, content, p.input.type === 'prompt' ? undefined : p.input); + if (p.input.type === 'prompt' && promptText(content).toLowerCase() === REFUSE_PROMPT) { + const refused = this.refuseTurn(session, content, parentTurnId); + refused.readContent = this.projectTurnSubmit(p.input); + this.sendFailure(p.clientReqId, REFUSE_MESSAGE, { code: 'operation_failed' }); + return; + } + const turn = this.beginTurn( + session, + content, + p.input.type === 'prompt' ? undefined : p.input, + parentTurnId, + ); turn.readContent = this.projectTurnSubmit(p.input); this.send({ kind: 'turn.submitted', replyTo: p.clientReqId, turnId: turn.graph.turnId }); if (p.input.type === 'prompt') { const result = await this.streamMockReply(session, content); - settleTurn(session, turn, result.ok ? 'completed' : 'failed'); + this.settleTurn(session, turn, result.state); return; } // Command/shell turns just echo — the mock has no directive execution behind turn.submit. - settleTurn(session, turn, 'completed'); + this.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( + /** Every device refetches the tree: a node it did not have moves the revision; a settle keeps + * it — the badge is what changed (the daemon's `announceGraph`). */ + private announceGraph(session: MockSession, gainedNode: boolean): void { + if (gainedNode) session.graphRevision += 1; + this.send({ + kind: 'conversation.graph.changed', + sessionId: session.sessionId, + graphRevision: session.graphRevision, + ...(session.activeLeafTurnId !== undefined && { activeLeafTurnId: session.activeLeafTurnId }), + }); + } + + private settleTurn( + session: MockSession, + turn: MockTurn, + state: 'completed' | 'failed' | 'cancelled', + ): void { + turn.graph.state = state; + if (session.runningTurnId === turn.graph.turnId) session.runningTurnId = undefined; + this.announceGraph(session, false); + } + + /** Persist a graph turn the way the daemon does before dispatch: a plain send lands under the + * active leaf, an explicit parent lands a sibling (or a root). */ + private mintTurn( session: MockSession, content: ContentBlock[], - input?: Exclude, + input: Exclude | undefined, + parentTurnId: TurnId | null | undefined, + state: 'running' | 'failed', ): MockTurn { this.turnSeq += 1; const id = this.turnSeq.toString(36); const turnId = `turn-mock-${id}` as TurnId; - const parent = session.graphTurns.at(-1); + const parent = parentTurnId === undefined ? (session.activeLeafTurnId ?? null) : parentTurnId; + const siblingOrdinal = + session.graphTurns.filter((turn) => turn.graph.parentTurnId === parent).length + 1; const turn: MockTurn = { graph: { turnId, sessionId: session.sessionId, - parentTurnId: parent?.graph.turnId ?? null, - siblingOrdinal: 1, + parentTurnId: parent, + siblingOrdinal, input: input ?? { type: 'prompt', promptId: `prompt-mock-${id}` as PromptId }, runId: `run-mock-${id}` as RunId, - state: 'running', + state, createdAt: Date.now(), inputSummary: promptText(content).slice(0, 140), }, content, }; session.graphTurns.push(turn); + return turn; + } + + /** Mint the graph turn a turn-starting input persists on the daemon (legacy inputs included) + * and point the frames that follow at it: the default leaf moves as the turn commits running. */ + private beginTurn( + session: MockSession, + content: ContentBlock[], + input?: Exclude, + parentTurnId?: TurnId | null, + ): MockTurn { + const turn = this.mintTurn(session, content, input, parentTurnId, 'running'); + const { turnId } = turn.graph; + session.activeLeafTurnId = turnId; 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. @@ -1493,12 +1591,20 @@ export class DevMockHost { messageId: userRowMessageId(turnId), content, }); - this.send({ - kind: 'conversation.graph.changed', - sessionId: session.sessionId, - graphRevision: session.graphTurns.length, - activeLeafTurnId: turnId, - }); + this.announceGraph(session, true); + return turn; + } + + /** A prompt the provider refuses before it runs — the daemon's `resolveFailed` shape: the tree + * gains a failed node and announces it, the default leaf stays, and nothing is echoed live. */ + private refuseTurn( + session: MockSession, + content: ContentBlock[], + parentTurnId: TurnId | null | undefined, + ): MockTurn { + const turn = this.mintTurn(session, content, undefined, parentTurnId, 'failed'); + turn.readContent = content; + this.announceGraph(session, true); return turn; } @@ -1518,18 +1624,26 @@ export class DevMockHost { return; } } + if (promptText(content).toLowerCase() === REFUSE_PROMPT) { + this.refuseTurn(session, content, undefined); + this.sendFailure(replyTo, REFUSE_MESSAGE, { code: 'operation_failed' }); + return; + } const turn = this.beginTurn(session, content); turn.readContent = await this.ingestInlineImages(session.sessionId, 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 }); + this.settleTurn(session, turn, result.state); + if (result.state === 'failed') { + this.sendFailure(replyTo, result.message, { reportedInConversation: true }); + } else { + this.sendSuccess(replyTo); + } } private async streamMockReply( session: MockSession, content: ContentBlock[], - ): Promise<{ ok: true } | { ok: false; message: string }> { + ): Promise { const text = promptText(content); if (text && !session.title) session.title = text.slice(0, 80); session.status = 'running'; @@ -1543,7 +1657,7 @@ export class DevMockHost { return session.epoch !== epoch; }; - if (await cancelledAfter(200)) return { ok: true }; + if (await cancelledAfter(200)) return { state: 'cancelled' }; const thoughtId = this.nextMessageId('mock-thought'); this.emit(session.sessionId, { type: 'agent-thought-chunk', @@ -1552,7 +1666,7 @@ export class DevMockHost { }); if (text.toLowerCase() === FAIL_PROMPT) { - if (await cancelledAfter(200)) return { ok: true }; + if (await cancelledAfter(200)) return { state: 'cancelled' }; const message = `Mock failure requested via the "${FAIL_PROMPT}" prompt.`; this.emit(session.sessionId, { type: 'error', @@ -1562,7 +1676,7 @@ export class DevMockHost { }); session.status = 'idle'; this.emit(session.sessionId, { type: 'status', status: 'idle' }); - return { ok: false, message }; + return { state: 'failed', message }; } const messageId = this.nextMessageId('mock-message'); @@ -1571,7 +1685,7 @@ export class DevMockHost { if (chunks != null) { for (let i = 0, len = chunks.length; i < len; i++) { // eslint-disable-next-line no-await-in-loop -- word-by-word streaming: chunks are paced sequentially by design. - if (await cancelledAfter(CHUNK_LATENCY_MS)) return { ok: true }; + if (await cancelledAfter(CHUNK_LATENCY_MS)) return { state: 'cancelled' }; this.emit(session.sessionId, { type: 'agent-message-chunk', messageId, @@ -1589,7 +1703,7 @@ export class DevMockHost { this.emit(session.sessionId, { type: 'stop', stopReason: 'end_turn' }); session.status = 'idle'; this.emit(session.sessionId, { type: 'status', status: 'idle' }); - return { ok: true }; + return { state: 'completed' }; } /** Emitted in one burst, not streamed: this transcript exists to be long, not to look live. */ @@ -1971,6 +2085,20 @@ export class DevMockHost { if (payload.operationId !== undefined) { const replayed = this.attachmentBegins.get(payload.operationId); if (replayed) { + // The daemon's rule: one operation id names one begin, so other declared fields are refused. + const upload = this.attachmentUploads.get(replayed.uploadId); + if ( + upload?.declaredSha256 !== payload.declaredSha256 || + upload.declaredSize !== payload.declaredSize || + upload.name !== payload.name || + upload.mimeType !== payload.mimeType || + upload.attachmentKind !== payload.attachmentKind + ) { + this.sendFailure(payload.clientReqId, 'The operation id belongs to another upload', { + code: 'invalid_request', + }); + return; + } this.send({ kind: 'attachment.upload.begun', replyTo: payload.clientReqId, @@ -2111,6 +2239,7 @@ export class DevMockHost { mimeType: upload.mimeType ?? 'application/octet-stream', kind: upload.attachmentKind, }); + this.forgetAttachmentBegins(payload.uploadId); this.send({ kind: 'attachment.upload.committed', replyTo: payload.clientReqId, @@ -2127,11 +2256,16 @@ export class DevMockHost { return; } this.attachmentUploads.delete(payload.uploadId); - // The replay must die with the upload it names, or a retried operationId resolves to a dead id. + this.forgetAttachmentBegins(payload.uploadId); + this.sendSuccess(payload.clientReqId); + } + + /** The daemon's `forget`: a replay dies with the upload it names — committed or aborted — or a + * retried operationId resolves to a dead or already-committed id instead of a fresh begin. */ + private forgetAttachmentBegins(uploadId: string): void { for (const [operationId, begun] of this.attachmentBegins) { - if (begun.uploadId === payload.uploadId) this.attachmentBegins.delete(operationId); + if (begun.uploadId === uploadId) this.attachmentBegins.delete(operationId); } - this.sendSuccess(payload.clientReqId); } private publishResourceAttachment( @@ -2306,7 +2440,9 @@ function turnSubmitContent(input: TurnSubmitInput): ContentBlock[] { block.type === 'text' ? [textBlock(block.text)] : [], ); case 'command': - return [textBlock(`/${input.name}${input.arguments ? ` ${input.arguments}` : ''}`)]; + return [ + textBlock(`/${input.name}${input.arguments === undefined ? '' : ` ${input.arguments}`}`), + ]; case 'shell-command': return [textBlock(`$ ${input.command}`)]; default: @@ -2323,14 +2459,28 @@ 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 turns on the root→leaf path, the way the daemon reads one lineage of the tree. */ +function pathTurnIds(session: MockSession, leafTurnId: TurnId | undefined): Set { + const onPath = new Set(); + let cursor = leafTurnId; + while (cursor !== undefined && !onPath.has(cursor)) { + const id: TurnId = cursor; + const turn = session.graphTurns.find((candidate) => candidate.graph.turnId === id); + if (turn === undefined) break; + onPath.add(id); + cursor = turn.graph.parentTurnId ?? undefined; + } + return onPath; } -/** 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[] { +/** The journal as one final page: every stamped frame of the leaf's lineage in order (session + * frames without a turn included), plus the daemon's prompt-only placeholder under any turn whose + * frames hold nothing but its own echo. */ +function readMockProjection( + session: MockSession, + leafTurnId: TurnId | undefined, +): ConversationReadItem[] { + const onPath = pathTurnIds(session, leafTurnId); const withOutput = new Set(); for (let i = 0, len = session.journal.length; i < len; i++) { const entry = session.journal[i]; @@ -2341,6 +2491,7 @@ function readMockProjection(session: MockSession): ConversationReadItem[] { const items: ConversationReadItem[] = []; for (let i = 0, len = session.journal.length; i < len; i++) { const entry = session.journal[i]; + if (entry.turnId !== undefined && !onPath.has(entry.turnId)) continue; items.push({ ...(entry.turnId !== undefined && { turnId: entry.turnId }), epoch: entry.epoch, @@ -2356,6 +2507,19 @@ function readMockProjection(session: MockSession): ConversationReadItem[] { items.push({ type: 'history-unavailable', turnId: entry.turnId }); } } + // A turn refused before it ran journaled nothing; its lineage's read still carries its host user + // row, the way the daemon reads one from the turn table — and no placeholder: nothing ran. + const leaf = session.graphTurns.find((turn) => turn.graph.turnId === leafTurnId); + if (leaf !== undefined && !session.journal.some((entry) => entry.turnId === leaf.graph.turnId)) { + items.push({ + turnId: leaf.graph.turnId, + event: { + type: 'user-message', + messageId: userRowMessageId(leaf.graph.turnId), + content: leaf.readContent ?? leaf.content, + }, + }); + } return items; } @@ -2369,6 +2533,9 @@ function toSessionInfo(session: MockSession): SessionInfo { updatedAt: session.updatedAt, title: session.title, origin: session.origin, + ...(MOCK_HISTORY_CAPABILITIES[session.kind] !== undefined && { + historyCapabilities: MOCK_HISTORY_CAPABILITIES[session.kind], + }), }; } diff --git a/packages/client/workbench/src/surface/__tests__/lineage-store.test.ts b/packages/client/workbench/src/surface/__tests__/lineage-store.test.ts new file mode 100644 index 000000000..239c704f3 --- /dev/null +++ b/packages/client/workbench/src/surface/__tests__/lineage-store.test.ts @@ -0,0 +1,65 @@ +import type { ConversationGraphSnapshot } from '@linkcode/client-core'; +import type { ConversationGraphTurn, SessionId, TurnId } from '@linkcode/schema'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { useLineageStore } from '../lineage-store'; + +const sessionId = 'sess-1' as SessionId; + +function turn(id: string, parent: string | null): ConversationGraphTurn { + return { + turnId: id as TurnId, + sessionId, + parentTurnId: parent as TurnId | null, + siblingOrdinal: 1, + input: { type: 'shell-command', command: id }, + runId: `run-${id}` as ConversationGraphTurn['runId'], + state: 'completed', + createdAt: 1, + }; +} + +function snapshot(activeLeaf: string, turns: ConversationGraphTurn[]): ConversationGraphSnapshot { + return { sessionId, graphRevision: 9, activeLeafTurnId: activeLeaf as TurnId, turns }; +} + +beforeEach(() => { + useLineageStore.setState({ parkedBySession: {}, preferredChildBySession: {} }); +}); + +describe('lineage store', () => { + it('parks against the host default, dismisses the elsewhere chip per default, and follows again', () => { + const store = useLineageStore.getState(); + store.park(sessionId, 'B1' as TurnId, 'B2' as TurnId); + expect(useLineageStore.getState().parkedBySession[sessionId]).toEqual({ + leafTurnId: 'B1', + sinceLeafTurnId: 'B2', + dismissedLeafTurnId: undefined, + }); + store.dismissElsewhere(sessionId, 'C2' as TurnId); + expect(useLineageStore.getState().parkedBySession[sessionId]?.dismissedLeafTurnId).toBe('C2'); + store.follow(sessionId); + expect(useLineageStore.getState().parkedBySession[sessionId]).toBeUndefined(); + }); + + it('releases a parked view once the host default runs through its leaf', () => { + const store = useLineageStore.getState(); + store.park(sessionId, 'B1' as TurnId, 'B2' as TurnId); + // The active lineage moved to a sibling: still parked. + store.noteGraph(snapshot('B2', [turn('A', null), turn('B1', 'A'), turn('B2', 'A')])); + expect(useLineageStore.getState().parkedBySession[sessionId]?.leafTurnId).toBe('B1'); + // A continue from the parked version: its lineage is the active one again. + store.noteGraph( + snapshot('C1', [turn('A', null), turn('B1', 'A'), turn('B2', 'A'), turn('C1', 'B1')]), + ); + expect(useLineageStore.getState().parkedBySession[sessionId]).toBeUndefined(); + }); + + it('remembers the chosen child per parent and session', () => { + useLineageStore.getState().rememberChild(sessionId, 'A', 'B1' as TurnId); + useLineageStore.getState().rememberChild(sessionId, 'root', 'A' as TurnId); + expect(useLineageStore.getState().preferredChildBySession[sessionId]).toEqual({ + A: 'B1', + root: 'A', + }); + }); +}); diff --git a/packages/client/workbench/src/surface/__tests__/lineage.test.ts b/packages/client/workbench/src/surface/__tests__/lineage.test.ts new file mode 100644 index 000000000..95e1f44f9 --- /dev/null +++ b/packages/client/workbench/src/surface/__tests__/lineage.test.ts @@ -0,0 +1,140 @@ +import type { ConversationGraphSnapshot } from '@linkcode/client-core'; +import type { ConversationGraphTurn, SessionId, TurnId } from '@linkcode/schema'; +import { userRowMessageId } from '@linkcode/schema'; +import { describe, expect, it } from 'vitest'; +import { + continuationParent, + descendToLeaf, + lineageIncludes, + lineageParentKey, + lineagePath, + lineageVersions, + onActiveLineage, + siblingsOf, + timelineLeftActiveLineage, + turnsById, +} from '../lineage'; + +const sessionId = 'sess-1' as SessionId; + +function turn( + id: string, + parent: string | null, + ordinal: number, + createdAt: number, + state: ConversationGraphTurn['state'] = 'completed', +): ConversationGraphTurn { + return { + turnId: id as TurnId, + sessionId, + parentTurnId: parent as TurnId | null, + siblingOrdinal: ordinal, + input: { type: 'shell-command', command: id }, + runId: `run-${id}` as ConversationGraphTurn['runId'], + state, + createdAt, + }; +} + +// A +// / \ +// B1 B2 (edited B) +// | | \ +// C1 C2 C3 (edited C2, failed) +const TURNS = [ + turn('A', null, 1, 1), + turn('B1', 'A', 1, 2), + turn('B2', 'A', 2, 5), + turn('C1', 'B1', 1, 3), + turn('C2', 'B2', 1, 6), + turn('C3', 'B2', 2, 7, 'failed'), +]; + +describe('lineage helpers', () => { + it('walks root to leaf and stops at a broken link', () => { + const byId = turnsById(TURNS); + expect(lineagePath(byId, 'C3' as TurnId).map((t) => t.turnId)).toEqual(['A', 'B2', 'C3']); + expect(lineagePath(byId, 'nope' as TurnId)).toEqual([]); + expect(lineageIncludes(byId, 'C3' as TurnId, 'B2' as TurnId)).toBe(true); + expect(lineageIncludes(byId, 'C3' as TurnId, 'B1' as TurnId)).toBe(false); + expect(lineageIncludes(byId, undefined, 'A' as TurnId)).toBe(false); + }); + + it('orders siblings by ordinal and reports each path turn’s version', () => { + expect(siblingsOf(TURNS, TURNS[2]).map((t) => t.turnId)).toEqual(['B1', 'B2']); + const versions = lineageVersions(TURNS, lineagePath(turnsById(TURNS), 'C3' as TurnId)); + expect(versions.get(userRowMessageId('A' as TurnId))).toEqual({ + index: 1, + count: 1, + state: null, + }); + expect(versions.get(userRowMessageId('B2' as TurnId))).toEqual({ + index: 2, + count: 2, + state: null, + }); + expect(versions.get(userRowMessageId('C3' as TurnId))).toEqual({ + index: 2, + count: 2, + state: 'failed', + }); + }); + + it('descends to the newest child unless a version was remembered', () => { + expect(descendToLeaf(TURNS, 'A' as TurnId, {})).toBe('C3'); + expect(descendToLeaf(TURNS, 'B1' as TurnId, {})).toBe('C1'); + const remembered = { + [lineageParentKey('A' as TurnId)]: 'B1' as TurnId, + [lineageParentKey('B2' as TurnId)]: 'C2' as TurnId, + }; + expect(descendToLeaf(TURNS, 'A' as TurnId, remembered)).toBe('C1'); + expect(descendToLeaf(TURNS, 'B2' as TurnId, remembered)).toBe('C2'); + // A remembered child that no longer exists falls back to the newest. + expect(descendToLeaf(TURNS, 'B2' as TurnId, { B2: 'gone' as TurnId })).toBe('C3'); + }); + + it('continues from a version’s last completed turn, never from a failed tip', () => { + const byId = turnsById(TURNS); + expect(continuationParent(byId, 'C3' as TurnId)).toBe('B2'); + expect(continuationParent(byId, 'C1' as TurnId)).toBe('C1'); + const failedRoot = turnsById([turn('F', null, 1, 1, 'failed')]); + expect(continuationParent(failedRoot, 'F' as TurnId)).toBeNull(); + }); + + it('places a read on the active lineage unless it is of another version', () => { + const graph = (activeLeaf: string): ConversationGraphSnapshot => ({ + sessionId, + graphRevision: 1, + activeLeafTurnId: activeLeaf as TurnId, + turns: TURNS, + }); + expect(onActiveLineage('C2' as TurnId, graph('C2'))).toBe(true); + // Behind the default on its own lineage: a continuation folds onto it. + expect(onActiveLineage('B2' as TurnId, graph('C2'))).toBe(true); + // Another version. + expect(onActiveLineage('C1' as TurnId, graph('C2'))).toBe(false); + // Ahead of a stale snapshot, or not placed by it yet. + expect(onActiveLineage('C2' as TurnId, graph('B2'))).toBe(true); + expect(onActiveLineage('new' as TurnId, graph('C2'))).toBe(true); + // Nothing to judge against without a leaf or a tree. + expect(onActiveLineage(undefined, graph('C2'))).toBe(true); + expect(onActiveLineage('C1' as TurnId, undefined)).toBe(true); + }); + + it('notices a timeline showing a turn off the active lineage', () => { + const graph = (activeLeaf: string): ConversationGraphSnapshot => ({ + sessionId, + graphRevision: 1, + activeLeafTurnId: activeLeaf as TurnId, + turns: TURNS, + }); + const rows = (...ids: string[]) => ids.map((id) => userRowMessageId(id as TurnId)); + // Following B1's lineage when the default moved to B2's: B1 and C1 are off it. + expect(timelineLeftActiveLineage(rows('A', 'B1', 'C1'), graph('C2'))).toBe(true); + // The edit's own echo folded onto the old lineage, then the tree caught up. + expect(timelineLeftActiveLineage(rows('A', 'B1', 'B2'), graph('B2'))).toBe(true); + expect(timelineLeftActiveLineage(rows('A', 'B2', 'C2'), graph('C2'))).toBe(false); + // A row the tree does not know yet is a fresher echo, not a foreign version. + expect(timelineLeftActiveLineage(rows('A', 'B2', 'C2', 'new'), graph('C2'))).toBe(false); + }); +}); diff --git a/packages/client/workbench/src/surface/__tests__/prompt-attachments.test.ts b/packages/client/workbench/src/surface/__tests__/prompt-attachments.test.ts index 4ec3e635a..ffc55d0b7 100644 --- a/packages/client/workbench/src/surface/__tests__/prompt-attachments.test.ts +++ b/packages/client/workbench/src/surface/__tests__/prompt-attachments.test.ts @@ -1,7 +1,8 @@ -import type { Conversation, LinkCodeClient } from '@linkcode/client-core'; +import type { AttachmentReadBytes, Conversation, LinkCodeClient } from '@linkcode/client-core'; import type { ContentBlock, SessionId, TurnId } from '@linkcode/schema'; import { AttachmentIdSchema, userRowMessageId } from '@linkcode/schema'; import type { ComposerAttachment } from '@linkcode/ui'; +import { noop } from 'foxts/noop'; import { describe, expect, it, vi } from 'vitest'; import { clearInflightUserAttachments, @@ -11,6 +12,8 @@ import { overlayPendingUserAttachments, pendingUserAttachmentsSnapshot, promptBlocksFromComposer, + resolveStoredAttachmentPreview, + revokeAttachmentObjectUrls, stageStoreAttachment, } from '../prompt-attachments'; @@ -64,6 +67,41 @@ describe('promptBlocksFromComposer', () => { }); }); +describe('resolveStoredAttachmentPreview', () => { + it('mints no URL for a read that outlives the session switch', async () => { + const createObjectURL = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:preview'); + const read: AttachmentReadBytes = { + blobId: 'sha256:a', + bytes: new Uint8Array([1]), + sizeBytes: 1, + }; + try { + let release: (bytes: AttachmentReadBytes) => void = noop; + const getAttachmentBytes = vi.fn( + () => + new Promise((resolve) => { + release = resolve; + }), + ); + const pending = resolveStoredAttachmentPreview({ getAttachmentBytes }, sessionId, 'att-1'); + revokeAttachmentObjectUrls(); + release(read); + expect(await pending).toBeNull(); + expect(createObjectURL).not.toHaveBeenCalled(); + + const settled = await resolveStoredAttachmentPreview( + { getAttachmentBytes: vi.fn(() => Promise.resolve(read)) }, + sessionId, + 'att-1', + ); + expect(settled).toEqual({ url: 'blob:preview' }); + } finally { + createObjectURL.mockRestore(); + revokeAttachmentObjectUrls(); + } + }); +}); + describe('stageStoreAttachment', () => { it('refuses bytes that are not the declared image before any upload frame', async () => { const putAttachment = vi.fn(); diff --git a/packages/client/workbench/src/surface/lineage-store.ts b/packages/client/workbench/src/surface/lineage-store.ts new file mode 100644 index 000000000..deb377903 --- /dev/null +++ b/packages/client/workbench/src/surface/lineage-store.ts @@ -0,0 +1,95 @@ +import type { ConversationGraphSnapshot } from '@linkcode/client-core'; +import type { SessionId, TurnId } from '@linkcode/schema'; +import { create } from 'zustand'; +import { lineageIncludes, turnsById } from './lineage'; + +/** A client browsing a version other than the host default. */ +export interface ParkedLineage { + /** The leaf the timeline reads toward. */ + leafTurnId: TurnId; + /** The host default when the viewer parked; a different one means the conversation moved on + * elsewhere while this viewer stayed — a failed attempt or a settle, which only bump the + * revision, is nobody's news. */ + sinceLeafTurnId: TurnId | undefined; + /** The host default whose "continued elsewhere" chip the viewer dismissed. */ + dismissedLeafTurnId: TurnId | undefined; +} + +interface LineageState { + parkedBySession: Record; + /** Remembered descent per parent (`lineageParentKey`): `‹ ›` returns to the version last viewed. */ + preferredChildBySession: Record>; + park: (sessionId: SessionId, leafTurnId: TurnId, activeLeafTurnId: TurnId | undefined) => void; + /** Back to the host default: the active lineage, following it live. */ + follow: (sessionId: SessionId) => void; + rememberChild: (sessionId: SessionId, parentKey: string, childTurnId: TurnId) => void; + dismissElsewhere: (sessionId: SessionId, activeLeafTurnId: TurnId | undefined) => void; + /** A fresh graph snapshot: once the host default runs through the parked leaf — the viewer's + * own edit or continue landed, or a plain send extended the version it was on — the view is at + * that lineage's tip again and follows. */ + noteGraph: (snapshot: ConversationGraphSnapshot) => void; +} + +function without(record: Record, key: string): Record { + const next = { ...record }; + delete next[key]; + return next; +} + +/** + * Client-local view position per session (`viewLeafTurnId` in the design): switching versions is + * a pure read that never touches the daemon's `activeLeafTurnId`. Not persisted — a reopened + * thread starts at the host default. + */ +export const useLineageStore = create()((set) => ({ + parkedBySession: {}, + preferredChildBySession: {}, + park: (sessionId, leafTurnId, activeLeafTurnId) => + set((state) => ({ + parkedBySession: { + ...state.parkedBySession, + [sessionId]: { + leafTurnId, + sinceLeafTurnId: activeLeafTurnId, + dismissedLeafTurnId: undefined, + }, + }, + })), + follow: (sessionId) => + set((state) => + sessionId in state.parkedBySession + ? { parkedBySession: without(state.parkedBySession, sessionId) } + : state, + ), + rememberChild: (sessionId, parentKey, childTurnId) => + set((state) => ({ + preferredChildBySession: { + ...state.preferredChildBySession, + [sessionId]: { ...state.preferredChildBySession[sessionId], [parentKey]: childTurnId }, + }, + })), + dismissElsewhere: (sessionId, activeLeafTurnId) => + set((state) => { + const parked = state.parkedBySession[sessionId]; + if (parked === undefined) return state; + return { + parkedBySession: { + ...state.parkedBySession, + [sessionId]: { ...parked, dismissedLeafTurnId: activeLeafTurnId }, + }, + }; + }), + noteGraph: (snapshot) => + set((state) => { + const parked = state.parkedBySession[snapshot.sessionId]; + if (parked === undefined) return state; + const caughtUp = lineageIncludes( + turnsById(snapshot.turns), + snapshot.activeLeafTurnId, + parked.leafTurnId, + ); + return caughtUp + ? { parkedBySession: without(state.parkedBySession, snapshot.sessionId) } + : state; + }), +})); diff --git a/packages/client/workbench/src/surface/lineage.ts b/packages/client/workbench/src/surface/lineage.ts new file mode 100644 index 000000000..4b26b8228 --- /dev/null +++ b/packages/client/workbench/src/surface/lineage.ts @@ -0,0 +1,164 @@ +import type { ConversationGraphSnapshot } from '@linkcode/client-core'; +import type { ConversationGraphTurn, TurnId } from '@linkcode/schema'; +import { userRowMessageId } from '@linkcode/schema'; +import type { TurnVersion } from '@linkcode/ui'; + +/** Key of the remembered-descent map: a root's parent is `null`. */ +export function lineageParentKey(parentTurnId: TurnId | null): string { + return parentTurnId ?? 'root'; +} + +export function turnsById( + turns: readonly ConversationGraphTurn[], +): Map { + return new Map(turns.map((turn) => [turn.turnId, turn])); +} + +/** Root→leaf through `parentTurnId`; a broken or cyclic chain ends the path rather than looping. */ +export function lineagePath( + byId: ReadonlyMap, + leafTurnId: TurnId | undefined, +): ConversationGraphTurn[] { + const path: ConversationGraphTurn[] = []; + const seen = new Set(); + let cursor = leafTurnId; + while (cursor !== undefined && !seen.has(cursor)) { + const turn = byId.get(cursor); + if (turn === undefined) break; + seen.add(cursor); + path.push(turn); + cursor = turn.parentTurnId ?? undefined; + } + return path.reverse(); +} + +/** Whether the lineage ending at `leafTurnId` passes through `turnId` (itself included). */ +export function lineageIncludes( + byId: ReadonlyMap, + leafTurnId: TurnId | undefined, + turnId: TurnId, +): boolean { + return lineagePath(byId, leafTurnId).some((turn) => turn.turnId === turnId); +} + +/** Where a send from a view of `leafTurnId` lands: the lineage's last completed turn — a failed or + * cancelled tip ran nothing to continue from, so the send is a sibling of it — else a new root. */ +export function continuationParent( + byId: ReadonlyMap, + leafTurnId: TurnId, +): TurnId | null { + const path = lineagePath(byId, leafTurnId); + for (let i = path.length - 1; i >= 0; i--) { + if (path[i].state === 'completed') return path[i].turnId; + } + return null; +} + +/** Whether a read toward `leafTurnId` is on the active lineage as far as `graph` knows: on or + * behind the host default, ahead of a stale snapshot, or not placed by it yet. Only a read of + * another version is not — the live stream, which belongs to the active lineage's run, must not + * fold into it. */ +export function onActiveLineage( + leafTurnId: TurnId | undefined, + graph: ConversationGraphSnapshot | undefined, +): boolean { + if (leafTurnId === undefined || graph?.activeLeafTurnId === undefined) return true; + const byId = turnsById(graph.turns); + if (!byId.has(leafTurnId)) return true; + return ( + lineageIncludes(byId, graph.activeLeafTurnId, leafTurnId) || + lineageIncludes(byId, leafTurnId, graph.activeLeafTurnId) + ); +} + +/** Whether a timeline shows a user row of a turn the active lineage does not run through: the host + * default moved to another version while this view followed it (an edit from any device), so the + * view must read toward the default. Rows the tree does not know yet — an echo fresher than the + * snapshot — do not count. */ +export function timelineLeftActiveLineage( + userRowIds: readonly string[], + graph: ConversationGraphSnapshot, +): boolean { + const known = new Set(graph.turns.map((turn) => userRowMessageId(turn.turnId))); + const onPath = new Set( + lineagePath(turnsById(graph.turns), graph.activeLeafTurnId).map((turn) => + userRowMessageId(turn.turnId), + ), + ); + for (let i = 0, len = userRowIds.length; i < len; i++) { + const id = userRowIds[i]; + if (known.has(id) && !onPath.has(id)) return true; + } + return false; +} + +/** A turn's siblings in ordinal order, itself included. */ +export function siblingsOf( + turns: readonly ConversationGraphTurn[], + turn: ConversationGraphTurn, +): ConversationGraphTurn[] { + return turns + .filter((candidate) => candidate.parentTurnId === turn.parentTurnId) + .sort((a, b) => a.siblingOrdinal - b.siblingOrdinal); +} + +function newestChild(children: readonly ConversationGraphTurn[]): ConversationGraphTurn { + let newest = children[0]; + for (let i = 1, len = children.length; i < len; i++) { + const child = children[i]; + if ( + child.createdAt > newest.createdAt || + (child.createdAt === newest.createdAt && child.siblingOrdinal > newest.siblingOrdinal) + ) { + newest = child; + } + } + return newest; +} + +/** Descend from a chosen turn to a leaf: the remembered child at each level, else the newest. */ +export function descendToLeaf( + turns: readonly ConversationGraphTurn[], + from: TurnId, + preferredChild: Readonly>, +): TurnId { + let cursor = from; + // Bounded by the tree size so a malformed graph cannot spin. + for (let depth = 0, len = turns.length; depth <= len; depth++) { + const children = turns.filter((turn) => turn.parentTurnId === cursor); + if (children.length === 0) break; + const remembered = preferredChild[lineageParentKey(cursor)]; + const next = children.find((child) => child.turnId === remembered) ?? newestChild(children); + cursor = next.turnId; + } + return cursor; +} + +/** Each path turn's version among its siblings, keyed by its user row's message id. */ +export function lineageVersions( + turns: readonly ConversationGraphTurn[], + path: readonly ConversationGraphTurn[], +): Map { + // Group once: a per-turn scan is quadratic on a long thread, and this runs on every render. + const byParent = new Map(); + for (let i = 0, len = turns.length; i < len; i++) { + const turn = turns[i]; + const key = lineageParentKey(turn.parentTurnId); + const group = byParent.get(key); + if (group) group.push(turn); + else byParent.set(key, [turn]); + } + const versions = new Map(); + for (let i = 0, len = path.length; i < len; i++) { + const turn = path[i]; + const siblings = (byParent.get(lineageParentKey(turn.parentTurnId)) ?? [turn]).sort( + (a, b) => a.siblingOrdinal - b.siblingOrdinal, + ); + versions.set(userRowMessageId(turn.turnId), { + index: siblings.findIndex((sibling) => sibling.turnId === turn.turnId) + 1, + count: siblings.length, + state: turn.state === 'failed' || turn.state === 'cancelled' ? turn.state : null, + }); + } + return versions; +} diff --git a/packages/client/workbench/src/surface/prompt-attachments.ts b/packages/client/workbench/src/surface/prompt-attachments.ts index b8dae9925..21c666ed3 100644 --- a/packages/client/workbench/src/surface/prompt-attachments.ts +++ b/packages/client/workbench/src/surface/prompt-attachments.ts @@ -12,10 +12,14 @@ import { attachmentIdFromUri, attachmentUri, declaredMimeTypeMatches, + MAX_ATTACHMENT_NAME_LENGTH, } from '@linkcode/schema'; import type { ComposerAttachment } from '@linkcode/ui'; +import { isErrorLikeObject } from 'foxts/extract-error-message'; const objectUrls = new Map(); +/** Bumped by each revoke: a preview read that started before it must not mint a URL after it. */ +let urlGeneration = 0; function blobUrlFor(bytes: Uint8Array, mimeType?: string): string { const copy = new Uint8Array(bytes.byteLength); @@ -37,10 +41,32 @@ export function attachmentObjectUrl( } export function revokeAttachmentObjectUrls(): void { + urlGeneration += 1; for (const url of objectUrls.values()) URL.revokeObjectURL(url); objectUrls.clear(); } +/** The timeline preview of a stored attachment; `null` is a durable miss. A read that outlives the + * session switch mints no URL: the preview store discards the result, and the revoke already ran. */ +export async function resolveStoredAttachmentPreview( + client: Pick, + sessionId: SessionId, + attachmentId: string, +): Promise<{ url: string } | null> { + const generation = urlGeneration; + try { + const { bytes } = await client.getAttachmentBytes( + sessionId, + AttachmentIdSchema.parse(attachmentId), + ); + if (generation !== urlGeneration) return null; + return { url: attachmentObjectUrl(attachmentId, bytes) }; + } catch (error) { + if (isErrorLikeObject(error) && 'code' in error && error.code === 'not_found') return null; + throw error; + } +} + export function isStoredAttachmentBlock(block: ContentBlock): boolean { return block.type === 'resource_link' && attachmentIdFromUri(block.uri) !== undefined; } @@ -97,9 +123,11 @@ export async function stageStoreAttachment( throw new Error(errors.contentMismatch); } const kind = pending.kind === 'image' ? 'image' : 'file'; + // Records cap the name; the daemon caps a legacy upload's the same way rather than refusing it. + const name = file.name.slice(0, MAX_ATTACHMENT_NAME_LENGTH); const { attachmentId } = await client.putAttachment({ bytes, - name: file.name, + name, mimeType: file.type || undefined, attachmentKind: kind, }); @@ -107,7 +135,7 @@ export async function stageStoreAttachment( ...pending, status: 'ready', url: blobUrlFor(bytes, file.type), - block: storedAttachmentResourceLink(attachmentId, file.name, file.type, file.size, kind), + block: storedAttachmentResourceLink(attachmentId, name, file.type, file.size, kind), }; } diff --git a/packages/client/workbench/src/surface/use-conversation-graph.ts b/packages/client/workbench/src/surface/use-conversation-graph.ts new file mode 100644 index 000000000..e87bc4ea6 --- /dev/null +++ b/packages/client/workbench/src/surface/use-conversation-graph.ts @@ -0,0 +1,39 @@ +import type { ConversationGraphSnapshot } from '@linkcode/client-core'; +import type { SessionId } from '@linkcode/schema'; +import type { Options, RequestResult } from '@linkcode/sdk'; +import { resolveClient } from '@linkcode/sdk'; +import { noop } from 'foxact/noop'; +import { useEffect } from 'react'; +import { useWorkbenchSdkClient } from '../runtime/provider'; +import { useData } from '../runtime/tayori'; + +async function fetchConversationGraph( + options: Options<{ sessionId: SessionId }>, +): RequestResult { + return { data: await resolveClient(options).raw.getConversationGraph(options.sessionId) }; +} + +/** + * The session's turn tree — ids, parents, ordinals, states — revalidated on every + * `conversation.graph.changed` (a settle re-announces at the same revision, so the badges follow). + * Undefined before the first read and on hosts without a graph. `onSnapshot` runs per fresh + * snapshot for event-time bookkeeping (a parked view that the host default caught up with), never + * during render. + */ +export function useConversationGraph( + sessionId: SessionId | null, + onSnapshot?: (snapshot: ConversationGraphSnapshot) => void, +): ConversationGraphSnapshot | undefined { + const client = useWorkbenchSdkClient().raw; + const enabled = sessionId !== null && client.supportsConversationGraph; + const { data, mutate } = useData(fetchConversationGraph, enabled ? { sessionId } : null, { + onSuccess: onSnapshot, + }); + useEffect(() => { + if (!enabled) return; + return client.subscribeGraphChanges(sessionId, () => { + void mutate().catch(noop); + }); + }, [client, enabled, sessionId, mutate]); + return data; +} diff --git a/packages/client/workbench/src/surface/use-seeded-conversation.ts b/packages/client/workbench/src/surface/use-seeded-conversation.ts index 11e4cd52f..f4319814b 100644 --- a/packages/client/workbench/src/surface/use-seeded-conversation.ts +++ b/packages/client/workbench/src/surface/use-seeded-conversation.ts @@ -1,24 +1,34 @@ import type { Conversation, + ConversationGraphSnapshot, ConversationProjectionSeed, ConversationSeed, ConversationSeedSource, } from '@linkcode/client-core'; import { readConversationSeed, useConversation } from '@linkcode/client-core'; -import type { SessionInfo } from '@linkcode/schema'; +import type { SessionInfo, TurnId } from '@linkcode/schema'; import type { Options, RequestResult } from '@linkcode/sdk'; import { resolveClient } from '@linkcode/sdk'; import { noop } from 'foxact/noop'; +import { useEffect } from 'react'; import { useData } from '../runtime/tayori'; +import { onActiveLineage, timelineLeftActiveLineage } from './lineage'; +import { useLineageStore } from './lineage-store'; import { loadPersistedProjection, loadPersistedSeed, persistProjection, persistSeed, } from './seed-cache'; +import { useConversationGraph } from './use-conversation-graph'; +interface SeedRead { + seed: ConversationProjectionSeed | ConversationSeed; + /** The parked version this read was made toward; absent for a read of the host default. */ + viewLeafTurnId?: TurnId; +} /** SWR data is `undefined` while loading, so "nothing to seed" needs its own value. */ -type SeedData = ConversationProjectionSeed | ConversationSeed | null; +type SeedData = SeedRead | null; /** * Read the seed for a session (see `readConversationSeed`) and persist it for the next reopen. @@ -28,11 +38,23 @@ type SeedData = ConversationProjectionSeed | ConversationSeed | null; async function fetchConversationSeed( options: Options, ): RequestResult { - const seed = await readConversationSeed(resolveClient(options).raw, options); + // The parked leaf is read at fetch time, not keyed: switching versions revalidates in place + // instead of flashing an empty timeline behind a new SWR key. + const leafTurnId = useLineageStore.getState().parkedBySession[options.sessionId]?.leafTurnId; + const seed = await readConversationSeed(resolveClient(options).raw, { ...options, leafTurnId }); if (seed === undefined) return { data: null }; + // Only the host default is worth the reopen cache; a parked read is one version of many. + if (leafTurnId !== undefined) return { data: { seed, viewLeafTurnId: leafTurnId } }; if ('items' in seed) persistProjection(options.sessionId, seed); else if (options.historyId !== undefined) persistSeed(options.agentKind, options.historyId, seed); - return { data: seed }; + return { data: { seed } }; +} + +function persistedSeed(active: SessionInfo): SeedRead | undefined { + const seed = + loadPersistedProjection(active.sessionId) ?? + (active.historyId ? loadPersistedSeed(active.kind, active.historyId) : undefined); + return seed === undefined ? undefined : { seed }; } /** @@ -40,13 +62,18 @@ async function fetchConversationSeed( * 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. + * behind it — and a projection store's resync request is answered by re-running the read. The read + * re-runs when the session's parked version changes (the lineage store) and when the timeline + * shows a turn the tree's active lineage does not run through (an edit from any device). A read + * made toward a parked version is frozen (`followLive: false`) until the active lineage runs + * through that version; a read of the host default keeps folding the live stream, even while the + * tree has moved past it and the re-read is in flight. */ export function useSeededConversation( active: SessionInfo | null, onError: (err: unknown) => void, -): Conversation { - const { data: seed, mutate } = useData( +): { conversation: Conversation; graph: ConversationGraphSnapshot | undefined } { + const { data, mutate } = useData( fetchConversationSeed, active ? { @@ -58,15 +85,46 @@ export function useSeededConversation( : null, { onError, - fallbackData: active - ? (loadPersistedProjection(active.sessionId) ?? - (active.historyId ? loadPersistedSeed(active.kind, active.historyId) : undefined)) - : undefined, + fallbackData: active ? persistedSeed(active) : 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 ?? undefined, () => { - void mutate().catch(noop); - }); + const sessionId = active?.sessionId ?? null; + const noteGraph = useLineageStore((state) => state.noteGraph); + const graph = useConversationGraph(sessionId, noteGraph); + useEffect(() => { + if (sessionId === null) return; + return useLineageStore.subscribe((state, previous) => { + if ( + state.parkedBySession[sessionId]?.leafTurnId !== + previous.parkedBySession[sessionId]?.leafTurnId + ) { + void mutate().catch(noop); + } + }); + }, [sessionId, mutate]); + const conversation = useConversation( + sessionId, + data?.seed, + () => { + void mutate().catch(noop); + }, + data?.viewLeafTurnId === undefined || onActiveLineage(data.viewLeafTurnId, graph), + ); + // The host default moved to another version while this view followed it — an edit from any + // device, this one included, whose echo folded in as if it continued the old lineage — so the + // view of the default reads toward it. A parked viewer keeps its version; the chip is its news. + const parked = useLineageStore((state) => + sessionId === null ? undefined : state.parkedBySession[sessionId], + ); + const userRowIds = conversation.items.flatMap((item) => + item.kind === 'message' && item.role === 'user' ? [item.id] : [], + ); + const leftActiveLineage = + parked === undefined && graph !== undefined && timelineLeftActiveLineage(userRowIds, graph); + useEffect(() => { + if (leftActiveLineage) void mutate().catch(noop); + }, [leftActiveLineage, mutate]); + return { conversation, graph }; } diff --git a/packages/client/workbench/src/surface/workbench.tsx b/packages/client/workbench/src/surface/workbench.tsx index f5eec0a65..738f7762d 100644 --- a/packages/client/workbench/src/surface/workbench.tsx +++ b/packages/client/workbench/src/surface/workbench.tsx @@ -1,4 +1,4 @@ -import type { Conversation } from '@linkcode/client-core'; +import type { Conversation, ConversationGraphSnapshot } from '@linkcode/client-core'; import { isRequestFailureReportedInConversation } from '@linkcode/client-core'; import type { AgentInput, @@ -9,12 +9,7 @@ import type { WorkspaceId, WorkspaceRecord, } from '@linkcode/schema'; -import { - AttachmentIdSchema, - MessageIdSchema, - userRowMessageId, - workspaceKind, -} from '@linkcode/schema'; +import { MessageIdSchema, userRowMessageId, workspaceKind } from '@linkcode/schema'; import { archiveWorkspace, cancelTurn, @@ -36,6 +31,7 @@ import type { ComposerAttachment, ComposerDirectiveControls, ConversationComposerController, + ConversationLineage, CurrentPlan, ModelOption, NewSessionDraft, @@ -55,7 +51,7 @@ import { } from '@linkcode/ui'; import { noop } from 'foxact/noop'; import { useSet } from 'foxact/use-set'; -import { extractErrorMessage, isErrorLikeObject } from 'foxts/extract-error-message'; +import { extractErrorMessage } from 'foxts/extract-error-message'; import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'; import { useTranslations } from 'use-intl'; import { useAgentRuntimeOnboarding } from '../agent-runtime/onboarding'; @@ -79,9 +75,19 @@ import { selectVisibleSessions } from '../sidebar/visible-sessions'; import { RuntimeTerminalBlock } from '../terminal/block'; import { useWorkspaces } from '../workspace/hooks'; import { submitActiveSessionInput } from './active-session-input'; +import { + continuationParent, + descendToLeaf, + lineageParentKey, + lineagePath, + lineageVersions, + siblingsOf, + turnsById, +} from './lineage'; +import type { ParkedLineage } from './lineage-store'; +import { useLineageStore } from './lineage-store'; import { useNewSessionDefaultsStore } from './new-session-defaults-store'; import { - attachmentObjectUrl, clearInflightUserAttachments, isStoredAttachmentBlock, noteInflightUserAttachments, @@ -89,6 +95,7 @@ import { overlayPendingUserAttachments, pendingUserAttachmentsSnapshot, promptBlocksFromComposer, + resolveStoredAttachmentPreview, revokeAttachmentObjectUrls, stageStoreAttachment, stageStoreAttachmentFromBase64, @@ -179,7 +186,11 @@ export function Workbench({ }, }; useWorkbenchKeyboardShortcuts(rootRef, sessions); - const conversation = useSeededConversation(sessions.active, handleError); + const activeSessionId = sessions.active?.sessionId ?? null; + const { conversation, graph } = useSeededConversation(sessions.active, handleError); + const parked = useLineageStore((state) => + activeSessionId === null ? undefined : state.parkedBySession[activeSessionId], + ); // Deliberately NOT keyed by the active session: the surface hosts the whole shell (chrome, // sidebar, panels, terminals), which must stay permanently mounted across session switches — @@ -191,6 +202,8 @@ export function Workbench({ { onClearError(); + if ( + parkedTarget !== undefined && + sessions.activeId !== null && + (input.type === 'command' || input.type === 'shell-command') + ) { + const sessionId = sessions.activeId; + return client.submitTurn(sessionId, input, parkedTarget).then(() => { + followSubmitted(sessionId); + }); + } return submitActiveSessionInput(input, turnInputMutation.trigger); } @@ -368,14 +418,22 @@ function WorkbenchSessionSurface({ } const blocks = promptBlocksFromComposer(content); if (blocks === undefined || blocks.length === 0) { + if (parkedTarget !== undefined) { + throw new Error('This content cannot continue an earlier version'); + } await submitActiveSessionInput({ type: 'prompt', content }, turnInputMutation.trigger); return; } noteInflightUserAttachments(sessionId, content); try { - const { turnId } = await client.submitTurn(sessionId, { type: 'prompt', blocks }); + const { turnId } = await client.submitTurn( + sessionId, + { type: 'prompt', blocks }, + parkedTarget, + ); notePendingUserAttachments(sessionId, userRowMessageId(turnId), content); clearInflightUserAttachments(sessionId); + followSubmitted(sessionId); } catch (error) { clearInflightUserAttachments(sessionId); if (!isRequestFailureReportedInConversation(error)) onError(error); @@ -394,11 +452,34 @@ function WorkbenchSessionSurface({ async function handleEditPrompt( messageId: string, - branchCursor: string, + branchCursor: string | undefined, content: ContentBlock[], ): Promise { - if (active?.historyCapabilities?.branch !== true) { - throw new Error('Prompt editing is unavailable for this session'); + // Non-destructive rewrite: a sibling under the edited turn's parent (a new root lineage for + // the first prompt). The old version stays switchable; the host rejects a stale revision. A + // row the graph does not know (a transcript-seeded session) takes the legacy branch below. + const turn = + graph !== undefined && graphEditable + ? graph.turns.find((candidate) => userRowMessageId(candidate.turnId) === messageId) + : undefined; + // Content the graph cannot carry (legacy inline images) branches the old way where it can. + const blocks = turn === undefined ? undefined : promptBlocksFromComposer(content); + if (graph !== undefined && active !== null && turn !== undefined && blocks?.length) { + const { turnId } = await client.submitTurn( + active.sessionId, + { type: 'prompt', blocks }, + { parentTurnId: turn.parentTurnId, expectedGraphRevision: graph.graphRevision }, + ); + notePendingUserAttachments(active.sessionId, userRowMessageId(turnId), content); + followSubmitted(active.sessionId); + return; + } + if (branchCursor === undefined || active?.historyCapabilities?.branch !== true) { + throw new Error( + turn === undefined + ? 'Prompt editing is unavailable for this session' + : 'Prompt editing is unavailable for this message', + ); } const stripped = content.filter((block) => !isStoredAttachmentBlock(block)); await rewriteMutation.trigger({ @@ -519,18 +600,9 @@ function WorkbenchSessionSurface({ }); } - async function resolveAttachmentPreview(attachmentId: string): Promise { - if (!activeSessionId) return null; - try { - const { bytes } = await client.getAttachmentBytes( - activeSessionId, - AttachmentIdSchema.parse(attachmentId), - ); - return { url: attachmentObjectUrl(attachmentId, bytes) }; - } catch (error) { - if (isErrorLikeObject(error) && 'code' in error && error.code === 'not_found') return null; - throw error; - } + function resolveAttachmentPreview(attachmentId: string): Promise { + if (!activeSessionId) return Promise.resolve(null); + return resolveStoredAttachmentPreview(client, activeSessionId, attachmentId); } function handleModeChange(modeId: string): Promise { @@ -592,6 +664,66 @@ function WorkbenchSessionSurface({ ? { state: 'ready', onRunShellCommand: handleRunShellCommand } : { state: 'unsupported' }, }; + function handleSelectVersion(messageId: string, direction: -1 | 1): void { + if (graph === undefined || active === null) return; + const turn = graph.turns.find((candidate) => userRowMessageId(candidate.turnId) === messageId); + if (turn === undefined) return; + const siblings = siblingsOf(graph.turns, turn); + const target = + siblings[siblings.findIndex((sibling) => sibling.turnId === turn.turnId) + direction]; + if (target === undefined) return; + const store = useLineageStore.getState(); + store.rememberChild(active.sessionId, lineageParentKey(target.parentTurnId), target.turnId); + const leaf = descendToLeaf( + graph.turns, + target.turnId, + useLineageStore.getState().preferredChildBySession[active.sessionId] ?? {}, + ); + // Switching is a pure read: the host default never moves until something is submitted. + if (leaf === graph.activeLeafTurnId) store.follow(active.sessionId); + else store.park(active.sessionId, leaf, graph.activeLeafTurnId); + } + + function handleJumpToLatest(): void { + if (active !== null) useLineageStore.getState().follow(active.sessionId); + } + + function handleDismissElsewhere(): void { + if (active !== null && graph !== undefined) { + useLineageStore.getState().dismissElsewhere(active.sessionId, graph.activeLeafTurnId); + } + } + + const isRunning = conversation.status === 'running' || conversation.status === 'starting'; + const lineage: ConversationLineage | undefined = + graph === undefined || graphById === undefined || active === null + ? undefined + : { + versions: lineageVersions( + graph.turns, + lineagePath(graphById, parked?.leafTurnId ?? graph.activeLeafTurnId), + ), + onSelectVersion: handleSelectVersion, + notice: + parked === undefined + ? null + : graph.activeLeafTurnId !== parked.sinceLeafTurnId && + graph.activeLeafTurnId !== parked.dismissedLeafTurnId + ? { + kind: 'elsewhere', + onJump: handleJumpToLatest, + onDismiss: handleDismissElsewhere, + } + : { kind: 'parked', onJump: handleJumpToLatest }, + rewritesViaGraph: graphEditable, + promptEditState: + graphEditable || active.historyCapabilities?.branch === true + ? isRunning + ? 'busy' + : 'enabled' + : 'unsupported', + }; + const conversationComposer: ConversationComposerController = { onSend: handleSend, onStop: handleStopTurn, @@ -776,6 +908,7 @@ function WorkbenchSessionSurface({ onContinueUnverified={onboarding.acknowledgeUnverified} conversation={displayedConversation} onEditPrompt={handleEditPrompt} + lineage={lineage} onPrepareAttachment={client.supportsAttachmentStore ? handlePrepareAttachment : undefined} respondingRequestIds={respondingRequestIds} responseErrors={visibleResponseErrors} diff --git a/packages/client/workbench/tests/integration/dev-mock-attachments.test.ts b/packages/client/workbench/tests/integration/dev-mock-attachments.test.ts index 4441019f9..7965ed5fe 100644 --- a/packages/client/workbench/tests/integration/dev-mock-attachments.test.ts +++ b/packages/client/workbench/tests/integration/dev-mock-attachments.test.ts @@ -1,8 +1,10 @@ +import { createHash } from 'node:crypto'; import { LinkCodeClient } from '@linkcode/client-core'; import { ATTACHMENT_UPLOAD_CHUNK_BYTES, AttachmentIdSchema, attachmentIdFromUri, + OperationIdSchema, } from '@linkcode/schema'; import { nullthrow } from 'foxts/guard'; import { describe, expect, it, vi } from 'vitest'; @@ -32,6 +34,45 @@ describe('dev mock attachment store', () => { client.dispose(); }); + it('refuses a replayed begin whose declared fields differ, the way the daemon does', async () => { + const client = await connectedClient(); + const declared = { + operationId: OperationIdSchema.parse('op-mock-shared'), + declaredSha256: 'a'.repeat(64), + declaredSize: 5, + name: 'draft.bin', + attachmentKind: 'file', + }; + const first = await client.beginAttachmentUpload(declared); + await expect( + client.beginAttachmentUpload({ ...declared, declaredSha256: 'b'.repeat(64) }), + ).rejects.toThrow('The operation id belongs to another upload'); + const replayed = await client.beginAttachmentUpload(declared); + expect(replayed.uploadId).toBe(first.uploadId); + client.dispose(); + }); + + it('mints a fresh upload for a begin replayed after its commit, the way the daemon forgets', async () => { + const client = await connectedClient(); + const bytes = new TextEncoder().encode('committed draft'); + const declared = { + operationId: OperationIdSchema.parse('op-mock-committed'), + declaredSha256: createHash('sha256').update(bytes).digest('hex'), + declaredSize: bytes.byteLength, + name: 'draft.txt', + mimeType: 'text/plain', + attachmentKind: 'file', + }; + const first = await client.beginAttachmentUpload(declared); + await client.sendAttachmentChunk(first.uploadId, 0, Buffer.from(bytes).toString('base64')); + await client.commitAttachmentUpload(first.uploadId); + + const again = await client.beginAttachmentUpload(declared); + expect(again.uploadId).not.toBe(first.uploadId); + expect(again.state).toBe('exists'); + client.dispose(); + }); + it('reads an attachment only from a session that roots it', async () => { const client = await connectedClient(); const sessionId = await client.startSession({ kind: 'codex', cwd: '/mock/repo' }); diff --git a/packages/client/workbench/tests/integration/dev-mock-lineage.test.ts b/packages/client/workbench/tests/integration/dev-mock-lineage.test.ts new file mode 100644 index 000000000..3c699a93b --- /dev/null +++ b/packages/client/workbench/tests/integration/dev-mock-lineage.test.ts @@ -0,0 +1,210 @@ +import { LinkCodeClient } from '@linkcode/client-core'; +import type { ConversationReadItem, TurnId } from '@linkcode/schema'; +import { userRowMessageId } from '@linkcode/schema'; +import { nullthrow } from 'foxts/guard'; +import { describe, expect, it, vi } from 'vitest'; +import { createDevMockTransport } from '../../src/mock/dev-mock-transport'; + +function userTexts(events: readonly ConversationReadItem[]): string[] { + return events.flatMap((item) => + 'event' in item && item.event.type === 'user-message' + ? item.event.content.flatMap((block) => (block.type === 'text' ? [block.text] : [])) + : [], + ); +} + +/** Shell-command turns settle at once in the mock, so a tree can be built without streaming. */ +describe('dev mock turn lineages', () => { + it('lands an explicit-parent submit as a sibling and reads either lineage', async () => { + const client = new LinkCodeClient(createDevMockTransport()); + await client.connect(); + const sessionId = await client.startSession({ kind: 'codex', cwd: '/mock/repo' }); + + const a = await client.submitTurn(sessionId, { type: 'shell-command', command: 'a' }); + const b = await client.submitTurn(sessionId, { type: 'shell-command', command: 'b' }); + let graph = await client.getConversationGraph(sessionId); + expect(graph.activeLeafTurnId).toBe(b.turnId); + + // Editing B = a sibling under parent(B); the old lineage stays readable. + const edited = await client.submitTurn( + sessionId, + { type: 'shell-command', command: 'b2' }, + { parentTurnId: a.turnId, expectedGraphRevision: graph.graphRevision }, + ); + graph = await client.getConversationGraph(sessionId); + expect(graph.activeLeafTurnId).toBe(edited.turnId); + expect(graph.graphRevision).toBe(3); + const ordinals = new Map(graph.turns.map((turn) => [turn.turnId, turn.siblingOrdinal])); + expect(ordinals.get(a.turnId)).toBe(1); + expect(ordinals.get(b.turnId)).toBe(1); + expect(ordinals.get(edited.turnId)).toBe(2); + expect(graph.turns.find((turn) => turn.turnId === edited.turnId)?.parentTurnId).toBe(a.turnId); + + const active = await client.readConversation(sessionId); + expect(active.leafTurnId).toBe(edited.turnId); + expect(userTexts(active.events)).toEqual(['$ a', '$ b2']); + const old = await client.readConversation(sessionId, { leafTurnId: b.turnId }); + expect(old.leafTurnId).toBe(b.turnId); + expect(userTexts(old.events)).toEqual(['$ a', '$ b']); + + // A new root lineage: editing the first prompt. + const root2 = await client.submitTurn( + sessionId, + { type: 'shell-command', command: 'a2' }, + { parentTurnId: null, expectedGraphRevision: graph.graphRevision }, + ); + graph = await client.getConversationGraph(sessionId); + expect(graph.turns.find((turn) => turn.turnId === root2.turnId)).toMatchObject({ + parentTurnId: null, + siblingOrdinal: 2, + }); + expect(userTexts((await client.readConversation(sessionId)).events)).toEqual(['$ a2']); + client.dispose(); + }); + + it('refuses a stale revision, an unknown parent, and a submit while a turn runs', async () => { + const client = new LinkCodeClient(createDevMockTransport()); + await client.connect(); + const sessionId = await client.startSession({ kind: 'codex', cwd: '/mock/repo' }); + const a = await client.submitTurn(sessionId, { type: 'shell-command', command: 'a' }); + const graph = await client.getConversationGraph(sessionId); + + await expect( + client.submitTurn( + sessionId, + { type: 'shell-command', command: 'stale' }, + { parentTurnId: a.turnId, expectedGraphRevision: graph.graphRevision - 1 }, + ), + ).rejects.toMatchObject({ code: 'conflict' }); + await expect( + client.submitTurn( + sessionId, + { type: 'shell-command', command: 'orphan' }, + { parentTurnId: 'turn-nope' as TurnId, expectedGraphRevision: graph.graphRevision }, + ), + ).rejects.toMatchObject({ code: 'not_found' }); + // The daemon judges the parent before the revision. + await expect( + client.submitTurn( + sessionId, + { type: 'shell-command', command: 'orphan' }, + { parentTurnId: 'turn-nope' as TurnId, expectedGraphRevision: graph.graphRevision - 1 }, + ), + ).rejects.toMatchObject({ code: 'not_found' }); + + // A prompt turn streams for a while: the session is busy until it settles. + const running = await client.submitTurn(sessionId, { + type: 'prompt', + blocks: [{ type: 'text', text: 'slow reply' }], + }); + const revision = nullthrow(client.latestGraphChange(sessionId)).graphRevision; + await expect( + client.submitTurn( + sessionId, + { type: 'shell-command', command: 'too soon' }, + { parentTurnId: running.turnId, expectedGraphRevision: revision }, + ), + ).rejects.toMatchObject({ code: 'busy' }); + await client.send(sessionId, { type: 'cancel' }); + client.dispose(); + }); + + it('re-announces a turn that failed after it began at the same revision, keeping its ordinal and the leaf', async () => { + const client = new LinkCodeClient(createDevMockTransport()); + await client.connect(); + const sessionId = await client.startSession({ kind: 'codex', cwd: '/mock/repo' }); + client.attachSession(sessionId); + await client.submitTurn(sessionId, { type: 'shell-command', command: 'a' }); + const failing = await client.submitTurn(sessionId, { + type: 'prompt', + blocks: [{ type: 'text', text: 'fail' }], + }); + const begun = nullthrow(client.latestGraphChange(sessionId)); + const graph = await vi.waitFor(async () => { + const snapshot = await client.getConversationGraph(sessionId); + const turn = snapshot.turns.find((candidate) => candidate.turnId === failing.turnId); + if (turn?.state !== 'failed') throw new Error('not failed yet'); + return snapshot; + }); + expect(graph.turns.find((turn) => turn.turnId === failing.turnId)?.siblingOrdinal).toBe(1); + expect(client.latestGraphChange(sessionId)).toEqual({ + graphRevision: begun.graphRevision, + activeLeafTurnId: begun.activeLeafTurnId, + }); + client.dispose(); + }); + + it('refuses a prompt before dispatch: the tree gains a failed sibling, the default leaf stays', async () => { + const client = new LinkCodeClient(createDevMockTransport()); + await client.connect(); + const sessionId = await client.startSession({ kind: 'codex', cwd: '/mock/repo' }); + client.attachSession(sessionId); + const echoes: string[] = []; + client.subscribe(sessionId, (entry) => { + if (entry.event.type === 'user-message') echoes.push(entry.event.messageId); + }); + const a = await client.submitTurn(sessionId, { type: 'shell-command', command: 'a' }); + const b = await client.submitTurn(sessionId, { type: 'shell-command', command: 'b' }); + let graph = await client.getConversationGraph(sessionId); + + await expect( + client.submitTurn( + sessionId, + { type: 'prompt', blocks: [{ type: 'text', text: 'refuse' }] }, + { parentTurnId: a.turnId, expectedGraphRevision: graph.graphRevision }, + ), + ).rejects.toMatchObject({ code: 'operation_failed' }); + + expect(client.latestGraphChange(sessionId)).toEqual({ + graphRevision: graph.graphRevision + 1, + activeLeafTurnId: b.turnId, + }); + graph = await client.getConversationGraph(sessionId); + const refused = nullthrow( + graph.turns.find((turn) => turn.parentTurnId === a.turnId && turn.siblingOrdinal === 2), + ); + expect(refused.state).toBe('failed'); + expect(graph.activeLeafTurnId).toBe(b.turnId); + expect(echoes).not.toContain(userRowMessageId(refused.turnId)); + // Its lineage reads as the shared prefix plus its own prompt row — no placeholder: nothing ran. + const read = await client.readConversation(sessionId, { leafTurnId: refused.turnId }); + expect(userTexts(read.events)).toEqual(['$ a', 'refuse']); + expect(read.events.some((item) => !('event' in item) && item.turnId === refused.turnId)).toBe( + false, + ); + client.dispose(); + }); + + it('settles a cancelled prompt as cancelled and re-announces the tree at its revision', async () => { + const client = new LinkCodeClient(createDevMockTransport()); + await client.connect(); + const sessionId = await client.startSession({ kind: 'codex', cwd: '/mock/repo' }); + client.attachSession(sessionId); + const running = await client.submitTurn(sessionId, { + type: 'prompt', + blocks: [{ type: 'text', text: 'slow reply' }], + }); + const begun = nullthrow(client.latestGraphChange(sessionId)); + await client.send(sessionId, { type: 'cancel' }); + await vi.waitFor(async () => { + const snapshot = await client.getConversationGraph(sessionId); + const turn = snapshot.turns.find((candidate) => candidate.turnId === running.turnId); + if (turn?.state !== 'cancelled') throw new Error('not cancelled yet'); + }); + expect(client.latestGraphChange(sessionId)).toEqual({ + graphRevision: begun.graphRevision, + activeLeafTurnId: running.turnId, + }); + client.dispose(); + }); + + it('refuses to read toward a turn the session does not have', async () => { + const client = new LinkCodeClient(createDevMockTransport()); + await client.connect(); + const sessionId = await client.startSession({ kind: 'codex', cwd: '/mock/repo' }); + await expect( + client.readConversation(sessionId, { leafTurnId: 'turn-nope' as TurnId }), + ).rejects.toMatchObject({ code: 'not_found' }); + client.dispose(); + }); +}); diff --git a/packages/foundation/schema/src/model/session/record.ts b/packages/foundation/schema/src/model/session/record.ts index 2eb738d95..ba0742684 100644 --- a/packages/foundation/schema/src/model/session/record.ts +++ b/packages/foundation/schema/src/model/session/record.ts @@ -72,6 +72,9 @@ export const SessionRunSchema = z.object({ approvalPolicyId: ApprovalPolicyIdSchema.optional(), startedAt: TimestampSchema, endedAt: TimestampSchema.optional(), + /** Launched onto other provider history for a submit whose turn never ran; that history is not + * the thread's, so history resolution skips the run. */ + abandonedAt: TimestampSchema.optional(), }); export type SessionRun = z.infer; diff --git a/packages/foundation/schema/src/wire/attachment.ts b/packages/foundation/schema/src/wire/attachment.ts index 1d90b1907..2ff864de9 100644 --- a/packages/foundation/schema/src/wire/attachment.ts +++ b/packages/foundation/schema/src/wire/attachment.ts @@ -33,7 +33,8 @@ export const attachmentWireVariants = [ z.object({ kind: z.literal('attachment.upload.begin'), clientReqId: WireRequestIdSchema, - /** Replay key for a lost begin reply; a second begin with the same id returns the first. */ + /** Replay key for a lost begin reply: a second begin with the same id and the same declared + * fields returns the first; other fields are refused. */ operationId: OperationIdSchema.optional(), declaredSha256: Sha256HexSchema, declaredSize: z.number().int().nonnegative().max(MAX_ATTACHMENT_BYTES), diff --git a/packages/foundation/schema/src/wire/conversation.ts b/packages/foundation/schema/src/wire/conversation.ts index 053173186..e87949a90 100644 --- a/packages/foundation/schema/src/wire/conversation.ts +++ b/packages/foundation/schema/src/wire/conversation.ts @@ -99,8 +99,9 @@ export const conversationWireVariants = [ activeLeafTurnId: TurnIdSchema.optional(), turns: z.array(ConversationGraphTurnSchema), }), - /** Session-scoped broadcast: the graph changed shape or moved its default leaf; clients holding - * a stale snapshot revalidate via `conversation.graph.get`. */ + /** Session-scoped broadcast: the graph gained a node or moved its default leaf (a new + * `graphRevision`), or a visible turn reached its terminal state (the revision stands); clients + * holding a stale snapshot revalidate via `conversation.graph.get`. */ z.object({ kind: z.literal('conversation.graph.changed'), sessionId: SessionIdSchema, diff --git a/packages/foundation/schema/src/wire/message.ts b/packages/foundation/schema/src/wire/message.ts index 454c86262..fd63d3d3b 100644 --- a/packages/foundation/schema/src/wire/message.ts +++ b/packages/foundation/schema/src/wire/message.ts @@ -9,7 +9,7 @@ import { WIRE_PAYLOAD_KINDS, WirePayloadSchema } from './payload'; */ /** Stamped on every frame this build sends; bump on any wire schema change. */ -export const WIRE_PROTOCOL_VERSION = 80 as const; +export const WIRE_PROTOCOL_VERSION = 81 as const; /** The oldest `v` this build still accepts. Bump only for a breaking change — a variant or field * removed, renamed, or given a new meaning; additive changes leave it alone. */ diff --git a/packages/foundation/schema/src/wire/resource.ts b/packages/foundation/schema/src/wire/resource.ts index 53ab57498..6f74f008e 100644 --- a/packages/foundation/schema/src/wire/resource.ts +++ b/packages/foundation/schema/src/wire/resource.ts @@ -1,5 +1,4 @@ import { z } from 'zod'; -import { AttachmentNameSchema, MimeTypeSchema } from '../model/attachment'; import { MAX_ATTACHMENT_BYTES } from '../model/content'; import { SessionIdSchema } from '../model/primitives'; import { @@ -24,8 +23,10 @@ export const resourceWireVariants = [ kind: z.literal('resource.source.upload'), clientReqId: WireRequestIdSchema, sessionId: SessionIdSchema, - name: AttachmentNameSchema, - mimeType: MimeTypeSchema.optional(), + /** Unbounded since v79 and kept so: tightening here would drop an older peer's frame unanswered. + * The handler caps the name and refuses a long MIME type typed instead. */ + name: z.string().min(1), + mimeType: z.string().min(1).optional(), data: z.string().max(4 * Math.ceil(MAX_ATTACHMENT_BYTES / 3)), }), z.object({ diff --git a/packages/foundation/schema/tests/contract/wire/attachment.test.ts b/packages/foundation/schema/tests/contract/wire/attachment.test.ts index 769f3ef1f..40b130fb9 100644 --- a/packages/foundation/schema/tests/contract/wire/attachment.test.ts +++ b/packages/foundation/schema/tests/contract/wire/attachment.test.ts @@ -203,6 +203,8 @@ describe('attachment upload/read frames', () => { expect(parses({ ...begin, name: 'x', mimeType: 'a'.repeat(MAX_MIME_TYPE_LENGTH + 1) })).toBe( false, ); + // The legacy frame shipped at v79 without bounds and keeps them off the wire: a released client's + // over-long name must still parse (the handler caps it), or its request hangs unanswered. expect( parses({ kind: 'resource.source.upload', @@ -211,6 +213,6 @@ describe('attachment upload/read frames', () => { name: 'x'.repeat(MAX_ATTACHMENT_NAME_LENGTH + 1), data: 'YQ==', }), - ).toBe(false); + ).toBe(true); }); }); diff --git a/packages/host/engine/src/__tests__/attachment-admit.test.ts b/packages/host/engine/src/__tests__/attachment-admit.test.ts index 1dd9e519c..e78155362 100644 --- a/packages/host/engine/src/__tests__/attachment-admit.test.ts +++ b/packages/host/engine/src/__tests__/attachment-admit.test.ts @@ -187,6 +187,28 @@ describe('assertInlineAttachmentsSupported', () => { } expect.fail('expected a typed refusal'); }); + + it('counts inline images against the harness maxCount, the way a ref submit is counted', () => { + const capability = nullthrow(effectiveAttachmentCapability('claude-code')); + const maxCount = nullthrow(capability.kinds.image).maxCount; + const image = { type: 'image' as const, mimeType: 'image/png', data: 'AA==' }; + expect(() => + assertInlineAttachmentsSupported( + createFixedArray(maxCount).map(() => image), + capability, + ), + ).not.toThrow(); + try { + assertInlineAttachmentsSupported( + createFixedArray(maxCount + 1).map(() => image), + capability, + ); + } catch (error) { + expect(error).toMatchObject({ code: 'limit_exceeded', message: 'Too many attachments' }); + return; + } + expect.fail('expected a typed refusal'); + }); }); describe('admitPromptAttachments with a file kind declared', () => { diff --git a/packages/host/engine/src/__tests__/attachment-upload.test.ts b/packages/host/engine/src/__tests__/attachment-upload.test.ts index d53e00531..874f7d743 100644 --- a/packages/host/engine/src/__tests__/attachment-upload.test.ts +++ b/packages/host/engine/src/__tests__/attachment-upload.test.ts @@ -10,12 +10,18 @@ import { SessionIdSchema, } from '@linkcode/schema'; import { Effect } from 'effect'; +import { createFixedArray } from 'foxts/create-fixed-array'; import { afterEach, describe, expect, it } from 'vitest'; import { InMemoryAttachmentStore } from '../attachment/attachment-store'; import { FsBlobStore } from '../attachment/blob-store'; import { AttachmentGc, UPLOAD_LEASE_TTL_MS } from '../attachment/gc'; +import { AttachmentIngest } from '../attachment/ingest'; import { AttachmentIoMutex } from '../attachment/io-mutex'; -import { AttachmentUploadService } from '../attachment/upload-service'; +import { + AttachmentUploadService, + MAX_LIVE_UPLOADS, + UPLOAD_IDLE_MS, +} from '../attachment/upload-service'; const temporaryDirectories: string[] = []; const sessionId = SessionIdSchema.parse('session-1'); @@ -48,6 +54,17 @@ function stagingEntries(root: string): Promise { return readdir(join(root, 'blobs', 'tmp')); } +/** A distinct small upload per label. */ +function declareUpload(label: string) { + const bytes = Buffer.from(`upload ${label}`); + return { + declaredSha256: sha256(bytes), + declaredSize: bytes.byteLength, + name: `${label}.bin`, + attachmentKind: 'file', + }; +} + function chunksOf(bytes: Buffer): Array<{ offset: number; data: string }> { const chunks: Array<{ offset: number; data: string }> = []; for (let offset = 0; offset < bytes.byteLength; offset += ATTACHMENT_UPLOAD_CHUNK_BYTES) { @@ -447,6 +464,91 @@ describe('AttachmentUploadService', () => { ); expect(live?.head.buffer.byteLength).toBeLessThanOrEqual(16); }); + + it('refuses a replayed begin whose declared fields differ instead of handing over the upload', async () => { + const { uploads } = await makeService(); + const bytes = Buffer.from('draft'); + const declared = { + operationId: 'op-shared', + declaredSha256: sha256(bytes), + declaredSize: bytes.byteLength, + name: 'draft.bin', + attachmentKind: 'file', + }; + const first = await run(uploads.begin(declared)); + await expect( + run(uploads.begin({ ...declared, declaredSha256: sha256(Buffer.from('other')) })), + ).rejects.toMatchObject({ _tag: 'RequestError', code: 'invalid_request' }); + await expect(run(uploads.begin({ ...declared, name: 'other.bin' }))).rejects.toMatchObject({ + _tag: 'RequestError', + code: 'invalid_request', + }); + expect((await run(uploads.begin(declared))).uploadId).toBe(first.uploadId); + }); + + it('releases an idle stage at the next begin, long before its lease expires', async () => { + let now = 1000; + const { root, uploads } = await makeService([], () => now); + const bytes = Buffer.from('stalled'); + const declare = (name: string) => ({ + declaredSha256: sha256(bytes), + declaredSize: bytes.byteLength, + name, + attachmentKind: 'file', + }); + const stalled = await run(uploads.begin(declare('stalled.bin'))); + // A chunk keeps the upload alive; silence past the idle window does not. + now += UPLOAD_IDLE_MS - 1; + await run(uploads.chunk(stalled.uploadId, 0, bytes.subarray(0, 2).toString('base64'))); + now += UPLOAD_IDLE_MS - 1; + const kept = await run(uploads.begin(declare('kept.bin'))); + expect((await stagingEntries(root)).sort()).toEqual([kept.uploadId, stalled.uploadId].sort()); + + now += UPLOAD_IDLE_MS; + const later = await run(uploads.begin(declare('later.bin'))); + expect(await stagingEntries(root)).toEqual([later.uploadId]); + await expect( + run(uploads.chunk(stalled.uploadId, 2, bytes.subarray(2).toString('base64'))), + ).rejects.toMatchObject({ _tag: 'RequestError', code: 'conflict' }); + }); + + it('refuses a begin past the live-upload cap until one ends, counting begins still in flight', async () => { + const { uploads } = await makeService(); + // One more than the cap, all in flight at once: exactly one must be turned away. + const settled = await Promise.allSettled( + createFixedArray(MAX_LIVE_UPLOADS + 1).map((i) => + run(uploads.begin(declareUpload(String(i)))), + ), + ); + const refused = settled.filter((result) => result.status === 'rejected'); + expect(refused).toHaveLength(1); + expect(refused[0]).toMatchObject({ + reason: { _tag: 'RequestError', code: 'limit_exceeded' }, + }); + const live = settled.find((result) => result.status === 'fulfilled'); + if (live?.status !== 'fulfilled') throw new Error('no upload was admitted'); + await run(uploads.abort(live.value.uploadId)); + expect((await run(uploads.begin(declareUpload('extra')))).state).toBe('ready'); + }); +}); + +describe('AttachmentIngest', () => { + it('keeps a rowed blob when a same-hash ingest fails to publish its record', async () => { + const { attachments, blobs } = await makeService(); + const ingest = new AttachmentIngest(blobs, attachments, new AttachmentIoMutex()); + const bytes = Buffer.from('the same brief twice'); + const record = { kind: 'file', name: 'brief.txt', mimeType: 'text/plain' }; + const first = await ingest.store(bytes, record); + const blobId = blobIdFromSha256(sha256(bytes)); + + const commitAttachment = attachments.commitAttachment.bind(attachments); + attachments.commitAttachment = () => Promise.reject(new Error('row insert failed')); + await expect(ingest.store(bytes, record)).rejects.toThrow('row insert failed'); + attachments.commitAttachment = commitAttachment; + + expect(await blobs.stat(blobId)).toEqual({ sizeBytes: bytes.byteLength }); + expect((await attachments.getAttachment(first))?.blobId).toBe(blobId); + }); }); describe('in-memory attachment reachability', () => { diff --git a/packages/host/engine/src/__tests__/conversation-projection.test.ts b/packages/host/engine/src/__tests__/conversation-projection.test.ts index 009987be4..f545e5c5c 100644 --- a/packages/host/engine/src/__tests__/conversation-projection.test.ts +++ b/packages/host/engine/src/__tests__/conversation-projection.test.ts @@ -18,12 +18,14 @@ import { noop } from 'foxts/noop'; import { describe, expect, it } from 'vitest'; import { ConversationCheckpointService } from '../conversation/checkpoint-service'; import { InMemoryConversationStore } from '../conversation/conversation-store'; +import { attributeCorpus } from '../conversation/lineage-attribution'; import type { JournaledEvent } from '../conversation/live-journal'; import { ConversationLiveJournals } from '../conversation/live-journal'; import { ConversationProjectionService, pageReadItems } from '../conversation/projection-service'; import { ConversationTurnService } from '../conversation/turn-service'; import { RequestError } from '../failure'; import { HistoryService } from '../session/history-service'; +import { promptContentFingerprint } from '../session/live-session'; import { SessionRecordRegistry } from '../session/session-record-registry'; import { InMemorySessionStore } from '../session/session-store'; import { FakeAdapter } from './fixtures/session-harness'; @@ -38,6 +40,11 @@ const OPEN_ASK: AgentEvent = { subject: { type: 'tool-call', toolCallId: 't1' }, options: [{ optionId: 'ok', name: 'Allow', kind: 'allow_once' }], }; +const RESPONDING: AgentEvent = { + type: 'prompt-response-status', + requestId: 'perm-open', + status: 'responding', +}; function chunk(messageId: string, text: string): AgentEvent { return { @@ -52,14 +59,14 @@ function stamped(seq: number, turnId: TurnId, event: AgentEvent): JournaledEvent } class CannedHistoryAdapter extends FakeAdapter { - constructor(private readonly events: AgentHistoryEvent[]) { + constructor(private readonly eventsFor: (historyId: string) => AgentHistoryEvent[]) { super(); } override readHistory(opts: AgentHistoryReadOptions): Promise { return Promise.resolve({ session: { historyId: opts.historyId, kind: this.kind, cwd: '/repo', createdAt: 1 }, - events: [...this.events], + events: [...this.eventsFor(opts.historyId)], }); } } @@ -68,7 +75,10 @@ async function makeService(opts: { journals: ConversationLiveJournals; record: SessionRecord; openRequests?: AgentEvent[]; + /** One corpus for every history id. */ historyEvents?: AgentHistoryEvent[]; + /** A corpus per history id — forked lineages read different histories. */ + historiesById?: Record; }) { const runTask = (effect: Effect.Effect) => { void Effect.runPromise(effect); @@ -85,8 +95,11 @@ async function makeService(opts: { records.register(opts.record); const store = new InMemoryConversationStore(); const turns = new ConversationTurnService(store, records, transport, runTask); + const { historyEvents, historiesById } = opts; const history = new HistoryService(() => - opts.historyEvents ? new CannedHistoryAdapter(opts.historyEvents) : new FakeAdapter(), + historyEvents || historiesById + ? new CannedHistoryAdapter((historyId) => historiesById?.[historyId] ?? historyEvents ?? []) + : new FakeAdapter(), ); const service = new ConversationProjectionService( turns, @@ -138,7 +151,7 @@ describe('conversation projection live tail (CODE-35)', () => { const { service, store } = await makeService({ journals, record: makeRecord(liveTurnId), - openRequests: [OPEN_ASK], + openRequests: [OPEN_ASK, RESPONDING], }); await store.saveTurn({ turnId: liveTurnId, @@ -171,9 +184,11 @@ describe('conversation projection live tail (CODE-35)', () => { turnId: liveTurnId, runId, }); - // The open ask reaches the reader even though its request event never survived the journal. + // The open ask reaches the reader even though its request event never survived the journal, + // and so does the status of the answer in flight — the UI keeps its "responding" state. const ask = result.events.find((item) => 'event' in item && item.event === OPEN_ASK); expect(ask).toMatchObject({ turnId: liveTurnId, runId }); + expect(result.events.some((item) => 'event' in item && item.event === RESPONDING)).toBe(true); }); it('surfaces truncation when a full-state event above the cut was evicted', async () => { @@ -330,6 +345,50 @@ describe('conversation projection live tail (CODE-35)', () => { }); expect(result.watermark).toEqual({ epoch: 3, seq: 3 }); }); + + it('carries the live tail only into the lineage that owns the running turn', async () => { + const rootTurnId = 'turn-root' as TurnId; + const firstVersionId = 'turn-v1' as TurnId; + const editedVersionId = 'turn-v2' as TurnId; + const strayTurnId = 'turn-stray' as TurnId; + const journals = new ConversationLiveJournals(); + const journal = journals.open(sessionId); + // After an edit the journal is the relaunch's: it holds the edited sibling's stream, and no + // entry of the version being read back — nothing settled the cut can anchor on. + journal.append(stamped(1, editedVersionId, chunk('msg-edited', 'edited'))); + journal.append(stamped(2, strayTurnId, chunk('msg-stray', 'a refused sibling'))); + + const { service, store } = await makeService({ + journals, + record: makeRecord(editedVersionId), + }); + const turnAt = ( + turnId: TurnId, + parentTurnId: TurnId | null, + ordinal: number, + state: ConversationTurnState, + ) => + store.saveTurn({ + turnId, + sessionId, + parentTurnId, + siblingOrdinal: ordinal, + input: { type: 'shell-command', command: turnId }, + runId, + state, + createdAt: ordinal, + }); + await turnAt(rootTurnId, null, 1, 'completed'); + await turnAt(firstVersionId, rootTurnId, 1, 'completed'); + await turnAt(editedVersionId, rootTurnId, 2, 'running'); + + const seqsOf = (items: ConversationReadItem[]) => + items.flatMap((item) => ('event' in item && item.seq !== undefined ? [item.seq] : [])); + const parked = await Effect.runPromise(service.read({ sessionId, leafTurnId: firstVersionId })); + expect(seqsOf(parked.events)).toEqual([]); + const active = await Effect.runPromise(service.read({ sessionId })); + expect(seqsOf(active.events)).toEqual([1]); + }); }); describe('conversation projection attribution gate', () => { @@ -424,36 +483,248 @@ describe('conversation projection attribution gate', () => { }, ); - it('never attributes positionally on an inactive sibling lineage — even an identical retry', async () => { + it('reads a shared turn from the run that wrote it, so an identical retry on a fork never claims its rows', async () => { + const forkRunId = 'run-2' as RunId; + const record: SessionRecord = { + ...makeRecord('turn-b2' as TurnId, true), + runs: [ + { runId, startedAt: 1, historyId: asHistoryId('hist-1') }, + { + runId: forkRunId, + startedAt: 2, + historyId: asHistoryId('hist-2'), + baseTurnId: 'turn-a' as TurnId, + }, + ], + }; + const { service, store } = await makeService({ + journals: new ConversationLiveJournals(), + record, + historiesById: { + 'hist-1': [ + providerUser('u-a', 'a'), + providerAnswer('ans-a', 'answer a'), + providerUser('u-b', 'b'), + providerAnswer('ans-b1', 'answer b'), + ], + // The fork copied A's rows — a copy, not the source: claude re-stamps what it copies — and + // the retry ran here with prompt text IDENTICAL to the sibling's. + 'hist-2': [ + providerUser('u-a-copy', 'a'), + providerAnswer('ans-a-copy', 'answer a'), + providerUser('u-b', 'b'), + providerAnswer('ans-b2', 'answer b'), + ], + }, + }); + await store.saveTurn(shellTurn('turn-a', null, 'a', 'completed')); + await store.saveTurn(shellTurn('turn-b1', 'turn-a', 'b', 'completed', 1)); + await store.saveTurn({ + ...shellTurn('turn-b2', 'turn-a', 'b', 'completed', 2), + runId: forkRunId, + }); + + // Both versions render A from hist-1, where A ran; each B reads the history its own run wrote. + const inactive = await Effect.runPromise( + service.read({ sessionId, leafTurnId: 'turn-b1' as TurnId }), + ); + expect(answers(inactive.events)).toEqual([ + ['ans-a', 'turn-a'], + ['ans-b1', 'turn-b1'], + ]); + const active = await Effect.runPromise(service.read({ sessionId })); + expect(answers(active.events)).toEqual([ + ['ans-a', 'turn-a'], + ['ans-b2', 'turn-b2'], + ]); + expect(placeholderTurnIds(active.events)).toEqual([]); + }); + + it('attributes an inactive lineage against its own run history, never the live one', async () => { + const forkRunId = 'run-2' as RunId; + const record: SessionRecord = { + ...makeRecord('turn-b2' as TurnId, true), + runs: [ + { runId, startedAt: 1, historyId: asHistoryId('hist-1') }, + { runId: forkRunId, startedAt: 2, historyId: asHistoryId('hist-2') }, + ], + }; + const { service, store } = await makeService({ + journals: new ConversationLiveJournals(), + record, + historiesById: { + 'hist-1': [ + providerUser('u-a', 'a'), + providerAnswer('ans-a', 'answer a'), + providerUser('u-b1', 'b1'), + providerAnswer('ans-b1', 'answer b1'), + ], + // The fork copied the prefix, then the sibling's own turn ran here. + 'hist-2': [ + providerUser('u-a', 'a'), + providerAnswer('ans-a2', 'answer a'), + providerUser('u-b2', 'b2'), + providerAnswer('ans-b2', 'answer b2'), + ], + }, + }); + await store.saveTurn(shellTurn('turn-a', null, 'a', 'completed')); + await store.saveTurn(shellTurn('turn-b1', 'turn-a', 'b1', 'completed', 1)); + await store.saveTurn({ + ...shellTurn('turn-b2', 'turn-a', 'b2', 'completed', 2), + runId: forkRunId, + }); + // A continue from B1 that never ran: its own lineage still reads from B1's history. + await store.saveTurn({ + ...shellTurn('turn-c1', 'turn-b1', 'c1', 'failed'), + runId: 'run-3' as RunId, + }); + + const expectOwnHistory = async (leafTurnId: TurnId) => { + const inactive = await Effect.runPromise(service.read({ sessionId, leafTurnId })); + expect(answers(inactive.events)).toEqual([ + ['ans-a', 'turn-a'], + ['ans-b1', 'turn-b1'], + ]); + expect(placeholderTurnIds(inactive.events)).toEqual([]); + }; + await expectOwnHistory('turn-b1' as TurnId); + await expectOwnHistory('turn-c1' as TurnId); + + // The active lineage's shared turn A also reads hist-1, never the fork's copy of it. + const active = await Effect.runPromise(service.read({ sessionId })); + expect(answers(active.events)).toEqual([ + ['ans-a', 'turn-a'], + ['ans-b2', 'turn-b2'], + ]); + }); + + it('reads the shared prefix of a lineage whose leaf failed from the live history', async () => { + const { service, store } = await makeService({ + journals: new ConversationLiveJournals(), + record: makeRecord('turn-l' as TurnId, true), + historyEvents: [ + providerUser('u-a', 'a'), + providerAnswer('ans-a', 'answer a'), + providerUser('u-l', 'l'), + providerAnswer('ans-l', 'answer l'), + ], + }); + await store.saveTurn(shellTurn('turn-a', null, 'a', 'completed')); + await store.saveTurn(shellTurn('turn-l', 'turn-a', 'l', 'completed', 1)); + // An edit of L refused at dispatch: a failed sibling on the live run, nothing durable ran. + await store.saveTurn(shellTurn('turn-l2', 'turn-a', 'l2', 'failed', 2)); + + const failed = await Effect.runPromise( + service.read({ sessionId, leafTurnId: 'turn-l2' as TurnId }), + ); + expect(answers(failed.events)).toEqual([['ans-a', 'turn-a']]); + expect(placeholderTurnIds(failed.events)).toEqual([]); + expect( + failed.events.flatMap((item) => + 'event' in item && item.event.type === 'user-message' ? [item.turnId] : [], + ), + ).toEqual(['turn-a', 'turn-l2']); + }); + + it('attributes a failed turn’s own provider rows and continues past it', async () => { const { service, store } = await makeService({ journals: new ConversationLiveJournals(), - record: makeRecord('turn-b2' as TurnId, true), + record: makeRecord('turn-b' as TurnId, true), + // The provider persisted the failed turn's prompt and its partial answer before the error. historyEvents: [ providerUser('u-a', 'a'), providerAnswer('ans-a', 'answer a'), + providerUser('u-f', 'f'), + providerAnswer('ans-f', 'partial f'), providerUser('u-b', 'b'), providerAnswer('ans-b', 'answer b'), ], }); await store.saveTurn(shellTurn('turn-a', null, 'a', 'completed')); - await store.saveTurn(shellTurn('turn-b1', 'turn-a', 'b', 'completed', 1)); - await store.saveTurn(shellTurn('turn-b2', 'turn-a', 'b', 'completed', 2)); + await store.saveTurn(shellTurn('turn-f', 'turn-a', 'f', 'failed')); + await store.saveTurn(shellTurn('turn-b', 'turn-f', 'b', 'completed')); - // The inactive sibling B1 carries IDENTICAL prompt text to the active B2: counts and - // fingerprints both pass, so only the active-lineage gate stops the mis-slice. - const inactive = await Effect.runPromise( - service.read({ sessionId, leafTurnId: 'turn-b1' as TurnId }), + const read = await Effect.runPromise(service.read({ sessionId })); + expect(answers(read.events)).toEqual([ + ['ans-a', 'turn-a'], + ['ans-f', 'turn-f'], + ['ans-b', 'turn-b'], + ]); + expect(placeholderTurnIds(read.events)).toEqual([]); + }); + + it('cuts a fork after a turn at the failed turn’s row that follows it', () => { + const fingerprint = (command: string) => + promptContentFingerprint([{ type: 'text', text: `$ ${command}` }]); + const attribution = attributeCorpus( + [ + providerUser('u-a', 'a', 'cut-a'), + providerAnswer('ans-a', 'answer a'), + providerUser('u-f', 'f', 'cut-f'), + providerUser('u-b', 'b', 'cut-b'), + providerAnswer('ans-b', 'answer b'), + ], + [ + { fingerprint: fingerprint('a'), failed: false }, + { fingerprint: fingerprint('f'), failed: true }, + { fingerprint: fingerprint('b'), failed: false }, + ], + undefined, ); - expect(answers(inactive.events)).toEqual([]); - expect(placeholderTurnIds(inactive.events)).toEqual(['turn-a', 'turn-b1']); + expect(attribution.attributed.map((partition) => partition.userRow.itemId)).toEqual([ + 'u-a', + 'u-b', + ]); + expect(attribution.failed.map((partition) => partition?.userRow.itemId)).toEqual(['u-f']); + // "After a" is before the failed attempt's row, not before b's. + expect(attribution.successors.map((row) => row?.itemId)).toEqual(['u-f', undefined]); + }); - // The active lineage attributes normally. - const active = await Effect.runPromise(service.read({ sessionId })); - expect(answers(active.events)).toEqual([ + it('consumes no partition for a failed turn that left no provider rows', async () => { + const { service, store } = await makeService({ + journals: new ConversationLiveJournals(), + record: makeRecord('turn-b' as TurnId, true), + historyEvents: [ + providerUser('u-a', 'a'), + providerAnswer('ans-a', 'answer a'), + providerUser('u-b', 'b'), + providerAnswer('ans-b', 'answer b'), + ], + }); + await store.saveTurn(shellTurn('turn-a', null, 'a', 'completed')); + await store.saveTurn(shellTurn('turn-f', 'turn-a', 'f', 'failed')); + await store.saveTurn(shellTurn('turn-b', 'turn-f', 'b', 'completed')); + + const read = await Effect.runPromise(service.read({ sessionId })); + expect(answers(read.events)).toEqual([ ['ans-a', 'turn-a'], - ['ans-b', 'turn-b2'], + ['ans-b', 'turn-b'], ]); - expect(placeholderTurnIds(active.events)).toEqual([]); + expect(placeholderTurnIds(read.events)).toEqual([]); + }); + + it('keeps the prefix before a failed turn when the provider footprint is ambiguous', async () => { + const { service, store } = await makeService({ + journals: new ConversationLiveJournals(), + record: makeRecord('turn-b' as TurnId, true), + // Two failed turns, one extra partition: which of them left rows is unknowable. + historyEvents: [ + providerUser('u-a', 'a'), + providerAnswer('ans-a', 'answer a'), + providerUser('u-f', 'f1'), + providerUser('u-b', 'b'), + providerAnswer('ans-b', 'answer b'), + ], + }); + await store.saveTurn(shellTurn('turn-a', null, 'a', 'completed')); + await store.saveTurn(shellTurn('turn-f1', 'turn-a', 'f1', 'failed')); + await store.saveTurn(shellTurn('turn-f2', 'turn-f1', 'f2', 'failed')); + await store.saveTurn(shellTurn('turn-b', 'turn-f2', 'b', 'completed')); + + const read = await Effect.runPromise(service.read({ sessionId })); + expect(answers(read.events)).toEqual([['ans-a', 'turn-a']]); + expect(placeholderTurnIds(read.events)).toEqual(['turn-b']); }); it('attributes nothing when the trailing extra partition is not the in-flight prompt', async () => { @@ -931,6 +1202,20 @@ describe('conversation read cursor integrity', () => { graphRevision: 999, leafTurnId: 'turn-live', settled: 1, + durable: 2, + offset: 1, + }), + }), + ); + // Right graph shape, wrong durable item count: the provider corpus moved between pages. + await expectConflict( + service.read({ + sessionId, + cursor: JSON.stringify({ + graphRevision: 1, + leafTurnId: 'turn-live', + settled: 1, + durable: 999, offset: 1, }), }), diff --git a/packages/host/engine/src/__tests__/engine-resources.test.ts b/packages/host/engine/src/__tests__/engine-resources.test.ts index 774e544da..dd8d73728 100644 --- a/packages/host/engine/src/__tests__/engine-resources.test.ts +++ b/packages/host/engine/src/__tests__/engine-resources.test.ts @@ -2,7 +2,12 @@ import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import type { AgentEvent, SessionResource, WirePayload } from '@linkcode/schema'; -import { MessageIdSchema, SessionIdSchema } from '@linkcode/schema'; +import { + MAX_ATTACHMENT_NAME_LENGTH, + MAX_MIME_TYPE_LENGTH, + MessageIdSchema, + SessionIdSchema, +} from '@linkcode/schema'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { RESOURCE_CONTEXT_SENTINEL } from '../resource/service'; import { createSessionHarness, startedSessionId } from './fixtures/session-harness'; @@ -31,6 +36,65 @@ function listedResources(sent: WirePayload[], replyTo: string): SessionResource[ } describe('engine session resources', () => { + it('caps a legacy upload name and refuses an over-long MIME type typed, so an older peer is answered', async () => { + const stateDir = await tempDirectory(); + const h = createSessionHarness( + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + { stateDir }, + ); + await h.engine.start(); + await h.inject({ + kind: 'session.start', + clientReqId: 'start', + opts: { kind: 'claude-code', cwd: stateDir }, + }); + const sessionId = startedSessionId(h.sent, 'start'); + + // The v79 frame bounds neither field; a released client may still send this. + await h.inject({ + kind: 'resource.source.upload', + clientReqId: 'long-name', + sessionId, + name: `${'n'.repeat(MAX_ATTACHMENT_NAME_LENGTH)}-and-then-some.txt`, + mimeType: 'text/plain', + data: Buffer.from('named').toString('base64'), + }); + await vi.waitFor(() => { + expect(h.sent).toContainEqual( + expect.objectContaining({ kind: 'resource.uploaded', replyTo: 'long-name' }), + ); + }); + const uploaded = h.sent.find( + (payload) => payload.kind === 'resource.uploaded' && payload.replyTo === 'long-name', + ); + if (uploaded?.kind !== 'resource.uploaded') throw new Error('no resource.uploaded'); + expect(uploaded.resource.status).toBe('ready'); + expect(uploaded.resource.name).toBe('n'.repeat(MAX_ATTACHMENT_NAME_LENGTH)); + + await h.inject({ + kind: 'resource.source.upload', + clientReqId: 'long-mime', + sessionId, + name: 'typed.bin', + mimeType: `application/${'x'.repeat(MAX_MIME_TYPE_LENGTH)}`, + data: Buffer.from('typed').toString('base64'), + }); + await vi.waitFor(() => { + expect(h.sent).toContainEqual( + expect.objectContaining({ + kind: 'request.failed', + replyTo: 'long-mime', + code: 'invalid_request', + }), + ); + }); + }); + it('persists a source, injects only its path into the adapter prompt, and cleans it up', async () => { const stateDir = await tempDirectory(); const h = createSessionHarness( 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 db018d0ae..a25b5dc9c 100644 --- a/packages/host/engine/src/__tests__/engine-turn-submit.test.ts +++ b/packages/host/engine/src/__tests__/engine-turn-submit.test.ts @@ -7,7 +7,9 @@ import type { AgentHistoryReadResult, AgentHistoryResumeOptions, AgentInput, + ConversationTurn, MessageId, + RunId, SessionId, StartOptions, TurnId, @@ -104,6 +106,17 @@ class ForkingAdapter extends FakeAdapter { } } +/** The fork child binds its own history, then the provider refuses the prompt. */ +class ForkThenRejectAdapter extends ForkingAdapter { + override send(input: AgentInput): Promise { + if (this.branchedFrom === null) return super.send(input); + this.sentInputs.push(input); + this.emit({ type: 'status', status: 'running' }); + this.emit({ type: 'status', status: 'idle' }); + return Promise.reject(new Error('provider refused the prompt')); + } +} + /** send() spans the whole turn (pi/grok): `running`, then a gate, then the turn's own checkpoint, * stop, and idle — all before it resolves. */ class GatedWholeTurnAdapter extends ForkingAdapter { @@ -217,6 +230,19 @@ function submittedTurnId(sent: WirePayload[], replyTo: string): TurnId { return reply.turnId; } +/** The history `session.list` reports for the harness session — what a relaunch would resume. */ +async function listedHistoryId( + h: Pick, 'inject' | 'sent'> & { sessionId: SessionId }, +) { + const clientReqId = `ls-${h.sent.length}`; + await h.inject({ kind: 'session.list', clientReqId }); + const reply = h.sent.find( + (payload) => payload.kind === 'session.listed' && payload.replyTo === clientReqId, + ); + if (reply?.kind !== 'session.listed') throw new Error('no session.listed reply'); + return nullthrow(reply.sessions.find((session) => session.sessionId === h.sessionId)).historyId; +} + function failure(sent: WirePayload[], replyTo: string) { const reply = sent.find( (payload) => payload.kind === 'request.failed' && payload.replyTo === replyTo, @@ -534,6 +560,97 @@ describe('turn.submit saga', () => { ); }); + it('a fork whose dispatch fails leaves the thread on its own history, never on the fork child', async () => { + const h = await startedHarness(() => new ForkThenRejectAdapter()); + const firstTurnId = await twoCheckpointedTurns(h); + const secondTurnId = submittedTurnId(h.sent, 's2'); + + await submitPrompt(h, 's3', 'edited second', { + parentTurnId: firstTurnId, + expectedGraphRevision: 2, + }); + await vi.waitFor(() => failure(h.sent, 's3')); + + const forked = forkedAdapter(h.adapters); + expect(forked.stopped).toBe(true); + expect(await listedHistoryId(h)).toBe('native-1'); + const [record] = await h.store.load(); + expect(record.activeLeafTurnId).toBe(secondTurnId); + expect(record.runs.at(-1)).toMatchObject({ historyId: 'native-child' }); + + // The next plain send resumes the thread's history under the old leaf; the child never sees it. + await submitPrompt(h, 's4', 'plain after the failure'); + await vi.waitFor(() => submittedTurnId(h.sent, 's4')); + expect(forked.sentInputs).toHaveLength(1); + expect(nullthrow(h.adapters.at(-1)).resumedFrom).toBe('native-1'); + const turns = await h.conversationStore.listTurns(h.sessionId); + expect(turns.find((turn) => turn.turnId === submittedTurnId(h.sent, 's4'))).toMatchObject({ + parentTurnId: secondTurnId, + state: 'running', + }); + }); + + it('boot recovery abandons a relaunch that died before its turn ran', async () => { + const store = new InMemorySessionStore(); + const conversationStore = new InMemoryConversationStore(); + const sessionId = 'sess-boot' as SessionId; + const sourceRunId = 'run-source' as RunId; + const childRunId = 'run-child' as RunId; + await store.save({ + sessionId, + kind: 'claude-code', + cwd: '/repo', + origin: { type: 'created' }, + createdAt: 1, + updatedAt: 3, + runs: [ + { runId: sourceRunId, historyId: asHistoryId('native-1'), startedAt: 1, endedAt: 3 }, + { + runId: childRunId, + baseTurnId: 'turn-a' as TurnId, + historyId: asHistoryId('native-child'), + startedAt: 3, + }, + ], + activeLeafTurnId: 'turn-l' as TurnId, + graphRevision: 2, + eventEpoch: 2, + }); + const turn = ( + turnId: string, + parentTurnId: string | null, + siblingOrdinal: number, + runId: RunId, + state: 'completed' | 'dispatching', + ): ConversationTurn => ({ + turnId: turnId as TurnId, + sessionId, + parentTurnId: parentTurnId as TurnId | null, + siblingOrdinal, + input: { type: 'shell-command', command: turnId }, + runId, + state, + createdAt: siblingOrdinal, + }); + await conversationStore.saveTurn(turn('turn-a', null, 1, sourceRunId, 'completed')); + await conversationStore.saveTurn(turn('turn-l', 'turn-a', 1, sourceRunId, 'completed')); + await conversationStore.saveTurn(turn('turn-x', 'turn-a', 2, childRunId, 'dispatching')); + + const h = harness(store, undefined, undefined, undefined, undefined, undefined, { + conversationStore, + }); + await h.engine.start(); + + const turns = await conversationStore.listTurns(sessionId); + expect(turns.find((candidate) => candidate.turnId === 'turn-x')).toMatchObject({ + state: 'failed', + }); + const [record] = await store.load(); + expect(record.runs.find((run) => run.runId === childRunId)?.abandonedAt).toBeTypeOf('number'); + expect(record.activeLeafTurnId).toBe('turn-l'); + expect(await listedHistoryId({ ...h, sessionId })).toBe('native-1'); + }); + it('falls back to the parent’s binding on the current history when its own run’s history is dead', async () => { const dead = new Set(); const h = await startedHarness(() => new DeadHistoryForkingAdapter(dead)); @@ -701,7 +818,10 @@ describe('turn.submit saga', () => { const operation = await h.conversationStore.getOperation(OperationIdSchema.parse('op-s1')); expect(operation?.state).toBe('failed'); expect((await h.conversationStore.listTurns(h.sessionId))[0].state).toBe('failed'); - expect(h.sent.filter((p) => p.kind === 'conversation.graph.changed')).toHaveLength(0); + // The failed turn keeps its ordinal, so the tree's shape is announced — without a leaf move. + expect(h.sent.filter((p) => p.kind === 'conversation.graph.changed')).toEqual([ + { kind: 'conversation.graph.changed', sessionId: h.sessionId, graphRevision: 1 }, + ]); const [record] = await h.store.load(); expect(record.activeLeafTurnId).toBeUndefined(); @@ -718,7 +838,10 @@ describe('turn.submit saga', () => { 'failed', ]); expect(await h.conversationStore.listOpenOperations(h.sessionId)).toHaveLength(0); - expect(h.sent.filter((p) => p.kind === 'conversation.graph.changed')).toHaveLength(0); + expect(h.sent.filter((p) => p.kind === 'conversation.graph.changed')).toEqual([ + { kind: 'conversation.graph.changed', sessionId: h.sessionId, graphRevision: 1 }, + { kind: 'conversation.graph.changed', sessionId: h.sessionId, graphRevision: 2 }, + ]); }); it('starts a new root fresh when the created session’s earlier run never wrote provider history', async () => { @@ -797,12 +920,15 @@ describe('turn.submit saga', () => { await vi.waitFor(() => submittedTurnId(h.sent, 's1')); const firstTurnId = submittedTurnId(h.sent, 's1'); await settleEngineTasks(); - // One commit, and the turn's own stop (held until then) settled THIS turn with its checkpoint. + // One commit, and the turn's own stop (held until then) settled THIS turn with its checkpoint; + // the settle re-announces the tree at the same revision. expect((await h.conversationStore.listTurns(h.sessionId))[0].state).toBe('completed'); expect(await h.conversationStore.listBindings(firstTurnId)).toEqual([ expect.objectContaining({ checkpoint: 'after-first', capturedFrom: 'live' }), ]); - expect(h.sent.filter((p) => p.kind === 'conversation.graph.changed')).toHaveLength(1); + expect( + h.sent.flatMap((p) => (p.kind === 'conversation.graph.changed' ? [p.graphRevision] : [])), + ).toEqual([1, 1]); await submitPrompt(h, 's2', 'second'); await vi.waitFor(() => expect(adapter.sentInputs).toHaveLength(2)); @@ -992,6 +1118,35 @@ describe('turn.submit saga', () => { }); }); + it('a second root edit on a created session still starts fresh', async () => { + const h = await startedHarness(() => new ForkingAdapter()); + await submitPrompt(h, 's1', 'first'); + h.adapter.emit({ type: 'session-ref', historyId: asHistoryId('native-1') }); + h.adapter.emit({ type: 'status', status: 'idle' }); + await settleEngineTasks(); + await submitPrompt(h, 's2', 'new root', { parentTurnId: null, expectedGraphRevision: 1 }); + await vi.waitFor(() => submittedTurnId(h.sent, 's2')); + const second = nullthrow(h.adapters[1]); + second.emit({ type: 'session-ref', historyId: asHistoryId('native-2') }); + second.emit({ type: 'status', status: 'idle' }); + await settleEngineTasks(); + + // The first root's run decides: nothing preceded it, so nothing precedes any root here — the + // fresh second root's own history is not hidden history behind a third. + await submitPrompt(h, 's3', 'third root', { parentTurnId: null, expectedGraphRevision: 2 }); + await vi.waitFor(() => submittedTurnId(h.sent, 's3')); + + const third = nullthrow(h.adapters[2]) as ForkingAdapter; + expect(third.branchedFrom).toBeNull(); + expect(third.resumedFrom).toBeNull(); + expect(third.startedWith).not.toBeNull(); + const turns = await h.conversationStore.listTurns(h.sessionId); + expect(turns.find((turn) => turn.turnId === submittedTurnId(h.sent, 's3'))).toMatchObject({ + parentTurnId: null, + siblingOrdinal: 3, + }); + }); + it('forks the first post-upgrade prompt of a pre-existing session after its hidden history — never fresh', async () => { const h = await preExistingSession([ { text: 'hidden one', cursor: 'before-hidden' }, @@ -1189,6 +1344,41 @@ describe('turn.submit saga', () => { expect(run?.baseTurnId).toBe(firstTurnId); }); + it('binds a preceding checkpoint under the parent’s own run when its successor runs elsewhere', async () => { + const h = await startedHarness(); + await submitPrompt(h, 's1', 'first'); + const firstTurnId = submittedTurnId(h.sent, 's1'); + h.adapter.emit({ type: 'session-ref', historyId: asHistoryId('native-1') }); + h.adapter.emit({ type: 'status', status: 'idle' }); + await settleEngineTasks(); + await h.inject({ kind: 'session.stop', clientReqId: 'stop', sessionId: h.sessionId }); + + await submitPrompt(h, 's2', 'second'); + await vi.waitFor(() => submittedTurnId(h.sent, 's2')); + const resumed = nullthrow(h.adapters[1]); + // The opencode shape: the successor's own prompt id is the cut that forks after its parent. + resumed.emitCheckpoint({ + historyId: asHistoryId('native-1'), + cursor: 'msg-second', + turn: 'preceding', + }); + await settleEngineTasks(); + + const turns = await h.conversationStore.listTurns(h.sessionId); + const first = nullthrow(turns.find((turn) => turn.turnId === firstTurnId)); + const second = nullthrow(turns.find((turn) => turn.turnId === submittedTurnId(h.sent, 's2'))); + expect(second.runId).not.toBe(first.runId); + expect(await h.conversationStore.listBindings(firstTurnId)).toEqual([ + { + turnId: firstTurnId, + runId: first.runId, + historyId: 'native-1', + checkpoint: 'msg-second', + capturedFrom: 'live', + }, + ]); + }); + it('refuses unknown sessions, unknown parents, and attachment blocks', async () => { const h = await startedHarness(); diff --git a/packages/host/engine/src/__tests__/engine-turn-tracking.test.ts b/packages/host/engine/src/__tests__/engine-turn-tracking.test.ts index 181cc08c4..71929b013 100644 --- a/packages/host/engine/src/__tests__/engine-turn-tracking.test.ts +++ b/packages/host/engine/src/__tests__/engine-turn-tracking.test.ts @@ -322,7 +322,8 @@ describe('legacy input turn tracking', () => { expect(operations).toHaveLength(0); const [record] = await h.store.load(); expect(record.activeLeafTurnId).toBeUndefined(); - expect(record.graphRevision).toBe(0); + // The failed turn is announced as a shape change (its ordinal is taken); no leaf moved. + expect(record.graphRevision).toBe(1); }); it('resolves the persisted turn when the session stops while its dispatch hangs', async () => { @@ -597,8 +598,13 @@ describe('commitRunning idempotence', () => { const operation = await store.getOperation(OperationIdSchema.parse('op-1')); expect(operation).toMatchObject({ state: 'failed', error: { code: 'timeout' } }); - expect(registry.get(sessionId)?.graphRevision).toBe(0); - expect(sent.filter((payload) => payload.kind === 'conversation.graph.changed')).toHaveLength(0); + // The failure itself is a shape change (the turn keeps its ordinal): one announcement with no + // leaf move; the lost commit adds nothing on top of it. + expect(registry.get(sessionId)?.graphRevision).toBe(1); + expect(registry.get(sessionId)?.activeLeafTurnId).toBeUndefined(); + expect(sent.filter((payload) => payload.kind === 'conversation.graph.changed')).toEqual([ + { kind: 'conversation.graph.changed', sessionId, graphRevision: 1 }, + ]); // Nor tracking: a settle for this run must find nothing to flip. turns.settleStop(sessionId, RunIdSchema.parse('run-1'), 'end_turn'); await settleEngineTasks(); diff --git a/packages/host/engine/src/__tests__/prompt-materializer.test.ts b/packages/host/engine/src/__tests__/prompt-materializer.test.ts index 505c873d3..6e345151f 100644 --- a/packages/host/engine/src/__tests__/prompt-materializer.test.ts +++ b/packages/host/engine/src/__tests__/prompt-materializer.test.ts @@ -2,6 +2,7 @@ import { createHash } from 'node:crypto'; import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; import type { AttachmentCapability, PromptRecord } from '@linkcode/schema'; import { AttachmentIdSchema, @@ -36,6 +37,18 @@ function sha256(bytes: Uint8Array): string { return createHash('sha256').update(bytes).digest('hex'); } +/** A harness that reads files by path — none ships yet, so this is the only declaration of it. */ +const FILE_CAPABILITY: AttachmentCapability = { + kinds: { + file: { + mimeTypes: ['image/png'], + maxBytes: MAX_ATTACHMENT_BYTES, + maxCount: 1, + }, + }, + representations: ['readonly_file'], +}; + async function fixture(bytes: Uint8Array = PNG_1X1): Promise<{ materializer: PromptMaterializer; store: InMemoryAttachmentStore; @@ -115,16 +128,7 @@ describe('PromptMaterializer', () => { const { materializer, prompt, store, blobs } = await fixture(); const stored = await store.getAttachment(AttachmentIdSchema.parse('att-1')); if (!stored) throw new Error('fixture attachment missing'); - const fileCapability: AttachmentCapability = { - kinds: { - file: { - mimeTypes: ['image/png'], - maxBytes: MAX_ATTACHMENT_BYTES, - maxCount: 1, - }, - }, - representations: ['readonly_file'], - }; + const fileCapability = FILE_CAPABILITY; await store.commitAttachment({ blob: { blobId: stored.blobId, sizeBytes: stored.sizeBytes, createdAt: 1 }, attachment: { ...stored, kind: 'file' }, @@ -159,6 +163,34 @@ describe('PromptMaterializer', () => { await expect(stat(againFile.path)).rejects.toMatchObject({ code: 'ENOENT' }); }); + it('hands a readonly_file projection to the adapter as a file resource link', async () => { + const { materializer, prompt, store } = await fixture(); + const stored = nullthrow(await store.getAttachment(AttachmentIdSchema.parse('att-1'))); + await store.commitAttachment({ + blob: { blobId: stored.blobId, sizeBytes: stored.sizeBytes, createdAt: 1 }, + attachment: { ...stored, kind: 'file' }, + }); + const prepared = await Effect.runPromise( + materializer.prepare( + SessionIdSchema.parse('sess-1'), + RunIdSchema.parse('run-1'), + { ...prompt, blocks: [{ type: 'attachment_ref', attachmentId: stored.attachmentId }] }, + FILE_CAPABILITY, + ), + ); + const file = prepared.blocks[0]; + if (file.type !== 'readonly_file') throw new Error('expected a readonly_file projection'); + expect(materializer.toContentBlocks(prepared)).toEqual([ + { + type: 'resource_link', + uri: pathToFileURL(file.path).href, + name: 'shot.png', + mimeType: 'image/png', + size: PNG_1X1.byteLength, + }, + ]); + }); + it('materializes a repeated readonly_file ref twice in one run', async () => { const { materializer, prompt, store } = await fixture(); const stored = await store.getAttachment(AttachmentIdSchema.parse('att-1')); diff --git a/packages/host/engine/src/__tests__/session-record-registry.test.ts b/packages/host/engine/src/__tests__/session-record-registry.test.ts index a43b9fa6f..a26a65d8e 100644 --- a/packages/host/engine/src/__tests__/session-record-registry.test.ts +++ b/packages/host/engine/src/__tests__/session-record-registry.test.ts @@ -47,6 +47,23 @@ describe('session record registry run addressing', () => { expect(runs.find((run) => run.runId === second)?.endedAt).toBeUndefined(); }); + it('resolves the thread history past an abandoned run', async () => { + const registry = await startedRegistry(); + const first = registry.beginRun(sessionId); + registry.bindHistoryId(sessionId, first, asHistoryId('native-1')); + const second = registry.beginRun(sessionId); + registry.bindHistoryId(sessionId, second, asHistoryId('native-child')); + + registry.abandonRun(sessionId, second); + + expect(registry.historyId(sessionId)).toBe('native-1'); + expect(registry.get(sessionId)?.runs.find((run) => run.runId === second)).toMatchObject({ + historyId: 'native-child', + abandonedAt: expect.any(Number), + endedAt: expect.any(Number), + }); + }); + it('binds a history id to the addressed run while a newer run exists', async () => { const registry = await startedRegistry(); const first = registry.beginRun(sessionId); diff --git a/packages/host/engine/src/attachment/admit.ts b/packages/host/engine/src/attachment/admit.ts index 572cd41b3..14e66f97e 100644 --- a/packages/host/engine/src/attachment/admit.ts +++ b/packages/host/engine/src/attachment/admit.ts @@ -123,11 +123,13 @@ export function admitPromptAttachments( } } -/** Legacy `agent.input` images: size/mime already passed the inline guard; this is the capability gate. */ +/** Legacy `agent.input` images: bytes already passed the inline guard; this is the capability gate + * (kind, MIME type, count) that a ref submit gets from {@link admitPromptAttachments}. */ export function assertInlineAttachmentsSupported( content: ContentBlock[], capability: AttachmentCapability | undefined, ): void { + let imageCount = 0; for (let i = 0, len = content.length; i < len; i++) { const block = content[i]; if ( @@ -158,6 +160,10 @@ export function assertInlineAttachmentsSupported( message: `Unsupported attachment type: ${block.mimeType}`, }); } + imageCount += 1; + if (imageCount > limits.maxCount) { + throw new RequestError({ code: 'limit_exceeded', message: 'Too many attachments' }); + } continue; } // A projected `attachment:` link is a stored attachment coming back in, not a file the harness diff --git a/packages/host/engine/src/attachment/materializer.ts b/packages/host/engine/src/attachment/materializer.ts index 46c5f6253..28ed64e57 100644 --- a/packages/host/engine/src/attachment/materializer.ts +++ b/packages/host/engine/src/attachment/materializer.ts @@ -1,6 +1,7 @@ import { Buffer } from 'node:buffer'; import { chmod, link, mkdir, rm } from 'node:fs/promises'; import { basename, join } from 'node:path'; +import { pathToFileURL } from 'node:url'; import type { AttachmentCapability, ContentBlock, @@ -107,9 +108,14 @@ export class PromptMaterializer { content.push(block.block); continue; } - throw new RequestError({ - code: 'unsupported_attachment', - message: 'This harness does not accept file attachments', + // A file projection reaches the harness as the link the inline guard admits for + // `readonly_file`: the materialized path, never the store's bytes. + content.push({ + type: 'resource_link', + uri: pathToFileURL(block.path).href, + name: block.attachment.name, + mimeType: block.attachment.mimeType, + size: block.attachment.sizeBytes, }); } return content; diff --git a/packages/host/engine/src/attachment/upload-service.ts b/packages/host/engine/src/attachment/upload-service.ts index 3453a7cf5..dd09a032d 100644 --- a/packages/host/engine/src/attachment/upload-service.ts +++ b/packages/host/engine/src/attachment/upload-service.ts @@ -65,11 +65,20 @@ interface LiveUpload { receivedBytes: number; head: Uint8Array; readonly state: 'ready' | 'exists'; + /** Last begin or chunk; an upload idle past `UPLOAD_IDLE_MS` is reaped like an expired one. */ + touchedAt: number; } +/** Uploads live at once. The reap runs on the next begin only, so this — not the 24h lease — + * bounds the descriptors and staging bytes an abandoned burst can pin. */ +export const MAX_LIVE_UPLOADS = 32; +/** v1 has no resume frame, so releasing a stalled upload's handle costs its client a restart. */ +export const UPLOAD_IDLE_MS = 5 * 60 * 1000; + export class AttachmentUploadService { private readonly live = new Map(); private readonly begunByOperation = new Map(); + private reserved = 0; constructor( private readonly blobs: BlobStore, @@ -81,20 +90,56 @@ export class AttachmentUploadService { begin( input: AttachmentBeginInput, ): Effect.Effect { - const store = this.store.bind(this); const files = this.files.bind(this); const reapExpired = this.reapExpired.bind(this); + const reserve = this.reserve.bind(this); + const release = this.release.bind(this); + const stageUpload = this.stageUpload.bind(this); return Effect.gen({ self: this }, function* () { if (input.operationId !== undefined) { const replayed = this.begunByOperation.get(input.operationId); - if (replayed) return replayed; + if (replayed) { + // One operation id names one begin: the same id with other declared fields is a client + // defect, and answering would hand it another upload's id — its chunks and its abort. + const lease = this.live.get(replayed.uploadId)?.lease; + if (lease === undefined || !sameDeclaration(lease, input)) { + return yield* invalid('invalid_request', 'The operation id belongs to another upload'); + } + return replayed; + } } if (input.declaredSize > MAX_ATTACHMENT_BYTES) { return yield* invalid('limit_exceeded', 'Attachment exceeds the 8 MiB limit'); } // A staging handle lives in `live` until commit or abort; a client that vanishes mid-upload - // never sends either, so expired leases release their descriptors here. + // never sends either, so expired and idle uploads release their descriptors here. yield* files('reap', reapExpired); + if (!reserve()) { + return yield* invalid('limit_exceeded', 'Too many uploads in flight; retry later'); + } + return yield* stageUpload(input).pipe(Effect.ensuring(Effect.sync(release))); + }); + } + + /** Counts a begin from its cap check until its `live` entry exists; without it a burst of + * concurrent begins all pass the check. */ + private reserve(): boolean { + if (this.live.size + this.reserved >= MAX_LIVE_UPLOADS) return false; + this.reserved += 1; + return true; + } + + private release(): void { + this.reserved -= 1; + } + + /** The lease, the dedupe check, and the staging handle behind one admitted begin. */ + private stageUpload( + input: AttachmentBeginInput, + ): Effect.Effect { + const store = this.store.bind(this); + const files = this.files.bind(this); + return Effect.gen({ self: this }, function* () { const uploadId = UploadIdSchema.parse(`upl-${randomUUID()}`); const now = this.clock(); const lease = yield* store('begin', () => @@ -132,6 +177,7 @@ export class AttachmentUploadService { receivedBytes: state === 'exists' ? input.declaredSize : 0, head: new Uint8Array(0), state, + touchedAt: now, }); const result: AttachmentBeginResult = { uploadId, @@ -183,6 +229,7 @@ export class AttachmentUploadService { live.head = new Uint8Array(bytes.subarray(0, Math.min(HEAD_BYTES, bytes.byteLength))); } live.receivedBytes += bytes.byteLength; + live.touchedAt = this.clock(); return { uploadId, receivedBytes: live.receivedBytes }; }), ); @@ -373,7 +420,7 @@ export class AttachmentUploadService { const now = this.clock(); const dead: BlobStage[] = []; for (const [uploadId, live] of this.live) { - if (live.lease.expiresAt > now) continue; + if (live.lease.expiresAt > now && live.touchedAt + UPLOAD_IDLE_MS > now) continue; if (live.stage) dead.push(live.stage); this.forget(uploadId); } @@ -420,6 +467,16 @@ function recordFromLease(lease: UploadLease, attachmentId: AttachmentId, now: nu }; } +function sameDeclaration(lease: UploadLease, input: AttachmentBeginInput): boolean { + return ( + lease.declaredSha256.toLowerCase() === input.declaredSha256.toLowerCase() && + lease.declaredSize === input.declaredSize && + lease.name === input.name && + lease.mimeType === input.mimeType && + lease.kind === input.attachmentKind + ); +} + function decodeChunk(data: string): Uint8Array { if (data.length === 0) return new Uint8Array(0); const bytes = Buffer.from(data, 'base64'); diff --git a/packages/host/engine/src/conversation/checkpoint-service.ts b/packages/host/engine/src/conversation/checkpoint-service.ts index ef640ad04..ae0b987ae 100644 --- a/packages/host/engine/src/conversation/checkpoint-service.ts +++ b/packages/host/engine/src/conversation/checkpoint-service.ts @@ -14,8 +14,13 @@ import { OperationError } from '../failure'; import type { HistoryBranchCut, HistoryService } from '../session/history-service'; import { promptContentFingerprint } from '../session/live-session'; import type { SessionRecordRegistry } from '../session/session-record-registry'; -import type { CorpusAttribution } from './lineage-attribution'; -import { attributeCorpus, hasHiddenPrefix, pathToLeaf } from './lineage-attribution'; +import type { CorpusAttribution, HostTurnFingerprint } from './lineage-attribution'; +import { + attributeCorpus, + hasHiddenPrefix, + pathToLeaf, + settledWithProvider, +} from './lineage-attribution'; import type { ConversationTurnService } from './turn-service'; import { TERMINAL_TURN_STATES } from './turn-service'; @@ -78,42 +83,43 @@ export class ConversationCheckpointService { } /** - * Attribute the active lineage (`path` root→active leaf, `contents` per path turn) to the - * latest provider history under the §9 gate. Side effect: every attributed turn whose - * successor row carries a provider cursor gains a `replay` binding on that history, unless a - * binding already exists there — a live capture is never overwritten by a cold read. + * Attribute one lineage (`path` root→leaf, `contents` per path turn) to the provider history + * that lineage wrote — the live history for the active lineage, an inactive lineage's own leaf + * run history otherwise — under the §9 gate. Side effect: every attributed turn whose successor + * row carries a provider cursor gains a `replay` binding on that history, unless a binding + * already exists there — a live capture is never overwritten by a cold read. */ - attributeActiveLineage( + attributeLineage( record: SessionRecord, path: readonly ConversationTurn[], contents: ReadonlyArray, + historyId: AgentHistoryId | undefined, ): Effect.Effect { - const { records } = this; const readCorpus = this.readCorpus.bind(this); const backfill = this.backfill.bind(this); return Effect.gen(function* () { - const historyId = records.historyId(record.sessionId); const expectsProvider = settledWithProvider(path); if (historyId === undefined || expectsProvider.length === 0) return; const corpus = yield* readCorpus(record, historyId); if (corpus === undefined) return; - const hostFingerprints: Array = []; + const hostTurns: HostTurnFingerprint[] = []; let liveFingerprint: string | undefined; for (let i = 0, len = path.length; i < len; i++) { const content = contents[i]; if (!TERMINAL_TURN_STATES.has(path[i].state)) { if (content) liveFingerprint = promptContentFingerprint(content); - } else if (path[i].state !== 'failed') { - hostFingerprints.push(content && promptContentFingerprint(content)); + } else { + hostTurns.push({ + fingerprint: content && promptContentFingerprint(content), + failed: path[i].state === 'failed', + }); } } const attribution = attributeCorpus( corpus, - hostFingerprints, + hostTurns, liveFingerprint, - // A failed turn may or may not have left provider rows, so the count behind the corpus - // tail is unknowable: end-anchored alignment is off for that lineage. - hasHiddenPrefix(record, path[0]) && !path.some((turn) => turn.state === 'failed'), + hasHiddenPrefix(record, path[0]), ); yield* backfill(expectsProvider, attribution, historyId); return attribution; @@ -209,7 +215,7 @@ export class ConversationCheckpointService { target: ConversationTurn, ): Effect.Effect { const { records, turns } = this; - const attributeActiveLineage = this.attributeActiveLineage.bind(this); + const attributeLineage = this.attributeLineage.bind(this); return Effect.gen(function* () { const anchor = path.find((turn) => turn.turnId === target.turnId) ?? @@ -219,8 +225,8 @@ export class ConversationCheckpointService { for (let i = 0, len = path.length; i < len; i++) { contents.push(yield* turns.hostUserContent(path[i])); } - const attribution = yield* attributeActiveLineage(record, path, contents); const historyId = records.historyId(record.sessionId); + const attribution = yield* attributeLineage(record, path, contents, historyId); if (attribution === undefined || historyId === undefined) return; const position = settledWithProvider(path).findIndex((turn) => turn.turnId === anchor.turnId); const row = @@ -241,9 +247,9 @@ export class ConversationCheckpointService { ): Effect.Effect { const { turns } = this; return Effect.gen(function* () { - const { attributed, trailingLive } = attribution; + const { attributed, successors } = attribution; for (let j = 0, len = attributed.length; j < len; j++) { - const successor = j + 1 < len ? attributed[j + 1].userRow : trailingLive; + const successor = successors[j]; const cursor = successor?.event.type === 'user-message' ? successor.event.branchCursor : undefined; if (cursor === undefined) continue; @@ -269,8 +275,3 @@ function toCut(binding: ProviderTurnBinding): AgentHistoryBranchOptions { function activePath(record: SessionRecord, turns: ConversationTurn[]): ConversationTurn[] { return pathToLeaf(new Map(turns.map((turn) => [turn.turnId, turn])), record.activeLeafTurnId); } - -/** The path turns that expect provider rows: settled, and not failed (nothing durable ran). */ -function settledWithProvider(path: readonly ConversationTurn[]): ConversationTurn[] { - return path.filter((turn) => TERMINAL_TURN_STATES.has(turn.state) && turn.state !== 'failed'); -} diff --git a/packages/host/engine/src/conversation/lineage-attribution.ts b/packages/host/engine/src/conversation/lineage-attribution.ts index f5a88826c..04b02447c 100644 --- a/packages/host/engine/src/conversation/lineage-attribution.ts +++ b/packages/host/engine/src/conversation/lineage-attribution.ts @@ -1,16 +1,30 @@ import type { AgentHistoryEvent, ConversationTurn, SessionRecord, TurnId } from '@linkcode/schema'; import { RequestError } from '../failure'; import { promptContentFingerprint } from '../session/live-session'; +import { TERMINAL_TURN_STATES } from './turn-service'; export interface ProviderPartition { readonly userRow: AgentHistoryEvent; readonly rest: AgentHistoryEvent[]; } +/** One settled path turn's prompt fingerprint. A failed turn may or may not have left provider rows + * (the prompt is persisted before generation, so a mid-run failure usually did; a refusal before + * dispatch did not), so its partition is attributed only when the corpus count settles which. */ +export interface HostTurnFingerprint { + readonly fingerprint: string | undefined; + readonly failed: boolean; +} + export interface CorpusAttribution { - /** Partition i is the i-th settled path turn's provider content: a prefix of the candidates, or - * all of them when the corpus tail was aligned behind hidden pre-graph history. */ + /** Partition i is the i-th settled non-failed path turn's provider content: a prefix of the + * candidates, or all of them when the corpus tail was aligned behind hidden pre-graph history. */ readonly attributed: ProviderPartition[]; + /** Per failed path turn, in order: its own partition when every failed turn provably left one. */ + readonly failed: ReadonlyArray; + /** Per attributed turn: the user row of the next verified partition in corpus order — a failed + * turn's included — or the in-flight row; where a fork after that turn cuts. */ + readonly successors: ReadonlyArray; /** Rows before the first attributed partition — those ahead of the first user row, plus the * hidden history's own partitions — rendered unattributed, as a cold read would. */ readonly leading: AgentHistoryEvent[]; @@ -20,13 +34,21 @@ export interface CorpusAttribution { /** Whether provider rows can precede the lineage's root turn: an imported transcript, or a created * session whose earlier runs wrote provider history before the root was recorded (a session older - * than its turn rows). A run that died before its first prompt left nothing behind. */ + * than its turn rows). A run that died before its first prompt, or was abandoned, left nothing + * behind. */ export function hasHiddenPrefix(record: SessionRecord, root: ConversationTurn): boolean { if (record.origin.type !== 'created') return true; const index = record.runs.findIndex((run) => run.runId === root.runId); // A root whose run cannot be placed (a pre-runId record) takes the safe direction. if (index < 0) return true; - return record.runs.slice(0, index).some((run) => run.historyId !== undefined); + return record.runs + .slice(0, index) + .some((run) => run.historyId !== undefined && run.abandonedAt === undefined); +} + +/** The path turns that expect provider rows: settled, and not failed (nothing durable ran). */ +export function settledWithProvider(path: readonly ConversationTurn[]): ConversationTurn[] { + return path.filter((turn) => TERMINAL_TURN_STATES.has(turn.state) && turn.state !== 'failed'); } /** Root→leaf path through `parentTurnId`; a broken chain fails loud rather than rendering wrong. */ @@ -62,15 +84,24 @@ export function pathToLeaf( * exist — the host turns align to the LAST partitions, every position must verify, and no other * offset may verify in full (repeated prompts let a shifted alignment verify too), else nothing * attributes; the unmatched head is hidden history. One trailing extra partition is tolerated only - * when it fingerprint-verifies as the in-flight turn's own row (the live tail owns it). + * when it fingerprint-verifies as the in-flight turn's own row (the live tail owns it). Failed turns + * make the expected count itself uncertain, so a lineage carrying one aligns from the START only: + * every failed turn left a row (the count says so and each verifies), none did, or — ambiguous — + * only the prefix before the first failed turn attributes. */ export function attributeCorpus( corpus: readonly AgentHistoryEvent[], - hostFingerprints: ReadonlyArray, + hostTurns: ReadonlyArray, liveFingerprint: string | undefined, hiddenPrefixAllowed = false, ): CorpusAttribution { - const none = { attributed: [], leading: [] }; + const settled: Array = []; + let failedCount = 0; + for (let i = 0, len = hostTurns.length; i < len; i++) { + if (hostTurns[i].failed) failedCount += 1; + else settled.push(hostTurns[i].fingerprint); + } + const none = noAttribution(failedCount); const split = partitionAtUserRows(corpus); const { partitions } = split; let candidates = partitions; @@ -79,18 +110,28 @@ export function attributeCorpus( if ( trailing !== undefined && liveFingerprint !== undefined && - partitions.length > hostFingerprints.length && + partitions.length > settled.length && userRowFingerprint(trailing.userRow) === liveFingerprint ) { trailingLive = trailing.userRow; candidates = partitions.slice(0, -1); } - const hidden = candidates.length - hostFingerprints.length; + if (failedCount > 0) { + return attributeAroundFailed( + split.leading, + candidates, + hostTurns, + settled.length, + trailingLive, + hiddenPrefixAllowed, + ); + } + const hidden = candidates.length - settled.length; if (hidden < 0 || (!hiddenPrefixAllowed && hidden > 0)) return none; const aligned = candidates.slice(hidden); const attributed: ProviderPartition[] = []; for (let i = 0, len = aligned.length; i < len; i++) { - if (!positionVerifies(aligned[i], hostFingerprints[i])) break; + if (!positionVerifies(aligned[i], settled[i])) break; attributed.push(aligned[i]); } if (attributed.length === 0) return none; @@ -99,8 +140,8 @@ export function attributeCorpus( // in full: the replay binding a wrong alignment backfills is never corrected. if (hiddenPrefixAllowed && (trailingLive !== undefined || hidden > 0)) { if (attributed.length !== aligned.length) return none; - for (let k = 0, last = partitions.length - hostFingerprints.length; k <= last; k++) { - if (k !== hidden && windowVerifies(partitions, k, hostFingerprints)) return none; + for (let k = 0, last = partitions.length - settled.length; k <= last; k++) { + if (k !== hidden && windowVerifies(partitions, k, settled)) return none; } } const leading = [...split.leading]; @@ -109,11 +150,93 @@ export function attributeCorpus( leading.push(partition.userRow); for (let j = 0, len = partition.rest.length; j < len; j++) leading.push(partition.rest[j]); } + const complete = attributed.length === aligned.length; + const successors: Array = []; + for (let i = 0, len = attributed.length; i < len; i++) { + // The live row is the successor of the LAST settled turn only when every position verified. + successors.push(i + 1 < len ? attributed[i + 1].userRow : complete ? trailingLive : undefined); + } return { attributed, + failed: [], + successors, leading, - // The live row is the successor of the LAST settled turn only when every position verified. - ...(attributed.length === aligned.length && trailingLive !== undefined && { trailingLive }), + ...(complete && trailingLive !== undefined && { trailingLive }), + }; +} + +function noAttribution(failedCount: number): CorpusAttribution { + const failed: Array = []; + for (let i = 0; i < failedCount; i++) failed.push(undefined); + return { attributed: [], failed, successors: [], leading: [] }; +} + +/** Start-anchored alignment through failed turns: the corpus count decides whether every failed + * turn consumes a partition (`all`), none does (`none`), or the answer is unknowable — then only + * the prefix before the first failed turn attributes; a wrong guess would splice a later turn's + * rows under the wrong prompt. Where hidden pre-graph rows are possible, an extra partition could + * be either, and even the first position cannot be trusted (an identical hidden prompt verifies), + * so any surplus attributes nothing. */ +function attributeAroundFailed( + leading: readonly AgentHistoryEvent[], + candidates: readonly ProviderPartition[], + hostTurns: ReadonlyArray, + settledCount: number, + trailingLive: AgentHistoryEvent | undefined, + hiddenPrefixPossible: boolean, +): CorpusAttribution { + const failedCount = hostTurns.length - settledCount; + if (candidates.length < settledCount) return noAttribution(failedCount); + if (hiddenPrefixPossible && candidates.length !== settledCount) return noAttribution(failedCount); + const mode = + candidates.length === settledCount + ? 'none' + : candidates.length === hostTurns.length + ? 'all' + : 'ambiguous'; + const attributed: ProviderPartition[] = []; + const failed: Array = []; + const consumedAt: number[] = []; + let next = 0; + let broken = false; + for (let i = 0, len = hostTurns.length; i < len; i++) { + const turn = hostTurns[i]; + if (turn.failed) { + if (mode === 'ambiguous') broken = true; + const partition = mode === 'all' && !broken ? candidates[next] : undefined; + if (partition === undefined || !positionVerifies(partition, turn.fingerprint)) { + if (mode === 'all') broken = true; + failed.push(undefined); + continue; + } + failed.push(partition); + next += 1; + continue; + } + if (broken) continue; + const partition = candidates[next]; + if (partition === undefined || !positionVerifies(partition, turn.fingerprint)) { + broken = true; + continue; + } + attributed.push(partition); + consumedAt.push(next); + next += 1; + } + const complete = !broken && next === candidates.length; + const successors: Array = []; + for (let i = 0, len = consumedAt.length; i < len; i++) { + const following = consumedAt[i] + 1; + successors.push( + following < next ? candidates[following].userRow : complete ? trailingLive : undefined, + ); + } + return { + attributed, + failed, + successors, + leading: [...leading], + ...(complete && trailingLive !== undefined && { trailingLive }), }; } diff --git a/packages/host/engine/src/conversation/projection-service.ts b/packages/host/engine/src/conversation/projection-service.ts index 91ca6060a..158108ec9 100644 --- a/packages/host/engine/src/conversation/projection-service.ts +++ b/packages/host/engine/src/conversation/projection-service.ts @@ -8,6 +8,7 @@ import type { ConversationGraphTurn, ConversationReadItem, ConversationTurn, + ConversationTurnState, ConversationWatermark, RunId, SessionId, @@ -27,7 +28,7 @@ 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'; +import type { CorpusAttribution } from './lineage-attribution'; import { pathToLeaf } from './lineage-attribution'; import type { ConversationLiveJournals } from './live-journal'; import { inflightChunkKey } from './live-journal'; @@ -58,10 +59,21 @@ export interface ConversationReadResult { readonly cursor?: string; } +/** One history's attribution plus each of its turns' index into it (settled non-failed / failed). */ +interface HistoryRead { + readonly attribution: CorpusAttribution; + readonly partition: ReadonlyMap; + readonly failed: ReadonlyMap; +} + const INPUT_SUMMARY_MAX_LENGTH = 140; +/** Turns a peer can see: running or settled. */ +const VISIBLE_TURN_STATES = new Set(['running', ...TERMINAL_TURN_STATES]); const WHITESPACE_RUN_RE = /\s+/g; /** One page = one logical tunnel message; oversized reassembly is silently dropped by the tunnel - * (the history-util.ts byte-budget rationale applies verbatim). */ + * (the history-util.ts byte-budget rationale applies verbatim). The live journal's byte cap + * (10 MiB) sits under this, so a retained tail never trims in production; `trimTailToBudget` + * backs the invariant for the smaller budgets tests use. */ const READ_PAGE_BYTE_BUDGET = MAX_ATTACHMENT_TOTAL_BASE64_LENGTH; /** @@ -96,6 +108,9 @@ export class ConversationProjectionService { sessionTurns.sort(byCreation); const graphTurns: ConversationGraphTurn[] = []; for (let i = 0, len = sessionTurns.length; i < len; i++) { + // A turn that has not run yet is the submitting client's alone: peers see it once it runs + // or fails, which is also when the tree announces it. + if (!VISIBLE_TURN_STATES.has(sessionTurns[i].state)) continue; const summary = yield* inputSummary(sessionTurns[i]); graphTurns.push( summary === undefined ? sessionTurns[i] : { ...sessionTurns[i], inputSummary: summary }, @@ -141,13 +156,19 @@ export class ConversationProjectionService { // leaf moves, garbage restarts silently): the cursor pins the exact projection shape it // paged, and any drift or undecodable cursor is a typed conflict — never a silent splice. const settled = path.filter((turn) => TERMINAL_TURN_STATES.has(turn.state)).length; + const activePath = + leafTurnId === record.activeLeafTurnId ? path : pathToLeaf(byId, record.activeLeafTurnId); + const durable = yield* composeDurable(record, path, sessionTurns, activePath); let offset = 0; if (request.cursor !== undefined) { const decoded = decodeReadCursor(request.cursor); if ( decoded?.graphRevision !== record.graphRevision || decoded.leafTurnId !== leafTurnId || - decoded.settled !== settled + decoded.settled !== settled || + // The item count pins the provider corpus too: rows gained or compacted between pages + // (a cache refresh) would shift offsets without moving the graph. + decoded.durable !== durable.length ) { return yield* Effect.fail( new RequestError({ @@ -158,12 +179,12 @@ export class ConversationProjectionService { } offset = decoded.offset; } - // Positional attribution is sound only on the active lineage: a sibling lineage has the - // same path length by construction (and can carry identical prompt text on a retry), so an - // inactive-leaf read renders host rows + placeholders until per-turn bindings (CODE-632). - const isActiveLineage = leafTurnId !== undefined && leafTurnId === record.activeLeafTurnId; - const durable = yield* composeDurable(record, path, isActiveLineage); - const { tail, watermark } = composeTail(request.sessionId, record.eventEpoch, path); + // The journal is the active run's: only the lineage that owns the running turn, or the host + // default itself, may carry it — another version or an ancestor view reads durable rows only. + const ownsTail = + leafTurnId === record.activeLeafTurnId || + path.some((turn) => !TERMINAL_TURN_STATES.has(turn.state)); + const { tail, watermark } = composeTail(request.sessionId, record.eventEpoch, path, ownsTail); const { events, nextOffset } = pageReadItems( durable, tail, @@ -176,6 +197,7 @@ export class ConversationProjectionService { graphRevision: record.graphRevision, leafTurnId, settled, + durable: durable.length, offset: nextOffset, }) : undefined; @@ -190,43 +212,115 @@ export class ConversationProjectionService { }); } - /** Host user rows, attributed provider output, then complete retained turns or placeholders. */ + /** Read each turn from its executing run, never a fork's re-stamped copy. If unavailable, + * replay its complete retained journal interval or show a placeholder. */ private composeDurable( record: SessionRecord, path: ConversationTurn[], - isActiveLineage: boolean, + sessionTurns: ConversationTurn[], + activePath: ConversationTurn[], ): Effect.Effect { - const { checkpoints, turns, journals } = this; + const { checkpoints, records, turns, journals } = this; return Effect.gen(function* () { - const items: ConversationReadItem[] = []; - const contents: Array = []; + // A provider history is one linear transcript, so the turns whose runs wrote to it, in + // creation order, are its user rows — the alignment the gate needs, whichever lineage reads. + const turnsByHistory = new Map(); + const ordered = [...sessionTurns].sort(byCreation); + for (let i = 0, len = ordered.length; i < len; i++) { + const historyId = runHistoryId(record, ordered[i].runId); + if (historyId === undefined) continue; + const group = turnsByHistory.get(historyId); + if (group) group.push(ordered[i]); + else turnsByHistory.set(historyId, [ordered[i]]); + } + const touched = new Set(); + const needed = new Map(); for (let i = 0, len = path.length; i < len; i++) { - contents.push(yield* turns.hostUserContent(path[i])); + needed.set(path[i].turnId, path[i]); + const historyId = runHistoryId(record, path[i].runId); + if (historyId !== undefined) touched.add(historyId); + } + for (const historyId of touched) { + const group = turnsByHistory.get(historyId) ?? []; + for (let i = 0, len = group.length; i < len; i++) needed.set(group[i].turnId, group[i]); } - let attributed: ProviderPartition[] = []; - let leading: AgentHistoryEvent[] = []; - if (isActiveLineage) { - // Reading the corpus also backfills replay bindings for the attributed turns. - const attribution = yield* checkpoints.attributeActiveLineage(record, path, contents); - if (attribution !== undefined) { - attributed = attribution.attributed; - leading = attribution.leading; + const neededTurns = [...needed.values()]; + const loaded = yield* Effect.forEach(neededTurns, (turn) => turns.hostUserContent(turn)); + const contentOf = new Map(); + for (let i = 0, len = neededTurns.length; i < len; i++) { + contentOf.set(neededTurns[i].turnId, loaded[i]); + } + const contentsOf = (lineage: readonly ConversationTurn[]) => + lineage.map((turn) => contentOf.get(turn.turnId)); + + // Reading a corpus also backfills replay bindings on it. + const reads = new Map(); + for (const historyId of touched) { + const hostTurns = chainOrder(turnsByHistory.get(historyId) ?? []); + const attribution = yield* checkpoints.attributeLineage( + record, + hostTurns, + contentsOf(hostTurns), + historyId, + ); + if (attribution === undefined) continue; + const partition = new Map(); + const failed = new Map(); + for (let i = 0, len = hostTurns.length; i < len; i++) { + const turn = hostTurns[i]; + if (!TERMINAL_TURN_STATES.has(turn.state)) continue; + if (turn.state === 'failed') failed.set(turn.turnId, failed.size); + else partition.set(turn.turnId, partition.size); } + reads.set(historyId, { attribution, partition, failed }); } - for (let i = 0, len = leading.length; i < len; i++) { - items.push(projectedItem(undefined, leading[i])); + // A fork's copy is still where a later fork after a copied turn cuts once that turn's own + // history is gone, so the active lineage also backfills its prefix's bindings on the live + // history. Rendering never reads this pass. + const liveHistoryId = records.historyId(record.sessionId); + if ( + path === activePath && + liveHistoryId !== undefined && + path.some((turn) => runHistoryId(record, turn.runId) !== liveHistoryId) + ) { + yield* checkpoints.attributeLineage(record, path, contentsOf(path), liveHistoryId); + } + + const items: ConversationReadItem[] = []; + // Rows ahead of the first user row are pre-graph history: they belong to the root's own + // history alone — a fork child's leading rows are its copy of the prefix, rendered from the + // source above. + const rootHistoryId = path.length === 0 ? undefined : runHistoryId(record, path[0].runId); + const rootRead = rootHistoryId === undefined ? undefined : reads.get(rootHistoryId); + if (rootRead !== undefined) { + const { leading } = rootRead.attribution; + for (let i = 0, len = leading.length; i < len; i++) { + items.push(projectedItem(undefined, leading[i])); + } } - let partitionIndex = 0; for (let i = 0, len = path.length; i < len; i++) { const turn = path[i]; - const content = contents[i]; + const content = contentOf.get(turn.turnId); 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]; - partitionIndex += 1; + const historyId = runHistoryId(record, turn.runId); + const read = historyId === undefined ? undefined : reads.get(historyId); + if (turn.state === 'failed') { + // The state badge is the story; whatever the provider kept of the attempt renders under + // it, and a turn that left nothing gets no placeholder — nothing durable ran. + const index = read?.failed.get(turn.turnId); + const partial = index === undefined ? undefined : read?.attribution.failed[index]; + if (partial !== undefined) { + for (let j = 0, restLen = partial.rest.length; j < restLen; j++) { + items.push(projectedItem(turn, partial.rest[j])); + } + } + continue; + } + const index = read?.partition.get(turn.turnId); + const partition = index === undefined ? undefined : read?.attribution.attributed[index]; if (partition === undefined) { const retained = journals.get(record.sessionId)?.completedTurn(turn.turnId, turn.runId); if (retained === undefined) { @@ -246,21 +340,26 @@ export class ConversationProjectionService { /** The live tail: retained journal events above the last event attributed to a settled path * turn, minus user echoes (host rows own user display) and headless chunk streams, plus the - * authoritative open interactive requests. */ + * authoritative open interactive requests. The journal is the active run's, so a lineage that + * does not own it (`ownsTail` false: another version, an ancestor view) gets durable rows only. */ private composeTail( sessionId: SessionId, eventEpoch: number, path: ConversationTurn[], + ownsTail: boolean, ): { tail: ConversationReadItem[]; watermark: ConversationWatermark } { const journal = this.journals.get(sessionId); const liveTurn = path.find((turn) => !TERMINAL_TURN_STATES.has(turn.state)); + const pathIds = new Set(path.map((turn) => turn.turnId)); const tail: ConversationReadItem[] = []; const seenRequestIds = new Set(); + const seenStatusIds = new Set(); // Journal-less sessions (cold, or a launch whose first event hasn't flowed) cut every prior // epoch and NOTHING in the current one: seqs start at 1, so the run's own events all compare // above {epoch, 0} — a client adopting this during the launch window drops nothing. let watermark: ConversationWatermark = { epoch: eventEpoch, seq: 0 }; - if (journal) { + if (journal?.watermark !== undefined) watermark = journal.watermark; + if (journal && ownsTail) { const snapshot = journal.snapshot(); const terminalIds = new Set(); for (let i = 0, len = path.length; i < len; i++) { @@ -282,12 +381,16 @@ export class ConversationProjectionService { for (let i = 0, len = aboveCut.length; i < len; i++) { const entry = aboveCut[i]; const event = entry.event; + // An entry stamped for a turn off this lineage (a refused sibling) is another version's. + if (entry.turnId !== undefined && !pathIds.has(entry.turnId)) continue; // User rows are host truth — a live echo must not double the durable row. if (event.type === 'user-message') continue; const chunkKey = inflightChunkKey(event); if (chunkKey !== undefined && journal.isChunkCleared(chunkKey)) continue; if (event.type === 'permission-request' || event.type === 'question-request') { seenRequestIds.add(event.requestId); + } else if (event.type === 'prompt-response-status') { + seenStatusIds.add(event.requestId); } tail.push({ ...(entry.turnId !== undefined && { turnId: entry.turnId }), @@ -306,15 +409,20 @@ export class ConversationProjectionService { if (gap && liveTurn !== undefined) { tail.push({ type: 'history-unavailable', turnId: liveTurn.turnId, runId: liveTurn.runId }); } - if (journal.watermark !== undefined) watermark = journal.watermark; } - // CODE-35 backstop: open interactive requests reach the reader even when their original - // events were evicted or fell below the durable cut. - const openRequests = this.openRequests(sessionId); + // CODE-35 backstop: open interactive requests — and the responding status of one being + // answered — reach the reader even when their original events were evicted or fell below the + // durable cut. + const openRequests = ownsTail ? this.openRequests(sessionId) : []; for (let i = 0, len = openRequests.length; i < len; i++) { const request = openRequests[i]; - if (request.type !== 'permission-request' && request.type !== 'question-request') continue; - if (seenRequestIds.has(request.requestId)) continue; + if (request.type === 'prompt-response-status') { + if (seenStatusIds.has(request.requestId)) continue; + } else if (request.type === 'permission-request' || request.type === 'question-request') { + if (seenRequestIds.has(request.requestId)) continue; + } else { + continue; + } tail.push({ ...(liveTurn !== undefined && { turnId: liveTurn.turnId, runId: liveTurn.runId }), event: request, @@ -379,6 +487,7 @@ interface ReadCursor { readonly graphRevision: number; readonly leafTurnId: TurnId; readonly settled: number; + readonly durable: number; readonly offset: number; } @@ -396,6 +505,8 @@ function decodeReadCursor(raw: string): ReadCursor | undefined { typeof parsed.graphRevision !== 'number' || !('settled' in parsed) || typeof parsed.settled !== 'number' || + !('durable' in parsed) || + typeof parsed.durable !== 'number' || !('offset' in parsed) || typeof parsed.offset !== 'number' || !Number.isSafeInteger(parsed.offset) || @@ -410,6 +521,7 @@ function decodeReadCursor(raw: string): ReadCursor | undefined { graphRevision: parsed.graphRevision, leafTurnId: leaf.data, settled: parsed.settled, + durable: parsed.durable, offset: parsed.offset, }; } @@ -460,6 +572,33 @@ function runHistoryId(record: SessionRecord, runId: RunId): AgentHistoryId | und return record.runs.find((run) => run.runId === runId)?.historyId; } +/** The turns that ran on one history in transcript order: the chain through `parentTurnId` from + * the turn whose parent ran elsewhere. Creation order (the input) stands when they form no chain. */ +function chainOrder(group: ConversationTurn[]): ConversationTurn[] { + const ids = new Set(group.map((turn) => turn.turnId)); + const childOf = new Map(); + let head: ConversationTurn | undefined; + for (let i = 0, len = group.length; i < len; i++) { + const turn = group[i]; + if (turn.parentTurnId !== null && ids.has(turn.parentTurnId)) { + childOf.set(turn.parentTurnId, turn); + } else { + head ??= turn; + } + } + const ordered: ConversationTurn[] = []; + const total = group.length; + let turn = head; + let count = 0; + // Bounded by the group size so a malformed graph cannot spin. + while (turn !== undefined && count < total) { + ordered.push(turn); + count += 1; + turn = childOf.get(turn.turnId); + } + return count === total ? ordered : group; +} + function projectedUserRow( turn: ConversationTurn, content: ContentBlock[], diff --git a/packages/host/engine/src/conversation/turn-service.ts b/packages/host/engine/src/conversation/turn-service.ts index 73612b0b4..157096c60 100644 --- a/packages/host/engine/src/conversation/turn-service.ts +++ b/packages/host/engine/src/conversation/turn-service.ts @@ -354,10 +354,30 @@ export class ConversationTurnService { if (this.dispatching.get(sessionId)?.turn.turnId === turnId) { this.dispatching.delete(sessionId); } + // A failed turn keeps its ordinal and renders with a state badge, so every device must learn + // the tree gained it — the default leaf did not move. + this.announceGraph(sessionId, true); return { ...operation, error }; }); } + /** Every device refetches the tree. A node they did not have bumps the revision — the shape + * moved; a visible turn reaching its terminal state keeps it — only its badge changed, and a + * settle must not turn a peer's in-flight explicit-parent submit into a `conflict`. */ + private announceGraph(sessionId: SessionId, gainedNode: boolean): void { + if (gainedNode) this.records.commitGraphShape(sessionId); + const record = this.records.get(sessionId); + if (record === undefined) return; + this.transport.send( + createWireMessage({ + kind: 'conversation.graph.changed', + sessionId, + graphRevision: record.graphRevision, + ...(record.activeLeafTurnId !== undefined && { activeLeafTurnId: record.activeLeafTurnId }), + }), + ); + } + /** {@link resolveFailed} for exit paths inside a session-scoped fiber: enqueued on the engine * task runner so an interrupting teardown never waits behind the store write. */ resolveFailedDetached(intent: PersistedTurnIntent, error: TurnFailure): void { @@ -388,12 +408,16 @@ export class ConversationTurnService { for (let i = 0, len = open.length; i < len; i++) sweep.add(open[i].sessionId); for (const sessionId of sweep) { const turns = yield* this.listTurns(sessionId); + const activeLeafTurnId = this.records.get(sessionId)?.activeLeafTurnId; + const threadRunId = turns.find((turn) => turn.turnId === activeLeafTurnId)?.runId; for (let i = 0, len = turns.length; i < len; i++) { const turn = turns[i]; if (TERMINAL_TURN_STATES.has(turn.state)) continue; yield* storeOperation('conversation.turn.save', () => this.store.saveTurn({ ...turn, state: 'failed' }), ); + // A dead turn off the thread's run was a relaunch that never became the thread. + if (turn.runId !== threadRunId) this.records.abandonRun(sessionId, turn.runId); } } const resolvedAt = Date.now(); @@ -420,22 +444,37 @@ export class ConversationTurnService { } /** Persist a live fork checkpoint as the binding of the turn it describes: `ending` → the turn - * `runId` is executing, `preceding` → that turn's parent (a root has none). A checkpoint from a - * run that is neither dispatching nor running a turn (a replaced adapter) binds nothing. */ + * `runId` is executing, `preceding` → that turn's parent (a root has none), filed under the + * parent's own run — a binding names the run that executed its turn, and a successor may run in + * another. A checkpoint from a run that is neither dispatching nor running a turn (a replaced + * adapter) binds nothing. */ bindLiveCheckpoint(sessionId: SessionId, runId: RunId, checkpoint: HistoryCheckpoint): void { const dispatching = this.dispatching.get(sessionId)?.turn; const turn = dispatching?.runId === runId ? dispatching : this.runningFor(sessionId, runId)?.turn; if (!turn) return; - const turnId = checkpoint.turn === 'ending' ? turn.turnId : turn.parentTurnId; - if (turnId === null) return; - this.saveBinding({ - turnId, - runId, + const cut = { historyId: checkpoint.historyId, checkpoint: checkpoint.cursor, - capturedFrom: 'live', - }); + capturedFrom: 'live' as const, + }; + if (checkpoint.turn === 'ending') { + this.saveBinding(turn.turnId, Effect.succeed({ ...cut, turnId: turn.turnId, runId })); + return; + } + const { parentTurnId } = turn; + if (parentTurnId === null) return; + this.saveBinding( + parentTurnId, + this.listTurns(sessionId).pipe( + Effect.map((turns) => { + const parent = turns.find((candidate) => candidate.turnId === parentTurnId); + return parent === undefined + ? undefined + : { ...cut, turnId: parentTurnId, runId: parent.runId }; + }), + ), + ); } /** An adapter `error` while the run's turn is live; decides `failed` on a stop-less settle. */ @@ -516,15 +555,19 @@ export class ConversationTurnService { } /** Bindings are written off synchronous adapter callbacks, best-effort like turn settles. */ - private saveBinding(binding: ProviderTurnBinding): void { + private saveBinding( + turnId: TurnId, + binding: Effect.Effect, + ): void { this.runTask( - storeOperation('conversation.binding.save', () => this.store.saveBinding(binding)).pipe( + binding.pipe( + Effect.flatMap((resolved) => + resolved === undefined + ? Effect.void + : storeOperation('conversation.binding.save', () => this.store.saveBinding(resolved)), + ), Effect.catch((error) => - Effect.logError( - error.publicMessage, - { operation: error.operation, turnId: binding.turnId }, - error.cause, - ), + Effect.logError(error.publicMessage, { operation: error.operation, turnId }, error.cause), ), ), ); @@ -536,6 +579,7 @@ export class ConversationTurnService { this.settledAt.set(turn.sessionId, Date.now()); this.runTask( storeOperation('conversation.turn.save', () => this.store.saveTurn({ ...turn, state })).pipe( + Effect.tap(() => Effect.sync(() => this.announceGraph(turn.sessionId, false))), Effect.catch((error) => Effect.logError( error.publicMessage, diff --git a/packages/host/engine/src/resource/service.ts b/packages/host/engine/src/resource/service.ts index fac7814e7..ac6e414fc 100644 --- a/packages/host/engine/src/resource/service.ts +++ b/packages/host/engine/src/resource/service.ts @@ -8,6 +8,8 @@ import { blobIdFromSha256, declaredMimeTypeMatches, MAX_ATTACHMENT_BYTES, + MAX_ATTACHMENT_NAME_LENGTH, + MAX_MIME_TYPE_LENGTH, SessionResourceIdSchema, } from '@linkcode/schema'; import type { Transport } from '@linkcode/transport'; @@ -74,15 +76,24 @@ export class ResourceService { upload( sessionId: SessionId, - name: string, + rawName: string, mimeType: string | undefined, data: string, ): Effect.Effect { const { blobs, ingest, records, transport } = this; + // The v79 frame bounds neither field; attachment records do. Cap the name the way ingest caps a + // legacy image's, and refuse a MIME type no record can hold — an older peer gets an answer. + const name = rawName.slice(0, MAX_ATTACHMENT_NAME_LENGTH); return Effect.gen({ self: this }, function* () { if (!records.has(sessionId)) { return yield* new RequestError({ code: 'not_found', message: 'Session not found' }); } + if (mimeType !== undefined && mimeType.length > MAX_MIME_TYPE_LENGTH) { + return yield* new RequestError({ + code: 'invalid_request', + message: `MIME type exceeds ${MAX_MIME_TYPE_LENGTH} characters`, + }); + } const bytes = Buffer.from(data, 'base64'); if (bytes.byteLength > MAX_ATTACHMENT_BYTES) { return yield* new RequestError({ diff --git a/packages/host/engine/src/session/lifecycle-service.ts b/packages/host/engine/src/session/lifecycle-service.ts index ce27da666..477598899 100644 --- a/packages/host/engine/src/session/lifecycle-service.ts +++ b/packages/host/engine/src/session/lifecycle-service.ts @@ -455,6 +455,7 @@ export class SessionLifecycleService { submitTurn(request: TurnSubmitRequest): Effect.Effect { const { sessions, turns } = this; const admitSubmit = this.admitSubmit.bind(this); + const abandonRelaunch = this.abandonRelaunch.bind(this); const relaunch = this.relaunch.bind(this); const resumeSession = this.resumeSession.bind(this); const materializeSubmitInput = this.materializeSubmitInput.bind(this); @@ -479,6 +480,13 @@ export class SessionLifecycleService { ); } const { intent, launch } = yield* admitSubmit(request); + // A relaunch onto other provider history becomes the thread's only if its turn runs; a + // failed one is unwound, or the next plain send would continue the child's history under + // the unmoved leaf. + const unwindLaunch = + launch.type === 'continue' || (launch.type === 'resume' && launch.historyId === undefined) + ? Effect.void + : abandonRelaunch(request.sessionId, intent.turn.runId); const dispatch = Effect.gen(function* () { if (launch.type !== 'continue') { const { runId } = intent.turn; @@ -558,7 +566,8 @@ export class SessionLifecycleService { ), ), // Any post-persist failure resolves the operation; a retry replays this stored error. - onFailure: (error) => turns.resolveFailed(intent, toRequestFailure(error)), + onFailure: (error) => + turns.resolveFailed(intent, toRequestFailure(error)).pipe(Effect.tap(unwindLaunch)), }), // Interrupts and defects bypass the typed match; the open operation must still resolve, // or the session wedges `busy` until the daemon restarts. @@ -572,7 +581,7 @@ export class SessionLifecycleService { error.cause, ), ), - Effect.asVoid, + Effect.andThen(unwindLaunch), ) : Effect.void, ), @@ -580,6 +589,23 @@ export class SessionLifecycleService { }); } + /** Unwind a relaunch whose turn never ran: the run is marked first, so the thread's history + * resolves past it even if stopping the child adapter fails. */ + private abandonRelaunch(sessionId: SessionId, runId: RunId): Effect.Effect { + const { records, sessions } = this; + return Effect.suspend(() => { + records.abandonRun(sessionId, runId); + if (sessions.liveRunId(sessionId) !== runId) return Effect.void; + return sessions + .stop(sessionId) + .pipe( + Effect.catch((error) => + Effect.logError('Failed to stop the abandoned relaunch', { sessionId }, error.cause), + ), + ); + }); + } + /** * Steps 1–2 of the submit saga under the per-session critical section: typed `busy` while a * turn runs or another operation is open, parent/revision validation for explicit-parent @@ -630,24 +656,30 @@ export class SessionLifecycleService { ); } parentTurnId = null; - // Editing "the first prompt" starts fresh only when nothing can precede a root here; - // otherwise the active lineage's root names the hidden history the new root forks after. + // Editing "the first prompt" starts fresh only when nothing can precede a root here. + // The session's FIRST root answers that — a later root, relaunched fresh, would read + // the earlier runs as hidden history of its own — while the cut anchors on the active + // lineage's root, whose history holds whatever the hidden prefix is. const existingTurns = yield* turns.listTurns(request.sessionId); - const root = pathToLeaf( - new Map(existingTurns.map((turn) => [turn.turnId, turn])), - record.activeLeafTurnId, - ).at(0); + const firstRoot = existingTurns.find( + (turn) => turn.parentTurnId === null && turn.siblingOrdinal === 1, + ); + const activeRoot = + pathToLeaf( + new Map(existingTurns.map((turn) => [turn.turnId, turn])), + record.activeLeafTurnId, + ).at(0) ?? firstRoot; const nothingPrecedes = - root === undefined + firstRoot === undefined ? records.historyId(request.sessionId) === undefined - : !hasHiddenPrefix(record, root); + : !hasHiddenPrefix(record, firstRoot); if (nothingPrecedes) { launch = { type: 'fresh' }; } else { const forkable = sessions.historyCapabilitiesOf(record.kind).forkAfterTurn === true; const cut = - forkable && root !== undefined - ? yield* checkpoints.forkCutBefore(record, root.turnId) + forkable && activeRoot !== undefined + ? yield* checkpoints.forkCutBefore(record, activeRoot.turnId) : undefined; if (cut === undefined) { return yield* Effect.fail( diff --git a/packages/host/engine/src/session/orchestrator.ts b/packages/host/engine/src/session/orchestrator.ts index 282a21f15..cf08e3ca7 100644 --- a/packages/host/engine/src/session/orchestrator.ts +++ b/packages/host/engine/src/session/orchestrator.ts @@ -34,6 +34,9 @@ import type { SessionRecordRegistry } from './session-record-registry'; export class SessionOrchestrator { private readonly sessions = new Map(); + /** Sessions mid-`delete`: a launch admitted during the delete's own store waits must not install + * a live run whose record is about to vanish (and whose journal the final drop would take). */ + private readonly deleting = new Set(); private readonly events: SessionEventProcessor; private readonly inputs: SessionInputDispatcher; @@ -119,14 +122,20 @@ export class SessionOrchestrator { if (session) this.events.broadcast(sessionId, session, session.replay()); } - /** Authoritative open/responding interactive requests — the CODE-35 backstop: a conversation - * read must carry them even when the journal evicted or cut their original events. */ + /** Authoritative open interactive requests and their responding statuses — the CODE-35 + * backstop: a conversation read must carry them even when the journal evicted or cut their + * original events. */ openInteractiveRequests(sessionId: SessionId): AgentEvent[] { const session = this.sessions.get(sessionId); if (!session) return []; return session.interactions .replay() - .filter((event) => event.type === 'permission-request' || event.type === 'question-request'); + .filter( + (event) => + event.type === 'permission-request' || + event.type === 'question-request' || + event.type === 'prompt-response-status', + ); } sendInput( @@ -158,8 +167,9 @@ export class SessionOrchestrator { } delete(sessionId: SessionId): Effect.Effect { - const { resources } = this; + const { deleting, resources } = this; return Effect.gen({ self: this }, function* () { + deleting.add(sessionId); const session = this.sessions.get(sessionId); if (session) { yield* this.teardown(sessionId, session, 'session.delete'); @@ -168,7 +178,13 @@ export class SessionOrchestrator { yield* this.turns.deleteSession(sessionId); yield* this.records.delete(sessionId); this.journals.drop(sessionId); - }); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + deleting.delete(sessionId); + }), + ), + ); } stopIfLive(sessionId: SessionId): Effect.Effect { @@ -294,6 +310,7 @@ export class SessionOrchestrator { } = {}, ): Effect.Effect { const { + deleting, events, factory, inputs, @@ -311,6 +328,11 @@ export class SessionOrchestrator { return observeOperation( Effect.gen(function* () { const sessionId = record.sessionId; + if (deleting.has(sessionId)) { + return yield* Effect.fail( + new RequestError({ code: 'not_found', message: `Unknown session: ${sessionId}` }), + ); + } const adapter = factory(record.kind); if (browserTools) adapter.attachBrowserTools?.(browserTools); const scope = yield* Scope.fork(parentScope); diff --git a/packages/host/engine/src/session/session-record-registry.ts b/packages/host/engine/src/session/session-record-registry.ts index a6cd571f2..883c0459e 100644 --- a/packages/host/engine/src/session/session-record-registry.ts +++ b/packages/host/engine/src/session/session-record-registry.ts @@ -193,6 +193,18 @@ export class SessionRecordRegistry { this.persist(record); } + /** A run launched onto other provider history whose turn never ran: it is sealed and marked so + * the thread's history resolves past it. */ + abandonRun(sessionId: SessionId, runId: RunId): void { + const record = this.records.get(sessionId); + const run = record?.runs.find((candidate) => candidate.runId === runId); + if (!record || !run || run.abandonedAt !== undefined) return; + const now = Date.now(); + run.abandonedAt = now; + run.endedAt ??= now; + this.persist(record); + } + /** Whether `runId` is the session's current (newest) run — the source-side gate that drops a * replaced adapter's session-scoped events. */ isCurrentRun(sessionId: SessionId, runId: RunId): boolean { @@ -211,6 +223,16 @@ export class SessionRecordRegistry { return record.graphRevision; } + /** The graph changed shape without moving the default leaf (a sibling failed before it ran): + * bump the revision so every device's `‹ 1/N ›` re-reads the tree. Returns the new revision. */ + commitGraphShape(sessionId: SessionId): number | undefined { + const record = this.records.get(sessionId); + if (!record) return undefined; + record.graphRevision += 1; + this.persist(record); + return record.graphRevision; + } + /** The single writer for a relaunch's run entry. `historyId` is known up front only when the * relaunch resumes a transcript; a fresh one gets it later via {@link bindHistoryId}. Returns * the run's identity (caller-supplied or minted here) even when the record is gone, so a @@ -353,8 +375,8 @@ function definedFields(fields: T): Partial { function latestHistoryId(record: SessionRecord): AgentHistoryId | undefined { for (let index = record.runs.length - 1; index >= 0; index -= 1) { - const historyId = record.runs[index].historyId; - if (historyId !== undefined) return historyId; + const { historyId, abandonedAt } = record.runs[index]; + if (historyId !== undefined && abandonedAt === undefined) return historyId; } return record.origin.type === 'imported' ? record.origin.historyId : undefined; } diff --git a/packages/presentation/i18n/src/locales/en.ts b/packages/presentation/i18n/src/locales/en.ts index 1dc26e70c..7ce6ef325 100644 --- a/packages/presentation/i18n/src/locales/en.ts +++ b/packages/presentation/i18n/src/locales/en.ts @@ -137,6 +137,11 @@ export const en = { compacted: 'Context compacted', compactedTokens: '{pre} → {post} tokens', historyUnavailable: 'Output for this turn is unavailable', + viewingEarlierVersion: 'You are viewing an earlier version. Sending continues from here.', + backToLatest: 'Back to latest', + continuedElsewhere: 'The conversation continued elsewhere.', + jumpToLatest: 'Jump to latest', + dismiss: 'Dismiss', insufficientCreditsTitle: 'LinkCode credits needed', insufficientCreditsHint: 'Top up your balance, then retry this message.', topUpCredits: 'Top up credits', @@ -229,6 +234,11 @@ export const en = { editSend: 'Send', editSending: 'Sending…', editError: 'Failed to rewrite prompt: {message}', + versionPrevious: 'Previous version', + versionNext: 'Next version', + versionOf: '{index}/{count}', + turnFailed: 'Failed', + turnCancelled: 'Cancelled', showMore: 'Show more', showLess: 'Show less', goodResponse: 'Good response', diff --git a/packages/presentation/i18n/src/locales/zh-cn.ts b/packages/presentation/i18n/src/locales/zh-cn.ts index 0c742d68a..11c5546e6 100644 --- a/packages/presentation/i18n/src/locales/zh-cn.ts +++ b/packages/presentation/i18n/src/locales/zh-cn.ts @@ -133,6 +133,11 @@ export const zhCN = { compacted: '上下文已压缩', compactedTokens: '{pre} → {post} tokens', historyUnavailable: '这一轮的输出已不可用', + viewingEarlierVersion: '正在查看较早的版本,发送的消息将从这里继续。', + backToLatest: '回到最新', + continuedElsewhere: '对话已在其他地方继续。', + jumpToLatest: '跳到最新', + dismiss: '关闭', insufficientCreditsTitle: '需要 LinkCode 额度', insufficientCreditsHint: '充值后即可安全重试这条消息。', topUpCredits: '充值额度', @@ -225,6 +230,11 @@ export const zhCN = { editSend: '发送', editSending: '正在发送…', editError: '重写提示词失败:{message}', + versionPrevious: '上一个版本', + versionNext: '下一个版本', + versionOf: '{index}/{count}', + turnFailed: '失败', + turnCancelled: '已取消', showMore: '展开', showLess: '收起', goodResponse: '有帮助', diff --git a/packages/presentation/ui/src/chat/__tests__/user-message.test.tsx b/packages/presentation/ui/src/chat/__tests__/user-message.test.tsx index 428a57f8c..6c5c634db 100644 --- a/packages/presentation/ui/src/chat/__tests__/user-message.test.tsx +++ b/packages/presentation/ui/src/chat/__tests__/user-message.test.tsx @@ -226,4 +226,87 @@ describe('UserMessage', () => { expect(multiline.container.querySelector('img')).toBeNull(); expect(screen.queryByText('/documents')).toBeNull(); }); + + it('shows the version control for a turn with siblings and reports a switch', () => { + const onSelectVersion = vi.fn(); + const item: Extract = { + id: 'msg-turn-2', + kind: 'message', + role: 'user', + turnId: 'turn-2', + blocks: [{ type: 'text', text: 'second try' }], + isStreaming: false, + }; + render( + , + ); + expect(screen.getByText('versionOf')).toBeDefined(); + expect( + screen.getByRole('button', { name: 'versionPrevious' }).disabled, + ).toBe(true); + fireEvent.click(screen.getByRole('button', { name: 'versionNext' })); + expect(onSelectVersion).toHaveBeenCalledWith(1); + }); + + it('badges a turn that did not complete and hides the arrows for an only child', () => { + const item: Extract = { + id: 'msg-turn-3', + kind: 'message', + role: 'user', + turnId: 'turn-3', + blocks: [{ type: 'text', text: 'went wrong' }], + isStreaming: false, + }; + render(); + expect(screen.getByText('turnFailed')).toBeDefined(); + expect(screen.queryByRole('button', { name: 'versionNext' })).toBeNull(); + }); + + it('edits a graph-known prompt without a legacy branch cursor', async () => { + const onEditPrompt = vi.fn(asyncNoop); + const item: Extract = { + id: 'msg-turn-4', + kind: 'message', + role: 'user', + turnId: 'turn-4', + blocks: [{ type: 'text', text: 'original prompt' }], + isStreaming: false, + }; + const { unmount } = render( + , + ); + // A known graph node on a harness whose edits still need a cursor stays uneditable. + expect( + screen.getByRole('button', { name: 'editUnavailable' }).disabled, + ).toBe(true); + unmount(); + + render( + , + ); + fireEvent.click(screen.getByRole('button', { name: 'edit' })); + const editor = screen.getByRole('textbox', { name: 'editPromptLabel' }); + fireEvent.change(editor, { target: { value: 'replacement prompt' } }); + fireEvent.click(screen.getByRole('button', { name: 'editSend' })); + await waitFor(() => { + expect(onEditPrompt).toHaveBeenCalledWith('msg-turn-4', undefined, [ + { type: 'text', text: 'replacement prompt' }, + ]); + }); + }); }); diff --git a/packages/presentation/ui/src/chat/conversation-view.tsx b/packages/presentation/ui/src/chat/conversation-view.tsx index d717078c8..79b9fabc4 100644 --- a/packages/presentation/ui/src/chat/conversation-view.tsx +++ b/packages/presentation/ui/src/chat/conversation-view.tsx @@ -13,7 +13,12 @@ import { ConversationMinimap, useConversationMinimap } from './conversation-mini import { SubagentViewer } from './subagent-viewer'; import { partitionSubagentItems } from './subagents'; import { TurnSegmentView } from './turn-segment-view'; -import type { ConversationItem, ConversationViewModel, PromptEditState } from './types'; +import type { + ConversationItem, + ConversationViewModel, + PromptEditState, + TurnVersion, +} from './types'; import { useTimelineModel } from './use-timeline-model'; export interface ConversationViewProps { @@ -26,9 +31,13 @@ export interface ConversationViewProps { promptEditState: PromptEditState; onEditPrompt?: ( messageId: string, - branchCursor: string, + branchCursor: string | undefined, content: ContentBlock[], ) => Promise; + /** Sibling versions per user row (by message id), from the turn graph. */ + versions?: ReadonlyMap; + onSelectVersion?: (messageId: string, direction: -1 | 1) => void; + rewritesViaGraph?: boolean; /** Opens this turn's workspace changes in the host review surface. */ onReviewChanges?: () => void; /** Opens the host-owned LinkCode billing surface for a typed gateway credit error. */ @@ -46,6 +55,9 @@ export function ConversationView({ TerminalBlockComponent, promptEditState, onEditPrompt, + versions, + onSelectVersion, + rewritesViaGraph, onReviewChanges, onOpenBilling, scrollContextRef, @@ -135,6 +147,9 @@ export function ConversationView({ TerminalBlockComponent={TerminalBlockComponent} promptEditState={promptEditState} onEditPrompt={onEditPrompt} + versions={versions} + onSelectVersion={onSelectVersion} + rewritesViaGraph={rewritesViaGraph} onExpandTask={setExpandedTaskId} onReviewChanges={onReviewChanges} onOpenBilling={onOpenBilling} diff --git a/packages/presentation/ui/src/chat/index.ts b/packages/presentation/ui/src/chat/index.ts index fd7a81e98..cd00bb2ed 100644 --- a/packages/presentation/ui/src/chat/index.ts +++ b/packages/presentation/ui/src/chat/index.ts @@ -40,5 +40,6 @@ export * from './test-results'; export * from './thought-block'; export * from './tool'; export * from './tool-call-item'; +export * from './turn-version-nav'; export type * from './types'; export * from './web-preview'; diff --git a/packages/presentation/ui/src/chat/turn-segment-view.tsx b/packages/presentation/ui/src/chat/turn-segment-view.tsx index f5dc76ef2..7d2fe2457 100644 --- a/packages/presentation/ui/src/chat/turn-segment-view.tsx +++ b/packages/presentation/ui/src/chat/turn-segment-view.tsx @@ -21,7 +21,7 @@ import { AgentTurnActions } from './turn-actions'; import { TurnDiffSummary } from './turn-diff-summary'; import type { TurnSegment } from './turn-edits'; import { turnFileEdits } from './turn-edits'; -import type { PromptEditState } from './types'; +import type { PromptEditState, TurnVersion } from './types'; import { UserMessage } from './user-message'; export interface TurnSegmentViewProps { @@ -42,9 +42,13 @@ export interface TurnSegmentViewProps { promptEditState: PromptEditState; onEditPrompt?: ( messageId: string, - branchCursor: string, + branchCursor: string | undefined, content: ContentBlock[], ) => Promise; + /** Sibling versions per user row (by message id), from the turn graph. */ + versions?: ReadonlyMap; + onSelectVersion?: (messageId: string, direction: -1 | 1) => void; + rewritesViaGraph?: boolean; /** Opens a subagent's full transcript in the conversation's viewer rail. */ onExpandTask: (toolCallId: string) => void; /** Opens this turn's workspace changes in the host review surface. */ @@ -72,6 +76,9 @@ export function TurnSegmentView({ TerminalBlockComponent, promptEditState, onEditPrompt, + versions, + onSelectVersion, + rewritesViaGraph, onExpandTask, onReviewChanges, onOpenBilling, @@ -155,6 +162,13 @@ export function TurnSegmentView({ item={item} promptEditState={promptEditState} onEditPrompt={onEditPrompt} + version={versions?.get(item.id)} + onSelectVersion={ + onSelectVersion === undefined + ? undefined + : (direction) => onSelectVersion(item.id, direction) + } + rewritesViaGraph={rewritesViaGraph} /> ); } diff --git a/packages/presentation/ui/src/chat/turn-version-nav.tsx b/packages/presentation/ui/src/chat/turn-version-nav.tsx new file mode 100644 index 000000000..3513105c5 --- /dev/null +++ b/packages/presentation/ui/src/chat/turn-version-nav.tsx @@ -0,0 +1,53 @@ +import { Badge } from 'coss-ui/components/badge'; +import { Button } from 'coss-ui/components/button'; +import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'; +import { useTranslations } from 'use-intl'; +import type { TurnVersion } from './types'; + +/** `‹ 1/N ›` between a turn's sibling versions, plus a badge when the turn did not complete. */ +export function TurnVersionNav({ + version, + onSelect, +}: { + version: TurnVersion; + onSelect?: (direction: -1 | 1) => void; +}): React.ReactNode { + const t = useTranslations('workbench.message'); + return ( + + {version.count > 1 ? ( + <> + + {t('versionOf', { index: version.index, count: version.count })} + + + ) : null} + {version.state === null ? null : ( + + {t(version.state === 'failed' ? 'turnFailed' : 'turnCancelled')} + + )} + + ); +} diff --git a/packages/presentation/ui/src/chat/types.ts b/packages/presentation/ui/src/chat/types.ts index 62b81bbb2..fd812700c 100644 --- a/packages/presentation/ui/src/chat/types.ts +++ b/packages/presentation/ui/src/chat/types.ts @@ -23,6 +23,31 @@ import type { export type ConversationTurnId = string | null; export type PromptEditState = 'enabled' | 'busy' | 'unsupported'; +/** A turn's place among its siblings (the `‹ 1/N ›` control) and a non-success state to badge. */ +export interface TurnVersion { + /** 1-based sibling ordinal. */ + index: number; + count: number; + state: 'failed' | 'cancelled' | null; +} + +export type ConversationLineageNotice = + /** The viewer browsed to an earlier version; sending continues from it. */ + | { kind: 'parked'; onJump: () => void } + /** The conversation moved on elsewhere while this viewer stayed parked. */ + | { kind: 'elsewhere'; onJump: () => void; onDismiss: () => void }; + +/** Turn-graph affordances for the conversation column, computed by the runtime from the graph. */ +export interface ConversationLineage { + /** Keyed by the user row's message id. */ + versions: ReadonlyMap; + onSelectVersion: (messageId: string, direction: -1 | 1) => void; + notice: ConversationLineageNotice | null; + promptEditState: PromptEditState; + /** Edits submit through the turn graph, so a row the graph knows needs no legacy branch cursor. */ + rewritesViaGraph: boolean; +} + /** * Fields every timeline item carries. `receivedAt` is the best-known time of the item's latest * event: client receive time for live events, the provider's own timestamp for history-seeded diff --git a/packages/presentation/ui/src/chat/user-message.tsx b/packages/presentation/ui/src/chat/user-message.tsx index a2efd149c..08eb28915 100644 --- a/packages/presentation/ui/src/chat/user-message.tsx +++ b/packages/presentation/ui/src/chat/user-message.tsx @@ -16,7 +16,8 @@ import { positionalBlockEntries } from './content-derived-keys'; import { contentBlocksText } from './conversation-text'; import { Chip } from './link-chip'; import { Message, MessageAction, MessageActions, MessageContent } from './message'; -import type { ConversationItem, PromptEditState } from './types'; +import { TurnVersionNav } from './turn-version-nav'; +import type { ConversationItem, PromptEditState, TurnVersion } from './types'; import { useCopyButton } from './use-copy-button'; /** Long pastes collapse past this many source lines. */ @@ -38,19 +39,27 @@ function commandEcho(text: string): { name: string; args: string } | undefined { type MessageItem = Extract; -/** A user bubble: collapses long messages, with copy/edit and the send time revealed on hover. */ +/** A user bubble: collapses long messages, with copy/edit and the send time revealed on hover. + * With `version`, the turn is a known graph node: its `‹ 1/N ›` control stays visible, and where + * edits submit through the graph (`rewritesViaGraph`) one needs no legacy branch cursor. */ export function UserMessage({ item, promptEditState = 'unsupported', onEditPrompt, + version, + onSelectVersion, + rewritesViaGraph = false, }: { item: MessageItem; promptEditState?: PromptEditState; onEditPrompt?: ( messageId: string, - branchCursor: string, + branchCursor: string | undefined, content: ContentBlock[], ) => Promise; + version?: TurnVersion; + onSelectVersion?: (direction: -1 | 1) => void; + rewritesViaGraph?: boolean; }): React.ReactNode { const t = useTranslations('workbench.message'); const format = useFormatter(); @@ -66,14 +75,16 @@ export function UserMessage({ const hasPromptAttachment = item.blocks.some( (block) => block.type === 'resource_link' && attachmentIdFromUri(block.uri) !== undefined, ); + // A graph rewrite resubmits text and attachment refs; only a legacy branch carries inline images. + const hasInlineImage = item.blocks.some((block) => block.type === 'image'); + const editable = + item.branchCursor !== undefined || + (version !== undefined && rewritesViaGraph && !hasInlineImage); const canEdit = - promptEditState === 'enabled' && - item.branchCursor !== undefined && - onEditPrompt !== undefined && - !hasPromptAttachment; + promptEditState === 'enabled' && editable && onEditPrompt !== undefined && !hasPromptAttachment; const editTooltip = hasPromptAttachment ? t('editAttachmentsUnsupported') - : item.branchCursor === undefined + : !editable ? t('editUnavailable') : promptEditState === 'busy' ? t('editBusy') @@ -98,7 +109,7 @@ export function UserMessage({ event: React.SyntheticEvent, ): Promise { event.preventDefault(); - if (!canEdit || draft.trim().length === 0 || item.branchCursor === undefined) return; + if (!canEdit || draft.trim().length === 0) return; setPending(true); setError(null); const retainedBlocks = item.blocks.filter( @@ -214,9 +225,20 @@ export function UserMessage({ )} - {/* Meta row under the bubble; revealed by hovering the message. */} + {/* Meta row under the bubble; revealed by hovering the message, always shown when the turn + has sibling versions or a state to report. */} {editing ? null : ( - + 1 || version.state !== null) + ? undefined + : 'opacity-0 group-focus-within:opacity-100 group-hover:opacity-100', + )} + > + {version !== undefined && (version.count > 1 || version.state !== null) ? ( + + ) : null} {item.receivedAt === undefined ? null : ( {format.dateTime(new Date(item.receivedAt), { timeStyle: 'short' })} diff --git a/packages/presentation/ui/src/shell/__tests__/lineage-notice.test.tsx b/packages/presentation/ui/src/shell/__tests__/lineage-notice.test.tsx new file mode 100644 index 000000000..59974d5f5 --- /dev/null +++ b/packages/presentation/ui/src/shell/__tests__/lineage-notice.test.tsx @@ -0,0 +1,31 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { LineageNotice } from '../lineage-notice'; + +vi.mock('use-intl', () => ({ useTranslations: () => (key: string) => key })); + +afterEach(cleanup); + +describe('LineageNotice', () => { + it('offers the way back from a parked version', () => { + const onJump = vi.fn(); + render(); + expect(screen.getByText('viewingEarlierVersion')).toBeDefined(); + expect(screen.queryByRole('button', { name: 'dismiss' })).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: 'backToLatest' })); + expect(onJump).toHaveBeenCalledTimes(1); + }); + + it('lets a parked viewer dismiss or follow a conversation that moved elsewhere', () => { + const onJump = vi.fn(); + const onDismiss = vi.fn(); + render(); + expect(screen.getByText('continuedElsewhere')).toBeDefined(); + fireEvent.click(screen.getByRole('button', { name: 'dismiss' })); + fireEvent.click(screen.getByRole('button', { name: 'jumpToLatest' })); + expect(onDismiss).toHaveBeenCalledTimes(1); + expect(onJump).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/presentation/ui/src/shell/conversation-surface.tsx b/packages/presentation/ui/src/shell/conversation-surface.tsx index b87002674..4f28907db 100644 --- a/packages/presentation/ui/src/shell/conversation-surface.tsx +++ b/packages/presentation/ui/src/shell/conversation-surface.tsx @@ -6,7 +6,7 @@ import { CommandCatalogProvider } from '../chat/command-brand'; import type { PermissionDecision } from '../chat/conversation-prompts'; import { selectPendingPromptItems } from '../chat/conversation-prompts'; import { ConversationView } from '../chat/conversation-view'; -import type { ConversationViewModel, PromptEditState } from '../chat/types'; +import type { ConversationLineage, ConversationViewModel, PromptEditState } from '../chat/types'; import { cn } from '../lib/cn'; import type { ModelOption } from './agent-models'; import type { AgentRuntimeCues } from './agent-onboarding-card'; @@ -19,6 +19,7 @@ import type { ComposerDirectiveControls, ComposerHandle, MentionItem } from './c import { Composer } from './composer'; import type { ComposerAttachment } from './composer-attachments'; import { ConversationPromptDock } from './conversation-prompt-dock'; +import { LineageNotice } from './lineage-notice'; import { UsageReportCard } from './usage-report-card'; /** Composer behavior that every app shell must carry as one unit. Keeping the complete controller @@ -64,9 +65,11 @@ export interface ConversationSurfaceProps { promptEditState?: PromptEditState; onEditPrompt?: ( messageId: string, - branchCursor: string, + branchCursor: string | undefined, content: ContentBlock[], ) => Promise; + /** Turn-graph affordances (versions, parked notice); its edit state wins over `promptEditState`. */ + lineage?: ConversationLineage; /** Entries for the composer's `@` menu (workspace files, sourced by the app). */ mentionItems?: MentionItem[]; /** Reports the live `@` query so the app can fetch `mentionItems` for it. */ @@ -114,6 +117,7 @@ export function ConversationSurface({ TerminalBlockComponent, promptEditState = 'unsupported', onEditPrompt, + lineage, mentionItems, onMentionQueryChange, showPlanInPromptDock = true, @@ -165,8 +169,11 @@ export function ConversationSurface({ modelName={modelName ?? conversation.currentModel ?? undefined} scrollContextRef={conversationScrollRef} TerminalBlockComponent={TerminalBlockComponent} - promptEditState={promptEditState} + promptEditState={lineage?.promptEditState ?? promptEditState} onEditPrompt={onEditPrompt} + versions={lineage?.versions} + onSelectVersion={lineage?.onSelectVersion} + rewritesViaGraph={lineage?.rewritesViaGraph} onReviewChanges={onReviewChanges} onOpenBilling={onOpenBilling} /> @@ -174,6 +181,13 @@ export function ConversationSurface({ {conversation.usageReport && } + {lineage?.notice ? ( +
+
+ +
+
+ ) : null} + + {t(parked ? 'viewingEarlierVersion' : 'continuedElsewhere')} + + + + {notice.kind === 'elsewhere' ? ( + + ) : null} + + + ); +} diff --git a/packages/presentation/ui/src/shell/shell-frame.tsx b/packages/presentation/ui/src/shell/shell-frame.tsx index 597a55aa8..cbb73da22 100644 --- a/packages/presentation/ui/src/shell/shell-frame.tsx +++ b/packages/presentation/ui/src/shell/shell-frame.tsx @@ -9,7 +9,7 @@ import type { WorkspaceId, WorkspaceRecord, } from '@linkcode/schema'; -import type { ConversationViewModel } from '../chat'; +import type { ConversationLineage, ConversationViewModel } from '../chat'; import type { PermissionDecision } from '../chat/conversation-prompts'; import type { ModelOption } from './agent-models'; import type { AgentRuntimeCues } from './agent-onboarding-card'; @@ -74,9 +74,11 @@ export interface ShellFrameProps conversation: ConversationViewModel; onEditPrompt?: ( messageId: string, - branchCursor: string, + branchCursor: string | undefined, content: ContentBlock[], ) => Promise; + /** Turn-graph affordances for the active conversation; absent on hosts without a graph. */ + lineage?: ConversationLineage; respondingRequestIds: ReadonlySet; responseErrors?: ReadonlyMap; header?: React.ReactNode; @@ -143,6 +145,7 @@ export function ShellFrame({ onOpenBilling, conversation, onEditPrompt, + lineage, respondingRequestIds, responseErrors, header, @@ -262,6 +265,7 @@ export function ShellFrame({ active?.historyCapabilities?.branch === true ? 'enabled' : 'unsupported' } onEditPrompt={onEditPrompt} + lineage={lineage} mentionItems={mentionItems} onMentionQueryChange={(query) => onMentionQueryChange(active?.cwd, query)} showPlanInPromptDock={showPlanInPromptDock}