feat(omp): make OMP a first-class provider (#5550)

Makes OMP a first-class provider: import keeps the session model, replay matches live rendering, /compact waits for OMP, Auto thinking, working multi-select and Other answers, mid-turn steering, fallback model and Fast mode, archived agents open without starting omp, profile-aware import, crash recovery and approval-mode changes, readable tool-call rows, one bubble per /skill, blocked and abandoned todos, OMP notifications, hidden todo reminders on reload, and session MCP servers for schedules and Hub runs.

Fixes #2727, fixes #2857, fixes #4000, fixes #4790, fixes #5354, fixes #5099

Co-authored-by: Abhishek Gupta <183943363+abhi-wan-kenobi@users.noreply.github.com>
Co-authored-by: Jegor Kitskerkin <jegor.kitskerkin@gmail.com>
Co-authored-by: Kh05ifr4nD <meandSSH0219@gmail.com>
Co-authored-by: Nguyen Quang Minh <135627235+n24q02m@users.noreply.github.com>
Co-authored-by: Omer Cohen <639682+omercnet@users.noreply.github.com>
Co-authored-by: Thomas Van <minhthong@gmail.com>
Co-authored-by: allo <alloevil@hotmail.com>
Co-authored-by: gray-graff <gray.graff@gmail.com>
Co-authored-by: kasrakhosravi <kasra@goldsky.com>
Co-authored-by: viet-initx <viet@initx.io>
This commit is contained in:
Mohamed Boudra
2026-09-29 14:12:53 +01:00
committed by GitHub
co-authored by Abhishek Gupta Jegor Kitskerkin Kh05ifr4nD Nguyen Quang Minh Omer Cohen Thomas Van allo gray-graff kasrakhosravi viet-initx
parent cc744cc669
commit 264f6e1cca
31 changed files with 3767 additions and 656 deletions
+2 -2
View File
@@ -34,8 +34,8 @@ workflows all live in the CLI process, and the completion notification that woul
agent never arrives. A runtime that dies mid-turn is reported by whatever is draining its stream, but
between turns nothing is watching, so the agent sits at `idle` looking healthy while its background
work is gone. Report that exit as a turn failure so the agent lands in `error` with a timeline entry.
Only the Claude provider does this today; the others still report a death only when a turn happens to
be in flight.
Claude and OMP report exits between turns. OMP relaunches from its session file on the next prompt;
the unfinished turn is lost.
### Cancellation
@@ -0,0 +1,64 @@
import { mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, test } from "vitest";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
import type { AgentPersistenceHandle } from "../../agent-sdk-types.js";
import { OmpAgentClient } from "./agent.js";
import { FakeOmp } from "./test-utils/fake-omp.js";
test("archived OMP history replays messages and todos without leaving a runtime", async () => {
const directory = await mkdtemp(join(tmpdir(), "paseo-omp-history-purpose-"));
const sessionFile = join(directory, "session.jsonl");
const entries = [
{ type: "session", id: "root", parentId: null },
{
type: "message",
id: "user-1",
parentId: "root",
message: { role: "user", content: "make a plan" },
},
{
type: "message",
id: "todo-1",
parentId: "user-1",
message: {
role: "toolResult",
toolName: "todo",
toolCallId: "call-1",
content: [{ type: "text", text: "one task" }],
details: {
phases: [{ name: "Tasks", tasks: [{ content: "Check history", status: "pending" }] }],
},
},
},
];
await writeFile(sessionFile, entries.map((entry) => JSON.stringify(entry)).join("\n"));
const runtime = new FakeOmp();
const client = new OmpAgentClient({ logger: createTestLogger(), runtime });
const handle: AgentPersistenceHandle = {
provider: "omp",
sessionId: "history-1",
nativeHandle: sessionFile,
metadata: { cwd: directory },
};
const session = await client.resumeSession(handle, undefined, undefined, { purpose: "history" });
const events = [];
try {
for await (const event of session.streamHistory()) events.push(event);
} finally {
await session.close();
}
expect(runtime.recordedLaunches).toHaveLength(0);
expect(events).toContainEqual({
type: "timeline",
provider: "omp",
item: {
type: "todo",
items: [{ text: "Check history", status: "pending", completed: false }],
},
});
});
@@ -1,9 +1,10 @@
import { describe, expect, test } from "vitest";
import { setImmediate as waitForImmediate } from "node:timers/promises";
import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
import { mkdtemp, mkdir, writeFile } from "node:fs/promises";
import { createRequire } from "node:module";
import { tmpdir } from "node:os";
import { join } from "node:path";
import path, { join } from "node:path";
import type { AgentStreamEvent } from "../../agent-sdk-types.js";
import type { PaseoToolCatalog } from "../../tools/types.js";
@@ -13,6 +14,9 @@ import type { OmpUsagePollScheduler } from "./usage-poller.js";
import { resolveOmpProviderParams } from "./provider-config.js";
import { OmpRuntimeEventSchema } from "./rpc-types.js";
import { OmpHarness } from "./test-utils/omp-harness.js";
import { OmpAgentClient } from "./agent.js";
import { FakeOmp } from "./test-utils/fake-omp.js";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
const TURN_LIFECYCLE_EVENTS = new Set<AgentStreamEvent["type"]>([
"turn_started",
@@ -47,6 +51,26 @@ test("OMP ready timeout defaults to 20 seconds and RPC timeout overrides both",
rpcTimeoutMs: 90_000,
});
});
test("OMP import uses the runtime's custom agent directory without a configured sessionDir", async () => {
const root = await mkdtemp(path.join(tmpdir(), "paseo-omp-import-dir-"));
const agentDir = path.join(root, "agent");
const sessionFile = path.join(agentDir, "sessions", "project", "session.jsonl");
await mkdir(path.dirname(sessionFile), { recursive: true });
await writeFile(
sessionFile,
JSON.stringify({ type: "session", id: "custom-dir", cwd: root, timestamp: "2026-09-28" }),
);
const client = new OmpAgentClient({
logger: createTestLogger(),
runtime: new FakeOmp(),
runtimeSettings: { env: { PI_CODING_AGENT_DIR: agentDir } },
});
expect(await client.listImportableSessions({ cwd: root })).toEqual([
expect.objectContaining({ providerHandleId: sessionFile }),
]);
});
class ManualIdleScheduler implements OmpProviderIdleScheduler {
private readonly retries: Array<() => void> = [];
private readonly waiters: Array<{ count: number; resolve: () => void }> = [];
@@ -235,7 +259,7 @@ describe("OMP agent client and session", () => {
[expect.objectContaining({ name: "create_agent" })],
]);
expect(omp.capabilities()).toMatchObject({
supportsMcpServers: false,
supportsMcpServers: true,
supportsNativePaseoTools: true,
});
});
@@ -247,6 +271,26 @@ describe("OMP agent client and session", () => {
expect(omp.launchConfiguration().argv).toEqual(expect.arrayContaining(["--thinking", "max"]));
});
test("launches with auto thinking when auto is selected", async () => {
const omp = new OmpHarness();
await omp.start({ thinkingOptionId: "auto" });
expect(omp.launchConfiguration().argv).toEqual(expect.arrayContaining(["--thinking", "auto"]));
});
test("keeps auto thinking when resuming a session", async () => {
const omp = new OmpHarness();
await omp.resume(
{
user: { id: "user-auto", text: "continue" },
assistant: { id: "assistant-auto", text: "ready" },
},
{ thinkingOptionId: "auto" },
);
expect(omp.launchConfiguration().argv).toEqual(expect.arrayContaining(["--thinking", "auto"]));
});
test("launches with write approval mode", async () => {
const omp = new OmpHarness();
await omp.start({ modeId: "write" });
@@ -443,6 +487,182 @@ describe("OMP agent client and session", () => {
await expect(completion).resolves.toMatchObject({ finalText: "first done" });
});
test("fails a turn when the provider idle gate passes its deadline", async () => {
const scheduler = new ManualIdleScheduler();
const omp = new OmpHarness({ providerIdleScheduler: scheduler, providerIdleDeadlineMs: 1 });
await omp.start();
const { completion } = await omp.startPromptUntilProviderIdle("first", "first done", {
isStreaming: true,
isCompacting: false,
});
await scheduler.waitForWaits(1);
omp.runtime().emit({
type: "tool_execution_start",
toolCallId: "tool-at-deadline",
toolName: "bash",
args: { command: "sleep 30" },
});
expect(omp.runningToolCallIds()).toEqual(["tool-at-deadline"]);
await new Promise((resolve) => setTimeout(resolve, 2));
scheduler.retry();
await expect(completion).rejects.toThrow(/provider idle/i);
expect(omp.runningToolCallIds()).toEqual([]);
});
test("steers a running turn and correlates a template-expanded echo exactly once", async () => {
const omp = new OmpHarness();
await omp.start();
const session = omp.requireSession();
const { turnId } = await session.startTurn("first", { clientMessageId: "client-first" });
const runtime = omp.runtime();
runtime.beginTurn();
runtime.acceptPrompt("first", "native-first");
await expect(
session.steerActiveTurn?.("expand template", {
expectedTurnId: turnId,
clientMessageId: "client-steer",
}),
).resolves.toEqual({ status: "accepted" });
runtime.acceptPrompt("expanded prompt", "native-steer");
runtime.acceptPrompt("expanded prompt", "native-steer");
expect(omp.timeline().filter((item) => item.type === "user_message")).toEqual([
expect.objectContaining({ messageId: "native-first", clientMessageId: "client-first" }),
expect.objectContaining({ messageId: "native-steer", clientMessageId: "client-steer" }),
]);
});
test("reports a rejected steer as unavailable", async () => {
const omp = new OmpHarness();
await omp.start();
const session = omp.requireSession();
const { turnId } = await session.startTurn("first");
omp.runtime().steerError = new Error("extension command cannot be steered");
await expect(
session.steerActiveTurn?.("extension input", { expectedTurnId: turnId }),
).resolves.toEqual({ status: "unavailable" });
});
test("propagates an OMP steer transport timeout", async () => {
const omp = new OmpHarness();
await omp.start();
const session = omp.requireSession();
const { turnId } = await session.startTurn("first");
const timeout = new Error(
"OMP RPC request timed out phase=steer elapsedMs=60000 timeoutMs=60000",
);
omp.runtime().steerError = timeout;
await expect(session.steerActiveTurn?.("second", { expectedTurnId: turnId })).rejects.toBe(
timeout,
);
});
test("shows OMP's fallback model without persisting it as the selected model", async () => {
const omp = new OmpHarness();
await omp.start({ model: "openrouter/google/gemini-3.8-flash" });
const runtime = omp.runtime();
runtime.state = {
...runtime.state,
model: { provider: "openrouter", id: "google/gemini-3.8-flash" },
};
runtime.emit({
type: "retry_fallback_applied",
from: "openrouter/google/gemini-3.8-flash",
to: "openrouter/other/model",
role: "primary",
});
runtime.state = { ...runtime.state, model: { provider: "openrouter", id: "other/model" } };
runtime.state = { ...runtime.state, fastModeEnabled: true, fastModeActive: false };
runtime.emit({ type: "model_changed" });
await waitForImmediate();
expect(omp.eventTypes()).toContain("model_changed");
expect((await omp.requireSession().getRuntimeInfo()).model).toBe("openrouter/other/model");
expect(omp.requireSession().describePersistence()?.metadata?.model).toBe(
"openrouter/google/gemini-3.8-flash",
);
expect(omp.requireSession().features).toEqual([
expect.objectContaining({
value: true,
description: expect.stringMatching(/does not apply/i),
}),
]);
});
test("Fast stays selected when OMP says it is inactive for the current model", async () => {
const omp = new OmpHarness();
await omp.start({ model: "openrouter/google/gemini-3.8-flash" });
const session = omp.requireSession();
omp.runtime().fastModeResult = { enabled: true, active: false };
await session.setFeature?.("fast_mode", true);
expect(omp.runtime().setFastModeRequests).toEqual([true]);
expect(session.features).toEqual([
expect.objectContaining({
id: "fast_mode",
value: true,
description: expect.stringMatching(/does not apply to this model/i),
tooltip: expect.stringMatching(/does not apply to this model/i),
}),
]);
});
test("shows Fast only when OMP reports its state fields", async () => {
const omp = new OmpHarness();
await omp.start();
const session = omp.requireSession();
const {
fastModeEnabled: _enabled,
fastModeActive: _active,
...olderState
} = omp.runtime().state;
omp.runtime().state = olderState;
await session.getRuntimeInfo();
expect(session.features).toEqual([]);
await expect(session.setFeature?.("fast_mode", true)).rejects.toThrow(/unavailable/i);
omp.runtime().state = { ...olderState, fastModeEnabled: false, fastModeActive: false };
await session.getRuntimeInfo();
expect(session.features).toHaveLength(1);
});
test("switching model refreshes whether Fast applies", async () => {
const omp = new OmpHarness();
await omp.start({ model: "openrouter/google/gemini-3.8-flash" });
const session = omp.requireSession();
omp.runtime().fastModeResult = { enabled: true, active: true };
await session.setFeature?.("fast_mode", true);
expect(session.features).toEqual([expect.objectContaining({ value: true })]);
omp.runtime().setModelResult = { provider: "openrouter", id: "other/model" };
omp.runtime().queueStateReports([
{
...omp.runtime().state,
model: omp.runtime().setModelResult,
fastModeEnabled: true,
fastModeActive: false,
},
]);
await session.setModel?.("openrouter/other/model");
expect(session.features).toEqual([
expect.objectContaining({
value: true,
description: expect.stringMatching(/does not apply/i),
}),
]);
});
test("restores Fast from the initial OMP state on create and resume", async () => {
const created = new OmpHarness();
await created.start({ featureValues: { fast_mode: true } });
expect(created.runtime().setFastModeRequests).toEqual([true]);
expect(created.runtime().getStateRequestCount).toBe(1);
const resumed = new OmpHarness();
await resumed.resume(
{ user: { id: "user-1", text: "hello" }, assistant: { id: "assistant-1", text: "hi" } },
{ featureValues: { fast_mode: true } },
);
expect(resumed.runtime().setFastModeRequests).toEqual([true]);
expect(resumed.runtime().getStateRequestCount).toBe(1);
});
test("does not complete on OMP's extension-notice agent_end", async () => {
const omp = new OmpHarness();
await omp.start();
@@ -515,13 +735,162 @@ describe("OMP agent client and session", () => {
message: "Background job DocsSmokeTwo completed",
},
]);
// Non-notice custom messages still fall through as assistant messages.
expect(omp.timeline().filter((item) => item.type === "assistant_message")).toMatchObject([
{ text: "done" },
{ text: "plain custom status text" },
// Non-notice custom messages still fall through as assistant messages with
// their own id so the stream coalescer never glues them onto the open reply.
expect(omp.timeline().filter((item) => item.type === "assistant_message")).toEqual([
{ type: "assistant_message", text: "done", messageId: "omp-assistant-1" },
{ type: "assistant_message", text: "plain custom status text", messageId: "omp-custom-1" },
]);
});
test("shows a concise error for a failed tool while keeping shell output", async () => {
const omp = new OmpHarness();
await omp.start();
omp.emit({
type: "tool_execution_start",
toolCallId: "failed-shell",
toolName: "bash",
args: { command: "false" },
});
omp.emit({
type: "tool_execution_end",
toolCallId: "failed-shell",
toolName: "bash",
result: {
content: [{ type: "text", text: "Command exited with code 1" }],
details: {},
isError: true,
exitCode: 1,
},
isError: true,
});
expect(omp.timeline().at(-1)).toMatchObject({
type: "tool_call",
status: "failed",
error: "Command exited with code 1",
detail: { type: "shell", command: "false", exitCode: 1 },
});
});
test("uses the exit message when a failed shell has no output", async () => {
const omp = new OmpHarness();
await omp.start();
omp.emit({
type: "tool_execution_start",
toolCallId: "empty-shell",
toolName: "bash",
args: { command: "false" },
});
omp.emit({
type: "tool_execution_end",
toolCallId: "empty-shell",
toolName: "bash",
result: {
content: [
{
type: "text",
text: "(no output)\n\nWall time: 0.02 seconds\n\nCommand exited with code 1",
},
],
isError: true,
},
isError: true,
});
expect(omp.timeline().at(-1)).toMatchObject({ error: "Command exited with code 1" });
});
test("does not duplicate the typed invocation for a live skill expansion", async () => {
const omp = new OmpHarness();
await omp.start();
await omp.runPromptWithCustomMessage(
"hello",
{
role: "custom",
content: "[IMPORTANT] Full skill body",
customType: "skill-prompt",
attribution: "user",
details: { name: "commit" },
display: true,
id: "skill-1",
},
"done",
);
expect(omp.timeline().filter((item) => item.type === "user_message")).toEqual([
{ type: "user_message", text: "hello", messageId: "user-1" },
]);
expect(omp.timeline()).not.toContainEqual(
expect.objectContaining({ text: "[IMPORTANT] Full skill body" }),
);
});
test.each([
{ kind: "initial", priorPrompt: false },
{ kind: "follow-up", priorPrompt: true },
])("correlates a $kind skill invocation without a user echo", async ({ priorPrompt }) => {
const omp = new OmpHarness();
await omp.start();
if (priorPrompt) {
await omp.runPrompt("Reply OK", "OK");
}
const typed = "/skill:tldr Summarize: hi.";
const runtime = omp.runtime();
const promptStarted = runtime.nextPrompt();
const run = omp.requireSession().run(typed, { clientMessageId: "client-skill" });
await promptStarted;
runtime.beginTurn();
runtime.emit({
type: "message_end",
message: {
role: "custom",
content: "[IMPORTANT] Full skill body",
customType: "skill-prompt",
attribution: "user",
details: { name: "tldr", args: "Summarize: hi." },
display: true,
id: "skill-1",
},
});
runtime.streamAssistantText("done");
runtime.finishTurn();
await run;
expect(omp.timeline().filter((item) => item.type === "user_message")).toEqual([
...(priorPrompt ? [{ type: "user_message", text: "Reply OK", messageId: "user-1" }] : []),
{ type: "user_message", text: typed, clientMessageId: "client-skill" },
]);
});
test("marks an OMP web search details error as failed even without isError", async () => {
const omp = new OmpHarness();
await omp.start();
omp.emit({
type: "tool_execution_start",
toolCallId: "search-failed",
toolName: "web_search",
args: { query: "Paseo" },
});
omp.emit({
type: "tool_execution_end",
toolCallId: "search-failed",
toolName: "web_search",
result: {
content: [{ type: "text", text: "Error: All web search providers failed" }],
details: {
response: { provider: "mojeek", sources: [] },
error: "All web search providers failed",
},
},
isError: false,
});
expect(omp.timeline().at(-1)).toMatchObject({
type: "tool_call",
status: "failed",
error: "All web search providers failed",
detail: { type: "search", query: "Paseo" },
});
});
test("does not complete a queued model turn from OMP's local-only hint", async () => {
const omp = new OmpHarness();
await omp.start();
@@ -540,6 +909,28 @@ describe("OMP agent client and session", () => {
expect(omp.completedTurnCount()).toBe(1);
});
test("completes a no-turn notify with one notification and no assistant text", async () => {
const scheduler = new ManualNoTurnScheduler();
const omp = new OmpHarness({ noTurnScheduler: scheduler });
await omp.start();
const prompt = await omp.startPromptWithFalseLocalOnlyResult("/autoresearch off");
omp.emit({
type: "extension_ui_request",
id: "local-notify",
method: "notify",
message: "Autoresearch mode disabled",
notifyType: "info",
});
scheduler.settle();
await expect(prompt.completion).resolves.toMatchObject({ finalText: "" });
expect(omp.completedTurnCount()).toBe(1);
expect(omp.timeline().filter((item) => item.type === "notification")).toEqual([
{ type: "notification", level: "info", message: "Autoresearch mode disabled" },
]);
expect(omp.timeline().filter((item) => item.type === "assistant_message")).toEqual([]);
});
test("waits for a delayed queued model turn after OMP's local-only result", async () => {
const omp = new OmpHarness();
await omp.start();
@@ -659,6 +1050,33 @@ describe("OMP agent client and session", () => {
]);
});
test("shows OMP notifications during a turn and while idle with their levels", async () => {
const omp = new OmpHarness();
await omp.start();
await omp.startTurn("work");
omp.runtime().beginTurn();
omp.emit({
type: "extension_ui_request",
id: "n1",
method: "notify",
message: "Working",
notifyType: "warning",
});
omp.runtime().finishTurn();
omp.emit({
type: "extension_ui_request",
id: "n2",
method: "notify",
message: "Done",
notifyType: "info",
});
expect(omp.timeline().filter((item) => item.type === "notification")).toEqual([
{ type: "notification", level: "warning", message: "Working" },
{ type: "notification", level: "info", message: "Done" },
]);
expect(omp.timeline().filter((item) => item.type === "assistant_message")).toEqual([]);
});
test("maps legacy select options without descriptions and preserves ordinary responses", async () => {
const omp = new OmpHarness();
await omp.start();
@@ -724,41 +1142,6 @@ describe("OMP agent client and session", () => {
]);
});
test("preserves combined selection descriptions and freeform sentinel behavior", async () => {
const omp = new OmpHarness();
await omp.start();
omp.emit({
type: "tool_execution_start",
toolCallId: "ask-user-1",
toolName: "ask_user",
args: { allowComment: true, allowFreeform: true, allowMultiple: false },
});
omp.emit({
type: "extension_ui_request",
id: "select-combined",
method: "select",
title: "Choose",
options: ["First", "✏️ Type custom response..."],
optionDetails: [{ description: "First detail" }, { description: "ignored" }],
});
const combinedInput = omp.pendingPermissions()[0]?.input;
expect(combinedInput?.questions?.[0]?.options).toStrictEqual([
{ label: "First", description: "First detail" },
]);
expect(combinedInput).toMatchObject({
questions: [{ allowOther: true }, { header: "Comment" }],
});
await omp.respondToPermission("select-combined", {
behavior: "allow",
updatedInput: { answers: { Response: "custom", Comment: "note" } },
});
expect(omp.extensionUiResponses()).toContainEqual({
id: "select-combined",
response: { value: "✏️ Type custom response..." },
});
});
test("exposes OMP modes and commands through the domain session", async () => {
const omp = new OmpHarness();
omp.queueCommands([{ name: "review", description: "Review changes", source: "skill" }]);
@@ -775,10 +1158,99 @@ describe("OMP agent client and session", () => {
expect.objectContaining({ name: "review", kind: "skill" }),
]),
);
await expect(omp.setMode("ask")).resolves.toBeUndefined();
await expect(omp.currentMode()).resolves.toBe("ask");
expect(omp.runtimeLaunches()[1]?.argv).toContain("--approval-mode");
expect(omp.runtimeLaunches()[1]?.argv).toContain("always-ask");
});
test("restarts the same conversation after an idle process exit", async () => {
const omp = new OmpHarness();
await omp.start({ modeId: "full" });
const previousId = omp.runtime().state.sessionId;
omp.processExit("OMP RPC process exited with code null and signal SIGKILL\nBun crashed");
expect(omp.turnFailures()).toEqual([
"OMP RPC process exited with code null and signal SIGKILL\nBun crashed",
]);
await omp.startTurn("remember the conversation");
expect(omp.runtimeLaunches()).toHaveLength(2);
expect(omp.runtimeLaunches()[1]?.session).toBe("/tmp/omp-session");
expect(omp.runtime().state.sessionId).toBe(previousId);
expect(omp.threadStartedSessionIds()).toEqual([]);
expect(omp.runtime().prompts).toEqual([
{ message: "remember the conversation", imageCount: 0 },
]);
});
test("reports a mid-turn process exit and resumes on the following prompt", async () => {
const omp = new OmpHarness();
await omp.start();
await omp.requireStartTurn("sleep 30");
omp.runtime().beginTurn();
omp.processExit("OMP RPC process exited with code 137 and signal null\nOOM");
expect(omp.turnFailures()).toEqual([
"OMP RPC process exited with code 137 and signal null\nOOM",
]);
await omp.startTurn("continue");
expect(omp.runtimeLaunches()).toHaveLength(2);
expect(omp.runtime().prompts).toEqual([{ message: "continue", imageCount: 0 }]);
});
test("reports an immediate relaunch failure without retrying in a loop", async () => {
const omp = new OmpHarness();
await omp.start();
omp.processExit("OMP RPC process exited with code null and signal SIGKILL");
omp.failNextStart(new Error("Bun failed during startup"));
await omp.startTurn("continue");
expect(omp.turnFailures()).toEqual([
"OMP RPC process exited with code null and signal SIGKILL",
"Bun failed during startup",
]);
expect(omp.runtimeLaunches()).toHaveLength(1);
});
test("closes a replacement process if the session closes during relaunch", async () => {
const omp = new OmpHarness();
await omp.start();
omp.processExit("OMP RPC process exited with code 1 and signal null");
const turn = omp.startTurnDetached("continue");
await omp.close();
await turn;
await waitForImmediate();
expect(omp.runtimeSessions().every((session) => session.closed)).toBe(true);
});
test("announces a fresh native session when an internal agent recovers", async () => {
const omp = new OmpHarness();
await omp.start({ internal: true });
const previousId = omp.runtime().state.sessionId;
omp.processExit("OMP RPC process exited with code 1 and signal null");
await omp.startTurn("continue");
expect(omp.runtimeLaunches()[1]?.argv).toContain("--no-session");
expect(omp.runtime().state.sessionId).not.toBe(previousId);
expect(omp.threadStartedSessionIds()).toEqual([omp.runtime().state.sessionId]);
});
test("leaves the current approval mode in place when relaunch fails", async () => {
const omp = new OmpHarness();
await omp.start({ modeId: "full" });
omp.failNextStart(new Error("OMP launch failed"));
await expect(omp.setMode("ask")).rejects.toThrow("OMP launch failed");
expect(await omp.currentMode()).toBe("full");
expect(omp.runtimeLaunches()).toHaveLength(1);
});
test("rejects a running approval-mode change until the turn ends", async () => {
const omp = new OmpHarness();
await omp.start({ modeId: "full" });
await omp.requireStartTurn("work");
await expect(omp.setMode("ask")).resolves.toEqual({
type: "warning",
message: "Start a new OMP session to change approval mode",
message: "Change approval mode once the current turn ends",
});
expect(omp.runtimeLaunches()).toHaveLength(1);
expect(await omp.currentMode()).toBe("full");
});
test("rewinds natively, interrupts, and shuts down", async () => {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,311 @@
import { describe, expect, test } from "vitest";
import { OmpHarness } from "./test-utils/omp-harness.js";
function startAsk(omp: OmpHarness, multi: boolean): void {
omp.emit({
type: "tool_execution_start",
toolCallId: "ask-1",
toolName: "ask",
args: {
questions: [
{
id: "colors",
question: "Which colors?",
multi,
options: [
{ label: "Red", description: "Warm" },
{ label: "Blue", description: "Cool" },
],
},
],
},
});
}
function select(omp: OmpHarness, id: string, title: string, options: string[]): void {
omp.emit({ type: "extension_ui_request", id, method: "select", title, options });
}
describe("OMP ask RPC UI", () => {
test("recognizes ask arguments from the assistant message before the UI request", async () => {
const omp = new OmpHarness();
await omp.start();
omp.emit({
type: "message_end",
message: {
role: "assistant",
content: [
{
type: "toolCall",
id: "ask-1",
name: "ask",
arguments: {
questions: [
{
id: "colors",
question: "Which colors?",
multi: true,
options: [
{ label: "Red", description: "Warm" },
{ label: "Blue", description: "Cool" },
],
},
],
},
},
],
},
});
select(omp, "select-1", "Which colors?", ["Red", "Blue", "Other (type your own)"]);
expect(omp.pendingPermissions()[0]?.input?.questions?.[0]).toMatchObject({
multiSelect: true,
allowOther: true,
});
startAsk(omp, true);
});
test("answers the multi-select loop from one Paseo question", async () => {
const omp = new OmpHarness();
await omp.start();
startAsk(omp, true);
select(omp, "select-1", "Which colors?", ["Red", "Blue", "Other (type your own)"]);
expect(omp.pendingPermissions()).toHaveLength(1);
expect(omp.pendingPermissions()[0]).toMatchObject({
kind: "question",
input: {
questions: [
{
question: "Which colors?",
multiSelect: true,
allowOther: true,
options: [{ label: "Red" }, { label: "Blue" }],
},
],
},
});
await omp.respondToPermission("select-1", {
behavior: "allow",
updatedInput: { answers: { Response: "Blue, Red" } },
});
select(omp, "select-2", "(1 selected) Which colors?", [
"Red",
"Blue",
"✓ Done selecting",
"Other (type your own)",
]);
select(omp, "select-3", "(2 selected) Which colors?", [
"Red",
"Blue",
"✔ Done selecting",
"Other (type your own)",
]);
expect(omp.pendingPermissions()).toHaveLength(0);
expect(omp.extensionUiResponses()).toEqual([
{ id: "select-1", response: { value: "Blue" } },
{ id: "select-2", response: { value: "Red" } },
{ id: "select-3", response: { value: "✔ Done selecting" } },
]);
});
test("passes a single-select Other answer through OMP's editor", async () => {
const omp = new OmpHarness();
await omp.start();
startAsk(omp, false);
select(omp, "select-1", "Which colors?", ["Red", "Blue", "Other (type your own)"]);
expect(omp.pendingPermissions()[0]?.input?.questions?.[0]).toMatchObject({ allowOther: true });
await omp.respondToPermission("select-1", {
behavior: "allow",
updatedInput: { answers: { Response: "Violet" } },
});
omp.emit({
type: "extension_ui_request",
id: "editor-1",
method: "editor",
title: "Which colors? ○ Red ○ Blue ◉ Other (type your own) Enter your response:",
});
expect(omp.pendingPermissions()).toHaveLength(0);
expect(omp.extensionUiResponses()).toEqual([
{ id: "select-1", response: { value: "Other (type your own)" } },
{ id: "editor-1", response: { value: "Violet" } },
]);
});
test("passes multi-select choices and Other through the same editor flow", async () => {
const omp = new OmpHarness();
await omp.start();
startAsk(omp, true);
select(omp, "select-1", "Which colors?", ["Red", "Blue", "Other (type your own)"]);
await omp.respondToPermission("select-1", {
behavior: "allow",
updatedInput: { answers: { Response: "Red, Violet" } },
});
select(omp, "select-2", "(1 selected) Which colors?", [
"Red",
"Blue",
"✓ Done selecting",
"Other (type your own)",
]);
omp.emit({
type: "extension_ui_request",
id: "editor-1",
method: "editor",
title: "(1 selected) Which colors?",
});
expect(omp.pendingPermissions()).toHaveLength(0);
expect(omp.extensionUiResponses()).toEqual([
{ id: "select-1", response: { value: "Red" } },
{ id: "select-2", response: { value: "Other (type your own)" } },
{ id: "editor-1", response: { value: "Violet" } },
]);
});
test("cancels an ask without leaving replay state behind", async () => {
const omp = new OmpHarness();
await omp.start();
startAsk(omp, true);
select(omp, "select-1", "Which colors?", ["Red", "Blue", "Other (type your own)"]);
await omp.respondToPermission("select-1", { behavior: "deny" });
select(omp, "select-after-cancel", "Another question", ["One", "Two"]);
expect(omp.extensionUiResponses()).toEqual([{ id: "select-1", response: { cancelled: true } }]);
expect(omp.pendingPermissions()).toHaveLength(1);
expect(omp.pendingPermissions()[0]?.id).toBe("select-after-cancel");
});
test("keeps single choices, tool approvals, and free-form input independent", async () => {
const omp = new OmpHarness();
await omp.start();
startAsk(omp, false);
select(omp, "select-1", "Which colors?", ["Red", "Blue", "Other (type your own)"]);
await omp.respondToPermission("select-1", {
behavior: "allow",
updatedInput: { answers: { Response: "Blue" } },
});
expect(omp.extensionUiResponses()).toContainEqual({
id: "select-1",
response: { value: "Blue" },
});
omp.emit({
type: "tool_execution_end",
toolCallId: "ask-1",
toolName: "ask",
result: "Blue",
isError: false,
});
omp.requestToolApproval({ id: "approval", tool: "bash", detail: "echo hi" });
expect(omp.pendingPermissions()[0]).toMatchObject({ id: "approval", kind: "tool" });
await omp.respondToPermission("approval", { behavior: "allow" });
expect(omp.extensionUiResponses()).toContainEqual({
id: "approval",
response: { value: "Approve" },
});
omp.emit({ type: "extension_ui_request", id: "input", method: "input", title: "Type a value" });
expect(omp.pendingPermissions()[0]).toMatchObject({ id: "input", kind: "question" });
await omp.respondToPermission("input", {
behavior: "allow",
updatedInput: { answers: { Response: "typed value" } },
});
expect(omp.extensionUiResponses()).toContainEqual({
id: "input",
response: { value: "typed value" },
});
});
test("recognizes OMP's numbered question title", async () => {
const omp = new OmpHarness();
await omp.start();
startAsk(omp, true);
select(omp, "select-1", "Which colors? (1/2)", ["Red", "Blue", "Other (type your own)"]);
expect(omp.pendingPermissions()[0]?.input?.questions?.[0]).toMatchObject({ multiSelect: true });
});
test("keeps descriptions with their labels when a select event reorders rows", async () => {
const omp = new OmpHarness();
await omp.start();
startAsk(omp, true);
select(omp, "select-1", "Which colors?", ["Blue", "Red", "Other (type your own)"]);
expect(omp.pendingPermissions()[0]?.input?.questions?.[0]?.options).toEqual([
{ label: "Blue", description: "Cool" },
{ label: "Red", description: "Warm" },
]);
});
test("keeps a recommended option's description with its display label", async () => {
const omp = new OmpHarness();
await omp.start();
startAsk(omp, false);
select(omp, "select-1", "Which colors?", [
"Red (Recommended)",
"Blue",
"Other (type your own)",
]);
expect(omp.pendingPermissions()[0]?.input?.questions?.[0]?.options).toEqual([
{ label: "Red (Recommended)", description: "Warm" },
{ label: "Blue", description: "Cool" },
]);
});
test("cancels cleanly when OMP omits Done selecting in a multi-question ask", async () => {
const omp = new OmpHarness();
await omp.start();
omp.emit({
type: "tool_execution_start",
toolCallId: "ask-1",
toolName: "ask",
args: {
questions: [
{
id: "colors",
question: "Which colors?",
multi: true,
options: [{ label: "Red" }, { label: "Blue" }],
},
{
id: "shape",
question: "Which shape?",
options: [{ label: "Round" }, { label: "Square" }],
},
],
},
});
select(omp, "select-1", "Which colors? (1/2)", ["Red", "Blue", "Other (type your own)"]);
await omp.respondToPermission("select-1", {
behavior: "allow",
updatedInput: { answers: { Response: "Red" } },
});
// OMP's RPC select omits the right-arrow navigation callback and, with
// allowForward true, also omits its Done row on every follow-up request.
select(omp, "select-2", "(1 selected) Which colors? (1/2)", [
"Red",
"Blue",
"Other (type your own)",
]);
expect(omp.extensionUiResponses()).toEqual([
{ id: "select-1", response: { value: "Red" } },
{ id: "select-2", response: { cancelled: true } },
]);
expect(omp.pendingPermissions()).toHaveLength(0);
omp.emit({
type: "tool_execution_end",
toolCallId: "ask-1",
toolName: "ask",
result: "Ask tool was cancelled by the user",
isError: true,
});
expect(omp.timeline()).toContainEqual(
expect.objectContaining({
type: "tool_call",
name: "ask",
status: "failed",
}),
);
});
});
@@ -98,6 +98,14 @@ function replyToCommands(
});
}
function capturePendingCommand(child: OmpChild, type: string): Promise<Record<string, unknown>> {
return new Promise((resolve) => {
onOmpCommand(child, (command) => {
if (command.type === type) resolve(command);
});
});
}
/** Kill the child the moment it receives `type`, so the request is in flight when it dies. */
function exitOnCommand(child: OmpChild, type: string): void {
onOmpCommand(child, (command) => {
@@ -113,6 +121,22 @@ function withoutRequestId(command: Record<string, unknown>): Record<string, unkn
}
describe("OMP CLI runtime", () => {
test("steer waits for OMP's response and surfaces rejection", async () => {
const child = createOmpChild();
let pending: Record<string, unknown> | null = null;
onOmpCommand(child, (command) => {
if (command.type === "steer") pending = command;
});
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
const result = session.steer("change direction");
await new Promise((resolve) => setImmediate(resolve));
expect(pending).toMatchObject({ type: "steer", message: "change direction" });
child.stdout.write(
`${JSON.stringify({ id: pending!.id, type: "response", command: "steer", success: false, error: "rejected" })}\n`,
);
await expect(result).rejects.toThrow("rejected");
await session.close();
});
test("uses the configured RPC timeout and attributes the pending phase", async () => {
vi.useFakeTimers();
const child = createOmpChild();
@@ -293,6 +317,27 @@ describe("OMP CLI runtime", () => {
]);
});
test("compact waits beyond the control-plane timeout for a late response", async () => {
vi.useFakeTimers();
const child = createOmpChild();
const pending = capturePendingCommand(child, "compact");
const session = await createRuntime(child, [], { requestTimeoutMs: 100 }).startSession({
cwd: "/workspace/project",
});
try {
const compact = session.compact("focus on tests");
const command = await pending;
await vi.advanceTimersByTimeAsync(101);
child.stdout.write(
`${JSON.stringify({ id: command.id, type: "response", command: "compact", success: true, data: {} })}\n`,
);
await expect(compact).resolves.toBeUndefined();
} finally {
vi.useRealTimers();
await session.close();
}
});
test("accepts the empty prompt acknowledgement emitted by OMP 17", async () => {
const child = createOmpChild();
replyToCommands(child, () => undefined);
@@ -1,8 +1,13 @@
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import type { Logger } from "pino";
import { z } from "zod";
import type { ProviderRuntimeSettings } from "../../provider-launch-config.js";
import { JsonlRpcProcess, type JsonlRpcLaunch } from "../jsonl-rpc-process.js";
import {
JSONL_RPC_NO_TIMEOUT,
JsonlRpcProcess,
type JsonlRpcLaunch,
} from "../jsonl-rpc-process.js";
import { establishOmpProtocol } from "./protocol-session.js";
import {
buildOmpLaunch,
@@ -144,10 +149,13 @@ class OmpCliRuntimeSession implements OmpRuntimeSession {
}
async compact(customInstructions?: string): Promise<void> {
await this.request({
type: "compact",
...(customInstructions ? { customInstructions } : {}),
});
await this.request(
{
type: "compact",
...(customInstructions ? { customInstructions } : {}),
},
JSONL_RPC_NO_TIMEOUT,
);
}
async setAutoCompaction(enabled: boolean): Promise<void> {
@@ -162,6 +170,11 @@ class OmpCliRuntimeSession implements OmpRuntimeSession {
return OmpSessionStateSchema.parse(await this.request({ type: "get_state" }));
}
async setFastMode(enabled: boolean): Promise<{ enabled: boolean; active: boolean }> {
const result = await this.request({ type: "set_fast_mode", enabled });
return z.object({ enabled: z.boolean(), active: z.boolean() }).parse(result);
}
async getMessages(): Promise<OmpAgentMessage[]> {
const data = OmpMessagesResultSchema.parse(await this.request({ type: "get_messages" }));
return data.messages ?? [];
@@ -255,8 +268,11 @@ class OmpCliRuntimeSession implements OmpRuntimeSession {
return data.messages ?? [];
}
steer(message: string, images?: Array<{ type: "image"; data: string; mimeType: string }>): void {
this.process.send({ type: "steer", message, ...(images?.length ? { images } : {}) });
async steer(
message: string,
images?: Array<{ type: "image"; data: string; mimeType: string }>,
): Promise<void> {
await this.request({ type: "steer", message, ...(images?.length ? { images } : {}) });
}
followUp(
@@ -5,3 +5,30 @@ type OmpCustomMessage = Extract<OmpAgentMessage, { role: "custom" }>;
export function shouldDisplayOmpCustomMessage(message: OmpCustomMessage): boolean {
return Reflect.get(message, "display") !== false;
}
// injected rows need their own message id so the stream coalescer never glues them
// onto the assistant reply they arrive next to
export function ompCustomMessageId(message: OmpCustomMessage, nextIndex: () => number): string {
const id = Reflect.get(message, "id");
return `omp-custom-${typeof id === "string" && id ? id : nextIndex()}`;
}
// omp does not persist the typed /skill:<name> as a user message, only the expansion
// it injected on the user's behalf; rebuild the bubble from the expansion's details
export function ompSkillPromptUserText(message: OmpCustomMessage): string | null {
if (
Reflect.get(message, "customType") !== "skill-prompt" ||
Reflect.get(message, "attribution") !== "user"
) {
return null;
}
const details = Reflect.get(message, "details");
const name = details && typeof details === "object" ? Reflect.get(details, "name") : undefined;
if (typeof name !== "string" || !name) {
return null;
}
const args = Reflect.get(details as object, "args");
return typeof args === "string" && args.trim()
? `/skill:${name} ${args.trim()}`
: `/skill:${name}`;
}
@@ -8,7 +8,7 @@ import { streamOmpCoreHistory, type OmpCapturedUserMessageEntry } from "./messag
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";
import { readOmpHistoryTodoState, streamOmpHistory } from "./history.js";
async function collectHistory(
messages: OmpAgentMessage[],
@@ -27,6 +27,117 @@ async function collectHistory(
}
describe("OMP history mapper", () => {
test("replays a web search details error as failed when OMP sets isError false", async () => {
const events = await collectHistory([
{
role: "assistant",
content: [
{ type: "toolCall", id: "web-1", name: "web_search", arguments: { query: "Paseo" } },
],
},
{
role: "toolResult",
toolCallId: "web-1",
toolName: "web_search",
content: [{ type: "text", text: "Error: All web search providers failed" }],
details: {
error: "All web search providers failed",
response: { provider: "none", sources: [] },
},
isError: false,
},
]);
expect(events.at(-1)?.item).toMatchObject({
type: "tool_call",
status: "failed",
error: "All web search providers failed",
});
});
test("restores blocked and abandoned todo state from a session file", async () => {
const dir = mkdtempSync(join(tmpdir(), "omp-todo-state-history-"));
const sessionFile = join(dir, "session.jsonl");
writeFileSync(
sessionFile,
[
{ type: "session", id: "root" },
{
type: "message",
id: "todos",
parentId: "root",
message: {
role: "toolResult",
toolName: "todo",
details: {
phases: [
{
name: "Tasks",
tasks: [
{ content: "Wait for approval", status: "blocked", blocker: "review" },
{ content: "Old route", status: "abandoned" },
],
},
],
},
},
},
]
.map((entry) => JSON.stringify(entry))
.join("\n"),
);
expect(await readOmpHistoryTodoState(sessionFile)).toEqual({
type: "todo",
items: [{ text: "Wait for approval (blocked: review)", status: "pending", completed: false }],
});
});
test("hides persisted developer reminders and shows other developer messages", async () => {
const dir = mkdtempSync(join(tmpdir(), "omp-developer-history-"));
const sessionFile = join(dir, "session.jsonl");
writeFileSync(
sessionFile,
[
{ type: "session", id: "root" },
{
type: "message",
id: "reminder",
parentId: "root",
message: {
role: "developer",
attribution: "agent",
content: [
{
type: "text",
text: "<system-reminder>\nContinue unfinished tasks\n</system-reminder>",
},
],
},
},
{
type: "message",
id: "other",
parentId: "reminder",
message: {
role: "developer",
content: [{ type: "text", text: "External instruction" }],
},
},
]
.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: "assistant_message",
text: "[developer] External instruction",
messageId: "omp-custom-1",
},
]);
});
test("coalesces replayed subagent poll calls by target set", async () => {
const events = await collectHistory([
{
@@ -223,12 +334,20 @@ describe("OMP history mapper", () => {
{
type: "timeline",
provider: "omp",
item: { type: "assistant_message", text: "visible explicit custom" },
item: {
type: "assistant_message",
text: "visible explicit custom",
messageId: "omp-custom-1",
},
},
{
type: "timeline",
provider: "omp",
item: { type: "assistant_message", text: "visible legacy custom" },
item: {
type: "assistant_message",
text: "visible legacy custom",
messageId: "omp-custom-2",
},
},
{
type: "timeline",
@@ -366,6 +485,17 @@ describe("OMP history mapper", () => {
},
},
});
// The running row must share the completed row's detail type, or the timeline merge keeps
// the running detail and drops the result.
expect(
events.flatMap((event) =>
event.type === "timeline" &&
event.item.type === "tool_call" &&
event.item.callId === "xd-write-call"
? [event.item.detail.type]
: [],
),
).toEqual(["plain_text", "unknown"]);
});
test("maps only the active JSONL chain with native user ids and visible unknown roles", async () => {
@@ -447,8 +577,12 @@ describe("OMP history mapper", () => {
}
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" },
{
type: "assistant_message",
text: "[future_control] Unsupported history record",
messageId: "omp-custom-1",
},
{ type: "assistant_message", text: "[developer] developer note", messageId: "omp-custom-2" },
]);
const omp = new FakeOmp();
@@ -474,6 +608,163 @@ describe("OMP history mapper", () => {
]);
});
test("maps omp 18.1 custom_message entries like live custom messages", async () => {
const dir = mkdtempSync(join(tmpdir(), "omp-custom-message-history-"));
const sessionFile = join(dir, "session.jsonl");
const skillPrompt =
'[IMPORTANT: User invoked the "commit" skill; follow its instructions. Full skill below.]\n\n# Commit';
const ircMessage = "<irc>\n<from>worker-1</from>\n<message>ready for review</message>\n</irc>";
writeFileSync(
sessionFile,
[
{ type: "session", id: "root", parentId: null },
{
type: "message",
id: "answer-1",
parentId: "root",
message: {
role: "assistant",
content: [{ type: "text", text: "Done." }],
responseId: "resp-1",
},
},
{
type: "custom_message",
customType: "skill-prompt",
content: skillPrompt,
display: true,
details: {
name: "commit",
path: "/home/me/.agents/skills/commit/SKILL.md",
lineCount: 12,
},
attribution: "user",
id: "skill-1",
parentId: "answer-1",
timestamp: "2026-09-13T13:11:35.811Z",
},
{
type: "custom_message",
customType: "irc:incoming",
content: ircMessage,
display: true,
details: { from: "worker-1", message: "ready for review" },
attribution: "user",
id: "irc-1",
parentId: "skill-1",
timestamp: "2026-09-13T13:11:36.811Z",
},
{
type: "custom_message",
customType: "hidden-reminder",
content: "must stay hidden",
display: false,
attribution: "user",
id: "hidden-1",
parentId: "irc-1",
timestamp: "2026-09-13T13:11:37.811Z",
},
{
type: "custom_message",
customType: "legacy-no-display",
content: "visible without display flag",
attribution: "user",
id: "legacy-1",
parentId: "hidden-1",
timestamp: "2026-09-13T13:11:38.811Z",
},
]
.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: "assistant_message", text: "Done.", messageId: "resp-1" },
{ type: "user_message", text: "/skill:commit", messageId: "omp-custom-skill-1-user" },
{ type: "assistant_message", text: ircMessage, messageId: "omp-custom-irc-1" },
{
type: "assistant_message",
text: "visible without display flag",
messageId: "omp-custom-legacy-1",
},
]);
});
test("synthesises the typed /skill bubble only for user-attributed skill prompts", async () => {
await expect(
collectHistory([
{
role: "custom",
content: '[IMPORTANT: User invoked the "improve" skill; follow its instructions.]',
customType: "skill-prompt",
display: true,
details: {
name: "improve",
path: "/home/me/.agents/skills/improve/SKILL.md",
lineCount: 9,
args: "tests",
},
attribution: "user",
id: "skill-args",
},
{
role: "custom",
content: '[IMPORTANT: Agent invoked the "improve" skill.]',
customType: "skill-prompt",
display: true,
details: {
name: "improve",
path: "/home/me/.agents/skills/improve/SKILL.md",
lineCount: 9,
},
attribution: "agent",
id: "skill-agent",
},
{
role: "custom",
content: "<irc>\n<from>worker-1</from>\n<message>hi</message>\n</irc>",
customType: "irc:incoming",
display: true,
details: { from: "worker-1", message: "hi" },
attribution: "user",
id: "irc-user",
},
]),
).resolves.toEqual([
{
type: "timeline",
provider: "omp",
item: {
type: "user_message",
text: "/skill:improve tests",
messageId: "omp-custom-skill-args-user",
},
},
{
type: "timeline",
provider: "omp",
item: {
type: "assistant_message",
text: '[IMPORTANT: Agent invoked the "improve" skill.]',
messageId: "omp-custom-skill-agent",
},
},
{
type: "timeline",
provider: "omp",
item: {
type: "assistant_message",
text: "<irc>\n<from>worker-1</from>\n<message>hi</message>\n</irc>",
messageId: "omp-custom-irc-user",
},
},
]);
});
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");
@@ -1,12 +1,15 @@
import { readFile } from "node:fs/promises";
import { basename, extname, join } from "node:path";
import type { AgentProvider, AgentStreamEvent } from "../../agent-sdk-types.js";
import type { AgentProvider, AgentStreamEvent, AgentTimelineItem } 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 type { OmpBridgedToolIdentity } from "./mcp-bridge.js";
import { formatOmpSubagentTitle } from "./subagent-title.js";
import { mapOmpTodoPhases } from "./todo-mapper.js";
import { OmpTodoPhaseSchema } from "./rpc-types.js";
interface OmpSessionEntry {
type?: string;
@@ -17,6 +20,24 @@ interface OmpSessionEntry {
[key: string]: unknown;
}
export async function readOmpHistoryTodoState(
sessionFile: string,
): Promise<AgentTimelineItem | null> {
const entries = await readActiveOmpEntryChain(sessionFile).catch((error: unknown) => {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
throw error;
});
for (let index = entries.length - 1; index >= 0; index -= 1) {
const message = entries[index]?.message;
if (message?.role !== "toolResult" || message.toolName !== "todo") continue;
const details = message.details;
if (!details || typeof details !== "object") return null;
const phases = OmpTodoPhaseSchema.array().safeParse(Reflect.get(details, "phases"));
return phases.success ? mapOmpTodoPhases(phases.data) : null;
}
return null;
}
function extractOmpSubagentModel(entries: readonly OmpSessionEntry[]): string | null {
let resolvedModel: string | null = null;
for (const entry of entries) {
@@ -45,6 +66,7 @@ export async function* streamOmpHistory(input: {
sessionFile?: string;
runtimeSession?: OmpRuntimeSession;
provider: AgentProvider;
bridgedTools?: ReadonlyMap<string, OmpBridgedToolIdentity>;
visitedSessionFiles?: Set<string>;
}): AsyncGenerator<AgentStreamEvent> {
if (!input.sessionFile) {
@@ -79,7 +101,12 @@ export async function* streamOmpHistory(input: {
userEntries.push({ id: entry.id, text: textOf(mapped.content) });
}
}
const mapper = new OmpHistoryMapper(input.provider, userEntries, OMP_HISTORY_MAPPER_HOOKS);
const mapper = new OmpHistoryMapper(
input.provider,
userEntries,
OMP_HISTORY_MAPPER_HOOKS,
input.bridgedTools,
);
for (let index = 0; index < messages.length; index += 1) {
const timestamp = normalizeProviderReplayTimestamp(messageEntries[index]?.timestamp);
for (const event of mapper.mapMessages([messages[index]!])) {
@@ -326,17 +353,52 @@ function mapEntryMessage(entry: OmpSessionEntry): OmpAgentMessage | null {
if (message.role === "system") {
return null;
}
if (message.role === "developer") {
const content = message.content;
if (
Array.isArray(content) &&
content.length === 1 &&
content[0]?.type === "text" &&
typeof content[0].text === "string" &&
content[0].text.trimStart().startsWith("<system-reminder>") &&
content[0].text.trimEnd().endsWith("</system-reminder>")
) {
return null;
}
return visibleFallback(message.role, message);
}
if (["user", "assistant", "toolResult", "custom", "bashExecution"].includes(message.role)) {
return message as unknown as OmpAgentMessage;
}
return visibleFallback(message.role, message);
}
if (entry.type === "custom_message") {
return mapCustomMessageEntry(entry);
}
if (!entry.type || isControlEntryType(entry.type)) {
return null;
}
return visibleFallback(entry.type, entry);
}
// omp 18.1+ persists injected rows (skill prompts, hub messages, job notices) as top-level
// custom_message entries without a message object; replay them as live custom messages
function mapCustomMessageEntry(entry: OmpSessionEntry): OmpAgentMessage | null {
const content = entry.content;
if (typeof content !== "string" && !Array.isArray(content)) {
return null;
}
return {
role: "custom",
content,
id: entry.id,
customType: entry.customType,
display: entry.display,
details: entry.details,
attribution: entry.attribution,
} as OmpAgentMessage;
}
function isControlEntryType(type: string): boolean {
return (
type === "session" ||
@@ -357,6 +419,8 @@ function visibleFallback(role: string, value: Record<string, unknown>): OmpAgent
let text = "Unsupported history record";
if (typeof value.content === "string") {
text = value.content;
} else if (Array.isArray(value.content)) {
text = textOf(value.content) || text;
} else if (typeof value.text === "string") {
text = value.text;
}
@@ -4,10 +4,9 @@ import { z } from "zod";
import type { PaseoToolCatalog, PaseoToolDefinition, PaseoToolResult } from "../../tools/types.js";
import {
clearOmpHostToolState,
handleOmpHostToolRuntimeEvent,
OmpHostToolRouter,
serializeOmpHostTools,
waitForOmpHostToolsIdle,
} from "./host-tools.js";
import type { OmpRpcHostToolResult } from "./rpc-types.js";
import { FakeOmp } from "./test-utils/fake-omp.js";
@@ -33,11 +32,14 @@ class OmpHostToolHarness {
private readonly controlledStart = new Promise<void>((resolve) => {
this.resolveControlledStart = resolve;
});
private readonly router: OmpHostToolRouter;
private constructor(
private readonly catalog: PaseoToolCatalog,
private readonly runtimeSession: Awaited<ReturnType<FakeOmp["startSession"]>>,
) {}
) {
this.router = new OmpHostToolRouter({ runtimeSession, catalog, logger: this.logger });
}
static async withTools(tools: PaseoToolDefinition[]): Promise<OmpHostToolHarness> {
const omp = new FakeOmp();
@@ -69,7 +71,12 @@ class OmpHostToolHarness {
arguments: Record<string, unknown>;
}): Promise<OmpRpcHostToolResult> {
const result = this.runtimeSession.nextHostToolResult();
handleOmpHostToolRuntimeEvent({ type: "host_tool_call", ...input }, this.routerInput());
handleOmpHostToolRuntimeEvent(
{ type: "host_tool_call", ...input },
this.router,
this.runtimeSession,
this.logger,
);
return await result;
}
@@ -82,7 +89,9 @@ class OmpHostToolHarness {
toolName: "wait_for_agent",
arguments: { agentId: "child-1" },
},
this.routerInput(),
this.router,
this.runtimeSession,
this.logger,
);
}
@@ -93,7 +102,9 @@ class OmpHostToolHarness {
cancelControlledCall(): void {
handleOmpHostToolRuntimeEvent(
{ type: "host_tool_cancel", id: "cancel-1", targetId: "host-cancel" },
this.routerInput(),
this.router,
this.runtimeSession,
this.logger,
);
}
@@ -103,7 +114,7 @@ class OmpHostToolHarness {
}
async waitForIdle(): Promise<void> {
await waitForOmpHostToolsIdle(this.runtimeSession);
await this.router.waitForIdle();
}
wasControlledCallAborted(): boolean {
@@ -119,11 +130,7 @@ class OmpHostToolHarness {
}
close(): void {
clearOmpHostToolState(this.runtimeSession);
}
private routerInput() {
return { runtimeSession: this.runtimeSession, paseoTools: this.catalog, logger: this.logger };
this.router.clear();
}
}
@@ -6,6 +6,7 @@ import {
} from "../../tools/paseo-tool-serialization.js";
import type { PaseoToolCatalog, PaseoToolResult } from "../../tools/types.js";
import type { OmpRuntimeSession } from "./runtime.js";
import type { OmpMcpBridge } from "./mcp-bridge.js";
import {
OmpRpcHostToolCallRequestSchema,
OmpRpcHostToolCancelRequestSchema,
@@ -24,12 +25,11 @@ interface PendingOmpHostToolCall {
interface OmpHostToolRouterInput {
runtimeSession: OmpRuntimeSession;
catalog: PaseoToolCatalog;
catalog?: PaseoToolCatalog;
bridge?: OmpMcpBridge;
logger: Logger;
}
const routersByRuntimeSession = new WeakMap<OmpRuntimeSession, OmpHostToolRouter>();
export function serializeOmpHostTools(catalog: PaseoToolCatalog): OmpRpcHostToolDefinition[] {
return [...catalog.tools.values()].map((tool) => {
const definition: OmpRpcHostToolDefinition = {
@@ -45,20 +45,11 @@ export function serializeOmpHostTools(catalog: PaseoToolCatalog): OmpRpcHostTool
});
}
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;
},
router: OmpHostToolRouter | undefined,
runtimeSession: OmpRuntimeSession,
logger: Logger,
): boolean {
if (!isRecord(event) || typeof event.type !== "string" || !isOmpHostToolEventType(event.type)) {
return false;
@@ -66,9 +57,8 @@ export function handleOmpHostToolRuntimeEvent(
const call = OmpRpcHostToolCallRequestSchema.safeParse(event);
if (call.success) {
const router = getRouter(input);
if (!router) {
sendMissingCatalogResult(input.runtimeSession, call.data);
sendMissingCatalogResult(runtimeSession, call.data);
return true;
}
router.handleCall(call.data);
@@ -77,17 +67,17 @@ export function handleOmpHostToolRuntimeEvent(
const cancel = OmpRpcHostToolCancelRequestSchema.safeParse(event);
if (cancel.success) {
getRouter(input)?.handleCancel(cancel.data.targetId);
router?.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");
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");
logger.debug({ event }, "Dropped malformed OMP host tool frame");
return true;
}
@@ -95,36 +85,6 @@ 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,
@@ -141,9 +101,10 @@ function isOmpHostToolEventType(type: string): boolean {
return type === "host_tool_call" || type === "host_tool_cancel" || type === "host_tool_update";
}
class OmpHostToolRouter {
export class OmpHostToolRouter {
private readonly runtimeSession: OmpRuntimeSession;
private readonly catalog: PaseoToolCatalog;
private readonly catalog?: PaseoToolCatalog;
readonly bridge?: OmpMcpBridge;
private readonly logger: Logger;
private readonly pendingCalls = new Map<string, PendingOmpHostToolCall>();
private readonly idleWaiters = new Set<() => void>();
@@ -151,9 +112,17 @@ class OmpHostToolRouter {
constructor(input: OmpHostToolRouterInput) {
this.runtimeSession = input.runtimeSession;
this.catalog = input.catalog;
this.bridge = input.bridge;
this.logger = input.logger;
}
async register(): Promise<string[]> {
return await this.runtimeSession.setHostTools([
...(this.catalog ? serializeOmpHostTools(this.catalog) : []),
...(this.bridge?.definitions ?? []),
]);
}
handleCall(request: OmpRpcHostToolCallRequest): void {
const entry: PendingOmpHostToolCall = {
controller: new AbortController(),
@@ -183,6 +152,11 @@ class OmpHostToolRouter {
this.resolveIdleWaiters();
}
async close(): Promise<void> {
this.clear();
await this.bridge?.close();
}
waitForIdle(): Promise<void> {
if (this.pendingCalls.size === 0) return Promise.resolve();
return new Promise((resolve) => this.idleWaiters.add(resolve));
@@ -193,15 +167,20 @@ class OmpHostToolRouter {
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);
},
});
const result = this.bridge?.tool(request.toolName)
? await this.bridge.execute(request.toolName, request.arguments, entry.controller.signal)
: ((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);
},
})) ?? {
content: [{ type: "text", text: `Unknown host tool: ${request.toolName}` }],
isError: true,
});
if (entry.canceled || entry.controller.signal.aborted) {
return;
}
@@ -26,7 +26,7 @@ describe("mapOmpModel thinking options", () => {
const result = mapOmpModel(model, "omp");
expect(result.thinkingOptions?.map((option) => option.id)).toEqual(["high", "xhigh"]);
expect(result.thinkingOptions?.map((option) => option.id)).toEqual(["auto", "high", "xhigh"]);
expect(result.defaultThinkingOptionId).toBe("xhigh");
expect(result.thinkingOptions?.find((option) => option.isDefault)?.id).toBe("xhigh");
});
@@ -37,6 +37,7 @@ describe("mapOmpModel thinking options", () => {
const result = mapOmpModel(model, "omp");
expect(result.thinkingOptions?.map((option) => option.id)).toEqual([
"auto",
"off",
"minimal",
"low",
@@ -57,6 +58,7 @@ describe("mapOmpModel thinking options", () => {
const result = mapOmpModel(model, "omp");
expect(result.thinkingOptions?.map((option) => option.id)).toEqual([
"auto",
"off",
"minimal",
"low",
@@ -93,7 +95,7 @@ describe("mapOmpModel thinking options", () => {
const result = mapOmpModel(model, "omp");
expect(result.thinkingOptions?.map((option) => option.id)).toEqual(["low", "high"]);
expect(result.thinkingOptions?.map((option) => option.id)).toEqual(["auto", "low", "high"]);
expect(result.defaultThinkingOptionId).toBe("low");
expect(result.thinkingOptions?.find((option) => option.isDefault)?.id).toBe("low");
});
@@ -106,7 +108,12 @@ describe("mapOmpModel thinking options", () => {
const result = mapOmpModel(model, "omp");
expect(result.thinkingOptions?.map((option) => option.id)).toEqual(["low", "medium", "high"]);
expect(result.thinkingOptions?.map((option) => option.id)).toEqual([
"auto",
"low",
"medium",
"high",
]);
expect(result.defaultThinkingOptionId).toBe("low");
});
@@ -119,6 +126,7 @@ describe("mapOmpModel thinking options", () => {
const result = mapOmpModel(model, "omp");
expect(result.thinkingOptions?.map((option) => option.id)).toEqual([
"auto",
"off",
"minimal",
"low",
@@ -130,6 +138,22 @@ describe("mapOmpModel thinking options", () => {
expect(result.defaultThinkingOptionId).toBe("medium");
});
test("offers auto alongside reported efforts without making it the default", () => {
const model = baseModel({
reasoning: true,
thinking: { mode: "effort", efforts: ["low", "high"], defaultLevel: "high" },
});
const result = mapOmpModel(model, "omp");
expect(result.thinkingOptions?.at(0)).toEqual({
id: "auto",
label: "Auto",
description: "OMP picks the effort each turn",
});
expect(result.defaultThinkingOptionId).toBe("high");
});
test("preserves provider and model id in the mapped definition", () => {
const model = baseModel({
reasoning: true,
@@ -13,6 +13,7 @@ export const OMP_THINKING_OPTIONS: ReadonlyArray<{
description: string;
isDefault?: boolean;
}> = [
{ id: "auto", label: "Auto", description: "OMP picks the effort each turn" },
{ id: "off", label: "Off", description: "No extra reasoning" },
{ id: "minimal", label: "Minimal", description: "Light reasoning" },
{ id: "low", label: "Low", description: "Faster reasoning" },
@@ -82,8 +83,10 @@ function resolveOmpThinkingConfig(model: OmpModel): {
reportedDefault && filtered.some((option) => option.id === reportedDefault)
? reportedDefault
: (filtered[0]?.id ?? DEFAULT_OMP_THINKING_LEVEL);
// `auto` is a session-level OMP mode, never a per-model effort, so models do not list it.
const options = [OMP_THINKING_OPTIONS[0], ...filtered.filter((option) => option.id !== "auto")];
return {
thinkingOptions: filtered.map((option) =>
thinkingOptions: options.map((option) =>
mapThinkingOption(option, option.id === defaultThinkingOptionId),
),
defaultThinkingOptionId,
@@ -0,0 +1,340 @@
import { fileURLToPath } from "node:url";
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, test, vi } from "vitest";
import { OmpHarness } from "./test-utils/omp-harness.js";
import type { PaseoToolCatalog } from "../../tools/types.js";
const fixture = fileURLToPath(new URL("./test-utils/echo-mcp-server.mjs", import.meta.url));
const healthy = { type: "stdio" as const, command: process.execPath, args: [fixture] };
describe("OMP MCP host tools", () => {
test("registers a stdio MCP tool and proxies its call", async () => {
await mkdir("/tmp/paseo-omp-agent-test", { recursive: true });
const omp = new OmpHarness();
await omp.start({ mcpServers: { local: healthy } });
try {
const tool = omp.registeredHostTools().at(-1)?.[0];
expect(tool?.name).toMatch(/^mcp_local__echo_secret/);
const runtime = omp.runtime();
const result = runtime.nextHostToolResult();
runtime.emit({
type: "host_tool_call",
id: "call-1",
toolCallId: "tool-1",
toolName: tool!.name,
arguments: { word: "hello" },
});
expect(await result).toMatchObject({
id: "call-1",
result: { content: [{ type: "text", text: "MANGO:hello" }] },
});
const failed = runtime.nextHostToolResult();
runtime.emit({
type: "host_tool_call",
id: "call-2",
toolCallId: "tool-2",
toolName: tool!.name,
arguments: { word: "FAIL" },
});
expect(await failed).toMatchObject({
id: "call-2",
isError: true,
result: { isError: true, content: [{ type: "text", text: "fixture failure" }] },
});
runtime.emit({
type: "tool_execution_start",
toolCallId: "tool-1",
toolName: tool!.name,
args: { word: "hello" },
});
runtime.emit({
type: "tool_execution_end",
toolCallId: "tool-1",
toolName: tool!.name,
result: { content: [{ type: "text", text: "MANGO:hello" }] },
});
expect(omp.timeline().at(-1)).toMatchObject({
type: "tool_call",
name: "local / echo_secret",
status: "completed",
detail: {
type: "unknown",
input: { word: "hello" },
output: { content: [{ type: "text", text: "MANGO:hello" }] },
},
});
runtime.emit({
type: "tool_execution_start",
toolCallId: "native-1",
toolName: "mcp__native_echo_secret",
args: { word: "native" },
});
runtime.emit({
type: "tool_execution_end",
toolCallId: "native-1",
toolName: "mcp__native_echo_secret",
result: { content: [{ type: "text", text: "native result" }] },
});
expect(omp.timeline().at(-1)).toMatchObject({
type: "tool_call",
name: "mcp__native_echo_secret",
detail: { type: "unknown", input: { word: "native" } },
});
} finally {
await omp.close();
}
});
test("skips a failed server and registers a healthy one", async () => {
await mkdir("/tmp/paseo-omp-agent-test", { recursive: true });
const omp = new OmpHarness();
await omp.start({
mcpServers: { broken: { type: "stdio", command: "/nonexistent/omp-mcp" }, local: healthy },
});
try {
expect(
omp
.registeredHostTools()
.at(-1)
?.map((tool) => tool.name),
).toEqual([expect.stringMatching(/^mcp_local__echo_secret/)]);
const runtime = omp.runtime();
const result = runtime.nextHostToolResult();
runtime.emit({
type: "host_tool_call",
id: "healthy-call",
toolCallId: "healthy-tool",
toolName: omp.registeredHostTools()[0]![0]!.name,
arguments: { word: "healthy" },
});
expect(await result).toMatchObject({
result: { content: [{ type: "text", text: "MANGO:healthy" }] },
});
} finally {
await omp.close();
}
});
test("starts with a healthy server when another never answers initialize", async () => {
await mkdir("/tmp/paseo-omp-agent-test", { recursive: true });
const omp = new OmpHarness();
const started = Date.now();
await omp.start({
mcpServers: {
silent: {
type: "stdio",
command: process.execPath,
args: ["-e", "process.stdin.resume();setInterval(()=>{},1<<30)"],
},
local: healthy,
},
});
try {
expect(Date.now() - started).toBeLessThan(15_000);
expect(
omp
.registeredHostTools()
.at(-1)
?.map((tool) => tool.name),
).toEqual([expect.stringMatching(/^mcp_local__echo_secret/)]);
} finally {
await omp.close();
}
}, 80_000);
test("passes runtime and launch env to stdio, then applies server env", async () => {
await mkdir("/tmp/paseo-omp-agent-test", { recursive: true });
const omp = new OmpHarness({
runtimeEnv: { OMP_MCP_RUNTIME_ENV: "runtime", OMP_MCP_PRECEDENCE: "runtime" },
});
await omp.start(
{
mcpServers: {
local: { ...healthy, env: { OMP_MCP_PRECEDENCE: "server" } },
},
},
undefined,
{ PASEO_AGENT_ID: "agent-123", OMP_MCP_PRECEDENCE: "launch" },
);
try {
const runtime = omp.runtime();
const result = runtime.nextHostToolResult();
runtime.emit({
type: "host_tool_call",
id: "env-call",
toolCallId: "env-tool",
toolName: omp.registeredHostTools()[0]![0]!.name,
arguments: { word: "ENV" },
});
expect(await result).toMatchObject({
result: {
content: [
{
type: "text",
text: '{"runtime":"runtime","agent":"agent-123","precedence":"server"}',
},
],
},
});
} finally {
await omp.close();
}
});
test("replays the bridge label and Codex-style detail after archive", async () => {
await mkdir("/tmp/paseo-omp-agent-test", { recursive: true });
const omp = new OmpHarness();
await omp.start({ mcpServers: { local: healthy } });
try {
const exposedName = omp.registeredHostTools()[0]![0]!.name;
const persisted = omp.persistence();
expect(persisted?.metadata?.bridgedTools).toEqual([
[exposedName, { server: "local", tool: "echo_secret" }],
]);
const directory = await mkdtemp(join(tmpdir(), "omp-mcp-history-"));
const sessionFile = join(directory, "session.jsonl");
await writeFile(
sessionFile,
[
{ type: "session", id: "root", parentId: null },
{
type: "message",
id: "call",
parentId: "root",
message: {
role: "assistant",
content: [
{
type: "toolCall",
id: "tool-1",
name: exposedName,
arguments: { word: "REVIEW" },
},
],
},
},
{
type: "message",
id: "result",
parentId: "call",
message: {
role: "toolResult",
toolCallId: "tool-1",
toolName: exposedName,
content: [{ type: "text", text: "MANGO:REVIEW" }],
},
},
]
.map((entry) => JSON.stringify(entry))
.join("\n"),
);
const events = await omp.replayHistory({ ...persisted!, nativeHandle: sessionFile });
expect(events.findLast((event) => event.type === "timeline")?.item).toMatchObject({
type: "tool_call",
name: "local / echo_secret",
detail: {
type: "unknown",
input: { word: "REVIEW" },
output: { content: [{ type: "text", text: "MANGO:REVIEW" }] },
},
});
} finally {
await omp.close();
}
});
test("registers Paseo and MCP tools in the same replacement set", async () => {
await mkdir("/tmp/paseo-omp-agent-test", { recursive: true });
const paseoTools: PaseoToolCatalog = {
tools: new Map([
[
"create_agent",
{
name: "create_agent",
description: "Create an agent",
handler: async () => ({ content: [] }),
},
],
]),
getTool: () => undefined,
executeTool: async () => ({ content: [] }),
};
const omp = new OmpHarness();
await omp.start({ mcpServers: { local: healthy } }, paseoTools);
try {
expect(omp.registeredHostTools()).toHaveLength(1);
expect(omp.registeredHostTools()[0]?.map((tool) => tool.name)).toEqual([
"create_agent",
expect.stringMatching(/^mcp_local__echo_secret_/),
]);
} finally {
await omp.close();
}
});
test("registers MCP tools after resume and crash relaunch", async () => {
await mkdir("/tmp/paseo-omp-agent-test", { recursive: true });
const omp = new OmpHarness();
await omp.resume(
{ user: { id: "user-1", text: "hello" }, assistant: { id: "assistant-1", text: "hi" } },
{ mcpServers: { local: healthy } },
);
try {
expect(omp.registeredHostTools().at(-1)?.[0]?.name).toMatch(/^mcp_local__echo_secret/);
omp.processExit("crashed");
await omp.startTurn("again");
await vi.waitFor(() => {
expect(omp.runtimeSessions()).toHaveLength(2);
expect(omp.registeredHostTools().at(-1)?.[0]?.name).toMatch(/^mcp_local__echo_secret/);
});
} finally {
await omp.close();
}
});
test("closes the stdio MCP child with the session", async () => {
await mkdir("/tmp/paseo-omp-agent-test", { recursive: true });
const directory = await mkdtemp(join(tmpdir(), "omp-mcp-pid-"));
const pidFile = join(directory, "pid");
const omp = new OmpHarness();
await omp.start({ mcpServers: { local: { ...healthy, env: { OMP_MCP_PID_FILE: pidFile } } } });
const pid = Number(await readFile(pidFile, "utf8"));
expect(() => process.kill(pid, 0)).not.toThrow();
await omp.close();
expect(() => process.kill(pid, 0)).toThrow();
});
test("cancels an in-flight MCP call when OMP cancels its host tool", async () => {
await mkdir("/tmp/paseo-omp-agent-test", { recursive: true });
const directory = await mkdtemp(join(tmpdir(), "omp-mcp-cancel-"));
const waitFile = join(directory, "waiting");
const cancelFile = join(directory, "cancelled");
const omp = new OmpHarness();
await omp.start({
mcpServers: {
local: {
...healthy,
env: { OMP_MCP_WAIT_FILE: waitFile, OMP_MCP_CANCEL_FILE: cancelFile },
},
},
});
try {
const runtime = omp.runtime();
runtime.emit({
type: "host_tool_call",
id: "wait-call",
toolCallId: "wait-tool",
toolName: omp.registeredHostTools()[0]![0]!.name,
arguments: { word: "WAIT" },
});
await vi.waitFor(async () => expect(await readFile(waitFile, "utf8")).toBe("waiting"));
runtime.emit({ type: "host_tool_cancel", id: "cancel-1", targetId: "wait-call" });
await vi.waitFor(async () => expect(await readFile(cancelFile, "utf8")).toBe("cancelled"));
expect(runtime.hostToolResults).toEqual([]);
} finally {
await omp.close();
}
});
});
@@ -0,0 +1,142 @@
import { createHash } from "node:crypto";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { CallToolResultSchema } from "@modelcontextprotocol/sdk/types.js";
import type { Logger } from "pino";
import type { McpServerConfig } from "../../agent-sdk-types.js";
import type { PaseoToolResult } from "../../tools/types.js";
import type { OmpRpcHostToolDefinition } from "./rpc-types.js";
interface BridgedTool {
client: Client;
server: string;
tool: string;
}
export interface OmpBridgedToolIdentity {
server: string;
tool: string;
}
const SERVER_TIMEOUT_MS = 10_000;
export class OmpMcpBridge {
readonly definitions: OmpRpcHostToolDefinition[] = [];
private readonly tools = new Map<string, BridgedTool>();
private readonly clients: Client[] = [];
private closed = false;
static async connect(
servers: Record<string, McpServerConfig> | undefined,
cwd: string,
logger: Logger,
env: NodeJS.ProcessEnv,
): Promise<OmpMcpBridge> {
const bridge = new OmpMcpBridge();
const connections = await Promise.all(
Object.entries(servers ?? {}).map(async ([serverName, config]) => {
const client = new Client({ name: "paseo-omp-mcp", version: "1.0.0" });
try {
const transport = makeTransport(config, cwd, env);
await client.connect(transport, { timeout: SERVER_TIMEOUT_MS });
const listed = await client.listTools(undefined, { timeout: SERVER_TIMEOUT_MS });
return { serverName, client, tools: listed.tools };
} catch (error) {
logger.warn({ err: error, server: serverName }, "OMP MCP server unavailable");
await client.close().catch(() => undefined);
return null;
}
}),
);
for (const connection of connections) {
if (!connection) continue;
const { serverName, client, tools } = connection;
bridge.clients.push(client);
for (const tool of tools) {
const name = bridgeName(serverName, tool.name);
bridge.tools.set(name, { client, server: serverName, tool: tool.name });
bridge.definitions.push({
name,
label: `${serverName} / ${tool.name}`,
description: tool.description || `${tool.name} from ${serverName}`,
loadMode: "essential",
parameters: tool.inputSchema,
});
}
}
return bridge;
}
tool(name: string): OmpBridgedToolIdentity | undefined {
const entry = this.tools.get(name);
return entry ? { server: entry.server, tool: entry.tool } : undefined;
}
toolIdentities(): Map<string, OmpBridgedToolIdentity> {
return new Map(
[...this.tools].map(([name, entry]) => [name, { server: entry.server, tool: entry.tool }]),
);
}
async execute(
name: string,
args: Record<string, unknown>,
signal: AbortSignal,
): Promise<PaseoToolResult> {
const entry = this.tools.get(name);
if (!entry || this.closed) throw new Error(`MCP tool ${name} is unavailable`);
const raw = await entry.client.callTool({ name: entry.tool, arguments: args }, undefined, {
signal,
});
const result = CallToolResultSchema.parse(raw);
return {
content: result.content.map((item) => ({ ...item })),
...(result.structuredContent !== undefined
? { structuredContent: result.structuredContent }
: {}),
...(result.isError !== undefined ? { isError: result.isError } : {}),
};
}
async close(): Promise<void> {
if (this.closed) return;
this.closed = true;
await Promise.all(this.clients.map((client) => client.close().catch(() => undefined)));
this.clients.length = 0;
}
}
function makeTransport(config: McpServerConfig, cwd: string, env: NodeJS.ProcessEnv) {
if (config.type === "stdio") {
return new StdioClientTransport({
command: config.command,
args: config.args,
cwd,
env: { ...env, ...config.env } as Record<string, string>,
stderr: "ignore",
});
}
if (config.type === "http") {
return new StreamableHTTPClientTransport(new URL(config.url), {
requestInit: { headers: config.headers },
});
}
return new SSEClientTransport(new URL(config.url), {
requestInit: { headers: config.headers },
eventSourceInit: {
fetch: (url, init) =>
fetch(url, { ...init, headers: { ...init?.headers, ...config.headers } }),
},
});
}
function bridgeName(server: string, tool: string): string {
const key = `${server}\0${tool}`;
const hash = createHash("sha256").update(key).digest("hex").slice(0, 12);
const clean = (name: string, length: number) =>
name.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, length);
// 64 characters also fits the stricter model tool-name limits OMP can reach.
return `mcp_${clean(server, 20)}__${clean(tool, 25)}_${hash}`;
}
@@ -1,12 +1,18 @@
import type { AgentStreamEvent, AgentTimelineItem, ToolCallDetail } from "../../agent-sdk-types.js";
import type { OmpAgentMessage, OmpImageContent, OmpTextContent } from "./rpc-types.js";
import { shouldDisplayOmpCustomMessage } from "./custom-message.js";
import type { OmpBridgedToolIdentity } from "./mcp-bridge.js";
import {
ompCustomMessageId,
ompSkillPromptUserText,
shouldDisplayOmpCustomMessage,
} from "./custom-message.js";
import {
extractTextFromToolResult,
mapToolDetail,
parseToolArgs,
parseToolResult,
resolveToolCallName,
toolFailureMessage,
isOmpToolFailure,
type OmpToolResult,
type OmpTrackedToolCall,
} from "./tool-call-detail.js";
@@ -26,7 +32,7 @@ export interface OmpHistoryMapperHooks {
mapToolDetail?: (
toolCall: OmpTrackedToolCall,
result: OmpToolResult,
context: { toolCallId: string },
context: { toolCallId: string; bridgedTool?: OmpBridgedToolIdentity },
) => ToolCallDetail | null;
}
@@ -58,11 +64,13 @@ export class OmpHistoryMapper {
private readonly pendingToolCalls = new Map<string, OmpTrackedToolCall>();
private userIndex = 0;
private assistantIndex = 0;
private customIndex = 0;
constructor(
private readonly provider: string,
private readonly userEntries: readonly OmpCapturedUserMessageEntry[] = [],
private readonly hooks: OmpHistoryMapperHooks = {},
private readonly bridgedTools: ReadonlyMap<string, OmpBridgedToolIdentity> = new Map(),
) {}
mapMessages(messages: readonly OmpAgentMessage[]): AgentStreamEvent[] {
@@ -126,15 +134,30 @@ export class OmpHistoryMapper {
if (mappedEvent) {
return [mappedEvent];
}
return text
? [
{
type: "timeline",
provider: this.provider,
item: { type: "assistant_message", text },
},
]
: [];
if (!text) {
return [];
}
const messageId = ompCustomMessageId(message, () => {
this.customIndex += 1;
return this.customIndex;
});
const skillPrompt = ompSkillPromptUserText(message);
if (skillPrompt) {
return [
{
type: "timeline",
provider: this.provider,
item: { type: "user_message", text: skillPrompt, messageId: `${messageId}-user` },
},
];
}
return [
{
type: "timeline",
provider: this.provider,
item: { type: "assistant_message", text, messageId },
},
];
}
private mapAssistantMessage(
@@ -174,7 +197,7 @@ export class OmpHistoryMapper {
item: {
type: "tool_call",
callId: this.resolveToolCallId(content.id, tracked),
name: tracked.toolName,
name: this.toolName(tracked, null),
status: "running",
detail,
error: null,
@@ -201,10 +224,10 @@ export class OmpHistoryMapper {
provider: this.provider,
item: toToolResultTimelineItem({
callId: this.resolveToolCallId(message.toolCallId, tracked),
name: resolveToolCallName(tracked, result),
isError: Boolean(message.isError),
name: this.toolName(tracked, result),
isError: isOmpToolFailure(tracked, result, Boolean(message.isError)),
detail,
errorText: extractTextFromToolResult(result) ?? "Tool call failed",
errorText: toolFailureMessage(result),
}),
};
}
@@ -242,7 +265,17 @@ export class OmpHistoryMapper {
result: OmpToolResult,
): ToolCallDetail | null {
const hook = this.hooks.mapToolDetail;
return hook ? hook(toolCall, result, { toolCallId }) : mapToolDetail(toolCall, result);
return hook
? hook(toolCall, result, {
toolCallId,
bridgedTool: this.bridgedTools.get(toolCall.toolName),
})
: mapToolDetail(toolCall, result);
}
private toolName(toolCall: OmpTrackedToolCall, result: OmpToolResult): string {
const bridged = this.bridgedTools.get(toolCall.toolName);
return bridged ? `${bridged.server} / ${bridged.tool}` : resolveToolCallName(toolCall, result);
}
}
@@ -6,7 +6,6 @@ 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";
const DEFAULT_OMP_READY_TIMEOUT_MS = 20_000;
const DEFAULT_OMP_RPC_TIMEOUT_MS = 60_000;
@@ -25,7 +24,7 @@ export const OmpProviderParamsSchema = z
.strict();
export interface OmpRuntimeProviderParams {
sessionDir: string;
sessionDir?: string;
readyTimeoutMs: number;
rpcTimeoutMs: number;
}
@@ -137,7 +136,7 @@ export function resolveOmpProviderParams(providerParams: unknown): {
const configuredRpcTimeoutMs = params.rpcTimeoutMs;
return {
runtimeProviderParams: {
sessionDir: params.sessionDir ?? OMP_SESSION_DIR,
sessionDir: params.sessionDir,
readyTimeoutMs: configuredRpcTimeoutMs ?? DEFAULT_OMP_READY_TIMEOUT_MS,
rpcTimeoutMs: configuredRpcTimeoutMs ?? DEFAULT_OMP_RPC_TIMEOUT_MS,
},
@@ -0,0 +1,355 @@
import type {
AgentPermissionRequest,
AgentPermissionResponse,
AgentProvider,
} from "../../agent-sdk-types.js";
import type { OmpAgentMessage, OmpRuntimeEvent } from "./rpc-types.js";
type UiRequest = Extract<OmpRuntimeEvent, { type: "extension_ui_request" }>;
interface UiResponse {
value?: string;
confirmed?: boolean;
cancelled?: boolean;
}
type SendResponse = (id: string, response: UiResponse) => void;
const OTHER = "Other (type your own)";
const ANSWER_HEADER = "Response";
interface SelectOption {
label: string;
description?: string;
}
interface AskQuestion {
title: string;
multi: boolean;
options: SelectOption[];
}
interface PendingAnswer {
question: AskQuestion;
selected: string[];
custom: string | null;
next: "select" | "editor";
}
function record(value: unknown): Record<string, unknown> | null {
return typeof value === "object" && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function string(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}
function readSelectOptions(event: UiRequest): SelectOption[] {
const details = Array.isArray(event.optionDetails) ? event.optionDetails : [];
return (event.options ?? []).map((label, index) => {
const description = string(record(details[index])?.description);
return description?.trim() ? { label, description } : { label };
});
}
function isOptionalInput(placeholder: string | undefined): boolean {
return /\boptional\b|\bskip\b/i.test(placeholder ?? "");
}
function inputTitle(title: string | undefined, placeholder: string | undefined): string {
if (!isOptionalInput(placeholder)) return title ?? "Enter a value";
return /\bcomment\b/i.test(`${title ?? ""}\n${placeholder ?? ""}`)
? "Optional comment"
: "Optional response";
}
function genericQuestion(
event: UiRequest,
provider: AgentProvider,
title: string,
options: SelectOption[],
input: { placeholder?: string; allowEmpty?: boolean; dismissLabel?: string } = {},
): AgentPermissionRequest {
return {
id: event.id,
provider,
name: `OMP ${event.method}`,
kind: "question",
title,
input: {
questions: [
{ question: title, header: ANSWER_HEADER, options, multiSelect: false, ...input },
],
},
metadata: { extensionUiMethod: event.method, answerHeader: ANSWER_HEADER },
};
}
function mapGenericQuestion(
event: UiRequest,
provider: AgentProvider,
): AgentPermissionRequest | null {
switch (event.method) {
case "select":
return genericQuestion(
event,
provider,
event.title ?? "Select an option",
readSelectOptions(event),
);
case "input": {
const placeholder = event.placeholder;
const optional = isOptionalInput(placeholder);
return genericQuestion(event, provider, inputTitle(event.title, placeholder), [], {
...(placeholder ? { placeholder } : {}),
...(optional ? { allowEmpty: true, dismissLabel: "Skip" } : {}),
});
}
case "editor":
return genericQuestion(event, provider, event.title ?? "Edit text", []);
case "confirm":
return genericQuestion(
event,
provider,
[event.title, event.message].filter(Boolean).join("\n\n"),
[{ label: "Yes" }, { label: "No" }],
);
default:
return null;
}
}
function genericResponse(
request: AgentPermissionRequest,
response: AgentPermissionResponse,
): UiResponse {
if (response.behavior === "deny") return { cancelled: true };
const answers = record(response.updatedInput?.answers);
const answer = Object.values(answers ?? {}).find(
(value): value is string => typeof value === "string",
);
if (answer === undefined) return { cancelled: true };
return request.metadata?.extensionUiMethod === "confirm"
? { confirmed: /^yes$/i.test(answer.trim()) }
: { value: answer };
}
function questionTitle(title: string): string {
return title.replace(/^\(\d+ selected\) /, "");
}
function matchesQuestion(title: string, question: AskQuestion): boolean {
const plainTitle = questionTitle(title);
if (plainTitle === question.title) return true;
return (
plainTitle.startsWith(question.title) &&
/^ \(\d+\/\d+\)$/.test(plainTitle.slice(question.title.length))
);
}
function readQuestions(args: unknown): AskQuestion[] {
const questions = record(args)?.questions;
if (!Array.isArray(questions)) return [];
return questions.flatMap((item) => {
const question = record(item);
if (typeof question?.question !== "string" || !Array.isArray(question.options)) return [];
const options = question.options.flatMap((optionValue) => {
const option = record(optionValue);
if (typeof option?.label !== "string") return [];
return [
{
label: option.label,
...(typeof option.description === "string" ? { description: option.description } : {}),
},
];
});
return [{ title: question.question, multi: question.multi === true, options }];
});
}
function selectedAnswer(
answer: string,
labels: string[],
multi: boolean,
): { selected: string[]; custom: string | null } {
if (!multi)
return labels.includes(answer)
? { selected: [answer], custom: null }
: { selected: [], custom: answer };
// The shared question UI sends a comma-joined answer in click order. Consume
// exact option labels from the front; any remaining text is the Other answer.
let remaining = answer;
const selected: string[] = [];
while (remaining.length > 0) {
const label = labels
.filter((candidate) => !selected.includes(candidate))
.sort((left, right) => right.length - left.length)
.find((candidate) => remaining === candidate || remaining.startsWith(`${candidate}, `));
if (!label) break;
selected.push(label);
remaining = remaining === label ? "" : remaining.slice(label.length + 2);
}
return { selected, custom: remaining || null };
}
/** Owns OMP question mapping and the ask tool's multi-RPC answer sequence. */
export class OmpQuestionUi {
private questions: AskQuestion[] = [];
private pending: PendingAnswer | null = null;
observeMessage(message: OmpAgentMessage): void {
if (message.role !== "assistant") return;
for (const item of message.content) {
if (item.type === "toolCall" && item.name === "ask") this.start(item.name, item.arguments);
}
}
start(toolName: string, args: unknown): void {
if (toolName === "ask") {
this.questions = readQuestions(args);
this.pending = null;
}
}
finish(toolName: string): void {
if (toolName === "ask") {
this.questions = [];
this.pending = null;
}
}
handleRequest(
event: UiRequest,
provider: AgentProvider,
send: SendResponse,
): AgentPermissionRequest | null {
if (this.consume(event, send)) return null;
return this.mapAsk(event, provider) ?? mapGenericQuestion(event, provider);
}
private mapAsk(event: UiRequest, provider: AgentProvider): AgentPermissionRequest | null {
if (event.method !== "select" || typeof event.title !== "string") return null;
const title = event.title;
const question = this.questions.find((item) => matchesQuestion(title, item));
if (!question || !Array.isArray(event.options) || !event.options.includes(OTHER)) return null;
const descriptions = new Map(
question.options.map((option) => [option.label, option.description]),
);
const options = readSelectOptions(event)
.filter((option) => option.label !== OTHER && !option.label.endsWith(" Done selecting"))
.map((option) => {
const description =
descriptions.get(option.label) ??
descriptions.get(option.label.replace(/ \(Recommended\)$/, "")) ??
option.description;
return description ? { label: option.label, description } : { label: option.label };
});
return {
id: event.id,
provider,
name: "OMP ask",
kind: "question",
title: question.title,
input: {
questions: [
{
question: question.title,
header: ANSWER_HEADER,
options,
multiSelect: question.multi,
allowOther: true,
},
],
},
metadata: {
ompAsk: true,
answerHeader: ANSWER_HEADER,
optionLabels: options.map((option) => option.label),
},
};
}
respond(
request: AgentPermissionRequest,
response: AgentPermissionResponse,
send: SendResponse,
): void {
if (request.metadata?.ompAsk !== true) {
send(request.id, genericResponse(request, response));
return;
}
const question = this.questions.find((item) => item.title === request.title);
const answers = record(
response.behavior === "allow" ? response.updatedInput?.answers : undefined,
);
const answer = answers?.[ANSWER_HEADER];
if (response.behavior === "deny" || typeof answer !== "string" || !question) {
this.pending = null;
send(request.id, { cancelled: true });
return;
}
const labels = Array.isArray(request.metadata.optionLabels)
? request.metadata.optionLabels.filter((item): item is string => typeof item === "string")
: [];
const { selected, custom } = selectedAnswer(answer, labels, question.multi);
if (selected.length === 0 && custom === null) {
this.pending = null;
send(request.id, { cancelled: true });
return;
}
if (question.multi) {
this.pending = { question, selected: selected.slice(1), custom, next: "select" };
send(request.id, { value: selected[0] ?? OTHER });
if (selected.length === 0) this.pending.next = "editor";
} else if (custom !== null) {
this.pending = { question, selected: [], custom, next: "editor" };
send(request.id, { value: OTHER });
} else {
this.pending = null;
send(request.id, { value: selected[0] });
}
}
consume(event: UiRequest, send: SendResponse): boolean {
const pending = this.pending;
if (pending?.next === "editor" && event.method === "editor" && pending.custom !== null) {
this.pending = null;
send(event.id, { value: pending.custom });
return true;
}
if (
!pending ||
typeof event.title !== "string" ||
!matchesQuestion(event.title, pending.question)
)
return false;
if (pending.next !== "select" || event.method !== "select" || !Array.isArray(event.options))
return false;
const choice = pending.selected[0];
if (choice && event.options.includes(choice)) {
pending.selected.shift();
send(event.id, { value: choice });
return true;
}
if (pending.custom !== null && event.options.includes(OTHER)) {
pending.next = "editor";
send(event.id, { value: OTHER });
return true;
}
const done = event.options.find((option) => option.endsWith(" Done selecting"));
if (!done) {
// OMP omits Done for multi-question asks and expects a right-arrow
// navigation callback, which its RPC response cannot express.
this.pending = null;
send(event.id, { cancelled: true });
return true;
}
this.pending = null;
send(event.id, { value: done });
return true;
}
}
@@ -8,6 +8,7 @@ export const OmpThinkingLevelSchema = z.enum([
"high",
"xhigh",
"max",
"auto",
]);
export const OmpImageContentSchema = z
@@ -128,6 +129,8 @@ export const OmpSessionStateSchema = z
isStreaming: z.boolean(),
isCompacting: z.boolean(),
autoCompactionEnabled: z.boolean().optional(),
fastModeEnabled: z.boolean().optional(),
fastModeActive: z.boolean().optional(),
sessionFile: z.string().optional(),
sessionId: z.string(),
sessionName: z.string().optional(),
@@ -333,7 +336,8 @@ export const OmpAgentSessionEventSchema = z.discriminatedUnion("type", [
export const OmpTodoItemSchema = z
.object({
content: z.string(),
status: z.enum(["pending", "in_progress", "completed", "abandoned"]),
status: z.enum(["pending", "in_progress", "completed", "abandoned", "blocked"]),
blocker: z.string().optional(),
})
.passthrough();
export const OmpTodoPhaseSchema = z
@@ -495,6 +499,7 @@ export const OmpRuntimeEventSchema = z.discriminatedUnion("type", [
OmpRpcHostToolCallRequestSchema,
OmpRpcHostToolCancelRequestSchema,
OmpRpcHostToolUpdateSchema,
z.object({ type: z.literal("model_changed") }).passthrough(),
]);
const OmpCommandBase = { id: z.string().optional() };
@@ -513,6 +518,13 @@ export const OmpRpcCommandSchema = z.discriminatedUnion("type", [
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("steer"),
message: z.string(),
images: z.array(OmpImageContentSchema).optional(),
}),
z.object({ ...OmpCommandBase, type: z.literal("set_fast_mode"), enabled: z.boolean() }),
z.object({ ...OmpCommandBase, type: z.literal("get_messages") }),
z.object({ ...OmpCommandBase, type: z.literal("get_available_models") }),
z.object({
@@ -52,6 +52,7 @@ export interface OmpRuntimeSession {
setAutoCompaction(enabled: boolean): Promise<void>;
abort(): Promise<void>;
getState(): Promise<OmpSessionState>;
setFastMode(enabled: boolean): Promise<{ enabled: boolean; active: boolean }>;
getMessages(): Promise<OmpAgentMessage[]>;
getAvailableModels(timeoutMs?: number | null): Promise<OmpModel[]>;
setModel(provider: string, modelId: string): Promise<OmpModel>;
@@ -65,7 +66,10 @@ export interface OmpRuntimeSession {
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;
steer(
message: string,
images?: Array<{ type: "image"; data: string; mimeType: string }>,
): Promise<void>;
followUp(
message: string,
images?: Array<{ type: "image"; data: string; mimeType: string }>,
@@ -3,7 +3,11 @@ import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, test } from "vitest";
import { listOmpImportableSessions, readOmpImportSessionConfig } from "./session-descriptor.js";
import {
listOmpImportableSessions,
readOmpImportSessionConfig,
resolveOmpSessionFile,
} from "./session-descriptor.js";
async function writeSession(root: string, relativePath: string, lines: unknown[]): Promise<string> {
const filePath = path.join(root, "sessions", relativePath);
@@ -95,6 +99,50 @@ describe("OMP session descriptor", () => {
});
});
test("resolves a bare session id to its file and reads the same import config", async () => {
const root = await mkdtemp(path.join(tmpdir(), "paseo-omp-session-by-id-"));
const sessionDir = path.join(root, "sessions");
const cwd = path.join(root, "repo");
const sessionId = "01a03dc8-77bb-7000-b0a3-bb7d25477e81";
const sessionFile = await writeSession(
root,
`project/2026-08-26T11-15-43-163Z_${sessionId}.jsonl`,
[
{ type: "title", v: 1, title: "", updatedAt: "2026-08-26T11:15:43.163Z" },
{ type: "session", version: 3, id: sessionId, timestamp: "2026-08-26T11:15:43.163Z", cwd },
{
type: "model_change",
id: "model-1",
timestamp: "2026-08-26T11:15:43.200Z",
model: "anthropic/claude-opus-5",
},
],
);
// A decoy whose filename does not follow the `<timestamp>_<id>` convention
// but whose header carries the id must still be found.
const oddlyNamedId = "renamed-session";
const oddlyNamed = await writeSession(root, "project/notes.jsonl", [
{ type: "session", id: oddlyNamedId, timestamp: "2026-08-27T00:00:00.000Z", cwd },
{
type: "model_change",
id: "model-2",
timestamp: "2026-08-27T00:00:00.100Z",
model: "mimorouter/claude-fable-5-1",
},
]);
await expect(resolveOmpSessionFile(sessionId, { sessionDir })).resolves.toBe(sessionFile);
await expect(resolveOmpSessionFile(sessionFile, { sessionDir })).resolves.toBe(sessionFile);
await expect(resolveOmpSessionFile(oddlyNamedId, { sessionDir })).resolves.toBe(oddlyNamed);
await expect(resolveOmpSessionFile("does-not-exist", { sessionDir })).resolves.toBeNull();
const byId = await readOmpImportSessionConfig(sessionId, { sessionDir });
const byPath = await readOmpImportSessionConfig(sessionFile, { sessionDir });
expect(byId).toEqual({ model: "anthropic/claude-opus-5" });
expect(byId).toEqual(byPath);
await expect(readOmpImportSessionConfig("does-not-exist", { sessionDir })).resolves.toEqual({});
});
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");
@@ -145,4 +193,99 @@ describe("OMP session descriptor", () => {
expect.objectContaining({ providerHandleId: sessionFile, cwd }),
]);
});
test("uses a named OMP profile's session directory", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-omp-profile-home-"));
const cwd = path.join(home, "repo");
const sessionFile = path.join(
home,
".omp",
"profiles",
"qa",
"agent",
"sessions",
"project",
"profile.jsonl",
);
await mkdir(path.dirname(sessionFile), { recursive: true });
await writeFile(sessionFile, JSON.stringify({ type: "session", id: "qa-profile", cwd }));
await expect(
listOmpImportableSessions({
homeDir: home,
env: {},
runtimeSettings: { env: { OMP_PROFILE: "qa" } },
}),
).resolves.toEqual([expect.objectContaining({ providerHandleId: sessionFile })]);
});
test.skipIf(process.platform === "win32")(
"uses a profile's XDG data directory after OMP has created it",
async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-omp-xdg-home-"));
const cwd = path.join(home, "repo");
const xdgDataHome = path.join(home, "xdg-data");
const sessionFile = path.join(
xdgDataHome,
"omp",
"profiles",
"qa",
"sessions",
"project",
"session.jsonl",
);
await mkdir(path.dirname(sessionFile), { recursive: true });
await writeFile(sessionFile, JSON.stringify({ type: "session", id: "xdg-profile", cwd }));
await expect(
listOmpImportableSessions({
homeDir: home,
env: { OMP_PROFILE: "qa", XDG_DATA_HOME: xdgDataHome },
}),
).resolves.toEqual([expect.objectContaining({ providerHandleId: sessionFile })]);
},
);
test.runIf(process.platform === "win32")(
"ignores a profile's XDG data directory on Windows",
async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-omp-windows-xdg-home-"));
const cwd = path.join(home, "repo");
const xdgDataHome = path.join(home, "xdg-data");
const xdgSessionFile = path.join(
xdgDataHome,
"omp",
"profiles",
"qa",
"sessions",
"project",
"xdg.jsonl",
);
const profileSessionFile = path.join(
home,
".omp",
"profiles",
"qa",
"agent",
"sessions",
"project",
"profile.jsonl",
);
await Promise.all([
mkdir(path.dirname(xdgSessionFile), { recursive: true }),
mkdir(path.dirname(profileSessionFile), { recursive: true }),
]);
await Promise.all([
writeFile(xdgSessionFile, JSON.stringify({ type: "session", id: "xdg-profile", cwd })),
writeFile(profileSessionFile, JSON.stringify({ type: "session", id: "qa-profile", cwd })),
]);
await expect(
listOmpImportableSessions({
homeDir: home,
env: { OMP_PROFILE: "qa", XDG_DATA_HOME: xdgDataHome },
}),
).resolves.toEqual([expect.objectContaining({ providerHandleId: profileSessionFile })]);
},
);
});
@@ -2,6 +2,7 @@ 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 { existsSync } from "node:fs";
import type {
ImportableProviderSession,
@@ -11,8 +12,7 @@ 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";
const OMP_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;
@@ -24,7 +24,7 @@ const FULL_SCAN_LINE_LIMIT = 2_000;
const IMPORT_CANDIDATE_OVERSCAN = 40;
const IMPORT_CANDIDATE_MIN = 400;
interface OmpSessionDescriptorOptions extends ListImportableSessionsOptions {
export interface OmpSessionDescriptorOptions extends ListImportableSessionsOptions {
sessionDir?: string;
runtimeSettings?: ProviderRuntimeSettings;
env?: NodeJS.ProcessEnv;
@@ -102,16 +102,70 @@ export async function listOmpImportableSessions(
);
}
/**
* Reads the model/thinking config of an OMP session for import.
*
* `handle` is either an absolute path to the session `.jsonl` (what the
* importable-sessions list hands back) or a bare OMP session id (what
* `paseo import <id>` supplies). Ids are resolved against the configured
* sessions directory; an unresolvable handle yields `{}`.
*/
export async function readOmpImportSessionConfig(
filePath: string,
handle: string,
options: OmpSessionDescriptorOptions = {},
): Promise<OmpImportSessionConfig> {
const filePath = await resolveOmpSessionFile(handle, options);
if (!filePath) return {};
const descriptor = await readOmpSessionDescriptor(filePath);
if (!descriptor) return {};
return toOmpImportSessionConfig(descriptor);
}
/**
* Resolves an import handle to a session file path. Returns the handle itself
* when it already names a readable file; otherwise searches the sessions
* directory for a file whose header `id` matches.
*/
export async function resolveOmpSessionFile(
handle: string,
options: OmpSessionDescriptorOptions = {},
): Promise<string | null> {
const trimmed = handle.trim();
if (!trimmed) return null;
if (await isReadableFile(trimmed)) return trimmed;
const sessionsDir = await resolveOmpSessionsDir(options);
const files = await walkJsonlFiles(sessionsDir);
// OMP names session files `<timestamp>_<id>.jsonl`; try those first so the
// common case opens a single file instead of scanning the directory.
const nameSuffix = `_${trimmed}.jsonl`;
const byName = files.filter((file) => path.basename(file).endsWith(nameSuffix));
for (const file of byName) {
if (await headerSessionIdMatches(file, trimmed)) return file;
}
const rest = byName.length > 0 ? files.filter((file) => !byName.includes(file)) : files;
for (const file of rest) {
if (await headerSessionIdMatches(file, trimmed)) return file;
}
return null;
}
async function isReadableFile(filePath: string): Promise<boolean> {
try {
return (await stat(filePath)).isFile();
} catch {
return false;
}
}
async function headerSessionIdMatches(filePath: string, sessionId: string): Promise<boolean> {
const headChunk = await readHeadChunk(filePath);
if (!headChunk) return false;
return parseSessionHeaderFromChunk(headChunk)?.sessionId === sessionId;
}
async function resolveOmpSessionsDir(options: OmpSessionDescriptorOptions): Promise<string> {
const env = options.env ?? process.env;
const env = { ...(options.env ?? process.env), ...options.runtimeSettings?.env };
const homeDir = options.homeDir ?? homedir();
const baseDir = options.cwd ?? process.cwd();
@@ -119,10 +173,9 @@ async function resolveOmpSessionsDir(options: OmpSessionDescriptorOptions): Prom
return resolveConfigPath(options.sessionDir, { baseDir, homeDir });
}
const agentDir = resolveOmpAgentDir({ runtimeSettings: options.runtimeSettings, env, homeDir });
const agentDir = resolveOmpAgentDir({ env, homeDir });
const envSessionDir =
options.runtimeSettings?.env?.[OMP_SESSION_DIR_ENV] ?? env[OMP_SESSION_DIR_ENV];
const envSessionDir = env[OMP_SESSION_DIR_ENV];
if (envSessionDir?.trim()) {
return resolveConfigPath(envSessionDir, { baseDir, homeDir });
}
@@ -135,20 +188,50 @@ async function resolveOmpSessionsDir(options: OmpSessionDescriptorOptions): Prom
return resolveConfigPath(settingsSessionDir, { baseDir, homeDir });
}
return resolveOmpDefaultSessionsDir(env, agentDir);
}
function resolveOmpDefaultSessionsDir(env: NodeJS.ProcessEnv, agentDir: string): string {
const profile = resolveOmpProfile(env);
const xdgDataHome = env.XDG_DATA_HOME;
if (
xdgDataHome &&
(process.platform === "linux" || process.platform === "darwin") &&
(profile || !env.PI_CODING_AGENT_DIR?.trim())
) {
const xdgRoot = path.join(xdgDataHome, "omp", ...(profile ? ["profiles", profile] : []));
if (existsSync(xdgRoot)) return path.join(xdgRoot, "sessions");
}
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 });
function resolveOmpAgentDir(input: { env: NodeJS.ProcessEnv; homeDir: string }): string {
const env = input.env;
const profile = resolveOmpProfile(env);
if (!profile) {
const configured = env.PI_CODING_AGENT_DIR;
if (configured?.trim()) {
return resolveConfigPath(configured, { baseDir: process.cwd(), homeDir: input.homeDir });
}
}
return path.join(input.homeDir, OMP_CONFIG_DIR_NAME, "agent");
const configRoot = path.join(input.homeDir, env.PI_CONFIG_DIR || OMP_CONFIG_DIR_NAME);
const profileRoot = profile ? path.join(configRoot, "profiles", profile) : configRoot;
return path.join(profileRoot, "agent");
}
function resolveOmpProfile(env: NodeJS.ProcessEnv): string | undefined {
const profile = (env.OMP_PROFILE !== undefined ? env.OMP_PROFILE : env.PI_PROFILE)?.trim();
if (!profile || profile === "default") return undefined;
if (
profile === "." ||
profile === ".." ||
profile.endsWith(".") ||
!/^[a-z0-9][a-z0-9._-]{0,63}$/.test(profile) ||
/^(?:CON|PRN|AUX|NUL|COM[0-9]|LPT[0-9])(?:\..*)?$/i.test(profile)
) {
throw new Error(`Invalid OMP profile "${profile}"`);
}
return profile;
}
async function readConfiguredSessionDir(input: {
@@ -0,0 +1,47 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { writeFileSync } from "node:fs";
if (process.env.OMP_MCP_PID_FILE) writeFileSync(process.env.OMP_MCP_PID_FILE, String(process.pid));
const server = new McpServer({ name: "omp-echo-fixture", version: "1.0.0" });
server.registerTool(
"echo_secret",
{ description: "Returns the fixture word", inputSchema: { word: z.string() } },
async ({ word }, extra) => {
if (word === "ENV") {
return {
content: [
{
type: "text",
text: JSON.stringify({
runtime: process.env.OMP_MCP_RUNTIME_ENV,
agent: process.env.PASEO_AGENT_ID,
precedence: process.env.OMP_MCP_PRECEDENCE,
}),
},
],
};
}
if (word === "WAIT") {
if (process.env.OMP_MCP_WAIT_FILE) writeFileSync(process.env.OMP_MCP_WAIT_FILE, "waiting");
return await new Promise((resolve) => {
extra.signal.addEventListener(
"abort",
() => {
if (process.env.OMP_MCP_CANCEL_FILE) {
writeFileSync(process.env.OMP_MCP_CANCEL_FILE, "cancelled");
}
resolve({ content: [{ type: "text", text: "cancelled" }] });
},
{ once: true },
);
});
}
return word === "FAIL"
? { content: [{ type: "text", text: "fixture failure" }], isError: true }
: { content: [{ type: "text", text: `MANGO:${word}` }] };
},
);
await server.connect(new StdioServerTransport());
@@ -54,6 +54,9 @@ export class FakeOmp implements OmpRuntime {
private readonly sessions: FakeOmpSession[] = [];
private readonly command: [string, ...string[]];
private readonly queuedCommands: OmpRpcSlashCommand[][] = [];
private nextStartError: Error | null = null;
private nextSessionId = 0;
private readonly sessionIdsByFile = new Map<string, string>();
private readonly queuedSubagentSubscriptionErrors = new Map<
FakeOmpSubagentSubscriptionLevel,
Error
@@ -64,12 +67,19 @@ export class FakeOmp implements OmpRuntime {
}
async startSession(input: OmpStartSessionInput): Promise<FakeOmpSession> {
const startError = this.nextStartError;
this.nextStartError = null;
if (startError) throw startError;
const launch = buildOmpLaunch({
command: this.command,
session: input,
});
this.recordedLaunches.push(launch);
const session = new FakeOmpSession(launch);
const sessionId =
(launch.session ? this.sessionIdsByFile.get(launch.session) : undefined) ??
`omp-session-${++this.nextSessionId}`;
const session = new FakeOmpSession(launch, sessionId);
if (!launch.noSession) this.sessionIdsByFile.set(session.state.sessionFile, sessionId);
session.commands = this.queuedCommands.shift() ?? [];
for (const [level, error] of this.queuedSubagentSubscriptionErrors) {
session.subagentSubscriptionErrors.set(level, error);
@@ -83,6 +93,10 @@ export class FakeOmp implements OmpRuntime {
this.queuedCommands.push(commands);
}
failNextStart(error: Error): void {
this.nextStartError = error;
}
failNextSubagentSubscription(level: FakeOmpSubagentSubscriptionLevel, error: Error): void {
this.queuedSubagentSubscriptionErrors.set(level, error);
}
@@ -94,9 +108,15 @@ export class FakeOmp implements OmpRuntime {
}
return session;
}
allSessions(): FakeOmpSession[] {
return [...this.sessions];
}
}
export class FakeOmpSession implements OmpRuntimeSession {
fastModeResult = { enabled: false, active: false };
readonly setFastModeRequests: boolean[] = [];
readonly prompts: Array<{ message: string; imageCount: number }> = [];
readonly compactRequests: Array<{ customInstructions?: string }> = [];
readonly setAutoCompactionRequests: boolean[] = [];
@@ -106,6 +126,7 @@ export class FakeOmpSession implements OmpRuntimeSession {
readonly setThinkingLevelRequests: OmpThinkingLevel[] = [];
readonly handoffRequests: Array<{ customInstructions?: string }> = [];
readonly steerRequests: Array<{ message: string; imageCount: number }> = [];
steerError: Error | null = null;
readonly followUpRequests: Array<{ message: string; imageCount: number }> = [];
readonly hostToolSetRequests: OmpRpcHostToolDefinition[][] = [];
readonly hostToolResults: OmpRpcHostToolResult[] = [];
@@ -152,15 +173,17 @@ export class FakeOmpSession implements OmpRuntimeSession {
private activeHeldPrompt: { promise: Promise<void>; reject: (error: Error) => void } | null =
null;
constructor(launch: OmpRuntimeLaunch) {
constructor(launch: OmpRuntimeLaunch, sessionId = "omp-session-1") {
this.state = {
model: null,
thinkingLevel: "medium",
isStreaming: false,
isCompacting: false,
autoCompactionEnabled: true,
fastModeEnabled: false,
fastModeActive: false,
sessionFile: launch.session ?? "/tmp/omp-session",
sessionId: "omp-session-1",
sessionId,
messageCount: 0,
queuedMessageCount: 0,
};
@@ -269,6 +292,16 @@ export class FakeOmpSession implements OmpRuntimeSession {
return this.state;
}
async setFastMode(enabled: boolean): Promise<{ enabled: boolean; active: boolean }> {
this.setFastModeRequests.push(enabled);
this.state = {
...this.state,
fastModeEnabled: this.fastModeResult.enabled,
fastModeActive: this.fastModeResult.active,
};
return this.fastModeResult;
}
/** Holds every state request until the returned function releases them. */
holdStateRequests(): () => void {
const held: Array<() => void> = [];
@@ -350,8 +383,12 @@ export class FakeOmpSession implements OmpRuntimeSession {
return this.branchMessages;
}
steer(message: string, images?: Array<{ type: "image"; data: string; mimeType: string }>): void {
async steer(
message: string,
images?: Array<{ type: "image"; data: string; mimeType: string }>,
): Promise<void> {
this.steerRequests.push({ message, imageCount: images?.length ?? 0 });
if (this.steerError) throw this.steerError;
}
followUp(
@@ -71,14 +71,18 @@ export class OmpHarness {
providerIdleScheduler?: OmpProviderIdleScheduler;
noTurnScheduler?: OmpNoTurnScheduler;
usagePollScheduler?: OmpUsagePollScheduler;
providerIdleDeadlineMs?: number;
runtimeEnv?: Record<string, string>;
} = {},
) {
this.client = new OmpAgentClient({
logger: pino({ level: "silent" }),
runtimeSettings: { env: options.runtimeEnv },
runtime: this.omp,
providerIdleScheduler: options.providerIdleScheduler,
noTurnScheduler: options.noTurnScheduler,
usagePollScheduler: options.usagePollScheduler,
providerIdleDeadlineMs: options.providerIdleDeadlineMs,
});
}
@@ -151,6 +155,20 @@ export class OmpHarness {
return this.client.capabilities;
}
persistence() {
return this.requireSession().describePersistence();
}
async replayHistory(handle: AgentPersistenceHandle): Promise<AgentStreamEvent[]> {
const session = await this.client.resumeSession(handle, undefined, undefined, {
purpose: "history",
});
const events: AgentStreamEvent[] = [];
for await (const event of session.streamHistory()) events.push(event);
await session.close();
return events;
}
async runPrompt(
input: string,
output: string,
@@ -451,6 +469,36 @@ export class OmpHarness {
return await this.requireSession().setMode(modeId);
}
currentMode() {
return this.requireSession().getCurrentMode();
}
runtimeLaunches() {
return this.omp.recordedLaunches;
}
runtimeSessions() {
return this.omp.allSessions();
}
processExit(error: string): void {
this.omp.latestSession().emit({ type: "process_exit", error });
}
failNextStart(error: Error): void {
this.omp.failNextStart(error);
}
turnFailures(): string[] {
return this.events.flatMap((event) => (event.type === "turn_failed" ? [event.error] : []));
}
threadStartedSessionIds(): string[] {
return this.events.flatMap((event) =>
event.type === "thread_started" ? [event.sessionId] : [],
);
}
async rewind(messageId: string, restoredPrompt: string): Promise<void> {
this.omp.latestSession().branchResponse = { text: restoredPrompt };
await this.requireSession().revertConversation({ messageId });
@@ -471,6 +519,15 @@ export class OmpHarness {
await promptStarted;
}
async startTurn(message: string): Promise<void> {
await this.requireSession().startTurn(message);
await waitForImmediate();
}
startTurnDetached(message: string) {
return this.requireSession().startTurn(message);
}
async interrupt(): Promise<void> {
await this.requireSession().interrupt();
}
@@ -103,4 +103,41 @@ describe("OMP todo mapper", () => {
mapOmpTodoToolResult({ details: { phases: [{ name: "Bad", tasks: [{}] }] } }),
).toBeNull();
});
test("keeps blocked work visible and excludes abandoned work from active todos", () => {
const phases = [
{
name: "Tasks",
tasks: [
{ content: "Wait for access", status: "blocked", blocker: "approval" },
{ content: "Old approach", status: "abandoned" },
],
},
];
const expected = {
type: "todo",
items: [{ text: "Wait for access (blocked: approval)", status: "pending", completed: false }],
};
expect(mapOmpTodoToolResult(parseToolResult({ content: [], details: { phases } }))).toEqual(
expected,
);
expect(
mapOmpTodoState({
isStreaming: false,
isCompacting: false,
sessionId: "s",
todoPhases: phases,
}),
).toEqual([expected]);
expect(
mapOmpTodoToolResult(
parseToolResult({
content: [],
details: {
phases: [{ name: "Tasks", tasks: [{ content: "Old approach", status: "abandoned" }] }],
},
}),
),
).toEqual({ type: "todo", items: [] });
});
});
@@ -39,11 +39,16 @@ function mapOmpTodoItems(items: readonly OmpTodoItem[]): AgentTimelineItem | nul
}
return {
type: "todo",
items: items.map((item) => ({
text: item.content,
status: normalizeOmpTodoStatus(item.status),
completed: item.status === "completed",
})),
items: items
.filter((item) => item.status !== "abandoned")
.map((item) => ({
text:
item.status === "blocked"
? `${item.content} (${item.blocker ? `blocked: ${item.blocker}` : "blocked"})`
: item.content,
status: normalizeOmpTodoStatus(item.status),
completed: item.status === "completed",
})),
};
}
@@ -59,6 +59,7 @@ interface OmpToolResultObject {
interface OmpToolResultDetails {
diff?: string;
error?: string;
mode?: string;
xdev?: unknown;
}
@@ -270,6 +271,39 @@ export function extractTextFromToolResult(result: OmpToolResult): string | undef
return textParts.length > 0 ? textParts.join("\n") : undefined;
}
export function toolFailureMessage(result: OmpToolResult): string {
if (result && typeof result !== "string" && result.details?.error) {
return result.details.error.split("\n", 1)[0].slice(0, 240);
}
const output = extractTextFromToolResult(result);
const exitMessage = output?.match(/(?:Command|Process) exited with code \d+/i)?.[0];
if (exitMessage) return exitMessage;
const firstLine = output
?.split("\n")
.find((line) => line.trim())
?.trim();
if (firstLine) return firstLine.slice(0, 240);
if (result && typeof result !== "string") {
const code = result.exitCode ?? result.code;
if (typeof code === "number") return `Tool exited with code ${code}`;
}
return "Tool call failed";
}
export function isOmpToolFailure(
toolCall: OmpTrackedToolCall,
result: OmpToolResult,
isError: boolean,
): boolean {
return (
isError ||
(toolCall.toolName === "web_search" &&
result !== null &&
typeof result !== "string" &&
typeof result.details?.error === "string")
);
}
export function parseToolArgs(toolName: string, rawArgs: unknown): OmpTrackedToolCall {
if (toolName === "edit") {
return parseEditToolArgs(rawArgs);
@@ -289,6 +323,12 @@ export function parseToolArgs(toolName: string, rawArgs: unknown): OmpTrackedToo
}
export function resolveToolCallName(toolCall: OmpTrackedToolCall, result?: OmpToolResult): string {
if (
(toolCall.kind === "read" || toolCall.kind === "write") &&
toolCall.args.path.startsWith("xd://")
) {
return toolCall.args.path.slice("xd://".length).split(/[/?#]/, 1)[0] || toolCall.toolName;
}
if (toolCall.kind === "write" && result && typeof result !== "string") {
const xdev = XdevExecuteDetailsSchema.safeParse(result.details?.xdev);
if (xdev.success) {
@@ -316,6 +356,20 @@ export function mapToolDetail(
};
}
case "read":
if (toolCall.args.path.startsWith("xd://")) {
return {
type: "plain_text",
label: resolveToolCallName(toolCall),
text: extractTextFromToolResult(parsedResult),
};
}
if (/^https?:\/\//.test(toolCall.args.path)) {
return {
type: "fetch",
url: toolCall.args.path,
result: extractTextFromToolResult(parsedResult),
};
}
return {
type: "read",
filePath: toolCall.args.path,
@@ -374,6 +428,15 @@ function mapWriteToolDetail(args: WriteToolInput, result: OmpToolResult): ToolCa
};
}
// A write to `xd://` runs a tool, not a file write. Name the invoked tool while it runs.
if (args.path.startsWith("xd://")) {
return {
type: "plain_text",
label: args.path.slice("xd://".length).split(/[/?#]/, 1)[0],
text: extractTextFromToolResult(result),
};
}
return {
type: "write",
filePath: args.path,
@@ -117,4 +117,218 @@ describe("OMP tool call mapper", () => {
output: null,
});
});
test("uses current OMP task arguments and wait results for readable rows", () => {
expect(
mapOmpToolDetail(
parseToolArgs("task", {
name: "Docs",
agent: "explore",
task: "Read src/a.ts",
solutionSpace: "Report findings",
}),
null,
),
).toMatchObject({ type: "sub_agent", subAgentType: "Docs", description: "Read src/a.ts" });
expect(
mapOmpToolDetail(
parseToolArgs("wait", {}),
parseToolResult({ content: [{ type: "text", text: "Waiting for Docs to finish" }] }),
),
).toMatchObject({ type: "plain_text", label: "Waiting for Docs to finish" });
});
test("names a task from OMP's tasks array while it runs and after it completes", () => {
const call = parseToolArgs("task", {
context: "Read a scratch file",
tasks: [{ name: "ReadNotes", task: "Read src/notes.ts and report its content" }],
i: "Delegating the read",
});
expect(mapOmpToolDetail(call, null)).toMatchObject({
type: "sub_agent",
subAgentType: "ReadNotes",
description: "Delegating the read",
});
expect(
mapOmpToolDetail(
call,
parseToolResult({ content: [{ type: "text", text: "Spawned agent `ReadNotes`" }] }),
),
).toMatchObject({ type: "sub_agent", subAgentType: "ReadNotes" });
});
test("uses the first task instruction when OMP omits its short intent", () => {
expect(
mapOmpToolDetail(
parseToolArgs("task", {
tasks: [
{
name: "WaitThenRead",
task: "# Target src/notes.ts in the scratch repository.\n# Change\nRun bash sleep 8.",
},
],
}),
null,
),
).toMatchObject({
type: "sub_agent",
subAgentType: "WaitThenRead",
description: "src/notes.ts in the scratch repository.",
});
});
test("skips a standalone target heading in a running task", () => {
expect(
mapOmpToolDetail(
parseToolArgs("task", {
tasks: [
{
name: "SlowRead",
task: "# Target\nsrc/notes.ts in the scratch repository.\n# Change\nRead the file.",
},
],
}),
null,
),
).toMatchObject({ description: "src/notes.ts in the scratch repository." });
});
test("names a structured subagent yield by its action", () => {
expect(
mapOmpToolDetail(
parseToolArgs("yield", { data: { content: "done" } }),
parseToolResult({ content: [{ type: "text", text: "Result submitted." }] }),
),
).toEqual({
type: "plain_text",
label: "Submitted subagent result",
text: "Result submitted.",
});
});
test("connects a steered background bash call with its wait result", () => {
const command = "sleep 2; echo finished";
expect(
mapOmpToolDetail(
parseToolArgs("bash", { command }),
parseToolResult({
content: [
{
type: "text",
text: "Backgrounded early to handle an incoming message; the command keeps running.\nJob: bg_1",
},
],
}),
),
).toMatchObject({ type: "shell", command });
const resultText =
"## Completed (1)\n\n### bg_1 [bash] — completed\nLabel: sleep 2; echo finished\nDelivery: not auto-delivered; recovered by this snapshot.\n```\nfinished\n```";
expect(
mapOmpToolDetail(
parseToolArgs("wait", {}),
parseToolResult({
content: [{ type: "text", text: resultText }],
details: {
op: "wait",
jobs: [
{
id: "bg_1",
type: "bash",
status: "completed",
label: command,
resultText: "finished",
},
],
},
}),
),
).toEqual({ type: "plain_text", label: `bash completed: ${command}`, text: "finished" });
});
test("maps web search and URL reads to search and fetch", () => {
expect(mapOmpToolDetail(parseToolArgs("web_search", { query: "Paseo" }), null)).toMatchObject({
type: "search",
query: "Paseo",
toolName: "web_search",
});
expect(
mapOmpToolDetail(parseToolArgs("read", { path: "https://example.com/" }), null),
).toMatchObject({ type: "fetch", url: "https://example.com/" });
});
test("names virtual device calls from their path on failure and while running", () => {
expect(mapOmpToolDetail(parseToolArgs("read", { path: "xd://ast_grep" }), null)).toMatchObject({
type: "plain_text",
label: "ast_grep",
});
expect(
mapOmpToolDetail(parseToolArgs("write", { path: "xd://lsp", content: "{}" }), null),
).toMatchObject({ type: "plain_text", label: "lsp" });
});
test("renders executed OMP device tools with readable action and output", () => {
expect(
mapOmpToolDetail(
parseToolArgs("write", { path: "xd://github", content: '{"op":"status"}' }),
parseToolResult({
content: [{ type: "text", text: "Working tree clean" }],
details: {
xdev: { tool: "github", mode: "execute", args: { op: "status" }, inner: {} },
},
}),
),
).toEqual({ type: "plain_text", label: "status", text: "Working tree clean" });
});
test("shows the first question in an ask row", () => {
expect(
mapOmpToolDetail(parseToolArgs("ask", { questions: [{ question: "Which file?" }] }), null),
).toMatchObject({ type: "plain_text", label: "Which file?" });
});
test("shows the question and selected answers after ask completes", () => {
const detail = mapOmpToolDetail(
parseToolArgs("ask", { questions: [{ question: "Which colors?" }] }),
parseToolResult({
content: [{ type: "text", text: "User selected: Red, Blue" }],
details: { question: "Which colors?", selectedOptions: ["Red", "Blue"] },
}),
);
expect(detail).toEqual({
type: "plain_text",
label: "Which colors?",
text: "Which colors?\nRed, Blue",
});
});
test("shows meaningful labels for OMP's remaining built-in tools", () => {
expect(mapOmpToolDetail(parseToolArgs("glob", { pattern: "src/**/*.ts" }), null)).toMatchObject(
{ type: "search", query: "src/**/*.ts", toolName: "glob" },
);
expect(
mapOmpToolDetail(parseToolArgs("ast_grep", { pattern: "console.log($X)" }), null),
).toMatchObject({ type: "search", query: "console.log($X)" });
expect(
mapOmpToolDetail(parseToolArgs("checkpoint", { goal: "Inspect parser" }), null),
).toMatchObject({ type: "plain_text", label: "Inspect parser" });
expect(
mapOmpToolDetail(parseToolArgs("manage_skill", { action: "create", name: "demo" }), null),
).toMatchObject({ type: "plain_text", label: "create demo" });
expect(
mapOmpToolDetail(parseToolArgs("think", { thoughts: "Check the next step" }), null),
).toEqual({
type: "plain_text",
label: "Thinking",
text: "Check the next step",
});
});
test("uses OMP ast_grep pat as the search query", () => {
expect(
mapOmpToolDetail(parseToolArgs("ast_grep", { pat: "console.log($A)" }), null),
).toMatchObject({
type: "search",
query: "console.log($A)",
});
});
});
@@ -1,7 +1,9 @@
import type { ToolCallDetail } from "../../agent-sdk-types.js";
import type { OmpBridgedToolIdentity } from "./mcp-bridge.js";
import {
extractTextFromToolResult,
mapToolDetail as mapOmpCoreToolDetail,
resolveToolCallName,
type OmpToolResult,
type OmpTrackedToolCall,
} from "./tool-call-detail.js";
@@ -11,9 +13,13 @@ export function mapOmpToolDetail(
result: OmpToolResult,
context?: {
toolCallId: string;
bridgedTool?: OmpBridgedToolIdentity;
mapSubagentDetail?: (baseDetail: ToolCallDetail) => ToolCallDetail;
},
): ToolCallDetail | null {
if (context?.bridgedTool) {
return { type: "unknown", input: toolCall.args, output: result };
}
if (toolCall.toolName === "todo") {
return null;
}
@@ -21,6 +27,10 @@ export function mapOmpToolDetail(
const detail = mapOmpTaskDetail(toolCall.args, result);
return context?.mapSubagentDetail?.(detail) ?? detail;
}
const deviceDetail = mapOmpDeviceToolDetail(toolCall, result);
if (deviceDetail) return deviceDetail;
const extended = mapOmpExtendedToolDetail(toolCall, result);
if (extended) return extended;
if (toolCall.toolName === "edit") {
return mapOmpEditDetail(toolCall, result);
}
@@ -30,19 +40,160 @@ export function mapOmpToolDetail(
return mapOmpCoreToolDetail(toolCall, result);
}
function mapOmpDeviceToolDetail(
toolCall: OmpTrackedToolCall,
result: OmpToolResult,
): ToolCallDetail | null {
if (toolCall.kind !== "write" || !toolCall.args.path.startsWith("xd://")) return null;
const toolName = resolveToolCallName(toolCall, result);
if (!OPAQUE_TEXT_TOOLS.has(toolName) && toolName !== "ast_grep") return null;
let args: unknown;
try {
args = JSON.parse(toolCall.args.content);
} catch {
args = null;
}
return mapOmpExtendedToolDetail({ kind: "unknown", toolName, args }, result);
}
function mapOmpExtendedToolDetail(
toolCall: OmpTrackedToolCall,
result: OmpToolResult,
): ToolCallDetail | null {
if (toolCall.toolName === "yield") {
return {
type: "plain_text",
label: "Submitted subagent result",
text: extractTextFromToolResult(result),
};
}
if (toolCall.toolName === "wait") {
return mapOmpWaitDetail(result);
}
if (toolCall.toolName === "web_search") {
const args = isRecord(toolCall.args) ? toolCall.args : {};
return {
type: "search",
query: firstString(args.query) ?? "Web search",
toolName: "web_search",
content: extractTextFromToolResult(result),
};
}
if (toolCall.toolName === "ask" || toolCall.toolName === "ask_user") {
return mapOmpAskDetail(toolCall.args, result);
}
if (toolCall.toolName === "glob" || toolCall.toolName === "ast_grep") {
const args = isRecord(toolCall.args) ? toolCall.args : {};
return {
type: "search",
query: firstString(args.pattern, args.pat, args.query) ?? toolCall.toolName,
...(toolCall.toolName === "glob" ? { toolName: "glob" as const } : {}),
content: extractTextFromToolResult(result),
};
}
if (toolCall.toolName === "think") {
const args = isRecord(toolCall.args) ? toolCall.args : {};
return { type: "plain_text", label: "Thinking", text: firstString(args.thoughts) };
}
return mapOmpOpaqueToolDetail(toolCall, result);
}
function mapOmpWaitDetail(result: OmpToolResult): ToolCallDetail {
const text = extractTextFromToolResult(result)?.trim();
const jobs = resultDetails(result)?.jobs;
const firstJob = Array.isArray(jobs) ? jobs.find(isRecord) : undefined;
const jobType = firstString(firstJob?.type);
const jobStatus = firstString(firstJob?.status);
const jobLabel = firstString(firstJob?.label, firstJob?.id);
const label =
jobType && jobStatus && jobLabel
? `${jobType} ${jobStatus}: ${jobLabel}`
: (text?.split("\n", 1)[0] ?? "Waiting for background work");
return {
type: "plain_text",
label,
text: firstString(firstJob?.resultText, firstJob?.errorText) ?? text,
};
}
function mapOmpAskDetail(rawArgs: unknown, result: OmpToolResult): ToolCallDetail {
const args = isRecord(rawArgs) ? rawArgs : {};
const firstQuestion = Array.isArray(args.questions) ? args.questions[0] : undefined;
const askedQuestion = isRecord(firstQuestion) ? firstString(firstQuestion.question) : undefined;
const details =
result && typeof result !== "string" && isRecord(result.details) ? result.details : {};
const answers = Array.isArray(details.results) ? details.results : [details];
const lines = answers.flatMap((answer) => {
if (!isRecord(answer)) return [];
const question = firstString(answer.question);
const selected = Array.isArray(answer.selectedOptions)
? answer.selectedOptions.filter((option): option is string => typeof option === "string")
: [];
const response =
[...selected, firstString(answer.customInput)].filter(Boolean).join(", ") || "No selection";
return question ? [`${question}\n${response}`] : [];
});
const text = lines.length ? lines.join("\n\n") : extractTextFromToolResult(result);
return {
type: "plain_text",
label: firstString(details.question, askedQuestion, args.question, args.title) ?? "Question",
text,
};
}
function mapOmpOpaqueToolDetail(
toolCall: OmpTrackedToolCall,
result: OmpToolResult,
): ToolCallDetail | null {
if (OPAQUE_TEXT_TOOLS.has(toolCall.toolName)) {
const args = isRecord(toolCall.args) ? toolCall.args : {};
const action = firstString(args.action, args.op, args.operation, args.command);
const target = firstString(args.name, args.goal, args.path, args.query, args.id, args.report);
const label =
[action, target].filter(Boolean).join(" ") || toolCall.toolName.replaceAll("_", " ");
return { type: "plain_text", label, text: extractTextFromToolResult(result) };
}
return null;
}
const OPAQUE_TEXT_TOOLS = new Set([
"ast_edit",
"debug",
"eval",
"github",
"checkpoint",
"rewind",
"context_notes",
"new_context",
"security_scan",
"memory_edit",
"retain",
"recall",
"reflect",
"learn",
"manage_skill",
"yield",
"goal",
]);
function mapOmpTaskDetail(args: unknown, result: OmpToolResult): ToolCallDetail {
const argRecord = isRecord(args) ? args : {};
const firstTask = Array.isArray(argRecord.tasks) ? argRecord.tasks.find(isRecord) : undefined;
const resultText = extractTextFromToolResult(result);
const childSessionId = readChildSessionId(result);
return {
type: "sub_agent",
subAgentType: firstString(
firstTask?.name,
argRecord.name,
argRecord.agent,
argRecord.subAgentType,
argRecord.agentType,
argRecord.type,
),
description: firstString(
argRecord.i,
taskInstructionSummary(firstTask?.task),
argRecord.description,
argRecord.task,
argRecord.prompt,
@@ -53,6 +204,15 @@ function mapOmpTaskDetail(args: unknown, result: OmpToolResult): ToolCallDetail
};
}
function taskInstructionSummary(value: unknown): string | undefined {
const instruction = firstString(value);
if (!instruction) return undefined;
return instruction
.split("\n")
.map((line) => line.replace(/^#+\s*(?:Target|Goal|Task)\s*/i, "").trim())
.find(Boolean);
}
function mapOmpEditDetail(
toolCall: OmpTrackedToolCall,
result: OmpToolResult,