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"); +});