From ce7b726c9aa784b757672bb4df0a3f73482a6939 Mon Sep 17 00:00:00 2001 From: Flavien Darche Date: Fri, 16 Oct 2026 01:04:04 +0200 Subject: [PATCH] fix(tools): show eval output and finish detached eval cards Running eval cards now carry the cell output tail in metadata.output. When OmO settles a detached cell it sends a hidden senpi-codemode:notification; the card that only said "detached and is running" is now completed with the final output, or marked as an error when the cell was cancelled or killed, both live and when history is rebuilt from the session file. --- CHANGELOG.md | 8 ++ src/pi-event-translation.ts | 24 +++++ src/session-history.ts | 11 +++ src/tool-map.ts | 42 ++++++++ test/detached-eval.test.ts | 184 ++++++++++++++++++++++++++++++++++++ 5 files changed, 269 insertions(+) create mode 100644 test/detached-eval.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bc5aa3..a0ed6f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project uses [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Fixed + +- OmO `eval` cards now show their output. A running cell streams its output + into the card, and a cell that detached after the foreground window is + finished with its final output (or marked as an error when it was cancelled + or killed) once OmO's completion notice arrives, live and after a reload. + Before, the card stopped at "detached and is running". + ## [0.2.8] - 2026-10-15 - fix(session): resend bare / commands as /skill: diff --git a/src/pi-event-translation.ts b/src/pi-event-translation.ts index fa2f513..7634d33 100644 --- a/src/pi-event-translation.ts +++ b/src/pi-event-translation.ts @@ -19,7 +19,10 @@ import type { SessionStore } from "./session-store.js"; import type { SubagentBridge } from "./subagents.js"; import type { MonitorSessions } from "./monitor-sessions.js"; import { + applyDetachedEval, applyProviderToolResults, + detachedEvalResults, + EVAL_NOTIFICATION_TYPE, finishProviderToolParts, mapTool, providerToolParts, @@ -312,6 +315,7 @@ export class PiEventTranslator { onPiEvent(s: ManagedSession, ev: Record): void { this.services.subagents.wake(s, ev); + this.onEvalNotification(s, ev); if (envSetting("DEBUG")) { const ame = (ev.assistantMessageEvent as { type?: string } | undefined)?.type; debugLog(`[pi-event] ${String(ev.type)}${ame ? `/${ame}` : ""} ${JSON.stringify(ev).slice(0, 300)}`); @@ -353,6 +357,21 @@ export class PiEventTranslator { } } + /** A detached eval cell settled: finish the card that only said "detached and is running". */ + private onEvalNotification(s: ManagedSession, ev: Record): void { + if (ev.type !== "message_end") return; + const message = ev.message as { customType?: unknown; content?: PiFileMessage["content"] } | undefined; + if (message?.customType !== EVAL_NOTIFICATION_TYPE) return; + for (const result of detachedEvalResults(textOf(message.content))) { + for (const owner of s.messages ?? []) { + const part = owner.parts.find((p): p is OCToolPart => p.type === "tool" && p.callID === result.cellId); + if (part === undefined || !applyDetachedEval(part, result, Date.now())) continue; + this.emitPart(s, part); + this.services.emit(s.info.directory, { type: "message.updated", properties: { info: owner.info } }); + } + } + } + private onTodoEntry(s: ManagedSession, ev: Record): void { s.todoBridge ??= new TodoBridge(s.info.id); const event = s.todoBridge.entry(ev.entry, (part) => this.emitPart(s, part)); @@ -462,6 +481,11 @@ export class PiEventTranslator { return; const part = typeof ev.toolCallId === "string" ? s.toolByCallId.get(ev.toolCallId) : undefined; if (part === undefined || part.state.status !== "running") return; + // eval streams its cell output tail; running cards render it from metadata.output, like bash. + if (part.tool === "eval" && ev.partialResult !== null && typeof ev.partialResult === "object") { + const output = resultText((ev.partialResult as { content?: unknown }).content); + part.state.metadata = { ...part.state.metadata, output }; + } this.emitPart(s, part); return; } diff --git a/src/session-history.ts b/src/session-history.ts index 81bc41a..5d2429d 100644 --- a/src/session-history.ts +++ b/src/session-history.ts @@ -4,7 +4,10 @@ import { readFileSync } from "node:fs"; import type { ManagedSession } from "./sessions.js"; import type { OCAssistantMessage, OCMessageWithParts, OCModelRef, OCPart, OCToolPart, OCUserMessage } from "./types.js"; import { + applyDetachedEval, applyProviderToolResults, + detachedEvalResults, + EVAL_NOTIFICATION_TYPE, finishProviderToolParts, mapTool, providerToolParts, @@ -514,6 +517,14 @@ export function parsePiSessionText(s: ManagedSession, agentName: string, raw: st } } } + for (const entry of entries) { + if (entry.type !== "custom_message" || entry.customType !== EVAL_NOTIFICATION_TYPE) continue; + const content = entry.content as PiFileMessage["content"]; + for (const result of detachedEvalResults(textOf(content))) { + const part = toolParts.get(result.cellId)?.[0]; + if (part !== undefined) applyDetachedEval(part, result, entryTime(entry)); + } + } const admissions = new Map(); for (const entry of entries) { if ( diff --git a/src/tool-map.ts b/src/tool-map.ts index 6e3a065..d495a4b 100644 --- a/src/tool-map.ts +++ b/src/tool-map.ts @@ -106,6 +106,48 @@ export function resultText(content: unknown): string { .join(""); } +/** OmO (senpi-codemode) sends this hidden message when an eval cell that outlived the foreground window settles. */ +export const EVAL_NOTIFICATION_TYPE = "senpi-codemode:notification"; + +export interface DetachedEvalResult { + cellId: string; + completed: boolean; + outcome: string; + output: string; +} + +// One block per cell: header, bounded output (or "(no output)"), then a one-line kernel-state note. +const DETACHED_CELL = + /Detached eval cell (\S+) \([^)\n]*\) ([^\n]*)\.\n([\s\S]*?)<\/system-reminder>/g; + +/** The notification carries no structured details, so the cell id and output come from its text. */ +export function detachedEvalResults(content: string): DetachedEvalResult[] { + return [...content.matchAll(DETACHED_CELL)].map(([, cellId = "", outcome = "", body = ""]) => { + const lines = body.split("\n"); + lines.pop(); + const output = lines.join("\n"); + return { cellId, completed: outcome === "completed", outcome, output: output === "(no output)" ? "" : output }; + }); +} + +/** Replace a detached eval card's "detached and is running" result with what the cell finally produced. */ +export function applyDetachedEval(part: OCToolPart, result: DetachedEvalResult, now: number): boolean { + if (part.tool !== "eval" || part.state.status === "pending") return false; + const { input, metadata = {} } = part.state; + const time = { start: part.state.time.start, end: now }; + const title = part.state.status === "error" ? "eval" : (part.state.title ?? "eval"); + part.state = result.completed + ? { status: "completed", input, output: result.output, title, metadata, time } + : { + status: "error", + input, + error: [`Detached cell ${result.outcome}.`, result.output].filter(Boolean).join("\n"), + metadata, + time, + }; + return true; +} + /** Only edit cards need patch metadata; write cards already preview input.content. */ export function resultMetadata(tool: string, result: ToolResult): Record { if (tool !== "edit" && tool !== "multiedit") return {}; diff --git a/test/detached-eval.test.ts b/test/detached-eval.test.ts new file mode 100644 index 0000000..c7afd9e --- /dev/null +++ b/test/detached-eval.test.ts @@ -0,0 +1,184 @@ +/// +import assert from "node:assert/strict"; +import { appendFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import test, { type TestContext } from "node:test"; +import { SessionManager, type ManagedSession } from "../src/sessions.js"; +import { ocIdFor, piSessionDir, SessionStore } from "../src/session-store.js"; +import type { OCMessageWithParts, OCToolPart } from "../src/types.js"; + +const DETACHED = "Eval cell cell-1 detached and is running in the py kernel (0 other live cells)."; +const args = { language: "py", code: "for i in range(3): print(i)", summary: "Count" }; + +// Shape built by senpi-codemode's detached-cell-notification.ts. +function notice(cellId: string, outcome: string, output: string): string { + return [ + `Detached eval cell ${cellId} (py) ${outcome}.`, + output, + "Kernel state updated - variables are available to the next eval cell.", + ].join("\n"); +} + +function setup(t: TestContext) { + const dir = mkdtempSync(join(tmpdir(), "antechamber-detached-eval-")); + const previousAgent = process.env.PI_CODING_AGENT_DIR; + const previousState = process.env.ANTECHAMBER_STATE_DIR; + process.env.PI_CODING_AGENT_DIR = join(dir, "agent"); + process.env.ANTECHAMBER_STATE_DIR = join(dir, "state"); + const directory = join(dir, "work"); + mkdirSync(directory); + const sessionPath = join(piSessionDir(directory), "parent.jsonl"); + mkdirSync(dirname(sessionPath), { recursive: true }); + writeFileSync( + sessionPath, + JSON.stringify({ + type: "session", + version: 3, + id: "parent-pi", + cwd: directory, + timestamp: "2026-10-15T00:00:00Z", + }) + "\n", + ); + const managers: SessionManager[] = []; + const open = (): { manager: SessionManager; session: ManagedSession } => { + const manager = new SessionManager(() => {}, new SessionStore(join(dir, "state")), directory); + managers.push(manager); + const session = manager.get(ocIdFor("parent-pi")); + assert.ok(session); + manager.messages(session.info.id); + return { manager, session }; + }; + t.after(async () => { + for (const manager of managers) await manager.disposeAll(); + if (previousAgent === undefined) delete process.env.PI_CODING_AGENT_DIR; + else process.env.PI_CODING_AGENT_DIR = previousAgent; + if (previousState === undefined) delete process.env.ANTECHAMBER_STATE_DIR; + else process.env.ANTECHAMBER_STATE_DIR = previousState; + rmSync(dir, { recursive: true, force: true }); + }); + return { open, sessionPath }; +} + +function startEval(manager: SessionManager, session: ManagedSession, callID: string): void { + manager["onPiEvent"](session, { type: "message_start", message: { role: "assistant" } }); + manager["onPiEvent"](session, { + type: "message_update", + assistantMessageEvent: { type: "toolcall_start", contentIndex: 0, id: callID, toolName: "eval" }, + }); + manager["onPiEvent"](session, { + type: "message_update", + assistantMessageEvent: { + type: "toolcall_end", + contentIndex: 0, + toolCall: { id: callID, name: "eval", arguments: args }, + }, + }); + manager["onPiEvent"](session, { type: "tool_execution_start", toolCallId: callID, toolName: "eval", args }); +} + +function detach(manager: SessionManager, session: ManagedSession, callID: string): void { + manager["onPiEvent"](session, { + type: "tool_execution_end", + toolCallId: callID, + toolName: "eval", + result: { content: [{ type: "text", text: DETACHED.replace("cell-1", callID) }] }, + }); +} + +function card(messages: OCMessageWithParts[] | undefined, callID: string): OCToolPart { + const part = (messages ?? []) + .flatMap((message) => message.parts) + .find((p): p is OCToolPart => p.type === "tool" && p.callID === callID); + assert.ok(part); + return part; +} + +test("a running eval card streams its cell output", (t) => { + const { open } = setup(t); + const { manager, session } = open(); + startEval(manager, session, "cell-1"); + manager["onPiEvent"](session, { + type: "tool_execution_update", + toolCallId: "cell-1", + toolName: "eval", + partialResult: { content: [{ type: "text", text: "0\n1" }] }, + }); + const part = card(session.messages, "cell-1"); + assert.equal(part.state.status, "running"); + assert.equal(part.state.status === "running" ? part.state.metadata?.output : undefined, "0\n1"); +}); + +test("a detached cell's completion notice finishes its card with the final output", (t) => { + const { open } = setup(t); + const { manager, session } = open(); + startEval(manager, session, "cell-1"); + detach(manager, session, "cell-1"); + startEval(manager, session, "cell-2"); + detach(manager, session, "cell-2"); + const detached = card(session.messages, "cell-1").state; + assert.ok(detached.status === "completed"); + assert.match(detached.output, /detached/); + + manager["onPiEvent"](session, { + type: "message_end", + message: { + role: "custom", + customType: "senpi-codemode:notification", + display: false, + content: `${notice("cell-1", "completed", "0\n1\n2\ndone")}\n\n${notice("cell-2", "cancelled", "(no output)")}`, + }, + }); + + const done = card(session.messages, "cell-1").state; + assert.ok(done.status === "completed"); + assert.equal(done.output, "0\n1\n2\ndone"); + assert.equal(done.title, "Count"); + const cancelled = card(session.messages, "cell-2").state; + assert.ok(cancelled.status === "error"); + assert.equal(cancelled.error, "Detached cell cancelled."); +}); + +test("a restored session applies a detached cell's completion from the session file", (t) => { + const { open, sessionPath } = setup(t); + appendFileSync( + sessionPath, + [ + { type: "message", id: "u", parentId: null, timestamp: 1, message: { role: "user", content: "count" } }, + { + type: "message", + id: "a", + parentId: "u", + timestamp: 2, + message: { role: "assistant", content: [{ type: "toolCall", id: "cell-1", name: "eval", arguments: args }] }, + }, + { + type: "message", + id: "r", + parentId: "a", + timestamp: 3, + message: { + role: "toolResult", + toolCallId: "cell-1", + toolName: "eval", + content: [{ type: "text", text: DETACHED }], + }, + }, + { + type: "custom_message", + id: "n", + parentId: "r", + timestamp: 4, + customType: "senpi-codemode:notification", + display: false, + content: notice("cell-1", "completed", "0\n1\n2\ndone"), + }, + ] + .map((entry) => `${JSON.stringify(entry)}\n`) + .join(""), + ); + const { manager, session } = open(); + const state = card(manager.messages(session.info.id), "cell-1").state; + assert.ok(state.status === "completed"); + assert.equal(state.output, "0\n1\n2\ndone"); +});