@inflexa-ai/harness 0.15.1 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -37,6 +37,22 @@ export interface TextDeltaEvent {
37
37
  * admit the impossible "not an error, but denied".
38
38
  */
39
39
  export type ToolOutcome = "ok" | "error" | "denied";
40
+ /**
41
+ * How a RECORDED call ended — the three live outcomes plus `incomplete`.
42
+ *
43
+ * `incomplete` is the state a live event cannot carry and a record cannot avoid:
44
+ * the harness observed a dispatch and no completion, because the turn was cut off
45
+ * mid-call. A `tool-finished` is never incomplete, which is why this widens
46
+ * {@link ToolOutcome} rather than replacing it.
47
+ *
48
+ * It is a fourth value of ONE field rather than a second field beside the outcome,
49
+ * for the same reason the outcome is three-way instead of `isError` plus `denied`:
50
+ * a call has exactly one terminal state, and splitting that across two fields both
51
+ * admits impossible pairs and leaves every consumer to invent the meaning of the
52
+ * combination. A consumer switches on this one field, and a consumer that forgets
53
+ * a case fails to compile rather than silently reporting a success.
54
+ */
55
+ export type ToolCallOutcome = ToolOutcome | "incomplete";
40
56
  /**
41
57
  * One line naming what a tool call is doing, produced by the tool's own
42
58
  * `describeCall` hook and normalized at the emit site.
@@ -6,7 +6,7 @@
6
6
  * discriminant is `type`, so `switch (part.type) { case "data-report-preview": ... }`
7
7
  * narrows the union member without any `as` cast.
8
8
  */
9
- import type { ToolCallDetail, ToolOutcome } from "./chat-events.js";
9
+ import type { ToolCallDetail, ToolCallOutcome } from "./chat-events.js";
10
10
  import type { CortexChatPart } from "./chat-parts.js";
11
11
  import type { TokenUsageRollup } from "./usage.js";
12
12
  /** A plain assistant/user text run. */
@@ -17,20 +17,25 @@ export interface TextPart {
17
17
  /**
18
18
  * A tool call observed during the turn (or replayed from history).
19
19
  *
20
- * Live frames collapse a `tool-started`+`tool-finished` pair into a single
21
- * part keyed by `toolCallId`. History-replayed calls always arrive with
22
- * `status: "finished"`.
20
+ * Live frames collapse a `tool-started`+`tool-finished` pair into a single part
21
+ * keyed by `toolCallId`.
22
+ *
23
+ * `outcome` carries the call's whole terminal state in one field, and its absence
24
+ * means exactly one thing: the call is still in flight. A REPLAYED call therefore
25
+ * always has one — a turn cut off mid-call replays as `incomplete`, not as an
26
+ * absent outcome a reader would have to interpret. That is what lets a consumer
27
+ * switch on this single field and be told by the compiler when it misses a case,
28
+ * rather than each host inventing what "no outcome" means and disagreeing.
23
29
  */
24
30
  export interface ToolCallPart {
25
31
  type: "tool-call";
26
32
  toolCallId: string;
27
33
  toolName: string;
28
- status: "started" | "finished";
29
34
  /**
30
- * How the call ended. Absent while `status` is `"started"` — the outcome is
31
- * not known yet. See {@link ToolOutcome}.
35
+ * How the call ended. Absent ONLY while the call is in flight on a live
36
+ * surface; every replayed call carries one. See {@link ToolCallOutcome}.
32
37
  */
33
- outcome?: ToolOutcome;
38
+ outcome?: ToolCallOutcome;
34
39
  /** See {@link ToolCallDetail}. Absent when no detail was produced. */
35
40
  detail?: ToolCallDetail;
36
41
  }
@@ -20,6 +20,13 @@ export declare const ToolOutcomeSchema: z.ZodEnum<{
20
20
  ok: "ok";
21
21
  denied: "denied";
22
22
  }>;
23
+ /** Mirrors `ToolCallOutcome` — the live three plus the record-only `incomplete`. */
24
+ export declare const ToolCallOutcomeSchema: z.ZodEnum<{
25
+ error: "error";
26
+ ok: "ok";
27
+ denied: "denied";
28
+ incomplete: "incomplete";
29
+ }>;
23
30
  export declare const ToolStartedEventSchema: z.ZodObject<{
24
31
  type: z.ZodLiteral<"tool-started">;
25
32
  toolUseId: z.ZodString;
@@ -14,6 +14,8 @@ export const TextDeltaEventSchema = z.object({
14
14
  });
15
15
  /** Mirrors the `ToolOutcome` union — three states, never a boolean. */
16
16
  export const ToolOutcomeSchema = z.enum(["ok", "error", "denied"]);
17
+ /** Mirrors `ToolCallOutcome` — the live three plus the record-only `incomplete`. */
18
+ export const ToolCallOutcomeSchema = z.enum(["ok", "error", "denied", "incomplete"]);
17
19
  export const ToolStartedEventSchema = z.object({
18
20
  type: z.literal("tool-started"),
19
21
  toolUseId: z.string(),
package/dist/index.d.ts CHANGED
@@ -57,16 +57,15 @@ export { prepareChatTurn } from "./app/chat-turn.js";
57
57
  export type { PrepareChatTurnDeps, PrepareChatTurnParams, PrepareChatTurnResult } from "./app/chat-turn.js";
58
58
  export { createThreadStore } from "./memory/thread-store.js";
59
59
  export type { ThreadStore, Thread, CreateThreadInput, ListThreadsInput, ThreadPage } from "./memory/thread-store.js";
60
- export { createThreadHistory } from "./memory/thread-history.js";
61
- export type { ThreadHistory, StoredMessage, MessagePage, RetractOutcome } from "./memory/thread-history.js";
60
+ export { createThreadHistory, conversationRecordTurn } from "./memory/thread-history.js";
61
+ export type { ThreadHistory, StoredMessage, MessagePage, RetractOutcome, ConversationTurn } from "./memory/thread-history.js";
62
62
  export { syntheticUserMessage, syntheticRecordMessage, isSyntheticUserMessage, isSyntheticRecordMessage } from "./memory/ai-sdk-message-storage.js";
63
- export { contentToCortexMessages } from "./memory/content-to-cortex.js";
64
- export type { ContentToCortexOptions } from "./memory/content-to-cortex.js";
65
- export { createCardResolver } from "./memory/reconstruct-cards.js";
66
- export type { ToolCardResolver, StoredToolCallForCard } from "./memory/reconstruct-cards.js";
67
- export { createDetailResolver } from "./tools/detail-resolver.js";
68
- export type { ToolDetailResolver } from "./tools/detail-resolver.js";
69
- export type { CortexChatEvent, TextDeltaEvent, ToolStartedEvent, ToolFinishedEvent, ToolOutcome, ToolCallDetail, FinishEvent, ChatErrorEvent, } from "./contracts/chat-events.js";
63
+ export { createConversationDisplayRecorder } from "./memory/conversation-display-recorder.js";
64
+ export type { ConversationDisplayRecorder, ConversationDisplayRecorderOptions } from "./memory/conversation-display-recorder.js";
65
+ export { envelopeDisplayMessages, parseStoredDisplayEnvelope } from "./memory/conversation-display-storage.js";
66
+ export type { ConversationDisplayMetadata, ConversationUIData, ConversationUIMessage, StoredDisplayEnvelope } from "./memory/conversation-display-storage.js";
67
+ export { storedMessagesToCortex } from "./memory/conversation-display-replay.js";
68
+ export type { CortexChatEvent, TextDeltaEvent, ToolStartedEvent, ToolFinishedEvent, ToolOutcome, ToolCallOutcome, ToolCallDetail, FinishEvent, ChatErrorEvent, } from "./contracts/chat-events.js";
70
69
  export type { TokenUsageRollup } from "./contracts/usage.js";
71
70
  export type { CortexChatPart, PresentationPart, PresentationContent, PlanPart, RunCardPart, FileReferencePart, FileReferenceEntry, RunStartedPart, DagStatePart, DagStepState, StepStatus, StepActivityPart, StepPhase, StepFileTreePart, FileTreeEntry, StepOutputPart, StepOutputFile, StepSummaryPart, StepUsagePart, StepBlockedPart, RunSynthesisPart, SynthesizedFinding, BiologicalTheme, SynthesisProgressPart, SynthesisPhase, RunCompletedPart, RunCompletedFinding, RunFailedPart, PreviewPart, DataPreviewFailedPart, } from "./contracts/chat-parts.js";
72
71
  export { PART_REGISTRY, isTransient, isReconciling, isSidebarPart } from "./contracts/part-registry.js";
package/dist/index.js CHANGED
@@ -80,15 +80,13 @@ export { toProviderError, isProviderError } from "./providers/errors.js";
80
80
  // gets live token streaming out of the otherwise non-streaming loop.
81
81
  export { createStreamingChat } from "./providers/streaming-chat.js";
82
82
  // Conversation turn + thread memory — the transport-free halves of one chat
83
- // turn a same-process host drives itself. `prepareChatTurn` assembles the
84
- // message array (thread-ownership resolution, title seed, analysis-context
85
- // injection); the caller runs `runAgent` with its own `emit`; `appendTurn`
86
- // (`createThreadHistory`) persists the turn. `createThreadStore` owns thread
87
- // metadata (create/list/title). `contentToCortexMessages` + `createCardResolver`
88
- // reconstruct display cards from a persisted thread on reload.
83
+ // turn a same-process host drives itself. `prepareChatTurn` assembles the model
84
+ // message array; `createConversationDisplayRecorder` records the independently
85
+ // replayable UI projection while forwarding live events; `appendTurn` persists
86
+ // both atomically. `createThreadStore` owns thread metadata (create/list/title).
89
87
  export { prepareChatTurn } from "./app/chat-turn.js";
90
88
  export { createThreadStore } from "./memory/thread-store.js";
91
- export { createThreadHistory } from "./memory/thread-history.js";
89
+ export { createThreadHistory, conversationRecordTurn } from "./memory/thread-history.js";
92
90
  // The synthetic-message primitives. A host appends a record of out-of-band work (an analysis run's
93
91
  // outcome) into a thread through `appendTurn`, and needs a message the model reads but that is not
94
92
  // user input — which is exactly what the marker denotes. The constructor is exported rather than
@@ -96,16 +94,19 @@ export { createThreadHistory } from "./memory/thread-history.js";
96
94
  // (`isGenuineUserStart` and its SQL twin) would then fail to recognise; `isSyntheticUserMessage`
97
95
  // lets a host renderer tell such a message apart from a genuine user turn on the way back out.
98
96
  // `syntheticRecordMessage` is the one a HOST reaches for: equally non-turn-opening, but rendered —
99
- // the display reconstruction emits it as a `system` message rather than dropping it as loop
100
- // machinery, so an appended run outcome is visible in the transcript instead of silently absent.
97
+ // it displays as a `system` message rather than being dropped as loop machinery, so an appended run
98
+ // outcome is visible in the transcript instead of silently absent. `conversationRecordTurn` wraps it
99
+ // as a complete append (model message plus its display projection), which is what a host should
100
+ // reach for: a record appended without one would be stored but never replayed.
101
101
  export { syntheticUserMessage, syntheticRecordMessage, isSyntheticUserMessage, isSyntheticRecordMessage } from "./memory/ai-sdk-message-storage.js";
102
- export { contentToCortexMessages } from "./memory/content-to-cortex.js";
103
- export { createCardResolver } from "./memory/reconstruct-cards.js";
104
- // The call-detail resolver, the reload counterpart of the `describeCall` hook a
105
- // tool declares. Built over a tool list the HOST supplies — its own composed
106
- // roster, `HarnessRuntime.conversationAgent.tools` — because tools contributed
107
- // through the host-tools seam are invisible to any map the harness holds.
108
- export { createDetailResolver } from "./tools/detail-resolver.js";
102
+ export { createConversationDisplayRecorder } from "./memory/conversation-display-recorder.js";
103
+ export { envelopeDisplayMessages, parseStoredDisplayEnvelope } from "./memory/conversation-display-storage.js";
104
+ // The transcript read. Every append persists what it displayed, so replay concatenates stored
105
+ // projections and consults nothing else — no tool names, no card builders, no workspace or database
106
+ // lookups. The migration renderer that DOES consult those (`content-to-cortex.js`,
107
+ // `reconstruct-cards.js`, `tools/detail-resolver.js`) is deliberately absent from this barrel: it
108
+ // runs once per legacy row at startup and has no runtime caller.
109
+ export { storedMessagesToCortex } from "./memory/conversation-display-replay.js";
109
110
  export { PART_REGISTRY, isTransient, isReconciling, isSidebarPart } from "./contracts/part-registry.js";
110
111
  // Embedder runtime surface — what a same-process host needs to run durable
111
112
  // workflows itself: the DBOS lifecycle, per-workflow registration + triggers,
@@ -1,26 +1,27 @@
1
1
  /**
2
- * Converter: stored AI SDK model messages → wire `CortexMessage[]`.
2
+ * The MIGRATION renderer: stored AI SDK model messages → `CortexMessage[]`.
3
3
  *
4
- * The display read (`ThreadHistory.loadPage`) returns rows whose `content`
5
- * is the AI SDK `ModelMessage` that `appendTurn` persisted. This maps each
6
- * row to a `CortexMessage` for the chat wire:
4
+ * This is how a conversation written before display projections were persisted
5
+ * is rendered ONCE, at startup, so it can be frozen into a display envelope
6
+ * (`conversation-display-backfill.ts`). It is not a runtime read path — the
7
+ * runtime reads the stored projection (`conversation-display-replay.ts`) and
8
+ * never consults a tool name, a card builder, or the filesystem.
9
+ *
10
+ * Everything it does is inference from a transcript that was never meant to
11
+ * carry display, which is why it belongs to the migration and nothing else:
7
12
  *
8
13
  * - text part → text part
9
14
  * - tool-call part → a reconstructed display card (`data-plan`,
10
15
  * `data-presentation`) when `resolveCard` recognises
11
- * the tool, else a generic tool-call part
16
+ * the tool, else a generic tool-call part whose detail
17
+ * `resolveDetail` recomputes from the persisted input
12
18
  * - everything else → dropped (reasoning, tool-result, etc.) — the UI
13
19
  * does not render them
14
20
  *
15
- * Display cards are emitted live over the chat SSE stream but never persisted
16
- * (storage holds only the AI SDK model-message transcript). `resolveCard`
17
- * rebuilds them from the persisted tool-call part so they reappear on reload,
18
- * and `resolveDetail` rebuilds each call's one-line detail the same way.
19
- *
20
21
  * A `tool-result` block is not rendered, but it is not ignored either: it is the
21
22
  * only record of how its call ended. The blocks are indexed by `toolCallId`
22
23
  * before conversion so each tool-call part can report its own outcome — without
23
- * that pass every reloaded call reported success, including the failed ones.
24
+ * that pass every migrated call would report success, including the failed ones.
24
25
  *
25
26
  * A row that yields no renderable parts (e.g. a `tool`-role message carrying
26
27
  * only tool-result continuation parts) is omitted entirely, so the rendered
@@ -1,26 +1,27 @@
1
1
  /**
2
- * Converter: stored AI SDK model messages → wire `CortexMessage[]`.
2
+ * The MIGRATION renderer: stored AI SDK model messages → `CortexMessage[]`.
3
3
  *
4
- * The display read (`ThreadHistory.loadPage`) returns rows whose `content`
5
- * is the AI SDK `ModelMessage` that `appendTurn` persisted. This maps each
6
- * row to a `CortexMessage` for the chat wire:
4
+ * This is how a conversation written before display projections were persisted
5
+ * is rendered ONCE, at startup, so it can be frozen into a display envelope
6
+ * (`conversation-display-backfill.ts`). It is not a runtime read path — the
7
+ * runtime reads the stored projection (`conversation-display-replay.ts`) and
8
+ * never consults a tool name, a card builder, or the filesystem.
9
+ *
10
+ * Everything it does is inference from a transcript that was never meant to
11
+ * carry display, which is why it belongs to the migration and nothing else:
7
12
  *
8
13
  * - text part → text part
9
14
  * - tool-call part → a reconstructed display card (`data-plan`,
10
15
  * `data-presentation`) when `resolveCard` recognises
11
- * the tool, else a generic tool-call part
16
+ * the tool, else a generic tool-call part whose detail
17
+ * `resolveDetail` recomputes from the persisted input
12
18
  * - everything else → dropped (reasoning, tool-result, etc.) — the UI
13
19
  * does not render them
14
20
  *
15
- * Display cards are emitted live over the chat SSE stream but never persisted
16
- * (storage holds only the AI SDK model-message transcript). `resolveCard`
17
- * rebuilds them from the persisted tool-call part so they reappear on reload,
18
- * and `resolveDetail` rebuilds each call's one-line detail the same way.
19
- *
20
21
  * A `tool-result` block is not rendered, but it is not ignored either: it is the
21
22
  * only record of how its call ended. The blocks are indexed by `toolCallId`
22
23
  * before conversion so each tool-call part can report its own outcome — without
23
- * that pass every reloaded call reported success, including the failed ones.
24
+ * that pass every migrated call would report success, including the failed ones.
24
25
  *
25
26
  * A row that yields no renderable parts (e.g. a `tool`-role message carrying
26
27
  * only tool-result continuation parts) is omitted entirely, so the rendered
@@ -50,14 +51,16 @@ function indexOutcomes(messages) {
50
51
  }
51
52
  function genericToolCall(block, outcomes, resolveDetail) {
52
53
  // No paired result is the shape of a transcript whose `tool` row was never
53
- // appended (an aborted turn). Nothing failed, so `ok` is the honest report.
54
- const outcome = outcomes.get(block.toolCallId) ?? "ok";
54
+ // appended — an aborted turn. That is not a success: the call was dispatched and
55
+ // never completed, which is exactly `incomplete`. Reporting `ok` would claim a
56
+ // result the tool never returned, and it is the one outcome the transcript
57
+ // genuinely cannot supply, so it is named rather than guessed at.
58
+ const outcome = outcomes.get(block.toolCallId) ?? "incomplete";
55
59
  const detail = resolveDetail?.(block.toolName, block.input);
56
60
  return {
57
61
  type: "tool-call",
58
62
  toolCallId: block.toolCallId,
59
63
  toolName: block.toolName,
60
- status: "finished",
61
64
  outcome,
62
65
  ...(detail === undefined ? {} : { detail }),
63
66
  };
@@ -0,0 +1,25 @@
1
+ import type { Pool } from "pg";
2
+ import type { Tool } from "../tools/define-tool.js";
3
+ import type { ResolveWorkspaceRoot } from "../workspace/paths.js";
4
+ export interface ConversationDisplayBackfillDeps {
5
+ readonly pool: Pool;
6
+ readonly resolveWorkspaceRoot: ResolveWorkspaceRoot;
7
+ /**
8
+ * The conversation agent's composed roster, used to recover each legacy call's
9
+ * one-line detail from its persisted input. Tools contributed through the
10
+ * host-tools seam are invisible to any map the harness holds on its own, so the
11
+ * list has to come from the assembled runtime.
12
+ */
13
+ readonly tools: readonly Tool[];
14
+ readonly batchSize?: number;
15
+ }
16
+ /**
17
+ * Freeze the migration renderer's output into display envelopes, once per legacy
18
+ * turn, so the runtime read path never has to reconstruct anything.
19
+ *
20
+ * Missing mutable resources degrade to a generic, cardless display — a workspace
21
+ * that has moved is normal historical absence, not a fault. Malformed stored rows
22
+ * and database faults remain startup-fatal: a turn that cannot be migrated must
23
+ * not be silently skipped and then read as if it had no display.
24
+ */
25
+ export declare function backfillConversationDisplayEnvelopes(deps: ConversationDisplayBackfillDeps): Promise<number>;
@@ -0,0 +1,97 @@
1
+ import { createDetailResolver } from "../tools/detail-resolver.js";
2
+ import { envelopeDisplayMessages, cortexMessagesToConversationUI, parseStoredDisplayEnvelope } from "./conversation-display-storage.js";
3
+ import { parseStoredMessageEnvelope } from "./ai-sdk-message-storage.js";
4
+ import { contentToCortexMessages } from "./content-to-cortex.js";
5
+ import { createCardResolver } from "./reconstruct-cards.js";
6
+ const BATCH_SIZE = 100;
7
+ const GENUINE_USER_SQL = `m.message_envelope->'message'->>'role' = 'user'
8
+ AND m.message_envelope->'message'->'providerOptions'->'cortex'->>'synthetic' IS DISTINCT FROM 'true'`;
9
+ /**
10
+ * Freeze the migration renderer's output into display envelopes, once per legacy
11
+ * turn, so the runtime read path never has to reconstruct anything.
12
+ *
13
+ * Missing mutable resources degrade to a generic, cardless display — a workspace
14
+ * that has moved is normal historical absence, not a fault. Malformed stored rows
15
+ * and database faults remain startup-fatal: a turn that cannot be migrated must
16
+ * not be silently skipped and then read as if it had no display.
17
+ */
18
+ export async function backfillConversationDisplayEnvelopes(deps) {
19
+ const batchSize = Math.max(1, deps.batchSize ?? BATCH_SIZE);
20
+ const resolveDetail = createDetailResolver(deps.tools);
21
+ let migrated = 0;
22
+ let validationOffset = 0;
23
+ for (;;) {
24
+ const { rows } = await deps.pool.query(`SELECT thread_id, seq::text AS seq, display_envelope
25
+ FROM messages
26
+ WHERE display_envelope IS NOT NULL
27
+ ORDER BY thread_id, seq
28
+ LIMIT $1 OFFSET $2`, [batchSize, validationOffset]);
29
+ await Promise.all(rows.map((row) => parseStoredDisplayEnvelope(row.display_envelope, `${row.thread_id}/${row.seq}/display`)));
30
+ if (rows.length < batchSize)
31
+ break;
32
+ validationOffset += rows.length;
33
+ }
34
+ for (;;) {
35
+ const { rows: heads } = await deps.pool.query(`SELECT m.thread_id, m.seq::text AS seq, t.analysis_id
36
+ FROM messages m
37
+ LEFT JOIN cortex_analysis_threads t ON t.thread_id = m.thread_id
38
+ WHERE ${GENUINE_USER_SQL}
39
+ AND m.display_envelope IS NULL
40
+ ORDER BY m.thread_id, m.seq
41
+ LIMIT $1`, [batchSize]);
42
+ if (heads.length === 0)
43
+ return migrated;
44
+ for (const head of heads) {
45
+ const { rows } = await deps.pool.query(`SELECT m.seq::text AS seq, m.message_envelope
46
+ FROM messages m
47
+ WHERE m.thread_id = $1
48
+ AND m.seq >= $2::bigint
49
+ AND m.seq < COALESCE(
50
+ (
51
+ SELECT MIN(next.seq)
52
+ FROM messages next
53
+ WHERE next.thread_id = m.thread_id
54
+ AND next.seq > $2::bigint
55
+ AND next.message_envelope->'message'->>'role' = 'user'
56
+ AND next.message_envelope->'message'->'providerOptions'->'cortex'->>'synthetic' IS DISTINCT FROM 'true'
57
+ ),
58
+ 9223372036854775807
59
+ )
60
+ ORDER BY m.seq`, [head.thread_id, head.seq]);
61
+ const stored = rows.map((row) => {
62
+ const envelope = parseStoredMessageEnvelope(row.message_envelope, `${head.thread_id}/${row.seq}`);
63
+ return { seq: Number(row.seq), envelope, message: envelope.message };
64
+ });
65
+ let resolveCard;
66
+ if (head.analysis_id !== null) {
67
+ try {
68
+ resolveCard = createCardResolver(deps.pool, head.analysis_id, deps.resolveWorkspaceRoot(head.analysis_id));
69
+ }
70
+ catch {
71
+ // A removed/moved workspace is normal historical absence.
72
+ resolveCard = undefined;
73
+ }
74
+ }
75
+ const cortex = await contentToCortexMessages(stored, { resolveCard, resolveDetail });
76
+ const uiMessages = cortexMessagesToConversationUI(cortex);
77
+ // A genuine turn always contributes at least its user message. Keep a
78
+ // defensive model-derived fallback so absence can never strand a NULL
79
+ // envelope and make startup retry forever.
80
+ const display = uiMessages.length > 0
81
+ ? uiMessages
82
+ : [
83
+ {
84
+ id: head.seq,
85
+ role: "user",
86
+ parts: [{ type: "text", text: "", state: "done" }],
87
+ },
88
+ ];
89
+ const envelope = envelopeDisplayMessages(display);
90
+ const result = await deps.pool.query(`UPDATE messages
91
+ SET display_envelope = $1::jsonb
92
+ WHERE thread_id = $2 AND seq = $3::bigint
93
+ AND display_envelope IS NULL`, [JSON.stringify(envelope), head.thread_id, head.seq]);
94
+ migrated += result.rowCount ?? 0;
95
+ }
96
+ }
97
+ }
@@ -0,0 +1,17 @@
1
+ import type { EmitFn } from "../loop/types.js";
2
+ import { type ConversationUIMessage } from "./conversation-display-storage.js";
3
+ export interface ConversationDisplayRecorder {
4
+ readonly emit: EmitFn;
5
+ finish(options?: {
6
+ readonly fallbackText?: string;
7
+ readonly interrupted?: boolean;
8
+ }): ConversationUIMessage[];
9
+ }
10
+ export interface ConversationDisplayRecorderOptions {
11
+ readonly userText: string;
12
+ readonly topLevelCallPath: readonly string[];
13
+ readonly sink: EmitFn;
14
+ readonly userMessageId?: string;
15
+ readonly assistantMessageId?: string;
16
+ }
17
+ export declare function createConversationDisplayRecorder(options: ConversationDisplayRecorderOptions): ConversationDisplayRecorder;
@@ -0,0 +1,156 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { PART_REGISTRY } from "../contracts/part-registry.js";
3
+ import { CortexChatPartSchema } from "../contracts/schemas/chat-parts.js";
4
+ import { conversationDisplayPart } from "./conversation-display-storage.js";
5
+ function jsonCopy(value) {
6
+ return JSON.parse(JSON.stringify(value));
7
+ }
8
+ function isSubAgent(event, topLevelCallPath) {
9
+ if (!("source" in event) || event.source === undefined)
10
+ return false;
11
+ const path = event.source.callPath;
12
+ return path.length > topLevelCallPath.length && topLevelCallPath.every((entry, index) => path[index] === entry);
13
+ }
14
+ function durableConversationType(type) {
15
+ if (!Object.hasOwn(PART_REGISTRY, type))
16
+ return false;
17
+ const descriptor = PART_REGISTRY[type];
18
+ return descriptor.emitter === "conversation" && descriptor.consumer === "conversation" && !descriptor.transient;
19
+ }
20
+ function dataPart(event) {
21
+ const parsed = CortexChatPartSchema.safeParse({ type: event.type, ...event.data });
22
+ if (!parsed.success)
23
+ throw new Error(`Invalid emitted conversation part ${event.type}: ${parsed.error.message}`);
24
+ const { type, ...data } = parsed.data;
25
+ return {
26
+ type,
27
+ id: "id" in data && typeof data.id === "string" ? data.id : undefined,
28
+ data: jsonCopy(data),
29
+ };
30
+ }
31
+ export function createConversationDisplayRecorder(options) {
32
+ const assistantParts = [];
33
+ // Where a reconciling part lives, keyed by `type:id`. Type-qualified because a
34
+ // stable id is only unique within its own family — a plan and a run card may
35
+ // legitimately share one.
36
+ const reconcileIndexes = new Map();
37
+ let finished = false;
38
+ /**
39
+ * Place a part, replacing the one it reconciles with when it has a stable id.
40
+ * A tool call reaching its `finished` state and an `ask` reaching its resolved
41
+ * status are the same operation: latest-wins, in the position the part first
42
+ * took, so a later part never jumps the ordering the user saw.
43
+ */
44
+ function upsert(part, reconciling) {
45
+ if (!reconciling || part.id === undefined) {
46
+ assistantParts.push(part);
47
+ return;
48
+ }
49
+ const key = `${part.type}:${part.id}`;
50
+ const index = reconcileIndexes.get(key);
51
+ if (index !== undefined) {
52
+ assistantParts[index] = part;
53
+ return;
54
+ }
55
+ reconcileIndexes.set(key, assistantParts.length);
56
+ assistantParts.push(part);
57
+ }
58
+ /**
59
+ * Place or update a call's recorded state.
60
+ *
61
+ * A call is recorded `incomplete` the moment it is dispatched and overwritten with
62
+ * its real outcome when it finishes. The record is therefore honest at every
63
+ * instant, and a turn that ends mid-call needs no closing pass — the calls that
64
+ * never finished are already saying so.
65
+ */
66
+ function recordToolCall(toolCallId, toolName, outcome, detail) {
67
+ upsert(conversationDisplayPart({
68
+ type: "tool-call",
69
+ toolCallId,
70
+ toolName,
71
+ outcome,
72
+ ...(detail === undefined ? {} : { detail }),
73
+ }), true);
74
+ }
75
+ function appendText(text) {
76
+ if (text.length === 0)
77
+ return;
78
+ const previous = assistantParts.at(-1);
79
+ if (previous?.type === "text") {
80
+ previous.text += text;
81
+ return;
82
+ }
83
+ assistantParts.push({ type: "text", text, state: "streaming" });
84
+ }
85
+ function recordData(event) {
86
+ if (!durableConversationType(event.type))
87
+ return;
88
+ upsert(dataPart(event), PART_REGISTRY[event.type].reconciling);
89
+ }
90
+ const emit = (event) => {
91
+ if (finished)
92
+ return options.sink(event);
93
+ if (!isSubAgent(event, options.topLevelCallPath)) {
94
+ switch (event.type) {
95
+ case "text-delta":
96
+ appendText(event.text);
97
+ break;
98
+ // The detail the loop computed at dispatch is recorded as shown. It is
99
+ // NOT re-derived on reload: doing that would need the tool's schema and
100
+ // the raw input, which is the tool-name coupling the display projection
101
+ // exists to remove.
102
+ case "tool-started":
103
+ recordToolCall(event.toolUseId, event.name, "incomplete", event.detail);
104
+ break;
105
+ case "tool-finished":
106
+ recordToolCall(event.toolUseId, event.name, event.outcome, event.detail);
107
+ break;
108
+ case "done":
109
+ case "iteration":
110
+ break;
111
+ default:
112
+ recordData(event);
113
+ break;
114
+ }
115
+ }
116
+ return options.sink(event);
117
+ };
118
+ function finish(finishOptions) {
119
+ if (!finished) {
120
+ finished = true;
121
+ if (!assistantParts.some((part) => part.type === "text") && finishOptions?.fallbackText) {
122
+ appendText(finishOptions.fallbackText);
123
+ }
124
+ // Calls need no closing pass: one that never reached `tool-finished` still holds
125
+ // the `incomplete` it was recorded with at dispatch, which is the whole of what
126
+ // the harness observed. An approval does need one — `pending` is a question, and
127
+ // a turn that ends without answering it aborted it.
128
+ for (const part of assistantParts) {
129
+ if (part.type === "data-ask" && part.data.status === "pending") {
130
+ part.data.status = "aborted";
131
+ }
132
+ }
133
+ for (const part of assistantParts) {
134
+ if (part.type === "text")
135
+ part.state = "done";
136
+ }
137
+ }
138
+ const messages = [
139
+ {
140
+ id: options.userMessageId ?? randomUUID(),
141
+ role: "user",
142
+ parts: [{ type: "text", text: options.userText, state: "done" }],
143
+ },
144
+ ];
145
+ if (assistantParts.length > 0) {
146
+ messages.push({
147
+ id: options.assistantMessageId ?? randomUUID(),
148
+ role: "assistant",
149
+ ...(finishOptions?.interrupted ? { metadata: { interrupted: true } } : {}),
150
+ parts: jsonCopy(assistantParts),
151
+ });
152
+ }
153
+ return messages;
154
+ }
155
+ return { emit, finish };
156
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The runtime transcript read: stored display projections → `CortexMessage[]`.
3
+ *
4
+ * `ThreadHistory.loadPage` returns a thread's rows in `seq` order, and every
5
+ * append's first row carries the display projection for that append. Replay is
6
+ * therefore a concatenation: walk the rows, emit each envelope's messages in
7
+ * order, done. No turn grouping, no tool-name recognition, no card rebuilding,
8
+ * no filesystem or database lookup — nothing that could render a reloaded
9
+ * conversation differently from the one the user was shown.
10
+ *
11
+ * A row with no envelope predates the display projection and is skipped. The
12
+ * startup backfill (`conversation-display-backfill.ts`) writes one for every such
13
+ * row before the runtime serves traffic, so in a booted harness there are none;
14
+ * skipping rather than falling back to the migration renderer is what keeps the
15
+ * reconstruction path out of the runtime entirely.
16
+ */
17
+ import type { CortexMessage } from "../contracts/message.js";
18
+ import type { StoredMessage } from "./thread-history.js";
19
+ export declare function storedMessagesToCortex(messages: readonly StoredMessage[]): CortexMessage[];
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The runtime transcript read: stored display projections → `CortexMessage[]`.
3
+ *
4
+ * `ThreadHistory.loadPage` returns a thread's rows in `seq` order, and every
5
+ * append's first row carries the display projection for that append. Replay is
6
+ * therefore a concatenation: walk the rows, emit each envelope's messages in
7
+ * order, done. No turn grouping, no tool-name recognition, no card rebuilding,
8
+ * no filesystem or database lookup — nothing that could render a reloaded
9
+ * conversation differently from the one the user was shown.
10
+ *
11
+ * A row with no envelope predates the display projection and is skipped. The
12
+ * startup backfill (`conversation-display-backfill.ts`) writes one for every such
13
+ * row before the runtime serves traffic, so in a booted harness there are none;
14
+ * skipping rather than falling back to the migration renderer is what keeps the
15
+ * reconstruction path out of the runtime entirely.
16
+ */
17
+ import { conversationUIToCortexMessages } from "./conversation-display-storage.js";
18
+ export function storedMessagesToCortex(messages) {
19
+ const out = [];
20
+ // The messages the append currently being walked produced. A row bearing an
21
+ // envelope opens one; every row after it belongs to it until the next does.
22
+ let append = null;
23
+ for (const row of messages) {
24
+ if (row.displayEnvelope) {
25
+ append = conversationUIToCortexMessages(row.displayEnvelope.messages);
26
+ out.push(...append);
27
+ }
28
+ // The turn's reported rollup rides the model row that ENDED the turn, not the
29
+ // display projection — it is a fact about what the turn cost, not about what it
30
+ // showed, and persisting it in both places would let the two disagree. Fold it
31
+ // onto the assistant reply a reader associates with the figure.
32
+ if (row.usage && append) {
33
+ const last = append.at(-1);
34
+ if (last?.role === "assistant")
35
+ last.usage = row.usage;
36
+ }
37
+ }
38
+ return out;
39
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * The durable conversation display projection — what the user was shown,
3
+ * persisted verbatim.
4
+ *
5
+ * AI SDK `UIMessage` is the CONTAINER: it supplies message identity, role,
6
+ * ordering, metadata, and `validateUIMessages` for runtime validation. The PARTS
7
+ * vocabulary is Cortex's own, because ours is the richer one — a `CortexPart`
8
+ * carries a tool call's four-way outcome and its one-line detail, and AI SDK's
9
+ * `dynamic-tool` part has nowhere to put either. Mapping through `dynamic-tool`
10
+ * would silently drop both on every reload, so every non-text part rides as a
11
+ * typed `DataUIPart` whose payload IS the Cortex part minus its discriminant.
12
+ *
13
+ * The projection is therefore lossless in both directions, and the read path
14
+ * ({@link conversationUIToCortexMessages}) is a near-identity rather than a
15
+ * reconstruction.
16
+ */
17
+ import { type DataUIPart, type UIMessage } from "ai";
18
+ import type { AskPart, FileReferencePart, PlanPart, PresentationPart, PreviewPart, DataPreviewFailedPart, RunCardPart } from "../contracts/chat-parts.js";
19
+ import type { ToolCallOutcome } from "../contracts/chat-events.js";
20
+ import type { CortexMessage, CortexPart, ToolCallPart } from "../contracts/message.js";
21
+ export declare const SUPPORTED_DISPLAY_SCHEMA_VERSION = 1;
22
+ type Payload<T extends {
23
+ type: string;
24
+ }> = Omit<T, "type">;
25
+ /**
26
+ * The AI SDK custom-data vocabulary persisted for one conversation display.
27
+ *
28
+ * Each key `k` becomes a `data-${k}` part whose payload is the matching Cortex
29
+ * part without its `type` field, so a stored part and a wire part differ only in
30
+ * where the discriminant lives.
31
+ */
32
+ export type ConversationUIData = {
33
+ "tool-call": Omit<Payload<ToolCallPart>, "outcome"> & {
34
+ outcome: ToolCallOutcome;
35
+ };
36
+ presentation: Payload<PresentationPart>;
37
+ plan: Payload<PlanPart>;
38
+ "run-card": Payload<RunCardPart>;
39
+ "file-reference": Payload<FileReferencePart>;
40
+ ask: Payload<AskPart>;
41
+ "report-preview": Payload<PreviewPart>;
42
+ "report-preview-failed": Payload<DataPreviewFailedPart>;
43
+ };
44
+ export interface ConversationDisplayMetadata {
45
+ interrupted?: boolean;
46
+ }
47
+ export type ConversationUIMessage = UIMessage<ConversationDisplayMetadata, ConversationUIData>;
48
+ export interface StoredDisplayEnvelope {
49
+ readonly kind: "ai-sdk-ui-messages";
50
+ readonly aiSdkMajor: 7;
51
+ readonly schemaVersion: typeof SUPPORTED_DISPLAY_SCHEMA_VERSION;
52
+ readonly messages: ConversationUIMessage[];
53
+ }
54
+ export declare function envelopeDisplayMessages(messages: readonly ConversationUIMessage[]): StoredDisplayEnvelope;
55
+ export declare function parseStoredDisplayEnvelope(value: unknown, identity: string): Promise<StoredDisplayEnvelope>;
56
+ /**
57
+ * Build the stored form of one non-text Cortex part.
58
+ *
59
+ * A tool call is the one part whose stored payload is not simply the Cortex part
60
+ * minus its discriminant: `outcome` is optional on the live part (absent means in
61
+ * flight) and required here, because a record is of a call that is no longer
62
+ * running. An in-flight call being recorded is one the turn never saw finish, so
63
+ * absence resolves to `incomplete` at exactly this boundary — once, where the
64
+ * lifetime changes — rather than at each reader.
65
+ */
66
+ export declare function conversationDisplayPart(part: Exclude<CortexPart, {
67
+ type: "text";
68
+ }>): DataUIPart<ConversationUIData>;
69
+ /**
70
+ * Project Cortex display messages into the durable representation.
71
+ *
72
+ * The startup backfill's only path into storage: it renders a legacy turn from
73
+ * the model transcript and freezes the result. The live recorder builds the same
74
+ * shape directly from the event stream instead of round-tripping through
75
+ * `CortexMessage`.
76
+ */
77
+ export declare function cortexMessagesToConversationUI(messages: readonly CortexMessage[]): ConversationUIMessage[];
78
+ /**
79
+ * Read the durable representation back as Cortex display messages.
80
+ *
81
+ * Every part round-trips by moving its discriminant back out of the part type,
82
+ * so nothing here consults a tool name, a registry, or the filesystem. A message
83
+ * whose parts all render empty is dropped so the transcript has no empty bubbles.
84
+ */
85
+ export declare function conversationUIToCortexMessages(messages: readonly ConversationUIMessage[]): CortexMessage[];
86
+ export {};
@@ -0,0 +1,168 @@
1
+ /**
2
+ * The durable conversation display projection — what the user was shown,
3
+ * persisted verbatim.
4
+ *
5
+ * AI SDK `UIMessage` is the CONTAINER: it supplies message identity, role,
6
+ * ordering, metadata, and `validateUIMessages` for runtime validation. The PARTS
7
+ * vocabulary is Cortex's own, because ours is the richer one — a `CortexPart`
8
+ * carries a tool call's four-way outcome and its one-line detail, and AI SDK's
9
+ * `dynamic-tool` part has nowhere to put either. Mapping through `dynamic-tool`
10
+ * would silently drop both on every reload, so every non-text part rides as a
11
+ * typed `DataUIPart` whose payload IS the Cortex part minus its discriminant.
12
+ *
13
+ * The projection is therefore lossless in both directions, and the read path
14
+ * ({@link conversationUIToCortexMessages}) is a near-identity rather than a
15
+ * reconstruction.
16
+ */
17
+ import { validateUIMessages } from "ai";
18
+ import { z } from "zod";
19
+ import { AskPartSchema, DataPreviewFailedPartSchema, FileReferencePartSchema, PlanPartSchema, PresentationPartSchema, PreviewPartSchema, RunCardPartSchema, } from "../contracts/schemas/chat-parts.js";
20
+ import { ToolCallOutcomeSchema } from "../contracts/schemas/chat-events.js";
21
+ export const SUPPORTED_DISPLAY_SCHEMA_VERSION = 1;
22
+ const DisplayEnvelopeHeaderSchema = z.object({
23
+ kind: z.literal("ai-sdk-ui-messages"),
24
+ aiSdkMajor: z.literal(7),
25
+ schemaVersion: z.literal(SUPPORTED_DISPLAY_SCHEMA_VERSION),
26
+ messages: z.array(z.unknown()).min(1),
27
+ });
28
+ /**
29
+ * Declared here rather than derived from `contracts/schemas/chat-parts.ts`: a
30
+ * tool call is not a chat DATA part on the wire — it reaches a host as the
31
+ * `tool-started`/`tool-finished` event pair — so `chat-parts.ts` has no member to
32
+ * omit a discriminant from. Storage is the only place the pair is flattened into
33
+ * one part, and this is that shape.
34
+ *
35
+ * `outcome` is REQUIRED, and carries the whole terminal state including
36
+ * `incomplete`. A record describes a call that is no longer running, so "still in
37
+ * flight" is not a state it can be in — a turn cut off mid-call recorded a
38
+ * dispatch with no completion, which is what `incomplete` says. Storing a
39
+ * lifecycle field beside an optional outcome instead would put the meaning of the
40
+ * combination in each reader's head, and readers would disagree.
41
+ */
42
+ const ToolCallDisplaySchema = z
43
+ .object({
44
+ toolCallId: z.string(),
45
+ toolName: z.string(),
46
+ outcome: ToolCallOutcomeSchema,
47
+ detail: z.string().optional(),
48
+ })
49
+ .strict();
50
+ const dataSchemas = {
51
+ "tool-call": ToolCallDisplaySchema,
52
+ presentation: PresentationPartSchema.omit({ type: true }).strict(),
53
+ plan: PlanPartSchema.omit({ type: true }).strict(),
54
+ "run-card": RunCardPartSchema.omit({ type: true }).strict(),
55
+ "file-reference": FileReferencePartSchema.omit({ type: true }).strict(),
56
+ ask: AskPartSchema.omit({ type: true }).strict(),
57
+ "report-preview": PreviewPartSchema.omit({ type: true }).strict(),
58
+ "report-preview-failed": DataPreviewFailedPartSchema.omit({ type: true }).strict(),
59
+ };
60
+ export function envelopeDisplayMessages(messages) {
61
+ if (messages.length === 0)
62
+ throw new Error("A conversation display envelope must contain at least one UI message");
63
+ return {
64
+ kind: "ai-sdk-ui-messages",
65
+ aiSdkMajor: 7,
66
+ schemaVersion: SUPPORTED_DISPLAY_SCHEMA_VERSION,
67
+ messages: structuredClone(messages),
68
+ };
69
+ }
70
+ export async function parseStoredDisplayEnvelope(value, identity) {
71
+ const header = DisplayEnvelopeHeaderSchema.safeParse(value);
72
+ if (!header.success) {
73
+ throw new Error(`Invalid stored conversation display envelope at ${identity}: ${header.error.message}`);
74
+ }
75
+ try {
76
+ const messages = await validateUIMessages({
77
+ messages: header.data.messages,
78
+ dataSchemas,
79
+ metadataSchema: z.object({ interrupted: z.boolean().optional() }).optional(),
80
+ });
81
+ return { ...header.data, messages };
82
+ }
83
+ catch (error) {
84
+ const message = error instanceof Error ? error.message : String(error);
85
+ throw new Error(`Invalid stored conversation display envelope at ${identity}: ${message}`, { cause: error });
86
+ }
87
+ }
88
+ /** The data-part key a Cortex part is stored under — its `type` minus the `data-` prefix. */
89
+ function displayPartType(part) {
90
+ return (part.type === "tool-call" ? "data-tool-call" : part.type);
91
+ }
92
+ /** The id a part reconciles on: a tool call by its call id, a card by its own stable id. */
93
+ function displayPartId(part) {
94
+ if (part.type === "tool-call")
95
+ return part.toolCallId;
96
+ return "id" in part && typeof part.id === "string" ? part.id : undefined;
97
+ }
98
+ /**
99
+ * Build the stored form of one non-text Cortex part.
100
+ *
101
+ * A tool call is the one part whose stored payload is not simply the Cortex part
102
+ * minus its discriminant: `outcome` is optional on the live part (absent means in
103
+ * flight) and required here, because a record is of a call that is no longer
104
+ * running. An in-flight call being recorded is one the turn never saw finish, so
105
+ * absence resolves to `incomplete` at exactly this boundary — once, where the
106
+ * lifetime changes — rather than at each reader.
107
+ */
108
+ export function conversationDisplayPart(part) {
109
+ const { type: _type, ...rest } = part;
110
+ const data = part.type === "tool-call" ? { ...rest, outcome: part.outcome ?? "incomplete" } : rest;
111
+ return { type: displayPartType(part), id: displayPartId(part), data };
112
+ }
113
+ /**
114
+ * Project Cortex display messages into the durable representation.
115
+ *
116
+ * The startup backfill's only path into storage: it renders a legacy turn from
117
+ * the model transcript and freezes the result. The live recorder builds the same
118
+ * shape directly from the event stream instead of round-tripping through
119
+ * `CortexMessage`.
120
+ */
121
+ export function cortexMessagesToConversationUI(messages) {
122
+ return messages.map((message) => {
123
+ const parts = message.parts.map((part) => part.type === "text" ? { type: "text", text: part.text, state: "done" } : conversationDisplayPart(part));
124
+ return {
125
+ id: message.id,
126
+ role: message.role,
127
+ ...(message.interrupted ? { metadata: { interrupted: true } } : {}),
128
+ parts,
129
+ };
130
+ });
131
+ }
132
+ /**
133
+ * Read the durable representation back as Cortex display messages.
134
+ *
135
+ * Every part round-trips by moving its discriminant back out of the part type,
136
+ * so nothing here consults a tool name, a registry, or the filesystem. A message
137
+ * whose parts all render empty is dropped so the transcript has no empty bubbles.
138
+ */
139
+ export function conversationUIToCortexMessages(messages) {
140
+ const out = [];
141
+ for (const message of messages) {
142
+ const parts = [];
143
+ for (const part of message.parts) {
144
+ if (part.type === "text") {
145
+ if (part.text.length > 0)
146
+ parts.push({ type: "text", text: part.text });
147
+ continue;
148
+ }
149
+ if (part.type === "data-tool-call") {
150
+ parts.push({ type: "tool-call", ...part.data });
151
+ continue;
152
+ }
153
+ if (part.type.startsWith("data-")) {
154
+ const data = part;
155
+ parts.push({ type: data.type, ...data.data });
156
+ }
157
+ }
158
+ if (parts.length === 0)
159
+ continue;
160
+ out.push({
161
+ id: message.id,
162
+ role: message.role,
163
+ parts,
164
+ ...(message.metadata?.interrupted ? { interrupted: true } : {}),
165
+ });
166
+ }
167
+ return out;
168
+ }
@@ -1,5 +1,8 @@
1
1
  /**
2
- * Read-path reconstruction of display cards from a persisted turn.
2
+ * @deprecated Compatibility-only reconstruction for pre-display-envelope
3
+ * turns. New runtime reads use stored AI SDK UI messages. Retain this module
4
+ * through the pre-v1 direct-upgrade window, then remove its runtime fallback
5
+ * in a dedicated compatibility change.
3
6
  *
4
7
  * `show_plan` / `show_user` / `show_file` / `execute_analysis` / `iterate_report` emit
5
8
  * `data-plan` / `data-presentation` / `data-file-reference` / `data-run-card` /
@@ -13,11 +16,14 @@
13
16
  */
14
17
  import type { CortexPart } from "@inflexa-ai/harness/contracts/message.js";
15
18
  import type { Pool } from "pg";
19
+ /** @deprecated Used only by the legacy display backfill/fallback. */
16
20
  export interface StoredToolCallForCard {
17
21
  readonly type: "tool_use";
18
22
  readonly id: string;
19
23
  readonly name: string;
20
24
  readonly input: unknown;
21
25
  }
26
+ /** @deprecated Used only by the legacy display backfill/fallback. */
22
27
  export type ToolCardResolver = (block: StoredToolCallForCard) => Promise<CortexPart | null>;
28
+ /** @deprecated Use persisted conversation display envelopes for runtime reads. */
23
29
  export declare function createCardResolver(pool: Pool, analysisId: string, workspaceRoot: string): ToolCardResolver;
@@ -1,5 +1,8 @@
1
1
  /**
2
- * Read-path reconstruction of display cards from a persisted turn.
2
+ * @deprecated Compatibility-only reconstruction for pre-display-envelope
3
+ * turns. New runtime reads use stored AI SDK UI messages. Retain this module
4
+ * through the pre-v1 direct-upgrade window, then remove its runtime fallback
5
+ * in a dedicated compatibility change.
3
6
  *
4
7
  * `show_plan` / `show_user` / `show_file` / `execute_analysis` / `iterate_report` emit
5
8
  * `data-plan` / `data-presentation` / `data-file-reference` / `data-run-card` /
@@ -15,6 +18,7 @@ import { unwrapOrThrow } from "../lib/result.js";
15
18
  import { adHocPlanId, adHocRunId } from "../tools/analysis-invocation.js";
16
19
  import { validatePath } from "../tools/lib/path-validation.js";
17
20
  import { buildFileReferenceCardData, buildPlanCardData, buildPresentationCardData, buildPreviewCardData, buildRunCardData } from "./card-builders.js";
21
+ /** @deprecated Use persisted conversation display envelopes for runtime reads. */
18
22
  export function createCardResolver(pool, analysisId, workspaceRoot) {
19
23
  return async (block) => {
20
24
  if (block.type !== "tool_use")
@@ -26,11 +26,18 @@ import type { Pool } from "pg";
26
26
  import type { TokenUsageRollup } from "../contracts/usage.js";
27
27
  import { type DbError } from "../lib/db-result.js";
28
28
  import { type StoredMessageEnvelope } from "./ai-sdk-message-storage.js";
29
+ import { type ConversationUIMessage, type StoredDisplayEnvelope } from "./conversation-display-storage.js";
29
30
  /** One stored message, as returned by the display read (`loadPage`). */
30
31
  export interface StoredMessage {
31
32
  readonly seq: number;
32
33
  readonly envelope: StoredMessageEnvelope;
33
34
  readonly message: ModelMessage;
35
+ /**
36
+ * The display projection of the append this row OPENED — present on the first
37
+ * row of each `appendTurn` and absent on every other row. A conversation turn
38
+ * and a host-appended record are both appends, so both carry one.
39
+ */
40
+ readonly displayEnvelope?: StoredDisplayEnvelope;
34
41
  /**
35
42
  * What providers reported for the whole TURN this row completed — present
36
43
  * only on the last assistant row of a turn appended with a rollup, absent
@@ -41,6 +48,41 @@ export interface StoredMessage {
41
48
  */
42
49
  readonly usage?: TokenUsageRollup;
43
50
  }
51
+ /**
52
+ * One atomic append: the exact provider-facing history, the display projection of
53
+ * what the user was shown, and what the turn reported spending.
54
+ *
55
+ * The two message projections are independent by design. `modelMessages` is what
56
+ * the provider sees and the only input to token accounting; `displayMessages` is
57
+ * what the transcript replays. Neither is derived from the other, and a failed
58
+ * write commits neither.
59
+ */
60
+ export interface ConversationTurn {
61
+ /** Exact provider-facing history; token accounting and model reads use only this projection. */
62
+ readonly modelMessages: readonly ModelMessage[];
63
+ /** Complete ordered AI SDK UI messages recorded from the live display event stream. */
64
+ readonly displayMessages: readonly ConversationUIMessage[];
65
+ /**
66
+ * The turn's reported rollup, as `AgentFinish.turnUsage` carries it out of the
67
+ * loop. Stored on the LAST assistant message of the turn — the row a reader
68
+ * associates with the reply. Omitting it, and supplying one that reports no
69
+ * quantity at all, both leave the row without one: a turn that reported nothing
70
+ * must not read back as a turn that cost zero.
71
+ */
72
+ readonly turnUsage?: TokenUsageRollup;
73
+ }
74
+ /**
75
+ * The append a host uses to record out-of-band work in a thread — an analysis
76
+ * run's outcome, typically.
77
+ *
78
+ * Both projections are built here because a record has no live turn behind it:
79
+ * no recorder observed it, so nothing else would produce its display projection,
80
+ * and a record appended without one would be stored, read by the model, and
81
+ * never shown. The harness owns the record-to-`system` mapping for the same
82
+ * reason it owns the marker — a host that hand-assembled either could produce a
83
+ * message the turn-boundary predicates fail to recognise.
84
+ */
85
+ export declare function conversationRecordTurn(text: string): ConversationTurn;
44
86
  /**
45
87
  * A page of stored messages plus pagination metadata. Pagination is
46
88
  * turn-based: `total` is the thread's turn count and `page`/`perPage` index
@@ -74,20 +116,13 @@ export type RetractOutcome = {
74
116
  */
75
117
  export interface ThreadHistory {
76
118
  /**
77
- * Append one conversation turn — every message written in a single
119
+ * Append one {@link ConversationTurn} — every message written in a single
78
120
  * transaction with a `seq` monotonically increasing per thread.
79
121
  *
80
- * `turnUsage` is the turn's reported rollup, as `AgentFinish.turnUsage`
81
- * carries it out of the loop and the `finish` event carries it to a live
82
- * surface. It is stored on the LAST assistant message of the turn — the row a
83
- * reader associates with the reply — so a reloaded transcript renders the
84
- * figure the live turn showed. Optional at every layer: omitting it, and
85
- * supplying one that reports no quantity at all, both leave the row without
86
- * one, because a turn that reported nothing must not read back as a turn that
87
- * cost zero. A turn writing no assistant message stores none and still
88
- * succeeds — there is no row on which the figure would mean anything.
122
+ * A turn writing no assistant message stores no rollup and still succeeds:
123
+ * there is no row on which the figure would mean anything.
89
124
  */
90
- appendTurn(threadId: string, messages: readonly ModelMessage[], turnUsage?: TokenUsageRollup): ResultAsync<void, DbError>;
125
+ appendTurn(threadId: string, turn: ConversationTurn): ResultAsync<void, DbError>;
91
126
  /**
92
127
  * Return a recent-turns window that fits `tokenBudget`, oldest-first,
93
128
  * snapped to a valid AI SDK model-message sequence. The window START advances
@@ -20,17 +20,36 @@
20
20
  * is the atomic unit; `appendTurn` writes one atomically and `loadRecent`
21
21
  * rounds the budget walk to turn boundaries.
22
22
  */
23
+ import { randomUUID } from "node:crypto";
23
24
  import { metrics } from "@opentelemetry/api";
24
25
  import { ResultAsync, ok, okAsync } from "neverthrow";
25
26
  import { stripNulCharacters } from "../input-sanitization.js";
26
27
  import { tryMutation, tryQuery, withTransaction } from "../lib/db-result.js";
27
28
  import { hasReportedUsage } from "../loop/metrics.js";
28
29
  import { countTokens } from "./count-tokens.js";
29
- import { HARNESS_PROVIDER_NAMESPACE, SYNTHETIC_MESSAGE_KEY, envelopeMessage, isSyntheticUserMessage, parseStoredMessageEnvelope, } from "./ai-sdk-message-storage.js";
30
+ import { HARNESS_PROVIDER_NAMESPACE, SYNTHETIC_MESSAGE_KEY, envelopeMessage, isSyntheticUserMessage, parseStoredMessageEnvelope, syntheticRecordMessage, } from "./ai-sdk-message-storage.js";
31
+ import { envelopeDisplayMessages, parseStoredDisplayEnvelope } from "./conversation-display-storage.js";
30
32
  /** A resolved `ok(undefined)` ResultAsync — the empty/seed transaction step. */
31
33
  function okVoid() {
32
34
  return new ResultAsync(Promise.resolve(ok(undefined)));
33
35
  }
36
+ /**
37
+ * The append a host uses to record out-of-band work in a thread — an analysis
38
+ * run's outcome, typically.
39
+ *
40
+ * Both projections are built here because a record has no live turn behind it:
41
+ * no recorder observed it, so nothing else would produce its display projection,
42
+ * and a record appended without one would be stored, read by the model, and
43
+ * never shown. The harness owns the record-to-`system` mapping for the same
44
+ * reason it owns the marker — a host that hand-assembled either could produce a
45
+ * message the turn-boundary predicates fail to recognise.
46
+ */
47
+ export function conversationRecordTurn(text) {
48
+ return {
49
+ modelMessages: [syntheticRecordMessage(text)],
50
+ displayMessages: [{ id: randomUUID(), role: "system", parts: [{ type: "text", text, state: "done" }] }],
51
+ };
52
+ }
34
53
  /**
35
54
  * A turn starts on a `user` message that a human actually sent. Two kinds of
36
55
  * message carry the `user` role without opening a turn, and both are excluded
@@ -156,9 +175,16 @@ export const EVICTION_BLOCK_TURNS = 4;
156
175
  * provisioned by the project's state-init DDL.
157
176
  */
158
177
  export function createThreadHistory(pool) {
159
- function appendTurn(threadId, messages, turnUsage) {
178
+ function appendTurn(threadId, turn) {
179
+ const { modelMessages: messages, displayMessages, turnUsage } = turn;
160
180
  if (messages.length === 0)
161
181
  return okVoid();
182
+ // The display projection rides the append's FIRST row, so one SELECT of a
183
+ // thread's rows yields every envelope in order with no join and no grouping.
184
+ // Whichever row that is — a genuine user message opening a turn, or the lone
185
+ // record row of an out-of-band append — is the row a tail retraction removes
186
+ // the envelope with, because it is the row the projection describes.
187
+ const displayEnvelope = displayMessages.length > 0 ? JSON.stringify(envelopeDisplayMessages(displayMessages)) : null;
162
188
  // A rollup that reports no quantity is stored as absent, not as a rollup of
163
189
  // absences, so "no figure" has exactly one representation in storage.
164
190
  // `hasReportedUsage` is the loop's own predicate for the same question,
@@ -185,14 +211,23 @@ export function createThreadHistory(pool) {
185
211
  // re-throws it to force ROLLBACK (a returned `err` that does not
186
212
  // reach `withTransaction` would COMMIT silently).
187
213
  messages.reduce((chain, message, i) => chain.andThen(() => tryMutation("thread-history.appendTurn.insert", () => client.query(
188
- // `message_envelope::json`, never `::jsonb`, and `reported_usage::jsonb`,
189
- // never `::json` — see the column comments in the state-init DDL.
190
- `INSERT INTO messages (thread_id, seq, message_envelope, tokens, reported_usage)
191
- VALUES ($1, $2, $3::json, $4, $5::jsonb)
214
+ // `message_envelope::json`, never `::jsonb`; `display_envelope` and
215
+ // `reported_usage::jsonb`, never `::json` — see the column comments
216
+ // in the state-init DDL.
217
+ `INSERT INTO messages (thread_id, seq, message_envelope, display_envelope, tokens, reported_usage)
218
+ VALUES ($1, $2, $3::json, $4::jsonb, $5, $6::jsonb)
192
219
  ON CONFLICT (thread_id, seq) DO UPDATE
193
220
  SET message_envelope = EXCLUDED.message_envelope,
221
+ display_envelope = EXCLUDED.display_envelope,
194
222
  tokens = EXCLUDED.tokens,
195
- reported_usage = EXCLUDED.reported_usage`, [threadId, startSeq + i, serializeEnvelope(message), countTokens(message.content), i === rollupRow ? rollup : null])).map(() => undefined)), okVoid()))
223
+ reported_usage = EXCLUDED.reported_usage`, [
224
+ threadId,
225
+ startSeq + i,
226
+ serializeEnvelope(message),
227
+ i === 0 ? displayEnvelope : null,
228
+ countTokens(message.content),
229
+ i === rollupRow ? rollup : null,
230
+ ])).map(() => undefined)), okVoid()))
196
231
  .andThen(() =>
197
232
  // Thread listings sort on `cortex_analysis_threads.updated_at`, and
198
233
  // the only other writer of it is the title update — so without this
@@ -311,15 +346,22 @@ export function createThreadHistory(pool) {
311
346
  // `seq::text AS seq` output alias (Postgres resolves an unqualified
312
347
  // ORDER BY name to the output column), sorting the bigint as text:
313
348
  // "10" before "2". The qualified name forces the bigint column.
314
- `SELECT seq::text AS seq, message_envelope, reported_usage
349
+ `SELECT seq::text AS seq, message_envelope, display_envelope, reported_usage
315
350
  FROM messages WHERE thread_id = $1
316
351
  ORDER BY messages.seq ASC`, [threadId]);
317
- const stored = rows.map((r) => {
352
+ // Spread rather than `usage: r.reported_usage ?? undefined`, so a row with
353
+ // no rollup carries no `usage` KEY at all — absent, not present-and-undefined.
354
+ const stored = await Promise.all(rows.map(async (r) => {
318
355
  const envelope = parseStoredMessageEnvelope(r.message_envelope, `${threadId}/${r.seq}`);
319
- // Spread rather than `usage: r.reported_usage ?? undefined`, so a row with
320
- // no rollup carries no `usage` KEY at all — absent, not present-and-undefined.
321
- return { seq: Number(r.seq), envelope, message: envelope.message, ...(r.reported_usage === null ? {} : { usage: r.reported_usage }) };
322
- });
356
+ const displayEnvelope = r.display_envelope == null ? undefined : await parseStoredDisplayEnvelope(r.display_envelope, `${threadId}/${r.seq}/display`);
357
+ return {
358
+ seq: Number(r.seq),
359
+ envelope,
360
+ message: envelope.message,
361
+ ...(displayEnvelope ? { displayEnvelope } : {}),
362
+ ...(r.reported_usage === null ? {} : { usage: r.reported_usage }),
363
+ };
364
+ }));
323
365
  const turns = groupTurns(stored, (row) => isGenuineUserStart(row.message));
324
366
  const total = turns.length;
325
367
  const offset = safePage * safePerPage;
@@ -20,11 +20,17 @@
20
20
  * misconfigured pool fails loudly at boot, not under load.
21
21
  * 5. `assembleCoreRuntime(core)` — registers the durable-workflow cohort and
22
22
  * builds the conversation agent (register-before-launch invariant).
23
- * 6. `beforeLaunch()` — embedder hook for host-specific pre-launch work that
23
+ * 6. `backfillConversationDisplayEnvelopes(...)` — validates every stored
24
+ * display envelope and freezes one for each legacy turn that has none, so
25
+ * the runtime read path never meets a row without a display projection.
26
+ * Runs AFTER assembly because migrating a legacy call's one-line detail
27
+ * needs the assembled conversation roster, embedder-contributed tools
28
+ * included; assembly starts no traffic, so this is still before any read.
29
+ * 7. `beforeLaunch()` — embedder hook for host-specific pre-launch work that
24
30
  * must attach before DBOS launch re-emits events (scheduled sweeps, an
25
31
  * legacy-workflow reap, an agent-switch install). Runs after registration
26
32
  * so it may close over the registered callables.
27
- * 7. `launchDbos(...)` — the last registration-dependent step.
33
+ * 8. `launchDbos(...)` — the last registration-dependent step.
28
34
  *
29
35
  * Boot-step errors PROPAGATE (the embedder's composition root catches them and
30
36
  * releases whatever it acquired). Only `shutdown` swallows per-step failures —
@@ -20,11 +20,17 @@
20
20
  * misconfigured pool fails loudly at boot, not under load.
21
21
  * 5. `assembleCoreRuntime(core)` — registers the durable-workflow cohort and
22
22
  * builds the conversation agent (register-before-launch invariant).
23
- * 6. `beforeLaunch()` — embedder hook for host-specific pre-launch work that
23
+ * 6. `backfillConversationDisplayEnvelopes(...)` — validates every stored
24
+ * display envelope and freezes one for each legacy turn that has none, so
25
+ * the runtime read path never meets a row without a display projection.
26
+ * Runs AFTER assembly because migrating a legacy call's one-line detail
27
+ * needs the assembled conversation roster, embedder-contributed tools
28
+ * included; assembly starts no traffic, so this is still before any read.
29
+ * 7. `beforeLaunch()` — embedder hook for host-specific pre-launch work that
24
30
  * must attach before DBOS launch re-emits events (scheduled sweeps, an
25
31
  * legacy-workflow reap, an agent-switch install). Runs after registration
26
32
  * so it may close over the registered callables.
27
- * 7. `launchDbos(...)` — the last registration-dependent step.
33
+ * 8. `launchDbos(...)` — the last registration-dependent step.
28
34
  *
29
35
  * Boot-step errors PROPAGATE (the embedder's composition root catches them and
30
36
  * releases whatever it acquired). Only `shutdown` swallows per-step failures —
@@ -33,6 +39,7 @@
33
39
  import { SANDBOX_AGENT_META } from "../agents/sandbox/index.js";
34
40
  import { validateAgentSkills } from "../agents/sandbox/validate-skills.js";
35
41
  import { initCortexState } from "../state/init.js";
42
+ import { backfillConversationDisplayEnvelopes } from "../memory/conversation-display-backfill.js";
36
43
  import { assembleCoreRuntime } from "./assemble.js";
37
44
  import { assertConnectionBudget } from "./connection-budget.js";
38
45
  import { launchDbos, shutdownDbos } from "./dbos.js";
@@ -47,6 +54,11 @@ export async function bootHarness(deps) {
47
54
  await initCortexState(pool);
48
55
  await assertConnectionBudget({ pool, logger, config: deps.connectionBudget });
49
56
  const runtime = assembleCoreRuntime(core);
57
+ await backfillConversationDisplayEnvelopes({
58
+ pool,
59
+ resolveWorkspaceRoot: core.conversation.resolveWorkspaceRoot,
60
+ tools: runtime.conversationAgent.tools,
61
+ });
50
62
  await deps.beforeLaunch?.();
51
63
  await launchDbos({ config: deps.dbos, logger });
52
64
  logger.named("boot").info("harness booted", { executorId: deps.dbos.executorId });
@@ -255,6 +255,9 @@ CREATE TABLE IF NOT EXISTS messages (
255
255
  role TEXT,
256
256
  content_jsonb JSONB,
257
257
  message_envelope JSON,
258
+ -- Versioned AI SDK UIMessage projection for the whole turn. Present only
259
+ -- on the genuine-user-start row. Model-history reads never select it.
260
+ display_envelope JSONB,
258
261
  tokens INTEGER NOT NULL,
259
262
  reported_usage JSONB,
260
263
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
@@ -475,6 +478,7 @@ export async function initCortexState(pool, injected) {
475
478
  ALTER TABLE messages ALTER COLUMN message_envelope TYPE JSON USING message_envelope::text::json;
476
479
  END IF;
477
480
  END $$`,
481
+ "ALTER TABLE messages ADD COLUMN IF NOT EXISTS display_envelope JSONB",
478
482
  // Grant key an 'always' records the standing grant under. Nullable
479
483
  // and unbackfilled: every new row writes it (command when absent), and
480
484
  // orphaned legacy pending rows are swept to 'expired' before any answer
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inflexa-ai/harness",
3
- "version": "0.15.1",
3
+ "version": "0.16.0",
4
4
  "description": "Host-agnostic bioinformatics agent harness \u2014 hand-rolled agent loop, DBOS-durable workflows, sandboxed compute.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",