{
function collectEvents(client: LinkCodeClient, sessionId: SessionId): AgentEvent[] {
const events: AgentEvent[] = [];
- client.subscribe(sessionId, (event) => events.push(event));
+ client.subscribe(sessionId, ({ event }) => events.push(event));
return events;
}
diff --git a/packages/foundation/schema/src/model/conversation.ts b/packages/foundation/schema/src/model/conversation.ts
index e3f2ed3f1..943027223 100644
--- a/packages/foundation/schema/src/model/conversation.ts
+++ b/packages/foundation/schema/src/model/conversation.ts
@@ -1,6 +1,8 @@
import { z } from 'zod';
+import type { MessageId, TurnId } from './primitives';
import {
AttachmentIdSchema,
+ MessageIdSchema,
OperationIdSchema,
PromptIdSchema,
RunIdSchema,
@@ -9,6 +11,12 @@ import {
TurnIdSchema,
} from './primitives';
+/** The one identity of a turn's user row: the daemon mints it for the live echo and for every
+ * `conversation.read`, and clients join graph turns to rows through it. */
+export function userRowMessageId(turnId: TurnId): MessageId {
+ return MessageIdSchema.parse(`msg-${turnId}`);
+}
+
/**
* The conversation turn tree: host-authoritative turn identity and durable user prompts.
* Every turn has a parent (null = child of the session root); siblings under one parent are the
diff --git a/packages/foundation/schema/src/wire/conversation.ts b/packages/foundation/schema/src/wire/conversation.ts
index a33430160..053173186 100644
--- a/packages/foundation/schema/src/wire/conversation.ts
+++ b/packages/foundation/schema/src/wire/conversation.ts
@@ -14,6 +14,10 @@ import {
} from '../model/primitives';
import { WireRequestIdSchema } from './request';
+/** The wire version that introduced the turn graph: `conversation.read`/`graph.get`, `turn.submit`,
+ * and the `(epoch, seq)` stamps on `agent.event`. Clients feature-detect the merge path on it. */
+export const CONVERSATION_GRAPH_WIRE_VERSION = 80 as const;
+
/** What a submit carries over the wire. Prompt content travels as blocks — the daemon mints the
* durable `PromptRecord` (and its id) when it persists the turn intent. */
export const TurnSubmitInputSchema = z.discriminatedUnion('type', [
diff --git a/packages/foundation/schema/src/wire/index.ts b/packages/foundation/schema/src/wire/index.ts
index fddcdcd2a..784aaa437 100644
--- a/packages/foundation/schema/src/wire/index.ts
+++ b/packages/foundation/schema/src/wire/index.ts
@@ -1,4 +1,5 @@
export {
+ CONVERSATION_GRAPH_WIRE_VERSION,
type ConversationEvent,
ConversationEventSchema,
type ConversationGraphTurn,
diff --git a/packages/host/engine/src/__tests__/engine-conversation-read.test.ts b/packages/host/engine/src/__tests__/engine-conversation-read.test.ts
index b7a5aabbd..ab7818425 100644
--- a/packages/host/engine/src/__tests__/engine-conversation-read.test.ts
+++ b/packages/host/engine/src/__tests__/engine-conversation-read.test.ts
@@ -18,6 +18,7 @@ import {
import { nullthrow } from 'foxts/guard';
import { describe, expect, it } from 'vitest';
import { InMemoryConversationStore } from '../conversation/conversation-store';
+import { decodeLiveBranchCursor } from '../session/live-session';
import {
FakeAdapter,
createSessionHarness as harness,
@@ -176,6 +177,38 @@ describe('conversation.graph.get', () => {
});
describe('conversation.read', () => {
+ it('mints user rows with the live echo’s identity and edit cursor', async () => {
+ const shared: SharedHistory = { events: [], failRead: false };
+ const h = await startedHarness(() => new HistoryFakeAdapter(shared));
+ h.adapter.emit({ type: 'session-ref', historyId: HISTORY_ID });
+ shared.events = [userRow('u1', 'hello one'), assistantRow('a1', 'answer one')];
+ await completeTurn(h, 's1', 'hello one');
+ const echo = h.sent.find(
+ (payload) => payload.kind === 'agent.event' && payload.event.type === 'user-message',
+ );
+ if (echo?.kind !== 'agent.event' || echo.event.type !== 'user-message') {
+ throw new Error('no live prompt echo');
+ }
+
+ await h.inject({ kind: 'conversation.read', clientReqId: 'rr', sessionId: h.sessionId });
+
+ const row = readResult(h.sent, 'rr').events.find(
+ (item) => 'event' in item && item.event.type === 'user-message',
+ );
+ if (row === undefined || !('event' in row) || row.event.type !== 'user-message') {
+ throw new Error('no user row');
+ }
+ // One identity per turn across the live view and the read: nothing to reconcile client-side.
+ expect(row.event.messageId).toBe(echo.event.messageId);
+ // Read rows stay editable through the legacy path exactly like the echo they replace.
+ expect(row.event.branchCursor).toBe(echo.event.branchCursor);
+ expect(decodeLiveBranchCursor(nullthrow(row.event.branchCursor))).toEqual({
+ type: 'live',
+ historyId: HISTORY_ID,
+ turnId: row.turnId,
+ });
+ });
+
it('renders prompts and placeholders when the harness has no history', async () => {
const h = await startedHarness();
await completeTurn(h, 's1', 'hello one');
diff --git a/packages/host/engine/src/__tests__/engine-schedule.test.ts b/packages/host/engine/src/__tests__/engine-schedule.test.ts
index c388c649f..b56b341f7 100644
--- a/packages/host/engine/src/__tests__/engine-schedule.test.ts
+++ b/packages/host/engine/src/__tests__/engine-schedule.test.ts
@@ -232,6 +232,18 @@ describe('engine schedule wiring', () => {
graphRevision: 1,
activeLeafTurnId: turn.turnId,
});
+ expect(
+ h.sent.filter(
+ (payload) => payload.kind === 'agent.event' && payload.event.type === 'user-message',
+ ),
+ ).toEqual([
+ expect.objectContaining({
+ event: expect.objectContaining({
+ type: 'user-message',
+ messageId: `msg-${turn.turnId}`,
+ }),
+ }),
+ ]);
});
it('reports an unknown schedule as not found', async () => {
diff --git a/packages/host/engine/src/__tests__/engine-turn-submit.test.ts b/packages/host/engine/src/__tests__/engine-turn-submit.test.ts
index 100e2bf82..02a6e05f1 100644
--- a/packages/host/engine/src/__tests__/engine-turn-submit.test.ts
+++ b/packages/host/engine/src/__tests__/engine-turn-submit.test.ts
@@ -345,11 +345,16 @@ describe('turn.submit saga', () => {
activeLeafTurnId: turnId,
}),
);
+ // The echo carries the durable row's identity, so a later conversation.read converges on it.
expect(
- h.sent.some(
+ h.sent.filter(
(payload) => payload.kind === 'agent.event' && payload.event.type === 'user-message',
),
- ).toBe(true);
+ ).toEqual([
+ expect.objectContaining({
+ event: expect.objectContaining({ type: 'user-message', messageId: `msg-${turnId}` }),
+ }),
+ ]);
});
it('replays a lost reply verbatim instead of duplicating a sibling', async () => {
diff --git a/packages/host/engine/src/conversation/projection-service.ts b/packages/host/engine/src/conversation/projection-service.ts
index 2dd8b9004..61bad2a10 100644
--- a/packages/host/engine/src/conversation/projection-service.ts
+++ b/packages/host/engine/src/conversation/projection-service.ts
@@ -3,11 +3,13 @@ import { boundedLimit } from '@linkcode/agent-adapter';
import type {
AgentEvent,
AgentHistoryEvent,
+ AgentHistoryId,
ContentBlock,
ConversationGraphTurn,
ConversationReadItem,
ConversationTurn,
ConversationWatermark,
+ RunId,
SessionId,
SessionRecord,
TurnId,
@@ -15,12 +17,13 @@ import type {
import {
compareConversationWatermarks,
MAX_ATTACHMENT_TOTAL_BASE64_LENGTH,
- MessageIdSchema,
TurnIdSchema,
+ userRowMessageId,
} from '@linkcode/schema';
import { Effect } from 'effect';
import type { OperationError } from '../failure';
import { RequestError } from '../failure';
+import { encodeLiveBranchCursor } from '../session/live-session';
import type { SessionRecordRegistry } from '../session/session-record-registry';
import type { ConversationCheckpointService } from './checkpoint-service';
import type { ProviderPartition } from './lineage-attribution';
@@ -217,7 +220,9 @@ export class ConversationProjectionService {
for (let i = 0, len = path.length; i < len; i++) {
const turn = path[i];
const content = contents[i];
- if (content !== undefined) items.push(projectedUserRow(turn, content));
+ if (content !== undefined) {
+ items.push(projectedUserRow(turn, content, runHistoryId(record, turn.runId)));
+ }
if (!TERMINAL_TURN_STATES.has(turn.state)) continue; // in-flight output rides the live tail
if (turn.state === 'failed') continue; // nothing durable ran; the state badge is the story
const partition = attributed[partitionIndex];
@@ -446,16 +451,28 @@ function projectedItem(
};
}
-function projectedUserRow(turn: ConversationTurn, content: ContentBlock[]): ConversationReadItem {
+function runHistoryId(record: SessionRecord, runId: RunId): AgentHistoryId | undefined {
+ return record.runs.find((run) => run.runId === runId)?.historyId;
+}
+
+function projectedUserRow(
+ turn: ConversationTurn,
+ content: ContentBlock[],
+ historyId: AgentHistoryId | undefined,
+): ConversationReadItem {
return {
turnId: turn.turnId,
runId: turn.runId,
ts: turn.createdAt,
event: {
type: 'user-message',
- // Deterministic identity: re-reads and page overlaps converge on one row per turn.
- messageId: MessageIdSchema.parse(`msg-${turn.turnId}`),
+ messageId: userRowMessageId(turn.turnId),
content,
+ // The cursor the live echo carries, so legacy `history.branch` can edit a row that was read
+ // rather than seen live; absent until the run's adapter reports its history.
+ ...(historyId !== undefined && {
+ branchCursor: encodeLiveBranchCursor(historyId, turn.turnId),
+ }),
},
};
}
diff --git a/packages/host/engine/src/session/live-session.ts b/packages/host/engine/src/session/live-session.ts
index c64ec5010..fe07b1f65 100644
--- a/packages/host/engine/src/session/live-session.ts
+++ b/packages/host/engine/src/session/live-session.ts
@@ -251,6 +251,6 @@ export function promptContentFingerprint(content: ContentBlock[]): string {
return createHash('sha256').update(contentToText(content)).digest('base64url');
}
-function encodeLiveBranchCursor(historyId: AgentHistoryId, turnId: TurnId): string {
+export function encodeLiveBranchCursor(historyId: AgentHistoryId, turnId: TurnId): string {
return JSON.stringify({ type: LIVE_BRANCH_CURSOR_TYPE, historyId, turnId });
}
diff --git a/packages/host/engine/src/session/orchestrator.ts b/packages/host/engine/src/session/orchestrator.ts
index 90fcc4c54..ebcf49251 100644
--- a/packages/host/engine/src/session/orchestrator.ts
+++ b/packages/host/engine/src/session/orchestrator.ts
@@ -1,5 +1,4 @@
import type { AdapterFactory, AgentAdapter, BrowserToolsetFactory } from '@linkcode/agent-adapter';
-import { nextMessageId } from '@linkcode/agent-adapter';
import type {
AgentEvent,
AgentHistoryCapabilities,
@@ -13,6 +12,7 @@ import type {
SessionInfo,
SessionRecord,
} from '@linkcode/schema';
+import { userRowMessageId } from '@linkcode/schema';
import type { Transport } from '@linkcode/transport';
import { createWireMessage } from '@linkcode/transport';
import { Cause, Deferred, Effect, Exit, Scope } from 'effect';
@@ -221,9 +221,15 @@ export class SessionOrchestrator {
input: { type: 'prompt', blocks: promptBlocksFromContent(content) },
});
const result = yield* Effect.sync(() => {
- this.events.broadcast(sessionId, session, [
- { type: 'user-message', messageId: nextMessageId(), content },
- ]);
+ this.events.broadcast(
+ sessionId,
+ session,
+ session.trackPrompt(
+ userRowMessageId(intent.turn.turnId),
+ content,
+ intent.turn.turnId,
+ ),
+ );
records.setTitleFromContent(sessionId, content);
}).pipe(
Effect.andThen(
diff --git a/packages/host/engine/src/session/session-input-dispatcher.ts b/packages/host/engine/src/session/session-input-dispatcher.ts
index 8dcf8ebd3..1075303a9 100644
--- a/packages/host/engine/src/session/session-input-dispatcher.ts
+++ b/packages/host/engine/src/session/session-input-dispatcher.ts
@@ -1,6 +1,5 @@
-import { nextMessageId } from '@linkcode/agent-adapter';
import type { AgentInput, SessionId } from '@linkcode/schema';
-import { agentCommandMatches } from '@linkcode/schema';
+import { agentCommandMatches, userRowMessageId } from '@linkcode/schema';
import { Cause, Effect, Exit } from 'effect';
import { nullthrow } from 'foxts/guard';
import type { ConversationTurnService, PersistedTurnIntent } from '../conversation/turn-service';
@@ -64,7 +63,6 @@ export class SessionInputDispatcher {
return Effect.fail(error);
}
const { events, records, resources, turns } = this;
- const promptMessageId = input.type === 'prompt' ? nextMessageId() : undefined;
// Set synchronously, before the first await, so a same-tick second turn input cannot slip
// past the gate above while this one is still validating; every failure exit releases it.
if (startsTurn) session.turnInputActive = true;
@@ -118,28 +116,34 @@ export class SessionInputDispatcher {
});
}
const persisted = intent;
+ const persistedTurnId = startsTurn
+ ? nullthrow(persisted, 'turn input without a persisted turn').turn.turnId
+ : undefined;
+ // The echo carries the durable row's identity: a client's live view and its later
+ // conversation.read converge on one row per turn instead of reconciling two ids.
+ const echoMessageId =
+ persistedTurnId === undefined ? undefined : userRowMessageId(persistedTurnId);
const dispatch = Effect.gen(function* () {
// Echo before awaiting send: provider events can outrun the dispatch acknowledgement.
- if (promptMessageId !== undefined && input.type === 'prompt') {
- const { turnId } = nullthrow(persisted, 'prompt dispatch without a persisted turn').turn;
- events.broadcast(
- sessionId,
- session,
- session.trackPrompt(promptMessageId, input.content, turnId),
- );
- records.setTitleFromContent(sessionId, input.content);
- } else if (input.type === 'command' || input.type === 'shell-command') {
- const text =
- input.type === 'command'
- ? `/${input.name}${input.arguments ? ` ${input.arguments}` : ''}`
- : `$ ${input.command}`;
- events.broadcast(sessionId, session, [
- {
- type: 'user-message',
- messageId: nextMessageId(),
- content: [{ type: 'text', text }],
- },
- ]);
+ if (persistedTurnId !== undefined && echoMessageId !== undefined) {
+ if (input.type === 'prompt') {
+ events.broadcast(
+ sessionId,
+ session,
+ session.trackPrompt(echoMessageId, input.content, persistedTurnId),
+ );
+ records.setTitleFromContent(sessionId, input.content);
+ } else if (input.type === 'command' || input.type === 'shell-command') {
+ const text =
+ input.type === 'command'
+ ? `/${input.name}${input.arguments ? ` ${input.arguments}` : ''}`
+ : `$ ${input.command}`;
+ events.broadcast(
+ sessionId,
+ session,
+ session.trackPrompt(echoMessageId, [{ type: 'text', text }], persistedTurnId),
+ );
+ }
}
const responseInput =
input.type === 'permission-response' || input.type === 'question-response'
@@ -177,7 +181,7 @@ export class SessionInputDispatcher {
session.interactions.restoreResponse(responseInput.requestId, respondingAsk),
);
}
- if (promptMessageId !== undefined) session.untrackPrompt(promptMessageId);
+ if (echoMessageId !== undefined) session.untrackPrompt(echoMessageId);
if (startsTurn) events.rejectInput(sessionId, session, error.publicMessage);
}),
),
diff --git a/packages/presentation/i18n/src/locales/en.ts b/packages/presentation/i18n/src/locales/en.ts
index 02a181740..35bba2d47 100644
--- a/packages/presentation/i18n/src/locales/en.ts
+++ b/packages/presentation/i18n/src/locales/en.ts
@@ -136,6 +136,7 @@ export const en = {
compacting: 'Compacting context…',
compacted: 'Context compacted',
compactedTokens: '{pre} → {post} tokens',
+ historyUnavailable: 'Output for this turn is unavailable',
insufficientCreditsTitle: 'LinkCode credits needed',
insufficientCreditsHint: 'Top up your balance, then retry this message.',
topUpCredits: 'Top up credits',
@@ -1392,6 +1393,7 @@ export const en = {
compacting: 'Compacting context…',
compacted: 'Context compacted',
compactedTokens: '{pre} → {post} tokens',
+ historyUnavailable: 'Output for this turn is unavailable',
},
chat: {
allowTitle: 'Allow "{title}"?',
diff --git a/packages/presentation/i18n/src/locales/zh-cn.ts b/packages/presentation/i18n/src/locales/zh-cn.ts
index 68d11d116..0f6b69285 100644
--- a/packages/presentation/i18n/src/locales/zh-cn.ts
+++ b/packages/presentation/i18n/src/locales/zh-cn.ts
@@ -132,6 +132,7 @@ export const zhCN = {
compacting: '正在压缩上下文…',
compacted: '上下文已压缩',
compactedTokens: '{pre} → {post} tokens',
+ historyUnavailable: '这一轮的输出已不可用',
insufficientCreditsTitle: '需要 LinkCode 额度',
insufficientCreditsHint: '充值后即可安全重试这条消息。',
topUpCredits: '充值额度',
@@ -1348,6 +1349,7 @@ export const zhCN = {
compacting: '正在压缩上下文…',
compacted: '上下文已压缩',
compactedTokens: '{pre} → {post} tokens',
+ historyUnavailable: '这一轮的输出已不可用',
},
chat: {
allowTitle: '允许「{title}」?',
diff --git a/packages/presentation/ui/src/__tests__/activity-groups.test.ts b/packages/presentation/ui/src/__tests__/activity-groups.test.ts
index 0f5c9b3f2..075a7412f 100644
--- a/packages/presentation/ui/src/__tests__/activity-groups.test.ts
+++ b/packages/presentation/ui/src/__tests__/activity-groups.test.ts
@@ -60,12 +60,14 @@ function approvalFor(toolCallId: string): ConversationItem {
}
function boundary(
- kind: 'plan' | 'approval' | 'question' | 'error' | 'compaction',
+ kind: 'plan' | 'approval' | 'question' | 'error' | 'compaction' | 'history-unavailable',
): ConversationItem {
const id = `boundary-${seq++}`;
switch (kind) {
case 'plan':
return { kind, id, turnId: 'turn-0', plan: { planId: id, entries: [] } };
+ case 'history-unavailable':
+ return { kind, id, turnId: 'turn-0' };
case 'approval':
return approvalFor(`unrelated-${id}`);
case 'question':
diff --git a/packages/presentation/ui/src/chat/history-unavailable-marker.tsx b/packages/presentation/ui/src/chat/history-unavailable-marker.tsx
new file mode 100644
index 000000000..711c3edba
--- /dev/null
+++ b/packages/presentation/ui/src/chat/history-unavailable-marker.tsx
@@ -0,0 +1,23 @@
+import { HistoryIcon } from 'lucide-react';
+import { useTranslations } from 'use-intl';
+import {
+ CHAT_DISCLOSURE_TEXT_CLASS_NAME,
+ CHAT_DISCLOSURE_TITLE_CLASS_NAME,
+ ChatDisclosureIconSlot,
+} from './disclosure-header';
+
+/** Stands where a turn's provider output would render when the daemon could not project it: the
+ * prompt-only fallback for a lost, compacted, or never-recorded transcript. */
+export function HistoryUnavailableMarker(): React.ReactNode {
+ const t = useTranslations('workbench.conversation');
+ return (
+
+
+
+
+
+ {t('historyUnavailable')}
+
+
+ );
+}
diff --git a/packages/presentation/ui/src/chat/turn-segment-view.tsx b/packages/presentation/ui/src/chat/turn-segment-view.tsx
index cfdc36b66..f5dc76ef2 100644
--- a/packages/presentation/ui/src/chat/turn-segment-view.tsx
+++ b/packages/presentation/ui/src/chat/turn-segment-view.tsx
@@ -10,6 +10,7 @@ import type { QuestionConversationItem } from './conversation-prompts';
import { declinedToolCall } from './conversation-prompts';
import { assistantTurnText, latestReceivedAt, turnModel } from './conversation-text';
import { ErrorMessage } from './error-message';
+import { HistoryUnavailableMarker } from './history-unavailable-marker';
import { Message, MessageContent } from './message';
import { QuestionCallItem } from './question-call-item';
import { SubagentCard } from './subagent-card';
@@ -193,6 +194,8 @@ export function TurnSegmentView({
summary={item.summary}
/>
);
+ case 'history-unavailable':
+ return ;
case 'approval':
// Accepted / pending asks leave no receipt — the tool row (or the dock card) is the
// record. A decline only materializes here when the agent never snapshotted the call.
diff --git a/packages/presentation/ui/src/chat/types.ts b/packages/presentation/ui/src/chat/types.ts
index 811d2719e..62b81bbb2 100644
--- a/packages/presentation/ui/src/chat/types.ts
+++ b/packages/presentation/ui/src/chat/types.ts
@@ -71,6 +71,9 @@ export type ConversationItem =
postTokens?: number;
summary?: string;
})
+ /** The daemon could not project this turn's provider output (no-history harness, lost or
+ * compacted transcript, migrated turn): the host prompt row is all there is. */
+ | (ConversationItemBase & { kind: 'history-unavailable' })
| (ConversationItemBase & {
kind: 'plan';
/** Turn that most recently emitted this stable plan identity. */