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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 /<skill> commands as /skill:<name>
Expand Down
24 changes: 24 additions & 0 deletions src/pi-event-translation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -312,6 +315,7 @@ export class PiEventTranslator {

onPiEvent(s: ManagedSession, ev: Record<string, unknown>): 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)}`);
Expand Down Expand Up @@ -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<string, unknown>): 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<string, unknown>): void {
s.todoBridge ??= new TodoBridge(s.info.id);
const event = s.todoBridge.entry(ev.entry, (part) => this.emitPart(s, part));
Expand Down Expand Up @@ -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;
}
Expand Down
11 changes: 11 additions & 0 deletions src/session-history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, PiFileEntry>();
for (const entry of entries) {
if (
Expand Down
42 changes: 42 additions & 0 deletions src/tool-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
/<system-reminder>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<string, unknown> {
if (tool !== "edit" && tool !== "multiedit") return {};
Expand Down
184 changes: 184 additions & 0 deletions test/detached-eval.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
/// <reference types="node" />
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 [
`<system-reminder>Detached eval cell ${cellId} (py) ${outcome}.`,
output,
"Kernel state updated - variables are available to the next eval cell.</system-reminder>",
].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");
});
Loading