diff --git a/changelog.d/incremental-omo-gjc-transcripts.md b/changelog.d/incremental-omo-gjc-transcripts.md new file mode 100644 index 0000000000..f66dae056f --- /dev/null +++ b/changelog.d/incremental-omo-gjc-transcripts.md @@ -0,0 +1,4 @@ +### Changed +- Growing OMO and GJC chats now parse appended transcript records incrementally, sharing the bounded + live parser cache already used by OMP, Claude Code and Codex. Tool results, task completions, + runtime notices, history resets and earlier responses keep their existing behavior. diff --git a/server/conversation.ts b/server/conversation.ts index 6b1aeed761..e2d79e412d 100644 --- a/server/conversation.ts +++ b/server/conversation.ts @@ -1084,7 +1084,7 @@ function parseTurns(source: RecognizedConversation["source"], path: string, text : parseOmpTranscript(text, Infinity, { toolImages: source === "pi-transcript" || source === "cmd-transcript", taskTitles }); } -type IncrementalSource = "codex-transcript" | "omp-transcript" | "claude-transcript"; +type IncrementalSource = "codex-transcript" | "omp-transcript" | "omo-transcript" | "gjc-transcript" | "claude-transcript"; interface LiveTranscriptTurn { source: IncrementalSource; @@ -1125,7 +1125,7 @@ function incrementalLiveTurn(path: string, stream: TranscriptStream, source: Inc } } } - // OMP and Claude used to reread the live task, so growth must not hide an in-place edit. + // Non-Codex readers used to reread the live task, so growth must not hide an in-place edit. // Verify committed bytes without decoding or reparsing them; keep only their digest. const currentBytes = source === "codex-transcript" ? undefined : readStream(stream, start, stream.length); let digest = currentBytes ? createHash("sha256") : undefined; @@ -1140,8 +1140,8 @@ function incrementalLiveTurn(path: string, stream: TranscriptStream, source: Inc if (!cached) { const subagents = source === "claude-transcript" ? { ids: new Set(), details: new Map() } : undefined; const parser = source === "codex-transcript" ? createCodexTranscriptParser() - : source === "omp-transcript" ? createOmpTranscriptParser({ taskTitles: new Map(before.taskTitles) }) - : createClaudeTranscriptParser({ subagents: subagents!.details, noticed: new Set(before.noticed) }); + : source === "claude-transcript" ? createClaudeTranscriptParser({ subagents: subagents!.details, noticed: new Set(before.noticed) }) + : createOmpTranscriptParser({ taskTitles: new Map(before.taskTitles) }); cached = { source, id: stream.id, start, pageStart: before.start, prefixBoundary: before.tail, scanned: start, boundary: bytesBefore(stream, start), parser, metadata: before.metadata, subagents }; } @@ -1166,7 +1166,7 @@ function incrementalLiveTurn(path: string, stream: TranscriptStream, source: Inc cached.boundary = bytesBefore(stream, cached.scanned); } if (digest) cached.contentHash = digest.update(bytes.subarray(0, complete)).digest("hex"); - // The original Codex cache's entry/source-byte budgets are shared by all three readers. + // All incremental readers share the original Codex cache's entry/source-byte budgets. // Retain normalized state only, never raw source records or the speculative tail. remember(liveTranscriptTurns, path, cached, 8); let retained = [...liveTranscriptTurns.values()].reduce((sum, turn) => sum + turn.scanned - turn.start, 0); @@ -1226,7 +1226,7 @@ function liveTurns(path: string, stream: TranscriptStream, source: RecognizedCon } settled.observedEnd = stream.length; rememberPage(settledTurns, key, settled, 8); - if (source === "codex-transcript" || source === "omp-transcript" || source === "claude-transcript") { + if (source === "codex-transcript" || source === "omp-transcript" || source === "omo-transcript" || source === "gjc-transcript" || source === "claude-transcript") { const live = incrementalLiveTurn(path, stream, source, last, settled); return { turns: [...settled.turns, ...live.turns], metadata: live.metadata }; } diff --git a/server/native-incremental.test.ts b/server/native-incremental.test.ts index f03594914c..104f208d53 100644 --- a/server/native-incremental.test.ts +++ b/server/native-incremental.test.ts @@ -5,7 +5,8 @@ import { dirname, join } from "node:path"; import type { ConversationTurn } from "../shared/protocol.ts"; import { forgetTranscriptState, HistoryChanged, transcriptPage } from "./conversation.ts"; -type Source = "omp-transcript" | "claude-transcript"; +const SOURCES = ["omp-transcript", "omo-transcript", "gjc-transcript", "claude-transcript"] as const; +type Source = typeof SOURCES[number]; const roots: string[] = []; const jsonl = (...entries: unknown[]) => entries.map(entry => JSON.stringify(entry) + "\n").join(""); const fixture = (text: string) => { @@ -15,13 +16,13 @@ const fixture = (text: string) => { writeFileSync(path, text); return path; }; -const message = (source: Source, role: string, content: unknown) => ({ type: source === "omp-transcript" ? "message" : role, message: { role, content } }); +const message = (source: Source, role: string, content: unknown) => ({ type: source === "claude-transcript" ? role : "message", message: { role, content } }); const prompt = (source: Source, text: string) => message(source, "user", [{ type: "text", text }]); const answer = (source: Source, text: string) => message(source, "assistant", [{ type: "text", text }]); -const call = (source: Source, id: string) => message(source, "assistant", [source === "omp-transcript" +const call = (source: Source, id: string) => message(source, "assistant", [source !== "claude-transcript" ? { type: "toolCall", id, name: "bash", arguments: { command: "printf synthetic" } } : { type: "tool_use", id, name: "Bash", input: { command: "printf synthetic" } }]); -const result = (source: Source, id: string, text: string) => source === "omp-transcript" +const result = (source: Source, id: string, text: string) => source !== "claude-transcript" ? { type: "message", message: { role: "toolResult", toolCallId: id, content: [{ type: "text", text }] } } : message(source, "user", [{ type: "tool_result", tool_use_id: id, content: text }]); const textParts = (turns: ConversationTurn[]) => turns.flatMap(turn => turn.parts.flatMap(part => part.kind === "text" ? [part.text] : [])); @@ -31,7 +32,7 @@ afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); -for (const source of ["omp-transcript", "claude-transcript"] as const) { +for (const source of SOURCES) { test(`${source}: a torn UTF-8 result previews without consuming the pending call or mutating earlier responses`, () => { const path = fixture(jsonl(prompt(source, "Read the result"), call(source, "read"))); const initial = transcriptPage(source, path); @@ -96,7 +97,7 @@ for (const source of ["omp-transcript", "claude-transcript"] as const) { const first = fixture(jsonl(prompt(source, "first"), call(source, "shared-id"))); const original = transcriptPage(source, first); for (let i = 0; i < 12; i++) { - const otherSource = i % 2 ? "omp-transcript" : "claude-transcript"; + const otherSource = SOURCES[i % SOURCES.length]!; const other = fixture(jsonl(prompt(otherSource, `other ${i}`), call(otherSource, "shared-id"), result(otherSource, "shared-id", `other result ${i}`))); expect(toolParts(transcriptPage(otherSource, other).turns)[0]?.output).toBe(`other result ${i}`); } @@ -106,6 +107,95 @@ for (const source of ["omp-transcript", "claude-transcript"] as const) { }); } +for (const source of ["omo-transcript", "gjc-transcript"] as const) { + test(`${source}: speculative settings never replace committed metadata and clear resets the old context`, () => { + const path = fixture(jsonl( + { type: "model_change", modelId: "initial-model" }, + { type: "thinking_level_change", thinkingLevel: "high" }, + prompt(source, "Initial question"), + { type: "message", message: { role: "assistant", content: [{ type: "text", text: "Initial answer" }], usage: { input: 100, cacheRead: 20, cacheWrite: 5 } } }, + )); + const initial = transcriptPage(source, path); + expect(initial.metadata).toEqual({ model: "initial-model", reasoning_effort: "high", context: { used: 125, window: null } }); + appendFileSync(path, JSON.stringify({ type: "model_change", modelId: "preview-model" })); + const preview = transcriptPage(source, path); + expect(preview.metadata.model).toBe("preview-model"); + appendFileSync(path, " discarded\n" + jsonl({ type: "thinking_level_change", thinkingLevel: "low" })); + const committed = transcriptPage(source, path); + expect(committed.metadata).toEqual({ model: "initial-model", reasoning_effort: "low", context: { used: 125, window: null } }); + expect(initial.metadata.reasoning_effort).toBe("high"); + expect(preview.metadata.model).toBe("preview-model"); + appendFileSync(path, jsonl({ type: "custom", customType: "context_clear" }, prompt(source, "New context"))); + const cleared = transcriptPage(source, path); + expect(cleared.metadata).toEqual({ model: null, reasoning_effort: null }); + expect(textParts(cleared.turns)).toEqual(["New context"]); + forgetTranscriptState(); + const cold = transcriptPage(source, path); + expect(cold.turns).toEqual(cleared.turns); + expect(cold.metadata).toEqual(cleared.metadata); + }); +} + +test("OMO task completions ignore a discarded title preview and retain the committed title after a wake", () => { + const source = "omo-transcript"; + const path = fixture(jsonl(prompt(source, "Review the parser"), { + type: "message", message: { role: "assistant", content: [{ type: "toolCall", id: "spawn", name: "task", arguments: { task_summary: "Review the parser" } }] }, + })); + const initial = transcriptPage(source, path); + const title = { + type: "message", message: { role: "toolResult", toolName: "task", toolCallId: "spawn", content: "Started", details: { task_id: "review", task_summary: "Discarded title" } }, + }; + appendFileSync(path, JSON.stringify(title)); + const preview = transcriptPage(source, path); + expect(toolParts(preview.turns)[0]?.output).toBe("Started"); + title.message.details.task_summary = "Committed review"; + appendFileSync(path, " discarded\n" + jsonl(title)); + const started = transcriptPage(source, path); + expect(toolParts(started.turns)[0]?.output).toBe("Started"); + const completed = { type: "message", message: { role: "assistant", stopReason: "stop", content: [{ type: "text", text: "Waiting for the review" }] } }; + appendFileSync(path, jsonl(completed, { + type: "custom_message", customType: "omo-senpi:wake", display: false, + details: [{ customType: "senpi-task.completion", details: [{ task_id: "review", status: "completed", agent_type: "reviewer", final_response: "No regression found" }] }], + })); + const woken = transcriptPage(source, path); + const task = woken.turns.at(-1)?.parts[0]; + expect(task).toMatchObject({ kind: "task_result", tasks: [{ id: "review", title: "Committed review", status: "completed", result: "No regression found" }] }); + appendFileSync(path, jsonl(answer(source, "Review applied"))); + const finished = transcriptPage(source, path); + expect(finished.turns.map(turn => turn.role)).toEqual(["user", "assistant", "user", "assistant"]); + expect(finished.turns[1]?.parts.at(-1)).toEqual({ kind: "text", text: "Waiting for the review", phase: "final_answer" }); + expect(toolParts(initial.turns)[0]?.output).toBe(""); + expect(woken.turns.map(turn => turn.role)).toEqual(["user", "assistant", "user"]); + forgetTranscriptState(); + expect(transcriptPage(source, path).turns).toEqual(finished.turns); +}); + +test("GJC background deliveries keep final answers separate and internal IRC stays hidden across refreshes", () => { + const source = "gjc-transcript"; + const path = fixture(jsonl(prompt(source, "Run the build"), { + type: "message", message: { role: "assistant", stopReason: "stop", content: [{ type: "text", text: "Build started" }] }, + })); + const initial = transcriptPage(source, path); + for (const customType of ["irc:incoming", "irc:autoreply", "irc:relay"]) { + appendFileSync(path, jsonl({ type: "custom_message", customType, attribution: "agent", display: true, content: "Internal delivery" })); + expect(transcriptPage(source, path).turns).toEqual(initial.turns); + } + appendFileSync(path, jsonl({ type: "custom_message", customType: "async-result", display: true, content: "Build passed" })); + const delivered = transcriptPage(source, path); + expect(delivered.turns.at(-1)?.parts).toEqual([{ kind: "notice", source: "async-result", text: "Build passed" }]); + appendFileSync(path, jsonl(call(source, "read"), result(source, "read", "Build receipt"), { + type: "custom_message", customType: "async-result", display: false, content: "Hidden runtime notice", + }, answer(source, "Checked the receipt"))); + const finished = transcriptPage(source, path); + expect(finished.turns.map(turn => turn.role)).toEqual(["user", "assistant", "user", "assistant"]); + expect(finished.turns[1]?.parts).toEqual([{ kind: "text", text: "Build started", phase: "final_answer" }]); + expect(toolParts(finished.turns)[0]?.output).toBe("Build receipt"); + expect(textParts(finished.turns)).toEqual(["Run the build", "Build started", "Checked the receipt"]); + expect(delivered.turns.at(-1)?.parts).toEqual([{ kind: "notice", source: "async-result", text: "Build passed" }]); + forgetTranscriptState(); + expect(transcriptPage(source, path).turns).toEqual(finished.turns); +}); + test("Claude live completion cards adopt late meta files without changing earlier snapshots", () => { const source = "claude-transcript"; const content = 'reviewagent-callcompletedAgent "Fallback" finishedChecked the result.';