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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions changelog.d/incremental-omo-gjc-transcripts.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 6 additions & 6 deletions server/conversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -1140,8 +1140,8 @@ function incrementalLiveTurn(path: string, stream: TranscriptStream, source: Inc
if (!cached) {
const subagents = source === "claude-transcript" ? { ids: new Set<string>(), details: new Map<string, SubagentDetail>() } : 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 };
}
Expand All @@ -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);
Expand Down Expand Up @@ -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 };
}
Expand Down
102 changes: 96 additions & 6 deletions server/native-incremental.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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] : []));
Expand All @@ -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);
Expand Down Expand Up @@ -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}`);
}
Expand All @@ -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: "<system-notice>Build passed</system-notice>" }));
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 = '<task-notification><task-id>review</task-id><tool-use-id>agent-call</tool-use-id><status>completed</status><summary>Agent "Fallback" finished</summary><result>Checked the result.</result></task-notification>';
Expand Down
Loading