diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index 182690d6a0..8662ee3287 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -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 diff --git a/packages/server/src/server/agent/providers/omp/agent.history-purpose.test.ts b/packages/server/src/server/agent/providers/omp/agent.history-purpose.test.ts new file mode 100644 index 0000000000..ddd30a2590 --- /dev/null +++ b/packages/server/src/server/agent/providers/omp/agent.history-purpose.test.ts @@ -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 }], + }, + }); +}); diff --git a/packages/server/src/server/agent/providers/omp/agent.test.ts b/packages/server/src/server/agent/providers/omp/agent.test.ts index 665dfee5ad..6858ed216d 100644 --- a/packages/server/src/server/agent/providers/omp/agent.test.ts +++ b/packages/server/src/server/agent/providers/omp/agent.test.ts @@ -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([ "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 () => { diff --git a/packages/server/src/server/agent/providers/omp/agent.ts b/packages/server/src/server/agent/providers/omp/agent.ts index 5555fa130e..8e931b55fd 100644 --- a/packages/server/src/server/agent/providers/omp/agent.ts +++ b/packages/server/src/server/agent/providers/omp/agent.ts @@ -19,6 +19,7 @@ import { type AgentPersistenceHandle, type AgentPromptInput, type AgentProvider, + type AgentResumeSessionOptions, type AgentRunOptions, type AgentRunResult, type AgentRuntimeInfo, @@ -34,6 +35,8 @@ import { type ListImportableSessionsOptions, type ProviderCatalog, type ProviderRefreshContext, + type SteerActiveTurnOptions, + type SteerResult, type ToolCallDetail, } from "../../agent-sdk-types.js"; import type { PaseoToolCatalog } from "../../tools/types.js"; @@ -67,13 +70,18 @@ import { } from "./provider-config.js"; export { formatOmpVersionSupport, resolveOmpDiagnosticPaths } from "./provider-config.js"; import { OmpSubagentCardTracker, type OmpSubagentCardScheduler } from "./subagent-card-tracker.js"; -import { shouldDisplayOmpCustomMessage } from "./custom-message.js"; +import { + ompCustomMessageId, + ompSkillPromptUserText, + shouldDisplayOmpCustomMessage, +} from "./custom-message.js"; import { getUserMessageText } from "./message-history.js"; import { mapOmpSystemNoticeToNotification } from "./system-notice.js"; import { materializeProviderImage } from "../provider-image-output.js"; import { OmpCliRuntime } from "./cli-runtime.js"; import { listOmpImportableSessions, readOmpImportSessionConfig } from "./session-descriptor.js"; import type { OmpRuntime, OmpRuntimeSession, OmpStartSessionInput } from "./runtime.js"; +import { OmpMcpBridge, type OmpBridgedToolIdentity } from "./mcp-bridge.js"; import type { OmpAgentSessionEvent, OmpAgentMessage, @@ -86,21 +94,20 @@ import type { import { parseToolArgs, parseToolResult, + toolFailureMessage, + isOmpToolFailure, resolveToolCallName, type OmpToolResult, type OmpTrackedToolCall, } from "./tool-call-detail.js"; import { mapOmpAvailableCommandsUpdate, mapOmpRuntimeSlashCommands } from "./commands.js"; -import { streamOmpHistory } from "./history.js"; +import { readOmpHistoryTodoState, streamOmpHistory } from "./history.js"; import { mapOmpTodoReminderEvent, mapOmpTodoState, mapOmpTodoToolResult } from "./todo-mapper.js"; import { mapOmpRuntimeEventToTimelineItem } from "./event-mapper.js"; import { mapOmpAdvisorMessageToToolCall } from "./advisor-message.js"; -import { - clearOmpHostToolState, - handleOmpHostToolRuntimeEvent, - setOmpHostTools, -} from "./host-tools.js"; +import { handleOmpHostToolRuntimeEvent, OmpHostToolRouter } from "./host-tools.js"; import { OmpSubagentIndex } from "./subagent-index.js"; +import { OmpQuestionUi } from "./question-ui.js"; import { mapOmpToolDetail } from "./tool-call-mapper.js"; import { OmpUsagePoller, type OmpUsagePollScheduler } from "./usage-poller.js"; import { @@ -111,17 +118,12 @@ import { DEFAULT_OMP_THINKING_LEVEL, mapOmpModel } from "./map-omp-model.js"; import { resolveOmpUsageReference } from "./usage-reference.js"; const OMP_PROVIDER = "omp"; -const QUESTION_RESPONSE_HEADER = "Response"; -const QUESTION_COMMENT_HEADER = "Comment"; -const OMP_ASK_USER_FREEFORM_SENTINEL = "✏️ Type custom response..."; -const COMBINED_ASK_USER_METADATA = "ask_user_select_optional_comment"; - const OMP_CORE_CAPABILITIES: AgentCapabilityFlags = { supportsStreaming: true, supportsSessionPersistence: true, supportsSessionListing: true, supportsDynamicModes: true, - supportsMcpServers: false, + supportsMcpServers: true, supportsReasoningStream: true, supportsToolInvocations: true, supportsRewindConversation: true, @@ -138,6 +140,7 @@ export interface OmpAgentClientOptions { providerIdleScheduler?: OmpProviderIdleScheduler; noTurnScheduler?: OmpNoTurnScheduler; usagePollScheduler?: OmpUsagePollScheduler; + providerIdleDeadlineMs?: number; } export interface OmpProviderIdleScheduler { @@ -153,6 +156,8 @@ export interface OmpNoTurnScheduler { // v0.2.0-beta.1; remove after January 20, 2027 once the minimum OMP version // guarantees prompt_result waits for queued extension work. const OMP_NO_TURN_SETTLE_MS = 5_000; +const OMP_PROVIDER_IDLE_DEADLINE_MS = 600_000; +const OMP_FAST_INACTIVE_MESSAGE = "Fast is enabled but does not apply to this model."; interface OmpPromptPayload { text: string; @@ -170,6 +175,7 @@ interface OmpPersistenceMetadata { thinkingOptionId?: string; modeId?: string; systemPrompt?: string; + bridgedTools: Map; } interface StartTurnResult { @@ -178,6 +184,14 @@ interface StartTurnResult { interface OmpAgentSessionOptions { runtimeSession: OmpRuntimeSession; + hostTools?: OmpHostToolRouter; + restartRuntime: ( + sessionFile: string | null, + modeId: string, + ) => Promise<{ + runtimeSession: OmpRuntimeSession; + hostTools?: OmpHostToolRouter; + }>; config: AgentSessionConfig; initialState: OmpSessionState; currentModeId?: string | null; @@ -186,7 +200,7 @@ interface OmpAgentSessionOptions { providerIdleScheduler?: OmpProviderIdleScheduler; noTurnScheduler?: OmpNoTurnScheduler; usagePollScheduler?: OmpUsagePollScheduler; - paseoTools?: PaseoToolCatalog; + providerIdleDeadlineMs?: number; usageEnv?: NodeJS.ProcessEnv; /** * When false (resumed sessions), replayed session events are dropped until @@ -220,24 +234,6 @@ interface OmpResumeConfig { config: AgentSessionConfig; } -interface ActiveAskUserDialog { - allowComment: boolean; - allowFreeform: boolean; - allowMultiple: boolean; -} - -interface PendingCombinedAskUserResponse { - comment: string; - freeform: string | null; -} - -interface ExtensionUiMappingOptions { - provider?: AgentProvider; - label?: string; - combineOptionalComment?: boolean; - allowFreeform?: boolean; -} - interface OmpSlashCommandInvocation { commandName: string; args?: string; @@ -283,7 +279,8 @@ function isOmpThinkingLevel(value: string | null | undefined): value is OmpThink value === "medium" || value === "high" || value === "xhigh" || - value === "max" + value === "max" || + value === "auto" ); } @@ -390,9 +387,20 @@ function parseModelReference(modelId: string | null): OmpModelReference | null { function parsePersistenceMetadata(metadata: AgentMetadata | undefined): OmpPersistenceMetadata { if (!metadata) { - return {}; + return { bridgedTools: new Map() }; + } + const bridgedTools = new Map(); + if (Array.isArray(metadata.bridgedTools)) { + for (const entry of metadata.bridgedTools) { + if (!Array.isArray(entry) || entry.length !== 2 || typeof entry[0] !== "string") continue; + const identity = entry[1]; + if (!identity || typeof identity !== "object" || Array.isArray(identity)) continue; + if (typeof identity.server !== "string" || typeof identity.tool !== "string") continue; + bridgedTools.set(entry[0], { server: identity.server, tool: identity.tool }); + } } return { + bridgedTools, ...(typeof metadata.cwd === "string" ? { cwd: metadata.cwd } : {}), ...(typeof metadata.model === "string" ? { model: metadata.model } : {}), ...(typeof metadata.thinkingOptionId === "string" @@ -464,7 +472,7 @@ function readNativeMessageId( function withOmpCapabilities(): AgentCapabilityFlags { return { ...OMP_CORE_CAPABILITIES, - supportsMcpServers: false, + supportsMcpServers: true, supportsNativePaseoTools: true, }; } @@ -477,6 +485,20 @@ function isOmpRequestAbortError(error: unknown): boolean { return /\brequest was aborted\b|\babort(ed)?\b/i.test(toDiagnosticErrorMessage(error)); } +function isOmpSteerTransportFailure(error: unknown): boolean { + // JsonlRpcProcess currently uses plain Error for both RPC rejections and transport failures. + // Only its own timeout/closed-process messages are transport failures; OMP rejections are unavailable. + const message = toDiagnosticErrorMessage(error); + return ( + /^OMP RPC request timed out phase=steer\b/.test(message) || + /^OMP RPC process (?:is closed|exited\b)/.test(message) + ); +} + +function hasOmpFastMode(state: OmpSessionState): boolean { + return typeof state.fastModeEnabled === "boolean" && typeof state.fastModeActive === "boolean"; +} + function resolveThinkingOptionId( cachedThinkingOptionId: string | null, sessionThinkingLevel: OmpThinkingLevel | undefined, @@ -533,134 +555,10 @@ function isOmpAbortedTerminalResponse(messages: OmpAgentMessage[]): boolean { return latestAssistant?.stopReason?.toLowerCase() === "aborted"; } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function optionalString(value: unknown): string | undefined { return typeof value === "string" ? value : undefined; } -function optionalBoolean(value: unknown): boolean | undefined { - return typeof value === "boolean" ? value : undefined; -} - -function readActiveAskUserDialog(toolName: string, args: unknown): ActiveAskUserDialog | null { - if (toolName !== "ask_user" || !isRecord(args)) { - return null; - } - return { - allowComment: optionalBoolean(args.allowComment) ?? false, - allowFreeform: optionalBoolean(args.allowFreeform) ?? true, - allowMultiple: optionalBoolean(args.allowMultiple) ?? false, - }; -} - -function isOptionalInputPlaceholder(placeholder: string | undefined): boolean { - return /\boptional\b|\bskip\b/i.test(placeholder ?? ""); -} - -function getInputQuestionTitle(title: string | undefined, placeholder: string | undefined): string { - if (!isOptionalInputPlaceholder(placeholder)) { - return title ?? "Enter a value"; - } - if (/\bcomment\b/i.test(`${title ?? ""}\n${placeholder ?? ""}`)) { - return "Optional comment"; - } - return "Optional response"; -} - -interface OmpSelectOption { - label: string; - description?: string; -} - -function readSelectOptions(options: unknown, optionDetails: unknown): OmpSelectOption[] { - const labels = readStringArray(options); - const details = Array.isArray(optionDetails) ? optionDetails : []; - return labels.map((label, index) => { - const detail = details[index]; - const description = - isRecord(detail) && typeof detail.description === "string" && detail.description.trim() !== "" - ? detail.description - : undefined; - return description === undefined ? { label } : { label, description }; - }); -} - -function readStringArray(value: unknown): string[] { - return Array.isArray(value) - ? value.filter((item): item is string => typeof item === "string") - : []; -} - -function isOmpAskUserFreeformOption(option: string): boolean { - return option === OMP_ASK_USER_FREEFORM_SENTINEL; -} - -function mapExtensionUiRequestToPermission( - event: Extract, - options: ExtensionUiMappingOptions = {}, -): AgentPermissionRequest | null { - const provider = options.provider ?? OMP_PROVIDER; - const label = options.label ?? "OMP"; - switch (event.method) { - case "select": { - const selectOptions = readSelectOptions(event.options, event.optionDetails); - if (options.combineOptionalComment) { - return buildCombinedAskUserQuestionPermission(event, { - provider, - label, - question: optionalString(event.title) ?? "Select an option", - options: selectOptions, - allowFreeform: options.allowFreeform === true, - }); - } - return buildExtensionUiQuestionPermission(event, { - provider, - label, - question: optionalString(event.title) ?? "Select an option", - options: selectOptions, - multiSelect: false, - }); - } - case "input": { - const placeholder = optionalString(event.placeholder); - const title = optionalString(event.title); - const allowEmpty = isOptionalInputPlaceholder(placeholder); - return buildExtensionUiQuestionPermission(event, { - provider, - label, - question: getInputQuestionTitle(title, placeholder), - options: [], - multiSelect: false, - ...(placeholder ? { placeholder } : {}), - ...(allowEmpty ? { allowEmpty: true, dismissLabel: "Skip" } : {}), - }); - } - case "editor": - return buildExtensionUiQuestionPermission(event, { - provider, - label, - question: optionalString(event.title) ?? "Edit text", - options: [], - multiSelect: false, - }); - case "confirm": - return buildExtensionUiQuestionPermission(event, { - provider, - label, - question: [optionalString(event.title), optionalString(event.message)] - .filter(Boolean) - .join("\n\n"), - options: [{ label: "Yes" }, { label: "No" }], - multiSelect: false, - }); - default: - return null; - } -} - function isExtensionUiRequestEvent( event: OmpRuntimeEvent, ): event is Extract { @@ -692,172 +590,6 @@ function isOmpAgentSessionEvent(event: OmpRuntimeEvent): event is OmpAgentSessio } } -function buildExtensionUiQuestionPermission( - event: Extract, - input: { - provider: AgentProvider; - label: string; - question: string; - options: OmpSelectOption[]; - multiSelect: boolean; - placeholder?: string; - allowEmpty?: boolean; - dismissLabel?: string; - }, -): AgentPermissionRequest { - return { - id: event.id, - provider: input.provider, - name: `${input.label} ${event.method}`, - kind: "question", - title: input.question, - input: { - questions: [ - { - question: input.question, - header: QUESTION_RESPONSE_HEADER, - options: input.options.map((option) => ({ - label: option.label, - ...(option.description === undefined ? {} : { description: option.description }), - })), - multiSelect: input.multiSelect, - ...(input.placeholder ? { placeholder: input.placeholder } : {}), - ...(input.allowEmpty ? { allowEmpty: true } : {}), - ...(input.dismissLabel ? { dismissLabel: input.dismissLabel } : {}), - }, - ], - }, - metadata: { - extensionUiMethod: event.method, - answerHeader: QUESTION_RESPONSE_HEADER, - }, - }; -} - -function buildCombinedAskUserQuestionPermission( - event: Extract, - input: { - provider: AgentProvider; - label: string; - question: string; - options: OmpSelectOption[]; - allowFreeform: boolean; - }, -): AgentPermissionRequest { - const visibleOptions = input.options.filter( - (option) => !isOmpAskUserFreeformOption(option.label), - ); - const allowOther = input.allowFreeform || visibleOptions.length !== input.options.length; - return { - id: event.id, - provider: input.provider, - name: `${input.label} ask_user`, - kind: "question", - title: input.question, - input: { - questions: [ - { - question: input.question, - header: QUESTION_RESPONSE_HEADER, - options: visibleOptions.map((option) => ({ - label: option.label, - ...(option.description === undefined ? {} : { description: option.description }), - })), - multiSelect: false, - ...(allowOther ? { allowOther: true } : {}), - }, - { - question: "Optional comment", - header: QUESTION_COMMENT_HEADER, - options: [], - multiSelect: false, - placeholder: "Optional comment (press Enter to skip)...", - allowEmpty: true, - }, - ], - }, - metadata: { - extensionUiMethod: event.method, - answerHeader: QUESTION_RESPONSE_HEADER, - commentHeader: QUESTION_COMMENT_HEADER, - combinedAskUser: COMBINED_ASK_USER_METADATA, - selectOptions: visibleOptions.map((option) => option.label), - ...(allowOther ? { freeformSentinel: OMP_ASK_USER_FREEFORM_SENTINEL } : {}), - }, - }; -} - -function permissionAnswer(input: AgentMetadata | undefined, header: string): string | null { - const answers = isRecord(input?.answers) ? input.answers : null; - if (!answers) { - return null; - } - const answer = answers[header]; - return typeof answer === "string" ? answer : null; -} - -function firstPermissionAnswer(input: AgentMetadata | undefined): string | null { - const answers = isRecord(input?.answers) ? input.answers : null; - if (!answers) { - return null; - } - const first = Object.values(answers).find((value) => typeof value === "string"); - return typeof first === "string" ? first : null; -} - -function isCombinedAskUserPermission(request: AgentPermissionRequest): boolean { - return request.metadata?.combinedAskUser === COMBINED_ASK_USER_METADATA; -} - -function buildCombinedAskUserSelectionResponse( - request: AgentPermissionRequest, - response: AgentPermissionResponse, -): { - uiResponse: { value?: string; cancelled?: boolean }; - pendingResponse: PendingCombinedAskUserResponse | null; -} { - if (response.behavior === "deny") { - return { uiResponse: { cancelled: true }, pendingResponse: null }; - } - - const answer = permissionAnswer(response.updatedInput, QUESTION_RESPONSE_HEADER); - if (answer === null) { - return { uiResponse: { cancelled: true }, pendingResponse: null }; - } - - const selectOptions = readStringArray(request.metadata?.selectOptions); - const freeformSentinel = optionalString(request.metadata?.freeformSentinel); - const isFreeform = Boolean(freeformSentinel) && !selectOptions.includes(answer); - const comment = permissionAnswer(response.updatedInput, QUESTION_COMMENT_HEADER) ?? ""; - return { - uiResponse: { value: isFreeform ? freeformSentinel : answer }, - pendingResponse: { - comment, - freeform: isFreeform ? answer : null, - }, - }; -} - -function buildExtensionUiResponse( - request: AgentPermissionRequest, - response: AgentPermissionResponse, -): { value?: string; confirmed?: boolean; cancelled?: boolean } { - if (response.behavior === "deny") { - return { cancelled: true }; - } - - const method = optionalString(request.metadata?.extensionUiMethod); - const answer = firstPermissionAnswer(response.updatedInput); - if (answer === null) { - return { cancelled: true }; - } - - if (method === "confirm") { - return { confirmed: /^yes$/i.test(answer.trim()) }; - } - return { value: answer }; -} - function createRuntime( logger: Logger, runtimeSettings: ProviderRuntimeSettings | undefined, @@ -873,6 +605,85 @@ function createRuntime( }); } +class OmpHistorySession implements AgentSession { + readonly provider: AgentProvider; + readonly capabilities: AgentCapabilityFlags = withOmpCapabilities(); + + constructor( + private readonly handle: AgentPersistenceHandle, + private readonly config: OmpResumeConfig, + private readonly sessionFile: string, + provider: AgentProvider, + private readonly bridgedTools: ReadonlyMap, + ) { + this.provider = provider; + } + + get id(): string | null { + return this.handle.sessionId; + } + + async run(): Promise { + throw new Error("OMP history session cannot start a turn"); + } + + async startTurn(): Promise<{ turnId: string }> { + throw new Error("OMP history session cannot start a turn"); + } + + subscribe(): () => void { + return () => undefined; + } + + async *streamHistory(): AsyncGenerator { + yield* streamOmpHistory({ + sessionFile: this.sessionFile, + provider: this.provider, + bridgedTools: this.bridgedTools, + }); + const todo = await readOmpHistoryTodoState(this.sessionFile); + if (todo) yield { type: "timeline", provider: this.provider, item: todo }; + } + + async getRuntimeInfo(): Promise { + return { + provider: this.provider, + sessionId: this.handle.sessionId, + model: this.config.model ?? null, + thinkingOptionId: this.config.thinkingOptionId ?? null, + modeId: this.config.modeId ?? null, + }; + } + + async getAvailableModes(): Promise { + return [...OMP_MODES]; + } + + async getCurrentMode(): Promise { + return this.config.modeId ?? null; + } + + async setMode(): Promise { + throw new Error("OMP history session cannot change mode"); + } + + getPendingPermissions(): AgentPermissionRequest[] { + return []; + } + + async respondToPermission(): Promise { + throw new Error("OMP history session has no pending permissions"); + } + + describePersistence(): AgentPersistenceHandle { + return this.handle; + } + + async interrupt(): Promise {} + + async close(): Promise {} +} + export class OmpAgentSession implements AgentSession { readonly provider: AgentProvider = OMP_PROVIDER; readonly capabilities: AgentCapabilityFlags = withOmpCapabilities(); @@ -880,10 +691,10 @@ export class OmpAgentSession implements AgentSession { private readonly subscribers = new Set<(event: AgentStreamEvent) => void>(); private readonly activeToolCalls = new Map(); private readonly pendingExtensionUiRequests = new Map(); - private activeAskUserDialog: ActiveAskUserDialog | null = null; - private pendingCombinedAskUserResponse: PendingCombinedAskUserResponse | null = null; + private readonly questionUi = new OmpQuestionUi(); private activeTurnId: string | null = null; - private activeClientMessageId: string | null = null; + private readonly pendingClientMessages: Array<{ clientMessageId: string | null; text: string }> = + []; private activeAssistantMessageId: string | null = null; private activeTurnTerminalAssistantMessage: OmpAgentMessage | null = null; private activeTurnStarted = false; @@ -903,24 +714,31 @@ export class OmpAgentSession implements AgentSession { private readonly subagentCardTracker: OmpSubagentCardTracker; private lastTodoItem: Extract | null = null; private state: OmpSessionState; - private readonly currentModeId: string | null; + private currentModeId: string | null; + private runtimeDead = false; + private replacingRuntime: Promise | null = null; + private unsubscribeRuntime: (() => void) | null = null; private readonly providerIdleScheduler: OmpProviderIdleScheduler; + private readonly providerIdleDeadlineMs: number; private readonly noTurnScheduler: OmpNoTurnScheduler; private readonly usagePoller: OmpUsagePoller; private closed = false; private live: boolean; private readonly emittedUserMessageIds = new Set(); + private customMessageIndex = 0; constructor(options: OmpAgentSessionOptions) { this.runtimeSession = options.runtimeSession; + this.hostTools = options.hostTools; + this.restartRuntime = options.restartRuntime; this.config = options.config; this.state = options.initialState; this.currentModeId = options.currentModeId ?? null; this.logger = options.logger; - this.paseoTools = options.paseoTools; this.usageEnv = options.usageEnv ?? process.env; this.live = options.live ?? true; this.providerIdleScheduler = options.providerIdleScheduler ?? createOmpProviderIdleScheduler(); + this.providerIdleDeadlineMs = options.providerIdleDeadlineMs ?? OMP_PROVIDER_IDLE_DEADLINE_MS; this.noTurnScheduler = options.noTurnScheduler ?? createOmpNoTurnScheduler(); this.usagePoller = new OmpUsagePoller({ scheduler: options.usagePollScheduler, @@ -944,29 +762,29 @@ export class OmpAgentSession implements AgentSession { normalizeOmpThinkingOption(options.config.thinkingOptionId) ?? this.state.thinkingLevel ?? null; - this.runtimeSession.onEvent((event) => { - this.handleRuntimeEvent(event); + this.attachRuntime(this.runtimeSession); + } + + private attachRuntime(runtime: OmpRuntimeSession): void { + this.unsubscribeRuntime = runtime.onEvent((event) => { + if (!this.closed && runtime === this.runtimeSession) this.handleRuntimeEvent(event); }); - void this.runtimeSession.setSubagentSubscription("events").catch((eventsError: unknown) => { + void runtime.setSubagentSubscription("events").catch((eventsError: unknown) => { this.logger.debug( { err: eventsError }, "OMP subagent event subscription unavailable; falling back to progress", ); - void this.runtimeSession - .setSubagentSubscription("progress") - .catch((progressError: unknown) => { - this.logger.debug( - { err: progressError }, - "OMP subagent progress subscription unavailable", - ); - }); + void runtime.setSubagentSubscription("progress").catch((progressError: unknown) => { + this.logger.debug({ err: progressError }, "OMP subagent progress subscription unavailable"); + }); }); } - private readonly runtimeSession: OmpRuntimeSession; + private runtimeSession: OmpRuntimeSession; + private hostTools?: OmpHostToolRouter; + private readonly restartRuntime: OmpAgentSessionOptions["restartRuntime"]; private readonly config: AgentSessionConfig; private readonly logger: Logger; - private readonly paseoTools?: PaseoToolCatalog; private readonly usageEnv: NodeJS.ProcessEnv; async getUsageReference() { @@ -978,6 +796,35 @@ export class OmpAgentSession implements AgentSession { return this.state.sessionId; } + get features(): AgentFeature[] { + if (!hasOmpFastMode(this.state)) return []; + return [ + { + type: "toggle", + id: "fast_mode", + label: "Fast", + icon: "zap", + value: this.state.fastModeEnabled === true, + ...(this.state.fastModeEnabled && !this.state.fastModeActive + ? { description: OMP_FAST_INACTIVE_MESSAGE, tooltip: OMP_FAST_INACTIVE_MESSAGE } + : {}), + }, + ]; + } + + async setFeature(featureId: string, value: unknown): Promise { + if (featureId !== "fast_mode") throw new Error(`Unknown OMP feature: ${featureId}`); + if (typeof value !== "boolean") throw new Error("OMP fast mode requires a boolean"); + if (!hasOmpFastMode(this.state)) throw new Error("OMP fast mode is unavailable"); + const result = await this.runtimeSession.setFastMode(value); + this.state = { + ...this.state, + fastModeEnabled: result.enabled, + fastModeActive: result.active, + }; + this.config.featureValues = { ...this.config.featureValues, fast_mode: result.enabled }; + } + async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise { return runProviderTurn({ prompt, @@ -999,7 +846,7 @@ export class OmpAgentSession implements AgentSession { const turnId = randomUUID(); this.live = true; this.activeTurnId = turnId; - this.activeClientMessageId = options?.clientMessageId ?? null; + this.rememberClientMessage(options?.clientMessageId ?? null, payload.text); this.activeAssistantMessageId = null; this.activeTurnTerminalAssistantMessage = null; this.activeTurnStarted = false; @@ -1011,6 +858,8 @@ export class OmpAgentSession implements AgentSession { void (async () => { try { + if (this.runtimeDead) await this.replaceRuntime(this.currentModeId ?? "full"); + if (this.closed) throw new Error("OMP session is closed"); const ack = await this.runtimeSession.prompt(payload.text, payload.images); this.activePromptRequestId = ack.requestId ?? null; const correlatedResult = ack.requestId @@ -1033,13 +882,7 @@ export class OmpAgentSession implements AgentSession { return; } this.usagePoller.stopTurn(); - this.activeTurnId = null; - this.activeClientMessageId = null; - this.activeTurnStarted = false; - this.activeTurnHasUserMessage = false; - this.activeAssistantMessageId = null; - this.activeTurnTerminalAssistantMessage = null; - this.clearNoTurnBuffers(); + this.resetActiveTurn({ terminalizeWork: true }); if (isOmpRequestAbortError(error)) { this.emit({ type: "turn_canceled", @@ -1061,6 +904,57 @@ export class OmpAgentSession implements AgentSession { return { turnId }; } + async steerActiveTurn( + prompt: AgentPromptInput, + options: SteerActiveTurnOptions, + ): Promise { + if (this.closed || this.activeTurnId !== options.expectedTurnId) + return { status: "unavailable" }; + const payload = convertPromptInput(prompt, { model: this.state.model }); + if (this.parseSlashCommandInput(payload.text)) return { status: "unavailable" }; + const submission = this.rememberClientMessage(options.clientMessageId ?? null, payload.text); + try { + await this.runtimeSession.steer(payload.text, payload.images); + } catch (error) { + this.forgetClientMessage(submission); + if (isOmpSteerTransportFailure(error)) throw error; + return { status: "unavailable" }; + } + if (this.closed || this.activeTurnId !== options.expectedTurnId) + return { status: "unavailable" }; + if (options.clearPendingPermissions) { + for (const requestId of this.pendingExtensionUiRequests.keys()) { + await this.respondToPermission(requestId, { + behavior: "deny", + message: "The user sent a message instead of approving.", + }); + } + } + return { status: "accepted" }; + } + + private rememberClientMessage( + clientMessageId: string | null, + text: string, + ): { + clientMessageId: string | null; + text: string; + } { + const submission = { clientMessageId, text }; + this.pendingClientMessages.push(submission); + if (this.pendingClientMessages.length > 16) this.pendingClientMessages.shift(); + return submission; + } + + private forgetClientMessage(submission: { clientMessageId: string | null; text: string }): void { + const index = this.pendingClientMessages.indexOf(submission); + if (index >= 0) this.pendingClientMessages.splice(index, 1); + } + + private takeClientMessage(): { clientMessageId: string | null; text: string } | undefined { + return this.pendingClientMessages.shift(); + } + subscribe(callback: (event: AgentStreamEvent) => void): () => void { this.subscribers.add(callback); return () => { @@ -1073,6 +967,7 @@ export class OmpAgentSession implements AgentSession { sessionFile: this.state.sessionFile, runtimeSession: this.runtimeSession, provider: this.provider, + bridgedTools: this.hostTools?.bridge?.toolIdentities(), }); for (const item of mapOmpTodoState(this.state)) { yield { @@ -1085,6 +980,10 @@ export class OmpAgentSession implements AgentSession { async getRuntimeInfo(): Promise { await this.refreshState(); + return this.runtimeInfoFromState(); + } + + private runtimeInfoFromState(): AgentRuntimeInfo { return { provider: this.provider, sessionId: this.state.sessionId, @@ -1109,10 +1008,56 @@ export class OmpAgentSession implements AgentSession { if (!OMP_MODES.some((mode) => mode.id === modeId)) { throw new Error(`Invalid OMP mode '${modeId}'`); } - return { - type: "warning", - message: "Start a new OMP session to change approval mode", + if (modeId === this.currentModeId) return; + if (this.activeTurnId || this.state.isStreaming || this.state.isCompacting) { + return { type: "warning", message: "Change approval mode once the current turn ends" }; + } + await this.replaceRuntime(modeId); + } + + private async replaceRuntime(modeId: string): Promise { + if (this.replacingRuntime) return this.replacingRuntime; + const replace = async () => { + const old = this.runtimeSession; + const oldHostTools = this.hostTools; + const previousSessionId = this.state.sessionId; + const restarted = await this.restartRuntime( + this.config.internal ? null : (this.state.sessionFile ?? null), + modeId, + ); + const next = restarted.runtimeSession; + try { + if (this.closed) throw new Error("OMP session is closed"); + const state = await next.getState(); + if (this.closed) throw new Error("OMP session is closed"); + this.unsubscribeRuntime?.(); + this.runtimeSession = next; + this.hostTools = restarted.hostTools; + this.state = state; + this.currentModeId = modeId; + this.config.modeId = modeId; + this.runtimeDead = false; + this.attachRuntime(next); + this.subagentIndex.clear(old); + await oldHostTools?.close(); + await old.close().catch(() => undefined); + if (state.sessionId !== previousSessionId) { + this.emit({ + type: "thread_started", + provider: this.provider, + sessionId: state.sessionId, + }); + } + } catch (error) { + await restarted.hostTools?.close(); + await next.close().catch(() => undefined); + throw error; + } }; + this.replacingRuntime = replace().finally(() => { + this.replacingRuntime = null; + }); + return this.replacingRuntime; } getPendingPermissions(): AgentPermissionRequest[] { @@ -1126,15 +1071,12 @@ export class OmpAgentSession implements AgentSession { } this.pendingExtensionUiRequests.delete(requestId); - if (isCombinedAskUserPermission(request)) { - const combined = buildCombinedAskUserSelectionResponse(request, response); - this.pendingCombinedAskUserResponse = combined.pendingResponse; - this.runtimeSession.respondToExtensionUiRequest(requestId, combined.uiResponse); + const approvalResponse = buildOmpRpcUiPermissionResponse(request, response); + if (approvalResponse) { + this.runtimeSession.respondToExtensionUiRequest(requestId, approvalResponse); } else { - this.runtimeSession.respondToExtensionUiRequest( - requestId, - buildOmpRpcUiPermissionResponse(request, response) ?? - buildExtensionUiResponse(request, response), + this.questionUi.respond(request, response, (id, uiResponse) => + this.runtimeSession.respondToExtensionUiRequest(id, uiResponse), ); } this.emit({ @@ -1153,6 +1095,9 @@ export class OmpAgentSession implements AgentSession { nativeHandle: this.state.sessionFile, metadata: { cwd: this.config.cwd, + ...(this.hostTools?.bridge + ? { bridgedTools: [...this.hostTools.bridge.toolIdentities()] } + : {}), ...(this.config.model ? { model: this.config.model } : {}), ...(this.config.thinkingOptionId ? { thinkingOptionId: this.config.thinkingOptionId } : {}), ...(this.currentModeId ? { modeId: this.currentModeId } : {}), @@ -1164,15 +1109,8 @@ export class OmpAgentSession implements AgentSession { const turnId = this.activeTurnId; await this.runtimeSession.abort(); if (turnId && this.activeTurnId === turnId) { - this.terminalizeActiveWork(); this.usagePoller.stopTurn(); - this.activeTurnId = null; - this.activeClientMessageId = null; - this.activeTurnStarted = false; - this.activeTurnHasUserMessage = false; - this.activeAssistantMessageId = null; - this.activeTurnTerminalAssistantMessage = null; - this.clearNoTurnBuffers(); + this.resetActiveTurn({ terminalizeWork: true }); this.emit({ type: "turn_canceled", provider: this.provider, @@ -1200,11 +1138,13 @@ export class OmpAgentSession implements AgentSession { return; } this.closed = true; + this.unsubscribeRuntime?.(); this.usagePoller.close(); this.cancelNoTurnPromptCompletion(); try { await this.runtimeSession.close(); } finally { + await this.hostTools?.close(); this.clearOmpSessionState(); } } @@ -1215,7 +1155,7 @@ export class OmpAgentSession implements AgentSession { } private clearOmpTurnState(): void { - clearOmpHostToolState(this.runtimeSession); + this.hostTools?.clear(); this.subagentCardTracker.clear(); } @@ -1230,6 +1170,17 @@ export class OmpAgentSession implements AgentSession { this.clearOmpTurnState(); } + private resetActiveTurn({ terminalizeWork }: { terminalizeWork: boolean }): void { + if (terminalizeWork) this.terminalizeActiveWork(); + this.activeTurnId = null; + this.activeAssistantMessageId = null; + this.activeTurnTerminalAssistantMessage = null; + this.activeTurnStarted = false; + this.activeTurnHasUserMessage = false; + this.pendingClientMessages.length = 0; + this.clearNoTurnBuffers(); + } + async listCommands(): Promise { if (this.commandCache) { return this.commandCache; @@ -1299,11 +1250,8 @@ export class OmpAgentSession implements AgentSession { } const model = await this.runtimeSession.setModel(parsedReference.provider, parsedReference.id); - this.state = { - ...this.state, - model, - }; this.config.model = `${model.provider}/${model.id}`; + await this.refreshState(); } async setThinkingOption(thinkingOptionId: string | null): Promise { @@ -1381,6 +1329,7 @@ export class OmpAgentSession implements AgentSession { const outputs = this.pendingNoTurnOutputs.filter((output) => output.turnId === turnId); this.clearNoTurnBuffers(); if (promptText) { + const clientMessageId = this.takeClientMessage()?.clientMessageId; this.emit({ type: "timeline", provider: this.provider, @@ -1388,7 +1337,7 @@ export class OmpAgentSession implements AgentSession { item: { type: "user_message", text: promptText, - ...(this.activeClientMessageId ? { clientMessageId: this.activeClientMessageId } : {}), + ...(clientMessageId ? { clientMessageId } : {}), }, }); } @@ -1558,7 +1507,20 @@ export class OmpAgentSession implements AgentSession { ): void { const message = optionalString(event.message); if (event.method === "notify" && message) { - this.bufferNoTurnOutput(message); + this.emit({ + type: "timeline", + provider: this.provider, + turnId: this.currentTurnIdForEvent(), + item: { + type: "notification", + level: + event.notifyType === "warning" || event.notifyType === "error" + ? event.notifyType + : "info", + message, + }, + }); + return; } const sideEffectItem = this.mapExtensionUiSideEffect(event); @@ -1572,22 +1534,11 @@ export class OmpAgentSession implements AgentSession { return; } - if (this.respondToCombinedAskUserFollowUp(event)) { - return; - } - - const shouldCombineOptionalComment = - event.method === "select" && - this.activeAskUserDialog?.allowComment === true && - this.activeAskUserDialog.allowMultiple === false; const request = mapOmpRpcUiPermissionRequest(event, { provider: this.provider }) ?? - mapExtensionUiRequestToPermission(event, { - provider: this.provider, - label: "OMP", - combineOptionalComment: shouldCombineOptionalComment, - allowFreeform: this.activeAskUserDialog?.allowFreeform, - }); + this.questionUi.handleRequest(event, this.provider, (id, response) => + this.runtimeSession.respondToExtensionUiRequest(id, response), + ); if (!request) { return; } @@ -1617,33 +1568,6 @@ export class OmpAgentSession implements AgentSession { return { type: "assistant_message", text: lines.join("\n") }; } - private respondToCombinedAskUserFollowUp( - event: Extract, - ): boolean { - const pending = this.pendingCombinedAskUserResponse; - if (!pending || event.method !== "input") { - return false; - } - - const placeholder = optionalString(event.placeholder); - if (pending.freeform !== null && !isOptionalInputPlaceholder(placeholder)) { - this.pendingCombinedAskUserResponse = { - ...pending, - freeform: null, - }; - this.runtimeSession.respondToExtensionUiRequest(event.id, { value: pending.freeform }); - return true; - } - - if (isOptionalInputPlaceholder(placeholder)) { - this.pendingCombinedAskUserResponse = null; - this.runtimeSession.respondToExtensionUiRequest(event.id, { value: pending.comment }); - return true; - } - - return false; - } - private handleCommandOutput(textValue: unknown): void { if (!this.activeTurnId) { return; @@ -1665,13 +1589,21 @@ export class OmpAgentSession implements AgentSession { } private handleExtraRuntimeEvent(event: OmpRuntimeEvent): boolean { - if ( - handleOmpHostToolRuntimeEvent(event, { - runtimeSession: this.runtimeSession, - paseoTools: this.paseoTools, - logger: this.logger, - }) - ) { + if (event.type === "model_changed") { + void this.refreshState() + .then(() => + this.emit({ + type: "model_changed", + provider: this.provider, + runtimeInfo: this.runtimeInfoFromState(), + }), + ) + .catch((error: unknown) => + this.logger.debug({ err: error }, "OMP state unavailable after model change"), + ); + return true; + } + if (handleOmpHostToolRuntimeEvent(event, this.hostTools, this.runtimeSession, this.logger)) { return true; } if (event.type === "subagent_lifecycle") { @@ -1818,19 +1750,18 @@ export class OmpAgentSession implements AgentSession { } private handleProcessExit(error: string): void { + this.runtimeDead = true; + void this.hostTools?.close(); this.usagePoller.stopTurn(); - this.terminalizeActiveWork(); - this.subagentIndex.clear(this.runtimeSession); if (!this.activeTurnId) { + this.terminalizeActiveWork(); + this.subagentIndex.clear(this.runtimeSession); + this.emit({ type: "turn_failed", provider: this.provider, error }); return; } const turnId = this.activeTurnId; - this.activeTurnId = null; - this.activeClientMessageId = null; - this.activeTurnStarted = false; - this.activeTurnHasUserMessage = false; - this.activeTurnTerminalAssistantMessage = null; - this.clearNoTurnBuffers(); + this.resetActiveTurn({ terminalizeWork: true }); + this.subagentIndex.clear(this.runtimeSession); this.emit({ type: "turn_failed", provider: this.provider, @@ -1876,7 +1807,7 @@ export class OmpAgentSession implements AgentSession { case "tool_execution_start": { const toolCall = parseToolArgs(event.toolName, event.args); this.activeToolCalls.set(event.toolCallId, toolCall); - this.activeAskUserDialog = readActiveAskUserDialog(event.toolName, event.args); + this.questionUi.start(event.toolName, event.args); this.emitToolCallEvent(event.toolCallId, toolCall, "running", null, null); return; } @@ -1946,14 +1877,12 @@ export class OmpAgentSession implements AgentSession { this.activeToolCalls.get(event.toolCallId) ?? parseToolArgs(event.toolName, null); this.activeToolCalls.delete(event.toolCallId); - if (event.toolName === "ask_user") { - this.activeAskUserDialog = null; - this.pendingCombinedAskUserResponse = null; - } + this.questionUi.finish(event.toolName); const result = parseToolResult(event.result); - const error = event.isError ? event.result : null; - const status = event.isError ? "failed" : "completed"; + const failed = isOmpToolFailure(toolCall, result, Boolean(event.isError)); + const error = failed ? toolFailureMessage(result) : null; + const status = failed ? "failed" : "completed"; this.emitToolCallEvent(event.toolCallId, toolCall, status, result, error); if (event.toolName === "task") { this.subagentCardTracker.delete(event.toolCallId); @@ -2048,6 +1977,7 @@ export class OmpAgentSession implements AgentSession { ): void { if (event.message.role === "assistant") { this.activeAssistantMessageId = null; + this.questionUi.observeMessage(event.message); if (turnId) { this.activeTurnTerminalAssistantMessage = event.message; } @@ -2057,6 +1987,11 @@ export class OmpAgentSession implements AgentSession { if (shouldDisplayOmpCustomMessage(event.message)) { const text = getUserMessageText(event.message.content); if (text) { + const skillPrompt = ompSkillPromptUserText(event.message); + if (skillPrompt) { + this.emitSkillPromptEcho(turnId); + return; + } const item = mapOmpAdvisorMessageToToolCall(event.message, text) ?? mapOmpSystemNoticeToNotification(text); @@ -2064,7 +1999,14 @@ export class OmpAgentSession implements AgentSession { type: "timeline", provider: this.provider, turnId, - item: item ?? { type: "assistant_message", text }, + item: item ?? { + type: "assistant_message", + text, + messageId: ompCustomMessageId(event.message, () => { + this.customMessageIndex += 1; + return this.customMessageIndex; + }), + }, }); } } @@ -2080,7 +2022,6 @@ export class OmpAgentSession implements AgentSession { } const nativeMessage = event.message as OmpAgentMessage & { id?: unknown; entryId?: unknown }; const messageId = readNativeMessageId(nativeMessage); - const clientMessageId = this.activeClientMessageId; const emitUserMessage = (resolvedMessageId?: string): void => { if (resolvedMessageId) { // OMP re-emits user message_end frames for entries it has already @@ -2091,6 +2032,7 @@ export class OmpAgentSession implements AgentSession { } this.emittedUserMessageIds.add(resolvedMessageId); } + const clientMessageId = this.takeClientMessage()?.clientMessageId; this.emit({ type: "timeline", provider: this.provider, @@ -2121,6 +2063,22 @@ export class OmpAgentSession implements AgentSession { }); } + private emitSkillPromptEcho(turnId: string | undefined): void { + const pending = this.takeClientMessage(); + if (!pending) return; + this.activeTurnHasUserMessage = true; + this.emit({ + type: "timeline", + provider: this.provider, + turnId, + item: { + type: "user_message", + text: pending.text, + ...(pending.clientMessageId ? { clientMessageId: pending.clientMessageId } : {}), + }, + }); + } + private emitToolCallEvent( toolCallId: string, toolCall: OmpTrackedToolCall, @@ -2133,10 +2091,13 @@ export class OmpAgentSession implements AgentSession { if (!detail) { return false; } + const bridgedTool = this.hostTools?.bridge?.tool(toolCall.toolName); const baseItem = { type: "tool_call" as const, callId: toolCallId, - name: resolveToolCallName(toolCall, result), + name: bridgedTool + ? `${bridgedTool.server} / ${bridgedTool.tool}` + : resolveToolCallName(toolCall, result), detail, }; const item = @@ -2157,24 +2118,18 @@ export class OmpAgentSession implements AgentSession { ): ToolCallDetail | null { return mapOmpToolDetail(toolCall, result, { toolCallId, + bridgedTool: this.hostTools?.bridge?.tool(toolCall.toolName), mapSubagentDetail: (detail) => this.subagentCardTracker.detailFor(toolCallId, detail) ?? detail, }); } private completeTurn(turnId: string | undefined, messages: OmpAgentMessage[]): void { - this.activeTurnId = null; - this.activeClientMessageId = null; - this.activeAssistantMessageId = null; - this.activeTurnTerminalAssistantMessage = null; - this.activeTurnStarted = false; - this.activeTurnHasUserMessage = false; - this.clearNoTurnBuffers(); // OMP reports a stopped turn as a terminal response carrying its interrupt // text as an error. That is the user's own Stop, not a failed turn. if (isOmpAbortedTerminalResponse(messages)) { this.usagePoller.stopTurn(); - this.terminalizeActiveWork(); + this.resetActiveTurn({ terminalizeWork: true }); this.emit({ type: "turn_canceled", provider: this.provider, @@ -2186,6 +2141,7 @@ export class OmpAgentSession implements AgentSession { const errorMessage = latestOmpErrorMessage(messages); if (typeof errorMessage === "string" && errorMessage.length > 0) { this.usagePoller.stopTurn(); + this.resetActiveTurn({ terminalizeWork: true }); this.emit({ type: "turn_failed", provider: this.provider, @@ -2195,6 +2151,7 @@ export class OmpAgentSession implements AgentSession { return; } const finalUsage = this.usagePoller.completeTurn(turnId); + this.resetActiveTurn({ terminalizeWork: false }); this.emit({ type: "turn_completed", provider: this.provider, @@ -2207,7 +2164,19 @@ export class OmpAgentSession implements AgentSession { turnId: string | undefined, messages: OmpAgentMessage[], ): Promise { + const deadline = Date.now() + this.providerIdleDeadlineMs; while (!this.closed && this.activeTurnStarted && this.currentTurnIdForEvent() === turnId) { + if (Date.now() >= deadline) { + this.usagePoller.stopTurn(); + this.resetActiveTurn({ terminalizeWork: true }); + this.emit({ + type: "turn_failed", + provider: this.provider, + turnId, + error: "OMP provider idle deadline exceeded", + }); + return; + } try { const state = await this.runtimeSession.getState(); this.state = state; @@ -2247,6 +2216,7 @@ export class OmpAgentClient implements AgentClient { private readonly providerIdleScheduler?: OmpProviderIdleScheduler; private readonly noTurnScheduler?: OmpNoTurnScheduler; private readonly usagePollScheduler?: OmpUsagePollScheduler; + private readonly providerIdleDeadlineMs?: number; private readonly runtime: OmpRuntime; constructor(options: OmpAgentClientOptions) { @@ -2270,6 +2240,7 @@ export class OmpAgentClient implements AgentClient { this.providerIdleScheduler = options.providerIdleScheduler; this.noTurnScheduler = options.noTurnScheduler; this.usagePollScheduler = options.usagePollScheduler; + this.providerIdleDeadlineMs = options.providerIdleDeadlineMs; this.runtime = options.runtime ?? createRuntime(options.logger, runtimeSettings, this.providerParams); } @@ -2277,11 +2248,37 @@ export class OmpAgentClient implements AgentClient { private async configureNativePaseoTools( runtimeSession: OmpRuntimeSession, catalog: PaseoToolCatalog | undefined, - ): Promise { - if (!catalog) { + config: AgentSessionConfig, + launchEnv?: NodeJS.ProcessEnv, + ): Promise { + if (!catalog && !config.mcpServers) { return; } - await setOmpHostTools(runtimeSession, catalog); + const bridge = await OmpMcpBridge.connect(config.mcpServers, config.cwd, this.logger, { + ...process.env, + ...this.runtimeSettings?.env, + ...launchEnv, + }); + const router = new OmpHostToolRouter({ runtimeSession, catalog, bridge, logger: this.logger }); + try { + await router.register(); + return router; + } catch (error) { + await router.close(); + throw error; + } + } + + private async restoreFastMode( + runtimeSession: OmpRuntimeSession, + config: AgentSessionConfig, + state: OmpSessionState, + ): Promise { + const value = config.featureValues?.fast_mode; + if (typeof value !== "boolean" || !hasOmpFastMode(state) || value === state.fastModeEnabled) + return state; + const result = await runtimeSession.setFastMode(value); + return { ...state, fastModeEnabled: result.enabled, fastModeActive: result.active }; } async createSession( @@ -2289,7 +2286,7 @@ export class OmpAgentClient implements AgentClient { launchContext?: AgentLaunchContext, ): Promise { const launchMode = this.resolveLaunchMode(config.modeId); - const runtimeSession = await this.runtime.startSession({ + const startInput: OmpStartSessionInput = { cwd: config.cwd, protocolMode: "rpc-ui", model: config.model, @@ -2299,23 +2296,38 @@ export class OmpAgentClient implements AgentClient { extraArgs: launchMode.extraArgs, systemPrompt: composeSystemPromptParts(config.systemPrompt, config.daemonAppendSystemPrompt), env: launchContext?.env, - }); + }; + const runtimeSession = await this.runtime.startSession(startInput); + let hostTools: OmpHostToolRouter | undefined; try { - await this.configureNativePaseoTools(runtimeSession, launchContext?.paseoTools); - return new OmpAgentSession({ + hostTools = await this.configureNativePaseoTools( + runtimeSession, + launchContext?.paseoTools, + config, + startInput.env, + ); + const initialState = await this.restoreFastMode( runtimeSession, config, - initialState: await runtimeSession.getState(), + await runtimeSession.getState(), + ); + return new OmpAgentSession({ + runtimeSession, + hostTools, + restartRuntime: this.buildRestartRuntime(startInput, config, launchContext), + config, + initialState, currentModeId: launchMode.modeId, logger: this.logger, subagentCardScheduler: this.subagentCardScheduler, providerIdleScheduler: this.providerIdleScheduler, noTurnScheduler: this.noTurnScheduler, usagePollScheduler: this.usagePollScheduler, - paseoTools: launchContext?.paseoTools, + providerIdleDeadlineMs: this.providerIdleDeadlineMs, usageEnv: { ...process.env, ...this.runtimeSettings?.env, ...launchContext?.env }, }); } catch (error) { + await hostTools?.close(); await runtimeSession.close().catch(() => undefined); throw error; } @@ -2325,6 +2337,7 @@ export class OmpAgentClient implements AgentClient { handle: AgentPersistenceHandle, overrides?: Partial, launchContext?: AgentLaunchContext, + options?: AgentResumeSessionOptions, ): Promise { const sessionFile = handle.nativeHandle; if (!sessionFile) { @@ -2334,37 +2347,94 @@ export class OmpAgentClient implements AgentClient { const persistenceMetadata = parsePersistenceMetadata(handle.metadata); const resumeConfig = buildResumeConfig(persistenceMetadata, overrides, this.provider); - const launchMode = this.resolveLaunchMode(resumeConfig.modeId); - const runtimeSession = await this.runtime.startSession( - buildResumeStartInput({ + if (options?.purpose === "history") { + return new OmpHistorySession( + handle, resumeConfig, sessionFile, - launchContext, - launchMode, - }), - ); + this.provider, + persistenceMetadata.bridgedTools, + ); + } + + const launchMode = this.resolveLaunchMode(resumeConfig.modeId); + const startInput = buildResumeStartInput({ + resumeConfig, + sessionFile, + launchContext, + launchMode, + }); + const runtimeSession = await this.runtime.startSession(startInput); + let hostTools: OmpHostToolRouter | undefined; try { - await this.configureNativePaseoTools(runtimeSession, launchContext?.paseoTools); + hostTools = await this.configureNativePaseoTools( + runtimeSession, + launchContext?.paseoTools, + resumeConfig.config, + startInput.env, + ); + const initialState = await this.restoreFastMode( + runtimeSession, + resumeConfig.config, + await runtimeSession.getState(), + ); return new OmpAgentSession({ runtimeSession, + hostTools, + restartRuntime: this.buildRestartRuntime(startInput, resumeConfig.config, launchContext), config: resumeConfig.config, - initialState: await runtimeSession.getState(), + initialState, currentModeId: launchMode.modeId, logger: this.logger, subagentCardScheduler: this.subagentCardScheduler, providerIdleScheduler: this.providerIdleScheduler, noTurnScheduler: this.noTurnScheduler, usagePollScheduler: this.usagePollScheduler, - paseoTools: launchContext?.paseoTools, + providerIdleDeadlineMs: this.providerIdleDeadlineMs, usageEnv: { ...process.env, ...this.runtimeSettings?.env, ...launchContext?.env }, live: false, }); } catch (error) { + await hostTools?.close(); await runtimeSession.close().catch(() => undefined); throw error; } } + private buildRestartRuntime( + startInput: OmpStartSessionInput, + config: AgentSessionConfig, + launchContext?: AgentLaunchContext, + ): OmpAgentSessionOptions["restartRuntime"] { + return async (sessionFile, modeId) => { + const launchMode = this.resolveLaunchMode(modeId); + const next = await this.runtime.startSession({ + ...startInput, + model: config.model, + thinkingOptionId: normalizeOmpThinkingOption(config.thinkingOptionId) ?? undefined, + modeId: launchMode.modeId, + extraArgs: launchMode.extraArgs, + ...(!startInput.noSession && sessionFile ? { session: sessionFile } : {}), + }); + let hostTools: OmpHostToolRouter | undefined; + try { + hostTools = await this.configureNativePaseoTools( + next, + launchContext?.paseoTools, + config, + startInput.env, + ); + const state = await next.getState(); + await this.restoreFastMode(next, config, state); + return { runtimeSession: next, hostTools }; + } catch (error) { + await hostTools?.close(); + await next.close().catch(() => undefined); + throw error; + } + }; + } + async fetchCatalog( options: FetchCatalogOptions, context?: ProviderRefreshContext, @@ -2421,7 +2491,14 @@ export class OmpAgentClient implements AgentClient { } async importSession(input: ImportProviderSessionInput, context: ImportProviderSessionContext) { - const importConfig = await readOmpImportSessionConfig(input.providerHandleId); + const descriptorOptions = { + sessionDir: this.providerParams.sessionDir, + runtimeSettings: this.runtimeSettings, + }; + const importConfig = await readOmpImportSessionConfig( + input.providerHandleId, + descriptorOptions, + ); return importSessionFromPersistence({ provider: this.provider, request: input, diff --git a/packages/server/src/server/agent/providers/omp/ask.test.ts b/packages/server/src/server/agent/providers/omp/ask.test.ts new file mode 100644 index 0000000000..7c72004e4a --- /dev/null +++ b/packages/server/src/server/agent/providers/omp/ask.test.ts @@ -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", + }), + ); + }); +}); diff --git a/packages/server/src/server/agent/providers/omp/cli-runtime.test.ts b/packages/server/src/server/agent/providers/omp/cli-runtime.test.ts index cb8bd13b51..f312079491 100644 --- a/packages/server/src/server/agent/providers/omp/cli-runtime.test.ts +++ b/packages/server/src/server/agent/providers/omp/cli-runtime.test.ts @@ -98,6 +98,14 @@ function replyToCommands( }); } +function capturePendingCommand(child: OmpChild, type: string): Promise> { + 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): Record { + test("steer waits for OMP's response and surfaces rejection", async () => { + const child = createOmpChild(); + let pending: Record | 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); diff --git a/packages/server/src/server/agent/providers/omp/cli-runtime.ts b/packages/server/src/server/agent/providers/omp/cli-runtime.ts index 06e5158a03..61ca592993 100644 --- a/packages/server/src/server/agent/providers/omp/cli-runtime.ts +++ b/packages/server/src/server/agent/providers/omp/cli-runtime.ts @@ -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 { - await this.request({ - type: "compact", - ...(customInstructions ? { customInstructions } : {}), - }); + await this.request( + { + type: "compact", + ...(customInstructions ? { customInstructions } : {}), + }, + JSONL_RPC_NO_TIMEOUT, + ); } async setAutoCompaction(enabled: boolean): Promise { @@ -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 { 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 { + await this.request({ type: "steer", message, ...(images?.length ? { images } : {}) }); } followUp( diff --git a/packages/server/src/server/agent/providers/omp/custom-message.ts b/packages/server/src/server/agent/providers/omp/custom-message.ts index e84095b4f0..4a85b198e7 100644 --- a/packages/server/src/server/agent/providers/omp/custom-message.ts +++ b/packages/server/src/server/agent/providers/omp/custom-message.ts @@ -5,3 +5,30 @@ type OmpCustomMessage = Extract; 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: 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}`; +} diff --git a/packages/server/src/server/agent/providers/omp/history-mapper.test.ts b/packages/server/src/server/agent/providers/omp/history-mapper.test.ts index 47cc41c0d0..9a6d9a5b82 100644 --- a/packages/server/src/server/agent/providers/omp/history-mapper.test.ts +++ b/packages/server/src/server/agent/providers/omp/history-mapper.test.ts @@ -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: "\nContinue unfinished tasks\n", + }, + ], + }, + }, + { + 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 = "\nworker-1\nready for review\n"; + 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: "\nworker-1\nhi\n", + 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: "\nworker-1\nhi\n", + 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"); diff --git a/packages/server/src/server/agent/providers/omp/history.ts b/packages/server/src/server/agent/providers/omp/history.ts index 7147e35e59..adca06a45d 100644 --- a/packages/server/src/server/agent/providers/omp/history.ts +++ b/packages/server/src/server/agent/providers/omp/history.ts @@ -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 { + 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; visitedSessionFiles?: Set; }): AsyncGenerator { 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("") && + content[0].text.trimEnd().endsWith("") + ) { + 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): 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; } diff --git a/packages/server/src/server/agent/providers/omp/host-tools.test.ts b/packages/server/src/server/agent/providers/omp/host-tools.test.ts index 70a795a5ee..61be9887c8 100644 --- a/packages/server/src/server/agent/providers/omp/host-tools.test.ts +++ b/packages/server/src/server/agent/providers/omp/host-tools.test.ts @@ -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((resolve) => { this.resolveControlledStart = resolve; }); + private readonly router: OmpHostToolRouter; private constructor( private readonly catalog: PaseoToolCatalog, private readonly runtimeSession: Awaited>, - ) {} + ) { + this.router = new OmpHostToolRouter({ runtimeSession, catalog, logger: this.logger }); + } static async withTools(tools: PaseoToolDefinition[]): Promise { const omp = new FakeOmp(); @@ -69,7 +71,12 @@ class OmpHostToolHarness { arguments: Record; }): Promise { 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 { - 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(); } } diff --git a/packages/server/src/server/agent/providers/omp/host-tools.ts b/packages/server/src/server/agent/providers/omp/host-tools.ts index 9850c654dd..1b0f7a24a7 100644 --- a/packages/server/src/server/agent/providers/omp/host-tools.ts +++ b/packages/server/src/server/agent/providers/omp/host-tools.ts @@ -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(); - 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 { - 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 { 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 { - 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(); 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 { + 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 { + this.clear(); + await this.bridge?.close(); + } + waitForIdle(): Promise { 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 { 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; } diff --git a/packages/server/src/server/agent/providers/omp/map-omp-model.test.ts b/packages/server/src/server/agent/providers/omp/map-omp-model.test.ts index 5a380e553d..da86a38ba7 100644 --- a/packages/server/src/server/agent/providers/omp/map-omp-model.test.ts +++ b/packages/server/src/server/agent/providers/omp/map-omp-model.test.ts @@ -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, diff --git a/packages/server/src/server/agent/providers/omp/map-omp-model.ts b/packages/server/src/server/agent/providers/omp/map-omp-model.ts index 6cffc77949..094b1adf35 100644 --- a/packages/server/src/server/agent/providers/omp/map-omp-model.ts +++ b/packages/server/src/server/agent/providers/omp/map-omp-model.ts @@ -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, diff --git a/packages/server/src/server/agent/providers/omp/mcp-bridge.test.ts b/packages/server/src/server/agent/providers/omp/mcp-bridge.test.ts new file mode 100644 index 0000000000..20cab26fd5 --- /dev/null +++ b/packages/server/src/server/agent/providers/omp/mcp-bridge.test.ts @@ -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(); + } + }); +}); diff --git a/packages/server/src/server/agent/providers/omp/mcp-bridge.ts b/packages/server/src/server/agent/providers/omp/mcp-bridge.ts new file mode 100644 index 0000000000..72f7a093f4 --- /dev/null +++ b/packages/server/src/server/agent/providers/omp/mcp-bridge.ts @@ -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(); + private readonly clients: Client[] = []; + private closed = false; + + static async connect( + servers: Record | undefined, + cwd: string, + logger: Logger, + env: NodeJS.ProcessEnv, + ): Promise { + 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 { + return new Map( + [...this.tools].map(([name, entry]) => [name, { server: entry.server, tool: entry.tool }]), + ); + } + + async execute( + name: string, + args: Record, + signal: AbortSignal, + ): Promise { + 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 { + 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, + 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}`; +} diff --git a/packages/server/src/server/agent/providers/omp/message-history.ts b/packages/server/src/server/agent/providers/omp/message-history.ts index b030ec513c..112a942b64 100644 --- a/packages/server/src/server/agent/providers/omp/message-history.ts +++ b/packages/server/src/server/agent/providers/omp/message-history.ts @@ -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(); 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 = 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); } } diff --git a/packages/server/src/server/agent/providers/omp/provider-config.ts b/packages/server/src/server/agent/providers/omp/provider-config.ts index 6c360cbc60..2fe5067845 100644 --- a/packages/server/src/server/agent/providers/omp/provider-config.ts +++ b/packages/server/src/server/agent/providers/omp/provider-config.ts @@ -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, }, diff --git a/packages/server/src/server/agent/providers/omp/question-ui.ts b/packages/server/src/server/agent/providers/omp/question-ui.ts new file mode 100644 index 0000000000..4fdb37f61c --- /dev/null +++ b/packages/server/src/server/agent/providers/omp/question-ui.ts @@ -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; +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 | null { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : 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; + } +} diff --git a/packages/server/src/server/agent/providers/omp/rpc-types.ts b/packages/server/src/server/agent/providers/omp/rpc-types.ts index 9cc8bc340b..4c69f2fbeb 100644 --- a/packages/server/src/server/agent/providers/omp/rpc-types.ts +++ b/packages/server/src/server/agent/providers/omp/rpc-types.ts @@ -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({ diff --git a/packages/server/src/server/agent/providers/omp/runtime.ts b/packages/server/src/server/agent/providers/omp/runtime.ts index dc8001e3ec..2381610ff6 100644 --- a/packages/server/src/server/agent/providers/omp/runtime.ts +++ b/packages/server/src/server/agent/providers/omp/runtime.ts @@ -52,6 +52,7 @@ export interface OmpRuntimeSession { setAutoCompaction(enabled: boolean): Promise; abort(): Promise; getState(): Promise; + setFastMode(enabled: boolean): Promise<{ enabled: boolean; active: boolean }>; getMessages(): Promise; getAvailableModels(timeoutMs?: number | null): Promise; setModel(provider: string, modelId: string): Promise; @@ -65,7 +66,10 @@ export interface OmpRuntimeSession { branch(entryId: string): Promise<{ text: string }>; getBranchMessages(): Promise>; 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; followUp( message: string, images?: Array<{ type: "image"; data: string; mimeType: string }>, diff --git a/packages/server/src/server/agent/providers/omp/session-descriptor.test.ts b/packages/server/src/server/agent/providers/omp/session-descriptor.test.ts index 9dc4ba876c..916c61e042 100644 --- a/packages/server/src/server/agent/providers/omp/session-descriptor.test.ts +++ b/packages/server/src/server/agent/providers/omp/session-descriptor.test.ts @@ -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 { 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 `_` 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 })]); + }, + ); }); diff --git a/packages/server/src/server/agent/providers/omp/session-descriptor.ts b/packages/server/src/server/agent/providers/omp/session-descriptor.ts index 5b471cb1ff..a97afe861d 100644 --- a/packages/server/src/server/agent/providers/omp/session-descriptor.ts +++ b/packages/server/src/server/agent/providers/omp/session-descriptor.ts @@ -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 ` supplies). Ids are resolved against the configured + * sessions directory; an unresolvable handle yields `{}`. + */ export async function readOmpImportSessionConfig( - filePath: string, + handle: string, + options: OmpSessionDescriptorOptions = {}, ): Promise { + 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 { + 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 `_.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 { + try { + return (await stat(filePath)).isFile(); + } catch { + return false; + } +} + +async function headerSessionIdMatches(filePath: string, sessionId: string): Promise { + const headChunk = await readHeadChunk(filePath); + if (!headChunk) return false; + return parseSessionHeaderFromChunk(headChunk)?.sessionId === sessionId; +} + async function resolveOmpSessionsDir(options: OmpSessionDescriptorOptions): Promise { - 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: { diff --git a/packages/server/src/server/agent/providers/omp/test-utils/echo-mcp-server.mjs b/packages/server/src/server/agent/providers/omp/test-utils/echo-mcp-server.mjs new file mode 100644 index 0000000000..1821afb47d --- /dev/null +++ b/packages/server/src/server/agent/providers/omp/test-utils/echo-mcp-server.mjs @@ -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()); diff --git a/packages/server/src/server/agent/providers/omp/test-utils/fake-omp.ts b/packages/server/src/server/agent/providers/omp/test-utils/fake-omp.ts index 68eed2fc3b..184a38bafa 100644 --- a/packages/server/src/server/agent/providers/omp/test-utils/fake-omp.ts +++ b/packages/server/src/server/agent/providers/omp/test-utils/fake-omp.ts @@ -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(); private readonly queuedSubagentSubscriptionErrors = new Map< FakeOmpSubagentSubscriptionLevel, Error @@ -64,12 +67,19 @@ export class FakeOmp implements OmpRuntime { } async startSession(input: OmpStartSessionInput): Promise { + 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; 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 { this.steerRequests.push({ message, imageCount: images?.length ?? 0 }); + if (this.steerError) throw this.steerError; } followUp( diff --git a/packages/server/src/server/agent/providers/omp/test-utils/omp-harness.ts b/packages/server/src/server/agent/providers/omp/test-utils/omp-harness.ts index 7c99fa772c..5c53dd3e4f 100644 --- a/packages/server/src/server/agent/providers/omp/test-utils/omp-harness.ts +++ b/packages/server/src/server/agent/providers/omp/test-utils/omp-harness.ts @@ -71,14 +71,18 @@ export class OmpHarness { providerIdleScheduler?: OmpProviderIdleScheduler; noTurnScheduler?: OmpNoTurnScheduler; usagePollScheduler?: OmpUsagePollScheduler; + providerIdleDeadlineMs?: number; + runtimeEnv?: Record; } = {}, ) { 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 { + 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 { 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 { + await this.requireSession().startTurn(message); + await waitForImmediate(); + } + + startTurnDetached(message: string) { + return this.requireSession().startTurn(message); + } + async interrupt(): Promise { await this.requireSession().interrupt(); } diff --git a/packages/server/src/server/agent/providers/omp/todo-mapper.test.ts b/packages/server/src/server/agent/providers/omp/todo-mapper.test.ts index e04ddee2bc..85e4b9a3a9 100644 --- a/packages/server/src/server/agent/providers/omp/todo-mapper.test.ts +++ b/packages/server/src/server/agent/providers/omp/todo-mapper.test.ts @@ -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: [] }); + }); }); diff --git a/packages/server/src/server/agent/providers/omp/todo-mapper.ts b/packages/server/src/server/agent/providers/omp/todo-mapper.ts index 0b41d5a43f..699a40d3aa 100644 --- a/packages/server/src/server/agent/providers/omp/todo-mapper.ts +++ b/packages/server/src/server/agent/providers/omp/todo-mapper.ts @@ -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", + })), }; } diff --git a/packages/server/src/server/agent/providers/omp/tool-call-detail.ts b/packages/server/src/server/agent/providers/omp/tool-call-detail.ts index 7df6efb676..f0e9d15532 100644 --- a/packages/server/src/server/agent/providers/omp/tool-call-detail.ts +++ b/packages/server/src/server/agent/providers/omp/tool-call-detail.ts @@ -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, diff --git a/packages/server/src/server/agent/providers/omp/tool-call-mapper.test.ts b/packages/server/src/server/agent/providers/omp/tool-call-mapper.test.ts index dcccea3b60..ae4c0d0d77 100644 --- a/packages/server/src/server/agent/providers/omp/tool-call-mapper.test.ts +++ b/packages/server/src/server/agent/providers/omp/tool-call-mapper.test.ts @@ -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)", + }); + }); }); diff --git a/packages/server/src/server/agent/providers/omp/tool-call-mapper.ts b/packages/server/src/server/agent/providers/omp/tool-call-mapper.ts index 180790ab3a..86b1ee53ef 100644 --- a/packages/server/src/server/agent/providers/omp/tool-call-mapper.ts +++ b/packages/server/src/server/agent/providers/omp/tool-call-mapper.ts @@ -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,