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