Skip to content
Merged
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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa

## [Unreleased]

### Fixed
- In the chat of an OmO pane, a background task that ends shows where OmO reported it: a
card that says how many tasks ended, and for each its summary, whether it finished, failed
or was cancelled, the agent and model it ran as, how long it took, its turns, tool calls and
tokens, and its answer on request. Before, the chat showed nothing when a task ended, and
what the agent did after it ran on in the same block as if nothing had come in. The `task`
row reads the summary the call gave its task instead of its short description, and opened
it lists each task it started with its agent and prompt, instead of its raw input or a
checklist of "task 1", "task 2" nobody ticks.
([#477](https://github.com/devswha/herdr-web-ui/pull/477) by @nahwan-kim)

## [0.3.50] - 2026-10-06

### Changed
Expand Down
12 changes: 12 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -588,6 +588,18 @@ One set for both themes: the card is island black wherever it shows.
failure count leave and end in an ellipsis. `· 1 failed` (`.work-block-failed`) is its own item
and is never cut, because the fold hides the failed row; at the largest chat type on the
narrowest phone the title wraps to a second line instead.
- An OmO or omp `task` row opens to the tasks it starts (`.chat-task-calls`): each summary in
`--text`, the agent as a hairline mono pill, the prompt in the bounded mono input box.

### Background tasks ended (`.chat-task-results`)
- Where OmO reports background tasks that ended, the transcript shows one `--bg-elevated` card
(hairline edge, `--radius-lg`) on the prose column: a dim `--fs-xs` line with the layers icon,
"2 background tasks ended" and the time, then one hairline-separated row per task.
- A row is the status icon (`--status-done` check, `--status-blocked` x, dim slash for
cancelled), the task's summary (`--fs-sm`, medium) over a dim `--fs-xs` meta line (agent ·
model · duration · turns · tool calls · tokens), and the status word at the right in the
icon's color, with the skill caret. Opened, the task's answer renders as Markdown, indented to
the title, bounded to 60vh.

### Prompt card (`.prompt-card`)
- Appears in chat while the agent is blocked and the visible pane contains a supported Claude, omp,
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/omo-background-tasks/mobile.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
122 changes: 122 additions & 0 deletions server/conversation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { forgetHistoryChains } from "./codex.ts";
import { ConversationUnavailable, gjcTranscriptPath, HistoryChanged, isOmoProcess, ompSessionPath, parseClaudeTranscript, unwrapPastes, transcriptImage, transcriptPage, transcriptToolOutput } from "./conversation.ts";
import { MAX_TURNS, parseOmpTranscript } from "./transcript-records.ts";
import { toolVerb } from "../src/lib/toolVerbs.ts";
import type { ConversationTurn } from "../shared/protocol.ts";

/** Minimal but shape-true slices of a Claude Code session jsonl. */
const lines = [
Expand Down Expand Up @@ -326,6 +327,69 @@ describe("gjc sessions", () => {
});
});

describe("OmO background task results", () => {
it("draws the background tasks an OmO wake reports as one card in the user's seat, titled by the call that started them", () => {
const text = [
omoUser("2026-10-05T00:00:00.000Z", "look into it"),
omoSpawn("2026-10-05T00:00:01.000Z", "c1", { description: "record location", task_summary: "Find where task records live", subagent_type: "explore", prompt: "TASK: find" }, { task_id: "st_1", task_summary: "Find where task records live" }),
omoSpawn("2026-10-05T00:00:02.000Z", "c2", { tasks: [{ task_summary: "Read shots 1-7", prompt: "a" }, { description: "shots 8-14", prompt: "b" }] }, { items: [{ task_id: "st_2", task_summary: "Read shots 1-7" }, { task_id: "st_3" }] }),
omoWake("2026-10-05T00:01:00.000Z", [
completion({ task_id: "st_1", name: "st_1", status: "completed", agent_type: "explore", resolved_model: { display: "lab/luna" }, model: "lab/luna-raw", duration_ms: 58_350, run_stats: { turns: 6, tool_calls: 14, total_tokens: 210_304 }, final_response: "**Found** it." }),
completion({ task_id: "st_2", name: "st_2", status: "error", category: "visual-engineering", model: "lab/kimi", duration_ms: 9087, run_stats: { turns: 0, tool_calls: 0 }, final_response: "Invalid native tool call event order" }),
// the batch result named no summary for it, and its name is its id: the agent it ran as
completion({ task_id: "st_3", name: "st_3", status: "cancelled", category: "quick" }),
completion({ task_id: "st_4", name: "named-run", status: "completed", final_response: "x".repeat(16_005) }),
]),
JSON.stringify({ type: "message", timestamp: "2026-10-05T00:01:02.000Z", message: { role: "assistant", content: [{ type: "text", text: "The record lives in .omo." }] } }),
].join("\n");
const turns = parseOmpTranscript(text);
expect(turns.map((turn) => turn.role)).toEqual(["user", "assistant", "user", "assistant"]);
expect(turns[2]).toEqual({ role: "user", ts: "2026-10-05T00:01:00.000Z", parts: [{ kind: "task_result", tasks: [
{ id: "st_1", title: "Find where task records live", agent: "explore", model: "lab/luna", status: "completed", duration_ms: 58_350, turns: 6, tool_calls: 14, tokens: 210_304, result: "**Found** it." },
{ id: "st_2", title: "Read shots 1-7", agent: "visual-engineering", model: "lab/kimi", status: "failed", duration_ms: 9087, turns: 0, tool_calls: 0, tokens: null, result: "Invalid native tool call event order" },
{ id: "st_3", title: "quick", agent: "quick", model: null, status: "cancelled", duration_ms: null, turns: null, tool_calls: null, tokens: null, result: "" },
{ id: "st_4", title: "named-run", agent: null, model: null, status: "completed", duration_ms: null, turns: null, tool_calls: null, tokens: null, result: "x".repeat(16_000), result_cut: true },
] }] });
// the row of the call reads the summary it gave, one per task of a batch
expect(turns[1]!.parts.map((part) => part.kind === "tool" ? part.summary : part.kind)).toEqual(["Find where task records live", "Read shots 1-7 · shots 8-14"]);
});

it("leaves a wake that reports no task result to the runtime", () => {
const text = [
omoUser("2026-10-05T00:00:00.000Z", "watch the build"),
JSON.stringify({ type: "custom_message", customType: "omo-senpi:wake", display: false, timestamp: "2026-10-05T00:01:00.000Z", content: "monitor fired", details: [{ customType: "senpi-monitor:notification", details: [{ line: "READY" }] }] }),
JSON.stringify({ type: "custom_message", customType: "omo-senpi:wake", display: false, timestamp: "2026-10-05T00:01:01.000Z", content: "empty", details: [{ customType: "senpi-task.completion", details: [{ name: "no id", status: "completed" }] }] }),
].join("\n");
expect(parseOmpTranscript(text).map((turn) => turn.parts.map((part) => part.kind))).toEqual([["text"]]);
});

it("draws a task reported twice in one wake once, as its later report", () => {
const text = [
omoUser("2026-10-05T00:00:00.000Z", "go"),
omoWake("2026-10-05T00:01:00.000Z", [
completion({ task_id: "st_1", name: "st_1", status: "completed", agent_type: "explore", final_response: "first" }),
completion({ task_id: "st_2", name: "st_2", status: "completed", agent_type: "explore", final_response: "other" }),
completion({ task_id: "st_1", name: "st_1", status: "error", agent_type: "explore", final_response: "again" }),
]),
].join("\n");
const ended = parseOmpTranscript(text)[1]?.parts[0];
expect(ended?.kind === "task_result" ? ended.tasks.map((task) => `${task.id} ${task.status} ${task.result}`) : ended).toEqual(["st_1 failed again", "st_2 completed other"]);
});
});

const omoUser = (ts: string, text: string) => JSON.stringify({ type: "message", timestamp: ts, message: { role: "user", content: [{ type: "text", text }] } });
/** an OmO `task` call and the result that names the tasks it started */
const omoSpawn = (ts: string, id: string, input: Record<string, unknown>, details: Record<string, unknown>) => [
JSON.stringify({ type: "message", timestamp: ts, message: { role: "assistant", content: [{ type: "toolCall", id, name: "task", arguments: { run_in_background: true, ...input } }] } }),
JSON.stringify({ type: "message", timestamp: ts, message: { role: "toolResult", toolCallId: id, toolName: "task", content: [{ type: "text", text: "Started task (running)." }], details: { status: "running", mode: "spawn", ...details } } }),
].join("\n");
const completion = (fields: Record<string, unknown>) => ({ continuation_hint: "Use task_send to continue.", ...fields });
/** how OmO wakes its agent when background tasks end */
const omoWake = (ts: string, tasks: Record<string, unknown>[]) => JSON.stringify({
type: "custom_message", customType: "omo-senpi:wake", display: false, timestamp: ts,
content: "task completion …", details: [{ customType: "senpi-task.completion", details: tasks }],
});

describe("transcript pages", () => {
const roots: string[] = [];
afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); });
Expand All @@ -340,6 +404,64 @@ describe("transcript pages", () => {
const texts = (turns: { parts: { kind: string; text?: string; output?: string }[] }[]) =>
turns.map((turn) => turn.parts.map((part) => part.kind === "tool" ? `[${part.output}]` : part.text).join(" "));

it("titles a background task that ends a prompt after the one that started it, live and cold alike", () => {
const root = temp();
const path = join(root, "omo.jsonl");
writeFileSync(path, `${[
omoUser("2026-10-05T00:00:00.000Z", "start it"),
omoSpawn("2026-10-05T00:00:01.000Z", "c1", { task_summary: "Survey the repo", subagent_type: "explore", prompt: "go" }, { task_id: "st_1", task_summary: "Survey the repo" }),
].join("\n")}\n`);
// the first read settles nothing yet; the next prompt makes the spawn's turn settled
expect(transcriptPage("omo-transcript", path).turns).toHaveLength(2);
appendFileSync(path, `${omoUser("2026-10-05T00:02:00.000Z", "meanwhile, something else")}\n`);
expect(transcriptPage("omo-transcript", path).turns).toHaveLength(3);
appendFileSync(path, `${omoWake("2026-10-05T00:03:00.000Z", [completion({ task_id: "st_1", name: "st_1", status: "completed", agent_type: "explore", final_response: "done" })])}\n`);
const cold = join(root, "cold.jsonl");
copyFileSync(path, cold);
for (const page of [transcriptPage("omo-transcript", path), transcriptPage("omo-transcript", cold)]) {
const ended = page.turns.at(-1)?.parts[0];
expect(ended?.kind === "task_result" ? ended.tasks.map((task) => task.title) : ended).toEqual(["Survey the repo"]);
}
});

const endedTitles = (page: { turns: ConversationTurn[] }): unknown => {
const ended = page.turns.at(-1)?.parts[0];
return ended?.kind === "task_result" ? ended.tasks.map((task) => task.title) : ended;
};

it("keeps a task's title when the newest page moves past the turn that started it, while the file is watched", () => {
const path = join(temp(), "omo.jsonl");
writeFileSync(path, `${[
omoUser("2026-10-05T00:00:00.000Z", "start it"),
omoSpawn("2026-10-05T00:00:01.000Z", "c1", { task_summary: "Survey the repo", subagent_type: "explore", prompt: "go" }, { task_id: "st_1", task_summary: "Survey the repo" }),
].join("\n")}\n`);
// polled as it grows: the page start passes the spawning turn once more prompts than a page holds followed
for (let n = 0; n < MAX_TURNS / 2 + 2; n += 1) {
expect(transcriptPage("omo-transcript", path).turns.length).toBeGreaterThan(0);
appendFileSync(path, `${omoUser(`2026-10-05T00:${String(10 + Math.floor(n / 60)).padStart(2, "0")}:${String(n % 60).padStart(2, "0")}.000Z`, `prompt ${n}`)}\n`);
}
const before = transcriptPage("omo-transcript", path);
expect(before.turns[0]?.parts[0]).toEqual({ kind: "text", text: "prompt 2" });
appendFileSync(path, `${omoWake("2026-10-05T00:20:00.000Z", [completion({ task_id: "st_1", name: "st_1", status: "completed", agent_type: "explore", final_response: "done" })])}\n`);
expect(endedTitles(transcriptPage("omo-transcript", path))).toEqual(["Survey the repo"]);
});

it("titles a wake read before the call that names its task as a cold read does, on every poll", () => {
const root = temp();
const path = join(root, "omo.jsonl");
writeFileSync(path, `${[
omoUser("2026-10-05T00:00:00.000Z", "start it"),
omoWake("2026-10-05T00:00:01.000Z", [completion({ task_id: "st_1", name: "st_1", status: "completed", agent_type: "explore", final_response: "done" })]),
omoSpawn("2026-10-05T00:00:02.000Z", "c1", { task_summary: "Survey the repo", subagent_type: "explore", prompt: "go" }, { task_id: "st_1", task_summary: "Survey the repo" }),
].join("\n")}\n`);
const titles = (page: { turns: ConversationTurn[] }) => page.turns.flatMap((turn) => turn.parts.flatMap((part) => part.kind === "task_result" ? part.tasks.map((task) => task.title) : []));
expect(titles(transcriptPage("omo-transcript", path))).toEqual(["explore"]);
appendFileSync(path, `${JSON.stringify({ type: "message", timestamp: "2026-10-05T00:00:03.000Z", message: { role: "assistant", content: [{ type: "text", text: "noted" }] } })}\n`);
const cold = join(root, "cold.jsonl");
copyFileSync(path, cold);
expect(titles(transcriptPage("omo-transcript", path))).toEqual(titles(transcriptPage("omo-transcript", cold)));
});

it("reads a growing file's newest page incrementally, exactly as a cold read of it", () => {
const root = temp();
const path = join(root, "session.jsonl");
Expand Down
18 changes: 13 additions & 5 deletions server/conversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -516,6 +516,8 @@ interface SettledTurns {
metadata: ConversationMetadata;
/** the bytes just before `end` (see LiveScan.tail) */
tail: string;
/** what OmO's `task` calls on the page called their tasks: a task can end in a later turn than the one that started it */
taskTitles: Map<string, string>;
}
const settledTurns = new Map<string, SettledTurns>();

Expand Down Expand Up @@ -557,12 +559,12 @@ function newestPage(path: string, stream: TranscriptStream, source: RecognizedCo
return start === undefined ? null : { start, starts };
}

function parseTurns(source: RecognizedConversation["source"], text: string): ConversationTurn[] {
function parseTurns(source: RecognizedConversation["source"], text: string, taskTitles?: Map<string, string>): ConversationTurn[] {
return source === "codex-transcript" ? parseCodexTranscript(text, Infinity)
// only pi keeps a tool's images in the entry as base64; omp, omo and gjc are read the same
// way but would carry image refs nothing can answer, so the option stays with pi alone
: source === "claude-transcript" ? parseClaudeTranscript(text, Infinity)
: parseOmpTranscript(text, Infinity, { toolImages: source === "pi-transcript" });
: parseOmpTranscript(text, Infinity, { toolImages: source === "pi-transcript", taskTitles });
}

interface LiveCodexTurn {
Expand Down Expand Up @@ -627,19 +629,25 @@ function liveTurns(path: string, stream: TranscriptStream, source: RecognizedCon
let settled = settledTurns.get(key);
if (!settled || settled.id !== stream.id || settled.end > last || bytesBefore(stream, settled.end) !== settled.tail) {
const head = start > stream.floor ? metadataHead(path, stream, source, start) : "";
settled = { id: stream.id, start, end: start, turns: [], metadata: parseConversationMetadata(`${head}\n`, source), tail: bytesBefore(stream, start) };
// a page that starts past the turn that started a task keeps the title that turn gave it,
// as long as this stream was watched while the title was on a page (a cold read cannot)
const earlier = [...settledTurns.values()].filter((kept) => kept.id === stream.id && kept.start < start && kept.end <= start && kept.taskTitles.size > 0);
const taskTitles = new Map(earlier.flatMap((kept) => [...kept.taskTitles]));
settled = { id: stream.id, start, end: start, turns: [], metadata: parseConversationMetadata(`${head}\n`, source), tail: bytesBefore(stream, start), taskTitles };
}
if (settled.end < last) {
const text = readStream(stream, settled.end, last).toString("utf8");
settled = { ...settled, end: last, turns: [...settled.turns, ...parseTurns(source, text)], metadata: parseConversationMetadata(text, source, settled.metadata), tail: bytesBefore(stream, last) };
settled = { ...settled, end: last, turns: [...settled.turns, ...parseTurns(source, text, settled.taskTitles)], metadata: parseConversationMetadata(text, source, settled.metadata), tail: bytesBefore(stream, last) };
}
remember(settledTurns, key, settled, 8);
if (source === "codex-transcript") {
const live = codexLiveTurn(path, stream, last, settled.metadata);
return { turns: [...settled.turns, ...live.turns], metadata: live.metadata };
}
const text = readStream(stream, last, stream.length).toString("utf8");
return { turns: [...settled.turns, ...parseTurns(source, text)], metadata: parseConversationMetadata(text, source, settled.metadata) };
// the live turn is parsed again on every poll: what it teaches about titles is kept only
// once it settles, so a read titles a task exactly as a cold read of the same bytes does
return { turns: [...settled.turns, ...parseTurns(source, text, new Map(settled.taskTitles))], metadata: parseConversationMetadata(text, source, settled.metadata) };
}

/** Forget every scan and parse kept between polls (tests compare against a cold read). */
Expand Down
Loading
Loading