feat(omp): add native OMP provider (#2067)

Pi and OMP share only the JSONL child-process transport while retaining provider-owned launch, RPC, runtime, session, history, and permission behavior. Removes captured fixtures in favor of typed harnesses and real-provider coverage.

Closes #2006
Closes #2060
This commit is contained in:
Ethan Greenfeld
2026-07-16 23:20:59 +02:00
committed by GitHub
parent d9a0b3e8d8
commit d2308f4835
72 changed files with 12992 additions and 740 deletions
+11 -54
View File
@@ -6,6 +6,7 @@ import type {
ServerRequest,
} from "@modelcontextprotocol/sdk/types.js";
import { addModelVisibleStructuredContent } from "./tools/paseo-tool-serialization.js";
import { createPaseoToolCatalog, type PaseoToolHostDependencies } from "./tools/paseo-tools.js";
import type { PaseoToolResult } from "./tools/types.js";
@@ -13,62 +14,18 @@ export type AgentMcpServerOptions = PaseoToolHostDependencies;
type McpToolContext = RequestHandlerExtra<ServerRequest, ServerNotification>;
function formatStructuredContentForModel(structuredContent: unknown): string {
if (
!structuredContent ||
typeof structuredContent !== "object" ||
Array.isArray(structuredContent)
) {
return JSON.stringify(structuredContent, null, 2);
}
const record = structuredContent as Record<string, unknown>;
const summary: string[] = [];
for (const [key, value] of Object.entries(record)) {
if (!Array.isArray(value)) {
continue;
}
summary.push(`${key}_count=${value.length}`);
const ids = value
.map((item) =>
item && typeof item === "object" && !Array.isArray(item)
? (item as Record<string, unknown>).id
: null,
)
.filter((id): id is string => typeof id === "string" && id.length > 0);
if (ids.length === value.length && ids.length > 0) {
summary.push(`${key}_ids=${ids.join(",")}`);
}
}
const json = JSON.stringify(structuredContent, null, 2);
return summary.length > 0 ? `${summary.join("\n")}\n\n${json}` : json;
}
function addModelVisibleStructuredContent(result: CallToolResult): CallToolResult {
if (result.structuredContent === undefined || result.content.length > 0) {
return result;
}
return {
...result,
content: [
{
type: "text",
text: formatStructuredContentForModel(result.structuredContent),
},
],
};
}
function toMcpToolResult(result: PaseoToolResult): CallToolResult {
return addModelVisibleStructuredContent({
content: result.content as CallToolResult["content"],
...(result.structuredContent !== undefined
? { structuredContent: result.structuredContent as CallToolResult["structuredContent"] }
const modelVisibleResult = addModelVisibleStructuredContent(result);
return {
content: modelVisibleResult.content as CallToolResult["content"],
...(modelVisibleResult.structuredContent !== undefined
? {
structuredContent:
modelVisibleResult.structuredContent as CallToolResult["structuredContent"],
}
: {}),
...(result.isError !== undefined ? { isError: result.isError } : {}),
});
...(modelVisibleResult.isError !== undefined ? { isError: modelVisibleResult.isError } : {}),
};
}
export async function createAgentMcpServer(options: AgentMcpServerOptions): Promise<McpServer> {
@@ -456,6 +456,7 @@ import {
buildProviderRegistry,
createAllClients,
} from "./provider-registry.js";
import { FakeOmp } from "./providers/omp/test-utils/fake-omp.js";
const logger = createTestLogger();
@@ -523,28 +524,27 @@ test("built-in override applies env", () => {
});
});
test("OMP is a disabled built-in backed by the Pi adapter", () => {
const registry = buildProviderRegistry(logger);
test("OMP is a disabled built-in backed by the real OMP adapter", async () => {
const omp = new FakeOmp();
const registry = buildProviderRegistry(logger, { ompRuntime: omp });
expect(registry.omp).toMatchObject({
id: "omp",
label: "OMP",
label: "Oh My Pi",
enabled: false,
derivedFromProviderId: null,
});
expect(registry.omp.createClient(logger).provider).toBe("omp");
expect(mockState.constructorArgs.pi.at(-1)).toEqual({
runtimeSettings: {
command: {
mode: "replace",
argv: ["omp"],
},
},
providerParams: {
sessionDir: "~/.omp/agent/sessions",
},
commandsRpcType: "get_available_commands",
});
const client = registry.omp.createClient(logger);
expect(client.provider).toBe("omp");
const session = await client.createSession({ provider: "omp", cwd: "/tmp/registry-omp" });
expect(omp.recordedLaunches).toEqual([
expect.objectContaining({
cwd: "/tmp/registry-omp",
protocolMode: "rpc-ui",
argv: ["omp", "--mode", "rpc-ui", "--approval-mode", "yolo", "--thinking", "medium"],
}),
]);
await session.close();
});
test("OMP can be enabled without custom provider boilerplate", () => {
@@ -574,8 +574,10 @@ test("new provider extending claude appears in registry", () => {
expect(registry.zai.createClient(logger).provider).toBe("zai");
});
test("built-in OMP override passes params to the Pi adapter constructor", () => {
test("built-in OMP override keeps the real OMP adapter enabled and launchable", async () => {
const omp = new FakeOmp(["custom-omp"]);
const registry = buildProviderRegistry(logger, {
ompRuntime: omp,
providerOverrides: {
omp: {
label: "OMP",
@@ -587,21 +589,19 @@ test("built-in OMP override passes params to the Pi adapter constructor", () =>
},
});
expect(registry.omp.createClient(logger).provider).toBe("omp");
expect(mockState.constructorArgs.pi.at(-1)).toEqual({
runtimeSettings: {
command: {
mode: "replace",
argv: ["omp"],
},
env: undefined,
disallowedTools: undefined,
},
providerParams: {
sessionDir: "~/.omp/agent/sessions",
},
commandsRpcType: "get_available_commands",
});
const client = registry.omp.createClient(logger);
const session = await client.createSession({ provider: "omp", cwd: "/tmp/registry-override" });
expect(client.provider).toBe("omp");
expect(omp.recordedLaunches[0]?.argv).toEqual([
"custom-omp",
"--mode",
"rpc-ui",
"--approval-mode",
"yolo",
"--thinking",
"medium",
]);
await session.close();
});
test("new provider extending acp uses GenericACPAgentClient", () => {
@@ -35,6 +35,8 @@ import { CursorACPAgentClient } from "./providers/cursor-acp-agent.js";
import { GenericACPAgentClient } from "./providers/generic-acp-agent.js";
import { KiroACPAgentClient } from "./providers/kiro-acp-agent.js";
import { OpenCodeAgentClient } from "./providers/opencode-agent.js";
import { OmpAgentClient } from "./providers/omp/agent.js";
import type { OmpRuntime } from "./providers/omp/runtime.js";
import { PiRpcAgentClient } from "./providers/pi/agent.js";
import { TraeACPAgentClient } from "./providers/trae-acp-agent.js";
import { MockLoadTestAgentClient } from "./providers/mock-load-test-agent.js";
@@ -79,11 +81,12 @@ export interface BuildProviderRegistryOptions {
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">;
managedProcesses?: ManagedProcessRegistry;
isDev?: boolean;
ompRuntime?: OmpRuntime;
}
interface ProviderClientFactoryOptions extends Pick<
BuildProviderRegistryOptions,
"workspaceGitService" | "managedProcesses"
"workspaceGitService" | "managedProcesses" | "ompRuntime"
> {
providerParams?: unknown;
customProvider?: {
@@ -144,21 +147,11 @@ const PROVIDER_CLIENT_FACTORIES: Record<string, ProviderClientFactory> = {
providerParams: options?.providerParams,
}),
omp: (logger, runtimeSettings, options) =>
new PiRpcAgentClient({
new OmpAgentClient({
logger,
runtimeSettings: mergeRuntimeSettings(
{
command: {
mode: "replace",
argv: ["omp"],
},
},
runtimeSettings,
),
providerParams: options?.providerParams ?? {
sessionDir: "~/.omp/agent/sessions",
},
commandsRpcType: "get_available_commands",
runtimeSettings,
providerParams: options?.providerParams,
runtime: options?.ompRuntime,
}),
mock: (logger) => new MockLoadTestAgentClient(logger),
"mock-slow": () => new MockSlowProviderClient(),
@@ -572,7 +565,10 @@ function createResolvedProviderClient(
function buildResolvedBuiltinProviders(
providerOverrides: Record<string, ProviderOverride>,
runtimeSettings: AgentProviderRuntimeSettingsMap | undefined,
options: Pick<BuildProviderRegistryOptions, "workspaceGitService" | "managedProcesses">,
options: Pick<
BuildProviderRegistryOptions,
"workspaceGitService" | "managedProcesses" | "ompRuntime"
>,
isDev: boolean,
): Map<string, ResolvedProvider> {
const resolvedProviders = new Map<string, ResolvedProvider>();
@@ -602,6 +598,7 @@ function buildResolvedBuiltinProviders(
factory(logger, mergedRuntimeSettings, {
workspaceGitService: options.workspaceGitService,
managedProcesses: options.managedProcesses,
ompRuntime: options.ompRuntime,
providerParams: override?.params,
}),
});
@@ -725,6 +722,7 @@ export function buildProviderRegistry(
{
workspaceGitService: options?.workspaceGitService,
managedProcesses: options?.managedProcesses,
ompRuntime: options?.ompRuntime,
},
options?.isDev === true,
);
@@ -0,0 +1,169 @@
import pino from "pino";
import { describe, expect, test } from "vitest";
import { JsonlRpcProcess, type JsonlRpcExit } from "./jsonl-rpc-process.js";
const CHILD_SOURCE = String.raw`
const readline = require("node:readline");
function respond(command, success, data, error) {
process.stdout.write(JSON.stringify({
type: "response",
id: command.id,
command: command.type,
success,
data,
error,
}) + "\n");
}
readline.createInterface({ input: process.stdin, crlfDelay: Infinity }).on("line", (line) => {
const command = JSON.parse(line);
if (command.type === "echo") {
setTimeout(() => respond(command, true, {
value: command.value,
cwd: process.cwd(),
env: process.env.JSONL_RPC_TEST_VALUE,
args: process.argv.slice(1),
}), command.delayMs || 0);
return;
}
if (command.type === "emit") {
process.stdout.write("not json\n");
process.stdout.write('{"type":"notice","text":"a');
setTimeout(() => {
process.stdout.write('\\u2028b"}\r\n');
respond(command, true, null);
}, 5);
return;
}
if (command.type === "fail") {
respond(command, false, null, "child rejected the request");
return;
}
if (command.type === "hang") {
process.stderr.write("still waiting");
return;
}
if (command.type === "exit") {
process.stderr.write("child exploded");
setTimeout(() => process.exit(7), 5);
}
});
`;
function startProcess(): JsonlRpcProcess {
return new JsonlRpcProcess({
launch: {
command: process.execPath,
args: ["-e", CHILD_SOURCE, "--", "resolved-arg"],
cwd: process.cwd(),
env: { JSONL_RPC_TEST_VALUE: "resolved-env" },
},
logger: pino({ level: "silent" }),
});
}
function nextExit(transport: JsonlRpcProcess): Promise<JsonlRpcExit> {
return new Promise((resolve) => {
const unsubscribe = transport.onExit((exit) => {
unsubscribe();
resolve(exit);
});
});
}
describe("JsonlRpcProcess", () => {
test("spawns a resolved command and correlates concurrent requests", async () => {
const transport = startProcess();
try {
const slow = transport.request({ type: "echo", value: "first", delayMs: 20 });
const fast = transport.request({ type: "echo", value: "second" });
await expect(Promise.all([slow, fast])).resolves.toEqual([
{
value: "first",
cwd: process.cwd(),
env: "resolved-env",
args: ["resolved-arg"],
},
{
value: "second",
cwd: process.cwd(),
env: "resolved-env",
args: ["resolved-arg"],
},
]);
} finally {
await transport.close();
}
});
test("publishes complete LF-delimited JSON messages", async () => {
const transport = startProcess();
const messages: Record<string, unknown>[] = [];
transport.onMessage((message) => messages.push(message));
try {
await transport.request({ type: "emit" });
expect(messages).toEqual([{ type: "notice", text: "a\u2028b" }]);
} finally {
await transport.close();
}
});
test("rejects unsuccessful responses", async () => {
const transport = startProcess();
try {
await expect(transport.request({ type: "fail" })).rejects.toThrow(
"child rejected the request",
);
} finally {
await transport.close();
}
});
test("includes buffered stderr when a request times out", async () => {
const transport = startProcess();
try {
await transport.request({ type: "echo", value: "ready" });
await expect(transport.request({ type: "hang" }, 50)).rejects.toThrow(
"JSONL RPC request timed out for hang\nstill waiting",
);
} finally {
await transport.close();
}
});
test("rejects pending requests and publishes stderr when the child exits", async () => {
const transport = startProcess();
const exit = nextExit(transport);
const request = transport.request({ type: "exit" });
await expect(request).rejects.toThrow("child exploded");
await expect(exit).resolves.toMatchObject({
code: 7,
signal: null,
error: expect.objectContaining({
message: expect.stringContaining("child exploded"),
}),
});
});
test("rejects pending requests while shutting down the child process", async () => {
const transport = startProcess();
await transport.request({ type: "echo", value: "ready" });
const request = transport.request({ type: "hang" });
const rejection = expect(request).rejects.toThrow("JSONL RPC process is closed");
await transport.close();
await rejection;
});
});
@@ -0,0 +1,255 @@
import { type ChildProcess, type ChildProcessWithoutNullStreams } from "node:child_process";
import type { Logger } from "pino";
import { spawnProcess } from "../../../utils/spawn.js";
import { terminateWithTreeKill } from "../../../utils/tree-kill.js";
const DEFAULT_TIMEOUT_MS = 30_000;
const STDERR_BUFFER_LIMIT = 8192;
const GRACEFUL_SHUTDOWN_TIMEOUT_MS = 2_000;
const FORCE_SHUTDOWN_TIMEOUT_MS = 1_000;
export interface JsonlRpcLaunch {
command: string;
args: string[];
cwd: string;
env?: Record<string, string>;
}
interface JsonlRpcResponse {
type: "response";
id?: string;
command?: string;
success?: boolean;
data?: unknown;
error?: string;
}
interface PendingRequest {
resolve: (value: unknown) => void;
reject: (error: Error) => void;
timer: NodeJS.Timeout;
}
export interface JsonlRpcExit {
code: number | null;
signal: NodeJS.Signals | null;
error: Error;
}
export interface JsonlRpcProcessOptions {
launch: JsonlRpcLaunch;
logger: Logger;
diagnosticName?: string;
spawn?: (launch: JsonlRpcLaunch) => ChildProcessWithoutNullStreams;
}
function assertChildWithPipes(
child: ChildProcess,
): asserts child is ChildProcessWithoutNullStreams {
if (!child.stdin || !child.stdout || !child.stderr) {
throw new Error("JSONL RPC process was spawned without stdio streams");
}
}
function spawnJsonlRpcProcess(launch: JsonlRpcLaunch): ChildProcessWithoutNullStreams {
const child = spawnProcess(launch.command, launch.args, {
cwd: launch.cwd,
envOverlay: launch.env,
stdio: ["pipe", "pipe", "pipe"],
});
assertChildWithPipes(child);
return child;
}
export class JsonlRpcProcess {
private readonly child: ChildProcessWithoutNullStreams;
private readonly diagnosticName: string;
private readonly pending = new Map<string, PendingRequest>();
private readonly messageSubscribers = new Set<(message: Record<string, unknown>) => void>();
private readonly exitSubscribers = new Set<(exit: JsonlRpcExit) => void>();
private stderrBuffer = "";
private nextRequestId = 1;
private disposed = false;
private stdoutBuffer = "";
constructor(private readonly options: JsonlRpcProcessOptions) {
this.diagnosticName = options.diagnosticName ?? "JSONL RPC";
this.child = (options.spawn ?? spawnJsonlRpcProcess)(options.launch);
this.child.stdout.on("data", (chunk) => {
this.handleStdoutChunk(chunk.toString());
});
this.child.stderr.on("data", (chunk) => {
this.stderrBuffer += chunk.toString();
if (this.stderrBuffer.length > STDERR_BUFFER_LIMIT) {
this.stderrBuffer = this.stderrBuffer.slice(-STDERR_BUFFER_LIMIT);
}
});
this.child.on("error", (error) => {
this.failAll(error instanceof Error ? error : new Error(String(error)));
});
this.child.on("exit", (code, signal) => {
const error = new Error(
`${this.diagnosticName} process exited with code ${code ?? "null"} and signal ${signal ?? "null"}\n${this.stderrBuffer}`.trim(),
);
const exit = { code, signal, error };
for (const subscriber of this.exitSubscribers) {
subscriber(exit);
}
this.failAll(error);
});
}
onMessage(callback: (message: Record<string, unknown>) => void): () => void {
this.messageSubscribers.add(callback);
return () => {
this.messageSubscribers.delete(callback);
};
}
onExit(callback: (exit: JsonlRpcExit) => void): () => void {
this.exitSubscribers.add(callback);
return () => {
this.exitSubscribers.delete(callback);
};
}
startRequest(
command: { type: string; [key: string]: unknown },
timeoutMs = DEFAULT_TIMEOUT_MS,
): { id: string; promise: Promise<unknown> } {
if (this.disposed) {
return {
id: "",
promise: Promise.reject(new Error(`${this.diagnosticName} process is closed`)),
};
}
const id = `req_${this.nextRequestId}`;
this.nextRequestId += 1;
const promise = new Promise<unknown>((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(id);
reject(
new Error(
`${this.diagnosticName} request timed out for ${command.type}\n${this.stderrBuffer}`.trim(),
),
);
}, timeoutMs);
this.pending.set(id, { resolve, reject, timer });
this.send({ ...command, id });
});
return { id, promise };
}
request(
command: { type: string; [key: string]: unknown },
timeoutMs = DEFAULT_TIMEOUT_MS,
): Promise<unknown> {
return this.startRequest(command, timeoutMs).promise;
}
send(message: Record<string, unknown>): void {
if (this.disposed || this.child.stdin.destroyed || !this.child.stdin.writable) {
return;
}
this.child.stdin.write(`${JSON.stringify(message)}\n`);
}
async close(error = new Error(`${this.diagnosticName} process is closed`)): Promise<void> {
if (this.disposed) return;
this.failAll(error);
try {
this.child.stdin.end();
} catch {
// Ignore cleanup races.
}
const result = await terminateWithTreeKill(this.child, {
gracefulTimeoutMs: GRACEFUL_SHUTDOWN_TIMEOUT_MS,
forceTimeoutMs: FORCE_SHUTDOWN_TIMEOUT_MS,
onForceSignal: () => {
this.options.logger.warn(
{ timeoutMs: GRACEFUL_SHUTDOWN_TIMEOUT_MS },
`${this.diagnosticName} process did not exit after SIGTERM; sending SIGKILL`,
);
},
});
if (result === "kill-timeout") {
this.options.logger.warn(
{ timeoutMs: FORCE_SHUTDOWN_TIMEOUT_MS },
`${this.diagnosticName} process did not report exit after SIGKILL`,
);
}
}
private handleStdoutChunk(chunk: string): void {
this.stdoutBuffer += chunk;
for (;;) {
const newlineIndex = this.stdoutBuffer.indexOf("\n");
if (newlineIndex === -1) {
break;
}
const line = this.stdoutBuffer.slice(0, newlineIndex).replace(/\r$/, "");
this.stdoutBuffer = this.stdoutBuffer.slice(newlineIndex + 1);
if (line.trim()) {
this.handleLine(line);
}
}
}
private handleLine(line: string): void {
let parsed: unknown;
try {
parsed = JSON.parse(line);
} catch (error) {
this.options.logger.warn(
{ error, line },
`Ignoring non-JSON ${this.diagnosticName} stdout line`,
);
return;
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return;
}
const message = parsed as Record<string, unknown>;
if (message.type === "response") {
this.handleResponse(message as unknown as JsonlRpcResponse);
return;
}
for (const subscriber of this.messageSubscribers) {
subscriber(message);
}
}
private handleResponse(response: JsonlRpcResponse): void {
if (!response.id) {
return;
}
const pending = this.pending.get(response.id);
if (!pending) {
return;
}
clearTimeout(pending.timer);
this.pending.delete(response.id);
if (!response.success) {
pending.reject(
new Error(
response.error ?? `${this.diagnosticName} ${response.command ?? "request"} failed`,
),
);
return;
}
pending.resolve(response.data);
}
private failAll(error: Error): void {
if (this.disposed) {
return;
}
this.disposed = true;
for (const pending of this.pending.values()) {
clearTimeout(pending.timer);
pending.reject(error);
}
this.pending.clear();
}
}
@@ -0,0 +1,108 @@
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, test } from "vitest";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
import { formatOmpVersionSupport, OmpAgentClient, resolveOmpDiagnosticPaths } from "./agent.js";
const tempDirs: string[] = [];
afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
});
describe("OMP diagnostics", () => {
test.each([
["omp 16.3.8", "16.3.8 (unsupported; minimum 16.3.9)"],
["16.3.9", "16.3.9 (supported; minimum 16.3.9)"],
["oh-my-pi v17.0.0", "17.0.0 (supported; minimum 16.3.9)"],
])("classifies installed version %s", (output, expected) => {
expect(formatOmpVersionSupport(output)).toBe(expected);
});
test("follows OMP 16.3.9 profile, agent override, and XDG path precedence", async () => {
const home = await makeTempDir();
const xdgData = path.join(home, "xdg-data");
const xdgState = path.join(home, "xdg-state");
const xdgCache = path.join(home, "xdg-cache");
await Promise.all([
mkdir(path.join(xdgData, "omp", "profiles", "work"), { recursive: true }),
mkdir(path.join(xdgState, "omp", "profiles", "work"), { recursive: true }),
mkdir(path.join(xdgCache, "omp", "profiles", "work"), { recursive: true }),
]);
const profiled = resolveOmpDiagnosticPaths(
{
OMP_PROFILE: "work",
PI_PROFILE: "ignored",
PI_CONFIG_DIR: ".omp-custom",
PI_CODING_AGENT_DIR: path.join(home, "ignored-agent-override"),
XDG_DATA_HOME: xdgData,
XDG_STATE_HOME: xdgState,
XDG_CACHE_HOME: xdgCache,
},
home,
"linux",
);
expect(profiled).toEqual({
profile: "work",
configRoot: path.join(home, ".omp-custom", "profiles", "work"),
agentDir: path.join(home, ".omp-custom", "profiles", "work", "agent"),
agentDb: path.join(xdgData, "omp", "profiles", "work", "agent.db"),
xdgDataRoot: path.join(xdgData, "omp", "profiles", "work"),
xdgStateRoot: path.join(xdgState, "omp", "profiles", "work"),
xdgCacheRoot: path.join(xdgCache, "omp", "profiles", "work"),
});
const overridden = resolveOmpDiagnosticPaths(
{ PI_CODING_AGENT_DIR: path.join(home, "custom-agent"), XDG_DATA_HOME: xdgData },
home,
"linux",
);
expect(overridden.agentDir).toBe(path.join(home, "custom-agent"));
expect(overridden.agentDb).toBe(path.join(home, "custom-agent", "agent.db"));
expect(overridden.xdgDataRoot).toBe(path.join(home, ".omp"));
});
test("reports an overridden command and OMP-only paths and caveats", async () => {
const dir = await makeTempDir();
const script = path.join(dir, "fake-omp.cjs");
await writeFile(script, 'process.stdout.write("omp 16.3.9\\n");\n', "utf8");
const agentDir = path.join(dir, "agent");
await mkdir(agentDir);
await writeFile(path.join(agentDir, "agent.db"), "", "utf8");
const client = new OmpAgentClient({
logger: createTestLogger(),
runtimeSettings: {
command: { mode: "replace", argv: [process.execPath, script] },
env: {
OMP_PROFILE: "default",
PI_CODING_AGENT_DIR: agentDir,
},
},
});
const { diagnostic } = await client.getDiagnostic();
expect(diagnostic).toContain("Oh My Pi (OMP)");
expect(diagnostic).toContain(`Configured command: ${process.execPath} ${script}`);
expect(diagnostic).toContain(`Resolved path: ${process.execPath}`);
expect(diagnostic).toContain("Version: omp 16.3.9");
expect(diagnostic).toContain("Version support: 16.3.9 (supported; minimum 16.3.9)");
expect(diagnostic).toContain("Active profile: default");
expect(diagnostic).toContain(`Agent directory: ${agentDir}`);
expect(diagnostic).toContain(`Agent database: ${path.join(agentDir, "agent.db")} (found)`);
expect(diagnostic).toContain("npm-installed OMP requires Bun >= 1.3.14");
expect(diagnostic).not.toContain("auth.json");
expect(diagnostic).not.toContain(".pi/agent");
});
});
async function makeTempDir(): Promise<string> {
const dir = await mkdtemp(path.join(tmpdir(), "paseo-omp-diagnostic-"));
tempDirs.push(dir);
return dir;
}
@@ -0,0 +1,331 @@
import { describe, expect, test } from "vitest";
import type { PaseoToolCatalog } from "../../tools/types.js";
import type { OmpProviderIdleScheduler } from "./agent.js";
import { OmpHarness } from "./test-utils/omp-harness.js";
class ManualIdleScheduler implements OmpProviderIdleScheduler {
private readonly retries: Array<() => void> = [];
private readonly waiters: Array<{ count: number; resolve: () => void }> = [];
private waitCount = 0;
waitForRetry(): Promise<void> {
this.waitCount += 1;
for (const waiter of this.waiters.splice(0)) {
if (this.waitCount >= waiter.count) waiter.resolve();
else this.waiters.push(waiter);
}
return new Promise((resolve) => this.retries.push(resolve));
}
waitForWaits(count: number): Promise<void> {
if (this.waitCount >= count) return Promise.resolve();
return new Promise((resolve) => this.waiters.push({ count, resolve }));
}
retry(): void {
const resolve = this.retries.shift();
if (!resolve) throw new Error("OMP has not requested an idle-state retry");
resolve();
}
}
function createToolCatalog(): PaseoToolCatalog {
return {
tools: new Map([
[
"create_agent",
{
name: "create_agent",
description: "Create a Paseo agent.",
handler: async () => ({ content: [] }),
},
],
]),
getTool: () => undefined,
executeTool: async () => ({ content: [] }),
};
}
describe("OMP agent client and session", () => {
test("owns launch configuration and registers native host tools", async () => {
const omp = new OmpHarness();
await omp.start({ modeId: "ask" }, createToolCatalog());
expect(omp.launchConfiguration()).toEqual({
cwd: "/tmp/paseo-omp-agent-test",
protocolMode: "rpc-ui",
modeId: "ask",
argv: ["omp", "--mode", "rpc-ui", "--approval-mode", "always-ask", "--thinking", "medium"],
});
expect(omp.registeredHostTools()).toEqual([
[expect.objectContaining({ name: "create_agent" })],
]);
expect(omp.capabilities()).toMatchObject({
supportsMcpServers: false,
supportsNativePaseoTools: true,
});
});
test("streams a prompt through completion", async () => {
const omp = new OmpHarness();
await omp.start();
await expect(omp.runPrompt("hello OMP", "hello from OMP")).resolves.toMatchObject({
finalText: "hello from OMP",
});
expect(omp.timeline()).toEqual([
{ type: "user_message", text: "hello OMP", messageId: "user-1" },
{ type: "assistant_message", text: "hello from OMP", messageId: "omp-assistant-1" },
]);
expect(omp.completedTurnCount()).toBe(1);
});
test("does not accept a follow-up until OMP reports stable idle", async () => {
const omp = new OmpHarness();
await omp.start();
await omp.runPrompt("first", "first done", [
{ isStreaming: true, isCompacting: false },
{ isStreaming: false, isCompacting: false },
{ isStreaming: false, isCompacting: false },
]);
await expect(omp.runPrompt("follow-up", "follow-up done")).resolves.toMatchObject({
finalText: "follow-up done",
});
});
test("stays active while OMP remains busy", async () => {
const scheduler = new ManualIdleScheduler();
const omp = new OmpHarness({ providerIdleScheduler: scheduler });
await omp.start();
const { completion } = await omp.startPromptUntilProviderIdle("first", "first done", {
isStreaming: true,
isCompacting: false,
});
await omp.waitForProviderStateChecks(2);
await scheduler.waitForWaits(1);
expect(omp.completedTurnCount()).toBe(0);
scheduler.retry();
await omp.waitForProviderStateChecks(3);
await scheduler.waitForWaits(2);
expect(omp.completedTurnCount()).toBe(0);
omp.reportProviderState({ isStreaming: false, isCompacting: false });
scheduler.retry();
await expect(completion).resolves.toMatchObject({ finalText: "first done" });
});
test("stays active when OMP state checks fail", async () => {
const scheduler = new ManualIdleScheduler();
const omp = new OmpHarness({ providerIdleScheduler: scheduler });
await omp.start();
omp.failProviderStateChecks(new Error("state unavailable"));
const { completion } = await omp.startPromptUntilProviderIdle("first", "first done", {
isStreaming: true,
isCompacting: false,
});
await omp.waitForProviderStateChecks(2);
await scheduler.waitForWaits(1);
expect(omp.completedTurnCount()).toBe(0);
omp.failProviderStateChecks(null);
omp.reportProviderState({ isStreaming: false, isCompacting: false });
scheduler.retry();
await expect(completion).resolves.toMatchObject({ finalText: "first done" });
});
test("does not complete on OMP's extension-notice agent_end", async () => {
const omp = new OmpHarness();
await omp.start();
await expect(
omp.runPromptAfterExtensionNotice("hello OMP", "model turn completed"),
).resolves.toMatchObject({ finalText: expect.stringContaining("model turn completed") });
expect(omp.completedTurnCount()).toBe(1);
});
test("does not complete a queued model turn from OMP's local-only hint", async () => {
const omp = new OmpHarness();
await omp.start();
await expect(
omp.runPromptAfterFalseLocalOnlyHint("hello OMP", "queued model turn completed"),
).resolves.toMatchObject({ finalText: "queued model turn completed" });
expect(omp.completedTurnCount()).toBe(1);
});
test("resumes an OMP session and replays its history", async () => {
const omp = new OmpHarness();
await omp.resume(
{
user: { id: "user-history", text: "continue the audit" },
assistant: { id: "assistant-history", text: "audit context restored" },
},
{ cwd: "/workspace/resumed", modeId: "ask", thinkingOptionId: "high" },
);
expect(omp.launchConfiguration()).toEqual({
cwd: "/workspace/resumed",
protocolMode: "rpc-ui",
modeId: "ask",
session: expect.stringMatching(/[\\/]paseo-omp-resume-.*[\\/]session\.jsonl$/),
argv: [
"omp",
"--mode",
"rpc-ui",
"--approval-mode",
"always-ask",
"--thinking",
"high",
"--session",
expect.stringMatching(/[\\/]paseo-omp-resume-.*[\\/]session\.jsonl$/),
],
});
await expect(omp.history()).resolves.toEqual([
{ type: "user_message", text: "continue the audit", messageId: "user-history" },
{
type: "assistant_message",
text: "audit context restored",
messageId: "assistant-history",
},
]);
});
test("maps permissions and sends the selected OMP response", async () => {
const omp = new OmpHarness();
await omp.start();
omp.requestToolApproval({ id: "approval-1", tool: "bash", detail: "git status" });
expect(omp.pendingPermissions()).toEqual([
expect.objectContaining({ id: "approval-1", name: "bash", kind: "tool" }),
]);
await omp.respondToPermission("approval-1", { behavior: "allow" });
expect(omp.extensionUiResponses()).toEqual([
{ id: "approval-1", response: { value: "Approve" } },
]);
});
test("exposes OMP modes and commands through the domain session", async () => {
const omp = new OmpHarness();
omp.queueCommands([{ name: "review", description: "Review changes", source: "skill" }]);
await omp.start();
await expect(omp.availableModes()).resolves.toEqual([
expect.objectContaining({ id: "full" }),
expect.objectContaining({ id: "ask" }),
]);
await expect(omp.commands()).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({ name: "handoff" }),
expect.objectContaining({ name: "review", kind: "skill" }),
]),
);
await expect(omp.setMode("ask")).resolves.toEqual(expect.objectContaining({ type: "info" }));
});
test("rewinds natively, interrupts, and shuts down", async () => {
const omp = new OmpHarness();
await omp.start();
await omp.rewind("user-history", "from history");
expect(omp.branchRequests()).toEqual(["user-history"]);
await omp.interruptActiveTurn("stop me");
expect(omp.wasAborted()).toBe(true);
expect(omp.canceledTurnCount()).toBe(1);
await omp.close();
expect(omp.isClosed()).toBe(true);
});
test("interrupt terminalizes in-flight tool calls and running subagents", async () => {
const omp = new OmpHarness();
await omp.start();
await omp.requireStartTurn("run something slow");
const runtime = omp.runtime();
runtime.beginTurn();
runtime.emit({
type: "tool_execution_start",
toolCallId: "tool-1",
toolName: "bash",
args: { command: "sleep 30" },
});
runtime.emit({
type: "subagent_lifecycle",
payload: {
id: "child-1",
agent: "worker",
status: "started",
parentToolCallId: "tool-1",
index: 0,
},
});
expect(omp.runningToolCallIds()).toEqual(["tool-1"]);
expect(omp.subagentUpserts()).toEqual([{ id: "child-1", status: "running" }]);
await omp.interrupt();
expect(omp.canceledTurnCount()).toBe(1);
expect(omp.runningToolCallIds()).toEqual([]);
expect(omp.subagentUpserts()).toEqual([
{ id: "child-1", status: "running" },
{ id: "child-1", status: "canceled" },
]);
// Late progress after interrupt must not resurrect a running card.
runtime.emit({
type: "subagent_progress",
payload: {
id: "child-1",
agent: "worker",
index: 0,
progress: { id: "child-1", status: "running" },
parentToolCallId: "tool-1",
},
});
expect(omp.runningToolCallIds()).toEqual([]);
});
test("a resumed session does not re-emit replayed events as live timeline items", async () => {
const omp = new OmpHarness();
await omp.resume({
user: { id: "user-history", text: "continue the audit" },
assistant: { id: "assistant-history", text: "audit context restored" },
});
const runtime = omp.runtime();
// OMP replays pre-existing conversation on startup with --session.
runtime.acceptPrompt("continue the audit", "user-history");
runtime.streamAssistantText("audit context restored", "assistant-history");
expect(omp.timeline()).toEqual([]);
// The first live prompt flows normally.
await expect(omp.runPrompt("next step", "on it")).resolves.toMatchObject({
finalText: "on it",
});
expect(omp.timeline()).toEqual([
{ type: "user_message", text: "next step", messageId: "user-1" },
{ type: "assistant_message", text: "on it", messageId: "omp-assistant-1" },
]);
});
test("re-emitted user message_end frames dedupe by native entry id", async () => {
const omp = new OmpHarness();
await omp.start();
await expect(omp.runPrompt("hello OMP", "hello from OMP")).resolves.toMatchObject({
finalText: "hello from OMP",
});
// OMP can re-send message_end for an entry it already surfaced.
omp.runtime().acceptPrompt("hello OMP", "user-1");
expect(omp.timeline().filter((item) => item.type === "user_message")).toEqual([
{ type: "user_message", text: "hello OMP", messageId: "user-1" },
]);
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,195 @@
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
import pino from "pino";
import { describe, expect, test } from "vitest";
import { OmpCliRuntime } from "./cli-runtime.js";
import type { OmpRuntimeLaunch } from "./runtime.js";
type OmpChild = ChildProcessWithoutNullStreams & {
stdin: PassThrough;
stdout: PassThrough;
stderr: PassThrough;
killedSignals: Array<NodeJS.Signals | number | undefined>;
};
function createOmpChild(): OmpChild {
const child = Object.assign(new EventEmitter(), {
stdin: new PassThrough(),
stdout: new PassThrough(),
stderr: new PassThrough(),
exitCode: null,
signalCode: null,
killedSignals: [],
}) as OmpChild;
child.kill = ((signal?: NodeJS.Signals | number) => {
child.killedSignals.push(signal);
queueMicrotask(() => child.emit("exit", null, signal ?? null));
return true;
}) as ChildProcessWithoutNullStreams["kill"];
return child;
}
function createRuntime(child: OmpChild, launches: OmpRuntimeLaunch[] = []): OmpCliRuntime {
return new OmpCliRuntime({
logger: pino({ level: "silent" }),
command: ["omp"],
commandsRpcName: "get_available_commands",
spawnProcess: (launch) => {
launches.push(launch);
return child;
},
});
}
function replyToCommands(
child: OmpChild,
handler: (command: Record<string, unknown>) => unknown,
): void {
let buffer = "";
child.stdin.on("data", (chunk) => {
buffer += chunk.toString();
for (;;) {
const newlineIndex = buffer.indexOf("\n");
if (newlineIndex === -1) break;
const line = buffer.slice(0, newlineIndex);
buffer = buffer.slice(newlineIndex + 1);
const command = JSON.parse(line) as Record<string, unknown>;
const result = handler(command);
child.stdout.write(
`${JSON.stringify({
id: command.id,
type: "response",
command: command.type,
success: true,
data: result,
})}\n`,
);
}
});
}
function withoutRequestId(command: Record<string, unknown>): Record<string, unknown> {
const { id: _id, ...rest } = command;
return rest;
}
describe("OMP CLI runtime", () => {
test("validates session state with the documented queued message count", async () => {
const child = createOmpChild();
replyToCommands(child, () => ({
model: null,
thinkingLevel: "medium",
isStreaming: false,
isCompacting: false,
sessionId: "session-1",
messageCount: 3,
queuedMessageCount: 1,
}));
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
await expect(session.getState()).resolves.toMatchObject({
sessionId: "session-1",
messageCount: 3,
queuedMessageCount: 1,
});
});
test("rejects malformed RPC results instead of trusting transport data", async () => {
const child = createOmpChild();
replyToCommands(child, () => ({
thinkingLevel: "medium",
isStreaming: "no",
isCompacting: false,
sessionId: "session-1",
messageCount: 0,
queuedMessageCount: 0,
}));
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
await expect(session.getState()).rejects.toThrow();
});
test("emits validated known events and drops unknown frames", async () => {
const child = createOmpChild();
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
const eventTypes: string[] = [];
session.onEvent((event) => eventTypes.push(event.type));
child.stdout.write(`${JSON.stringify({ type: "future_control", enabled: true })}\n`);
child.stdout.write(`${JSON.stringify({ type: "notice", level: "info", message: "ready" })}\n`);
expect(eventTypes).toEqual(["notice"]);
});
test("lists commands through get_available_commands", async () => {
const child = createOmpChild();
const commandTypes: string[] = [];
replyToCommands(child, (command) => {
commandTypes.push(String(command.type));
return {
commands: [
{ name: "prewalk", description: "Prewalk at the next action", source: "builtin" },
],
};
});
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
await expect(session.getCommands()).resolves.toEqual([
{
name: "prewalk",
description: "Prewalk at the next action",
source: "builtin",
},
]);
expect(commandTypes).toEqual(["get_available_commands"]);
});
test("accepts model catalogs with null maxTokens from newer OMP binaries", async () => {
const child = createOmpChild();
replyToCommands(child, () => ({
models: [
{
provider: "openai-codex",
id: "gpt-5.6-sol",
name: "gpt-5.6-sol",
maxTokens: null,
},
],
}));
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
await expect(session.getAvailableModels()).resolves.toEqual([
expect.objectContaining({
provider: "openai-codex",
id: "gpt-5.6-sol",
maxTokens: null,
}),
]);
});
test("wraps OMP subagent RPC commands", async () => {
const child = createOmpChild();
const commands: Record<string, unknown>[] = [];
replyToCommands(child, (command) => {
commands.push(command);
return undefined;
});
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
await session.setSubagentSubscription("events");
expect(commands.map(withoutRequestId)).toEqual([
{ type: "set_subagent_subscription", level: "events" },
]);
});
test("accepts the empty prompt acknowledgement emitted by OMP 17", async () => {
const child = createOmpChild();
replyToCommands(child, () => undefined);
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
await expect(session.prompt("hello")).resolves.toEqual({ requestId: "req_1" });
});
});
@@ -0,0 +1,281 @@
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import type { Logger } from "pino";
import type { ProviderRuntimeSettings } from "../../provider-launch-config.js";
import { JsonlRpcProcess, type JsonlRpcLaunch } from "../jsonl-rpc-process.js";
import {
buildOmpLaunch,
type OmpRuntime,
type OmpRuntimeLaunch,
type OmpRuntimeSession,
type OmpStartSessionInput,
} from "./runtime.js";
import {
OmpBranchMessagesResultSchema,
OmpBranchResultSchema,
OmpCommandsResultSchema,
OmpHostToolsResultSchema,
OmpMessagesResultSchema,
OmpModelSchema,
OmpModelsResultSchema,
OmpPromptAckSchema,
OmpRpcCommandSchema,
OmpRuntimeEventSchema,
OmpSessionStateSchema,
OmpSessionStatsSchema,
type OmpThinkingLevel,
type OmpAgentMessage,
type OmpModel,
type OmpPromptAck,
type OmpRpcCommand,
type OmpRpcHostToolDefinition,
type OmpRpcHostToolResult,
type OmpRpcHostToolUpdate,
type OmpRpcSlashCommand,
type OmpRuntimeEvent,
type OmpSessionState,
type OmpSessionStats,
type OmpSubagentSubscriptionLevel,
} from "./rpc-types.js";
const DEFAULT_OMP_COMMAND: [string, ...string[]] = [process.env.OMP_COMMAND ?? "omp"];
const DEFAULT_COMMANDS_RPC_NAME = "get_available_commands";
export interface OmpCliRuntimeOptions {
logger: Logger;
runtimeSettings?: ProviderRuntimeSettings;
command?: [string, ...string[]];
commandsRpcName?: "get_available_commands";
spawnProcess?: (launch: OmpRuntimeLaunch) => ChildProcessWithoutNullStreams;
}
export class OmpCliRuntime implements OmpRuntime {
private readonly command: [string, ...string[]];
private readonly commandsRpcName: "get_available_commands";
private readonly spawnProcess?: (launch: OmpRuntimeLaunch) => ChildProcessWithoutNullStreams;
constructor(private readonly options: OmpCliRuntimeOptions) {
this.command = options.command ?? DEFAULT_OMP_COMMAND;
this.commandsRpcName = options.commandsRpcName ?? DEFAULT_COMMANDS_RPC_NAME;
this.spawnProcess = options.spawnProcess;
}
async startSession(input: OmpStartSessionInput): Promise<OmpRuntimeSession> {
const launch = buildOmpLaunch({
command: this.command,
runtimeSettings: this.options.runtimeSettings,
session: input,
});
const [command, ...args] = launch.argv;
const processLaunch: JsonlRpcLaunch = {
command,
args,
cwd: launch.cwd,
env: launch.env,
};
const spawn = this.spawnProcess;
const processOptions = {
launch: processLaunch,
logger: this.options.logger,
diagnosticName: "OMP RPC",
...(spawn ? { spawn: () => spawn(launch) } : {}),
};
const process = new JsonlRpcProcess(processOptions);
return new OmpCliRuntimeSession(process, this.commandsRpcName);
}
}
class OmpCliRuntimeSession implements OmpRuntimeSession {
private readonly subscribers = new Set<(event: OmpRuntimeEvent) => void>();
activeBranchEntryId?: string;
constructor(
private readonly process: JsonlRpcProcess,
private readonly commandsRpcName: "get_available_commands",
) {
process.onMessage((message) => {
const event = OmpRuntimeEventSchema.safeParse(message);
if (event.success) {
this.emit(event.data);
}
});
process.onExit(({ error }) => {
this.emit({ type: "process_exit", error: error.message });
});
}
onEvent(callback: (event: OmpRuntimeEvent) => void): () => void {
this.subscribers.add(callback);
return () => {
this.subscribers.delete(callback);
};
}
async prompt(
message: string,
images?: Array<{ type: "image"; data: string; mimeType: string }>,
): Promise<OmpPromptAck> {
const { id: requestId, promise } = this.process.startRequest({
type: "prompt",
message,
...(images?.length ? { images } : {}),
});
const ack = OmpPromptAckSchema.parse(await promise) ?? {};
return { requestId, ...ack };
}
async compact(customInstructions?: string): Promise<void> {
await this.request({
type: "compact",
...(customInstructions ? { customInstructions } : {}),
});
}
async setAutoCompaction(enabled: boolean): Promise<void> {
await this.request({ type: "set_auto_compaction", enabled });
}
async abort(): Promise<void> {
await this.request({ type: "abort" });
}
async getState(): Promise<OmpSessionState> {
return OmpSessionStateSchema.parse(await this.request({ type: "get_state" }));
}
async getMessages(): Promise<OmpAgentMessage[]> {
const data = OmpMessagesResultSchema.parse(await this.request({ type: "get_messages" }));
return data.messages ?? [];
}
async getAvailableModels(timeoutMs?: number): Promise<OmpModel[]> {
const data = OmpModelsResultSchema.parse(
await this.request({ type: "get_available_models" }, timeoutMs),
);
return data.models ?? [];
}
async setModel(provider: string, modelId: string): Promise<OmpModel> {
return OmpModelSchema.parse(await this.request({ type: "set_model", provider, modelId }));
}
async setThinkingLevel(level: OmpThinkingLevel): Promise<void> {
await this.request({ type: "set_thinking_level", level });
}
async getSessionStats(): Promise<OmpSessionStats> {
// COMPAT(ompGetStateFallback): added in v0.1.105 — older OMP binaries
// lack the `get_session_stats` RPC command; fall back to extracting
// context window usage from `get_state`. Remove after 2027-01-10 once the
// supported OMP floor includes `get_session_stats`.
let stats: OmpSessionStats | undefined;
try {
stats = OmpSessionStatsSchema.parse(await this.request({ type: "get_session_stats" }));
} catch {
// get_session_stats not supported by this binary — will try get_state below
}
if (stats?.tokens == null && stats?.cost == null && stats?.contextUsage == null) {
try {
const state = OmpSessionStateSchema.parse(await this.request({ type: "get_state" }));
const ctx = state.contextUsage;
if (ctx) {
return {
contextUsage: {
tokens: typeof ctx.tokens === "number" ? ctx.tokens : undefined,
contextWindow: typeof ctx.contextWindow === "number" ? ctx.contextWindow : undefined,
},
};
}
} catch {
// get_state also failed — nothing we can do
}
}
return stats ?? {};
}
async getCommands(): Promise<OmpRpcSlashCommand[]> {
const data = OmpCommandsResultSchema.parse(await this.request({ type: this.commandsRpcName }));
return data.commands ?? [];
}
async setSubagentSubscription(level: OmpSubagentSubscriptionLevel): Promise<void> {
await this.request({ type: "set_subagent_subscription", level });
}
async setHostTools(tools: OmpRpcHostToolDefinition[]): Promise<string[]> {
const data = OmpHostToolsResultSchema.parse(
await this.request({ type: "set_host_tools", tools }),
);
return data.toolNames ?? [];
}
sendHostToolResult(result: OmpRpcHostToolResult): void {
this.process.send({ ...result });
}
sendHostToolUpdate(update: OmpRpcHostToolUpdate): void {
this.process.send({ ...update });
}
async branch(entryId: string): Promise<{ text: string }> {
const data = OmpBranchResultSchema.parse(await this.request({ type: "branch", entryId }));
if (data.cancelled === true) {
throw new Error("OMP branch was cancelled");
}
if (typeof data.text !== "string") {
throw new Error("OMP branch response did not include restored prompt text");
}
this.activeBranchEntryId = entryId;
return { text: data.text };
}
async getBranchMessages(): Promise<Array<{ entryId: string; text: string }>> {
const data = OmpBranchMessagesResultSchema.parse(
await this.request({ type: "get_branch_messages" }),
);
return data.messages ?? [];
}
steer(message: string, images?: Array<{ type: "image"; data: string; mimeType: string }>): void {
this.process.send({ type: "steer", message, ...(images?.length ? { images } : {}) });
}
followUp(
message: string,
images?: Array<{ type: "image"; data: string; mimeType: string }>,
): void {
this.process.send({ type: "follow_up", message, ...(images?.length ? { images } : {}) });
}
async handoff(customInstructions?: string): Promise<void> {
await this.request({
type: "handoff",
...(customInstructions ? { customInstructions } : {}),
});
}
respondToExtensionUiRequest(
id: string,
response: { value?: string; confirmed?: boolean; cancelled?: boolean },
): void {
this.process.send({ type: "extension_ui_response", id, ...response });
}
cancelExtensionUiRequest(id: string): void {
this.respondToExtensionUiRequest(id, { cancelled: true });
}
async close(): Promise<void> {
await this.process.close(new Error("OMP RPC session is closed"));
}
private request(command: OmpRpcCommand, timeoutMs?: number): Promise<unknown> {
return this.process.request(OmpRpcCommandSchema.parse(command), timeoutMs);
}
private emit(event: OmpRuntimeEvent): void {
for (const subscriber of this.subscribers) {
subscriber(event);
}
}
}
@@ -0,0 +1,71 @@
import { describe, expect, test } from "vitest";
import { mapOmpAvailableCommandsUpdate, mapOmpSlashCommands } from "./commands.js";
describe("OMP slash command mapper", () => {
test("maps command updates and preserves input hints", () => {
const commands = mapOmpAvailableCommandsUpdate({
type: "available_commands_update",
commands: [
{ name: "todo", description: "Manage todos", input: { hint: "<subcommand>" } },
{ name: "fast", description: "Toggle fast mode", input: { hint: "[on|off|status]" } },
{ name: "handoff", description: "Start a handoff" },
],
});
expect(commands?.find((command) => command.name === "todo")).toEqual({
name: "todo",
description: "Manage todos",
argumentHint: "<subcommand>",
kind: "command",
});
expect(commands?.find((command) => command.name === "fast")).toEqual({
name: "fast",
description: "Toggle fast mode",
argumentHint: "[on|off|status]",
kind: "command",
});
expect(commands?.find((command) => command.name === "handoff")).toEqual({
name: "handoff",
description: "Start a handoff",
argumentHint: "[instructions]",
kind: "command",
});
});
test("maps source-attributed OMP 17 commands", () => {
const commands = mapOmpAvailableCommandsUpdate({
type: "available_commands_update",
commands: [
{
name: "prewalk",
description: "Prewalk at the next action",
source: "builtin",
},
],
});
expect(commands?.find((command) => command.name === "prewalk")).toEqual({
name: "prewalk",
description: "Prewalk at the next action",
argumentHint: "",
kind: "command",
});
});
test("drops malformed command updates", () => {
expect(
mapOmpAvailableCommandsUpdate({ type: "available_commands_update", commands: [{}] }),
).toBeNull();
});
test("adds OMP-only out-of-band commands to handled built-ins", () => {
expect(mapOmpSlashCommands([]).map((command) => command.name)).toEqual([
"compact",
"autocompact",
"handoff",
"steer",
"follow-up",
]);
});
});
@@ -0,0 +1,74 @@
import type { AgentSlashCommand, AgentSlashCommandKind } from "../../agent-sdk-types.js";
import type { OmpRpcSlashCommand } from "./rpc-types.js";
import { OmpAvailableCommandsUpdateEventSchema, type OmpAvailableCommand } from "./rpc-types.js";
export const OMP_HANDLED_BUILTIN_SLASH_COMMANDS: readonly AgentSlashCommand[] = [
{
name: "compact",
description: "Manually compact the session context",
argumentHint: "[instructions]",
kind: "command",
},
{
name: "autocompact",
description: "Toggle automatic context compaction",
argumentHint: "[on|off|toggle]",
kind: "command",
},
{
name: "handoff",
description: "Hand off from planning to implementation",
argumentHint: "[instructions]",
kind: "command",
},
{
name: "steer",
description: "Steer the active OMP turn",
argumentHint: "<message>",
kind: "command",
},
{
name: "follow-up",
description: "Queue a follow-up message for OMP",
argumentHint: "<message>",
kind: "command",
},
];
export function mapOmpSlashCommands(commands: readonly OmpAvailableCommand[]): AgentSlashCommand[] {
const mappedCommands = new Map<string, AgentSlashCommand>(
OMP_HANDLED_BUILTIN_SLASH_COMMANDS.map((command) => [command.name, { ...command }]),
);
for (const command of commands) {
const knownCommand = mappedCommands.get(command.name);
mappedCommands.set(command.name, {
name: command.name,
description: command.description ?? command.source ?? "command",
argumentHint: command.input?.hint ?? knownCommand?.argumentHint ?? "",
kind: mapOmpCommandKind(command.source),
});
}
return [...mappedCommands.values()];
}
export function mapOmpRuntimeSlashCommands(
commands: readonly OmpRpcSlashCommand[],
): AgentSlashCommand[] {
return mapOmpSlashCommands(
commands.map((command) => ({
name: command.name,
...(command.description ? { description: command.description } : {}),
source: command.source,
...(command.input ? { input: command.input } : {}),
})),
);
}
export function mapOmpAvailableCommandsUpdate(event: unknown): AgentSlashCommand[] | null {
const parsed = OmpAvailableCommandsUpdateEventSchema.safeParse(event);
return parsed.success ? mapOmpSlashCommands(parsed.data.commands) : null;
}
function mapOmpCommandKind(source: string | undefined): AgentSlashCommandKind {
return source === "skill" ? "skill" : "command";
}
@@ -0,0 +1,270 @@
import { describe, expect, test } from "vitest";
import { mapOmpRuntimeEventToTimelineItem } from "./event-mapper.js";
describe("OMP runtime event mapper", () => {
test("maps notice events to timeline status lines", () => {
const event = {
type: "notice",
level: "warning",
message: "Provider quota is getting low",
source: "anthropic",
};
expect(mapOmpRuntimeEventToTimelineItem(event)).toMatchObject({
handled: true,
item: {
type: "tool_call",
callId: expect.stringMatching(/^omp-notice:/),
name: "omp_notice",
status: "completed",
error: null,
detail: {
type: "plain_text",
label: "OMP warning notice from anthropic",
text: "Provider quota is getting low",
icon: "sparkles",
},
metadata: {
synthetic: true,
source: "omp_notice",
level: "warning",
eventSource: "anthropic",
},
},
});
});
test("maps goal_updated events to provider notices and timeline status lines", () => {
expect(
mapOmpRuntimeEventToTimelineItem({
type: "goal_updated",
goal: {
id: "goal-1",
objective: "Ship Phase 5",
status: "active",
tokenBudget: 2000,
tokensUsed: 345,
timeUsedSeconds: 12,
},
state: {
enabled: true,
mode: "active",
},
}),
).toMatchObject({
handled: true,
item: {
type: "tool_call",
callId: "omp-goal:goal-1",
name: "omp_goal_updated",
status: "completed",
error: null,
detail: {
type: "plain_text",
label: "OMP goal active",
text: "Ship Phase 5\nStatus: active\nTokens used: 345\nToken budget: 2000\nTime used: 12s\nMode: active",
icon: "brain",
},
metadata: {
synthetic: true,
source: "omp_goal_updated",
goalId: "goal-1",
goalStatus: "active",
},
},
});
});
test("maps retry telemetry to status-line timeline items", () => {
expect(
mapOmpRuntimeEventToTimelineItem({
type: "auto_retry_start",
attempt: 1,
maxAttempts: 3,
delayMs: 25,
errorMessage: "retrying",
errorId: 7,
}),
).toMatchObject({
handled: true,
item: {
type: "tool_call",
callId: "omp-auto-retry:1",
name: "omp_auto_retry",
status: "running",
error: null,
detail: {
type: "plain_text",
label: "OMP retry 1/3",
text: "Retrying in 25ms: retrying",
icon: "sparkles",
},
metadata: {
source: "omp_auto_retry_start",
attempt: 1,
maxAttempts: 3,
delayMs: 25,
errorId: 7,
},
},
});
expect(
mapOmpRuntimeEventToTimelineItem({
type: "auto_retry_end",
success: false,
attempt: 1,
finalError: "still failed",
}),
).toMatchObject({
handled: true,
item: {
type: "tool_call",
callId: "omp-auto-retry:1",
name: "omp_auto_retry",
status: "failed",
error: "still failed",
detail: {
type: "plain_text",
label: "OMP retry 1 failed",
text: "still failed",
icon: "sparkles",
},
},
});
expect(
mapOmpRuntimeEventToTimelineItem({
type: "retry_fallback_applied",
from: "anthropic/claude-sonnet",
to: "openai/gpt-5",
role: "primary",
}),
).toMatchObject({
handled: true,
item: {
type: "tool_call",
name: "omp_retry_fallback",
status: "completed",
error: null,
detail: {
type: "plain_text",
label: "OMP fallback applied for primary",
text: "anthropic/claude-sonnet -> openai/gpt-5",
icon: "sparkles",
},
metadata: {
source: "omp_retry_fallback_applied",
role: "primary",
from: "anthropic/claude-sonnet",
to: "openai/gpt-5",
},
},
});
expect(
mapOmpRuntimeEventToTimelineItem({
type: "retry_fallback_succeeded",
model: "openai/gpt-5",
role: "primary",
}),
).toMatchObject({
handled: true,
item: {
type: "tool_call",
name: "omp_retry_fallback",
status: "completed",
error: null,
detail: {
type: "plain_text",
label: "OMP fallback succeeded for primary",
text: "Using openai/gpt-5",
icon: "sparkles",
},
metadata: {
source: "omp_retry_fallback_succeeded",
role: "primary",
model: "openai/gpt-5",
},
},
});
});
test("maps auto compaction events to compaction timeline items", () => {
expect(
mapOmpRuntimeEventToTimelineItem({
type: "auto_compaction_start",
reason: "threshold",
action: "context-full",
}),
).toEqual({
handled: true,
item: {
type: "compaction",
status: "loading",
trigger: "auto",
},
});
expect(
mapOmpRuntimeEventToTimelineItem({
type: "auto_compaction_end",
action: "context-full",
result: {
summary: "trimmed",
shortSummary: "trimmed",
firstKeptEntryId: "entry-1",
tokensBefore: 123,
},
aborted: false,
willRetry: false,
}),
).toEqual({
handled: true,
item: {
type: "compaction",
status: "completed",
trigger: "auto",
preTokens: 123,
},
});
expect(
mapOmpRuntimeEventToTimelineItem({
type: "auto_compaction_end",
aborted: true,
willRetry: true,
skipped: true,
errorMessage: "compaction failed",
}),
).toEqual({
handled: true,
item: {
type: "compaction",
status: "completed",
trigger: "auto",
},
});
});
test("leaves unknown events unhandled", () => {
expect(mapOmpRuntimeEventToTimelineItem({ type: "message_start" })).toEqual({
handled: false,
});
});
test("log-drops malformed known OMP events", () => {
expect(
mapOmpRuntimeEventToTimelineItem({
type: "notice",
level: "loud",
message: "bad",
}),
).toEqual({
handled: true,
item: null,
logReason: "malformed_omp_notice",
});
});
});
@@ -0,0 +1,336 @@
import { createHash } from "node:crypto";
import type { AgentTimelineItem, ToolCallIconName } from "../../agent-sdk-types.js";
import {
OmpAutoCompactionEndEventSchema,
OmpAutoCompactionStartEventSchema,
OmpAutoRetryEndEventSchema,
OmpAutoRetryStartEventSchema,
OmpGoalUpdatedEventSchema,
OmpNoticeEventSchema,
OmpRetryFallbackAppliedEventSchema,
OmpRetryFallbackSucceededEventSchema,
type OmpGoal,
type OmpGoalUpdatedEvent,
} from "./rpc-types.js";
type OmpTelemetryToolCallItem = Extract<AgentTimelineItem, { type: "tool_call" }>;
export type OmpRuntimeEventMapping =
| {
handled: false;
}
| {
handled: true;
item: AgentTimelineItem;
}
| {
handled: true;
item: null;
logReason: string;
};
interface StatusLineNoticeInput {
callId: string;
name: string;
label: string;
text?: string;
icon?: ToolCallIconName;
status?: "running" | "completed" | "failed";
error?: unknown;
metadata?: Record<string, unknown>;
}
export function mapOmpRuntimeEventToTimelineItem(event: unknown): OmpRuntimeEventMapping {
const type = readEventType(event);
switch (type) {
case "notice":
return mapNoticeEvent(event);
case "goal_updated":
return mapGoalUpdatedEvent(event);
case "auto_retry_start":
return mapAutoRetryStartEvent(event);
case "auto_retry_end":
return mapAutoRetryEndEvent(event);
case "retry_fallback_applied":
return mapRetryFallbackAppliedEvent(event);
case "retry_fallback_succeeded":
return mapRetryFallbackSucceededEvent(event);
case "auto_compaction_start":
return mapAutoCompactionStartEvent(event);
case "auto_compaction_end":
return mapAutoCompactionEndEvent(event);
default:
return { handled: false };
}
}
function mapNoticeEvent(event: unknown): OmpRuntimeEventMapping {
const parsed = OmpNoticeEventSchema.safeParse(event);
if (!parsed.success) {
return { handled: true, item: null, logReason: "malformed_omp_notice" };
}
const label = parsed.data.source
? `OMP ${parsed.data.level} notice from ${parsed.data.source}`
: `OMP ${parsed.data.level} notice`;
return {
handled: true,
item: buildStatusLineNotice({
callId: `omp-notice:${hashParts(parsed.data.level, parsed.data.source ?? "", parsed.data.message)}`,
name: "omp_notice",
label,
text: parsed.data.message,
icon: "sparkles",
status: parsed.data.level === "error" ? "failed" : "completed",
error: parsed.data.level === "error" ? parsed.data.message : undefined,
metadata: {
synthetic: true,
source: "omp_notice",
level: parsed.data.level,
...(parsed.data.source ? { eventSource: parsed.data.source } : {}),
},
}),
};
}
function mapGoalUpdatedEvent(event: unknown): OmpRuntimeEventMapping {
const parsed = OmpGoalUpdatedEventSchema.safeParse(event);
if (!parsed.success) {
return { handled: true, item: null, logReason: "malformed_omp_goal_updated" };
}
const goal = resolveGoal(parsed.data);
const label = goal?.status ? `OMP goal ${goal.status}` : "OMP goal updated";
const text = formatGoalText(parsed.data, goal);
return {
handled: true,
item: buildStatusLineNotice({
callId: `omp-goal:${goal?.id ?? hashParts(text)}`,
name: "omp_goal_updated",
label,
text,
icon: "brain",
metadata: {
synthetic: true,
source: "omp_goal_updated",
...(goal?.id ? { goalId: goal.id } : {}),
...(goal?.status ? { goalStatus: goal.status } : {}),
},
}),
};
}
function mapAutoRetryStartEvent(event: unknown): OmpRuntimeEventMapping {
const parsed = OmpAutoRetryStartEventSchema.safeParse(event);
if (!parsed.success) {
return { handled: true, item: null, logReason: "malformed_omp_auto_retry_start" };
}
const data = parsed.data;
return {
handled: true,
item: buildStatusLineNotice({
callId: `omp-auto-retry:${data.attempt}`,
name: "omp_auto_retry",
label: `OMP retry ${data.attempt}/${data.maxAttempts}`,
text: `Retrying in ${formatDelay(data.delayMs)}: ${data.errorMessage}`,
icon: "sparkles",
status: "running",
metadata: {
synthetic: true,
source: "omp_auto_retry_start",
attempt: data.attempt,
maxAttempts: data.maxAttempts,
delayMs: data.delayMs,
...(data.errorId !== undefined ? { errorId: data.errorId } : {}),
},
}),
};
}
function mapAutoRetryEndEvent(event: unknown): OmpRuntimeEventMapping {
const parsed = OmpAutoRetryEndEventSchema.safeParse(event);
if (!parsed.success) {
return { handled: true, item: null, logReason: "malformed_omp_auto_retry_end" };
}
const data = parsed.data;
const status = data.success ? "completed" : "failed";
return {
handled: true,
item: buildStatusLineNotice({
callId: `omp-auto-retry:${data.attempt}`,
name: "omp_auto_retry",
label: data.success
? `OMP retry ${data.attempt} recovered`
: `OMP retry ${data.attempt} failed`,
text: data.finalError ?? (data.success ? "Retry recovered." : "Retry failed."),
icon: "sparkles",
status,
error: data.success ? undefined : (data.finalError ?? "OMP retry failed"),
metadata: {
synthetic: true,
source: "omp_auto_retry_end",
attempt: data.attempt,
success: data.success,
},
}),
};
}
function mapRetryFallbackAppliedEvent(event: unknown): OmpRuntimeEventMapping {
const parsed = OmpRetryFallbackAppliedEventSchema.safeParse(event);
if (!parsed.success) {
return { handled: true, item: null, logReason: "malformed_omp_retry_fallback_applied" };
}
const data = parsed.data;
return {
handled: true,
item: buildStatusLineNotice({
callId: `omp-retry-fallback:${hashParts(data.role, data.from, data.to)}`,
name: "omp_retry_fallback",
label: `OMP fallback applied for ${data.role}`,
text: `${data.from} -> ${data.to}`,
icon: "sparkles",
metadata: {
synthetic: true,
source: "omp_retry_fallback_applied",
role: data.role,
from: data.from,
to: data.to,
},
}),
};
}
function mapRetryFallbackSucceededEvent(event: unknown): OmpRuntimeEventMapping {
const parsed = OmpRetryFallbackSucceededEventSchema.safeParse(event);
if (!parsed.success) {
return { handled: true, item: null, logReason: "malformed_omp_retry_fallback_succeeded" };
}
const data = parsed.data;
return {
handled: true,
item: buildStatusLineNotice({
callId: `omp-retry-fallback-succeeded:${hashParts(data.role, data.model)}`,
name: "omp_retry_fallback",
label: `OMP fallback succeeded for ${data.role}`,
text: `Using ${data.model}`,
icon: "sparkles",
metadata: {
synthetic: true,
source: "omp_retry_fallback_succeeded",
role: data.role,
model: data.model,
},
}),
};
}
function mapAutoCompactionStartEvent(event: unknown): OmpRuntimeEventMapping {
const parsed = OmpAutoCompactionStartEventSchema.safeParse(event);
if (!parsed.success) {
return { handled: true, item: null, logReason: "malformed_omp_auto_compaction_start" };
}
return {
handled: true,
item: {
type: "compaction",
status: "loading",
trigger: "auto",
},
};
}
function mapAutoCompactionEndEvent(event: unknown): OmpRuntimeEventMapping {
const parsed = OmpAutoCompactionEndEventSchema.safeParse(event);
if (!parsed.success) {
return { handled: true, item: null, logReason: "malformed_omp_auto_compaction_end" };
}
const preTokens = readCompactionPreTokens(parsed.data.result);
// Wire compaction items only support loading/completed, so every end event clears loading.
return {
handled: true,
item: {
type: "compaction",
status: "completed",
trigger: "auto",
...(preTokens !== undefined ? { preTokens } : {}),
},
};
}
function buildStatusLineNotice(input: StatusLineNoticeInput): OmpTelemetryToolCallItem {
const base = {
type: "tool_call" as const,
callId: input.callId,
name: input.name,
detail: {
type: "plain_text" as const,
label: input.label,
...(input.text ? { text: input.text } : {}),
icon: input.icon ?? "sparkles",
},
metadata: input.metadata,
};
if (input.status === "running") {
return { ...base, status: "running", error: null };
}
if (input.status === "failed") {
return { ...base, status: "failed", error: input.error ?? input.text ?? input.label };
}
return { ...base, status: "completed", error: null };
}
function resolveGoal(event: OmpGoalUpdatedEvent): OmpGoal | null {
return event.goal ?? event.state?.goal ?? null;
}
function formatGoalText(event: OmpGoalUpdatedEvent, goal: OmpGoal | null): string {
if (!goal) {
return "OMP goal cleared.";
}
const parts = [
goal.objective?.trim() || "OMP goal updated.",
goal.status ? `Status: ${goal.status}` : null,
goal.tokensUsed !== undefined ? `Tokens used: ${goal.tokensUsed}` : null,
goal.tokenBudget !== undefined ? `Token budget: ${goal.tokenBudget}` : null,
goal.timeUsedSeconds !== undefined ? `Time used: ${goal.timeUsedSeconds}s` : null,
event.state?.mode ? `Mode: ${event.state.mode}` : null,
].filter((part): part is string => part !== null);
return parts.join("\n");
}
function readEventType(value: unknown): string | null {
if (!isRecord(value)) {
return null;
}
return typeof value.type === "string" ? value.type : null;
}
function readCompactionPreTokens(value: unknown): number | undefined {
if (!isRecord(value)) {
return undefined;
}
return typeof value.tokensBefore === "number" ? value.tokensBefore : undefined;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function formatDelay(delayMs: number): string {
if (delayMs < 1000) {
return `${delayMs}ms`;
}
if (delayMs % 1000 === 0) {
return `${delayMs / 1000}s`;
}
return `${(delayMs / 1000).toFixed(1)}s`;
}
function hashParts(...parts: string[]): string {
const hash = createHash("sha1");
for (const part of parts) {
hash.update(part);
hash.update("\0");
}
return hash.digest("hex").slice(0, 12);
}
@@ -0,0 +1,13 @@
import type { OmpHistoryMapperHooks } from "./message-history.js";
import { mapOmpSystemNoticeToToolCall } from "./system-notice.js";
import { mapOmpToolDetail } from "./tool-call-mapper.js";
import { resolveOmpEmittedToolCallId } from "./tool-call-id.js";
export const OMP_HISTORY_MAPPER_HOOKS: OmpHistoryMapperHooks = {
mapToolDetail: mapOmpToolDetail,
mapCustomMessage: (text, provider) => {
const noticeItem = mapOmpSystemNoticeToToolCall(text);
return noticeItem ? { type: "timeline", provider, item: noticeItem } : null;
},
resolveToolCallId: resolveOmpEmittedToolCallId,
};
@@ -0,0 +1,546 @@
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, test } from "vitest";
import type { AgentStreamEvent } from "../../agent-sdk-types.js";
import { streamOmpCoreHistory, type OmpCapturedUserMessageEntry } from "./message-history.js";
import type { OmpAgentMessage } from "./rpc-types.js";
import { FakeOmp } from "./test-utils/fake-omp.js";
import { OMP_HISTORY_MAPPER_HOOKS } from "./history-hooks.js";
import { streamOmpHistory } from "./history.js";
async function collectHistory(
messages: OmpAgentMessage[],
userEntries: OmpCapturedUserMessageEntry[] = [],
): Promise<AgentStreamEvent[]> {
const events: AgentStreamEvent[] = [];
for await (const event of streamOmpCoreHistory(
"omp",
messages,
userEntries,
OMP_HISTORY_MAPPER_HOOKS,
)) {
events.push(event);
}
return events;
}
describe("OMP history mapper", () => {
test("coalesces replayed subagent poll calls by target set", async () => {
const events = await collectHistory([
{
role: "assistant",
content: [
{ type: "toolCall", id: "poll-1", name: "subagent", arguments: { poll: ["job-a"] } },
],
},
{
role: "toolResult",
toolCallId: "poll-1",
toolName: "subagent",
content: [{ type: "text", text: "first poll" }],
},
{
role: "assistant",
content: [
{ type: "toolCall", id: "poll-2", name: "subagent", arguments: { poll: ["job-a"] } },
{ type: "toolCall", id: "poll-3", name: "subagent", arguments: { poll: ["job-b"] } },
{
type: "toolCall",
id: "spawn-1",
name: "subagent",
arguments: { spawn: [{ task: "go" }] },
},
],
},
{
role: "toolResult",
toolCallId: "poll-2",
toolName: "subagent",
content: [{ type: "text", text: "second poll" }],
},
{
role: "toolResult",
toolCallId: "poll-3",
toolName: "subagent",
content: [{ type: "text", text: "other poll" }],
},
{
role: "toolResult",
toolCallId: "spawn-1",
toolName: "subagent",
content: [{ type: "text", text: "spawned" }],
},
]);
expect(
events.map((event) => (event.item.type === "tool_call" ? event.item.callId : null)),
).toEqual([
"omp-poll:job-a",
"omp-poll:job-a",
"omp-poll:job-a",
"omp-poll:job-b",
"spawn-1",
"omp-poll:job-a",
"omp-poll:job-b",
"spawn-1",
]);
});
test("absorbs replayed omp system-notice custom messages as synthetic tool calls", async () => {
const notice = [
"<system-notice>",
"Background job DocsSmokeTwo has completed. Resume your work using the result below.",
'<task-result id="DocsSmokeTwo" agent="explore" status="completed" duration="21.6s">',
"<output>done</output>",
"</task-result>",
"</system-notice>",
].join("\n");
await expect(
collectHistory(
[
{ role: "user", content: "first prompt" },
{ role: "custom", content: notice },
{ role: "user", content: "second prompt" },
],
[
{ id: "entry-user-1", text: "first prompt" },
{ id: "entry-user-2", text: "second prompt" },
],
),
).resolves.toEqual([
{
type: "timeline",
provider: "omp",
item: {
type: "user_message",
text: "first prompt",
messageId: "entry-user-1",
},
},
{
type: "timeline",
provider: "omp",
item: {
type: "tool_call",
callId: "omp-notice:DocsSmokeTwo",
name: "task_notification",
status: "completed",
detail: {
type: "plain_text",
label: "Background job DocsSmokeTwo completed",
text: notice,
icon: "wrench",
},
metadata: {
synthetic: true,
source: "omp_system_notice",
taskId: "DocsSmokeTwo",
subagentType: "explore",
status: "completed",
},
error: null,
},
},
{
type: "timeline",
provider: "omp",
item: {
type: "user_message",
text: "second prompt",
messageId: "entry-user-2",
},
},
]);
});
test("suppresses replayed raw todo tool calls through the OMP detail hook", async () => {
await expect(
collectHistory([
{
role: "assistant",
content: [{ type: "toolCall", id: "todo-1", name: "todo", arguments: { op: "view" } }],
},
{
role: "toolResult",
toolCallId: "todo-1",
toolName: "todo",
content: [{ type: "text", text: "todos" }],
},
]),
).resolves.toEqual([]);
});
test("replays task tool results as static sub-agent details", async () => {
await expect(
collectHistory([
{
role: "assistant",
content: [
{
type: "toolCall",
id: "task-1",
name: "task",
arguments: { agent: "explore", description: "Inspect files" },
},
],
},
{
role: "toolResult",
toolCallId: "task-1",
toolName: "task",
content: [{ type: "text", text: "done\ntranscript: /tmp/omp-task/Explore.jsonl" }],
},
]),
).resolves.toEqual([
{
type: "timeline",
provider: "omp",
item: {
type: "tool_call",
callId: "task-1",
name: "task",
status: "running",
detail: {
type: "sub_agent",
subAgentType: "explore",
description: "Inspect files",
log: "",
},
error: null,
},
},
{
type: "timeline",
provider: "omp",
item: {
type: "tool_call",
callId: "task-1",
name: "task",
status: "completed",
detail: {
type: "sub_agent",
subAgentType: "explore",
description: "Inspect files",
childSessionId: "/tmp/omp-task/Explore.jsonl",
log: "done\ntranscript: /tmp/omp-task/Explore.jsonl",
},
error: null,
},
},
]);
});
test("replays OMP 17 xd writes as the executed inner tool", async () => {
const events = await collectHistory([
{
role: "assistant",
content: [
{
type: "toolCall",
id: "xd-write-call",
name: "write",
arguments: {
path: "xd://browser",
content: '{"action":"open","name":"docs","url":"https://example.com"}',
},
},
],
},
{
role: "toolResult",
toolCallId: "xd-write-call",
toolName: "write",
content: [{ type: "text", text: "Opened Example Domain" }],
details: {
xdev: {
tool: "browser",
mode: "execute",
args: { action: "open", name: "docs", url: "https://example.com" },
inner: { action: "open", name: "docs", url: "https://example.com" },
},
},
} as OmpAgentMessage,
]);
expect(events.at(-1)).toMatchObject({
item: {
type: "tool_call",
callId: "xd-write-call",
name: "browser",
status: "completed",
detail: {
type: "unknown",
input: { action: "open", name: "docs", url: "https://example.com" },
output: {
content: [{ type: "text", text: "Opened Example Domain" }],
details: { action: "open", name: "docs", url: "https://example.com" },
},
},
},
});
});
test("maps only the active JSONL chain with native user ids and visible unknown roles", async () => {
const dir = mkdtempSync(join(tmpdir(), "omp-history-"));
const sessionFile = join(dir, "session.jsonl");
writeFileSync(
sessionFile,
[
{ type: "session", id: "root", parentId: null },
{
type: "message",
id: "user-old",
parentId: "root",
message: { role: "user", content: "old branch" },
},
{
type: "message",
id: "assistant-old",
parentId: "user-old",
message: { role: "assistant", content: [{ type: "text", text: "old answer" }] },
},
{
type: "session_init",
id: "init-active",
parentId: "root",
systemPrompt: "must stay hidden",
},
{
type: "message",
id: "system-active",
parentId: "init-active",
message: { role: "system", content: "secret system prompt" },
},
{
type: "message",
id: "user-active",
parentId: "system-active",
message: { role: "user", content: "active branch" },
},
{
type: "title",
id: "title-control",
parentId: "user-active",
title: "Updated title",
},
{
type: "custom",
customType: "tool_execution_start",
id: "custom-control",
parentId: "title-control",
data: { toolName: "task" },
},
{
type: "tool_execution_start",
id: "tool-control",
parentId: "custom-control",
command: "secret internal command",
},
{
type: "future_control",
id: "unknown-active",
parentId: "tool-control",
secret: "must not stringify",
},
{
type: "message",
id: "developer-active",
parentId: "unknown-active",
message: { role: "developer", content: "developer note" },
},
]
.map((entry) => JSON.stringify(entry))
.join("\n"),
);
const events: AgentStreamEvent[] = [];
for await (const event of streamOmpHistory({ sessionFile, provider: "omp" })) {
events.push(event);
}
expect(events.map((event) => event.item)).toEqual([
{ type: "user_message", text: "active branch", messageId: "user-active" },
{ type: "assistant_message", text: "[future_control] Unsupported history record" },
{ type: "assistant_message", text: "[developer] developer note" },
]);
const omp = new FakeOmp();
const runtimeSession = await omp.startSession({ cwd: dir });
runtimeSession.activeBranchEntryId = "assistant-old";
const selectedEvents: AgentStreamEvent[] = [];
for await (const event of streamOmpHistory({
sessionFile,
runtimeSession,
provider: "omp",
})) {
selectedEvents.push(event);
}
expect(
selectedEvents.flatMap((event) => (event.type === "timeline" ? [event.item] : [])),
).toEqual([
{ type: "user_message", text: "old branch", messageId: "user-old" },
{
type: "assistant_message",
text: "old answer",
messageId: "omp-history-assistant-1",
},
]);
});
test("rehydrates structured batch and nested task transcripts with stable status and time", async () => {
const dir = mkdtempSync(join(tmpdir(), "omp-subagent-history-"));
const parentFile = join(dir, "parent.jsonl");
const parentStem = parentFile.slice(0, -".jsonl".length);
const echoId = "EchoChild";
const echoFile = join(parentStem, `${echoId}.jsonl`);
const failedFile = join(parentStem, "FailedChild.jsonl");
const abortedFile = join(parentStem, "AbortedChild.jsonl");
const nestedFile = join(parentStem, echoId, "NestedChild.jsonl");
mkdirSync(join(parentStem, echoId), { recursive: true });
const writeEntries = (file: string, entries: object[]): void => {
writeFileSync(file, entries.map((entry) => JSON.stringify(entry)).join("\n"));
};
writeEntries(nestedFile, [
{ type: "session", id: "nested-root", parentId: null, timestamp: "2026-07-07T03:00:00Z" },
{
type: "message",
id: "nested-answer",
parentId: "nested-root",
timestamp: "2026-07-07T03:00:01Z",
message: { role: "assistant", content: [{ type: "text", text: "Nested answer" }] },
},
]);
writeEntries(echoFile, [
{ type: "session", id: "echo-root", parentId: null, timestamp: "2026-07-07T02:00:00Z" },
{
type: "model_change",
id: "echo-model",
parentId: "echo-root",
timestamp: "2026-07-07T02:00:00.500Z",
provider: "openai-codex",
modelId: "gpt-5.5",
},
{
type: "message",
id: "echo-answer",
parentId: "echo-model",
timestamp: "2026-07-07T02:00:01Z",
message: { role: "assistant", content: [{ type: "text", text: "Found it" }] },
},
{
type: "message",
id: "nested-call",
parentId: "echo-answer",
timestamp: "2026-07-07T02:00:02Z",
message: {
role: "assistant",
content: [
{
type: "toolCall",
id: "nested-task",
name: "task",
arguments: { agent: "task" },
},
],
},
},
{
type: "message",
id: "nested-result",
parentId: "nested-call",
timestamp: "2026-07-07T02:00:03Z",
message: {
role: "toolResult",
toolCallId: "nested-task",
toolName: "task",
content: [{ type: "text", text: "nested done" }],
details: { results: [{ id: "NestedChild", exitCode: 0 }] },
},
},
]);
writeEntries(failedFile, [
{ type: "session", id: "failed-root", parentId: null, timestamp: "2026-07-07T04:00:00Z" },
]);
writeEntries(abortedFile, [
{ type: "session", id: "aborted-root", parentId: null, timestamp: 1_752_000_000 },
]);
writeEntries(parentFile, [
{ type: "session", id: "parent-root", parentId: null, timestamp: "2026-07-07T01:00:00Z" },
{
type: "message",
id: "task-call",
parentId: "parent-root",
timestamp: "2026-07-07T01:00:01Z",
message: {
role: "assistant",
content: [{ type: "toolCall", id: "task-1", name: "task", arguments: { agent: "task" } }],
},
},
{
type: "message",
id: "task-result",
parentId: "task-call",
timestamp: "2026-07-07T01:00:02Z",
message: {
role: "toolResult",
toolCallId: "task-1",
toolName: "task",
content: [{ type: "text", text: "batch done" }],
details: {
results: [
{ id: echoId, agent: "task", exitCode: 0 },
{ id: "FailedChild", exitCode: 2, error: "boom" },
{ id: "AbortedChild", aborted: true },
],
},
},
},
]);
const events: AgentStreamEvent[] = [];
for await (const event of streamOmpHistory({ sessionFile: parentFile, provider: "omp" })) {
events.push(event);
}
const subagentEvents = events.flatMap((event) =>
event.type === "provider_subagent" ? [event.event] : [],
);
expect(subagentEvents).toContainEqual({
type: "timeline",
id: echoId,
timestamp: "2026-07-07T02:00:01Z",
item: {
type: "assistant_message",
text: "Found it",
messageId: "omp-history-assistant-1",
},
});
expect(subagentEvents).toContainEqual(
expect.objectContaining({
type: "timeline",
id: "NestedChild",
timestamp: "2026-07-07T03:00:01Z",
}),
);
expect(subagentEvents.filter((event) => event.type === "upsert")).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: echoId,
title: "task · gpt-5.5 (openai-codex)",
status: "running",
timestamp: "2026-07-07T02:00:00Z",
}),
expect.objectContaining({
id: echoId,
title: "task · gpt-5.5 (openai-codex)",
status: "completed",
timestamp: "2026-07-07T02:00:03Z",
}),
expect.objectContaining({ id: "FailedChild", status: "failed" }),
expect.objectContaining({ id: "AbortedChild", status: "canceled" }),
expect.objectContaining({ id: "NestedChild", status: "completed" }),
]),
);
});
});
@@ -0,0 +1,376 @@
import { readFile } from "node:fs/promises";
import { basename, extname, join } from "node:path";
import type { AgentProvider, AgentStreamEvent } from "../../agent-sdk-types.js";
import { normalizeProviderReplayTimestamp } from "../../provider-history-timestamps.js";
import { OmpHistoryMapper, type OmpCapturedUserMessageEntry } from "./message-history.js";
import type { OmpAgentMessage } from "./rpc-types.js";
import type { OmpRuntimeSession } from "./runtime.js";
import { OMP_HISTORY_MAPPER_HOOKS } from "./history-hooks.js";
import { formatOmpSubagentTitle } from "./subagent-title.js";
interface OmpSessionEntry {
type?: string;
id?: string;
parentId?: string | null;
timestamp?: string | number;
message?: Record<string, unknown>;
[key: string]: unknown;
}
function extractOmpSubagentModel(entries: readonly OmpSessionEntry[]): string | null {
let resolvedModel: string | null = null;
for (const entry of entries) {
let candidate: string | null = null;
if (entry.type === "model_change") {
candidate = buildOmpModelId(entry.provider, entry.modelId);
} else if (entry.type === "message" && entry.message?.role === "assistant") {
candidate = buildOmpModelId(
entry.message.provider,
entry.message.responseModel ?? entry.message.model,
);
}
resolvedModel = candidate ?? resolvedModel;
}
return resolvedModel;
}
function buildOmpModelId(provider: unknown, model: unknown): string | null {
if (typeof provider !== "string" || typeof model !== "string") return null;
const normalizedProvider = provider.trim();
const normalizedModel = model.trim();
return normalizedProvider && normalizedModel ? `${normalizedProvider}/${normalizedModel}` : null;
}
export async function* streamOmpHistory(input: {
sessionFile?: string;
runtimeSession?: OmpRuntimeSession;
provider: AgentProvider;
visitedSessionFiles?: Set<string>;
}): AsyncGenerator<AgentStreamEvent> {
if (!input.sessionFile) {
return;
}
const visitedSessionFiles = input.visitedSessionFiles ?? new Set<string>();
if (visitedSessionFiles.has(input.sessionFile)) {
return;
}
visitedSessionFiles.add(input.sessionFile);
let entries: OmpSessionEntry[];
try {
entries = await readActiveOmpEntryChain(
input.sessionFile,
input.runtimeSession?.activeBranchEntryId,
);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return;
}
throw error;
}
const messages: OmpAgentMessage[] = [];
const messageEntries: OmpSessionEntry[] = [];
const userEntries: OmpCapturedUserMessageEntry[] = [];
for (const entry of entries) {
const mapped = mapEntryMessage(entry);
if (!mapped) continue;
messages.push(mapped);
messageEntries.push(entry);
if (mapped.role === "user" && entry.id) {
userEntries.push({ id: entry.id, text: textOf(mapped.content) });
}
}
const mapper = new OmpHistoryMapper(input.provider, userEntries, OMP_HISTORY_MAPPER_HOOKS);
for (let index = 0; index < messages.length; index += 1) {
const timestamp = normalizeProviderReplayTimestamp(messageEntries[index]?.timestamp);
for (const event of mapper.mapMessages([messages[index]!])) {
yield timestamp && event.type === "timeline" ? { ...event, timestamp } : event;
}
}
for (const transcript of readSubagentTranscripts(messages, input.sessionFile)) {
yield* replaySubagentTranscript(transcript, input.provider, visitedSessionFiles);
}
}
async function* replaySubagentTranscript(
transcript: OmpSubagentTranscript,
provider: AgentProvider,
visitedSessionFiles: Set<string>,
): AsyncGenerator<AgentStreamEvent> {
const childEntries = await readActiveOmpEntryChain(transcript.sessionFile).catch(
(error: unknown) => {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
throw error;
},
);
const resolvedModel = extractOmpSubagentModel(childEntries);
const firstTimestamp = normalizeProviderReplayTimestamp(childEntries[0]?.timestamp);
yield subagentUpsert(transcript, provider, "running", firstTimestamp, resolvedModel);
for await (const event of streamOmpHistory({
sessionFile: transcript.sessionFile,
provider,
visitedSessionFiles,
})) {
if (event.type === "timeline") {
yield {
type: "provider_subagent",
provider,
event: {
type: "timeline",
id: transcript.id,
item: event.item,
...(event.timestamp ? { timestamp: event.timestamp } : {}),
},
};
} else if (event.type === "provider_subagent") {
yield event;
}
}
const lastTimestamp = normalizeProviderReplayTimestamp(childEntries.at(-1)?.timestamp);
yield subagentUpsert(transcript, provider, transcript.status, lastTimestamp, resolvedModel);
}
function subagentUpsert(
transcript: OmpSubagentTranscript,
provider: AgentProvider,
status: OmpSubagentTranscript["status"] | "running",
timestamp: string | null,
resolvedModel: string | null,
): AgentStreamEvent {
return {
type: "provider_subagent",
provider,
event: {
type: "upsert",
id: transcript.id,
title: formatOmpSubagentTitle(transcript.title, resolvedModel),
status,
toolCallId: transcript.toolCallId,
...(timestamp ? { timestamp } : {}),
},
};
}
interface OmpSubagentTranscript {
id: string;
title: string;
toolCallId: string;
sessionFile: string;
status: "completed" | "failed" | "canceled";
}
function readSubagentTranscripts(
messages: readonly OmpAgentMessage[],
parentSessionFile: string,
): OmpSubagentTranscript[] {
const taskCalls = collectTaskCalls(messages);
const transcripts: OmpSubagentTranscript[] = [];
for (const message of messages) {
if (message.role !== "toolResult" || message.toolName !== "task") continue;
const call = taskCalls.get(message.toolCallId);
if (!call) continue;
const results = readTaskResults(message);
for (const result of results) {
transcripts.push({
id: result.id,
title: result.agent ?? call.title,
toolCallId: message.toolCallId,
sessionFile: join(stripExtension(parentSessionFile), `${basename(result.id)}.jsonl`),
status: taskResultStatus(result, message.isError === true),
});
}
if (results.length > 0) continue;
const legacy = readLegacyTranscript(message, call.title);
if (legacy) transcripts.push(legacy);
}
return transcripts;
}
function collectTaskCalls(messages: readonly OmpAgentMessage[]): Map<string, { title: string }> {
const taskCalls = new Map<string, { title: string }>();
for (const message of messages) {
if (message.role !== "assistant" || !Array.isArray(message.content)) continue;
for (const block of message.content) {
if (block.type !== "toolCall" || block.name !== "task" || typeof block.id !== "string") {
continue;
}
const args = block.arguments;
const title =
args && typeof args === "object" && typeof Reflect.get(args, "agent") === "string"
? Reflect.get(args, "agent")
: "OMP subagent";
taskCalls.set(block.id, { title });
}
}
return taskCalls;
}
function readLegacyTranscript(
message: Extract<OmpAgentMessage, { role: "toolResult" }>,
title: string,
): OmpSubagentTranscript | null {
const text = taskResultText(message);
const sessionFile = text.match(/(?:session|transcript)(?: file)?:\s*(?<path>\/\S+\.jsonl)/i)
?.groups?.path;
if (!sessionFile) return null;
const fileName = basename(sessionFile);
const extension = extname(fileName);
return {
id: extension ? fileName.slice(0, -extension.length) : fileName,
title,
toolCallId: message.toolCallId,
sessionFile,
status: message.isError ? "failed" : "completed",
};
}
interface OmpTaskResult {
id: string;
agent?: string;
exitCode?: number;
error?: unknown;
aborted?: boolean;
}
function readTaskResults(
message: Extract<OmpAgentMessage, { role: "toolResult" }>,
): OmpTaskResult[] {
const details =
Reflect.get(message, "details") ??
(message.content && typeof message.content === "object"
? Reflect.get(message.content, "details")
: undefined);
const results =
details && typeof details === "object" ? Reflect.get(details, "results") : undefined;
if (!Array.isArray(results)) return [];
return results.flatMap((result) => {
if (!result || typeof result !== "object") return [];
const id = Reflect.get(result, "id");
if (typeof id !== "string" || !id) return [];
const agent = Reflect.get(result, "agent");
const exitCode = Reflect.get(result, "exitCode");
return [
{
id,
...(typeof agent === "string" ? { agent } : {}),
...(typeof exitCode === "number" ? { exitCode } : {}),
error: Reflect.get(result, "error"),
aborted: Reflect.get(result, "aborted") === true,
},
];
});
}
function taskResultStatus(
result: OmpTaskResult,
messageIsError: boolean,
): "completed" | "failed" | "canceled" {
if (messageIsError || result.error || (result.exitCode !== undefined && result.exitCode !== 0)) {
return "failed";
}
return result.aborted ? "canceled" : "completed";
}
function taskResultText(message: Extract<OmpAgentMessage, { role: "toolResult" }>): string {
return Array.isArray(message.content)
? message.content
.flatMap((block) =>
block &&
typeof block === "object" &&
Reflect.get(block, "type") === "text" &&
typeof Reflect.get(block, "text") === "string"
? [Reflect.get(block, "text") as string]
: [],
)
.join("\n")
: "";
}
function stripExtension(filePath: string): string {
const extension = extname(filePath);
return extension ? filePath.slice(0, -extension.length) : filePath;
}
export async function readActiveOmpEntryChain(
sessionFile: string,
activeEntryId?: string,
): Promise<OmpSessionEntry[]> {
const content = await readFile(sessionFile, "utf8");
const entries = content.split("\n").flatMap((line) => {
if (!line.trim()) return [];
try {
const value = JSON.parse(line) as OmpSessionEntry;
return value && typeof value === "object" && typeof value.id === "string" ? [value] : [];
} catch {
return [];
}
});
if (entries.length === 0) return [];
const byId = new Map(entries.map((entry) => [entry.id!, entry]));
const parentIds = new Set(entries.flatMap((entry) => (entry.parentId ? [entry.parentId] : [])));
const leaves = entries.filter((entry) => !parentIds.has(entry.id!));
let current: OmpSessionEntry | undefined =
(activeEntryId ? byId.get(activeEntryId) : undefined) ?? leaves.at(-1) ?? entries.at(-1);
const chain: OmpSessionEntry[] = [];
const seen = new Set<string>();
while (current?.id && !seen.has(current.id)) {
chain.push(current);
seen.add(current.id);
current = current.parentId ? byId.get(current.parentId) : undefined;
}
return chain.toReversed();
}
function mapEntryMessage(entry: OmpSessionEntry): OmpAgentMessage | null {
const message = entry.message;
if (message && typeof message.role === "string") {
if (message.role === "system") {
return null;
}
if (["user", "assistant", "toolResult", "custom", "bashExecution"].includes(message.role)) {
return message as unknown as OmpAgentMessage;
}
return visibleFallback(message.role, message);
}
if (!entry.type || isControlEntryType(entry.type)) {
return null;
}
return visibleFallback(entry.type, entry);
}
function isControlEntryType(type: string): boolean {
return (
type === "session" ||
type === "session_init" ||
type === "system" ||
type === "title" ||
type === "title_change" ||
type === "custom" ||
type === "system_prompt" ||
type === "model_change" ||
type === "thinking_level_change" ||
type === "tool_execution" ||
type.startsWith("tool_execution_")
);
}
function visibleFallback(role: string, value: Record<string, unknown>): OmpAgentMessage {
let text = "Unsupported history record";
if (typeof value.content === "string") {
text = value.content;
} else if (typeof value.text === "string") {
text = value.text;
}
return { role: "custom", content: `[${role}] ${text}` } as OmpAgentMessage;
}
function textOf(content: unknown): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content
.flatMap((part) =>
part && typeof part === "object" && typeof (part as { text?: unknown }).text === "string"
? [(part as { text: string }).text]
: [],
)
.join("\n");
}
@@ -0,0 +1,202 @@
import pino from "pino";
import { describe, expect, test } from "vitest";
import { z } from "zod";
import type { PaseoToolCatalog, PaseoToolDefinition, PaseoToolResult } from "../../tools/types.js";
import {
clearOmpHostToolState,
handleOmpHostToolRuntimeEvent,
serializeOmpHostTools,
waitForOmpHostToolsIdle,
} from "./host-tools.js";
import type { OmpRpcHostToolResult } from "./rpc-types.js";
import { FakeOmp } from "./test-utils/fake-omp.js";
function createCatalog(tools: PaseoToolDefinition[]): PaseoToolCatalog {
const toolMap = new Map(tools.map((tool) => [tool.name, tool]));
return {
tools: toolMap,
getTool: (name) => toolMap.get(name),
executeTool: async (name, input, context = {}) => {
const tool = toolMap.get(name);
if (!tool) throw new Error(`Missing tool ${name}`);
return await tool.handler(input, context);
},
};
}
class OmpHostToolHarness {
private readonly logger = pino({ level: "silent" });
private resolveControlledResult: ((result: PaseoToolResult) => void) | null = null;
private controlledSignal: AbortSignal | null = null;
private resolveControlledStart: (() => void) | null = null;
private readonly controlledStart = new Promise<void>((resolve) => {
this.resolveControlledStart = resolve;
});
private constructor(
private readonly catalog: PaseoToolCatalog,
private readonly runtimeSession: Awaited<ReturnType<FakeOmp["startSession"]>>,
) {}
static async withTools(tools: PaseoToolDefinition[]): Promise<OmpHostToolHarness> {
const omp = new FakeOmp();
const runtimeSession = await omp.startSession({ cwd: "/workspace/project" });
return new OmpHostToolHarness(createCatalog(tools), runtimeSession);
}
static async cancellable(): Promise<OmpHostToolHarness> {
let harness: OmpHostToolHarness;
const tool: PaseoToolDefinition = {
name: "wait_for_agent",
description: "Wait for a Paseo agent.",
handler: async (_input, context) => {
harness.controlledSignal = context.signal ?? null;
harness.resolveControlledStart?.();
return await new Promise<PaseoToolResult>((resolve) => {
harness.resolveControlledResult = resolve;
});
},
};
harness = await OmpHostToolHarness.withTools([tool]);
return harness;
}
async call(input: {
id: string;
toolCallId: string;
toolName: string;
arguments: Record<string, unknown>;
}): Promise<OmpRpcHostToolResult> {
const result = this.runtimeSession.nextHostToolResult();
handleOmpHostToolRuntimeEvent({ type: "host_tool_call", ...input }, this.routerInput());
return await result;
}
startControlledCall(): void {
handleOmpHostToolRuntimeEvent(
{
type: "host_tool_call",
id: "host-cancel",
toolCallId: "tool-cancel",
toolName: "wait_for_agent",
arguments: { agentId: "child-1" },
},
this.routerInput(),
);
}
async waitForControlledCall(): Promise<void> {
await this.controlledStart;
}
cancelControlledCall(): void {
handleOmpHostToolRuntimeEvent(
{ type: "host_tool_cancel", id: "cancel-1", targetId: "host-cancel" },
this.routerInput(),
);
}
completeControlledCall(result: PaseoToolResult): void {
if (!this.resolveControlledResult) throw new Error("Controlled host tool has not started");
this.resolveControlledResult(result);
}
async waitForIdle(): Promise<void> {
await waitForOmpHostToolsIdle(this.runtimeSession);
}
wasControlledCallAborted(): boolean {
return this.controlledSignal?.aborted === true;
}
updates() {
return this.runtimeSession.hostToolUpdates;
}
results() {
return this.runtimeSession.hostToolResults;
}
close(): void {
clearOmpHostToolState(this.runtimeSession);
}
private routerInput() {
return { runtimeSession: this.runtimeSession, paseoTools: this.catalog, logger: this.logger };
}
}
describe("OMP host tools", () => {
test("serializes the caller-scoped Paseo catalog for set_host_tools", () => {
const catalog = createCatalog([
{
name: "create_agent",
title: "Create agent",
description: "Create a Paseo agent.",
inputSchema: { initialPrompt: z.string().describe("Prompt for the new agent.") },
handler: async () => ({ content: [] }),
},
]);
expect(serializeOmpHostTools(catalog)).toEqual([
{
name: "create_agent",
label: "Create agent",
description: "Create a Paseo agent.",
parameters: expect.objectContaining({ type: "object", required: ["initialPrompt"] }),
},
]);
});
test("routes calls and progress through the typed OMP runtime", async () => {
const omp = await OmpHostToolHarness.withTools([
{
name: "create_agent",
description: "Create a Paseo agent.",
handler: async (input, context) => {
context.sendUpdate?.({ content: [{ type: "text", text: "creating" }] });
return { content: [], structuredContent: { input, agentId: "child-1" } };
},
},
]);
await expect(
omp.call({
id: "host-1",
toolCallId: "tool-1",
toolName: "create_agent",
arguments: { initialPrompt: "Inspect the bug" },
}),
).resolves.toEqual({
type: "host_tool_result",
id: "host-1",
result: {
content: [{ type: "text", text: expect.stringContaining('"agentId": "child-1"') }],
details: { input: { initialPrompt: "Inspect the bug" }, agentId: "child-1" },
},
});
expect(omp.updates()).toEqual([
{
type: "host_tool_update",
id: "host-1",
partialResult: { content: [{ type: "text", text: "creating" }] },
},
]);
});
test("cancels an in-flight host tool and drops its late result", async () => {
const omp = await OmpHostToolHarness.cancellable();
omp.startControlledCall();
await omp.waitForControlledCall();
omp.cancelControlledCall();
omp.completeControlledCall({ content: [{ type: "text", text: "late" }] });
await omp.waitForIdle();
expect(omp.wasControlledCallAborted()).toBe(true);
expect(omp.results()).toEqual([]);
expect(omp.updates()).toEqual([]);
omp.close();
});
});
@@ -0,0 +1,270 @@
import type { Logger } from "pino";
import {
addModelVisibleStructuredContent,
serializePaseoToolInputParameters,
} from "../../tools/paseo-tool-serialization.js";
import type { PaseoToolCatalog, PaseoToolResult } from "../../tools/types.js";
import type { OmpRuntimeSession } from "./runtime.js";
import {
OmpRpcHostToolCallRequestSchema,
OmpRpcHostToolCancelRequestSchema,
OmpRpcHostToolUpdateSchema,
type OmpAgentToolResult,
type OmpRpcHostToolCallRequest,
type OmpRpcHostToolDefinition,
type OmpRpcHostToolResult,
type OmpRpcHostToolUpdate,
} from "./rpc-types.js";
interface PendingOmpHostToolCall {
controller: AbortController;
canceled: boolean;
}
interface OmpHostToolRouterInput {
runtimeSession: OmpRuntimeSession;
catalog: PaseoToolCatalog;
logger: Logger;
}
const routersByRuntimeSession = new WeakMap<OmpRuntimeSession, OmpHostToolRouter>();
export function serializeOmpHostTools(catalog: PaseoToolCatalog): OmpRpcHostToolDefinition[] {
return [...catalog.tools.values()].map((tool) => {
const definition: OmpRpcHostToolDefinition = {
name: tool.name,
description: tool.description,
parameters: serializePaseoToolInputParameters(tool),
};
if (tool.title) {
definition.label = tool.title;
}
return definition;
});
}
export async function setOmpHostTools(
runtimeSession: OmpRuntimeSession,
catalog: PaseoToolCatalog,
): Promise<string[]> {
return await runtimeSession.setHostTools(serializeOmpHostTools(catalog));
}
export function handleOmpHostToolRuntimeEvent(
event: unknown,
input: {
runtimeSession: OmpRuntimeSession;
paseoTools?: PaseoToolCatalog;
logger: Logger;
},
): boolean {
if (!isRecord(event) || typeof event.type !== "string" || !isOmpHostToolEventType(event.type)) {
return false;
}
const call = OmpRpcHostToolCallRequestSchema.safeParse(event);
if (call.success) {
const router = getRouter(input);
if (!router) {
sendMissingCatalogResult(input.runtimeSession, call.data);
return true;
}
router.handleCall(call.data);
return true;
}
const cancel = OmpRpcHostToolCancelRequestSchema.safeParse(event);
if (cancel.success) {
getRouter(input)?.handleCancel(cancel.data.targetId);
return true;
}
const update = OmpRpcHostToolUpdateSchema.safeParse(event);
if (update.success) {
input.logger.debug({ id: update.data.id }, "Ignoring unexpected inbound OMP host tool update");
return true;
}
input.logger.debug({ event }, "Dropped malformed OMP host tool frame");
return true;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function clearOmpHostToolState(runtimeSession: OmpRuntimeSession): void {
routersByRuntimeSession.get(runtimeSession)?.clear();
routersByRuntimeSession.delete(runtimeSession);
}
export async function waitForOmpHostToolsIdle(runtimeSession: OmpRuntimeSession): Promise<void> {
await routersByRuntimeSession.get(runtimeSession)?.waitForIdle();
}
function getRouter(input: {
runtimeSession: OmpRuntimeSession;
paseoTools?: PaseoToolCatalog;
logger: Logger;
}): OmpHostToolRouter | null {
if (!input.paseoTools) {
return null;
}
const existing = routersByRuntimeSession.get(input.runtimeSession);
if (existing) {
return existing;
}
const router = new OmpHostToolRouter({
runtimeSession: input.runtimeSession,
catalog: input.paseoTools,
logger: input.logger,
});
routersByRuntimeSession.set(input.runtimeSession, router);
return router;
}
function sendMissingCatalogResult(
runtimeSession: OmpRuntimeSession,
request: OmpRpcHostToolCallRequest,
): void {
runtimeSession.sendHostToolResult(
toOmpHostToolErrorResult(
request.id,
`Host tool "${request.toolName}" was called before Paseo tools were registered`,
),
);
}
function isOmpHostToolEventType(type: string): boolean {
return type === "host_tool_call" || type === "host_tool_cancel" || type === "host_tool_update";
}
class OmpHostToolRouter {
private readonly runtimeSession: OmpRuntimeSession;
private readonly catalog: PaseoToolCatalog;
private readonly logger: Logger;
private readonly pendingCalls = new Map<string, PendingOmpHostToolCall>();
private readonly idleWaiters = new Set<() => void>();
constructor(input: OmpHostToolRouterInput) {
this.runtimeSession = input.runtimeSession;
this.catalog = input.catalog;
this.logger = input.logger;
}
handleCall(request: OmpRpcHostToolCallRequest): void {
const entry: PendingOmpHostToolCall = {
controller: new AbortController(),
canceled: false,
};
this.pendingCalls.set(request.id, entry);
void this.executeCall(request, entry).catch((error: unknown) => {
this.logger.warn({ err: error, toolName: request.toolName }, "OMP host tool call failed");
});
}
handleCancel(targetId: string): void {
const pending = this.pendingCalls.get(targetId);
if (!pending) {
return;
}
pending.canceled = true;
pending.controller.abort(new Error(`OMP host tool call ${targetId} cancelled`));
}
clear(): void {
for (const pending of this.pendingCalls.values()) {
pending.canceled = true;
pending.controller.abort(new Error("OMP session closed"));
}
this.pendingCalls.clear();
this.resolveIdleWaiters();
}
waitForIdle(): Promise<void> {
if (this.pendingCalls.size === 0) return Promise.resolve();
return new Promise((resolve) => this.idleWaiters.add(resolve));
}
private async executeCall(
request: OmpRpcHostToolCallRequest,
entry: PendingOmpHostToolCall,
): Promise<void> {
try {
const result = await this.catalog.executeTool(request.toolName, request.arguments, {
signal: entry.controller.signal,
sendUpdate: (update) => {
if (entry.canceled || entry.controller.signal.aborted) {
return;
}
this.sendUpdate(request.id, update);
},
});
if (entry.canceled || entry.controller.signal.aborted) {
return;
}
this.runtimeSession.sendHostToolResult(toOmpHostToolResult(request.id, result));
} catch (error) {
if (entry.canceled || entry.controller.signal.aborted) {
return;
}
this.runtimeSession.sendHostToolResult(toOmpHostToolErrorResult(request.id, error));
} finally {
this.pendingCalls.delete(request.id);
this.resolveIdleWaiters();
}
}
private resolveIdleWaiters(): void {
if (this.pendingCalls.size > 0) return;
for (const resolve of this.idleWaiters) resolve();
this.idleWaiters.clear();
}
private sendUpdate(callId: string, result: PaseoToolResult): void {
const update: OmpRpcHostToolUpdate = {
type: "host_tool_update",
id: callId,
partialResult: toOmpAgentToolResult(addModelVisibleStructuredContent(result)),
};
this.runtimeSession.sendHostToolUpdate(update);
}
}
function toOmpHostToolResult(id: string, result: PaseoToolResult): OmpRpcHostToolResult {
const modelVisibleResult = addModelVisibleStructuredContent(result);
const mappedResult = toOmpAgentToolResult(modelVisibleResult);
return {
type: "host_tool_result",
id,
result: mappedResult,
...(modelVisibleResult.isError !== undefined ? { isError: modelVisibleResult.isError } : {}),
};
}
function toOmpHostToolErrorResult(id: string, error: unknown): OmpRpcHostToolResult {
return {
type: "host_tool_result",
id,
result: {
content: [{ type: "text", text: error instanceof Error ? error.message : String(error) }],
details: {},
isError: true,
},
isError: true,
};
}
function toOmpAgentToolResult(result: PaseoToolResult): OmpAgentToolResult {
const mapped: OmpAgentToolResult = {
content: result.content.map((item) => ({ ...item })),
};
if (result.structuredContent !== undefined) {
mapped.details = result.structuredContent;
}
if (result.isError !== undefined) {
mapped.isError = result.isError;
}
return mapped;
}
@@ -0,0 +1,283 @@
import type { AgentStreamEvent, AgentTimelineItem, ToolCallDetail } from "../../agent-sdk-types.js";
import type { OmpAgentMessage, OmpImageContent, OmpTextContent } from "./rpc-types.js";
import {
extractTextFromToolResult,
mapToolDetail,
parseToolArgs,
parseToolResult,
resolveToolCallName,
type OmpToolResult,
type OmpTrackedToolCall,
} from "./tool-call-detail.js";
export interface OmpCapturedUserMessageEntry {
id: string;
text: string;
}
export interface OmpHistoryMapperHooks {
mapCustomMessage?: (
text: string,
provider: string,
) => Extract<AgentStreamEvent, { type: "timeline" }> | null;
resolveToolCallId?: (toolCallId: string, toolCall: OmpTrackedToolCall) => string;
mapToolDetail?: (
toolCall: OmpTrackedToolCall,
result: OmpToolResult,
context: { toolCallId: string },
) => ToolCallDetail | null;
}
function isTextContentBlock(block: unknown): block is OmpTextContent {
return (
typeof block === "object" &&
block !== null &&
!Array.isArray(block) &&
Reflect.get(block, "type") === "text" &&
typeof Reflect.get(block, "text") === "string"
);
}
export function getUserMessageText(content: string | (OmpTextContent | OmpImageContent)[]): string {
if (typeof content === "string") {
return content;
}
const textParts: string[] = [];
for (const block of content) {
if (isTextContentBlock(block)) {
textParts.push(block.text);
}
}
return textParts.join("\n\n");
}
export class OmpHistoryMapper {
private readonly pendingToolCalls = new Map<string, OmpTrackedToolCall>();
private userIndex = 0;
private assistantIndex = 0;
constructor(
private readonly provider: string,
private readonly userEntries: readonly OmpCapturedUserMessageEntry[] = [],
private readonly hooks: OmpHistoryMapperHooks = {},
) {}
mapMessages(messages: readonly OmpAgentMessage[]): AgentStreamEvent[] {
const events: AgentStreamEvent[] = [];
for (const message of messages) {
switch (message.role) {
case "user":
events.push(...this.mapUserMessage(message));
break;
case "custom":
events.push(...this.mapCustomMessage(message));
break;
case "assistant":
events.push(...this.mapAssistantMessage(message));
break;
case "toolResult": {
const event = this.mapToolResultMessage(message);
if (event) {
events.push(event);
}
break;
}
case "bashExecution":
events.push(this.mapBashExecutionMessage(message));
break;
}
}
return events;
}
private mapUserMessage(message: Extract<OmpAgentMessage, { role: "user" }>): AgentStreamEvent[] {
const text = getUserMessageText(message.content);
this.userIndex += 1;
if (!text) {
return [];
}
const userEntry = this.userEntries[this.userIndex - 1];
return [
{
type: "timeline",
provider: this.provider,
item: {
type: "user_message",
text,
...(userEntry ? { messageId: userEntry.id } : {}),
},
},
];
}
private mapCustomMessage(
message: Extract<OmpAgentMessage, { role: "custom" }>,
): AgentStreamEvent[] {
const text = getUserMessageText(message.content);
const mappedEvent = text ? this.hooks.mapCustomMessage?.(text, this.provider) : null;
if (mappedEvent) {
return [mappedEvent];
}
return text
? [
{
type: "timeline",
provider: this.provider,
item: { type: "assistant_message", text },
},
]
: [];
}
private mapAssistantMessage(
message: Extract<OmpAgentMessage, { role: "assistant" }>,
): AgentStreamEvent[] {
const events: AgentStreamEvent[] = [];
this.assistantIndex += 1;
const messageId =
message.responseId || `${this.provider}-history-assistant-${this.assistantIndex}`;
for (const content of message.content) {
if (content.type === "text" && content.text) {
events.push({
type: "timeline",
provider: this.provider,
item: { type: "assistant_message", text: content.text, messageId },
});
continue;
}
if (content.type === "thinking" && content.thinking) {
events.push({
type: "timeline",
provider: this.provider,
item: { type: "reasoning", text: content.thinking },
});
continue;
}
if (content.type === "toolCall") {
const tracked = parseToolArgs(content.name, content.arguments);
this.pendingToolCalls.set(content.id, tracked);
const detail = this.mapToolDetail(content.id, tracked, null);
if (!detail) {
continue;
}
events.push({
type: "timeline",
provider: this.provider,
item: {
type: "tool_call",
callId: this.resolveToolCallId(content.id, tracked),
name: tracked.toolName,
status: "running",
detail,
error: null,
},
});
}
}
return events;
}
private mapToolResultMessage(
message: Extract<OmpAgentMessage, { role: "toolResult" }>,
): AgentStreamEvent | null {
const tracked =
this.pendingToolCalls.get(message.toolCallId) ?? parseToolArgs(message.toolName, null);
this.pendingToolCalls.delete(message.toolCallId);
const result = parseToolResult({ content: message.content, details: message.details });
const detail = this.mapToolDetail(message.toolCallId, tracked, result);
if (!detail) {
return null;
}
return {
type: "timeline",
provider: this.provider,
item: toToolResultTimelineItem({
callId: this.resolveToolCallId(message.toolCallId, tracked),
name: resolveToolCallName(tracked, result),
isError: Boolean(message.isError),
detail,
errorText: extractTextFromToolResult(result) ?? "Tool call failed",
}),
};
}
private mapBashExecutionMessage(
message: Extract<OmpAgentMessage, { role: "bashExecution" }>,
): AgentStreamEvent {
const detail: ToolCallDetail = {
type: "shell",
command: message.command,
output: message.output,
exitCode: message.exitCode ?? null,
};
return {
type: "timeline",
provider: this.provider,
item: {
type: "tool_call",
callId: `omp-bash-${message.timestamp}`,
name: "bash",
status: message.cancelled ? "canceled" : "completed",
detail,
error: null,
},
};
}
private resolveToolCallId(toolCallId: string, toolCall: OmpTrackedToolCall): string {
return this.hooks.resolveToolCallId?.(toolCallId, toolCall) ?? toolCallId;
}
private mapToolDetail(
toolCallId: string,
toolCall: OmpTrackedToolCall,
result: OmpToolResult,
): ToolCallDetail | null {
const hook = this.hooks.mapToolDetail;
return hook ? hook(toolCall, result, { toolCallId }) : mapToolDetail(toolCall, result);
}
}
export async function* streamOmpCoreHistory(
provider: string,
messages: OmpAgentMessage[],
userEntries: readonly OmpCapturedUserMessageEntry[] = [],
hooks: OmpHistoryMapperHooks = {},
): AsyncGenerator<AgentStreamEvent> {
const mapper = new OmpHistoryMapper(provider, userEntries, hooks);
for (const event of mapper.mapMessages(messages)) {
if (event) {
yield event;
}
}
}
function toToolResultTimelineItem(input: {
callId: string;
name: string;
isError: boolean;
detail: ToolCallDetail;
errorText: string;
}): AgentTimelineItem {
if (input.isError) {
return {
type: "tool_call",
callId: input.callId,
name: input.name,
status: "failed",
detail: input.detail,
error: input.errorText,
};
}
return {
type: "tool_call",
callId: input.callId,
name: input.name,
status: "completed",
detail: input.detail,
error: null,
};
}
@@ -0,0 +1,150 @@
import { existsSync } from "node:fs";
import { homedir } from "node:os";
import { join, resolve } from "node:path";
import { OMP_MODES } from "@getpaseo/protocol/provider-manifest";
import { z } from "zod";
import type { ProviderRuntimeSettings } from "../../provider-launch-config.js";
const OMP_SESSION_DIR = "~/.omp/agent/sessions";
const DEFAULT_OMP_MODE_ID = "full";
export const MIN_SUPPORTED_OMP_VERSION = "16.3.9";
export { OMP_MODES };
export const OmpProviderParamsSchema = z
.object({
sessionDir: z.string().min(1).optional(),
smolModel: z.string().min(1).optional(),
slowModel: z.string().min(1).optional(),
planModel: z.string().min(1).optional(),
})
.strict();
export interface OmpRuntimeProviderParams {
sessionDir: string;
}
export interface OmpModelRoleParams {
smolModel?: string;
slowModel?: string;
planModel?: string;
}
export function resolveOmpLaunchMode(
modeId: string | undefined,
modelRoleParams: OmpModelRoleParams = {},
): { modeId: string; extraArgs: string[] } {
const modelRoleArgs = resolveOmpModelRoleArgs(modelRoleParams);
switch (modeId ?? DEFAULT_OMP_MODE_ID) {
case "full":
return { modeId: "full", extraArgs: ["--approval-mode", "yolo", ...modelRoleArgs] };
case "ask":
return {
modeId: "ask",
extraArgs: ["--approval-mode", "always-ask", ...modelRoleArgs],
};
default:
throw new Error(`Unsupported OMP mode '${modeId}'`);
}
}
function resolveOmpModelRoleArgs(modelRoleParams: OmpModelRoleParams): string[] {
const args: string[] = [];
if (modelRoleParams.smolModel) args.push("--smol", modelRoleParams.smolModel);
if (modelRoleParams.slowModel) args.push("--slow", modelRoleParams.slowModel);
if (modelRoleParams.planModel) args.push("--plan", modelRoleParams.planModel);
return args;
}
export interface OmpDiagnosticPaths {
profile: string;
configRoot: string;
agentDir: string;
agentDb: string;
xdgDataRoot: string;
xdgStateRoot: string;
xdgCacheRoot: string;
}
export function resolveOmpDiagnosticPaths(
env: NodeJS.ProcessEnv = process.env,
home: string = homedir(),
platform: NodeJS.Platform = process.platform,
): OmpDiagnosticPaths {
const normalizedProfile = (env.OMP_PROFILE ?? env.PI_PROFILE)?.trim();
const profile =
normalizedProfile && normalizedProfile !== "default" ? normalizedProfile : "default";
const baseConfigRoot = join(home, env.PI_CONFIG_DIR || ".omp");
const configRoot =
profile === "default" ? baseConfigRoot : join(baseConfigRoot, "profiles", profile);
const defaultAgentDir = join(configRoot, "agent");
const agentDir =
profile === "default" && env.PI_CODING_AGENT_DIR
? resolve(env.PI_CODING_AGENT_DIR)
: defaultAgentDir;
const xdgSupported = platform === "linux" || platform === "darwin";
const resolveXdgRoot = (variable: "XDG_DATA_HOME" | "XDG_STATE_HOME" | "XDG_CACHE_HOME") => {
const base = env[variable];
if (!xdgSupported || agentDir !== defaultAgentDir || !base) return undefined;
const appRoot = join(base, "omp");
const candidate = profile === "default" ? appRoot : join(appRoot, "profiles", profile);
return existsSync(candidate) ? candidate : undefined;
};
const xdgDataRoot = resolveXdgRoot("XDG_DATA_HOME") ?? configRoot;
const xdgStateRoot = resolveXdgRoot("XDG_STATE_HOME") ?? configRoot;
const xdgCacheRoot = resolveXdgRoot("XDG_CACHE_HOME") ?? configRoot;
return {
profile,
configRoot,
agentDir,
agentDb: join(xdgDataRoot === configRoot ? agentDir : xdgDataRoot, "agent.db"),
xdgDataRoot,
xdgStateRoot,
xdgCacheRoot,
};
}
export function formatOmpVersionSupport(versionOutput: string): string {
const match = versionOutput.match(/(\d+)\.(\d+)\.(\d+)\b/);
if (!match) return `unknown (minimum ${MIN_SUPPORTED_OMP_VERSION})`;
const installed = [Number(match[1]), Number(match[2]), Number(match[3])];
const minimum = MIN_SUPPORTED_OMP_VERSION.split(".").map(Number);
const supported =
installed[0]! > minimum[0]! ||
(installed[0] === minimum[0] &&
(installed[1]! > minimum[1]! ||
(installed[1] === minimum[1]! && installed[2]! >= minimum[2]!)));
return `${match[1]}.${match[2]}.${match[3]} (${supported ? "supported" : "unsupported"}; minimum ${MIN_SUPPORTED_OMP_VERSION})`;
}
export function resolveOmpProviderParams(providerParams: unknown): {
runtimeProviderParams: OmpRuntimeProviderParams;
modelRoleParams: OmpModelRoleParams;
} {
const params = OmpProviderParamsSchema.parse(providerParams ?? {});
return {
runtimeProviderParams: { sessionDir: params.sessionDir ?? OMP_SESSION_DIR },
modelRoleParams: {
...(params.smolModel ? { smolModel: params.smolModel } : {}),
...(params.slowModel ? { slowModel: params.slowModel } : {}),
...(params.planModel ? { planModel: params.planModel } : {}),
},
};
}
export function mergeOmpRuntimeSettings(
base: ProviderRuntimeSettings | undefined,
override: ProviderRuntimeSettings | undefined,
): ProviderRuntimeSettings | undefined {
if (!base && !override) return undefined;
return {
command: override?.command ?? base?.command,
env: base?.env || override?.env ? { ...base?.env, ...override?.env } : undefined,
disallowedTools:
base?.disallowedTools || override?.disallowedTools
? [...(base?.disallowedTools ?? []), ...(override?.disallowedTools ?? [])]
: undefined,
};
}
@@ -0,0 +1,621 @@
import { z } from "zod";
export const OmpThinkingLevelSchema = z.enum(["off", "minimal", "low", "medium", "high", "xhigh"]);
export const OmpImageContentSchema = z
.object({ type: z.literal("image"), data: z.string(), mimeType: z.string() })
.passthrough();
export const OmpTextContentSchema = z
.object({ type: z.literal("text"), text: z.string() })
.passthrough();
export const OmpThinkingContentSchema = z
.object({ type: z.literal("thinking"), thinking: z.string() })
.passthrough();
export const OmpToolCallContentSchema = z
.object({
type: z.literal("toolCall"),
id: z.string(),
name: z.string(),
arguments: z.unknown(),
})
.passthrough();
export const OmpAssistantContentSchema = z.discriminatedUnion("type", [
OmpTextContentSchema,
OmpThinkingContentSchema,
OmpToolCallContentSchema,
]);
const OmpUserMessageSchema = z
.object({
role: z.literal("user"),
content: z.union([z.string(), z.array(z.union([OmpTextContentSchema, OmpImageContentSchema]))]),
})
.passthrough();
const OmpCustomMessageSchema = z
.object({
role: z.literal("custom"),
content: z.union([z.string(), z.array(z.union([OmpTextContentSchema, OmpImageContentSchema]))]),
})
.passthrough();
const OmpAssistantMessageSchema = z
.object({
role: z.literal("assistant"),
content: z.array(OmpAssistantContentSchema),
provider: z.string().optional(),
model: z.string().optional(),
responseId: z.string().optional(),
responseModel: z.string().optional(),
errorMessage: z.string().nullable().optional(),
stopReason: z.string().optional(),
})
.passthrough();
const OmpToolResultMessageSchema = z
.object({
role: z.literal("toolResult"),
toolCallId: z.string(),
toolName: z.string(),
content: z.unknown(),
isError: z.boolean().optional(),
details: z.unknown().optional(),
})
.passthrough();
const OmpBashExecutionMessageSchema = z
.object({
role: z.literal("bashExecution"),
command: z.string(),
output: z.string().optional(),
exitCode: z.number().nullable().optional(),
cancelled: z.boolean().optional(),
timestamp: z.number(),
})
.passthrough();
export const OmpAgentMessageSchema = z.discriminatedUnion("role", [
OmpUserMessageSchema,
OmpCustomMessageSchema,
OmpAssistantMessageSchema,
OmpToolResultMessageSchema,
OmpBashExecutionMessageSchema,
]);
export const OmpModelSchema = z
.object({
provider: z.string(),
id: z.string(),
name: z.string().optional(),
reasoning: z.boolean().optional(),
contextWindow: z.number().optional(),
maxTokens: z.number().nullable().optional(),
api: z.string().optional(),
baseUrl: z.string().optional(),
input: z.array(z.string()).optional(),
cost: z.record(z.string(), z.unknown()).optional(),
compat: z.unknown().optional(),
})
.passthrough();
const OmpContextUsageSchema = z
.object({
tokens: z.number().nullable().optional(),
contextWindow: z.number().nullable().optional(),
percent: z.number().nullable().optional(),
})
.passthrough();
export const OmpSessionStateSchema = z
.object({
model: OmpModelSchema.nullable().optional(),
thinkingLevel: OmpThinkingLevelSchema,
isStreaming: z.boolean(),
isCompacting: z.boolean(),
autoCompactionEnabled: z.boolean().optional(),
sessionFile: z.string().optional(),
sessionId: z.string(),
sessionName: z.string().optional(),
messageCount: z.number().int().nonnegative(),
queuedMessageCount: z.number().int().nonnegative(),
contextUsage: OmpContextUsageSchema.optional(),
todoPhases: z.unknown().optional(),
})
.passthrough();
export const OmpSessionStatsSchema = z
.object({
tokens: z
.object({
input: z.number().optional(),
output: z.number().optional(),
cacheRead: z.number().optional(),
cacheWrite: z.number().optional(),
total: z.number().optional(),
})
.passthrough()
.optional(),
cost: z.number().optional(),
contextUsage: OmpContextUsageSchema.optional(),
})
.passthrough();
export const OmpRpcSlashCommandSchema = z
.object({
name: z.string(),
description: z.string().optional(),
source: z.enum(["extension", "prompt", "skill", "builtin"]),
sourceInfo: z.record(z.string(), z.unknown()).optional(),
input: z.object({ hint: z.string().optional() }).passthrough().nullable().optional(),
})
.passthrough();
export const OmpAgentToolResultSchema = z
.object({
content: z.array(z.object({ type: z.string(), text: z.string().optional() }).passthrough()),
details: z.unknown().optional(),
isError: z.boolean().optional(),
})
.passthrough();
export const OmpRpcHostToolDefinitionSchema = z
.object({
name: z.string(),
label: z.string().optional(),
description: z.string(),
parameters: z.record(z.string(), z.unknown()),
hidden: z.boolean().optional(),
})
.passthrough();
export const OmpRpcHostToolCallRequestSchema = z
.object({
type: z.literal("host_tool_call"),
id: z.string(),
toolCallId: z.string(),
toolName: z.string(),
arguments: z.record(z.string(), z.unknown()),
})
.passthrough();
export const OmpRpcHostToolCancelRequestSchema = z
.object({
type: z.literal("host_tool_cancel"),
id: z.string(),
targetId: z.string(),
})
.passthrough();
export const OmpRpcHostToolUpdateSchema = z
.object({
type: z.literal("host_tool_update"),
id: z.string(),
partialResult: OmpAgentToolResultSchema,
})
.passthrough();
export const OmpRpcHostToolResultSchema = z
.object({
type: z.literal("host_tool_result"),
id: z.string(),
result: OmpAgentToolResultSchema,
isError: z.boolean().optional(),
})
.passthrough();
export const OmpSubagentSubscriptionLevelSchema = z.enum(["off", "progress", "events"]);
export const OmpSubagentStatusSchema = z.enum([
"pending",
"running",
"completed",
"failed",
"aborted",
]);
export const OmpSubagentLifecyclePayloadSchema = z
.object({
id: z.string(),
agent: z.string(),
agentSource: z.string().optional(),
description: z.string().optional(),
status: z.enum(["started", "completed", "failed", "aborted"]),
sessionFile: z.string().optional(),
parentToolCallId: z.string().optional(),
index: z.number().int().nonnegative(),
detached: z.boolean().optional(),
})
.passthrough();
export const OmpSubagentProgressSchema = z
.object({
id: z.string(),
status: OmpSubagentStatusSchema,
description: z.string().optional(),
currentTool: z.unknown().optional(),
recentTools: z.array(z.unknown()).optional(),
recentOutput: z.array(z.unknown()).optional(),
resolvedModel: z.string().optional(),
})
.passthrough();
export const OmpSubagentProgressPayloadSchema = z
.object({
index: z.number().int().nonnegative(),
agent: z.string(),
agentSource: z.string().optional(),
task: z.string(),
parentToolCallId: z.string().optional(),
assignment: z.string().optional(),
progress: OmpSubagentProgressSchema,
sessionFile: z.string().optional(),
detached: z.boolean().optional(),
})
.passthrough();
export const OmpAssistantMessageEventSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("text_delta"), delta: z.string().optional() }).passthrough(),
z.object({ type: z.literal("thinking_delta"), delta: z.string().optional() }).passthrough(),
z.object({ type: z.literal("start") }).passthrough(),
z.object({ type: z.literal("text_start") }).passthrough(),
z.object({ type: z.literal("text_end") }).passthrough(),
z.object({ type: z.literal("thinking_start") }).passthrough(),
z.object({ type: z.literal("thinking_end") }).passthrough(),
z.object({ type: z.literal("done") }).passthrough(),
]);
export const OmpAgentSessionEventSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("agent_start") }).passthrough(),
z.object({ type: z.literal("turn_start") }).passthrough(),
z.object({ type: z.literal("message_start"), message: OmpAgentMessageSchema }).passthrough(),
z.object({ type: z.literal("message_end"), message: OmpAgentMessageSchema }).passthrough(),
z
.object({
type: z.literal("message_update"),
message: OmpAgentMessageSchema,
assistantMessageEvent: OmpAssistantMessageEventSchema,
})
.passthrough(),
z
.object({
type: z.literal("tool_execution_start"),
toolCallId: z.string(),
toolName: z.string(),
args: z.unknown(),
})
.passthrough(),
z
.object({
type: z.literal("tool_execution_update"),
toolCallId: z.string(),
toolName: z.string(),
args: z.unknown().optional(),
partialResult: z.unknown(),
})
.passthrough(),
z
.object({
type: z.literal("tool_execution_end"),
toolCallId: z.string(),
toolName: z.string(),
result: z.unknown(),
isError: z.boolean().optional(),
})
.passthrough(),
z
.object({
type: z.literal("compaction_start"),
reason: z.string().optional(),
})
.passthrough(),
z
.object({
type: z.literal("compaction_end"),
reason: z.string().optional(),
errorMessage: z.string().optional(),
aborted: z.boolean().optional(),
})
.passthrough(),
z
.object({ type: z.literal("agent_end"), messages: z.array(OmpAgentMessageSchema).optional() })
.passthrough(),
]);
export const OmpTodoItemSchema = z
.object({
content: z.string(),
status: z.enum(["pending", "in_progress", "completed", "abandoned"]),
})
.passthrough();
export const OmpTodoPhaseSchema = z
.object({ name: z.string(), tasks: z.array(OmpTodoItemSchema) })
.passthrough();
export const OmpTodoReminderEventSchema = z
.object({ type: z.literal("todo_reminder"), todos: z.array(OmpTodoItemSchema) })
.passthrough();
export const OmpNoticeEventSchema = z
.object({
type: z.literal("notice"),
level: z.enum(["info", "warning", "error"]),
message: z.string(),
source: z.string().optional(),
})
.passthrough();
export const OmpGoalSchema = z
.object({
id: z.string().optional(),
objective: z.string().optional(),
status: z.string().optional(),
tokenBudget: z.number().optional(),
tokensUsed: z.number().optional(),
timeUsedSeconds: z.number().optional(),
createdAt: z.string().optional(),
updatedAt: z.string().optional(),
})
.passthrough();
export const OmpGoalModeStateSchema = z
.object({
enabled: z.boolean().optional(),
mode: z.string().optional(),
reason: z.string().optional(),
goal: OmpGoalSchema.optional(),
})
.passthrough();
export const OmpGoalUpdatedEventSchema = z
.object({
type: z.literal("goal_updated"),
goal: OmpGoalSchema.nullable().optional(),
state: OmpGoalModeStateSchema.optional(),
})
.passthrough();
export const OmpAutoRetryStartEventSchema = z
.object({
type: z.literal("auto_retry_start"),
attempt: z.number().int().nonnegative(),
maxAttempts: z.number().int().positive(),
delayMs: z.number().int().nonnegative(),
errorMessage: z.string(),
errorId: z.number().int().optional(),
})
.passthrough();
export const OmpAutoRetryEndEventSchema = z
.object({
type: z.literal("auto_retry_end"),
success: z.boolean(),
attempt: z.number().int().nonnegative(),
finalError: z.string().optional(),
recoveredErrors: z.unknown().optional(),
})
.passthrough();
export const OmpRetryFallbackAppliedEventSchema = z
.object({
type: z.literal("retry_fallback_applied"),
from: z.string(),
to: z.string(),
role: z.string(),
})
.passthrough();
export const OmpRetryFallbackSucceededEventSchema = z
.object({ type: z.literal("retry_fallback_succeeded"), model: z.string(), role: z.string() })
.passthrough();
export const OmpAutoCompactionStartEventSchema = z
.object({ type: z.literal("auto_compaction_start"), reason: z.string(), action: z.string() })
.passthrough();
export const OmpAutoCompactionEndEventSchema = z
.object({
type: z.literal("auto_compaction_end"),
action: z.string().optional(),
result: z.unknown().optional(),
aborted: z.boolean(),
willRetry: z.boolean(),
errorMessage: z.string().optional(),
skipped: z.boolean().optional(),
})
.passthrough();
export const OmpAvailableCommandSchema = z
.object({
name: z.string(),
description: z.string().optional(),
source: z.string().optional(),
input: z.object({ hint: z.string().optional() }).passthrough().nullable().optional(),
})
.passthrough();
export const OmpAvailableCommandsUpdateEventSchema = z
.object({
type: z.literal("available_commands_update"),
commands: z.array(OmpAvailableCommandSchema),
})
.passthrough();
const OmpExtensionUiRequestSchema = z
.object({
type: z.literal("extension_ui_request"),
id: z.string(),
method: z.string(),
title: z.string().optional(),
message: z.string().optional(),
options: z.array(z.string()).optional(),
placeholder: z.string().optional(),
url: z.string().optional(),
launchUrl: z.string().optional(),
instructions: z.string().optional(),
})
.passthrough();
const OmpSubagentLifecycleEventSchema = z
.object({ type: z.literal("subagent_lifecycle"), payload: OmpSubagentLifecyclePayloadSchema })
.passthrough();
const OmpSubagentProgressEventSchema = z
.object({ type: z.literal("subagent_progress"), payload: OmpSubagentProgressPayloadSchema })
.passthrough();
export const OmpSubagentEventPayloadSchema = z
.object({ id: z.string(), event: OmpAgentSessionEventSchema })
.passthrough();
const OmpSubagentEventSchema = z
.object({
type: z.literal("subagent_event"),
payload: OmpSubagentEventPayloadSchema,
})
.passthrough();
export const OmpRuntimeEventSchema = z.discriminatedUnion("type", [
...OmpAgentSessionEventSchema.options,
OmpExtensionUiRequestSchema,
z.object({ type: z.literal("command_output"), text: z.string().optional() }).passthrough(),
z
.object({
type: z.literal("prompt_result"),
id: z.string().optional(),
agentInvoked: z.boolean().optional(),
})
.passthrough(),
z.object({ type: z.literal("process_exit"), error: z.string() }).passthrough(),
OmpSubagentLifecycleEventSchema,
OmpSubagentProgressEventSchema,
OmpSubagentEventSchema,
OmpTodoReminderEventSchema,
OmpNoticeEventSchema,
OmpGoalUpdatedEventSchema,
OmpAutoRetryStartEventSchema,
OmpAutoRetryEndEventSchema,
OmpRetryFallbackAppliedEventSchema,
OmpRetryFallbackSucceededEventSchema,
OmpAutoCompactionStartEventSchema,
OmpAutoCompactionEndEventSchema,
OmpAvailableCommandsUpdateEventSchema,
OmpRpcHostToolCallRequestSchema,
OmpRpcHostToolCancelRequestSchema,
OmpRpcHostToolUpdateSchema,
]);
const OmpCommandBase = { id: z.string().optional() };
export const OmpRpcCommandSchema = z.discriminatedUnion("type", [
z.object({
...OmpCommandBase,
type: z.literal("prompt"),
message: z.string(),
images: z.array(OmpImageContentSchema).optional(),
}),
z.object({
...OmpCommandBase,
type: z.literal("compact"),
customInstructions: z.string().optional(),
}),
z.object({ ...OmpCommandBase, type: z.literal("set_auto_compaction"), enabled: z.boolean() }),
z.object({ ...OmpCommandBase, type: z.literal("abort") }),
z.object({ ...OmpCommandBase, type: z.literal("get_state") }),
z.object({ ...OmpCommandBase, type: z.literal("get_messages") }),
z.object({ ...OmpCommandBase, type: z.literal("get_available_models") }),
z.object({
...OmpCommandBase,
type: z.literal("set_model"),
provider: z.string(),
modelId: z.string(),
}),
z.object({
...OmpCommandBase,
type: z.literal("set_thinking_level"),
level: OmpThinkingLevelSchema,
}),
z.object({ ...OmpCommandBase, type: z.literal("get_session_stats") }),
z.object({ ...OmpCommandBase, type: z.literal("get_available_commands") }),
z.object({
...OmpCommandBase,
type: z.literal("set_subagent_subscription"),
level: OmpSubagentSubscriptionLevelSchema,
}),
z.object({
...OmpCommandBase,
type: z.literal("set_host_tools"),
tools: z.array(OmpRpcHostToolDefinitionSchema),
}),
z.object({ ...OmpCommandBase, type: z.literal("branch"), entryId: z.string() }),
z.object({ ...OmpCommandBase, type: z.literal("get_branch_messages") }),
z.object({
...OmpCommandBase,
type: z.literal("handoff"),
customInstructions: z.string().optional(),
}),
]);
export const OmpPromptAckSchema = z
.object({ agentInvoked: z.boolean().optional() })
.passthrough()
.optional();
export const OmpMessagesResultSchema = z
.object({ messages: z.array(OmpAgentMessageSchema).optional() })
.passthrough();
export const OmpModelsResultSchema = z
.object({ models: z.array(OmpModelSchema).optional() })
.passthrough();
export const OmpCommandsResultSchema = z
.object({ commands: z.array(OmpRpcSlashCommandSchema).optional() })
.passthrough();
export const OmpHostToolsResultSchema = z
.object({ toolNames: z.array(z.string()).optional() })
.passthrough();
export const OmpBranchResultSchema = z
.object({ text: z.string().optional(), cancelled: z.boolean().optional() })
.passthrough();
export const OmpBranchMessagesResultSchema = z
.object({
messages: z.array(z.object({ entryId: z.string(), text: z.string() }).passthrough()).optional(),
})
.passthrough();
export type OmpThinkingLevel = z.infer<typeof OmpThinkingLevelSchema>;
export type OmpImageContent = z.infer<typeof OmpImageContentSchema>;
export type OmpTextContent = z.infer<typeof OmpTextContentSchema>;
export type OmpThinkingContent = z.infer<typeof OmpThinkingContentSchema>;
export type OmpToolCallContent = z.infer<typeof OmpToolCallContentSchema>;
export type OmpAssistantContent = z.infer<typeof OmpAssistantContentSchema>;
export type OmpAgentMessage = z.infer<typeof OmpAgentMessageSchema>;
export type OmpModel = z.infer<typeof OmpModelSchema>;
export type OmpSessionState = z.infer<typeof OmpSessionStateSchema>;
export type OmpSessionStats = z.infer<typeof OmpSessionStatsSchema>;
export type OmpRpcSlashCommand = z.infer<typeof OmpRpcSlashCommandSchema>;
export type OmpAgentToolResult = z.infer<typeof OmpAgentToolResultSchema>;
export type OmpRpcHostToolDefinition = z.infer<typeof OmpRpcHostToolDefinitionSchema>;
export type OmpRpcHostToolCallRequest = z.infer<typeof OmpRpcHostToolCallRequestSchema>;
export type OmpRpcHostToolUpdate = z.infer<typeof OmpRpcHostToolUpdateSchema>;
export type OmpRpcHostToolResult = z.infer<typeof OmpRpcHostToolResultSchema>;
export type OmpSubagentSubscriptionLevel = z.infer<typeof OmpSubagentSubscriptionLevelSchema>;
export type OmpSubagentStatus = z.infer<typeof OmpSubagentStatusSchema>;
export type OmpSubagentLifecyclePayload = z.infer<typeof OmpSubagentLifecyclePayloadSchema>;
export type OmpSubagentProgressPayload = z.infer<typeof OmpSubagentProgressPayloadSchema>;
export type OmpSubagentEventPayload = z.infer<typeof OmpSubagentEventPayloadSchema>;
export type OmpAssistantMessageEvent = z.infer<typeof OmpAssistantMessageEventSchema>;
export type OmpAgentSessionEvent = z.infer<typeof OmpAgentSessionEventSchema>;
export type OmpRuntimeEvent = z.infer<typeof OmpRuntimeEventSchema>;
export type OmpTodoItem = z.infer<typeof OmpTodoItemSchema>;
export type OmpTodoPhase = z.infer<typeof OmpTodoPhaseSchema>;
export type OmpTodoReminderEvent = z.infer<typeof OmpTodoReminderEventSchema>;
export type OmpNoticeEvent = z.infer<typeof OmpNoticeEventSchema>;
export type OmpGoal = z.infer<typeof OmpGoalSchema>;
export type OmpGoalUpdatedEvent = z.infer<typeof OmpGoalUpdatedEventSchema>;
export type OmpAutoRetryStartEvent = z.infer<typeof OmpAutoRetryStartEventSchema>;
export type OmpAutoRetryEndEvent = z.infer<typeof OmpAutoRetryEndEventSchema>;
export type OmpRetryFallbackAppliedEvent = z.infer<typeof OmpRetryFallbackAppliedEventSchema>;
export type OmpRetryFallbackSucceededEvent = z.infer<typeof OmpRetryFallbackSucceededEventSchema>;
export type OmpAutoCompactionStartEvent = z.infer<typeof OmpAutoCompactionStartEventSchema>;
export type OmpAutoCompactionEndEvent = z.infer<typeof OmpAutoCompactionEndEventSchema>;
export type OmpAvailableCommand = z.infer<typeof OmpAvailableCommandSchema>;
export type OmpAvailableCommandsUpdateEvent = z.infer<typeof OmpAvailableCommandsUpdateEventSchema>;
export type OmpRpcCommand = z.infer<typeof OmpRpcCommandSchema>;
export type OmpPromptAck = z.infer<typeof OmpPromptAckSchema> & { requestId?: string };
export interface OmpSubagentSnapshot {
id: string;
index: number;
agent: string;
description?: string;
status: OmpSubagentStatus;
task?: string;
assignment?: string;
sessionFile?: string;
parentToolCallId?: string;
lastUpdate?: number;
}
export interface OmpSubagentMessagesResult {
sessionFile: string;
fromByte: number;
nextByte: number;
reset: boolean;
messages: OmpAgentMessage[];
}
export interface OmpSubagentMessagesSelector {
subagentId?: string;
sessionFile?: string;
fromByte?: number;
}
@@ -0,0 +1,155 @@
import { describe, expect, test } from "vitest";
import type { OmpRuntimeEvent } from "./rpc-types.js";
import {
buildOmpRpcUiPermissionResponse,
classifyOmpRpcUiPermissionRequest,
} from "./rpc-ui-permission-mapper.js";
type ExtensionUiRequestEvent = Extract<OmpRuntimeEvent, { type: "extension_ui_request" }>;
const RPC_UI_CASES: ExtensionUiRequestEvent[] = [
{ type: "extension_ui_request", id: "widget", method: "setWidget", widgetKey: "status" },
{ type: "extension_ui_request", id: "notify", method: "notify", message: "done" },
{
type: "extension_ui_request",
id: "bash",
method: "select",
title: "Allow tool: bash\nCommand: echo rpc-ui-hi",
options: ["Approve", "Deny"],
},
{
type: "extension_ui_request",
id: "edit",
method: "select",
title: "Allow tool: edit\nFile: fixture.txt",
options: ["Approve", "Deny"],
},
{
type: "extension_ui_request",
id: "write",
method: "select",
title: "Allow tool: write\nPath: created.txt\nContent:\nhello write",
options: ["Approve", "Deny"],
},
];
function toolApproval(id: string) {
const event = RPC_UI_CASES.find((candidate) => candidate.id === id);
if (!event) throw new Error(`Missing RPC-UI case ${id}`);
const classification = classifyOmpRpcUiPermissionRequest(event);
if (classification.kind !== "tool") throw new Error(`Expected ${id} to be a tool approval`);
return classification.request;
}
describe("OMP rpc-ui permission mapper", () => {
test("classifies tool approvals and passes through unrelated UI requests", () => {
expect(
RPC_UI_CASES.map((event) => {
const classification = classifyOmpRpcUiPermissionRequest(event);
return [event.id, classification.kind];
}),
).toEqual([
["widget", "passthrough"],
["notify", "passthrough"],
["bash", "tool"],
["edit", "tool"],
["write", "tool"],
]);
});
test("maps tool approvals to renderable permissions", () => {
expect([toolApproval("bash"), toolApproval("edit"), toolApproval("write")]).toEqual([
expect.objectContaining({
id: "bash",
provider: "omp",
name: "bash",
kind: "tool",
detail: { type: "shell", command: "echo rpc-ui-hi" },
metadata: expect.objectContaining({
toolName: "bash",
toolArgs: { command: "echo rpc-ui-hi" },
approveValue: "Approve",
denyValue: "Deny",
}),
}),
expect.objectContaining({
id: "edit",
provider: "omp",
name: "edit",
kind: "tool",
detail: { type: "edit", filePath: "fixture.txt" },
metadata: expect.objectContaining({ toolName: "edit", toolArgs: { path: "fixture.txt" } }),
}),
expect.objectContaining({
id: "write",
provider: "omp",
name: "write",
kind: "tool",
detail: { type: "write", filePath: "created.txt", content: "hello write" },
metadata: expect.objectContaining({
toolName: "write",
toolArgs: { path: "created.txt", content: "hello write" },
}),
}),
]);
});
test("preserves destructive multiline CRLF bash commands exactly", () => {
const title = "Allow tool: bash\r\nCommand: printf first\r\n\r\n rm -rf /tmp/example\r\n";
const classification = classifyOmpRpcUiPermissionRequest({
type: "extension_ui_request",
id: "multiline-bash",
method: "select",
title,
options: ["Approve", "Deny"],
});
if (classification.kind !== "tool") throw new Error("Expected multiline bash approval");
expect(classification.request.detail).toEqual({
type: "shell",
command: "printf first\r\n\r\n rm -rf /tmp/example\r\n",
});
expect(classification.request.metadata?.toolArgs).toEqual({
command: "printf first\r\n\r\n rm -rf /tmp/example\r\n",
});
});
test("rejects approval lookalikes and unknown tools", () => {
expect(
classifyOmpRpcUiPermissionRequest({
type: "extension_ui_request",
id: "not-tool",
method: "select",
title: "Allow tool: bash\nCommand: echo hi",
options: ["Yes", "No"],
}),
).toEqual({ kind: "passthrough" });
expect(
classifyOmpRpcUiPermissionRequest({
type: "extension_ui_request",
id: "unknown-tool",
method: "select",
title: "Allow tool: custom_tool\nReason: needs approval",
options: ["Approve", "Deny"],
}),
).toEqual({ kind: "passthrough" });
});
test("responds to tool approvals with exact select values", () => {
const request = toolApproval("bash");
expect(buildOmpRpcUiPermissionResponse(request, { behavior: "allow" })).toEqual({
value: "Approve",
});
expect(
buildOmpRpcUiPermissionResponse(request, {
behavior: "allow",
selectedActionId: "allow_always",
}),
).toEqual({ value: "Approve" });
expect(buildOmpRpcUiPermissionResponse(request, { behavior: "deny", message: "no" })).toEqual({
value: "Deny",
});
});
});
@@ -0,0 +1,210 @@
import type {
AgentMetadata,
AgentPermissionRequest,
AgentPermissionResponse,
AgentProvider,
ToolCallDetail,
} from "../../agent-sdk-types.js";
import type { OmpRuntimeEvent } from "./rpc-types.js";
const OMP_PROVIDER = "omp";
const OMP_RPC_UI_TOOL_APPROVAL_METADATA = "omp_rpc_ui_tool_approval";
const TOOL_APPROVAL_APPROVE_VALUE = "Approve";
const TOOL_APPROVAL_DENY_VALUE = "Deny";
const TOOL_APPROVAL_OPTIONS = [TOOL_APPROVAL_APPROVE_VALUE, TOOL_APPROVAL_DENY_VALUE] as const;
const TOOL_TITLE_PREFIX = "Allow tool: ";
export type OmpRpcUiPermissionClassification =
| { kind: "tool"; request: AgentPermissionRequest }
| { kind: "passthrough" };
type ExtensionUiRequestEvent = Extract<OmpRuntimeEvent, { type: "extension_ui_request" }>;
interface ToolApprovalDescriptor {
toolName: "bash" | "edit" | "write";
args: AgentMetadata;
detail: ToolCallDetail;
description?: string;
}
export function classifyOmpRpcUiPermissionRequest(
event: ExtensionUiRequestEvent,
options: { provider?: AgentProvider } = {},
): OmpRpcUiPermissionClassification {
const descriptor = parseToolApprovalDescriptor(event);
if (!descriptor) {
return { kind: "passthrough" };
}
const provider = options.provider ?? OMP_PROVIDER;
return {
kind: "tool",
request: {
id: event.id,
provider,
name: descriptor.toolName,
kind: "tool",
title: `Allow tool: ${descriptor.toolName}`,
...(descriptor.description ? { description: descriptor.description } : {}),
detail: descriptor.detail,
actions: [
{
id: "deny",
label: TOOL_APPROVAL_DENY_VALUE,
behavior: "deny",
variant: "danger",
intent: "dismiss",
},
{
id: "approve",
label: TOOL_APPROVAL_APPROVE_VALUE,
behavior: "allow",
variant: "primary",
},
],
metadata: {
extensionUiMethod: "select",
toolApproval: OMP_RPC_UI_TOOL_APPROVAL_METADATA,
toolName: descriptor.toolName,
toolArgs: descriptor.args,
approveValue: TOOL_APPROVAL_APPROVE_VALUE,
denyValue: TOOL_APPROVAL_DENY_VALUE,
},
},
};
}
export function mapOmpRpcUiPermissionRequest(
event: ExtensionUiRequestEvent,
options: { provider?: AgentProvider } = {},
): AgentPermissionRequest | null {
const classification = classifyOmpRpcUiPermissionRequest(event, options);
return classification.kind === "tool" ? classification.request : null;
}
export function buildOmpRpcUiPermissionResponse(
request: AgentPermissionRequest,
response: AgentPermissionResponse,
): { value?: string; confirmed?: boolean; cancelled?: boolean } | null {
if (
request.kind !== "tool" ||
request.metadata?.toolApproval !== OMP_RPC_UI_TOOL_APPROVAL_METADATA
) {
return null;
}
const approveValue = readString(request.metadata.approveValue) ?? TOOL_APPROVAL_APPROVE_VALUE;
const denyValue = readString(request.metadata.denyValue) ?? TOOL_APPROVAL_DENY_VALUE;
return { value: response.behavior === "allow" ? approveValue : denyValue };
}
function parseToolApprovalDescriptor(
event: ExtensionUiRequestEvent,
): ToolApprovalDescriptor | null {
if (event.method !== "select" || !hasExactToolApprovalOptions(event.options)) {
return null;
}
const title = readString(event.title);
if (!title) {
return null;
}
const firstLineBreak = title.search(/\r?\n/);
const firstLine = (firstLineBreak < 0 ? title : title.slice(0, firstLineBreak)).trim();
if (!firstLine.startsWith(TOOL_TITLE_PREFIX)) {
return null;
}
const rawToolName = firstLine.slice(TOOL_TITLE_PREFIX.length).trim();
const body = firstLineBreak < 0 ? "" : title.slice(firstLineBreak).replace(/^\r?\n/, "");
const bodyLines = body.split(/\r?\n/);
switch (rawToolName) {
case "bash":
return parseBashApproval(body);
case "edit":
return parseEditApproval(bodyLines);
case "write":
return parseWriteApproval(bodyLines);
default:
return null;
}
}
function parseBashApproval(body: string): ToolApprovalDescriptor | null {
const match = /(?:^|\r?\n)[\t ]*Command:[\t ]?(.*)$/s.exec(body);
const command = match?.[1];
if (!command) {
return null;
}
return {
toolName: "bash",
args: { command },
description: `Command: ${command}`,
detail: { type: "shell", command },
};
}
function parseEditApproval(lines: string[]): ToolApprovalDescriptor | null {
const filePath = readPrefixedValue(lines, "File:");
if (!filePath) {
return null;
}
return {
toolName: "edit",
args: { path: filePath },
description: `File: ${filePath}`,
detail: { type: "edit", filePath },
};
}
function parseWriteApproval(lines: string[]): ToolApprovalDescriptor | null {
const pathLineIndex = lines.findIndex((line) => line.trim().startsWith("Path:"));
if (pathLineIndex < 0) {
return null;
}
const filePath = stripPrefix(lines[pathLineIndex], "Path:")?.trim();
if (!filePath) {
return null;
}
const contentLineIndex = lines.findIndex(
(line, index) => index > pathLineIndex && line.trim() === "Content:",
);
if (contentLineIndex < 0) {
return null;
}
const content = lines.slice(contentLineIndex + 1).join("\n");
return {
toolName: "write",
args: { path: filePath, content },
description: `Path: ${filePath}`,
detail: { type: "write", filePath, content },
};
}
function hasExactToolApprovalOptions(options: unknown): boolean {
return (
Array.isArray(options) &&
options.length === TOOL_APPROVAL_OPTIONS.length &&
options.every((option, index) => option === TOOL_APPROVAL_OPTIONS[index])
);
}
function readPrefixedValue(lines: readonly string[], prefix: string): string | null {
for (const line of lines) {
const value = stripPrefix(line, prefix)?.trim();
if (value) {
return value;
}
}
return null;
}
function stripPrefix(line: string | undefined, prefix: string): string | null {
const trimmed = line?.trim();
return trimmed?.startsWith(prefix) ? trimmed.slice(prefix.length) : null;
}
function readString(value: unknown): string | null {
return typeof value === "string" && value.length > 0 ? value : null;
}
@@ -0,0 +1,11 @@
import { expect, test } from "vitest";
import { OmpHarness } from "./test-utils/omp-harness.js";
test("falls back to progress when the event subscription is unavailable", async () => {
const omp = new OmpHarness();
omp.failEventSubscription(new Error("events unsupported"));
await omp.start();
await expect(omp.waitForSubscriptionFallback()).resolves.toEqual(["events", "progress"]);
});
@@ -0,0 +1,159 @@
import type {
OmpAgentMessage,
OmpModel,
OmpPromptAck,
OmpRpcHostToolDefinition,
OmpRpcHostToolResult,
OmpRpcHostToolUpdate,
OmpRpcSlashCommand,
OmpRuntimeEvent,
OmpSessionState,
OmpSessionStats,
OmpSubagentSubscriptionLevel,
OmpThinkingLevel,
} from "./rpc-types.js";
import type { ProviderRuntimeSettings } from "../../provider-launch-config.js";
export interface OmpRuntimeLaunch {
cwd: string;
argv: string[];
env?: Record<string, string>;
protocolMode?: "rpc" | "rpc-ui";
model?: string;
thinkingOptionId?: string;
modeId?: string;
session?: string;
noSession?: boolean;
systemPrompt?: string;
extraArgs?: string[];
}
export interface OmpStartSessionInput {
cwd: string;
env?: Record<string, string>;
protocolMode?: "rpc" | "rpc-ui";
model?: string;
thinkingOptionId?: string;
modeId?: string;
session?: string;
noSession?: boolean;
systemPrompt?: string;
extraArgs?: string[];
}
export interface OmpRuntimeSession {
onEvent(callback: (event: OmpRuntimeEvent) => void): () => void;
prompt(
message: string,
images?: Array<{ type: "image"; data: string; mimeType: string }>,
): Promise<OmpPromptAck>;
compact(customInstructions?: string): Promise<void>;
setAutoCompaction(enabled: boolean): Promise<void>;
abort(): Promise<void>;
getState(): Promise<OmpSessionState>;
getMessages(): Promise<OmpAgentMessage[]>;
getAvailableModels(timeoutMs?: number): Promise<OmpModel[]>;
setModel(provider: string, modelId: string): Promise<OmpModel>;
setThinkingLevel(level: OmpThinkingLevel): Promise<void>;
getSessionStats(): Promise<OmpSessionStats>;
getCommands(): Promise<OmpRpcSlashCommand[]>;
setSubagentSubscription(level: OmpSubagentSubscriptionLevel): Promise<void>;
setHostTools(tools: OmpRpcHostToolDefinition[]): Promise<string[]>;
sendHostToolResult(result: OmpRpcHostToolResult): void;
sendHostToolUpdate(update: OmpRpcHostToolUpdate): void;
branch(entryId: string): Promise<{ text: string }>;
getBranchMessages(): Promise<Array<{ entryId: string; text: string }>>;
activeBranchEntryId?: string;
steer(message: string, images?: Array<{ type: "image"; data: string; mimeType: string }>): void;
followUp(
message: string,
images?: Array<{ type: "image"; data: string; mimeType: string }>,
): void;
handoff(customInstructions?: string): Promise<void>;
respondToExtensionUiRequest(
id: string,
response: { value?: string; confirmed?: boolean; cancelled?: boolean },
): void;
cancelExtensionUiRequest(id: string): void;
close(): Promise<void>;
}
export interface OmpRuntime {
startSession(input: OmpStartSessionInput): Promise<OmpRuntimeSession>;
}
export function buildOmpLaunch(input: {
command: [string, ...string[]];
runtimeSettings?: ProviderRuntimeSettings;
session: OmpStartSessionInput;
}): OmpRuntimeLaunch {
const command =
input.runtimeSettings?.command?.mode === "replace" && input.runtimeSettings.command.argv[0]
? input.runtimeSettings.command.argv
: input.command;
const argv = [...command];
const protocolMode = input.session.protocolMode ?? "rpc";
const systemPrompt = input.session.systemPrompt?.trim();
appendOmpLaunchArgs(argv, input.session, protocolMode, systemPrompt);
return {
cwd: input.session.cwd,
argv,
env:
input.runtimeSettings?.env || input.session.env
? {
...input.runtimeSettings?.env,
...input.session.env,
}
: undefined,
model: input.session.model,
thinkingOptionId: input.session.thinkingOptionId,
protocolMode,
modeId: input.session.modeId,
session: input.session.session,
noSession: input.session.noSession,
systemPrompt,
extraArgs: input.session.extraArgs,
};
}
function appendOmpLaunchArgs(
argv: string[],
session: OmpStartSessionInput,
protocolMode: "rpc" | "rpc-ui",
systemPrompt: string | undefined,
): void {
if (!hasModeFlag(argv)) {
argv.push("--mode", protocolMode);
}
if (session.extraArgs?.length) {
argv.push(...session.extraArgs);
}
if (session.model) {
argv.push("--model", session.model);
}
if (session.thinkingOptionId) {
argv.push("--thinking", session.thinkingOptionId);
}
if (session.noSession) {
argv.push("--no-session");
} else if (session.session) {
argv.push("--session", session.session);
}
if (systemPrompt) {
argv.push("--append-system-prompt", systemPrompt);
}
}
function hasModeFlag(argv: string[]): boolean {
for (let i = 0; i < argv.length; i += 1) {
if (argv[i] === "--mode") {
return true;
}
if (argv[i]?.startsWith("--mode=")) {
return true;
}
}
return false;
}
@@ -0,0 +1,112 @@
import { mkdtemp, mkdir, utimes, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, test } from "vitest";
import { listOmpImportableSessions, readOmpImportSessionConfig } from "./session-descriptor.js";
async function writeSession(root: string, relativePath: string, lines: unknown[]): Promise<string> {
const filePath = path.join(root, "sessions", relativePath);
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(filePath, `${lines.map((line) => JSON.stringify(line)).join("\n")}\n`, "utf8");
return filePath;
}
describe("OMP session descriptor", () => {
test("reads title-first sessions and OMP combined model identifiers", async () => {
const root = await mkdtemp(path.join(tmpdir(), "paseo-omp-session-title-first-"));
const cwd = path.join(root, "repo");
const sessionFile = await writeSession(root, "project/session.jsonl", [
{
type: "title",
id: "title-1",
timestamp: "2026-06-09T00:00:00.000Z",
title: "Deploy Paseo and verify",
},
{
type: "session",
version: 3,
id: "session-title-first",
timestamp: "2026-06-09T00:00:00.100Z",
cwd,
},
{
type: "model_change",
id: "model-1",
timestamp: "2026-06-09T00:00:00.200Z",
model: "openai-codex/gpt-5.1",
},
{
type: "message",
id: "user-1",
timestamp: "2026-06-09T00:00:01.000Z",
message: { role: "user", content: [{ type: "text", text: "import me" }] },
},
]);
await expect(
listOmpImportableSessions({ sessionDir: path.join(root, "sessions") }),
).resolves.toEqual([
expect.objectContaining({
providerHandleId: sessionFile,
cwd,
title: "Deploy Paseo and verify",
firstPromptPreview: "import me",
}),
]);
await expect(readOmpImportSessionConfig(sessionFile)).resolves.toEqual({
model: "openai-codex/gpt-5.1",
});
});
test("keeps recent nested OMP subagent sessions importable", async () => {
const root = await mkdtemp(path.join(tmpdir(), "paseo-omp-session-nested-"));
const cwd = path.join(root, "repo");
const parent = await writeSession(root, "project/parent.jsonl", [
{ type: "session", id: "parent", timestamp: "2026-06-10T00:00:00.000Z", cwd },
{
type: "message",
id: "parent-user",
timestamp: "2026-06-10T00:00:01.000Z",
message: { role: "user", content: "parent prompt" },
},
]);
const child = await writeSession(root, "project/parent/Explore.jsonl", [
{ type: "session", id: "child", timestamp: "2026-06-09T00:00:00.000Z", cwd },
{
type: "message",
id: "child-user",
timestamp: "2026-06-09T00:00:01.000Z",
message: { role: "user", content: "child prompt" },
},
]);
await utimes(parent, new Date("2026-06-08"), new Date("2026-06-08"));
await utimes(child, new Date("2026-06-09"), new Date("2026-06-09"));
await expect(
listOmpImportableSessions({ sessionDir: path.join(root, "sessions"), limit: 1 }),
).resolves.toEqual([
expect.objectContaining({
providerHandleId: child,
title: "Explore",
firstPromptPreview: "child prompt",
}),
]);
});
test("uses OMP's own default session directory", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-omp-session-home-"));
const cwd = path.join(home, "repo");
const sessionFile = path.join(home, ".omp", "agent", "sessions", "project", "session.jsonl");
await mkdir(path.dirname(sessionFile), { recursive: true });
await writeFile(
sessionFile,
`${JSON.stringify({ type: "session", id: "default-dir", timestamp: "2026-06-09", cwd })}\n`,
"utf8",
);
await expect(listOmpImportableSessions({ homeDir: home, env: {} })).resolves.toEqual([
expect.objectContaining({ providerHandleId: sessionFile, cwd }),
]);
});
});
@@ -0,0 +1,504 @@
import type { Dirent } from "node:fs";
import { open, readdir, readFile, stat } from "node:fs/promises";
import { homedir } from "node:os";
import path from "node:path";
import type {
ImportableProviderSession,
ListImportableSessionsOptions,
} from "../../agent-sdk-types.js";
import type { ProviderRuntimeSettings } from "../../provider-launch-config.js";
import { createRealpathAwarePathMatcher } from "../../../../utils/path.js";
const OMP_CONFIG_DIR_NAME = ".omp";
const OMP_AGENT_DIR_ENV = "OMP_AGENT_DIR";
const OMP_SESSION_DIR_ENV = "OMP_SESSION_DIR";
// Import listing intentionally bounds header parsing to this window. Sessions
// with unusually large preambles may omit their first-prompt preview.
const HEAD_BYTES = 64 * 1024;
const TAIL_BYTES = 256 * 1024;
const FULL_SCAN_LINE_LIMIT = 2_000;
// Rank all discovered files cheaply, then parse only a bounded recent window.
// OMP keeps nested completed-subagent transcripts importable, so discovery
// remains recursive rather than applying Pi's historical parent-only depth cap.
const IMPORT_CANDIDATE_OVERSCAN = 40;
const IMPORT_CANDIDATE_MIN = 400;
interface OmpSessionDescriptorOptions extends ListImportableSessionsOptions {
sessionDir?: string;
runtimeSettings?: ProviderRuntimeSettings;
env?: NodeJS.ProcessEnv;
homeDir?: string;
}
interface OmpSessionHeader {
sessionId: string;
cwd: string;
createdAt: Date | null;
}
interface OmpSessionTail {
title: string | null;
lastActivityAt: Date | null;
lastUserMessage: string | null;
model: string | null;
thinkingOptionId: string | null;
}
interface OmpSessionHead {
title: string | null;
firstUserMessage: string | null;
model: string | null;
thinkingOptionId: string | null;
}
interface OmpSessionDescriptor {
cwd: string;
title: string | null;
firstUserMessage: string | null;
lastUserMessage: string | null;
lastActivityAt: Date;
model: string | null;
thinkingOptionId: string | null;
}
interface RankedSessionFile {
file: string;
mtime: Date;
}
export interface OmpImportSessionConfig {
model?: string;
thinkingOptionId?: string;
}
export async function listOmpImportableSessions(
options: OmpSessionDescriptorOptions = {},
): Promise<ImportableProviderSession[]> {
const sessionsDir = await resolveOmpSessionsDir(options);
const files = await walkJsonlFiles(sessionsDir);
const matchesCwd = options.cwd ? createRealpathAwarePathMatcher(options.cwd) : null;
const limit = options.limit ?? 20;
const ranked = await rankSessionFilesByMtime(files);
const candidateLimit = Math.max(limit * IMPORT_CANDIDATE_OVERSCAN, IMPORT_CANDIDATE_MIN);
const sessions: ImportableProviderSession[] = [];
for (const entry of ranked.slice(0, candidateLimit)) {
const session = await readOmpImportableSession(entry.file);
if (!session) continue;
if (matchesCwd && !matchesCwd(session.cwd)) continue;
sessions.push(session);
if (sessions.length >= limit) {
break;
}
}
return sessions.sort(
(left, right) => right.lastActivityAt.getTime() - left.lastActivityAt.getTime(),
);
}
export async function readOmpImportSessionConfig(
filePath: string,
): Promise<OmpImportSessionConfig> {
const descriptor = await readOmpSessionDescriptor(filePath);
if (!descriptor) return {};
return toOmpImportSessionConfig(descriptor);
}
async function resolveOmpSessionsDir(options: OmpSessionDescriptorOptions): Promise<string> {
const env = options.env ?? process.env;
const homeDir = options.homeDir ?? homedir();
const baseDir = options.cwd ?? process.cwd();
if (options.sessionDir?.trim()) {
return resolveConfigPath(options.sessionDir, { baseDir, homeDir });
}
const agentDir = resolveOmpAgentDir({ runtimeSettings: options.runtimeSettings, env, homeDir });
const envSessionDir =
options.runtimeSettings?.env?.[OMP_SESSION_DIR_ENV] ?? env[OMP_SESSION_DIR_ENV];
if (envSessionDir?.trim()) {
return resolveConfigPath(envSessionDir, { baseDir, homeDir });
}
const settingsSessionDir = await readConfiguredSessionDir({
agentDir,
cwd: options.cwd,
});
if (settingsSessionDir?.trim()) {
return resolveConfigPath(settingsSessionDir, { baseDir, homeDir });
}
return path.join(agentDir, "sessions");
}
function resolveOmpAgentDir(input: {
runtimeSettings?: ProviderRuntimeSettings;
env: NodeJS.ProcessEnv;
homeDir: string;
}): string {
const configured =
input.runtimeSettings?.env?.[OMP_AGENT_DIR_ENV] ?? input.env[OMP_AGENT_DIR_ENV];
if (configured?.trim()) {
return resolveConfigPath(configured, { baseDir: process.cwd(), homeDir: input.homeDir });
}
return path.join(input.homeDir, OMP_CONFIG_DIR_NAME, "agent");
}
async function readConfiguredSessionDir(input: {
agentDir: string;
cwd: string | undefined;
}): Promise<string | null> {
const values = await Promise.all([
readSessionDirFromSettings(path.join(input.agentDir, "settings.json")),
input.cwd
? readSessionDirFromSettings(path.join(input.cwd, OMP_CONFIG_DIR_NAME, "settings.json"))
: null,
]);
return values[1] ?? values[0] ?? null;
}
async function readSessionDirFromSettings(settingsPath: string): Promise<string | null> {
try {
const parsed = JSON.parse(await readFile(settingsPath, "utf8")) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return null;
}
const sessionDir = Reflect.get(parsed, "sessionDir");
return typeof sessionDir === "string" && sessionDir.trim() ? sessionDir : null;
} catch {
return null;
}
}
function resolveConfigPath(value: string, options: { baseDir: string; homeDir: string }): string {
if (value === "~") {
return options.homeDir;
}
if (value.startsWith("~/")) {
return path.join(options.homeDir, value.slice(2));
}
return path.isAbsolute(value) ? value : path.resolve(options.baseDir, value);
}
async function walkJsonlFiles(root: string): Promise<string[]> {
let entries: Dirent[];
try {
entries = await readdir(root, { withFileTypes: true });
} catch {
return [];
}
const files = await Promise.all(
entries.map(async (entry) => {
const entryPath = path.join(root, entry.name);
if (entry.isDirectory()) {
return await walkJsonlFiles(entryPath);
}
return entry.isFile() && entry.name.endsWith(".jsonl") ? [entryPath] : [];
}),
);
return files.flat();
}
async function rankSessionFilesByMtime(files: string[]): Promise<RankedSessionFile[]> {
const ranked = await Promise.all(
files.map(async (file) => {
const mtime = await readFileMtime(file);
return mtime ? { file, mtime } : null;
}),
);
return ranked
.filter((entry): entry is RankedSessionFile => entry !== null)
.sort((left, right) => right.mtime.getTime() - left.mtime.getTime());
}
async function readOmpImportableSession(
filePath: string,
): Promise<ImportableProviderSession | null> {
const descriptor = await readOmpSessionDescriptor(filePath);
if (!descriptor) return null;
return {
providerHandleId: filePath,
cwd: descriptor.cwd,
title: descriptor.title,
firstPromptPreview: normalizePromptPreview(descriptor.firstUserMessage),
lastPromptPreview: normalizePromptPreview(
descriptor.lastUserMessage ?? descriptor.firstUserMessage,
),
lastActivityAt: descriptor.lastActivityAt,
};
}
async function readOmpSessionDescriptor(filePath: string): Promise<OmpSessionDescriptor | null> {
// OMP may emit title/session_info lines before the session header.
const headChunk = await readHeadChunk(filePath);
if (!headChunk) return null;
const header = parseSessionHeaderFromChunk(headChunk);
if (!header) return null;
const tail = await readTail(filePath).catch(() => "");
const tailInfo = parseSessionTail(tail);
const headInfo = parseSessionHeadFromChunk(headChunk);
const title =
tailInfo.title ??
headInfo.title ??
readReadableSessionTitleFromPath(filePath) ??
headInfo.firstUserMessage;
const model = tailInfo.model ?? headInfo.model;
const thinkingOptionId = tailInfo.thinkingOptionId ?? headInfo.thinkingOptionId;
const lastActivityAt =
tailInfo.lastActivityAt ?? (await readFileMtime(filePath)) ?? header.createdAt ?? new Date(0);
return {
cwd: header.cwd,
title,
firstUserMessage: headInfo.firstUserMessage,
lastUserMessage: tailInfo.lastUserMessage,
lastActivityAt,
model,
thinkingOptionId,
};
}
function toOmpImportSessionConfig(descriptor: OmpSessionDescriptor): OmpImportSessionConfig {
return {
...(descriptor.model ? { model: descriptor.model } : {}),
...(descriptor.thinkingOptionId ? { thinkingOptionId: descriptor.thinkingOptionId } : {}),
};
}
async function readHeadChunk(filePath: string): Promise<string | null> {
const handle = await open(filePath, "r").catch(() => null);
if (!handle) return null;
try {
const buffer = Buffer.alloc(HEAD_BYTES);
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
if (bytesRead <= 0) return null;
return buffer.subarray(0, bytesRead).toString("utf8");
} finally {
await handle.close().catch(() => undefined);
}
}
function parseSessionHeaderFromChunk(chunk: string): OmpSessionHeader | null {
for (const line of chunk.split(/\r?\n/u)) {
const header = parseSessionHeader(line.trim());
if (header) return header;
}
return null;
}
function parseSessionHeadFromChunk(chunk: string): OmpSessionHead {
let title: string | null = null;
let firstUserMessage: string | null = null;
let model: string | null = null;
let thinkingOptionId: string | null = null;
let lineCount = 0;
for (const rawLine of chunk.split(/\r?\n/u)) {
lineCount += 1;
const entry = parseJsonRecord(rawLine.trim());
if (!entry) continue;
if (entry.type === "session_info") {
title = readNonEmptyString(entry.name) ?? title;
}
if (entry.type === "title") {
title = readNonEmptyString(entry.title) ?? title;
}
model = extractModel(entry) ?? model;
thinkingOptionId = extractThinkingOptionId(entry) ?? thinkingOptionId;
if (!firstUserMessage && entry.type === "message" && isRecord(entry.message)) {
if (entry.message.role === "user") {
firstUserMessage = extractMessageText(entry.message.content);
}
}
if (title && firstUserMessage && model && thinkingOptionId) {
break;
}
if (lineCount >= FULL_SCAN_LINE_LIMIT && firstUserMessage) {
break;
}
}
return { title, firstUserMessage, model, thinkingOptionId };
}
async function readTail(filePath: string): Promise<string> {
const fileStats = await stat(filePath);
const start = Math.max(0, fileStats.size - TAIL_BYTES);
const length = fileStats.size - start;
const handle = await open(filePath, "r");
try {
const buffer = Buffer.alloc(length);
const { bytesRead } = await handle.read(buffer, 0, buffer.length, start);
return buffer.subarray(0, bytesRead).toString("utf8");
} finally {
await handle.close().catch(() => undefined);
}
}
async function readFileMtime(filePath: string): Promise<Date | null> {
try {
return (await stat(filePath)).mtime;
} catch {
return null;
}
}
function parseSessionHeader(firstLine: string): OmpSessionHeader | null {
const entry = parseJsonRecord(firstLine);
if (!entry || entry.type !== "session") return null;
const sessionId = typeof entry.id === "string" ? entry.id : null;
const cwd = typeof entry.cwd === "string" ? entry.cwd : null;
if (!sessionId || !cwd) return null;
const createdAt = parseDate(entry.timestamp);
return { sessionId, cwd, createdAt };
}
function parseSessionTail(tail: string): OmpSessionTail {
const lines = tail.split(/\r?\n/u);
let title: string | null = null;
let lastActivityAt: Date | null = null;
let fallbackTimestamp: Date | null = null;
let lastUserMessage: string | null = null;
let model: string | null = null;
let thinkingOptionId: string | null = null;
for (let index = lines.length - 1; index >= 0; index -= 1) {
const entry = parseJsonRecord(lines[index].trim());
if (!entry) continue;
if (!title && entry.type === "session_info") {
title = readNonEmptyString(entry.name);
}
if (!title && entry.type === "title") {
title = readNonEmptyString(entry.title);
}
if (!model) {
model = extractModel(entry);
}
if (!thinkingOptionId) {
thinkingOptionId = extractThinkingOptionId(entry);
}
const entryTimestamp = parseDate(entry.timestamp);
if (!fallbackTimestamp && entryTimestamp) {
fallbackTimestamp = entryTimestamp;
}
if (entry.type !== "message") continue;
if (!lastActivityAt && entryTimestamp) {
lastActivityAt = entryTimestamp;
}
if (!lastUserMessage && isRecord(entry.message) && entry.message.role === "user") {
lastUserMessage = extractMessageText(entry.message.content);
}
}
return {
title,
lastActivityAt: lastActivityAt ?? fallbackTimestamp,
lastUserMessage,
model,
thinkingOptionId,
};
}
function extractModel(entry: Record<string, unknown>): string | null {
if (entry.type === "model_change") {
// Pi records provider + modelId; OMP records a combined model string.
return buildModelId(entry.provider, entry.modelId) ?? readNonEmptyString(entry.model);
}
if (entry.type === "message" && isRecord(entry.message)) {
return buildModelId(entry.message.provider, entry.message.model);
}
return null;
}
function extractThinkingOptionId(entry: Record<string, unknown>): string | null {
return entry.type === "thinking_level_change" ? readNonEmptyString(entry.thinkingLevel) : null;
}
function buildModelId(provider: unknown, modelId: unknown): string | null {
const providerName = readNonEmptyString(provider);
const modelName = readNonEmptyString(modelId);
if (!providerName || !modelName) {
return null;
}
return `${providerName}/${modelName}`;
}
function parseJsonRecord(line: string): Record<string, unknown> | null {
if (!line) return null;
try {
const parsed = JSON.parse(line) as unknown;
return isRecord(parsed) ? parsed : null;
} catch {
return null;
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function readNonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function normalizePromptPreview(text: string | null): string | null {
const normalized = text?.trim().replace(/\s+/g, " ") ?? "";
if (!normalized) return null;
return normalized.length > 160 ? normalized.slice(0, 160) : normalized;
}
function readReadableSessionTitleFromPath(filePath: string): string | null {
const stem = path.basename(filePath, ".jsonl").trim();
if (!stem) {
return null;
}
if (/^\d{4}-\d{2}-\d{2}T\d{2}[-:]\d{2}[-:]\d{2}/u.test(stem)) {
return null;
}
if (/^[0-9a-f]{8,}$/iu.test(stem)) {
return null;
}
return stem;
}
function parseDate(value: unknown): Date | null {
if (typeof value !== "string" && typeof value !== "number") {
return null;
}
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
function extractMessageText(content: unknown): string | null {
if (typeof content === "string") {
return content.trim() || null;
}
if (!Array.isArray(content)) {
return null;
}
const text = content
.flatMap((part) =>
isRecord(part) && part.type === "text" && typeof part.text === "string" ? [part.text] : [],
)
.join("\n\n")
.trim();
return text || null;
}
@@ -0,0 +1,242 @@
import { describe, expect, test } from "vitest";
import { OmpSubagentCardTracker, type OmpSubagentCardScheduler } from "./subagent-card-tracker.js";
import type { OmpSubagentLifecyclePayload, OmpSubagentProgressPayload } from "./rpc-types.js";
const PARENT_TOOL_CALL_ID = "task-1";
const SESSION_FILE = "/tmp/omp-task/EchoSubagent.jsonl";
const LIFECYCLE: OmpSubagentLifecyclePayload = {
id: "EchoSubagent",
agent: "task",
description: "Run echo in subagent",
status: "started",
sessionFile: SESSION_FILE,
parentToolCallId: PARENT_TOOL_CALL_ID,
index: 0,
};
const PROGRESS: OmpSubagentProgressPayload[] = [
{
index: 0,
agent: "task",
task: "Run echo",
parentToolCallId: PARENT_TOOL_CALL_ID,
sessionFile: SESSION_FILE,
progress: {
id: "EchoSubagent",
status: "running",
recentTools: [{ tool: "bash", args: "echo subagent-hi", endMs: 1 }],
},
},
{
index: 0,
agent: "task",
task: "Run echo",
parentToolCallId: PARENT_TOOL_CALL_ID,
sessionFile: SESSION_FILE,
progress: {
id: "EchoSubagent",
status: "running",
recentTools: [
{ tool: "yield", args: "", endMs: 2 },
{ tool: "bash", args: "echo subagent-hi", endMs: 1 },
],
},
},
{
index: 0,
agent: "task",
task: "Run echo",
parentToolCallId: PARENT_TOOL_CALL_ID,
sessionFile: SESSION_FILE,
progress: {
id: "EchoSubagent",
status: "completed",
recentTools: [
{ tool: "yield", args: "", endMs: 2 },
{ tool: "bash", args: "echo subagent-hi", endMs: 1 },
],
},
},
];
class ManualScheduler implements OmpSubagentCardScheduler {
private currentMs = 0;
private nextId = 1;
private readonly timers = new Map<number, { dueMs: number; callback: () => void }>();
now(): number {
return this.currentMs;
}
setTimeout(callback: () => void, delayMs: number) {
const id = this.nextId;
this.nextId += 1;
this.timers.set(id, { dueMs: this.currentMs + delayMs, callback });
return { token: id };
}
clearTimeout(timer: { token: unknown }): void {
if (typeof timer.token === "number") {
this.timers.delete(timer.token);
}
}
advance(ms: number): void {
this.currentMs += ms;
const dueTimers = [...this.timers.entries()]
.filter(([, timer]) => timer.dueMs <= this.currentMs)
.sort((left, right) => left[1].dueMs - right[1].dueMs);
for (const [id, timer] of dueTimers) {
if (this.timers.delete(id)) {
timer.callback();
}
}
}
}
describe("OmpSubagentCardTracker", () => {
test("folds lifecycle and progress into one throttled sub-agent detail", () => {
const scheduler = new ManualScheduler();
const emitted: string[] = [];
const tracker = new OmpSubagentCardTracker({
scheduler,
emitToolCall: (toolCallId) => {
emitted.push(toolCallId);
return true;
},
});
tracker.handleLifecycle(LIFECYCLE);
for (const payload of PROGRESS) {
tracker.handleProgress(payload);
}
expect(emitted).toEqual([PARENT_TOOL_CALL_ID]);
const detailBeforeTrailing = tracker.detailFor(PARENT_TOOL_CALL_ID, {
type: "sub_agent",
subAgentType: "task",
description: "Task arg description wins",
log: "",
});
expect(detailBeforeTrailing).toEqual({
type: "sub_agent",
subAgentType: "task",
description: "Task arg description wins",
childSessionId: SESSION_FILE,
log: [
"EchoSubagent started",
"[bash] echo subagent-hi",
"[yield]",
"EchoSubagent completed",
].join("\n"),
});
scheduler.advance(500);
expect(emitted).toEqual([PARENT_TOOL_CALL_ID, PARENT_TOOL_CALL_ID]);
});
test("aggregates batch progress streams into one index-prefixed log", () => {
const scheduler = new ManualScheduler();
const emitted: string[] = [];
const tracker = new OmpSubagentCardTracker({
scheduler,
emitToolCall: (toolCallId) => {
emitted.push(toolCallId);
return true;
},
});
tracker.handleLifecycle({
id: "Explore",
agent: "task",
description: "Inspect files",
status: "started",
sessionFile: "/tmp/one.jsonl",
parentToolCallId: "task.batch-1",
index: 0,
});
scheduler.advance(600);
tracker.handleProgress({
index: 5,
agent: "task",
task: "Run tests",
parentToolCallId: "task.batch-1",
progress: {
id: "Test",
status: "running",
description: "Run tests",
recentTools: [{ tool: "bash", args: "npm test", endMs: 10 }],
},
sessionFile: "/tmp/two.jsonl",
});
expect(emitted).toEqual(["task.batch-1", "task.batch-1"]);
expect(
tracker.detailFor("task.batch-1", {
type: "sub_agent",
subAgentType: "batch",
actions: [],
log: "",
}),
).toEqual({
type: "sub_agent",
subAgentType: "batch",
description: "Inspect files",
childSessionId: "/tmp/one.jsonl",
log: "[1/6] Explore started\n[6/6] [bash] npm test",
actions: [],
});
});
test("keeps dirty trailing state available for final detail and cancels it on cleanup", () => {
const scheduler = new ManualScheduler();
const emitted: string[] = [];
const tracker = new OmpSubagentCardTracker({
scheduler,
emitToolCall: (toolCallId) => {
emitted.push(toolCallId);
return true;
},
});
tracker.handleLifecycle({
id: "Explore",
agent: "task",
description: "Inspect files",
status: "started",
parentToolCallId: "task-1",
index: 0,
});
scheduler.advance(100);
tracker.handleProgress({
index: 0,
agent: "task",
task: "Inspect files",
parentToolCallId: "task-1",
progress: {
id: "Explore",
status: "running",
recentOutput: ["found target file"],
},
});
expect(emitted).toEqual(["task-1"]);
expect(
tracker.detailFor("task-1", {
type: "sub_agent",
log: "",
}).log,
).toBe("Explore started\nfound target file");
tracker.delete("task-1");
scheduler.advance(500);
expect(emitted).toEqual(["task-1"]);
expect(
tracker.detailFor("task-1", {
type: "sub_agent",
log: "static",
}).log,
).toBe("static");
});
});
@@ -0,0 +1,361 @@
import type { ToolCallDetail } from "../../agent-sdk-types.js";
import type { OmpSubagentLifecyclePayload, OmpSubagentProgressPayload } from "./rpc-types.js";
export interface OmpSubagentCardTimer {
readonly token: unknown;
}
export interface OmpSubagentCardScheduler {
now(): number;
setTimeout(callback: () => void, delayMs: number): OmpSubagentCardTimer;
clearTimeout(timer: OmpSubagentCardTimer): void;
}
export interface OmpSubagentCardTrackerOptions {
scheduler?: OmpSubagentCardScheduler;
emitToolCall?: (toolCallId: string) => boolean;
}
interface OmpSubagentLogLine {
key: string;
text: string;
}
interface OmpSubagentCardItem {
index: number;
description?: string;
agent?: string;
status?: "pending" | "running" | "completed" | "failed" | "aborted";
childSessionId?: string;
lines: OmpSubagentLogLine[];
lineKeys: Set<string>;
}
interface OmpSubagentCardState {
items: Map<number, OmpSubagentCardItem>;
emitToolCall: (toolCallId: string) => boolean;
lastEmitMs: number | null;
trailingTimer: OmpSubagentCardTimer | null;
dirty: boolean;
}
const MAX_LOG_LINES_PER_ITEM = 200;
const MAX_LOG_LINE_CHARS = 240;
const THROTTLE_INTERVAL_MS = 500;
export function createOmpSubagentCardScheduler(): OmpSubagentCardScheduler {
return {
now: () => Date.now(),
setTimeout: (callback, delayMs) => ({ token: setTimeout(callback, delayMs) }),
clearTimeout: (timer) => {
clearTimeout(timer.token as ReturnType<typeof setTimeout>);
},
};
}
export class OmpSubagentCardTracker {
private readonly scheduler: OmpSubagentCardScheduler;
private readonly emitToolCall: (toolCallId: string) => boolean;
private readonly states = new Map<string, OmpSubagentCardState>();
constructor(options: OmpSubagentCardTrackerOptions) {
this.scheduler = options.scheduler ?? createOmpSubagentCardScheduler();
this.emitToolCall = options.emitToolCall ?? (() => false);
}
handleLifecycle(
payload: OmpSubagentLifecyclePayload,
emitToolCall: (toolCallId: string) => boolean = this.emitToolCall,
): void {
const parentToolCallId = readTrimmedString(payload.parentToolCallId);
if (!parentToolCallId) {
return;
}
this.stateFor(parentToolCallId).emitToolCall = emitToolCall;
const item = this.upsertItem(parentToolCallId, {
index: payload.index,
agent: payload.agent,
description: payload.description,
status: payload.status === "started" ? "running" : payload.status,
childSessionId: payload.sessionFile,
});
this.appendLine(
item,
`lifecycle:${payload.status}:${payload.id}`,
`${payload.id} ${payload.status}`,
);
this.requestEmit(parentToolCallId);
}
handleProgress(
payload: OmpSubagentProgressPayload,
emitToolCall: (toolCallId: string) => boolean = this.emitToolCall,
): void {
const parentToolCallId = readTrimmedString(payload.parentToolCallId);
if (!parentToolCallId) {
return;
}
this.stateFor(parentToolCallId).emitToolCall = emitToolCall;
const item = this.upsertItem(parentToolCallId, {
index: payload.index,
agent: payload.agent,
description: payload.progress.description,
status: payload.progress.status,
childSessionId: payload.sessionFile,
});
const currentToolLine = summarizeTool(readRecord(payload.progress.currentTool));
if (currentToolLine) {
this.appendLine(item, `current:${currentToolLine.key}`, currentToolLine.text);
}
for (const tool of readRecordArray(payload.progress.recentTools)) {
const line = summarizeTool(tool);
if (line) {
this.appendLine(item, `tool:${line.key}`, line.text);
}
}
for (const output of readOutputLines(payload.progress.recentOutput)) {
this.appendLine(item, `output:${output}`, output);
}
if (payload.progress.status !== "running") {
this.appendLine(
item,
`status:${payload.progress.status}:${payload.progress.id}`,
`${payload.progress.id} ${payload.progress.status}`,
);
}
this.requestEmit(parentToolCallId);
}
detailFor(toolCallId: string, baseDetail: ToolCallDetail): ToolCallDetail {
if (baseDetail.type !== "sub_agent") {
return baseDetail;
}
const state = this.states.get(toolCallId);
if (!state) {
return baseDetail;
}
const items = [...state.items.values()].sort((left, right) => left.index - right.index);
const firstItem = items[0];
if (!firstItem) {
return baseDetail;
}
const detail: ToolCallDetail = {
type: "sub_agent",
...(baseDetail.subAgentType ? { subAgentType: baseDetail.subAgentType } : {}),
...((baseDetail.description ?? firstItem.description)
? { description: baseDetail.description ?? firstItem.description }
: {}),
...((firstItem.childSessionId ?? baseDetail.childSessionId)
? { childSessionId: firstItem.childSessionId ?? baseDetail.childSessionId }
: {}),
log: buildLog(items, baseDetail.log),
};
return baseDetail.actions ? { ...detail, actions: baseDetail.actions } : detail;
}
flush(toolCallId: string): void {
const state = this.states.get(toolCallId);
if (!state || !state.dirty) {
return;
}
this.emitNow(toolCallId, state);
}
delete(toolCallId: string): void {
const state = this.states.get(toolCallId);
if (state?.trailingTimer) {
this.scheduler.clearTimeout(state.trailingTimer);
}
this.states.delete(toolCallId);
}
clear(): void {
for (const toolCallId of this.states.keys()) {
this.delete(toolCallId);
}
}
private upsertItem(
parentToolCallId: string,
input: {
index: number;
agent?: string;
status?: "pending" | "running" | "completed" | "failed" | "aborted";
description?: string;
childSessionId?: string;
},
): OmpSubagentCardItem {
const state = this.stateFor(parentToolCallId);
const existing = state.items.get(input.index);
if (existing) {
existing.agent = readTrimmedString(input.agent) ?? existing.agent;
existing.status = input.status ?? existing.status;
existing.description = readTrimmedString(input.description) ?? existing.description;
existing.childSessionId = readTrimmedString(input.childSessionId) ?? existing.childSessionId;
return existing;
}
const item: OmpSubagentCardItem = {
index: input.index,
lines: [],
lineKeys: new Set<string>(),
...(readTrimmedString(input.agent) ? { agent: readTrimmedString(input.agent) } : {}),
...(input.status ? { status: input.status } : {}),
...(readTrimmedString(input.description)
? { description: readTrimmedString(input.description) }
: {}),
...(readTrimmedString(input.childSessionId)
? { childSessionId: readTrimmedString(input.childSessionId) }
: {}),
};
state.items.set(input.index, item);
return item;
}
private stateFor(parentToolCallId: string): OmpSubagentCardState {
const existing = this.states.get(parentToolCallId);
if (existing) {
return existing;
}
const state: OmpSubagentCardState = {
items: new Map(),
emitToolCall: this.emitToolCall,
lastEmitMs: null,
trailingTimer: null,
dirty: false,
};
this.states.set(parentToolCallId, state);
return state;
}
private appendLine(item: OmpSubagentCardItem, key: string, text: string): void {
const normalizedText = normalizeLine(text);
if (!normalizedText || item.lineKeys.has(key)) {
return;
}
item.lines.push({ key, text: normalizedText });
item.lineKeys.add(key);
while (item.lines.length > MAX_LOG_LINES_PER_ITEM) {
const removed = item.lines.shift();
if (removed) {
item.lineKeys.delete(removed.key);
}
}
}
private requestEmit(toolCallId: string): void {
const state = this.stateFor(toolCallId);
const now = this.scheduler.now();
const elapsedMs = state.lastEmitMs === null ? THROTTLE_INTERVAL_MS : now - state.lastEmitMs;
if (elapsedMs >= THROTTLE_INTERVAL_MS) {
this.emitNow(toolCallId, state);
return;
}
state.dirty = true;
if (state.trailingTimer) {
return;
}
state.trailingTimer = this.scheduler.setTimeout(() => {
state.trailingTimer = null;
if (state.dirty) {
this.emitNow(toolCallId, state);
}
}, THROTTLE_INTERVAL_MS - elapsedMs);
}
private emitNow(toolCallId: string, state: OmpSubagentCardState): void {
if (state.trailingTimer) {
this.scheduler.clearTimeout(state.trailingTimer);
state.trailingTimer = null;
}
state.dirty = false;
state.lastEmitMs = this.scheduler.now();
state.emitToolCall(toolCallId);
}
}
function buildLog(items: OmpSubagentCardItem[], fallback: string): string {
const itemCount = Math.max(...items.map((item) => item.index)) + 1;
const lines = items.flatMap((item) => {
const prefix = itemCount > 1 ? `[${item.index + 1}/${itemCount}] ` : "";
return item.lines.map((line) => `${prefix}${line.text}`);
});
return lines.length > 0 ? lines.join("\n") : fallback;
}
function summarizeTool(tool: Record<string, unknown> | null): OmpSubagentLogLine | null {
if (!tool) {
return null;
}
const toolName = readTrimmedString(tool.tool) ?? readTrimmedString(tool.name);
if (!toolName) {
return null;
}
const args = readTrimmedString(tool.args) ?? summarizeUnknown(tool.input);
const endMs = typeof tool.endMs === "number" ? `:${tool.endMs}` : "";
return {
key: `${toolName}:${args ?? ""}${endMs}`,
text: args ? `[${toolName}] ${args}` : `[${toolName}]`,
};
}
function readOutputLines(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
return value.flatMap((item) => {
const text =
readTrimmedString(item) ??
(isRecord(item)
? (readTrimmedString(item.text) ??
readTrimmedString(item.output) ??
readTrimmedString(item.content))
: undefined);
return text ? [text] : [];
});
}
function readRecord(value: unknown): Record<string, unknown> | null {
return isRecord(value) ? value : null;
}
function readRecordArray(value: unknown): Record<string, unknown>[] {
return Array.isArray(value) ? value.filter(isRecord) : [];
}
function summarizeUnknown(value: unknown): string | undefined {
if (value === undefined || value === null) {
return undefined;
}
if (typeof value === "string") {
return readTrimmedString(value);
}
const json = JSON.stringify(value);
return typeof json === "string" ? normalizeLine(json) : undefined;
}
function normalizeLine(value: string): string | undefined {
const normalized = value.trim().replace(/\s+/g, " ");
if (!normalized) {
return undefined;
}
if (normalized.length <= MAX_LOG_LINE_CHARS) {
return normalized;
}
return `${normalized.slice(0, MAX_LOG_LINE_CHARS)}...`;
}
function readTrimmedString(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
@@ -0,0 +1,117 @@
import { describe, expect, test } from "vitest";
import { OmpSubagentIndex } from "./subagent-index.js";
describe("OMP provider subagent mapper", () => {
test("maps lifecycle and progress frames to stable provider_subagent descriptors", () => {
const index = new OmpSubagentIndex();
const parent = {};
expect(
index.handleLifecycle(parent, {
id: "child-1",
agent: "explore",
description: "Inspect files",
status: "started",
parentToolCallId: "task-1",
index: 0,
}),
).toEqual([
{
type: "provider_subagent",
provider: "omp",
event: {
type: "upsert",
id: "child-1",
title: "explore",
description: "Inspect files",
status: "running",
toolCallId: "task-1",
},
},
]);
expect(
index.handleProgress(parent, {
index: 0,
agent: "explore",
task: "Inspect files",
parentToolCallId: "task-1",
progress: {
id: "child-1",
status: "running",
resolvedModel: "openai-codex/gpt-5.5",
},
})[0],
).toMatchObject({
event: {
id: "child-1",
status: "running",
title: "explore · gpt-5.5 (openai-codex)",
},
});
expect(
index.handleProgress(parent, {
index: 0,
agent: "explore",
task: "Inspect files",
parentToolCallId: "task-1",
progress: {
id: "child-1",
status: "completed",
resolvedModel: "anthropic/claude-sonnet-5",
},
})[0],
).toMatchObject({
event: {
id: "child-1",
status: "completed",
title: "explore · claude-sonnet-5 (anthropic)",
},
});
});
test("maps child message events onto the descriptor timeline", () => {
const index = new OmpSubagentIndex();
const parent = {};
expect(
index.handleEvent(parent, {
id: "child-1",
event: {
type: "message_end",
message: {
role: "assistant",
content: [{ type: "text", text: "Child answer" }],
},
},
}),
).toEqual([
{
type: "provider_subagent",
provider: "omp",
event: {
type: "timeline",
id: "child-1",
item: {
type: "assistant_message",
text: "Child answer",
messageId: "omp-history-assistant-1",
},
},
},
]);
});
test("maps aborted lifecycle status to canceled", () => {
const index = new OmpSubagentIndex();
const parent = {};
expect(
index.handleLifecycle(parent, {
id: "child-1",
agent: "task",
status: "aborted",
index: 0,
})[0],
).toMatchObject({ event: { id: "child-1", status: "canceled" } });
});
});
@@ -0,0 +1,141 @@
import type { AgentStreamEvent } from "../../agent-sdk-types.js";
import { OmpHistoryMapper } from "./message-history.js";
import type { OmpAgentMessage, OmpAgentSessionEvent } from "./rpc-types.js";
import { OMP_HISTORY_MAPPER_HOOKS } from "./history-hooks.js";
import { formatOmpSubagentTitle } from "./subagent-title.js";
import type {
OmpSubagentEventPayload,
OmpSubagentLifecyclePayload,
OmpSubagentProgressPayload,
} from "./rpc-types.js";
interface OmpSubagentState {
title: string;
description: string | null;
resolvedModel: string | null;
toolCallId: string | null;
status: "running" | "completed" | "failed" | "canceled";
mapper: OmpHistoryMapper;
}
export class OmpSubagentIndex {
private readonly statesByParent = new WeakMap<object, Map<string, OmpSubagentState>>();
handleLifecycle(parent: object, payload: OmpSubagentLifecyclePayload): AgentStreamEvent[] {
const state = this.stateFor(parent, payload.id, payload.agent);
state.title = payload.agent || state.title;
state.description = payload.description ?? state.description;
state.toolCallId = payload.parentToolCallId ?? state.toolCallId;
state.status = mapLifecycleStatus(payload.status);
return [this.upsert(payload.id, state.status, state)];
}
handleProgress(parent: object, payload: OmpSubagentProgressPayload): AgentStreamEvent[] {
const id = payload.progress.id;
const state = this.stateFor(parent, id, payload.agent);
state.title = payload.agent || state.title;
state.description = payload.progress.description ?? payload.assignment ?? state.description;
if (payload.progress.resolvedModel?.trim()) {
state.resolvedModel = payload.progress.resolvedModel;
}
state.toolCallId = payload.parentToolCallId ?? state.toolCallId;
state.status = mapProgressStatus(payload.progress.status);
return [this.upsert(id, state.status, state)];
}
handleEvent(parent: object, payload: OmpSubagentEventPayload): AgentStreamEvent[] {
const state = this.stateFor(parent, payload.id, "OMP subagent");
const messages = messagesFromSessionEvent(payload.event);
return state.mapper.mapMessages(messages).flatMap((mapped) =>
mapped.type === "timeline"
? [
{
type: "provider_subagent" as const,
provider: "omp",
event: {
type: "timeline" as const,
id: payload.id,
item: mapped.item,
...(mapped.timestamp ? { timestamp: mapped.timestamp } : {}),
},
},
]
: [],
);
}
terminalizeRunning(parent: object): AgentStreamEvent[] {
const states = this.statesByParent.get(parent);
if (!states) {
return [];
}
const events: AgentStreamEvent[] = [];
for (const [id, state] of states) {
if (state.status !== "running") {
continue;
}
state.status = "canceled";
events.push(this.upsert(id, state.status, state));
}
return events;
}
clear(parent: object): void {
this.statesByParent.delete(parent);
}
private stateFor(parent: object, id: string, title: string): OmpSubagentState {
const states = this.statesByParent.get(parent) ?? new Map<string, OmpSubagentState>();
const existing = states.get(id);
if (existing) return existing;
const state: OmpSubagentState = {
title,
description: null,
resolvedModel: null,
toolCallId: null,
status: "running",
mapper: new OmpHistoryMapper("omp", [], OMP_HISTORY_MAPPER_HOOKS),
};
states.set(id, state);
this.statesByParent.set(parent, states);
return state;
}
private upsert(
id: string,
status: "running" | "completed" | "failed" | "canceled",
state: OmpSubagentState,
): AgentStreamEvent {
return {
type: "provider_subagent",
provider: "omp",
event: {
type: "upsert",
id,
title: formatOmpSubagentTitle(state.title, state.resolvedModel),
description: state.description,
status,
toolCallId: state.toolCallId,
},
};
}
}
function messagesFromSessionEvent(event: OmpAgentSessionEvent): OmpAgentMessage[] {
if (event.type === "message_end") return [event.message];
return [];
}
function mapLifecycleStatus(
status: OmpSubagentLifecyclePayload["status"],
): "running" | "completed" | "failed" | "canceled" {
if (status === "started") return "running";
return status === "aborted" ? "canceled" : status;
}
function mapProgressStatus(
status: OmpSubagentProgressPayload["progress"]["status"],
): "running" | "completed" | "failed" | "canceled" {
if (status === "completed" || status === "failed") return status;
return status === "aborted" ? "canceled" : "running";
}
@@ -0,0 +1,14 @@
export function formatOmpSubagentTitle(title: string, resolvedModel?: string | null): string {
const name = title.trim() || "OMP subagent";
const model = resolvedModel?.trim();
if (!model) return name;
const separator = model.indexOf("/");
if (separator <= 0 || separator === model.length - 1) {
return `${name} · ${model}`;
}
const provider = model.slice(0, separator);
const modelName = model.slice(separator + 1);
return `${name} · ${modelName} (${provider})`;
}
@@ -0,0 +1,109 @@
import { describe, expect, test } from "vitest";
import { isOmpSystemNotice, mapOmpSystemNoticeToToolCall } from "./system-notice.js";
const COMPLETED_NOTICE = [
"<system-notice>",
"Background job DocsSmokeTwo has completed. Resume your work using the result below.",
'<task-result id="DocsSmokeTwo" agent="explore" status="completed" duration="21.6s">',
'<meta lines="22" size="2.5KB" />',
"<output>",
'{"summary":"docs smoke check done"}',
"</output>",
"</task-result>",
"</system-notice>",
"DocsSmokeTwo is now idle — transcript at history://DocsSmokeTwo",
].join("\n");
describe("omp system notice detection", () => {
test("detects messages that start with the system-notice tag", () => {
expect(isOmpSystemNotice(COMPLETED_NOTICE)).toBe(true);
expect(isOmpSystemNotice(" \n<system-notice>plain</system-notice>")).toBe(true);
});
test("ignores regular prompts, including ones that mention the tag mid-message", () => {
expect(isOmpSystemNotice("please fix the bug")).toBe(false);
expect(isOmpSystemNotice("what does <system-notice> mean in omp?")).toBe(false);
expect(mapOmpSystemNoticeToToolCall("what does <system-notice> mean in omp?")).toBeNull();
});
});
describe("omp system notice tool call mapping", () => {
test("maps a completed task-result notice to a synthetic completed tool call", () => {
expect(mapOmpSystemNoticeToToolCall(COMPLETED_NOTICE)).toEqual({
type: "tool_call",
callId: "omp-notice:DocsSmokeTwo",
name: "task_notification",
status: "completed",
detail: {
type: "plain_text",
label: "Background job DocsSmokeTwo completed",
text: COMPLETED_NOTICE,
icon: "wrench",
},
metadata: {
synthetic: true,
source: "omp_system_notice",
taskId: "DocsSmokeTwo",
subagentType: "explore",
status: "completed",
},
error: null,
});
});
test("maps a failed task-result notice to a failed tool call", () => {
const notice = [
"<system-notice>",
"Background job RepoSmokeOne has failed.",
'<task-result id="RepoSmokeOne" agent="explore" status="failed" duration="3s">',
"<output>boom</output>",
"</task-result>",
"</system-notice>",
].join("\n");
const item = mapOmpSystemNoticeToToolCall(notice);
expect(item).toMatchObject({
callId: "omp-notice:RepoSmokeOne",
status: "failed",
error: "Background job RepoSmokeOne failed",
});
});
test("parses task-result attributes with typographic quotes", () => {
const notice = [
"<system-notice>",
"Background job DocsSmokeTwo has completed.",
"<task-result id=“DocsSmokeTwo” agent=“explore” status=“completed” duration=“21.6s”>",
"<output>ok</output>",
"</task-result>",
"</system-notice>",
].join("\n");
expect(mapOmpSystemNoticeToToolCall(notice)).toMatchObject({
callId: "omp-notice:DocsSmokeTwo",
status: "completed",
metadata: {
taskId: "DocsSmokeTwo",
subagentType: "explore",
},
});
});
test("maps a notice without a task-result using its first line and a stable hash id", () => {
const notice = "<system-notice>\nThe daemon rotated its logs.\n</system-notice>";
const first = mapOmpSystemNoticeToToolCall(notice);
const second = mapOmpSystemNoticeToToolCall(notice);
expect(first).toEqual(second);
expect(first).toMatchObject({
status: "completed",
detail: {
type: "plain_text",
label: "The daemon rotated its logs.",
text: notice,
},
});
expect(first?.callId).toMatch(/^omp-notice:[0-9a-f]{12}$/);
});
});
@@ -0,0 +1,128 @@
import { createHash } from "node:crypto";
import type { AgentTimelineItem } from "../../agent-sdk-types.js";
const SYSTEM_NOTICE_OPEN_TAG = "<system-notice>";
const SYSTEM_NOTICE_CLOSE_TAG = "</system-notice>";
const TASK_RESULT_TAG_PATTERN = /<task-result\b([^>]*)>/i;
// The omp harness emits straight quotes, but transcripts have been observed
// with typographic quotes after copy/paste round-trips; accept both.
const TASK_RESULT_ATTRIBUTE_PATTERN = /([\w-]+)=["'“‘]([^"'“”‘’]*)["'”’]/g;
type OmpSystemNoticeToolCallItem = Extract<AgentTimelineItem, { type: "tool_call" }>;
interface OmpTaskResultSummary {
id: string | null;
agent: string | null;
status: string | null;
}
type OmpNoticeLifecycle =
| { status: "completed"; error: null }
| { status: "failed"; error: string }
| { status: "canceled"; error: null };
export function isOmpSystemNotice(text: string): boolean {
return text.trimStart().startsWith(SYSTEM_NOTICE_OPEN_TAG);
}
function readTaskResult(text: string): OmpTaskResultSummary | null {
const tagMatch = text.match(TASK_RESULT_TAG_PATTERN);
if (!tagMatch) {
return null;
}
const attributes = new Map<string, string>();
for (const attributeMatch of (tagMatch[1] ?? "").matchAll(TASK_RESULT_ATTRIBUTE_PATTERN)) {
const name = attributeMatch[1];
const value = attributeMatch[2];
if (name && value !== undefined) {
attributes.set(name, value.trim());
}
}
return {
id: attributes.get("id") || null,
agent: attributes.get("agent") || null,
status: attributes.get("status") || null,
};
}
function readNoticeFirstLine(text: string): string | null {
const openIndex = text.indexOf(SYSTEM_NOTICE_OPEN_TAG);
const closeIndex = text.indexOf(SYSTEM_NOTICE_CLOSE_TAG);
const body = text.slice(
openIndex + SYSTEM_NOTICE_OPEN_TAG.length,
closeIndex === -1 ? undefined : closeIndex,
);
for (const line of body.split("\n")) {
const trimmed = line.trim();
if (trimmed && !trimmed.startsWith("<")) {
return trimmed;
}
}
return null;
}
function buildLifecycle(
taskResult: OmpTaskResultSummary | null,
label: string,
): OmpNoticeLifecycle {
const status = taskResult?.status?.toLowerCase() ?? null;
if (status === "failed" || status === "error") {
return { status: "failed", error: label };
}
if (status === "canceled" || status === "cancelled" || status === "stopped") {
return { status: "canceled", error: null };
}
return { status: "completed", error: null };
}
function buildLabel(taskResult: OmpTaskResultSummary | null, text: string): string {
if (taskResult?.id) {
return `Background job ${taskResult.id} ${taskResult.status ?? "completed"}`;
}
return readNoticeFirstLine(text) ?? "System notice";
}
function buildCallId(taskResult: OmpTaskResultSummary | null, text: string): string {
if (taskResult?.id) {
return `omp-notice:${taskResult.id}`;
}
const digest = createHash("sha1").update(text.trim()).digest("hex").slice(0, 12);
return `omp-notice:${digest}`;
}
export function mapOmpSystemNoticeToToolCall(text: string): OmpSystemNoticeToolCallItem | null {
if (!isOmpSystemNotice(text)) {
return null;
}
const taskResult = readTaskResult(text);
const label = buildLabel(taskResult, text);
const lifecycle = buildLifecycle(taskResult, label);
const base = {
type: "tool_call" as const,
callId: buildCallId(taskResult, text),
name: "task_notification",
detail: {
type: "plain_text" as const,
label,
text,
icon: "wrench" as const,
},
metadata: {
synthetic: true,
source: "omp_system_notice",
...(taskResult?.id ? { taskId: taskResult.id } : {}),
...(taskResult?.agent ? { subagentType: taskResult.agent } : {}),
...(taskResult?.status ? { status: taskResult.status } : {}),
},
};
if (lifecycle.status === "failed") {
return { ...base, status: "failed", error: lifecycle.error };
}
if (lifecycle.status === "canceled") {
return { ...base, status: "canceled", error: null };
}
return { ...base, status: "completed", error: null };
}
@@ -0,0 +1,467 @@
import type {
OmpRuntime,
OmpRuntimeLaunch,
OmpRuntimeSession,
OmpStartSessionInput,
} from "../runtime.js";
import type {
OmpRpcHostToolDefinition,
OmpRpcHostToolResult,
OmpRpcHostToolUpdate,
OmpAgentMessage,
OmpModel,
OmpPromptAck,
OmpRpcSlashCommand,
OmpRuntimeEvent,
OmpSessionState,
OmpSessionStats,
OmpThinkingLevel,
} from "../rpc-types.js";
import { buildOmpLaunch } from "../runtime.js";
type FakeOmpSubagentSubscriptionLevel = "off" | "progress" | "events";
type FakeOmpSubagentStatus = "pending" | "running" | "completed" | "failed" | "aborted";
export interface FakeOmpSubagentSnapshot {
id: string;
index: number;
agent: string;
description?: string;
status: FakeOmpSubagentStatus;
task?: string;
assignment?: string;
sessionFile?: string;
parentToolCallId?: string;
lastUpdate?: number;
}
export interface FakeOmpSubagentMessagesSelector {
subagentId?: string;
sessionFile?: string;
fromByte?: number;
}
export interface FakeOmpSubagentMessagesResult {
sessionFile: string;
fromByte: number;
nextByte: number;
reset: boolean;
messages: OmpAgentMessage[];
}
export class FakeOmp implements OmpRuntime {
readonly recordedLaunches: OmpRuntimeLaunch[] = [];
private readonly sessions: FakeOmpSession[] = [];
private readonly command: [string, ...string[]];
private readonly queuedCommands: OmpRpcSlashCommand[][] = [];
private readonly queuedSubagentSubscriptionErrors = new Map<
FakeOmpSubagentSubscriptionLevel,
Error
>();
constructor(command: [string, ...string[]] = ["omp"]) {
this.command = command;
}
async startSession(input: OmpStartSessionInput): Promise<FakeOmpSession> {
const launch = buildOmpLaunch({
command: this.command,
session: input,
});
this.recordedLaunches.push(launch);
const session = new FakeOmpSession(launch);
session.commands = this.queuedCommands.shift() ?? [];
for (const [level, error] of this.queuedSubagentSubscriptionErrors) {
session.subagentSubscriptionErrors.set(level, error);
}
this.queuedSubagentSubscriptionErrors.clear();
this.sessions.push(session);
return session;
}
queueCommands(commands: OmpRpcSlashCommand[]): void {
this.queuedCommands.push(commands);
}
failNextSubagentSubscription(level: FakeOmpSubagentSubscriptionLevel, error: Error): void {
this.queuedSubagentSubscriptionErrors.set(level, error);
}
latestSession(): FakeOmpSession {
const session = this.sessions.at(-1);
if (!session) {
throw new Error("FakeOmp has no sessions");
}
return session;
}
}
export class FakeOmpSession implements OmpRuntimeSession {
readonly prompts: Array<{ message: string; imageCount: number }> = [];
readonly compactRequests: Array<{ customInstructions?: string }> = [];
readonly setAutoCompactionRequests: boolean[] = [];
readonly subagentSubscriptionRequests: FakeOmpSubagentSubscriptionLevel[] = [];
readonly subagentMessageRequests: FakeOmpSubagentMessagesSelector[] = [];
readonly setModelRequests: Array<{ provider: string; modelId: string }> = [];
readonly setThinkingLevelRequests: OmpThinkingLevel[] = [];
readonly handoffRequests: Array<{ customInstructions?: string }> = [];
readonly steerRequests: Array<{ message: string; imageCount: number }> = [];
readonly followUpRequests: Array<{ message: string; imageCount: number }> = [];
readonly hostToolSetRequests: OmpRpcHostToolDefinition[][] = [];
readonly hostToolResults: OmpRpcHostToolResult[] = [];
readonly hostToolUpdates: OmpRpcHostToolUpdate[] = [];
getStateRequestCount = 0;
abortRequested = false;
readonly canceledExtensionUiRequests: string[] = [];
readonly extensionUiResponses: Array<{
id: string;
response: { value?: string; confirmed?: boolean; cancelled?: boolean };
}> = [];
setModelResult: OmpModel | null = null;
models: OmpModel[] = [];
messages: OmpAgentMessage[] = [];
stats: OmpSessionStats = {
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
cost: 0,
};
commands: OmpRpcSlashCommand[] = [];
subagents: FakeOmpSubagentSnapshot[] = [];
readonly subagentSubscriptionErrors = new Map<FakeOmpSubagentSubscriptionLevel, Error>();
compactError: Error | null = null;
emitCompactEnd = true;
getStateError: Error | null = null;
promptAck: OmpPromptAck = {};
branchResponse: { text?: string; cancelled?: boolean } = { text: "" };
branchMessages: Array<{ entryId: string; text: string }> = [];
readonly branchRequests: string[] = [];
activeBranchEntryId?: string;
closed = false;
state: OmpSessionState;
private readonly subscribers = new Set<(event: OmpRuntimeEvent) => void>();
private readonly stateReports: OmpSessionState[] = [];
private readonly stateRequestWaiters: Array<{ count: number; resolve: () => void }> = [];
private readonly hostToolResultWaiters: Array<(result: OmpRpcHostToolResult) => void> = [];
private readonly promptWaiters: Array<() => void> = [];
private readonly subscriptionWaiters: Array<{ count: number; resolve: () => void }> = [];
private readonly subagentMessageResults = new Map<string, FakeOmpSubagentMessagesResult[]>();
private nextHeldPrompt: { promise: Promise<void>; reject: (error: Error) => void } | null = null;
private activeHeldPrompt: { promise: Promise<void>; reject: (error: Error) => void } | null =
null;
constructor(launch: OmpRuntimeLaunch) {
this.state = {
model: null,
thinkingLevel: "medium",
isStreaming: false,
isCompacting: false,
autoCompactionEnabled: true,
sessionFile: launch.session ?? "/tmp/omp-session",
sessionId: "omp-session-1",
messageCount: 0,
queuedMessageCount: 0,
};
}
onEvent(callback: (event: OmpRuntimeEvent) => void): () => void {
this.subscribers.add(callback);
return () => {
this.subscribers.delete(callback);
};
}
async prompt(
message: string,
images?: Array<{ type: "image"; data: string; mimeType: string }>,
): Promise<OmpPromptAck> {
if (this.state.isStreaming || this.state.isCompacting) {
throw new Error("Agent is already processing");
}
this.prompts.push({ message, imageCount: images?.length ?? 0 });
this.promptWaiters.shift()?.();
const heldPrompt = this.nextHeldPrompt;
if (heldPrompt) {
this.nextHeldPrompt = null;
this.activeHeldPrompt = heldPrompt;
try {
await heldPrompt.promise;
} finally {
if (this.activeHeldPrompt === heldPrompt) {
this.activeHeldPrompt = null;
}
}
}
return this.promptAck;
}
nextPrompt(): Promise<void> {
return new Promise((resolve) => this.promptWaiters.push(resolve));
}
holdNextPrompt(): void {
if (this.nextHeldPrompt || this.activeHeldPrompt) {
throw new Error("FakeOmp already has a held prompt");
}
let reject!: (error: Error) => void;
const promise = new Promise<void>((_resolve, rejectPromise) => {
reject = rejectPromise;
});
this.nextHeldPrompt = { promise, reject };
}
async failHeldPrompt(error: Error): Promise<void> {
const heldPrompt = this.activeHeldPrompt ?? this.nextHeldPrompt;
if (!heldPrompt) {
throw new Error("FakeOmp has no held prompt");
}
heldPrompt.reject(error);
await new Promise<void>((resolve) => setImmediate(resolve));
}
async compact(customInstructions?: string): Promise<void> {
this.compactRequests.push(customInstructions === undefined ? {} : { customInstructions });
this.emit({ type: "compaction_start", reason: "manual" });
if (this.emitCompactEnd) {
this.emit({ type: "compaction_end", reason: "manual" });
}
if (this.compactError) {
throw this.compactError;
}
}
async setAutoCompaction(enabled: boolean): Promise<void> {
this.setAutoCompactionRequests.push(enabled);
this.state = {
...this.state,
autoCompactionEnabled: enabled,
};
}
async abort(): Promise<void> {
this.abortRequested = true;
}
async getState(): Promise<OmpSessionState> {
this.getStateRequestCount += 1;
for (const waiter of this.stateRequestWaiters.splice(0)) {
if (this.getStateRequestCount >= waiter.count) waiter.resolve();
else this.stateRequestWaiters.push(waiter);
}
if (this.getStateError) {
throw this.getStateError;
}
const report = this.stateReports.shift();
if (report) {
this.state = report;
}
return this.state;
}
queueStateReports(states: OmpSessionState[]): void {
this.stateReports.push(...states);
}
waitForStateRequests(count: number): Promise<void> {
if (this.getStateRequestCount >= count) return Promise.resolve();
return new Promise((resolve) => this.stateRequestWaiters.push({ count, resolve }));
}
async getMessages(): Promise<OmpAgentMessage[]> {
return this.messages;
}
async getAvailableModels(_timeoutMs?: number): Promise<OmpModel[]> {
return this.models;
}
async setModel(provider: string, modelId: string): Promise<OmpModel> {
this.setModelRequests.push({ provider, modelId });
if (!this.setModelResult) {
throw new Error("FakeOmp setModel requires setModelResult to be scripted");
}
return this.setModelResult;
}
async setThinkingLevel(level: OmpThinkingLevel): Promise<void> {
this.setThinkingLevelRequests.push(level);
}
async getSessionStats(): Promise<OmpSessionStats> {
return this.stats;
}
async setSubagentSubscription(level: FakeOmpSubagentSubscriptionLevel): Promise<void> {
this.subagentSubscriptionRequests.push(level);
for (const waiter of this.subscriptionWaiters.splice(0)) {
if (this.subagentSubscriptionRequests.length >= waiter.count) waiter.resolve();
else this.subscriptionWaiters.push(waiter);
}
const error = this.subagentSubscriptionErrors.get(level);
if (error) {
throw error;
}
}
waitForSubagentSubscriptions(count: number): Promise<void> {
if (this.subagentSubscriptionRequests.length >= count) return Promise.resolve();
return new Promise((resolve) => this.subscriptionWaiters.push({ count, resolve }));
}
async setHostTools(tools: OmpRpcHostToolDefinition[]): Promise<string[]> {
this.hostToolSetRequests.push(tools);
return tools.map((tool) => tool.name);
}
async branch(entryId: string): Promise<{ text: string }> {
this.branchRequests.push(entryId);
if (this.branchResponse.cancelled === true) {
throw new Error("OMP branch was cancelled");
}
if (typeof this.branchResponse.text !== "string") {
throw new Error("FakeOmp branch response requires text");
}
this.activeBranchEntryId = entryId;
return { text: this.branchResponse.text };
}
async getBranchMessages(): Promise<Array<{ entryId: string; text: string }>> {
return this.branchMessages;
}
steer(message: string, images?: Array<{ type: "image"; data: string; mimeType: string }>): void {
this.steerRequests.push({ message, imageCount: images?.length ?? 0 });
}
followUp(
message: string,
images?: Array<{ type: "image"; data: string; mimeType: string }>,
): void {
this.followUpRequests.push({ message, imageCount: images?.length ?? 0 });
}
sendHostToolResult(result: OmpRpcHostToolResult): void {
this.hostToolResults.push(result);
this.hostToolResultWaiters.shift()?.(result);
}
sendHostToolUpdate(update: OmpRpcHostToolUpdate): void {
this.hostToolUpdates.push(update);
}
nextHostToolResult(): Promise<OmpRpcHostToolResult> {
return new Promise((resolve) => this.hostToolResultWaiters.push(resolve));
}
async getSubagents(): Promise<FakeOmpSubagentSnapshot[]> {
return this.subagents;
}
async getSubagentMessages(
selector: FakeOmpSubagentMessagesSelector,
): Promise<FakeOmpSubagentMessagesResult> {
this.subagentMessageRequests.push(selector);
const key = selector.sessionFile ?? selector.subagentId;
if (!key) {
throw new Error("FakeOmp getSubagentMessages requires a selector");
}
const results = this.subagentMessageResults.get(key);
const result = results?.shift();
if (!result) {
throw new Error(`FakeOmp has no subagent messages queued for ${key}`);
}
return result;
}
queueSubagentMessages(result: FakeOmpSubagentMessagesResult): void {
const results = this.subagentMessageResults.get(result.sessionFile) ?? [];
results.push(result);
this.subagentMessageResults.set(result.sessionFile, results);
}
async getCommands(): Promise<OmpRpcSlashCommand[]> {
return this.commands;
}
async handoff(customInstructions?: string): Promise<void> {
this.handoffRequests.push(customInstructions ? { customInstructions } : {});
}
respondToExtensionUiRequest(
id: string,
response: { value?: string; confirmed?: boolean; cancelled?: boolean },
): void {
this.extensionUiResponses.push({ id, response });
}
cancelExtensionUiRequest(id: string): void {
this.canceledExtensionUiRequests.push(id);
this.respondToExtensionUiRequest(id, { cancelled: true });
}
async close(): Promise<void> {
this.closed = true;
}
emit(event: OmpRuntimeEvent): void {
for (const subscriber of this.subscribers) {
subscriber(event);
}
}
finishTurn(message: OmpAgentMessage = { role: "assistant", content: [] }): void {
this.messages = [...this.messages, message];
this.emit({ type: "agent_end", messages: this.messages });
}
beginTurn(): void {
this.emit({ type: "turn_start" });
}
acceptPrompt(text: string, entryId = "omp-user-1"): void {
this.emit({
type: "message_end",
message: { role: "user", content: text, entryId } as OmpAgentMessage,
});
}
acceptCustomMessage(content: string): void {
this.emit({
type: "message_end",
message: { role: "custom", content },
});
}
streamAssistantText(text: string, responseId = "omp-assistant-1"): void {
const message: OmpAgentMessage = {
role: "assistant",
content: [],
responseId,
};
this.emit({ type: "message_start", message });
this.emit({
type: "message_update",
message,
assistantMessageEvent: { type: "text_delta", delta: text },
});
}
requestToolApproval(input: {
id: string;
tool: "bash" | "edit" | "write";
detail: string;
}): void {
let detailLabel = "Path";
if (input.tool === "bash") {
detailLabel = "Command";
} else if (input.tool === "edit") {
detailLabel = "File";
}
this.emit({
type: "extension_ui_request",
id: input.id,
method: "select",
title: `Allow tool: ${input.tool}\n${detailLabel}: ${input.detail}`,
options: ["Approve", "Deny"],
});
}
}
@@ -0,0 +1,342 @@
import { mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import pino from "pino";
import type {
AgentPersistenceHandle,
AgentPermissionResponse,
AgentSessionConfig,
AgentStreamEvent,
AgentTimelineItem,
} from "../../../agent-sdk-types.js";
import type { PaseoToolCatalog } from "../../../tools/types.js";
import { OmpAgentClient, OmpAgentSession, type OmpProviderIdleScheduler } from "../agent.js";
import type { OmpRpcSlashCommand } from "../rpc-types.js";
import { FakeOmp } from "./fake-omp.js";
const CWD = "/tmp/paseo-omp-agent-test";
interface OmpHistoryMessage {
id: string;
text: string;
}
interface OmpResumeHistory {
user: OmpHistoryMessage;
assistant: OmpHistoryMessage;
}
async function writeOmpHistory(history: OmpResumeHistory): Promise<string> {
const directory = await mkdtemp(join(tmpdir(), "paseo-omp-resume-"));
const sessionFile = join(directory, "session.jsonl");
const entries = [
{ type: "session", id: "session-root", parentId: null },
{
type: "message",
id: history.user.id,
parentId: "session-root",
message: { role: "user", content: history.user.text },
},
{
type: "message",
id: history.assistant.id,
parentId: history.user.id,
message: {
role: "assistant",
content: [{ type: "text", text: history.assistant.text }],
responseId: history.assistant.id,
},
},
];
await writeFile(sessionFile, entries.map((entry) => JSON.stringify(entry)).join("\n"), "utf8");
return sessionFile;
}
export class OmpHarness {
private readonly omp = new FakeOmp();
private readonly client: OmpAgentClient;
private readonly events: AgentStreamEvent[] = [];
private session: OmpAgentSession | null = null;
constructor(options: { providerIdleScheduler?: OmpProviderIdleScheduler } = {}) {
this.client = new OmpAgentClient({
logger: pino({ level: "silent" }),
runtime: this.omp,
providerIdleScheduler: options.providerIdleScheduler,
});
}
queueCommands(commands: OmpRpcSlashCommand[]): void {
this.omp.queueCommands(commands);
}
failEventSubscription(error: Error): void {
this.omp.failNextSubagentSubscription("events", error);
}
async start(
config: Partial<AgentSessionConfig> = {},
paseoTools?: PaseoToolCatalog,
): Promise<void> {
const session = await this.client.createSession(
{ provider: "omp", cwd: CWD, ...config },
paseoTools ? { paseoTools } : undefined,
);
if (!(session instanceof OmpAgentSession)) {
throw new Error("OMP client returned a non-OMP session");
}
this.session = session;
this.session.subscribe((event) => this.events.push(event));
}
async resume(
history: OmpResumeHistory,
overrides: Partial<AgentSessionConfig> = {},
): Promise<void> {
const sessionFile = await writeOmpHistory(history);
const handle: AgentPersistenceHandle = {
provider: "omp",
sessionId: "omp-session-1",
nativeHandle: sessionFile,
metadata: { cwd: CWD },
};
const session = await this.client.resumeSession(handle, overrides);
if (!(session instanceof OmpAgentSession)) {
throw new Error("OMP client returned a non-OMP session");
}
this.session = session;
this.session.subscribe((event) => this.events.push(event));
}
launchConfiguration(): {
cwd: string;
protocolMode?: string;
modeId?: string;
session?: string;
argv: string[];
} {
const launch = this.omp.recordedLaunches[0];
if (!launch) throw new Error("OMP harness has not launched");
return {
cwd: launch.cwd,
protocolMode: launch.protocolMode,
modeId: launch.modeId,
...(launch.session ? { session: launch.session } : {}),
argv: launch.argv,
};
}
registeredHostTools() {
return this.omp.latestSession().hostToolSetRequests;
}
capabilities() {
return this.client.capabilities;
}
async runPrompt(
input: string,
output: string,
providerStatesAfterEnd: Array<{ isStreaming: boolean; isCompacting: boolean }> = [],
): Promise<unknown> {
const session = this.requireSession();
const promptStarted = this.omp.latestSession().nextPrompt();
const run = session.run(input);
await promptStarted;
const runtime = this.omp.latestSession();
runtime.beginTurn();
runtime.acceptPrompt(input, "user-1");
runtime.streamAssistantText(output);
runtime.queueStateReports(
providerStatesAfterEnd.map((state) => ({ ...runtime.state, ...state })),
);
runtime.finishTurn();
return await run;
}
async runPromptAfterExtensionNotice(input: string, output: string): Promise<unknown> {
const session = this.requireSession();
const promptStarted = this.omp.latestSession().nextPrompt();
const run = session.run(input);
await promptStarted;
const runtime = this.omp.latestSession();
runtime.beginTurn();
runtime.acceptPrompt(input, "user-1");
runtime.acceptCustomMessage("extension inventory changed");
runtime.finishTurn({ role: "custom", content: "extension inventory changed" });
runtime.beginTurn();
runtime.streamAssistantText(output);
runtime.finishTurn();
return await run;
}
async startPromptUntilProviderIdle(
input: string,
output: string,
providerState: { isStreaming: boolean; isCompacting: boolean },
): Promise<{ completion: Promise<unknown> }> {
const session = this.requireSession();
const promptStarted = this.omp.latestSession().nextPrompt();
const run = session.run(input);
await promptStarted;
const runtime = this.omp.latestSession();
runtime.beginTurn();
runtime.acceptPrompt(input, "user-1");
runtime.streamAssistantText(output);
runtime.state = { ...runtime.state, ...providerState };
runtime.finishTurn();
return { completion: run };
}
waitForProviderStateChecks(count: number): Promise<void> {
return this.omp.latestSession().waitForStateRequests(count);
}
reportProviderState(state: { isStreaming: boolean; isCompacting: boolean }): void {
const runtime = this.omp.latestSession();
runtime.state = { ...runtime.state, ...state };
}
failProviderStateChecks(error: Error | null): void {
this.omp.latestSession().getStateError = error;
}
async runPromptAfterFalseLocalOnlyHint(input: string, output: string): Promise<unknown> {
const session = this.requireSession();
const runtime = this.omp.latestSession();
runtime.promptAck = { agentInvoked: false };
const promptStarted = runtime.nextPrompt();
const run = session.run(input);
await promptStarted;
runtime.acceptPrompt(input, "user-1");
await new Promise<void>((resolve) => setImmediate(resolve));
runtime.beginTurn();
runtime.streamAssistantText(output);
runtime.finishTurn();
return await run;
}
timeline(): AgentTimelineItem[] {
return this.events.flatMap((event) => (event.type === "timeline" ? [event.item] : []));
}
async history(): Promise<AgentTimelineItem[]> {
const items: AgentTimelineItem[] = [];
for await (const event of this.requireSession().streamHistory()) {
if (event.type === "timeline") items.push(event.item);
}
return items;
}
completedTurnCount(): number {
return this.events.filter((event) => event.type === "turn_completed").length;
}
requestToolApproval(input: {
id: string;
tool: "bash" | "edit" | "write";
detail: string;
}): void {
this.omp.latestSession().requestToolApproval(input);
}
pendingPermissions() {
return this.requireSession().getPendingPermissions();
}
async respondToPermission(id: string, response: AgentPermissionResponse): Promise<void> {
await this.requireSession().respondToPermission(id, response);
}
extensionUiResponses() {
return this.omp.latestSession().extensionUiResponses;
}
async availableModes() {
return await this.requireSession().getAvailableModes();
}
async commands() {
return await this.requireSession().listCommands();
}
async setMode(modeId: string) {
return await this.requireSession().setMode(modeId);
}
async rewind(messageId: string, restoredPrompt: string): Promise<void> {
this.omp.latestSession().branchResponse = { text: restoredPrompt };
await this.requireSession().revertConversation({ messageId });
}
branchRequests(): string[] {
return this.omp.latestSession().branchRequests;
}
async interruptActiveTurn(message: string): Promise<void> {
await this.requireSession().startTurn(message);
await this.requireSession().interrupt();
}
async requireStartTurn(message: string): Promise<void> {
const promptStarted = this.omp.latestSession().nextPrompt();
await this.requireSession().startTurn(message);
await promptStarted;
}
async interrupt(): Promise<void> {
await this.requireSession().interrupt();
}
wasAborted(): boolean {
return this.omp.latestSession().abortRequested;
}
runtime() {
return this.omp.latestSession();
}
runningToolCallIds(): string[] {
const statusByCall = new Map<string, string>();
for (const item of this.timeline()) {
if (item.type === "tool_call") {
statusByCall.set(item.callId, item.status);
}
}
return [...statusByCall.entries()]
.filter(([, status]) => status === "running")
.map(([callId]) => callId);
}
subagentUpserts(): Array<{ id: string; status: string }> {
return this.events.flatMap((event) =>
event.type === "provider_subagent" && event.event.type === "upsert"
? [{ id: event.event.id, status: event.event.status }]
: [],
);
}
canceledTurnCount(): number {
return this.events.filter((event) => event.type === "turn_canceled").length;
}
async close(): Promise<void> {
await this.requireSession().close();
}
isClosed(): boolean {
return this.omp.latestSession().closed;
}
async waitForSubscriptionFallback(): Promise<string[]> {
const runtime = this.omp.latestSession();
await runtime.waitForSubagentSubscriptions(2);
return runtime.subagentSubscriptionRequests;
}
private requireSession(): OmpAgentSession {
if (!this.session) throw new Error("OMP harness has not started");
return this.session;
}
}
@@ -0,0 +1,106 @@
import { describe, expect, test } from "vitest";
import { parseToolResult } from "./tool-call-detail.js";
import { mapOmpTodoReminderEvent, mapOmpTodoState, mapOmpTodoToolResult } from "./todo-mapper.js";
const TODO_PHASES = [
{
name: "Tasks",
tasks: [
{ content: "alpha task", status: "completed" },
{ content: "beta task", status: "in_progress" },
{ content: "gamma task", status: "pending" },
],
},
] as const;
describe("OMP todo mapper", () => {
test("maps todo tool results and collapses statuses to completed booleans", () => {
expect(
mapOmpTodoToolResult(
parseToolResult({
content: [],
details: {
phases: [
{
name: "Tasks",
tasks: [
{ content: "alpha task", status: "in_progress" },
{ content: "beta task", status: "pending" },
{ content: "gamma task", status: "pending" },
],
},
],
},
}),
),
).toEqual({
type: "todo",
items: [
{ text: "alpha task", completed: false },
{ text: "beta task", completed: false },
{ text: "gamma task", completed: false },
],
});
expect(
mapOmpTodoToolResult(parseToolResult({ content: [], details: { phases: TODO_PHASES } })),
).toEqual({
type: "todo",
items: [
{ text: "alpha task", completed: true },
{ text: "beta task", completed: false },
{ text: "gamma task", completed: false },
],
});
});
test("maps todo reminder events", () => {
expect(
mapOmpTodoReminderEvent({
type: "todo_reminder",
todos: [
{ content: "beta task", status: "in_progress" },
{ content: "gamma task", status: "pending" },
],
}),
).toEqual({
type: "todo",
items: [
{ text: "beta task", completed: false },
{ text: "gamma task", completed: false },
],
});
});
test("hydrates current todos from session state", () => {
expect(
mapOmpTodoState({
model: null,
thinkingLevel: "medium",
isStreaming: false,
isCompacting: false,
sessionId: "session",
messageCount: 0,
queuedMessageCount: 0,
todoPhases: TODO_PHASES,
}),
).toEqual([
{
type: "todo",
items: [
{ text: "alpha task", completed: true },
{ text: "beta task", completed: false },
{ text: "gamma task", completed: false },
],
},
]);
});
test("drops malformed todo inputs", () => {
expect(mapOmpTodoReminderEvent({ type: "todo_reminder", todos: [{ content: 1 }] })).toBeNull();
expect(
mapOmpTodoToolResult({ details: { phases: [{ name: "Bad", tasks: [{}] }] } }),
).toBeNull();
});
});
@@ -0,0 +1,58 @@
import type { AgentTimelineItem } from "../../agent-sdk-types.js";
import type { OmpSessionState } from "./rpc-types.js";
import type { OmpToolResult } from "./tool-call-detail.js";
import {
OmpTodoPhaseSchema,
OmpTodoReminderEventSchema,
type OmpTodoItem,
type OmpTodoPhase,
} from "./rpc-types.js";
export function mapOmpTodoToolResult(result: OmpToolResult): AgentTimelineItem | null {
const details = resultDetails(result);
const phases = OmpTodoPhaseSchema.array().safeParse(details?.phases);
return phases.success ? mapOmpTodoPhases(phases.data) : null;
}
export function mapOmpTodoReminderEvent(event: unknown): AgentTimelineItem | null {
const parsed = OmpTodoReminderEventSchema.safeParse(event);
return parsed.success ? mapOmpTodoItems(parsed.data.todos) : null;
}
export function mapOmpTodoState(state: OmpSessionState): AgentTimelineItem[] {
const phases = OmpTodoPhaseSchema.array().safeParse(state.todoPhases);
if (!phases.success) {
return [];
}
const item = mapOmpTodoPhases(phases.data);
return item ? [item] : [];
}
export function mapOmpTodoPhases(phases: readonly OmpTodoPhase[]): AgentTimelineItem | null {
const todos = phases.flatMap((phase) => phase.tasks);
return mapOmpTodoItems(todos);
}
function mapOmpTodoItems(items: readonly OmpTodoItem[]): AgentTimelineItem | null {
if (items.length === 0) {
return null;
}
return {
type: "todo",
items: items.map((item) => ({
text: item.content,
completed: item.status === "completed",
})),
};
}
function resultDetails(result: OmpToolResult): Record<string, unknown> | null {
if (typeof result === "string" || result === null) {
return null;
}
return isRecord(result.details) ? result.details : null;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
@@ -0,0 +1,473 @@
import { z } from "zod";
import type { ToolCallDetail } from "../../agent-sdk-types.js";
interface BashToolInput {
command: string;
timeout?: number;
}
interface ReadToolInput {
path: string;
offset?: number;
limit?: number;
}
interface EditToolInput {
path: string;
edits: Array<{
oldText: string;
newText: string;
}>;
}
interface WriteToolInput {
path: string;
content: string;
}
interface FindToolInput {
pattern: string;
path?: string;
limit?: number;
}
interface GrepToolInput {
pattern: string;
path?: string;
glob?: string;
ignoreCase?: boolean;
literal?: boolean;
context?: number;
limit?: number;
}
interface LsToolInput {
path?: string;
limit?: number;
}
interface OmpToolResultObject {
output?: string;
stdout?: string;
text?: string;
content?: OmpToolResultContent[];
exitCode?: number;
code?: number;
details?: OmpToolResultDetails;
}
interface OmpToolResultDetails {
diff?: string;
mode?: string;
xdev?: unknown;
}
interface OmpToolResultTextContent {
type: "text";
text: string;
}
interface OmpToolResultUnknownContent {
type: string;
}
type OmpToolResultContent = OmpToolResultTextContent | OmpToolResultUnknownContent;
export type OmpToolResult = string | OmpToolResultObject | null;
interface OmpBashToolCall {
kind: "bash";
toolName: "bash";
args: BashToolInput;
}
interface OmpReadToolCall {
kind: "read";
toolName: "read";
args: ReadToolInput;
}
interface OmpEditToolCall {
kind: "edit";
toolName: "edit";
args: EditToolInput;
}
interface OmpWriteToolCall {
kind: "write";
toolName: "write";
args: WriteToolInput;
}
interface OmpFindToolCall {
kind: "find";
toolName: "find";
args: FindToolInput;
}
interface OmpGrepToolCall {
kind: "grep";
toolName: "grep";
args: GrepToolInput;
}
interface OmpLsToolCall {
kind: "ls";
toolName: "ls";
args: LsToolInput;
}
interface OmpUnknownToolCall {
kind: "unknown";
toolName: string;
args: unknown;
}
export type OmpTrackedToolCall =
| OmpBashToolCall
| OmpReadToolCall
| OmpEditToolCall
| OmpWriteToolCall
| OmpFindToolCall
| OmpGrepToolCall
| OmpLsToolCall
| OmpUnknownToolCall;
interface ToolCallOutputSummary {
output?: string;
exitCode?: number | null;
}
const OmpToolResultTextContentSchema = z.object({
type: z.literal("text"),
text: z.string(),
});
const OmpToolResultUnknownContentSchema = z
.object({
type: z.string(),
})
.passthrough();
const OmpToolResultContentSchema = z.union([
OmpToolResultTextContentSchema,
OmpToolResultUnknownContentSchema,
]);
const OmpToolResultDetailsSchema = z
.object({
diff: z.string().optional(),
})
.passthrough();
const XdevExecuteDetailsSchema = z.object({
tool: z.string().trim().min(1),
mode: z.literal("execute"),
args: z.unknown().optional(),
inner: z.unknown().optional(),
});
const OmpToolResultObjectSchema = z
.object({
output: z.string().optional(),
stdout: z.string().optional(),
text: z.string().optional(),
content: z.array(OmpToolResultContentSchema).optional(),
exitCode: z.number().optional(),
code: z.number().optional(),
details: OmpToolResultDetailsSchema.optional(),
})
.passthrough();
const OmpToolResultSchema = z.union([z.string(), OmpToolResultObjectSchema, z.null()]);
const BashToolInputSchema: z.ZodType<BashToolInput> = z.object({
command: z.string(),
timeout: z.number().optional(),
});
const ReadToolInputSchema: z.ZodType<ReadToolInput> = z.object({
path: z.string(),
offset: z.number().optional(),
limit: z.number().optional(),
});
const EditToolInputSchema: z.ZodType<EditToolInput> = z.object({
path: z.string(),
edits: z.array(
z.object({
oldText: z.string(),
newText: z.string(),
}),
),
});
const LegacyEditToolInputSchema = z.object({
path: z.string(),
old_string: z.string().optional(),
oldString: z.string().optional(),
new_string: z.string().optional(),
newString: z.string().optional(),
});
const WriteToolInputSchema: z.ZodType<WriteToolInput> = z.object({
path: z.string(),
content: z.string(),
});
const FindToolInputSchema: z.ZodType<FindToolInput> = z.object({
pattern: z.string(),
path: z.string().optional(),
limit: z.number().optional(),
});
const GrepToolInputSchema: z.ZodType<GrepToolInput> = z.object({
pattern: z.string(),
path: z.string().optional(),
glob: z.string().optional(),
ignoreCase: z.boolean().optional(),
literal: z.boolean().optional(),
context: z.number().optional(),
limit: z.number().optional(),
});
const LsToolInputSchema: z.ZodType<LsToolInput> = z.object({
path: z.string().optional(),
limit: z.number().optional(),
});
export function parseToolResult(rawResult: unknown): OmpToolResult {
const parsed = OmpToolResultSchema.safeParse(rawResult);
if (parsed.success) {
return parsed.data;
}
return null;
}
export function extractTextFromToolResult(result: OmpToolResult): string | undefined {
if (typeof result === "string") {
return result;
}
if (!result) {
return undefined;
}
const directText = result.output ?? result.stdout ?? result.text;
if (directText) {
return directText;
}
if (!result.content) {
return undefined;
}
const textParts: string[] = [];
for (const block of result.content) {
if (block.type === "text" && "text" in block) {
textParts.push(block.text);
}
}
return textParts.length > 0 ? textParts.join("\n") : undefined;
}
export function parseToolArgs(toolName: string, rawArgs: unknown): OmpTrackedToolCall {
if (toolName === "edit") {
return parseEditToolArgs(rawArgs);
}
const schema = SIMPLE_TOOL_SCHEMAS[toolName as SimpleToolKind];
if (schema) {
const parsed = schema.safeParse(rawArgs);
if (parsed.success) {
return {
kind: toolName as SimpleToolKind,
toolName,
args: parsed.data,
} as OmpTrackedToolCall;
}
}
return { kind: "unknown", toolName, args: rawArgs ?? null };
}
export function resolveToolCallName(toolCall: OmpTrackedToolCall, result?: OmpToolResult): string {
if (toolCall.kind === "write" && result && typeof result !== "string") {
const xdev = XdevExecuteDetailsSchema.safeParse(result.details?.xdev);
if (xdev.success) {
return xdev.data.tool;
}
}
return toolCall.toolName;
}
export function mapToolDetail(
toolCall: OmpTrackedToolCall,
result?: OmpToolResult,
): ToolCallDetail {
const parsedResult = result ?? null;
switch (toolCall.kind) {
case "bash": {
const summary = resolveToolCallOutput(parsedResult);
return {
type: "shell",
command: toolCall.args.command,
output: summary.output,
exitCode: summary.exitCode,
};
}
case "read":
return {
type: "read",
filePath: toolCall.args.path,
content: extractTextFromToolResult(parsedResult),
offset: toolCall.args.offset,
limit: toolCall.args.limit,
};
case "edit": {
const firstEdit = toolCall.args.edits[0];
const unifiedDiff =
parsedResult && typeof parsedResult !== "string" ? parsedResult.details?.diff : undefined;
return {
type: "edit",
filePath: toolCall.args.path,
oldString: firstEdit?.oldText,
newString: firstEdit?.newText,
unifiedDiff,
};
}
case "write":
return mapWriteToolDetail(toolCall.args, parsedResult);
case "find":
return mapFindToolDetail(toolCall.args, parsedResult);
case "grep":
return mapGrepToolDetail(toolCall.args, parsedResult);
case "ls":
return mapLsToolDetail(toolCall.args, parsedResult);
default:
return {
type: "unknown",
input: toolCall.args,
output: parsedResult,
};
}
}
function mapWriteToolDetail(args: WriteToolInput, result: OmpToolResult): ToolCallDetail {
if (result && typeof result !== "string" && result.details && "xdev" in result.details) {
const xdev = XdevExecuteDetailsSchema.safeParse(result.details.xdev);
if (xdev.success) {
return {
type: "unknown",
input: xdev.data.args ?? null,
output: {
...result,
details: xdev.data.inner ?? null,
},
};
}
return {
type: "unknown",
input: args,
output: result,
};
}
return {
type: "write",
filePath: args.path,
content: args.content,
};
}
function resolveToolCallOutput(result: OmpToolResult): ToolCallOutputSummary {
if (typeof result === "string") {
return { output: result };
}
if (!result) {
return {};
}
const summary: ToolCallOutputSummary = {
output: extractTextFromToolResult(result),
};
if (typeof result.exitCode === "number") {
summary.exitCode = result.exitCode;
return summary;
}
if (typeof result.code === "number") {
summary.exitCode = result.code;
return summary;
}
summary.exitCode = null;
return summary;
}
function normalizeLegacyEditArgs(rawArgs: unknown): EditToolInput | null {
const parsed = LegacyEditToolInputSchema.safeParse(rawArgs);
if (!parsed.success) {
return null;
}
const oldText = parsed.data.old_string ?? parsed.data.oldString;
const newText = parsed.data.new_string ?? parsed.data.newString;
if (!oldText || newText === undefined) {
return null;
}
return {
path: parsed.data.path,
edits: [{ oldText, newText }],
};
}
function parseEditToolArgs(rawArgs: unknown): OmpTrackedToolCall {
const parsed = EditToolInputSchema.safeParse(rawArgs);
if (parsed.success) {
return { kind: "edit", toolName: "edit", args: parsed.data };
}
const legacyArgs = normalizeLegacyEditArgs(rawArgs);
if (legacyArgs) {
return { kind: "edit", toolName: "edit", args: legacyArgs };
}
return { kind: "unknown", toolName: "edit", args: rawArgs ?? null };
}
type SimpleToolKind = "bash" | "read" | "write" | "find" | "grep" | "ls";
const SIMPLE_TOOL_SCHEMAS: {
[K in SimpleToolKind]: { safeParse: (data: unknown) => { success: boolean; data?: unknown } };
} = {
bash: BashToolInputSchema,
read: ReadToolInputSchema,
write: WriteToolInputSchema,
find: FindToolInputSchema,
grep: GrepToolInputSchema,
ls: LsToolInputSchema,
};
function mapFindToolDetail(args: FindToolInput, result: OmpToolResult): ToolCallDetail {
return {
type: "search",
query: args.pattern,
toolName: "search",
content: typeof result === "string" ? result : undefined,
};
}
function mapGrepToolDetail(args: GrepToolInput, result: OmpToolResult): ToolCallDetail {
return {
type: "search",
query: args.pattern,
toolName: "grep",
content: typeof result === "string" ? result : undefined,
};
}
function mapLsToolDetail(args: LsToolInput, result: OmpToolResult): ToolCallDetail {
return {
type: "search",
query: args.path ?? "ls",
content: typeof result === "string" ? result : undefined,
};
}
@@ -0,0 +1,33 @@
import { describe, expect, test } from "vitest";
import { parseToolArgs } from "./tool-call-detail.js";
import { resolveOmpEmittedToolCallId } from "./tool-call-id.js";
describe("OMP tool call ids", () => {
test("uses stable synthetic ids only for subagent poll calls", () => {
expect(
resolveOmpEmittedToolCallId(
"poll-1",
parseToolArgs("subagent", { poll: ["job-b", "job-a"] }),
),
).toBe("omp-poll:job-a,job-b");
expect(
resolveOmpEmittedToolCallId(
"poll-2",
parseToolArgs("subagent", { poll: ["job-a", "job-b"] }),
),
).toBe("omp-poll:job-a,job-b");
expect(
resolveOmpEmittedToolCallId("poll-3", parseToolArgs("subagent", { poll: ["job-a"] })),
).toBe("omp-poll:job-a");
expect(
resolveOmpEmittedToolCallId(
"spawn-1",
parseToolArgs("subagent", { spawn: [{ prompt: "go" }] }),
),
).toBe("spawn-1");
expect(
resolveOmpEmittedToolCallId("bash-1", parseToolArgs("bash", { command: "echo hi" })),
).toBe("bash-1");
});
});
@@ -0,0 +1,43 @@
import type { OmpTrackedToolCall } from "./tool-call-detail.js";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function readNonEmptyString(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}
export function readPollTargets(args: unknown): string[] | null {
if (!isRecord(args) || !Array.isArray(args.poll)) {
return null;
}
const targets: string[] = [];
for (const target of args.poll) {
const value = readNonEmptyString(target);
if (!value) {
return null;
}
targets.push(value);
}
if (targets.length === 0) {
return null;
}
return targets.toSorted();
}
export function resolveOmpEmittedToolCallId(
toolCallId: string,
toolCall: OmpTrackedToolCall,
): string {
if (toolCall.toolName !== "subagent") {
return toolCallId;
}
const targets = readPollTargets(toolCall.args);
if (!targets) {
return toolCallId;
}
return `omp-poll:${targets.join(",")}`;
}
@@ -0,0 +1,120 @@
import { describe, expect, test } from "vitest";
import { parseToolArgs, parseToolResult } from "./tool-call-detail.js";
import { mapOmpToolDetail } from "./tool-call-mapper.js";
describe("OMP tool call mapper", () => {
test("maps OMP bash, read, hashline edit, and write calls", () => {
expect(
mapOmpToolDetail(
parseToolArgs("bash", { command: "echo hi" }),
parseToolResult({
content: [{ type: "text", text: "hi\n\n\nWall time: 0.02 seconds" }],
}),
),
).toEqual({
type: "shell",
command: "echo hi",
output: "hi\n\n\nWall time: 0.02 seconds",
exitCode: null,
});
expect(
mapOmpToolDetail(
parseToolArgs("read", { path: "fixture.txt" }),
parseToolResult({
content: [{ type: "text", text: "[fixture.txt#0063]\n1:alpha\n2:beta\n3:" }],
details: { displayContent: { text: "alpha\nbeta\n" } },
}),
),
).toEqual({
type: "read",
filePath: "fixture.txt",
content: "alpha\nbeta\n",
offset: undefined,
limit: undefined,
});
expect(
mapOmpToolDetail(
parseToolArgs("edit", {
input: "*** Begin Patch\n[fixture.txt#0063]\nSWAP 2.=2:\n+gamma\n*** End Patch\n",
}),
parseToolResult({
content: [],
details: {
path: "fixture.txt",
oldText: "alpha\nbeta\n",
newText: "alpha\ngamma\n",
diff: " 1|alpha\n-2|beta\n+2|gamma",
},
}),
),
).toEqual({
type: "edit",
filePath: "fixture.txt",
oldString: "alpha\nbeta\n",
newString: "alpha\ngamma\n",
unifiedDiff: " 1|alpha\n-2|beta\n+2|gamma",
});
expect(
mapOmpToolDetail(
parseToolArgs("write", { path: "created.txt", content: "hello write" }),
null,
),
).toEqual({
type: "write",
filePath: "created.txt",
content: "hello write",
});
});
test("maps task to sub-agent detail and suppresses todo raw cards", () => {
expect(
mapOmpToolDetail(
parseToolArgs("task", {
agent: "explore",
description: "Inspect the target files",
}),
null,
),
).toEqual({
type: "sub_agent",
subAgentType: "explore",
description: "Inspect the target files",
log: "",
});
expect(mapOmpToolDetail(parseToolArgs("todo", { op: "view" }), null)).toBeNull();
});
test("uses task result text and transcript path as the best static replay detail", () => {
expect(
mapOmpToolDetail(
parseToolArgs("task", {
agent: "explore",
description: "Inspect the target files",
}),
parseToolResult({
content: [
{
type: "text",
text: "done\ntranscript: /tmp/omp-task-static/Explore.jsonl",
},
],
}),
),
).toEqual({
type: "sub_agent",
subAgentType: "explore",
description: "Inspect the target files",
childSessionId: "/tmp/omp-task-static/Explore.jsonl",
log: "done\ntranscript: /tmp/omp-task-static/Explore.jsonl",
});
});
test("falls back to shared unknown detail for unmapped tools", () => {
expect(mapOmpToolDetail(parseToolArgs("lsp", { op: "hover" }), null)).toEqual({
type: "unknown",
input: { op: "hover" },
output: null,
});
});
});
@@ -0,0 +1,136 @@
import type { ToolCallDetail } from "../../agent-sdk-types.js";
import {
extractTextFromToolResult,
mapToolDetail as mapOmpCoreToolDetail,
type OmpToolResult,
type OmpTrackedToolCall,
} from "./tool-call-detail.js";
export function mapOmpToolDetail(
toolCall: OmpTrackedToolCall,
result: OmpToolResult,
context?: {
toolCallId: string;
mapSubagentDetail?: (baseDetail: ToolCallDetail) => ToolCallDetail;
},
): ToolCallDetail | null {
if (toolCall.toolName === "todo") {
return null;
}
if (toolCall.toolName === "task") {
const detail = mapOmpTaskDetail(toolCall.args, result);
return context?.mapSubagentDetail?.(detail) ?? detail;
}
if (toolCall.toolName === "edit") {
return mapOmpEditDetail(toolCall, result);
}
if (toolCall.toolName === "read") {
return mapOmpReadDetail(toolCall, result);
}
return mapOmpCoreToolDetail(toolCall, result);
}
function mapOmpTaskDetail(args: unknown, result: OmpToolResult): ToolCallDetail {
const argRecord = isRecord(args) ? args : {};
const resultText = extractTextFromToolResult(result);
const childSessionId = readChildSessionId(result);
return {
type: "sub_agent",
subAgentType: firstString(
argRecord.agent,
argRecord.subAgentType,
argRecord.agentType,
argRecord.type,
),
description: firstString(
argRecord.description,
argRecord.task,
argRecord.prompt,
argRecord.assignment,
),
...(childSessionId ? { childSessionId } : {}),
log: resultText?.trim() ?? "",
};
}
function mapOmpEditDetail(
toolCall: OmpTrackedToolCall,
result: OmpToolResult,
): ToolCallDetail | null {
const fallback = mapOmpCoreToolDetail(toolCall, result);
const details = resultDetails(result);
const filePath =
firstString(details?.path, details?.filePath) ?? readPatchInputPath(toolCall.args);
if (!filePath) {
return fallback;
}
return {
type: "edit",
filePath,
oldString: firstString(details?.oldText, details?.old_string),
newString: firstString(details?.newText, details?.new_string),
unifiedDiff: firstString(details?.diff),
};
}
function mapOmpReadDetail(
toolCall: OmpTrackedToolCall,
result: OmpToolResult,
): ToolCallDetail | null {
const fallback = mapOmpCoreToolDetail(toolCall, result);
if (!fallback || fallback.type !== "read") {
return fallback;
}
const details = resultDetails(result);
const displayContent = isRecord(details?.displayContent) ? details.displayContent : null;
const displayText = firstString(displayContent?.text);
if (!displayText) {
return fallback;
}
return {
...fallback,
content: displayText,
};
}
function resultDetails(result: OmpToolResult): Record<string, unknown> | null {
if (typeof result === "string" || result === null) {
return null;
}
return isRecord(result.details) ? result.details : null;
}
function readChildSessionId(result: OmpToolResult): string | undefined {
const details = resultDetails(result);
const direct = firstString(details?.sessionFile, details?.session_file, details?.childSessionId);
if (direct) {
return direct;
}
const text = extractTextFromToolResult(result);
return text?.match(/(?:session|transcript)(?: file)?:\s*(?<path>\/\S+\.jsonl)/i)?.groups?.path;
}
function readPatchInputPath(args: unknown): string | undefined {
if (!isRecord(args)) {
return undefined;
}
const input = args.input;
if (typeof input !== "string") {
return undefined;
}
const match = /^\[(?<path>.+?)#[^\]\n]+]/m.exec(input);
return match?.groups?.path;
}
function firstString(...values: unknown[]): string | undefined {
for (const value of values) {
if (typeof value === "string" && value.trim()) {
return value;
}
}
return undefined;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
@@ -0,0 +1,39 @@
import { describe, expect, test } from "vitest";
import { mapOmpUsage } from "./usage-mapper.js";
describe("OMP usage mapper", () => {
test("adds OMP context usage to token and cost totals", () => {
const usage = mapOmpUsage({
stats: {
tokens: { input: 28237, output: 548, cacheRead: 269824, cacheWrite: 0, total: 298609 },
cost: 0.29253700000000005,
},
state: {
model: null,
thinkingLevel: "medium",
isStreaming: false,
isCompacting: false,
sessionId: "session",
messageCount: 0,
queuedMessageCount: 0,
contextUsage: { tokens: 23656, contextWindow: 272000, percent: 8.7 },
},
baseUsage: {
inputTokens: 28237,
cachedInputTokens: 269824,
outputTokens: 548,
totalCostUsd: 0.29253700000000005,
},
});
expect(usage).toEqual({
inputTokens: 28237,
cachedInputTokens: 269824,
outputTokens: 548,
totalCostUsd: 0.29253700000000005,
contextWindowMaxTokens: 272000,
contextWindowUsedTokens: 23656,
});
});
});
@@ -0,0 +1,24 @@
import type { AgentUsage } from "../../agent-sdk-types.js";
import type { OmpSessionState, OmpSessionStats } from "./rpc-types.js";
export function mapOmpUsage(input: {
stats: OmpSessionStats;
state: OmpSessionState;
baseUsage: AgentUsage | undefined;
}): AgentUsage | undefined {
const contextWindowUsedTokens = finiteNumber(input.state.contextUsage?.tokens);
const contextWindowMaxTokens = finiteNumber(input.state.contextUsage?.contextWindow);
if (contextWindowUsedTokens === undefined && contextWindowMaxTokens === undefined) {
return input.baseUsage;
}
return {
...input.baseUsage,
...(contextWindowMaxTokens !== undefined ? { contextWindowMaxTokens } : {}),
...(contextWindowUsedTokens !== undefined ? { contextWindowUsedTokens } : {}),
};
}
function finiteNumber(value: number | null | undefined): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
@@ -0,0 +1,45 @@
import { describe, expect, test } from "vitest";
import { parseToolArgs, parseToolResult } from "./tool-call-detail.js";
import { OmpAvailableCommandsUpdateEventSchema } from "./rpc-types.js";
import { mapOmpToolDetail } from "./tool-call-mapper.js";
describe("OMP 17 RPC compatibility", () => {
test("parses source-attributed command updates", () => {
const event = OmpAvailableCommandsUpdateEventSchema.parse({
type: "available_commands_update",
commands: [{ name: "prewalk", description: "Prewalk at the next action", source: "builtin" }],
});
expect(event.commands).toEqual([
{ name: "prewalk", description: "Prewalk at the next action", source: "builtin" },
]);
});
test("maps subscribed custom tool events without assuming built-in names", () => {
const event = {
type: "tool_execution_start",
toolCallId: "hub-call",
toolName: "hub",
args: { op: "list" },
};
expect(mapOmpToolDetail(parseToolArgs(event.toolName, event.args), null)).toEqual({
type: "unknown",
input: { op: "list" },
output: null,
});
});
test("parses arbitrary custom tool results", () => {
expect(
parseToolResult({
content: [{ type: "text", text: "No peers registered" }],
details: { op: "list", peers: [] },
}),
).toEqual({
content: [{ type: "text", text: "No peers registered" }],
details: { op: "list", peers: [] },
});
});
});
@@ -896,6 +896,7 @@ describe("PiRpcAgentSession", () => {
error: "Pi exited",
});
});
test("completes locally handled slash commands when agentInvoked is false", async () => {
const { pi, session, events } = await createSession();
const fakeSession = pi.latestSession();
File diff suppressed because it is too large Load Diff
@@ -5,7 +5,6 @@ import pino from "pino";
import { describe, expect, test } from "vitest";
import { PiCliRuntime } from "./cli-runtime.js";
import type { PiCommandsRpcType } from "./rpc-types.js";
import type { PiRuntimeLaunch } from "./runtime.js";
type PiChild = ChildProcessWithoutNullStreams & {
@@ -35,12 +34,12 @@ function createPiChild(): PiChild {
function createRuntime(
child: PiChild,
launches: PiRuntimeLaunch[] = [],
options?: { commandsRpcType?: PiCommandsRpcType },
options?: { commandsRpcName?: string },
): PiCliRuntime {
return new PiCliRuntime({
logger: pino({ level: "silent" }),
command: ["pi"],
commandsRpcType: options?.commandsRpcType,
commandsRpcName: options?.commandsRpcName,
spawnProcess: (launch) => {
launches.push(launch);
return child;
@@ -150,7 +149,7 @@ describe("PiCliRuntime", () => {
runtimeSettings: {
command: {
mode: "replace",
argv: ["omp"],
argv: ["custom-pi"],
},
},
spawnProcess: (launch) => {
@@ -159,13 +158,42 @@ describe("PiCliRuntime", () => {
},
});
await runtime.startSession({ cwd: "/workspace/project", session: "/tmp/omp-session.jsonl" });
await runtime.startSession({ cwd: "/workspace/project", session: "/tmp/pi-session.jsonl" });
expect(launches).toEqual([
expect.objectContaining({
cwd: "/workspace/project",
session: "/tmp/omp-session.jsonl",
argv: ["omp", "--mode", "rpc", "--session", "/tmp/omp-session.jsonl"],
session: "/tmp/pi-session.jsonl",
argv: ["custom-pi", "--mode", "rpc", "--session", "/tmp/pi-session.jsonl"],
}),
]);
});
test("does not append rpc mode when the configured command already includes a mode flag", async () => {
const child = createPiChild();
replyToCommands(child, () => ({}));
const launches: PiRuntimeLaunch[] = [];
const runtime = new PiCliRuntime({
logger: pino({ level: "silent" }),
command: ["pi"],
runtimeSettings: {
command: {
mode: "replace",
argv: ["custom-pi", "--mode", "json"],
},
},
spawnProcess: (launch) => {
launches.push(launch);
return child;
},
});
await runtime.startSession({ cwd: "/workspace/project" });
expect(launches).toEqual([
expect.objectContaining({
cwd: "/workspace/project",
argv: ["custom-pi", "--mode", "json"],
}),
]);
});
@@ -220,25 +248,6 @@ describe("PiCliRuntime", () => {
expect(commandTypes).toEqual(["get_commands"]);
});
test("lists commands through the OMP-compatible get_available_commands RPC", async () => {
const child = createPiChild();
const commandTypes: string[] = [];
replyToCommands(child, (command) => {
commandTypes.push(String(command.type));
return {
commands: [{ name: "skill:ctx-stats", description: "Show context stats", source: "skill" }],
};
});
const session = await createRuntime(child, [], {
commandsRpcType: "get_available_commands",
}).startSession({ cwd: "/workspace/project" });
await expect(session.getCommands()).resolves.toEqual([
{ name: "skill:ctx-stats", description: "Show context stats", source: "skill" },
]);
expect(commandTypes).toEqual(["get_available_commands"]);
});
test("keeps unicode line separators inside one JSONL record", async () => {
const child = createPiChild();
replyToCommands(child, () => ({}));
@@ -262,6 +271,17 @@ describe("PiCliRuntime", () => {
await expect(state).rejects.toThrow("boom");
});
test("rejects pending commands when the Pi session closes", async () => {
const child = createPiChild();
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
const state = session.getState();
const rejection = expect(state).rejects.toThrow("Pi RPC session is closed");
await session.close();
await rejection;
});
test("disposes the Pi process", async () => {
const child = createPiChild();
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
@@ -1,9 +1,8 @@
import { type ChildProcess, type ChildProcessWithoutNullStreams } from "node:child_process";
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import type { Logger } from "pino";
import { spawnProcess } from "../../../../utils/spawn.js";
import { terminateWithTreeKill } from "../../../../utils/tree-kill.js";
import type { ProviderRuntimeSettings } from "../../provider-launch-config.js";
import { JsonlRpcProcess, type JsonlRpcLaunch } from "../jsonl-rpc-process.js";
import {
buildPiLaunch,
type PiRuntime,
@@ -13,11 +12,9 @@ import {
} from "./runtime.js";
import type {
PiAgentMessage,
PiCommandsRpcType,
PiModel,
PiPromptAck,
PiRpcCommand,
PiRpcResponse,
PiRpcSlashCommand,
PiRuntimeEvent,
PiSessionState,
@@ -27,54 +24,25 @@ import type {
const DEFAULT_PI_COMMAND: [string, ...string[]] = [
process.env.PI_COMMAND ?? process.env.PI_ACP_PI_COMMAND ?? "pi",
];
const DEFAULT_TIMEOUT_MS = 30_000;
const DEFAULT_COMMANDS_RPC_TYPE: PiCommandsRpcType = "get_commands";
const STDERR_BUFFER_LIMIT = 8192;
const GRACEFUL_SHUTDOWN_TIMEOUT_MS = 2_000;
const FORCE_SHUTDOWN_TIMEOUT_MS = 1_000;
function assertChildWithPipes(
child: ChildProcess,
): asserts child is ChildProcessWithoutNullStreams {
if (!child.stdin || !child.stdout || !child.stderr) {
throw new Error("Pi process was spawned without stdio streams");
}
}
interface PendingRequest {
resolve: (value: unknown) => void;
reject: (error: Error) => void;
timer: NodeJS.Timeout;
}
const DEFAULT_COMMANDS_RPC_NAME = "get_commands";
export interface PiCliRuntimeOptions {
logger: Logger;
runtimeSettings?: ProviderRuntimeSettings;
command?: [string, ...string[]];
commandsRpcType?: PiCommandsRpcType;
commandsRpcName?: string;
spawnProcess?: (launch: PiRuntimeLaunch) => ChildProcessWithoutNullStreams;
}
export class PiCliRuntime implements PiRuntime {
private readonly command: [string, ...string[]];
private readonly commandsRpcType: PiCommandsRpcType;
private readonly spawnProcess: (launch: PiRuntimeLaunch) => ChildProcessWithoutNullStreams;
private readonly commandsRpcName: string;
private readonly spawnProcess?: (launch: PiRuntimeLaunch) => ChildProcessWithoutNullStreams;
constructor(private readonly options: PiCliRuntimeOptions) {
this.command = options.command ?? DEFAULT_PI_COMMAND;
this.commandsRpcType = options.commandsRpcType ?? DEFAULT_COMMANDS_RPC_TYPE;
this.spawnProcess =
options.spawnProcess ??
((launch) => {
const [command, ...args] = launch.argv;
const child = spawnProcess(command, args, {
cwd: launch.cwd,
envOverlay: launch.env,
stdio: ["pipe", "pipe", "pipe"],
});
assertChildWithPipes(child);
return child;
});
this.commandsRpcName = options.commandsRpcName ?? DEFAULT_COMMANDS_RPC_NAME;
this.spawnProcess = options.spawnProcess;
}
async startSession(input: PiStartSessionInput): Promise<PiRuntimeSession> {
@@ -83,47 +51,37 @@ export class PiCliRuntime implements PiRuntime {
runtimeSettings: this.options.runtimeSettings,
session: input,
});
return new PiCliRuntimeSession(
launch,
this.spawnProcess(launch),
this.options.logger,
this.commandsRpcType,
);
const [command, ...args] = launch.argv;
const processLaunch: JsonlRpcLaunch = {
command,
args,
cwd: launch.cwd,
env: launch.env,
};
const spawn = this.spawnProcess;
const processOptions = {
launch: processLaunch,
logger: this.options.logger,
diagnosticName: "Pi RPC",
...(spawn ? { spawn: () => spawn(launch) } : {}),
};
const process = new JsonlRpcProcess(processOptions);
return new PiCliRuntimeSession(process, this.commandsRpcName);
}
}
class PiCliRuntimeSession implements PiRuntimeSession {
private readonly pending = new Map<string, PendingRequest>();
private readonly subscribers = new Set<(event: PiRuntimeEvent) => void>();
private stderrBuffer = "";
private nextRequestId = 1;
private disposed = false;
private stdoutBuffer = "";
constructor(
_launch: PiRuntimeLaunch,
private readonly child: ChildProcessWithoutNullStreams,
private readonly logger: Logger,
private readonly commandsRpcType: PiCommandsRpcType,
private readonly process: JsonlRpcProcess,
private readonly commandsRpcName: string,
) {
child.stdout.on("data", (chunk) => {
this.handleStdoutChunk(chunk.toString());
process.onMessage((message) => {
this.emit(message as PiRuntimeEvent);
});
child.stderr.on("data", (chunk) => {
this.stderrBuffer += chunk.toString();
if (this.stderrBuffer.length > STDERR_BUFFER_LIMIT) {
this.stderrBuffer = this.stderrBuffer.slice(-STDERR_BUFFER_LIMIT);
}
});
child.on("error", (error) => {
this.failAll(error instanceof Error ? error : new Error(String(error)));
});
child.on("exit", (code, signal) => {
const error = new Error(
`Pi RPC process exited with code ${code ?? "null"} and signal ${signal ?? "null"}\n${this.stderrBuffer}`.trim(),
);
process.onExit(({ error }) => {
this.emit({ type: "process_exit", error: error.message });
this.failAll(error);
});
}
@@ -138,18 +96,19 @@ class PiCliRuntimeSession implements PiRuntimeSession {
message: string,
images?: Array<{ type: "image"; data: string; mimeType: string }>,
): Promise<PiPromptAck> {
const data = await this.request({
const { id: requestId, promise } = this.process.startRequest({
type: "prompt",
message,
...(images?.length ? { images } : {}),
});
const data = await promise;
if (typeof data === "object" && data !== null && !Array.isArray(data)) {
const { agentInvoked } = data as Record<string, unknown>;
if (typeof agentInvoked === "boolean") {
return { agentInvoked };
return { requestId, agentInvoked };
}
}
return {};
return { requestId };
}
async compact(customInstructions?: string): Promise<void> {
@@ -224,17 +183,21 @@ class PiCliRuntimeSession implements PiRuntimeSession {
}
async getCommands(): Promise<PiRpcSlashCommand[]> {
const data = (await this.request({ type: this.commandsRpcType })) as {
const data = (await this.request({ type: this.commandsRpcName })) as {
commands?: PiRpcSlashCommand[];
};
return data.commands ?? [];
}
sendRawFrame(frame: object & { type: string }): void {
this.process.send(frame);
}
respondToExtensionUiRequest(
id: string,
response: { value?: string; confirmed?: boolean; cancelled?: boolean },
): void {
this.writeJsonLine({ type: "extension_ui_response", id, ...response });
this.process.send({ type: "extension_ui_response", id, ...response });
}
cancelExtensionUiRequest(id: string): void {
@@ -242,106 +205,11 @@ class PiCliRuntimeSession implements PiRuntimeSession {
}
async close(): Promise<void> {
if (this.disposed) return;
this.disposed = true;
try {
this.child.stdin.end();
} catch {
// ignore
}
const result = await terminateWithTreeKill(this.child, {
gracefulTimeoutMs: GRACEFUL_SHUTDOWN_TIMEOUT_MS,
forceTimeoutMs: FORCE_SHUTDOWN_TIMEOUT_MS,
onForceSignal: () => {
this.logger.warn(
{ timeoutMs: GRACEFUL_SHUTDOWN_TIMEOUT_MS },
"Pi RPC process did not exit after SIGTERM; sending SIGKILL",
);
},
});
if (result === "kill-timeout") {
this.logger.warn(
{ timeoutMs: FORCE_SHUTDOWN_TIMEOUT_MS },
"Pi RPC process did not report exit after SIGKILL",
);
}
await this.process.close(new Error("Pi RPC session is closed"));
}
private request(command: PiRpcCommand, timeoutMs = DEFAULT_TIMEOUT_MS): Promise<unknown> {
if (this.disposed) {
return Promise.reject(new Error("Pi RPC session is closed"));
}
const id = `req_${this.nextRequestId}`;
this.nextRequestId += 1;
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(id);
reject(
new Error(`Pi RPC request timed out for ${command.type}\n${this.stderrBuffer}`.trim()),
);
}, timeoutMs);
this.pending.set(id, { resolve, reject, timer });
this.writeJsonLine({ ...command, id });
});
}
private writeJsonLine(value: unknown): void {
if (this.disposed || this.child.stdin.destroyed || !this.child.stdin.writable) {
return;
}
this.child.stdin.write(`${JSON.stringify(value)}\n`);
}
private handleStdoutChunk(chunk: string): void {
this.stdoutBuffer += chunk;
for (;;) {
const newlineIndex = this.stdoutBuffer.indexOf("\n");
if (newlineIndex === -1) {
break;
}
const line = this.stdoutBuffer.slice(0, newlineIndex).replace(/\r$/, "");
this.stdoutBuffer = this.stdoutBuffer.slice(newlineIndex + 1);
if (line.trim()) {
this.handleLine(line);
}
}
}
private handleLine(line: string): void {
let parsed: unknown;
try {
parsed = JSON.parse(line);
} catch (error) {
this.logger.warn({ error, line }, "Ignoring non-JSON Pi RPC stdout line");
return;
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return;
}
const message = parsed as Record<string, unknown>;
if (message.type === "response") {
this.handleResponse(message as unknown as PiRpcResponse);
return;
}
this.emit(message as PiRuntimeEvent);
}
private handleResponse(response: PiRpcResponse): void {
const id = response.id;
if (!id) {
return;
}
const pending = this.pending.get(id);
if (!pending) {
return;
}
clearTimeout(pending.timer);
this.pending.delete(id);
if (!response.success) {
pending.reject(new Error(response.error ?? `Pi RPC ${response.command} failed`));
return;
}
pending.resolve(response.data);
request(command: PiRpcCommand, timeoutMs?: number): Promise<unknown> {
return this.process.request(command, timeoutMs);
}
private emit(event: PiRuntimeEvent): void {
@@ -349,16 +217,4 @@ class PiCliRuntimeSession implements PiRuntimeSession {
subscriber(event);
}
}
private failAll(error: Error): void {
if (this.disposed) {
return;
}
this.disposed = true;
for (const pending of this.pending.values()) {
clearTimeout(pending.timer);
pending.reject(error);
}
this.pending.clear();
}
}
@@ -1,8 +1,8 @@
import { describe, expect, test } from "vitest";
import type { AgentStreamEvent } from "../../agent-sdk-types.js";
import type { PiAgentMessage } from "./rpc-types.js";
import { streamPiHistory, type PiCapturedUserMessageEntry } from "./history-mapper.js";
import type { PiAgentMessage } from "./rpc-types.js";
async function collectHistory(
messages: PiAgentMessage[],
@@ -128,6 +128,18 @@ describe("Pi history mapper", () => {
]);
});
test("replays non-notice custom messages as assistant text, matching the live path", async () => {
await expect(
collectHistory([{ role: "custom", content: "Extension command output" }]),
).resolves.toEqual([
{
type: "timeline",
provider: "pi",
item: { type: "assistant_message", text: "Extension command output" },
},
]);
});
test("uses Pi tree entry ids for replayed user messages", async () => {
await expect(
collectHistory(
@@ -5,6 +5,8 @@ import {
mapToolDetail,
parseToolArgs,
parseToolResult,
resolveToolCallName,
type PiToolResult,
type PiTrackedToolCall,
} from "./tool-call-mapper.js";
@@ -13,6 +15,19 @@ export interface PiCapturedUserMessageEntry {
text: string;
}
export interface PiHistoryMapperHooks {
mapCustomMessage?: (
text: string,
provider: string,
) => Extract<AgentStreamEvent, { type: "timeline" }> | null;
resolveToolCallId?: (toolCallId: string, toolCall: PiTrackedToolCall) => string;
mapToolDetail?: (
toolCall: PiTrackedToolCall,
result: PiToolResult,
context: { toolCallId: string },
) => ToolCallDetail | null;
}
function isTextContentBlock(block: unknown): block is PiTextContent {
return (
typeof block === "object" &&
@@ -37,116 +52,205 @@ export function getUserMessageText(content: string | (PiTextContent | PiImageCon
return textParts.join("\n\n");
}
export class PiHistoryMapper {
private readonly pendingToolCalls = new Map<string, PiTrackedToolCall>();
private userIndex = 0;
private assistantIndex = 0;
constructor(
private readonly provider: string,
private readonly userEntries: readonly PiCapturedUserMessageEntry[] = [],
private readonly hooks: PiHistoryMapperHooks = {},
) {}
mapMessages(messages: readonly PiAgentMessage[]): AgentStreamEvent[] {
const events: AgentStreamEvent[] = [];
for (const message of messages) {
switch (message.role) {
case "user":
events.push(...this.mapUserMessage(message));
break;
case "custom":
events.push(...this.mapCustomMessage(message));
break;
case "assistant":
events.push(...this.mapAssistantMessage(message));
break;
case "toolResult": {
const event = this.mapToolResultMessage(message);
if (event) {
events.push(event);
}
break;
}
case "bashExecution":
events.push(this.mapBashExecutionMessage(message));
break;
}
}
return events;
}
private mapUserMessage(message: Extract<PiAgentMessage, { role: "user" }>): AgentStreamEvent[] {
const text = getUserMessageText(message.content);
this.userIndex += 1;
if (!text) {
return [];
}
const userEntry = this.userEntries[this.userIndex - 1];
return [
{
type: "timeline",
provider: this.provider,
item: {
type: "user_message",
text,
...(userEntry ? { messageId: userEntry.id } : {}),
},
},
];
}
private mapCustomMessage(
message: Extract<PiAgentMessage, { role: "custom" }>,
): AgentStreamEvent[] {
const text = getUserMessageText(message.content);
const mappedEvent = text ? this.hooks.mapCustomMessage?.(text, this.provider) : null;
if (mappedEvent) {
return [mappedEvent];
}
return text
? [
{
type: "timeline",
provider: this.provider,
item: { type: "assistant_message", text },
},
]
: [];
}
private mapAssistantMessage(
message: Extract<PiAgentMessage, { role: "assistant" }>,
): AgentStreamEvent[] {
const events: AgentStreamEvent[] = [];
this.assistantIndex += 1;
const messageId =
message.responseId || `${this.provider}-history-assistant-${this.assistantIndex}`;
for (const content of message.content) {
if (content.type === "text" && content.text) {
events.push({
type: "timeline",
provider: this.provider,
item: { type: "assistant_message", text: content.text, messageId },
});
continue;
}
if (content.type === "thinking" && content.thinking) {
events.push({
type: "timeline",
provider: this.provider,
item: { type: "reasoning", text: content.thinking },
});
continue;
}
if (content.type === "toolCall") {
const tracked = parseToolArgs(content.name, content.arguments);
this.pendingToolCalls.set(content.id, tracked);
const detail = this.mapToolDetail(content.id, tracked, null);
if (!detail) {
continue;
}
events.push({
type: "timeline",
provider: this.provider,
item: {
type: "tool_call",
callId: this.resolveToolCallId(content.id, tracked),
name: tracked.toolName,
status: "running",
detail,
error: null,
},
});
}
}
return events;
}
private mapToolResultMessage(
message: Extract<PiAgentMessage, { role: "toolResult" }>,
): AgentStreamEvent | null {
const tracked =
this.pendingToolCalls.get(message.toolCallId) ?? parseToolArgs(message.toolName, null);
this.pendingToolCalls.delete(message.toolCallId);
const result = parseToolResult({ content: message.content, details: message.details });
const detail = this.mapToolDetail(message.toolCallId, tracked, result);
if (!detail) {
return null;
}
return {
type: "timeline",
provider: this.provider,
item: toToolResultTimelineItem({
callId: this.resolveToolCallId(message.toolCallId, tracked),
name: resolveToolCallName(tracked, result),
isError: Boolean(message.isError),
detail,
errorText: extractTextFromToolResult(result) ?? "Tool call failed",
}),
};
}
private mapBashExecutionMessage(
message: Extract<PiAgentMessage, { role: "bashExecution" }>,
): AgentStreamEvent {
const detail: ToolCallDetail = {
type: "shell",
command: message.command,
output: message.output,
exitCode: message.exitCode ?? null,
};
return {
type: "timeline",
provider: this.provider,
item: {
type: "tool_call",
callId: `pi-bash-${message.timestamp}`,
name: "bash",
status: message.cancelled ? "canceled" : "completed",
detail,
error: null,
},
};
}
private resolveToolCallId(toolCallId: string, toolCall: PiTrackedToolCall): string {
return this.hooks.resolveToolCallId?.(toolCallId, toolCall) ?? toolCallId;
}
private mapToolDetail(
toolCallId: string,
toolCall: PiTrackedToolCall,
result: PiToolResult,
): ToolCallDetail | null {
const hook = this.hooks.mapToolDetail;
return hook ? hook(toolCall, result, { toolCallId }) : mapToolDetail(toolCall, result);
}
}
export async function* streamPiHistory(
provider: string,
messages: PiAgentMessage[],
userEntries: readonly PiCapturedUserMessageEntry[] = [],
hooks: PiHistoryMapperHooks = {},
): AsyncGenerator<AgentStreamEvent> {
const pendingToolCalls = new Map<string, PiTrackedToolCall>();
let userIndex = 0;
let assistantIndex = 0;
for (const message of messages) {
if (message.role === "user") {
const text = getUserMessageText(message.content);
if (text) {
const userEntry = userEntries[userIndex];
yield {
type: "timeline",
provider,
item: {
type: "user_message",
text,
...(userEntry ? { messageId: userEntry.id } : {}),
},
};
}
userIndex += 1;
continue;
}
if (message.role === "assistant") {
assistantIndex += 1;
const messageId = message.responseId || `${provider}-history-assistant-${assistantIndex}`;
for (const content of message.content) {
if (content.type === "text" && content.text) {
yield {
type: "timeline",
provider,
item: { type: "assistant_message", text: content.text, messageId },
};
continue;
}
if (content.type === "thinking" && content.thinking) {
yield {
type: "timeline",
provider,
item: { type: "reasoning", text: content.thinking },
};
continue;
}
if (content.type === "toolCall") {
const tracked = parseToolArgs(content.name, content.arguments);
pendingToolCalls.set(content.id, tracked);
yield {
type: "timeline",
provider,
item: {
type: "tool_call",
callId: content.id,
name: tracked.toolName,
status: "running",
detail: mapToolDetail(tracked, null),
error: null,
},
};
}
}
continue;
}
if (message.role === "toolResult") {
const tracked =
pendingToolCalls.get(message.toolCallId) ?? parseToolArgs(message.toolName, null);
pendingToolCalls.delete(message.toolCallId);
const result = parseToolResult({ content: message.content });
const detail = mapToolDetail(tracked, result);
yield {
type: "timeline",
provider,
item: toToolResultTimelineItem({
callId: message.toolCallId,
name: tracked.toolName,
isError: Boolean(message.isError),
detail,
errorText: extractTextFromToolResult(result) ?? "Tool call failed",
}),
};
continue;
}
if (message.role === "bashExecution") {
const callId = `pi-bash-${message.timestamp}`;
const detail: ToolCallDetail = {
type: "shell",
command: message.command,
output: message.output,
exitCode: message.exitCode ?? null,
};
yield {
type: "timeline",
provider,
item: {
type: "tool_call",
callId,
name: "bash",
status: message.cancelled ? "canceled" : "completed",
detail,
error: null,
},
};
const mapper = new PiHistoryMapper(provider, userEntries, hooks);
for (const event of mapper.mapMessages(messages)) {
if (event) {
yield event;
}
}
}
@@ -9,6 +9,11 @@ export interface PiPromptAck {
agentInvoked?: boolean;
}
export interface PiPromptAck {
requestId?: string;
agentInvoked?: boolean;
}
export interface PiTextContent {
type: "text";
text: string;
@@ -53,6 +58,7 @@ export type PiAgentMessage =
toolName: string;
content: unknown;
isError?: boolean;
details?: unknown;
}
| {
role: "bashExecution";
@@ -88,6 +94,12 @@ export interface PiSessionState {
sessionName?: string;
messageCount: number;
pendingMessageCount: number;
contextUsage?: {
tokens?: number | null;
contextWindow?: number | null;
percent?: number | null;
};
todoPhases?: unknown;
}
export interface PiSessionStats {
@@ -111,10 +123,9 @@ export interface PiRpcSlashCommand {
description?: string;
source: "extension" | "prompt" | "skill";
sourceInfo?: Record<string, unknown>;
input?: { hint?: string };
}
export type PiCommandsRpcType = "get_commands" | "get_available_commands";
export type PiRpcCommand =
| { id?: string; type: "prompt"; message: string; images?: PiImageContent[] }
| { id?: string; type: "compact"; customInstructions?: string }
@@ -126,7 +137,7 @@ export type PiRpcCommand =
| { id?: string; type: "set_model"; provider: string; modelId: string }
| { id?: string; type: "set_thinking_level"; level: PiThinkingLevel }
| { id?: string; type: "get_session_stats" }
| { id?: string; type: PiCommandsRpcType };
| { id?: string; type: string };
export interface PiRpcResponse {
id?: string;
@@ -191,4 +202,8 @@ export type PiRuntimeEvent =
| {
type: "process_exit";
error: string;
}
| {
type: string;
[key: string]: unknown;
};
@@ -13,25 +13,31 @@ export interface PiRuntimeLaunch {
cwd: string;
argv: string[];
env?: Record<string, string>;
protocolMode?: "rpc" | "rpc-ui";
model?: string;
thinkingOptionId?: string;
modeId?: string;
session?: string;
noSession?: boolean;
systemPrompt?: string;
mcpConfigPath?: string;
extensionPaths?: string[];
extraArgs?: string[];
}
export interface PiStartSessionInput {
cwd: string;
env?: Record<string, string>;
protocolMode?: "rpc" | "rpc-ui";
model?: string;
thinkingOptionId?: string;
modeId?: string;
session?: string;
noSession?: boolean;
systemPrompt?: string;
mcpConfigPath?: string;
extensionPaths?: string[];
extraArgs?: string[];
}
export interface PiRuntimeSession {
@@ -50,6 +56,8 @@ export interface PiRuntimeSession {
setThinkingLevel(level: string): Promise<void>;
getSessionStats(): Promise<PiSessionStats>;
getCommands(): Promise<PiRpcSlashCommand[]>;
request(command: { type: string; [key: string]: unknown }, timeoutMs?: number): Promise<unknown>;
sendRawFrame(frame: object & { type: string }): void;
respondToExtensionUiRequest(
id: string,
response: { value?: string; confirmed?: boolean; cancelled?: boolean },
@@ -73,30 +81,9 @@ export function buildPiLaunch(input: {
: input.command;
const argv = [...command];
if (!hasModeRpc(argv)) {
argv.push("--mode", "rpc");
}
if (input.session.model) {
argv.push("--model", input.session.model);
}
if (input.session.thinkingOptionId) {
argv.push("--thinking", input.session.thinkingOptionId);
}
if (input.session.noSession) {
argv.push("--no-session");
} else if (input.session.session) {
argv.push("--session", input.session.session);
}
const protocolMode = input.session.protocolMode ?? "rpc";
const systemPrompt = input.session.systemPrompt?.trim();
if (systemPrompt) {
argv.push("--append-system-prompt", systemPrompt);
}
if (input.session.mcpConfigPath) {
argv.push("--mcp-config", input.session.mcpConfigPath);
}
for (const extensionPath of input.session.extensionPaths ?? []) {
argv.push("--extension", extensionPath);
}
appendPiLaunchArgs(argv, input.session, protocolMode, systemPrompt);
return {
cwd: input.session.cwd,
@@ -110,20 +97,57 @@ export function buildPiLaunch(input: {
: undefined,
model: input.session.model,
thinkingOptionId: input.session.thinkingOptionId,
protocolMode,
modeId: input.session.modeId,
session: input.session.session,
noSession: input.session.noSession,
systemPrompt,
mcpConfigPath: input.session.mcpConfigPath,
extensionPaths: input.session.extensionPaths,
extraArgs: input.session.extraArgs,
};
}
function hasModeRpc(argv: string[]): boolean {
function appendPiLaunchArgs(
argv: string[],
session: PiStartSessionInput,
protocolMode: "rpc" | "rpc-ui",
systemPrompt: string | undefined,
): void {
if (!hasModeFlag(argv)) {
argv.push("--mode", protocolMode);
}
if (session.extraArgs?.length) {
argv.push(...session.extraArgs);
}
if (session.model) {
argv.push("--model", session.model);
}
if (session.thinkingOptionId) {
argv.push("--thinking", session.thinkingOptionId);
}
if (session.noSession) {
argv.push("--no-session");
} else if (session.session) {
argv.push("--session", session.session);
}
if (systemPrompt) {
argv.push("--append-system-prompt", systemPrompt);
}
if (session.mcpConfigPath) {
argv.push("--mcp-config", session.mcpConfigPath);
}
for (const extensionPath of session.extensionPaths ?? []) {
argv.push("--extension", extensionPath);
}
}
function hasModeFlag(argv: string[]): boolean {
for (let i = 0; i < argv.length; i += 1) {
if (argv[i] === "--mode" && argv[i + 1] === "rpc") {
if (argv[i] === "--mode") {
return true;
}
if (argv[i] === "--mode=rpc") {
if (argv[i]?.startsWith("--mode=")) {
return true;
}
}
@@ -1,3 +1,4 @@
import type { Dirent } from "node:fs";
import { open, readdir, readFile, stat } from "node:fs/promises";
import { homedir } from "node:os";
import path from "node:path";
@@ -12,9 +13,14 @@ import { createRealpathAwarePathMatcher } from "../../../../utils/path.js";
const PI_CONFIG_DIR_NAME = ".pi";
const PI_AGENT_DIR_ENV = "PI_CODING_AGENT_DIR";
const PI_SESSION_DIR_ENV = "PI_CODING_AGENT_SESSION_DIR";
// Import listing intentionally bounds header parsing to this window. Sessions
// with unusually large preambles may omit their first-prompt preview.
const HEAD_BYTES = 64 * 1024;
const TAIL_BYTES = 256 * 1024;
const FULL_SCAN_LINE_LIMIT = 2_000;
// Rank all discovered files cheaply, then parse only a bounded recent window.
const IMPORT_CANDIDATE_OVERSCAN = 40;
const IMPORT_CANDIDATE_MIN = 400;
interface PiSessionDescriptorOptions extends ListImportableSessionsOptions {
sessionDir?: string;
@@ -53,6 +59,10 @@ interface PiSessionDescriptor {
model: string | null;
thinkingOptionId: string | null;
}
interface RankedSessionFile {
file: string;
mtime: Date;
}
export interface PiImportSessionConfig {
model?: string;
@@ -66,18 +76,23 @@ export async function listPiImportableSessions(
const files = await walkJsonlFiles(sessionsDir);
const matchesCwd = options.cwd ? createRealpathAwarePathMatcher(options.cwd) : null;
const limit = options.limit ?? 20;
const ranked = await rankSessionFilesByMtime(files);
const candidateLimit = Math.max(limit * IMPORT_CANDIDATE_OVERSCAN, IMPORT_CANDIDATE_MIN);
const sessions: ImportableProviderSession[] = [];
for (const file of files) {
const session = await readPiImportableSession(file);
for (const entry of ranked.slice(0, candidateLimit)) {
const session = await readPiImportableSession(entry.file);
if (!session) continue;
if (matchesCwd && !matchesCwd(session.cwd)) continue;
sessions.push(session);
if (sessions.length >= limit) {
break;
}
}
return sessions
.sort((left, right) => right.lastActivityAt.getTime() - left.lastActivityAt.getTime())
.slice(0, limit);
return sessions.sort(
(left, right) => right.lastActivityAt.getTime() - left.lastActivityAt.getTime(),
);
}
export async function readPiImportSessionConfig(filePath: string): Promise<PiImportSessionConfig> {
@@ -163,7 +178,7 @@ function resolveConfigPath(value: string, options: { baseDir: string; homeDir: s
}
async function walkJsonlFiles(root: string): Promise<string[]> {
let entries: import("node:fs").Dirent[];
let entries: Dirent[];
try {
entries = await readdir(root, { withFileTypes: true });
} catch {
@@ -182,6 +197,18 @@ async function walkJsonlFiles(root: string): Promise<string[]> {
return files.flat();
}
async function rankSessionFilesByMtime(files: string[]): Promise<RankedSessionFile[]> {
const ranked = await Promise.all(
files.map(async (file) => {
const mtime = await readFileMtime(file);
return mtime ? { file, mtime } : null;
}),
);
return ranked
.filter((entry): entry is RankedSessionFile => entry !== null)
.sort((left, right) => right.mtime.getTime() - left.mtime.getTime());
}
async function readPiImportableSession(
filePath: string,
): Promise<ImportableProviderSession | null> {
@@ -201,14 +228,14 @@ async function readPiImportableSession(
}
async function readPiSessionDescriptor(filePath: string): Promise<PiSessionDescriptor | null> {
const firstLine = await readFirstLine(filePath);
if (!firstLine) return null;
const header = parseSessionHeader(firstLine);
const headChunk = await readHeadChunk(filePath);
if (!headChunk) return null;
const header = parseSessionHeader(headChunk.split(/\r?\n/u, 1)[0]?.trim() ?? "");
if (!header) return null;
const tail = await readTail(filePath).catch(() => "");
const tailInfo = parseSessionTail(tail);
const headInfo = await scanSessionHead(filePath);
const headInfo = parseSessionHeadFromChunk(headChunk);
const title = tailInfo.title ?? headInfo.title ?? headInfo.firstUserMessage;
const model = tailInfo.model ?? headInfo.model;
const thinkingOptionId = tailInfo.thinkingOptionId ?? headInfo.thinkingOptionId;
@@ -233,21 +260,54 @@ function toPiImportSessionConfig(descriptor: PiSessionDescriptor): PiImportSessi
};
}
async function readFirstLine(filePath: string): Promise<string | null> {
async function readHeadChunk(filePath: string): Promise<string | null> {
const handle = await open(filePath, "r").catch(() => null);
if (!handle) return null;
try {
const buffer = Buffer.alloc(HEAD_BYTES);
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
if (bytesRead <= 0) return null;
const chunk = buffer.subarray(0, bytesRead).toString("utf8");
const newlineIndex = chunk.indexOf("\n");
return (newlineIndex === -1 ? chunk : chunk.slice(0, newlineIndex)).trim();
return buffer.subarray(0, bytesRead).toString("utf8");
} finally {
await handle.close().catch(() => undefined);
}
}
function parseSessionHeadFromChunk(chunk: string): PiSessionHead {
let title: string | null = null;
let firstUserMessage: string | null = null;
let model: string | null = null;
let thinkingOptionId: string | null = null;
let lineCount = 0;
for (const rawLine of chunk.split(/\r?\n/u)) {
lineCount += 1;
const entry = parseJsonRecord(rawLine.trim());
if (!entry) continue;
if (entry.type === "session_info") {
title = readNonEmptyString(entry.name) ?? title;
}
model = extractModel(entry) ?? model;
thinkingOptionId = extractThinkingOptionId(entry) ?? thinkingOptionId;
if (!firstUserMessage && entry.type === "message" && isRecord(entry.message)) {
if (entry.message.role === "user") {
firstUserMessage = extractMessageText(entry.message.content);
}
}
if (title && firstUserMessage && model && thinkingOptionId) {
break;
}
if (lineCount >= FULL_SCAN_LINE_LIMIT && firstUserMessage) {
break;
}
}
return { title, firstUserMessage, model, thinkingOptionId };
}
async function readTail(filePath: string): Promise<string> {
const fileStats = await stat(filePath);
const start = Math.max(0, fileStats.size - TAIL_BYTES);
@@ -296,7 +356,6 @@ function parseSessionTail(tail: string): PiSessionTail {
if (!title && entry.type === "session_info") {
title = readNonEmptyString(entry.name);
}
if (!model) {
model = extractModel(entry);
}
@@ -330,49 +389,6 @@ function parseSessionTail(tail: string): PiSessionTail {
};
}
async function scanSessionHead(filePath: string): Promise<PiSessionHead> {
let content: string;
try {
content = await readFile(filePath, "utf8");
} catch {
return { title: null, firstUserMessage: null, model: null, thinkingOptionId: null };
}
let title: string | null = null;
let firstUserMessage: string | null = null;
let model: string | null = null;
let thinkingOptionId: string | null = null;
let lineCount = 0;
for (const rawLine of content.split(/\r?\n/u)) {
lineCount += 1;
const entry = parseJsonRecord(rawLine.trim());
if (!entry) continue;
if (entry.type === "session_info") {
title = readNonEmptyString(entry.name) ?? title;
}
model = extractModel(entry) ?? model;
thinkingOptionId = extractThinkingOptionId(entry) ?? thinkingOptionId;
if (!firstUserMessage && entry.type === "message" && isRecord(entry.message)) {
if (entry.message.role === "user") {
firstUserMessage = extractMessageText(entry.message.content);
}
}
if (title && firstUserMessage && model && thinkingOptionId) {
break;
}
if (lineCount >= FULL_SCAN_LINE_LIMIT && firstUserMessage) {
break;
}
}
return { title, firstUserMessage, model, thinkingOptionId };
}
function extractModel(entry: Record<string, unknown>): string | null {
if (entry.type === "model_change") {
return buildModelId(entry.provider, entry.modelId);
@@ -15,11 +15,42 @@ import type {
} from "../rpc-types.js";
import { buildPiLaunch } from "../runtime.js";
type FakePiSubagentSubscriptionLevel = "off" | "progress" | "events";
type FakePiSubagentStatus = "pending" | "running" | "completed" | "failed" | "aborted";
export interface FakePiSubagentSnapshot {
id: string;
index: number;
agent: string;
description?: string;
status: FakePiSubagentStatus;
task?: string;
assignment?: string;
sessionFile?: string;
parentToolCallId?: string;
lastUpdate?: number;
}
export interface FakePiSubagentMessagesSelector {
subagentId?: string;
sessionFile?: string;
fromByte?: number;
}
export interface FakePiSubagentMessagesResult {
sessionFile: string;
fromByte: number;
nextByte: number;
reset: boolean;
messages: PiAgentMessage[];
}
export class FakePi implements PiRuntime {
readonly recordedLaunches: PiRuntimeLaunch[] = [];
private readonly sessions: FakePiSession[] = [];
private readonly command: [string, ...string[]];
private readonly queuedCommands: PiRpcSlashCommand[][] = [];
private readonly queuedSessionSetups: Array<(session: FakePiSession) => void> = [];
constructor(command: [string, ...string[]] = ["pi"]) {
this.command = command;
@@ -33,6 +64,7 @@ export class FakePi implements PiRuntime {
this.recordedLaunches.push(launch);
const session = new FakePiSession(launch);
session.commands = this.queuedCommands.shift() ?? [];
this.queuedSessionSetups.shift()?.(session);
this.sessions.push(session);
return session;
}
@@ -41,6 +73,10 @@ export class FakePi implements PiRuntime {
this.queuedCommands.push(commands);
}
queueSessionSetup(setup: (session: FakePiSession) => void): void {
this.queuedSessionSetups.push(setup);
}
latestSession(): FakePiSession {
const session = this.sessions.at(-1);
if (!session) {
@@ -54,9 +90,14 @@ export class FakePiSession implements PiRuntimeSession {
readonly prompts: Array<{ message: string; imageCount: number }> = [];
readonly compactRequests: Array<{ customInstructions?: string }> = [];
readonly setAutoCompactionRequests: boolean[] = [];
readonly subagentSubscriptionRequests: FakePiSubagentSubscriptionLevel[] = [];
readonly subagentMessageRequests: FakePiSubagentMessagesSelector[] = [];
readonly setModelRequests: Array<{ provider: string; modelId: string }> = [];
readonly setThinkingLevelRequests: string[] = [];
readonly treeNavigationRequests: string[] = [];
readonly handoffRequests: Array<{ customInstructions?: string }> = [];
readonly sessionNameRequests: string[] = [];
readonly rawFrames: Array<object & { type: string }> = [];
capturedUserEntries: Array<{ id: string; parentId: string | null; text: string }> = [];
abortRequested = false;
readonly canceledExtensionUiRequests: string[] = [];
@@ -72,13 +113,19 @@ export class FakePiSession implements PiRuntimeSession {
cost: 0,
};
commands: PiRpcSlashCommand[] = [];
subagents: FakePiSubagentSnapshot[] = [];
subagentSubscriptionError: Error | null = null;
setSessionNameError: Error | null = null;
compactError: Error | null = null;
emitCompactEnd = true;
getStateError: Error | null = null;
promptAck: PiPromptAck = {};
branchResponse: { text?: string; cancelled?: boolean } = { text: "" };
readonly branchRequests: string[] = [];
state: PiSessionState;
private readonly subscribers = new Set<(event: PiRuntimeEvent) => void>();
private readonly subagentMessageResults = new Map<string, FakePiSubagentMessagesResult[]>();
private nextHeldPrompt: { promise: Promise<void>; reject: (error: Error) => void } | null = null;
private activeHeldPrompt: { promise: Promise<void>; reject: (error: Error) => void } | null =
null;
@@ -200,10 +247,83 @@ export class FakePiSession implements PiRuntimeSession {
return this.stats;
}
async setSubagentSubscription(level: FakePiSubagentSubscriptionLevel): Promise<void> {
this.subagentSubscriptionRequests.push(level);
if (this.subagentSubscriptionError) {
throw this.subagentSubscriptionError;
}
}
async getSubagents(): Promise<FakePiSubagentSnapshot[]> {
return this.subagents;
}
async getSubagentMessages(
selector: FakePiSubagentMessagesSelector,
): Promise<FakePiSubagentMessagesResult> {
this.subagentMessageRequests.push(selector);
const key = selector.sessionFile ?? selector.subagentId;
if (!key) {
throw new Error("FakePi getSubagentMessages requires a selector");
}
const results = this.subagentMessageResults.get(key);
const result = results?.shift();
if (!result) {
throw new Error(`FakePi has no subagent messages queued for ${key}`);
}
return result;
}
queueSubagentMessages(result: FakePiSubagentMessagesResult): void {
const results = this.subagentMessageResults.get(result.sessionFile) ?? [];
results.push(result);
this.subagentMessageResults.set(result.sessionFile, results);
}
async getCommands(): Promise<PiRpcSlashCommand[]> {
return this.commands;
}
sendRawFrame(frame: object & { type: string }): void {
this.rawFrames.push(frame);
}
async request(command: { type: string; [key: string]: unknown }): Promise<unknown> {
switch (command.type) {
case "set_subagent_subscription":
await this.setSubagentSubscription(command.level as FakePiSubagentSubscriptionLevel);
return {};
case "get_subagents":
return { subagents: await this.getSubagents() };
case "get_subagent_messages":
return await this.getSubagentMessages({
...(typeof command.subagentId === "string" ? { subagentId: command.subagentId } : {}),
...(typeof command.sessionFile === "string" ? { sessionFile: command.sessionFile } : {}),
...(typeof command.fromByte === "number" ? { fromByte: command.fromByte } : {}),
});
case "branch": {
const entryId = typeof command.entryId === "string" ? command.entryId : "";
this.branchRequests.push(entryId);
return this.branchResponse;
}
case "handoff":
this.handoffRequests.push(
typeof command.customInstructions === "string"
? { customInstructions: command.customInstructions }
: {},
);
return {};
case "set_session_name":
this.sessionNameRequests.push(typeof command.name === "string" ? command.name : "");
if (this.setSessionNameError) {
throw this.setSessionNameError;
}
return {};
default:
throw new Error(`FakePi request does not implement ${command.type}`);
}
}
respondToExtensionUiRequest(
id: string,
response: { value?: string; confirmed?: boolean; cancelled?: boolean },
@@ -37,6 +37,94 @@ describe("Pi tool call mapper", () => {
});
});
test("preserves ordinary writes as write details", () => {
const toolCall = parseToolArgs("write", {
path: "notes.txt",
content: "unchanged\n",
});
expect(mapToolDetail(toolCall, parseToolResult({ text: "Wrote notes.txt" }))).toEqual({
type: "write",
filePath: "notes.txt",
content: "unchanged\n",
});
});
test("maps executed xdev writes to their wrapped tool detail", () => {
const toolCall = parseToolArgs("write", {
path: "xd://browser",
content: "{}",
});
const result = parseToolResult({
content: [{ type: "text", text: "Opened Example Domain" }],
details: {
xdev: {
tool: "browser",
mode: "execute",
args: { action: "open", url: "https://example.com" },
inner: { title: "Example Domain" },
},
},
});
expect(mapToolDetail(toolCall, result)).toEqual({
type: "unknown",
input: { action: "open", url: "https://example.com" },
output: {
content: [{ type: "text", text: "Opened Example Domain" }],
details: { title: "Example Domain" },
},
});
expect(resolveToolCallName(toolCall, result)).toBe("browser");
});
test("does not treat xdev help metadata as an executed inner tool", () => {
const toolCall = parseToolArgs("write", {
path: "xd://browser",
content: "",
});
const result = parseToolResult({
details: {
xdev: {
tool: "browser",
mode: "help",
inner: "Browser help",
},
},
});
expect(mapToolDetail(toolCall, result)).toEqual({
type: "unknown",
input: { path: "xd://browser", content: "" },
output: result,
});
expect(resolveToolCallName(toolCall, result)).toBe("write");
});
test("does not treat malformed xdev metadata as an executed inner tool", () => {
const toolCall = parseToolArgs("write", {
path: "xd://browser",
content: "{}",
});
const result = parseToolResult({
details: {
xdev: {
tool: "",
mode: "execute",
args: { action: "open" },
inner: { title: "must not surface" },
},
},
});
expect(mapToolDetail(toolCall, result)).toEqual({
type: "unknown",
input: { path: "xd://browser", content: "{}" },
output: result,
});
expect(resolveToolCallName(toolCall, result)).toBe("write");
});
test("preserves unknown tool input and parsed output", () => {
const toolCall = parseToolArgs("custom_tool", { value: 42 });
const result = parseToolResult({ text: "custom result" });
@@ -63,6 +63,7 @@ interface PiToolResultDetails {
server?: string;
tool?: string;
mcpResult?: unknown;
xdev?: unknown;
}
interface PiToolResultTextContent {
@@ -162,6 +163,13 @@ const PiToolResultDetailsSchema = z
})
.passthrough();
const XdevExecuteDetailsSchema = z.object({
tool: z.string().trim().min(1),
mode: z.literal("execute"),
args: z.unknown().optional(),
inner: z.unknown().optional(),
});
const PiToolResultObjectSchema = z
.object({
output: z.string().optional(),
@@ -293,6 +301,13 @@ function stripMcpProxyPrefix(toolName: string, serverName: string): string {
}
export function resolveToolCallName(toolCall: PiTrackedToolCall, result?: PiToolResult): string {
if (toolCall.kind === "write" && result && typeof result !== "string") {
const xdev = XdevExecuteDetailsSchema.safeParse(result.details?.xdev);
if (xdev.success) {
return xdev.data.tool;
}
}
if (toolCall.toolName !== "mcp") {
return toolCall.toolName;
}
@@ -357,11 +372,7 @@ export function mapToolDetail(toolCall: PiTrackedToolCall, result?: PiToolResult
};
}
case "write":
return {
type: "write",
filePath: toolCall.args.path,
content: toolCall.args.content,
};
return mapWriteToolDetail(toolCall.args, parsedResult);
case "find":
return mapFindToolDetail(toolCall.args, parsedResult);
case "grep":
@@ -377,6 +388,34 @@ export function mapToolDetail(toolCall: PiTrackedToolCall, result?: PiToolResult
}
}
function mapWriteToolDetail(args: WriteToolInput, result: PiToolResult): ToolCallDetail {
if (result && typeof result !== "string" && result.details && "xdev" in result.details) {
const xdev = XdevExecuteDetailsSchema.safeParse(result.details.xdev);
if (xdev.success) {
return {
type: "unknown",
input: xdev.data.args ?? null,
output: {
...result,
details: xdev.data.inner ?? null,
},
};
}
return {
type: "unknown",
input: args,
output: result,
};
}
return {
type: "write",
filePath: args.path,
content: args.content,
};
}
function resolveToolCallOutput(result: PiToolResult): ToolCallOutputSummary {
if (typeof result === "string") {
return { output: result };
@@ -0,0 +1,78 @@
// These SDK internals are reachable through @modelcontextprotocol/sdk's wildcard export,
// not a curated public subpath. Keep that package pinned exactly and re-verify these
// paths on every SDK bump so native host-tool schemas stay byte-compatible.
import {
normalizeObjectSchema,
type AnySchema,
type ZodRawShapeCompat,
} from "@modelcontextprotocol/sdk/server/zod-compat.js";
import { toJsonSchemaCompat } from "@modelcontextprotocol/sdk/server/zod-json-schema-compat.js";
import type { PaseoToolDefinition, PaseoToolResult } from "./types.js";
const EMPTY_OBJECT_JSON_SCHEMA: Record<string, unknown> = {
type: "object",
properties: {},
};
function formatStructuredContentForModel(structuredContent: unknown): string {
if (
!structuredContent ||
typeof structuredContent !== "object" ||
Array.isArray(structuredContent)
) {
return JSON.stringify(structuredContent, null, 2);
}
const record = structuredContent as Record<string, unknown>;
const summary: string[] = [];
for (const [key, value] of Object.entries(record)) {
if (!Array.isArray(value)) {
continue;
}
summary.push(`${key}_count=${value.length}`);
const ids = value
.map((item) =>
item && typeof item === "object" && !Array.isArray(item)
? (item as Record<string, unknown>).id
: null,
)
.filter((id): id is string => typeof id === "string" && id.length > 0);
if (ids.length === value.length && ids.length > 0) {
summary.push(`${key}_ids=${ids.join(",")}`);
}
}
const json = JSON.stringify(structuredContent, null, 2);
return summary.length > 0 ? `${summary.join("\n")}\n\n${json}` : json;
}
export function addModelVisibleStructuredContent(result: PaseoToolResult): PaseoToolResult {
if (result.structuredContent === undefined || result.content.length > 0) {
return result;
}
return {
...result,
content: [
{
type: "text",
text: formatStructuredContentForModel(result.structuredContent),
},
],
};
}
export function serializePaseoToolInputParameters(
tool: PaseoToolDefinition,
): Record<string, unknown> {
const schema = normalizeObjectSchema(
tool.inputSchema as AnySchema | ZodRawShapeCompat | undefined,
);
return schema
? toJsonSchemaCompat(schema, {
strictUnions: true,
pipeStrategy: "input",
})
: { ...EMPTY_OBJECT_JSON_SCHEMA };
}
@@ -2,6 +2,7 @@ import type { z } from "zod";
export interface PaseoToolExecutionContext {
signal?: AbortSignal;
sendUpdate?: (update: PaseoToolResult) => void;
}
export interface PaseoToolResult {
@@ -57,6 +57,14 @@ export const agentConfigs = {
provider: "pi",
thinkingOptionId: "medium",
},
omp: {
provider: "omp",
thinkingOptionId: "medium",
modes: {
full: "full", // launches omp with yolo approval mode
ask: "ask", // launches omp with always-ask approval mode
},
},
} as const satisfies Record<string, AgentTestConfig>;
export type AgentProvider = keyof typeof agentConfigs;
@@ -92,4 +100,4 @@ export function getAskModeConfig(provider: AgentProvider) {
/**
* Helper to run a test for each provider.
*/
export const allProviders: AgentProvider[] = ["claude", "codex", "opencode", "pi"];
export const allProviders: AgentProvider[] = ["claude", "codex", "opencode", "pi", "omp"];
@@ -0,0 +1,492 @@
import { execFile } from "node:child_process";
import { randomUUID } from "node:crypto";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import pino from "pino";
import { afterEach, beforeAll, describe, expect, test } from "vitest";
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
import {
MIN_SUPPORTED_OMP_VERSION,
formatOmpVersionSupport,
} from "../agent/providers/omp/agent.js";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { createRealProviderClients, getRealProviderConfig } from "./real-provider-test-config.js";
const execFileAsync = promisify(execFile);
const TIMEOUT_MS = 300_000;
const MODEL = getRealProviderConfig("omp").model;
const SHELL_TOOL_PATTERN = /(?:bash|shell|%!)/i;
const roots = new Set<string>();
interface Harness {
daemon: TestPaseoDaemon;
client: DaemonClient;
cwd: string;
paseoHomeRoot: string;
staticDir: string;
}
async function preflight(): Promise<void> {
const command = process.env.OMP_COMMAND?.trim() || "omp";
let stdout: string;
try {
({ stdout } = await execFileAsync(command, ["--version"], {
timeout: 10_000,
}));
} catch (error) {
throw new Error(`OMP real E2E gate requires an executable OMP binary (${command})`, {
cause: error,
});
}
const support = formatOmpVersionSupport(stdout);
if (!support.includes("supported")) {
throw new Error(`OMP real E2E gate requires OMP >= ${MIN_SUPPORTED_OMP_VERSION}: ${support}`);
}
}
async function createHarness(): Promise<Harness> {
const cwd = mkdtempSync(path.join(tmpdir(), "paseo-real-omp-"));
const paseoHomeRoot = mkdtempSync(path.join(tmpdir(), "paseo-real-omp-home-"));
const staticDir = mkdtempSync(path.join(tmpdir(), "paseo-real-omp-static-"));
for (const root of [cwd, paseoHomeRoot, staticDir]) roots.add(root);
const logger = pino({ level: process.env.OMP_E2E_LOG_LEVEL ?? "silent" });
const daemon = await createTestPaseoDaemon({
agentClients: createRealProviderClients(["omp"], logger),
providerOverrides: { omp: { enabled: true } },
logger,
paseoHomeRoot,
staticDir,
cleanup: false,
});
const client = new DaemonClient({
url: `ws://127.0.0.1:${daemon.port}/ws`,
appVersion: "0.1.45",
});
await client.connect();
await client.fetchAgents({
subscribe: { subscriptionId: `omp-real-${randomUUID()}` },
});
return { daemon, client, cwd, paseoHomeRoot, staticDir };
}
async function closeHarness(harness: Harness): Promise<void> {
await harness.client.close().catch(() => undefined);
await harness.daemon.close().catch(() => undefined);
for (const root of [harness.cwd, harness.paseoHomeRoot, harness.staticDir]) {
rmSync(root, { recursive: true, force: true });
roots.delete(root);
}
}
async function timeline(client: DaemonClient, agentId: string): Promise<AgentTimelineItem[]> {
const result = await client.fetchAgentTimeline(agentId, {
direction: "tail",
limit: 0,
projection: "canonical",
});
return result.entries.map((entry) => entry.item);
}
function latestTools(items: AgentTimelineItem[]) {
const latest = new Map<string, Extract<AgentTimelineItem, { type: "tool_call" }>>();
for (const item of items) if (item.type === "tool_call") latest.set(item.callId, item);
return [...latest.values()];
}
function toolResult(item: Extract<AgentTimelineItem, { type: "tool_call" }>): string {
if (item.detail.type === "shell") return item.detail.output ?? "";
if (item.detail.type === "fetch") return item.detail.result ?? "";
if (item.detail.type === "plain_text") return item.detail.text ?? "";
return "";
}
function completedTools(items: AgentTimelineItem[], pattern: RegExp) {
return latestTools(items).filter(
(item) => pattern.test(item.name) && item.status === "completed",
);
}
async function createAgent(harness: Harness, title: string, modeId = "full") {
return await harness.client.createAgent({
cwd: harness.cwd,
title,
provider: "omp",
model: MODEL,
modeId,
});
}
async function promptAndFinish(harness: Harness, agentId: string, prompt: string) {
await harness.client.sendMessage(agentId, prompt);
const finish = await harness.client.waitForFinish(agentId, TIMEOUT_MS);
expect(finish.status).toBe("idle");
const items = await timeline(harness.client, agentId);
expect(items.some((item) => item.type === "error")).toBe(false);
expect(latestTools(items).every((item) => item.status !== "running")).toBe(true);
return items;
}
async function waitForProviderSubagent(
client: DaemonClient,
parentAgentId: string,
status: "running" | "completed",
): Promise<Awaited<ReturnType<DaemonClient["listProviderSubagents"]>>["subagents"][number]> {
return await new Promise((resolve, reject) => {
let settled = false;
const finish = (
subagent: Awaited<ReturnType<DaemonClient["listProviderSubagents"]>>["subagents"][number],
): void => {
if (settled) return;
settled = true;
clearTimeout(timeout);
unsubscribe();
resolve(subagent);
};
const unsubscribe = client.on("agent.provider_subagents.update", (message) => {
if (
message.payload.kind === "upsert" &&
message.payload.subagent.parentAgentId === parentAgentId &&
message.payload.subagent.status === status
) {
finish(message.payload.subagent);
}
});
const timeout = setTimeout(() => {
if (settled) return;
settled = true;
unsubscribe();
reject(new Error(`Timed out waiting for OMP provider subagent status ${status}`));
}, TIMEOUT_MS);
void client
.listProviderSubagents(parentAgentId)
.then(({ subagents }) => {
const match = subagents.find((subagent) => subagent.status === status);
if (match) finish(match);
return undefined;
})
.catch((error: unknown) => {
if (settled) return undefined;
settled = true;
clearTimeout(timeout);
unsubscribe();
reject(error);
return undefined;
});
});
}
beforeAll(preflight, 15_000);
afterEach(() => {
for (const root of roots) rmSync(root, { recursive: true, force: true });
roots.clear();
});
describe("daemon E2E (real OMP)", () => {
test(
"prompt, native tool, and resumed follow-up",
async () => {
const harness = await createHarness();
try {
const agent = await createAgent(harness, "prompt-tool-resume");
await promptAndFinish(
harness,
agent.id,
"Run exactly this bash command: printf 'OMP_FIRST\\n'",
);
const items = await promptAndFinish(harness, agent.id, "Reply exactly OMP_RESUMED");
const outputs = completedTools(items, SHELL_TOOL_PATTERN).map(toolResult);
expect(outputs).toEqual(expect.arrayContaining([expect.stringContaining("OMP_FIRST")]));
const assistantText = items
.filter((item) => item.type === "assistant_message")
.map((item) => item.text);
expect(assistantText).toEqual(
expect.arrayContaining([expect.stringContaining("OMP_RESUMED")]),
);
} finally {
await closeHarness(harness);
}
},
TIMEOUT_MS,
);
test(
"ask mode preserves multiline approval details",
async () => {
const harness = await createHarness();
try {
const agent = await createAgent(harness, "multiline-approval", "ask");
await harness.client.sendMessage(
agent.id,
"Run exactly this bash command: printf 'line one\\nline two\\n'",
);
const pending = await harness.client.waitForAgentUpsert(
agent.id,
(snapshot) => snapshot.pendingPermissions.length > 0,
TIMEOUT_MS,
);
const permission = pending.pendingPermissions[0];
if (permission.detail?.type !== "shell") {
throw new Error("Expected OMP shell permission detail");
}
expect(permission.detail.command).toContain("line one");
expect(permission.detail.command).toContain("line two");
await harness.client.respondToPermission(agent.id, permission.id, {
behavior: "allow",
});
expect((await harness.client.waitForFinish(agent.id, TIMEOUT_MS)).status).toBe("idle");
const output = completedTools(
await timeline(harness.client, agent.id),
SHELL_TOOL_PATTERN,
).map(toolResult);
expect(output.join("\n")).toContain("line one");
expect(output.join("\n")).toContain("line two");
} finally {
await closeHarness(harness);
}
},
TIMEOUT_MS,
);
test(
"active steer and interrupt",
async () => {
const harness = await createHarness();
try {
const agent = await createAgent(harness, "interrupt-steer");
await harness.client.sendMessage(
agent.id,
"Run exactly this bash command: sleep 30; printf 'SHOULD_NOT_FINISH\\n'",
);
await harness.client.waitForAgentUpsert(
agent.id,
(snapshot) => snapshot.status === "running",
TIMEOUT_MS,
);
await harness.client.sendMessage(
agent.id,
"/steer stop sleeping and run exactly this bash command: printf 'OMP_ACTIVE_STEER\\n'",
);
expect((await harness.client.waitForFinish(agent.id, TIMEOUT_MS)).status).toBe("idle");
let items = await timeline(harness.client, agent.id);
expect(completedTools(items, SHELL_TOOL_PATTERN).map(toolResult).join("\n")).toContain(
"OMP_ACTIVE_STEER",
);
await harness.client.sendMessage(
agent.id,
"Run exactly this bash command: sleep 30; printf 'LATE\\n'",
);
await harness.client.waitForAgentUpsert(
agent.id,
(snapshot) => snapshot.status === "running",
TIMEOUT_MS,
);
await harness.client.cancelAgent(agent.id);
expect((await harness.client.waitForFinish(agent.id, TIMEOUT_MS)).status).not.toBe(
"running",
);
items = await timeline(harness.client, agent.id);
expect(
completedTools(items, SHELL_TOOL_PATTERN).some((call) =>
toolResult(call).includes("LATE"),
),
).toBe(false);
} finally {
await closeHarness(harness);
}
},
TIMEOUT_MS,
);
test(
"todo lifecycle",
async () => {
const harness = await createHarness();
try {
const agent = await createAgent(harness, "todo-lifecycle");
const items = await promptAndFinish(
harness,
agent.id,
"Create exactly these two todos in order: verify first child, verify second child. Complete both, without spawning a task.",
);
const snapshots = items.filter((item) => item.type === "todo").map((item) => item.items);
const hasFreshPair = (snapshot: { completed: boolean }[]): boolean =>
snapshot.length === 2 && snapshot.every((entry) => !entry.completed);
expect(snapshots.some(hasFreshPair)).toBe(true);
expect(snapshots.at(-1)).toEqual([
{ text: "verify first child", completed: true },
{ text: "verify second child", completed: true },
]);
} finally {
await closeHarness(harness);
}
},
TIMEOUT_MS,
);
test(
"subagent lifecycle streams descriptors and timeline on the parent",
async () => {
const harness = await createHarness();
try {
const parent = await createAgent(harness, "subagent-track");
const run = harness.client.sendMessage(
parent.id,
"Use task to create exactly one child named TrackChild. It must run exactly this bash command: printf 'TRACK_CHILD_OK\\n'. Wait for it.",
);
const started = await waitForProviderSubagent(harness.client, parent.id, "running");
expect(started.parentAgentId).toBe(parent.id);
await run;
expect((await harness.client.waitForFinish(parent.id, TIMEOUT_MS)).status).toBe("idle");
const { subagents } = await harness.client.listProviderSubagents(parent.id);
expect(subagents).toHaveLength(1);
expect(subagents[0].status).toBe("completed");
const childTimeline = await harness.client.fetchProviderSubagentTimeline(
parent.id,
subagents[0].id,
{
direction: "tail",
limit: 0,
},
);
expect(childTimeline.rows.length).toBeGreaterThan(0);
const childToolOutput = childTimeline.rows
.map((row) => row.item)
.filter((item) => item.type === "tool_call")
.map(toolResult)
.join("\n");
expect(childToolOutput).toContain("TRACK_CHILD_OK");
} finally {
await closeHarness(harness);
}
},
TIMEOUT_MS,
);
test(
"a completed child imports as a normal promptable OMP agent",
async () => {
const harness = await createHarness();
try {
const parent = await createAgent(harness, "child-import");
await promptAndFinish(
harness,
parent.id,
"Use task to create exactly one child named ImportChild. It must run exactly this bash command: printf 'IMPORT_CHILD_DONE\\n'. Wait for it.",
);
const completedChild = await waitForProviderSubagent(
harness.client,
parent.id,
"completed",
);
expect(completedChild.parentAgentId).toBe(parent.id);
const recent = await harness.client.fetchRecentProviderSessions({
cwd: harness.cwd,
providers: ["omp"],
});
const childSession = recent.entries.find(
(entry) =>
entry.providerId === "omp" &&
(path.basename(entry.providerHandleId) === `${completedChild.id}.jsonl` ||
entry.title?.includes("ImportChild") ||
entry.firstPromptPreview?.includes("ImportChild") ||
entry.lastPromptPreview?.includes("IMPORT_CHILD_DONE")),
);
if (!childSession) {
throw new Error("Expected completed OMP child session to be importable");
}
const imported = await harness.client.importAgent({
provider: "omp",
sessionId: childSession.providerHandleId,
cwd: harness.cwd,
});
expect(imported.id).not.toBe(parent.id);
const items = await promptAndFinish(
harness,
imported.id,
"Run exactly this bash command: printf 'IMPORTED_CHILD_RESUMED\\n'",
);
expect(completedTools(items, SHELL_TOOL_PATTERN).map(toolResult).join("\n")).toContain(
"IMPORTED_CHILD_RESUMED",
);
} finally {
await closeHarness(harness);
}
},
TIMEOUT_MS,
);
test(
"native Paseo host tools execute through RPC-UI",
async () => {
const harness = await createHarness();
try {
const agent = await createAgent(harness, "native-host-tool");
const items = await promptAndFinish(
harness,
agent.id,
"Use the Paseo host tool list_agents exactly once. Find the agent titled native-host-tool in the result, then reply exactly HOST_TOOL_OK:<its id>.",
);
const hostTools = completedTools(items, /^list_agents$/i);
expect(hostTools).toHaveLength(1);
expect(
items
.filter((item) => item.type === "assistant_message")
.map((item) => item.text.trim())
.join("")
.replace(/\s/g, ""),
).toContain(`HOST_TOOL_OK:${agent.id}`);
} finally {
await closeHarness(harness);
}
},
TIMEOUT_MS,
);
test(
"native branch rewinds conversation history and keeps the session usable",
async () => {
const harness = await createHarness();
try {
const agent = await createAgent(harness, "native-branch");
await promptAndFinish(harness, agent.id, "Reply exactly OMP_BRANCH_FIRST");
await promptAndFinish(harness, agent.id, "Reply exactly OMP_BRANCH_REMOVED");
const before = await timeline(harness.client, agent.id);
const firstMessage = before.find(
(item) => item.type === "user_message" && item.text.includes("OMP_BRANCH_FIRST"),
);
if (firstMessage?.type !== "user_message" || !firstMessage.messageId) {
throw new Error("Expected the first OMP prompt to have a provider message id");
}
await harness.client.rewindAgent(agent.id, firstMessage.messageId, "conversation");
const after = await timeline(harness.client, agent.id);
expect(
after.some(
(item) => item.type === "user_message" && item.text.includes("OMP_BRANCH_REMOVED"),
),
).toBe(false);
const continued = await promptAndFinish(
harness,
agent.id,
"Reply exactly OMP_BRANCH_CONTINUED",
);
expect(
continued
.filter((item) => item.type === "assistant_message")
.map((item) => item.text)
.join(""),
).toContain("OMP_BRANCH_CONTINUED");
} finally {
await closeHarness(harness);
}
},
TIMEOUT_MS,
);
});
@@ -24,7 +24,11 @@ process.env.PASEO_SUPERVISED = "0";
const PI_TEST_TIMEOUT_MS = 240_000;
const PI_REAL_TEST_MODEL = getRealProviderConfig("pi").model;
const PI_FREE_COMPACTION_TEST_MODEL = "openrouter/openai/gpt-oss-20b:free";
const PI_COMPACTION_TEST_MODEL = PI_REAL_TEST_MODEL.startsWith("openai-codex/")
? "openai-codex/gpt-5.4-mini"
: PI_REAL_TEST_MODEL;
const PI_COMPACTION_RESERVE_TOKENS =
PI_COMPACTION_TEST_MODEL === PI_REAL_TEST_MODEL ? 126_000 : 270_000;
type ToolCallItem = Extract<AgentTimelineItem, { type: "tool_call" }>;
@@ -146,7 +150,7 @@ test(
cwd,
title: "pi-compact-commands",
provider: "pi",
model: PI_FREE_COMPACTION_TEST_MODEL,
model: PI_REAL_TEST_MODEL,
});
const result = await client.listCommands({ agentId: agent.id });
@@ -186,7 +190,7 @@ test(
cwd,
title: "pi-manual-compact",
provider: "pi",
model: PI_FREE_COMPACTION_TEST_MODEL,
model: PI_COMPACTION_TEST_MODEL,
});
await client.sendMessage(agent.id, "Reply exactly: compact-ready");
@@ -219,10 +223,11 @@ test(
items.some((item) => item.type === "user_message" && item.text.includes("/compact")),
).toBe(false);
expect(
items.some(
(item) => item.type === "assistant_message" && item.text.includes("Failed to compact"),
),
).toBe(false);
items
.filter((item) => item.type === "assistant_message")
.map((item) => item.text)
.filter((text) => text.includes("Failed to compact")),
).toEqual([]);
});
} finally {
rmSync(cwd, { recursive: true, force: true });
@@ -242,7 +247,7 @@ test(
cwd,
title: "pi-autocompact-toggle",
provider: "pi",
model: PI_FREE_COMPACTION_TEST_MODEL,
model: PI_COMPACTION_TEST_MODEL,
});
await client.sendMessage(agent.id, "/autocompact off");
@@ -279,7 +284,7 @@ test(
try {
writePiCompactionSettings(cwd, {
enabled: true,
reserveTokens: 126_000,
reserveTokens: PI_COMPACTION_RESERVE_TOKENS,
keepRecentTokens: 1,
});
@@ -288,7 +293,7 @@ test(
cwd,
title: "pi-auto-compact",
provider: "pi",
model: PI_FREE_COMPACTION_TEST_MODEL,
model: PI_COMPACTION_TEST_MODEL,
});
await client.sendMessage(agent.id, "Reply exactly: auto-compact-ready");
@@ -524,7 +529,7 @@ test(
await client.sendMessage(
agent.id,
"Think step by step about what 7 * 13 equals, and give the final answer at the end.",
"Work out 37 * 43 carefully, then answer with only the number.",
);
const finish = await client.waitForFinish(agent.id, PI_TEST_TIMEOUT_MS);
@@ -535,7 +540,14 @@ test(
(item): item is Extract<AgentTimelineItem, { type: "reasoning" }> =>
item.type === "reasoning" && item.text.trim().length > 0,
);
const assistantTexts = items
.filter(
(item): item is Extract<AgentTimelineItem, { type: "assistant_message" }> =>
item.type === "assistant_message",
)
.map((item) => item.text);
expect(assistantTexts.join("\n")).toContain("1591");
expect(reasoningItems.length).toBeGreaterThan(0);
});
} finally {
@@ -1,6 +1,6 @@
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { existsSync, mkdtempSync, readFileSync } from "node:fs";
import { homedir, tmpdir } from "node:os";
import path, { join } from "node:path";
import type { Logger } from "pino";
@@ -9,10 +9,11 @@ import type { ProviderRuntimeSettings } from "../agent/provider-launch-config.js
import { ClaudeAgentClient } from "../agent/providers/claude/agent.js";
import { CodexAppServerAgentClient } from "../agent/providers/codex-app-server-agent.js";
import { OpenCodeAgentClient } from "../agent/providers/opencode-agent.js";
import { OmpAgentClient } from "../agent/providers/omp/agent.js";
import { PiRpcAgentClient } from "../agent/providers/pi/agent.js";
import { isCommandAvailable } from "../../executable-resolution/executable-resolution.js";
export const realProviders = ["claude", "codex", "opencode", "pi"] as const;
export const realProviders = ["claude", "codex", "opencode", "pi", "omp"] as const;
export type RealProvider = (typeof realProviders)[number];
export type RealProviderConfig = Pick<
AgentSessionConfig,
@@ -21,10 +22,15 @@ export type RealProviderConfig = Pick<
const OPENROUTER_BASE_URL = "https://openrouter.ai/api";
const OPENROUTER_OPENAI_BASE_URL = "https://openrouter.ai/api";
const PI_AUTH_CONFIG_PATH = join(homedir(), ".pi", "agent", "auth.json");
const CODEX_AUTH_CONFIG_PATH = join(homedir(), ".codex", "auth.json");
const CLAUDE_REAL_TEST_MODEL = "haiku";
const CODEX_REAL_TEST_MODEL = "~openai/gpt-latest";
const OPENCODE_REAL_TEST_MODEL = "openrouter/google/gemini-2.5-flash-lite";
const PI_REAL_TEST_MODEL = "openrouter/google/gemini-2.5-flash-lite";
const PI_OPENROUTER_REAL_TEST_MODEL = "openrouter/google/gemini-2.5-flash-lite";
const PI_CODEX_REAL_TEST_MODEL = "openai-codex/gpt-5.4";
const OMP_OPENROUTER_REAL_TEST_MODEL = "openrouter/google/gemini-2.5-flash-lite";
const OMP_CODEX_REAL_TEST_MODEL = "openai-codex/gpt-5.6-sol";
const availabilityCache = new Map<RealProvider, Promise<boolean>>();
@@ -52,13 +58,40 @@ export function getRealProviderConfig(provider: RealProvider): RealProviderConfi
case "pi":
return {
provider,
model: PI_REAL_TEST_MODEL,
model: getPiRealTestModel(),
thinkingOptionId: "medium",
};
case "omp":
return {
provider,
model: getOmpRealTestModel(),
thinkingOptionId: "medium",
modeId: "full",
};
}
}
export function getRealProviderRuntimeSettings(provider: RealProvider): ProviderRuntimeSettings {
if (provider === "omp" || provider === "pi") {
if (hasCodexAuthTokens()) {
return {
env: {
// Clear stale shell-level keys so the local CLI auth stores win.
OPENAI_API_KEY: "",
},
};
}
const apiKey = getOpenRouterApiKeyOrNull();
if (apiKey) {
return {
env: {
OPENROUTER_API_KEY: apiKey,
OPENAI_API_KEY: "",
},
};
}
return {};
}
const apiKey = getOpenRouterApiKey();
switch (provider) {
case "claude":
@@ -95,11 +128,7 @@ export function getRealProviderRuntimeSettings(provider: RealProvider): Provider
};
}
case "pi":
return {
env: {
OPENROUTER_API_KEY: apiKey,
},
};
return {};
}
}
@@ -120,6 +149,8 @@ export function createRealProviderClient(provider: RealProvider, logger: Logger)
return new OpenCodeAgentClient(logger, runtimeSettings);
case "pi":
return new PiRpcAgentClient({ logger, runtimeSettings });
case "omp":
return new OmpAgentClient({ logger, runtimeSettings });
}
}
@@ -139,7 +170,7 @@ export function canRunRealProvider(provider: RealProvider): Promise<boolean> {
}
const availability = (async () => {
if (!getOpenRouterApiKeyOrNull()) {
if (provider !== "omp" && provider !== "pi" && !getOpenRouterApiKeyOrNull()) {
return false;
}
return await isCommandAvailable(getProviderBinary(provider));
@@ -149,6 +180,22 @@ export function canRunRealProvider(provider: RealProvider): Promise<boolean> {
return availability;
}
function getOmpRealTestModel(): string {
const configured = process.env.OMP_REAL_TEST_MODEL?.trim();
if (configured) {
return configured;
}
return hasCodexAuthTokens() ? OMP_CODEX_REAL_TEST_MODEL : OMP_OPENROUTER_REAL_TEST_MODEL;
}
function getPiRealTestModel(): string {
const configured = process.env.PI_REAL_TEST_MODEL?.trim();
if (configured) {
return configured;
}
return hasCodexAuthTokens() ? PI_CODEX_REAL_TEST_MODEL : PI_OPENROUTER_REAL_TEST_MODEL;
}
function getOpenRouterApiKey(): string {
const apiKey = getOpenRouterApiKeyOrNull();
if (!apiKey) {
@@ -158,13 +205,52 @@ function getOpenRouterApiKey(): string {
}
function getOpenRouterApiKeyOrNull(): string | null {
const value = process.env.OPENROUTER_API_KEY?.trim();
const value = readPiOpenRouterApiKey() ?? process.env.OPENROUTER_API_KEY?.trim();
return value && value.length > 0 ? value : null;
}
function readPiOpenRouterApiKey(): string | null {
const auth = readJsonFile(PI_AUTH_CONFIG_PATH);
const value =
auth && typeof auth === "object" && "openrouter" in auth ? auth.openrouter : undefined;
if (!value || typeof value !== "object" || value === null || !("key" in value)) {
return null;
}
return typeof value.key === "string" && value.key.trim().length > 0 ? value.key.trim() : null;
}
function hasCodexAuthTokens(): boolean {
const auth = readJsonFile(CODEX_AUTH_CONFIG_PATH);
if (!auth || typeof auth !== "object" || !("tokens" in auth)) {
return false;
}
const tokens = auth.tokens;
if (!tokens || typeof tokens !== "object") {
return false;
}
return (
(typeof tokens.access_token === "string" && tokens.access_token.length > 0) ||
(typeof tokens.refresh_token === "string" && tokens.refresh_token.length > 0)
);
}
function readJsonFile(filePath: string): unknown {
if (!existsSync(filePath)) {
return null;
}
try {
return JSON.parse(readFileSync(filePath, "utf8")) as unknown;
} catch {
return null;
}
}
function getProviderBinary(provider: RealProvider): string {
if (provider === "pi") {
return process.env.PI_COMMAND ?? process.env.PI_ACP_PI_COMMAND ?? "pi";
}
if (provider === "omp") {
return process.env.OMP_COMMAND ?? "omp";
}
return provider;
}