diff --git a/packages/client/core/src/__tests__/connection.test.ts b/packages/client/core/src/__tests__/connection.test.ts index 7f8d0121c..b6bbd389b 100644 --- a/packages/client/core/src/__tests__/connection.test.ts +++ b/packages/client/core/src/__tests__/connection.test.ts @@ -1,7 +1,7 @@ import type { ValidatedWireMessage, WirePayload } from '@linkcode/schema'; import { SessionIdSchema, SessionResourceSchema, WIRE_PROTOCOL_VERSION } from '@linkcode/schema'; import type { Transport, Unsubscribe } from '@linkcode/transport'; -import { createWireMessage, pong } from '@linkcode/transport'; +import { createWireMessage, pong, WsTransport } from '@linkcode/transport'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { LinkCodeClient } from '../client'; @@ -52,6 +52,23 @@ class ControlledTransport implements Transport { afterEach(() => vi.useRealTimers()); describe('LinkCodeClient connection lifetime', () => { + it('rejects transports that hide physical reconnects', () => { + expect( + () => new LinkCodeClient(new WsTransport({ url: 'ws://localhost', reconnect: true })), + ).toThrow('create a fresh client generation'); + expect( + () => + new LinkCodeClient(new WsTransport({ url: 'ws://localhost', reconnect: { baseMs: 500 } })), + ).toThrow('create a fresh client generation'); + expect( + () => + new LinkCodeClient( + new WsTransport({ url: 'ws://localhost', reconnect: { maxRetries: 0 } }), + ), + ).not.toThrow(); + expect(() => new LinkCodeClient(new WsTransport({ url: 'ws://localhost' }))).not.toThrow(); + }); + it('becomes ready only after a LinkCode pong and cannot connect twice', async () => { const transport = new ControlledTransport(); const client = new LinkCodeClient(transport); diff --git a/packages/client/core/src/client.ts b/packages/client/core/src/client.ts index 4ffd9fa2b..76684ddb4 100644 --- a/packages/client/core/src/client.ts +++ b/packages/client/core/src/client.ts @@ -238,6 +238,10 @@ export class LinkCodeClient { /** Framebuffer-frame listeners keyed by udid, so each panel tab only sees its device's frames. */ private readonly simulatorFrameSubs = new Map>(); private readonly connectionCloseSubs = new Set(); + /** Sessions whose initial fresh run this client has observed without a delivery gap. */ + private readonly freshSessionIds = new Set(); + private subscriptionMode: SessionSubscriptionMode = 'all'; + private subscriptionEpoch = 0; private unsub: Unsubscribe | null = null; private offClose: Unsubscribe | null = null; private state: ConnectionState = 'idle'; @@ -250,6 +254,11 @@ export class LinkCodeClient { private readonly transport: Transport, options: LinkCodeClientOptions = {}, ) { + if (transport.reconnectsTransparently === true) { + throw new Error( + 'LinkCodeClient: transport cannot reconnect transparently; create a fresh client generation', + ); + } const randomUUID = resolveRandomUUID(options.randomUUID); this.pending = new PendingRegistry(randomUUID); this.control = new ControlChannel(transport, this.pending); @@ -665,7 +674,18 @@ export class LinkCodeClient { } startSessionWithWarnings(opts: StartOptions): Promise { - return this.control.startSession(opts); + const provenanceEpoch = this.subscriptionMode === 'all' ? this.subscriptionEpoch : undefined; + return this.control.startSession(opts).then((result) => { + if (provenanceEpoch === this.subscriptionEpoch && this.subscriptionMode === 'all') { + this.freshSessionIds.add(result.sessionId); + } + return result; + }); + } + + /** Whether fresh-run provenance remains valid for trusted seed binds. */ + hasFreshSessionProvenance(sessionId: SessionId): boolean { + return this.freshSessionIds.has(sessionId); } getAgentCatalog(agentKind: AgentKind, cwd?: string): Promise { @@ -788,7 +808,15 @@ export class LinkCodeClient { /** See {@link ControlChannel.setSubscriptionMode}. */ setSubscriptionMode(mode: SessionSubscriptionMode): Promise { - return this.control.setSubscriptionMode(mode); + if (mode === 'attached') { + this.subscriptionMode = mode; + this.subscriptionEpoch += 1; + this.freshSessionIds.clear(); + } + return this.control.setSubscriptionMode(mode).then((ack) => { + this.subscriptionMode = mode; + return ack; + }); } setModel(sessionId: SessionId, model: string, accountId?: string): Promise { diff --git a/packages/client/core/src/conversation-store.ts b/packages/client/core/src/conversation-store.ts index 8ec749625..688a83f8d 100644 --- a/packages/client/core/src/conversation-store.ts +++ b/packages/client/core/src/conversation-store.ts @@ -1,8 +1,8 @@ -import type { AgentEvent, SessionId } from '@linkcode/schema'; +import type { AgentEvent, MessageId, SessionId } from '@linkcode/schema'; import type { Unsubscribe } from '@linkcode/transport'; import { noop } from 'foxact/noop'; import type { LinkCodeClient, SequencedAgentEvent } from './client'; -import type { Conversation, ConversationBuilder, ConversationSeed } from './conversation'; +import type { Conversation, ConversationSeed } from './conversation'; import { createConversationBuilder } from './conversation'; /** A `useSyncExternalStore`-shaped incremental projection of one session's conversation. @@ -30,79 +30,60 @@ const EMPTY_CONVERSATION: Conversation = { }; type UserMessageEvent = Extract; -interface SeedUserMessageQueue { - messages: UserMessageEvent[]; - nextIndex: number; +interface SeedPromptQueue { + rows: UserMessageEvent[]; + next: number; } -function takeSeedUserMessage( - messagesByContent: Map, +function takeSeedPrompt( + rowsByContent: Map, content: UserMessageEvent['content'], ): UserMessageEvent | undefined { const key = JSON.stringify(content); - const queue = messagesByContent.get(key); + const queue = rowsByContent.get(key); if (!queue) return undefined; - const message = queue.messages[queue.nextIndex]; - queue.nextIndex += 1; - if (queue.nextIndex === queue.messages.length) messagesByContent.delete(key); - return message; + const row = queue.rows[queue.next]; + queue.next += 1; + if (queue.next === queue.rows.length) rowsByContent.delete(key); + return row; } -/** Fold a pre-cut event only when the transcript snapshot does not already cover it. */ -function foldPreCutEvent( - builder: ConversationBuilder, +/** + * Whether the seed's transcript snapshot can be assumed to contain this event — the only license + * the `uptoSeq` cut has to drop it as "already in the snapshot". Providers flush transcripts by + * whole item, so coverage is checked per provider identity. User prompts are not decided here: + * their host and provider ids cannot converge, so `sync` folds them through the seed-row alias + * instead (see {@link createConversationStore}). A chunk of a message the snapshot never saw (the + * in-flight reply — claude-code writes the row only when the message completes) must survive a + * mid-turn reseed, or the streamed text vanishes at a chunk boundary (CODE-272). Everything + * outside the switch (interactive requests and resolutions, status, stop, errors, usage …) is + * ephemeral: it never appears in `history.read`, so cutting it would erase it outright — a pending + * permission-request would vanish and strand the turn (CODE-35). + */ +function coveredBySeed( event: AgentEvent, - receivedAt: number | undefined, seedMessageIds: ReadonlySet, seedToolIds: ReadonlySet, - seedUserMessages: Map, -): void { +): boolean { switch (event.type) { case 'agent-message': case 'agent-message-chunk': case 'agent-thought': - case 'agent-thought-chunk': { - if (!seedMessageIds.has(event.messageId)) builder.advance(event, receivedAt); - break; - } - case 'user-message': { - // Host and provider ids cannot converge, so consume matching seed rows by value. Some - // histories omit images; use the full live echo to enrich that seed row in place. - if (takeSeedUserMessage(seedUserMessages, event.content)) break; - if (event.content.some((block) => block.type === 'image')) { - const seedMessage = takeSeedUserMessage( - seedUserMessages, - event.content.filter((block) => block.type !== 'image'), - ); - if (seedMessage) { - builder.advance({ - ...event, - messageId: seedMessage.messageId, - branchCursor: seedMessage.branchCursor, - }); - break; - } - } - builder.advance(event, receivedAt); - break; - } - case 'tool-call': { - if (!seedToolIds.has(event.toolCall.toolCallId)) builder.advance(event, receivedAt); - break; - } - case 'tool-call-content-chunk': { - if (!seedToolIds.has(event.toolCallId)) builder.advance(event, receivedAt); - break; - } + case 'agent-thought-chunk': + return seedMessageIds.has(event.messageId); + case 'tool-call': + return seedToolIds.has(event.toolCall.toolCallId); + case 'tool-call-content-chunk': + return seedToolIds.has(event.toolCallId); default: - builder.advance(event, receivedAt); + return false; } } /** * Project a session's conversation from a transcript seed plus the live event buffer: the seed * folds once, then `getSnapshot` lazily advances by unconsumed events, skipping events inside the - * `uptoSeq` cut that the snapshot verifiably covers (see {@link foldPreCutEvent}). The sync is idempotent and monotone with a stable snapshot identity + * `uptoSeq` cut that the snapshot verifiably covers (see {@link coveredBySeed}). The sync is idempotent and monotone with a stable snapshot identity * between events — the `useSyncExternalStore` getSnapshot contract. A store is bound to one * (session, seed) pair; create a fresh one when either changes. */ @@ -120,7 +101,13 @@ export function createConversationStore( // Identities the snapshot actually holds, for the per-event coverage check of the cut. const seedMessageIds = new Set(); const seedToolIds = new Set(); - const seedUserMessages = new Map(); + /** Content key → seed user rows in transcript order plus the next unconsumed index. */ + const seedPromptRows = new Map(); + /** Host echo id → its consumed seed row; `trusted` = a provenance-checked fresh-run first + * prompt, the only content bind allowed to drive cursor fills and rewind translation. */ + const promptAliases = new Map(); + /** The transcript's first user row — the only row a fresh run's first prompt can be. */ + let firstUserRowId: MessageId | undefined; if (seed) { for (const { event } of seed.events) { switch (event.type) { @@ -132,9 +119,10 @@ export function createConversationStore( break; case 'user-message': { const key = JSON.stringify(event.content); - const queue = seedUserMessages.get(key); - if (queue) queue.messages.push(event); - else seedUserMessages.set(key, { messages: [event], nextIndex: 0 }); + const entry = seedPromptRows.get(key); + if (entry) entry.rows.push(event); + else seedPromptRows.set(key, { rows: [event], next: 0 }); + firstUserRowId ??= event.messageId; break; } case 'tool-call': @@ -152,6 +140,41 @@ export function createConversationStore( /** Highest receive seq already examined (not necessarily folded — covered ones may be cut). */ let consumedSeq = 0; + /** Fold one prompt echo. A bind grants display dedupe only — never content: an in-cut echo + * consuming its exact-content seed row is simply not re-folded. A trusted bind's cursor-bearing + * re-echo re-advances the seed row itself, so the fresh-run cursor lands without the echo ever + * writing blocks; every other echo folds as its own live item. */ + const advancePrompt = ( + event: UserMessageEvent, + seq: number, + cleanBuffer: boolean, + receivedAt?: number, + ): void => { + const alias = promptAliases.get(event.messageId); + if (alias !== undefined) { + const { row, trusted } = alias; + if (trusted && row.branchCursor === undefined && event.branchCursor !== undefined) { + builder.advance({ ...row, branchCursor: event.branchCursor }); + } + return; + } + if (seq <= uptoSeq) { + const row = takeSeedPrompt(seedPromptRows, event.content); + if (row !== undefined) { + // A bare pre-binding echo alone proves nothing (resume/branch runs retain history behind + // an async ref window); trust also needs uninterrupted fresh-run provenance and no wipe. + const trusted = + cleanBuffer && + client.hasFreshSessionProvenance(sessionId) && + event.branchCursor === undefined && + row.messageId === firstUserRowId; + promptAliases.set(event.messageId, { row, trusted }); + return; + } + } + builder.advance(event, receivedAt); + }; + const sync = (): void => { if (!seeded) { seeded = true; @@ -159,12 +182,27 @@ export function createConversationStore( } if (client.eventSeq(sessionId) <= consumedSeq) return; const events = client.eventsSnapshot(sessionId); + // Seq 1 still buffered ⟺ no rewind/stop wiped this connection's view of the session — a gap + // can hide retained history a bare echo would otherwise pass for a fresh first prompt. + const cleanBuffer = events[0]?.seq === 1; for (let i = firstIndexAfter(events, consumedSeq); i < events.length; i += 1) { const { event, seq, receivedAt } = events[i]; - if (seq > uptoSeq) { + if (event.type === 'user-message') { + advancePrompt(event, seq, cleanBuffer, receivedAt); + continue; + } + if (event.type === 'conversation-rewind') { + // Only a trusted bind may aim the destructive cut; an ambiguous one falls through (a + // missed cut renders stale until the next reseed — never truncates valid turns). + const alias = promptAliases.get(event.messageId); + builder.advance( + alias?.trusted ? { ...event, messageId: alias.row.messageId } : event, + receivedAt, + ); + continue; + } + if (seq > uptoSeq || !coveredBySeed(event, seedMessageIds, seedToolIds)) { builder.advance(event, receivedAt); - } else { - foldPreCutEvent(builder, event, receivedAt, seedMessageIds, seedToolIds, seedUserMessages); } } // Snap to the counter even when the buffer lags it (cleared by a stop): those events are diff --git a/packages/client/core/src/conversation.ts b/packages/client/core/src/conversation.ts index 35738746c..b05d49dc1 100644 --- a/packages/client/core/src/conversation.ts +++ b/packages/client/core/src/conversation.ts @@ -207,8 +207,10 @@ export function createConversationBuilder(): ConversationBuilder { return; } + // Cut at the id's FIRST entry — the item's creation. Later entries with the same id are + // in-place updates (echo cursor merges), and anchoring on one would replay the rewound item. let cut = -1; - for (let index = entries.length - 1; index >= 0; index -= 1) { + for (let index = 0; index < entries.length; index += 1) { const candidate = entries[index].event; if (candidate.type === 'user-message' && candidate.messageId === event.messageId) { cut = index; diff --git a/packages/client/core/tests/integration/control-client.test.ts b/packages/client/core/tests/integration/control-client.test.ts index 13eceba59..3a6611c43 100644 --- a/packages/client/core/tests/integration/control-client.test.ts +++ b/packages/client/core/tests/integration/control-client.test.ts @@ -8,6 +8,7 @@ import type { WirePayload, } from '@linkcode/schema'; import { createWireMessage } from '@linkcode/transport'; +import { nullthrow } from 'foxts/guard'; import { wait } from 'foxts/wait'; import { describe, expect, it } from 'vitest'; import type { SequencedAgentEvent } from '../../src/client'; @@ -213,6 +214,67 @@ describe('LinkCodeClient control API', () => { }); describe('LinkCodeClient event delivery scope', () => { + it('requires uninterrupted all-event delivery for fresh-session provenance', async () => { + const { client, serverTransport } = await createConnectedLocalClient(); + const nextSessionId = 'sess-control-next' as SessionId; + const lastSessionId = 'sess-control-last' as SessionId; + const stableSessionId = 'sess-control-stable' as SessionId; + const sessionIds = [sessionId, nextSessionId, lastSessionId, stableSessionId]; + const delayedReplies: Array<() => void> = []; + let sessionIndex = 0; + + serverTransport.onMessage((msg) => { + const payload = msg.payload; + if (payload.kind === 'session.start') { + const startedSessionId = sessionIds[sessionIndex] ?? lastSessionId; + sessionIndex += 1; + const reply = (): void => { + serverTransport.send( + createWireMessage({ + kind: 'session.started', + replyTo: payload.clientReqId, + sessionId: startedSessionId, + }), + ); + }; + if (sessionIndex === 1) reply(); + else delayedReplies.push(reply); + } else if (payload.kind === 'subscription.set') { + serverTransport.send( + createWireMessage({ kind: 'request.succeeded', replyTo: payload.clientReqId }), + ); + } + }); + + const first = await client.startSession({ kind: 'claude-code', cwd: '/workspace' }); + expect(client.hasFreshSessionProvenance(first)).toBe(true); + await client.setSubscriptionMode('attached'); + expect(client.hasFreshSessionProvenance(first)).toBe(false); + const attachedStart = client.startSession({ kind: 'claude-code', cwd: '/workspace' }); + await wait(10); + await client.setSubscriptionMode('all'); + nullthrow(delayedReplies[0])(); + const second = await attachedStart; + expect(client.hasFreshSessionProvenance(second)).toBe(false); + + const interruptedStart = client.startSession({ kind: 'claude-code', cwd: '/workspace' }); + await wait(10); + await client.setSubscriptionMode('attached'); + await client.setSubscriptionMode('all'); + nullthrow(delayedReplies[1])(); + const third = await interruptedStart; + expect(client.hasFreshSessionProvenance(third)).toBe(false); + + const stableStart = client.startSession({ kind: 'claude-code', cwd: '/workspace' }); + await wait(10); + nullthrow(delayedReplies[2])(); + const fourth = await stableStart; + expect(client.hasFreshSessionProvenance(fourth)).toBe(true); + + client.dispose(); + serverTransport.close(); + }); + it('negotiates the subscription mode and announces which sessions it observes', async () => { const { client, serverTransport } = await createConnectedLocalClient({ randomUUID: () => 'scope', diff --git a/packages/client/core/tests/integration/conversation-store.test.ts b/packages/client/core/tests/integration/conversation-store.test.ts index b63f18a65..6169830f2 100644 --- a/packages/client/core/tests/integration/conversation-store.test.ts +++ b/packages/client/core/tests/integration/conversation-store.test.ts @@ -1,31 +1,73 @@ import type { AgentEvent, MessageId, SessionId } from '@linkcode/schema'; -import { createWireMessage } from '@linkcode/transport'; +import { createLocalTransportPair, createWireMessage } from '@linkcode/transport'; +import { Hub } from '@linkcode/transport/server'; import { wait } from 'foxts/wait'; import { describe, expect, it } from 'vitest'; +import { LinkCodeClient } from '../../src/client'; import { createConversationStore } from '../../src/conversation-store'; import { createConnectedLocalClient } from '../support/local-client'; const sessionId = 'sess-store' as SessionId; -function userText(text: string, messageId = `user:${text}`): AgentEvent { +function userText(text: string, messageId = `user:${text}`, branchCursor?: string): AgentEvent { return { type: 'user-message', messageId: messageId as MessageId, content: [{ type: 'text', text }], + ...(branchCursor !== undefined && { branchCursor }), }; } +function agentText(text: string, messageId: string): AgentEvent { + return { + type: 'agent-message', + messageId: messageId as MessageId, + content: [{ type: 'text', text }], + }; +} + +function userTexts(store: ReturnType): string[] { + const { items } = store.getSnapshot(); + return items.flatMap((item) => + item.kind === 'message' && item.role === 'user' && item.blocks[0]?.type === 'text' + ? [item.blocks[0].text] + : [], + ); +} + +function userCursors(store: ReturnType): Array { + const { items } = store.getSnapshot(); + return items.flatMap((item) => + item.kind === 'message' && item.role === 'user' ? [item.branchCursor] : [], + ); +} + function tick(): Promise { return wait(10); } async function harness() { const { client, serverTransport } = await createConnectedLocalClient(); + serverTransport.onMessage((message) => { + if (message.payload.kind === 'session.start') { + serverTransport.send( + createWireMessage({ + kind: 'session.started', + replyTo: message.payload.clientReqId, + sessionId, + }), + ); + } + }); return { client, send(this: void, event: AgentEvent) { serverTransport.send(createWireMessage({ kind: 'agent.event', sessionId, event })); }, + /** Round-trip a real session.start so the client records fresh-run creation provenance. */ + async createSession(this: void) { + await client.startSession({ kind: 'claude-code', cwd: '/workspace' }); + }, close(this: void) { client.dispose(); serverTransport.close(); @@ -33,6 +75,25 @@ async function harness() { }; } +async function attachedHarness() { + const [clientTransport, hubTransport] = createLocalTransportPair(); + await hubTransport.connect(); + const hub = new Hub(); + hub.addConnection(hubTransport); + const client = new LinkCodeClient(clientTransport); + await client.connect(); + await client.setSubscriptionMode('attached'); + return { + client, + hub, + close(this: void) { + client.dispose(); + hub.removeConnection(hubTransport); + hubTransport.close(); + }, + }; +} + describe('createConversationStore', () => { it('returns a stable empty conversation without a session', async () => { const { client, close } = await harness(); @@ -73,7 +134,9 @@ describe('createConversationStore', () => { close(); }); - it('enriches a lossy seeded prompt with its live image instead of appending a duplicate', async () => { + // A lossy provider row can never exact-match an attachment echo: the echo renders as its own + // message — attachments stay on it, the row keeps its cursor — instead of enriching the row. + it('renders a lossy seeded prompt and its attachment echo separately', async () => { const { client, send, close } = await harness(); const livePrompt: AgentEvent = { type: 'user-message', @@ -110,14 +173,20 @@ describe('createConversationStore', () => { }); const messages = store.getSnapshot().items.filter((item) => item.kind === 'message'); - expect(messages).toHaveLength(2); + expect(messages).toHaveLength(3); expect(messages[0]).toMatchObject({ id: 'provider-prompt', role: 'user', - blocks: livePrompt.content, + blocks: [{ type: 'text', text: 'describe this image' }], branchCursor: 'provider-cursor', }); expect(messages[1]).toMatchObject({ id: 'reply', role: 'assistant' }); + expect(messages[2]).toMatchObject({ + id: 'host-prompt', + role: 'user', + blocks: livePrompt.content, + branchCursor: 'live-cursor', + }); close(); }); @@ -310,4 +379,340 @@ describe('createConversationStore', () => { expect(store.getSnapshot().items).toHaveLength(1); close(); }); + + it('keeps prompt order when a reseed covers a same-id double echo', async () => { + const { client, send, createSession, close } = await harness(); + await createSession(); + send(userText('old prompt', 'host-m1')); + send(userText('old prompt', 'host-m1', 'live-cursor-old')); + send(agentText('reply', 'provider-reply')); + send(userText('new prompt', 'host-m2', 'live-cursor-new')); + await tick(); + + const store = createConversationStore(client, sessionId, { + events: [ + { event: userText('old prompt', 'provider-u1') }, + { event: agentText('reply', 'provider-reply') }, + { event: userText('new prompt', 'provider-u2', 'provider-cursor-new') }, + ], + uptoSeq: 4, + }); + expect(userTexts(store)).toEqual(['old prompt', 'new prompt']); + expect(userCursors(store)).toEqual(['live-cursor-old', 'provider-cursor-new']); + close(); + }); + + it('keeps a single covered prompt in place across a reseed', async () => { + const { client, send, createSession, close } = await harness(); + await createSession(); + send(userText('first prompt', 'host-m1')); + send(userText('first prompt', 'host-m1', 'live-cursor')); + send(agentText('reply', 'provider-reply')); + await tick(); + + const store = createConversationStore(client, sessionId, { + events: [ + { event: userText('first prompt', 'provider-u1'), ts: 1_700_000_000_000 }, + { event: agentText('reply', 'provider-reply') }, + ], + uptoSeq: 3, + }); + expect(userTexts(store)).toEqual(['first prompt']); + expect(userCursors(store)).toEqual(['live-cursor']); + // The cursor fill must not disturb the provider timestamp or duplicate the item. + expect(store.getSnapshot().items[0].receivedAt).toBe(1_700_000_000_000); + expect(store.getSnapshot().items).toHaveLength(2); + close(); + }); + + it('folds a covered prompt re-echo into the seed row even when it lands past the cut', async () => { + const { client, send, createSession, close } = await harness(); + await createSession(); + send(userText('first prompt', 'host-m1')); + await tick(); + + const store = createConversationStore(client, sessionId, { + events: [{ event: userText('first prompt', 'provider-u1') }], + uptoSeq: 1, + }); + expect(userTexts(store)).toEqual(['first prompt']); + send(userText('first prompt', 'host-m1', 'live-cursor')); + await tick(); + expect(userTexts(store)).toEqual(['first prompt']); + expect(userCursors(store)).toEqual(['live-cursor']); + close(); + }); + + it('keeps the seed row cursor when the consuming echo carries none', async () => { + const { client, send, close } = await harness(); + send(userText('first prompt', 'host-m1')); + await tick(); + + const store = createConversationStore(client, sessionId, { + events: [{ event: userText('first prompt', 'provider-u1', 'provider-cursor') }], + uptoSeq: 1, + }); + expect(userCursors(store)).toEqual(['provider-cursor']); + close(); + }); + + it('never lets an echo cursor displace a provider cursor', async () => { + const { client, send, close } = await harness(); + send(userText('first prompt', 'host-m1', 'live-cursor')); + await tick(); + + const store = createConversationStore(client, sessionId, { + events: [{ event: userText('first prompt', 'provider-u1', 'provider-cursor') }], + uptoSeq: 1, + }); + expect(userCursors(store)).toEqual(['provider-cursor']); + close(); + }); + + it('keeps provider cursors in place when a repeated prompt mis-binds', async () => { + const { client, send, close } = await harness(); + send(userText('repeat', 'host-m2', 'live-cursor')); + await tick(); + + const store = createConversationStore(client, sessionId, { + events: [ + { event: userText('repeat', 'provider-u1', 'provider-cursor-1') }, + { event: userText('repeat', 'provider-u2', 'provider-cursor-2') }, + ], + uptoSeq: 1, + }); + expect(userTexts(store)).toEqual(['repeat', 'repeat']); + expect(userCursors(store)).toEqual(['provider-cursor-1', 'provider-cursor-2']); + close(); + }); + + it('never fills a cursor-less row through an ambiguous bind', async () => { + const { client, send, close } = await harness(); + send(userText('repeat', 'host-m2', 'live-cursor')); + await tick(); + + const store = createConversationStore(client, sessionId, { + events: [ + { event: userText('repeat', 'provider-u1') }, + { event: userText('repeat', 'provider-u2') }, + ], + uptoSeq: 1, + }); + expect(userTexts(store)).toEqual(['repeat', 'repeat']); + expect(userCursors(store)).toEqual([undefined, undefined]); + close(); + }); + + it('does not aim a rewind through an ambiguous bind', async () => { + const { client, send, close } = await harness(); + send(userText('repeat', 'host-m2', 'live-cursor')); + await tick(); + + const store = createConversationStore(client, sessionId, { + events: [ + { event: userText('repeat', 'provider-u1') }, + { event: agentText('reply one', 'provider-r1') }, + { event: userText('repeat', 'provider-u2') }, + { event: agentText('reply two', 'provider-r2') }, + ], + uptoSeq: 1, + }); + expect(userTexts(store)).toEqual(['repeat', 'repeat']); + + send({ type: 'conversation-rewind', messageId: 'host-m2' as MessageId }); + send(userText('replacement prompt', 'host-m3')); + await tick(); + expect(userTexts(store)).toEqual(['repeat', 'repeat', 'replacement prompt']); + const { items } = store.getSnapshot(); + const agentTexts = items.flatMap((item) => + item.kind === 'message' && item.role === 'assistant' && item.blocks[0]?.type === 'text' + ? [item.blocks[0].text] + : [], + ); + expect(agentTexts).toEqual(['reply one', 'reply two']); + close(); + }); + + it('translates a rewind citing the host id of the trusted first prompt', async () => { + const { client, send, createSession, close } = await harness(); + await createSession(); + send(userText('old prompt', 'host-m1')); + send(agentText('reply', 'provider-reply')); + await tick(); + + const store = createConversationStore(client, sessionId, { + events: [ + { event: userText('old prompt', 'provider-u1') }, + { event: agentText('reply', 'provider-reply') }, + ], + uptoSeq: 2, + }); + expect(userTexts(store)).toEqual(['old prompt']); + + // A live-only co-viewer rewrites the prompt it knows as host-m1; this store folded it as + // provider-u1, so the trusted alias translates the rewind to cut the seeded entry. + send({ type: 'conversation-rewind', messageId: 'host-m1' as MessageId }); + send(userText('rewritten prompt', 'host-m2')); + await tick(); + expect(userTexts(store)).toEqual(['rewritten prompt']); + close(); + }); + + // Branch/rewrite production ordering: the replacement echoes bare before the new run's ref, + // but the preceding rewind wiped the buffer — retained history forbids trusting the bind. + it('does not trust a bare echo that follows a rewind', async () => { + const { client, send, createSession, close } = await harness(); + await createSession(); + send(userText('repeat', 'host-m0')); + await tick(); + send({ type: 'conversation-rewind', messageId: 'host-m0' as MessageId }); + send(userText('repeat', 'host-m1')); + await tick(); + + const store = createConversationStore(client, sessionId, { + events: [ + { event: userText('repeat', 'provider-u1') }, + { event: agentText('reply one', 'provider-r1') }, + ], + uptoSeq: 3, + }); + expect(userTexts(store)).toEqual(['repeat']); + send(userText('repeat', 'host-m1', 'live-cursor')); + await tick(); + expect(userCursors(store)).toEqual([undefined]); + + send({ type: 'conversation-rewind', messageId: 'host-m1' as MessageId }); + send(userText('replacement prompt', 'host-m2')); + await tick(); + expect(userTexts(store)).toEqual(['repeat', 'replacement prompt']); + close(); + }); + + // A reconnected client sees seqs restart at 1 and record origin survives resumes — neither is + // run provenance. Without same-client creation, a resume-window bare echo must not be trusted. + it('does not trust a bare echo on a session this client did not create', async () => { + const { client, send, close } = await harness(); + send(userText('first prompt', 'host-m1')); + await tick(); + + const store = createConversationStore(client, sessionId, { + events: [{ event: userText('first prompt', 'provider-u1') }], + uptoSeq: 1, + }); + expect(userTexts(store)).toEqual(['first prompt']); + send(userText('first prompt', 'host-m1', 'live-cursor')); + await tick(); + expect(userCursors(store)).toEqual([undefined]); + send({ type: 'conversation-rewind', messageId: 'host-m1' as MessageId }); + send(userText('replacement prompt', 'host-m2')); + await tick(); + expect(userTexts(store)).toEqual(['first prompt', 'replacement prompt']); + close(); + }); + + it('does not trust creation provenance before the first attached delivery', async () => { + const { client, hub, close } = await attachedHarness(); + const broadcast = (event: AgentEvent): void => { + hub.send(createWireMessage({ kind: 'agent.event', sessionId, event })); + }; + hub.onMessage(({ payload }) => { + if (payload.kind !== 'session.start' && payload.kind !== 'session.resume') return; + if (payload.kind === 'session.resume') { + broadcast({ type: 'status', status: 'starting' }); + broadcast({ type: 'status', status: 'idle' }); + } + hub.send( + createWireMessage({ + kind: 'session.started', + replyTo: payload.clientReqId, + sessionId, + }), + ); + }); + + await client.startSession({ kind: 'claude-code', cwd: '/workspace' }); + // Another client can create provider history before this attached-mode client observes the id. + broadcast(userText('repeat', 'missed-old')); + broadcast(agentText('valid old reply', 'provider-reply')); + broadcast({ type: 'status', status: 'stopped' }); + await tick(); + expect(client.eventSeq(sessionId)).toBe(0); + + client.attachSession(sessionId); + await tick(); + await client.resumeSession(sessionId); + expect(client.eventsSnapshot(sessionId)[0]?.seq).toBe(1); + // Claude can echo before its detached consume loop reports the resumed session-ref. + broadcast(userText('repeat', 'host-new')); + await tick(); + + const store = createConversationStore(client, sessionId, { + events: [ + { event: userText('repeat', 'provider-old') }, + { event: agentText('valid old reply', 'provider-reply') }, + ], + uptoSeq: client.eventSeq(sessionId), + }); + expect(userTexts(store)).toEqual(['repeat']); + + broadcast(userText('repeat', 'host-new', 'new-run-cursor')); + await tick(); + expect(userCursors(store)).toEqual([undefined]); + + broadcast({ type: 'conversation-rewind', messageId: 'host-new' as MessageId }); + broadcast(userText('replacement', 'host-replacement')); + await tick(); + expect(userTexts(store)).toEqual(['repeat', 'replacement']); + close(); + }); + + it('does not trust a bare echo bound past the first user row', async () => { + const { client, send, createSession, close } = await harness(); + await createSession(); + send(userText('second prompt', 'host-m2')); + await tick(); + + const store = createConversationStore(client, sessionId, { + events: [ + { event: userText('first prompt', 'provider-u1') }, + { event: userText('second prompt', 'provider-u2') }, + ], + uptoSeq: 1, + }); + send(userText('second prompt', 'host-m2', 'live-cursor')); + await tick(); + expect(userCursors(store)).toEqual([undefined, undefined]); + close(); + }); + + it('folds a same-id re-echo in place when no seed covers it', async () => { + const { client, send, close } = await harness(); + send(userText('first prompt', 'host-m1')); + send(userText('first prompt', 'host-m1', 'live-cursor')); + send(agentText('reply', 'provider-reply')); + await tick(); + + const store = createConversationStore(client, sessionId); + const items = store.getSnapshot().items; + const user = items.find((i) => i.kind === 'message' && i.role === 'user'); + expect(user?.kind === 'message' ? user.branchCursor : undefined).toBe('live-cursor'); + expect(items.filter((i) => i.kind === 'message' && i.role === 'user')).toHaveLength(1); + expect(items).toHaveLength(2); + close(); + }); + + it('folds a cursor-bearing re-echo of a prompt the snapshot never flushed', async () => { + const { client, send, close } = await harness(); + send(userText('first prompt', 'host-m1')); + await tick(); + + const store = createConversationStore(client, sessionId, { events: [], uptoSeq: 1 }); + expect(userTexts(store)).toEqual(['first prompt']); + send(userText('first prompt', 'host-m1', 'live-cursor')); + await tick(); + expect(userTexts(store)).toEqual(['first prompt']); + const user = store.getSnapshot().items.find((i) => i.kind === 'message' && i.role === 'user'); + expect(user?.kind === 'message' ? user.branchCursor : undefined).toBe('live-cursor'); + close(); + }); }); diff --git a/packages/foundation/transport/src/transport.ts b/packages/foundation/transport/src/transport.ts index 552e14395..a01672018 100644 --- a/packages/foundation/transport/src/transport.ts +++ b/packages/foundation/transport/src/transport.ts @@ -12,6 +12,8 @@ export type Unsubscribe = () => void; * WireMessage, and upper layers never know whether the connection is local or a tunnel. */ export interface Transport { + /** True when carrier recovery can replace the physical connection without emitting `onClose`. */ + readonly reconnectsTransparently?: boolean; connect(): Promise; /** Send a wire message. The {@link ValidatedWireMessage} brand is the proof of validity; no parse runs on the send path. */ send(msg: ValidatedWireMessage): void | Promise; diff --git a/packages/foundation/transport/src/ws.ts b/packages/foundation/transport/src/ws.ts index a54c2de8b..165eba5e8 100644 --- a/packages/foundation/transport/src/ws.ts +++ b/packages/foundation/transport/src/ws.ts @@ -63,6 +63,10 @@ export class WsTransport extends WireConnection { this.armClosedListener(); } + get reconnectsTransparently(): boolean { + return this.reconnectOpts !== null && !(this.attempt >= this.reconnectOpts.maxRetries); + } + override connect(): Promise { this.disposed = false; this.established = false;