/**
* File-backed message content: the JSONL delta format and the ref resolver.
*
* A `messages.content` value is a union of two shapes:
* - Inline: a JSON-serialized `ContentBlock[]` (or a legacy plain string).
* - Ref: `{ "ref": "conversations/
/inflight/.jsonl" }`
* pointing at a JSONL delta file that folds to a
* `ContentBlock[]`.
*
* The ref path is workspace-relative and MUST live under the reserved
* `conversations/` prefix with a `.jsonl` extension — the schema rejects
* anything else. This is the migration-safe discriminator against legacy
* plain-string rows: a legacy message whose entire text happens to parse as
* `{ "ref": … }` is only misread if it also names a reserved content path,
* which no organic legacy text does.
*
* The delta file is append-only. Each line is `{ i, seq, block }` where `i`
* is the block index in the final array and `seq` is a monotonically
* increasing write sequence. Folding keeps the highest-`seq` line per `i`
* and orders the surviving blocks by `i`, so a partially-flushed or
* crash-truncated file still folds to the newest complete snapshot of each
* block.
*
* {@link resolveMessageContentBlocks} is the single resolution seam:
* consumers take the raw stored string off the message row and resolve it
* to typed blocks here — a raw `{ ref }` value is never interpreted
* anywhere else.
*/
import { appendFileSync, mkdirSync, readFileSync } from "node:fs";
import { dirname, resolve, sep } from "node:path";
import { z } from "zod";
import { contentBlockSchema } from "../providers/content-block-schema.js";
import type { ContentBlock } from "../providers/types.js";
import { getLogger } from "../util/logger.js";
import { getWorkspaceDir } from "../util/platform.js";
const log = getLogger("message-content-file");
/**
* Ref-shaped `messages.content` value. The reserved-path constraint is the
* discriminator that keeps legacy plain-string rows that parse as arbitrary
* JSON objects from being mistaken for a ref.
*/
export const messageContentRefSchema = z.object({
ref: z
.string()
.regex(
/^conversations\/.+\.jsonl$/,
"content refs must be workspace-relative paths under conversations/ ending in .jsonl",
),
});
export type MessageContentRef = z.infer;
/** One line of the append-only content delta file. */
const contentDeltaLineSchema = z.object({
/** Index of `block` in the folded `ContentBlock[]`. */
i: z.number(),
/** Monotonic write sequence; the highest `seq` per `i` wins the fold. */
seq: z.number(),
block: contentBlockSchema,
});
export type ContentDeltaLine = z.infer;
/**
* Parse a raw `messages.content` string to a ref, or null when the value is
* inline content. The charCode fast path keeps the overwhelmingly common
* inline-array case to a single character check.
*/
export function parseContentRef(raw: string): MessageContentRef | null {
if (raw.charCodeAt(0) !== 0x7b /* '{' */) {
return null;
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return null;
}
const result = messageContentRefSchema.safeParse(parsed);
return result.success ? result.data : null;
}
/**
* Resolve a workspace-relative content ref to an absolute path, rejecting
* anything that escapes the workspace directory (e.g. a ref containing
* `..` segments that still matched the reserved-prefix schema). Returns
* null on escape.
*/
export function resolveContentRefPath(ref: string): string | null {
const workspaceDir = resolve(getWorkspaceDir());
const abs = resolve(workspaceDir, ref);
if (abs !== workspaceDir && !abs.startsWith(workspaceDir + sep)) {
return null;
}
return abs;
}
/**
* Append delta lines to a content file, creating parent directories on
* first write. Callers own write exclusivity — a message's delta file has
* exactly one writer (the owning turn) for its lifetime.
*/
export function appendContentDeltas(
absPath: string,
deltas: ContentDeltaLine[],
): void {
if (deltas.length === 0) {
return;
}
mkdirSync(dirname(absPath), { recursive: true });
appendFileSync(absPath, deltas.map((d) => JSON.stringify(d) + "\n").join(""));
}
/**
* Fold raw JSONL delta text into a `ContentBlock[]`: highest `seq` per
* block index wins, blocks ordered by index. Malformed lines (including a
* crash-truncated final line) are skipped.
*/
export function foldContentDeltas(text: string): ContentBlock[] {
const byIndex = new Map();
for (const line of text.split("\n")) {
if (!line) {
continue;
}
let parsed: unknown;
try {
parsed = JSON.parse(line);
} catch {
continue;
}
const result = contentDeltaLineSchema.safeParse(parsed);
if (!result.success) {
continue;
}
const { i, seq, block } = result.data;
const existing = byIndex.get(i);
if (!existing || seq > existing.seq) {
byIndex.set(i, { seq, block });
}
}
return [...byIndex.entries()]
.sort(([a], [b]) => a - b)
.map(([, entry]) => entry.block);
}
/**
* Read and fold a content delta file. Returns null when the file is
* missing or unreadable — callers decide the fallback.
*/
export function foldContentFile(absPath: string): ContentBlock[] | null {
let text: string;
try {
text = readFileSync(absPath, "utf8");
} catch {
return null;
}
return foldContentDeltas(text);
}
/** Fold a validated ref to blocks; empty content on missing/escaping refs. */
function resolveRefToBlocks(ref: MessageContentRef): ContentBlock[] {
const absPath = resolveContentRefPath(ref.ref);
if (!absPath) {
log.warn(
{ ref: ref.ref },
"Content ref escapes the workspace directory; resolving as empty",
);
return [];
}
const blocks = foldContentFile(absPath);
if (blocks === null) {
// Normal for a row born in-flight that has not partial-flushed yet
// (fast replies inside the debounce window) — the file only exists
// once the first flush appends to it.
log.debug(
{ ref: ref.ref },
"Content ref file missing or unreadable; resolving as empty",
);
return [];
}
return blocks;
}
/** Serialize an arbitrary value for embedding in a repaired text block. */
function safeJson(value: unknown): string {
try {
return JSON.stringify(value) ?? "";
} catch {
return String(value);
}
}
/**
* Repair a historical block that fails the current schema. Field-level
* repair for the variants whose string fields consumers touch directly
* (text, tool_result, web_search_tool_result); any other block that at
* least carries a string `type` passes through untouched — a persisted
* kind the union does not model yet is live data whose renderers own its
* shape. Only type-less values are wrapped in a text block carrying their
* serialized payload.
*/
function coerceLegacyBlock(block: unknown): ContentBlock {
if (typeof block === "object" && block !== null) {
const rec = block as Record;
if (rec.type === "text") {
return {
type: "text",
text: typeof rec.text === "string" ? rec.text : safeJson(rec.text),
};
}
if (rec.type === "tool_result" || rec.type === "web_search_tool_result") {
const toolUseId =
typeof rec.tool_use_id === "string" ? rec.tool_use_id : "";
if (rec.type === "web_search_tool_result") {
// content is opaque (provider-specific) — only the id needs repair.
return {
type: "web_search_tool_result",
tool_use_id: toolUseId,
content: rec.content,
};
}
return {
type: "tool_result",
tool_use_id: toolUseId,
content:
typeof rec.content === "string" ? rec.content : safeJson(rec.content),
...(typeof rec.is_error === "boolean"
? { is_error: rec.is_error }
: {}),
};
}
if (typeof rec.type === "string") {
return block as ContentBlock;
}
}
return { type: "text", text: safeJson(block) };
}
/**
* Resolve a stored `messages.content` value to a `ContentBlock[]`.
*
* This is the expressive form of the resolver:
* - Inline `ContentBlock[]` JSON parses to its blocks.
* - A `{ ref }` folds its delta file (empty on missing/escaping refs).
* - A JSON string unwraps to its parsed value as a single text block
* (parity with the legacy readers); any other legacy plain string or
* non-array JSON becomes a text block carrying the raw value.
*
* Every returned block is guaranteed to satisfy the ContentBlock schema —
* historical rows with malformed or retired block shapes are repaired
* per-block ({@link coerceLegacyBlock}) rather than passed through, so
* consumers can use variant fields without runtime shape guards.
*
* Never throws — content reads must not take down read paths.
*/
export function resolveMessageContentBlocks(raw: unknown): ContentBlock[] {
if (typeof raw !== "string") {
log.warn(
{ rawType: typeof raw },
"Non-string stored message content; resolving as empty",
);
return [];
}
const ref = parseContentRef(raw);
if (ref) {
return resolveRefToBlocks(ref);
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
// legacy plain string
return [{ type: "text", text: raw }];
}
if (typeof parsed === "string") {
return [{ type: "text", text: parsed }];
}
if (Array.isArray(parsed)) {
return resolveInlineBlockArray(parsed);
}
return [{ type: "text", text: raw }];
}
/**
* Resolve an already-parsed inline content array to schema-valid blocks.
* Valid arrays pass through untouched; historical rows with malformed or
* retired block shapes are repaired per block ({@link coerceLegacyBlock}),
* so the returned array always satisfies the ContentBlock schema. Exported
* for callers that have already parsed the stored JSON and must not pay a
* second parse.
*/
export function resolveInlineBlockArray(parsed: unknown[]): ContentBlock[] {
const result = z.array(contentBlockSchema).safeParse(parsed);
if (result.success) {
return result.data;
}
// Only the rare invalid row pays the per-block repair.
log.warn(
{ issueCount: result.error.issues.length },
"Inline content array has invalid block shapes; repairing per block",
);
return parsed.map((block) => {
const one = contentBlockSchema.safeParse(block);
return one.success ? one.data : coerceLegacyBlock(block);
});
}