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

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

Closes #2006
Closes #2060
This commit is contained in:
Ethan Greenfeld
2026-07-16 14:20:59 -07:00
committed by GitHub
parent d9a0b3e8d8
commit d2308f4835
72 changed files with 12992 additions and 740 deletions

View File

@@ -36,6 +36,12 @@ Users can also detach an existing subagent from the subagents track. Detach remo
`notifyOnFinish` defaults to `true` for agent-scoped creation and background prompt follow-ups because most delegated work needs to report back to the creating agent. Set it to `false` only for truly fire-and-forget agents or prompts.
## Provider-managed child agents
Some providers can create their own child sessions inside one provider runtime. OMP's task tool reports these with `child_session` events; `AgentManager` imports the live provider handle, stamps `paseo.parent-agent-id`, and surfaces the result as a normal subagent in the parent's subagents track.
The provider still owns the underlying runtime. Paseo keeps an agent record so the child can be opened, tracked, archived, and cascaded with the parent, but prompts and history hydration route through the provider adapter for that native child handle.
## Archive
Archive is a **soft delete**: the agent record stays on disk with `archivedAt` set, the runtime is closed, and the agent disappears from active lists. Archive is **global** — it lives on the server and propagates to every connected client.

View File

@@ -347,9 +347,9 @@ Override the command used to launch any provider with the `command` field. This
The `command` array completely replaces the default command for that provider. The binary must exist on the system — Paseo checks for its availability and will mark the provider as unavailable if not found.
### Pi-compatible forks with their own session directory
### OMP profiles and Pi-compatible forks
OMP already ships as a built-in provider option. It is disabled by default; enable it with:
OMP ships as a first-class built-in provider option. It is disabled by default; enable it with:
```json
{
@@ -361,6 +361,34 @@ OMP already ships as a built-in provider option. It is disabled by default; enab
}
```
Custom OMP profiles should extend `omp`. They inherit the OMP adapter's `rpc-ui` approvals, native Paseo host tools, provider-managed subagents, and import behavior:
```json
{
"agents": {
"providers": {
"omp-work": {
"extends": "omp",
"label": "Oh My Pi (Work)",
"command": ["omp"],
"env": {
"XDG_CONFIG_HOME": "~/.config/omp-work",
"XDG_STATE_HOME": "~/.local/state/omp-work"
},
"params": {
"sessionDir": "~/.local/state/omp-work/omp/agent/sessions",
"smolModel": "openai/gpt-5-mini",
"slowModel": "anthropic/claude-opus-4-1",
"planModel": "openai/o3"
}
}
}
}
}
```
`params.sessionDir` is used only for importing sessions that were started outside Paseo. If `command` or XDG env vars move OMP's state directory, set `params.sessionDir` to the resulting OMP JSONL session directory; launching and resuming still go through the configured command.
For other providers that keep Pi's `--mode rpc` API but write sessions somewhere else, extend `pi`, replace the command, and provide the JSONL session directory:
```json
@@ -380,7 +408,7 @@ For other providers that keep Pi's `--mode rpc` API but write sessions somewhere
}
```
The session directory is used only for importing sessions that were started outside Paseo. Launching and resuming still go through the configured command, so this example resumes with `my-pi-fork --mode rpc --session <session-file>`.
This session directory is also import-only. Launching and resuming still go through the configured command, so this example resumes with `my-pi-fork --mode rpc --session <session-file>`.
---

View File

@@ -21,7 +21,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Agent session** — One running instance of an agent inside a workspace (one provider, one model, one cwd, one timeline). The conceptual unit; in the UI this opens as a tab. Moving toward this as the canonical term over "Agent". Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`).
- **Session** — Two senses: (a) per-client connection to a daemon, internal; (b) user-facing agent session, see **Agent session**. Code: `Session` (`packages/server/src/server/session.ts`) for (a). Don't confuse with: provider-side agent session log.
- **Profile** — Internal name for the persisted shape of a host. Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Never user-facing.
- **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi, OMP). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/protocol/src/messages.ts:198`).
- **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi, Oh My Pi). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/protocol/src/messages.ts:198`).
- **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/protocol/src/messages.ts:187`).
- **Tab** — UI surface representing one session inside a workspace. Not a conceptual unit; use **Agent session** when talking about the model. Code: `WorkspaceTabDescriptor` (`packages/app/src/screens/workspace/workspace-tabs-types.ts`).
- **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/protocol/src/terminal-stream-protocol.ts`).
@@ -35,6 +35,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Agent controls** — Provider, model, mode, thinking, and provider-feature controls for an agent or draft agent. Code: `AgentControls` / `DraftAgentControls` (`packages/app/src/composer/agent-controls/index.tsx`). Forbidden: "Agent status bar".
- **Composer footer** — Optional area rendered below the composer input but still inside the keyboard-shifted composer layout. Code: `Composer.footer` (`packages/app/src/composer/index.tsx`).
- **Composer track** — A contextual lane above the composer input. Specific tracks use the `<thing> track` form: **Queue track**, **Subagents track**. Code: queue track inside `Composer` (`packages/app/src/composer/index.tsx`), `SubagentsTrack` (`packages/app/src/subagents/track.tsx`).
- **Subagent** — User-facing term for an agent session related to a parent agent session. Use **subagents** in UI copy and docs. Internal daemon/provider plumbing may say "child agent" or `child_session`, especially for provider-managed imports; do not surface "child agent" as a product term.
- **Attachment tray** — The selected-attachments row inside the composer input, above the text input. Code: `renderAttachmentTray` (`packages/app/src/composer/index.tsx`). Forbidden: "Attachment bar".
- **Conflict** — Two distinct senses; do NOT use the bare word in UI copy without qualifying which: (a) **stale-write conflict** on `paseo.json` ("Config changed on disk", code `stale_project_config`, `packages/app/src/screens/project-settings-screen.tsx:593`); (b) **git merge conflict** (no current UI string).

View File

@@ -16,7 +16,7 @@ Copilot custom agents are exposed through ACP session config, not the slash-comm
Implement the `AgentClient` and `AgentSession` interfaces from `agent-sdk-types.ts` yourself. This gives full control but requires you to handle process management, streaming, permissions, and session persistence from scratch.
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode-agent.ts`), `pi` (`providers/pi/agent.ts`), and `omp` (a Pi-compatible built-in backed by the Pi adapter). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode-agent.ts`), `pi` (`providers/pi/agent.ts`), and `omp` (`providers/omp/agent.ts`). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
Claude first-party model metadata lives in `packages/server/src/server/agent/providers/claude/model-manifest.ts`. When adding or updating a Claude model, update that manifest only; the model picker thinking options and Claude-specific feature gates are derived from the manifest. Do not add model-specific Claude capability lists in feature code.
@@ -32,9 +32,9 @@ Pi MCP support depends on the open-source `pi-mcp-adapter` extension being loade
Pi import discovery reads Pi's persisted JSONL session files because Pi RPC does not expose a recent-session listing command. Resume and full history hydration still go through `pi --mode rpc` using the session file as `nativeHandle`.
OMP is a built-in Pi-compatible provider, disabled by default. It uses the `omp` command and imports terminal-started sessions from `~/.omp/agent/sessions` when enabled. Other Pi-compatible forks can still be custom providers that extend `pi`, override `command`, and set `params.sessionDir` to their JSONL session directory.
OMP is a first-class built-in provider, disabled by default. Its launch contract, typed runtime, agent/session behavior, history, permissions, imports, and test fake live under `providers/omp/`; only the provider-neutral JSONL child-process transport is shared with Pi. It launches `omp --mode rpc-ui`, uses OMP's `get_available_commands` RPC for slash-command discovery, bridges OMP `rpc-ui` approval dialogs into Paseo permissions, and imports terminal-started sessions from `~/.omp/agent/sessions` when enabled.
Pi and OMP currently use different RPC names for slash-command discovery. The Pi package accepts `get_commands`; OMP accepts `get_available_commands`. Keep this as an explicit adapter setting for the built-in provider instead of probing with a fallback, because both packages return unknown-command errors without the request `id`, which otherwise turns a fast mismatch into the normal RPC timeout.
OMP supports native Paseo host tools. The adapter registers the caller-scoped Paseo tool catalog directly with OMP, so `create_agent`, `send_agent_prompt`, `wait_for_agent`, and related tools do not need the internal MCP fallback. OMP's provider-managed task subagents are surfaced as Paseo subagents through `child_session` imports; the parent keeps the subagents track while the child runtime stays owned by OMP. Custom OMP profiles should extend `omp`; other Pi-compatible forks can still extend `pi`, override `command`, and set `params.sessionDir` to their JSONL session directory.
Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are bridged into Paseo question permissions and answered with `extension_ui_response`. Pi extensions such as `ask_user` may chain dialogs: for example, a `select` can be followed by an optional-comment `input`. When an `ask_user` tool call declares `allowComment: true`, Paseo presents the selection and optional comment as one question permission, answers Pi's initial `select` immediately, then auto-answers the follow-up optional `input` with the comment the user already supplied (or an empty string). Preserve placeholders and optional/skip semantics for standalone optional inputs so the app can still distinguish "skip this optional input" from "cancel the whole dialog." Fire-and-forget extension UI requests such as notifications are intentionally ignored by the provider adapter unless Paseo grows first-class UI for them.

View File

@@ -59,7 +59,7 @@ async function closeTopSheet(page: Page) {
async function closeSheetByHeaderButton(page: Page, testId: string) {
const sheet = page.getByTestId(testId);
await sheet.getByLabel("Close", { exact: true }).click({ force: true });
await sheet.getByLabel("Close", { exact: true }).click();
await expect(sheet).not.toBeVisible({ timeout: 10_000 });
}

View File

@@ -1,4 +1,4 @@
import Svg, { Path } from "react-native-svg";
import Svg, { Circle, Rect } from "react-native-svg";
interface OmpIconProps {
size?: number;
@@ -6,9 +6,17 @@ interface OmpIconProps {
}
export function OmpIcon({ size = 16, color = "currentColor" }: OmpIconProps) {
// Ported from the Oh My Pi MIT-licensed assets/icon.svg mark.
return (
<Svg width={size} height={size} viewBox="0 0 64 64" fill={color}>
<Path d="M10 14h44v9H43v33h-9V23h-9v22h-9V23H10z" fill={color} />
<Svg width={size} height={size} viewBox="0 0 120 90" fill="none">
<Rect x={10} y={8} width={100} height={12} rx={2} fill={color} />
<Rect x={25} y={20} width={12} height={62} rx={2} fill={color} />
<Rect x={75} y={20} width={12} height={45} rx={2} fill={color} />
<Rect x={71} y={55} width={20} height={16} rx={3} fill="#f97316" />
<Rect x={76} y={59} width={3} height={8} rx={1} fill="#0d0d0d" />
<Rect x={82} y={59} width={3} height={8} rx={1} fill="#0d0d0d" />
<Circle cx={18} cy={14} r={2} fill="#f97316" opacity={0.8} />
<Circle cx={102} cy={14} r={2} fill="#f97316" opacity={0.8} />
</Svg>
);
}

View File

@@ -142,6 +142,24 @@ const OPENCODE_MODES: AgentProviderModeDefinition[] = [
},
];
export const OMP_MODES: AgentProviderModeDefinition[] = [
{
id: "full",
label: "Full Access",
description: "Launches OMP with yolo approval mode so tools run without prompts.",
icon: "ShieldOff",
colorTier: "dangerous",
isUnattended: true,
},
{
id: "ask",
label: "Always Ask",
description: "Launches OMP with always-ask approval mode for write and exec tools.",
icon: "ShieldCheck",
colorTier: "safe",
},
];
const MOCK_LOAD_TEST_MODES: AgentProviderModeDefinition[] = [
{
id: "load-test",
@@ -217,11 +235,11 @@ export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
},
{
id: "omp",
label: "OMP",
description: "Pi-compatible coding agent distributed as Oh My Pi",
label: "Oh My Pi",
description: "Multi-provider coding agent with native approvals, host tools, and subagents",
enabledByDefault: false,
defaultModeId: null,
modes: [],
defaultModeId: "full",
modes: OMP_MODES,
},
];

View File

@@ -6,6 +6,7 @@ import type {
ServerRequest,
} from "@modelcontextprotocol/sdk/types.js";
import { addModelVisibleStructuredContent } from "./tools/paseo-tool-serialization.js";
import { createPaseoToolCatalog, type PaseoToolHostDependencies } from "./tools/paseo-tools.js";
import type { PaseoToolResult } from "./tools/types.js";
@@ -13,62 +14,18 @@ export type AgentMcpServerOptions = PaseoToolHostDependencies;
type McpToolContext = RequestHandlerExtra<ServerRequest, ServerNotification>;
function formatStructuredContentForModel(structuredContent: unknown): string {
if (
!structuredContent ||
typeof structuredContent !== "object" ||
Array.isArray(structuredContent)
) {
return JSON.stringify(structuredContent, null, 2);
}
const record = structuredContent as Record<string, unknown>;
const summary: string[] = [];
for (const [key, value] of Object.entries(record)) {
if (!Array.isArray(value)) {
continue;
}
summary.push(`${key}_count=${value.length}`);
const ids = value
.map((item) =>
item && typeof item === "object" && !Array.isArray(item)
? (item as Record<string, unknown>).id
: null,
)
.filter((id): id is string => typeof id === "string" && id.length > 0);
if (ids.length === value.length && ids.length > 0) {
summary.push(`${key}_ids=${ids.join(",")}`);
}
}
const json = JSON.stringify(structuredContent, null, 2);
return summary.length > 0 ? `${summary.join("\n")}\n\n${json}` : json;
}
function addModelVisibleStructuredContent(result: CallToolResult): CallToolResult {
if (result.structuredContent === undefined || result.content.length > 0) {
return result;
}
return {
...result,
content: [
{
type: "text",
text: formatStructuredContentForModel(result.structuredContent),
},
],
};
}
function toMcpToolResult(result: PaseoToolResult): CallToolResult {
return addModelVisibleStructuredContent({
content: result.content as CallToolResult["content"],
...(result.structuredContent !== undefined
? { structuredContent: result.structuredContent as CallToolResult["structuredContent"] }
const modelVisibleResult = addModelVisibleStructuredContent(result);
return {
content: modelVisibleResult.content as CallToolResult["content"],
...(modelVisibleResult.structuredContent !== undefined
? {
structuredContent:
modelVisibleResult.structuredContent as CallToolResult["structuredContent"],
}
: {}),
...(result.isError !== undefined ? { isError: result.isError } : {}),
});
...(modelVisibleResult.isError !== undefined ? { isError: modelVisibleResult.isError } : {}),
};
}
export async function createAgentMcpServer(options: AgentMcpServerOptions): Promise<McpServer> {

View File

@@ -456,6 +456,7 @@ import {
buildProviderRegistry,
createAllClients,
} from "./provider-registry.js";
import { FakeOmp } from "./providers/omp/test-utils/fake-omp.js";
const logger = createTestLogger();
@@ -523,28 +524,27 @@ test("built-in override applies env", () => {
});
});
test("OMP is a disabled built-in backed by the Pi adapter", () => {
const registry = buildProviderRegistry(logger);
test("OMP is a disabled built-in backed by the real OMP adapter", async () => {
const omp = new FakeOmp();
const registry = buildProviderRegistry(logger, { ompRuntime: omp });
expect(registry.omp).toMatchObject({
id: "omp",
label: "OMP",
label: "Oh My Pi",
enabled: false,
derivedFromProviderId: null,
});
expect(registry.omp.createClient(logger).provider).toBe("omp");
expect(mockState.constructorArgs.pi.at(-1)).toEqual({
runtimeSettings: {
command: {
mode: "replace",
argv: ["omp"],
},
},
providerParams: {
sessionDir: "~/.omp/agent/sessions",
},
commandsRpcType: "get_available_commands",
});
const client = registry.omp.createClient(logger);
expect(client.provider).toBe("omp");
const session = await client.createSession({ provider: "omp", cwd: "/tmp/registry-omp" });
expect(omp.recordedLaunches).toEqual([
expect.objectContaining({
cwd: "/tmp/registry-omp",
protocolMode: "rpc-ui",
argv: ["omp", "--mode", "rpc-ui", "--approval-mode", "yolo", "--thinking", "medium"],
}),
]);
await session.close();
});
test("OMP can be enabled without custom provider boilerplate", () => {
@@ -574,8 +574,10 @@ test("new provider extending claude appears in registry", () => {
expect(registry.zai.createClient(logger).provider).toBe("zai");
});
test("built-in OMP override passes params to the Pi adapter constructor", () => {
test("built-in OMP override keeps the real OMP adapter enabled and launchable", async () => {
const omp = new FakeOmp(["custom-omp"]);
const registry = buildProviderRegistry(logger, {
ompRuntime: omp,
providerOverrides: {
omp: {
label: "OMP",
@@ -587,21 +589,19 @@ test("built-in OMP override passes params to the Pi adapter constructor", () =>
},
});
expect(registry.omp.createClient(logger).provider).toBe("omp");
expect(mockState.constructorArgs.pi.at(-1)).toEqual({
runtimeSettings: {
command: {
mode: "replace",
argv: ["omp"],
},
env: undefined,
disallowedTools: undefined,
},
providerParams: {
sessionDir: "~/.omp/agent/sessions",
},
commandsRpcType: "get_available_commands",
});
const client = registry.omp.createClient(logger);
const session = await client.createSession({ provider: "omp", cwd: "/tmp/registry-override" });
expect(client.provider).toBe("omp");
expect(omp.recordedLaunches[0]?.argv).toEqual([
"custom-omp",
"--mode",
"rpc-ui",
"--approval-mode",
"yolo",
"--thinking",
"medium",
]);
await session.close();
});
test("new provider extending acp uses GenericACPAgentClient", () => {

View File

@@ -35,6 +35,8 @@ import { CursorACPAgentClient } from "./providers/cursor-acp-agent.js";
import { GenericACPAgentClient } from "./providers/generic-acp-agent.js";
import { KiroACPAgentClient } from "./providers/kiro-acp-agent.js";
import { OpenCodeAgentClient } from "./providers/opencode-agent.js";
import { OmpAgentClient } from "./providers/omp/agent.js";
import type { OmpRuntime } from "./providers/omp/runtime.js";
import { PiRpcAgentClient } from "./providers/pi/agent.js";
import { TraeACPAgentClient } from "./providers/trae-acp-agent.js";
import { MockLoadTestAgentClient } from "./providers/mock-load-test-agent.js";
@@ -79,11 +81,12 @@ export interface BuildProviderRegistryOptions {
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">;
managedProcesses?: ManagedProcessRegistry;
isDev?: boolean;
ompRuntime?: OmpRuntime;
}
interface ProviderClientFactoryOptions extends Pick<
BuildProviderRegistryOptions,
"workspaceGitService" | "managedProcesses"
"workspaceGitService" | "managedProcesses" | "ompRuntime"
> {
providerParams?: unknown;
customProvider?: {
@@ -144,21 +147,11 @@ const PROVIDER_CLIENT_FACTORIES: Record<string, ProviderClientFactory> = {
providerParams: options?.providerParams,
}),
omp: (logger, runtimeSettings, options) =>
new PiRpcAgentClient({
new OmpAgentClient({
logger,
runtimeSettings: mergeRuntimeSettings(
{
command: {
mode: "replace",
argv: ["omp"],
},
},
runtimeSettings,
),
providerParams: options?.providerParams ?? {
sessionDir: "~/.omp/agent/sessions",
},
commandsRpcType: "get_available_commands",
runtimeSettings,
providerParams: options?.providerParams,
runtime: options?.ompRuntime,
}),
mock: (logger) => new MockLoadTestAgentClient(logger),
"mock-slow": () => new MockSlowProviderClient(),
@@ -572,7 +565,10 @@ function createResolvedProviderClient(
function buildResolvedBuiltinProviders(
providerOverrides: Record<string, ProviderOverride>,
runtimeSettings: AgentProviderRuntimeSettingsMap | undefined,
options: Pick<BuildProviderRegistryOptions, "workspaceGitService" | "managedProcesses">,
options: Pick<
BuildProviderRegistryOptions,
"workspaceGitService" | "managedProcesses" | "ompRuntime"
>,
isDev: boolean,
): Map<string, ResolvedProvider> {
const resolvedProviders = new Map<string, ResolvedProvider>();
@@ -602,6 +598,7 @@ function buildResolvedBuiltinProviders(
factory(logger, mergedRuntimeSettings, {
workspaceGitService: options.workspaceGitService,
managedProcesses: options.managedProcesses,
ompRuntime: options.ompRuntime,
providerParams: override?.params,
}),
});
@@ -725,6 +722,7 @@ export function buildProviderRegistry(
{
workspaceGitService: options?.workspaceGitService,
managedProcesses: options?.managedProcesses,
ompRuntime: options?.ompRuntime,
},
options?.isDev === true,
);

View File

@@ -0,0 +1,169 @@
import pino from "pino";
import { describe, expect, test } from "vitest";
import { JsonlRpcProcess, type JsonlRpcExit } from "./jsonl-rpc-process.js";
const CHILD_SOURCE = String.raw`
const readline = require("node:readline");
function respond(command, success, data, error) {
process.stdout.write(JSON.stringify({
type: "response",
id: command.id,
command: command.type,
success,
data,
error,
}) + "\n");
}
readline.createInterface({ input: process.stdin, crlfDelay: Infinity }).on("line", (line) => {
const command = JSON.parse(line);
if (command.type === "echo") {
setTimeout(() => respond(command, true, {
value: command.value,
cwd: process.cwd(),
env: process.env.JSONL_RPC_TEST_VALUE,
args: process.argv.slice(1),
}), command.delayMs || 0);
return;
}
if (command.type === "emit") {
process.stdout.write("not json\n");
process.stdout.write('{"type":"notice","text":"a');
setTimeout(() => {
process.stdout.write('\\u2028b"}\r\n');
respond(command, true, null);
}, 5);
return;
}
if (command.type === "fail") {
respond(command, false, null, "child rejected the request");
return;
}
if (command.type === "hang") {
process.stderr.write("still waiting");
return;
}
if (command.type === "exit") {
process.stderr.write("child exploded");
setTimeout(() => process.exit(7), 5);
}
});
`;
function startProcess(): JsonlRpcProcess {
return new JsonlRpcProcess({
launch: {
command: process.execPath,
args: ["-e", CHILD_SOURCE, "--", "resolved-arg"],
cwd: process.cwd(),
env: { JSONL_RPC_TEST_VALUE: "resolved-env" },
},
logger: pino({ level: "silent" }),
});
}
function nextExit(transport: JsonlRpcProcess): Promise<JsonlRpcExit> {
return new Promise((resolve) => {
const unsubscribe = transport.onExit((exit) => {
unsubscribe();
resolve(exit);
});
});
}
describe("JsonlRpcProcess", () => {
test("spawns a resolved command and correlates concurrent requests", async () => {
const transport = startProcess();
try {
const slow = transport.request({ type: "echo", value: "first", delayMs: 20 });
const fast = transport.request({ type: "echo", value: "second" });
await expect(Promise.all([slow, fast])).resolves.toEqual([
{
value: "first",
cwd: process.cwd(),
env: "resolved-env",
args: ["resolved-arg"],
},
{
value: "second",
cwd: process.cwd(),
env: "resolved-env",
args: ["resolved-arg"],
},
]);
} finally {
await transport.close();
}
});
test("publishes complete LF-delimited JSON messages", async () => {
const transport = startProcess();
const messages: Record<string, unknown>[] = [];
transport.onMessage((message) => messages.push(message));
try {
await transport.request({ type: "emit" });
expect(messages).toEqual([{ type: "notice", text: "a\u2028b" }]);
} finally {
await transport.close();
}
});
test("rejects unsuccessful responses", async () => {
const transport = startProcess();
try {
await expect(transport.request({ type: "fail" })).rejects.toThrow(
"child rejected the request",
);
} finally {
await transport.close();
}
});
test("includes buffered stderr when a request times out", async () => {
const transport = startProcess();
try {
await transport.request({ type: "echo", value: "ready" });
await expect(transport.request({ type: "hang" }, 50)).rejects.toThrow(
"JSONL RPC request timed out for hang\nstill waiting",
);
} finally {
await transport.close();
}
});
test("rejects pending requests and publishes stderr when the child exits", async () => {
const transport = startProcess();
const exit = nextExit(transport);
const request = transport.request({ type: "exit" });
await expect(request).rejects.toThrow("child exploded");
await expect(exit).resolves.toMatchObject({
code: 7,
signal: null,
error: expect.objectContaining({
message: expect.stringContaining("child exploded"),
}),
});
});
test("rejects pending requests while shutting down the child process", async () => {
const transport = startProcess();
await transport.request({ type: "echo", value: "ready" });
const request = transport.request({ type: "hang" });
const rejection = expect(request).rejects.toThrow("JSONL RPC process is closed");
await transport.close();
await rejection;
});
});

View File

@@ -0,0 +1,255 @@
import { type ChildProcess, type ChildProcessWithoutNullStreams } from "node:child_process";
import type { Logger } from "pino";
import { spawnProcess } from "../../../utils/spawn.js";
import { terminateWithTreeKill } from "../../../utils/tree-kill.js";
const DEFAULT_TIMEOUT_MS = 30_000;
const STDERR_BUFFER_LIMIT = 8192;
const GRACEFUL_SHUTDOWN_TIMEOUT_MS = 2_000;
const FORCE_SHUTDOWN_TIMEOUT_MS = 1_000;
export interface JsonlRpcLaunch {
command: string;
args: string[];
cwd: string;
env?: Record<string, string>;
}
interface JsonlRpcResponse {
type: "response";
id?: string;
command?: string;
success?: boolean;
data?: unknown;
error?: string;
}
interface PendingRequest {
resolve: (value: unknown) => void;
reject: (error: Error) => void;
timer: NodeJS.Timeout;
}
export interface JsonlRpcExit {
code: number | null;
signal: NodeJS.Signals | null;
error: Error;
}
export interface JsonlRpcProcessOptions {
launch: JsonlRpcLaunch;
logger: Logger;
diagnosticName?: string;
spawn?: (launch: JsonlRpcLaunch) => ChildProcessWithoutNullStreams;
}
function assertChildWithPipes(
child: ChildProcess,
): asserts child is ChildProcessWithoutNullStreams {
if (!child.stdin || !child.stdout || !child.stderr) {
throw new Error("JSONL RPC process was spawned without stdio streams");
}
}
function spawnJsonlRpcProcess(launch: JsonlRpcLaunch): ChildProcessWithoutNullStreams {
const child = spawnProcess(launch.command, launch.args, {
cwd: launch.cwd,
envOverlay: launch.env,
stdio: ["pipe", "pipe", "pipe"],
});
assertChildWithPipes(child);
return child;
}
export class JsonlRpcProcess {
private readonly child: ChildProcessWithoutNullStreams;
private readonly diagnosticName: string;
private readonly pending = new Map<string, PendingRequest>();
private readonly messageSubscribers = new Set<(message: Record<string, unknown>) => void>();
private readonly exitSubscribers = new Set<(exit: JsonlRpcExit) => void>();
private stderrBuffer = "";
private nextRequestId = 1;
private disposed = false;
private stdoutBuffer = "";
constructor(private readonly options: JsonlRpcProcessOptions) {
this.diagnosticName = options.diagnosticName ?? "JSONL RPC";
this.child = (options.spawn ?? spawnJsonlRpcProcess)(options.launch);
this.child.stdout.on("data", (chunk) => {
this.handleStdoutChunk(chunk.toString());
});
this.child.stderr.on("data", (chunk) => {
this.stderrBuffer += chunk.toString();
if (this.stderrBuffer.length > STDERR_BUFFER_LIMIT) {
this.stderrBuffer = this.stderrBuffer.slice(-STDERR_BUFFER_LIMIT);
}
});
this.child.on("error", (error) => {
this.failAll(error instanceof Error ? error : new Error(String(error)));
});
this.child.on("exit", (code, signal) => {
const error = new Error(
`${this.diagnosticName} process exited with code ${code ?? "null"} and signal ${signal ?? "null"}\n${this.stderrBuffer}`.trim(),
);
const exit = { code, signal, error };
for (const subscriber of this.exitSubscribers) {
subscriber(exit);
}
this.failAll(error);
});
}
onMessage(callback: (message: Record<string, unknown>) => void): () => void {
this.messageSubscribers.add(callback);
return () => {
this.messageSubscribers.delete(callback);
};
}
onExit(callback: (exit: JsonlRpcExit) => void): () => void {
this.exitSubscribers.add(callback);
return () => {
this.exitSubscribers.delete(callback);
};
}
startRequest(
command: { type: string; [key: string]: unknown },
timeoutMs = DEFAULT_TIMEOUT_MS,
): { id: string; promise: Promise<unknown> } {
if (this.disposed) {
return {
id: "",
promise: Promise.reject(new Error(`${this.diagnosticName} process is closed`)),
};
}
const id = `req_${this.nextRequestId}`;
this.nextRequestId += 1;
const promise = new Promise<unknown>((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(id);
reject(
new Error(
`${this.diagnosticName} request timed out for ${command.type}\n${this.stderrBuffer}`.trim(),
),
);
}, timeoutMs);
this.pending.set(id, { resolve, reject, timer });
this.send({ ...command, id });
});
return { id, promise };
}
request(
command: { type: string; [key: string]: unknown },
timeoutMs = DEFAULT_TIMEOUT_MS,
): Promise<unknown> {
return this.startRequest(command, timeoutMs).promise;
}
send(message: Record<string, unknown>): void {
if (this.disposed || this.child.stdin.destroyed || !this.child.stdin.writable) {
return;
}
this.child.stdin.write(`${JSON.stringify(message)}\n`);
}
async close(error = new Error(`${this.diagnosticName} process is closed`)): Promise<void> {
if (this.disposed) return;
this.failAll(error);
try {
this.child.stdin.end();
} catch {
// Ignore cleanup races.
}
const result = await terminateWithTreeKill(this.child, {
gracefulTimeoutMs: GRACEFUL_SHUTDOWN_TIMEOUT_MS,
forceTimeoutMs: FORCE_SHUTDOWN_TIMEOUT_MS,
onForceSignal: () => {
this.options.logger.warn(
{ timeoutMs: GRACEFUL_SHUTDOWN_TIMEOUT_MS },
`${this.diagnosticName} process did not exit after SIGTERM; sending SIGKILL`,
);
},
});
if (result === "kill-timeout") {
this.options.logger.warn(
{ timeoutMs: FORCE_SHUTDOWN_TIMEOUT_MS },
`${this.diagnosticName} process did not report exit after SIGKILL`,
);
}
}
private handleStdoutChunk(chunk: string): void {
this.stdoutBuffer += chunk;
for (;;) {
const newlineIndex = this.stdoutBuffer.indexOf("\n");
if (newlineIndex === -1) {
break;
}
const line = this.stdoutBuffer.slice(0, newlineIndex).replace(/\r$/, "");
this.stdoutBuffer = this.stdoutBuffer.slice(newlineIndex + 1);
if (line.trim()) {
this.handleLine(line);
}
}
}
private handleLine(line: string): void {
let parsed: unknown;
try {
parsed = JSON.parse(line);
} catch (error) {
this.options.logger.warn(
{ error, line },
`Ignoring non-JSON ${this.diagnosticName} stdout line`,
);
return;
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return;
}
const message = parsed as Record<string, unknown>;
if (message.type === "response") {
this.handleResponse(message as unknown as JsonlRpcResponse);
return;
}
for (const subscriber of this.messageSubscribers) {
subscriber(message);
}
}
private handleResponse(response: JsonlRpcResponse): void {
if (!response.id) {
return;
}
const pending = this.pending.get(response.id);
if (!pending) {
return;
}
clearTimeout(pending.timer);
this.pending.delete(response.id);
if (!response.success) {
pending.reject(
new Error(
response.error ?? `${this.diagnosticName} ${response.command ?? "request"} failed`,
),
);
return;
}
pending.resolve(response.data);
}
private failAll(error: Error): void {
if (this.disposed) {
return;
}
this.disposed = true;
for (const pending of this.pending.values()) {
clearTimeout(pending.timer);
pending.reject(error);
}
this.pending.clear();
}
}

View File

@@ -0,0 +1,108 @@
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, test } from "vitest";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
import { formatOmpVersionSupport, OmpAgentClient, resolveOmpDiagnosticPaths } from "./agent.js";
const tempDirs: string[] = [];
afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
});
describe("OMP diagnostics", () => {
test.each([
["omp 16.3.8", "16.3.8 (unsupported; minimum 16.3.9)"],
["16.3.9", "16.3.9 (supported; minimum 16.3.9)"],
["oh-my-pi v17.0.0", "17.0.0 (supported; minimum 16.3.9)"],
])("classifies installed version %s", (output, expected) => {
expect(formatOmpVersionSupport(output)).toBe(expected);
});
test("follows OMP 16.3.9 profile, agent override, and XDG path precedence", async () => {
const home = await makeTempDir();
const xdgData = path.join(home, "xdg-data");
const xdgState = path.join(home, "xdg-state");
const xdgCache = path.join(home, "xdg-cache");
await Promise.all([
mkdir(path.join(xdgData, "omp", "profiles", "work"), { recursive: true }),
mkdir(path.join(xdgState, "omp", "profiles", "work"), { recursive: true }),
mkdir(path.join(xdgCache, "omp", "profiles", "work"), { recursive: true }),
]);
const profiled = resolveOmpDiagnosticPaths(
{
OMP_PROFILE: "work",
PI_PROFILE: "ignored",
PI_CONFIG_DIR: ".omp-custom",
PI_CODING_AGENT_DIR: path.join(home, "ignored-agent-override"),
XDG_DATA_HOME: xdgData,
XDG_STATE_HOME: xdgState,
XDG_CACHE_HOME: xdgCache,
},
home,
"linux",
);
expect(profiled).toEqual({
profile: "work",
configRoot: path.join(home, ".omp-custom", "profiles", "work"),
agentDir: path.join(home, ".omp-custom", "profiles", "work", "agent"),
agentDb: path.join(xdgData, "omp", "profiles", "work", "agent.db"),
xdgDataRoot: path.join(xdgData, "omp", "profiles", "work"),
xdgStateRoot: path.join(xdgState, "omp", "profiles", "work"),
xdgCacheRoot: path.join(xdgCache, "omp", "profiles", "work"),
});
const overridden = resolveOmpDiagnosticPaths(
{ PI_CODING_AGENT_DIR: path.join(home, "custom-agent"), XDG_DATA_HOME: xdgData },
home,
"linux",
);
expect(overridden.agentDir).toBe(path.join(home, "custom-agent"));
expect(overridden.agentDb).toBe(path.join(home, "custom-agent", "agent.db"));
expect(overridden.xdgDataRoot).toBe(path.join(home, ".omp"));
});
test("reports an overridden command and OMP-only paths and caveats", async () => {
const dir = await makeTempDir();
const script = path.join(dir, "fake-omp.cjs");
await writeFile(script, 'process.stdout.write("omp 16.3.9\\n");\n', "utf8");
const agentDir = path.join(dir, "agent");
await mkdir(agentDir);
await writeFile(path.join(agentDir, "agent.db"), "", "utf8");
const client = new OmpAgentClient({
logger: createTestLogger(),
runtimeSettings: {
command: { mode: "replace", argv: [process.execPath, script] },
env: {
OMP_PROFILE: "default",
PI_CODING_AGENT_DIR: agentDir,
},
},
});
const { diagnostic } = await client.getDiagnostic();
expect(diagnostic).toContain("Oh My Pi (OMP)");
expect(diagnostic).toContain(`Configured command: ${process.execPath} ${script}`);
expect(diagnostic).toContain(`Resolved path: ${process.execPath}`);
expect(diagnostic).toContain("Version: omp 16.3.9");
expect(diagnostic).toContain("Version support: 16.3.9 (supported; minimum 16.3.9)");
expect(diagnostic).toContain("Active profile: default");
expect(diagnostic).toContain(`Agent directory: ${agentDir}`);
expect(diagnostic).toContain(`Agent database: ${path.join(agentDir, "agent.db")} (found)`);
expect(diagnostic).toContain("npm-installed OMP requires Bun >= 1.3.14");
expect(diagnostic).not.toContain("auth.json");
expect(diagnostic).not.toContain(".pi/agent");
});
});
async function makeTempDir(): Promise<string> {
const dir = await mkdtemp(path.join(tmpdir(), "paseo-omp-diagnostic-"));
tempDirs.push(dir);
return dir;
}

View File

@@ -0,0 +1,331 @@
import { describe, expect, test } from "vitest";
import type { PaseoToolCatalog } from "../../tools/types.js";
import type { OmpProviderIdleScheduler } from "./agent.js";
import { OmpHarness } from "./test-utils/omp-harness.js";
class ManualIdleScheduler implements OmpProviderIdleScheduler {
private readonly retries: Array<() => void> = [];
private readonly waiters: Array<{ count: number; resolve: () => void }> = [];
private waitCount = 0;
waitForRetry(): Promise<void> {
this.waitCount += 1;
for (const waiter of this.waiters.splice(0)) {
if (this.waitCount >= waiter.count) waiter.resolve();
else this.waiters.push(waiter);
}
return new Promise((resolve) => this.retries.push(resolve));
}
waitForWaits(count: number): Promise<void> {
if (this.waitCount >= count) return Promise.resolve();
return new Promise((resolve) => this.waiters.push({ count, resolve }));
}
retry(): void {
const resolve = this.retries.shift();
if (!resolve) throw new Error("OMP has not requested an idle-state retry");
resolve();
}
}
function createToolCatalog(): PaseoToolCatalog {
return {
tools: new Map([
[
"create_agent",
{
name: "create_agent",
description: "Create a Paseo agent.",
handler: async () => ({ content: [] }),
},
],
]),
getTool: () => undefined,
executeTool: async () => ({ content: [] }),
};
}
describe("OMP agent client and session", () => {
test("owns launch configuration and registers native host tools", async () => {
const omp = new OmpHarness();
await omp.start({ modeId: "ask" }, createToolCatalog());
expect(omp.launchConfiguration()).toEqual({
cwd: "/tmp/paseo-omp-agent-test",
protocolMode: "rpc-ui",
modeId: "ask",
argv: ["omp", "--mode", "rpc-ui", "--approval-mode", "always-ask", "--thinking", "medium"],
});
expect(omp.registeredHostTools()).toEqual([
[expect.objectContaining({ name: "create_agent" })],
]);
expect(omp.capabilities()).toMatchObject({
supportsMcpServers: false,
supportsNativePaseoTools: true,
});
});
test("streams a prompt through completion", async () => {
const omp = new OmpHarness();
await omp.start();
await expect(omp.runPrompt("hello OMP", "hello from OMP")).resolves.toMatchObject({
finalText: "hello from OMP",
});
expect(omp.timeline()).toEqual([
{ type: "user_message", text: "hello OMP", messageId: "user-1" },
{ type: "assistant_message", text: "hello from OMP", messageId: "omp-assistant-1" },
]);
expect(omp.completedTurnCount()).toBe(1);
});
test("does not accept a follow-up until OMP reports stable idle", async () => {
const omp = new OmpHarness();
await omp.start();
await omp.runPrompt("first", "first done", [
{ isStreaming: true, isCompacting: false },
{ isStreaming: false, isCompacting: false },
{ isStreaming: false, isCompacting: false },
]);
await expect(omp.runPrompt("follow-up", "follow-up done")).resolves.toMatchObject({
finalText: "follow-up done",
});
});
test("stays active while OMP remains busy", async () => {
const scheduler = new ManualIdleScheduler();
const omp = new OmpHarness({ providerIdleScheduler: scheduler });
await omp.start();
const { completion } = await omp.startPromptUntilProviderIdle("first", "first done", {
isStreaming: true,
isCompacting: false,
});
await omp.waitForProviderStateChecks(2);
await scheduler.waitForWaits(1);
expect(omp.completedTurnCount()).toBe(0);
scheduler.retry();
await omp.waitForProviderStateChecks(3);
await scheduler.waitForWaits(2);
expect(omp.completedTurnCount()).toBe(0);
omp.reportProviderState({ isStreaming: false, isCompacting: false });
scheduler.retry();
await expect(completion).resolves.toMatchObject({ finalText: "first done" });
});
test("stays active when OMP state checks fail", async () => {
const scheduler = new ManualIdleScheduler();
const omp = new OmpHarness({ providerIdleScheduler: scheduler });
await omp.start();
omp.failProviderStateChecks(new Error("state unavailable"));
const { completion } = await omp.startPromptUntilProviderIdle("first", "first done", {
isStreaming: true,
isCompacting: false,
});
await omp.waitForProviderStateChecks(2);
await scheduler.waitForWaits(1);
expect(omp.completedTurnCount()).toBe(0);
omp.failProviderStateChecks(null);
omp.reportProviderState({ isStreaming: false, isCompacting: false });
scheduler.retry();
await expect(completion).resolves.toMatchObject({ finalText: "first done" });
});
test("does not complete on OMP's extension-notice agent_end", async () => {
const omp = new OmpHarness();
await omp.start();
await expect(
omp.runPromptAfterExtensionNotice("hello OMP", "model turn completed"),
).resolves.toMatchObject({ finalText: expect.stringContaining("model turn completed") });
expect(omp.completedTurnCount()).toBe(1);
});
test("does not complete a queued model turn from OMP's local-only hint", async () => {
const omp = new OmpHarness();
await omp.start();
await expect(
omp.runPromptAfterFalseLocalOnlyHint("hello OMP", "queued model turn completed"),
).resolves.toMatchObject({ finalText: "queued model turn completed" });
expect(omp.completedTurnCount()).toBe(1);
});
test("resumes an OMP session and replays its history", async () => {
const omp = new OmpHarness();
await omp.resume(
{
user: { id: "user-history", text: "continue the audit" },
assistant: { id: "assistant-history", text: "audit context restored" },
},
{ cwd: "/workspace/resumed", modeId: "ask", thinkingOptionId: "high" },
);
expect(omp.launchConfiguration()).toEqual({
cwd: "/workspace/resumed",
protocolMode: "rpc-ui",
modeId: "ask",
session: expect.stringMatching(/[\\/]paseo-omp-resume-.*[\\/]session\.jsonl$/),
argv: [
"omp",
"--mode",
"rpc-ui",
"--approval-mode",
"always-ask",
"--thinking",
"high",
"--session",
expect.stringMatching(/[\\/]paseo-omp-resume-.*[\\/]session\.jsonl$/),
],
});
await expect(omp.history()).resolves.toEqual([
{ type: "user_message", text: "continue the audit", messageId: "user-history" },
{
type: "assistant_message",
text: "audit context restored",
messageId: "assistant-history",
},
]);
});
test("maps permissions and sends the selected OMP response", async () => {
const omp = new OmpHarness();
await omp.start();
omp.requestToolApproval({ id: "approval-1", tool: "bash", detail: "git status" });
expect(omp.pendingPermissions()).toEqual([
expect.objectContaining({ id: "approval-1", name: "bash", kind: "tool" }),
]);
await omp.respondToPermission("approval-1", { behavior: "allow" });
expect(omp.extensionUiResponses()).toEqual([
{ id: "approval-1", response: { value: "Approve" } },
]);
});
test("exposes OMP modes and commands through the domain session", async () => {
const omp = new OmpHarness();
omp.queueCommands([{ name: "review", description: "Review changes", source: "skill" }]);
await omp.start();
await expect(omp.availableModes()).resolves.toEqual([
expect.objectContaining({ id: "full" }),
expect.objectContaining({ id: "ask" }),
]);
await expect(omp.commands()).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({ name: "handoff" }),
expect.objectContaining({ name: "review", kind: "skill" }),
]),
);
await expect(omp.setMode("ask")).resolves.toEqual(expect.objectContaining({ type: "info" }));
});
test("rewinds natively, interrupts, and shuts down", async () => {
const omp = new OmpHarness();
await omp.start();
await omp.rewind("user-history", "from history");
expect(omp.branchRequests()).toEqual(["user-history"]);
await omp.interruptActiveTurn("stop me");
expect(omp.wasAborted()).toBe(true);
expect(omp.canceledTurnCount()).toBe(1);
await omp.close();
expect(omp.isClosed()).toBe(true);
});
test("interrupt terminalizes in-flight tool calls and running subagents", async () => {
const omp = new OmpHarness();
await omp.start();
await omp.requireStartTurn("run something slow");
const runtime = omp.runtime();
runtime.beginTurn();
runtime.emit({
type: "tool_execution_start",
toolCallId: "tool-1",
toolName: "bash",
args: { command: "sleep 30" },
});
runtime.emit({
type: "subagent_lifecycle",
payload: {
id: "child-1",
agent: "worker",
status: "started",
parentToolCallId: "tool-1",
index: 0,
},
});
expect(omp.runningToolCallIds()).toEqual(["tool-1"]);
expect(omp.subagentUpserts()).toEqual([{ id: "child-1", status: "running" }]);
await omp.interrupt();
expect(omp.canceledTurnCount()).toBe(1);
expect(omp.runningToolCallIds()).toEqual([]);
expect(omp.subagentUpserts()).toEqual([
{ id: "child-1", status: "running" },
{ id: "child-1", status: "canceled" },
]);
// Late progress after interrupt must not resurrect a running card.
runtime.emit({
type: "subagent_progress",
payload: {
id: "child-1",
agent: "worker",
index: 0,
progress: { id: "child-1", status: "running" },
parentToolCallId: "tool-1",
},
});
expect(omp.runningToolCallIds()).toEqual([]);
});
test("a resumed session does not re-emit replayed events as live timeline items", async () => {
const omp = new OmpHarness();
await omp.resume({
user: { id: "user-history", text: "continue the audit" },
assistant: { id: "assistant-history", text: "audit context restored" },
});
const runtime = omp.runtime();
// OMP replays pre-existing conversation on startup with --session.
runtime.acceptPrompt("continue the audit", "user-history");
runtime.streamAssistantText("audit context restored", "assistant-history");
expect(omp.timeline()).toEqual([]);
// The first live prompt flows normally.
await expect(omp.runPrompt("next step", "on it")).resolves.toMatchObject({
finalText: "on it",
});
expect(omp.timeline()).toEqual([
{ type: "user_message", text: "next step", messageId: "user-1" },
{ type: "assistant_message", text: "on it", messageId: "omp-assistant-1" },
]);
});
test("re-emitted user message_end frames dedupe by native entry id", async () => {
const omp = new OmpHarness();
await omp.start();
await expect(omp.runPrompt("hello OMP", "hello from OMP")).resolves.toMatchObject({
finalText: "hello from OMP",
});
// OMP can re-send message_end for an entry it already surfaced.
omp.runtime().acceptPrompt("hello OMP", "user-1");
expect(omp.timeline().filter((item) => item.type === "user_message")).toEqual([
{ type: "user_message", text: "hello OMP", messageId: "user-1" },
]);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,195 @@
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
import pino from "pino";
import { describe, expect, test } from "vitest";
import { OmpCliRuntime } from "./cli-runtime.js";
import type { OmpRuntimeLaunch } from "./runtime.js";
type OmpChild = ChildProcessWithoutNullStreams & {
stdin: PassThrough;
stdout: PassThrough;
stderr: PassThrough;
killedSignals: Array<NodeJS.Signals | number | undefined>;
};
function createOmpChild(): OmpChild {
const child = Object.assign(new EventEmitter(), {
stdin: new PassThrough(),
stdout: new PassThrough(),
stderr: new PassThrough(),
exitCode: null,
signalCode: null,
killedSignals: [],
}) as OmpChild;
child.kill = ((signal?: NodeJS.Signals | number) => {
child.killedSignals.push(signal);
queueMicrotask(() => child.emit("exit", null, signal ?? null));
return true;
}) as ChildProcessWithoutNullStreams["kill"];
return child;
}
function createRuntime(child: OmpChild, launches: OmpRuntimeLaunch[] = []): OmpCliRuntime {
return new OmpCliRuntime({
logger: pino({ level: "silent" }),
command: ["omp"],
commandsRpcName: "get_available_commands",
spawnProcess: (launch) => {
launches.push(launch);
return child;
},
});
}
function replyToCommands(
child: OmpChild,
handler: (command: Record<string, unknown>) => unknown,
): void {
let buffer = "";
child.stdin.on("data", (chunk) => {
buffer += chunk.toString();
for (;;) {
const newlineIndex = buffer.indexOf("\n");
if (newlineIndex === -1) break;
const line = buffer.slice(0, newlineIndex);
buffer = buffer.slice(newlineIndex + 1);
const command = JSON.parse(line) as Record<string, unknown>;
const result = handler(command);
child.stdout.write(
`${JSON.stringify({
id: command.id,
type: "response",
command: command.type,
success: true,
data: result,
})}\n`,
);
}
});
}
function withoutRequestId(command: Record<string, unknown>): Record<string, unknown> {
const { id: _id, ...rest } = command;
return rest;
}
describe("OMP CLI runtime", () => {
test("validates session state with the documented queued message count", async () => {
const child = createOmpChild();
replyToCommands(child, () => ({
model: null,
thinkingLevel: "medium",
isStreaming: false,
isCompacting: false,
sessionId: "session-1",
messageCount: 3,
queuedMessageCount: 1,
}));
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
await expect(session.getState()).resolves.toMatchObject({
sessionId: "session-1",
messageCount: 3,
queuedMessageCount: 1,
});
});
test("rejects malformed RPC results instead of trusting transport data", async () => {
const child = createOmpChild();
replyToCommands(child, () => ({
thinkingLevel: "medium",
isStreaming: "no",
isCompacting: false,
sessionId: "session-1",
messageCount: 0,
queuedMessageCount: 0,
}));
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
await expect(session.getState()).rejects.toThrow();
});
test("emits validated known events and drops unknown frames", async () => {
const child = createOmpChild();
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
const eventTypes: string[] = [];
session.onEvent((event) => eventTypes.push(event.type));
child.stdout.write(`${JSON.stringify({ type: "future_control", enabled: true })}\n`);
child.stdout.write(`${JSON.stringify({ type: "notice", level: "info", message: "ready" })}\n`);
expect(eventTypes).toEqual(["notice"]);
});
test("lists commands through get_available_commands", async () => {
const child = createOmpChild();
const commandTypes: string[] = [];
replyToCommands(child, (command) => {
commandTypes.push(String(command.type));
return {
commands: [
{ name: "prewalk", description: "Prewalk at the next action", source: "builtin" },
],
};
});
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
await expect(session.getCommands()).resolves.toEqual([
{
name: "prewalk",
description: "Prewalk at the next action",
source: "builtin",
},
]);
expect(commandTypes).toEqual(["get_available_commands"]);
});
test("accepts model catalogs with null maxTokens from newer OMP binaries", async () => {
const child = createOmpChild();
replyToCommands(child, () => ({
models: [
{
provider: "openai-codex",
id: "gpt-5.6-sol",
name: "gpt-5.6-sol",
maxTokens: null,
},
],
}));
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
await expect(session.getAvailableModels()).resolves.toEqual([
expect.objectContaining({
provider: "openai-codex",
id: "gpt-5.6-sol",
maxTokens: null,
}),
]);
});
test("wraps OMP subagent RPC commands", async () => {
const child = createOmpChild();
const commands: Record<string, unknown>[] = [];
replyToCommands(child, (command) => {
commands.push(command);
return undefined;
});
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
await session.setSubagentSubscription("events");
expect(commands.map(withoutRequestId)).toEqual([
{ type: "set_subagent_subscription", level: "events" },
]);
});
test("accepts the empty prompt acknowledgement emitted by OMP 17", async () => {
const child = createOmpChild();
replyToCommands(child, () => undefined);
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
await expect(session.prompt("hello")).resolves.toEqual({ requestId: "req_1" });
});
});

View File

@@ -0,0 +1,281 @@
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import type { Logger } from "pino";
import type { ProviderRuntimeSettings } from "../../provider-launch-config.js";
import { JsonlRpcProcess, type JsonlRpcLaunch } from "../jsonl-rpc-process.js";
import {
buildOmpLaunch,
type OmpRuntime,
type OmpRuntimeLaunch,
type OmpRuntimeSession,
type OmpStartSessionInput,
} from "./runtime.js";
import {
OmpBranchMessagesResultSchema,
OmpBranchResultSchema,
OmpCommandsResultSchema,
OmpHostToolsResultSchema,
OmpMessagesResultSchema,
OmpModelSchema,
OmpModelsResultSchema,
OmpPromptAckSchema,
OmpRpcCommandSchema,
OmpRuntimeEventSchema,
OmpSessionStateSchema,
OmpSessionStatsSchema,
type OmpThinkingLevel,
type OmpAgentMessage,
type OmpModel,
type OmpPromptAck,
type OmpRpcCommand,
type OmpRpcHostToolDefinition,
type OmpRpcHostToolResult,
type OmpRpcHostToolUpdate,
type OmpRpcSlashCommand,
type OmpRuntimeEvent,
type OmpSessionState,
type OmpSessionStats,
type OmpSubagentSubscriptionLevel,
} from "./rpc-types.js";
const DEFAULT_OMP_COMMAND: [string, ...string[]] = [process.env.OMP_COMMAND ?? "omp"];
const DEFAULT_COMMANDS_RPC_NAME = "get_available_commands";
export interface OmpCliRuntimeOptions {
logger: Logger;
runtimeSettings?: ProviderRuntimeSettings;
command?: [string, ...string[]];
commandsRpcName?: "get_available_commands";
spawnProcess?: (launch: OmpRuntimeLaunch) => ChildProcessWithoutNullStreams;
}
export class OmpCliRuntime implements OmpRuntime {
private readonly command: [string, ...string[]];
private readonly commandsRpcName: "get_available_commands";
private readonly spawnProcess?: (launch: OmpRuntimeLaunch) => ChildProcessWithoutNullStreams;
constructor(private readonly options: OmpCliRuntimeOptions) {
this.command = options.command ?? DEFAULT_OMP_COMMAND;
this.commandsRpcName = options.commandsRpcName ?? DEFAULT_COMMANDS_RPC_NAME;
this.spawnProcess = options.spawnProcess;
}
async startSession(input: OmpStartSessionInput): Promise<OmpRuntimeSession> {
const launch = buildOmpLaunch({
command: this.command,
runtimeSettings: this.options.runtimeSettings,
session: input,
});
const [command, ...args] = launch.argv;
const processLaunch: JsonlRpcLaunch = {
command,
args,
cwd: launch.cwd,
env: launch.env,
};
const spawn = this.spawnProcess;
const processOptions = {
launch: processLaunch,
logger: this.options.logger,
diagnosticName: "OMP RPC",
...(spawn ? { spawn: () => spawn(launch) } : {}),
};
const process = new JsonlRpcProcess(processOptions);
return new OmpCliRuntimeSession(process, this.commandsRpcName);
}
}
class OmpCliRuntimeSession implements OmpRuntimeSession {
private readonly subscribers = new Set<(event: OmpRuntimeEvent) => void>();
activeBranchEntryId?: string;
constructor(
private readonly process: JsonlRpcProcess,
private readonly commandsRpcName: "get_available_commands",
) {
process.onMessage((message) => {
const event = OmpRuntimeEventSchema.safeParse(message);
if (event.success) {
this.emit(event.data);
}
});
process.onExit(({ error }) => {
this.emit({ type: "process_exit", error: error.message });
});
}
onEvent(callback: (event: OmpRuntimeEvent) => void): () => void {
this.subscribers.add(callback);
return () => {
this.subscribers.delete(callback);
};
}
async prompt(
message: string,
images?: Array<{ type: "image"; data: string; mimeType: string }>,
): Promise<OmpPromptAck> {
const { id: requestId, promise } = this.process.startRequest({
type: "prompt",
message,
...(images?.length ? { images } : {}),
});
const ack = OmpPromptAckSchema.parse(await promise) ?? {};
return { requestId, ...ack };
}
async compact(customInstructions?: string): Promise<void> {
await this.request({
type: "compact",
...(customInstructions ? { customInstructions } : {}),
});
}
async setAutoCompaction(enabled: boolean): Promise<void> {
await this.request({ type: "set_auto_compaction", enabled });
}
async abort(): Promise<void> {
await this.request({ type: "abort" });
}
async getState(): Promise<OmpSessionState> {
return OmpSessionStateSchema.parse(await this.request({ type: "get_state" }));
}
async getMessages(): Promise<OmpAgentMessage[]> {
const data = OmpMessagesResultSchema.parse(await this.request({ type: "get_messages" }));
return data.messages ?? [];
}
async getAvailableModels(timeoutMs?: number): Promise<OmpModel[]> {
const data = OmpModelsResultSchema.parse(
await this.request({ type: "get_available_models" }, timeoutMs),
);
return data.models ?? [];
}
async setModel(provider: string, modelId: string): Promise<OmpModel> {
return OmpModelSchema.parse(await this.request({ type: "set_model", provider, modelId }));
}
async setThinkingLevel(level: OmpThinkingLevel): Promise<void> {
await this.request({ type: "set_thinking_level", level });
}
async getSessionStats(): Promise<OmpSessionStats> {
// COMPAT(ompGetStateFallback): added in v0.1.105 — older OMP binaries
// lack the `get_session_stats` RPC command; fall back to extracting
// context window usage from `get_state`. Remove after 2027-01-10 once the
// supported OMP floor includes `get_session_stats`.
let stats: OmpSessionStats | undefined;
try {
stats = OmpSessionStatsSchema.parse(await this.request({ type: "get_session_stats" }));
} catch {
// get_session_stats not supported by this binary — will try get_state below
}
if (stats?.tokens == null && stats?.cost == null && stats?.contextUsage == null) {
try {
const state = OmpSessionStateSchema.parse(await this.request({ type: "get_state" }));
const ctx = state.contextUsage;
if (ctx) {
return {
contextUsage: {
tokens: typeof ctx.tokens === "number" ? ctx.tokens : undefined,
contextWindow: typeof ctx.contextWindow === "number" ? ctx.contextWindow : undefined,
},
};
}
} catch {
// get_state also failed — nothing we can do
}
}
return stats ?? {};
}
async getCommands(): Promise<OmpRpcSlashCommand[]> {
const data = OmpCommandsResultSchema.parse(await this.request({ type: this.commandsRpcName }));
return data.commands ?? [];
}
async setSubagentSubscription(level: OmpSubagentSubscriptionLevel): Promise<void> {
await this.request({ type: "set_subagent_subscription", level });
}
async setHostTools(tools: OmpRpcHostToolDefinition[]): Promise<string[]> {
const data = OmpHostToolsResultSchema.parse(
await this.request({ type: "set_host_tools", tools }),
);
return data.toolNames ?? [];
}
sendHostToolResult(result: OmpRpcHostToolResult): void {
this.process.send({ ...result });
}
sendHostToolUpdate(update: OmpRpcHostToolUpdate): void {
this.process.send({ ...update });
}
async branch(entryId: string): Promise<{ text: string }> {
const data = OmpBranchResultSchema.parse(await this.request({ type: "branch", entryId }));
if (data.cancelled === true) {
throw new Error("OMP branch was cancelled");
}
if (typeof data.text !== "string") {
throw new Error("OMP branch response did not include restored prompt text");
}
this.activeBranchEntryId = entryId;
return { text: data.text };
}
async getBranchMessages(): Promise<Array<{ entryId: string; text: string }>> {
const data = OmpBranchMessagesResultSchema.parse(
await this.request({ type: "get_branch_messages" }),
);
return data.messages ?? [];
}
steer(message: string, images?: Array<{ type: "image"; data: string; mimeType: string }>): void {
this.process.send({ type: "steer", message, ...(images?.length ? { images } : {}) });
}
followUp(
message: string,
images?: Array<{ type: "image"; data: string; mimeType: string }>,
): void {
this.process.send({ type: "follow_up", message, ...(images?.length ? { images } : {}) });
}
async handoff(customInstructions?: string): Promise<void> {
await this.request({
type: "handoff",
...(customInstructions ? { customInstructions } : {}),
});
}
respondToExtensionUiRequest(
id: string,
response: { value?: string; confirmed?: boolean; cancelled?: boolean },
): void {
this.process.send({ type: "extension_ui_response", id, ...response });
}
cancelExtensionUiRequest(id: string): void {
this.respondToExtensionUiRequest(id, { cancelled: true });
}
async close(): Promise<void> {
await this.process.close(new Error("OMP RPC session is closed"));
}
private request(command: OmpRpcCommand, timeoutMs?: number): Promise<unknown> {
return this.process.request(OmpRpcCommandSchema.parse(command), timeoutMs);
}
private emit(event: OmpRuntimeEvent): void {
for (const subscriber of this.subscribers) {
subscriber(event);
}
}
}

View File

@@ -0,0 +1,71 @@
import { describe, expect, test } from "vitest";
import { mapOmpAvailableCommandsUpdate, mapOmpSlashCommands } from "./commands.js";
describe("OMP slash command mapper", () => {
test("maps command updates and preserves input hints", () => {
const commands = mapOmpAvailableCommandsUpdate({
type: "available_commands_update",
commands: [
{ name: "todo", description: "Manage todos", input: { hint: "<subcommand>" } },
{ name: "fast", description: "Toggle fast mode", input: { hint: "[on|off|status]" } },
{ name: "handoff", description: "Start a handoff" },
],
});
expect(commands?.find((command) => command.name === "todo")).toEqual({
name: "todo",
description: "Manage todos",
argumentHint: "<subcommand>",
kind: "command",
});
expect(commands?.find((command) => command.name === "fast")).toEqual({
name: "fast",
description: "Toggle fast mode",
argumentHint: "[on|off|status]",
kind: "command",
});
expect(commands?.find((command) => command.name === "handoff")).toEqual({
name: "handoff",
description: "Start a handoff",
argumentHint: "[instructions]",
kind: "command",
});
});
test("maps source-attributed OMP 17 commands", () => {
const commands = mapOmpAvailableCommandsUpdate({
type: "available_commands_update",
commands: [
{
name: "prewalk",
description: "Prewalk at the next action",
source: "builtin",
},
],
});
expect(commands?.find((command) => command.name === "prewalk")).toEqual({
name: "prewalk",
description: "Prewalk at the next action",
argumentHint: "",
kind: "command",
});
});
test("drops malformed command updates", () => {
expect(
mapOmpAvailableCommandsUpdate({ type: "available_commands_update", commands: [{}] }),
).toBeNull();
});
test("adds OMP-only out-of-band commands to handled built-ins", () => {
expect(mapOmpSlashCommands([]).map((command) => command.name)).toEqual([
"compact",
"autocompact",
"handoff",
"steer",
"follow-up",
]);
});
});

View File

@@ -0,0 +1,74 @@
import type { AgentSlashCommand, AgentSlashCommandKind } from "../../agent-sdk-types.js";
import type { OmpRpcSlashCommand } from "./rpc-types.js";
import { OmpAvailableCommandsUpdateEventSchema, type OmpAvailableCommand } from "./rpc-types.js";
export const OMP_HANDLED_BUILTIN_SLASH_COMMANDS: readonly AgentSlashCommand[] = [
{
name: "compact",
description: "Manually compact the session context",
argumentHint: "[instructions]",
kind: "command",
},
{
name: "autocompact",
description: "Toggle automatic context compaction",
argumentHint: "[on|off|toggle]",
kind: "command",
},
{
name: "handoff",
description: "Hand off from planning to implementation",
argumentHint: "[instructions]",
kind: "command",
},
{
name: "steer",
description: "Steer the active OMP turn",
argumentHint: "<message>",
kind: "command",
},
{
name: "follow-up",
description: "Queue a follow-up message for OMP",
argumentHint: "<message>",
kind: "command",
},
];
export function mapOmpSlashCommands(commands: readonly OmpAvailableCommand[]): AgentSlashCommand[] {
const mappedCommands = new Map<string, AgentSlashCommand>(
OMP_HANDLED_BUILTIN_SLASH_COMMANDS.map((command) => [command.name, { ...command }]),
);
for (const command of commands) {
const knownCommand = mappedCommands.get(command.name);
mappedCommands.set(command.name, {
name: command.name,
description: command.description ?? command.source ?? "command",
argumentHint: command.input?.hint ?? knownCommand?.argumentHint ?? "",
kind: mapOmpCommandKind(command.source),
});
}
return [...mappedCommands.values()];
}
export function mapOmpRuntimeSlashCommands(
commands: readonly OmpRpcSlashCommand[],
): AgentSlashCommand[] {
return mapOmpSlashCommands(
commands.map((command) => ({
name: command.name,
...(command.description ? { description: command.description } : {}),
source: command.source,
...(command.input ? { input: command.input } : {}),
})),
);
}
export function mapOmpAvailableCommandsUpdate(event: unknown): AgentSlashCommand[] | null {
const parsed = OmpAvailableCommandsUpdateEventSchema.safeParse(event);
return parsed.success ? mapOmpSlashCommands(parsed.data.commands) : null;
}
function mapOmpCommandKind(source: string | undefined): AgentSlashCommandKind {
return source === "skill" ? "skill" : "command";
}

View File

@@ -0,0 +1,270 @@
import { describe, expect, test } from "vitest";
import { mapOmpRuntimeEventToTimelineItem } from "./event-mapper.js";
describe("OMP runtime event mapper", () => {
test("maps notice events to timeline status lines", () => {
const event = {
type: "notice",
level: "warning",
message: "Provider quota is getting low",
source: "anthropic",
};
expect(mapOmpRuntimeEventToTimelineItem(event)).toMatchObject({
handled: true,
item: {
type: "tool_call",
callId: expect.stringMatching(/^omp-notice:/),
name: "omp_notice",
status: "completed",
error: null,
detail: {
type: "plain_text",
label: "OMP warning notice from anthropic",
text: "Provider quota is getting low",
icon: "sparkles",
},
metadata: {
synthetic: true,
source: "omp_notice",
level: "warning",
eventSource: "anthropic",
},
},
});
});
test("maps goal_updated events to provider notices and timeline status lines", () => {
expect(
mapOmpRuntimeEventToTimelineItem({
type: "goal_updated",
goal: {
id: "goal-1",
objective: "Ship Phase 5",
status: "active",
tokenBudget: 2000,
tokensUsed: 345,
timeUsedSeconds: 12,
},
state: {
enabled: true,
mode: "active",
},
}),
).toMatchObject({
handled: true,
item: {
type: "tool_call",
callId: "omp-goal:goal-1",
name: "omp_goal_updated",
status: "completed",
error: null,
detail: {
type: "plain_text",
label: "OMP goal active",
text: "Ship Phase 5\nStatus: active\nTokens used: 345\nToken budget: 2000\nTime used: 12s\nMode: active",
icon: "brain",
},
metadata: {
synthetic: true,
source: "omp_goal_updated",
goalId: "goal-1",
goalStatus: "active",
},
},
});
});
test("maps retry telemetry to status-line timeline items", () => {
expect(
mapOmpRuntimeEventToTimelineItem({
type: "auto_retry_start",
attempt: 1,
maxAttempts: 3,
delayMs: 25,
errorMessage: "retrying",
errorId: 7,
}),
).toMatchObject({
handled: true,
item: {
type: "tool_call",
callId: "omp-auto-retry:1",
name: "omp_auto_retry",
status: "running",
error: null,
detail: {
type: "plain_text",
label: "OMP retry 1/3",
text: "Retrying in 25ms: retrying",
icon: "sparkles",
},
metadata: {
source: "omp_auto_retry_start",
attempt: 1,
maxAttempts: 3,
delayMs: 25,
errorId: 7,
},
},
});
expect(
mapOmpRuntimeEventToTimelineItem({
type: "auto_retry_end",
success: false,
attempt: 1,
finalError: "still failed",
}),
).toMatchObject({
handled: true,
item: {
type: "tool_call",
callId: "omp-auto-retry:1",
name: "omp_auto_retry",
status: "failed",
error: "still failed",
detail: {
type: "plain_text",
label: "OMP retry 1 failed",
text: "still failed",
icon: "sparkles",
},
},
});
expect(
mapOmpRuntimeEventToTimelineItem({
type: "retry_fallback_applied",
from: "anthropic/claude-sonnet",
to: "openai/gpt-5",
role: "primary",
}),
).toMatchObject({
handled: true,
item: {
type: "tool_call",
name: "omp_retry_fallback",
status: "completed",
error: null,
detail: {
type: "plain_text",
label: "OMP fallback applied for primary",
text: "anthropic/claude-sonnet -> openai/gpt-5",
icon: "sparkles",
},
metadata: {
source: "omp_retry_fallback_applied",
role: "primary",
from: "anthropic/claude-sonnet",
to: "openai/gpt-5",
},
},
});
expect(
mapOmpRuntimeEventToTimelineItem({
type: "retry_fallback_succeeded",
model: "openai/gpt-5",
role: "primary",
}),
).toMatchObject({
handled: true,
item: {
type: "tool_call",
name: "omp_retry_fallback",
status: "completed",
error: null,
detail: {
type: "plain_text",
label: "OMP fallback succeeded for primary",
text: "Using openai/gpt-5",
icon: "sparkles",
},
metadata: {
source: "omp_retry_fallback_succeeded",
role: "primary",
model: "openai/gpt-5",
},
},
});
});
test("maps auto compaction events to compaction timeline items", () => {
expect(
mapOmpRuntimeEventToTimelineItem({
type: "auto_compaction_start",
reason: "threshold",
action: "context-full",
}),
).toEqual({
handled: true,
item: {
type: "compaction",
status: "loading",
trigger: "auto",
},
});
expect(
mapOmpRuntimeEventToTimelineItem({
type: "auto_compaction_end",
action: "context-full",
result: {
summary: "trimmed",
shortSummary: "trimmed",
firstKeptEntryId: "entry-1",
tokensBefore: 123,
},
aborted: false,
willRetry: false,
}),
).toEqual({
handled: true,
item: {
type: "compaction",
status: "completed",
trigger: "auto",
preTokens: 123,
},
});
expect(
mapOmpRuntimeEventToTimelineItem({
type: "auto_compaction_end",
aborted: true,
willRetry: true,
skipped: true,
errorMessage: "compaction failed",
}),
).toEqual({
handled: true,
item: {
type: "compaction",
status: "completed",
trigger: "auto",
},
});
});
test("leaves unknown events unhandled", () => {
expect(mapOmpRuntimeEventToTimelineItem({ type: "message_start" })).toEqual({
handled: false,
});
});
test("log-drops malformed known OMP events", () => {
expect(
mapOmpRuntimeEventToTimelineItem({
type: "notice",
level: "loud",
message: "bad",
}),
).toEqual({
handled: true,
item: null,
logReason: "malformed_omp_notice",
});
});
});

View File

@@ -0,0 +1,336 @@
import { createHash } from "node:crypto";
import type { AgentTimelineItem, ToolCallIconName } from "../../agent-sdk-types.js";
import {
OmpAutoCompactionEndEventSchema,
OmpAutoCompactionStartEventSchema,
OmpAutoRetryEndEventSchema,
OmpAutoRetryStartEventSchema,
OmpGoalUpdatedEventSchema,
OmpNoticeEventSchema,
OmpRetryFallbackAppliedEventSchema,
OmpRetryFallbackSucceededEventSchema,
type OmpGoal,
type OmpGoalUpdatedEvent,
} from "./rpc-types.js";
type OmpTelemetryToolCallItem = Extract<AgentTimelineItem, { type: "tool_call" }>;
export type OmpRuntimeEventMapping =
| {
handled: false;
}
| {
handled: true;
item: AgentTimelineItem;
}
| {
handled: true;
item: null;
logReason: string;
};
interface StatusLineNoticeInput {
callId: string;
name: string;
label: string;
text?: string;
icon?: ToolCallIconName;
status?: "running" | "completed" | "failed";
error?: unknown;
metadata?: Record<string, unknown>;
}
export function mapOmpRuntimeEventToTimelineItem(event: unknown): OmpRuntimeEventMapping {
const type = readEventType(event);
switch (type) {
case "notice":
return mapNoticeEvent(event);
case "goal_updated":
return mapGoalUpdatedEvent(event);
case "auto_retry_start":
return mapAutoRetryStartEvent(event);
case "auto_retry_end":
return mapAutoRetryEndEvent(event);
case "retry_fallback_applied":
return mapRetryFallbackAppliedEvent(event);
case "retry_fallback_succeeded":
return mapRetryFallbackSucceededEvent(event);
case "auto_compaction_start":
return mapAutoCompactionStartEvent(event);
case "auto_compaction_end":
return mapAutoCompactionEndEvent(event);
default:
return { handled: false };
}
}
function mapNoticeEvent(event: unknown): OmpRuntimeEventMapping {
const parsed = OmpNoticeEventSchema.safeParse(event);
if (!parsed.success) {
return { handled: true, item: null, logReason: "malformed_omp_notice" };
}
const label = parsed.data.source
? `OMP ${parsed.data.level} notice from ${parsed.data.source}`
: `OMP ${parsed.data.level} notice`;
return {
handled: true,
item: buildStatusLineNotice({
callId: `omp-notice:${hashParts(parsed.data.level, parsed.data.source ?? "", parsed.data.message)}`,
name: "omp_notice",
label,
text: parsed.data.message,
icon: "sparkles",
status: parsed.data.level === "error" ? "failed" : "completed",
error: parsed.data.level === "error" ? parsed.data.message : undefined,
metadata: {
synthetic: true,
source: "omp_notice",
level: parsed.data.level,
...(parsed.data.source ? { eventSource: parsed.data.source } : {}),
},
}),
};
}
function mapGoalUpdatedEvent(event: unknown): OmpRuntimeEventMapping {
const parsed = OmpGoalUpdatedEventSchema.safeParse(event);
if (!parsed.success) {
return { handled: true, item: null, logReason: "malformed_omp_goal_updated" };
}
const goal = resolveGoal(parsed.data);
const label = goal?.status ? `OMP goal ${goal.status}` : "OMP goal updated";
const text = formatGoalText(parsed.data, goal);
return {
handled: true,
item: buildStatusLineNotice({
callId: `omp-goal:${goal?.id ?? hashParts(text)}`,
name: "omp_goal_updated",
label,
text,
icon: "brain",
metadata: {
synthetic: true,
source: "omp_goal_updated",
...(goal?.id ? { goalId: goal.id } : {}),
...(goal?.status ? { goalStatus: goal.status } : {}),
},
}),
};
}
function mapAutoRetryStartEvent(event: unknown): OmpRuntimeEventMapping {
const parsed = OmpAutoRetryStartEventSchema.safeParse(event);
if (!parsed.success) {
return { handled: true, item: null, logReason: "malformed_omp_auto_retry_start" };
}
const data = parsed.data;
return {
handled: true,
item: buildStatusLineNotice({
callId: `omp-auto-retry:${data.attempt}`,
name: "omp_auto_retry",
label: `OMP retry ${data.attempt}/${data.maxAttempts}`,
text: `Retrying in ${formatDelay(data.delayMs)}: ${data.errorMessage}`,
icon: "sparkles",
status: "running",
metadata: {
synthetic: true,
source: "omp_auto_retry_start",
attempt: data.attempt,
maxAttempts: data.maxAttempts,
delayMs: data.delayMs,
...(data.errorId !== undefined ? { errorId: data.errorId } : {}),
},
}),
};
}
function mapAutoRetryEndEvent(event: unknown): OmpRuntimeEventMapping {
const parsed = OmpAutoRetryEndEventSchema.safeParse(event);
if (!parsed.success) {
return { handled: true, item: null, logReason: "malformed_omp_auto_retry_end" };
}
const data = parsed.data;
const status = data.success ? "completed" : "failed";
return {
handled: true,
item: buildStatusLineNotice({
callId: `omp-auto-retry:${data.attempt}`,
name: "omp_auto_retry",
label: data.success
? `OMP retry ${data.attempt} recovered`
: `OMP retry ${data.attempt} failed`,
text: data.finalError ?? (data.success ? "Retry recovered." : "Retry failed."),
icon: "sparkles",
status,
error: data.success ? undefined : (data.finalError ?? "OMP retry failed"),
metadata: {
synthetic: true,
source: "omp_auto_retry_end",
attempt: data.attempt,
success: data.success,
},
}),
};
}
function mapRetryFallbackAppliedEvent(event: unknown): OmpRuntimeEventMapping {
const parsed = OmpRetryFallbackAppliedEventSchema.safeParse(event);
if (!parsed.success) {
return { handled: true, item: null, logReason: "malformed_omp_retry_fallback_applied" };
}
const data = parsed.data;
return {
handled: true,
item: buildStatusLineNotice({
callId: `omp-retry-fallback:${hashParts(data.role, data.from, data.to)}`,
name: "omp_retry_fallback",
label: `OMP fallback applied for ${data.role}`,
text: `${data.from} -> ${data.to}`,
icon: "sparkles",
metadata: {
synthetic: true,
source: "omp_retry_fallback_applied",
role: data.role,
from: data.from,
to: data.to,
},
}),
};
}
function mapRetryFallbackSucceededEvent(event: unknown): OmpRuntimeEventMapping {
const parsed = OmpRetryFallbackSucceededEventSchema.safeParse(event);
if (!parsed.success) {
return { handled: true, item: null, logReason: "malformed_omp_retry_fallback_succeeded" };
}
const data = parsed.data;
return {
handled: true,
item: buildStatusLineNotice({
callId: `omp-retry-fallback-succeeded:${hashParts(data.role, data.model)}`,
name: "omp_retry_fallback",
label: `OMP fallback succeeded for ${data.role}`,
text: `Using ${data.model}`,
icon: "sparkles",
metadata: {
synthetic: true,
source: "omp_retry_fallback_succeeded",
role: data.role,
model: data.model,
},
}),
};
}
function mapAutoCompactionStartEvent(event: unknown): OmpRuntimeEventMapping {
const parsed = OmpAutoCompactionStartEventSchema.safeParse(event);
if (!parsed.success) {
return { handled: true, item: null, logReason: "malformed_omp_auto_compaction_start" };
}
return {
handled: true,
item: {
type: "compaction",
status: "loading",
trigger: "auto",
},
};
}
function mapAutoCompactionEndEvent(event: unknown): OmpRuntimeEventMapping {
const parsed = OmpAutoCompactionEndEventSchema.safeParse(event);
if (!parsed.success) {
return { handled: true, item: null, logReason: "malformed_omp_auto_compaction_end" };
}
const preTokens = readCompactionPreTokens(parsed.data.result);
// Wire compaction items only support loading/completed, so every end event clears loading.
return {
handled: true,
item: {
type: "compaction",
status: "completed",
trigger: "auto",
...(preTokens !== undefined ? { preTokens } : {}),
},
};
}
function buildStatusLineNotice(input: StatusLineNoticeInput): OmpTelemetryToolCallItem {
const base = {
type: "tool_call" as const,
callId: input.callId,
name: input.name,
detail: {
type: "plain_text" as const,
label: input.label,
...(input.text ? { text: input.text } : {}),
icon: input.icon ?? "sparkles",
},
metadata: input.metadata,
};
if (input.status === "running") {
return { ...base, status: "running", error: null };
}
if (input.status === "failed") {
return { ...base, status: "failed", error: input.error ?? input.text ?? input.label };
}
return { ...base, status: "completed", error: null };
}
function resolveGoal(event: OmpGoalUpdatedEvent): OmpGoal | null {
return event.goal ?? event.state?.goal ?? null;
}
function formatGoalText(event: OmpGoalUpdatedEvent, goal: OmpGoal | null): string {
if (!goal) {
return "OMP goal cleared.";
}
const parts = [
goal.objective?.trim() || "OMP goal updated.",
goal.status ? `Status: ${goal.status}` : null,
goal.tokensUsed !== undefined ? `Tokens used: ${goal.tokensUsed}` : null,
goal.tokenBudget !== undefined ? `Token budget: ${goal.tokenBudget}` : null,
goal.timeUsedSeconds !== undefined ? `Time used: ${goal.timeUsedSeconds}s` : null,
event.state?.mode ? `Mode: ${event.state.mode}` : null,
].filter((part): part is string => part !== null);
return parts.join("\n");
}
function readEventType(value: unknown): string | null {
if (!isRecord(value)) {
return null;
}
return typeof value.type === "string" ? value.type : null;
}
function readCompactionPreTokens(value: unknown): number | undefined {
if (!isRecord(value)) {
return undefined;
}
return typeof value.tokensBefore === "number" ? value.tokensBefore : undefined;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function formatDelay(delayMs: number): string {
if (delayMs < 1000) {
return `${delayMs}ms`;
}
if (delayMs % 1000 === 0) {
return `${delayMs / 1000}s`;
}
return `${(delayMs / 1000).toFixed(1)}s`;
}
function hashParts(...parts: string[]): string {
const hash = createHash("sha1");
for (const part of parts) {
hash.update(part);
hash.update("\0");
}
return hash.digest("hex").slice(0, 12);
}

View File

@@ -0,0 +1,13 @@
import type { OmpHistoryMapperHooks } from "./message-history.js";
import { mapOmpSystemNoticeToToolCall } from "./system-notice.js";
import { mapOmpToolDetail } from "./tool-call-mapper.js";
import { resolveOmpEmittedToolCallId } from "./tool-call-id.js";
export const OMP_HISTORY_MAPPER_HOOKS: OmpHistoryMapperHooks = {
mapToolDetail: mapOmpToolDetail,
mapCustomMessage: (text, provider) => {
const noticeItem = mapOmpSystemNoticeToToolCall(text);
return noticeItem ? { type: "timeline", provider, item: noticeItem } : null;
},
resolveToolCallId: resolveOmpEmittedToolCallId,
};

View File

@@ -0,0 +1,546 @@
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, test } from "vitest";
import type { AgentStreamEvent } from "../../agent-sdk-types.js";
import { streamOmpCoreHistory, type OmpCapturedUserMessageEntry } from "./message-history.js";
import type { OmpAgentMessage } from "./rpc-types.js";
import { FakeOmp } from "./test-utils/fake-omp.js";
import { OMP_HISTORY_MAPPER_HOOKS } from "./history-hooks.js";
import { streamOmpHistory } from "./history.js";
async function collectHistory(
messages: OmpAgentMessage[],
userEntries: OmpCapturedUserMessageEntry[] = [],
): Promise<AgentStreamEvent[]> {
const events: AgentStreamEvent[] = [];
for await (const event of streamOmpCoreHistory(
"omp",
messages,
userEntries,
OMP_HISTORY_MAPPER_HOOKS,
)) {
events.push(event);
}
return events;
}
describe("OMP history mapper", () => {
test("coalesces replayed subagent poll calls by target set", async () => {
const events = await collectHistory([
{
role: "assistant",
content: [
{ type: "toolCall", id: "poll-1", name: "subagent", arguments: { poll: ["job-a"] } },
],
},
{
role: "toolResult",
toolCallId: "poll-1",
toolName: "subagent",
content: [{ type: "text", text: "first poll" }],
},
{
role: "assistant",
content: [
{ type: "toolCall", id: "poll-2", name: "subagent", arguments: { poll: ["job-a"] } },
{ type: "toolCall", id: "poll-3", name: "subagent", arguments: { poll: ["job-b"] } },
{
type: "toolCall",
id: "spawn-1",
name: "subagent",
arguments: { spawn: [{ task: "go" }] },
},
],
},
{
role: "toolResult",
toolCallId: "poll-2",
toolName: "subagent",
content: [{ type: "text", text: "second poll" }],
},
{
role: "toolResult",
toolCallId: "poll-3",
toolName: "subagent",
content: [{ type: "text", text: "other poll" }],
},
{
role: "toolResult",
toolCallId: "spawn-1",
toolName: "subagent",
content: [{ type: "text", text: "spawned" }],
},
]);
expect(
events.map((event) => (event.item.type === "tool_call" ? event.item.callId : null)),
).toEqual([
"omp-poll:job-a",
"omp-poll:job-a",
"omp-poll:job-a",
"omp-poll:job-b",
"spawn-1",
"omp-poll:job-a",
"omp-poll:job-b",
"spawn-1",
]);
});
test("absorbs replayed omp system-notice custom messages as synthetic tool calls", async () => {
const notice = [
"<system-notice>",
"Background job DocsSmokeTwo has completed. Resume your work using the result below.",
'<task-result id="DocsSmokeTwo" agent="explore" status="completed" duration="21.6s">',
"<output>done</output>",
"</task-result>",
"</system-notice>",
].join("\n");
await expect(
collectHistory(
[
{ role: "user", content: "first prompt" },
{ role: "custom", content: notice },
{ role: "user", content: "second prompt" },
],
[
{ id: "entry-user-1", text: "first prompt" },
{ id: "entry-user-2", text: "second prompt" },
],
),
).resolves.toEqual([
{
type: "timeline",
provider: "omp",
item: {
type: "user_message",
text: "first prompt",
messageId: "entry-user-1",
},
},
{
type: "timeline",
provider: "omp",
item: {
type: "tool_call",
callId: "omp-notice:DocsSmokeTwo",
name: "task_notification",
status: "completed",
detail: {
type: "plain_text",
label: "Background job DocsSmokeTwo completed",
text: notice,
icon: "wrench",
},
metadata: {
synthetic: true,
source: "omp_system_notice",
taskId: "DocsSmokeTwo",
subagentType: "explore",
status: "completed",
},
error: null,
},
},
{
type: "timeline",
provider: "omp",
item: {
type: "user_message",
text: "second prompt",
messageId: "entry-user-2",
},
},
]);
});
test("suppresses replayed raw todo tool calls through the OMP detail hook", async () => {
await expect(
collectHistory([
{
role: "assistant",
content: [{ type: "toolCall", id: "todo-1", name: "todo", arguments: { op: "view" } }],
},
{
role: "toolResult",
toolCallId: "todo-1",
toolName: "todo",
content: [{ type: "text", text: "todos" }],
},
]),
).resolves.toEqual([]);
});
test("replays task tool results as static sub-agent details", async () => {
await expect(
collectHistory([
{
role: "assistant",
content: [
{
type: "toolCall",
id: "task-1",
name: "task",
arguments: { agent: "explore", description: "Inspect files" },
},
],
},
{
role: "toolResult",
toolCallId: "task-1",
toolName: "task",
content: [{ type: "text", text: "done\ntranscript: /tmp/omp-task/Explore.jsonl" }],
},
]),
).resolves.toEqual([
{
type: "timeline",
provider: "omp",
item: {
type: "tool_call",
callId: "task-1",
name: "task",
status: "running",
detail: {
type: "sub_agent",
subAgentType: "explore",
description: "Inspect files",
log: "",
},
error: null,
},
},
{
type: "timeline",
provider: "omp",
item: {
type: "tool_call",
callId: "task-1",
name: "task",
status: "completed",
detail: {
type: "sub_agent",
subAgentType: "explore",
description: "Inspect files",
childSessionId: "/tmp/omp-task/Explore.jsonl",
log: "done\ntranscript: /tmp/omp-task/Explore.jsonl",
},
error: null,
},
},
]);
});
test("replays OMP 17 xd writes as the executed inner tool", async () => {
const events = await collectHistory([
{
role: "assistant",
content: [
{
type: "toolCall",
id: "xd-write-call",
name: "write",
arguments: {
path: "xd://browser",
content: '{"action":"open","name":"docs","url":"https://example.com"}',
},
},
],
},
{
role: "toolResult",
toolCallId: "xd-write-call",
toolName: "write",
content: [{ type: "text", text: "Opened Example Domain" }],
details: {
xdev: {
tool: "browser",
mode: "execute",
args: { action: "open", name: "docs", url: "https://example.com" },
inner: { action: "open", name: "docs", url: "https://example.com" },
},
},
} as OmpAgentMessage,
]);
expect(events.at(-1)).toMatchObject({
item: {
type: "tool_call",
callId: "xd-write-call",
name: "browser",
status: "completed",
detail: {
type: "unknown",
input: { action: "open", name: "docs", url: "https://example.com" },
output: {
content: [{ type: "text", text: "Opened Example Domain" }],
details: { action: "open", name: "docs", url: "https://example.com" },
},
},
},
});
});
test("maps only the active JSONL chain with native user ids and visible unknown roles", async () => {
const dir = mkdtempSync(join(tmpdir(), "omp-history-"));
const sessionFile = join(dir, "session.jsonl");
writeFileSync(
sessionFile,
[
{ type: "session", id: "root", parentId: null },
{
type: "message",
id: "user-old",
parentId: "root",
message: { role: "user", content: "old branch" },
},
{
type: "message",
id: "assistant-old",
parentId: "user-old",
message: { role: "assistant", content: [{ type: "text", text: "old answer" }] },
},
{
type: "session_init",
id: "init-active",
parentId: "root",
systemPrompt: "must stay hidden",
},
{
type: "message",
id: "system-active",
parentId: "init-active",
message: { role: "system", content: "secret system prompt" },
},
{
type: "message",
id: "user-active",
parentId: "system-active",
message: { role: "user", content: "active branch" },
},
{
type: "title",
id: "title-control",
parentId: "user-active",
title: "Updated title",
},
{
type: "custom",
customType: "tool_execution_start",
id: "custom-control",
parentId: "title-control",
data: { toolName: "task" },
},
{
type: "tool_execution_start",
id: "tool-control",
parentId: "custom-control",
command: "secret internal command",
},
{
type: "future_control",
id: "unknown-active",
parentId: "tool-control",
secret: "must not stringify",
},
{
type: "message",
id: "developer-active",
parentId: "unknown-active",
message: { role: "developer", content: "developer note" },
},
]
.map((entry) => JSON.stringify(entry))
.join("\n"),
);
const events: AgentStreamEvent[] = [];
for await (const event of streamOmpHistory({ sessionFile, provider: "omp" })) {
events.push(event);
}
expect(events.map((event) => event.item)).toEqual([
{ type: "user_message", text: "active branch", messageId: "user-active" },
{ type: "assistant_message", text: "[future_control] Unsupported history record" },
{ type: "assistant_message", text: "[developer] developer note" },
]);
const omp = new FakeOmp();
const runtimeSession = await omp.startSession({ cwd: dir });
runtimeSession.activeBranchEntryId = "assistant-old";
const selectedEvents: AgentStreamEvent[] = [];
for await (const event of streamOmpHistory({
sessionFile,
runtimeSession,
provider: "omp",
})) {
selectedEvents.push(event);
}
expect(
selectedEvents.flatMap((event) => (event.type === "timeline" ? [event.item] : [])),
).toEqual([
{ type: "user_message", text: "old branch", messageId: "user-old" },
{
type: "assistant_message",
text: "old answer",
messageId: "omp-history-assistant-1",
},
]);
});
test("rehydrates structured batch and nested task transcripts with stable status and time", async () => {
const dir = mkdtempSync(join(tmpdir(), "omp-subagent-history-"));
const parentFile = join(dir, "parent.jsonl");
const parentStem = parentFile.slice(0, -".jsonl".length);
const echoId = "EchoChild";
const echoFile = join(parentStem, `${echoId}.jsonl`);
const failedFile = join(parentStem, "FailedChild.jsonl");
const abortedFile = join(parentStem, "AbortedChild.jsonl");
const nestedFile = join(parentStem, echoId, "NestedChild.jsonl");
mkdirSync(join(parentStem, echoId), { recursive: true });
const writeEntries = (file: string, entries: object[]): void => {
writeFileSync(file, entries.map((entry) => JSON.stringify(entry)).join("\n"));
};
writeEntries(nestedFile, [
{ type: "session", id: "nested-root", parentId: null, timestamp: "2026-07-07T03:00:00Z" },
{
type: "message",
id: "nested-answer",
parentId: "nested-root",
timestamp: "2026-07-07T03:00:01Z",
message: { role: "assistant", content: [{ type: "text", text: "Nested answer" }] },
},
]);
writeEntries(echoFile, [
{ type: "session", id: "echo-root", parentId: null, timestamp: "2026-07-07T02:00:00Z" },
{
type: "model_change",
id: "echo-model",
parentId: "echo-root",
timestamp: "2026-07-07T02:00:00.500Z",
provider: "openai-codex",
modelId: "gpt-5.5",
},
{
type: "message",
id: "echo-answer",
parentId: "echo-model",
timestamp: "2026-07-07T02:00:01Z",
message: { role: "assistant", content: [{ type: "text", text: "Found it" }] },
},
{
type: "message",
id: "nested-call",
parentId: "echo-answer",
timestamp: "2026-07-07T02:00:02Z",
message: {
role: "assistant",
content: [
{
type: "toolCall",
id: "nested-task",
name: "task",
arguments: { agent: "task" },
},
],
},
},
{
type: "message",
id: "nested-result",
parentId: "nested-call",
timestamp: "2026-07-07T02:00:03Z",
message: {
role: "toolResult",
toolCallId: "nested-task",
toolName: "task",
content: [{ type: "text", text: "nested done" }],
details: { results: [{ id: "NestedChild", exitCode: 0 }] },
},
},
]);
writeEntries(failedFile, [
{ type: "session", id: "failed-root", parentId: null, timestamp: "2026-07-07T04:00:00Z" },
]);
writeEntries(abortedFile, [
{ type: "session", id: "aborted-root", parentId: null, timestamp: 1_752_000_000 },
]);
writeEntries(parentFile, [
{ type: "session", id: "parent-root", parentId: null, timestamp: "2026-07-07T01:00:00Z" },
{
type: "message",
id: "task-call",
parentId: "parent-root",
timestamp: "2026-07-07T01:00:01Z",
message: {
role: "assistant",
content: [{ type: "toolCall", id: "task-1", name: "task", arguments: { agent: "task" } }],
},
},
{
type: "message",
id: "task-result",
parentId: "task-call",
timestamp: "2026-07-07T01:00:02Z",
message: {
role: "toolResult",
toolCallId: "task-1",
toolName: "task",
content: [{ type: "text", text: "batch done" }],
details: {
results: [
{ id: echoId, agent: "task", exitCode: 0 },
{ id: "FailedChild", exitCode: 2, error: "boom" },
{ id: "AbortedChild", aborted: true },
],
},
},
},
]);
const events: AgentStreamEvent[] = [];
for await (const event of streamOmpHistory({ sessionFile: parentFile, provider: "omp" })) {
events.push(event);
}
const subagentEvents = events.flatMap((event) =>
event.type === "provider_subagent" ? [event.event] : [],
);
expect(subagentEvents).toContainEqual({
type: "timeline",
id: echoId,
timestamp: "2026-07-07T02:00:01Z",
item: {
type: "assistant_message",
text: "Found it",
messageId: "omp-history-assistant-1",
},
});
expect(subagentEvents).toContainEqual(
expect.objectContaining({
type: "timeline",
id: "NestedChild",
timestamp: "2026-07-07T03:00:01Z",
}),
);
expect(subagentEvents.filter((event) => event.type === "upsert")).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: echoId,
title: "task · gpt-5.5 (openai-codex)",
status: "running",
timestamp: "2026-07-07T02:00:00Z",
}),
expect.objectContaining({
id: echoId,
title: "task · gpt-5.5 (openai-codex)",
status: "completed",
timestamp: "2026-07-07T02:00:03Z",
}),
expect.objectContaining({ id: "FailedChild", status: "failed" }),
expect.objectContaining({ id: "AbortedChild", status: "canceled" }),
expect.objectContaining({ id: "NestedChild", status: "completed" }),
]),
);
});
});

View File

@@ -0,0 +1,376 @@
import { readFile } from "node:fs/promises";
import { basename, extname, join } from "node:path";
import type { AgentProvider, AgentStreamEvent } from "../../agent-sdk-types.js";
import { normalizeProviderReplayTimestamp } from "../../provider-history-timestamps.js";
import { OmpHistoryMapper, type OmpCapturedUserMessageEntry } from "./message-history.js";
import type { OmpAgentMessage } from "./rpc-types.js";
import type { OmpRuntimeSession } from "./runtime.js";
import { OMP_HISTORY_MAPPER_HOOKS } from "./history-hooks.js";
import { formatOmpSubagentTitle } from "./subagent-title.js";
interface OmpSessionEntry {
type?: string;
id?: string;
parentId?: string | null;
timestamp?: string | number;
message?: Record<string, unknown>;
[key: string]: unknown;
}
function extractOmpSubagentModel(entries: readonly OmpSessionEntry[]): string | null {
let resolvedModel: string | null = null;
for (const entry of entries) {
let candidate: string | null = null;
if (entry.type === "model_change") {
candidate = buildOmpModelId(entry.provider, entry.modelId);
} else if (entry.type === "message" && entry.message?.role === "assistant") {
candidate = buildOmpModelId(
entry.message.provider,
entry.message.responseModel ?? entry.message.model,
);
}
resolvedModel = candidate ?? resolvedModel;
}
return resolvedModel;
}
function buildOmpModelId(provider: unknown, model: unknown): string | null {
if (typeof provider !== "string" || typeof model !== "string") return null;
const normalizedProvider = provider.trim();
const normalizedModel = model.trim();
return normalizedProvider && normalizedModel ? `${normalizedProvider}/${normalizedModel}` : null;
}
export async function* streamOmpHistory(input: {
sessionFile?: string;
runtimeSession?: OmpRuntimeSession;
provider: AgentProvider;
visitedSessionFiles?: Set<string>;
}): AsyncGenerator<AgentStreamEvent> {
if (!input.sessionFile) {
return;
}
const visitedSessionFiles = input.visitedSessionFiles ?? new Set<string>();
if (visitedSessionFiles.has(input.sessionFile)) {
return;
}
visitedSessionFiles.add(input.sessionFile);
let entries: OmpSessionEntry[];
try {
entries = await readActiveOmpEntryChain(
input.sessionFile,
input.runtimeSession?.activeBranchEntryId,
);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return;
}
throw error;
}
const messages: OmpAgentMessage[] = [];
const messageEntries: OmpSessionEntry[] = [];
const userEntries: OmpCapturedUserMessageEntry[] = [];
for (const entry of entries) {
const mapped = mapEntryMessage(entry);
if (!mapped) continue;
messages.push(mapped);
messageEntries.push(entry);
if (mapped.role === "user" && entry.id) {
userEntries.push({ id: entry.id, text: textOf(mapped.content) });
}
}
const mapper = new OmpHistoryMapper(input.provider, userEntries, OMP_HISTORY_MAPPER_HOOKS);
for (let index = 0; index < messages.length; index += 1) {
const timestamp = normalizeProviderReplayTimestamp(messageEntries[index]?.timestamp);
for (const event of mapper.mapMessages([messages[index]!])) {
yield timestamp && event.type === "timeline" ? { ...event, timestamp } : event;
}
}
for (const transcript of readSubagentTranscripts(messages, input.sessionFile)) {
yield* replaySubagentTranscript(transcript, input.provider, visitedSessionFiles);
}
}
async function* replaySubagentTranscript(
transcript: OmpSubagentTranscript,
provider: AgentProvider,
visitedSessionFiles: Set<string>,
): AsyncGenerator<AgentStreamEvent> {
const childEntries = await readActiveOmpEntryChain(transcript.sessionFile).catch(
(error: unknown) => {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
throw error;
},
);
const resolvedModel = extractOmpSubagentModel(childEntries);
const firstTimestamp = normalizeProviderReplayTimestamp(childEntries[0]?.timestamp);
yield subagentUpsert(transcript, provider, "running", firstTimestamp, resolvedModel);
for await (const event of streamOmpHistory({
sessionFile: transcript.sessionFile,
provider,
visitedSessionFiles,
})) {
if (event.type === "timeline") {
yield {
type: "provider_subagent",
provider,
event: {
type: "timeline",
id: transcript.id,
item: event.item,
...(event.timestamp ? { timestamp: event.timestamp } : {}),
},
};
} else if (event.type === "provider_subagent") {
yield event;
}
}
const lastTimestamp = normalizeProviderReplayTimestamp(childEntries.at(-1)?.timestamp);
yield subagentUpsert(transcript, provider, transcript.status, lastTimestamp, resolvedModel);
}
function subagentUpsert(
transcript: OmpSubagentTranscript,
provider: AgentProvider,
status: OmpSubagentTranscript["status"] | "running",
timestamp: string | null,
resolvedModel: string | null,
): AgentStreamEvent {
return {
type: "provider_subagent",
provider,
event: {
type: "upsert",
id: transcript.id,
title: formatOmpSubagentTitle(transcript.title, resolvedModel),
status,
toolCallId: transcript.toolCallId,
...(timestamp ? { timestamp } : {}),
},
};
}
interface OmpSubagentTranscript {
id: string;
title: string;
toolCallId: string;
sessionFile: string;
status: "completed" | "failed" | "canceled";
}
function readSubagentTranscripts(
messages: readonly OmpAgentMessage[],
parentSessionFile: string,
): OmpSubagentTranscript[] {
const taskCalls = collectTaskCalls(messages);
const transcripts: OmpSubagentTranscript[] = [];
for (const message of messages) {
if (message.role !== "toolResult" || message.toolName !== "task") continue;
const call = taskCalls.get(message.toolCallId);
if (!call) continue;
const results = readTaskResults(message);
for (const result of results) {
transcripts.push({
id: result.id,
title: result.agent ?? call.title,
toolCallId: message.toolCallId,
sessionFile: join(stripExtension(parentSessionFile), `${basename(result.id)}.jsonl`),
status: taskResultStatus(result, message.isError === true),
});
}
if (results.length > 0) continue;
const legacy = readLegacyTranscript(message, call.title);
if (legacy) transcripts.push(legacy);
}
return transcripts;
}
function collectTaskCalls(messages: readonly OmpAgentMessage[]): Map<string, { title: string }> {
const taskCalls = new Map<string, { title: string }>();
for (const message of messages) {
if (message.role !== "assistant" || !Array.isArray(message.content)) continue;
for (const block of message.content) {
if (block.type !== "toolCall" || block.name !== "task" || typeof block.id !== "string") {
continue;
}
const args = block.arguments;
const title =
args && typeof args === "object" && typeof Reflect.get(args, "agent") === "string"
? Reflect.get(args, "agent")
: "OMP subagent";
taskCalls.set(block.id, { title });
}
}
return taskCalls;
}
function readLegacyTranscript(
message: Extract<OmpAgentMessage, { role: "toolResult" }>,
title: string,
): OmpSubagentTranscript | null {
const text = taskResultText(message);
const sessionFile = text.match(/(?:session|transcript)(?: file)?:\s*(?<path>\/\S+\.jsonl)/i)
?.groups?.path;
if (!sessionFile) return null;
const fileName = basename(sessionFile);
const extension = extname(fileName);
return {
id: extension ? fileName.slice(0, -extension.length) : fileName,
title,
toolCallId: message.toolCallId,
sessionFile,
status: message.isError ? "failed" : "completed",
};
}
interface OmpTaskResult {
id: string;
agent?: string;
exitCode?: number;
error?: unknown;
aborted?: boolean;
}
function readTaskResults(
message: Extract<OmpAgentMessage, { role: "toolResult" }>,
): OmpTaskResult[] {
const details =
Reflect.get(message, "details") ??
(message.content && typeof message.content === "object"
? Reflect.get(message.content, "details")
: undefined);
const results =
details && typeof details === "object" ? Reflect.get(details, "results") : undefined;
if (!Array.isArray(results)) return [];
return results.flatMap((result) => {
if (!result || typeof result !== "object") return [];
const id = Reflect.get(result, "id");
if (typeof id !== "string" || !id) return [];
const agent = Reflect.get(result, "agent");
const exitCode = Reflect.get(result, "exitCode");
return [
{
id,
...(typeof agent === "string" ? { agent } : {}),
...(typeof exitCode === "number" ? { exitCode } : {}),
error: Reflect.get(result, "error"),
aborted: Reflect.get(result, "aborted") === true,
},
];
});
}
function taskResultStatus(
result: OmpTaskResult,
messageIsError: boolean,
): "completed" | "failed" | "canceled" {
if (messageIsError || result.error || (result.exitCode !== undefined && result.exitCode !== 0)) {
return "failed";
}
return result.aborted ? "canceled" : "completed";
}
function taskResultText(message: Extract<OmpAgentMessage, { role: "toolResult" }>): string {
return Array.isArray(message.content)
? message.content
.flatMap((block) =>
block &&
typeof block === "object" &&
Reflect.get(block, "type") === "text" &&
typeof Reflect.get(block, "text") === "string"
? [Reflect.get(block, "text") as string]
: [],
)
.join("\n")
: "";
}
function stripExtension(filePath: string): string {
const extension = extname(filePath);
return extension ? filePath.slice(0, -extension.length) : filePath;
}
export async function readActiveOmpEntryChain(
sessionFile: string,
activeEntryId?: string,
): Promise<OmpSessionEntry[]> {
const content = await readFile(sessionFile, "utf8");
const entries = content.split("\n").flatMap((line) => {
if (!line.trim()) return [];
try {
const value = JSON.parse(line) as OmpSessionEntry;
return value && typeof value === "object" && typeof value.id === "string" ? [value] : [];
} catch {
return [];
}
});
if (entries.length === 0) return [];
const byId = new Map(entries.map((entry) => [entry.id!, entry]));
const parentIds = new Set(entries.flatMap((entry) => (entry.parentId ? [entry.parentId] : [])));
const leaves = entries.filter((entry) => !parentIds.has(entry.id!));
let current: OmpSessionEntry | undefined =
(activeEntryId ? byId.get(activeEntryId) : undefined) ?? leaves.at(-1) ?? entries.at(-1);
const chain: OmpSessionEntry[] = [];
const seen = new Set<string>();
while (current?.id && !seen.has(current.id)) {
chain.push(current);
seen.add(current.id);
current = current.parentId ? byId.get(current.parentId) : undefined;
}
return chain.toReversed();
}
function mapEntryMessage(entry: OmpSessionEntry): OmpAgentMessage | null {
const message = entry.message;
if (message && typeof message.role === "string") {
if (message.role === "system") {
return null;
}
if (["user", "assistant", "toolResult", "custom", "bashExecution"].includes(message.role)) {
return message as unknown as OmpAgentMessage;
}
return visibleFallback(message.role, message);
}
if (!entry.type || isControlEntryType(entry.type)) {
return null;
}
return visibleFallback(entry.type, entry);
}
function isControlEntryType(type: string): boolean {
return (
type === "session" ||
type === "session_init" ||
type === "system" ||
type === "title" ||
type === "title_change" ||
type === "custom" ||
type === "system_prompt" ||
type === "model_change" ||
type === "thinking_level_change" ||
type === "tool_execution" ||
type.startsWith("tool_execution_")
);
}
function visibleFallback(role: string, value: Record<string, unknown>): OmpAgentMessage {
let text = "Unsupported history record";
if (typeof value.content === "string") {
text = value.content;
} else if (typeof value.text === "string") {
text = value.text;
}
return { role: "custom", content: `[${role}] ${text}` } as OmpAgentMessage;
}
function textOf(content: unknown): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content
.flatMap((part) =>
part && typeof part === "object" && typeof (part as { text?: unknown }).text === "string"
? [(part as { text: string }).text]
: [],
)
.join("\n");
}

View File

@@ -0,0 +1,202 @@
import pino from "pino";
import { describe, expect, test } from "vitest";
import { z } from "zod";
import type { PaseoToolCatalog, PaseoToolDefinition, PaseoToolResult } from "../../tools/types.js";
import {
clearOmpHostToolState,
handleOmpHostToolRuntimeEvent,
serializeOmpHostTools,
waitForOmpHostToolsIdle,
} from "./host-tools.js";
import type { OmpRpcHostToolResult } from "./rpc-types.js";
import { FakeOmp } from "./test-utils/fake-omp.js";
function createCatalog(tools: PaseoToolDefinition[]): PaseoToolCatalog {
const toolMap = new Map(tools.map((tool) => [tool.name, tool]));
return {
tools: toolMap,
getTool: (name) => toolMap.get(name),
executeTool: async (name, input, context = {}) => {
const tool = toolMap.get(name);
if (!tool) throw new Error(`Missing tool ${name}`);
return await tool.handler(input, context);
},
};
}
class OmpHostToolHarness {
private readonly logger = pino({ level: "silent" });
private resolveControlledResult: ((result: PaseoToolResult) => void) | null = null;
private controlledSignal: AbortSignal | null = null;
private resolveControlledStart: (() => void) | null = null;
private readonly controlledStart = new Promise<void>((resolve) => {
this.resolveControlledStart = resolve;
});
private constructor(
private readonly catalog: PaseoToolCatalog,
private readonly runtimeSession: Awaited<ReturnType<FakeOmp["startSession"]>>,
) {}
static async withTools(tools: PaseoToolDefinition[]): Promise<OmpHostToolHarness> {
const omp = new FakeOmp();
const runtimeSession = await omp.startSession({ cwd: "/workspace/project" });
return new OmpHostToolHarness(createCatalog(tools), runtimeSession);
}
static async cancellable(): Promise<OmpHostToolHarness> {
let harness: OmpHostToolHarness;
const tool: PaseoToolDefinition = {
name: "wait_for_agent",
description: "Wait for a Paseo agent.",
handler: async (_input, context) => {
harness.controlledSignal = context.signal ?? null;
harness.resolveControlledStart?.();
return await new Promise<PaseoToolResult>((resolve) => {
harness.resolveControlledResult = resolve;
});
},
};
harness = await OmpHostToolHarness.withTools([tool]);
return harness;
}
async call(input: {
id: string;
toolCallId: string;
toolName: string;
arguments: Record<string, unknown>;
}): Promise<OmpRpcHostToolResult> {
const result = this.runtimeSession.nextHostToolResult();
handleOmpHostToolRuntimeEvent({ type: "host_tool_call", ...input }, this.routerInput());
return await result;
}
startControlledCall(): void {
handleOmpHostToolRuntimeEvent(
{
type: "host_tool_call",
id: "host-cancel",
toolCallId: "tool-cancel",
toolName: "wait_for_agent",
arguments: { agentId: "child-1" },
},
this.routerInput(),
);
}
async waitForControlledCall(): Promise<void> {
await this.controlledStart;
}
cancelControlledCall(): void {
handleOmpHostToolRuntimeEvent(
{ type: "host_tool_cancel", id: "cancel-1", targetId: "host-cancel" },
this.routerInput(),
);
}
completeControlledCall(result: PaseoToolResult): void {
if (!this.resolveControlledResult) throw new Error("Controlled host tool has not started");
this.resolveControlledResult(result);
}
async waitForIdle(): Promise<void> {
await waitForOmpHostToolsIdle(this.runtimeSession);
}
wasControlledCallAborted(): boolean {
return this.controlledSignal?.aborted === true;
}
updates() {
return this.runtimeSession.hostToolUpdates;
}
results() {
return this.runtimeSession.hostToolResults;
}
close(): void {
clearOmpHostToolState(this.runtimeSession);
}
private routerInput() {
return { runtimeSession: this.runtimeSession, paseoTools: this.catalog, logger: this.logger };
}
}
describe("OMP host tools", () => {
test("serializes the caller-scoped Paseo catalog for set_host_tools", () => {
const catalog = createCatalog([
{
name: "create_agent",
title: "Create agent",
description: "Create a Paseo agent.",
inputSchema: { initialPrompt: z.string().describe("Prompt for the new agent.") },
handler: async () => ({ content: [] }),
},
]);
expect(serializeOmpHostTools(catalog)).toEqual([
{
name: "create_agent",
label: "Create agent",
description: "Create a Paseo agent.",
parameters: expect.objectContaining({ type: "object", required: ["initialPrompt"] }),
},
]);
});
test("routes calls and progress through the typed OMP runtime", async () => {
const omp = await OmpHostToolHarness.withTools([
{
name: "create_agent",
description: "Create a Paseo agent.",
handler: async (input, context) => {
context.sendUpdate?.({ content: [{ type: "text", text: "creating" }] });
return { content: [], structuredContent: { input, agentId: "child-1" } };
},
},
]);
await expect(
omp.call({
id: "host-1",
toolCallId: "tool-1",
toolName: "create_agent",
arguments: { initialPrompt: "Inspect the bug" },
}),
).resolves.toEqual({
type: "host_tool_result",
id: "host-1",
result: {
content: [{ type: "text", text: expect.stringContaining('"agentId": "child-1"') }],
details: { input: { initialPrompt: "Inspect the bug" }, agentId: "child-1" },
},
});
expect(omp.updates()).toEqual([
{
type: "host_tool_update",
id: "host-1",
partialResult: { content: [{ type: "text", text: "creating" }] },
},
]);
});
test("cancels an in-flight host tool and drops its late result", async () => {
const omp = await OmpHostToolHarness.cancellable();
omp.startControlledCall();
await omp.waitForControlledCall();
omp.cancelControlledCall();
omp.completeControlledCall({ content: [{ type: "text", text: "late" }] });
await omp.waitForIdle();
expect(omp.wasControlledCallAborted()).toBe(true);
expect(omp.results()).toEqual([]);
expect(omp.updates()).toEqual([]);
omp.close();
});
});

View File

@@ -0,0 +1,270 @@
import type { Logger } from "pino";
import {
addModelVisibleStructuredContent,
serializePaseoToolInputParameters,
} from "../../tools/paseo-tool-serialization.js";
import type { PaseoToolCatalog, PaseoToolResult } from "../../tools/types.js";
import type { OmpRuntimeSession } from "./runtime.js";
import {
OmpRpcHostToolCallRequestSchema,
OmpRpcHostToolCancelRequestSchema,
OmpRpcHostToolUpdateSchema,
type OmpAgentToolResult,
type OmpRpcHostToolCallRequest,
type OmpRpcHostToolDefinition,
type OmpRpcHostToolResult,
type OmpRpcHostToolUpdate,
} from "./rpc-types.js";
interface PendingOmpHostToolCall {
controller: AbortController;
canceled: boolean;
}
interface OmpHostToolRouterInput {
runtimeSession: OmpRuntimeSession;
catalog: PaseoToolCatalog;
logger: Logger;
}
const routersByRuntimeSession = new WeakMap<OmpRuntimeSession, OmpHostToolRouter>();
export function serializeOmpHostTools(catalog: PaseoToolCatalog): OmpRpcHostToolDefinition[] {
return [...catalog.tools.values()].map((tool) => {
const definition: OmpRpcHostToolDefinition = {
name: tool.name,
description: tool.description,
parameters: serializePaseoToolInputParameters(tool),
};
if (tool.title) {
definition.label = tool.title;
}
return definition;
});
}
export async function setOmpHostTools(
runtimeSession: OmpRuntimeSession,
catalog: PaseoToolCatalog,
): Promise<string[]> {
return await runtimeSession.setHostTools(serializeOmpHostTools(catalog));
}
export function handleOmpHostToolRuntimeEvent(
event: unknown,
input: {
runtimeSession: OmpRuntimeSession;
paseoTools?: PaseoToolCatalog;
logger: Logger;
},
): boolean {
if (!isRecord(event) || typeof event.type !== "string" || !isOmpHostToolEventType(event.type)) {
return false;
}
const call = OmpRpcHostToolCallRequestSchema.safeParse(event);
if (call.success) {
const router = getRouter(input);
if (!router) {
sendMissingCatalogResult(input.runtimeSession, call.data);
return true;
}
router.handleCall(call.data);
return true;
}
const cancel = OmpRpcHostToolCancelRequestSchema.safeParse(event);
if (cancel.success) {
getRouter(input)?.handleCancel(cancel.data.targetId);
return true;
}
const update = OmpRpcHostToolUpdateSchema.safeParse(event);
if (update.success) {
input.logger.debug({ id: update.data.id }, "Ignoring unexpected inbound OMP host tool update");
return true;
}
input.logger.debug({ event }, "Dropped malformed OMP host tool frame");
return true;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function clearOmpHostToolState(runtimeSession: OmpRuntimeSession): void {
routersByRuntimeSession.get(runtimeSession)?.clear();
routersByRuntimeSession.delete(runtimeSession);
}
export async function waitForOmpHostToolsIdle(runtimeSession: OmpRuntimeSession): Promise<void> {
await routersByRuntimeSession.get(runtimeSession)?.waitForIdle();
}
function getRouter(input: {
runtimeSession: OmpRuntimeSession;
paseoTools?: PaseoToolCatalog;
logger: Logger;
}): OmpHostToolRouter | null {
if (!input.paseoTools) {
return null;
}
const existing = routersByRuntimeSession.get(input.runtimeSession);
if (existing) {
return existing;
}
const router = new OmpHostToolRouter({
runtimeSession: input.runtimeSession,
catalog: input.paseoTools,
logger: input.logger,
});
routersByRuntimeSession.set(input.runtimeSession, router);
return router;
}
function sendMissingCatalogResult(
runtimeSession: OmpRuntimeSession,
request: OmpRpcHostToolCallRequest,
): void {
runtimeSession.sendHostToolResult(
toOmpHostToolErrorResult(
request.id,
`Host tool "${request.toolName}" was called before Paseo tools were registered`,
),
);
}
function isOmpHostToolEventType(type: string): boolean {
return type === "host_tool_call" || type === "host_tool_cancel" || type === "host_tool_update";
}
class OmpHostToolRouter {
private readonly runtimeSession: OmpRuntimeSession;
private readonly catalog: PaseoToolCatalog;
private readonly logger: Logger;
private readonly pendingCalls = new Map<string, PendingOmpHostToolCall>();
private readonly idleWaiters = new Set<() => void>();
constructor(input: OmpHostToolRouterInput) {
this.runtimeSession = input.runtimeSession;
this.catalog = input.catalog;
this.logger = input.logger;
}
handleCall(request: OmpRpcHostToolCallRequest): void {
const entry: PendingOmpHostToolCall = {
controller: new AbortController(),
canceled: false,
};
this.pendingCalls.set(request.id, entry);
void this.executeCall(request, entry).catch((error: unknown) => {
this.logger.warn({ err: error, toolName: request.toolName }, "OMP host tool call failed");
});
}
handleCancel(targetId: string): void {
const pending = this.pendingCalls.get(targetId);
if (!pending) {
return;
}
pending.canceled = true;
pending.controller.abort(new Error(`OMP host tool call ${targetId} cancelled`));
}
clear(): void {
for (const pending of this.pendingCalls.values()) {
pending.canceled = true;
pending.controller.abort(new Error("OMP session closed"));
}
this.pendingCalls.clear();
this.resolveIdleWaiters();
}
waitForIdle(): Promise<void> {
if (this.pendingCalls.size === 0) return Promise.resolve();
return new Promise((resolve) => this.idleWaiters.add(resolve));
}
private async executeCall(
request: OmpRpcHostToolCallRequest,
entry: PendingOmpHostToolCall,
): Promise<void> {
try {
const result = await this.catalog.executeTool(request.toolName, request.arguments, {
signal: entry.controller.signal,
sendUpdate: (update) => {
if (entry.canceled || entry.controller.signal.aborted) {
return;
}
this.sendUpdate(request.id, update);
},
});
if (entry.canceled || entry.controller.signal.aborted) {
return;
}
this.runtimeSession.sendHostToolResult(toOmpHostToolResult(request.id, result));
} catch (error) {
if (entry.canceled || entry.controller.signal.aborted) {
return;
}
this.runtimeSession.sendHostToolResult(toOmpHostToolErrorResult(request.id, error));
} finally {
this.pendingCalls.delete(request.id);
this.resolveIdleWaiters();
}
}
private resolveIdleWaiters(): void {
if (this.pendingCalls.size > 0) return;
for (const resolve of this.idleWaiters) resolve();
this.idleWaiters.clear();
}
private sendUpdate(callId: string, result: PaseoToolResult): void {
const update: OmpRpcHostToolUpdate = {
type: "host_tool_update",
id: callId,
partialResult: toOmpAgentToolResult(addModelVisibleStructuredContent(result)),
};
this.runtimeSession.sendHostToolUpdate(update);
}
}
function toOmpHostToolResult(id: string, result: PaseoToolResult): OmpRpcHostToolResult {
const modelVisibleResult = addModelVisibleStructuredContent(result);
const mappedResult = toOmpAgentToolResult(modelVisibleResult);
return {
type: "host_tool_result",
id,
result: mappedResult,
...(modelVisibleResult.isError !== undefined ? { isError: modelVisibleResult.isError } : {}),
};
}
function toOmpHostToolErrorResult(id: string, error: unknown): OmpRpcHostToolResult {
return {
type: "host_tool_result",
id,
result: {
content: [{ type: "text", text: error instanceof Error ? error.message : String(error) }],
details: {},
isError: true,
},
isError: true,
};
}
function toOmpAgentToolResult(result: PaseoToolResult): OmpAgentToolResult {
const mapped: OmpAgentToolResult = {
content: result.content.map((item) => ({ ...item })),
};
if (result.structuredContent !== undefined) {
mapped.details = result.structuredContent;
}
if (result.isError !== undefined) {
mapped.isError = result.isError;
}
return mapped;
}

View File

@@ -0,0 +1,283 @@
import type { AgentStreamEvent, AgentTimelineItem, ToolCallDetail } from "../../agent-sdk-types.js";
import type { OmpAgentMessage, OmpImageContent, OmpTextContent } from "./rpc-types.js";
import {
extractTextFromToolResult,
mapToolDetail,
parseToolArgs,
parseToolResult,
resolveToolCallName,
type OmpToolResult,
type OmpTrackedToolCall,
} from "./tool-call-detail.js";
export interface OmpCapturedUserMessageEntry {
id: string;
text: string;
}
export interface OmpHistoryMapperHooks {
mapCustomMessage?: (
text: string,
provider: string,
) => Extract<AgentStreamEvent, { type: "timeline" }> | null;
resolveToolCallId?: (toolCallId: string, toolCall: OmpTrackedToolCall) => string;
mapToolDetail?: (
toolCall: OmpTrackedToolCall,
result: OmpToolResult,
context: { toolCallId: string },
) => ToolCallDetail | null;
}
function isTextContentBlock(block: unknown): block is OmpTextContent {
return (
typeof block === "object" &&
block !== null &&
!Array.isArray(block) &&
Reflect.get(block, "type") === "text" &&
typeof Reflect.get(block, "text") === "string"
);
}
export function getUserMessageText(content: string | (OmpTextContent | OmpImageContent)[]): string {
if (typeof content === "string") {
return content;
}
const textParts: string[] = [];
for (const block of content) {
if (isTextContentBlock(block)) {
textParts.push(block.text);
}
}
return textParts.join("\n\n");
}
export class OmpHistoryMapper {
private readonly pendingToolCalls = new Map<string, OmpTrackedToolCall>();
private userIndex = 0;
private assistantIndex = 0;
constructor(
private readonly provider: string,
private readonly userEntries: readonly OmpCapturedUserMessageEntry[] = [],
private readonly hooks: OmpHistoryMapperHooks = {},
) {}
mapMessages(messages: readonly OmpAgentMessage[]): AgentStreamEvent[] {
const events: AgentStreamEvent[] = [];
for (const message of messages) {
switch (message.role) {
case "user":
events.push(...this.mapUserMessage(message));
break;
case "custom":
events.push(...this.mapCustomMessage(message));
break;
case "assistant":
events.push(...this.mapAssistantMessage(message));
break;
case "toolResult": {
const event = this.mapToolResultMessage(message);
if (event) {
events.push(event);
}
break;
}
case "bashExecution":
events.push(this.mapBashExecutionMessage(message));
break;
}
}
return events;
}
private mapUserMessage(message: Extract<OmpAgentMessage, { role: "user" }>): AgentStreamEvent[] {
const text = getUserMessageText(message.content);
this.userIndex += 1;
if (!text) {
return [];
}
const userEntry = this.userEntries[this.userIndex - 1];
return [
{
type: "timeline",
provider: this.provider,
item: {
type: "user_message",
text,
...(userEntry ? { messageId: userEntry.id } : {}),
},
},
];
}
private mapCustomMessage(
message: Extract<OmpAgentMessage, { role: "custom" }>,
): AgentStreamEvent[] {
const text = getUserMessageText(message.content);
const mappedEvent = text ? this.hooks.mapCustomMessage?.(text, this.provider) : null;
if (mappedEvent) {
return [mappedEvent];
}
return text
? [
{
type: "timeline",
provider: this.provider,
item: { type: "assistant_message", text },
},
]
: [];
}
private mapAssistantMessage(
message: Extract<OmpAgentMessage, { role: "assistant" }>,
): AgentStreamEvent[] {
const events: AgentStreamEvent[] = [];
this.assistantIndex += 1;
const messageId =
message.responseId || `${this.provider}-history-assistant-${this.assistantIndex}`;
for (const content of message.content) {
if (content.type === "text" && content.text) {
events.push({
type: "timeline",
provider: this.provider,
item: { type: "assistant_message", text: content.text, messageId },
});
continue;
}
if (content.type === "thinking" && content.thinking) {
events.push({
type: "timeline",
provider: this.provider,
item: { type: "reasoning", text: content.thinking },
});
continue;
}
if (content.type === "toolCall") {
const tracked = parseToolArgs(content.name, content.arguments);
this.pendingToolCalls.set(content.id, tracked);
const detail = this.mapToolDetail(content.id, tracked, null);
if (!detail) {
continue;
}
events.push({
type: "timeline",
provider: this.provider,
item: {
type: "tool_call",
callId: this.resolveToolCallId(content.id, tracked),
name: tracked.toolName,
status: "running",
detail,
error: null,
},
});
}
}
return events;
}
private mapToolResultMessage(
message: Extract<OmpAgentMessage, { role: "toolResult" }>,
): AgentStreamEvent | null {
const tracked =
this.pendingToolCalls.get(message.toolCallId) ?? parseToolArgs(message.toolName, null);
this.pendingToolCalls.delete(message.toolCallId);
const result = parseToolResult({ content: message.content, details: message.details });
const detail = this.mapToolDetail(message.toolCallId, tracked, result);
if (!detail) {
return null;
}
return {
type: "timeline",
provider: this.provider,
item: toToolResultTimelineItem({
callId: this.resolveToolCallId(message.toolCallId, tracked),
name: resolveToolCallName(tracked, result),
isError: Boolean(message.isError),
detail,
errorText: extractTextFromToolResult(result) ?? "Tool call failed",
}),
};
}
private mapBashExecutionMessage(
message: Extract<OmpAgentMessage, { role: "bashExecution" }>,
): AgentStreamEvent {
const detail: ToolCallDetail = {
type: "shell",
command: message.command,
output: message.output,
exitCode: message.exitCode ?? null,
};
return {
type: "timeline",
provider: this.provider,
item: {
type: "tool_call",
callId: `omp-bash-${message.timestamp}`,
name: "bash",
status: message.cancelled ? "canceled" : "completed",
detail,
error: null,
},
};
}
private resolveToolCallId(toolCallId: string, toolCall: OmpTrackedToolCall): string {
return this.hooks.resolveToolCallId?.(toolCallId, toolCall) ?? toolCallId;
}
private mapToolDetail(
toolCallId: string,
toolCall: OmpTrackedToolCall,
result: OmpToolResult,
): ToolCallDetail | null {
const hook = this.hooks.mapToolDetail;
return hook ? hook(toolCall, result, { toolCallId }) : mapToolDetail(toolCall, result);
}
}
export async function* streamOmpCoreHistory(
provider: string,
messages: OmpAgentMessage[],
userEntries: readonly OmpCapturedUserMessageEntry[] = [],
hooks: OmpHistoryMapperHooks = {},
): AsyncGenerator<AgentStreamEvent> {
const mapper = new OmpHistoryMapper(provider, userEntries, hooks);
for (const event of mapper.mapMessages(messages)) {
if (event) {
yield event;
}
}
}
function toToolResultTimelineItem(input: {
callId: string;
name: string;
isError: boolean;
detail: ToolCallDetail;
errorText: string;
}): AgentTimelineItem {
if (input.isError) {
return {
type: "tool_call",
callId: input.callId,
name: input.name,
status: "failed",
detail: input.detail,
error: input.errorText,
};
}
return {
type: "tool_call",
callId: input.callId,
name: input.name,
status: "completed",
detail: input.detail,
error: null,
};
}

View File

@@ -0,0 +1,150 @@
import { existsSync } from "node:fs";
import { homedir } from "node:os";
import { join, resolve } from "node:path";
import { OMP_MODES } from "@getpaseo/protocol/provider-manifest";
import { z } from "zod";
import type { ProviderRuntimeSettings } from "../../provider-launch-config.js";
const OMP_SESSION_DIR = "~/.omp/agent/sessions";
const DEFAULT_OMP_MODE_ID = "full";
export const MIN_SUPPORTED_OMP_VERSION = "16.3.9";
export { OMP_MODES };
export const OmpProviderParamsSchema = z
.object({
sessionDir: z.string().min(1).optional(),
smolModel: z.string().min(1).optional(),
slowModel: z.string().min(1).optional(),
planModel: z.string().min(1).optional(),
})
.strict();
export interface OmpRuntimeProviderParams {
sessionDir: string;
}
export interface OmpModelRoleParams {
smolModel?: string;
slowModel?: string;
planModel?: string;
}
export function resolveOmpLaunchMode(
modeId: string | undefined,
modelRoleParams: OmpModelRoleParams = {},
): { modeId: string; extraArgs: string[] } {
const modelRoleArgs = resolveOmpModelRoleArgs(modelRoleParams);
switch (modeId ?? DEFAULT_OMP_MODE_ID) {
case "full":
return { modeId: "full", extraArgs: ["--approval-mode", "yolo", ...modelRoleArgs] };
case "ask":
return {
modeId: "ask",
extraArgs: ["--approval-mode", "always-ask", ...modelRoleArgs],
};
default:
throw new Error(`Unsupported OMP mode '${modeId}'`);
}
}
function resolveOmpModelRoleArgs(modelRoleParams: OmpModelRoleParams): string[] {
const args: string[] = [];
if (modelRoleParams.smolModel) args.push("--smol", modelRoleParams.smolModel);
if (modelRoleParams.slowModel) args.push("--slow", modelRoleParams.slowModel);
if (modelRoleParams.planModel) args.push("--plan", modelRoleParams.planModel);
return args;
}
export interface OmpDiagnosticPaths {
profile: string;
configRoot: string;
agentDir: string;
agentDb: string;
xdgDataRoot: string;
xdgStateRoot: string;
xdgCacheRoot: string;
}
export function resolveOmpDiagnosticPaths(
env: NodeJS.ProcessEnv = process.env,
home: string = homedir(),
platform: NodeJS.Platform = process.platform,
): OmpDiagnosticPaths {
const normalizedProfile = (env.OMP_PROFILE ?? env.PI_PROFILE)?.trim();
const profile =
normalizedProfile && normalizedProfile !== "default" ? normalizedProfile : "default";
const baseConfigRoot = join(home, env.PI_CONFIG_DIR || ".omp");
const configRoot =
profile === "default" ? baseConfigRoot : join(baseConfigRoot, "profiles", profile);
const defaultAgentDir = join(configRoot, "agent");
const agentDir =
profile === "default" && env.PI_CODING_AGENT_DIR
? resolve(env.PI_CODING_AGENT_DIR)
: defaultAgentDir;
const xdgSupported = platform === "linux" || platform === "darwin";
const resolveXdgRoot = (variable: "XDG_DATA_HOME" | "XDG_STATE_HOME" | "XDG_CACHE_HOME") => {
const base = env[variable];
if (!xdgSupported || agentDir !== defaultAgentDir || !base) return undefined;
const appRoot = join(base, "omp");
const candidate = profile === "default" ? appRoot : join(appRoot, "profiles", profile);
return existsSync(candidate) ? candidate : undefined;
};
const xdgDataRoot = resolveXdgRoot("XDG_DATA_HOME") ?? configRoot;
const xdgStateRoot = resolveXdgRoot("XDG_STATE_HOME") ?? configRoot;
const xdgCacheRoot = resolveXdgRoot("XDG_CACHE_HOME") ?? configRoot;
return {
profile,
configRoot,
agentDir,
agentDb: join(xdgDataRoot === configRoot ? agentDir : xdgDataRoot, "agent.db"),
xdgDataRoot,
xdgStateRoot,
xdgCacheRoot,
};
}
export function formatOmpVersionSupport(versionOutput: string): string {
const match = versionOutput.match(/(\d+)\.(\d+)\.(\d+)\b/);
if (!match) return `unknown (minimum ${MIN_SUPPORTED_OMP_VERSION})`;
const installed = [Number(match[1]), Number(match[2]), Number(match[3])];
const minimum = MIN_SUPPORTED_OMP_VERSION.split(".").map(Number);
const supported =
installed[0]! > minimum[0]! ||
(installed[0] === minimum[0] &&
(installed[1]! > minimum[1]! ||
(installed[1] === minimum[1]! && installed[2]! >= minimum[2]!)));
return `${match[1]}.${match[2]}.${match[3]} (${supported ? "supported" : "unsupported"}; minimum ${MIN_SUPPORTED_OMP_VERSION})`;
}
export function resolveOmpProviderParams(providerParams: unknown): {
runtimeProviderParams: OmpRuntimeProviderParams;
modelRoleParams: OmpModelRoleParams;
} {
const params = OmpProviderParamsSchema.parse(providerParams ?? {});
return {
runtimeProviderParams: { sessionDir: params.sessionDir ?? OMP_SESSION_DIR },
modelRoleParams: {
...(params.smolModel ? { smolModel: params.smolModel } : {}),
...(params.slowModel ? { slowModel: params.slowModel } : {}),
...(params.planModel ? { planModel: params.planModel } : {}),
},
};
}
export function mergeOmpRuntimeSettings(
base: ProviderRuntimeSettings | undefined,
override: ProviderRuntimeSettings | undefined,
): ProviderRuntimeSettings | undefined {
if (!base && !override) return undefined;
return {
command: override?.command ?? base?.command,
env: base?.env || override?.env ? { ...base?.env, ...override?.env } : undefined,
disallowedTools:
base?.disallowedTools || override?.disallowedTools
? [...(base?.disallowedTools ?? []), ...(override?.disallowedTools ?? [])]
: undefined,
};
}

View File

@@ -0,0 +1,621 @@
import { z } from "zod";
export const OmpThinkingLevelSchema = z.enum(["off", "minimal", "low", "medium", "high", "xhigh"]);
export const OmpImageContentSchema = z
.object({ type: z.literal("image"), data: z.string(), mimeType: z.string() })
.passthrough();
export const OmpTextContentSchema = z
.object({ type: z.literal("text"), text: z.string() })
.passthrough();
export const OmpThinkingContentSchema = z
.object({ type: z.literal("thinking"), thinking: z.string() })
.passthrough();
export const OmpToolCallContentSchema = z
.object({
type: z.literal("toolCall"),
id: z.string(),
name: z.string(),
arguments: z.unknown(),
})
.passthrough();
export const OmpAssistantContentSchema = z.discriminatedUnion("type", [
OmpTextContentSchema,
OmpThinkingContentSchema,
OmpToolCallContentSchema,
]);
const OmpUserMessageSchema = z
.object({
role: z.literal("user"),
content: z.union([z.string(), z.array(z.union([OmpTextContentSchema, OmpImageContentSchema]))]),
})
.passthrough();
const OmpCustomMessageSchema = z
.object({
role: z.literal("custom"),
content: z.union([z.string(), z.array(z.union([OmpTextContentSchema, OmpImageContentSchema]))]),
})
.passthrough();
const OmpAssistantMessageSchema = z
.object({
role: z.literal("assistant"),
content: z.array(OmpAssistantContentSchema),
provider: z.string().optional(),
model: z.string().optional(),
responseId: z.string().optional(),
responseModel: z.string().optional(),
errorMessage: z.string().nullable().optional(),
stopReason: z.string().optional(),
})
.passthrough();
const OmpToolResultMessageSchema = z
.object({
role: z.literal("toolResult"),
toolCallId: z.string(),
toolName: z.string(),
content: z.unknown(),
isError: z.boolean().optional(),
details: z.unknown().optional(),
})
.passthrough();
const OmpBashExecutionMessageSchema = z
.object({
role: z.literal("bashExecution"),
command: z.string(),
output: z.string().optional(),
exitCode: z.number().nullable().optional(),
cancelled: z.boolean().optional(),
timestamp: z.number(),
})
.passthrough();
export const OmpAgentMessageSchema = z.discriminatedUnion("role", [
OmpUserMessageSchema,
OmpCustomMessageSchema,
OmpAssistantMessageSchema,
OmpToolResultMessageSchema,
OmpBashExecutionMessageSchema,
]);
export const OmpModelSchema = z
.object({
provider: z.string(),
id: z.string(),
name: z.string().optional(),
reasoning: z.boolean().optional(),
contextWindow: z.number().optional(),
maxTokens: z.number().nullable().optional(),
api: z.string().optional(),
baseUrl: z.string().optional(),
input: z.array(z.string()).optional(),
cost: z.record(z.string(), z.unknown()).optional(),
compat: z.unknown().optional(),
})
.passthrough();
const OmpContextUsageSchema = z
.object({
tokens: z.number().nullable().optional(),
contextWindow: z.number().nullable().optional(),
percent: z.number().nullable().optional(),
})
.passthrough();
export const OmpSessionStateSchema = z
.object({
model: OmpModelSchema.nullable().optional(),
thinkingLevel: OmpThinkingLevelSchema,
isStreaming: z.boolean(),
isCompacting: z.boolean(),
autoCompactionEnabled: z.boolean().optional(),
sessionFile: z.string().optional(),
sessionId: z.string(),
sessionName: z.string().optional(),
messageCount: z.number().int().nonnegative(),
queuedMessageCount: z.number().int().nonnegative(),
contextUsage: OmpContextUsageSchema.optional(),
todoPhases: z.unknown().optional(),
})
.passthrough();
export const OmpSessionStatsSchema = z
.object({
tokens: z
.object({
input: z.number().optional(),
output: z.number().optional(),
cacheRead: z.number().optional(),
cacheWrite: z.number().optional(),
total: z.number().optional(),
})
.passthrough()
.optional(),
cost: z.number().optional(),
contextUsage: OmpContextUsageSchema.optional(),
})
.passthrough();
export const OmpRpcSlashCommandSchema = z
.object({
name: z.string(),
description: z.string().optional(),
source: z.enum(["extension", "prompt", "skill", "builtin"]),
sourceInfo: z.record(z.string(), z.unknown()).optional(),
input: z.object({ hint: z.string().optional() }).passthrough().nullable().optional(),
})
.passthrough();
export const OmpAgentToolResultSchema = z
.object({
content: z.array(z.object({ type: z.string(), text: z.string().optional() }).passthrough()),
details: z.unknown().optional(),
isError: z.boolean().optional(),
})
.passthrough();
export const OmpRpcHostToolDefinitionSchema = z
.object({
name: z.string(),
label: z.string().optional(),
description: z.string(),
parameters: z.record(z.string(), z.unknown()),
hidden: z.boolean().optional(),
})
.passthrough();
export const OmpRpcHostToolCallRequestSchema = z
.object({
type: z.literal("host_tool_call"),
id: z.string(),
toolCallId: z.string(),
toolName: z.string(),
arguments: z.record(z.string(), z.unknown()),
})
.passthrough();
export const OmpRpcHostToolCancelRequestSchema = z
.object({
type: z.literal("host_tool_cancel"),
id: z.string(),
targetId: z.string(),
})
.passthrough();
export const OmpRpcHostToolUpdateSchema = z
.object({
type: z.literal("host_tool_update"),
id: z.string(),
partialResult: OmpAgentToolResultSchema,
})
.passthrough();
export const OmpRpcHostToolResultSchema = z
.object({
type: z.literal("host_tool_result"),
id: z.string(),
result: OmpAgentToolResultSchema,
isError: z.boolean().optional(),
})
.passthrough();
export const OmpSubagentSubscriptionLevelSchema = z.enum(["off", "progress", "events"]);
export const OmpSubagentStatusSchema = z.enum([
"pending",
"running",
"completed",
"failed",
"aborted",
]);
export const OmpSubagentLifecyclePayloadSchema = z
.object({
id: z.string(),
agent: z.string(),
agentSource: z.string().optional(),
description: z.string().optional(),
status: z.enum(["started", "completed", "failed", "aborted"]),
sessionFile: z.string().optional(),
parentToolCallId: z.string().optional(),
index: z.number().int().nonnegative(),
detached: z.boolean().optional(),
})
.passthrough();
export const OmpSubagentProgressSchema = z
.object({
id: z.string(),
status: OmpSubagentStatusSchema,
description: z.string().optional(),
currentTool: z.unknown().optional(),
recentTools: z.array(z.unknown()).optional(),
recentOutput: z.array(z.unknown()).optional(),
resolvedModel: z.string().optional(),
})
.passthrough();
export const OmpSubagentProgressPayloadSchema = z
.object({
index: z.number().int().nonnegative(),
agent: z.string(),
agentSource: z.string().optional(),
task: z.string(),
parentToolCallId: z.string().optional(),
assignment: z.string().optional(),
progress: OmpSubagentProgressSchema,
sessionFile: z.string().optional(),
detached: z.boolean().optional(),
})
.passthrough();
export const OmpAssistantMessageEventSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("text_delta"), delta: z.string().optional() }).passthrough(),
z.object({ type: z.literal("thinking_delta"), delta: z.string().optional() }).passthrough(),
z.object({ type: z.literal("start") }).passthrough(),
z.object({ type: z.literal("text_start") }).passthrough(),
z.object({ type: z.literal("text_end") }).passthrough(),
z.object({ type: z.literal("thinking_start") }).passthrough(),
z.object({ type: z.literal("thinking_end") }).passthrough(),
z.object({ type: z.literal("done") }).passthrough(),
]);
export const OmpAgentSessionEventSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("agent_start") }).passthrough(),
z.object({ type: z.literal("turn_start") }).passthrough(),
z.object({ type: z.literal("message_start"), message: OmpAgentMessageSchema }).passthrough(),
z.object({ type: z.literal("message_end"), message: OmpAgentMessageSchema }).passthrough(),
z
.object({
type: z.literal("message_update"),
message: OmpAgentMessageSchema,
assistantMessageEvent: OmpAssistantMessageEventSchema,
})
.passthrough(),
z
.object({
type: z.literal("tool_execution_start"),
toolCallId: z.string(),
toolName: z.string(),
args: z.unknown(),
})
.passthrough(),
z
.object({
type: z.literal("tool_execution_update"),
toolCallId: z.string(),
toolName: z.string(),
args: z.unknown().optional(),
partialResult: z.unknown(),
})
.passthrough(),
z
.object({
type: z.literal("tool_execution_end"),
toolCallId: z.string(),
toolName: z.string(),
result: z.unknown(),
isError: z.boolean().optional(),
})
.passthrough(),
z
.object({
type: z.literal("compaction_start"),
reason: z.string().optional(),
})
.passthrough(),
z
.object({
type: z.literal("compaction_end"),
reason: z.string().optional(),
errorMessage: z.string().optional(),
aborted: z.boolean().optional(),
})
.passthrough(),
z
.object({ type: z.literal("agent_end"), messages: z.array(OmpAgentMessageSchema).optional() })
.passthrough(),
]);
export const OmpTodoItemSchema = z
.object({
content: z.string(),
status: z.enum(["pending", "in_progress", "completed", "abandoned"]),
})
.passthrough();
export const OmpTodoPhaseSchema = z
.object({ name: z.string(), tasks: z.array(OmpTodoItemSchema) })
.passthrough();
export const OmpTodoReminderEventSchema = z
.object({ type: z.literal("todo_reminder"), todos: z.array(OmpTodoItemSchema) })
.passthrough();
export const OmpNoticeEventSchema = z
.object({
type: z.literal("notice"),
level: z.enum(["info", "warning", "error"]),
message: z.string(),
source: z.string().optional(),
})
.passthrough();
export const OmpGoalSchema = z
.object({
id: z.string().optional(),
objective: z.string().optional(),
status: z.string().optional(),
tokenBudget: z.number().optional(),
tokensUsed: z.number().optional(),
timeUsedSeconds: z.number().optional(),
createdAt: z.string().optional(),
updatedAt: z.string().optional(),
})
.passthrough();
export const OmpGoalModeStateSchema = z
.object({
enabled: z.boolean().optional(),
mode: z.string().optional(),
reason: z.string().optional(),
goal: OmpGoalSchema.optional(),
})
.passthrough();
export const OmpGoalUpdatedEventSchema = z
.object({
type: z.literal("goal_updated"),
goal: OmpGoalSchema.nullable().optional(),
state: OmpGoalModeStateSchema.optional(),
})
.passthrough();
export const OmpAutoRetryStartEventSchema = z
.object({
type: z.literal("auto_retry_start"),
attempt: z.number().int().nonnegative(),
maxAttempts: z.number().int().positive(),
delayMs: z.number().int().nonnegative(),
errorMessage: z.string(),
errorId: z.number().int().optional(),
})
.passthrough();
export const OmpAutoRetryEndEventSchema = z
.object({
type: z.literal("auto_retry_end"),
success: z.boolean(),
attempt: z.number().int().nonnegative(),
finalError: z.string().optional(),
recoveredErrors: z.unknown().optional(),
})
.passthrough();
export const OmpRetryFallbackAppliedEventSchema = z
.object({
type: z.literal("retry_fallback_applied"),
from: z.string(),
to: z.string(),
role: z.string(),
})
.passthrough();
export const OmpRetryFallbackSucceededEventSchema = z
.object({ type: z.literal("retry_fallback_succeeded"), model: z.string(), role: z.string() })
.passthrough();
export const OmpAutoCompactionStartEventSchema = z
.object({ type: z.literal("auto_compaction_start"), reason: z.string(), action: z.string() })
.passthrough();
export const OmpAutoCompactionEndEventSchema = z
.object({
type: z.literal("auto_compaction_end"),
action: z.string().optional(),
result: z.unknown().optional(),
aborted: z.boolean(),
willRetry: z.boolean(),
errorMessage: z.string().optional(),
skipped: z.boolean().optional(),
})
.passthrough();
export const OmpAvailableCommandSchema = z
.object({
name: z.string(),
description: z.string().optional(),
source: z.string().optional(),
input: z.object({ hint: z.string().optional() }).passthrough().nullable().optional(),
})
.passthrough();
export const OmpAvailableCommandsUpdateEventSchema = z
.object({
type: z.literal("available_commands_update"),
commands: z.array(OmpAvailableCommandSchema),
})
.passthrough();
const OmpExtensionUiRequestSchema = z
.object({
type: z.literal("extension_ui_request"),
id: z.string(),
method: z.string(),
title: z.string().optional(),
message: z.string().optional(),
options: z.array(z.string()).optional(),
placeholder: z.string().optional(),
url: z.string().optional(),
launchUrl: z.string().optional(),
instructions: z.string().optional(),
})
.passthrough();
const OmpSubagentLifecycleEventSchema = z
.object({ type: z.literal("subagent_lifecycle"), payload: OmpSubagentLifecyclePayloadSchema })
.passthrough();
const OmpSubagentProgressEventSchema = z
.object({ type: z.literal("subagent_progress"), payload: OmpSubagentProgressPayloadSchema })
.passthrough();
export const OmpSubagentEventPayloadSchema = z
.object({ id: z.string(), event: OmpAgentSessionEventSchema })
.passthrough();
const OmpSubagentEventSchema = z
.object({
type: z.literal("subagent_event"),
payload: OmpSubagentEventPayloadSchema,
})
.passthrough();
export const OmpRuntimeEventSchema = z.discriminatedUnion("type", [
...OmpAgentSessionEventSchema.options,
OmpExtensionUiRequestSchema,
z.object({ type: z.literal("command_output"), text: z.string().optional() }).passthrough(),
z
.object({
type: z.literal("prompt_result"),
id: z.string().optional(),
agentInvoked: z.boolean().optional(),
})
.passthrough(),
z.object({ type: z.literal("process_exit"), error: z.string() }).passthrough(),
OmpSubagentLifecycleEventSchema,
OmpSubagentProgressEventSchema,
OmpSubagentEventSchema,
OmpTodoReminderEventSchema,
OmpNoticeEventSchema,
OmpGoalUpdatedEventSchema,
OmpAutoRetryStartEventSchema,
OmpAutoRetryEndEventSchema,
OmpRetryFallbackAppliedEventSchema,
OmpRetryFallbackSucceededEventSchema,
OmpAutoCompactionStartEventSchema,
OmpAutoCompactionEndEventSchema,
OmpAvailableCommandsUpdateEventSchema,
OmpRpcHostToolCallRequestSchema,
OmpRpcHostToolCancelRequestSchema,
OmpRpcHostToolUpdateSchema,
]);
const OmpCommandBase = { id: z.string().optional() };
export const OmpRpcCommandSchema = z.discriminatedUnion("type", [
z.object({
...OmpCommandBase,
type: z.literal("prompt"),
message: z.string(),
images: z.array(OmpImageContentSchema).optional(),
}),
z.object({
...OmpCommandBase,
type: z.literal("compact"),
customInstructions: z.string().optional(),
}),
z.object({ ...OmpCommandBase, type: z.literal("set_auto_compaction"), enabled: z.boolean() }),
z.object({ ...OmpCommandBase, type: z.literal("abort") }),
z.object({ ...OmpCommandBase, type: z.literal("get_state") }),
z.object({ ...OmpCommandBase, type: z.literal("get_messages") }),
z.object({ ...OmpCommandBase, type: z.literal("get_available_models") }),
z.object({
...OmpCommandBase,
type: z.literal("set_model"),
provider: z.string(),
modelId: z.string(),
}),
z.object({
...OmpCommandBase,
type: z.literal("set_thinking_level"),
level: OmpThinkingLevelSchema,
}),
z.object({ ...OmpCommandBase, type: z.literal("get_session_stats") }),
z.object({ ...OmpCommandBase, type: z.literal("get_available_commands") }),
z.object({
...OmpCommandBase,
type: z.literal("set_subagent_subscription"),
level: OmpSubagentSubscriptionLevelSchema,
}),
z.object({
...OmpCommandBase,
type: z.literal("set_host_tools"),
tools: z.array(OmpRpcHostToolDefinitionSchema),
}),
z.object({ ...OmpCommandBase, type: z.literal("branch"), entryId: z.string() }),
z.object({ ...OmpCommandBase, type: z.literal("get_branch_messages") }),
z.object({
...OmpCommandBase,
type: z.literal("handoff"),
customInstructions: z.string().optional(),
}),
]);
export const OmpPromptAckSchema = z
.object({ agentInvoked: z.boolean().optional() })
.passthrough()
.optional();
export const OmpMessagesResultSchema = z
.object({ messages: z.array(OmpAgentMessageSchema).optional() })
.passthrough();
export const OmpModelsResultSchema = z
.object({ models: z.array(OmpModelSchema).optional() })
.passthrough();
export const OmpCommandsResultSchema = z
.object({ commands: z.array(OmpRpcSlashCommandSchema).optional() })
.passthrough();
export const OmpHostToolsResultSchema = z
.object({ toolNames: z.array(z.string()).optional() })
.passthrough();
export const OmpBranchResultSchema = z
.object({ text: z.string().optional(), cancelled: z.boolean().optional() })
.passthrough();
export const OmpBranchMessagesResultSchema = z
.object({
messages: z.array(z.object({ entryId: z.string(), text: z.string() }).passthrough()).optional(),
})
.passthrough();
export type OmpThinkingLevel = z.infer<typeof OmpThinkingLevelSchema>;
export type OmpImageContent = z.infer<typeof OmpImageContentSchema>;
export type OmpTextContent = z.infer<typeof OmpTextContentSchema>;
export type OmpThinkingContent = z.infer<typeof OmpThinkingContentSchema>;
export type OmpToolCallContent = z.infer<typeof OmpToolCallContentSchema>;
export type OmpAssistantContent = z.infer<typeof OmpAssistantContentSchema>;
export type OmpAgentMessage = z.infer<typeof OmpAgentMessageSchema>;
export type OmpModel = z.infer<typeof OmpModelSchema>;
export type OmpSessionState = z.infer<typeof OmpSessionStateSchema>;
export type OmpSessionStats = z.infer<typeof OmpSessionStatsSchema>;
export type OmpRpcSlashCommand = z.infer<typeof OmpRpcSlashCommandSchema>;
export type OmpAgentToolResult = z.infer<typeof OmpAgentToolResultSchema>;
export type OmpRpcHostToolDefinition = z.infer<typeof OmpRpcHostToolDefinitionSchema>;
export type OmpRpcHostToolCallRequest = z.infer<typeof OmpRpcHostToolCallRequestSchema>;
export type OmpRpcHostToolUpdate = z.infer<typeof OmpRpcHostToolUpdateSchema>;
export type OmpRpcHostToolResult = z.infer<typeof OmpRpcHostToolResultSchema>;
export type OmpSubagentSubscriptionLevel = z.infer<typeof OmpSubagentSubscriptionLevelSchema>;
export type OmpSubagentStatus = z.infer<typeof OmpSubagentStatusSchema>;
export type OmpSubagentLifecyclePayload = z.infer<typeof OmpSubagentLifecyclePayloadSchema>;
export type OmpSubagentProgressPayload = z.infer<typeof OmpSubagentProgressPayloadSchema>;
export type OmpSubagentEventPayload = z.infer<typeof OmpSubagentEventPayloadSchema>;
export type OmpAssistantMessageEvent = z.infer<typeof OmpAssistantMessageEventSchema>;
export type OmpAgentSessionEvent = z.infer<typeof OmpAgentSessionEventSchema>;
export type OmpRuntimeEvent = z.infer<typeof OmpRuntimeEventSchema>;
export type OmpTodoItem = z.infer<typeof OmpTodoItemSchema>;
export type OmpTodoPhase = z.infer<typeof OmpTodoPhaseSchema>;
export type OmpTodoReminderEvent = z.infer<typeof OmpTodoReminderEventSchema>;
export type OmpNoticeEvent = z.infer<typeof OmpNoticeEventSchema>;
export type OmpGoal = z.infer<typeof OmpGoalSchema>;
export type OmpGoalUpdatedEvent = z.infer<typeof OmpGoalUpdatedEventSchema>;
export type OmpAutoRetryStartEvent = z.infer<typeof OmpAutoRetryStartEventSchema>;
export type OmpAutoRetryEndEvent = z.infer<typeof OmpAutoRetryEndEventSchema>;
export type OmpRetryFallbackAppliedEvent = z.infer<typeof OmpRetryFallbackAppliedEventSchema>;
export type OmpRetryFallbackSucceededEvent = z.infer<typeof OmpRetryFallbackSucceededEventSchema>;
export type OmpAutoCompactionStartEvent = z.infer<typeof OmpAutoCompactionStartEventSchema>;
export type OmpAutoCompactionEndEvent = z.infer<typeof OmpAutoCompactionEndEventSchema>;
export type OmpAvailableCommand = z.infer<typeof OmpAvailableCommandSchema>;
export type OmpAvailableCommandsUpdateEvent = z.infer<typeof OmpAvailableCommandsUpdateEventSchema>;
export type OmpRpcCommand = z.infer<typeof OmpRpcCommandSchema>;
export type OmpPromptAck = z.infer<typeof OmpPromptAckSchema> & { requestId?: string };
export interface OmpSubagentSnapshot {
id: string;
index: number;
agent: string;
description?: string;
status: OmpSubagentStatus;
task?: string;
assignment?: string;
sessionFile?: string;
parentToolCallId?: string;
lastUpdate?: number;
}
export interface OmpSubagentMessagesResult {
sessionFile: string;
fromByte: number;
nextByte: number;
reset: boolean;
messages: OmpAgentMessage[];
}
export interface OmpSubagentMessagesSelector {
subagentId?: string;
sessionFile?: string;
fromByte?: number;
}

View File

@@ -0,0 +1,155 @@
import { describe, expect, test } from "vitest";
import type { OmpRuntimeEvent } from "./rpc-types.js";
import {
buildOmpRpcUiPermissionResponse,
classifyOmpRpcUiPermissionRequest,
} from "./rpc-ui-permission-mapper.js";
type ExtensionUiRequestEvent = Extract<OmpRuntimeEvent, { type: "extension_ui_request" }>;
const RPC_UI_CASES: ExtensionUiRequestEvent[] = [
{ type: "extension_ui_request", id: "widget", method: "setWidget", widgetKey: "status" },
{ type: "extension_ui_request", id: "notify", method: "notify", message: "done" },
{
type: "extension_ui_request",
id: "bash",
method: "select",
title: "Allow tool: bash\nCommand: echo rpc-ui-hi",
options: ["Approve", "Deny"],
},
{
type: "extension_ui_request",
id: "edit",
method: "select",
title: "Allow tool: edit\nFile: fixture.txt",
options: ["Approve", "Deny"],
},
{
type: "extension_ui_request",
id: "write",
method: "select",
title: "Allow tool: write\nPath: created.txt\nContent:\nhello write",
options: ["Approve", "Deny"],
},
];
function toolApproval(id: string) {
const event = RPC_UI_CASES.find((candidate) => candidate.id === id);
if (!event) throw new Error(`Missing RPC-UI case ${id}`);
const classification = classifyOmpRpcUiPermissionRequest(event);
if (classification.kind !== "tool") throw new Error(`Expected ${id} to be a tool approval`);
return classification.request;
}
describe("OMP rpc-ui permission mapper", () => {
test("classifies tool approvals and passes through unrelated UI requests", () => {
expect(
RPC_UI_CASES.map((event) => {
const classification = classifyOmpRpcUiPermissionRequest(event);
return [event.id, classification.kind];
}),
).toEqual([
["widget", "passthrough"],
["notify", "passthrough"],
["bash", "tool"],
["edit", "tool"],
["write", "tool"],
]);
});
test("maps tool approvals to renderable permissions", () => {
expect([toolApproval("bash"), toolApproval("edit"), toolApproval("write")]).toEqual([
expect.objectContaining({
id: "bash",
provider: "omp",
name: "bash",
kind: "tool",
detail: { type: "shell", command: "echo rpc-ui-hi" },
metadata: expect.objectContaining({
toolName: "bash",
toolArgs: { command: "echo rpc-ui-hi" },
approveValue: "Approve",
denyValue: "Deny",
}),
}),
expect.objectContaining({
id: "edit",
provider: "omp",
name: "edit",
kind: "tool",
detail: { type: "edit", filePath: "fixture.txt" },
metadata: expect.objectContaining({ toolName: "edit", toolArgs: { path: "fixture.txt" } }),
}),
expect.objectContaining({
id: "write",
provider: "omp",
name: "write",
kind: "tool",
detail: { type: "write", filePath: "created.txt", content: "hello write" },
metadata: expect.objectContaining({
toolName: "write",
toolArgs: { path: "created.txt", content: "hello write" },
}),
}),
]);
});
test("preserves destructive multiline CRLF bash commands exactly", () => {
const title = "Allow tool: bash\r\nCommand: printf first\r\n\r\n rm -rf /tmp/example\r\n";
const classification = classifyOmpRpcUiPermissionRequest({
type: "extension_ui_request",
id: "multiline-bash",
method: "select",
title,
options: ["Approve", "Deny"],
});
if (classification.kind !== "tool") throw new Error("Expected multiline bash approval");
expect(classification.request.detail).toEqual({
type: "shell",
command: "printf first\r\n\r\n rm -rf /tmp/example\r\n",
});
expect(classification.request.metadata?.toolArgs).toEqual({
command: "printf first\r\n\r\n rm -rf /tmp/example\r\n",
});
});
test("rejects approval lookalikes and unknown tools", () => {
expect(
classifyOmpRpcUiPermissionRequest({
type: "extension_ui_request",
id: "not-tool",
method: "select",
title: "Allow tool: bash\nCommand: echo hi",
options: ["Yes", "No"],
}),
).toEqual({ kind: "passthrough" });
expect(
classifyOmpRpcUiPermissionRequest({
type: "extension_ui_request",
id: "unknown-tool",
method: "select",
title: "Allow tool: custom_tool\nReason: needs approval",
options: ["Approve", "Deny"],
}),
).toEqual({ kind: "passthrough" });
});
test("responds to tool approvals with exact select values", () => {
const request = toolApproval("bash");
expect(buildOmpRpcUiPermissionResponse(request, { behavior: "allow" })).toEqual({
value: "Approve",
});
expect(
buildOmpRpcUiPermissionResponse(request, {
behavior: "allow",
selectedActionId: "allow_always",
}),
).toEqual({ value: "Approve" });
expect(buildOmpRpcUiPermissionResponse(request, { behavior: "deny", message: "no" })).toEqual({
value: "Deny",
});
});
});

View File

@@ -0,0 +1,210 @@
import type {
AgentMetadata,
AgentPermissionRequest,
AgentPermissionResponse,
AgentProvider,
ToolCallDetail,
} from "../../agent-sdk-types.js";
import type { OmpRuntimeEvent } from "./rpc-types.js";
const OMP_PROVIDER = "omp";
const OMP_RPC_UI_TOOL_APPROVAL_METADATA = "omp_rpc_ui_tool_approval";
const TOOL_APPROVAL_APPROVE_VALUE = "Approve";
const TOOL_APPROVAL_DENY_VALUE = "Deny";
const TOOL_APPROVAL_OPTIONS = [TOOL_APPROVAL_APPROVE_VALUE, TOOL_APPROVAL_DENY_VALUE] as const;
const TOOL_TITLE_PREFIX = "Allow tool: ";
export type OmpRpcUiPermissionClassification =
| { kind: "tool"; request: AgentPermissionRequest }
| { kind: "passthrough" };
type ExtensionUiRequestEvent = Extract<OmpRuntimeEvent, { type: "extension_ui_request" }>;
interface ToolApprovalDescriptor {
toolName: "bash" | "edit" | "write";
args: AgentMetadata;
detail: ToolCallDetail;
description?: string;
}
export function classifyOmpRpcUiPermissionRequest(
event: ExtensionUiRequestEvent,
options: { provider?: AgentProvider } = {},
): OmpRpcUiPermissionClassification {
const descriptor = parseToolApprovalDescriptor(event);
if (!descriptor) {
return { kind: "passthrough" };
}
const provider = options.provider ?? OMP_PROVIDER;
return {
kind: "tool",
request: {
id: event.id,
provider,
name: descriptor.toolName,
kind: "tool",
title: `Allow tool: ${descriptor.toolName}`,
...(descriptor.description ? { description: descriptor.description } : {}),
detail: descriptor.detail,
actions: [
{
id: "deny",
label: TOOL_APPROVAL_DENY_VALUE,
behavior: "deny",
variant: "danger",
intent: "dismiss",
},
{
id: "approve",
label: TOOL_APPROVAL_APPROVE_VALUE,
behavior: "allow",
variant: "primary",
},
],
metadata: {
extensionUiMethod: "select",
toolApproval: OMP_RPC_UI_TOOL_APPROVAL_METADATA,
toolName: descriptor.toolName,
toolArgs: descriptor.args,
approveValue: TOOL_APPROVAL_APPROVE_VALUE,
denyValue: TOOL_APPROVAL_DENY_VALUE,
},
},
};
}
export function mapOmpRpcUiPermissionRequest(
event: ExtensionUiRequestEvent,
options: { provider?: AgentProvider } = {},
): AgentPermissionRequest | null {
const classification = classifyOmpRpcUiPermissionRequest(event, options);
return classification.kind === "tool" ? classification.request : null;
}
export function buildOmpRpcUiPermissionResponse(
request: AgentPermissionRequest,
response: AgentPermissionResponse,
): { value?: string; confirmed?: boolean; cancelled?: boolean } | null {
if (
request.kind !== "tool" ||
request.metadata?.toolApproval !== OMP_RPC_UI_TOOL_APPROVAL_METADATA
) {
return null;
}
const approveValue = readString(request.metadata.approveValue) ?? TOOL_APPROVAL_APPROVE_VALUE;
const denyValue = readString(request.metadata.denyValue) ?? TOOL_APPROVAL_DENY_VALUE;
return { value: response.behavior === "allow" ? approveValue : denyValue };
}
function parseToolApprovalDescriptor(
event: ExtensionUiRequestEvent,
): ToolApprovalDescriptor | null {
if (event.method !== "select" || !hasExactToolApprovalOptions(event.options)) {
return null;
}
const title = readString(event.title);
if (!title) {
return null;
}
const firstLineBreak = title.search(/\r?\n/);
const firstLine = (firstLineBreak < 0 ? title : title.slice(0, firstLineBreak)).trim();
if (!firstLine.startsWith(TOOL_TITLE_PREFIX)) {
return null;
}
const rawToolName = firstLine.slice(TOOL_TITLE_PREFIX.length).trim();
const body = firstLineBreak < 0 ? "" : title.slice(firstLineBreak).replace(/^\r?\n/, "");
const bodyLines = body.split(/\r?\n/);
switch (rawToolName) {
case "bash":
return parseBashApproval(body);
case "edit":
return parseEditApproval(bodyLines);
case "write":
return parseWriteApproval(bodyLines);
default:
return null;
}
}
function parseBashApproval(body: string): ToolApprovalDescriptor | null {
const match = /(?:^|\r?\n)[\t ]*Command:[\t ]?(.*)$/s.exec(body);
const command = match?.[1];
if (!command) {
return null;
}
return {
toolName: "bash",
args: { command },
description: `Command: ${command}`,
detail: { type: "shell", command },
};
}
function parseEditApproval(lines: string[]): ToolApprovalDescriptor | null {
const filePath = readPrefixedValue(lines, "File:");
if (!filePath) {
return null;
}
return {
toolName: "edit",
args: { path: filePath },
description: `File: ${filePath}`,
detail: { type: "edit", filePath },
};
}
function parseWriteApproval(lines: string[]): ToolApprovalDescriptor | null {
const pathLineIndex = lines.findIndex((line) => line.trim().startsWith("Path:"));
if (pathLineIndex < 0) {
return null;
}
const filePath = stripPrefix(lines[pathLineIndex], "Path:")?.trim();
if (!filePath) {
return null;
}
const contentLineIndex = lines.findIndex(
(line, index) => index > pathLineIndex && line.trim() === "Content:",
);
if (contentLineIndex < 0) {
return null;
}
const content = lines.slice(contentLineIndex + 1).join("\n");
return {
toolName: "write",
args: { path: filePath, content },
description: `Path: ${filePath}`,
detail: { type: "write", filePath, content },
};
}
function hasExactToolApprovalOptions(options: unknown): boolean {
return (
Array.isArray(options) &&
options.length === TOOL_APPROVAL_OPTIONS.length &&
options.every((option, index) => option === TOOL_APPROVAL_OPTIONS[index])
);
}
function readPrefixedValue(lines: readonly string[], prefix: string): string | null {
for (const line of lines) {
const value = stripPrefix(line, prefix)?.trim();
if (value) {
return value;
}
}
return null;
}
function stripPrefix(line: string | undefined, prefix: string): string | null {
const trimmed = line?.trim();
return trimmed?.startsWith(prefix) ? trimmed.slice(prefix.length) : null;
}
function readString(value: unknown): string | null {
return typeof value === "string" && value.length > 0 ? value : null;
}

View File

@@ -0,0 +1,11 @@
import { expect, test } from "vitest";
import { OmpHarness } from "./test-utils/omp-harness.js";
test("falls back to progress when the event subscription is unavailable", async () => {
const omp = new OmpHarness();
omp.failEventSubscription(new Error("events unsupported"));
await omp.start();
await expect(omp.waitForSubscriptionFallback()).resolves.toEqual(["events", "progress"]);
});

View File

@@ -0,0 +1,159 @@
import type {
OmpAgentMessage,
OmpModel,
OmpPromptAck,
OmpRpcHostToolDefinition,
OmpRpcHostToolResult,
OmpRpcHostToolUpdate,
OmpRpcSlashCommand,
OmpRuntimeEvent,
OmpSessionState,
OmpSessionStats,
OmpSubagentSubscriptionLevel,
OmpThinkingLevel,
} from "./rpc-types.js";
import type { ProviderRuntimeSettings } from "../../provider-launch-config.js";
export interface OmpRuntimeLaunch {
cwd: string;
argv: string[];
env?: Record<string, string>;
protocolMode?: "rpc" | "rpc-ui";
model?: string;
thinkingOptionId?: string;
modeId?: string;
session?: string;
noSession?: boolean;
systemPrompt?: string;
extraArgs?: string[];
}
export interface OmpStartSessionInput {
cwd: string;
env?: Record<string, string>;
protocolMode?: "rpc" | "rpc-ui";
model?: string;
thinkingOptionId?: string;
modeId?: string;
session?: string;
noSession?: boolean;
systemPrompt?: string;
extraArgs?: string[];
}
export interface OmpRuntimeSession {
onEvent(callback: (event: OmpRuntimeEvent) => void): () => void;
prompt(
message: string,
images?: Array<{ type: "image"; data: string; mimeType: string }>,
): Promise<OmpPromptAck>;
compact(customInstructions?: string): Promise<void>;
setAutoCompaction(enabled: boolean): Promise<void>;
abort(): Promise<void>;
getState(): Promise<OmpSessionState>;
getMessages(): Promise<OmpAgentMessage[]>;
getAvailableModels(timeoutMs?: number): Promise<OmpModel[]>;
setModel(provider: string, modelId: string): Promise<OmpModel>;
setThinkingLevel(level: OmpThinkingLevel): Promise<void>;
getSessionStats(): Promise<OmpSessionStats>;
getCommands(): Promise<OmpRpcSlashCommand[]>;
setSubagentSubscription(level: OmpSubagentSubscriptionLevel): Promise<void>;
setHostTools(tools: OmpRpcHostToolDefinition[]): Promise<string[]>;
sendHostToolResult(result: OmpRpcHostToolResult): void;
sendHostToolUpdate(update: OmpRpcHostToolUpdate): void;
branch(entryId: string): Promise<{ text: string }>;
getBranchMessages(): Promise<Array<{ entryId: string; text: string }>>;
activeBranchEntryId?: string;
steer(message: string, images?: Array<{ type: "image"; data: string; mimeType: string }>): void;
followUp(
message: string,
images?: Array<{ type: "image"; data: string; mimeType: string }>,
): void;
handoff(customInstructions?: string): Promise<void>;
respondToExtensionUiRequest(
id: string,
response: { value?: string; confirmed?: boolean; cancelled?: boolean },
): void;
cancelExtensionUiRequest(id: string): void;
close(): Promise<void>;
}
export interface OmpRuntime {
startSession(input: OmpStartSessionInput): Promise<OmpRuntimeSession>;
}
export function buildOmpLaunch(input: {
command: [string, ...string[]];
runtimeSettings?: ProviderRuntimeSettings;
session: OmpStartSessionInput;
}): OmpRuntimeLaunch {
const command =
input.runtimeSettings?.command?.mode === "replace" && input.runtimeSettings.command.argv[0]
? input.runtimeSettings.command.argv
: input.command;
const argv = [...command];
const protocolMode = input.session.protocolMode ?? "rpc";
const systemPrompt = input.session.systemPrompt?.trim();
appendOmpLaunchArgs(argv, input.session, protocolMode, systemPrompt);
return {
cwd: input.session.cwd,
argv,
env:
input.runtimeSettings?.env || input.session.env
? {
...input.runtimeSettings?.env,
...input.session.env,
}
: undefined,
model: input.session.model,
thinkingOptionId: input.session.thinkingOptionId,
protocolMode,
modeId: input.session.modeId,
session: input.session.session,
noSession: input.session.noSession,
systemPrompt,
extraArgs: input.session.extraArgs,
};
}
function appendOmpLaunchArgs(
argv: string[],
session: OmpStartSessionInput,
protocolMode: "rpc" | "rpc-ui",
systemPrompt: string | undefined,
): void {
if (!hasModeFlag(argv)) {
argv.push("--mode", protocolMode);
}
if (session.extraArgs?.length) {
argv.push(...session.extraArgs);
}
if (session.model) {
argv.push("--model", session.model);
}
if (session.thinkingOptionId) {
argv.push("--thinking", session.thinkingOptionId);
}
if (session.noSession) {
argv.push("--no-session");
} else if (session.session) {
argv.push("--session", session.session);
}
if (systemPrompt) {
argv.push("--append-system-prompt", systemPrompt);
}
}
function hasModeFlag(argv: string[]): boolean {
for (let i = 0; i < argv.length; i += 1) {
if (argv[i] === "--mode") {
return true;
}
if (argv[i]?.startsWith("--mode=")) {
return true;
}
}
return false;
}

View File

@@ -0,0 +1,112 @@
import { mkdtemp, mkdir, utimes, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, test } from "vitest";
import { listOmpImportableSessions, readOmpImportSessionConfig } from "./session-descriptor.js";
async function writeSession(root: string, relativePath: string, lines: unknown[]): Promise<string> {
const filePath = path.join(root, "sessions", relativePath);
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(filePath, `${lines.map((line) => JSON.stringify(line)).join("\n")}\n`, "utf8");
return filePath;
}
describe("OMP session descriptor", () => {
test("reads title-first sessions and OMP combined model identifiers", async () => {
const root = await mkdtemp(path.join(tmpdir(), "paseo-omp-session-title-first-"));
const cwd = path.join(root, "repo");
const sessionFile = await writeSession(root, "project/session.jsonl", [
{
type: "title",
id: "title-1",
timestamp: "2026-06-09T00:00:00.000Z",
title: "Deploy Paseo and verify",
},
{
type: "session",
version: 3,
id: "session-title-first",
timestamp: "2026-06-09T00:00:00.100Z",
cwd,
},
{
type: "model_change",
id: "model-1",
timestamp: "2026-06-09T00:00:00.200Z",
model: "openai-codex/gpt-5.1",
},
{
type: "message",
id: "user-1",
timestamp: "2026-06-09T00:00:01.000Z",
message: { role: "user", content: [{ type: "text", text: "import me" }] },
},
]);
await expect(
listOmpImportableSessions({ sessionDir: path.join(root, "sessions") }),
).resolves.toEqual([
expect.objectContaining({
providerHandleId: sessionFile,
cwd,
title: "Deploy Paseo and verify",
firstPromptPreview: "import me",
}),
]);
await expect(readOmpImportSessionConfig(sessionFile)).resolves.toEqual({
model: "openai-codex/gpt-5.1",
});
});
test("keeps recent nested OMP subagent sessions importable", async () => {
const root = await mkdtemp(path.join(tmpdir(), "paseo-omp-session-nested-"));
const cwd = path.join(root, "repo");
const parent = await writeSession(root, "project/parent.jsonl", [
{ type: "session", id: "parent", timestamp: "2026-06-10T00:00:00.000Z", cwd },
{
type: "message",
id: "parent-user",
timestamp: "2026-06-10T00:00:01.000Z",
message: { role: "user", content: "parent prompt" },
},
]);
const child = await writeSession(root, "project/parent/Explore.jsonl", [
{ type: "session", id: "child", timestamp: "2026-06-09T00:00:00.000Z", cwd },
{
type: "message",
id: "child-user",
timestamp: "2026-06-09T00:00:01.000Z",
message: { role: "user", content: "child prompt" },
},
]);
await utimes(parent, new Date("2026-06-08"), new Date("2026-06-08"));
await utimes(child, new Date("2026-06-09"), new Date("2026-06-09"));
await expect(
listOmpImportableSessions({ sessionDir: path.join(root, "sessions"), limit: 1 }),
).resolves.toEqual([
expect.objectContaining({
providerHandleId: child,
title: "Explore",
firstPromptPreview: "child prompt",
}),
]);
});
test("uses OMP's own default session directory", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-omp-session-home-"));
const cwd = path.join(home, "repo");
const sessionFile = path.join(home, ".omp", "agent", "sessions", "project", "session.jsonl");
await mkdir(path.dirname(sessionFile), { recursive: true });
await writeFile(
sessionFile,
`${JSON.stringify({ type: "session", id: "default-dir", timestamp: "2026-06-09", cwd })}\n`,
"utf8",
);
await expect(listOmpImportableSessions({ homeDir: home, env: {} })).resolves.toEqual([
expect.objectContaining({ providerHandleId: sessionFile, cwd }),
]);
});
});

View File

@@ -0,0 +1,504 @@
import type { Dirent } from "node:fs";
import { open, readdir, readFile, stat } from "node:fs/promises";
import { homedir } from "node:os";
import path from "node:path";
import type {
ImportableProviderSession,
ListImportableSessionsOptions,
} from "../../agent-sdk-types.js";
import type { ProviderRuntimeSettings } from "../../provider-launch-config.js";
import { createRealpathAwarePathMatcher } from "../../../../utils/path.js";
const OMP_CONFIG_DIR_NAME = ".omp";
const OMP_AGENT_DIR_ENV = "OMP_AGENT_DIR";
const OMP_SESSION_DIR_ENV = "OMP_SESSION_DIR";
// Import listing intentionally bounds header parsing to this window. Sessions
// with unusually large preambles may omit their first-prompt preview.
const HEAD_BYTES = 64 * 1024;
const TAIL_BYTES = 256 * 1024;
const FULL_SCAN_LINE_LIMIT = 2_000;
// Rank all discovered files cheaply, then parse only a bounded recent window.
// OMP keeps nested completed-subagent transcripts importable, so discovery
// remains recursive rather than applying Pi's historical parent-only depth cap.
const IMPORT_CANDIDATE_OVERSCAN = 40;
const IMPORT_CANDIDATE_MIN = 400;
interface OmpSessionDescriptorOptions extends ListImportableSessionsOptions {
sessionDir?: string;
runtimeSettings?: ProviderRuntimeSettings;
env?: NodeJS.ProcessEnv;
homeDir?: string;
}
interface OmpSessionHeader {
sessionId: string;
cwd: string;
createdAt: Date | null;
}
interface OmpSessionTail {
title: string | null;
lastActivityAt: Date | null;
lastUserMessage: string | null;
model: string | null;
thinkingOptionId: string | null;
}
interface OmpSessionHead {
title: string | null;
firstUserMessage: string | null;
model: string | null;
thinkingOptionId: string | null;
}
interface OmpSessionDescriptor {
cwd: string;
title: string | null;
firstUserMessage: string | null;
lastUserMessage: string | null;
lastActivityAt: Date;
model: string | null;
thinkingOptionId: string | null;
}
interface RankedSessionFile {
file: string;
mtime: Date;
}
export interface OmpImportSessionConfig {
model?: string;
thinkingOptionId?: string;
}
export async function listOmpImportableSessions(
options: OmpSessionDescriptorOptions = {},
): Promise<ImportableProviderSession[]> {
const sessionsDir = await resolveOmpSessionsDir(options);
const files = await walkJsonlFiles(sessionsDir);
const matchesCwd = options.cwd ? createRealpathAwarePathMatcher(options.cwd) : null;
const limit = options.limit ?? 20;
const ranked = await rankSessionFilesByMtime(files);
const candidateLimit = Math.max(limit * IMPORT_CANDIDATE_OVERSCAN, IMPORT_CANDIDATE_MIN);
const sessions: ImportableProviderSession[] = [];
for (const entry of ranked.slice(0, candidateLimit)) {
const session = await readOmpImportableSession(entry.file);
if (!session) continue;
if (matchesCwd && !matchesCwd(session.cwd)) continue;
sessions.push(session);
if (sessions.length >= limit) {
break;
}
}
return sessions.sort(
(left, right) => right.lastActivityAt.getTime() - left.lastActivityAt.getTime(),
);
}
export async function readOmpImportSessionConfig(
filePath: string,
): Promise<OmpImportSessionConfig> {
const descriptor = await readOmpSessionDescriptor(filePath);
if (!descriptor) return {};
return toOmpImportSessionConfig(descriptor);
}
async function resolveOmpSessionsDir(options: OmpSessionDescriptorOptions): Promise<string> {
const env = options.env ?? process.env;
const homeDir = options.homeDir ?? homedir();
const baseDir = options.cwd ?? process.cwd();
if (options.sessionDir?.trim()) {
return resolveConfigPath(options.sessionDir, { baseDir, homeDir });
}
const agentDir = resolveOmpAgentDir({ runtimeSettings: options.runtimeSettings, env, homeDir });
const envSessionDir =
options.runtimeSettings?.env?.[OMP_SESSION_DIR_ENV] ?? env[OMP_SESSION_DIR_ENV];
if (envSessionDir?.trim()) {
return resolveConfigPath(envSessionDir, { baseDir, homeDir });
}
const settingsSessionDir = await readConfiguredSessionDir({
agentDir,
cwd: options.cwd,
});
if (settingsSessionDir?.trim()) {
return resolveConfigPath(settingsSessionDir, { baseDir, homeDir });
}
return path.join(agentDir, "sessions");
}
function resolveOmpAgentDir(input: {
runtimeSettings?: ProviderRuntimeSettings;
env: NodeJS.ProcessEnv;
homeDir: string;
}): string {
const configured =
input.runtimeSettings?.env?.[OMP_AGENT_DIR_ENV] ?? input.env[OMP_AGENT_DIR_ENV];
if (configured?.trim()) {
return resolveConfigPath(configured, { baseDir: process.cwd(), homeDir: input.homeDir });
}
return path.join(input.homeDir, OMP_CONFIG_DIR_NAME, "agent");
}
async function readConfiguredSessionDir(input: {
agentDir: string;
cwd: string | undefined;
}): Promise<string | null> {
const values = await Promise.all([
readSessionDirFromSettings(path.join(input.agentDir, "settings.json")),
input.cwd
? readSessionDirFromSettings(path.join(input.cwd, OMP_CONFIG_DIR_NAME, "settings.json"))
: null,
]);
return values[1] ?? values[0] ?? null;
}
async function readSessionDirFromSettings(settingsPath: string): Promise<string | null> {
try {
const parsed = JSON.parse(await readFile(settingsPath, "utf8")) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return null;
}
const sessionDir = Reflect.get(parsed, "sessionDir");
return typeof sessionDir === "string" && sessionDir.trim() ? sessionDir : null;
} catch {
return null;
}
}
function resolveConfigPath(value: string, options: { baseDir: string; homeDir: string }): string {
if (value === "~") {
return options.homeDir;
}
if (value.startsWith("~/")) {
return path.join(options.homeDir, value.slice(2));
}
return path.isAbsolute(value) ? value : path.resolve(options.baseDir, value);
}
async function walkJsonlFiles(root: string): Promise<string[]> {
let entries: Dirent[];
try {
entries = await readdir(root, { withFileTypes: true });
} catch {
return [];
}
const files = await Promise.all(
entries.map(async (entry) => {
const entryPath = path.join(root, entry.name);
if (entry.isDirectory()) {
return await walkJsonlFiles(entryPath);
}
return entry.isFile() && entry.name.endsWith(".jsonl") ? [entryPath] : [];
}),
);
return files.flat();
}
async function rankSessionFilesByMtime(files: string[]): Promise<RankedSessionFile[]> {
const ranked = await Promise.all(
files.map(async (file) => {
const mtime = await readFileMtime(file);
return mtime ? { file, mtime } : null;
}),
);
return ranked
.filter((entry): entry is RankedSessionFile => entry !== null)
.sort((left, right) => right.mtime.getTime() - left.mtime.getTime());
}
async function readOmpImportableSession(
filePath: string,
): Promise<ImportableProviderSession | null> {
const descriptor = await readOmpSessionDescriptor(filePath);
if (!descriptor) return null;
return {
providerHandleId: filePath,
cwd: descriptor.cwd,
title: descriptor.title,
firstPromptPreview: normalizePromptPreview(descriptor.firstUserMessage),
lastPromptPreview: normalizePromptPreview(
descriptor.lastUserMessage ?? descriptor.firstUserMessage,
),
lastActivityAt: descriptor.lastActivityAt,
};
}
async function readOmpSessionDescriptor(filePath: string): Promise<OmpSessionDescriptor | null> {
// OMP may emit title/session_info lines before the session header.
const headChunk = await readHeadChunk(filePath);
if (!headChunk) return null;
const header = parseSessionHeaderFromChunk(headChunk);
if (!header) return null;
const tail = await readTail(filePath).catch(() => "");
const tailInfo = parseSessionTail(tail);
const headInfo = parseSessionHeadFromChunk(headChunk);
const title =
tailInfo.title ??
headInfo.title ??
readReadableSessionTitleFromPath(filePath) ??
headInfo.firstUserMessage;
const model = tailInfo.model ?? headInfo.model;
const thinkingOptionId = tailInfo.thinkingOptionId ?? headInfo.thinkingOptionId;
const lastActivityAt =
tailInfo.lastActivityAt ?? (await readFileMtime(filePath)) ?? header.createdAt ?? new Date(0);
return {
cwd: header.cwd,
title,
firstUserMessage: headInfo.firstUserMessage,
lastUserMessage: tailInfo.lastUserMessage,
lastActivityAt,
model,
thinkingOptionId,
};
}
function toOmpImportSessionConfig(descriptor: OmpSessionDescriptor): OmpImportSessionConfig {
return {
...(descriptor.model ? { model: descriptor.model } : {}),
...(descriptor.thinkingOptionId ? { thinkingOptionId: descriptor.thinkingOptionId } : {}),
};
}
async function readHeadChunk(filePath: string): Promise<string | null> {
const handle = await open(filePath, "r").catch(() => null);
if (!handle) return null;
try {
const buffer = Buffer.alloc(HEAD_BYTES);
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
if (bytesRead <= 0) return null;
return buffer.subarray(0, bytesRead).toString("utf8");
} finally {
await handle.close().catch(() => undefined);
}
}
function parseSessionHeaderFromChunk(chunk: string): OmpSessionHeader | null {
for (const line of chunk.split(/\r?\n/u)) {
const header = parseSessionHeader(line.trim());
if (header) return header;
}
return null;
}
function parseSessionHeadFromChunk(chunk: string): OmpSessionHead {
let title: string | null = null;
let firstUserMessage: string | null = null;
let model: string | null = null;
let thinkingOptionId: string | null = null;
let lineCount = 0;
for (const rawLine of chunk.split(/\r?\n/u)) {
lineCount += 1;
const entry = parseJsonRecord(rawLine.trim());
if (!entry) continue;
if (entry.type === "session_info") {
title = readNonEmptyString(entry.name) ?? title;
}
if (entry.type === "title") {
title = readNonEmptyString(entry.title) ?? title;
}
model = extractModel(entry) ?? model;
thinkingOptionId = extractThinkingOptionId(entry) ?? thinkingOptionId;
if (!firstUserMessage && entry.type === "message" && isRecord(entry.message)) {
if (entry.message.role === "user") {
firstUserMessage = extractMessageText(entry.message.content);
}
}
if (title && firstUserMessage && model && thinkingOptionId) {
break;
}
if (lineCount >= FULL_SCAN_LINE_LIMIT && firstUserMessage) {
break;
}
}
return { title, firstUserMessage, model, thinkingOptionId };
}
async function readTail(filePath: string): Promise<string> {
const fileStats = await stat(filePath);
const start = Math.max(0, fileStats.size - TAIL_BYTES);
const length = fileStats.size - start;
const handle = await open(filePath, "r");
try {
const buffer = Buffer.alloc(length);
const { bytesRead } = await handle.read(buffer, 0, buffer.length, start);
return buffer.subarray(0, bytesRead).toString("utf8");
} finally {
await handle.close().catch(() => undefined);
}
}
async function readFileMtime(filePath: string): Promise<Date | null> {
try {
return (await stat(filePath)).mtime;
} catch {
return null;
}
}
function parseSessionHeader(firstLine: string): OmpSessionHeader | null {
const entry = parseJsonRecord(firstLine);
if (!entry || entry.type !== "session") return null;
const sessionId = typeof entry.id === "string" ? entry.id : null;
const cwd = typeof entry.cwd === "string" ? entry.cwd : null;
if (!sessionId || !cwd) return null;
const createdAt = parseDate(entry.timestamp);
return { sessionId, cwd, createdAt };
}
function parseSessionTail(tail: string): OmpSessionTail {
const lines = tail.split(/\r?\n/u);
let title: string | null = null;
let lastActivityAt: Date | null = null;
let fallbackTimestamp: Date | null = null;
let lastUserMessage: string | null = null;
let model: string | null = null;
let thinkingOptionId: string | null = null;
for (let index = lines.length - 1; index >= 0; index -= 1) {
const entry = parseJsonRecord(lines[index].trim());
if (!entry) continue;
if (!title && entry.type === "session_info") {
title = readNonEmptyString(entry.name);
}
if (!title && entry.type === "title") {
title = readNonEmptyString(entry.title);
}
if (!model) {
model = extractModel(entry);
}
if (!thinkingOptionId) {
thinkingOptionId = extractThinkingOptionId(entry);
}
const entryTimestamp = parseDate(entry.timestamp);
if (!fallbackTimestamp && entryTimestamp) {
fallbackTimestamp = entryTimestamp;
}
if (entry.type !== "message") continue;
if (!lastActivityAt && entryTimestamp) {
lastActivityAt = entryTimestamp;
}
if (!lastUserMessage && isRecord(entry.message) && entry.message.role === "user") {
lastUserMessage = extractMessageText(entry.message.content);
}
}
return {
title,
lastActivityAt: lastActivityAt ?? fallbackTimestamp,
lastUserMessage,
model,
thinkingOptionId,
};
}
function extractModel(entry: Record<string, unknown>): string | null {
if (entry.type === "model_change") {
// Pi records provider + modelId; OMP records a combined model string.
return buildModelId(entry.provider, entry.modelId) ?? readNonEmptyString(entry.model);
}
if (entry.type === "message" && isRecord(entry.message)) {
return buildModelId(entry.message.provider, entry.message.model);
}
return null;
}
function extractThinkingOptionId(entry: Record<string, unknown>): string | null {
return entry.type === "thinking_level_change" ? readNonEmptyString(entry.thinkingLevel) : null;
}
function buildModelId(provider: unknown, modelId: unknown): string | null {
const providerName = readNonEmptyString(provider);
const modelName = readNonEmptyString(modelId);
if (!providerName || !modelName) {
return null;
}
return `${providerName}/${modelName}`;
}
function parseJsonRecord(line: string): Record<string, unknown> | null {
if (!line) return null;
try {
const parsed = JSON.parse(line) as unknown;
return isRecord(parsed) ? parsed : null;
} catch {
return null;
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function readNonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function normalizePromptPreview(text: string | null): string | null {
const normalized = text?.trim().replace(/\s+/g, " ") ?? "";
if (!normalized) return null;
return normalized.length > 160 ? normalized.slice(0, 160) : normalized;
}
function readReadableSessionTitleFromPath(filePath: string): string | null {
const stem = path.basename(filePath, ".jsonl").trim();
if (!stem) {
return null;
}
if (/^\d{4}-\d{2}-\d{2}T\d{2}[-:]\d{2}[-:]\d{2}/u.test(stem)) {
return null;
}
if (/^[0-9a-f]{8,}$/iu.test(stem)) {
return null;
}
return stem;
}
function parseDate(value: unknown): Date | null {
if (typeof value !== "string" && typeof value !== "number") {
return null;
}
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
function extractMessageText(content: unknown): string | null {
if (typeof content === "string") {
return content.trim() || null;
}
if (!Array.isArray(content)) {
return null;
}
const text = content
.flatMap((part) =>
isRecord(part) && part.type === "text" && typeof part.text === "string" ? [part.text] : [],
)
.join("\n\n")
.trim();
return text || null;
}

View File

@@ -0,0 +1,242 @@
import { describe, expect, test } from "vitest";
import { OmpSubagentCardTracker, type OmpSubagentCardScheduler } from "./subagent-card-tracker.js";
import type { OmpSubagentLifecyclePayload, OmpSubagentProgressPayload } from "./rpc-types.js";
const PARENT_TOOL_CALL_ID = "task-1";
const SESSION_FILE = "/tmp/omp-task/EchoSubagent.jsonl";
const LIFECYCLE: OmpSubagentLifecyclePayload = {
id: "EchoSubagent",
agent: "task",
description: "Run echo in subagent",
status: "started",
sessionFile: SESSION_FILE,
parentToolCallId: PARENT_TOOL_CALL_ID,
index: 0,
};
const PROGRESS: OmpSubagentProgressPayload[] = [
{
index: 0,
agent: "task",
task: "Run echo",
parentToolCallId: PARENT_TOOL_CALL_ID,
sessionFile: SESSION_FILE,
progress: {
id: "EchoSubagent",
status: "running",
recentTools: [{ tool: "bash", args: "echo subagent-hi", endMs: 1 }],
},
},
{
index: 0,
agent: "task",
task: "Run echo",
parentToolCallId: PARENT_TOOL_CALL_ID,
sessionFile: SESSION_FILE,
progress: {
id: "EchoSubagent",
status: "running",
recentTools: [
{ tool: "yield", args: "", endMs: 2 },
{ tool: "bash", args: "echo subagent-hi", endMs: 1 },
],
},
},
{
index: 0,
agent: "task",
task: "Run echo",
parentToolCallId: PARENT_TOOL_CALL_ID,
sessionFile: SESSION_FILE,
progress: {
id: "EchoSubagent",
status: "completed",
recentTools: [
{ tool: "yield", args: "", endMs: 2 },
{ tool: "bash", args: "echo subagent-hi", endMs: 1 },
],
},
},
];
class ManualScheduler implements OmpSubagentCardScheduler {
private currentMs = 0;
private nextId = 1;
private readonly timers = new Map<number, { dueMs: number; callback: () => void }>();
now(): number {
return this.currentMs;
}
setTimeout(callback: () => void, delayMs: number) {
const id = this.nextId;
this.nextId += 1;
this.timers.set(id, { dueMs: this.currentMs + delayMs, callback });
return { token: id };
}
clearTimeout(timer: { token: unknown }): void {
if (typeof timer.token === "number") {
this.timers.delete(timer.token);
}
}
advance(ms: number): void {
this.currentMs += ms;
const dueTimers = [...this.timers.entries()]
.filter(([, timer]) => timer.dueMs <= this.currentMs)
.sort((left, right) => left[1].dueMs - right[1].dueMs);
for (const [id, timer] of dueTimers) {
if (this.timers.delete(id)) {
timer.callback();
}
}
}
}
describe("OmpSubagentCardTracker", () => {
test("folds lifecycle and progress into one throttled sub-agent detail", () => {
const scheduler = new ManualScheduler();
const emitted: string[] = [];
const tracker = new OmpSubagentCardTracker({
scheduler,
emitToolCall: (toolCallId) => {
emitted.push(toolCallId);
return true;
},
});
tracker.handleLifecycle(LIFECYCLE);
for (const payload of PROGRESS) {
tracker.handleProgress(payload);
}
expect(emitted).toEqual([PARENT_TOOL_CALL_ID]);
const detailBeforeTrailing = tracker.detailFor(PARENT_TOOL_CALL_ID, {
type: "sub_agent",
subAgentType: "task",
description: "Task arg description wins",
log: "",
});
expect(detailBeforeTrailing).toEqual({
type: "sub_agent",
subAgentType: "task",
description: "Task arg description wins",
childSessionId: SESSION_FILE,
log: [
"EchoSubagent started",
"[bash] echo subagent-hi",
"[yield]",
"EchoSubagent completed",
].join("\n"),
});
scheduler.advance(500);
expect(emitted).toEqual([PARENT_TOOL_CALL_ID, PARENT_TOOL_CALL_ID]);
});
test("aggregates batch progress streams into one index-prefixed log", () => {
const scheduler = new ManualScheduler();
const emitted: string[] = [];
const tracker = new OmpSubagentCardTracker({
scheduler,
emitToolCall: (toolCallId) => {
emitted.push(toolCallId);
return true;
},
});
tracker.handleLifecycle({
id: "Explore",
agent: "task",
description: "Inspect files",
status: "started",
sessionFile: "/tmp/one.jsonl",
parentToolCallId: "task.batch-1",
index: 0,
});
scheduler.advance(600);
tracker.handleProgress({
index: 5,
agent: "task",
task: "Run tests",
parentToolCallId: "task.batch-1",
progress: {
id: "Test",
status: "running",
description: "Run tests",
recentTools: [{ tool: "bash", args: "npm test", endMs: 10 }],
},
sessionFile: "/tmp/two.jsonl",
});
expect(emitted).toEqual(["task.batch-1", "task.batch-1"]);
expect(
tracker.detailFor("task.batch-1", {
type: "sub_agent",
subAgentType: "batch",
actions: [],
log: "",
}),
).toEqual({
type: "sub_agent",
subAgentType: "batch",
description: "Inspect files",
childSessionId: "/tmp/one.jsonl",
log: "[1/6] Explore started\n[6/6] [bash] npm test",
actions: [],
});
});
test("keeps dirty trailing state available for final detail and cancels it on cleanup", () => {
const scheduler = new ManualScheduler();
const emitted: string[] = [];
const tracker = new OmpSubagentCardTracker({
scheduler,
emitToolCall: (toolCallId) => {
emitted.push(toolCallId);
return true;
},
});
tracker.handleLifecycle({
id: "Explore",
agent: "task",
description: "Inspect files",
status: "started",
parentToolCallId: "task-1",
index: 0,
});
scheduler.advance(100);
tracker.handleProgress({
index: 0,
agent: "task",
task: "Inspect files",
parentToolCallId: "task-1",
progress: {
id: "Explore",
status: "running",
recentOutput: ["found target file"],
},
});
expect(emitted).toEqual(["task-1"]);
expect(
tracker.detailFor("task-1", {
type: "sub_agent",
log: "",
}).log,
).toBe("Explore started\nfound target file");
tracker.delete("task-1");
scheduler.advance(500);
expect(emitted).toEqual(["task-1"]);
expect(
tracker.detailFor("task-1", {
type: "sub_agent",
log: "static",
}).log,
).toBe("static");
});
});

View File

@@ -0,0 +1,361 @@
import type { ToolCallDetail } from "../../agent-sdk-types.js";
import type { OmpSubagentLifecyclePayload, OmpSubagentProgressPayload } from "./rpc-types.js";
export interface OmpSubagentCardTimer {
readonly token: unknown;
}
export interface OmpSubagentCardScheduler {
now(): number;
setTimeout(callback: () => void, delayMs: number): OmpSubagentCardTimer;
clearTimeout(timer: OmpSubagentCardTimer): void;
}
export interface OmpSubagentCardTrackerOptions {
scheduler?: OmpSubagentCardScheduler;
emitToolCall?: (toolCallId: string) => boolean;
}
interface OmpSubagentLogLine {
key: string;
text: string;
}
interface OmpSubagentCardItem {
index: number;
description?: string;
agent?: string;
status?: "pending" | "running" | "completed" | "failed" | "aborted";
childSessionId?: string;
lines: OmpSubagentLogLine[];
lineKeys: Set<string>;
}
interface OmpSubagentCardState {
items: Map<number, OmpSubagentCardItem>;
emitToolCall: (toolCallId: string) => boolean;
lastEmitMs: number | null;
trailingTimer: OmpSubagentCardTimer | null;
dirty: boolean;
}
const MAX_LOG_LINES_PER_ITEM = 200;
const MAX_LOG_LINE_CHARS = 240;
const THROTTLE_INTERVAL_MS = 500;
export function createOmpSubagentCardScheduler(): OmpSubagentCardScheduler {
return {
now: () => Date.now(),
setTimeout: (callback, delayMs) => ({ token: setTimeout(callback, delayMs) }),
clearTimeout: (timer) => {
clearTimeout(timer.token as ReturnType<typeof setTimeout>);
},
};
}
export class OmpSubagentCardTracker {
private readonly scheduler: OmpSubagentCardScheduler;
private readonly emitToolCall: (toolCallId: string) => boolean;
private readonly states = new Map<string, OmpSubagentCardState>();
constructor(options: OmpSubagentCardTrackerOptions) {
this.scheduler = options.scheduler ?? createOmpSubagentCardScheduler();
this.emitToolCall = options.emitToolCall ?? (() => false);
}
handleLifecycle(
payload: OmpSubagentLifecyclePayload,
emitToolCall: (toolCallId: string) => boolean = this.emitToolCall,
): void {
const parentToolCallId = readTrimmedString(payload.parentToolCallId);
if (!parentToolCallId) {
return;
}
this.stateFor(parentToolCallId).emitToolCall = emitToolCall;
const item = this.upsertItem(parentToolCallId, {
index: payload.index,
agent: payload.agent,
description: payload.description,
status: payload.status === "started" ? "running" : payload.status,
childSessionId: payload.sessionFile,
});
this.appendLine(
item,
`lifecycle:${payload.status}:${payload.id}`,
`${payload.id} ${payload.status}`,
);
this.requestEmit(parentToolCallId);
}
handleProgress(
payload: OmpSubagentProgressPayload,
emitToolCall: (toolCallId: string) => boolean = this.emitToolCall,
): void {
const parentToolCallId = readTrimmedString(payload.parentToolCallId);
if (!parentToolCallId) {
return;
}
this.stateFor(parentToolCallId).emitToolCall = emitToolCall;
const item = this.upsertItem(parentToolCallId, {
index: payload.index,
agent: payload.agent,
description: payload.progress.description,
status: payload.progress.status,
childSessionId: payload.sessionFile,
});
const currentToolLine = summarizeTool(readRecord(payload.progress.currentTool));
if (currentToolLine) {
this.appendLine(item, `current:${currentToolLine.key}`, currentToolLine.text);
}
for (const tool of readRecordArray(payload.progress.recentTools)) {
const line = summarizeTool(tool);
if (line) {
this.appendLine(item, `tool:${line.key}`, line.text);
}
}
for (const output of readOutputLines(payload.progress.recentOutput)) {
this.appendLine(item, `output:${output}`, output);
}
if (payload.progress.status !== "running") {
this.appendLine(
item,
`status:${payload.progress.status}:${payload.progress.id}`,
`${payload.progress.id} ${payload.progress.status}`,
);
}
this.requestEmit(parentToolCallId);
}
detailFor(toolCallId: string, baseDetail: ToolCallDetail): ToolCallDetail {
if (baseDetail.type !== "sub_agent") {
return baseDetail;
}
const state = this.states.get(toolCallId);
if (!state) {
return baseDetail;
}
const items = [...state.items.values()].sort((left, right) => left.index - right.index);
const firstItem = items[0];
if (!firstItem) {
return baseDetail;
}
const detail: ToolCallDetail = {
type: "sub_agent",
...(baseDetail.subAgentType ? { subAgentType: baseDetail.subAgentType } : {}),
...((baseDetail.description ?? firstItem.description)
? { description: baseDetail.description ?? firstItem.description }
: {}),
...((firstItem.childSessionId ?? baseDetail.childSessionId)
? { childSessionId: firstItem.childSessionId ?? baseDetail.childSessionId }
: {}),
log: buildLog(items, baseDetail.log),
};
return baseDetail.actions ? { ...detail, actions: baseDetail.actions } : detail;
}
flush(toolCallId: string): void {
const state = this.states.get(toolCallId);
if (!state || !state.dirty) {
return;
}
this.emitNow(toolCallId, state);
}
delete(toolCallId: string): void {
const state = this.states.get(toolCallId);
if (state?.trailingTimer) {
this.scheduler.clearTimeout(state.trailingTimer);
}
this.states.delete(toolCallId);
}
clear(): void {
for (const toolCallId of this.states.keys()) {
this.delete(toolCallId);
}
}
private upsertItem(
parentToolCallId: string,
input: {
index: number;
agent?: string;
status?: "pending" | "running" | "completed" | "failed" | "aborted";
description?: string;
childSessionId?: string;
},
): OmpSubagentCardItem {
const state = this.stateFor(parentToolCallId);
const existing = state.items.get(input.index);
if (existing) {
existing.agent = readTrimmedString(input.agent) ?? existing.agent;
existing.status = input.status ?? existing.status;
existing.description = readTrimmedString(input.description) ?? existing.description;
existing.childSessionId = readTrimmedString(input.childSessionId) ?? existing.childSessionId;
return existing;
}
const item: OmpSubagentCardItem = {
index: input.index,
lines: [],
lineKeys: new Set<string>(),
...(readTrimmedString(input.agent) ? { agent: readTrimmedString(input.agent) } : {}),
...(input.status ? { status: input.status } : {}),
...(readTrimmedString(input.description)
? { description: readTrimmedString(input.description) }
: {}),
...(readTrimmedString(input.childSessionId)
? { childSessionId: readTrimmedString(input.childSessionId) }
: {}),
};
state.items.set(input.index, item);
return item;
}
private stateFor(parentToolCallId: string): OmpSubagentCardState {
const existing = this.states.get(parentToolCallId);
if (existing) {
return existing;
}
const state: OmpSubagentCardState = {
items: new Map(),
emitToolCall: this.emitToolCall,
lastEmitMs: null,
trailingTimer: null,
dirty: false,
};
this.states.set(parentToolCallId, state);
return state;
}
private appendLine(item: OmpSubagentCardItem, key: string, text: string): void {
const normalizedText = normalizeLine(text);
if (!normalizedText || item.lineKeys.has(key)) {
return;
}
item.lines.push({ key, text: normalizedText });
item.lineKeys.add(key);
while (item.lines.length > MAX_LOG_LINES_PER_ITEM) {
const removed = item.lines.shift();
if (removed) {
item.lineKeys.delete(removed.key);
}
}
}
private requestEmit(toolCallId: string): void {
const state = this.stateFor(toolCallId);
const now = this.scheduler.now();
const elapsedMs = state.lastEmitMs === null ? THROTTLE_INTERVAL_MS : now - state.lastEmitMs;
if (elapsedMs >= THROTTLE_INTERVAL_MS) {
this.emitNow(toolCallId, state);
return;
}
state.dirty = true;
if (state.trailingTimer) {
return;
}
state.trailingTimer = this.scheduler.setTimeout(() => {
state.trailingTimer = null;
if (state.dirty) {
this.emitNow(toolCallId, state);
}
}, THROTTLE_INTERVAL_MS - elapsedMs);
}
private emitNow(toolCallId: string, state: OmpSubagentCardState): void {
if (state.trailingTimer) {
this.scheduler.clearTimeout(state.trailingTimer);
state.trailingTimer = null;
}
state.dirty = false;
state.lastEmitMs = this.scheduler.now();
state.emitToolCall(toolCallId);
}
}
function buildLog(items: OmpSubagentCardItem[], fallback: string): string {
const itemCount = Math.max(...items.map((item) => item.index)) + 1;
const lines = items.flatMap((item) => {
const prefix = itemCount > 1 ? `[${item.index + 1}/${itemCount}] ` : "";
return item.lines.map((line) => `${prefix}${line.text}`);
});
return lines.length > 0 ? lines.join("\n") : fallback;
}
function summarizeTool(tool: Record<string, unknown> | null): OmpSubagentLogLine | null {
if (!tool) {
return null;
}
const toolName = readTrimmedString(tool.tool) ?? readTrimmedString(tool.name);
if (!toolName) {
return null;
}
const args = readTrimmedString(tool.args) ?? summarizeUnknown(tool.input);
const endMs = typeof tool.endMs === "number" ? `:${tool.endMs}` : "";
return {
key: `${toolName}:${args ?? ""}${endMs}`,
text: args ? `[${toolName}] ${args}` : `[${toolName}]`,
};
}
function readOutputLines(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
return value.flatMap((item) => {
const text =
readTrimmedString(item) ??
(isRecord(item)
? (readTrimmedString(item.text) ??
readTrimmedString(item.output) ??
readTrimmedString(item.content))
: undefined);
return text ? [text] : [];
});
}
function readRecord(value: unknown): Record<string, unknown> | null {
return isRecord(value) ? value : null;
}
function readRecordArray(value: unknown): Record<string, unknown>[] {
return Array.isArray(value) ? value.filter(isRecord) : [];
}
function summarizeUnknown(value: unknown): string | undefined {
if (value === undefined || value === null) {
return undefined;
}
if (typeof value === "string") {
return readTrimmedString(value);
}
const json = JSON.stringify(value);
return typeof json === "string" ? normalizeLine(json) : undefined;
}
function normalizeLine(value: string): string | undefined {
const normalized = value.trim().replace(/\s+/g, " ");
if (!normalized) {
return undefined;
}
if (normalized.length <= MAX_LOG_LINE_CHARS) {
return normalized;
}
return `${normalized.slice(0, MAX_LOG_LINE_CHARS)}...`;
}
function readTrimmedString(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

View File

@@ -0,0 +1,117 @@
import { describe, expect, test } from "vitest";
import { OmpSubagentIndex } from "./subagent-index.js";
describe("OMP provider subagent mapper", () => {
test("maps lifecycle and progress frames to stable provider_subagent descriptors", () => {
const index = new OmpSubagentIndex();
const parent = {};
expect(
index.handleLifecycle(parent, {
id: "child-1",
agent: "explore",
description: "Inspect files",
status: "started",
parentToolCallId: "task-1",
index: 0,
}),
).toEqual([
{
type: "provider_subagent",
provider: "omp",
event: {
type: "upsert",
id: "child-1",
title: "explore",
description: "Inspect files",
status: "running",
toolCallId: "task-1",
},
},
]);
expect(
index.handleProgress(parent, {
index: 0,
agent: "explore",
task: "Inspect files",
parentToolCallId: "task-1",
progress: {
id: "child-1",
status: "running",
resolvedModel: "openai-codex/gpt-5.5",
},
})[0],
).toMatchObject({
event: {
id: "child-1",
status: "running",
title: "explore · gpt-5.5 (openai-codex)",
},
});
expect(
index.handleProgress(parent, {
index: 0,
agent: "explore",
task: "Inspect files",
parentToolCallId: "task-1",
progress: {
id: "child-1",
status: "completed",
resolvedModel: "anthropic/claude-sonnet-5",
},
})[0],
).toMatchObject({
event: {
id: "child-1",
status: "completed",
title: "explore · claude-sonnet-5 (anthropic)",
},
});
});
test("maps child message events onto the descriptor timeline", () => {
const index = new OmpSubagentIndex();
const parent = {};
expect(
index.handleEvent(parent, {
id: "child-1",
event: {
type: "message_end",
message: {
role: "assistant",
content: [{ type: "text", text: "Child answer" }],
},
},
}),
).toEqual([
{
type: "provider_subagent",
provider: "omp",
event: {
type: "timeline",
id: "child-1",
item: {
type: "assistant_message",
text: "Child answer",
messageId: "omp-history-assistant-1",
},
},
},
]);
});
test("maps aborted lifecycle status to canceled", () => {
const index = new OmpSubagentIndex();
const parent = {};
expect(
index.handleLifecycle(parent, {
id: "child-1",
agent: "task",
status: "aborted",
index: 0,
})[0],
).toMatchObject({ event: { id: "child-1", status: "canceled" } });
});
});

View File

@@ -0,0 +1,141 @@
import type { AgentStreamEvent } from "../../agent-sdk-types.js";
import { OmpHistoryMapper } from "./message-history.js";
import type { OmpAgentMessage, OmpAgentSessionEvent } from "./rpc-types.js";
import { OMP_HISTORY_MAPPER_HOOKS } from "./history-hooks.js";
import { formatOmpSubagentTitle } from "./subagent-title.js";
import type {
OmpSubagentEventPayload,
OmpSubagentLifecyclePayload,
OmpSubagentProgressPayload,
} from "./rpc-types.js";
interface OmpSubagentState {
title: string;
description: string | null;
resolvedModel: string | null;
toolCallId: string | null;
status: "running" | "completed" | "failed" | "canceled";
mapper: OmpHistoryMapper;
}
export class OmpSubagentIndex {
private readonly statesByParent = new WeakMap<object, Map<string, OmpSubagentState>>();
handleLifecycle(parent: object, payload: OmpSubagentLifecyclePayload): AgentStreamEvent[] {
const state = this.stateFor(parent, payload.id, payload.agent);
state.title = payload.agent || state.title;
state.description = payload.description ?? state.description;
state.toolCallId = payload.parentToolCallId ?? state.toolCallId;
state.status = mapLifecycleStatus(payload.status);
return [this.upsert(payload.id, state.status, state)];
}
handleProgress(parent: object, payload: OmpSubagentProgressPayload): AgentStreamEvent[] {
const id = payload.progress.id;
const state = this.stateFor(parent, id, payload.agent);
state.title = payload.agent || state.title;
state.description = payload.progress.description ?? payload.assignment ?? state.description;
if (payload.progress.resolvedModel?.trim()) {
state.resolvedModel = payload.progress.resolvedModel;
}
state.toolCallId = payload.parentToolCallId ?? state.toolCallId;
state.status = mapProgressStatus(payload.progress.status);
return [this.upsert(id, state.status, state)];
}
handleEvent(parent: object, payload: OmpSubagentEventPayload): AgentStreamEvent[] {
const state = this.stateFor(parent, payload.id, "OMP subagent");
const messages = messagesFromSessionEvent(payload.event);
return state.mapper.mapMessages(messages).flatMap((mapped) =>
mapped.type === "timeline"
? [
{
type: "provider_subagent" as const,
provider: "omp",
event: {
type: "timeline" as const,
id: payload.id,
item: mapped.item,
...(mapped.timestamp ? { timestamp: mapped.timestamp } : {}),
},
},
]
: [],
);
}
terminalizeRunning(parent: object): AgentStreamEvent[] {
const states = this.statesByParent.get(parent);
if (!states) {
return [];
}
const events: AgentStreamEvent[] = [];
for (const [id, state] of states) {
if (state.status !== "running") {
continue;
}
state.status = "canceled";
events.push(this.upsert(id, state.status, state));
}
return events;
}
clear(parent: object): void {
this.statesByParent.delete(parent);
}
private stateFor(parent: object, id: string, title: string): OmpSubagentState {
const states = this.statesByParent.get(parent) ?? new Map<string, OmpSubagentState>();
const existing = states.get(id);
if (existing) return existing;
const state: OmpSubagentState = {
title,
description: null,
resolvedModel: null,
toolCallId: null,
status: "running",
mapper: new OmpHistoryMapper("omp", [], OMP_HISTORY_MAPPER_HOOKS),
};
states.set(id, state);
this.statesByParent.set(parent, states);
return state;
}
private upsert(
id: string,
status: "running" | "completed" | "failed" | "canceled",
state: OmpSubagentState,
): AgentStreamEvent {
return {
type: "provider_subagent",
provider: "omp",
event: {
type: "upsert",
id,
title: formatOmpSubagentTitle(state.title, state.resolvedModel),
description: state.description,
status,
toolCallId: state.toolCallId,
},
};
}
}
function messagesFromSessionEvent(event: OmpAgentSessionEvent): OmpAgentMessage[] {
if (event.type === "message_end") return [event.message];
return [];
}
function mapLifecycleStatus(
status: OmpSubagentLifecyclePayload["status"],
): "running" | "completed" | "failed" | "canceled" {
if (status === "started") return "running";
return status === "aborted" ? "canceled" : status;
}
function mapProgressStatus(
status: OmpSubagentProgressPayload["progress"]["status"],
): "running" | "completed" | "failed" | "canceled" {
if (status === "completed" || status === "failed") return status;
return status === "aborted" ? "canceled" : "running";
}

View File

@@ -0,0 +1,14 @@
export function formatOmpSubagentTitle(title: string, resolvedModel?: string | null): string {
const name = title.trim() || "OMP subagent";
const model = resolvedModel?.trim();
if (!model) return name;
const separator = model.indexOf("/");
if (separator <= 0 || separator === model.length - 1) {
return `${name} · ${model}`;
}
const provider = model.slice(0, separator);
const modelName = model.slice(separator + 1);
return `${name} · ${modelName} (${provider})`;
}

View File

@@ -0,0 +1,109 @@
import { describe, expect, test } from "vitest";
import { isOmpSystemNotice, mapOmpSystemNoticeToToolCall } from "./system-notice.js";
const COMPLETED_NOTICE = [
"<system-notice>",
"Background job DocsSmokeTwo has completed. Resume your work using the result below.",
'<task-result id="DocsSmokeTwo" agent="explore" status="completed" duration="21.6s">',
'<meta lines="22" size="2.5KB" />',
"<output>",
'{"summary":"docs smoke check done"}',
"</output>",
"</task-result>",
"</system-notice>",
"DocsSmokeTwo is now idle — transcript at history://DocsSmokeTwo",
].join("\n");
describe("omp system notice detection", () => {
test("detects messages that start with the system-notice tag", () => {
expect(isOmpSystemNotice(COMPLETED_NOTICE)).toBe(true);
expect(isOmpSystemNotice(" \n<system-notice>plain</system-notice>")).toBe(true);
});
test("ignores regular prompts, including ones that mention the tag mid-message", () => {
expect(isOmpSystemNotice("please fix the bug")).toBe(false);
expect(isOmpSystemNotice("what does <system-notice> mean in omp?")).toBe(false);
expect(mapOmpSystemNoticeToToolCall("what does <system-notice> mean in omp?")).toBeNull();
});
});
describe("omp system notice tool call mapping", () => {
test("maps a completed task-result notice to a synthetic completed tool call", () => {
expect(mapOmpSystemNoticeToToolCall(COMPLETED_NOTICE)).toEqual({
type: "tool_call",
callId: "omp-notice:DocsSmokeTwo",
name: "task_notification",
status: "completed",
detail: {
type: "plain_text",
label: "Background job DocsSmokeTwo completed",
text: COMPLETED_NOTICE,
icon: "wrench",
},
metadata: {
synthetic: true,
source: "omp_system_notice",
taskId: "DocsSmokeTwo",
subagentType: "explore",
status: "completed",
},
error: null,
});
});
test("maps a failed task-result notice to a failed tool call", () => {
const notice = [
"<system-notice>",
"Background job RepoSmokeOne has failed.",
'<task-result id="RepoSmokeOne" agent="explore" status="failed" duration="3s">',
"<output>boom</output>",
"</task-result>",
"</system-notice>",
].join("\n");
const item = mapOmpSystemNoticeToToolCall(notice);
expect(item).toMatchObject({
callId: "omp-notice:RepoSmokeOne",
status: "failed",
error: "Background job RepoSmokeOne failed",
});
});
test("parses task-result attributes with typographic quotes", () => {
const notice = [
"<system-notice>",
"Background job DocsSmokeTwo has completed.",
"<task-result id=“DocsSmokeTwo” agent=“explore” status=“completed” duration=“21.6s”>",
"<output>ok</output>",
"</task-result>",
"</system-notice>",
].join("\n");
expect(mapOmpSystemNoticeToToolCall(notice)).toMatchObject({
callId: "omp-notice:DocsSmokeTwo",
status: "completed",
metadata: {
taskId: "DocsSmokeTwo",
subagentType: "explore",
},
});
});
test("maps a notice without a task-result using its first line and a stable hash id", () => {
const notice = "<system-notice>\nThe daemon rotated its logs.\n</system-notice>";
const first = mapOmpSystemNoticeToToolCall(notice);
const second = mapOmpSystemNoticeToToolCall(notice);
expect(first).toEqual(second);
expect(first).toMatchObject({
status: "completed",
detail: {
type: "plain_text",
label: "The daemon rotated its logs.",
text: notice,
},
});
expect(first?.callId).toMatch(/^omp-notice:[0-9a-f]{12}$/);
});
});

View File

@@ -0,0 +1,128 @@
import { createHash } from "node:crypto";
import type { AgentTimelineItem } from "../../agent-sdk-types.js";
const SYSTEM_NOTICE_OPEN_TAG = "<system-notice>";
const SYSTEM_NOTICE_CLOSE_TAG = "</system-notice>";
const TASK_RESULT_TAG_PATTERN = /<task-result\b([^>]*)>/i;
// The omp harness emits straight quotes, but transcripts have been observed
// with typographic quotes after copy/paste round-trips; accept both.
const TASK_RESULT_ATTRIBUTE_PATTERN = /([\w-]+)=["'“‘]([^"'“”‘’]*)["'”’]/g;
type OmpSystemNoticeToolCallItem = Extract<AgentTimelineItem, { type: "tool_call" }>;
interface OmpTaskResultSummary {
id: string | null;
agent: string | null;
status: string | null;
}
type OmpNoticeLifecycle =
| { status: "completed"; error: null }
| { status: "failed"; error: string }
| { status: "canceled"; error: null };
export function isOmpSystemNotice(text: string): boolean {
return text.trimStart().startsWith(SYSTEM_NOTICE_OPEN_TAG);
}
function readTaskResult(text: string): OmpTaskResultSummary | null {
const tagMatch = text.match(TASK_RESULT_TAG_PATTERN);
if (!tagMatch) {
return null;
}
const attributes = new Map<string, string>();
for (const attributeMatch of (tagMatch[1] ?? "").matchAll(TASK_RESULT_ATTRIBUTE_PATTERN)) {
const name = attributeMatch[1];
const value = attributeMatch[2];
if (name && value !== undefined) {
attributes.set(name, value.trim());
}
}
return {
id: attributes.get("id") || null,
agent: attributes.get("agent") || null,
status: attributes.get("status") || null,
};
}
function readNoticeFirstLine(text: string): string | null {
const openIndex = text.indexOf(SYSTEM_NOTICE_OPEN_TAG);
const closeIndex = text.indexOf(SYSTEM_NOTICE_CLOSE_TAG);
const body = text.slice(
openIndex + SYSTEM_NOTICE_OPEN_TAG.length,
closeIndex === -1 ? undefined : closeIndex,
);
for (const line of body.split("\n")) {
const trimmed = line.trim();
if (trimmed && !trimmed.startsWith("<")) {
return trimmed;
}
}
return null;
}
function buildLifecycle(
taskResult: OmpTaskResultSummary | null,
label: string,
): OmpNoticeLifecycle {
const status = taskResult?.status?.toLowerCase() ?? null;
if (status === "failed" || status === "error") {
return { status: "failed", error: label };
}
if (status === "canceled" || status === "cancelled" || status === "stopped") {
return { status: "canceled", error: null };
}
return { status: "completed", error: null };
}
function buildLabel(taskResult: OmpTaskResultSummary | null, text: string): string {
if (taskResult?.id) {
return `Background job ${taskResult.id} ${taskResult.status ?? "completed"}`;
}
return readNoticeFirstLine(text) ?? "System notice";
}
function buildCallId(taskResult: OmpTaskResultSummary | null, text: string): string {
if (taskResult?.id) {
return `omp-notice:${taskResult.id}`;
}
const digest = createHash("sha1").update(text.trim()).digest("hex").slice(0, 12);
return `omp-notice:${digest}`;
}
export function mapOmpSystemNoticeToToolCall(text: string): OmpSystemNoticeToolCallItem | null {
if (!isOmpSystemNotice(text)) {
return null;
}
const taskResult = readTaskResult(text);
const label = buildLabel(taskResult, text);
const lifecycle = buildLifecycle(taskResult, label);
const base = {
type: "tool_call" as const,
callId: buildCallId(taskResult, text),
name: "task_notification",
detail: {
type: "plain_text" as const,
label,
text,
icon: "wrench" as const,
},
metadata: {
synthetic: true,
source: "omp_system_notice",
...(taskResult?.id ? { taskId: taskResult.id } : {}),
...(taskResult?.agent ? { subagentType: taskResult.agent } : {}),
...(taskResult?.status ? { status: taskResult.status } : {}),
},
};
if (lifecycle.status === "failed") {
return { ...base, status: "failed", error: lifecycle.error };
}
if (lifecycle.status === "canceled") {
return { ...base, status: "canceled", error: null };
}
return { ...base, status: "completed", error: null };
}

View File

@@ -0,0 +1,467 @@
import type {
OmpRuntime,
OmpRuntimeLaunch,
OmpRuntimeSession,
OmpStartSessionInput,
} from "../runtime.js";
import type {
OmpRpcHostToolDefinition,
OmpRpcHostToolResult,
OmpRpcHostToolUpdate,
OmpAgentMessage,
OmpModel,
OmpPromptAck,
OmpRpcSlashCommand,
OmpRuntimeEvent,
OmpSessionState,
OmpSessionStats,
OmpThinkingLevel,
} from "../rpc-types.js";
import { buildOmpLaunch } from "../runtime.js";
type FakeOmpSubagentSubscriptionLevel = "off" | "progress" | "events";
type FakeOmpSubagentStatus = "pending" | "running" | "completed" | "failed" | "aborted";
export interface FakeOmpSubagentSnapshot {
id: string;
index: number;
agent: string;
description?: string;
status: FakeOmpSubagentStatus;
task?: string;
assignment?: string;
sessionFile?: string;
parentToolCallId?: string;
lastUpdate?: number;
}
export interface FakeOmpSubagentMessagesSelector {
subagentId?: string;
sessionFile?: string;
fromByte?: number;
}
export interface FakeOmpSubagentMessagesResult {
sessionFile: string;
fromByte: number;
nextByte: number;
reset: boolean;
messages: OmpAgentMessage[];
}
export class FakeOmp implements OmpRuntime {
readonly recordedLaunches: OmpRuntimeLaunch[] = [];
private readonly sessions: FakeOmpSession[] = [];
private readonly command: [string, ...string[]];
private readonly queuedCommands: OmpRpcSlashCommand[][] = [];
private readonly queuedSubagentSubscriptionErrors = new Map<
FakeOmpSubagentSubscriptionLevel,
Error
>();
constructor(command: [string, ...string[]] = ["omp"]) {
this.command = command;
}
async startSession(input: OmpStartSessionInput): Promise<FakeOmpSession> {
const launch = buildOmpLaunch({
command: this.command,
session: input,
});
this.recordedLaunches.push(launch);
const session = new FakeOmpSession(launch);
session.commands = this.queuedCommands.shift() ?? [];
for (const [level, error] of this.queuedSubagentSubscriptionErrors) {
session.subagentSubscriptionErrors.set(level, error);
}
this.queuedSubagentSubscriptionErrors.clear();
this.sessions.push(session);
return session;
}
queueCommands(commands: OmpRpcSlashCommand[]): void {
this.queuedCommands.push(commands);
}
failNextSubagentSubscription(level: FakeOmpSubagentSubscriptionLevel, error: Error): void {
this.queuedSubagentSubscriptionErrors.set(level, error);
}
latestSession(): FakeOmpSession {
const session = this.sessions.at(-1);
if (!session) {
throw new Error("FakeOmp has no sessions");
}
return session;
}
}
export class FakeOmpSession implements OmpRuntimeSession {
readonly prompts: Array<{ message: string; imageCount: number }> = [];
readonly compactRequests: Array<{ customInstructions?: string }> = [];
readonly setAutoCompactionRequests: boolean[] = [];
readonly subagentSubscriptionRequests: FakeOmpSubagentSubscriptionLevel[] = [];
readonly subagentMessageRequests: FakeOmpSubagentMessagesSelector[] = [];
readonly setModelRequests: Array<{ provider: string; modelId: string }> = [];
readonly setThinkingLevelRequests: OmpThinkingLevel[] = [];
readonly handoffRequests: Array<{ customInstructions?: string }> = [];
readonly steerRequests: Array<{ message: string; imageCount: number }> = [];
readonly followUpRequests: Array<{ message: string; imageCount: number }> = [];
readonly hostToolSetRequests: OmpRpcHostToolDefinition[][] = [];
readonly hostToolResults: OmpRpcHostToolResult[] = [];
readonly hostToolUpdates: OmpRpcHostToolUpdate[] = [];
getStateRequestCount = 0;
abortRequested = false;
readonly canceledExtensionUiRequests: string[] = [];
readonly extensionUiResponses: Array<{
id: string;
response: { value?: string; confirmed?: boolean; cancelled?: boolean };
}> = [];
setModelResult: OmpModel | null = null;
models: OmpModel[] = [];
messages: OmpAgentMessage[] = [];
stats: OmpSessionStats = {
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
cost: 0,
};
commands: OmpRpcSlashCommand[] = [];
subagents: FakeOmpSubagentSnapshot[] = [];
readonly subagentSubscriptionErrors = new Map<FakeOmpSubagentSubscriptionLevel, Error>();
compactError: Error | null = null;
emitCompactEnd = true;
getStateError: Error | null = null;
promptAck: OmpPromptAck = {};
branchResponse: { text?: string; cancelled?: boolean } = { text: "" };
branchMessages: Array<{ entryId: string; text: string }> = [];
readonly branchRequests: string[] = [];
activeBranchEntryId?: string;
closed = false;
state: OmpSessionState;
private readonly subscribers = new Set<(event: OmpRuntimeEvent) => void>();
private readonly stateReports: OmpSessionState[] = [];
private readonly stateRequestWaiters: Array<{ count: number; resolve: () => void }> = [];
private readonly hostToolResultWaiters: Array<(result: OmpRpcHostToolResult) => void> = [];
private readonly promptWaiters: Array<() => void> = [];
private readonly subscriptionWaiters: Array<{ count: number; resolve: () => void }> = [];
private readonly subagentMessageResults = new Map<string, FakeOmpSubagentMessagesResult[]>();
private nextHeldPrompt: { promise: Promise<void>; reject: (error: Error) => void } | null = null;
private activeHeldPrompt: { promise: Promise<void>; reject: (error: Error) => void } | null =
null;
constructor(launch: OmpRuntimeLaunch) {
this.state = {
model: null,
thinkingLevel: "medium",
isStreaming: false,
isCompacting: false,
autoCompactionEnabled: true,
sessionFile: launch.session ?? "/tmp/omp-session",
sessionId: "omp-session-1",
messageCount: 0,
queuedMessageCount: 0,
};
}
onEvent(callback: (event: OmpRuntimeEvent) => void): () => void {
this.subscribers.add(callback);
return () => {
this.subscribers.delete(callback);
};
}
async prompt(
message: string,
images?: Array<{ type: "image"; data: string; mimeType: string }>,
): Promise<OmpPromptAck> {
if (this.state.isStreaming || this.state.isCompacting) {
throw new Error("Agent is already processing");
}
this.prompts.push({ message, imageCount: images?.length ?? 0 });
this.promptWaiters.shift()?.();
const heldPrompt = this.nextHeldPrompt;
if (heldPrompt) {
this.nextHeldPrompt = null;
this.activeHeldPrompt = heldPrompt;
try {
await heldPrompt.promise;
} finally {
if (this.activeHeldPrompt === heldPrompt) {
this.activeHeldPrompt = null;
}
}
}
return this.promptAck;
}
nextPrompt(): Promise<void> {
return new Promise((resolve) => this.promptWaiters.push(resolve));
}
holdNextPrompt(): void {
if (this.nextHeldPrompt || this.activeHeldPrompt) {
throw new Error("FakeOmp already has a held prompt");
}
let reject!: (error: Error) => void;
const promise = new Promise<void>((_resolve, rejectPromise) => {
reject = rejectPromise;
});
this.nextHeldPrompt = { promise, reject };
}
async failHeldPrompt(error: Error): Promise<void> {
const heldPrompt = this.activeHeldPrompt ?? this.nextHeldPrompt;
if (!heldPrompt) {
throw new Error("FakeOmp has no held prompt");
}
heldPrompt.reject(error);
await new Promise<void>((resolve) => setImmediate(resolve));
}
async compact(customInstructions?: string): Promise<void> {
this.compactRequests.push(customInstructions === undefined ? {} : { customInstructions });
this.emit({ type: "compaction_start", reason: "manual" });
if (this.emitCompactEnd) {
this.emit({ type: "compaction_end", reason: "manual" });
}
if (this.compactError) {
throw this.compactError;
}
}
async setAutoCompaction(enabled: boolean): Promise<void> {
this.setAutoCompactionRequests.push(enabled);
this.state = {
...this.state,
autoCompactionEnabled: enabled,
};
}
async abort(): Promise<void> {
this.abortRequested = true;
}
async getState(): Promise<OmpSessionState> {
this.getStateRequestCount += 1;
for (const waiter of this.stateRequestWaiters.splice(0)) {
if (this.getStateRequestCount >= waiter.count) waiter.resolve();
else this.stateRequestWaiters.push(waiter);
}
if (this.getStateError) {
throw this.getStateError;
}
const report = this.stateReports.shift();
if (report) {
this.state = report;
}
return this.state;
}
queueStateReports(states: OmpSessionState[]): void {
this.stateReports.push(...states);
}
waitForStateRequests(count: number): Promise<void> {
if (this.getStateRequestCount >= count) return Promise.resolve();
return new Promise((resolve) => this.stateRequestWaiters.push({ count, resolve }));
}
async getMessages(): Promise<OmpAgentMessage[]> {
return this.messages;
}
async getAvailableModels(_timeoutMs?: number): Promise<OmpModel[]> {
return this.models;
}
async setModel(provider: string, modelId: string): Promise<OmpModel> {
this.setModelRequests.push({ provider, modelId });
if (!this.setModelResult) {
throw new Error("FakeOmp setModel requires setModelResult to be scripted");
}
return this.setModelResult;
}
async setThinkingLevel(level: OmpThinkingLevel): Promise<void> {
this.setThinkingLevelRequests.push(level);
}
async getSessionStats(): Promise<OmpSessionStats> {
return this.stats;
}
async setSubagentSubscription(level: FakeOmpSubagentSubscriptionLevel): Promise<void> {
this.subagentSubscriptionRequests.push(level);
for (const waiter of this.subscriptionWaiters.splice(0)) {
if (this.subagentSubscriptionRequests.length >= waiter.count) waiter.resolve();
else this.subscriptionWaiters.push(waiter);
}
const error = this.subagentSubscriptionErrors.get(level);
if (error) {
throw error;
}
}
waitForSubagentSubscriptions(count: number): Promise<void> {
if (this.subagentSubscriptionRequests.length >= count) return Promise.resolve();
return new Promise((resolve) => this.subscriptionWaiters.push({ count, resolve }));
}
async setHostTools(tools: OmpRpcHostToolDefinition[]): Promise<string[]> {
this.hostToolSetRequests.push(tools);
return tools.map((tool) => tool.name);
}
async branch(entryId: string): Promise<{ text: string }> {
this.branchRequests.push(entryId);
if (this.branchResponse.cancelled === true) {
throw new Error("OMP branch was cancelled");
}
if (typeof this.branchResponse.text !== "string") {
throw new Error("FakeOmp branch response requires text");
}
this.activeBranchEntryId = entryId;
return { text: this.branchResponse.text };
}
async getBranchMessages(): Promise<Array<{ entryId: string; text: string }>> {
return this.branchMessages;
}
steer(message: string, images?: Array<{ type: "image"; data: string; mimeType: string }>): void {
this.steerRequests.push({ message, imageCount: images?.length ?? 0 });
}
followUp(
message: string,
images?: Array<{ type: "image"; data: string; mimeType: string }>,
): void {
this.followUpRequests.push({ message, imageCount: images?.length ?? 0 });
}
sendHostToolResult(result: OmpRpcHostToolResult): void {
this.hostToolResults.push(result);
this.hostToolResultWaiters.shift()?.(result);
}
sendHostToolUpdate(update: OmpRpcHostToolUpdate): void {
this.hostToolUpdates.push(update);
}
nextHostToolResult(): Promise<OmpRpcHostToolResult> {
return new Promise((resolve) => this.hostToolResultWaiters.push(resolve));
}
async getSubagents(): Promise<FakeOmpSubagentSnapshot[]> {
return this.subagents;
}
async getSubagentMessages(
selector: FakeOmpSubagentMessagesSelector,
): Promise<FakeOmpSubagentMessagesResult> {
this.subagentMessageRequests.push(selector);
const key = selector.sessionFile ?? selector.subagentId;
if (!key) {
throw new Error("FakeOmp getSubagentMessages requires a selector");
}
const results = this.subagentMessageResults.get(key);
const result = results?.shift();
if (!result) {
throw new Error(`FakeOmp has no subagent messages queued for ${key}`);
}
return result;
}
queueSubagentMessages(result: FakeOmpSubagentMessagesResult): void {
const results = this.subagentMessageResults.get(result.sessionFile) ?? [];
results.push(result);
this.subagentMessageResults.set(result.sessionFile, results);
}
async getCommands(): Promise<OmpRpcSlashCommand[]> {
return this.commands;
}
async handoff(customInstructions?: string): Promise<void> {
this.handoffRequests.push(customInstructions ? { customInstructions } : {});
}
respondToExtensionUiRequest(
id: string,
response: { value?: string; confirmed?: boolean; cancelled?: boolean },
): void {
this.extensionUiResponses.push({ id, response });
}
cancelExtensionUiRequest(id: string): void {
this.canceledExtensionUiRequests.push(id);
this.respondToExtensionUiRequest(id, { cancelled: true });
}
async close(): Promise<void> {
this.closed = true;
}
emit(event: OmpRuntimeEvent): void {
for (const subscriber of this.subscribers) {
subscriber(event);
}
}
finishTurn(message: OmpAgentMessage = { role: "assistant", content: [] }): void {
this.messages = [...this.messages, message];
this.emit({ type: "agent_end", messages: this.messages });
}
beginTurn(): void {
this.emit({ type: "turn_start" });
}
acceptPrompt(text: string, entryId = "omp-user-1"): void {
this.emit({
type: "message_end",
message: { role: "user", content: text, entryId } as OmpAgentMessage,
});
}
acceptCustomMessage(content: string): void {
this.emit({
type: "message_end",
message: { role: "custom", content },
});
}
streamAssistantText(text: string, responseId = "omp-assistant-1"): void {
const message: OmpAgentMessage = {
role: "assistant",
content: [],
responseId,
};
this.emit({ type: "message_start", message });
this.emit({
type: "message_update",
message,
assistantMessageEvent: { type: "text_delta", delta: text },
});
}
requestToolApproval(input: {
id: string;
tool: "bash" | "edit" | "write";
detail: string;
}): void {
let detailLabel = "Path";
if (input.tool === "bash") {
detailLabel = "Command";
} else if (input.tool === "edit") {
detailLabel = "File";
}
this.emit({
type: "extension_ui_request",
id: input.id,
method: "select",
title: `Allow tool: ${input.tool}\n${detailLabel}: ${input.detail}`,
options: ["Approve", "Deny"],
});
}
}

View File

@@ -0,0 +1,342 @@
import { mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import pino from "pino";
import type {
AgentPersistenceHandle,
AgentPermissionResponse,
AgentSessionConfig,
AgentStreamEvent,
AgentTimelineItem,
} from "../../../agent-sdk-types.js";
import type { PaseoToolCatalog } from "../../../tools/types.js";
import { OmpAgentClient, OmpAgentSession, type OmpProviderIdleScheduler } from "../agent.js";
import type { OmpRpcSlashCommand } from "../rpc-types.js";
import { FakeOmp } from "./fake-omp.js";
const CWD = "/tmp/paseo-omp-agent-test";
interface OmpHistoryMessage {
id: string;
text: string;
}
interface OmpResumeHistory {
user: OmpHistoryMessage;
assistant: OmpHistoryMessage;
}
async function writeOmpHistory(history: OmpResumeHistory): Promise<string> {
const directory = await mkdtemp(join(tmpdir(), "paseo-omp-resume-"));
const sessionFile = join(directory, "session.jsonl");
const entries = [
{ type: "session", id: "session-root", parentId: null },
{
type: "message",
id: history.user.id,
parentId: "session-root",
message: { role: "user", content: history.user.text },
},
{
type: "message",
id: history.assistant.id,
parentId: history.user.id,
message: {
role: "assistant",
content: [{ type: "text", text: history.assistant.text }],
responseId: history.assistant.id,
},
},
];
await writeFile(sessionFile, entries.map((entry) => JSON.stringify(entry)).join("\n"), "utf8");
return sessionFile;
}
export class OmpHarness {
private readonly omp = new FakeOmp();
private readonly client: OmpAgentClient;
private readonly events: AgentStreamEvent[] = [];
private session: OmpAgentSession | null = null;
constructor(options: { providerIdleScheduler?: OmpProviderIdleScheduler } = {}) {
this.client = new OmpAgentClient({
logger: pino({ level: "silent" }),
runtime: this.omp,
providerIdleScheduler: options.providerIdleScheduler,
});
}
queueCommands(commands: OmpRpcSlashCommand[]): void {
this.omp.queueCommands(commands);
}
failEventSubscription(error: Error): void {
this.omp.failNextSubagentSubscription("events", error);
}
async start(
config: Partial<AgentSessionConfig> = {},
paseoTools?: PaseoToolCatalog,
): Promise<void> {
const session = await this.client.createSession(
{ provider: "omp", cwd: CWD, ...config },
paseoTools ? { paseoTools } : undefined,
);
if (!(session instanceof OmpAgentSession)) {
throw new Error("OMP client returned a non-OMP session");
}
this.session = session;
this.session.subscribe((event) => this.events.push(event));
}
async resume(
history: OmpResumeHistory,
overrides: Partial<AgentSessionConfig> = {},
): Promise<void> {
const sessionFile = await writeOmpHistory(history);
const handle: AgentPersistenceHandle = {
provider: "omp",
sessionId: "omp-session-1",
nativeHandle: sessionFile,
metadata: { cwd: CWD },
};
const session = await this.client.resumeSession(handle, overrides);
if (!(session instanceof OmpAgentSession)) {
throw new Error("OMP client returned a non-OMP session");
}
this.session = session;
this.session.subscribe((event) => this.events.push(event));
}
launchConfiguration(): {
cwd: string;
protocolMode?: string;
modeId?: string;
session?: string;
argv: string[];
} {
const launch = this.omp.recordedLaunches[0];
if (!launch) throw new Error("OMP harness has not launched");
return {
cwd: launch.cwd,
protocolMode: launch.protocolMode,
modeId: launch.modeId,
...(launch.session ? { session: launch.session } : {}),
argv: launch.argv,
};
}
registeredHostTools() {
return this.omp.latestSession().hostToolSetRequests;
}
capabilities() {
return this.client.capabilities;
}
async runPrompt(
input: string,
output: string,
providerStatesAfterEnd: Array<{ isStreaming: boolean; isCompacting: boolean }> = [],
): Promise<unknown> {
const session = this.requireSession();
const promptStarted = this.omp.latestSession().nextPrompt();
const run = session.run(input);
await promptStarted;
const runtime = this.omp.latestSession();
runtime.beginTurn();
runtime.acceptPrompt(input, "user-1");
runtime.streamAssistantText(output);
runtime.queueStateReports(
providerStatesAfterEnd.map((state) => ({ ...runtime.state, ...state })),
);
runtime.finishTurn();
return await run;
}
async runPromptAfterExtensionNotice(input: string, output: string): Promise<unknown> {
const session = this.requireSession();
const promptStarted = this.omp.latestSession().nextPrompt();
const run = session.run(input);
await promptStarted;
const runtime = this.omp.latestSession();
runtime.beginTurn();
runtime.acceptPrompt(input, "user-1");
runtime.acceptCustomMessage("extension inventory changed");
runtime.finishTurn({ role: "custom", content: "extension inventory changed" });
runtime.beginTurn();
runtime.streamAssistantText(output);
runtime.finishTurn();
return await run;
}
async startPromptUntilProviderIdle(
input: string,
output: string,
providerState: { isStreaming: boolean; isCompacting: boolean },
): Promise<{ completion: Promise<unknown> }> {
const session = this.requireSession();
const promptStarted = this.omp.latestSession().nextPrompt();
const run = session.run(input);
await promptStarted;
const runtime = this.omp.latestSession();
runtime.beginTurn();
runtime.acceptPrompt(input, "user-1");
runtime.streamAssistantText(output);
runtime.state = { ...runtime.state, ...providerState };
runtime.finishTurn();
return { completion: run };
}
waitForProviderStateChecks(count: number): Promise<void> {
return this.omp.latestSession().waitForStateRequests(count);
}
reportProviderState(state: { isStreaming: boolean; isCompacting: boolean }): void {
const runtime = this.omp.latestSession();
runtime.state = { ...runtime.state, ...state };
}
failProviderStateChecks(error: Error | null): void {
this.omp.latestSession().getStateError = error;
}
async runPromptAfterFalseLocalOnlyHint(input: string, output: string): Promise<unknown> {
const session = this.requireSession();
const runtime = this.omp.latestSession();
runtime.promptAck = { agentInvoked: false };
const promptStarted = runtime.nextPrompt();
const run = session.run(input);
await promptStarted;
runtime.acceptPrompt(input, "user-1");
await new Promise<void>((resolve) => setImmediate(resolve));
runtime.beginTurn();
runtime.streamAssistantText(output);
runtime.finishTurn();
return await run;
}
timeline(): AgentTimelineItem[] {
return this.events.flatMap((event) => (event.type === "timeline" ? [event.item] : []));
}
async history(): Promise<AgentTimelineItem[]> {
const items: AgentTimelineItem[] = [];
for await (const event of this.requireSession().streamHistory()) {
if (event.type === "timeline") items.push(event.item);
}
return items;
}
completedTurnCount(): number {
return this.events.filter((event) => event.type === "turn_completed").length;
}
requestToolApproval(input: {
id: string;
tool: "bash" | "edit" | "write";
detail: string;
}): void {
this.omp.latestSession().requestToolApproval(input);
}
pendingPermissions() {
return this.requireSession().getPendingPermissions();
}
async respondToPermission(id: string, response: AgentPermissionResponse): Promise<void> {
await this.requireSession().respondToPermission(id, response);
}
extensionUiResponses() {
return this.omp.latestSession().extensionUiResponses;
}
async availableModes() {
return await this.requireSession().getAvailableModes();
}
async commands() {
return await this.requireSession().listCommands();
}
async setMode(modeId: string) {
return await this.requireSession().setMode(modeId);
}
async rewind(messageId: string, restoredPrompt: string): Promise<void> {
this.omp.latestSession().branchResponse = { text: restoredPrompt };
await this.requireSession().revertConversation({ messageId });
}
branchRequests(): string[] {
return this.omp.latestSession().branchRequests;
}
async interruptActiveTurn(message: string): Promise<void> {
await this.requireSession().startTurn(message);
await this.requireSession().interrupt();
}
async requireStartTurn(message: string): Promise<void> {
const promptStarted = this.omp.latestSession().nextPrompt();
await this.requireSession().startTurn(message);
await promptStarted;
}
async interrupt(): Promise<void> {
await this.requireSession().interrupt();
}
wasAborted(): boolean {
return this.omp.latestSession().abortRequested;
}
runtime() {
return this.omp.latestSession();
}
runningToolCallIds(): string[] {
const statusByCall = new Map<string, string>();
for (const item of this.timeline()) {
if (item.type === "tool_call") {
statusByCall.set(item.callId, item.status);
}
}
return [...statusByCall.entries()]
.filter(([, status]) => status === "running")
.map(([callId]) => callId);
}
subagentUpserts(): Array<{ id: string; status: string }> {
return this.events.flatMap((event) =>
event.type === "provider_subagent" && event.event.type === "upsert"
? [{ id: event.event.id, status: event.event.status }]
: [],
);
}
canceledTurnCount(): number {
return this.events.filter((event) => event.type === "turn_canceled").length;
}
async close(): Promise<void> {
await this.requireSession().close();
}
isClosed(): boolean {
return this.omp.latestSession().closed;
}
async waitForSubscriptionFallback(): Promise<string[]> {
const runtime = this.omp.latestSession();
await runtime.waitForSubagentSubscriptions(2);
return runtime.subagentSubscriptionRequests;
}
private requireSession(): OmpAgentSession {
if (!this.session) throw new Error("OMP harness has not started");
return this.session;
}
}

View File

@@ -0,0 +1,106 @@
import { describe, expect, test } from "vitest";
import { parseToolResult } from "./tool-call-detail.js";
import { mapOmpTodoReminderEvent, mapOmpTodoState, mapOmpTodoToolResult } from "./todo-mapper.js";
const TODO_PHASES = [
{
name: "Tasks",
tasks: [
{ content: "alpha task", status: "completed" },
{ content: "beta task", status: "in_progress" },
{ content: "gamma task", status: "pending" },
],
},
] as const;
describe("OMP todo mapper", () => {
test("maps todo tool results and collapses statuses to completed booleans", () => {
expect(
mapOmpTodoToolResult(
parseToolResult({
content: [],
details: {
phases: [
{
name: "Tasks",
tasks: [
{ content: "alpha task", status: "in_progress" },
{ content: "beta task", status: "pending" },
{ content: "gamma task", status: "pending" },
],
},
],
},
}),
),
).toEqual({
type: "todo",
items: [
{ text: "alpha task", completed: false },
{ text: "beta task", completed: false },
{ text: "gamma task", completed: false },
],
});
expect(
mapOmpTodoToolResult(parseToolResult({ content: [], details: { phases: TODO_PHASES } })),
).toEqual({
type: "todo",
items: [
{ text: "alpha task", completed: true },
{ text: "beta task", completed: false },
{ text: "gamma task", completed: false },
],
});
});
test("maps todo reminder events", () => {
expect(
mapOmpTodoReminderEvent({
type: "todo_reminder",
todos: [
{ content: "beta task", status: "in_progress" },
{ content: "gamma task", status: "pending" },
],
}),
).toEqual({
type: "todo",
items: [
{ text: "beta task", completed: false },
{ text: "gamma task", completed: false },
],
});
});
test("hydrates current todos from session state", () => {
expect(
mapOmpTodoState({
model: null,
thinkingLevel: "medium",
isStreaming: false,
isCompacting: false,
sessionId: "session",
messageCount: 0,
queuedMessageCount: 0,
todoPhases: TODO_PHASES,
}),
).toEqual([
{
type: "todo",
items: [
{ text: "alpha task", completed: true },
{ text: "beta task", completed: false },
{ text: "gamma task", completed: false },
],
},
]);
});
test("drops malformed todo inputs", () => {
expect(mapOmpTodoReminderEvent({ type: "todo_reminder", todos: [{ content: 1 }] })).toBeNull();
expect(
mapOmpTodoToolResult({ details: { phases: [{ name: "Bad", tasks: [{}] }] } }),
).toBeNull();
});
});

View File

@@ -0,0 +1,58 @@
import type { AgentTimelineItem } from "../../agent-sdk-types.js";
import type { OmpSessionState } from "./rpc-types.js";
import type { OmpToolResult } from "./tool-call-detail.js";
import {
OmpTodoPhaseSchema,
OmpTodoReminderEventSchema,
type OmpTodoItem,
type OmpTodoPhase,
} from "./rpc-types.js";
export function mapOmpTodoToolResult(result: OmpToolResult): AgentTimelineItem | null {
const details = resultDetails(result);
const phases = OmpTodoPhaseSchema.array().safeParse(details?.phases);
return phases.success ? mapOmpTodoPhases(phases.data) : null;
}
export function mapOmpTodoReminderEvent(event: unknown): AgentTimelineItem | null {
const parsed = OmpTodoReminderEventSchema.safeParse(event);
return parsed.success ? mapOmpTodoItems(parsed.data.todos) : null;
}
export function mapOmpTodoState(state: OmpSessionState): AgentTimelineItem[] {
const phases = OmpTodoPhaseSchema.array().safeParse(state.todoPhases);
if (!phases.success) {
return [];
}
const item = mapOmpTodoPhases(phases.data);
return item ? [item] : [];
}
export function mapOmpTodoPhases(phases: readonly OmpTodoPhase[]): AgentTimelineItem | null {
const todos = phases.flatMap((phase) => phase.tasks);
return mapOmpTodoItems(todos);
}
function mapOmpTodoItems(items: readonly OmpTodoItem[]): AgentTimelineItem | null {
if (items.length === 0) {
return null;
}
return {
type: "todo",
items: items.map((item) => ({
text: item.content,
completed: item.status === "completed",
})),
};
}
function resultDetails(result: OmpToolResult): Record<string, unknown> | null {
if (typeof result === "string" || result === null) {
return null;
}
return isRecord(result.details) ? result.details : null;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

View File

@@ -0,0 +1,473 @@
import { z } from "zod";
import type { ToolCallDetail } from "../../agent-sdk-types.js";
interface BashToolInput {
command: string;
timeout?: number;
}
interface ReadToolInput {
path: string;
offset?: number;
limit?: number;
}
interface EditToolInput {
path: string;
edits: Array<{
oldText: string;
newText: string;
}>;
}
interface WriteToolInput {
path: string;
content: string;
}
interface FindToolInput {
pattern: string;
path?: string;
limit?: number;
}
interface GrepToolInput {
pattern: string;
path?: string;
glob?: string;
ignoreCase?: boolean;
literal?: boolean;
context?: number;
limit?: number;
}
interface LsToolInput {
path?: string;
limit?: number;
}
interface OmpToolResultObject {
output?: string;
stdout?: string;
text?: string;
content?: OmpToolResultContent[];
exitCode?: number;
code?: number;
details?: OmpToolResultDetails;
}
interface OmpToolResultDetails {
diff?: string;
mode?: string;
xdev?: unknown;
}
interface OmpToolResultTextContent {
type: "text";
text: string;
}
interface OmpToolResultUnknownContent {
type: string;
}
type OmpToolResultContent = OmpToolResultTextContent | OmpToolResultUnknownContent;
export type OmpToolResult = string | OmpToolResultObject | null;
interface OmpBashToolCall {
kind: "bash";
toolName: "bash";
args: BashToolInput;
}
interface OmpReadToolCall {
kind: "read";
toolName: "read";
args: ReadToolInput;
}
interface OmpEditToolCall {
kind: "edit";
toolName: "edit";
args: EditToolInput;
}
interface OmpWriteToolCall {
kind: "write";
toolName: "write";
args: WriteToolInput;
}
interface OmpFindToolCall {
kind: "find";
toolName: "find";
args: FindToolInput;
}
interface OmpGrepToolCall {
kind: "grep";
toolName: "grep";
args: GrepToolInput;
}
interface OmpLsToolCall {
kind: "ls";
toolName: "ls";
args: LsToolInput;
}
interface OmpUnknownToolCall {
kind: "unknown";
toolName: string;
args: unknown;
}
export type OmpTrackedToolCall =
| OmpBashToolCall
| OmpReadToolCall
| OmpEditToolCall
| OmpWriteToolCall
| OmpFindToolCall
| OmpGrepToolCall
| OmpLsToolCall
| OmpUnknownToolCall;
interface ToolCallOutputSummary {
output?: string;
exitCode?: number | null;
}
const OmpToolResultTextContentSchema = z.object({
type: z.literal("text"),
text: z.string(),
});
const OmpToolResultUnknownContentSchema = z
.object({
type: z.string(),
})
.passthrough();
const OmpToolResultContentSchema = z.union([
OmpToolResultTextContentSchema,
OmpToolResultUnknownContentSchema,
]);
const OmpToolResultDetailsSchema = z
.object({
diff: z.string().optional(),
})
.passthrough();
const XdevExecuteDetailsSchema = z.object({
tool: z.string().trim().min(1),
mode: z.literal("execute"),
args: z.unknown().optional(),
inner: z.unknown().optional(),
});
const OmpToolResultObjectSchema = z
.object({
output: z.string().optional(),
stdout: z.string().optional(),
text: z.string().optional(),
content: z.array(OmpToolResultContentSchema).optional(),
exitCode: z.number().optional(),
code: z.number().optional(),
details: OmpToolResultDetailsSchema.optional(),
})
.passthrough();
const OmpToolResultSchema = z.union([z.string(), OmpToolResultObjectSchema, z.null()]);
const BashToolInputSchema: z.ZodType<BashToolInput> = z.object({
command: z.string(),
timeout: z.number().optional(),
});
const ReadToolInputSchema: z.ZodType<ReadToolInput> = z.object({
path: z.string(),
offset: z.number().optional(),
limit: z.number().optional(),
});
const EditToolInputSchema: z.ZodType<EditToolInput> = z.object({
path: z.string(),
edits: z.array(
z.object({
oldText: z.string(),
newText: z.string(),
}),
),
});
const LegacyEditToolInputSchema = z.object({
path: z.string(),
old_string: z.string().optional(),
oldString: z.string().optional(),
new_string: z.string().optional(),
newString: z.string().optional(),
});
const WriteToolInputSchema: z.ZodType<WriteToolInput> = z.object({
path: z.string(),
content: z.string(),
});
const FindToolInputSchema: z.ZodType<FindToolInput> = z.object({
pattern: z.string(),
path: z.string().optional(),
limit: z.number().optional(),
});
const GrepToolInputSchema: z.ZodType<GrepToolInput> = z.object({
pattern: z.string(),
path: z.string().optional(),
glob: z.string().optional(),
ignoreCase: z.boolean().optional(),
literal: z.boolean().optional(),
context: z.number().optional(),
limit: z.number().optional(),
});
const LsToolInputSchema: z.ZodType<LsToolInput> = z.object({
path: z.string().optional(),
limit: z.number().optional(),
});
export function parseToolResult(rawResult: unknown): OmpToolResult {
const parsed = OmpToolResultSchema.safeParse(rawResult);
if (parsed.success) {
return parsed.data;
}
return null;
}
export function extractTextFromToolResult(result: OmpToolResult): string | undefined {
if (typeof result === "string") {
return result;
}
if (!result) {
return undefined;
}
const directText = result.output ?? result.stdout ?? result.text;
if (directText) {
return directText;
}
if (!result.content) {
return undefined;
}
const textParts: string[] = [];
for (const block of result.content) {
if (block.type === "text" && "text" in block) {
textParts.push(block.text);
}
}
return textParts.length > 0 ? textParts.join("\n") : undefined;
}
export function parseToolArgs(toolName: string, rawArgs: unknown): OmpTrackedToolCall {
if (toolName === "edit") {
return parseEditToolArgs(rawArgs);
}
const schema = SIMPLE_TOOL_SCHEMAS[toolName as SimpleToolKind];
if (schema) {
const parsed = schema.safeParse(rawArgs);
if (parsed.success) {
return {
kind: toolName as SimpleToolKind,
toolName,
args: parsed.data,
} as OmpTrackedToolCall;
}
}
return { kind: "unknown", toolName, args: rawArgs ?? null };
}
export function resolveToolCallName(toolCall: OmpTrackedToolCall, result?: OmpToolResult): string {
if (toolCall.kind === "write" && result && typeof result !== "string") {
const xdev = XdevExecuteDetailsSchema.safeParse(result.details?.xdev);
if (xdev.success) {
return xdev.data.tool;
}
}
return toolCall.toolName;
}
export function mapToolDetail(
toolCall: OmpTrackedToolCall,
result?: OmpToolResult,
): ToolCallDetail {
const parsedResult = result ?? null;
switch (toolCall.kind) {
case "bash": {
const summary = resolveToolCallOutput(parsedResult);
return {
type: "shell",
command: toolCall.args.command,
output: summary.output,
exitCode: summary.exitCode,
};
}
case "read":
return {
type: "read",
filePath: toolCall.args.path,
content: extractTextFromToolResult(parsedResult),
offset: toolCall.args.offset,
limit: toolCall.args.limit,
};
case "edit": {
const firstEdit = toolCall.args.edits[0];
const unifiedDiff =
parsedResult && typeof parsedResult !== "string" ? parsedResult.details?.diff : undefined;
return {
type: "edit",
filePath: toolCall.args.path,
oldString: firstEdit?.oldText,
newString: firstEdit?.newText,
unifiedDiff,
};
}
case "write":
return mapWriteToolDetail(toolCall.args, parsedResult);
case "find":
return mapFindToolDetail(toolCall.args, parsedResult);
case "grep":
return mapGrepToolDetail(toolCall.args, parsedResult);
case "ls":
return mapLsToolDetail(toolCall.args, parsedResult);
default:
return {
type: "unknown",
input: toolCall.args,
output: parsedResult,
};
}
}
function mapWriteToolDetail(args: WriteToolInput, result: OmpToolResult): ToolCallDetail {
if (result && typeof result !== "string" && result.details && "xdev" in result.details) {
const xdev = XdevExecuteDetailsSchema.safeParse(result.details.xdev);
if (xdev.success) {
return {
type: "unknown",
input: xdev.data.args ?? null,
output: {
...result,
details: xdev.data.inner ?? null,
},
};
}
return {
type: "unknown",
input: args,
output: result,
};
}
return {
type: "write",
filePath: args.path,
content: args.content,
};
}
function resolveToolCallOutput(result: OmpToolResult): ToolCallOutputSummary {
if (typeof result === "string") {
return { output: result };
}
if (!result) {
return {};
}
const summary: ToolCallOutputSummary = {
output: extractTextFromToolResult(result),
};
if (typeof result.exitCode === "number") {
summary.exitCode = result.exitCode;
return summary;
}
if (typeof result.code === "number") {
summary.exitCode = result.code;
return summary;
}
summary.exitCode = null;
return summary;
}
function normalizeLegacyEditArgs(rawArgs: unknown): EditToolInput | null {
const parsed = LegacyEditToolInputSchema.safeParse(rawArgs);
if (!parsed.success) {
return null;
}
const oldText = parsed.data.old_string ?? parsed.data.oldString;
const newText = parsed.data.new_string ?? parsed.data.newString;
if (!oldText || newText === undefined) {
return null;
}
return {
path: parsed.data.path,
edits: [{ oldText, newText }],
};
}
function parseEditToolArgs(rawArgs: unknown): OmpTrackedToolCall {
const parsed = EditToolInputSchema.safeParse(rawArgs);
if (parsed.success) {
return { kind: "edit", toolName: "edit", args: parsed.data };
}
const legacyArgs = normalizeLegacyEditArgs(rawArgs);
if (legacyArgs) {
return { kind: "edit", toolName: "edit", args: legacyArgs };
}
return { kind: "unknown", toolName: "edit", args: rawArgs ?? null };
}
type SimpleToolKind = "bash" | "read" | "write" | "find" | "grep" | "ls";
const SIMPLE_TOOL_SCHEMAS: {
[K in SimpleToolKind]: { safeParse: (data: unknown) => { success: boolean; data?: unknown } };
} = {
bash: BashToolInputSchema,
read: ReadToolInputSchema,
write: WriteToolInputSchema,
find: FindToolInputSchema,
grep: GrepToolInputSchema,
ls: LsToolInputSchema,
};
function mapFindToolDetail(args: FindToolInput, result: OmpToolResult): ToolCallDetail {
return {
type: "search",
query: args.pattern,
toolName: "search",
content: typeof result === "string" ? result : undefined,
};
}
function mapGrepToolDetail(args: GrepToolInput, result: OmpToolResult): ToolCallDetail {
return {
type: "search",
query: args.pattern,
toolName: "grep",
content: typeof result === "string" ? result : undefined,
};
}
function mapLsToolDetail(args: LsToolInput, result: OmpToolResult): ToolCallDetail {
return {
type: "search",
query: args.path ?? "ls",
content: typeof result === "string" ? result : undefined,
};
}

View File

@@ -0,0 +1,33 @@
import { describe, expect, test } from "vitest";
import { parseToolArgs } from "./tool-call-detail.js";
import { resolveOmpEmittedToolCallId } from "./tool-call-id.js";
describe("OMP tool call ids", () => {
test("uses stable synthetic ids only for subagent poll calls", () => {
expect(
resolveOmpEmittedToolCallId(
"poll-1",
parseToolArgs("subagent", { poll: ["job-b", "job-a"] }),
),
).toBe("omp-poll:job-a,job-b");
expect(
resolveOmpEmittedToolCallId(
"poll-2",
parseToolArgs("subagent", { poll: ["job-a", "job-b"] }),
),
).toBe("omp-poll:job-a,job-b");
expect(
resolveOmpEmittedToolCallId("poll-3", parseToolArgs("subagent", { poll: ["job-a"] })),
).toBe("omp-poll:job-a");
expect(
resolveOmpEmittedToolCallId(
"spawn-1",
parseToolArgs("subagent", { spawn: [{ prompt: "go" }] }),
),
).toBe("spawn-1");
expect(
resolveOmpEmittedToolCallId("bash-1", parseToolArgs("bash", { command: "echo hi" })),
).toBe("bash-1");
});
});

View File

@@ -0,0 +1,43 @@
import type { OmpTrackedToolCall } from "./tool-call-detail.js";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function readNonEmptyString(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}
export function readPollTargets(args: unknown): string[] | null {
if (!isRecord(args) || !Array.isArray(args.poll)) {
return null;
}
const targets: string[] = [];
for (const target of args.poll) {
const value = readNonEmptyString(target);
if (!value) {
return null;
}
targets.push(value);
}
if (targets.length === 0) {
return null;
}
return targets.toSorted();
}
export function resolveOmpEmittedToolCallId(
toolCallId: string,
toolCall: OmpTrackedToolCall,
): string {
if (toolCall.toolName !== "subagent") {
return toolCallId;
}
const targets = readPollTargets(toolCall.args);
if (!targets) {
return toolCallId;
}
return `omp-poll:${targets.join(",")}`;
}

View File

@@ -0,0 +1,120 @@
import { describe, expect, test } from "vitest";
import { parseToolArgs, parseToolResult } from "./tool-call-detail.js";
import { mapOmpToolDetail } from "./tool-call-mapper.js";
describe("OMP tool call mapper", () => {
test("maps OMP bash, read, hashline edit, and write calls", () => {
expect(
mapOmpToolDetail(
parseToolArgs("bash", { command: "echo hi" }),
parseToolResult({
content: [{ type: "text", text: "hi\n\n\nWall time: 0.02 seconds" }],
}),
),
).toEqual({
type: "shell",
command: "echo hi",
output: "hi\n\n\nWall time: 0.02 seconds",
exitCode: null,
});
expect(
mapOmpToolDetail(
parseToolArgs("read", { path: "fixture.txt" }),
parseToolResult({
content: [{ type: "text", text: "[fixture.txt#0063]\n1:alpha\n2:beta\n3:" }],
details: { displayContent: { text: "alpha\nbeta\n" } },
}),
),
).toEqual({
type: "read",
filePath: "fixture.txt",
content: "alpha\nbeta\n",
offset: undefined,
limit: undefined,
});
expect(
mapOmpToolDetail(
parseToolArgs("edit", {
input: "*** Begin Patch\n[fixture.txt#0063]\nSWAP 2.=2:\n+gamma\n*** End Patch\n",
}),
parseToolResult({
content: [],
details: {
path: "fixture.txt",
oldText: "alpha\nbeta\n",
newText: "alpha\ngamma\n",
diff: " 1|alpha\n-2|beta\n+2|gamma",
},
}),
),
).toEqual({
type: "edit",
filePath: "fixture.txt",
oldString: "alpha\nbeta\n",
newString: "alpha\ngamma\n",
unifiedDiff: " 1|alpha\n-2|beta\n+2|gamma",
});
expect(
mapOmpToolDetail(
parseToolArgs("write", { path: "created.txt", content: "hello write" }),
null,
),
).toEqual({
type: "write",
filePath: "created.txt",
content: "hello write",
});
});
test("maps task to sub-agent detail and suppresses todo raw cards", () => {
expect(
mapOmpToolDetail(
parseToolArgs("task", {
agent: "explore",
description: "Inspect the target files",
}),
null,
),
).toEqual({
type: "sub_agent",
subAgentType: "explore",
description: "Inspect the target files",
log: "",
});
expect(mapOmpToolDetail(parseToolArgs("todo", { op: "view" }), null)).toBeNull();
});
test("uses task result text and transcript path as the best static replay detail", () => {
expect(
mapOmpToolDetail(
parseToolArgs("task", {
agent: "explore",
description: "Inspect the target files",
}),
parseToolResult({
content: [
{
type: "text",
text: "done\ntranscript: /tmp/omp-task-static/Explore.jsonl",
},
],
}),
),
).toEqual({
type: "sub_agent",
subAgentType: "explore",
description: "Inspect the target files",
childSessionId: "/tmp/omp-task-static/Explore.jsonl",
log: "done\ntranscript: /tmp/omp-task-static/Explore.jsonl",
});
});
test("falls back to shared unknown detail for unmapped tools", () => {
expect(mapOmpToolDetail(parseToolArgs("lsp", { op: "hover" }), null)).toEqual({
type: "unknown",
input: { op: "hover" },
output: null,
});
});
});

View File

@@ -0,0 +1,136 @@
import type { ToolCallDetail } from "../../agent-sdk-types.js";
import {
extractTextFromToolResult,
mapToolDetail as mapOmpCoreToolDetail,
type OmpToolResult,
type OmpTrackedToolCall,
} from "./tool-call-detail.js";
export function mapOmpToolDetail(
toolCall: OmpTrackedToolCall,
result: OmpToolResult,
context?: {
toolCallId: string;
mapSubagentDetail?: (baseDetail: ToolCallDetail) => ToolCallDetail;
},
): ToolCallDetail | null {
if (toolCall.toolName === "todo") {
return null;
}
if (toolCall.toolName === "task") {
const detail = mapOmpTaskDetail(toolCall.args, result);
return context?.mapSubagentDetail?.(detail) ?? detail;
}
if (toolCall.toolName === "edit") {
return mapOmpEditDetail(toolCall, result);
}
if (toolCall.toolName === "read") {
return mapOmpReadDetail(toolCall, result);
}
return mapOmpCoreToolDetail(toolCall, result);
}
function mapOmpTaskDetail(args: unknown, result: OmpToolResult): ToolCallDetail {
const argRecord = isRecord(args) ? args : {};
const resultText = extractTextFromToolResult(result);
const childSessionId = readChildSessionId(result);
return {
type: "sub_agent",
subAgentType: firstString(
argRecord.agent,
argRecord.subAgentType,
argRecord.agentType,
argRecord.type,
),
description: firstString(
argRecord.description,
argRecord.task,
argRecord.prompt,
argRecord.assignment,
),
...(childSessionId ? { childSessionId } : {}),
log: resultText?.trim() ?? "",
};
}
function mapOmpEditDetail(
toolCall: OmpTrackedToolCall,
result: OmpToolResult,
): ToolCallDetail | null {
const fallback = mapOmpCoreToolDetail(toolCall, result);
const details = resultDetails(result);
const filePath =
firstString(details?.path, details?.filePath) ?? readPatchInputPath(toolCall.args);
if (!filePath) {
return fallback;
}
return {
type: "edit",
filePath,
oldString: firstString(details?.oldText, details?.old_string),
newString: firstString(details?.newText, details?.new_string),
unifiedDiff: firstString(details?.diff),
};
}
function mapOmpReadDetail(
toolCall: OmpTrackedToolCall,
result: OmpToolResult,
): ToolCallDetail | null {
const fallback = mapOmpCoreToolDetail(toolCall, result);
if (!fallback || fallback.type !== "read") {
return fallback;
}
const details = resultDetails(result);
const displayContent = isRecord(details?.displayContent) ? details.displayContent : null;
const displayText = firstString(displayContent?.text);
if (!displayText) {
return fallback;
}
return {
...fallback,
content: displayText,
};
}
function resultDetails(result: OmpToolResult): Record<string, unknown> | null {
if (typeof result === "string" || result === null) {
return null;
}
return isRecord(result.details) ? result.details : null;
}
function readChildSessionId(result: OmpToolResult): string | undefined {
const details = resultDetails(result);
const direct = firstString(details?.sessionFile, details?.session_file, details?.childSessionId);
if (direct) {
return direct;
}
const text = extractTextFromToolResult(result);
return text?.match(/(?:session|transcript)(?: file)?:\s*(?<path>\/\S+\.jsonl)/i)?.groups?.path;
}
function readPatchInputPath(args: unknown): string | undefined {
if (!isRecord(args)) {
return undefined;
}
const input = args.input;
if (typeof input !== "string") {
return undefined;
}
const match = /^\[(?<path>.+?)#[^\]\n]+]/m.exec(input);
return match?.groups?.path;
}
function firstString(...values: unknown[]): string | undefined {
for (const value of values) {
if (typeof value === "string" && value.trim()) {
return value;
}
}
return undefined;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

View File

@@ -0,0 +1,39 @@
import { describe, expect, test } from "vitest";
import { mapOmpUsage } from "./usage-mapper.js";
describe("OMP usage mapper", () => {
test("adds OMP context usage to token and cost totals", () => {
const usage = mapOmpUsage({
stats: {
tokens: { input: 28237, output: 548, cacheRead: 269824, cacheWrite: 0, total: 298609 },
cost: 0.29253700000000005,
},
state: {
model: null,
thinkingLevel: "medium",
isStreaming: false,
isCompacting: false,
sessionId: "session",
messageCount: 0,
queuedMessageCount: 0,
contextUsage: { tokens: 23656, contextWindow: 272000, percent: 8.7 },
},
baseUsage: {
inputTokens: 28237,
cachedInputTokens: 269824,
outputTokens: 548,
totalCostUsd: 0.29253700000000005,
},
});
expect(usage).toEqual({
inputTokens: 28237,
cachedInputTokens: 269824,
outputTokens: 548,
totalCostUsd: 0.29253700000000005,
contextWindowMaxTokens: 272000,
contextWindowUsedTokens: 23656,
});
});
});

View File

@@ -0,0 +1,24 @@
import type { AgentUsage } from "../../agent-sdk-types.js";
import type { OmpSessionState, OmpSessionStats } from "./rpc-types.js";
export function mapOmpUsage(input: {
stats: OmpSessionStats;
state: OmpSessionState;
baseUsage: AgentUsage | undefined;
}): AgentUsage | undefined {
const contextWindowUsedTokens = finiteNumber(input.state.contextUsage?.tokens);
const contextWindowMaxTokens = finiteNumber(input.state.contextUsage?.contextWindow);
if (contextWindowUsedTokens === undefined && contextWindowMaxTokens === undefined) {
return input.baseUsage;
}
return {
...input.baseUsage,
...(contextWindowMaxTokens !== undefined ? { contextWindowMaxTokens } : {}),
...(contextWindowUsedTokens !== undefined ? { contextWindowUsedTokens } : {}),
};
}
function finiteNumber(value: number | null | undefined): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}

View File

@@ -0,0 +1,45 @@
import { describe, expect, test } from "vitest";
import { parseToolArgs, parseToolResult } from "./tool-call-detail.js";
import { OmpAvailableCommandsUpdateEventSchema } from "./rpc-types.js";
import { mapOmpToolDetail } from "./tool-call-mapper.js";
describe("OMP 17 RPC compatibility", () => {
test("parses source-attributed command updates", () => {
const event = OmpAvailableCommandsUpdateEventSchema.parse({
type: "available_commands_update",
commands: [{ name: "prewalk", description: "Prewalk at the next action", source: "builtin" }],
});
expect(event.commands).toEqual([
{ name: "prewalk", description: "Prewalk at the next action", source: "builtin" },
]);
});
test("maps subscribed custom tool events without assuming built-in names", () => {
const event = {
type: "tool_execution_start",
toolCallId: "hub-call",
toolName: "hub",
args: { op: "list" },
};
expect(mapOmpToolDetail(parseToolArgs(event.toolName, event.args), null)).toEqual({
type: "unknown",
input: { op: "list" },
output: null,
});
});
test("parses arbitrary custom tool results", () => {
expect(
parseToolResult({
content: [{ type: "text", text: "No peers registered" }],
details: { op: "list", peers: [] },
}),
).toEqual({
content: [{ type: "text", text: "No peers registered" }],
details: { op: "list", peers: [] },
});
});
});

View File

@@ -896,6 +896,7 @@ describe("PiRpcAgentSession", () => {
error: "Pi exited",
});
});
test("completes locally handled slash commands when agentInvoked is false", async () => {
const { pi, session, events } = await createSession();
const fakeSession = pi.latestSession();

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,6 @@ import pino from "pino";
import { describe, expect, test } from "vitest";
import { PiCliRuntime } from "./cli-runtime.js";
import type { PiCommandsRpcType } from "./rpc-types.js";
import type { PiRuntimeLaunch } from "./runtime.js";
type PiChild = ChildProcessWithoutNullStreams & {
@@ -35,12 +34,12 @@ function createPiChild(): PiChild {
function createRuntime(
child: PiChild,
launches: PiRuntimeLaunch[] = [],
options?: { commandsRpcType?: PiCommandsRpcType },
options?: { commandsRpcName?: string },
): PiCliRuntime {
return new PiCliRuntime({
logger: pino({ level: "silent" }),
command: ["pi"],
commandsRpcType: options?.commandsRpcType,
commandsRpcName: options?.commandsRpcName,
spawnProcess: (launch) => {
launches.push(launch);
return child;
@@ -150,7 +149,7 @@ describe("PiCliRuntime", () => {
runtimeSettings: {
command: {
mode: "replace",
argv: ["omp"],
argv: ["custom-pi"],
},
},
spawnProcess: (launch) => {
@@ -159,13 +158,42 @@ describe("PiCliRuntime", () => {
},
});
await runtime.startSession({ cwd: "/workspace/project", session: "/tmp/omp-session.jsonl" });
await runtime.startSession({ cwd: "/workspace/project", session: "/tmp/pi-session.jsonl" });
expect(launches).toEqual([
expect.objectContaining({
cwd: "/workspace/project",
session: "/tmp/omp-session.jsonl",
argv: ["omp", "--mode", "rpc", "--session", "/tmp/omp-session.jsonl"],
session: "/tmp/pi-session.jsonl",
argv: ["custom-pi", "--mode", "rpc", "--session", "/tmp/pi-session.jsonl"],
}),
]);
});
test("does not append rpc mode when the configured command already includes a mode flag", async () => {
const child = createPiChild();
replyToCommands(child, () => ({}));
const launches: PiRuntimeLaunch[] = [];
const runtime = new PiCliRuntime({
logger: pino({ level: "silent" }),
command: ["pi"],
runtimeSettings: {
command: {
mode: "replace",
argv: ["custom-pi", "--mode", "json"],
},
},
spawnProcess: (launch) => {
launches.push(launch);
return child;
},
});
await runtime.startSession({ cwd: "/workspace/project" });
expect(launches).toEqual([
expect.objectContaining({
cwd: "/workspace/project",
argv: ["custom-pi", "--mode", "json"],
}),
]);
});
@@ -220,25 +248,6 @@ describe("PiCliRuntime", () => {
expect(commandTypes).toEqual(["get_commands"]);
});
test("lists commands through the OMP-compatible get_available_commands RPC", async () => {
const child = createPiChild();
const commandTypes: string[] = [];
replyToCommands(child, (command) => {
commandTypes.push(String(command.type));
return {
commands: [{ name: "skill:ctx-stats", description: "Show context stats", source: "skill" }],
};
});
const session = await createRuntime(child, [], {
commandsRpcType: "get_available_commands",
}).startSession({ cwd: "/workspace/project" });
await expect(session.getCommands()).resolves.toEqual([
{ name: "skill:ctx-stats", description: "Show context stats", source: "skill" },
]);
expect(commandTypes).toEqual(["get_available_commands"]);
});
test("keeps unicode line separators inside one JSONL record", async () => {
const child = createPiChild();
replyToCommands(child, () => ({}));
@@ -262,6 +271,17 @@ describe("PiCliRuntime", () => {
await expect(state).rejects.toThrow("boom");
});
test("rejects pending commands when the Pi session closes", async () => {
const child = createPiChild();
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
const state = session.getState();
const rejection = expect(state).rejects.toThrow("Pi RPC session is closed");
await session.close();
await rejection;
});
test("disposes the Pi process", async () => {
const child = createPiChild();
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });

View File

@@ -1,9 +1,8 @@
import { type ChildProcess, type ChildProcessWithoutNullStreams } from "node:child_process";
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import type { Logger } from "pino";
import { spawnProcess } from "../../../../utils/spawn.js";
import { terminateWithTreeKill } from "../../../../utils/tree-kill.js";
import type { ProviderRuntimeSettings } from "../../provider-launch-config.js";
import { JsonlRpcProcess, type JsonlRpcLaunch } from "../jsonl-rpc-process.js";
import {
buildPiLaunch,
type PiRuntime,
@@ -13,11 +12,9 @@ import {
} from "./runtime.js";
import type {
PiAgentMessage,
PiCommandsRpcType,
PiModel,
PiPromptAck,
PiRpcCommand,
PiRpcResponse,
PiRpcSlashCommand,
PiRuntimeEvent,
PiSessionState,
@@ -27,54 +24,25 @@ import type {
const DEFAULT_PI_COMMAND: [string, ...string[]] = [
process.env.PI_COMMAND ?? process.env.PI_ACP_PI_COMMAND ?? "pi",
];
const DEFAULT_TIMEOUT_MS = 30_000;
const DEFAULT_COMMANDS_RPC_TYPE: PiCommandsRpcType = "get_commands";
const STDERR_BUFFER_LIMIT = 8192;
const GRACEFUL_SHUTDOWN_TIMEOUT_MS = 2_000;
const FORCE_SHUTDOWN_TIMEOUT_MS = 1_000;
function assertChildWithPipes(
child: ChildProcess,
): asserts child is ChildProcessWithoutNullStreams {
if (!child.stdin || !child.stdout || !child.stderr) {
throw new Error("Pi process was spawned without stdio streams");
}
}
interface PendingRequest {
resolve: (value: unknown) => void;
reject: (error: Error) => void;
timer: NodeJS.Timeout;
}
const DEFAULT_COMMANDS_RPC_NAME = "get_commands";
export interface PiCliRuntimeOptions {
logger: Logger;
runtimeSettings?: ProviderRuntimeSettings;
command?: [string, ...string[]];
commandsRpcType?: PiCommandsRpcType;
commandsRpcName?: string;
spawnProcess?: (launch: PiRuntimeLaunch) => ChildProcessWithoutNullStreams;
}
export class PiCliRuntime implements PiRuntime {
private readonly command: [string, ...string[]];
private readonly commandsRpcType: PiCommandsRpcType;
private readonly spawnProcess: (launch: PiRuntimeLaunch) => ChildProcessWithoutNullStreams;
private readonly commandsRpcName: string;
private readonly spawnProcess?: (launch: PiRuntimeLaunch) => ChildProcessWithoutNullStreams;
constructor(private readonly options: PiCliRuntimeOptions) {
this.command = options.command ?? DEFAULT_PI_COMMAND;
this.commandsRpcType = options.commandsRpcType ?? DEFAULT_COMMANDS_RPC_TYPE;
this.spawnProcess =
options.spawnProcess ??
((launch) => {
const [command, ...args] = launch.argv;
const child = spawnProcess(command, args, {
cwd: launch.cwd,
envOverlay: launch.env,
stdio: ["pipe", "pipe", "pipe"],
});
assertChildWithPipes(child);
return child;
});
this.commandsRpcName = options.commandsRpcName ?? DEFAULT_COMMANDS_RPC_NAME;
this.spawnProcess = options.spawnProcess;
}
async startSession(input: PiStartSessionInput): Promise<PiRuntimeSession> {
@@ -83,47 +51,37 @@ export class PiCliRuntime implements PiRuntime {
runtimeSettings: this.options.runtimeSettings,
session: input,
});
return new PiCliRuntimeSession(
launch,
this.spawnProcess(launch),
this.options.logger,
this.commandsRpcType,
);
const [command, ...args] = launch.argv;
const processLaunch: JsonlRpcLaunch = {
command,
args,
cwd: launch.cwd,
env: launch.env,
};
const spawn = this.spawnProcess;
const processOptions = {
launch: processLaunch,
logger: this.options.logger,
diagnosticName: "Pi RPC",
...(spawn ? { spawn: () => spawn(launch) } : {}),
};
const process = new JsonlRpcProcess(processOptions);
return new PiCliRuntimeSession(process, this.commandsRpcName);
}
}
class PiCliRuntimeSession implements PiRuntimeSession {
private readonly pending = new Map<string, PendingRequest>();
private readonly subscribers = new Set<(event: PiRuntimeEvent) => void>();
private stderrBuffer = "";
private nextRequestId = 1;
private disposed = false;
private stdoutBuffer = "";
constructor(
_launch: PiRuntimeLaunch,
private readonly child: ChildProcessWithoutNullStreams,
private readonly logger: Logger,
private readonly commandsRpcType: PiCommandsRpcType,
private readonly process: JsonlRpcProcess,
private readonly commandsRpcName: string,
) {
child.stdout.on("data", (chunk) => {
this.handleStdoutChunk(chunk.toString());
process.onMessage((message) => {
this.emit(message as PiRuntimeEvent);
});
child.stderr.on("data", (chunk) => {
this.stderrBuffer += chunk.toString();
if (this.stderrBuffer.length > STDERR_BUFFER_LIMIT) {
this.stderrBuffer = this.stderrBuffer.slice(-STDERR_BUFFER_LIMIT);
}
});
child.on("error", (error) => {
this.failAll(error instanceof Error ? error : new Error(String(error)));
});
child.on("exit", (code, signal) => {
const error = new Error(
`Pi RPC process exited with code ${code ?? "null"} and signal ${signal ?? "null"}\n${this.stderrBuffer}`.trim(),
);
process.onExit(({ error }) => {
this.emit({ type: "process_exit", error: error.message });
this.failAll(error);
});
}
@@ -138,18 +96,19 @@ class PiCliRuntimeSession implements PiRuntimeSession {
message: string,
images?: Array<{ type: "image"; data: string; mimeType: string }>,
): Promise<PiPromptAck> {
const data = await this.request({
const { id: requestId, promise } = this.process.startRequest({
type: "prompt",
message,
...(images?.length ? { images } : {}),
});
const data = await promise;
if (typeof data === "object" && data !== null && !Array.isArray(data)) {
const { agentInvoked } = data as Record<string, unknown>;
if (typeof agentInvoked === "boolean") {
return { agentInvoked };
return { requestId, agentInvoked };
}
}
return {};
return { requestId };
}
async compact(customInstructions?: string): Promise<void> {
@@ -224,17 +183,21 @@ class PiCliRuntimeSession implements PiRuntimeSession {
}
async getCommands(): Promise<PiRpcSlashCommand[]> {
const data = (await this.request({ type: this.commandsRpcType })) as {
const data = (await this.request({ type: this.commandsRpcName })) as {
commands?: PiRpcSlashCommand[];
};
return data.commands ?? [];
}
sendRawFrame(frame: object & { type: string }): void {
this.process.send(frame);
}
respondToExtensionUiRequest(
id: string,
response: { value?: string; confirmed?: boolean; cancelled?: boolean },
): void {
this.writeJsonLine({ type: "extension_ui_response", id, ...response });
this.process.send({ type: "extension_ui_response", id, ...response });
}
cancelExtensionUiRequest(id: string): void {
@@ -242,106 +205,11 @@ class PiCliRuntimeSession implements PiRuntimeSession {
}
async close(): Promise<void> {
if (this.disposed) return;
this.disposed = true;
try {
this.child.stdin.end();
} catch {
// ignore
}
const result = await terminateWithTreeKill(this.child, {
gracefulTimeoutMs: GRACEFUL_SHUTDOWN_TIMEOUT_MS,
forceTimeoutMs: FORCE_SHUTDOWN_TIMEOUT_MS,
onForceSignal: () => {
this.logger.warn(
{ timeoutMs: GRACEFUL_SHUTDOWN_TIMEOUT_MS },
"Pi RPC process did not exit after SIGTERM; sending SIGKILL",
);
},
});
if (result === "kill-timeout") {
this.logger.warn(
{ timeoutMs: FORCE_SHUTDOWN_TIMEOUT_MS },
"Pi RPC process did not report exit after SIGKILL",
);
}
await this.process.close(new Error("Pi RPC session is closed"));
}
private request(command: PiRpcCommand, timeoutMs = DEFAULT_TIMEOUT_MS): Promise<unknown> {
if (this.disposed) {
return Promise.reject(new Error("Pi RPC session is closed"));
}
const id = `req_${this.nextRequestId}`;
this.nextRequestId += 1;
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(id);
reject(
new Error(`Pi RPC request timed out for ${command.type}\n${this.stderrBuffer}`.trim()),
);
}, timeoutMs);
this.pending.set(id, { resolve, reject, timer });
this.writeJsonLine({ ...command, id });
});
}
private writeJsonLine(value: unknown): void {
if (this.disposed || this.child.stdin.destroyed || !this.child.stdin.writable) {
return;
}
this.child.stdin.write(`${JSON.stringify(value)}\n`);
}
private handleStdoutChunk(chunk: string): void {
this.stdoutBuffer += chunk;
for (;;) {
const newlineIndex = this.stdoutBuffer.indexOf("\n");
if (newlineIndex === -1) {
break;
}
const line = this.stdoutBuffer.slice(0, newlineIndex).replace(/\r$/, "");
this.stdoutBuffer = this.stdoutBuffer.slice(newlineIndex + 1);
if (line.trim()) {
this.handleLine(line);
}
}
}
private handleLine(line: string): void {
let parsed: unknown;
try {
parsed = JSON.parse(line);
} catch (error) {
this.logger.warn({ error, line }, "Ignoring non-JSON Pi RPC stdout line");
return;
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return;
}
const message = parsed as Record<string, unknown>;
if (message.type === "response") {
this.handleResponse(message as unknown as PiRpcResponse);
return;
}
this.emit(message as PiRuntimeEvent);
}
private handleResponse(response: PiRpcResponse): void {
const id = response.id;
if (!id) {
return;
}
const pending = this.pending.get(id);
if (!pending) {
return;
}
clearTimeout(pending.timer);
this.pending.delete(id);
if (!response.success) {
pending.reject(new Error(response.error ?? `Pi RPC ${response.command} failed`));
return;
}
pending.resolve(response.data);
request(command: PiRpcCommand, timeoutMs?: number): Promise<unknown> {
return this.process.request(command, timeoutMs);
}
private emit(event: PiRuntimeEvent): void {
@@ -349,16 +217,4 @@ class PiCliRuntimeSession implements PiRuntimeSession {
subscriber(event);
}
}
private failAll(error: Error): void {
if (this.disposed) {
return;
}
this.disposed = true;
for (const pending of this.pending.values()) {
clearTimeout(pending.timer);
pending.reject(error);
}
this.pending.clear();
}
}

View File

@@ -1,8 +1,8 @@
import { describe, expect, test } from "vitest";
import type { AgentStreamEvent } from "../../agent-sdk-types.js";
import type { PiAgentMessage } from "./rpc-types.js";
import { streamPiHistory, type PiCapturedUserMessageEntry } from "./history-mapper.js";
import type { PiAgentMessage } from "./rpc-types.js";
async function collectHistory(
messages: PiAgentMessage[],
@@ -128,6 +128,18 @@ describe("Pi history mapper", () => {
]);
});
test("replays non-notice custom messages as assistant text, matching the live path", async () => {
await expect(
collectHistory([{ role: "custom", content: "Extension command output" }]),
).resolves.toEqual([
{
type: "timeline",
provider: "pi",
item: { type: "assistant_message", text: "Extension command output" },
},
]);
});
test("uses Pi tree entry ids for replayed user messages", async () => {
await expect(
collectHistory(

View File

@@ -5,6 +5,8 @@ import {
mapToolDetail,
parseToolArgs,
parseToolResult,
resolveToolCallName,
type PiToolResult,
type PiTrackedToolCall,
} from "./tool-call-mapper.js";
@@ -13,6 +15,19 @@ export interface PiCapturedUserMessageEntry {
text: string;
}
export interface PiHistoryMapperHooks {
mapCustomMessage?: (
text: string,
provider: string,
) => Extract<AgentStreamEvent, { type: "timeline" }> | null;
resolveToolCallId?: (toolCallId: string, toolCall: PiTrackedToolCall) => string;
mapToolDetail?: (
toolCall: PiTrackedToolCall,
result: PiToolResult,
context: { toolCallId: string },
) => ToolCallDetail | null;
}
function isTextContentBlock(block: unknown): block is PiTextContent {
return (
typeof block === "object" &&
@@ -37,116 +52,205 @@ export function getUserMessageText(content: string | (PiTextContent | PiImageCon
return textParts.join("\n\n");
}
export class PiHistoryMapper {
private readonly pendingToolCalls = new Map<string, PiTrackedToolCall>();
private userIndex = 0;
private assistantIndex = 0;
constructor(
private readonly provider: string,
private readonly userEntries: readonly PiCapturedUserMessageEntry[] = [],
private readonly hooks: PiHistoryMapperHooks = {},
) {}
mapMessages(messages: readonly PiAgentMessage[]): AgentStreamEvent[] {
const events: AgentStreamEvent[] = [];
for (const message of messages) {
switch (message.role) {
case "user":
events.push(...this.mapUserMessage(message));
break;
case "custom":
events.push(...this.mapCustomMessage(message));
break;
case "assistant":
events.push(...this.mapAssistantMessage(message));
break;
case "toolResult": {
const event = this.mapToolResultMessage(message);
if (event) {
events.push(event);
}
break;
}
case "bashExecution":
events.push(this.mapBashExecutionMessage(message));
break;
}
}
return events;
}
private mapUserMessage(message: Extract<PiAgentMessage, { role: "user" }>): AgentStreamEvent[] {
const text = getUserMessageText(message.content);
this.userIndex += 1;
if (!text) {
return [];
}
const userEntry = this.userEntries[this.userIndex - 1];
return [
{
type: "timeline",
provider: this.provider,
item: {
type: "user_message",
text,
...(userEntry ? { messageId: userEntry.id } : {}),
},
},
];
}
private mapCustomMessage(
message: Extract<PiAgentMessage, { role: "custom" }>,
): AgentStreamEvent[] {
const text = getUserMessageText(message.content);
const mappedEvent = text ? this.hooks.mapCustomMessage?.(text, this.provider) : null;
if (mappedEvent) {
return [mappedEvent];
}
return text
? [
{
type: "timeline",
provider: this.provider,
item: { type: "assistant_message", text },
},
]
: [];
}
private mapAssistantMessage(
message: Extract<PiAgentMessage, { role: "assistant" }>,
): AgentStreamEvent[] {
const events: AgentStreamEvent[] = [];
this.assistantIndex += 1;
const messageId =
message.responseId || `${this.provider}-history-assistant-${this.assistantIndex}`;
for (const content of message.content) {
if (content.type === "text" && content.text) {
events.push({
type: "timeline",
provider: this.provider,
item: { type: "assistant_message", text: content.text, messageId },
});
continue;
}
if (content.type === "thinking" && content.thinking) {
events.push({
type: "timeline",
provider: this.provider,
item: { type: "reasoning", text: content.thinking },
});
continue;
}
if (content.type === "toolCall") {
const tracked = parseToolArgs(content.name, content.arguments);
this.pendingToolCalls.set(content.id, tracked);
const detail = this.mapToolDetail(content.id, tracked, null);
if (!detail) {
continue;
}
events.push({
type: "timeline",
provider: this.provider,
item: {
type: "tool_call",
callId: this.resolveToolCallId(content.id, tracked),
name: tracked.toolName,
status: "running",
detail,
error: null,
},
});
}
}
return events;
}
private mapToolResultMessage(
message: Extract<PiAgentMessage, { role: "toolResult" }>,
): AgentStreamEvent | null {
const tracked =
this.pendingToolCalls.get(message.toolCallId) ?? parseToolArgs(message.toolName, null);
this.pendingToolCalls.delete(message.toolCallId);
const result = parseToolResult({ content: message.content, details: message.details });
const detail = this.mapToolDetail(message.toolCallId, tracked, result);
if (!detail) {
return null;
}
return {
type: "timeline",
provider: this.provider,
item: toToolResultTimelineItem({
callId: this.resolveToolCallId(message.toolCallId, tracked),
name: resolveToolCallName(tracked, result),
isError: Boolean(message.isError),
detail,
errorText: extractTextFromToolResult(result) ?? "Tool call failed",
}),
};
}
private mapBashExecutionMessage(
message: Extract<PiAgentMessage, { role: "bashExecution" }>,
): AgentStreamEvent {
const detail: ToolCallDetail = {
type: "shell",
command: message.command,
output: message.output,
exitCode: message.exitCode ?? null,
};
return {
type: "timeline",
provider: this.provider,
item: {
type: "tool_call",
callId: `pi-bash-${message.timestamp}`,
name: "bash",
status: message.cancelled ? "canceled" : "completed",
detail,
error: null,
},
};
}
private resolveToolCallId(toolCallId: string, toolCall: PiTrackedToolCall): string {
return this.hooks.resolveToolCallId?.(toolCallId, toolCall) ?? toolCallId;
}
private mapToolDetail(
toolCallId: string,
toolCall: PiTrackedToolCall,
result: PiToolResult,
): ToolCallDetail | null {
const hook = this.hooks.mapToolDetail;
return hook ? hook(toolCall, result, { toolCallId }) : mapToolDetail(toolCall, result);
}
}
export async function* streamPiHistory(
provider: string,
messages: PiAgentMessage[],
userEntries: readonly PiCapturedUserMessageEntry[] = [],
hooks: PiHistoryMapperHooks = {},
): AsyncGenerator<AgentStreamEvent> {
const pendingToolCalls = new Map<string, PiTrackedToolCall>();
let userIndex = 0;
let assistantIndex = 0;
for (const message of messages) {
if (message.role === "user") {
const text = getUserMessageText(message.content);
if (text) {
const userEntry = userEntries[userIndex];
yield {
type: "timeline",
provider,
item: {
type: "user_message",
text,
...(userEntry ? { messageId: userEntry.id } : {}),
},
};
}
userIndex += 1;
continue;
}
if (message.role === "assistant") {
assistantIndex += 1;
const messageId = message.responseId || `${provider}-history-assistant-${assistantIndex}`;
for (const content of message.content) {
if (content.type === "text" && content.text) {
yield {
type: "timeline",
provider,
item: { type: "assistant_message", text: content.text, messageId },
};
continue;
}
if (content.type === "thinking" && content.thinking) {
yield {
type: "timeline",
provider,
item: { type: "reasoning", text: content.thinking },
};
continue;
}
if (content.type === "toolCall") {
const tracked = parseToolArgs(content.name, content.arguments);
pendingToolCalls.set(content.id, tracked);
yield {
type: "timeline",
provider,
item: {
type: "tool_call",
callId: content.id,
name: tracked.toolName,
status: "running",
detail: mapToolDetail(tracked, null),
error: null,
},
};
}
}
continue;
}
if (message.role === "toolResult") {
const tracked =
pendingToolCalls.get(message.toolCallId) ?? parseToolArgs(message.toolName, null);
pendingToolCalls.delete(message.toolCallId);
const result = parseToolResult({ content: message.content });
const detail = mapToolDetail(tracked, result);
yield {
type: "timeline",
provider,
item: toToolResultTimelineItem({
callId: message.toolCallId,
name: tracked.toolName,
isError: Boolean(message.isError),
detail,
errorText: extractTextFromToolResult(result) ?? "Tool call failed",
}),
};
continue;
}
if (message.role === "bashExecution") {
const callId = `pi-bash-${message.timestamp}`;
const detail: ToolCallDetail = {
type: "shell",
command: message.command,
output: message.output,
exitCode: message.exitCode ?? null,
};
yield {
type: "timeline",
provider,
item: {
type: "tool_call",
callId,
name: "bash",
status: message.cancelled ? "canceled" : "completed",
detail,
error: null,
},
};
const mapper = new PiHistoryMapper(provider, userEntries, hooks);
for (const event of mapper.mapMessages(messages)) {
if (event) {
yield event;
}
}
}

View File

@@ -9,6 +9,11 @@ export interface PiPromptAck {
agentInvoked?: boolean;
}
export interface PiPromptAck {
requestId?: string;
agentInvoked?: boolean;
}
export interface PiTextContent {
type: "text";
text: string;
@@ -53,6 +58,7 @@ export type PiAgentMessage =
toolName: string;
content: unknown;
isError?: boolean;
details?: unknown;
}
| {
role: "bashExecution";
@@ -88,6 +94,12 @@ export interface PiSessionState {
sessionName?: string;
messageCount: number;
pendingMessageCount: number;
contextUsage?: {
tokens?: number | null;
contextWindow?: number | null;
percent?: number | null;
};
todoPhases?: unknown;
}
export interface PiSessionStats {
@@ -111,10 +123,9 @@ export interface PiRpcSlashCommand {
description?: string;
source: "extension" | "prompt" | "skill";
sourceInfo?: Record<string, unknown>;
input?: { hint?: string };
}
export type PiCommandsRpcType = "get_commands" | "get_available_commands";
export type PiRpcCommand =
| { id?: string; type: "prompt"; message: string; images?: PiImageContent[] }
| { id?: string; type: "compact"; customInstructions?: string }
@@ -126,7 +137,7 @@ export type PiRpcCommand =
| { id?: string; type: "set_model"; provider: string; modelId: string }
| { id?: string; type: "set_thinking_level"; level: PiThinkingLevel }
| { id?: string; type: "get_session_stats" }
| { id?: string; type: PiCommandsRpcType };
| { id?: string; type: string };
export interface PiRpcResponse {
id?: string;
@@ -191,4 +202,8 @@ export type PiRuntimeEvent =
| {
type: "process_exit";
error: string;
}
| {
type: string;
[key: string]: unknown;
};

View File

@@ -13,25 +13,31 @@ export interface PiRuntimeLaunch {
cwd: string;
argv: string[];
env?: Record<string, string>;
protocolMode?: "rpc" | "rpc-ui";
model?: string;
thinkingOptionId?: string;
modeId?: string;
session?: string;
noSession?: boolean;
systemPrompt?: string;
mcpConfigPath?: string;
extensionPaths?: string[];
extraArgs?: string[];
}
export interface PiStartSessionInput {
cwd: string;
env?: Record<string, string>;
protocolMode?: "rpc" | "rpc-ui";
model?: string;
thinkingOptionId?: string;
modeId?: string;
session?: string;
noSession?: boolean;
systemPrompt?: string;
mcpConfigPath?: string;
extensionPaths?: string[];
extraArgs?: string[];
}
export interface PiRuntimeSession {
@@ -50,6 +56,8 @@ export interface PiRuntimeSession {
setThinkingLevel(level: string): Promise<void>;
getSessionStats(): Promise<PiSessionStats>;
getCommands(): Promise<PiRpcSlashCommand[]>;
request(command: { type: string; [key: string]: unknown }, timeoutMs?: number): Promise<unknown>;
sendRawFrame(frame: object & { type: string }): void;
respondToExtensionUiRequest(
id: string,
response: { value?: string; confirmed?: boolean; cancelled?: boolean },
@@ -73,30 +81,9 @@ export function buildPiLaunch(input: {
: input.command;
const argv = [...command];
if (!hasModeRpc(argv)) {
argv.push("--mode", "rpc");
}
if (input.session.model) {
argv.push("--model", input.session.model);
}
if (input.session.thinkingOptionId) {
argv.push("--thinking", input.session.thinkingOptionId);
}
if (input.session.noSession) {
argv.push("--no-session");
} else if (input.session.session) {
argv.push("--session", input.session.session);
}
const protocolMode = input.session.protocolMode ?? "rpc";
const systemPrompt = input.session.systemPrompt?.trim();
if (systemPrompt) {
argv.push("--append-system-prompt", systemPrompt);
}
if (input.session.mcpConfigPath) {
argv.push("--mcp-config", input.session.mcpConfigPath);
}
for (const extensionPath of input.session.extensionPaths ?? []) {
argv.push("--extension", extensionPath);
}
appendPiLaunchArgs(argv, input.session, protocolMode, systemPrompt);
return {
cwd: input.session.cwd,
@@ -110,20 +97,57 @@ export function buildPiLaunch(input: {
: undefined,
model: input.session.model,
thinkingOptionId: input.session.thinkingOptionId,
protocolMode,
modeId: input.session.modeId,
session: input.session.session,
noSession: input.session.noSession,
systemPrompt,
mcpConfigPath: input.session.mcpConfigPath,
extensionPaths: input.session.extensionPaths,
extraArgs: input.session.extraArgs,
};
}
function hasModeRpc(argv: string[]): boolean {
function appendPiLaunchArgs(
argv: string[],
session: PiStartSessionInput,
protocolMode: "rpc" | "rpc-ui",
systemPrompt: string | undefined,
): void {
if (!hasModeFlag(argv)) {
argv.push("--mode", protocolMode);
}
if (session.extraArgs?.length) {
argv.push(...session.extraArgs);
}
if (session.model) {
argv.push("--model", session.model);
}
if (session.thinkingOptionId) {
argv.push("--thinking", session.thinkingOptionId);
}
if (session.noSession) {
argv.push("--no-session");
} else if (session.session) {
argv.push("--session", session.session);
}
if (systemPrompt) {
argv.push("--append-system-prompt", systemPrompt);
}
if (session.mcpConfigPath) {
argv.push("--mcp-config", session.mcpConfigPath);
}
for (const extensionPath of session.extensionPaths ?? []) {
argv.push("--extension", extensionPath);
}
}
function hasModeFlag(argv: string[]): boolean {
for (let i = 0; i < argv.length; i += 1) {
if (argv[i] === "--mode" && argv[i + 1] === "rpc") {
if (argv[i] === "--mode") {
return true;
}
if (argv[i] === "--mode=rpc") {
if (argv[i]?.startsWith("--mode=")) {
return true;
}
}

View File

@@ -1,3 +1,4 @@
import type { Dirent } from "node:fs";
import { open, readdir, readFile, stat } from "node:fs/promises";
import { homedir } from "node:os";
import path from "node:path";
@@ -12,9 +13,14 @@ import { createRealpathAwarePathMatcher } from "../../../../utils/path.js";
const PI_CONFIG_DIR_NAME = ".pi";
const PI_AGENT_DIR_ENV = "PI_CODING_AGENT_DIR";
const PI_SESSION_DIR_ENV = "PI_CODING_AGENT_SESSION_DIR";
// Import listing intentionally bounds header parsing to this window. Sessions
// with unusually large preambles may omit their first-prompt preview.
const HEAD_BYTES = 64 * 1024;
const TAIL_BYTES = 256 * 1024;
const FULL_SCAN_LINE_LIMIT = 2_000;
// Rank all discovered files cheaply, then parse only a bounded recent window.
const IMPORT_CANDIDATE_OVERSCAN = 40;
const IMPORT_CANDIDATE_MIN = 400;
interface PiSessionDescriptorOptions extends ListImportableSessionsOptions {
sessionDir?: string;
@@ -53,6 +59,10 @@ interface PiSessionDescriptor {
model: string | null;
thinkingOptionId: string | null;
}
interface RankedSessionFile {
file: string;
mtime: Date;
}
export interface PiImportSessionConfig {
model?: string;
@@ -66,18 +76,23 @@ export async function listPiImportableSessions(
const files = await walkJsonlFiles(sessionsDir);
const matchesCwd = options.cwd ? createRealpathAwarePathMatcher(options.cwd) : null;
const limit = options.limit ?? 20;
const ranked = await rankSessionFilesByMtime(files);
const candidateLimit = Math.max(limit * IMPORT_CANDIDATE_OVERSCAN, IMPORT_CANDIDATE_MIN);
const sessions: ImportableProviderSession[] = [];
for (const file of files) {
const session = await readPiImportableSession(file);
for (const entry of ranked.slice(0, candidateLimit)) {
const session = await readPiImportableSession(entry.file);
if (!session) continue;
if (matchesCwd && !matchesCwd(session.cwd)) continue;
sessions.push(session);
if (sessions.length >= limit) {
break;
}
}
return sessions
.sort((left, right) => right.lastActivityAt.getTime() - left.lastActivityAt.getTime())
.slice(0, limit);
return sessions.sort(
(left, right) => right.lastActivityAt.getTime() - left.lastActivityAt.getTime(),
);
}
export async function readPiImportSessionConfig(filePath: string): Promise<PiImportSessionConfig> {
@@ -163,7 +178,7 @@ function resolveConfigPath(value: string, options: { baseDir: string; homeDir: s
}
async function walkJsonlFiles(root: string): Promise<string[]> {
let entries: import("node:fs").Dirent[];
let entries: Dirent[];
try {
entries = await readdir(root, { withFileTypes: true });
} catch {
@@ -182,6 +197,18 @@ async function walkJsonlFiles(root: string): Promise<string[]> {
return files.flat();
}
async function rankSessionFilesByMtime(files: string[]): Promise<RankedSessionFile[]> {
const ranked = await Promise.all(
files.map(async (file) => {
const mtime = await readFileMtime(file);
return mtime ? { file, mtime } : null;
}),
);
return ranked
.filter((entry): entry is RankedSessionFile => entry !== null)
.sort((left, right) => right.mtime.getTime() - left.mtime.getTime());
}
async function readPiImportableSession(
filePath: string,
): Promise<ImportableProviderSession | null> {
@@ -201,14 +228,14 @@ async function readPiImportableSession(
}
async function readPiSessionDescriptor(filePath: string): Promise<PiSessionDescriptor | null> {
const firstLine = await readFirstLine(filePath);
if (!firstLine) return null;
const header = parseSessionHeader(firstLine);
const headChunk = await readHeadChunk(filePath);
if (!headChunk) return null;
const header = parseSessionHeader(headChunk.split(/\r?\n/u, 1)[0]?.trim() ?? "");
if (!header) return null;
const tail = await readTail(filePath).catch(() => "");
const tailInfo = parseSessionTail(tail);
const headInfo = await scanSessionHead(filePath);
const headInfo = parseSessionHeadFromChunk(headChunk);
const title = tailInfo.title ?? headInfo.title ?? headInfo.firstUserMessage;
const model = tailInfo.model ?? headInfo.model;
const thinkingOptionId = tailInfo.thinkingOptionId ?? headInfo.thinkingOptionId;
@@ -233,21 +260,54 @@ function toPiImportSessionConfig(descriptor: PiSessionDescriptor): PiImportSessi
};
}
async function readFirstLine(filePath: string): Promise<string | null> {
async function readHeadChunk(filePath: string): Promise<string | null> {
const handle = await open(filePath, "r").catch(() => null);
if (!handle) return null;
try {
const buffer = Buffer.alloc(HEAD_BYTES);
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
if (bytesRead <= 0) return null;
const chunk = buffer.subarray(0, bytesRead).toString("utf8");
const newlineIndex = chunk.indexOf("\n");
return (newlineIndex === -1 ? chunk : chunk.slice(0, newlineIndex)).trim();
return buffer.subarray(0, bytesRead).toString("utf8");
} finally {
await handle.close().catch(() => undefined);
}
}
function parseSessionHeadFromChunk(chunk: string): PiSessionHead {
let title: string | null = null;
let firstUserMessage: string | null = null;
let model: string | null = null;
let thinkingOptionId: string | null = null;
let lineCount = 0;
for (const rawLine of chunk.split(/\r?\n/u)) {
lineCount += 1;
const entry = parseJsonRecord(rawLine.trim());
if (!entry) continue;
if (entry.type === "session_info") {
title = readNonEmptyString(entry.name) ?? title;
}
model = extractModel(entry) ?? model;
thinkingOptionId = extractThinkingOptionId(entry) ?? thinkingOptionId;
if (!firstUserMessage && entry.type === "message" && isRecord(entry.message)) {
if (entry.message.role === "user") {
firstUserMessage = extractMessageText(entry.message.content);
}
}
if (title && firstUserMessage && model && thinkingOptionId) {
break;
}
if (lineCount >= FULL_SCAN_LINE_LIMIT && firstUserMessage) {
break;
}
}
return { title, firstUserMessage, model, thinkingOptionId };
}
async function readTail(filePath: string): Promise<string> {
const fileStats = await stat(filePath);
const start = Math.max(0, fileStats.size - TAIL_BYTES);
@@ -296,7 +356,6 @@ function parseSessionTail(tail: string): PiSessionTail {
if (!title && entry.type === "session_info") {
title = readNonEmptyString(entry.name);
}
if (!model) {
model = extractModel(entry);
}
@@ -330,49 +389,6 @@ function parseSessionTail(tail: string): PiSessionTail {
};
}
async function scanSessionHead(filePath: string): Promise<PiSessionHead> {
let content: string;
try {
content = await readFile(filePath, "utf8");
} catch {
return { title: null, firstUserMessage: null, model: null, thinkingOptionId: null };
}
let title: string | null = null;
let firstUserMessage: string | null = null;
let model: string | null = null;
let thinkingOptionId: string | null = null;
let lineCount = 0;
for (const rawLine of content.split(/\r?\n/u)) {
lineCount += 1;
const entry = parseJsonRecord(rawLine.trim());
if (!entry) continue;
if (entry.type === "session_info") {
title = readNonEmptyString(entry.name) ?? title;
}
model = extractModel(entry) ?? model;
thinkingOptionId = extractThinkingOptionId(entry) ?? thinkingOptionId;
if (!firstUserMessage && entry.type === "message" && isRecord(entry.message)) {
if (entry.message.role === "user") {
firstUserMessage = extractMessageText(entry.message.content);
}
}
if (title && firstUserMessage && model && thinkingOptionId) {
break;
}
if (lineCount >= FULL_SCAN_LINE_LIMIT && firstUserMessage) {
break;
}
}
return { title, firstUserMessage, model, thinkingOptionId };
}
function extractModel(entry: Record<string, unknown>): string | null {
if (entry.type === "model_change") {
return buildModelId(entry.provider, entry.modelId);

View File

@@ -15,11 +15,42 @@ import type {
} from "../rpc-types.js";
import { buildPiLaunch } from "../runtime.js";
type FakePiSubagentSubscriptionLevel = "off" | "progress" | "events";
type FakePiSubagentStatus = "pending" | "running" | "completed" | "failed" | "aborted";
export interface FakePiSubagentSnapshot {
id: string;
index: number;
agent: string;
description?: string;
status: FakePiSubagentStatus;
task?: string;
assignment?: string;
sessionFile?: string;
parentToolCallId?: string;
lastUpdate?: number;
}
export interface FakePiSubagentMessagesSelector {
subagentId?: string;
sessionFile?: string;
fromByte?: number;
}
export interface FakePiSubagentMessagesResult {
sessionFile: string;
fromByte: number;
nextByte: number;
reset: boolean;
messages: PiAgentMessage[];
}
export class FakePi implements PiRuntime {
readonly recordedLaunches: PiRuntimeLaunch[] = [];
private readonly sessions: FakePiSession[] = [];
private readonly command: [string, ...string[]];
private readonly queuedCommands: PiRpcSlashCommand[][] = [];
private readonly queuedSessionSetups: Array<(session: FakePiSession) => void> = [];
constructor(command: [string, ...string[]] = ["pi"]) {
this.command = command;
@@ -33,6 +64,7 @@ export class FakePi implements PiRuntime {
this.recordedLaunches.push(launch);
const session = new FakePiSession(launch);
session.commands = this.queuedCommands.shift() ?? [];
this.queuedSessionSetups.shift()?.(session);
this.sessions.push(session);
return session;
}
@@ -41,6 +73,10 @@ export class FakePi implements PiRuntime {
this.queuedCommands.push(commands);
}
queueSessionSetup(setup: (session: FakePiSession) => void): void {
this.queuedSessionSetups.push(setup);
}
latestSession(): FakePiSession {
const session = this.sessions.at(-1);
if (!session) {
@@ -54,9 +90,14 @@ export class FakePiSession implements PiRuntimeSession {
readonly prompts: Array<{ message: string; imageCount: number }> = [];
readonly compactRequests: Array<{ customInstructions?: string }> = [];
readonly setAutoCompactionRequests: boolean[] = [];
readonly subagentSubscriptionRequests: FakePiSubagentSubscriptionLevel[] = [];
readonly subagentMessageRequests: FakePiSubagentMessagesSelector[] = [];
readonly setModelRequests: Array<{ provider: string; modelId: string }> = [];
readonly setThinkingLevelRequests: string[] = [];
readonly treeNavigationRequests: string[] = [];
readonly handoffRequests: Array<{ customInstructions?: string }> = [];
readonly sessionNameRequests: string[] = [];
readonly rawFrames: Array<object & { type: string }> = [];
capturedUserEntries: Array<{ id: string; parentId: string | null; text: string }> = [];
abortRequested = false;
readonly canceledExtensionUiRequests: string[] = [];
@@ -72,13 +113,19 @@ export class FakePiSession implements PiRuntimeSession {
cost: 0,
};
commands: PiRpcSlashCommand[] = [];
subagents: FakePiSubagentSnapshot[] = [];
subagentSubscriptionError: Error | null = null;
setSessionNameError: Error | null = null;
compactError: Error | null = null;
emitCompactEnd = true;
getStateError: Error | null = null;
promptAck: PiPromptAck = {};
branchResponse: { text?: string; cancelled?: boolean } = { text: "" };
readonly branchRequests: string[] = [];
state: PiSessionState;
private readonly subscribers = new Set<(event: PiRuntimeEvent) => void>();
private readonly subagentMessageResults = new Map<string, FakePiSubagentMessagesResult[]>();
private nextHeldPrompt: { promise: Promise<void>; reject: (error: Error) => void } | null = null;
private activeHeldPrompt: { promise: Promise<void>; reject: (error: Error) => void } | null =
null;
@@ -200,10 +247,83 @@ export class FakePiSession implements PiRuntimeSession {
return this.stats;
}
async setSubagentSubscription(level: FakePiSubagentSubscriptionLevel): Promise<void> {
this.subagentSubscriptionRequests.push(level);
if (this.subagentSubscriptionError) {
throw this.subagentSubscriptionError;
}
}
async getSubagents(): Promise<FakePiSubagentSnapshot[]> {
return this.subagents;
}
async getSubagentMessages(
selector: FakePiSubagentMessagesSelector,
): Promise<FakePiSubagentMessagesResult> {
this.subagentMessageRequests.push(selector);
const key = selector.sessionFile ?? selector.subagentId;
if (!key) {
throw new Error("FakePi getSubagentMessages requires a selector");
}
const results = this.subagentMessageResults.get(key);
const result = results?.shift();
if (!result) {
throw new Error(`FakePi has no subagent messages queued for ${key}`);
}
return result;
}
queueSubagentMessages(result: FakePiSubagentMessagesResult): void {
const results = this.subagentMessageResults.get(result.sessionFile) ?? [];
results.push(result);
this.subagentMessageResults.set(result.sessionFile, results);
}
async getCommands(): Promise<PiRpcSlashCommand[]> {
return this.commands;
}
sendRawFrame(frame: object & { type: string }): void {
this.rawFrames.push(frame);
}
async request(command: { type: string; [key: string]: unknown }): Promise<unknown> {
switch (command.type) {
case "set_subagent_subscription":
await this.setSubagentSubscription(command.level as FakePiSubagentSubscriptionLevel);
return {};
case "get_subagents":
return { subagents: await this.getSubagents() };
case "get_subagent_messages":
return await this.getSubagentMessages({
...(typeof command.subagentId === "string" ? { subagentId: command.subagentId } : {}),
...(typeof command.sessionFile === "string" ? { sessionFile: command.sessionFile } : {}),
...(typeof command.fromByte === "number" ? { fromByte: command.fromByte } : {}),
});
case "branch": {
const entryId = typeof command.entryId === "string" ? command.entryId : "";
this.branchRequests.push(entryId);
return this.branchResponse;
}
case "handoff":
this.handoffRequests.push(
typeof command.customInstructions === "string"
? { customInstructions: command.customInstructions }
: {},
);
return {};
case "set_session_name":
this.sessionNameRequests.push(typeof command.name === "string" ? command.name : "");
if (this.setSessionNameError) {
throw this.setSessionNameError;
}
return {};
default:
throw new Error(`FakePi request does not implement ${command.type}`);
}
}
respondToExtensionUiRequest(
id: string,
response: { value?: string; confirmed?: boolean; cancelled?: boolean },

View File

@@ -37,6 +37,94 @@ describe("Pi tool call mapper", () => {
});
});
test("preserves ordinary writes as write details", () => {
const toolCall = parseToolArgs("write", {
path: "notes.txt",
content: "unchanged\n",
});
expect(mapToolDetail(toolCall, parseToolResult({ text: "Wrote notes.txt" }))).toEqual({
type: "write",
filePath: "notes.txt",
content: "unchanged\n",
});
});
test("maps executed xdev writes to their wrapped tool detail", () => {
const toolCall = parseToolArgs("write", {
path: "xd://browser",
content: "{}",
});
const result = parseToolResult({
content: [{ type: "text", text: "Opened Example Domain" }],
details: {
xdev: {
tool: "browser",
mode: "execute",
args: { action: "open", url: "https://example.com" },
inner: { title: "Example Domain" },
},
},
});
expect(mapToolDetail(toolCall, result)).toEqual({
type: "unknown",
input: { action: "open", url: "https://example.com" },
output: {
content: [{ type: "text", text: "Opened Example Domain" }],
details: { title: "Example Domain" },
},
});
expect(resolveToolCallName(toolCall, result)).toBe("browser");
});
test("does not treat xdev help metadata as an executed inner tool", () => {
const toolCall = parseToolArgs("write", {
path: "xd://browser",
content: "",
});
const result = parseToolResult({
details: {
xdev: {
tool: "browser",
mode: "help",
inner: "Browser help",
},
},
});
expect(mapToolDetail(toolCall, result)).toEqual({
type: "unknown",
input: { path: "xd://browser", content: "" },
output: result,
});
expect(resolveToolCallName(toolCall, result)).toBe("write");
});
test("does not treat malformed xdev metadata as an executed inner tool", () => {
const toolCall = parseToolArgs("write", {
path: "xd://browser",
content: "{}",
});
const result = parseToolResult({
details: {
xdev: {
tool: "",
mode: "execute",
args: { action: "open" },
inner: { title: "must not surface" },
},
},
});
expect(mapToolDetail(toolCall, result)).toEqual({
type: "unknown",
input: { path: "xd://browser", content: "{}" },
output: result,
});
expect(resolveToolCallName(toolCall, result)).toBe("write");
});
test("preserves unknown tool input and parsed output", () => {
const toolCall = parseToolArgs("custom_tool", { value: 42 });
const result = parseToolResult({ text: "custom result" });

View File

@@ -63,6 +63,7 @@ interface PiToolResultDetails {
server?: string;
tool?: string;
mcpResult?: unknown;
xdev?: unknown;
}
interface PiToolResultTextContent {
@@ -162,6 +163,13 @@ const PiToolResultDetailsSchema = z
})
.passthrough();
const XdevExecuteDetailsSchema = z.object({
tool: z.string().trim().min(1),
mode: z.literal("execute"),
args: z.unknown().optional(),
inner: z.unknown().optional(),
});
const PiToolResultObjectSchema = z
.object({
output: z.string().optional(),
@@ -293,6 +301,13 @@ function stripMcpProxyPrefix(toolName: string, serverName: string): string {
}
export function resolveToolCallName(toolCall: PiTrackedToolCall, result?: PiToolResult): string {
if (toolCall.kind === "write" && result && typeof result !== "string") {
const xdev = XdevExecuteDetailsSchema.safeParse(result.details?.xdev);
if (xdev.success) {
return xdev.data.tool;
}
}
if (toolCall.toolName !== "mcp") {
return toolCall.toolName;
}
@@ -357,11 +372,7 @@ export function mapToolDetail(toolCall: PiTrackedToolCall, result?: PiToolResult
};
}
case "write":
return {
type: "write",
filePath: toolCall.args.path,
content: toolCall.args.content,
};
return mapWriteToolDetail(toolCall.args, parsedResult);
case "find":
return mapFindToolDetail(toolCall.args, parsedResult);
case "grep":
@@ -377,6 +388,34 @@ export function mapToolDetail(toolCall: PiTrackedToolCall, result?: PiToolResult
}
}
function mapWriteToolDetail(args: WriteToolInput, result: PiToolResult): ToolCallDetail {
if (result && typeof result !== "string" && result.details && "xdev" in result.details) {
const xdev = XdevExecuteDetailsSchema.safeParse(result.details.xdev);
if (xdev.success) {
return {
type: "unknown",
input: xdev.data.args ?? null,
output: {
...result,
details: xdev.data.inner ?? null,
},
};
}
return {
type: "unknown",
input: args,
output: result,
};
}
return {
type: "write",
filePath: args.path,
content: args.content,
};
}
function resolveToolCallOutput(result: PiToolResult): ToolCallOutputSummary {
if (typeof result === "string") {
return { output: result };

View File

@@ -0,0 +1,78 @@
// These SDK internals are reachable through @modelcontextprotocol/sdk's wildcard export,
// not a curated public subpath. Keep that package pinned exactly and re-verify these
// paths on every SDK bump so native host-tool schemas stay byte-compatible.
import {
normalizeObjectSchema,
type AnySchema,
type ZodRawShapeCompat,
} from "@modelcontextprotocol/sdk/server/zod-compat.js";
import { toJsonSchemaCompat } from "@modelcontextprotocol/sdk/server/zod-json-schema-compat.js";
import type { PaseoToolDefinition, PaseoToolResult } from "./types.js";
const EMPTY_OBJECT_JSON_SCHEMA: Record<string, unknown> = {
type: "object",
properties: {},
};
function formatStructuredContentForModel(structuredContent: unknown): string {
if (
!structuredContent ||
typeof structuredContent !== "object" ||
Array.isArray(structuredContent)
) {
return JSON.stringify(structuredContent, null, 2);
}
const record = structuredContent as Record<string, unknown>;
const summary: string[] = [];
for (const [key, value] of Object.entries(record)) {
if (!Array.isArray(value)) {
continue;
}
summary.push(`${key}_count=${value.length}`);
const ids = value
.map((item) =>
item && typeof item === "object" && !Array.isArray(item)
? (item as Record<string, unknown>).id
: null,
)
.filter((id): id is string => typeof id === "string" && id.length > 0);
if (ids.length === value.length && ids.length > 0) {
summary.push(`${key}_ids=${ids.join(",")}`);
}
}
const json = JSON.stringify(structuredContent, null, 2);
return summary.length > 0 ? `${summary.join("\n")}\n\n${json}` : json;
}
export function addModelVisibleStructuredContent(result: PaseoToolResult): PaseoToolResult {
if (result.structuredContent === undefined || result.content.length > 0) {
return result;
}
return {
...result,
content: [
{
type: "text",
text: formatStructuredContentForModel(result.structuredContent),
},
],
};
}
export function serializePaseoToolInputParameters(
tool: PaseoToolDefinition,
): Record<string, unknown> {
const schema = normalizeObjectSchema(
tool.inputSchema as AnySchema | ZodRawShapeCompat | undefined,
);
return schema
? toJsonSchemaCompat(schema, {
strictUnions: true,
pipeStrategy: "input",
})
: { ...EMPTY_OBJECT_JSON_SCHEMA };
}

View File

@@ -2,6 +2,7 @@ import type { z } from "zod";
export interface PaseoToolExecutionContext {
signal?: AbortSignal;
sendUpdate?: (update: PaseoToolResult) => void;
}
export interface PaseoToolResult {

View File

@@ -57,6 +57,14 @@ export const agentConfigs = {
provider: "pi",
thinkingOptionId: "medium",
},
omp: {
provider: "omp",
thinkingOptionId: "medium",
modes: {
full: "full", // launches omp with yolo approval mode
ask: "ask", // launches omp with always-ask approval mode
},
},
} as const satisfies Record<string, AgentTestConfig>;
export type AgentProvider = keyof typeof agentConfigs;
@@ -92,4 +100,4 @@ export function getAskModeConfig(provider: AgentProvider) {
/**
* Helper to run a test for each provider.
*/
export const allProviders: AgentProvider[] = ["claude", "codex", "opencode", "pi"];
export const allProviders: AgentProvider[] = ["claude", "codex", "opencode", "pi", "omp"];

View File

@@ -0,0 +1,492 @@
import { execFile } from "node:child_process";
import { randomUUID } from "node:crypto";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import pino from "pino";
import { afterEach, beforeAll, describe, expect, test } from "vitest";
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
import {
MIN_SUPPORTED_OMP_VERSION,
formatOmpVersionSupport,
} from "../agent/providers/omp/agent.js";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { createRealProviderClients, getRealProviderConfig } from "./real-provider-test-config.js";
const execFileAsync = promisify(execFile);
const TIMEOUT_MS = 300_000;
const MODEL = getRealProviderConfig("omp").model;
const SHELL_TOOL_PATTERN = /(?:bash|shell|%!)/i;
const roots = new Set<string>();
interface Harness {
daemon: TestPaseoDaemon;
client: DaemonClient;
cwd: string;
paseoHomeRoot: string;
staticDir: string;
}
async function preflight(): Promise<void> {
const command = process.env.OMP_COMMAND?.trim() || "omp";
let stdout: string;
try {
({ stdout } = await execFileAsync(command, ["--version"], {
timeout: 10_000,
}));
} catch (error) {
throw new Error(`OMP real E2E gate requires an executable OMP binary (${command})`, {
cause: error,
});
}
const support = formatOmpVersionSupport(stdout);
if (!support.includes("supported")) {
throw new Error(`OMP real E2E gate requires OMP >= ${MIN_SUPPORTED_OMP_VERSION}: ${support}`);
}
}
async function createHarness(): Promise<Harness> {
const cwd = mkdtempSync(path.join(tmpdir(), "paseo-real-omp-"));
const paseoHomeRoot = mkdtempSync(path.join(tmpdir(), "paseo-real-omp-home-"));
const staticDir = mkdtempSync(path.join(tmpdir(), "paseo-real-omp-static-"));
for (const root of [cwd, paseoHomeRoot, staticDir]) roots.add(root);
const logger = pino({ level: process.env.OMP_E2E_LOG_LEVEL ?? "silent" });
const daemon = await createTestPaseoDaemon({
agentClients: createRealProviderClients(["omp"], logger),
providerOverrides: { omp: { enabled: true } },
logger,
paseoHomeRoot,
staticDir,
cleanup: false,
});
const client = new DaemonClient({
url: `ws://127.0.0.1:${daemon.port}/ws`,
appVersion: "0.1.45",
});
await client.connect();
await client.fetchAgents({
subscribe: { subscriptionId: `omp-real-${randomUUID()}` },
});
return { daemon, client, cwd, paseoHomeRoot, staticDir };
}
async function closeHarness(harness: Harness): Promise<void> {
await harness.client.close().catch(() => undefined);
await harness.daemon.close().catch(() => undefined);
for (const root of [harness.cwd, harness.paseoHomeRoot, harness.staticDir]) {
rmSync(root, { recursive: true, force: true });
roots.delete(root);
}
}
async function timeline(client: DaemonClient, agentId: string): Promise<AgentTimelineItem[]> {
const result = await client.fetchAgentTimeline(agentId, {
direction: "tail",
limit: 0,
projection: "canonical",
});
return result.entries.map((entry) => entry.item);
}
function latestTools(items: AgentTimelineItem[]) {
const latest = new Map<string, Extract<AgentTimelineItem, { type: "tool_call" }>>();
for (const item of items) if (item.type === "tool_call") latest.set(item.callId, item);
return [...latest.values()];
}
function toolResult(item: Extract<AgentTimelineItem, { type: "tool_call" }>): string {
if (item.detail.type === "shell") return item.detail.output ?? "";
if (item.detail.type === "fetch") return item.detail.result ?? "";
if (item.detail.type === "plain_text") return item.detail.text ?? "";
return "";
}
function completedTools(items: AgentTimelineItem[], pattern: RegExp) {
return latestTools(items).filter(
(item) => pattern.test(item.name) && item.status === "completed",
);
}
async function createAgent(harness: Harness, title: string, modeId = "full") {
return await harness.client.createAgent({
cwd: harness.cwd,
title,
provider: "omp",
model: MODEL,
modeId,
});
}
async function promptAndFinish(harness: Harness, agentId: string, prompt: string) {
await harness.client.sendMessage(agentId, prompt);
const finish = await harness.client.waitForFinish(agentId, TIMEOUT_MS);
expect(finish.status).toBe("idle");
const items = await timeline(harness.client, agentId);
expect(items.some((item) => item.type === "error")).toBe(false);
expect(latestTools(items).every((item) => item.status !== "running")).toBe(true);
return items;
}
async function waitForProviderSubagent(
client: DaemonClient,
parentAgentId: string,
status: "running" | "completed",
): Promise<Awaited<ReturnType<DaemonClient["listProviderSubagents"]>>["subagents"][number]> {
return await new Promise((resolve, reject) => {
let settled = false;
const finish = (
subagent: Awaited<ReturnType<DaemonClient["listProviderSubagents"]>>["subagents"][number],
): void => {
if (settled) return;
settled = true;
clearTimeout(timeout);
unsubscribe();
resolve(subagent);
};
const unsubscribe = client.on("agent.provider_subagents.update", (message) => {
if (
message.payload.kind === "upsert" &&
message.payload.subagent.parentAgentId === parentAgentId &&
message.payload.subagent.status === status
) {
finish(message.payload.subagent);
}
});
const timeout = setTimeout(() => {
if (settled) return;
settled = true;
unsubscribe();
reject(new Error(`Timed out waiting for OMP provider subagent status ${status}`));
}, TIMEOUT_MS);
void client
.listProviderSubagents(parentAgentId)
.then(({ subagents }) => {
const match = subagents.find((subagent) => subagent.status === status);
if (match) finish(match);
return undefined;
})
.catch((error: unknown) => {
if (settled) return undefined;
settled = true;
clearTimeout(timeout);
unsubscribe();
reject(error);
return undefined;
});
});
}
beforeAll(preflight, 15_000);
afterEach(() => {
for (const root of roots) rmSync(root, { recursive: true, force: true });
roots.clear();
});
describe("daemon E2E (real OMP)", () => {
test(
"prompt, native tool, and resumed follow-up",
async () => {
const harness = await createHarness();
try {
const agent = await createAgent(harness, "prompt-tool-resume");
await promptAndFinish(
harness,
agent.id,
"Run exactly this bash command: printf 'OMP_FIRST\\n'",
);
const items = await promptAndFinish(harness, agent.id, "Reply exactly OMP_RESUMED");
const outputs = completedTools(items, SHELL_TOOL_PATTERN).map(toolResult);
expect(outputs).toEqual(expect.arrayContaining([expect.stringContaining("OMP_FIRST")]));
const assistantText = items
.filter((item) => item.type === "assistant_message")
.map((item) => item.text);
expect(assistantText).toEqual(
expect.arrayContaining([expect.stringContaining("OMP_RESUMED")]),
);
} finally {
await closeHarness(harness);
}
},
TIMEOUT_MS,
);
test(
"ask mode preserves multiline approval details",
async () => {
const harness = await createHarness();
try {
const agent = await createAgent(harness, "multiline-approval", "ask");
await harness.client.sendMessage(
agent.id,
"Run exactly this bash command: printf 'line one\\nline two\\n'",
);
const pending = await harness.client.waitForAgentUpsert(
agent.id,
(snapshot) => snapshot.pendingPermissions.length > 0,
TIMEOUT_MS,
);
const permission = pending.pendingPermissions[0];
if (permission.detail?.type !== "shell") {
throw new Error("Expected OMP shell permission detail");
}
expect(permission.detail.command).toContain("line one");
expect(permission.detail.command).toContain("line two");
await harness.client.respondToPermission(agent.id, permission.id, {
behavior: "allow",
});
expect((await harness.client.waitForFinish(agent.id, TIMEOUT_MS)).status).toBe("idle");
const output = completedTools(
await timeline(harness.client, agent.id),
SHELL_TOOL_PATTERN,
).map(toolResult);
expect(output.join("\n")).toContain("line one");
expect(output.join("\n")).toContain("line two");
} finally {
await closeHarness(harness);
}
},
TIMEOUT_MS,
);
test(
"active steer and interrupt",
async () => {
const harness = await createHarness();
try {
const agent = await createAgent(harness, "interrupt-steer");
await harness.client.sendMessage(
agent.id,
"Run exactly this bash command: sleep 30; printf 'SHOULD_NOT_FINISH\\n'",
);
await harness.client.waitForAgentUpsert(
agent.id,
(snapshot) => snapshot.status === "running",
TIMEOUT_MS,
);
await harness.client.sendMessage(
agent.id,
"/steer stop sleeping and run exactly this bash command: printf 'OMP_ACTIVE_STEER\\n'",
);
expect((await harness.client.waitForFinish(agent.id, TIMEOUT_MS)).status).toBe("idle");
let items = await timeline(harness.client, agent.id);
expect(completedTools(items, SHELL_TOOL_PATTERN).map(toolResult).join("\n")).toContain(
"OMP_ACTIVE_STEER",
);
await harness.client.sendMessage(
agent.id,
"Run exactly this bash command: sleep 30; printf 'LATE\\n'",
);
await harness.client.waitForAgentUpsert(
agent.id,
(snapshot) => snapshot.status === "running",
TIMEOUT_MS,
);
await harness.client.cancelAgent(agent.id);
expect((await harness.client.waitForFinish(agent.id, TIMEOUT_MS)).status).not.toBe(
"running",
);
items = await timeline(harness.client, agent.id);
expect(
completedTools(items, SHELL_TOOL_PATTERN).some((call) =>
toolResult(call).includes("LATE"),
),
).toBe(false);
} finally {
await closeHarness(harness);
}
},
TIMEOUT_MS,
);
test(
"todo lifecycle",
async () => {
const harness = await createHarness();
try {
const agent = await createAgent(harness, "todo-lifecycle");
const items = await promptAndFinish(
harness,
agent.id,
"Create exactly these two todos in order: verify first child, verify second child. Complete both, without spawning a task.",
);
const snapshots = items.filter((item) => item.type === "todo").map((item) => item.items);
const hasFreshPair = (snapshot: { completed: boolean }[]): boolean =>
snapshot.length === 2 && snapshot.every((entry) => !entry.completed);
expect(snapshots.some(hasFreshPair)).toBe(true);
expect(snapshots.at(-1)).toEqual([
{ text: "verify first child", completed: true },
{ text: "verify second child", completed: true },
]);
} finally {
await closeHarness(harness);
}
},
TIMEOUT_MS,
);
test(
"subagent lifecycle streams descriptors and timeline on the parent",
async () => {
const harness = await createHarness();
try {
const parent = await createAgent(harness, "subagent-track");
const run = harness.client.sendMessage(
parent.id,
"Use task to create exactly one child named TrackChild. It must run exactly this bash command: printf 'TRACK_CHILD_OK\\n'. Wait for it.",
);
const started = await waitForProviderSubagent(harness.client, parent.id, "running");
expect(started.parentAgentId).toBe(parent.id);
await run;
expect((await harness.client.waitForFinish(parent.id, TIMEOUT_MS)).status).toBe("idle");
const { subagents } = await harness.client.listProviderSubagents(parent.id);
expect(subagents).toHaveLength(1);
expect(subagents[0].status).toBe("completed");
const childTimeline = await harness.client.fetchProviderSubagentTimeline(
parent.id,
subagents[0].id,
{
direction: "tail",
limit: 0,
},
);
expect(childTimeline.rows.length).toBeGreaterThan(0);
const childToolOutput = childTimeline.rows
.map((row) => row.item)
.filter((item) => item.type === "tool_call")
.map(toolResult)
.join("\n");
expect(childToolOutput).toContain("TRACK_CHILD_OK");
} finally {
await closeHarness(harness);
}
},
TIMEOUT_MS,
);
test(
"a completed child imports as a normal promptable OMP agent",
async () => {
const harness = await createHarness();
try {
const parent = await createAgent(harness, "child-import");
await promptAndFinish(
harness,
parent.id,
"Use task to create exactly one child named ImportChild. It must run exactly this bash command: printf 'IMPORT_CHILD_DONE\\n'. Wait for it.",
);
const completedChild = await waitForProviderSubagent(
harness.client,
parent.id,
"completed",
);
expect(completedChild.parentAgentId).toBe(parent.id);
const recent = await harness.client.fetchRecentProviderSessions({
cwd: harness.cwd,
providers: ["omp"],
});
const childSession = recent.entries.find(
(entry) =>
entry.providerId === "omp" &&
(path.basename(entry.providerHandleId) === `${completedChild.id}.jsonl` ||
entry.title?.includes("ImportChild") ||
entry.firstPromptPreview?.includes("ImportChild") ||
entry.lastPromptPreview?.includes("IMPORT_CHILD_DONE")),
);
if (!childSession) {
throw new Error("Expected completed OMP child session to be importable");
}
const imported = await harness.client.importAgent({
provider: "omp",
sessionId: childSession.providerHandleId,
cwd: harness.cwd,
});
expect(imported.id).not.toBe(parent.id);
const items = await promptAndFinish(
harness,
imported.id,
"Run exactly this bash command: printf 'IMPORTED_CHILD_RESUMED\\n'",
);
expect(completedTools(items, SHELL_TOOL_PATTERN).map(toolResult).join("\n")).toContain(
"IMPORTED_CHILD_RESUMED",
);
} finally {
await closeHarness(harness);
}
},
TIMEOUT_MS,
);
test(
"native Paseo host tools execute through RPC-UI",
async () => {
const harness = await createHarness();
try {
const agent = await createAgent(harness, "native-host-tool");
const items = await promptAndFinish(
harness,
agent.id,
"Use the Paseo host tool list_agents exactly once. Find the agent titled native-host-tool in the result, then reply exactly HOST_TOOL_OK:<its id>.",
);
const hostTools = completedTools(items, /^list_agents$/i);
expect(hostTools).toHaveLength(1);
expect(
items
.filter((item) => item.type === "assistant_message")
.map((item) => item.text.trim())
.join("")
.replace(/\s/g, ""),
).toContain(`HOST_TOOL_OK:${agent.id}`);
} finally {
await closeHarness(harness);
}
},
TIMEOUT_MS,
);
test(
"native branch rewinds conversation history and keeps the session usable",
async () => {
const harness = await createHarness();
try {
const agent = await createAgent(harness, "native-branch");
await promptAndFinish(harness, agent.id, "Reply exactly OMP_BRANCH_FIRST");
await promptAndFinish(harness, agent.id, "Reply exactly OMP_BRANCH_REMOVED");
const before = await timeline(harness.client, agent.id);
const firstMessage = before.find(
(item) => item.type === "user_message" && item.text.includes("OMP_BRANCH_FIRST"),
);
if (firstMessage?.type !== "user_message" || !firstMessage.messageId) {
throw new Error("Expected the first OMP prompt to have a provider message id");
}
await harness.client.rewindAgent(agent.id, firstMessage.messageId, "conversation");
const after = await timeline(harness.client, agent.id);
expect(
after.some(
(item) => item.type === "user_message" && item.text.includes("OMP_BRANCH_REMOVED"),
),
).toBe(false);
const continued = await promptAndFinish(
harness,
agent.id,
"Reply exactly OMP_BRANCH_CONTINUED",
);
expect(
continued
.filter((item) => item.type === "assistant_message")
.map((item) => item.text)
.join(""),
).toContain("OMP_BRANCH_CONTINUED");
} finally {
await closeHarness(harness);
}
},
TIMEOUT_MS,
);
});

View File

@@ -24,7 +24,11 @@ process.env.PASEO_SUPERVISED = "0";
const PI_TEST_TIMEOUT_MS = 240_000;
const PI_REAL_TEST_MODEL = getRealProviderConfig("pi").model;
const PI_FREE_COMPACTION_TEST_MODEL = "openrouter/openai/gpt-oss-20b:free";
const PI_COMPACTION_TEST_MODEL = PI_REAL_TEST_MODEL.startsWith("openai-codex/")
? "openai-codex/gpt-5.4-mini"
: PI_REAL_TEST_MODEL;
const PI_COMPACTION_RESERVE_TOKENS =
PI_COMPACTION_TEST_MODEL === PI_REAL_TEST_MODEL ? 126_000 : 270_000;
type ToolCallItem = Extract<AgentTimelineItem, { type: "tool_call" }>;
@@ -146,7 +150,7 @@ test(
cwd,
title: "pi-compact-commands",
provider: "pi",
model: PI_FREE_COMPACTION_TEST_MODEL,
model: PI_REAL_TEST_MODEL,
});
const result = await client.listCommands({ agentId: agent.id });
@@ -186,7 +190,7 @@ test(
cwd,
title: "pi-manual-compact",
provider: "pi",
model: PI_FREE_COMPACTION_TEST_MODEL,
model: PI_COMPACTION_TEST_MODEL,
});
await client.sendMessage(agent.id, "Reply exactly: compact-ready");
@@ -219,10 +223,11 @@ test(
items.some((item) => item.type === "user_message" && item.text.includes("/compact")),
).toBe(false);
expect(
items.some(
(item) => item.type === "assistant_message" && item.text.includes("Failed to compact"),
),
).toBe(false);
items
.filter((item) => item.type === "assistant_message")
.map((item) => item.text)
.filter((text) => text.includes("Failed to compact")),
).toEqual([]);
});
} finally {
rmSync(cwd, { recursive: true, force: true });
@@ -242,7 +247,7 @@ test(
cwd,
title: "pi-autocompact-toggle",
provider: "pi",
model: PI_FREE_COMPACTION_TEST_MODEL,
model: PI_COMPACTION_TEST_MODEL,
});
await client.sendMessage(agent.id, "/autocompact off");
@@ -279,7 +284,7 @@ test(
try {
writePiCompactionSettings(cwd, {
enabled: true,
reserveTokens: 126_000,
reserveTokens: PI_COMPACTION_RESERVE_TOKENS,
keepRecentTokens: 1,
});
@@ -288,7 +293,7 @@ test(
cwd,
title: "pi-auto-compact",
provider: "pi",
model: PI_FREE_COMPACTION_TEST_MODEL,
model: PI_COMPACTION_TEST_MODEL,
});
await client.sendMessage(agent.id, "Reply exactly: auto-compact-ready");
@@ -524,7 +529,7 @@ test(
await client.sendMessage(
agent.id,
"Think step by step about what 7 * 13 equals, and give the final answer at the end.",
"Work out 37 * 43 carefully, then answer with only the number.",
);
const finish = await client.waitForFinish(agent.id, PI_TEST_TIMEOUT_MS);
@@ -535,7 +540,14 @@ test(
(item): item is Extract<AgentTimelineItem, { type: "reasoning" }> =>
item.type === "reasoning" && item.text.trim().length > 0,
);
const assistantTexts = items
.filter(
(item): item is Extract<AgentTimelineItem, { type: "assistant_message" }> =>
item.type === "assistant_message",
)
.map((item) => item.text);
expect(assistantTexts.join("\n")).toContain("1591");
expect(reasoningItems.length).toBeGreaterThan(0);
});
} finally {

View File

@@ -1,6 +1,6 @@
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { existsSync, mkdtempSync, readFileSync } from "node:fs";
import { homedir, tmpdir } from "node:os";
import path, { join } from "node:path";
import type { Logger } from "pino";
@@ -9,10 +9,11 @@ import type { ProviderRuntimeSettings } from "../agent/provider-launch-config.js
import { ClaudeAgentClient } from "../agent/providers/claude/agent.js";
import { CodexAppServerAgentClient } from "../agent/providers/codex-app-server-agent.js";
import { OpenCodeAgentClient } from "../agent/providers/opencode-agent.js";
import { OmpAgentClient } from "../agent/providers/omp/agent.js";
import { PiRpcAgentClient } from "../agent/providers/pi/agent.js";
import { isCommandAvailable } from "../../executable-resolution/executable-resolution.js";
export const realProviders = ["claude", "codex", "opencode", "pi"] as const;
export const realProviders = ["claude", "codex", "opencode", "pi", "omp"] as const;
export type RealProvider = (typeof realProviders)[number];
export type RealProviderConfig = Pick<
AgentSessionConfig,
@@ -21,10 +22,15 @@ export type RealProviderConfig = Pick<
const OPENROUTER_BASE_URL = "https://openrouter.ai/api";
const OPENROUTER_OPENAI_BASE_URL = "https://openrouter.ai/api";
const PI_AUTH_CONFIG_PATH = join(homedir(), ".pi", "agent", "auth.json");
const CODEX_AUTH_CONFIG_PATH = join(homedir(), ".codex", "auth.json");
const CLAUDE_REAL_TEST_MODEL = "haiku";
const CODEX_REAL_TEST_MODEL = "~openai/gpt-latest";
const OPENCODE_REAL_TEST_MODEL = "openrouter/google/gemini-2.5-flash-lite";
const PI_REAL_TEST_MODEL = "openrouter/google/gemini-2.5-flash-lite";
const PI_OPENROUTER_REAL_TEST_MODEL = "openrouter/google/gemini-2.5-flash-lite";
const PI_CODEX_REAL_TEST_MODEL = "openai-codex/gpt-5.4";
const OMP_OPENROUTER_REAL_TEST_MODEL = "openrouter/google/gemini-2.5-flash-lite";
const OMP_CODEX_REAL_TEST_MODEL = "openai-codex/gpt-5.6-sol";
const availabilityCache = new Map<RealProvider, Promise<boolean>>();
@@ -52,13 +58,40 @@ export function getRealProviderConfig(provider: RealProvider): RealProviderConfi
case "pi":
return {
provider,
model: PI_REAL_TEST_MODEL,
model: getPiRealTestModel(),
thinkingOptionId: "medium",
};
case "omp":
return {
provider,
model: getOmpRealTestModel(),
thinkingOptionId: "medium",
modeId: "full",
};
}
}
export function getRealProviderRuntimeSettings(provider: RealProvider): ProviderRuntimeSettings {
if (provider === "omp" || provider === "pi") {
if (hasCodexAuthTokens()) {
return {
env: {
// Clear stale shell-level keys so the local CLI auth stores win.
OPENAI_API_KEY: "",
},
};
}
const apiKey = getOpenRouterApiKeyOrNull();
if (apiKey) {
return {
env: {
OPENROUTER_API_KEY: apiKey,
OPENAI_API_KEY: "",
},
};
}
return {};
}
const apiKey = getOpenRouterApiKey();
switch (provider) {
case "claude":
@@ -95,11 +128,7 @@ export function getRealProviderRuntimeSettings(provider: RealProvider): Provider
};
}
case "pi":
return {
env: {
OPENROUTER_API_KEY: apiKey,
},
};
return {};
}
}
@@ -120,6 +149,8 @@ export function createRealProviderClient(provider: RealProvider, logger: Logger)
return new OpenCodeAgentClient(logger, runtimeSettings);
case "pi":
return new PiRpcAgentClient({ logger, runtimeSettings });
case "omp":
return new OmpAgentClient({ logger, runtimeSettings });
}
}
@@ -139,7 +170,7 @@ export function canRunRealProvider(provider: RealProvider): Promise<boolean> {
}
const availability = (async () => {
if (!getOpenRouterApiKeyOrNull()) {
if (provider !== "omp" && provider !== "pi" && !getOpenRouterApiKeyOrNull()) {
return false;
}
return await isCommandAvailable(getProviderBinary(provider));
@@ -149,6 +180,22 @@ export function canRunRealProvider(provider: RealProvider): Promise<boolean> {
return availability;
}
function getOmpRealTestModel(): string {
const configured = process.env.OMP_REAL_TEST_MODEL?.trim();
if (configured) {
return configured;
}
return hasCodexAuthTokens() ? OMP_CODEX_REAL_TEST_MODEL : OMP_OPENROUTER_REAL_TEST_MODEL;
}
function getPiRealTestModel(): string {
const configured = process.env.PI_REAL_TEST_MODEL?.trim();
if (configured) {
return configured;
}
return hasCodexAuthTokens() ? PI_CODEX_REAL_TEST_MODEL : PI_OPENROUTER_REAL_TEST_MODEL;
}
function getOpenRouterApiKey(): string {
const apiKey = getOpenRouterApiKeyOrNull();
if (!apiKey) {
@@ -158,13 +205,52 @@ function getOpenRouterApiKey(): string {
}
function getOpenRouterApiKeyOrNull(): string | null {
const value = process.env.OPENROUTER_API_KEY?.trim();
const value = readPiOpenRouterApiKey() ?? process.env.OPENROUTER_API_KEY?.trim();
return value && value.length > 0 ? value : null;
}
function readPiOpenRouterApiKey(): string | null {
const auth = readJsonFile(PI_AUTH_CONFIG_PATH);
const value =
auth && typeof auth === "object" && "openrouter" in auth ? auth.openrouter : undefined;
if (!value || typeof value !== "object" || value === null || !("key" in value)) {
return null;
}
return typeof value.key === "string" && value.key.trim().length > 0 ? value.key.trim() : null;
}
function hasCodexAuthTokens(): boolean {
const auth = readJsonFile(CODEX_AUTH_CONFIG_PATH);
if (!auth || typeof auth !== "object" || !("tokens" in auth)) {
return false;
}
const tokens = auth.tokens;
if (!tokens || typeof tokens !== "object") {
return false;
}
return (
(typeof tokens.access_token === "string" && tokens.access_token.length > 0) ||
(typeof tokens.refresh_token === "string" && tokens.refresh_token.length > 0)
);
}
function readJsonFile(filePath: string): unknown {
if (!existsSync(filePath)) {
return null;
}
try {
return JSON.parse(readFileSync(filePath, "utf8")) as unknown;
} catch {
return null;
}
}
function getProviderBinary(provider: RealProvider): string {
if (provider === "pi") {
return process.env.PI_COMMAND ?? process.env.PI_ACP_PI_COMMAND ?? "pi";
}
if (provider === "omp") {
return process.env.OMP_COMMAND ?? "omp";
}
return provider;
}