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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion packages/client/core/src/__tests__/connection.test.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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);
Expand Down
32 changes: 30 additions & 2 deletions packages/client/core/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Set<SimulatorFrameCb>>();
private readonly connectionCloseSubs = new Set<ConnectionCloseCb>();
/** Sessions whose initial fresh run this client has observed without a delivery gap. */
private readonly freshSessionIds = new Set<SessionId>();
private subscriptionMode: SessionSubscriptionMode = 'all';
private subscriptionEpoch = 0;
private unsub: Unsubscribe | null = null;
private offClose: Unsubscribe | null = null;
private state: ConnectionState = 'idle';
Expand All @@ -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);
Expand Down Expand Up @@ -665,7 +674,18 @@ export class LinkCodeClient {
}

startSessionWithWarnings(opts: StartOptions): Promise<SessionStartResult> {
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<AgentStartCatalog> {
Expand Down Expand Up @@ -788,7 +808,15 @@ export class LinkCodeClient {

/** See {@link ControlChannel.setSubscriptionMode}. */
setSubscriptionMode(mode: SessionSubscriptionMode): Promise<RequestAck> {
return this.control.setSubscriptionMode(mode);
if (mode === 'attached') {
this.subscriptionMode = mode;
this.subscriptionEpoch += 1;
this.freshSessionIds.clear();
Comment on lines 810 to +814
}
return this.control.setSubscriptionMode(mode).then((ack) => {
this.subscriptionMode = mode;
return ack;
});
}

setModel(sessionId: SessionId, model: string, accountId?: string): Promise<RequestAck> {
Expand Down
158 changes: 98 additions & 60 deletions packages/client/core/src/conversation-store.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -30,79 +30,60 @@ const EMPTY_CONVERSATION: Conversation = {
};

type UserMessageEvent = Extract<AgentEvent, { type: 'user-message' }>;
interface SeedUserMessageQueue {
messages: UserMessageEvent[];
nextIndex: number;
interface SeedPromptQueue {
rows: UserMessageEvent[];
next: number;
}

function takeSeedUserMessage(
messagesByContent: Map<string, SeedUserMessageQueue>,
function takeSeedPrompt(
rowsByContent: Map<string, SeedPromptQueue>,
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<string>,
seedToolIds: ReadonlySet<string>,
seedUserMessages: Map<string, SeedUserMessageQueue>,
): 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.
*/
Expand All @@ -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<string>();
const seedToolIds = new Set<string>();
const seedUserMessages = new Map<string, SeedUserMessageQueue>();
/** Content key → seed user rows in transcript order plus the next unconsumed index. */
const seedPromptRows = new Map<string, SeedPromptQueue>();
/** 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<MessageId, { row: UserMessageEvent; trusted: boolean }>();
/** 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) {
Expand All @@ -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':
Expand All @@ -152,19 +140,69 @@ 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Dropping the image-stripped fallback means every attachment prompt renders twice after a reseed — and this is structural, not a race.

textHistoryEvent at packages/host/agent-adapter/src/history-util.ts:84 emits content: [textBlock(text)], and every adapter (claude-code, codex, pi, opencode) routes its history user rows through it. So a provider history user row is text-only by construction. An echo carrying an image block can therefore never exact-match its seed row via takeSeedPrompt's JSON.stringify key, and always falls through to this line as its own live item — landing after the agent reply that answered it, with its own Edit button (packages/presentation/ui/src/chat/user-message.tsx:66 gates canEdit on branchCursor !== undefined).

The PR's own test encodes this outcome: the expected message count went from 2 to 3.

Technical details

Required outcome: an attachment prompt appears exactly once in the transcript after a reseed, in its original position, with its image intact.

Why the old fallback wasn't the bug: the pre-PR code already kept the seed row's own branchCursor on a stripped match — it never let the echo's cursor overwrite the row's. The cursor-corruption vector this PR is fixing was the unconditional exact-content bind granting shared identity, not the stripped-content enrichment. Those are separable.

Suggested approach: restore the stripped-content match as a dedupe-only bind (trusted: false), so the echo consumes its row and is not re-folded, but gains none of the trusted powers. Optionally require the stripped match to be unambiguous (exactly one candidate row) so it can't mis-bind across repeated text.

};

const sync = (): void => {
if (!seeded) {
seeded = true;
if (seed) for (const entry of seed.events) builder.advance(entry.event, entry.ts);
}
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
Expand Down
4 changes: 3 additions & 1 deletion packages/client/core/src/conversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This forward scan is a real fix on a destructive code path, but it ships with zero coverage — I reverted it to the backward scan (findLastIndex-style) and all 110 packages/client/core tests still pass. Nothing in the suite distinguishes the two directions.

Repro that does distinguish them

No seed. Feed the buffer:

  1. user-message host-m1, text 'old prompt'
  2. re-echo host-m1 carrying a branchCursor (the in-place update that appends the second entries row with the same id)
  3. agent-message agent-r1
  4. conversation-rewind citing host-m1
  5. user-message host-m2, text 'rewritten prompt'

Forward scan → ['rewritten prompt']. Backward scan → ['old prompt', 'rewritten prompt'] — the rewound prompt survives the cut and the transcript shows both.

Given this governs a destructive truncation, I'd want that case pinned before merge — otherwise the next person refactoring the scan has no signal.

const candidate = entries[index].event;
if (candidate.type === 'user-message' && candidate.messageId === event.messageId) {
cut = index;
Expand Down
Loading
Loading