feat(omp): add native OMP provider (#2067)
Pi and OMP share only the JSONL child-process transport while retaining provider-owned launch, RPC, runtime, session, history, and permission behavior. Removes captured fixtures in favor of typed harnesses and real-provider coverage. Closes #2006 Closes #2060
This commit is contained in:
@@ -6,6 +6,7 @@ import type {
|
||||
ServerRequest,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
|
||||
import { addModelVisibleStructuredContent } from "./tools/paseo-tool-serialization.js";
|
||||
import { createPaseoToolCatalog, type PaseoToolHostDependencies } from "./tools/paseo-tools.js";
|
||||
import type { PaseoToolResult } from "./tools/types.js";
|
||||
|
||||
@@ -13,62 +14,18 @@ export type AgentMcpServerOptions = PaseoToolHostDependencies;
|
||||
|
||||
type McpToolContext = RequestHandlerExtra<ServerRequest, ServerNotification>;
|
||||
|
||||
function formatStructuredContentForModel(structuredContent: unknown): string {
|
||||
if (
|
||||
!structuredContent ||
|
||||
typeof structuredContent !== "object" ||
|
||||
Array.isArray(structuredContent)
|
||||
) {
|
||||
return JSON.stringify(structuredContent, null, 2);
|
||||
}
|
||||
|
||||
const record = structuredContent as Record<string, unknown>;
|
||||
const summary: string[] = [];
|
||||
for (const [key, value] of Object.entries(record)) {
|
||||
if (!Array.isArray(value)) {
|
||||
continue;
|
||||
}
|
||||
summary.push(`${key}_count=${value.length}`);
|
||||
const ids = value
|
||||
.map((item) =>
|
||||
item && typeof item === "object" && !Array.isArray(item)
|
||||
? (item as Record<string, unknown>).id
|
||||
: null,
|
||||
)
|
||||
.filter((id): id is string => typeof id === "string" && id.length > 0);
|
||||
if (ids.length === value.length && ids.length > 0) {
|
||||
summary.push(`${key}_ids=${ids.join(",")}`);
|
||||
}
|
||||
}
|
||||
|
||||
const json = JSON.stringify(structuredContent, null, 2);
|
||||
return summary.length > 0 ? `${summary.join("\n")}\n\n${json}` : json;
|
||||
}
|
||||
|
||||
function addModelVisibleStructuredContent(result: CallToolResult): CallToolResult {
|
||||
if (result.structuredContent === undefined || result.content.length > 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
return {
|
||||
...result,
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: formatStructuredContentForModel(result.structuredContent),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function toMcpToolResult(result: PaseoToolResult): CallToolResult {
|
||||
return addModelVisibleStructuredContent({
|
||||
content: result.content as CallToolResult["content"],
|
||||
...(result.structuredContent !== undefined
|
||||
? { structuredContent: result.structuredContent as CallToolResult["structuredContent"] }
|
||||
const modelVisibleResult = addModelVisibleStructuredContent(result);
|
||||
return {
|
||||
content: modelVisibleResult.content as CallToolResult["content"],
|
||||
...(modelVisibleResult.structuredContent !== undefined
|
||||
? {
|
||||
structuredContent:
|
||||
modelVisibleResult.structuredContent as CallToolResult["structuredContent"],
|
||||
}
|
||||
: {}),
|
||||
...(result.isError !== undefined ? { isError: result.isError } : {}),
|
||||
});
|
||||
...(modelVisibleResult.isError !== undefined ? { isError: modelVisibleResult.isError } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function createAgentMcpServer(options: AgentMcpServerOptions): Promise<McpServer> {
|
||||
|
||||
@@ -456,6 +456,7 @@ import {
|
||||
buildProviderRegistry,
|
||||
createAllClients,
|
||||
} from "./provider-registry.js";
|
||||
import { FakeOmp } from "./providers/omp/test-utils/fake-omp.js";
|
||||
|
||||
const logger = createTestLogger();
|
||||
|
||||
@@ -523,28 +524,27 @@ test("built-in override applies env", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("OMP is a disabled built-in backed by the Pi adapter", () => {
|
||||
const registry = buildProviderRegistry(logger);
|
||||
test("OMP is a disabled built-in backed by the real OMP adapter", async () => {
|
||||
const omp = new FakeOmp();
|
||||
const registry = buildProviderRegistry(logger, { ompRuntime: omp });
|
||||
|
||||
expect(registry.omp).toMatchObject({
|
||||
id: "omp",
|
||||
label: "OMP",
|
||||
label: "Oh My Pi",
|
||||
enabled: false,
|
||||
derivedFromProviderId: null,
|
||||
});
|
||||
expect(registry.omp.createClient(logger).provider).toBe("omp");
|
||||
expect(mockState.constructorArgs.pi.at(-1)).toEqual({
|
||||
runtimeSettings: {
|
||||
command: {
|
||||
mode: "replace",
|
||||
argv: ["omp"],
|
||||
},
|
||||
},
|
||||
providerParams: {
|
||||
sessionDir: "~/.omp/agent/sessions",
|
||||
},
|
||||
commandsRpcType: "get_available_commands",
|
||||
});
|
||||
const client = registry.omp.createClient(logger);
|
||||
expect(client.provider).toBe("omp");
|
||||
const session = await client.createSession({ provider: "omp", cwd: "/tmp/registry-omp" });
|
||||
expect(omp.recordedLaunches).toEqual([
|
||||
expect.objectContaining({
|
||||
cwd: "/tmp/registry-omp",
|
||||
protocolMode: "rpc-ui",
|
||||
argv: ["omp", "--mode", "rpc-ui", "--approval-mode", "yolo", "--thinking", "medium"],
|
||||
}),
|
||||
]);
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("OMP can be enabled without custom provider boilerplate", () => {
|
||||
@@ -574,8 +574,10 @@ test("new provider extending claude appears in registry", () => {
|
||||
expect(registry.zai.createClient(logger).provider).toBe("zai");
|
||||
});
|
||||
|
||||
test("built-in OMP override passes params to the Pi adapter constructor", () => {
|
||||
test("built-in OMP override keeps the real OMP adapter enabled and launchable", async () => {
|
||||
const omp = new FakeOmp(["custom-omp"]);
|
||||
const registry = buildProviderRegistry(logger, {
|
||||
ompRuntime: omp,
|
||||
providerOverrides: {
|
||||
omp: {
|
||||
label: "OMP",
|
||||
@@ -587,21 +589,19 @@ test("built-in OMP override passes params to the Pi adapter constructor", () =>
|
||||
},
|
||||
});
|
||||
|
||||
expect(registry.omp.createClient(logger).provider).toBe("omp");
|
||||
expect(mockState.constructorArgs.pi.at(-1)).toEqual({
|
||||
runtimeSettings: {
|
||||
command: {
|
||||
mode: "replace",
|
||||
argv: ["omp"],
|
||||
},
|
||||
env: undefined,
|
||||
disallowedTools: undefined,
|
||||
},
|
||||
providerParams: {
|
||||
sessionDir: "~/.omp/agent/sessions",
|
||||
},
|
||||
commandsRpcType: "get_available_commands",
|
||||
});
|
||||
const client = registry.omp.createClient(logger);
|
||||
const session = await client.createSession({ provider: "omp", cwd: "/tmp/registry-override" });
|
||||
expect(client.provider).toBe("omp");
|
||||
expect(omp.recordedLaunches[0]?.argv).toEqual([
|
||||
"custom-omp",
|
||||
"--mode",
|
||||
"rpc-ui",
|
||||
"--approval-mode",
|
||||
"yolo",
|
||||
"--thinking",
|
||||
"medium",
|
||||
]);
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("new provider extending acp uses GenericACPAgentClient", () => {
|
||||
|
||||
@@ -35,6 +35,8 @@ import { CursorACPAgentClient } from "./providers/cursor-acp-agent.js";
|
||||
import { GenericACPAgentClient } from "./providers/generic-acp-agent.js";
|
||||
import { KiroACPAgentClient } from "./providers/kiro-acp-agent.js";
|
||||
import { OpenCodeAgentClient } from "./providers/opencode-agent.js";
|
||||
import { OmpAgentClient } from "./providers/omp/agent.js";
|
||||
import type { OmpRuntime } from "./providers/omp/runtime.js";
|
||||
import { PiRpcAgentClient } from "./providers/pi/agent.js";
|
||||
import { TraeACPAgentClient } from "./providers/trae-acp-agent.js";
|
||||
import { MockLoadTestAgentClient } from "./providers/mock-load-test-agent.js";
|
||||
@@ -79,11 +81,12 @@ export interface BuildProviderRegistryOptions {
|
||||
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">;
|
||||
managedProcesses?: ManagedProcessRegistry;
|
||||
isDev?: boolean;
|
||||
ompRuntime?: OmpRuntime;
|
||||
}
|
||||
|
||||
interface ProviderClientFactoryOptions extends Pick<
|
||||
BuildProviderRegistryOptions,
|
||||
"workspaceGitService" | "managedProcesses"
|
||||
"workspaceGitService" | "managedProcesses" | "ompRuntime"
|
||||
> {
|
||||
providerParams?: unknown;
|
||||
customProvider?: {
|
||||
@@ -144,21 +147,11 @@ const PROVIDER_CLIENT_FACTORIES: Record<string, ProviderClientFactory> = {
|
||||
providerParams: options?.providerParams,
|
||||
}),
|
||||
omp: (logger, runtimeSettings, options) =>
|
||||
new PiRpcAgentClient({
|
||||
new OmpAgentClient({
|
||||
logger,
|
||||
runtimeSettings: mergeRuntimeSettings(
|
||||
{
|
||||
command: {
|
||||
mode: "replace",
|
||||
argv: ["omp"],
|
||||
},
|
||||
},
|
||||
runtimeSettings,
|
||||
),
|
||||
providerParams: options?.providerParams ?? {
|
||||
sessionDir: "~/.omp/agent/sessions",
|
||||
},
|
||||
commandsRpcType: "get_available_commands",
|
||||
runtimeSettings,
|
||||
providerParams: options?.providerParams,
|
||||
runtime: options?.ompRuntime,
|
||||
}),
|
||||
mock: (logger) => new MockLoadTestAgentClient(logger),
|
||||
"mock-slow": () => new MockSlowProviderClient(),
|
||||
@@ -572,7 +565,10 @@ function createResolvedProviderClient(
|
||||
function buildResolvedBuiltinProviders(
|
||||
providerOverrides: Record<string, ProviderOverride>,
|
||||
runtimeSettings: AgentProviderRuntimeSettingsMap | undefined,
|
||||
options: Pick<BuildProviderRegistryOptions, "workspaceGitService" | "managedProcesses">,
|
||||
options: Pick<
|
||||
BuildProviderRegistryOptions,
|
||||
"workspaceGitService" | "managedProcesses" | "ompRuntime"
|
||||
>,
|
||||
isDev: boolean,
|
||||
): Map<string, ResolvedProvider> {
|
||||
const resolvedProviders = new Map<string, ResolvedProvider>();
|
||||
@@ -602,6 +598,7 @@ function buildResolvedBuiltinProviders(
|
||||
factory(logger, mergedRuntimeSettings, {
|
||||
workspaceGitService: options.workspaceGitService,
|
||||
managedProcesses: options.managedProcesses,
|
||||
ompRuntime: options.ompRuntime,
|
||||
providerParams: override?.params,
|
||||
}),
|
||||
});
|
||||
@@ -725,6 +722,7 @@ export function buildProviderRegistry(
|
||||
{
|
||||
workspaceGitService: options?.workspaceGitService,
|
||||
managedProcesses: options?.managedProcesses,
|
||||
ompRuntime: options?.ompRuntime,
|
||||
},
|
||||
options?.isDev === true,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import pino from "pino";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { JsonlRpcProcess, type JsonlRpcExit } from "./jsonl-rpc-process.js";
|
||||
|
||||
const CHILD_SOURCE = String.raw`
|
||||
const readline = require("node:readline");
|
||||
|
||||
function respond(command, success, data, error) {
|
||||
process.stdout.write(JSON.stringify({
|
||||
type: "response",
|
||||
id: command.id,
|
||||
command: command.type,
|
||||
success,
|
||||
data,
|
||||
error,
|
||||
}) + "\n");
|
||||
}
|
||||
|
||||
readline.createInterface({ input: process.stdin, crlfDelay: Infinity }).on("line", (line) => {
|
||||
const command = JSON.parse(line);
|
||||
if (command.type === "echo") {
|
||||
setTimeout(() => respond(command, true, {
|
||||
value: command.value,
|
||||
cwd: process.cwd(),
|
||||
env: process.env.JSONL_RPC_TEST_VALUE,
|
||||
args: process.argv.slice(1),
|
||||
}), command.delayMs || 0);
|
||||
return;
|
||||
}
|
||||
if (command.type === "emit") {
|
||||
process.stdout.write("not json\n");
|
||||
process.stdout.write('{"type":"notice","text":"a');
|
||||
setTimeout(() => {
|
||||
process.stdout.write('\\u2028b"}\r\n');
|
||||
respond(command, true, null);
|
||||
}, 5);
|
||||
return;
|
||||
}
|
||||
if (command.type === "fail") {
|
||||
respond(command, false, null, "child rejected the request");
|
||||
return;
|
||||
}
|
||||
if (command.type === "hang") {
|
||||
process.stderr.write("still waiting");
|
||||
return;
|
||||
}
|
||||
if (command.type === "exit") {
|
||||
process.stderr.write("child exploded");
|
||||
setTimeout(() => process.exit(7), 5);
|
||||
}
|
||||
});
|
||||
`;
|
||||
|
||||
function startProcess(): JsonlRpcProcess {
|
||||
return new JsonlRpcProcess({
|
||||
launch: {
|
||||
command: process.execPath,
|
||||
args: ["-e", CHILD_SOURCE, "--", "resolved-arg"],
|
||||
cwd: process.cwd(),
|
||||
env: { JSONL_RPC_TEST_VALUE: "resolved-env" },
|
||||
},
|
||||
logger: pino({ level: "silent" }),
|
||||
});
|
||||
}
|
||||
|
||||
function nextExit(transport: JsonlRpcProcess): Promise<JsonlRpcExit> {
|
||||
return new Promise((resolve) => {
|
||||
const unsubscribe = transport.onExit((exit) => {
|
||||
unsubscribe();
|
||||
resolve(exit);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe("JsonlRpcProcess", () => {
|
||||
test("spawns a resolved command and correlates concurrent requests", async () => {
|
||||
const transport = startProcess();
|
||||
|
||||
try {
|
||||
const slow = transport.request({ type: "echo", value: "first", delayMs: 20 });
|
||||
const fast = transport.request({ type: "echo", value: "second" });
|
||||
|
||||
await expect(Promise.all([slow, fast])).resolves.toEqual([
|
||||
{
|
||||
value: "first",
|
||||
cwd: process.cwd(),
|
||||
env: "resolved-env",
|
||||
args: ["resolved-arg"],
|
||||
},
|
||||
{
|
||||
value: "second",
|
||||
cwd: process.cwd(),
|
||||
env: "resolved-env",
|
||||
args: ["resolved-arg"],
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
await transport.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("publishes complete LF-delimited JSON messages", async () => {
|
||||
const transport = startProcess();
|
||||
const messages: Record<string, unknown>[] = [];
|
||||
transport.onMessage((message) => messages.push(message));
|
||||
|
||||
try {
|
||||
await transport.request({ type: "emit" });
|
||||
|
||||
expect(messages).toEqual([{ type: "notice", text: "a\u2028b" }]);
|
||||
} finally {
|
||||
await transport.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects unsuccessful responses", async () => {
|
||||
const transport = startProcess();
|
||||
|
||||
try {
|
||||
await expect(transport.request({ type: "fail" })).rejects.toThrow(
|
||||
"child rejected the request",
|
||||
);
|
||||
} finally {
|
||||
await transport.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("includes buffered stderr when a request times out", async () => {
|
||||
const transport = startProcess();
|
||||
|
||||
try {
|
||||
await transport.request({ type: "echo", value: "ready" });
|
||||
|
||||
await expect(transport.request({ type: "hang" }, 50)).rejects.toThrow(
|
||||
"JSONL RPC request timed out for hang\nstill waiting",
|
||||
);
|
||||
} finally {
|
||||
await transport.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects pending requests and publishes stderr when the child exits", async () => {
|
||||
const transport = startProcess();
|
||||
const exit = nextExit(transport);
|
||||
|
||||
const request = transport.request({ type: "exit" });
|
||||
|
||||
await expect(request).rejects.toThrow("child exploded");
|
||||
await expect(exit).resolves.toMatchObject({
|
||||
code: 7,
|
||||
signal: null,
|
||||
error: expect.objectContaining({
|
||||
message: expect.stringContaining("child exploded"),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects pending requests while shutting down the child process", async () => {
|
||||
const transport = startProcess();
|
||||
await transport.request({ type: "echo", value: "ready" });
|
||||
const request = transport.request({ type: "hang" });
|
||||
|
||||
const rejection = expect(request).rejects.toThrow("JSONL RPC process is closed");
|
||||
await transport.close();
|
||||
|
||||
await rejection;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,255 @@
|
||||
import { type ChildProcess, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import { spawnProcess } from "../../../utils/spawn.js";
|
||||
import { terminateWithTreeKill } from "../../../utils/tree-kill.js";
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||
const STDERR_BUFFER_LIMIT = 8192;
|
||||
const GRACEFUL_SHUTDOWN_TIMEOUT_MS = 2_000;
|
||||
const FORCE_SHUTDOWN_TIMEOUT_MS = 1_000;
|
||||
|
||||
export interface JsonlRpcLaunch {
|
||||
command: string;
|
||||
args: string[];
|
||||
cwd: string;
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface JsonlRpcResponse {
|
||||
type: "response";
|
||||
id?: string;
|
||||
command?: string;
|
||||
success?: boolean;
|
||||
data?: unknown;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface PendingRequest {
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (error: Error) => void;
|
||||
timer: NodeJS.Timeout;
|
||||
}
|
||||
|
||||
export interface JsonlRpcExit {
|
||||
code: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
error: Error;
|
||||
}
|
||||
|
||||
export interface JsonlRpcProcessOptions {
|
||||
launch: JsonlRpcLaunch;
|
||||
logger: Logger;
|
||||
diagnosticName?: string;
|
||||
spawn?: (launch: JsonlRpcLaunch) => ChildProcessWithoutNullStreams;
|
||||
}
|
||||
|
||||
function assertChildWithPipes(
|
||||
child: ChildProcess,
|
||||
): asserts child is ChildProcessWithoutNullStreams {
|
||||
if (!child.stdin || !child.stdout || !child.stderr) {
|
||||
throw new Error("JSONL RPC process was spawned without stdio streams");
|
||||
}
|
||||
}
|
||||
|
||||
function spawnJsonlRpcProcess(launch: JsonlRpcLaunch): ChildProcessWithoutNullStreams {
|
||||
const child = spawnProcess(launch.command, launch.args, {
|
||||
cwd: launch.cwd,
|
||||
envOverlay: launch.env,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
assertChildWithPipes(child);
|
||||
return child;
|
||||
}
|
||||
|
||||
export class JsonlRpcProcess {
|
||||
private readonly child: ChildProcessWithoutNullStreams;
|
||||
private readonly diagnosticName: string;
|
||||
private readonly pending = new Map<string, PendingRequest>();
|
||||
private readonly messageSubscribers = new Set<(message: Record<string, unknown>) => void>();
|
||||
private readonly exitSubscribers = new Set<(exit: JsonlRpcExit) => void>();
|
||||
private stderrBuffer = "";
|
||||
private nextRequestId = 1;
|
||||
private disposed = false;
|
||||
private stdoutBuffer = "";
|
||||
|
||||
constructor(private readonly options: JsonlRpcProcessOptions) {
|
||||
this.diagnosticName = options.diagnosticName ?? "JSONL RPC";
|
||||
this.child = (options.spawn ?? spawnJsonlRpcProcess)(options.launch);
|
||||
this.child.stdout.on("data", (chunk) => {
|
||||
this.handleStdoutChunk(chunk.toString());
|
||||
});
|
||||
this.child.stderr.on("data", (chunk) => {
|
||||
this.stderrBuffer += chunk.toString();
|
||||
if (this.stderrBuffer.length > STDERR_BUFFER_LIMIT) {
|
||||
this.stderrBuffer = this.stderrBuffer.slice(-STDERR_BUFFER_LIMIT);
|
||||
}
|
||||
});
|
||||
this.child.on("error", (error) => {
|
||||
this.failAll(error instanceof Error ? error : new Error(String(error)));
|
||||
});
|
||||
this.child.on("exit", (code, signal) => {
|
||||
const error = new Error(
|
||||
`${this.diagnosticName} process exited with code ${code ?? "null"} and signal ${signal ?? "null"}\n${this.stderrBuffer}`.trim(),
|
||||
);
|
||||
const exit = { code, signal, error };
|
||||
for (const subscriber of this.exitSubscribers) {
|
||||
subscriber(exit);
|
||||
}
|
||||
this.failAll(error);
|
||||
});
|
||||
}
|
||||
|
||||
onMessage(callback: (message: Record<string, unknown>) => void): () => void {
|
||||
this.messageSubscribers.add(callback);
|
||||
return () => {
|
||||
this.messageSubscribers.delete(callback);
|
||||
};
|
||||
}
|
||||
|
||||
onExit(callback: (exit: JsonlRpcExit) => void): () => void {
|
||||
this.exitSubscribers.add(callback);
|
||||
return () => {
|
||||
this.exitSubscribers.delete(callback);
|
||||
};
|
||||
}
|
||||
|
||||
startRequest(
|
||||
command: { type: string; [key: string]: unknown },
|
||||
timeoutMs = DEFAULT_TIMEOUT_MS,
|
||||
): { id: string; promise: Promise<unknown> } {
|
||||
if (this.disposed) {
|
||||
return {
|
||||
id: "",
|
||||
promise: Promise.reject(new Error(`${this.diagnosticName} process is closed`)),
|
||||
};
|
||||
}
|
||||
const id = `req_${this.nextRequestId}`;
|
||||
this.nextRequestId += 1;
|
||||
const promise = new Promise<unknown>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pending.delete(id);
|
||||
reject(
|
||||
new Error(
|
||||
`${this.diagnosticName} request timed out for ${command.type}\n${this.stderrBuffer}`.trim(),
|
||||
),
|
||||
);
|
||||
}, timeoutMs);
|
||||
this.pending.set(id, { resolve, reject, timer });
|
||||
this.send({ ...command, id });
|
||||
});
|
||||
return { id, promise };
|
||||
}
|
||||
|
||||
request(
|
||||
command: { type: string; [key: string]: unknown },
|
||||
timeoutMs = DEFAULT_TIMEOUT_MS,
|
||||
): Promise<unknown> {
|
||||
return this.startRequest(command, timeoutMs).promise;
|
||||
}
|
||||
|
||||
send(message: Record<string, unknown>): void {
|
||||
if (this.disposed || this.child.stdin.destroyed || !this.child.stdin.writable) {
|
||||
return;
|
||||
}
|
||||
this.child.stdin.write(`${JSON.stringify(message)}\n`);
|
||||
}
|
||||
|
||||
async close(error = new Error(`${this.diagnosticName} process is closed`)): Promise<void> {
|
||||
if (this.disposed) return;
|
||||
this.failAll(error);
|
||||
try {
|
||||
this.child.stdin.end();
|
||||
} catch {
|
||||
// Ignore cleanup races.
|
||||
}
|
||||
const result = await terminateWithTreeKill(this.child, {
|
||||
gracefulTimeoutMs: GRACEFUL_SHUTDOWN_TIMEOUT_MS,
|
||||
forceTimeoutMs: FORCE_SHUTDOWN_TIMEOUT_MS,
|
||||
onForceSignal: () => {
|
||||
this.options.logger.warn(
|
||||
{ timeoutMs: GRACEFUL_SHUTDOWN_TIMEOUT_MS },
|
||||
`${this.diagnosticName} process did not exit after SIGTERM; sending SIGKILL`,
|
||||
);
|
||||
},
|
||||
});
|
||||
if (result === "kill-timeout") {
|
||||
this.options.logger.warn(
|
||||
{ timeoutMs: FORCE_SHUTDOWN_TIMEOUT_MS },
|
||||
`${this.diagnosticName} process did not report exit after SIGKILL`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private handleStdoutChunk(chunk: string): void {
|
||||
this.stdoutBuffer += chunk;
|
||||
for (;;) {
|
||||
const newlineIndex = this.stdoutBuffer.indexOf("\n");
|
||||
if (newlineIndex === -1) {
|
||||
break;
|
||||
}
|
||||
const line = this.stdoutBuffer.slice(0, newlineIndex).replace(/\r$/, "");
|
||||
this.stdoutBuffer = this.stdoutBuffer.slice(newlineIndex + 1);
|
||||
if (line.trim()) {
|
||||
this.handleLine(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleLine(line: string): void {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(line);
|
||||
} catch (error) {
|
||||
this.options.logger.warn(
|
||||
{ error, line },
|
||||
`Ignoring non-JSON ${this.diagnosticName} stdout line`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return;
|
||||
}
|
||||
const message = parsed as Record<string, unknown>;
|
||||
if (message.type === "response") {
|
||||
this.handleResponse(message as unknown as JsonlRpcResponse);
|
||||
return;
|
||||
}
|
||||
for (const subscriber of this.messageSubscribers) {
|
||||
subscriber(message);
|
||||
}
|
||||
}
|
||||
|
||||
private handleResponse(response: JsonlRpcResponse): void {
|
||||
if (!response.id) {
|
||||
return;
|
||||
}
|
||||
const pending = this.pending.get(response.id);
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(pending.timer);
|
||||
this.pending.delete(response.id);
|
||||
if (!response.success) {
|
||||
pending.reject(
|
||||
new Error(
|
||||
response.error ?? `${this.diagnosticName} ${response.command ?? "request"} failed`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
pending.resolve(response.data);
|
||||
}
|
||||
|
||||
private failAll(error: Error): void {
|
||||
if (this.disposed) {
|
||||
return;
|
||||
}
|
||||
this.disposed = true;
|
||||
for (const pending of this.pending.values()) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.reject(error);
|
||||
}
|
||||
this.pending.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../../../test-utils/test-logger.js";
|
||||
import { formatOmpVersionSupport, OmpAgentClient, resolveOmpDiagnosticPaths } from "./agent.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe("OMP diagnostics", () => {
|
||||
test.each([
|
||||
["omp 16.3.8", "16.3.8 (unsupported; minimum 16.3.9)"],
|
||||
["16.3.9", "16.3.9 (supported; minimum 16.3.9)"],
|
||||
["oh-my-pi v17.0.0", "17.0.0 (supported; minimum 16.3.9)"],
|
||||
])("classifies installed version %s", (output, expected) => {
|
||||
expect(formatOmpVersionSupport(output)).toBe(expected);
|
||||
});
|
||||
|
||||
test("follows OMP 16.3.9 profile, agent override, and XDG path precedence", async () => {
|
||||
const home = await makeTempDir();
|
||||
const xdgData = path.join(home, "xdg-data");
|
||||
const xdgState = path.join(home, "xdg-state");
|
||||
const xdgCache = path.join(home, "xdg-cache");
|
||||
await Promise.all([
|
||||
mkdir(path.join(xdgData, "omp", "profiles", "work"), { recursive: true }),
|
||||
mkdir(path.join(xdgState, "omp", "profiles", "work"), { recursive: true }),
|
||||
mkdir(path.join(xdgCache, "omp", "profiles", "work"), { recursive: true }),
|
||||
]);
|
||||
|
||||
const profiled = resolveOmpDiagnosticPaths(
|
||||
{
|
||||
OMP_PROFILE: "work",
|
||||
PI_PROFILE: "ignored",
|
||||
PI_CONFIG_DIR: ".omp-custom",
|
||||
PI_CODING_AGENT_DIR: path.join(home, "ignored-agent-override"),
|
||||
XDG_DATA_HOME: xdgData,
|
||||
XDG_STATE_HOME: xdgState,
|
||||
XDG_CACHE_HOME: xdgCache,
|
||||
},
|
||||
home,
|
||||
"linux",
|
||||
);
|
||||
|
||||
expect(profiled).toEqual({
|
||||
profile: "work",
|
||||
configRoot: path.join(home, ".omp-custom", "profiles", "work"),
|
||||
agentDir: path.join(home, ".omp-custom", "profiles", "work", "agent"),
|
||||
agentDb: path.join(xdgData, "omp", "profiles", "work", "agent.db"),
|
||||
xdgDataRoot: path.join(xdgData, "omp", "profiles", "work"),
|
||||
xdgStateRoot: path.join(xdgState, "omp", "profiles", "work"),
|
||||
xdgCacheRoot: path.join(xdgCache, "omp", "profiles", "work"),
|
||||
});
|
||||
|
||||
const overridden = resolveOmpDiagnosticPaths(
|
||||
{ PI_CODING_AGENT_DIR: path.join(home, "custom-agent"), XDG_DATA_HOME: xdgData },
|
||||
home,
|
||||
"linux",
|
||||
);
|
||||
expect(overridden.agentDir).toBe(path.join(home, "custom-agent"));
|
||||
expect(overridden.agentDb).toBe(path.join(home, "custom-agent", "agent.db"));
|
||||
expect(overridden.xdgDataRoot).toBe(path.join(home, ".omp"));
|
||||
});
|
||||
|
||||
test("reports an overridden command and OMP-only paths and caveats", async () => {
|
||||
const dir = await makeTempDir();
|
||||
const script = path.join(dir, "fake-omp.cjs");
|
||||
await writeFile(script, 'process.stdout.write("omp 16.3.9\\n");\n', "utf8");
|
||||
const agentDir = path.join(dir, "agent");
|
||||
await mkdir(agentDir);
|
||||
await writeFile(path.join(agentDir, "agent.db"), "", "utf8");
|
||||
|
||||
const client = new OmpAgentClient({
|
||||
logger: createTestLogger(),
|
||||
runtimeSettings: {
|
||||
command: { mode: "replace", argv: [process.execPath, script] },
|
||||
env: {
|
||||
OMP_PROFILE: "default",
|
||||
PI_CODING_AGENT_DIR: agentDir,
|
||||
},
|
||||
},
|
||||
});
|
||||
const { diagnostic } = await client.getDiagnostic();
|
||||
|
||||
expect(diagnostic).toContain("Oh My Pi (OMP)");
|
||||
expect(diagnostic).toContain(`Configured command: ${process.execPath} ${script}`);
|
||||
expect(diagnostic).toContain(`Resolved path: ${process.execPath}`);
|
||||
expect(diagnostic).toContain("Version: omp 16.3.9");
|
||||
expect(diagnostic).toContain("Version support: 16.3.9 (supported; minimum 16.3.9)");
|
||||
expect(diagnostic).toContain("Active profile: default");
|
||||
expect(diagnostic).toContain(`Agent directory: ${agentDir}`);
|
||||
expect(diagnostic).toContain(`Agent database: ${path.join(agentDir, "agent.db")} (found)`);
|
||||
expect(diagnostic).toContain("npm-installed OMP requires Bun >= 1.3.14");
|
||||
expect(diagnostic).not.toContain("auth.json");
|
||||
expect(diagnostic).not.toContain(".pi/agent");
|
||||
});
|
||||
});
|
||||
|
||||
async function makeTempDir(): Promise<string> {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), "paseo-omp-diagnostic-"));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { PaseoToolCatalog } from "../../tools/types.js";
|
||||
import type { OmpProviderIdleScheduler } from "./agent.js";
|
||||
import { OmpHarness } from "./test-utils/omp-harness.js";
|
||||
|
||||
class ManualIdleScheduler implements OmpProviderIdleScheduler {
|
||||
private readonly retries: Array<() => void> = [];
|
||||
private readonly waiters: Array<{ count: number; resolve: () => void }> = [];
|
||||
private waitCount = 0;
|
||||
|
||||
waitForRetry(): Promise<void> {
|
||||
this.waitCount += 1;
|
||||
for (const waiter of this.waiters.splice(0)) {
|
||||
if (this.waitCount >= waiter.count) waiter.resolve();
|
||||
else this.waiters.push(waiter);
|
||||
}
|
||||
return new Promise((resolve) => this.retries.push(resolve));
|
||||
}
|
||||
|
||||
waitForWaits(count: number): Promise<void> {
|
||||
if (this.waitCount >= count) return Promise.resolve();
|
||||
return new Promise((resolve) => this.waiters.push({ count, resolve }));
|
||||
}
|
||||
|
||||
retry(): void {
|
||||
const resolve = this.retries.shift();
|
||||
if (!resolve) throw new Error("OMP has not requested an idle-state retry");
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
|
||||
function createToolCatalog(): PaseoToolCatalog {
|
||||
return {
|
||||
tools: new Map([
|
||||
[
|
||||
"create_agent",
|
||||
{
|
||||
name: "create_agent",
|
||||
description: "Create a Paseo agent.",
|
||||
handler: async () => ({ content: [] }),
|
||||
},
|
||||
],
|
||||
]),
|
||||
getTool: () => undefined,
|
||||
executeTool: async () => ({ content: [] }),
|
||||
};
|
||||
}
|
||||
|
||||
describe("OMP agent client and session", () => {
|
||||
test("owns launch configuration and registers native host tools", async () => {
|
||||
const omp = new OmpHarness();
|
||||
await omp.start({ modeId: "ask" }, createToolCatalog());
|
||||
|
||||
expect(omp.launchConfiguration()).toEqual({
|
||||
cwd: "/tmp/paseo-omp-agent-test",
|
||||
protocolMode: "rpc-ui",
|
||||
modeId: "ask",
|
||||
argv: ["omp", "--mode", "rpc-ui", "--approval-mode", "always-ask", "--thinking", "medium"],
|
||||
});
|
||||
expect(omp.registeredHostTools()).toEqual([
|
||||
[expect.objectContaining({ name: "create_agent" })],
|
||||
]);
|
||||
expect(omp.capabilities()).toMatchObject({
|
||||
supportsMcpServers: false,
|
||||
supportsNativePaseoTools: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("streams a prompt through completion", async () => {
|
||||
const omp = new OmpHarness();
|
||||
await omp.start();
|
||||
|
||||
await expect(omp.runPrompt("hello OMP", "hello from OMP")).resolves.toMatchObject({
|
||||
finalText: "hello from OMP",
|
||||
});
|
||||
expect(omp.timeline()).toEqual([
|
||||
{ type: "user_message", text: "hello OMP", messageId: "user-1" },
|
||||
{ type: "assistant_message", text: "hello from OMP", messageId: "omp-assistant-1" },
|
||||
]);
|
||||
expect(omp.completedTurnCount()).toBe(1);
|
||||
});
|
||||
|
||||
test("does not accept a follow-up until OMP reports stable idle", async () => {
|
||||
const omp = new OmpHarness();
|
||||
await omp.start();
|
||||
|
||||
await omp.runPrompt("first", "first done", [
|
||||
{ isStreaming: true, isCompacting: false },
|
||||
{ isStreaming: false, isCompacting: false },
|
||||
{ isStreaming: false, isCompacting: false },
|
||||
]);
|
||||
await expect(omp.runPrompt("follow-up", "follow-up done")).resolves.toMatchObject({
|
||||
finalText: "follow-up done",
|
||||
});
|
||||
});
|
||||
|
||||
test("stays active while OMP remains busy", async () => {
|
||||
const scheduler = new ManualIdleScheduler();
|
||||
const omp = new OmpHarness({ providerIdleScheduler: scheduler });
|
||||
await omp.start();
|
||||
|
||||
const { completion } = await omp.startPromptUntilProviderIdle("first", "first done", {
|
||||
isStreaming: true,
|
||||
isCompacting: false,
|
||||
});
|
||||
await omp.waitForProviderStateChecks(2);
|
||||
await scheduler.waitForWaits(1);
|
||||
|
||||
expect(omp.completedTurnCount()).toBe(0);
|
||||
scheduler.retry();
|
||||
await omp.waitForProviderStateChecks(3);
|
||||
await scheduler.waitForWaits(2);
|
||||
expect(omp.completedTurnCount()).toBe(0);
|
||||
|
||||
omp.reportProviderState({ isStreaming: false, isCompacting: false });
|
||||
scheduler.retry();
|
||||
await expect(completion).resolves.toMatchObject({ finalText: "first done" });
|
||||
});
|
||||
|
||||
test("stays active when OMP state checks fail", async () => {
|
||||
const scheduler = new ManualIdleScheduler();
|
||||
const omp = new OmpHarness({ providerIdleScheduler: scheduler });
|
||||
await omp.start();
|
||||
omp.failProviderStateChecks(new Error("state unavailable"));
|
||||
|
||||
const { completion } = await omp.startPromptUntilProviderIdle("first", "first done", {
|
||||
isStreaming: true,
|
||||
isCompacting: false,
|
||||
});
|
||||
await omp.waitForProviderStateChecks(2);
|
||||
await scheduler.waitForWaits(1);
|
||||
expect(omp.completedTurnCount()).toBe(0);
|
||||
|
||||
omp.failProviderStateChecks(null);
|
||||
omp.reportProviderState({ isStreaming: false, isCompacting: false });
|
||||
scheduler.retry();
|
||||
await expect(completion).resolves.toMatchObject({ finalText: "first done" });
|
||||
});
|
||||
|
||||
test("does not complete on OMP's extension-notice agent_end", async () => {
|
||||
const omp = new OmpHarness();
|
||||
await omp.start();
|
||||
|
||||
await expect(
|
||||
omp.runPromptAfterExtensionNotice("hello OMP", "model turn completed"),
|
||||
).resolves.toMatchObject({ finalText: expect.stringContaining("model turn completed") });
|
||||
expect(omp.completedTurnCount()).toBe(1);
|
||||
});
|
||||
|
||||
test("does not complete a queued model turn from OMP's local-only hint", async () => {
|
||||
const omp = new OmpHarness();
|
||||
await omp.start();
|
||||
|
||||
await expect(
|
||||
omp.runPromptAfterFalseLocalOnlyHint("hello OMP", "queued model turn completed"),
|
||||
).resolves.toMatchObject({ finalText: "queued model turn completed" });
|
||||
expect(omp.completedTurnCount()).toBe(1);
|
||||
});
|
||||
|
||||
test("resumes an OMP session and replays its history", async () => {
|
||||
const omp = new OmpHarness();
|
||||
await omp.resume(
|
||||
{
|
||||
user: { id: "user-history", text: "continue the audit" },
|
||||
assistant: { id: "assistant-history", text: "audit context restored" },
|
||||
},
|
||||
{ cwd: "/workspace/resumed", modeId: "ask", thinkingOptionId: "high" },
|
||||
);
|
||||
|
||||
expect(omp.launchConfiguration()).toEqual({
|
||||
cwd: "/workspace/resumed",
|
||||
protocolMode: "rpc-ui",
|
||||
modeId: "ask",
|
||||
session: expect.stringMatching(/[\\/]paseo-omp-resume-.*[\\/]session\.jsonl$/),
|
||||
argv: [
|
||||
"omp",
|
||||
"--mode",
|
||||
"rpc-ui",
|
||||
"--approval-mode",
|
||||
"always-ask",
|
||||
"--thinking",
|
||||
"high",
|
||||
"--session",
|
||||
expect.stringMatching(/[\\/]paseo-omp-resume-.*[\\/]session\.jsonl$/),
|
||||
],
|
||||
});
|
||||
await expect(omp.history()).resolves.toEqual([
|
||||
{ type: "user_message", text: "continue the audit", messageId: "user-history" },
|
||||
{
|
||||
type: "assistant_message",
|
||||
text: "audit context restored",
|
||||
messageId: "assistant-history",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("maps permissions and sends the selected OMP response", async () => {
|
||||
const omp = new OmpHarness();
|
||||
await omp.start();
|
||||
|
||||
omp.requestToolApproval({ id: "approval-1", tool: "bash", detail: "git status" });
|
||||
expect(omp.pendingPermissions()).toEqual([
|
||||
expect.objectContaining({ id: "approval-1", name: "bash", kind: "tool" }),
|
||||
]);
|
||||
|
||||
await omp.respondToPermission("approval-1", { behavior: "allow" });
|
||||
expect(omp.extensionUiResponses()).toEqual([
|
||||
{ id: "approval-1", response: { value: "Approve" } },
|
||||
]);
|
||||
});
|
||||
|
||||
test("exposes OMP modes and commands through the domain session", async () => {
|
||||
const omp = new OmpHarness();
|
||||
omp.queueCommands([{ name: "review", description: "Review changes", source: "skill" }]);
|
||||
await omp.start();
|
||||
|
||||
await expect(omp.availableModes()).resolves.toEqual([
|
||||
expect.objectContaining({ id: "full" }),
|
||||
expect.objectContaining({ id: "ask" }),
|
||||
]);
|
||||
await expect(omp.commands()).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ name: "handoff" }),
|
||||
expect.objectContaining({ name: "review", kind: "skill" }),
|
||||
]),
|
||||
);
|
||||
await expect(omp.setMode("ask")).resolves.toEqual(expect.objectContaining({ type: "info" }));
|
||||
});
|
||||
|
||||
test("rewinds natively, interrupts, and shuts down", async () => {
|
||||
const omp = new OmpHarness();
|
||||
await omp.start();
|
||||
|
||||
await omp.rewind("user-history", "from history");
|
||||
expect(omp.branchRequests()).toEqual(["user-history"]);
|
||||
|
||||
await omp.interruptActiveTurn("stop me");
|
||||
expect(omp.wasAborted()).toBe(true);
|
||||
expect(omp.canceledTurnCount()).toBe(1);
|
||||
|
||||
await omp.close();
|
||||
expect(omp.isClosed()).toBe(true);
|
||||
});
|
||||
|
||||
test("interrupt terminalizes in-flight tool calls and running subagents", async () => {
|
||||
const omp = new OmpHarness();
|
||||
await omp.start();
|
||||
|
||||
await omp.requireStartTurn("run something slow");
|
||||
const runtime = omp.runtime();
|
||||
runtime.beginTurn();
|
||||
runtime.emit({
|
||||
type: "tool_execution_start",
|
||||
toolCallId: "tool-1",
|
||||
toolName: "bash",
|
||||
args: { command: "sleep 30" },
|
||||
});
|
||||
runtime.emit({
|
||||
type: "subagent_lifecycle",
|
||||
payload: {
|
||||
id: "child-1",
|
||||
agent: "worker",
|
||||
status: "started",
|
||||
parentToolCallId: "tool-1",
|
||||
index: 0,
|
||||
},
|
||||
});
|
||||
expect(omp.runningToolCallIds()).toEqual(["tool-1"]);
|
||||
expect(omp.subagentUpserts()).toEqual([{ id: "child-1", status: "running" }]);
|
||||
|
||||
await omp.interrupt();
|
||||
|
||||
expect(omp.canceledTurnCount()).toBe(1);
|
||||
expect(omp.runningToolCallIds()).toEqual([]);
|
||||
expect(omp.subagentUpserts()).toEqual([
|
||||
{ id: "child-1", status: "running" },
|
||||
{ id: "child-1", status: "canceled" },
|
||||
]);
|
||||
|
||||
// Late progress after interrupt must not resurrect a running card.
|
||||
runtime.emit({
|
||||
type: "subagent_progress",
|
||||
payload: {
|
||||
id: "child-1",
|
||||
agent: "worker",
|
||||
index: 0,
|
||||
progress: { id: "child-1", status: "running" },
|
||||
parentToolCallId: "tool-1",
|
||||
},
|
||||
});
|
||||
expect(omp.runningToolCallIds()).toEqual([]);
|
||||
});
|
||||
|
||||
test("a resumed session does not re-emit replayed events as live timeline items", async () => {
|
||||
const omp = new OmpHarness();
|
||||
await omp.resume({
|
||||
user: { id: "user-history", text: "continue the audit" },
|
||||
assistant: { id: "assistant-history", text: "audit context restored" },
|
||||
});
|
||||
|
||||
const runtime = omp.runtime();
|
||||
// OMP replays pre-existing conversation on startup with --session.
|
||||
runtime.acceptPrompt("continue the audit", "user-history");
|
||||
runtime.streamAssistantText("audit context restored", "assistant-history");
|
||||
expect(omp.timeline()).toEqual([]);
|
||||
|
||||
// The first live prompt flows normally.
|
||||
await expect(omp.runPrompt("next step", "on it")).resolves.toMatchObject({
|
||||
finalText: "on it",
|
||||
});
|
||||
expect(omp.timeline()).toEqual([
|
||||
{ type: "user_message", text: "next step", messageId: "user-1" },
|
||||
{ type: "assistant_message", text: "on it", messageId: "omp-assistant-1" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("re-emitted user message_end frames dedupe by native entry id", async () => {
|
||||
const omp = new OmpHarness();
|
||||
await omp.start();
|
||||
|
||||
await expect(omp.runPrompt("hello OMP", "hello from OMP")).resolves.toMatchObject({
|
||||
finalText: "hello from OMP",
|
||||
});
|
||||
// OMP can re-send message_end for an entry it already surfaced.
|
||||
omp.runtime().acceptPrompt("hello OMP", "user-1");
|
||||
expect(omp.timeline().filter((item) => item.type === "user_message")).toEqual([
|
||||
{ type: "user_message", text: "hello OMP", messageId: "user-1" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,195 @@
|
||||
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { PassThrough } from "node:stream";
|
||||
import pino from "pino";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { OmpCliRuntime } from "./cli-runtime.js";
|
||||
import type { OmpRuntimeLaunch } from "./runtime.js";
|
||||
|
||||
type OmpChild = ChildProcessWithoutNullStreams & {
|
||||
stdin: PassThrough;
|
||||
stdout: PassThrough;
|
||||
stderr: PassThrough;
|
||||
killedSignals: Array<NodeJS.Signals | number | undefined>;
|
||||
};
|
||||
|
||||
function createOmpChild(): OmpChild {
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
stdin: new PassThrough(),
|
||||
stdout: new PassThrough(),
|
||||
stderr: new PassThrough(),
|
||||
exitCode: null,
|
||||
signalCode: null,
|
||||
killedSignals: [],
|
||||
}) as OmpChild;
|
||||
child.kill = ((signal?: NodeJS.Signals | number) => {
|
||||
child.killedSignals.push(signal);
|
||||
queueMicrotask(() => child.emit("exit", null, signal ?? null));
|
||||
return true;
|
||||
}) as ChildProcessWithoutNullStreams["kill"];
|
||||
return child;
|
||||
}
|
||||
|
||||
function createRuntime(child: OmpChild, launches: OmpRuntimeLaunch[] = []): OmpCliRuntime {
|
||||
return new OmpCliRuntime({
|
||||
logger: pino({ level: "silent" }),
|
||||
command: ["omp"],
|
||||
commandsRpcName: "get_available_commands",
|
||||
spawnProcess: (launch) => {
|
||||
launches.push(launch);
|
||||
return child;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function replyToCommands(
|
||||
child: OmpChild,
|
||||
handler: (command: Record<string, unknown>) => unknown,
|
||||
): void {
|
||||
let buffer = "";
|
||||
child.stdin.on("data", (chunk) => {
|
||||
buffer += chunk.toString();
|
||||
for (;;) {
|
||||
const newlineIndex = buffer.indexOf("\n");
|
||||
if (newlineIndex === -1) break;
|
||||
const line = buffer.slice(0, newlineIndex);
|
||||
buffer = buffer.slice(newlineIndex + 1);
|
||||
const command = JSON.parse(line) as Record<string, unknown>;
|
||||
const result = handler(command);
|
||||
child.stdout.write(
|
||||
`${JSON.stringify({
|
||||
id: command.id,
|
||||
type: "response",
|
||||
command: command.type,
|
||||
success: true,
|
||||
data: result,
|
||||
})}\n`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function withoutRequestId(command: Record<string, unknown>): Record<string, unknown> {
|
||||
const { id: _id, ...rest } = command;
|
||||
return rest;
|
||||
}
|
||||
|
||||
describe("OMP CLI runtime", () => {
|
||||
test("validates session state with the documented queued message count", async () => {
|
||||
const child = createOmpChild();
|
||||
replyToCommands(child, () => ({
|
||||
model: null,
|
||||
thinkingLevel: "medium",
|
||||
isStreaming: false,
|
||||
isCompacting: false,
|
||||
sessionId: "session-1",
|
||||
messageCount: 3,
|
||||
queuedMessageCount: 1,
|
||||
}));
|
||||
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
|
||||
|
||||
await expect(session.getState()).resolves.toMatchObject({
|
||||
sessionId: "session-1",
|
||||
messageCount: 3,
|
||||
queuedMessageCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects malformed RPC results instead of trusting transport data", async () => {
|
||||
const child = createOmpChild();
|
||||
replyToCommands(child, () => ({
|
||||
thinkingLevel: "medium",
|
||||
isStreaming: "no",
|
||||
isCompacting: false,
|
||||
sessionId: "session-1",
|
||||
messageCount: 0,
|
||||
queuedMessageCount: 0,
|
||||
}));
|
||||
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
|
||||
|
||||
await expect(session.getState()).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("emits validated known events and drops unknown frames", async () => {
|
||||
const child = createOmpChild();
|
||||
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
|
||||
const eventTypes: string[] = [];
|
||||
session.onEvent((event) => eventTypes.push(event.type));
|
||||
|
||||
child.stdout.write(`${JSON.stringify({ type: "future_control", enabled: true })}\n`);
|
||||
child.stdout.write(`${JSON.stringify({ type: "notice", level: "info", message: "ready" })}\n`);
|
||||
|
||||
expect(eventTypes).toEqual(["notice"]);
|
||||
});
|
||||
|
||||
test("lists commands through get_available_commands", async () => {
|
||||
const child = createOmpChild();
|
||||
const commandTypes: string[] = [];
|
||||
replyToCommands(child, (command) => {
|
||||
commandTypes.push(String(command.type));
|
||||
return {
|
||||
commands: [
|
||||
{ name: "prewalk", description: "Prewalk at the next action", source: "builtin" },
|
||||
],
|
||||
};
|
||||
});
|
||||
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
|
||||
|
||||
await expect(session.getCommands()).resolves.toEqual([
|
||||
{
|
||||
name: "prewalk",
|
||||
description: "Prewalk at the next action",
|
||||
source: "builtin",
|
||||
},
|
||||
]);
|
||||
expect(commandTypes).toEqual(["get_available_commands"]);
|
||||
});
|
||||
|
||||
test("accepts model catalogs with null maxTokens from newer OMP binaries", async () => {
|
||||
const child = createOmpChild();
|
||||
replyToCommands(child, () => ({
|
||||
models: [
|
||||
{
|
||||
provider: "openai-codex",
|
||||
id: "gpt-5.6-sol",
|
||||
name: "gpt-5.6-sol",
|
||||
maxTokens: null,
|
||||
},
|
||||
],
|
||||
}));
|
||||
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
|
||||
|
||||
await expect(session.getAvailableModels()).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
provider: "openai-codex",
|
||||
id: "gpt-5.6-sol",
|
||||
maxTokens: null,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test("wraps OMP subagent RPC commands", async () => {
|
||||
const child = createOmpChild();
|
||||
const commands: Record<string, unknown>[] = [];
|
||||
replyToCommands(child, (command) => {
|
||||
commands.push(command);
|
||||
return undefined;
|
||||
});
|
||||
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
|
||||
|
||||
await session.setSubagentSubscription("events");
|
||||
|
||||
expect(commands.map(withoutRequestId)).toEqual([
|
||||
{ type: "set_subagent_subscription", level: "events" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("accepts the empty prompt acknowledgement emitted by OMP 17", async () => {
|
||||
const child = createOmpChild();
|
||||
replyToCommands(child, () => undefined);
|
||||
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
|
||||
|
||||
await expect(session.prompt("hello")).resolves.toEqual({ requestId: "req_1" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,281 @@
|
||||
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import type { ProviderRuntimeSettings } from "../../provider-launch-config.js";
|
||||
import { JsonlRpcProcess, type JsonlRpcLaunch } from "../jsonl-rpc-process.js";
|
||||
import {
|
||||
buildOmpLaunch,
|
||||
type OmpRuntime,
|
||||
type OmpRuntimeLaunch,
|
||||
type OmpRuntimeSession,
|
||||
type OmpStartSessionInput,
|
||||
} from "./runtime.js";
|
||||
import {
|
||||
OmpBranchMessagesResultSchema,
|
||||
OmpBranchResultSchema,
|
||||
OmpCommandsResultSchema,
|
||||
OmpHostToolsResultSchema,
|
||||
OmpMessagesResultSchema,
|
||||
OmpModelSchema,
|
||||
OmpModelsResultSchema,
|
||||
OmpPromptAckSchema,
|
||||
OmpRpcCommandSchema,
|
||||
OmpRuntimeEventSchema,
|
||||
OmpSessionStateSchema,
|
||||
OmpSessionStatsSchema,
|
||||
type OmpThinkingLevel,
|
||||
type OmpAgentMessage,
|
||||
type OmpModel,
|
||||
type OmpPromptAck,
|
||||
type OmpRpcCommand,
|
||||
type OmpRpcHostToolDefinition,
|
||||
type OmpRpcHostToolResult,
|
||||
type OmpRpcHostToolUpdate,
|
||||
type OmpRpcSlashCommand,
|
||||
type OmpRuntimeEvent,
|
||||
type OmpSessionState,
|
||||
type OmpSessionStats,
|
||||
type OmpSubagentSubscriptionLevel,
|
||||
} from "./rpc-types.js";
|
||||
|
||||
const DEFAULT_OMP_COMMAND: [string, ...string[]] = [process.env.OMP_COMMAND ?? "omp"];
|
||||
const DEFAULT_COMMANDS_RPC_NAME = "get_available_commands";
|
||||
|
||||
export interface OmpCliRuntimeOptions {
|
||||
logger: Logger;
|
||||
runtimeSettings?: ProviderRuntimeSettings;
|
||||
command?: [string, ...string[]];
|
||||
commandsRpcName?: "get_available_commands";
|
||||
spawnProcess?: (launch: OmpRuntimeLaunch) => ChildProcessWithoutNullStreams;
|
||||
}
|
||||
|
||||
export class OmpCliRuntime implements OmpRuntime {
|
||||
private readonly command: [string, ...string[]];
|
||||
private readonly commandsRpcName: "get_available_commands";
|
||||
private readonly spawnProcess?: (launch: OmpRuntimeLaunch) => ChildProcessWithoutNullStreams;
|
||||
|
||||
constructor(private readonly options: OmpCliRuntimeOptions) {
|
||||
this.command = options.command ?? DEFAULT_OMP_COMMAND;
|
||||
this.commandsRpcName = options.commandsRpcName ?? DEFAULT_COMMANDS_RPC_NAME;
|
||||
this.spawnProcess = options.spawnProcess;
|
||||
}
|
||||
|
||||
async startSession(input: OmpStartSessionInput): Promise<OmpRuntimeSession> {
|
||||
const launch = buildOmpLaunch({
|
||||
command: this.command,
|
||||
runtimeSettings: this.options.runtimeSettings,
|
||||
session: input,
|
||||
});
|
||||
const [command, ...args] = launch.argv;
|
||||
const processLaunch: JsonlRpcLaunch = {
|
||||
command,
|
||||
args,
|
||||
cwd: launch.cwd,
|
||||
env: launch.env,
|
||||
};
|
||||
const spawn = this.spawnProcess;
|
||||
const processOptions = {
|
||||
launch: processLaunch,
|
||||
logger: this.options.logger,
|
||||
diagnosticName: "OMP RPC",
|
||||
...(spawn ? { spawn: () => spawn(launch) } : {}),
|
||||
};
|
||||
const process = new JsonlRpcProcess(processOptions);
|
||||
return new OmpCliRuntimeSession(process, this.commandsRpcName);
|
||||
}
|
||||
}
|
||||
|
||||
class OmpCliRuntimeSession implements OmpRuntimeSession {
|
||||
private readonly subscribers = new Set<(event: OmpRuntimeEvent) => void>();
|
||||
activeBranchEntryId?: string;
|
||||
|
||||
constructor(
|
||||
private readonly process: JsonlRpcProcess,
|
||||
private readonly commandsRpcName: "get_available_commands",
|
||||
) {
|
||||
process.onMessage((message) => {
|
||||
const event = OmpRuntimeEventSchema.safeParse(message);
|
||||
if (event.success) {
|
||||
this.emit(event.data);
|
||||
}
|
||||
});
|
||||
process.onExit(({ error }) => {
|
||||
this.emit({ type: "process_exit", error: error.message });
|
||||
});
|
||||
}
|
||||
|
||||
onEvent(callback: (event: OmpRuntimeEvent) => void): () => void {
|
||||
this.subscribers.add(callback);
|
||||
return () => {
|
||||
this.subscribers.delete(callback);
|
||||
};
|
||||
}
|
||||
|
||||
async prompt(
|
||||
message: string,
|
||||
images?: Array<{ type: "image"; data: string; mimeType: string }>,
|
||||
): Promise<OmpPromptAck> {
|
||||
const { id: requestId, promise } = this.process.startRequest({
|
||||
type: "prompt",
|
||||
message,
|
||||
...(images?.length ? { images } : {}),
|
||||
});
|
||||
const ack = OmpPromptAckSchema.parse(await promise) ?? {};
|
||||
return { requestId, ...ack };
|
||||
}
|
||||
|
||||
async compact(customInstructions?: string): Promise<void> {
|
||||
await this.request({
|
||||
type: "compact",
|
||||
...(customInstructions ? { customInstructions } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
async setAutoCompaction(enabled: boolean): Promise<void> {
|
||||
await this.request({ type: "set_auto_compaction", enabled });
|
||||
}
|
||||
|
||||
async abort(): Promise<void> {
|
||||
await this.request({ type: "abort" });
|
||||
}
|
||||
|
||||
async getState(): Promise<OmpSessionState> {
|
||||
return OmpSessionStateSchema.parse(await this.request({ type: "get_state" }));
|
||||
}
|
||||
|
||||
async getMessages(): Promise<OmpAgentMessage[]> {
|
||||
const data = OmpMessagesResultSchema.parse(await this.request({ type: "get_messages" }));
|
||||
return data.messages ?? [];
|
||||
}
|
||||
|
||||
async getAvailableModels(timeoutMs?: number): Promise<OmpModel[]> {
|
||||
const data = OmpModelsResultSchema.parse(
|
||||
await this.request({ type: "get_available_models" }, timeoutMs),
|
||||
);
|
||||
return data.models ?? [];
|
||||
}
|
||||
|
||||
async setModel(provider: string, modelId: string): Promise<OmpModel> {
|
||||
return OmpModelSchema.parse(await this.request({ type: "set_model", provider, modelId }));
|
||||
}
|
||||
|
||||
async setThinkingLevel(level: OmpThinkingLevel): Promise<void> {
|
||||
await this.request({ type: "set_thinking_level", level });
|
||||
}
|
||||
|
||||
async getSessionStats(): Promise<OmpSessionStats> {
|
||||
// COMPAT(ompGetStateFallback): added in v0.1.105 — older OMP binaries
|
||||
// lack the `get_session_stats` RPC command; fall back to extracting
|
||||
// context window usage from `get_state`. Remove after 2027-01-10 once the
|
||||
// supported OMP floor includes `get_session_stats`.
|
||||
let stats: OmpSessionStats | undefined;
|
||||
try {
|
||||
stats = OmpSessionStatsSchema.parse(await this.request({ type: "get_session_stats" }));
|
||||
} catch {
|
||||
// get_session_stats not supported by this binary — will try get_state below
|
||||
}
|
||||
if (stats?.tokens == null && stats?.cost == null && stats?.contextUsage == null) {
|
||||
try {
|
||||
const state = OmpSessionStateSchema.parse(await this.request({ type: "get_state" }));
|
||||
const ctx = state.contextUsage;
|
||||
if (ctx) {
|
||||
return {
|
||||
contextUsage: {
|
||||
tokens: typeof ctx.tokens === "number" ? ctx.tokens : undefined,
|
||||
contextWindow: typeof ctx.contextWindow === "number" ? ctx.contextWindow : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// get_state also failed — nothing we can do
|
||||
}
|
||||
}
|
||||
return stats ?? {};
|
||||
}
|
||||
|
||||
async getCommands(): Promise<OmpRpcSlashCommand[]> {
|
||||
const data = OmpCommandsResultSchema.parse(await this.request({ type: this.commandsRpcName }));
|
||||
return data.commands ?? [];
|
||||
}
|
||||
|
||||
async setSubagentSubscription(level: OmpSubagentSubscriptionLevel): Promise<void> {
|
||||
await this.request({ type: "set_subagent_subscription", level });
|
||||
}
|
||||
|
||||
async setHostTools(tools: OmpRpcHostToolDefinition[]): Promise<string[]> {
|
||||
const data = OmpHostToolsResultSchema.parse(
|
||||
await this.request({ type: "set_host_tools", tools }),
|
||||
);
|
||||
return data.toolNames ?? [];
|
||||
}
|
||||
|
||||
sendHostToolResult(result: OmpRpcHostToolResult): void {
|
||||
this.process.send({ ...result });
|
||||
}
|
||||
|
||||
sendHostToolUpdate(update: OmpRpcHostToolUpdate): void {
|
||||
this.process.send({ ...update });
|
||||
}
|
||||
|
||||
async branch(entryId: string): Promise<{ text: string }> {
|
||||
const data = OmpBranchResultSchema.parse(await this.request({ type: "branch", entryId }));
|
||||
if (data.cancelled === true) {
|
||||
throw new Error("OMP branch was cancelled");
|
||||
}
|
||||
if (typeof data.text !== "string") {
|
||||
throw new Error("OMP branch response did not include restored prompt text");
|
||||
}
|
||||
this.activeBranchEntryId = entryId;
|
||||
return { text: data.text };
|
||||
}
|
||||
|
||||
async getBranchMessages(): Promise<Array<{ entryId: string; text: string }>> {
|
||||
const data = OmpBranchMessagesResultSchema.parse(
|
||||
await this.request({ type: "get_branch_messages" }),
|
||||
);
|
||||
return data.messages ?? [];
|
||||
}
|
||||
|
||||
steer(message: string, images?: Array<{ type: "image"; data: string; mimeType: string }>): void {
|
||||
this.process.send({ type: "steer", message, ...(images?.length ? { images } : {}) });
|
||||
}
|
||||
|
||||
followUp(
|
||||
message: string,
|
||||
images?: Array<{ type: "image"; data: string; mimeType: string }>,
|
||||
): void {
|
||||
this.process.send({ type: "follow_up", message, ...(images?.length ? { images } : {}) });
|
||||
}
|
||||
|
||||
async handoff(customInstructions?: string): Promise<void> {
|
||||
await this.request({
|
||||
type: "handoff",
|
||||
...(customInstructions ? { customInstructions } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
respondToExtensionUiRequest(
|
||||
id: string,
|
||||
response: { value?: string; confirmed?: boolean; cancelled?: boolean },
|
||||
): void {
|
||||
this.process.send({ type: "extension_ui_response", id, ...response });
|
||||
}
|
||||
|
||||
cancelExtensionUiRequest(id: string): void {
|
||||
this.respondToExtensionUiRequest(id, { cancelled: true });
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.process.close(new Error("OMP RPC session is closed"));
|
||||
}
|
||||
|
||||
private request(command: OmpRpcCommand, timeoutMs?: number): Promise<unknown> {
|
||||
return this.process.request(OmpRpcCommandSchema.parse(command), timeoutMs);
|
||||
}
|
||||
|
||||
private emit(event: OmpRuntimeEvent): void {
|
||||
for (const subscriber of this.subscribers) {
|
||||
subscriber(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { mapOmpAvailableCommandsUpdate, mapOmpSlashCommands } from "./commands.js";
|
||||
|
||||
describe("OMP slash command mapper", () => {
|
||||
test("maps command updates and preserves input hints", () => {
|
||||
const commands = mapOmpAvailableCommandsUpdate({
|
||||
type: "available_commands_update",
|
||||
commands: [
|
||||
{ name: "todo", description: "Manage todos", input: { hint: "<subcommand>" } },
|
||||
{ name: "fast", description: "Toggle fast mode", input: { hint: "[on|off|status]" } },
|
||||
{ name: "handoff", description: "Start a handoff" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(commands?.find((command) => command.name === "todo")).toEqual({
|
||||
name: "todo",
|
||||
description: "Manage todos",
|
||||
argumentHint: "<subcommand>",
|
||||
kind: "command",
|
||||
});
|
||||
expect(commands?.find((command) => command.name === "fast")).toEqual({
|
||||
name: "fast",
|
||||
description: "Toggle fast mode",
|
||||
argumentHint: "[on|off|status]",
|
||||
kind: "command",
|
||||
});
|
||||
expect(commands?.find((command) => command.name === "handoff")).toEqual({
|
||||
name: "handoff",
|
||||
description: "Start a handoff",
|
||||
argumentHint: "[instructions]",
|
||||
kind: "command",
|
||||
});
|
||||
});
|
||||
|
||||
test("maps source-attributed OMP 17 commands", () => {
|
||||
const commands = mapOmpAvailableCommandsUpdate({
|
||||
type: "available_commands_update",
|
||||
commands: [
|
||||
{
|
||||
name: "prewalk",
|
||||
description: "Prewalk at the next action",
|
||||
source: "builtin",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(commands?.find((command) => command.name === "prewalk")).toEqual({
|
||||
name: "prewalk",
|
||||
description: "Prewalk at the next action",
|
||||
argumentHint: "",
|
||||
kind: "command",
|
||||
});
|
||||
});
|
||||
|
||||
test("drops malformed command updates", () => {
|
||||
expect(
|
||||
mapOmpAvailableCommandsUpdate({ type: "available_commands_update", commands: [{}] }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test("adds OMP-only out-of-band commands to handled built-ins", () => {
|
||||
expect(mapOmpSlashCommands([]).map((command) => command.name)).toEqual([
|
||||
"compact",
|
||||
"autocompact",
|
||||
"handoff",
|
||||
"steer",
|
||||
"follow-up",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { AgentSlashCommand, AgentSlashCommandKind } from "../../agent-sdk-types.js";
|
||||
import type { OmpRpcSlashCommand } from "./rpc-types.js";
|
||||
import { OmpAvailableCommandsUpdateEventSchema, type OmpAvailableCommand } from "./rpc-types.js";
|
||||
|
||||
export const OMP_HANDLED_BUILTIN_SLASH_COMMANDS: readonly AgentSlashCommand[] = [
|
||||
{
|
||||
name: "compact",
|
||||
description: "Manually compact the session context",
|
||||
argumentHint: "[instructions]",
|
||||
kind: "command",
|
||||
},
|
||||
{
|
||||
name: "autocompact",
|
||||
description: "Toggle automatic context compaction",
|
||||
argumentHint: "[on|off|toggle]",
|
||||
kind: "command",
|
||||
},
|
||||
{
|
||||
name: "handoff",
|
||||
description: "Hand off from planning to implementation",
|
||||
argumentHint: "[instructions]",
|
||||
kind: "command",
|
||||
},
|
||||
{
|
||||
name: "steer",
|
||||
description: "Steer the active OMP turn",
|
||||
argumentHint: "<message>",
|
||||
kind: "command",
|
||||
},
|
||||
{
|
||||
name: "follow-up",
|
||||
description: "Queue a follow-up message for OMP",
|
||||
argumentHint: "<message>",
|
||||
kind: "command",
|
||||
},
|
||||
];
|
||||
|
||||
export function mapOmpSlashCommands(commands: readonly OmpAvailableCommand[]): AgentSlashCommand[] {
|
||||
const mappedCommands = new Map<string, AgentSlashCommand>(
|
||||
OMP_HANDLED_BUILTIN_SLASH_COMMANDS.map((command) => [command.name, { ...command }]),
|
||||
);
|
||||
for (const command of commands) {
|
||||
const knownCommand = mappedCommands.get(command.name);
|
||||
mappedCommands.set(command.name, {
|
||||
name: command.name,
|
||||
description: command.description ?? command.source ?? "command",
|
||||
argumentHint: command.input?.hint ?? knownCommand?.argumentHint ?? "",
|
||||
kind: mapOmpCommandKind(command.source),
|
||||
});
|
||||
}
|
||||
return [...mappedCommands.values()];
|
||||
}
|
||||
|
||||
export function mapOmpRuntimeSlashCommands(
|
||||
commands: readonly OmpRpcSlashCommand[],
|
||||
): AgentSlashCommand[] {
|
||||
return mapOmpSlashCommands(
|
||||
commands.map((command) => ({
|
||||
name: command.name,
|
||||
...(command.description ? { description: command.description } : {}),
|
||||
source: command.source,
|
||||
...(command.input ? { input: command.input } : {}),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
export function mapOmpAvailableCommandsUpdate(event: unknown): AgentSlashCommand[] | null {
|
||||
const parsed = OmpAvailableCommandsUpdateEventSchema.safeParse(event);
|
||||
return parsed.success ? mapOmpSlashCommands(parsed.data.commands) : null;
|
||||
}
|
||||
|
||||
function mapOmpCommandKind(source: string | undefined): AgentSlashCommandKind {
|
||||
return source === "skill" ? "skill" : "command";
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { mapOmpRuntimeEventToTimelineItem } from "./event-mapper.js";
|
||||
|
||||
describe("OMP runtime event mapper", () => {
|
||||
test("maps notice events to timeline status lines", () => {
|
||||
const event = {
|
||||
type: "notice",
|
||||
level: "warning",
|
||||
message: "Provider quota is getting low",
|
||||
source: "anthropic",
|
||||
};
|
||||
|
||||
expect(mapOmpRuntimeEventToTimelineItem(event)).toMatchObject({
|
||||
handled: true,
|
||||
item: {
|
||||
type: "tool_call",
|
||||
callId: expect.stringMatching(/^omp-notice:/),
|
||||
name: "omp_notice",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: {
|
||||
type: "plain_text",
|
||||
label: "OMP warning notice from anthropic",
|
||||
text: "Provider quota is getting low",
|
||||
icon: "sparkles",
|
||||
},
|
||||
metadata: {
|
||||
synthetic: true,
|
||||
source: "omp_notice",
|
||||
level: "warning",
|
||||
eventSource: "anthropic",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("maps goal_updated events to provider notices and timeline status lines", () => {
|
||||
expect(
|
||||
mapOmpRuntimeEventToTimelineItem({
|
||||
type: "goal_updated",
|
||||
goal: {
|
||||
id: "goal-1",
|
||||
objective: "Ship Phase 5",
|
||||
status: "active",
|
||||
tokenBudget: 2000,
|
||||
tokensUsed: 345,
|
||||
timeUsedSeconds: 12,
|
||||
},
|
||||
state: {
|
||||
enabled: true,
|
||||
mode: "active",
|
||||
},
|
||||
}),
|
||||
).toMatchObject({
|
||||
handled: true,
|
||||
item: {
|
||||
type: "tool_call",
|
||||
callId: "omp-goal:goal-1",
|
||||
name: "omp_goal_updated",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: {
|
||||
type: "plain_text",
|
||||
label: "OMP goal active",
|
||||
text: "Ship Phase 5\nStatus: active\nTokens used: 345\nToken budget: 2000\nTime used: 12s\nMode: active",
|
||||
icon: "brain",
|
||||
},
|
||||
metadata: {
|
||||
synthetic: true,
|
||||
source: "omp_goal_updated",
|
||||
goalId: "goal-1",
|
||||
goalStatus: "active",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("maps retry telemetry to status-line timeline items", () => {
|
||||
expect(
|
||||
mapOmpRuntimeEventToTimelineItem({
|
||||
type: "auto_retry_start",
|
||||
attempt: 1,
|
||||
maxAttempts: 3,
|
||||
delayMs: 25,
|
||||
errorMessage: "retrying",
|
||||
errorId: 7,
|
||||
}),
|
||||
).toMatchObject({
|
||||
handled: true,
|
||||
item: {
|
||||
type: "tool_call",
|
||||
callId: "omp-auto-retry:1",
|
||||
name: "omp_auto_retry",
|
||||
status: "running",
|
||||
error: null,
|
||||
detail: {
|
||||
type: "plain_text",
|
||||
label: "OMP retry 1/3",
|
||||
text: "Retrying in 25ms: retrying",
|
||||
icon: "sparkles",
|
||||
},
|
||||
metadata: {
|
||||
source: "omp_auto_retry_start",
|
||||
attempt: 1,
|
||||
maxAttempts: 3,
|
||||
delayMs: 25,
|
||||
errorId: 7,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
mapOmpRuntimeEventToTimelineItem({
|
||||
type: "auto_retry_end",
|
||||
success: false,
|
||||
attempt: 1,
|
||||
finalError: "still failed",
|
||||
}),
|
||||
).toMatchObject({
|
||||
handled: true,
|
||||
item: {
|
||||
type: "tool_call",
|
||||
callId: "omp-auto-retry:1",
|
||||
name: "omp_auto_retry",
|
||||
status: "failed",
|
||||
error: "still failed",
|
||||
detail: {
|
||||
type: "plain_text",
|
||||
label: "OMP retry 1 failed",
|
||||
text: "still failed",
|
||||
icon: "sparkles",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
mapOmpRuntimeEventToTimelineItem({
|
||||
type: "retry_fallback_applied",
|
||||
from: "anthropic/claude-sonnet",
|
||||
to: "openai/gpt-5",
|
||||
role: "primary",
|
||||
}),
|
||||
).toMatchObject({
|
||||
handled: true,
|
||||
item: {
|
||||
type: "tool_call",
|
||||
name: "omp_retry_fallback",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: {
|
||||
type: "plain_text",
|
||||
label: "OMP fallback applied for primary",
|
||||
text: "anthropic/claude-sonnet -> openai/gpt-5",
|
||||
icon: "sparkles",
|
||||
},
|
||||
metadata: {
|
||||
source: "omp_retry_fallback_applied",
|
||||
role: "primary",
|
||||
from: "anthropic/claude-sonnet",
|
||||
to: "openai/gpt-5",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
mapOmpRuntimeEventToTimelineItem({
|
||||
type: "retry_fallback_succeeded",
|
||||
model: "openai/gpt-5",
|
||||
role: "primary",
|
||||
}),
|
||||
).toMatchObject({
|
||||
handled: true,
|
||||
item: {
|
||||
type: "tool_call",
|
||||
name: "omp_retry_fallback",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: {
|
||||
type: "plain_text",
|
||||
label: "OMP fallback succeeded for primary",
|
||||
text: "Using openai/gpt-5",
|
||||
icon: "sparkles",
|
||||
},
|
||||
metadata: {
|
||||
source: "omp_retry_fallback_succeeded",
|
||||
role: "primary",
|
||||
model: "openai/gpt-5",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("maps auto compaction events to compaction timeline items", () => {
|
||||
expect(
|
||||
mapOmpRuntimeEventToTimelineItem({
|
||||
type: "auto_compaction_start",
|
||||
reason: "threshold",
|
||||
action: "context-full",
|
||||
}),
|
||||
).toEqual({
|
||||
handled: true,
|
||||
item: {
|
||||
type: "compaction",
|
||||
status: "loading",
|
||||
trigger: "auto",
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
mapOmpRuntimeEventToTimelineItem({
|
||||
type: "auto_compaction_end",
|
||||
action: "context-full",
|
||||
result: {
|
||||
summary: "trimmed",
|
||||
shortSummary: "trimmed",
|
||||
firstKeptEntryId: "entry-1",
|
||||
tokensBefore: 123,
|
||||
},
|
||||
aborted: false,
|
||||
willRetry: false,
|
||||
}),
|
||||
).toEqual({
|
||||
handled: true,
|
||||
item: {
|
||||
type: "compaction",
|
||||
status: "completed",
|
||||
trigger: "auto",
|
||||
preTokens: 123,
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
mapOmpRuntimeEventToTimelineItem({
|
||||
type: "auto_compaction_end",
|
||||
aborted: true,
|
||||
willRetry: true,
|
||||
skipped: true,
|
||||
errorMessage: "compaction failed",
|
||||
}),
|
||||
).toEqual({
|
||||
handled: true,
|
||||
item: {
|
||||
type: "compaction",
|
||||
status: "completed",
|
||||
trigger: "auto",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("leaves unknown events unhandled", () => {
|
||||
expect(mapOmpRuntimeEventToTimelineItem({ type: "message_start" })).toEqual({
|
||||
handled: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("log-drops malformed known OMP events", () => {
|
||||
expect(
|
||||
mapOmpRuntimeEventToTimelineItem({
|
||||
type: "notice",
|
||||
level: "loud",
|
||||
message: "bad",
|
||||
}),
|
||||
).toEqual({
|
||||
handled: true,
|
||||
item: null,
|
||||
logReason: "malformed_omp_notice",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,336 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import type { AgentTimelineItem, ToolCallIconName } from "../../agent-sdk-types.js";
|
||||
import {
|
||||
OmpAutoCompactionEndEventSchema,
|
||||
OmpAutoCompactionStartEventSchema,
|
||||
OmpAutoRetryEndEventSchema,
|
||||
OmpAutoRetryStartEventSchema,
|
||||
OmpGoalUpdatedEventSchema,
|
||||
OmpNoticeEventSchema,
|
||||
OmpRetryFallbackAppliedEventSchema,
|
||||
OmpRetryFallbackSucceededEventSchema,
|
||||
type OmpGoal,
|
||||
type OmpGoalUpdatedEvent,
|
||||
} from "./rpc-types.js";
|
||||
|
||||
type OmpTelemetryToolCallItem = Extract<AgentTimelineItem, { type: "tool_call" }>;
|
||||
|
||||
export type OmpRuntimeEventMapping =
|
||||
| {
|
||||
handled: false;
|
||||
}
|
||||
| {
|
||||
handled: true;
|
||||
item: AgentTimelineItem;
|
||||
}
|
||||
| {
|
||||
handled: true;
|
||||
item: null;
|
||||
logReason: string;
|
||||
};
|
||||
|
||||
interface StatusLineNoticeInput {
|
||||
callId: string;
|
||||
name: string;
|
||||
label: string;
|
||||
text?: string;
|
||||
icon?: ToolCallIconName;
|
||||
status?: "running" | "completed" | "failed";
|
||||
error?: unknown;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function mapOmpRuntimeEventToTimelineItem(event: unknown): OmpRuntimeEventMapping {
|
||||
const type = readEventType(event);
|
||||
switch (type) {
|
||||
case "notice":
|
||||
return mapNoticeEvent(event);
|
||||
case "goal_updated":
|
||||
return mapGoalUpdatedEvent(event);
|
||||
case "auto_retry_start":
|
||||
return mapAutoRetryStartEvent(event);
|
||||
case "auto_retry_end":
|
||||
return mapAutoRetryEndEvent(event);
|
||||
case "retry_fallback_applied":
|
||||
return mapRetryFallbackAppliedEvent(event);
|
||||
case "retry_fallback_succeeded":
|
||||
return mapRetryFallbackSucceededEvent(event);
|
||||
case "auto_compaction_start":
|
||||
return mapAutoCompactionStartEvent(event);
|
||||
case "auto_compaction_end":
|
||||
return mapAutoCompactionEndEvent(event);
|
||||
default:
|
||||
return { handled: false };
|
||||
}
|
||||
}
|
||||
|
||||
function mapNoticeEvent(event: unknown): OmpRuntimeEventMapping {
|
||||
const parsed = OmpNoticeEventSchema.safeParse(event);
|
||||
if (!parsed.success) {
|
||||
return { handled: true, item: null, logReason: "malformed_omp_notice" };
|
||||
}
|
||||
const label = parsed.data.source
|
||||
? `OMP ${parsed.data.level} notice from ${parsed.data.source}`
|
||||
: `OMP ${parsed.data.level} notice`;
|
||||
return {
|
||||
handled: true,
|
||||
item: buildStatusLineNotice({
|
||||
callId: `omp-notice:${hashParts(parsed.data.level, parsed.data.source ?? "", parsed.data.message)}`,
|
||||
name: "omp_notice",
|
||||
label,
|
||||
text: parsed.data.message,
|
||||
icon: "sparkles",
|
||||
status: parsed.data.level === "error" ? "failed" : "completed",
|
||||
error: parsed.data.level === "error" ? parsed.data.message : undefined,
|
||||
metadata: {
|
||||
synthetic: true,
|
||||
source: "omp_notice",
|
||||
level: parsed.data.level,
|
||||
...(parsed.data.source ? { eventSource: parsed.data.source } : {}),
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function mapGoalUpdatedEvent(event: unknown): OmpRuntimeEventMapping {
|
||||
const parsed = OmpGoalUpdatedEventSchema.safeParse(event);
|
||||
if (!parsed.success) {
|
||||
return { handled: true, item: null, logReason: "malformed_omp_goal_updated" };
|
||||
}
|
||||
const goal = resolveGoal(parsed.data);
|
||||
const label = goal?.status ? `OMP goal ${goal.status}` : "OMP goal updated";
|
||||
const text = formatGoalText(parsed.data, goal);
|
||||
return {
|
||||
handled: true,
|
||||
item: buildStatusLineNotice({
|
||||
callId: `omp-goal:${goal?.id ?? hashParts(text)}`,
|
||||
name: "omp_goal_updated",
|
||||
label,
|
||||
text,
|
||||
icon: "brain",
|
||||
metadata: {
|
||||
synthetic: true,
|
||||
source: "omp_goal_updated",
|
||||
...(goal?.id ? { goalId: goal.id } : {}),
|
||||
...(goal?.status ? { goalStatus: goal.status } : {}),
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function mapAutoRetryStartEvent(event: unknown): OmpRuntimeEventMapping {
|
||||
const parsed = OmpAutoRetryStartEventSchema.safeParse(event);
|
||||
if (!parsed.success) {
|
||||
return { handled: true, item: null, logReason: "malformed_omp_auto_retry_start" };
|
||||
}
|
||||
const data = parsed.data;
|
||||
return {
|
||||
handled: true,
|
||||
item: buildStatusLineNotice({
|
||||
callId: `omp-auto-retry:${data.attempt}`,
|
||||
name: "omp_auto_retry",
|
||||
label: `OMP retry ${data.attempt}/${data.maxAttempts}`,
|
||||
text: `Retrying in ${formatDelay(data.delayMs)}: ${data.errorMessage}`,
|
||||
icon: "sparkles",
|
||||
status: "running",
|
||||
metadata: {
|
||||
synthetic: true,
|
||||
source: "omp_auto_retry_start",
|
||||
attempt: data.attempt,
|
||||
maxAttempts: data.maxAttempts,
|
||||
delayMs: data.delayMs,
|
||||
...(data.errorId !== undefined ? { errorId: data.errorId } : {}),
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function mapAutoRetryEndEvent(event: unknown): OmpRuntimeEventMapping {
|
||||
const parsed = OmpAutoRetryEndEventSchema.safeParse(event);
|
||||
if (!parsed.success) {
|
||||
return { handled: true, item: null, logReason: "malformed_omp_auto_retry_end" };
|
||||
}
|
||||
const data = parsed.data;
|
||||
const status = data.success ? "completed" : "failed";
|
||||
return {
|
||||
handled: true,
|
||||
item: buildStatusLineNotice({
|
||||
callId: `omp-auto-retry:${data.attempt}`,
|
||||
name: "omp_auto_retry",
|
||||
label: data.success
|
||||
? `OMP retry ${data.attempt} recovered`
|
||||
: `OMP retry ${data.attempt} failed`,
|
||||
text: data.finalError ?? (data.success ? "Retry recovered." : "Retry failed."),
|
||||
icon: "sparkles",
|
||||
status,
|
||||
error: data.success ? undefined : (data.finalError ?? "OMP retry failed"),
|
||||
metadata: {
|
||||
synthetic: true,
|
||||
source: "omp_auto_retry_end",
|
||||
attempt: data.attempt,
|
||||
success: data.success,
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function mapRetryFallbackAppliedEvent(event: unknown): OmpRuntimeEventMapping {
|
||||
const parsed = OmpRetryFallbackAppliedEventSchema.safeParse(event);
|
||||
if (!parsed.success) {
|
||||
return { handled: true, item: null, logReason: "malformed_omp_retry_fallback_applied" };
|
||||
}
|
||||
const data = parsed.data;
|
||||
return {
|
||||
handled: true,
|
||||
item: buildStatusLineNotice({
|
||||
callId: `omp-retry-fallback:${hashParts(data.role, data.from, data.to)}`,
|
||||
name: "omp_retry_fallback",
|
||||
label: `OMP fallback applied for ${data.role}`,
|
||||
text: `${data.from} -> ${data.to}`,
|
||||
icon: "sparkles",
|
||||
metadata: {
|
||||
synthetic: true,
|
||||
source: "omp_retry_fallback_applied",
|
||||
role: data.role,
|
||||
from: data.from,
|
||||
to: data.to,
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function mapRetryFallbackSucceededEvent(event: unknown): OmpRuntimeEventMapping {
|
||||
const parsed = OmpRetryFallbackSucceededEventSchema.safeParse(event);
|
||||
if (!parsed.success) {
|
||||
return { handled: true, item: null, logReason: "malformed_omp_retry_fallback_succeeded" };
|
||||
}
|
||||
const data = parsed.data;
|
||||
return {
|
||||
handled: true,
|
||||
item: buildStatusLineNotice({
|
||||
callId: `omp-retry-fallback-succeeded:${hashParts(data.role, data.model)}`,
|
||||
name: "omp_retry_fallback",
|
||||
label: `OMP fallback succeeded for ${data.role}`,
|
||||
text: `Using ${data.model}`,
|
||||
icon: "sparkles",
|
||||
metadata: {
|
||||
synthetic: true,
|
||||
source: "omp_retry_fallback_succeeded",
|
||||
role: data.role,
|
||||
model: data.model,
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function mapAutoCompactionStartEvent(event: unknown): OmpRuntimeEventMapping {
|
||||
const parsed = OmpAutoCompactionStartEventSchema.safeParse(event);
|
||||
if (!parsed.success) {
|
||||
return { handled: true, item: null, logReason: "malformed_omp_auto_compaction_start" };
|
||||
}
|
||||
return {
|
||||
handled: true,
|
||||
item: {
|
||||
type: "compaction",
|
||||
status: "loading",
|
||||
trigger: "auto",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function mapAutoCompactionEndEvent(event: unknown): OmpRuntimeEventMapping {
|
||||
const parsed = OmpAutoCompactionEndEventSchema.safeParse(event);
|
||||
if (!parsed.success) {
|
||||
return { handled: true, item: null, logReason: "malformed_omp_auto_compaction_end" };
|
||||
}
|
||||
const preTokens = readCompactionPreTokens(parsed.data.result);
|
||||
// Wire compaction items only support loading/completed, so every end event clears loading.
|
||||
return {
|
||||
handled: true,
|
||||
item: {
|
||||
type: "compaction",
|
||||
status: "completed",
|
||||
trigger: "auto",
|
||||
...(preTokens !== undefined ? { preTokens } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildStatusLineNotice(input: StatusLineNoticeInput): OmpTelemetryToolCallItem {
|
||||
const base = {
|
||||
type: "tool_call" as const,
|
||||
callId: input.callId,
|
||||
name: input.name,
|
||||
detail: {
|
||||
type: "plain_text" as const,
|
||||
label: input.label,
|
||||
...(input.text ? { text: input.text } : {}),
|
||||
icon: input.icon ?? "sparkles",
|
||||
},
|
||||
metadata: input.metadata,
|
||||
};
|
||||
if (input.status === "running") {
|
||||
return { ...base, status: "running", error: null };
|
||||
}
|
||||
if (input.status === "failed") {
|
||||
return { ...base, status: "failed", error: input.error ?? input.text ?? input.label };
|
||||
}
|
||||
return { ...base, status: "completed", error: null };
|
||||
}
|
||||
|
||||
function resolveGoal(event: OmpGoalUpdatedEvent): OmpGoal | null {
|
||||
return event.goal ?? event.state?.goal ?? null;
|
||||
}
|
||||
|
||||
function formatGoalText(event: OmpGoalUpdatedEvent, goal: OmpGoal | null): string {
|
||||
if (!goal) {
|
||||
return "OMP goal cleared.";
|
||||
}
|
||||
const parts = [
|
||||
goal.objective?.trim() || "OMP goal updated.",
|
||||
goal.status ? `Status: ${goal.status}` : null,
|
||||
goal.tokensUsed !== undefined ? `Tokens used: ${goal.tokensUsed}` : null,
|
||||
goal.tokenBudget !== undefined ? `Token budget: ${goal.tokenBudget}` : null,
|
||||
goal.timeUsedSeconds !== undefined ? `Time used: ${goal.timeUsedSeconds}s` : null,
|
||||
event.state?.mode ? `Mode: ${event.state.mode}` : null,
|
||||
].filter((part): part is string => part !== null);
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
function readEventType(value: unknown): string | null {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
return typeof value.type === "string" ? value.type : null;
|
||||
}
|
||||
|
||||
function readCompactionPreTokens(value: unknown): number | undefined {
|
||||
if (!isRecord(value)) {
|
||||
return undefined;
|
||||
}
|
||||
return typeof value.tokensBefore === "number" ? value.tokensBefore : undefined;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function formatDelay(delayMs: number): string {
|
||||
if (delayMs < 1000) {
|
||||
return `${delayMs}ms`;
|
||||
}
|
||||
if (delayMs % 1000 === 0) {
|
||||
return `${delayMs / 1000}s`;
|
||||
}
|
||||
return `${(delayMs / 1000).toFixed(1)}s`;
|
||||
}
|
||||
|
||||
function hashParts(...parts: string[]): string {
|
||||
const hash = createHash("sha1");
|
||||
for (const part of parts) {
|
||||
hash.update(part);
|
||||
hash.update("\0");
|
||||
}
|
||||
return hash.digest("hex").slice(0, 12);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { OmpHistoryMapperHooks } from "./message-history.js";
|
||||
import { mapOmpSystemNoticeToToolCall } from "./system-notice.js";
|
||||
import { mapOmpToolDetail } from "./tool-call-mapper.js";
|
||||
import { resolveOmpEmittedToolCallId } from "./tool-call-id.js";
|
||||
|
||||
export const OMP_HISTORY_MAPPER_HOOKS: OmpHistoryMapperHooks = {
|
||||
mapToolDetail: mapOmpToolDetail,
|
||||
mapCustomMessage: (text, provider) => {
|
||||
const noticeItem = mapOmpSystemNoticeToToolCall(text);
|
||||
return noticeItem ? { type: "timeline", provider, item: noticeItem } : null;
|
||||
},
|
||||
resolveToolCallId: resolveOmpEmittedToolCallId,
|
||||
};
|
||||
@@ -0,0 +1,546 @@
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { AgentStreamEvent } from "../../agent-sdk-types.js";
|
||||
import { streamOmpCoreHistory, type OmpCapturedUserMessageEntry } from "./message-history.js";
|
||||
import type { OmpAgentMessage } from "./rpc-types.js";
|
||||
import { FakeOmp } from "./test-utils/fake-omp.js";
|
||||
import { OMP_HISTORY_MAPPER_HOOKS } from "./history-hooks.js";
|
||||
import { streamOmpHistory } from "./history.js";
|
||||
|
||||
async function collectHistory(
|
||||
messages: OmpAgentMessage[],
|
||||
userEntries: OmpCapturedUserMessageEntry[] = [],
|
||||
): Promise<AgentStreamEvent[]> {
|
||||
const events: AgentStreamEvent[] = [];
|
||||
for await (const event of streamOmpCoreHistory(
|
||||
"omp",
|
||||
messages,
|
||||
userEntries,
|
||||
OMP_HISTORY_MAPPER_HOOKS,
|
||||
)) {
|
||||
events.push(event);
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
describe("OMP history mapper", () => {
|
||||
test("coalesces replayed subagent poll calls by target set", async () => {
|
||||
const events = await collectHistory([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "toolCall", id: "poll-1", name: "subagent", arguments: { poll: ["job-a"] } },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "poll-1",
|
||||
toolName: "subagent",
|
||||
content: [{ type: "text", text: "first poll" }],
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "toolCall", id: "poll-2", name: "subagent", arguments: { poll: ["job-a"] } },
|
||||
{ type: "toolCall", id: "poll-3", name: "subagent", arguments: { poll: ["job-b"] } },
|
||||
{
|
||||
type: "toolCall",
|
||||
id: "spawn-1",
|
||||
name: "subagent",
|
||||
arguments: { spawn: [{ task: "go" }] },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "poll-2",
|
||||
toolName: "subagent",
|
||||
content: [{ type: "text", text: "second poll" }],
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "poll-3",
|
||||
toolName: "subagent",
|
||||
content: [{ type: "text", text: "other poll" }],
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "spawn-1",
|
||||
toolName: "subagent",
|
||||
content: [{ type: "text", text: "spawned" }],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(
|
||||
events.map((event) => (event.item.type === "tool_call" ? event.item.callId : null)),
|
||||
).toEqual([
|
||||
"omp-poll:job-a",
|
||||
"omp-poll:job-a",
|
||||
"omp-poll:job-a",
|
||||
"omp-poll:job-b",
|
||||
"spawn-1",
|
||||
"omp-poll:job-a",
|
||||
"omp-poll:job-b",
|
||||
"spawn-1",
|
||||
]);
|
||||
});
|
||||
|
||||
test("absorbs replayed omp system-notice custom messages as synthetic tool calls", async () => {
|
||||
const notice = [
|
||||
"<system-notice>",
|
||||
"Background job DocsSmokeTwo has completed. Resume your work using the result below.",
|
||||
'<task-result id="DocsSmokeTwo" agent="explore" status="completed" duration="21.6s">',
|
||||
"<output>done</output>",
|
||||
"</task-result>",
|
||||
"</system-notice>",
|
||||
].join("\n");
|
||||
|
||||
await expect(
|
||||
collectHistory(
|
||||
[
|
||||
{ role: "user", content: "first prompt" },
|
||||
{ role: "custom", content: notice },
|
||||
{ role: "user", content: "second prompt" },
|
||||
],
|
||||
[
|
||||
{ id: "entry-user-1", text: "first prompt" },
|
||||
{ id: "entry-user-2", text: "second prompt" },
|
||||
],
|
||||
),
|
||||
).resolves.toEqual([
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "omp",
|
||||
item: {
|
||||
type: "user_message",
|
||||
text: "first prompt",
|
||||
messageId: "entry-user-1",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "omp",
|
||||
item: {
|
||||
type: "tool_call",
|
||||
callId: "omp-notice:DocsSmokeTwo",
|
||||
name: "task_notification",
|
||||
status: "completed",
|
||||
detail: {
|
||||
type: "plain_text",
|
||||
label: "Background job DocsSmokeTwo completed",
|
||||
text: notice,
|
||||
icon: "wrench",
|
||||
},
|
||||
metadata: {
|
||||
synthetic: true,
|
||||
source: "omp_system_notice",
|
||||
taskId: "DocsSmokeTwo",
|
||||
subagentType: "explore",
|
||||
status: "completed",
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "omp",
|
||||
item: {
|
||||
type: "user_message",
|
||||
text: "second prompt",
|
||||
messageId: "entry-user-2",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("suppresses replayed raw todo tool calls through the OMP detail hook", async () => {
|
||||
await expect(
|
||||
collectHistory([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "toolCall", id: "todo-1", name: "todo", arguments: { op: "view" } }],
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "todo-1",
|
||||
toolName: "todo",
|
||||
content: [{ type: "text", text: "todos" }],
|
||||
},
|
||||
]),
|
||||
).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
test("replays task tool results as static sub-agent details", async () => {
|
||||
await expect(
|
||||
collectHistory([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "toolCall",
|
||||
id: "task-1",
|
||||
name: "task",
|
||||
arguments: { agent: "explore", description: "Inspect files" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "task-1",
|
||||
toolName: "task",
|
||||
content: [{ type: "text", text: "done\ntranscript: /tmp/omp-task/Explore.jsonl" }],
|
||||
},
|
||||
]),
|
||||
).resolves.toEqual([
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "omp",
|
||||
item: {
|
||||
type: "tool_call",
|
||||
callId: "task-1",
|
||||
name: "task",
|
||||
status: "running",
|
||||
detail: {
|
||||
type: "sub_agent",
|
||||
subAgentType: "explore",
|
||||
description: "Inspect files",
|
||||
log: "",
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "omp",
|
||||
item: {
|
||||
type: "tool_call",
|
||||
callId: "task-1",
|
||||
name: "task",
|
||||
status: "completed",
|
||||
detail: {
|
||||
type: "sub_agent",
|
||||
subAgentType: "explore",
|
||||
description: "Inspect files",
|
||||
childSessionId: "/tmp/omp-task/Explore.jsonl",
|
||||
log: "done\ntranscript: /tmp/omp-task/Explore.jsonl",
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
test("replays OMP 17 xd writes as the executed inner tool", async () => {
|
||||
const events = await collectHistory([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "toolCall",
|
||||
id: "xd-write-call",
|
||||
name: "write",
|
||||
arguments: {
|
||||
path: "xd://browser",
|
||||
content: '{"action":"open","name":"docs","url":"https://example.com"}',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "xd-write-call",
|
||||
toolName: "write",
|
||||
content: [{ type: "text", text: "Opened Example Domain" }],
|
||||
details: {
|
||||
xdev: {
|
||||
tool: "browser",
|
||||
mode: "execute",
|
||||
args: { action: "open", name: "docs", url: "https://example.com" },
|
||||
inner: { action: "open", name: "docs", url: "https://example.com" },
|
||||
},
|
||||
},
|
||||
} as OmpAgentMessage,
|
||||
]);
|
||||
|
||||
expect(events.at(-1)).toMatchObject({
|
||||
item: {
|
||||
type: "tool_call",
|
||||
callId: "xd-write-call",
|
||||
name: "browser",
|
||||
status: "completed",
|
||||
detail: {
|
||||
type: "unknown",
|
||||
input: { action: "open", name: "docs", url: "https://example.com" },
|
||||
output: {
|
||||
content: [{ type: "text", text: "Opened Example Domain" }],
|
||||
details: { action: "open", name: "docs", url: "https://example.com" },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("maps only the active JSONL chain with native user ids and visible unknown roles", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "omp-history-"));
|
||||
const sessionFile = join(dir, "session.jsonl");
|
||||
writeFileSync(
|
||||
sessionFile,
|
||||
[
|
||||
{ type: "session", id: "root", parentId: null },
|
||||
{
|
||||
type: "message",
|
||||
id: "user-old",
|
||||
parentId: "root",
|
||||
message: { role: "user", content: "old branch" },
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
id: "assistant-old",
|
||||
parentId: "user-old",
|
||||
message: { role: "assistant", content: [{ type: "text", text: "old answer" }] },
|
||||
},
|
||||
{
|
||||
type: "session_init",
|
||||
id: "init-active",
|
||||
parentId: "root",
|
||||
systemPrompt: "must stay hidden",
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
id: "system-active",
|
||||
parentId: "init-active",
|
||||
message: { role: "system", content: "secret system prompt" },
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
id: "user-active",
|
||||
parentId: "system-active",
|
||||
message: { role: "user", content: "active branch" },
|
||||
},
|
||||
{
|
||||
type: "title",
|
||||
id: "title-control",
|
||||
parentId: "user-active",
|
||||
title: "Updated title",
|
||||
},
|
||||
{
|
||||
type: "custom",
|
||||
customType: "tool_execution_start",
|
||||
id: "custom-control",
|
||||
parentId: "title-control",
|
||||
data: { toolName: "task" },
|
||||
},
|
||||
{
|
||||
type: "tool_execution_start",
|
||||
id: "tool-control",
|
||||
parentId: "custom-control",
|
||||
command: "secret internal command",
|
||||
},
|
||||
{
|
||||
type: "future_control",
|
||||
id: "unknown-active",
|
||||
parentId: "tool-control",
|
||||
secret: "must not stringify",
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
id: "developer-active",
|
||||
parentId: "unknown-active",
|
||||
message: { role: "developer", content: "developer note" },
|
||||
},
|
||||
]
|
||||
.map((entry) => JSON.stringify(entry))
|
||||
.join("\n"),
|
||||
);
|
||||
|
||||
const events: AgentStreamEvent[] = [];
|
||||
for await (const event of streamOmpHistory({ sessionFile, provider: "omp" })) {
|
||||
events.push(event);
|
||||
}
|
||||
expect(events.map((event) => event.item)).toEqual([
|
||||
{ type: "user_message", text: "active branch", messageId: "user-active" },
|
||||
{ type: "assistant_message", text: "[future_control] Unsupported history record" },
|
||||
{ type: "assistant_message", text: "[developer] developer note" },
|
||||
]);
|
||||
|
||||
const omp = new FakeOmp();
|
||||
const runtimeSession = await omp.startSession({ cwd: dir });
|
||||
runtimeSession.activeBranchEntryId = "assistant-old";
|
||||
const selectedEvents: AgentStreamEvent[] = [];
|
||||
for await (const event of streamOmpHistory({
|
||||
sessionFile,
|
||||
runtimeSession,
|
||||
provider: "omp",
|
||||
})) {
|
||||
selectedEvents.push(event);
|
||||
}
|
||||
expect(
|
||||
selectedEvents.flatMap((event) => (event.type === "timeline" ? [event.item] : [])),
|
||||
).toEqual([
|
||||
{ type: "user_message", text: "old branch", messageId: "user-old" },
|
||||
{
|
||||
type: "assistant_message",
|
||||
text: "old answer",
|
||||
messageId: "omp-history-assistant-1",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("rehydrates structured batch and nested task transcripts with stable status and time", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "omp-subagent-history-"));
|
||||
const parentFile = join(dir, "parent.jsonl");
|
||||
const parentStem = parentFile.slice(0, -".jsonl".length);
|
||||
const echoId = "EchoChild";
|
||||
const echoFile = join(parentStem, `${echoId}.jsonl`);
|
||||
const failedFile = join(parentStem, "FailedChild.jsonl");
|
||||
const abortedFile = join(parentStem, "AbortedChild.jsonl");
|
||||
const nestedFile = join(parentStem, echoId, "NestedChild.jsonl");
|
||||
mkdirSync(join(parentStem, echoId), { recursive: true });
|
||||
|
||||
const writeEntries = (file: string, entries: object[]): void => {
|
||||
writeFileSync(file, entries.map((entry) => JSON.stringify(entry)).join("\n"));
|
||||
};
|
||||
writeEntries(nestedFile, [
|
||||
{ type: "session", id: "nested-root", parentId: null, timestamp: "2026-07-07T03:00:00Z" },
|
||||
{
|
||||
type: "message",
|
||||
id: "nested-answer",
|
||||
parentId: "nested-root",
|
||||
timestamp: "2026-07-07T03:00:01Z",
|
||||
message: { role: "assistant", content: [{ type: "text", text: "Nested answer" }] },
|
||||
},
|
||||
]);
|
||||
writeEntries(echoFile, [
|
||||
{ type: "session", id: "echo-root", parentId: null, timestamp: "2026-07-07T02:00:00Z" },
|
||||
{
|
||||
type: "model_change",
|
||||
id: "echo-model",
|
||||
parentId: "echo-root",
|
||||
timestamp: "2026-07-07T02:00:00.500Z",
|
||||
provider: "openai-codex",
|
||||
modelId: "gpt-5.5",
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
id: "echo-answer",
|
||||
parentId: "echo-model",
|
||||
timestamp: "2026-07-07T02:00:01Z",
|
||||
message: { role: "assistant", content: [{ type: "text", text: "Found it" }] },
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
id: "nested-call",
|
||||
parentId: "echo-answer",
|
||||
timestamp: "2026-07-07T02:00:02Z",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "toolCall",
|
||||
id: "nested-task",
|
||||
name: "task",
|
||||
arguments: { agent: "task" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
id: "nested-result",
|
||||
parentId: "nested-call",
|
||||
timestamp: "2026-07-07T02:00:03Z",
|
||||
message: {
|
||||
role: "toolResult",
|
||||
toolCallId: "nested-task",
|
||||
toolName: "task",
|
||||
content: [{ type: "text", text: "nested done" }],
|
||||
details: { results: [{ id: "NestedChild", exitCode: 0 }] },
|
||||
},
|
||||
},
|
||||
]);
|
||||
writeEntries(failedFile, [
|
||||
{ type: "session", id: "failed-root", parentId: null, timestamp: "2026-07-07T04:00:00Z" },
|
||||
]);
|
||||
writeEntries(abortedFile, [
|
||||
{ type: "session", id: "aborted-root", parentId: null, timestamp: 1_752_000_000 },
|
||||
]);
|
||||
writeEntries(parentFile, [
|
||||
{ type: "session", id: "parent-root", parentId: null, timestamp: "2026-07-07T01:00:00Z" },
|
||||
{
|
||||
type: "message",
|
||||
id: "task-call",
|
||||
parentId: "parent-root",
|
||||
timestamp: "2026-07-07T01:00:01Z",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "toolCall", id: "task-1", name: "task", arguments: { agent: "task" } }],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
id: "task-result",
|
||||
parentId: "task-call",
|
||||
timestamp: "2026-07-07T01:00:02Z",
|
||||
message: {
|
||||
role: "toolResult",
|
||||
toolCallId: "task-1",
|
||||
toolName: "task",
|
||||
content: [{ type: "text", text: "batch done" }],
|
||||
details: {
|
||||
results: [
|
||||
{ id: echoId, agent: "task", exitCode: 0 },
|
||||
{ id: "FailedChild", exitCode: 2, error: "boom" },
|
||||
{ id: "AbortedChild", aborted: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const events: AgentStreamEvent[] = [];
|
||||
for await (const event of streamOmpHistory({ sessionFile: parentFile, provider: "omp" })) {
|
||||
events.push(event);
|
||||
}
|
||||
const subagentEvents = events.flatMap((event) =>
|
||||
event.type === "provider_subagent" ? [event.event] : [],
|
||||
);
|
||||
expect(subagentEvents).toContainEqual({
|
||||
type: "timeline",
|
||||
id: echoId,
|
||||
timestamp: "2026-07-07T02:00:01Z",
|
||||
item: {
|
||||
type: "assistant_message",
|
||||
text: "Found it",
|
||||
messageId: "omp-history-assistant-1",
|
||||
},
|
||||
});
|
||||
expect(subagentEvents).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "timeline",
|
||||
id: "NestedChild",
|
||||
timestamp: "2026-07-07T03:00:01Z",
|
||||
}),
|
||||
);
|
||||
expect(subagentEvents.filter((event) => event.type === "upsert")).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: echoId,
|
||||
title: "task · gpt-5.5 (openai-codex)",
|
||||
status: "running",
|
||||
timestamp: "2026-07-07T02:00:00Z",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: echoId,
|
||||
title: "task · gpt-5.5 (openai-codex)",
|
||||
status: "completed",
|
||||
timestamp: "2026-07-07T02:00:03Z",
|
||||
}),
|
||||
expect.objectContaining({ id: "FailedChild", status: "failed" }),
|
||||
expect.objectContaining({ id: "AbortedChild", status: "canceled" }),
|
||||
expect.objectContaining({ id: "NestedChild", status: "completed" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,376 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { basename, extname, join } from "node:path";
|
||||
import type { AgentProvider, AgentStreamEvent } from "../../agent-sdk-types.js";
|
||||
import { normalizeProviderReplayTimestamp } from "../../provider-history-timestamps.js";
|
||||
import { OmpHistoryMapper, type OmpCapturedUserMessageEntry } from "./message-history.js";
|
||||
import type { OmpAgentMessage } from "./rpc-types.js";
|
||||
import type { OmpRuntimeSession } from "./runtime.js";
|
||||
import { OMP_HISTORY_MAPPER_HOOKS } from "./history-hooks.js";
|
||||
import { formatOmpSubagentTitle } from "./subagent-title.js";
|
||||
|
||||
interface OmpSessionEntry {
|
||||
type?: string;
|
||||
id?: string;
|
||||
parentId?: string | null;
|
||||
timestamp?: string | number;
|
||||
message?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
function extractOmpSubagentModel(entries: readonly OmpSessionEntry[]): string | null {
|
||||
let resolvedModel: string | null = null;
|
||||
for (const entry of entries) {
|
||||
let candidate: string | null = null;
|
||||
if (entry.type === "model_change") {
|
||||
candidate = buildOmpModelId(entry.provider, entry.modelId);
|
||||
} else if (entry.type === "message" && entry.message?.role === "assistant") {
|
||||
candidate = buildOmpModelId(
|
||||
entry.message.provider,
|
||||
entry.message.responseModel ?? entry.message.model,
|
||||
);
|
||||
}
|
||||
resolvedModel = candidate ?? resolvedModel;
|
||||
}
|
||||
return resolvedModel;
|
||||
}
|
||||
|
||||
function buildOmpModelId(provider: unknown, model: unknown): string | null {
|
||||
if (typeof provider !== "string" || typeof model !== "string") return null;
|
||||
const normalizedProvider = provider.trim();
|
||||
const normalizedModel = model.trim();
|
||||
return normalizedProvider && normalizedModel ? `${normalizedProvider}/${normalizedModel}` : null;
|
||||
}
|
||||
|
||||
export async function* streamOmpHistory(input: {
|
||||
sessionFile?: string;
|
||||
runtimeSession?: OmpRuntimeSession;
|
||||
provider: AgentProvider;
|
||||
visitedSessionFiles?: Set<string>;
|
||||
}): AsyncGenerator<AgentStreamEvent> {
|
||||
if (!input.sessionFile) {
|
||||
return;
|
||||
}
|
||||
const visitedSessionFiles = input.visitedSessionFiles ?? new Set<string>();
|
||||
if (visitedSessionFiles.has(input.sessionFile)) {
|
||||
return;
|
||||
}
|
||||
visitedSessionFiles.add(input.sessionFile);
|
||||
let entries: OmpSessionEntry[];
|
||||
try {
|
||||
entries = await readActiveOmpEntryChain(
|
||||
input.sessionFile,
|
||||
input.runtimeSession?.activeBranchEntryId,
|
||||
);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const messages: OmpAgentMessage[] = [];
|
||||
const messageEntries: OmpSessionEntry[] = [];
|
||||
const userEntries: OmpCapturedUserMessageEntry[] = [];
|
||||
for (const entry of entries) {
|
||||
const mapped = mapEntryMessage(entry);
|
||||
if (!mapped) continue;
|
||||
messages.push(mapped);
|
||||
messageEntries.push(entry);
|
||||
if (mapped.role === "user" && entry.id) {
|
||||
userEntries.push({ id: entry.id, text: textOf(mapped.content) });
|
||||
}
|
||||
}
|
||||
const mapper = new OmpHistoryMapper(input.provider, userEntries, OMP_HISTORY_MAPPER_HOOKS);
|
||||
for (let index = 0; index < messages.length; index += 1) {
|
||||
const timestamp = normalizeProviderReplayTimestamp(messageEntries[index]?.timestamp);
|
||||
for (const event of mapper.mapMessages([messages[index]!])) {
|
||||
yield timestamp && event.type === "timeline" ? { ...event, timestamp } : event;
|
||||
}
|
||||
}
|
||||
for (const transcript of readSubagentTranscripts(messages, input.sessionFile)) {
|
||||
yield* replaySubagentTranscript(transcript, input.provider, visitedSessionFiles);
|
||||
}
|
||||
}
|
||||
|
||||
async function* replaySubagentTranscript(
|
||||
transcript: OmpSubagentTranscript,
|
||||
provider: AgentProvider,
|
||||
visitedSessionFiles: Set<string>,
|
||||
): AsyncGenerator<AgentStreamEvent> {
|
||||
const childEntries = await readActiveOmpEntryChain(transcript.sessionFile).catch(
|
||||
(error: unknown) => {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
||||
throw error;
|
||||
},
|
||||
);
|
||||
const resolvedModel = extractOmpSubagentModel(childEntries);
|
||||
const firstTimestamp = normalizeProviderReplayTimestamp(childEntries[0]?.timestamp);
|
||||
yield subagentUpsert(transcript, provider, "running", firstTimestamp, resolvedModel);
|
||||
for await (const event of streamOmpHistory({
|
||||
sessionFile: transcript.sessionFile,
|
||||
provider,
|
||||
visitedSessionFiles,
|
||||
})) {
|
||||
if (event.type === "timeline") {
|
||||
yield {
|
||||
type: "provider_subagent",
|
||||
provider,
|
||||
event: {
|
||||
type: "timeline",
|
||||
id: transcript.id,
|
||||
item: event.item,
|
||||
...(event.timestamp ? { timestamp: event.timestamp } : {}),
|
||||
},
|
||||
};
|
||||
} else if (event.type === "provider_subagent") {
|
||||
yield event;
|
||||
}
|
||||
}
|
||||
const lastTimestamp = normalizeProviderReplayTimestamp(childEntries.at(-1)?.timestamp);
|
||||
yield subagentUpsert(transcript, provider, transcript.status, lastTimestamp, resolvedModel);
|
||||
}
|
||||
|
||||
function subagentUpsert(
|
||||
transcript: OmpSubagentTranscript,
|
||||
provider: AgentProvider,
|
||||
status: OmpSubagentTranscript["status"] | "running",
|
||||
timestamp: string | null,
|
||||
resolvedModel: string | null,
|
||||
): AgentStreamEvent {
|
||||
return {
|
||||
type: "provider_subagent",
|
||||
provider,
|
||||
event: {
|
||||
type: "upsert",
|
||||
id: transcript.id,
|
||||
title: formatOmpSubagentTitle(transcript.title, resolvedModel),
|
||||
status,
|
||||
toolCallId: transcript.toolCallId,
|
||||
...(timestamp ? { timestamp } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface OmpSubagentTranscript {
|
||||
id: string;
|
||||
title: string;
|
||||
toolCallId: string;
|
||||
sessionFile: string;
|
||||
status: "completed" | "failed" | "canceled";
|
||||
}
|
||||
|
||||
function readSubagentTranscripts(
|
||||
messages: readonly OmpAgentMessage[],
|
||||
parentSessionFile: string,
|
||||
): OmpSubagentTranscript[] {
|
||||
const taskCalls = collectTaskCalls(messages);
|
||||
const transcripts: OmpSubagentTranscript[] = [];
|
||||
for (const message of messages) {
|
||||
if (message.role !== "toolResult" || message.toolName !== "task") continue;
|
||||
const call = taskCalls.get(message.toolCallId);
|
||||
if (!call) continue;
|
||||
const results = readTaskResults(message);
|
||||
for (const result of results) {
|
||||
transcripts.push({
|
||||
id: result.id,
|
||||
title: result.agent ?? call.title,
|
||||
toolCallId: message.toolCallId,
|
||||
sessionFile: join(stripExtension(parentSessionFile), `${basename(result.id)}.jsonl`),
|
||||
status: taskResultStatus(result, message.isError === true),
|
||||
});
|
||||
}
|
||||
if (results.length > 0) continue;
|
||||
const legacy = readLegacyTranscript(message, call.title);
|
||||
if (legacy) transcripts.push(legacy);
|
||||
}
|
||||
return transcripts;
|
||||
}
|
||||
|
||||
function collectTaskCalls(messages: readonly OmpAgentMessage[]): Map<string, { title: string }> {
|
||||
const taskCalls = new Map<string, { title: string }>();
|
||||
for (const message of messages) {
|
||||
if (message.role !== "assistant" || !Array.isArray(message.content)) continue;
|
||||
for (const block of message.content) {
|
||||
if (block.type !== "toolCall" || block.name !== "task" || typeof block.id !== "string") {
|
||||
continue;
|
||||
}
|
||||
const args = block.arguments;
|
||||
const title =
|
||||
args && typeof args === "object" && typeof Reflect.get(args, "agent") === "string"
|
||||
? Reflect.get(args, "agent")
|
||||
: "OMP subagent";
|
||||
taskCalls.set(block.id, { title });
|
||||
}
|
||||
}
|
||||
return taskCalls;
|
||||
}
|
||||
|
||||
function readLegacyTranscript(
|
||||
message: Extract<OmpAgentMessage, { role: "toolResult" }>,
|
||||
title: string,
|
||||
): OmpSubagentTranscript | null {
|
||||
const text = taskResultText(message);
|
||||
const sessionFile = text.match(/(?:session|transcript)(?: file)?:\s*(?<path>\/\S+\.jsonl)/i)
|
||||
?.groups?.path;
|
||||
if (!sessionFile) return null;
|
||||
const fileName = basename(sessionFile);
|
||||
const extension = extname(fileName);
|
||||
return {
|
||||
id: extension ? fileName.slice(0, -extension.length) : fileName,
|
||||
title,
|
||||
toolCallId: message.toolCallId,
|
||||
sessionFile,
|
||||
status: message.isError ? "failed" : "completed",
|
||||
};
|
||||
}
|
||||
|
||||
interface OmpTaskResult {
|
||||
id: string;
|
||||
agent?: string;
|
||||
exitCode?: number;
|
||||
error?: unknown;
|
||||
aborted?: boolean;
|
||||
}
|
||||
|
||||
function readTaskResults(
|
||||
message: Extract<OmpAgentMessage, { role: "toolResult" }>,
|
||||
): OmpTaskResult[] {
|
||||
const details =
|
||||
Reflect.get(message, "details") ??
|
||||
(message.content && typeof message.content === "object"
|
||||
? Reflect.get(message.content, "details")
|
||||
: undefined);
|
||||
const results =
|
||||
details && typeof details === "object" ? Reflect.get(details, "results") : undefined;
|
||||
if (!Array.isArray(results)) return [];
|
||||
return results.flatMap((result) => {
|
||||
if (!result || typeof result !== "object") return [];
|
||||
const id = Reflect.get(result, "id");
|
||||
if (typeof id !== "string" || !id) return [];
|
||||
const agent = Reflect.get(result, "agent");
|
||||
const exitCode = Reflect.get(result, "exitCode");
|
||||
return [
|
||||
{
|
||||
id,
|
||||
...(typeof agent === "string" ? { agent } : {}),
|
||||
...(typeof exitCode === "number" ? { exitCode } : {}),
|
||||
error: Reflect.get(result, "error"),
|
||||
aborted: Reflect.get(result, "aborted") === true,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
function taskResultStatus(
|
||||
result: OmpTaskResult,
|
||||
messageIsError: boolean,
|
||||
): "completed" | "failed" | "canceled" {
|
||||
if (messageIsError || result.error || (result.exitCode !== undefined && result.exitCode !== 0)) {
|
||||
return "failed";
|
||||
}
|
||||
return result.aborted ? "canceled" : "completed";
|
||||
}
|
||||
|
||||
function taskResultText(message: Extract<OmpAgentMessage, { role: "toolResult" }>): string {
|
||||
return Array.isArray(message.content)
|
||||
? message.content
|
||||
.flatMap((block) =>
|
||||
block &&
|
||||
typeof block === "object" &&
|
||||
Reflect.get(block, "type") === "text" &&
|
||||
typeof Reflect.get(block, "text") === "string"
|
||||
? [Reflect.get(block, "text") as string]
|
||||
: [],
|
||||
)
|
||||
.join("\n")
|
||||
: "";
|
||||
}
|
||||
|
||||
function stripExtension(filePath: string): string {
|
||||
const extension = extname(filePath);
|
||||
return extension ? filePath.slice(0, -extension.length) : filePath;
|
||||
}
|
||||
|
||||
export async function readActiveOmpEntryChain(
|
||||
sessionFile: string,
|
||||
activeEntryId?: string,
|
||||
): Promise<OmpSessionEntry[]> {
|
||||
const content = await readFile(sessionFile, "utf8");
|
||||
const entries = content.split("\n").flatMap((line) => {
|
||||
if (!line.trim()) return [];
|
||||
try {
|
||||
const value = JSON.parse(line) as OmpSessionEntry;
|
||||
return value && typeof value === "object" && typeof value.id === "string" ? [value] : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
if (entries.length === 0) return [];
|
||||
const byId = new Map(entries.map((entry) => [entry.id!, entry]));
|
||||
const parentIds = new Set(entries.flatMap((entry) => (entry.parentId ? [entry.parentId] : [])));
|
||||
const leaves = entries.filter((entry) => !parentIds.has(entry.id!));
|
||||
let current: OmpSessionEntry | undefined =
|
||||
(activeEntryId ? byId.get(activeEntryId) : undefined) ?? leaves.at(-1) ?? entries.at(-1);
|
||||
const chain: OmpSessionEntry[] = [];
|
||||
const seen = new Set<string>();
|
||||
while (current?.id && !seen.has(current.id)) {
|
||||
chain.push(current);
|
||||
seen.add(current.id);
|
||||
current = current.parentId ? byId.get(current.parentId) : undefined;
|
||||
}
|
||||
return chain.toReversed();
|
||||
}
|
||||
|
||||
function mapEntryMessage(entry: OmpSessionEntry): OmpAgentMessage | null {
|
||||
const message = entry.message;
|
||||
if (message && typeof message.role === "string") {
|
||||
if (message.role === "system") {
|
||||
return null;
|
||||
}
|
||||
if (["user", "assistant", "toolResult", "custom", "bashExecution"].includes(message.role)) {
|
||||
return message as unknown as OmpAgentMessage;
|
||||
}
|
||||
return visibleFallback(message.role, message);
|
||||
}
|
||||
if (!entry.type || isControlEntryType(entry.type)) {
|
||||
return null;
|
||||
}
|
||||
return visibleFallback(entry.type, entry);
|
||||
}
|
||||
|
||||
function isControlEntryType(type: string): boolean {
|
||||
return (
|
||||
type === "session" ||
|
||||
type === "session_init" ||
|
||||
type === "system" ||
|
||||
type === "title" ||
|
||||
type === "title_change" ||
|
||||
type === "custom" ||
|
||||
type === "system_prompt" ||
|
||||
type === "model_change" ||
|
||||
type === "thinking_level_change" ||
|
||||
type === "tool_execution" ||
|
||||
type.startsWith("tool_execution_")
|
||||
);
|
||||
}
|
||||
|
||||
function visibleFallback(role: string, value: Record<string, unknown>): OmpAgentMessage {
|
||||
let text = "Unsupported history record";
|
||||
if (typeof value.content === "string") {
|
||||
text = value.content;
|
||||
} else if (typeof value.text === "string") {
|
||||
text = value.text;
|
||||
}
|
||||
return { role: "custom", content: `[${role}] ${text}` } as OmpAgentMessage;
|
||||
}
|
||||
|
||||
function textOf(content: unknown): string {
|
||||
if (typeof content === "string") return content;
|
||||
if (!Array.isArray(content)) return "";
|
||||
return content
|
||||
.flatMap((part) =>
|
||||
part && typeof part === "object" && typeof (part as { text?: unknown }).text === "string"
|
||||
? [(part as { text: string }).text]
|
||||
: [],
|
||||
)
|
||||
.join("\n");
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import pino from "pino";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { PaseoToolCatalog, PaseoToolDefinition, PaseoToolResult } from "../../tools/types.js";
|
||||
import {
|
||||
clearOmpHostToolState,
|
||||
handleOmpHostToolRuntimeEvent,
|
||||
serializeOmpHostTools,
|
||||
waitForOmpHostToolsIdle,
|
||||
} from "./host-tools.js";
|
||||
import type { OmpRpcHostToolResult } from "./rpc-types.js";
|
||||
import { FakeOmp } from "./test-utils/fake-omp.js";
|
||||
|
||||
function createCatalog(tools: PaseoToolDefinition[]): PaseoToolCatalog {
|
||||
const toolMap = new Map(tools.map((tool) => [tool.name, tool]));
|
||||
return {
|
||||
tools: toolMap,
|
||||
getTool: (name) => toolMap.get(name),
|
||||
executeTool: async (name, input, context = {}) => {
|
||||
const tool = toolMap.get(name);
|
||||
if (!tool) throw new Error(`Missing tool ${name}`);
|
||||
return await tool.handler(input, context);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
class OmpHostToolHarness {
|
||||
private readonly logger = pino({ level: "silent" });
|
||||
private resolveControlledResult: ((result: PaseoToolResult) => void) | null = null;
|
||||
private controlledSignal: AbortSignal | null = null;
|
||||
private resolveControlledStart: (() => void) | null = null;
|
||||
private readonly controlledStart = new Promise<void>((resolve) => {
|
||||
this.resolveControlledStart = resolve;
|
||||
});
|
||||
|
||||
private constructor(
|
||||
private readonly catalog: PaseoToolCatalog,
|
||||
private readonly runtimeSession: Awaited<ReturnType<FakeOmp["startSession"]>>,
|
||||
) {}
|
||||
|
||||
static async withTools(tools: PaseoToolDefinition[]): Promise<OmpHostToolHarness> {
|
||||
const omp = new FakeOmp();
|
||||
const runtimeSession = await omp.startSession({ cwd: "/workspace/project" });
|
||||
return new OmpHostToolHarness(createCatalog(tools), runtimeSession);
|
||||
}
|
||||
|
||||
static async cancellable(): Promise<OmpHostToolHarness> {
|
||||
let harness: OmpHostToolHarness;
|
||||
const tool: PaseoToolDefinition = {
|
||||
name: "wait_for_agent",
|
||||
description: "Wait for a Paseo agent.",
|
||||
handler: async (_input, context) => {
|
||||
harness.controlledSignal = context.signal ?? null;
|
||||
harness.resolveControlledStart?.();
|
||||
return await new Promise<PaseoToolResult>((resolve) => {
|
||||
harness.resolveControlledResult = resolve;
|
||||
});
|
||||
},
|
||||
};
|
||||
harness = await OmpHostToolHarness.withTools([tool]);
|
||||
return harness;
|
||||
}
|
||||
|
||||
async call(input: {
|
||||
id: string;
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
arguments: Record<string, unknown>;
|
||||
}): Promise<OmpRpcHostToolResult> {
|
||||
const result = this.runtimeSession.nextHostToolResult();
|
||||
handleOmpHostToolRuntimeEvent({ type: "host_tool_call", ...input }, this.routerInput());
|
||||
return await result;
|
||||
}
|
||||
|
||||
startControlledCall(): void {
|
||||
handleOmpHostToolRuntimeEvent(
|
||||
{
|
||||
type: "host_tool_call",
|
||||
id: "host-cancel",
|
||||
toolCallId: "tool-cancel",
|
||||
toolName: "wait_for_agent",
|
||||
arguments: { agentId: "child-1" },
|
||||
},
|
||||
this.routerInput(),
|
||||
);
|
||||
}
|
||||
|
||||
async waitForControlledCall(): Promise<void> {
|
||||
await this.controlledStart;
|
||||
}
|
||||
|
||||
cancelControlledCall(): void {
|
||||
handleOmpHostToolRuntimeEvent(
|
||||
{ type: "host_tool_cancel", id: "cancel-1", targetId: "host-cancel" },
|
||||
this.routerInput(),
|
||||
);
|
||||
}
|
||||
|
||||
completeControlledCall(result: PaseoToolResult): void {
|
||||
if (!this.resolveControlledResult) throw new Error("Controlled host tool has not started");
|
||||
this.resolveControlledResult(result);
|
||||
}
|
||||
|
||||
async waitForIdle(): Promise<void> {
|
||||
await waitForOmpHostToolsIdle(this.runtimeSession);
|
||||
}
|
||||
|
||||
wasControlledCallAborted(): boolean {
|
||||
return this.controlledSignal?.aborted === true;
|
||||
}
|
||||
|
||||
updates() {
|
||||
return this.runtimeSession.hostToolUpdates;
|
||||
}
|
||||
|
||||
results() {
|
||||
return this.runtimeSession.hostToolResults;
|
||||
}
|
||||
|
||||
close(): void {
|
||||
clearOmpHostToolState(this.runtimeSession);
|
||||
}
|
||||
|
||||
private routerInput() {
|
||||
return { runtimeSession: this.runtimeSession, paseoTools: this.catalog, logger: this.logger };
|
||||
}
|
||||
}
|
||||
|
||||
describe("OMP host tools", () => {
|
||||
test("serializes the caller-scoped Paseo catalog for set_host_tools", () => {
|
||||
const catalog = createCatalog([
|
||||
{
|
||||
name: "create_agent",
|
||||
title: "Create agent",
|
||||
description: "Create a Paseo agent.",
|
||||
inputSchema: { initialPrompt: z.string().describe("Prompt for the new agent.") },
|
||||
handler: async () => ({ content: [] }),
|
||||
},
|
||||
]);
|
||||
|
||||
expect(serializeOmpHostTools(catalog)).toEqual([
|
||||
{
|
||||
name: "create_agent",
|
||||
label: "Create agent",
|
||||
description: "Create a Paseo agent.",
|
||||
parameters: expect.objectContaining({ type: "object", required: ["initialPrompt"] }),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("routes calls and progress through the typed OMP runtime", async () => {
|
||||
const omp = await OmpHostToolHarness.withTools([
|
||||
{
|
||||
name: "create_agent",
|
||||
description: "Create a Paseo agent.",
|
||||
handler: async (input, context) => {
|
||||
context.sendUpdate?.({ content: [{ type: "text", text: "creating" }] });
|
||||
return { content: [], structuredContent: { input, agentId: "child-1" } };
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(
|
||||
omp.call({
|
||||
id: "host-1",
|
||||
toolCallId: "tool-1",
|
||||
toolName: "create_agent",
|
||||
arguments: { initialPrompt: "Inspect the bug" },
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
type: "host_tool_result",
|
||||
id: "host-1",
|
||||
result: {
|
||||
content: [{ type: "text", text: expect.stringContaining('"agentId": "child-1"') }],
|
||||
details: { input: { initialPrompt: "Inspect the bug" }, agentId: "child-1" },
|
||||
},
|
||||
});
|
||||
expect(omp.updates()).toEqual([
|
||||
{
|
||||
type: "host_tool_update",
|
||||
id: "host-1",
|
||||
partialResult: { content: [{ type: "text", text: "creating" }] },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("cancels an in-flight host tool and drops its late result", async () => {
|
||||
const omp = await OmpHostToolHarness.cancellable();
|
||||
|
||||
omp.startControlledCall();
|
||||
await omp.waitForControlledCall();
|
||||
omp.cancelControlledCall();
|
||||
omp.completeControlledCall({ content: [{ type: "text", text: "late" }] });
|
||||
await omp.waitForIdle();
|
||||
|
||||
expect(omp.wasControlledCallAborted()).toBe(true);
|
||||
expect(omp.results()).toEqual([]);
|
||||
expect(omp.updates()).toEqual([]);
|
||||
omp.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,270 @@
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import {
|
||||
addModelVisibleStructuredContent,
|
||||
serializePaseoToolInputParameters,
|
||||
} from "../../tools/paseo-tool-serialization.js";
|
||||
import type { PaseoToolCatalog, PaseoToolResult } from "../../tools/types.js";
|
||||
import type { OmpRuntimeSession } from "./runtime.js";
|
||||
import {
|
||||
OmpRpcHostToolCallRequestSchema,
|
||||
OmpRpcHostToolCancelRequestSchema,
|
||||
OmpRpcHostToolUpdateSchema,
|
||||
type OmpAgentToolResult,
|
||||
type OmpRpcHostToolCallRequest,
|
||||
type OmpRpcHostToolDefinition,
|
||||
type OmpRpcHostToolResult,
|
||||
type OmpRpcHostToolUpdate,
|
||||
} from "./rpc-types.js";
|
||||
|
||||
interface PendingOmpHostToolCall {
|
||||
controller: AbortController;
|
||||
canceled: boolean;
|
||||
}
|
||||
|
||||
interface OmpHostToolRouterInput {
|
||||
runtimeSession: OmpRuntimeSession;
|
||||
catalog: PaseoToolCatalog;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
const routersByRuntimeSession = new WeakMap<OmpRuntimeSession, OmpHostToolRouter>();
|
||||
|
||||
export function serializeOmpHostTools(catalog: PaseoToolCatalog): OmpRpcHostToolDefinition[] {
|
||||
return [...catalog.tools.values()].map((tool) => {
|
||||
const definition: OmpRpcHostToolDefinition = {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: serializePaseoToolInputParameters(tool),
|
||||
};
|
||||
if (tool.title) {
|
||||
definition.label = tool.title;
|
||||
}
|
||||
return definition;
|
||||
});
|
||||
}
|
||||
|
||||
export async function setOmpHostTools(
|
||||
runtimeSession: OmpRuntimeSession,
|
||||
catalog: PaseoToolCatalog,
|
||||
): Promise<string[]> {
|
||||
return await runtimeSession.setHostTools(serializeOmpHostTools(catalog));
|
||||
}
|
||||
|
||||
export function handleOmpHostToolRuntimeEvent(
|
||||
event: unknown,
|
||||
input: {
|
||||
runtimeSession: OmpRuntimeSession;
|
||||
paseoTools?: PaseoToolCatalog;
|
||||
logger: Logger;
|
||||
},
|
||||
): boolean {
|
||||
if (!isRecord(event) || typeof event.type !== "string" || !isOmpHostToolEventType(event.type)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const call = OmpRpcHostToolCallRequestSchema.safeParse(event);
|
||||
if (call.success) {
|
||||
const router = getRouter(input);
|
||||
if (!router) {
|
||||
sendMissingCatalogResult(input.runtimeSession, call.data);
|
||||
return true;
|
||||
}
|
||||
router.handleCall(call.data);
|
||||
return true;
|
||||
}
|
||||
|
||||
const cancel = OmpRpcHostToolCancelRequestSchema.safeParse(event);
|
||||
if (cancel.success) {
|
||||
getRouter(input)?.handleCancel(cancel.data.targetId);
|
||||
return true;
|
||||
}
|
||||
|
||||
const update = OmpRpcHostToolUpdateSchema.safeParse(event);
|
||||
if (update.success) {
|
||||
input.logger.debug({ id: update.data.id }, "Ignoring unexpected inbound OMP host tool update");
|
||||
return true;
|
||||
}
|
||||
|
||||
input.logger.debug({ event }, "Dropped malformed OMP host tool frame");
|
||||
return true;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function clearOmpHostToolState(runtimeSession: OmpRuntimeSession): void {
|
||||
routersByRuntimeSession.get(runtimeSession)?.clear();
|
||||
routersByRuntimeSession.delete(runtimeSession);
|
||||
}
|
||||
|
||||
export async function waitForOmpHostToolsIdle(runtimeSession: OmpRuntimeSession): Promise<void> {
|
||||
await routersByRuntimeSession.get(runtimeSession)?.waitForIdle();
|
||||
}
|
||||
|
||||
function getRouter(input: {
|
||||
runtimeSession: OmpRuntimeSession;
|
||||
paseoTools?: PaseoToolCatalog;
|
||||
logger: Logger;
|
||||
}): OmpHostToolRouter | null {
|
||||
if (!input.paseoTools) {
|
||||
return null;
|
||||
}
|
||||
const existing = routersByRuntimeSession.get(input.runtimeSession);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const router = new OmpHostToolRouter({
|
||||
runtimeSession: input.runtimeSession,
|
||||
catalog: input.paseoTools,
|
||||
logger: input.logger,
|
||||
});
|
||||
routersByRuntimeSession.set(input.runtimeSession, router);
|
||||
return router;
|
||||
}
|
||||
|
||||
function sendMissingCatalogResult(
|
||||
runtimeSession: OmpRuntimeSession,
|
||||
request: OmpRpcHostToolCallRequest,
|
||||
): void {
|
||||
runtimeSession.sendHostToolResult(
|
||||
toOmpHostToolErrorResult(
|
||||
request.id,
|
||||
`Host tool "${request.toolName}" was called before Paseo tools were registered`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function isOmpHostToolEventType(type: string): boolean {
|
||||
return type === "host_tool_call" || type === "host_tool_cancel" || type === "host_tool_update";
|
||||
}
|
||||
|
||||
class OmpHostToolRouter {
|
||||
private readonly runtimeSession: OmpRuntimeSession;
|
||||
private readonly catalog: PaseoToolCatalog;
|
||||
private readonly logger: Logger;
|
||||
private readonly pendingCalls = new Map<string, PendingOmpHostToolCall>();
|
||||
private readonly idleWaiters = new Set<() => void>();
|
||||
|
||||
constructor(input: OmpHostToolRouterInput) {
|
||||
this.runtimeSession = input.runtimeSession;
|
||||
this.catalog = input.catalog;
|
||||
this.logger = input.logger;
|
||||
}
|
||||
|
||||
handleCall(request: OmpRpcHostToolCallRequest): void {
|
||||
const entry: PendingOmpHostToolCall = {
|
||||
controller: new AbortController(),
|
||||
canceled: false,
|
||||
};
|
||||
this.pendingCalls.set(request.id, entry);
|
||||
void this.executeCall(request, entry).catch((error: unknown) => {
|
||||
this.logger.warn({ err: error, toolName: request.toolName }, "OMP host tool call failed");
|
||||
});
|
||||
}
|
||||
|
||||
handleCancel(targetId: string): void {
|
||||
const pending = this.pendingCalls.get(targetId);
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
pending.canceled = true;
|
||||
pending.controller.abort(new Error(`OMP host tool call ${targetId} cancelled`));
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
for (const pending of this.pendingCalls.values()) {
|
||||
pending.canceled = true;
|
||||
pending.controller.abort(new Error("OMP session closed"));
|
||||
}
|
||||
this.pendingCalls.clear();
|
||||
this.resolveIdleWaiters();
|
||||
}
|
||||
|
||||
waitForIdle(): Promise<void> {
|
||||
if (this.pendingCalls.size === 0) return Promise.resolve();
|
||||
return new Promise((resolve) => this.idleWaiters.add(resolve));
|
||||
}
|
||||
|
||||
private async executeCall(
|
||||
request: OmpRpcHostToolCallRequest,
|
||||
entry: PendingOmpHostToolCall,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const result = await this.catalog.executeTool(request.toolName, request.arguments, {
|
||||
signal: entry.controller.signal,
|
||||
sendUpdate: (update) => {
|
||||
if (entry.canceled || entry.controller.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
this.sendUpdate(request.id, update);
|
||||
},
|
||||
});
|
||||
if (entry.canceled || entry.controller.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
this.runtimeSession.sendHostToolResult(toOmpHostToolResult(request.id, result));
|
||||
} catch (error) {
|
||||
if (entry.canceled || entry.controller.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
this.runtimeSession.sendHostToolResult(toOmpHostToolErrorResult(request.id, error));
|
||||
} finally {
|
||||
this.pendingCalls.delete(request.id);
|
||||
this.resolveIdleWaiters();
|
||||
}
|
||||
}
|
||||
|
||||
private resolveIdleWaiters(): void {
|
||||
if (this.pendingCalls.size > 0) return;
|
||||
for (const resolve of this.idleWaiters) resolve();
|
||||
this.idleWaiters.clear();
|
||||
}
|
||||
|
||||
private sendUpdate(callId: string, result: PaseoToolResult): void {
|
||||
const update: OmpRpcHostToolUpdate = {
|
||||
type: "host_tool_update",
|
||||
id: callId,
|
||||
partialResult: toOmpAgentToolResult(addModelVisibleStructuredContent(result)),
|
||||
};
|
||||
this.runtimeSession.sendHostToolUpdate(update);
|
||||
}
|
||||
}
|
||||
|
||||
function toOmpHostToolResult(id: string, result: PaseoToolResult): OmpRpcHostToolResult {
|
||||
const modelVisibleResult = addModelVisibleStructuredContent(result);
|
||||
const mappedResult = toOmpAgentToolResult(modelVisibleResult);
|
||||
return {
|
||||
type: "host_tool_result",
|
||||
id,
|
||||
result: mappedResult,
|
||||
...(modelVisibleResult.isError !== undefined ? { isError: modelVisibleResult.isError } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function toOmpHostToolErrorResult(id: string, error: unknown): OmpRpcHostToolResult {
|
||||
return {
|
||||
type: "host_tool_result",
|
||||
id,
|
||||
result: {
|
||||
content: [{ type: "text", text: error instanceof Error ? error.message : String(error) }],
|
||||
details: {},
|
||||
isError: true,
|
||||
},
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
function toOmpAgentToolResult(result: PaseoToolResult): OmpAgentToolResult {
|
||||
const mapped: OmpAgentToolResult = {
|
||||
content: result.content.map((item) => ({ ...item })),
|
||||
};
|
||||
if (result.structuredContent !== undefined) {
|
||||
mapped.details = result.structuredContent;
|
||||
}
|
||||
if (result.isError !== undefined) {
|
||||
mapped.isError = result.isError;
|
||||
}
|
||||
return mapped;
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
import type { AgentStreamEvent, AgentTimelineItem, ToolCallDetail } from "../../agent-sdk-types.js";
|
||||
import type { OmpAgentMessage, OmpImageContent, OmpTextContent } from "./rpc-types.js";
|
||||
import {
|
||||
extractTextFromToolResult,
|
||||
mapToolDetail,
|
||||
parseToolArgs,
|
||||
parseToolResult,
|
||||
resolveToolCallName,
|
||||
type OmpToolResult,
|
||||
type OmpTrackedToolCall,
|
||||
} from "./tool-call-detail.js";
|
||||
|
||||
export interface OmpCapturedUserMessageEntry {
|
||||
id: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface OmpHistoryMapperHooks {
|
||||
mapCustomMessage?: (
|
||||
text: string,
|
||||
provider: string,
|
||||
) => Extract<AgentStreamEvent, { type: "timeline" }> | null;
|
||||
resolveToolCallId?: (toolCallId: string, toolCall: OmpTrackedToolCall) => string;
|
||||
mapToolDetail?: (
|
||||
toolCall: OmpTrackedToolCall,
|
||||
result: OmpToolResult,
|
||||
context: { toolCallId: string },
|
||||
) => ToolCallDetail | null;
|
||||
}
|
||||
|
||||
function isTextContentBlock(block: unknown): block is OmpTextContent {
|
||||
return (
|
||||
typeof block === "object" &&
|
||||
block !== null &&
|
||||
!Array.isArray(block) &&
|
||||
Reflect.get(block, "type") === "text" &&
|
||||
typeof Reflect.get(block, "text") === "string"
|
||||
);
|
||||
}
|
||||
|
||||
export function getUserMessageText(content: string | (OmpTextContent | OmpImageContent)[]): string {
|
||||
if (typeof content === "string") {
|
||||
return content;
|
||||
}
|
||||
|
||||
const textParts: string[] = [];
|
||||
for (const block of content) {
|
||||
if (isTextContentBlock(block)) {
|
||||
textParts.push(block.text);
|
||||
}
|
||||
}
|
||||
return textParts.join("\n\n");
|
||||
}
|
||||
|
||||
export class OmpHistoryMapper {
|
||||
private readonly pendingToolCalls = new Map<string, OmpTrackedToolCall>();
|
||||
private userIndex = 0;
|
||||
private assistantIndex = 0;
|
||||
|
||||
constructor(
|
||||
private readonly provider: string,
|
||||
private readonly userEntries: readonly OmpCapturedUserMessageEntry[] = [],
|
||||
private readonly hooks: OmpHistoryMapperHooks = {},
|
||||
) {}
|
||||
|
||||
mapMessages(messages: readonly OmpAgentMessage[]): AgentStreamEvent[] {
|
||||
const events: AgentStreamEvent[] = [];
|
||||
|
||||
for (const message of messages) {
|
||||
switch (message.role) {
|
||||
case "user":
|
||||
events.push(...this.mapUserMessage(message));
|
||||
break;
|
||||
case "custom":
|
||||
events.push(...this.mapCustomMessage(message));
|
||||
break;
|
||||
case "assistant":
|
||||
events.push(...this.mapAssistantMessage(message));
|
||||
break;
|
||||
case "toolResult": {
|
||||
const event = this.mapToolResultMessage(message);
|
||||
if (event) {
|
||||
events.push(event);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "bashExecution":
|
||||
events.push(this.mapBashExecutionMessage(message));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
private mapUserMessage(message: Extract<OmpAgentMessage, { role: "user" }>): AgentStreamEvent[] {
|
||||
const text = getUserMessageText(message.content);
|
||||
this.userIndex += 1;
|
||||
if (!text) {
|
||||
return [];
|
||||
}
|
||||
const userEntry = this.userEntries[this.userIndex - 1];
|
||||
return [
|
||||
{
|
||||
type: "timeline",
|
||||
provider: this.provider,
|
||||
item: {
|
||||
type: "user_message",
|
||||
text,
|
||||
...(userEntry ? { messageId: userEntry.id } : {}),
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private mapCustomMessage(
|
||||
message: Extract<OmpAgentMessage, { role: "custom" }>,
|
||||
): AgentStreamEvent[] {
|
||||
const text = getUserMessageText(message.content);
|
||||
const mappedEvent = text ? this.hooks.mapCustomMessage?.(text, this.provider) : null;
|
||||
if (mappedEvent) {
|
||||
return [mappedEvent];
|
||||
}
|
||||
return text
|
||||
? [
|
||||
{
|
||||
type: "timeline",
|
||||
provider: this.provider,
|
||||
item: { type: "assistant_message", text },
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}
|
||||
|
||||
private mapAssistantMessage(
|
||||
message: Extract<OmpAgentMessage, { role: "assistant" }>,
|
||||
): AgentStreamEvent[] {
|
||||
const events: AgentStreamEvent[] = [];
|
||||
this.assistantIndex += 1;
|
||||
const messageId =
|
||||
message.responseId || `${this.provider}-history-assistant-${this.assistantIndex}`;
|
||||
for (const content of message.content) {
|
||||
if (content.type === "text" && content.text) {
|
||||
events.push({
|
||||
type: "timeline",
|
||||
provider: this.provider,
|
||||
item: { type: "assistant_message", text: content.text, messageId },
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (content.type === "thinking" && content.thinking) {
|
||||
events.push({
|
||||
type: "timeline",
|
||||
provider: this.provider,
|
||||
item: { type: "reasoning", text: content.thinking },
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (content.type === "toolCall") {
|
||||
const tracked = parseToolArgs(content.name, content.arguments);
|
||||
this.pendingToolCalls.set(content.id, tracked);
|
||||
const detail = this.mapToolDetail(content.id, tracked, null);
|
||||
if (!detail) {
|
||||
continue;
|
||||
}
|
||||
events.push({
|
||||
type: "timeline",
|
||||
provider: this.provider,
|
||||
item: {
|
||||
type: "tool_call",
|
||||
callId: this.resolveToolCallId(content.id, tracked),
|
||||
name: tracked.toolName,
|
||||
status: "running",
|
||||
detail,
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
private mapToolResultMessage(
|
||||
message: Extract<OmpAgentMessage, { role: "toolResult" }>,
|
||||
): AgentStreamEvent | null {
|
||||
const tracked =
|
||||
this.pendingToolCalls.get(message.toolCallId) ?? parseToolArgs(message.toolName, null);
|
||||
this.pendingToolCalls.delete(message.toolCallId);
|
||||
const result = parseToolResult({ content: message.content, details: message.details });
|
||||
const detail = this.mapToolDetail(message.toolCallId, tracked, result);
|
||||
if (!detail) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
type: "timeline",
|
||||
provider: this.provider,
|
||||
item: toToolResultTimelineItem({
|
||||
callId: this.resolveToolCallId(message.toolCallId, tracked),
|
||||
name: resolveToolCallName(tracked, result),
|
||||
isError: Boolean(message.isError),
|
||||
detail,
|
||||
errorText: extractTextFromToolResult(result) ?? "Tool call failed",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
private mapBashExecutionMessage(
|
||||
message: Extract<OmpAgentMessage, { role: "bashExecution" }>,
|
||||
): AgentStreamEvent {
|
||||
const detail: ToolCallDetail = {
|
||||
type: "shell",
|
||||
command: message.command,
|
||||
output: message.output,
|
||||
exitCode: message.exitCode ?? null,
|
||||
};
|
||||
return {
|
||||
type: "timeline",
|
||||
provider: this.provider,
|
||||
item: {
|
||||
type: "tool_call",
|
||||
callId: `omp-bash-${message.timestamp}`,
|
||||
name: "bash",
|
||||
status: message.cancelled ? "canceled" : "completed",
|
||||
detail,
|
||||
error: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private resolveToolCallId(toolCallId: string, toolCall: OmpTrackedToolCall): string {
|
||||
return this.hooks.resolveToolCallId?.(toolCallId, toolCall) ?? toolCallId;
|
||||
}
|
||||
|
||||
private mapToolDetail(
|
||||
toolCallId: string,
|
||||
toolCall: OmpTrackedToolCall,
|
||||
result: OmpToolResult,
|
||||
): ToolCallDetail | null {
|
||||
const hook = this.hooks.mapToolDetail;
|
||||
return hook ? hook(toolCall, result, { toolCallId }) : mapToolDetail(toolCall, result);
|
||||
}
|
||||
}
|
||||
|
||||
export async function* streamOmpCoreHistory(
|
||||
provider: string,
|
||||
messages: OmpAgentMessage[],
|
||||
userEntries: readonly OmpCapturedUserMessageEntry[] = [],
|
||||
hooks: OmpHistoryMapperHooks = {},
|
||||
): AsyncGenerator<AgentStreamEvent> {
|
||||
const mapper = new OmpHistoryMapper(provider, userEntries, hooks);
|
||||
for (const event of mapper.mapMessages(messages)) {
|
||||
if (event) {
|
||||
yield event;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toToolResultTimelineItem(input: {
|
||||
callId: string;
|
||||
name: string;
|
||||
isError: boolean;
|
||||
detail: ToolCallDetail;
|
||||
errorText: string;
|
||||
}): AgentTimelineItem {
|
||||
if (input.isError) {
|
||||
return {
|
||||
type: "tool_call",
|
||||
callId: input.callId,
|
||||
name: input.name,
|
||||
status: "failed",
|
||||
detail: input.detail,
|
||||
error: input.errorText,
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: "tool_call",
|
||||
callId: input.callId,
|
||||
name: input.name,
|
||||
status: "completed",
|
||||
detail: input.detail,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { OMP_MODES } from "@getpaseo/protocol/provider-manifest";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { ProviderRuntimeSettings } from "../../provider-launch-config.js";
|
||||
|
||||
const OMP_SESSION_DIR = "~/.omp/agent/sessions";
|
||||
const DEFAULT_OMP_MODE_ID = "full";
|
||||
|
||||
export const MIN_SUPPORTED_OMP_VERSION = "16.3.9";
|
||||
export { OMP_MODES };
|
||||
|
||||
export const OmpProviderParamsSchema = z
|
||||
.object({
|
||||
sessionDir: z.string().min(1).optional(),
|
||||
smolModel: z.string().min(1).optional(),
|
||||
slowModel: z.string().min(1).optional(),
|
||||
planModel: z.string().min(1).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export interface OmpRuntimeProviderParams {
|
||||
sessionDir: string;
|
||||
}
|
||||
|
||||
export interface OmpModelRoleParams {
|
||||
smolModel?: string;
|
||||
slowModel?: string;
|
||||
planModel?: string;
|
||||
}
|
||||
|
||||
export function resolveOmpLaunchMode(
|
||||
modeId: string | undefined,
|
||||
modelRoleParams: OmpModelRoleParams = {},
|
||||
): { modeId: string; extraArgs: string[] } {
|
||||
const modelRoleArgs = resolveOmpModelRoleArgs(modelRoleParams);
|
||||
switch (modeId ?? DEFAULT_OMP_MODE_ID) {
|
||||
case "full":
|
||||
return { modeId: "full", extraArgs: ["--approval-mode", "yolo", ...modelRoleArgs] };
|
||||
case "ask":
|
||||
return {
|
||||
modeId: "ask",
|
||||
extraArgs: ["--approval-mode", "always-ask", ...modelRoleArgs],
|
||||
};
|
||||
default:
|
||||
throw new Error(`Unsupported OMP mode '${modeId}'`);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveOmpModelRoleArgs(modelRoleParams: OmpModelRoleParams): string[] {
|
||||
const args: string[] = [];
|
||||
if (modelRoleParams.smolModel) args.push("--smol", modelRoleParams.smolModel);
|
||||
if (modelRoleParams.slowModel) args.push("--slow", modelRoleParams.slowModel);
|
||||
if (modelRoleParams.planModel) args.push("--plan", modelRoleParams.planModel);
|
||||
return args;
|
||||
}
|
||||
|
||||
export interface OmpDiagnosticPaths {
|
||||
profile: string;
|
||||
configRoot: string;
|
||||
agentDir: string;
|
||||
agentDb: string;
|
||||
xdgDataRoot: string;
|
||||
xdgStateRoot: string;
|
||||
xdgCacheRoot: string;
|
||||
}
|
||||
|
||||
export function resolveOmpDiagnosticPaths(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
home: string = homedir(),
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): OmpDiagnosticPaths {
|
||||
const normalizedProfile = (env.OMP_PROFILE ?? env.PI_PROFILE)?.trim();
|
||||
const profile =
|
||||
normalizedProfile && normalizedProfile !== "default" ? normalizedProfile : "default";
|
||||
const baseConfigRoot = join(home, env.PI_CONFIG_DIR || ".omp");
|
||||
const configRoot =
|
||||
profile === "default" ? baseConfigRoot : join(baseConfigRoot, "profiles", profile);
|
||||
const defaultAgentDir = join(configRoot, "agent");
|
||||
const agentDir =
|
||||
profile === "default" && env.PI_CODING_AGENT_DIR
|
||||
? resolve(env.PI_CODING_AGENT_DIR)
|
||||
: defaultAgentDir;
|
||||
const xdgSupported = platform === "linux" || platform === "darwin";
|
||||
const resolveXdgRoot = (variable: "XDG_DATA_HOME" | "XDG_STATE_HOME" | "XDG_CACHE_HOME") => {
|
||||
const base = env[variable];
|
||||
if (!xdgSupported || agentDir !== defaultAgentDir || !base) return undefined;
|
||||
const appRoot = join(base, "omp");
|
||||
const candidate = profile === "default" ? appRoot : join(appRoot, "profiles", profile);
|
||||
return existsSync(candidate) ? candidate : undefined;
|
||||
};
|
||||
const xdgDataRoot = resolveXdgRoot("XDG_DATA_HOME") ?? configRoot;
|
||||
const xdgStateRoot = resolveXdgRoot("XDG_STATE_HOME") ?? configRoot;
|
||||
const xdgCacheRoot = resolveXdgRoot("XDG_CACHE_HOME") ?? configRoot;
|
||||
|
||||
return {
|
||||
profile,
|
||||
configRoot,
|
||||
agentDir,
|
||||
agentDb: join(xdgDataRoot === configRoot ? agentDir : xdgDataRoot, "agent.db"),
|
||||
xdgDataRoot,
|
||||
xdgStateRoot,
|
||||
xdgCacheRoot,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatOmpVersionSupport(versionOutput: string): string {
|
||||
const match = versionOutput.match(/(\d+)\.(\d+)\.(\d+)\b/);
|
||||
if (!match) return `unknown (minimum ${MIN_SUPPORTED_OMP_VERSION})`;
|
||||
const installed = [Number(match[1]), Number(match[2]), Number(match[3])];
|
||||
const minimum = MIN_SUPPORTED_OMP_VERSION.split(".").map(Number);
|
||||
const supported =
|
||||
installed[0]! > minimum[0]! ||
|
||||
(installed[0] === minimum[0] &&
|
||||
(installed[1]! > minimum[1]! ||
|
||||
(installed[1] === minimum[1]! && installed[2]! >= minimum[2]!)));
|
||||
return `${match[1]}.${match[2]}.${match[3]} (${supported ? "supported" : "unsupported"}; minimum ${MIN_SUPPORTED_OMP_VERSION})`;
|
||||
}
|
||||
|
||||
export function resolveOmpProviderParams(providerParams: unknown): {
|
||||
runtimeProviderParams: OmpRuntimeProviderParams;
|
||||
modelRoleParams: OmpModelRoleParams;
|
||||
} {
|
||||
const params = OmpProviderParamsSchema.parse(providerParams ?? {});
|
||||
return {
|
||||
runtimeProviderParams: { sessionDir: params.sessionDir ?? OMP_SESSION_DIR },
|
||||
modelRoleParams: {
|
||||
...(params.smolModel ? { smolModel: params.smolModel } : {}),
|
||||
...(params.slowModel ? { slowModel: params.slowModel } : {}),
|
||||
...(params.planModel ? { planModel: params.planModel } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeOmpRuntimeSettings(
|
||||
base: ProviderRuntimeSettings | undefined,
|
||||
override: ProviderRuntimeSettings | undefined,
|
||||
): ProviderRuntimeSettings | undefined {
|
||||
if (!base && !override) return undefined;
|
||||
return {
|
||||
command: override?.command ?? base?.command,
|
||||
env: base?.env || override?.env ? { ...base?.env, ...override?.env } : undefined,
|
||||
disallowedTools:
|
||||
base?.disallowedTools || override?.disallowedTools
|
||||
? [...(base?.disallowedTools ?? []), ...(override?.disallowedTools ?? [])]
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,621 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const OmpThinkingLevelSchema = z.enum(["off", "minimal", "low", "medium", "high", "xhigh"]);
|
||||
|
||||
export const OmpImageContentSchema = z
|
||||
.object({ type: z.literal("image"), data: z.string(), mimeType: z.string() })
|
||||
.passthrough();
|
||||
export const OmpTextContentSchema = z
|
||||
.object({ type: z.literal("text"), text: z.string() })
|
||||
.passthrough();
|
||||
export const OmpThinkingContentSchema = z
|
||||
.object({ type: z.literal("thinking"), thinking: z.string() })
|
||||
.passthrough();
|
||||
export const OmpToolCallContentSchema = z
|
||||
.object({
|
||||
type: z.literal("toolCall"),
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
arguments: z.unknown(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const OmpAssistantContentSchema = z.discriminatedUnion("type", [
|
||||
OmpTextContentSchema,
|
||||
OmpThinkingContentSchema,
|
||||
OmpToolCallContentSchema,
|
||||
]);
|
||||
|
||||
const OmpUserMessageSchema = z
|
||||
.object({
|
||||
role: z.literal("user"),
|
||||
content: z.union([z.string(), z.array(z.union([OmpTextContentSchema, OmpImageContentSchema]))]),
|
||||
})
|
||||
.passthrough();
|
||||
const OmpCustomMessageSchema = z
|
||||
.object({
|
||||
role: z.literal("custom"),
|
||||
content: z.union([z.string(), z.array(z.union([OmpTextContentSchema, OmpImageContentSchema]))]),
|
||||
})
|
||||
.passthrough();
|
||||
const OmpAssistantMessageSchema = z
|
||||
.object({
|
||||
role: z.literal("assistant"),
|
||||
content: z.array(OmpAssistantContentSchema),
|
||||
provider: z.string().optional(),
|
||||
model: z.string().optional(),
|
||||
responseId: z.string().optional(),
|
||||
responseModel: z.string().optional(),
|
||||
errorMessage: z.string().nullable().optional(),
|
||||
stopReason: z.string().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
const OmpToolResultMessageSchema = z
|
||||
.object({
|
||||
role: z.literal("toolResult"),
|
||||
toolCallId: z.string(),
|
||||
toolName: z.string(),
|
||||
content: z.unknown(),
|
||||
isError: z.boolean().optional(),
|
||||
details: z.unknown().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
const OmpBashExecutionMessageSchema = z
|
||||
.object({
|
||||
role: z.literal("bashExecution"),
|
||||
command: z.string(),
|
||||
output: z.string().optional(),
|
||||
exitCode: z.number().nullable().optional(),
|
||||
cancelled: z.boolean().optional(),
|
||||
timestamp: z.number(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const OmpAgentMessageSchema = z.discriminatedUnion("role", [
|
||||
OmpUserMessageSchema,
|
||||
OmpCustomMessageSchema,
|
||||
OmpAssistantMessageSchema,
|
||||
OmpToolResultMessageSchema,
|
||||
OmpBashExecutionMessageSchema,
|
||||
]);
|
||||
|
||||
export const OmpModelSchema = z
|
||||
.object({
|
||||
provider: z.string(),
|
||||
id: z.string(),
|
||||
name: z.string().optional(),
|
||||
reasoning: z.boolean().optional(),
|
||||
contextWindow: z.number().optional(),
|
||||
maxTokens: z.number().nullable().optional(),
|
||||
api: z.string().optional(),
|
||||
baseUrl: z.string().optional(),
|
||||
input: z.array(z.string()).optional(),
|
||||
cost: z.record(z.string(), z.unknown()).optional(),
|
||||
compat: z.unknown().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const OmpContextUsageSchema = z
|
||||
.object({
|
||||
tokens: z.number().nullable().optional(),
|
||||
contextWindow: z.number().nullable().optional(),
|
||||
percent: z.number().nullable().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const OmpSessionStateSchema = z
|
||||
.object({
|
||||
model: OmpModelSchema.nullable().optional(),
|
||||
thinkingLevel: OmpThinkingLevelSchema,
|
||||
isStreaming: z.boolean(),
|
||||
isCompacting: z.boolean(),
|
||||
autoCompactionEnabled: z.boolean().optional(),
|
||||
sessionFile: z.string().optional(),
|
||||
sessionId: z.string(),
|
||||
sessionName: z.string().optional(),
|
||||
messageCount: z.number().int().nonnegative(),
|
||||
queuedMessageCount: z.number().int().nonnegative(),
|
||||
contextUsage: OmpContextUsageSchema.optional(),
|
||||
todoPhases: z.unknown().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const OmpSessionStatsSchema = z
|
||||
.object({
|
||||
tokens: z
|
||||
.object({
|
||||
input: z.number().optional(),
|
||||
output: z.number().optional(),
|
||||
cacheRead: z.number().optional(),
|
||||
cacheWrite: z.number().optional(),
|
||||
total: z.number().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.optional(),
|
||||
cost: z.number().optional(),
|
||||
contextUsage: OmpContextUsageSchema.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const OmpRpcSlashCommandSchema = z
|
||||
.object({
|
||||
name: z.string(),
|
||||
description: z.string().optional(),
|
||||
source: z.enum(["extension", "prompt", "skill", "builtin"]),
|
||||
sourceInfo: z.record(z.string(), z.unknown()).optional(),
|
||||
input: z.object({ hint: z.string().optional() }).passthrough().nullable().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const OmpAgentToolResultSchema = z
|
||||
.object({
|
||||
content: z.array(z.object({ type: z.string(), text: z.string().optional() }).passthrough()),
|
||||
details: z.unknown().optional(),
|
||||
isError: z.boolean().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const OmpRpcHostToolDefinitionSchema = z
|
||||
.object({
|
||||
name: z.string(),
|
||||
label: z.string().optional(),
|
||||
description: z.string(),
|
||||
parameters: z.record(z.string(), z.unknown()),
|
||||
hidden: z.boolean().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
export const OmpRpcHostToolCallRequestSchema = z
|
||||
.object({
|
||||
type: z.literal("host_tool_call"),
|
||||
id: z.string(),
|
||||
toolCallId: z.string(),
|
||||
toolName: z.string(),
|
||||
arguments: z.record(z.string(), z.unknown()),
|
||||
})
|
||||
.passthrough();
|
||||
export const OmpRpcHostToolCancelRequestSchema = z
|
||||
.object({
|
||||
type: z.literal("host_tool_cancel"),
|
||||
id: z.string(),
|
||||
targetId: z.string(),
|
||||
})
|
||||
.passthrough();
|
||||
export const OmpRpcHostToolUpdateSchema = z
|
||||
.object({
|
||||
type: z.literal("host_tool_update"),
|
||||
id: z.string(),
|
||||
partialResult: OmpAgentToolResultSchema,
|
||||
})
|
||||
.passthrough();
|
||||
export const OmpRpcHostToolResultSchema = z
|
||||
.object({
|
||||
type: z.literal("host_tool_result"),
|
||||
id: z.string(),
|
||||
result: OmpAgentToolResultSchema,
|
||||
isError: z.boolean().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const OmpSubagentSubscriptionLevelSchema = z.enum(["off", "progress", "events"]);
|
||||
export const OmpSubagentStatusSchema = z.enum([
|
||||
"pending",
|
||||
"running",
|
||||
"completed",
|
||||
"failed",
|
||||
"aborted",
|
||||
]);
|
||||
|
||||
export const OmpSubagentLifecyclePayloadSchema = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
agent: z.string(),
|
||||
agentSource: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
status: z.enum(["started", "completed", "failed", "aborted"]),
|
||||
sessionFile: z.string().optional(),
|
||||
parentToolCallId: z.string().optional(),
|
||||
index: z.number().int().nonnegative(),
|
||||
detached: z.boolean().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
export const OmpSubagentProgressSchema = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
status: OmpSubagentStatusSchema,
|
||||
description: z.string().optional(),
|
||||
currentTool: z.unknown().optional(),
|
||||
recentTools: z.array(z.unknown()).optional(),
|
||||
recentOutput: z.array(z.unknown()).optional(),
|
||||
resolvedModel: z.string().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
export const OmpSubagentProgressPayloadSchema = z
|
||||
.object({
|
||||
index: z.number().int().nonnegative(),
|
||||
agent: z.string(),
|
||||
agentSource: z.string().optional(),
|
||||
task: z.string(),
|
||||
parentToolCallId: z.string().optional(),
|
||||
assignment: z.string().optional(),
|
||||
progress: OmpSubagentProgressSchema,
|
||||
sessionFile: z.string().optional(),
|
||||
detached: z.boolean().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const OmpAssistantMessageEventSchema = z.discriminatedUnion("type", [
|
||||
z.object({ type: z.literal("text_delta"), delta: z.string().optional() }).passthrough(),
|
||||
z.object({ type: z.literal("thinking_delta"), delta: z.string().optional() }).passthrough(),
|
||||
z.object({ type: z.literal("start") }).passthrough(),
|
||||
z.object({ type: z.literal("text_start") }).passthrough(),
|
||||
z.object({ type: z.literal("text_end") }).passthrough(),
|
||||
z.object({ type: z.literal("thinking_start") }).passthrough(),
|
||||
z.object({ type: z.literal("thinking_end") }).passthrough(),
|
||||
z.object({ type: z.literal("done") }).passthrough(),
|
||||
]);
|
||||
|
||||
export const OmpAgentSessionEventSchema = z.discriminatedUnion("type", [
|
||||
z.object({ type: z.literal("agent_start") }).passthrough(),
|
||||
z.object({ type: z.literal("turn_start") }).passthrough(),
|
||||
z.object({ type: z.literal("message_start"), message: OmpAgentMessageSchema }).passthrough(),
|
||||
z.object({ type: z.literal("message_end"), message: OmpAgentMessageSchema }).passthrough(),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("message_update"),
|
||||
message: OmpAgentMessageSchema,
|
||||
assistantMessageEvent: OmpAssistantMessageEventSchema,
|
||||
})
|
||||
.passthrough(),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("tool_execution_start"),
|
||||
toolCallId: z.string(),
|
||||
toolName: z.string(),
|
||||
args: z.unknown(),
|
||||
})
|
||||
.passthrough(),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("tool_execution_update"),
|
||||
toolCallId: z.string(),
|
||||
toolName: z.string(),
|
||||
args: z.unknown().optional(),
|
||||
partialResult: z.unknown(),
|
||||
})
|
||||
.passthrough(),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("tool_execution_end"),
|
||||
toolCallId: z.string(),
|
||||
toolName: z.string(),
|
||||
result: z.unknown(),
|
||||
isError: z.boolean().optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("compaction_start"),
|
||||
reason: z.string().optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("compaction_end"),
|
||||
reason: z.string().optional(),
|
||||
errorMessage: z.string().optional(),
|
||||
aborted: z.boolean().optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
z
|
||||
.object({ type: z.literal("agent_end"), messages: z.array(OmpAgentMessageSchema).optional() })
|
||||
.passthrough(),
|
||||
]);
|
||||
|
||||
export const OmpTodoItemSchema = z
|
||||
.object({
|
||||
content: z.string(),
|
||||
status: z.enum(["pending", "in_progress", "completed", "abandoned"]),
|
||||
})
|
||||
.passthrough();
|
||||
export const OmpTodoPhaseSchema = z
|
||||
.object({ name: z.string(), tasks: z.array(OmpTodoItemSchema) })
|
||||
.passthrough();
|
||||
export const OmpTodoReminderEventSchema = z
|
||||
.object({ type: z.literal("todo_reminder"), todos: z.array(OmpTodoItemSchema) })
|
||||
.passthrough();
|
||||
export const OmpNoticeEventSchema = z
|
||||
.object({
|
||||
type: z.literal("notice"),
|
||||
level: z.enum(["info", "warning", "error"]),
|
||||
message: z.string(),
|
||||
source: z.string().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
export const OmpGoalSchema = z
|
||||
.object({
|
||||
id: z.string().optional(),
|
||||
objective: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
tokenBudget: z.number().optional(),
|
||||
tokensUsed: z.number().optional(),
|
||||
timeUsedSeconds: z.number().optional(),
|
||||
createdAt: z.string().optional(),
|
||||
updatedAt: z.string().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
export const OmpGoalModeStateSchema = z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
mode: z.string().optional(),
|
||||
reason: z.string().optional(),
|
||||
goal: OmpGoalSchema.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
export const OmpGoalUpdatedEventSchema = z
|
||||
.object({
|
||||
type: z.literal("goal_updated"),
|
||||
goal: OmpGoalSchema.nullable().optional(),
|
||||
state: OmpGoalModeStateSchema.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
export const OmpAutoRetryStartEventSchema = z
|
||||
.object({
|
||||
type: z.literal("auto_retry_start"),
|
||||
attempt: z.number().int().nonnegative(),
|
||||
maxAttempts: z.number().int().positive(),
|
||||
delayMs: z.number().int().nonnegative(),
|
||||
errorMessage: z.string(),
|
||||
errorId: z.number().int().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
export const OmpAutoRetryEndEventSchema = z
|
||||
.object({
|
||||
type: z.literal("auto_retry_end"),
|
||||
success: z.boolean(),
|
||||
attempt: z.number().int().nonnegative(),
|
||||
finalError: z.string().optional(),
|
||||
recoveredErrors: z.unknown().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
export const OmpRetryFallbackAppliedEventSchema = z
|
||||
.object({
|
||||
type: z.literal("retry_fallback_applied"),
|
||||
from: z.string(),
|
||||
to: z.string(),
|
||||
role: z.string(),
|
||||
})
|
||||
.passthrough();
|
||||
export const OmpRetryFallbackSucceededEventSchema = z
|
||||
.object({ type: z.literal("retry_fallback_succeeded"), model: z.string(), role: z.string() })
|
||||
.passthrough();
|
||||
export const OmpAutoCompactionStartEventSchema = z
|
||||
.object({ type: z.literal("auto_compaction_start"), reason: z.string(), action: z.string() })
|
||||
.passthrough();
|
||||
export const OmpAutoCompactionEndEventSchema = z
|
||||
.object({
|
||||
type: z.literal("auto_compaction_end"),
|
||||
action: z.string().optional(),
|
||||
result: z.unknown().optional(),
|
||||
aborted: z.boolean(),
|
||||
willRetry: z.boolean(),
|
||||
errorMessage: z.string().optional(),
|
||||
skipped: z.boolean().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
export const OmpAvailableCommandSchema = z
|
||||
.object({
|
||||
name: z.string(),
|
||||
description: z.string().optional(),
|
||||
source: z.string().optional(),
|
||||
input: z.object({ hint: z.string().optional() }).passthrough().nullable().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
export const OmpAvailableCommandsUpdateEventSchema = z
|
||||
.object({
|
||||
type: z.literal("available_commands_update"),
|
||||
commands: z.array(OmpAvailableCommandSchema),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const OmpExtensionUiRequestSchema = z
|
||||
.object({
|
||||
type: z.literal("extension_ui_request"),
|
||||
id: z.string(),
|
||||
method: z.string(),
|
||||
title: z.string().optional(),
|
||||
message: z.string().optional(),
|
||||
options: z.array(z.string()).optional(),
|
||||
placeholder: z.string().optional(),
|
||||
url: z.string().optional(),
|
||||
launchUrl: z.string().optional(),
|
||||
instructions: z.string().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
const OmpSubagentLifecycleEventSchema = z
|
||||
.object({ type: z.literal("subagent_lifecycle"), payload: OmpSubagentLifecyclePayloadSchema })
|
||||
.passthrough();
|
||||
const OmpSubagentProgressEventSchema = z
|
||||
.object({ type: z.literal("subagent_progress"), payload: OmpSubagentProgressPayloadSchema })
|
||||
.passthrough();
|
||||
export const OmpSubagentEventPayloadSchema = z
|
||||
.object({ id: z.string(), event: OmpAgentSessionEventSchema })
|
||||
.passthrough();
|
||||
const OmpSubagentEventSchema = z
|
||||
.object({
|
||||
type: z.literal("subagent_event"),
|
||||
payload: OmpSubagentEventPayloadSchema,
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const OmpRuntimeEventSchema = z.discriminatedUnion("type", [
|
||||
...OmpAgentSessionEventSchema.options,
|
||||
OmpExtensionUiRequestSchema,
|
||||
z.object({ type: z.literal("command_output"), text: z.string().optional() }).passthrough(),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("prompt_result"),
|
||||
id: z.string().optional(),
|
||||
agentInvoked: z.boolean().optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
z.object({ type: z.literal("process_exit"), error: z.string() }).passthrough(),
|
||||
OmpSubagentLifecycleEventSchema,
|
||||
OmpSubagentProgressEventSchema,
|
||||
OmpSubagentEventSchema,
|
||||
OmpTodoReminderEventSchema,
|
||||
OmpNoticeEventSchema,
|
||||
OmpGoalUpdatedEventSchema,
|
||||
OmpAutoRetryStartEventSchema,
|
||||
OmpAutoRetryEndEventSchema,
|
||||
OmpRetryFallbackAppliedEventSchema,
|
||||
OmpRetryFallbackSucceededEventSchema,
|
||||
OmpAutoCompactionStartEventSchema,
|
||||
OmpAutoCompactionEndEventSchema,
|
||||
OmpAvailableCommandsUpdateEventSchema,
|
||||
OmpRpcHostToolCallRequestSchema,
|
||||
OmpRpcHostToolCancelRequestSchema,
|
||||
OmpRpcHostToolUpdateSchema,
|
||||
]);
|
||||
|
||||
const OmpCommandBase = { id: z.string().optional() };
|
||||
export const OmpRpcCommandSchema = z.discriminatedUnion("type", [
|
||||
z.object({
|
||||
...OmpCommandBase,
|
||||
type: z.literal("prompt"),
|
||||
message: z.string(),
|
||||
images: z.array(OmpImageContentSchema).optional(),
|
||||
}),
|
||||
z.object({
|
||||
...OmpCommandBase,
|
||||
type: z.literal("compact"),
|
||||
customInstructions: z.string().optional(),
|
||||
}),
|
||||
z.object({ ...OmpCommandBase, type: z.literal("set_auto_compaction"), enabled: z.boolean() }),
|
||||
z.object({ ...OmpCommandBase, type: z.literal("abort") }),
|
||||
z.object({ ...OmpCommandBase, type: z.literal("get_state") }),
|
||||
z.object({ ...OmpCommandBase, type: z.literal("get_messages") }),
|
||||
z.object({ ...OmpCommandBase, type: z.literal("get_available_models") }),
|
||||
z.object({
|
||||
...OmpCommandBase,
|
||||
type: z.literal("set_model"),
|
||||
provider: z.string(),
|
||||
modelId: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
...OmpCommandBase,
|
||||
type: z.literal("set_thinking_level"),
|
||||
level: OmpThinkingLevelSchema,
|
||||
}),
|
||||
z.object({ ...OmpCommandBase, type: z.literal("get_session_stats") }),
|
||||
z.object({ ...OmpCommandBase, type: z.literal("get_available_commands") }),
|
||||
z.object({
|
||||
...OmpCommandBase,
|
||||
type: z.literal("set_subagent_subscription"),
|
||||
level: OmpSubagentSubscriptionLevelSchema,
|
||||
}),
|
||||
z.object({
|
||||
...OmpCommandBase,
|
||||
type: z.literal("set_host_tools"),
|
||||
tools: z.array(OmpRpcHostToolDefinitionSchema),
|
||||
}),
|
||||
z.object({ ...OmpCommandBase, type: z.literal("branch"), entryId: z.string() }),
|
||||
z.object({ ...OmpCommandBase, type: z.literal("get_branch_messages") }),
|
||||
z.object({
|
||||
...OmpCommandBase,
|
||||
type: z.literal("handoff"),
|
||||
customInstructions: z.string().optional(),
|
||||
}),
|
||||
]);
|
||||
|
||||
export const OmpPromptAckSchema = z
|
||||
.object({ agentInvoked: z.boolean().optional() })
|
||||
.passthrough()
|
||||
.optional();
|
||||
export const OmpMessagesResultSchema = z
|
||||
.object({ messages: z.array(OmpAgentMessageSchema).optional() })
|
||||
.passthrough();
|
||||
export const OmpModelsResultSchema = z
|
||||
.object({ models: z.array(OmpModelSchema).optional() })
|
||||
.passthrough();
|
||||
export const OmpCommandsResultSchema = z
|
||||
.object({ commands: z.array(OmpRpcSlashCommandSchema).optional() })
|
||||
.passthrough();
|
||||
export const OmpHostToolsResultSchema = z
|
||||
.object({ toolNames: z.array(z.string()).optional() })
|
||||
.passthrough();
|
||||
export const OmpBranchResultSchema = z
|
||||
.object({ text: z.string().optional(), cancelled: z.boolean().optional() })
|
||||
.passthrough();
|
||||
export const OmpBranchMessagesResultSchema = z
|
||||
.object({
|
||||
messages: z.array(z.object({ entryId: z.string(), text: z.string() }).passthrough()).optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export type OmpThinkingLevel = z.infer<typeof OmpThinkingLevelSchema>;
|
||||
export type OmpImageContent = z.infer<typeof OmpImageContentSchema>;
|
||||
export type OmpTextContent = z.infer<typeof OmpTextContentSchema>;
|
||||
export type OmpThinkingContent = z.infer<typeof OmpThinkingContentSchema>;
|
||||
export type OmpToolCallContent = z.infer<typeof OmpToolCallContentSchema>;
|
||||
export type OmpAssistantContent = z.infer<typeof OmpAssistantContentSchema>;
|
||||
export type OmpAgentMessage = z.infer<typeof OmpAgentMessageSchema>;
|
||||
export type OmpModel = z.infer<typeof OmpModelSchema>;
|
||||
export type OmpSessionState = z.infer<typeof OmpSessionStateSchema>;
|
||||
export type OmpSessionStats = z.infer<typeof OmpSessionStatsSchema>;
|
||||
export type OmpRpcSlashCommand = z.infer<typeof OmpRpcSlashCommandSchema>;
|
||||
export type OmpAgentToolResult = z.infer<typeof OmpAgentToolResultSchema>;
|
||||
export type OmpRpcHostToolDefinition = z.infer<typeof OmpRpcHostToolDefinitionSchema>;
|
||||
export type OmpRpcHostToolCallRequest = z.infer<typeof OmpRpcHostToolCallRequestSchema>;
|
||||
export type OmpRpcHostToolUpdate = z.infer<typeof OmpRpcHostToolUpdateSchema>;
|
||||
export type OmpRpcHostToolResult = z.infer<typeof OmpRpcHostToolResultSchema>;
|
||||
export type OmpSubagentSubscriptionLevel = z.infer<typeof OmpSubagentSubscriptionLevelSchema>;
|
||||
export type OmpSubagentStatus = z.infer<typeof OmpSubagentStatusSchema>;
|
||||
export type OmpSubagentLifecyclePayload = z.infer<typeof OmpSubagentLifecyclePayloadSchema>;
|
||||
export type OmpSubagentProgressPayload = z.infer<typeof OmpSubagentProgressPayloadSchema>;
|
||||
export type OmpSubagentEventPayload = z.infer<typeof OmpSubagentEventPayloadSchema>;
|
||||
export type OmpAssistantMessageEvent = z.infer<typeof OmpAssistantMessageEventSchema>;
|
||||
export type OmpAgentSessionEvent = z.infer<typeof OmpAgentSessionEventSchema>;
|
||||
export type OmpRuntimeEvent = z.infer<typeof OmpRuntimeEventSchema>;
|
||||
export type OmpTodoItem = z.infer<typeof OmpTodoItemSchema>;
|
||||
export type OmpTodoPhase = z.infer<typeof OmpTodoPhaseSchema>;
|
||||
export type OmpTodoReminderEvent = z.infer<typeof OmpTodoReminderEventSchema>;
|
||||
export type OmpNoticeEvent = z.infer<typeof OmpNoticeEventSchema>;
|
||||
export type OmpGoal = z.infer<typeof OmpGoalSchema>;
|
||||
export type OmpGoalUpdatedEvent = z.infer<typeof OmpGoalUpdatedEventSchema>;
|
||||
export type OmpAutoRetryStartEvent = z.infer<typeof OmpAutoRetryStartEventSchema>;
|
||||
export type OmpAutoRetryEndEvent = z.infer<typeof OmpAutoRetryEndEventSchema>;
|
||||
export type OmpRetryFallbackAppliedEvent = z.infer<typeof OmpRetryFallbackAppliedEventSchema>;
|
||||
export type OmpRetryFallbackSucceededEvent = z.infer<typeof OmpRetryFallbackSucceededEventSchema>;
|
||||
export type OmpAutoCompactionStartEvent = z.infer<typeof OmpAutoCompactionStartEventSchema>;
|
||||
export type OmpAutoCompactionEndEvent = z.infer<typeof OmpAutoCompactionEndEventSchema>;
|
||||
export type OmpAvailableCommand = z.infer<typeof OmpAvailableCommandSchema>;
|
||||
export type OmpAvailableCommandsUpdateEvent = z.infer<typeof OmpAvailableCommandsUpdateEventSchema>;
|
||||
export type OmpRpcCommand = z.infer<typeof OmpRpcCommandSchema>;
|
||||
export type OmpPromptAck = z.infer<typeof OmpPromptAckSchema> & { requestId?: string };
|
||||
|
||||
export interface OmpSubagentSnapshot {
|
||||
id: string;
|
||||
index: number;
|
||||
agent: string;
|
||||
description?: string;
|
||||
status: OmpSubagentStatus;
|
||||
task?: string;
|
||||
assignment?: string;
|
||||
sessionFile?: string;
|
||||
parentToolCallId?: string;
|
||||
lastUpdate?: number;
|
||||
}
|
||||
|
||||
export interface OmpSubagentMessagesResult {
|
||||
sessionFile: string;
|
||||
fromByte: number;
|
||||
nextByte: number;
|
||||
reset: boolean;
|
||||
messages: OmpAgentMessage[];
|
||||
}
|
||||
|
||||
export interface OmpSubagentMessagesSelector {
|
||||
subagentId?: string;
|
||||
sessionFile?: string;
|
||||
fromByte?: number;
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { OmpRuntimeEvent } from "./rpc-types.js";
|
||||
import {
|
||||
buildOmpRpcUiPermissionResponse,
|
||||
classifyOmpRpcUiPermissionRequest,
|
||||
} from "./rpc-ui-permission-mapper.js";
|
||||
|
||||
type ExtensionUiRequestEvent = Extract<OmpRuntimeEvent, { type: "extension_ui_request" }>;
|
||||
|
||||
const RPC_UI_CASES: ExtensionUiRequestEvent[] = [
|
||||
{ type: "extension_ui_request", id: "widget", method: "setWidget", widgetKey: "status" },
|
||||
{ type: "extension_ui_request", id: "notify", method: "notify", message: "done" },
|
||||
{
|
||||
type: "extension_ui_request",
|
||||
id: "bash",
|
||||
method: "select",
|
||||
title: "Allow tool: bash\nCommand: echo rpc-ui-hi",
|
||||
options: ["Approve", "Deny"],
|
||||
},
|
||||
{
|
||||
type: "extension_ui_request",
|
||||
id: "edit",
|
||||
method: "select",
|
||||
title: "Allow tool: edit\nFile: fixture.txt",
|
||||
options: ["Approve", "Deny"],
|
||||
},
|
||||
{
|
||||
type: "extension_ui_request",
|
||||
id: "write",
|
||||
method: "select",
|
||||
title: "Allow tool: write\nPath: created.txt\nContent:\nhello write",
|
||||
options: ["Approve", "Deny"],
|
||||
},
|
||||
];
|
||||
|
||||
function toolApproval(id: string) {
|
||||
const event = RPC_UI_CASES.find((candidate) => candidate.id === id);
|
||||
if (!event) throw new Error(`Missing RPC-UI case ${id}`);
|
||||
const classification = classifyOmpRpcUiPermissionRequest(event);
|
||||
if (classification.kind !== "tool") throw new Error(`Expected ${id} to be a tool approval`);
|
||||
return classification.request;
|
||||
}
|
||||
|
||||
describe("OMP rpc-ui permission mapper", () => {
|
||||
test("classifies tool approvals and passes through unrelated UI requests", () => {
|
||||
expect(
|
||||
RPC_UI_CASES.map((event) => {
|
||||
const classification = classifyOmpRpcUiPermissionRequest(event);
|
||||
return [event.id, classification.kind];
|
||||
}),
|
||||
).toEqual([
|
||||
["widget", "passthrough"],
|
||||
["notify", "passthrough"],
|
||||
["bash", "tool"],
|
||||
["edit", "tool"],
|
||||
["write", "tool"],
|
||||
]);
|
||||
});
|
||||
|
||||
test("maps tool approvals to renderable permissions", () => {
|
||||
expect([toolApproval("bash"), toolApproval("edit"), toolApproval("write")]).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "bash",
|
||||
provider: "omp",
|
||||
name: "bash",
|
||||
kind: "tool",
|
||||
detail: { type: "shell", command: "echo rpc-ui-hi" },
|
||||
metadata: expect.objectContaining({
|
||||
toolName: "bash",
|
||||
toolArgs: { command: "echo rpc-ui-hi" },
|
||||
approveValue: "Approve",
|
||||
denyValue: "Deny",
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "edit",
|
||||
provider: "omp",
|
||||
name: "edit",
|
||||
kind: "tool",
|
||||
detail: { type: "edit", filePath: "fixture.txt" },
|
||||
metadata: expect.objectContaining({ toolName: "edit", toolArgs: { path: "fixture.txt" } }),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "write",
|
||||
provider: "omp",
|
||||
name: "write",
|
||||
kind: "tool",
|
||||
detail: { type: "write", filePath: "created.txt", content: "hello write" },
|
||||
metadata: expect.objectContaining({
|
||||
toolName: "write",
|
||||
toolArgs: { path: "created.txt", content: "hello write" },
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test("preserves destructive multiline CRLF bash commands exactly", () => {
|
||||
const title = "Allow tool: bash\r\nCommand: printf first\r\n\r\n rm -rf /tmp/example\r\n";
|
||||
const classification = classifyOmpRpcUiPermissionRequest({
|
||||
type: "extension_ui_request",
|
||||
id: "multiline-bash",
|
||||
method: "select",
|
||||
title,
|
||||
options: ["Approve", "Deny"],
|
||||
});
|
||||
if (classification.kind !== "tool") throw new Error("Expected multiline bash approval");
|
||||
|
||||
expect(classification.request.detail).toEqual({
|
||||
type: "shell",
|
||||
command: "printf first\r\n\r\n rm -rf /tmp/example\r\n",
|
||||
});
|
||||
expect(classification.request.metadata?.toolArgs).toEqual({
|
||||
command: "printf first\r\n\r\n rm -rf /tmp/example\r\n",
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects approval lookalikes and unknown tools", () => {
|
||||
expect(
|
||||
classifyOmpRpcUiPermissionRequest({
|
||||
type: "extension_ui_request",
|
||||
id: "not-tool",
|
||||
method: "select",
|
||||
title: "Allow tool: bash\nCommand: echo hi",
|
||||
options: ["Yes", "No"],
|
||||
}),
|
||||
).toEqual({ kind: "passthrough" });
|
||||
expect(
|
||||
classifyOmpRpcUiPermissionRequest({
|
||||
type: "extension_ui_request",
|
||||
id: "unknown-tool",
|
||||
method: "select",
|
||||
title: "Allow tool: custom_tool\nReason: needs approval",
|
||||
options: ["Approve", "Deny"],
|
||||
}),
|
||||
).toEqual({ kind: "passthrough" });
|
||||
});
|
||||
|
||||
test("responds to tool approvals with exact select values", () => {
|
||||
const request = toolApproval("bash");
|
||||
|
||||
expect(buildOmpRpcUiPermissionResponse(request, { behavior: "allow" })).toEqual({
|
||||
value: "Approve",
|
||||
});
|
||||
expect(
|
||||
buildOmpRpcUiPermissionResponse(request, {
|
||||
behavior: "allow",
|
||||
selectedActionId: "allow_always",
|
||||
}),
|
||||
).toEqual({ value: "Approve" });
|
||||
expect(buildOmpRpcUiPermissionResponse(request, { behavior: "deny", message: "no" })).toEqual({
|
||||
value: "Deny",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,210 @@
|
||||
import type {
|
||||
AgentMetadata,
|
||||
AgentPermissionRequest,
|
||||
AgentPermissionResponse,
|
||||
AgentProvider,
|
||||
ToolCallDetail,
|
||||
} from "../../agent-sdk-types.js";
|
||||
import type { OmpRuntimeEvent } from "./rpc-types.js";
|
||||
|
||||
const OMP_PROVIDER = "omp";
|
||||
const OMP_RPC_UI_TOOL_APPROVAL_METADATA = "omp_rpc_ui_tool_approval";
|
||||
const TOOL_APPROVAL_APPROVE_VALUE = "Approve";
|
||||
const TOOL_APPROVAL_DENY_VALUE = "Deny";
|
||||
const TOOL_APPROVAL_OPTIONS = [TOOL_APPROVAL_APPROVE_VALUE, TOOL_APPROVAL_DENY_VALUE] as const;
|
||||
const TOOL_TITLE_PREFIX = "Allow tool: ";
|
||||
|
||||
export type OmpRpcUiPermissionClassification =
|
||||
| { kind: "tool"; request: AgentPermissionRequest }
|
||||
| { kind: "passthrough" };
|
||||
|
||||
type ExtensionUiRequestEvent = Extract<OmpRuntimeEvent, { type: "extension_ui_request" }>;
|
||||
|
||||
interface ToolApprovalDescriptor {
|
||||
toolName: "bash" | "edit" | "write";
|
||||
args: AgentMetadata;
|
||||
detail: ToolCallDetail;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export function classifyOmpRpcUiPermissionRequest(
|
||||
event: ExtensionUiRequestEvent,
|
||||
options: { provider?: AgentProvider } = {},
|
||||
): OmpRpcUiPermissionClassification {
|
||||
const descriptor = parseToolApprovalDescriptor(event);
|
||||
if (!descriptor) {
|
||||
return { kind: "passthrough" };
|
||||
}
|
||||
|
||||
const provider = options.provider ?? OMP_PROVIDER;
|
||||
return {
|
||||
kind: "tool",
|
||||
request: {
|
||||
id: event.id,
|
||||
provider,
|
||||
name: descriptor.toolName,
|
||||
kind: "tool",
|
||||
title: `Allow tool: ${descriptor.toolName}`,
|
||||
...(descriptor.description ? { description: descriptor.description } : {}),
|
||||
detail: descriptor.detail,
|
||||
actions: [
|
||||
{
|
||||
id: "deny",
|
||||
label: TOOL_APPROVAL_DENY_VALUE,
|
||||
behavior: "deny",
|
||||
variant: "danger",
|
||||
intent: "dismiss",
|
||||
},
|
||||
{
|
||||
id: "approve",
|
||||
label: TOOL_APPROVAL_APPROVE_VALUE,
|
||||
behavior: "allow",
|
||||
variant: "primary",
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
extensionUiMethod: "select",
|
||||
toolApproval: OMP_RPC_UI_TOOL_APPROVAL_METADATA,
|
||||
toolName: descriptor.toolName,
|
||||
toolArgs: descriptor.args,
|
||||
approveValue: TOOL_APPROVAL_APPROVE_VALUE,
|
||||
denyValue: TOOL_APPROVAL_DENY_VALUE,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function mapOmpRpcUiPermissionRequest(
|
||||
event: ExtensionUiRequestEvent,
|
||||
options: { provider?: AgentProvider } = {},
|
||||
): AgentPermissionRequest | null {
|
||||
const classification = classifyOmpRpcUiPermissionRequest(event, options);
|
||||
return classification.kind === "tool" ? classification.request : null;
|
||||
}
|
||||
|
||||
export function buildOmpRpcUiPermissionResponse(
|
||||
request: AgentPermissionRequest,
|
||||
response: AgentPermissionResponse,
|
||||
): { value?: string; confirmed?: boolean; cancelled?: boolean } | null {
|
||||
if (
|
||||
request.kind !== "tool" ||
|
||||
request.metadata?.toolApproval !== OMP_RPC_UI_TOOL_APPROVAL_METADATA
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const approveValue = readString(request.metadata.approveValue) ?? TOOL_APPROVAL_APPROVE_VALUE;
|
||||
const denyValue = readString(request.metadata.denyValue) ?? TOOL_APPROVAL_DENY_VALUE;
|
||||
return { value: response.behavior === "allow" ? approveValue : denyValue };
|
||||
}
|
||||
|
||||
function parseToolApprovalDescriptor(
|
||||
event: ExtensionUiRequestEvent,
|
||||
): ToolApprovalDescriptor | null {
|
||||
if (event.method !== "select" || !hasExactToolApprovalOptions(event.options)) {
|
||||
return null;
|
||||
}
|
||||
const title = readString(event.title);
|
||||
if (!title) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const firstLineBreak = title.search(/\r?\n/);
|
||||
const firstLine = (firstLineBreak < 0 ? title : title.slice(0, firstLineBreak)).trim();
|
||||
if (!firstLine.startsWith(TOOL_TITLE_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
const rawToolName = firstLine.slice(TOOL_TITLE_PREFIX.length).trim();
|
||||
const body = firstLineBreak < 0 ? "" : title.slice(firstLineBreak).replace(/^\r?\n/, "");
|
||||
const bodyLines = body.split(/\r?\n/);
|
||||
|
||||
switch (rawToolName) {
|
||||
case "bash":
|
||||
return parseBashApproval(body);
|
||||
case "edit":
|
||||
return parseEditApproval(bodyLines);
|
||||
case "write":
|
||||
return parseWriteApproval(bodyLines);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseBashApproval(body: string): ToolApprovalDescriptor | null {
|
||||
const match = /(?:^|\r?\n)[\t ]*Command:[\t ]?(.*)$/s.exec(body);
|
||||
const command = match?.[1];
|
||||
if (!command) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
toolName: "bash",
|
||||
args: { command },
|
||||
description: `Command: ${command}`,
|
||||
detail: { type: "shell", command },
|
||||
};
|
||||
}
|
||||
|
||||
function parseEditApproval(lines: string[]): ToolApprovalDescriptor | null {
|
||||
const filePath = readPrefixedValue(lines, "File:");
|
||||
if (!filePath) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
toolName: "edit",
|
||||
args: { path: filePath },
|
||||
description: `File: ${filePath}`,
|
||||
detail: { type: "edit", filePath },
|
||||
};
|
||||
}
|
||||
|
||||
function parseWriteApproval(lines: string[]): ToolApprovalDescriptor | null {
|
||||
const pathLineIndex = lines.findIndex((line) => line.trim().startsWith("Path:"));
|
||||
if (pathLineIndex < 0) {
|
||||
return null;
|
||||
}
|
||||
const filePath = stripPrefix(lines[pathLineIndex], "Path:")?.trim();
|
||||
if (!filePath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const contentLineIndex = lines.findIndex(
|
||||
(line, index) => index > pathLineIndex && line.trim() === "Content:",
|
||||
);
|
||||
if (contentLineIndex < 0) {
|
||||
return null;
|
||||
}
|
||||
const content = lines.slice(contentLineIndex + 1).join("\n");
|
||||
return {
|
||||
toolName: "write",
|
||||
args: { path: filePath, content },
|
||||
description: `Path: ${filePath}`,
|
||||
detail: { type: "write", filePath, content },
|
||||
};
|
||||
}
|
||||
|
||||
function hasExactToolApprovalOptions(options: unknown): boolean {
|
||||
return (
|
||||
Array.isArray(options) &&
|
||||
options.length === TOOL_APPROVAL_OPTIONS.length &&
|
||||
options.every((option, index) => option === TOOL_APPROVAL_OPTIONS[index])
|
||||
);
|
||||
}
|
||||
|
||||
function readPrefixedValue(lines: readonly string[], prefix: string): string | null {
|
||||
for (const line of lines) {
|
||||
const value = stripPrefix(line, prefix)?.trim();
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function stripPrefix(line: string | undefined, prefix: string): string | null {
|
||||
const trimmed = line?.trim();
|
||||
return trimmed?.startsWith(prefix) ? trimmed.slice(prefix.length) : null;
|
||||
}
|
||||
|
||||
function readString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.length > 0 ? value : null;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { expect, test } from "vitest";
|
||||
|
||||
import { OmpHarness } from "./test-utils/omp-harness.js";
|
||||
|
||||
test("falls back to progress when the event subscription is unavailable", async () => {
|
||||
const omp = new OmpHarness();
|
||||
omp.failEventSubscription(new Error("events unsupported"));
|
||||
await omp.start();
|
||||
|
||||
await expect(omp.waitForSubscriptionFallback()).resolves.toEqual(["events", "progress"]);
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
import type {
|
||||
OmpAgentMessage,
|
||||
OmpModel,
|
||||
OmpPromptAck,
|
||||
OmpRpcHostToolDefinition,
|
||||
OmpRpcHostToolResult,
|
||||
OmpRpcHostToolUpdate,
|
||||
OmpRpcSlashCommand,
|
||||
OmpRuntimeEvent,
|
||||
OmpSessionState,
|
||||
OmpSessionStats,
|
||||
OmpSubagentSubscriptionLevel,
|
||||
OmpThinkingLevel,
|
||||
} from "./rpc-types.js";
|
||||
import type { ProviderRuntimeSettings } from "../../provider-launch-config.js";
|
||||
|
||||
export interface OmpRuntimeLaunch {
|
||||
cwd: string;
|
||||
argv: string[];
|
||||
env?: Record<string, string>;
|
||||
protocolMode?: "rpc" | "rpc-ui";
|
||||
model?: string;
|
||||
thinkingOptionId?: string;
|
||||
modeId?: string;
|
||||
session?: string;
|
||||
noSession?: boolean;
|
||||
systemPrompt?: string;
|
||||
extraArgs?: string[];
|
||||
}
|
||||
|
||||
export interface OmpStartSessionInput {
|
||||
cwd: string;
|
||||
env?: Record<string, string>;
|
||||
protocolMode?: "rpc" | "rpc-ui";
|
||||
model?: string;
|
||||
thinkingOptionId?: string;
|
||||
modeId?: string;
|
||||
session?: string;
|
||||
noSession?: boolean;
|
||||
systemPrompt?: string;
|
||||
extraArgs?: string[];
|
||||
}
|
||||
|
||||
export interface OmpRuntimeSession {
|
||||
onEvent(callback: (event: OmpRuntimeEvent) => void): () => void;
|
||||
prompt(
|
||||
message: string,
|
||||
images?: Array<{ type: "image"; data: string; mimeType: string }>,
|
||||
): Promise<OmpPromptAck>;
|
||||
compact(customInstructions?: string): Promise<void>;
|
||||
setAutoCompaction(enabled: boolean): Promise<void>;
|
||||
abort(): Promise<void>;
|
||||
getState(): Promise<OmpSessionState>;
|
||||
getMessages(): Promise<OmpAgentMessage[]>;
|
||||
getAvailableModels(timeoutMs?: number): Promise<OmpModel[]>;
|
||||
setModel(provider: string, modelId: string): Promise<OmpModel>;
|
||||
setThinkingLevel(level: OmpThinkingLevel): Promise<void>;
|
||||
getSessionStats(): Promise<OmpSessionStats>;
|
||||
getCommands(): Promise<OmpRpcSlashCommand[]>;
|
||||
setSubagentSubscription(level: OmpSubagentSubscriptionLevel): Promise<void>;
|
||||
setHostTools(tools: OmpRpcHostToolDefinition[]): Promise<string[]>;
|
||||
sendHostToolResult(result: OmpRpcHostToolResult): void;
|
||||
sendHostToolUpdate(update: OmpRpcHostToolUpdate): void;
|
||||
branch(entryId: string): Promise<{ text: string }>;
|
||||
getBranchMessages(): Promise<Array<{ entryId: string; text: string }>>;
|
||||
activeBranchEntryId?: string;
|
||||
steer(message: string, images?: Array<{ type: "image"; data: string; mimeType: string }>): void;
|
||||
followUp(
|
||||
message: string,
|
||||
images?: Array<{ type: "image"; data: string; mimeType: string }>,
|
||||
): void;
|
||||
handoff(customInstructions?: string): Promise<void>;
|
||||
respondToExtensionUiRequest(
|
||||
id: string,
|
||||
response: { value?: string; confirmed?: boolean; cancelled?: boolean },
|
||||
): void;
|
||||
cancelExtensionUiRequest(id: string): void;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface OmpRuntime {
|
||||
startSession(input: OmpStartSessionInput): Promise<OmpRuntimeSession>;
|
||||
}
|
||||
|
||||
export function buildOmpLaunch(input: {
|
||||
command: [string, ...string[]];
|
||||
runtimeSettings?: ProviderRuntimeSettings;
|
||||
session: OmpStartSessionInput;
|
||||
}): OmpRuntimeLaunch {
|
||||
const command =
|
||||
input.runtimeSettings?.command?.mode === "replace" && input.runtimeSettings.command.argv[0]
|
||||
? input.runtimeSettings.command.argv
|
||||
: input.command;
|
||||
const argv = [...command];
|
||||
|
||||
const protocolMode = input.session.protocolMode ?? "rpc";
|
||||
const systemPrompt = input.session.systemPrompt?.trim();
|
||||
appendOmpLaunchArgs(argv, input.session, protocolMode, systemPrompt);
|
||||
|
||||
return {
|
||||
cwd: input.session.cwd,
|
||||
argv,
|
||||
env:
|
||||
input.runtimeSettings?.env || input.session.env
|
||||
? {
|
||||
...input.runtimeSettings?.env,
|
||||
...input.session.env,
|
||||
}
|
||||
: undefined,
|
||||
model: input.session.model,
|
||||
thinkingOptionId: input.session.thinkingOptionId,
|
||||
protocolMode,
|
||||
modeId: input.session.modeId,
|
||||
session: input.session.session,
|
||||
noSession: input.session.noSession,
|
||||
systemPrompt,
|
||||
extraArgs: input.session.extraArgs,
|
||||
};
|
||||
}
|
||||
|
||||
function appendOmpLaunchArgs(
|
||||
argv: string[],
|
||||
session: OmpStartSessionInput,
|
||||
protocolMode: "rpc" | "rpc-ui",
|
||||
systemPrompt: string | undefined,
|
||||
): void {
|
||||
if (!hasModeFlag(argv)) {
|
||||
argv.push("--mode", protocolMode);
|
||||
}
|
||||
if (session.extraArgs?.length) {
|
||||
argv.push(...session.extraArgs);
|
||||
}
|
||||
if (session.model) {
|
||||
argv.push("--model", session.model);
|
||||
}
|
||||
if (session.thinkingOptionId) {
|
||||
argv.push("--thinking", session.thinkingOptionId);
|
||||
}
|
||||
if (session.noSession) {
|
||||
argv.push("--no-session");
|
||||
} else if (session.session) {
|
||||
argv.push("--session", session.session);
|
||||
}
|
||||
if (systemPrompt) {
|
||||
argv.push("--append-system-prompt", systemPrompt);
|
||||
}
|
||||
}
|
||||
|
||||
function hasModeFlag(argv: string[]): boolean {
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
if (argv[i] === "--mode") {
|
||||
return true;
|
||||
}
|
||||
if (argv[i]?.startsWith("--mode=")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { mkdtemp, mkdir, utimes, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { listOmpImportableSessions, readOmpImportSessionConfig } from "./session-descriptor.js";
|
||||
|
||||
async function writeSession(root: string, relativePath: string, lines: unknown[]): Promise<string> {
|
||||
const filePath = path.join(root, "sessions", relativePath);
|
||||
await mkdir(path.dirname(filePath), { recursive: true });
|
||||
await writeFile(filePath, `${lines.map((line) => JSON.stringify(line)).join("\n")}\n`, "utf8");
|
||||
return filePath;
|
||||
}
|
||||
|
||||
describe("OMP session descriptor", () => {
|
||||
test("reads title-first sessions and OMP combined model identifiers", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "paseo-omp-session-title-first-"));
|
||||
const cwd = path.join(root, "repo");
|
||||
const sessionFile = await writeSession(root, "project/session.jsonl", [
|
||||
{
|
||||
type: "title",
|
||||
id: "title-1",
|
||||
timestamp: "2026-06-09T00:00:00.000Z",
|
||||
title: "Deploy Paseo and verify",
|
||||
},
|
||||
{
|
||||
type: "session",
|
||||
version: 3,
|
||||
id: "session-title-first",
|
||||
timestamp: "2026-06-09T00:00:00.100Z",
|
||||
cwd,
|
||||
},
|
||||
{
|
||||
type: "model_change",
|
||||
id: "model-1",
|
||||
timestamp: "2026-06-09T00:00:00.200Z",
|
||||
model: "openai-codex/gpt-5.1",
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
id: "user-1",
|
||||
timestamp: "2026-06-09T00:00:01.000Z",
|
||||
message: { role: "user", content: [{ type: "text", text: "import me" }] },
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(
|
||||
listOmpImportableSessions({ sessionDir: path.join(root, "sessions") }),
|
||||
).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
providerHandleId: sessionFile,
|
||||
cwd,
|
||||
title: "Deploy Paseo and verify",
|
||||
firstPromptPreview: "import me",
|
||||
}),
|
||||
]);
|
||||
await expect(readOmpImportSessionConfig(sessionFile)).resolves.toEqual({
|
||||
model: "openai-codex/gpt-5.1",
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps recent nested OMP subagent sessions importable", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "paseo-omp-session-nested-"));
|
||||
const cwd = path.join(root, "repo");
|
||||
const parent = await writeSession(root, "project/parent.jsonl", [
|
||||
{ type: "session", id: "parent", timestamp: "2026-06-10T00:00:00.000Z", cwd },
|
||||
{
|
||||
type: "message",
|
||||
id: "parent-user",
|
||||
timestamp: "2026-06-10T00:00:01.000Z",
|
||||
message: { role: "user", content: "parent prompt" },
|
||||
},
|
||||
]);
|
||||
const child = await writeSession(root, "project/parent/Explore.jsonl", [
|
||||
{ type: "session", id: "child", timestamp: "2026-06-09T00:00:00.000Z", cwd },
|
||||
{
|
||||
type: "message",
|
||||
id: "child-user",
|
||||
timestamp: "2026-06-09T00:00:01.000Z",
|
||||
message: { role: "user", content: "child prompt" },
|
||||
},
|
||||
]);
|
||||
await utimes(parent, new Date("2026-06-08"), new Date("2026-06-08"));
|
||||
await utimes(child, new Date("2026-06-09"), new Date("2026-06-09"));
|
||||
|
||||
await expect(
|
||||
listOmpImportableSessions({ sessionDir: path.join(root, "sessions"), limit: 1 }),
|
||||
).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
providerHandleId: child,
|
||||
title: "Explore",
|
||||
firstPromptPreview: "child prompt",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test("uses OMP's own default session directory", async () => {
|
||||
const home = await mkdtemp(path.join(tmpdir(), "paseo-omp-session-home-"));
|
||||
const cwd = path.join(home, "repo");
|
||||
const sessionFile = path.join(home, ".omp", "agent", "sessions", "project", "session.jsonl");
|
||||
await mkdir(path.dirname(sessionFile), { recursive: true });
|
||||
await writeFile(
|
||||
sessionFile,
|
||||
`${JSON.stringify({ type: "session", id: "default-dir", timestamp: "2026-06-09", cwd })}\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
await expect(listOmpImportableSessions({ homeDir: home, env: {} })).resolves.toEqual([
|
||||
expect.objectContaining({ providerHandleId: sessionFile, cwd }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,504 @@
|
||||
import type { Dirent } from "node:fs";
|
||||
import { open, readdir, readFile, stat } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import type {
|
||||
ImportableProviderSession,
|
||||
ListImportableSessionsOptions,
|
||||
} from "../../agent-sdk-types.js";
|
||||
import type { ProviderRuntimeSettings } from "../../provider-launch-config.js";
|
||||
import { createRealpathAwarePathMatcher } from "../../../../utils/path.js";
|
||||
|
||||
const OMP_CONFIG_DIR_NAME = ".omp";
|
||||
const OMP_AGENT_DIR_ENV = "OMP_AGENT_DIR";
|
||||
const OMP_SESSION_DIR_ENV = "OMP_SESSION_DIR";
|
||||
// Import listing intentionally bounds header parsing to this window. Sessions
|
||||
// with unusually large preambles may omit their first-prompt preview.
|
||||
const HEAD_BYTES = 64 * 1024;
|
||||
const TAIL_BYTES = 256 * 1024;
|
||||
const FULL_SCAN_LINE_LIMIT = 2_000;
|
||||
// Rank all discovered files cheaply, then parse only a bounded recent window.
|
||||
// OMP keeps nested completed-subagent transcripts importable, so discovery
|
||||
// remains recursive rather than applying Pi's historical parent-only depth cap.
|
||||
const IMPORT_CANDIDATE_OVERSCAN = 40;
|
||||
const IMPORT_CANDIDATE_MIN = 400;
|
||||
|
||||
interface OmpSessionDescriptorOptions extends ListImportableSessionsOptions {
|
||||
sessionDir?: string;
|
||||
runtimeSettings?: ProviderRuntimeSettings;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
homeDir?: string;
|
||||
}
|
||||
|
||||
interface OmpSessionHeader {
|
||||
sessionId: string;
|
||||
cwd: string;
|
||||
createdAt: Date | null;
|
||||
}
|
||||
|
||||
interface OmpSessionTail {
|
||||
title: string | null;
|
||||
lastActivityAt: Date | null;
|
||||
lastUserMessage: string | null;
|
||||
model: string | null;
|
||||
thinkingOptionId: string | null;
|
||||
}
|
||||
|
||||
interface OmpSessionHead {
|
||||
title: string | null;
|
||||
firstUserMessage: string | null;
|
||||
model: string | null;
|
||||
thinkingOptionId: string | null;
|
||||
}
|
||||
|
||||
interface OmpSessionDescriptor {
|
||||
cwd: string;
|
||||
title: string | null;
|
||||
firstUserMessage: string | null;
|
||||
lastUserMessage: string | null;
|
||||
lastActivityAt: Date;
|
||||
model: string | null;
|
||||
thinkingOptionId: string | null;
|
||||
}
|
||||
interface RankedSessionFile {
|
||||
file: string;
|
||||
mtime: Date;
|
||||
}
|
||||
|
||||
export interface OmpImportSessionConfig {
|
||||
model?: string;
|
||||
thinkingOptionId?: string;
|
||||
}
|
||||
|
||||
export async function listOmpImportableSessions(
|
||||
options: OmpSessionDescriptorOptions = {},
|
||||
): Promise<ImportableProviderSession[]> {
|
||||
const sessionsDir = await resolveOmpSessionsDir(options);
|
||||
const files = await walkJsonlFiles(sessionsDir);
|
||||
const matchesCwd = options.cwd ? createRealpathAwarePathMatcher(options.cwd) : null;
|
||||
const limit = options.limit ?? 20;
|
||||
const ranked = await rankSessionFilesByMtime(files);
|
||||
const candidateLimit = Math.max(limit * IMPORT_CANDIDATE_OVERSCAN, IMPORT_CANDIDATE_MIN);
|
||||
const sessions: ImportableProviderSession[] = [];
|
||||
|
||||
for (const entry of ranked.slice(0, candidateLimit)) {
|
||||
const session = await readOmpImportableSession(entry.file);
|
||||
if (!session) continue;
|
||||
if (matchesCwd && !matchesCwd(session.cwd)) continue;
|
||||
sessions.push(session);
|
||||
if (sessions.length >= limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return sessions.sort(
|
||||
(left, right) => right.lastActivityAt.getTime() - left.lastActivityAt.getTime(),
|
||||
);
|
||||
}
|
||||
|
||||
export async function readOmpImportSessionConfig(
|
||||
filePath: string,
|
||||
): Promise<OmpImportSessionConfig> {
|
||||
const descriptor = await readOmpSessionDescriptor(filePath);
|
||||
if (!descriptor) return {};
|
||||
return toOmpImportSessionConfig(descriptor);
|
||||
}
|
||||
|
||||
async function resolveOmpSessionsDir(options: OmpSessionDescriptorOptions): Promise<string> {
|
||||
const env = options.env ?? process.env;
|
||||
const homeDir = options.homeDir ?? homedir();
|
||||
const baseDir = options.cwd ?? process.cwd();
|
||||
|
||||
if (options.sessionDir?.trim()) {
|
||||
return resolveConfigPath(options.sessionDir, { baseDir, homeDir });
|
||||
}
|
||||
|
||||
const agentDir = resolveOmpAgentDir({ runtimeSettings: options.runtimeSettings, env, homeDir });
|
||||
|
||||
const envSessionDir =
|
||||
options.runtimeSettings?.env?.[OMP_SESSION_DIR_ENV] ?? env[OMP_SESSION_DIR_ENV];
|
||||
if (envSessionDir?.trim()) {
|
||||
return resolveConfigPath(envSessionDir, { baseDir, homeDir });
|
||||
}
|
||||
|
||||
const settingsSessionDir = await readConfiguredSessionDir({
|
||||
agentDir,
|
||||
cwd: options.cwd,
|
||||
});
|
||||
if (settingsSessionDir?.trim()) {
|
||||
return resolveConfigPath(settingsSessionDir, { baseDir, homeDir });
|
||||
}
|
||||
|
||||
return path.join(agentDir, "sessions");
|
||||
}
|
||||
|
||||
function resolveOmpAgentDir(input: {
|
||||
runtimeSettings?: ProviderRuntimeSettings;
|
||||
env: NodeJS.ProcessEnv;
|
||||
homeDir: string;
|
||||
}): string {
|
||||
const configured =
|
||||
input.runtimeSettings?.env?.[OMP_AGENT_DIR_ENV] ?? input.env[OMP_AGENT_DIR_ENV];
|
||||
if (configured?.trim()) {
|
||||
return resolveConfigPath(configured, { baseDir: process.cwd(), homeDir: input.homeDir });
|
||||
}
|
||||
return path.join(input.homeDir, OMP_CONFIG_DIR_NAME, "agent");
|
||||
}
|
||||
|
||||
async function readConfiguredSessionDir(input: {
|
||||
agentDir: string;
|
||||
cwd: string | undefined;
|
||||
}): Promise<string | null> {
|
||||
const values = await Promise.all([
|
||||
readSessionDirFromSettings(path.join(input.agentDir, "settings.json")),
|
||||
input.cwd
|
||||
? readSessionDirFromSettings(path.join(input.cwd, OMP_CONFIG_DIR_NAME, "settings.json"))
|
||||
: null,
|
||||
]);
|
||||
return values[1] ?? values[0] ?? null;
|
||||
}
|
||||
|
||||
async function readSessionDirFromSettings(settingsPath: string): Promise<string | null> {
|
||||
try {
|
||||
const parsed = JSON.parse(await readFile(settingsPath, "utf8")) as unknown;
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return null;
|
||||
}
|
||||
const sessionDir = Reflect.get(parsed, "sessionDir");
|
||||
return typeof sessionDir === "string" && sessionDir.trim() ? sessionDir : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveConfigPath(value: string, options: { baseDir: string; homeDir: string }): string {
|
||||
if (value === "~") {
|
||||
return options.homeDir;
|
||||
}
|
||||
if (value.startsWith("~/")) {
|
||||
return path.join(options.homeDir, value.slice(2));
|
||||
}
|
||||
return path.isAbsolute(value) ? value : path.resolve(options.baseDir, value);
|
||||
}
|
||||
|
||||
async function walkJsonlFiles(root: string): Promise<string[]> {
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = await readdir(root, { withFileTypes: true });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const files = await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
const entryPath = path.join(root, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
return await walkJsonlFiles(entryPath);
|
||||
}
|
||||
return entry.isFile() && entry.name.endsWith(".jsonl") ? [entryPath] : [];
|
||||
}),
|
||||
);
|
||||
return files.flat();
|
||||
}
|
||||
|
||||
async function rankSessionFilesByMtime(files: string[]): Promise<RankedSessionFile[]> {
|
||||
const ranked = await Promise.all(
|
||||
files.map(async (file) => {
|
||||
const mtime = await readFileMtime(file);
|
||||
return mtime ? { file, mtime } : null;
|
||||
}),
|
||||
);
|
||||
return ranked
|
||||
.filter((entry): entry is RankedSessionFile => entry !== null)
|
||||
.sort((left, right) => right.mtime.getTime() - left.mtime.getTime());
|
||||
}
|
||||
|
||||
async function readOmpImportableSession(
|
||||
filePath: string,
|
||||
): Promise<ImportableProviderSession | null> {
|
||||
const descriptor = await readOmpSessionDescriptor(filePath);
|
||||
if (!descriptor) return null;
|
||||
|
||||
return {
|
||||
providerHandleId: filePath,
|
||||
cwd: descriptor.cwd,
|
||||
title: descriptor.title,
|
||||
firstPromptPreview: normalizePromptPreview(descriptor.firstUserMessage),
|
||||
lastPromptPreview: normalizePromptPreview(
|
||||
descriptor.lastUserMessage ?? descriptor.firstUserMessage,
|
||||
),
|
||||
lastActivityAt: descriptor.lastActivityAt,
|
||||
};
|
||||
}
|
||||
|
||||
async function readOmpSessionDescriptor(filePath: string): Promise<OmpSessionDescriptor | null> {
|
||||
// OMP may emit title/session_info lines before the session header.
|
||||
const headChunk = await readHeadChunk(filePath);
|
||||
if (!headChunk) return null;
|
||||
const header = parseSessionHeaderFromChunk(headChunk);
|
||||
if (!header) return null;
|
||||
|
||||
const tail = await readTail(filePath).catch(() => "");
|
||||
const tailInfo = parseSessionTail(tail);
|
||||
const headInfo = parseSessionHeadFromChunk(headChunk);
|
||||
const title =
|
||||
tailInfo.title ??
|
||||
headInfo.title ??
|
||||
readReadableSessionTitleFromPath(filePath) ??
|
||||
headInfo.firstUserMessage;
|
||||
const model = tailInfo.model ?? headInfo.model;
|
||||
const thinkingOptionId = tailInfo.thinkingOptionId ?? headInfo.thinkingOptionId;
|
||||
const lastActivityAt =
|
||||
tailInfo.lastActivityAt ?? (await readFileMtime(filePath)) ?? header.createdAt ?? new Date(0);
|
||||
|
||||
return {
|
||||
cwd: header.cwd,
|
||||
title,
|
||||
firstUserMessage: headInfo.firstUserMessage,
|
||||
lastUserMessage: tailInfo.lastUserMessage,
|
||||
lastActivityAt,
|
||||
model,
|
||||
thinkingOptionId,
|
||||
};
|
||||
}
|
||||
|
||||
function toOmpImportSessionConfig(descriptor: OmpSessionDescriptor): OmpImportSessionConfig {
|
||||
return {
|
||||
...(descriptor.model ? { model: descriptor.model } : {}),
|
||||
...(descriptor.thinkingOptionId ? { thinkingOptionId: descriptor.thinkingOptionId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function readHeadChunk(filePath: string): Promise<string | null> {
|
||||
const handle = await open(filePath, "r").catch(() => null);
|
||||
if (!handle) return null;
|
||||
try {
|
||||
const buffer = Buffer.alloc(HEAD_BYTES);
|
||||
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
||||
if (bytesRead <= 0) return null;
|
||||
return buffer.subarray(0, bytesRead).toString("utf8");
|
||||
} finally {
|
||||
await handle.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function parseSessionHeaderFromChunk(chunk: string): OmpSessionHeader | null {
|
||||
for (const line of chunk.split(/\r?\n/u)) {
|
||||
const header = parseSessionHeader(line.trim());
|
||||
if (header) return header;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseSessionHeadFromChunk(chunk: string): OmpSessionHead {
|
||||
let title: string | null = null;
|
||||
let firstUserMessage: string | null = null;
|
||||
let model: string | null = null;
|
||||
let thinkingOptionId: string | null = null;
|
||||
let lineCount = 0;
|
||||
|
||||
for (const rawLine of chunk.split(/\r?\n/u)) {
|
||||
lineCount += 1;
|
||||
const entry = parseJsonRecord(rawLine.trim());
|
||||
if (!entry) continue;
|
||||
|
||||
if (entry.type === "session_info") {
|
||||
title = readNonEmptyString(entry.name) ?? title;
|
||||
}
|
||||
if (entry.type === "title") {
|
||||
title = readNonEmptyString(entry.title) ?? title;
|
||||
}
|
||||
|
||||
model = extractModel(entry) ?? model;
|
||||
thinkingOptionId = extractThinkingOptionId(entry) ?? thinkingOptionId;
|
||||
|
||||
if (!firstUserMessage && entry.type === "message" && isRecord(entry.message)) {
|
||||
if (entry.message.role === "user") {
|
||||
firstUserMessage = extractMessageText(entry.message.content);
|
||||
}
|
||||
}
|
||||
|
||||
if (title && firstUserMessage && model && thinkingOptionId) {
|
||||
break;
|
||||
}
|
||||
if (lineCount >= FULL_SCAN_LINE_LIMIT && firstUserMessage) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { title, firstUserMessage, model, thinkingOptionId };
|
||||
}
|
||||
|
||||
async function readTail(filePath: string): Promise<string> {
|
||||
const fileStats = await stat(filePath);
|
||||
const start = Math.max(0, fileStats.size - TAIL_BYTES);
|
||||
const length = fileStats.size - start;
|
||||
const handle = await open(filePath, "r");
|
||||
try {
|
||||
const buffer = Buffer.alloc(length);
|
||||
const { bytesRead } = await handle.read(buffer, 0, buffer.length, start);
|
||||
return buffer.subarray(0, bytesRead).toString("utf8");
|
||||
} finally {
|
||||
await handle.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function readFileMtime(filePath: string): Promise<Date | null> {
|
||||
try {
|
||||
return (await stat(filePath)).mtime;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseSessionHeader(firstLine: string): OmpSessionHeader | null {
|
||||
const entry = parseJsonRecord(firstLine);
|
||||
if (!entry || entry.type !== "session") return null;
|
||||
const sessionId = typeof entry.id === "string" ? entry.id : null;
|
||||
const cwd = typeof entry.cwd === "string" ? entry.cwd : null;
|
||||
if (!sessionId || !cwd) return null;
|
||||
const createdAt = parseDate(entry.timestamp);
|
||||
return { sessionId, cwd, createdAt };
|
||||
}
|
||||
|
||||
function parseSessionTail(tail: string): OmpSessionTail {
|
||||
const lines = tail.split(/\r?\n/u);
|
||||
let title: string | null = null;
|
||||
let lastActivityAt: Date | null = null;
|
||||
let fallbackTimestamp: Date | null = null;
|
||||
let lastUserMessage: string | null = null;
|
||||
let model: string | null = null;
|
||||
let thinkingOptionId: string | null = null;
|
||||
|
||||
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
||||
const entry = parseJsonRecord(lines[index].trim());
|
||||
if (!entry) continue;
|
||||
|
||||
if (!title && entry.type === "session_info") {
|
||||
title = readNonEmptyString(entry.name);
|
||||
}
|
||||
if (!title && entry.type === "title") {
|
||||
title = readNonEmptyString(entry.title);
|
||||
}
|
||||
|
||||
if (!model) {
|
||||
model = extractModel(entry);
|
||||
}
|
||||
|
||||
if (!thinkingOptionId) {
|
||||
thinkingOptionId = extractThinkingOptionId(entry);
|
||||
}
|
||||
|
||||
const entryTimestamp = parseDate(entry.timestamp);
|
||||
if (!fallbackTimestamp && entryTimestamp) {
|
||||
fallbackTimestamp = entryTimestamp;
|
||||
}
|
||||
|
||||
if (entry.type !== "message") continue;
|
||||
|
||||
if (!lastActivityAt && entryTimestamp) {
|
||||
lastActivityAt = entryTimestamp;
|
||||
}
|
||||
|
||||
if (!lastUserMessage && isRecord(entry.message) && entry.message.role === "user") {
|
||||
lastUserMessage = extractMessageText(entry.message.content);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
lastActivityAt: lastActivityAt ?? fallbackTimestamp,
|
||||
lastUserMessage,
|
||||
model,
|
||||
thinkingOptionId,
|
||||
};
|
||||
}
|
||||
|
||||
function extractModel(entry: Record<string, unknown>): string | null {
|
||||
if (entry.type === "model_change") {
|
||||
// Pi records provider + modelId; OMP records a combined model string.
|
||||
return buildModelId(entry.provider, entry.modelId) ?? readNonEmptyString(entry.model);
|
||||
}
|
||||
|
||||
if (entry.type === "message" && isRecord(entry.message)) {
|
||||
return buildModelId(entry.message.provider, entry.message.model);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractThinkingOptionId(entry: Record<string, unknown>): string | null {
|
||||
return entry.type === "thinking_level_change" ? readNonEmptyString(entry.thinkingLevel) : null;
|
||||
}
|
||||
|
||||
function buildModelId(provider: unknown, modelId: unknown): string | null {
|
||||
const providerName = readNonEmptyString(provider);
|
||||
const modelName = readNonEmptyString(modelId);
|
||||
if (!providerName || !modelName) {
|
||||
return null;
|
||||
}
|
||||
return `${providerName}/${modelName}`;
|
||||
}
|
||||
|
||||
function parseJsonRecord(line: string): Record<string, unknown> | null {
|
||||
if (!line) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(line) as unknown;
|
||||
return isRecord(parsed) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readNonEmptyString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function normalizePromptPreview(text: string | null): string | null {
|
||||
const normalized = text?.trim().replace(/\s+/g, " ") ?? "";
|
||||
if (!normalized) return null;
|
||||
return normalized.length > 160 ? normalized.slice(0, 160) : normalized;
|
||||
}
|
||||
|
||||
function readReadableSessionTitleFromPath(filePath: string): string | null {
|
||||
const stem = path.basename(filePath, ".jsonl").trim();
|
||||
if (!stem) {
|
||||
return null;
|
||||
}
|
||||
if (/^\d{4}-\d{2}-\d{2}T\d{2}[-:]\d{2}[-:]\d{2}/u.test(stem)) {
|
||||
return null;
|
||||
}
|
||||
if (/^[0-9a-f]{8,}$/iu.test(stem)) {
|
||||
return null;
|
||||
}
|
||||
return stem;
|
||||
}
|
||||
|
||||
function parseDate(value: unknown): Date | null {
|
||||
if (typeof value !== "string" && typeof value !== "number") {
|
||||
return null;
|
||||
}
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
function extractMessageText(content: unknown): string | null {
|
||||
if (typeof content === "string") {
|
||||
return content.trim() || null;
|
||||
}
|
||||
if (!Array.isArray(content)) {
|
||||
return null;
|
||||
}
|
||||
const text = content
|
||||
.flatMap((part) =>
|
||||
isRecord(part) && part.type === "text" && typeof part.text === "string" ? [part.text] : [],
|
||||
)
|
||||
.join("\n\n")
|
||||
.trim();
|
||||
return text || null;
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { OmpSubagentCardTracker, type OmpSubagentCardScheduler } from "./subagent-card-tracker.js";
|
||||
import type { OmpSubagentLifecyclePayload, OmpSubagentProgressPayload } from "./rpc-types.js";
|
||||
|
||||
const PARENT_TOOL_CALL_ID = "task-1";
|
||||
const SESSION_FILE = "/tmp/omp-task/EchoSubagent.jsonl";
|
||||
const LIFECYCLE: OmpSubagentLifecyclePayload = {
|
||||
id: "EchoSubagent",
|
||||
agent: "task",
|
||||
description: "Run echo in subagent",
|
||||
status: "started",
|
||||
sessionFile: SESSION_FILE,
|
||||
parentToolCallId: PARENT_TOOL_CALL_ID,
|
||||
index: 0,
|
||||
};
|
||||
const PROGRESS: OmpSubagentProgressPayload[] = [
|
||||
{
|
||||
index: 0,
|
||||
agent: "task",
|
||||
task: "Run echo",
|
||||
parentToolCallId: PARENT_TOOL_CALL_ID,
|
||||
sessionFile: SESSION_FILE,
|
||||
progress: {
|
||||
id: "EchoSubagent",
|
||||
status: "running",
|
||||
recentTools: [{ tool: "bash", args: "echo subagent-hi", endMs: 1 }],
|
||||
},
|
||||
},
|
||||
{
|
||||
index: 0,
|
||||
agent: "task",
|
||||
task: "Run echo",
|
||||
parentToolCallId: PARENT_TOOL_CALL_ID,
|
||||
sessionFile: SESSION_FILE,
|
||||
progress: {
|
||||
id: "EchoSubagent",
|
||||
status: "running",
|
||||
recentTools: [
|
||||
{ tool: "yield", args: "", endMs: 2 },
|
||||
{ tool: "bash", args: "echo subagent-hi", endMs: 1 },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
index: 0,
|
||||
agent: "task",
|
||||
task: "Run echo",
|
||||
parentToolCallId: PARENT_TOOL_CALL_ID,
|
||||
sessionFile: SESSION_FILE,
|
||||
progress: {
|
||||
id: "EchoSubagent",
|
||||
status: "completed",
|
||||
recentTools: [
|
||||
{ tool: "yield", args: "", endMs: 2 },
|
||||
{ tool: "bash", args: "echo subagent-hi", endMs: 1 },
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
class ManualScheduler implements OmpSubagentCardScheduler {
|
||||
private currentMs = 0;
|
||||
private nextId = 1;
|
||||
private readonly timers = new Map<number, { dueMs: number; callback: () => void }>();
|
||||
|
||||
now(): number {
|
||||
return this.currentMs;
|
||||
}
|
||||
|
||||
setTimeout(callback: () => void, delayMs: number) {
|
||||
const id = this.nextId;
|
||||
this.nextId += 1;
|
||||
this.timers.set(id, { dueMs: this.currentMs + delayMs, callback });
|
||||
return { token: id };
|
||||
}
|
||||
|
||||
clearTimeout(timer: { token: unknown }): void {
|
||||
if (typeof timer.token === "number") {
|
||||
this.timers.delete(timer.token);
|
||||
}
|
||||
}
|
||||
|
||||
advance(ms: number): void {
|
||||
this.currentMs += ms;
|
||||
const dueTimers = [...this.timers.entries()]
|
||||
.filter(([, timer]) => timer.dueMs <= this.currentMs)
|
||||
.sort((left, right) => left[1].dueMs - right[1].dueMs);
|
||||
for (const [id, timer] of dueTimers) {
|
||||
if (this.timers.delete(id)) {
|
||||
timer.callback();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe("OmpSubagentCardTracker", () => {
|
||||
test("folds lifecycle and progress into one throttled sub-agent detail", () => {
|
||||
const scheduler = new ManualScheduler();
|
||||
const emitted: string[] = [];
|
||||
const tracker = new OmpSubagentCardTracker({
|
||||
scheduler,
|
||||
emitToolCall: (toolCallId) => {
|
||||
emitted.push(toolCallId);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
tracker.handleLifecycle(LIFECYCLE);
|
||||
for (const payload of PROGRESS) {
|
||||
tracker.handleProgress(payload);
|
||||
}
|
||||
|
||||
expect(emitted).toEqual([PARENT_TOOL_CALL_ID]);
|
||||
const detailBeforeTrailing = tracker.detailFor(PARENT_TOOL_CALL_ID, {
|
||||
type: "sub_agent",
|
||||
subAgentType: "task",
|
||||
description: "Task arg description wins",
|
||||
log: "",
|
||||
});
|
||||
expect(detailBeforeTrailing).toEqual({
|
||||
type: "sub_agent",
|
||||
subAgentType: "task",
|
||||
description: "Task arg description wins",
|
||||
childSessionId: SESSION_FILE,
|
||||
log: [
|
||||
"EchoSubagent started",
|
||||
"[bash] echo subagent-hi",
|
||||
"[yield]",
|
||||
"EchoSubagent completed",
|
||||
].join("\n"),
|
||||
});
|
||||
|
||||
scheduler.advance(500);
|
||||
|
||||
expect(emitted).toEqual([PARENT_TOOL_CALL_ID, PARENT_TOOL_CALL_ID]);
|
||||
});
|
||||
|
||||
test("aggregates batch progress streams into one index-prefixed log", () => {
|
||||
const scheduler = new ManualScheduler();
|
||||
const emitted: string[] = [];
|
||||
const tracker = new OmpSubagentCardTracker({
|
||||
scheduler,
|
||||
emitToolCall: (toolCallId) => {
|
||||
emitted.push(toolCallId);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
tracker.handleLifecycle({
|
||||
id: "Explore",
|
||||
agent: "task",
|
||||
description: "Inspect files",
|
||||
status: "started",
|
||||
sessionFile: "/tmp/one.jsonl",
|
||||
parentToolCallId: "task.batch-1",
|
||||
index: 0,
|
||||
});
|
||||
scheduler.advance(600);
|
||||
tracker.handleProgress({
|
||||
index: 5,
|
||||
agent: "task",
|
||||
task: "Run tests",
|
||||
parentToolCallId: "task.batch-1",
|
||||
progress: {
|
||||
id: "Test",
|
||||
status: "running",
|
||||
description: "Run tests",
|
||||
recentTools: [{ tool: "bash", args: "npm test", endMs: 10 }],
|
||||
},
|
||||
sessionFile: "/tmp/two.jsonl",
|
||||
});
|
||||
|
||||
expect(emitted).toEqual(["task.batch-1", "task.batch-1"]);
|
||||
expect(
|
||||
tracker.detailFor("task.batch-1", {
|
||||
type: "sub_agent",
|
||||
subAgentType: "batch",
|
||||
actions: [],
|
||||
log: "",
|
||||
}),
|
||||
).toEqual({
|
||||
type: "sub_agent",
|
||||
subAgentType: "batch",
|
||||
description: "Inspect files",
|
||||
childSessionId: "/tmp/one.jsonl",
|
||||
log: "[1/6] Explore started\n[6/6] [bash] npm test",
|
||||
actions: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps dirty trailing state available for final detail and cancels it on cleanup", () => {
|
||||
const scheduler = new ManualScheduler();
|
||||
const emitted: string[] = [];
|
||||
const tracker = new OmpSubagentCardTracker({
|
||||
scheduler,
|
||||
emitToolCall: (toolCallId) => {
|
||||
emitted.push(toolCallId);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
tracker.handleLifecycle({
|
||||
id: "Explore",
|
||||
agent: "task",
|
||||
description: "Inspect files",
|
||||
status: "started",
|
||||
parentToolCallId: "task-1",
|
||||
index: 0,
|
||||
});
|
||||
scheduler.advance(100);
|
||||
tracker.handleProgress({
|
||||
index: 0,
|
||||
agent: "task",
|
||||
task: "Inspect files",
|
||||
parentToolCallId: "task-1",
|
||||
progress: {
|
||||
id: "Explore",
|
||||
status: "running",
|
||||
recentOutput: ["found target file"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(emitted).toEqual(["task-1"]);
|
||||
expect(
|
||||
tracker.detailFor("task-1", {
|
||||
type: "sub_agent",
|
||||
log: "",
|
||||
}).log,
|
||||
).toBe("Explore started\nfound target file");
|
||||
|
||||
tracker.delete("task-1");
|
||||
scheduler.advance(500);
|
||||
|
||||
expect(emitted).toEqual(["task-1"]);
|
||||
expect(
|
||||
tracker.detailFor("task-1", {
|
||||
type: "sub_agent",
|
||||
log: "static",
|
||||
}).log,
|
||||
).toBe("static");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,361 @@
|
||||
import type { ToolCallDetail } from "../../agent-sdk-types.js";
|
||||
import type { OmpSubagentLifecyclePayload, OmpSubagentProgressPayload } from "./rpc-types.js";
|
||||
|
||||
export interface OmpSubagentCardTimer {
|
||||
readonly token: unknown;
|
||||
}
|
||||
|
||||
export interface OmpSubagentCardScheduler {
|
||||
now(): number;
|
||||
setTimeout(callback: () => void, delayMs: number): OmpSubagentCardTimer;
|
||||
clearTimeout(timer: OmpSubagentCardTimer): void;
|
||||
}
|
||||
|
||||
export interface OmpSubagentCardTrackerOptions {
|
||||
scheduler?: OmpSubagentCardScheduler;
|
||||
emitToolCall?: (toolCallId: string) => boolean;
|
||||
}
|
||||
|
||||
interface OmpSubagentLogLine {
|
||||
key: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface OmpSubagentCardItem {
|
||||
index: number;
|
||||
description?: string;
|
||||
agent?: string;
|
||||
status?: "pending" | "running" | "completed" | "failed" | "aborted";
|
||||
childSessionId?: string;
|
||||
lines: OmpSubagentLogLine[];
|
||||
lineKeys: Set<string>;
|
||||
}
|
||||
|
||||
interface OmpSubagentCardState {
|
||||
items: Map<number, OmpSubagentCardItem>;
|
||||
emitToolCall: (toolCallId: string) => boolean;
|
||||
lastEmitMs: number | null;
|
||||
trailingTimer: OmpSubagentCardTimer | null;
|
||||
dirty: boolean;
|
||||
}
|
||||
|
||||
const MAX_LOG_LINES_PER_ITEM = 200;
|
||||
const MAX_LOG_LINE_CHARS = 240;
|
||||
const THROTTLE_INTERVAL_MS = 500;
|
||||
|
||||
export function createOmpSubagentCardScheduler(): OmpSubagentCardScheduler {
|
||||
return {
|
||||
now: () => Date.now(),
|
||||
setTimeout: (callback, delayMs) => ({ token: setTimeout(callback, delayMs) }),
|
||||
clearTimeout: (timer) => {
|
||||
clearTimeout(timer.token as ReturnType<typeof setTimeout>);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export class OmpSubagentCardTracker {
|
||||
private readonly scheduler: OmpSubagentCardScheduler;
|
||||
private readonly emitToolCall: (toolCallId: string) => boolean;
|
||||
private readonly states = new Map<string, OmpSubagentCardState>();
|
||||
|
||||
constructor(options: OmpSubagentCardTrackerOptions) {
|
||||
this.scheduler = options.scheduler ?? createOmpSubagentCardScheduler();
|
||||
this.emitToolCall = options.emitToolCall ?? (() => false);
|
||||
}
|
||||
|
||||
handleLifecycle(
|
||||
payload: OmpSubagentLifecyclePayload,
|
||||
emitToolCall: (toolCallId: string) => boolean = this.emitToolCall,
|
||||
): void {
|
||||
const parentToolCallId = readTrimmedString(payload.parentToolCallId);
|
||||
if (!parentToolCallId) {
|
||||
return;
|
||||
}
|
||||
this.stateFor(parentToolCallId).emitToolCall = emitToolCall;
|
||||
const item = this.upsertItem(parentToolCallId, {
|
||||
index: payload.index,
|
||||
agent: payload.agent,
|
||||
description: payload.description,
|
||||
status: payload.status === "started" ? "running" : payload.status,
|
||||
childSessionId: payload.sessionFile,
|
||||
});
|
||||
this.appendLine(
|
||||
item,
|
||||
`lifecycle:${payload.status}:${payload.id}`,
|
||||
`${payload.id} ${payload.status}`,
|
||||
);
|
||||
this.requestEmit(parentToolCallId);
|
||||
}
|
||||
|
||||
handleProgress(
|
||||
payload: OmpSubagentProgressPayload,
|
||||
emitToolCall: (toolCallId: string) => boolean = this.emitToolCall,
|
||||
): void {
|
||||
const parentToolCallId = readTrimmedString(payload.parentToolCallId);
|
||||
if (!parentToolCallId) {
|
||||
return;
|
||||
}
|
||||
this.stateFor(parentToolCallId).emitToolCall = emitToolCall;
|
||||
const item = this.upsertItem(parentToolCallId, {
|
||||
index: payload.index,
|
||||
agent: payload.agent,
|
||||
description: payload.progress.description,
|
||||
status: payload.progress.status,
|
||||
childSessionId: payload.sessionFile,
|
||||
});
|
||||
|
||||
const currentToolLine = summarizeTool(readRecord(payload.progress.currentTool));
|
||||
if (currentToolLine) {
|
||||
this.appendLine(item, `current:${currentToolLine.key}`, currentToolLine.text);
|
||||
}
|
||||
|
||||
for (const tool of readRecordArray(payload.progress.recentTools)) {
|
||||
const line = summarizeTool(tool);
|
||||
if (line) {
|
||||
this.appendLine(item, `tool:${line.key}`, line.text);
|
||||
}
|
||||
}
|
||||
|
||||
for (const output of readOutputLines(payload.progress.recentOutput)) {
|
||||
this.appendLine(item, `output:${output}`, output);
|
||||
}
|
||||
|
||||
if (payload.progress.status !== "running") {
|
||||
this.appendLine(
|
||||
item,
|
||||
`status:${payload.progress.status}:${payload.progress.id}`,
|
||||
`${payload.progress.id} ${payload.progress.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
this.requestEmit(parentToolCallId);
|
||||
}
|
||||
|
||||
detailFor(toolCallId: string, baseDetail: ToolCallDetail): ToolCallDetail {
|
||||
if (baseDetail.type !== "sub_agent") {
|
||||
return baseDetail;
|
||||
}
|
||||
const state = this.states.get(toolCallId);
|
||||
if (!state) {
|
||||
return baseDetail;
|
||||
}
|
||||
const items = [...state.items.values()].sort((left, right) => left.index - right.index);
|
||||
const firstItem = items[0];
|
||||
if (!firstItem) {
|
||||
return baseDetail;
|
||||
}
|
||||
|
||||
const detail: ToolCallDetail = {
|
||||
type: "sub_agent",
|
||||
...(baseDetail.subAgentType ? { subAgentType: baseDetail.subAgentType } : {}),
|
||||
...((baseDetail.description ?? firstItem.description)
|
||||
? { description: baseDetail.description ?? firstItem.description }
|
||||
: {}),
|
||||
...((firstItem.childSessionId ?? baseDetail.childSessionId)
|
||||
? { childSessionId: firstItem.childSessionId ?? baseDetail.childSessionId }
|
||||
: {}),
|
||||
log: buildLog(items, baseDetail.log),
|
||||
};
|
||||
return baseDetail.actions ? { ...detail, actions: baseDetail.actions } : detail;
|
||||
}
|
||||
|
||||
flush(toolCallId: string): void {
|
||||
const state = this.states.get(toolCallId);
|
||||
if (!state || !state.dirty) {
|
||||
return;
|
||||
}
|
||||
this.emitNow(toolCallId, state);
|
||||
}
|
||||
|
||||
delete(toolCallId: string): void {
|
||||
const state = this.states.get(toolCallId);
|
||||
if (state?.trailingTimer) {
|
||||
this.scheduler.clearTimeout(state.trailingTimer);
|
||||
}
|
||||
this.states.delete(toolCallId);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
for (const toolCallId of this.states.keys()) {
|
||||
this.delete(toolCallId);
|
||||
}
|
||||
}
|
||||
|
||||
private upsertItem(
|
||||
parentToolCallId: string,
|
||||
input: {
|
||||
index: number;
|
||||
agent?: string;
|
||||
status?: "pending" | "running" | "completed" | "failed" | "aborted";
|
||||
description?: string;
|
||||
childSessionId?: string;
|
||||
},
|
||||
): OmpSubagentCardItem {
|
||||
const state = this.stateFor(parentToolCallId);
|
||||
const existing = state.items.get(input.index);
|
||||
if (existing) {
|
||||
existing.agent = readTrimmedString(input.agent) ?? existing.agent;
|
||||
existing.status = input.status ?? existing.status;
|
||||
existing.description = readTrimmedString(input.description) ?? existing.description;
|
||||
existing.childSessionId = readTrimmedString(input.childSessionId) ?? existing.childSessionId;
|
||||
return existing;
|
||||
}
|
||||
|
||||
const item: OmpSubagentCardItem = {
|
||||
index: input.index,
|
||||
lines: [],
|
||||
lineKeys: new Set<string>(),
|
||||
...(readTrimmedString(input.agent) ? { agent: readTrimmedString(input.agent) } : {}),
|
||||
...(input.status ? { status: input.status } : {}),
|
||||
...(readTrimmedString(input.description)
|
||||
? { description: readTrimmedString(input.description) }
|
||||
: {}),
|
||||
...(readTrimmedString(input.childSessionId)
|
||||
? { childSessionId: readTrimmedString(input.childSessionId) }
|
||||
: {}),
|
||||
};
|
||||
state.items.set(input.index, item);
|
||||
return item;
|
||||
}
|
||||
|
||||
private stateFor(parentToolCallId: string): OmpSubagentCardState {
|
||||
const existing = this.states.get(parentToolCallId);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const state: OmpSubagentCardState = {
|
||||
items: new Map(),
|
||||
emitToolCall: this.emitToolCall,
|
||||
lastEmitMs: null,
|
||||
trailingTimer: null,
|
||||
dirty: false,
|
||||
};
|
||||
this.states.set(parentToolCallId, state);
|
||||
return state;
|
||||
}
|
||||
|
||||
private appendLine(item: OmpSubagentCardItem, key: string, text: string): void {
|
||||
const normalizedText = normalizeLine(text);
|
||||
if (!normalizedText || item.lineKeys.has(key)) {
|
||||
return;
|
||||
}
|
||||
item.lines.push({ key, text: normalizedText });
|
||||
item.lineKeys.add(key);
|
||||
while (item.lines.length > MAX_LOG_LINES_PER_ITEM) {
|
||||
const removed = item.lines.shift();
|
||||
if (removed) {
|
||||
item.lineKeys.delete(removed.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private requestEmit(toolCallId: string): void {
|
||||
const state = this.stateFor(toolCallId);
|
||||
const now = this.scheduler.now();
|
||||
const elapsedMs = state.lastEmitMs === null ? THROTTLE_INTERVAL_MS : now - state.lastEmitMs;
|
||||
if (elapsedMs >= THROTTLE_INTERVAL_MS) {
|
||||
this.emitNow(toolCallId, state);
|
||||
return;
|
||||
}
|
||||
|
||||
state.dirty = true;
|
||||
if (state.trailingTimer) {
|
||||
return;
|
||||
}
|
||||
state.trailingTimer = this.scheduler.setTimeout(() => {
|
||||
state.trailingTimer = null;
|
||||
if (state.dirty) {
|
||||
this.emitNow(toolCallId, state);
|
||||
}
|
||||
}, THROTTLE_INTERVAL_MS - elapsedMs);
|
||||
}
|
||||
|
||||
private emitNow(toolCallId: string, state: OmpSubagentCardState): void {
|
||||
if (state.trailingTimer) {
|
||||
this.scheduler.clearTimeout(state.trailingTimer);
|
||||
state.trailingTimer = null;
|
||||
}
|
||||
state.dirty = false;
|
||||
state.lastEmitMs = this.scheduler.now();
|
||||
state.emitToolCall(toolCallId);
|
||||
}
|
||||
}
|
||||
|
||||
function buildLog(items: OmpSubagentCardItem[], fallback: string): string {
|
||||
const itemCount = Math.max(...items.map((item) => item.index)) + 1;
|
||||
const lines = items.flatMap((item) => {
|
||||
const prefix = itemCount > 1 ? `[${item.index + 1}/${itemCount}] ` : "";
|
||||
return item.lines.map((line) => `${prefix}${line.text}`);
|
||||
});
|
||||
return lines.length > 0 ? lines.join("\n") : fallback;
|
||||
}
|
||||
|
||||
function summarizeTool(tool: Record<string, unknown> | null): OmpSubagentLogLine | null {
|
||||
if (!tool) {
|
||||
return null;
|
||||
}
|
||||
const toolName = readTrimmedString(tool.tool) ?? readTrimmedString(tool.name);
|
||||
if (!toolName) {
|
||||
return null;
|
||||
}
|
||||
const args = readTrimmedString(tool.args) ?? summarizeUnknown(tool.input);
|
||||
const endMs = typeof tool.endMs === "number" ? `:${tool.endMs}` : "";
|
||||
return {
|
||||
key: `${toolName}:${args ?? ""}${endMs}`,
|
||||
text: args ? `[${toolName}] ${args}` : `[${toolName}]`,
|
||||
};
|
||||
}
|
||||
|
||||
function readOutputLines(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value.flatMap((item) => {
|
||||
const text =
|
||||
readTrimmedString(item) ??
|
||||
(isRecord(item)
|
||||
? (readTrimmedString(item.text) ??
|
||||
readTrimmedString(item.output) ??
|
||||
readTrimmedString(item.content))
|
||||
: undefined);
|
||||
return text ? [text] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function readRecord(value: unknown): Record<string, unknown> | null {
|
||||
return isRecord(value) ? value : null;
|
||||
}
|
||||
|
||||
function readRecordArray(value: unknown): Record<string, unknown>[] {
|
||||
return Array.isArray(value) ? value.filter(isRecord) : [];
|
||||
}
|
||||
|
||||
function summarizeUnknown(value: unknown): string | undefined {
|
||||
if (value === undefined || value === null) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return readTrimmedString(value);
|
||||
}
|
||||
const json = JSON.stringify(value);
|
||||
return typeof json === "string" ? normalizeLine(json) : undefined;
|
||||
}
|
||||
|
||||
function normalizeLine(value: string): string | undefined {
|
||||
const normalized = value.trim().replace(/\s+/g, " ");
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
if (normalized.length <= MAX_LOG_LINE_CHARS) {
|
||||
return normalized;
|
||||
}
|
||||
return `${normalized.slice(0, MAX_LOG_LINE_CHARS)}...`;
|
||||
}
|
||||
|
||||
function readTrimmedString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { OmpSubagentIndex } from "./subagent-index.js";
|
||||
|
||||
describe("OMP provider subagent mapper", () => {
|
||||
test("maps lifecycle and progress frames to stable provider_subagent descriptors", () => {
|
||||
const index = new OmpSubagentIndex();
|
||||
const parent = {};
|
||||
expect(
|
||||
index.handleLifecycle(parent, {
|
||||
id: "child-1",
|
||||
agent: "explore",
|
||||
description: "Inspect files",
|
||||
status: "started",
|
||||
parentToolCallId: "task-1",
|
||||
index: 0,
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
type: "provider_subagent",
|
||||
provider: "omp",
|
||||
event: {
|
||||
type: "upsert",
|
||||
id: "child-1",
|
||||
title: "explore",
|
||||
description: "Inspect files",
|
||||
status: "running",
|
||||
toolCallId: "task-1",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(
|
||||
index.handleProgress(parent, {
|
||||
index: 0,
|
||||
agent: "explore",
|
||||
task: "Inspect files",
|
||||
parentToolCallId: "task-1",
|
||||
progress: {
|
||||
id: "child-1",
|
||||
status: "running",
|
||||
resolvedModel: "openai-codex/gpt-5.5",
|
||||
},
|
||||
})[0],
|
||||
).toMatchObject({
|
||||
event: {
|
||||
id: "child-1",
|
||||
status: "running",
|
||||
title: "explore · gpt-5.5 (openai-codex)",
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
index.handleProgress(parent, {
|
||||
index: 0,
|
||||
agent: "explore",
|
||||
task: "Inspect files",
|
||||
parentToolCallId: "task-1",
|
||||
progress: {
|
||||
id: "child-1",
|
||||
status: "completed",
|
||||
resolvedModel: "anthropic/claude-sonnet-5",
|
||||
},
|
||||
})[0],
|
||||
).toMatchObject({
|
||||
event: {
|
||||
id: "child-1",
|
||||
status: "completed",
|
||||
title: "explore · claude-sonnet-5 (anthropic)",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("maps child message events onto the descriptor timeline", () => {
|
||||
const index = new OmpSubagentIndex();
|
||||
const parent = {};
|
||||
expect(
|
||||
index.handleEvent(parent, {
|
||||
id: "child-1",
|
||||
event: {
|
||||
type: "message_end",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Child answer" }],
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
type: "provider_subagent",
|
||||
provider: "omp",
|
||||
event: {
|
||||
type: "timeline",
|
||||
id: "child-1",
|
||||
item: {
|
||||
type: "assistant_message",
|
||||
text: "Child answer",
|
||||
messageId: "omp-history-assistant-1",
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("maps aborted lifecycle status to canceled", () => {
|
||||
const index = new OmpSubagentIndex();
|
||||
const parent = {};
|
||||
expect(
|
||||
index.handleLifecycle(parent, {
|
||||
id: "child-1",
|
||||
agent: "task",
|
||||
status: "aborted",
|
||||
index: 0,
|
||||
})[0],
|
||||
).toMatchObject({ event: { id: "child-1", status: "canceled" } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import type { AgentStreamEvent } from "../../agent-sdk-types.js";
|
||||
import { OmpHistoryMapper } from "./message-history.js";
|
||||
import type { OmpAgentMessage, OmpAgentSessionEvent } from "./rpc-types.js";
|
||||
import { OMP_HISTORY_MAPPER_HOOKS } from "./history-hooks.js";
|
||||
import { formatOmpSubagentTitle } from "./subagent-title.js";
|
||||
import type {
|
||||
OmpSubagentEventPayload,
|
||||
OmpSubagentLifecyclePayload,
|
||||
OmpSubagentProgressPayload,
|
||||
} from "./rpc-types.js";
|
||||
|
||||
interface OmpSubagentState {
|
||||
title: string;
|
||||
description: string | null;
|
||||
resolvedModel: string | null;
|
||||
toolCallId: string | null;
|
||||
status: "running" | "completed" | "failed" | "canceled";
|
||||
mapper: OmpHistoryMapper;
|
||||
}
|
||||
|
||||
export class OmpSubagentIndex {
|
||||
private readonly statesByParent = new WeakMap<object, Map<string, OmpSubagentState>>();
|
||||
|
||||
handleLifecycle(parent: object, payload: OmpSubagentLifecyclePayload): AgentStreamEvent[] {
|
||||
const state = this.stateFor(parent, payload.id, payload.agent);
|
||||
state.title = payload.agent || state.title;
|
||||
state.description = payload.description ?? state.description;
|
||||
state.toolCallId = payload.parentToolCallId ?? state.toolCallId;
|
||||
state.status = mapLifecycleStatus(payload.status);
|
||||
return [this.upsert(payload.id, state.status, state)];
|
||||
}
|
||||
|
||||
handleProgress(parent: object, payload: OmpSubagentProgressPayload): AgentStreamEvent[] {
|
||||
const id = payload.progress.id;
|
||||
const state = this.stateFor(parent, id, payload.agent);
|
||||
state.title = payload.agent || state.title;
|
||||
state.description = payload.progress.description ?? payload.assignment ?? state.description;
|
||||
if (payload.progress.resolvedModel?.trim()) {
|
||||
state.resolvedModel = payload.progress.resolvedModel;
|
||||
}
|
||||
state.toolCallId = payload.parentToolCallId ?? state.toolCallId;
|
||||
state.status = mapProgressStatus(payload.progress.status);
|
||||
return [this.upsert(id, state.status, state)];
|
||||
}
|
||||
|
||||
handleEvent(parent: object, payload: OmpSubagentEventPayload): AgentStreamEvent[] {
|
||||
const state = this.stateFor(parent, payload.id, "OMP subagent");
|
||||
const messages = messagesFromSessionEvent(payload.event);
|
||||
return state.mapper.mapMessages(messages).flatMap((mapped) =>
|
||||
mapped.type === "timeline"
|
||||
? [
|
||||
{
|
||||
type: "provider_subagent" as const,
|
||||
provider: "omp",
|
||||
event: {
|
||||
type: "timeline" as const,
|
||||
id: payload.id,
|
||||
item: mapped.item,
|
||||
...(mapped.timestamp ? { timestamp: mapped.timestamp } : {}),
|
||||
},
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
}
|
||||
|
||||
terminalizeRunning(parent: object): AgentStreamEvent[] {
|
||||
const states = this.statesByParent.get(parent);
|
||||
if (!states) {
|
||||
return [];
|
||||
}
|
||||
const events: AgentStreamEvent[] = [];
|
||||
for (const [id, state] of states) {
|
||||
if (state.status !== "running") {
|
||||
continue;
|
||||
}
|
||||
state.status = "canceled";
|
||||
events.push(this.upsert(id, state.status, state));
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
clear(parent: object): void {
|
||||
this.statesByParent.delete(parent);
|
||||
}
|
||||
|
||||
private stateFor(parent: object, id: string, title: string): OmpSubagentState {
|
||||
const states = this.statesByParent.get(parent) ?? new Map<string, OmpSubagentState>();
|
||||
const existing = states.get(id);
|
||||
if (existing) return existing;
|
||||
const state: OmpSubagentState = {
|
||||
title,
|
||||
description: null,
|
||||
resolvedModel: null,
|
||||
toolCallId: null,
|
||||
status: "running",
|
||||
mapper: new OmpHistoryMapper("omp", [], OMP_HISTORY_MAPPER_HOOKS),
|
||||
};
|
||||
states.set(id, state);
|
||||
this.statesByParent.set(parent, states);
|
||||
return state;
|
||||
}
|
||||
|
||||
private upsert(
|
||||
id: string,
|
||||
status: "running" | "completed" | "failed" | "canceled",
|
||||
state: OmpSubagentState,
|
||||
): AgentStreamEvent {
|
||||
return {
|
||||
type: "provider_subagent",
|
||||
provider: "omp",
|
||||
event: {
|
||||
type: "upsert",
|
||||
id,
|
||||
title: formatOmpSubagentTitle(state.title, state.resolvedModel),
|
||||
description: state.description,
|
||||
status,
|
||||
toolCallId: state.toolCallId,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function messagesFromSessionEvent(event: OmpAgentSessionEvent): OmpAgentMessage[] {
|
||||
if (event.type === "message_end") return [event.message];
|
||||
return [];
|
||||
}
|
||||
|
||||
function mapLifecycleStatus(
|
||||
status: OmpSubagentLifecyclePayload["status"],
|
||||
): "running" | "completed" | "failed" | "canceled" {
|
||||
if (status === "started") return "running";
|
||||
return status === "aborted" ? "canceled" : status;
|
||||
}
|
||||
|
||||
function mapProgressStatus(
|
||||
status: OmpSubagentProgressPayload["progress"]["status"],
|
||||
): "running" | "completed" | "failed" | "canceled" {
|
||||
if (status === "completed" || status === "failed") return status;
|
||||
return status === "aborted" ? "canceled" : "running";
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export function formatOmpSubagentTitle(title: string, resolvedModel?: string | null): string {
|
||||
const name = title.trim() || "OMP subagent";
|
||||
const model = resolvedModel?.trim();
|
||||
if (!model) return name;
|
||||
|
||||
const separator = model.indexOf("/");
|
||||
if (separator <= 0 || separator === model.length - 1) {
|
||||
return `${name} · ${model}`;
|
||||
}
|
||||
|
||||
const provider = model.slice(0, separator);
|
||||
const modelName = model.slice(separator + 1);
|
||||
return `${name} · ${modelName} (${provider})`;
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { isOmpSystemNotice, mapOmpSystemNoticeToToolCall } from "./system-notice.js";
|
||||
|
||||
const COMPLETED_NOTICE = [
|
||||
"<system-notice>",
|
||||
"Background job DocsSmokeTwo has completed. Resume your work using the result below.",
|
||||
'<task-result id="DocsSmokeTwo" agent="explore" status="completed" duration="21.6s">',
|
||||
'<meta lines="22" size="2.5KB" />',
|
||||
"<output>",
|
||||
'{"summary":"docs smoke check done"}',
|
||||
"</output>",
|
||||
"</task-result>",
|
||||
"</system-notice>",
|
||||
"DocsSmokeTwo is now idle — transcript at history://DocsSmokeTwo",
|
||||
].join("\n");
|
||||
|
||||
describe("omp system notice detection", () => {
|
||||
test("detects messages that start with the system-notice tag", () => {
|
||||
expect(isOmpSystemNotice(COMPLETED_NOTICE)).toBe(true);
|
||||
expect(isOmpSystemNotice(" \n<system-notice>plain</system-notice>")).toBe(true);
|
||||
});
|
||||
|
||||
test("ignores regular prompts, including ones that mention the tag mid-message", () => {
|
||||
expect(isOmpSystemNotice("please fix the bug")).toBe(false);
|
||||
expect(isOmpSystemNotice("what does <system-notice> mean in omp?")).toBe(false);
|
||||
expect(mapOmpSystemNoticeToToolCall("what does <system-notice> mean in omp?")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("omp system notice tool call mapping", () => {
|
||||
test("maps a completed task-result notice to a synthetic completed tool call", () => {
|
||||
expect(mapOmpSystemNoticeToToolCall(COMPLETED_NOTICE)).toEqual({
|
||||
type: "tool_call",
|
||||
callId: "omp-notice:DocsSmokeTwo",
|
||||
name: "task_notification",
|
||||
status: "completed",
|
||||
detail: {
|
||||
type: "plain_text",
|
||||
label: "Background job DocsSmokeTwo completed",
|
||||
text: COMPLETED_NOTICE,
|
||||
icon: "wrench",
|
||||
},
|
||||
metadata: {
|
||||
synthetic: true,
|
||||
source: "omp_system_notice",
|
||||
taskId: "DocsSmokeTwo",
|
||||
subagentType: "explore",
|
||||
status: "completed",
|
||||
},
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("maps a failed task-result notice to a failed tool call", () => {
|
||||
const notice = [
|
||||
"<system-notice>",
|
||||
"Background job RepoSmokeOne has failed.",
|
||||
'<task-result id="RepoSmokeOne" agent="explore" status="failed" duration="3s">',
|
||||
"<output>boom</output>",
|
||||
"</task-result>",
|
||||
"</system-notice>",
|
||||
].join("\n");
|
||||
|
||||
const item = mapOmpSystemNoticeToToolCall(notice);
|
||||
expect(item).toMatchObject({
|
||||
callId: "omp-notice:RepoSmokeOne",
|
||||
status: "failed",
|
||||
error: "Background job RepoSmokeOne failed",
|
||||
});
|
||||
});
|
||||
|
||||
test("parses task-result attributes with typographic quotes", () => {
|
||||
const notice = [
|
||||
"<system-notice>",
|
||||
"Background job DocsSmokeTwo has completed.",
|
||||
"<task-result id=“DocsSmokeTwo” agent=“explore” status=“completed” duration=“21.6s”>",
|
||||
"<output>ok</output>",
|
||||
"</task-result>",
|
||||
"</system-notice>",
|
||||
].join("\n");
|
||||
|
||||
expect(mapOmpSystemNoticeToToolCall(notice)).toMatchObject({
|
||||
callId: "omp-notice:DocsSmokeTwo",
|
||||
status: "completed",
|
||||
metadata: {
|
||||
taskId: "DocsSmokeTwo",
|
||||
subagentType: "explore",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("maps a notice without a task-result using its first line and a stable hash id", () => {
|
||||
const notice = "<system-notice>\nThe daemon rotated its logs.\n</system-notice>";
|
||||
|
||||
const first = mapOmpSystemNoticeToToolCall(notice);
|
||||
const second = mapOmpSystemNoticeToToolCall(notice);
|
||||
expect(first).toEqual(second);
|
||||
expect(first).toMatchObject({
|
||||
status: "completed",
|
||||
detail: {
|
||||
type: "plain_text",
|
||||
label: "The daemon rotated its logs.",
|
||||
text: notice,
|
||||
},
|
||||
});
|
||||
expect(first?.callId).toMatch(/^omp-notice:[0-9a-f]{12}$/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import type { AgentTimelineItem } from "../../agent-sdk-types.js";
|
||||
|
||||
const SYSTEM_NOTICE_OPEN_TAG = "<system-notice>";
|
||||
const SYSTEM_NOTICE_CLOSE_TAG = "</system-notice>";
|
||||
const TASK_RESULT_TAG_PATTERN = /<task-result\b([^>]*)>/i;
|
||||
// The omp harness emits straight quotes, but transcripts have been observed
|
||||
// with typographic quotes after copy/paste round-trips; accept both.
|
||||
const TASK_RESULT_ATTRIBUTE_PATTERN = /([\w-]+)=["'“‘]([^"'“”‘’]*)["'”’]/g;
|
||||
|
||||
type OmpSystemNoticeToolCallItem = Extract<AgentTimelineItem, { type: "tool_call" }>;
|
||||
|
||||
interface OmpTaskResultSummary {
|
||||
id: string | null;
|
||||
agent: string | null;
|
||||
status: string | null;
|
||||
}
|
||||
|
||||
type OmpNoticeLifecycle =
|
||||
| { status: "completed"; error: null }
|
||||
| { status: "failed"; error: string }
|
||||
| { status: "canceled"; error: null };
|
||||
|
||||
export function isOmpSystemNotice(text: string): boolean {
|
||||
return text.trimStart().startsWith(SYSTEM_NOTICE_OPEN_TAG);
|
||||
}
|
||||
|
||||
function readTaskResult(text: string): OmpTaskResultSummary | null {
|
||||
const tagMatch = text.match(TASK_RESULT_TAG_PATTERN);
|
||||
if (!tagMatch) {
|
||||
return null;
|
||||
}
|
||||
const attributes = new Map<string, string>();
|
||||
for (const attributeMatch of (tagMatch[1] ?? "").matchAll(TASK_RESULT_ATTRIBUTE_PATTERN)) {
|
||||
const name = attributeMatch[1];
|
||||
const value = attributeMatch[2];
|
||||
if (name && value !== undefined) {
|
||||
attributes.set(name, value.trim());
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: attributes.get("id") || null,
|
||||
agent: attributes.get("agent") || null,
|
||||
status: attributes.get("status") || null,
|
||||
};
|
||||
}
|
||||
|
||||
function readNoticeFirstLine(text: string): string | null {
|
||||
const openIndex = text.indexOf(SYSTEM_NOTICE_OPEN_TAG);
|
||||
const closeIndex = text.indexOf(SYSTEM_NOTICE_CLOSE_TAG);
|
||||
const body = text.slice(
|
||||
openIndex + SYSTEM_NOTICE_OPEN_TAG.length,
|
||||
closeIndex === -1 ? undefined : closeIndex,
|
||||
);
|
||||
for (const line of body.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed && !trimmed.startsWith("<")) {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildLifecycle(
|
||||
taskResult: OmpTaskResultSummary | null,
|
||||
label: string,
|
||||
): OmpNoticeLifecycle {
|
||||
const status = taskResult?.status?.toLowerCase() ?? null;
|
||||
if (status === "failed" || status === "error") {
|
||||
return { status: "failed", error: label };
|
||||
}
|
||||
if (status === "canceled" || status === "cancelled" || status === "stopped") {
|
||||
return { status: "canceled", error: null };
|
||||
}
|
||||
return { status: "completed", error: null };
|
||||
}
|
||||
|
||||
function buildLabel(taskResult: OmpTaskResultSummary | null, text: string): string {
|
||||
if (taskResult?.id) {
|
||||
return `Background job ${taskResult.id} ${taskResult.status ?? "completed"}`;
|
||||
}
|
||||
return readNoticeFirstLine(text) ?? "System notice";
|
||||
}
|
||||
|
||||
function buildCallId(taskResult: OmpTaskResultSummary | null, text: string): string {
|
||||
if (taskResult?.id) {
|
||||
return `omp-notice:${taskResult.id}`;
|
||||
}
|
||||
const digest = createHash("sha1").update(text.trim()).digest("hex").slice(0, 12);
|
||||
return `omp-notice:${digest}`;
|
||||
}
|
||||
|
||||
export function mapOmpSystemNoticeToToolCall(text: string): OmpSystemNoticeToolCallItem | null {
|
||||
if (!isOmpSystemNotice(text)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const taskResult = readTaskResult(text);
|
||||
const label = buildLabel(taskResult, text);
|
||||
const lifecycle = buildLifecycle(taskResult, label);
|
||||
const base = {
|
||||
type: "tool_call" as const,
|
||||
callId: buildCallId(taskResult, text),
|
||||
name: "task_notification",
|
||||
detail: {
|
||||
type: "plain_text" as const,
|
||||
label,
|
||||
text,
|
||||
icon: "wrench" as const,
|
||||
},
|
||||
metadata: {
|
||||
synthetic: true,
|
||||
source: "omp_system_notice",
|
||||
...(taskResult?.id ? { taskId: taskResult.id } : {}),
|
||||
...(taskResult?.agent ? { subagentType: taskResult.agent } : {}),
|
||||
...(taskResult?.status ? { status: taskResult.status } : {}),
|
||||
},
|
||||
};
|
||||
|
||||
if (lifecycle.status === "failed") {
|
||||
return { ...base, status: "failed", error: lifecycle.error };
|
||||
}
|
||||
if (lifecycle.status === "canceled") {
|
||||
return { ...base, status: "canceled", error: null };
|
||||
}
|
||||
return { ...base, status: "completed", error: null };
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
import type {
|
||||
OmpRuntime,
|
||||
OmpRuntimeLaunch,
|
||||
OmpRuntimeSession,
|
||||
OmpStartSessionInput,
|
||||
} from "../runtime.js";
|
||||
import type {
|
||||
OmpRpcHostToolDefinition,
|
||||
OmpRpcHostToolResult,
|
||||
OmpRpcHostToolUpdate,
|
||||
OmpAgentMessage,
|
||||
OmpModel,
|
||||
OmpPromptAck,
|
||||
OmpRpcSlashCommand,
|
||||
OmpRuntimeEvent,
|
||||
OmpSessionState,
|
||||
OmpSessionStats,
|
||||
OmpThinkingLevel,
|
||||
} from "../rpc-types.js";
|
||||
import { buildOmpLaunch } from "../runtime.js";
|
||||
|
||||
type FakeOmpSubagentSubscriptionLevel = "off" | "progress" | "events";
|
||||
type FakeOmpSubagentStatus = "pending" | "running" | "completed" | "failed" | "aborted";
|
||||
|
||||
export interface FakeOmpSubagentSnapshot {
|
||||
id: string;
|
||||
index: number;
|
||||
agent: string;
|
||||
description?: string;
|
||||
status: FakeOmpSubagentStatus;
|
||||
task?: string;
|
||||
assignment?: string;
|
||||
sessionFile?: string;
|
||||
parentToolCallId?: string;
|
||||
lastUpdate?: number;
|
||||
}
|
||||
|
||||
export interface FakeOmpSubagentMessagesSelector {
|
||||
subagentId?: string;
|
||||
sessionFile?: string;
|
||||
fromByte?: number;
|
||||
}
|
||||
|
||||
export interface FakeOmpSubagentMessagesResult {
|
||||
sessionFile: string;
|
||||
fromByte: number;
|
||||
nextByte: number;
|
||||
reset: boolean;
|
||||
messages: OmpAgentMessage[];
|
||||
}
|
||||
|
||||
export class FakeOmp implements OmpRuntime {
|
||||
readonly recordedLaunches: OmpRuntimeLaunch[] = [];
|
||||
private readonly sessions: FakeOmpSession[] = [];
|
||||
private readonly command: [string, ...string[]];
|
||||
private readonly queuedCommands: OmpRpcSlashCommand[][] = [];
|
||||
private readonly queuedSubagentSubscriptionErrors = new Map<
|
||||
FakeOmpSubagentSubscriptionLevel,
|
||||
Error
|
||||
>();
|
||||
|
||||
constructor(command: [string, ...string[]] = ["omp"]) {
|
||||
this.command = command;
|
||||
}
|
||||
|
||||
async startSession(input: OmpStartSessionInput): Promise<FakeOmpSession> {
|
||||
const launch = buildOmpLaunch({
|
||||
command: this.command,
|
||||
session: input,
|
||||
});
|
||||
this.recordedLaunches.push(launch);
|
||||
const session = new FakeOmpSession(launch);
|
||||
session.commands = this.queuedCommands.shift() ?? [];
|
||||
for (const [level, error] of this.queuedSubagentSubscriptionErrors) {
|
||||
session.subagentSubscriptionErrors.set(level, error);
|
||||
}
|
||||
this.queuedSubagentSubscriptionErrors.clear();
|
||||
this.sessions.push(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
queueCommands(commands: OmpRpcSlashCommand[]): void {
|
||||
this.queuedCommands.push(commands);
|
||||
}
|
||||
|
||||
failNextSubagentSubscription(level: FakeOmpSubagentSubscriptionLevel, error: Error): void {
|
||||
this.queuedSubagentSubscriptionErrors.set(level, error);
|
||||
}
|
||||
|
||||
latestSession(): FakeOmpSession {
|
||||
const session = this.sessions.at(-1);
|
||||
if (!session) {
|
||||
throw new Error("FakeOmp has no sessions");
|
||||
}
|
||||
return session;
|
||||
}
|
||||
}
|
||||
|
||||
export class FakeOmpSession implements OmpRuntimeSession {
|
||||
readonly prompts: Array<{ message: string; imageCount: number }> = [];
|
||||
readonly compactRequests: Array<{ customInstructions?: string }> = [];
|
||||
readonly setAutoCompactionRequests: boolean[] = [];
|
||||
readonly subagentSubscriptionRequests: FakeOmpSubagentSubscriptionLevel[] = [];
|
||||
readonly subagentMessageRequests: FakeOmpSubagentMessagesSelector[] = [];
|
||||
readonly setModelRequests: Array<{ provider: string; modelId: string }> = [];
|
||||
readonly setThinkingLevelRequests: OmpThinkingLevel[] = [];
|
||||
readonly handoffRequests: Array<{ customInstructions?: string }> = [];
|
||||
readonly steerRequests: Array<{ message: string; imageCount: number }> = [];
|
||||
readonly followUpRequests: Array<{ message: string; imageCount: number }> = [];
|
||||
readonly hostToolSetRequests: OmpRpcHostToolDefinition[][] = [];
|
||||
readonly hostToolResults: OmpRpcHostToolResult[] = [];
|
||||
readonly hostToolUpdates: OmpRpcHostToolUpdate[] = [];
|
||||
getStateRequestCount = 0;
|
||||
abortRequested = false;
|
||||
readonly canceledExtensionUiRequests: string[] = [];
|
||||
readonly extensionUiResponses: Array<{
|
||||
id: string;
|
||||
response: { value?: string; confirmed?: boolean; cancelled?: boolean };
|
||||
}> = [];
|
||||
setModelResult: OmpModel | null = null;
|
||||
models: OmpModel[] = [];
|
||||
messages: OmpAgentMessage[] = [];
|
||||
stats: OmpSessionStats = {
|
||||
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
cost: 0,
|
||||
};
|
||||
commands: OmpRpcSlashCommand[] = [];
|
||||
subagents: FakeOmpSubagentSnapshot[] = [];
|
||||
readonly subagentSubscriptionErrors = new Map<FakeOmpSubagentSubscriptionLevel, Error>();
|
||||
compactError: Error | null = null;
|
||||
emitCompactEnd = true;
|
||||
getStateError: Error | null = null;
|
||||
promptAck: OmpPromptAck = {};
|
||||
branchResponse: { text?: string; cancelled?: boolean } = { text: "" };
|
||||
branchMessages: Array<{ entryId: string; text: string }> = [];
|
||||
readonly branchRequests: string[] = [];
|
||||
activeBranchEntryId?: string;
|
||||
closed = false;
|
||||
state: OmpSessionState;
|
||||
|
||||
private readonly subscribers = new Set<(event: OmpRuntimeEvent) => void>();
|
||||
private readonly stateReports: OmpSessionState[] = [];
|
||||
private readonly stateRequestWaiters: Array<{ count: number; resolve: () => void }> = [];
|
||||
private readonly hostToolResultWaiters: Array<(result: OmpRpcHostToolResult) => void> = [];
|
||||
private readonly promptWaiters: Array<() => void> = [];
|
||||
private readonly subscriptionWaiters: Array<{ count: number; resolve: () => void }> = [];
|
||||
private readonly subagentMessageResults = new Map<string, FakeOmpSubagentMessagesResult[]>();
|
||||
private nextHeldPrompt: { promise: Promise<void>; reject: (error: Error) => void } | null = null;
|
||||
private activeHeldPrompt: { promise: Promise<void>; reject: (error: Error) => void } | null =
|
||||
null;
|
||||
|
||||
constructor(launch: OmpRuntimeLaunch) {
|
||||
this.state = {
|
||||
model: null,
|
||||
thinkingLevel: "medium",
|
||||
isStreaming: false,
|
||||
isCompacting: false,
|
||||
autoCompactionEnabled: true,
|
||||
sessionFile: launch.session ?? "/tmp/omp-session",
|
||||
sessionId: "omp-session-1",
|
||||
messageCount: 0,
|
||||
queuedMessageCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
onEvent(callback: (event: OmpRuntimeEvent) => void): () => void {
|
||||
this.subscribers.add(callback);
|
||||
return () => {
|
||||
this.subscribers.delete(callback);
|
||||
};
|
||||
}
|
||||
|
||||
async prompt(
|
||||
message: string,
|
||||
images?: Array<{ type: "image"; data: string; mimeType: string }>,
|
||||
): Promise<OmpPromptAck> {
|
||||
if (this.state.isStreaming || this.state.isCompacting) {
|
||||
throw new Error("Agent is already processing");
|
||||
}
|
||||
this.prompts.push({ message, imageCount: images?.length ?? 0 });
|
||||
this.promptWaiters.shift()?.();
|
||||
const heldPrompt = this.nextHeldPrompt;
|
||||
if (heldPrompt) {
|
||||
this.nextHeldPrompt = null;
|
||||
this.activeHeldPrompt = heldPrompt;
|
||||
try {
|
||||
await heldPrompt.promise;
|
||||
} finally {
|
||||
if (this.activeHeldPrompt === heldPrompt) {
|
||||
this.activeHeldPrompt = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
return this.promptAck;
|
||||
}
|
||||
|
||||
nextPrompt(): Promise<void> {
|
||||
return new Promise((resolve) => this.promptWaiters.push(resolve));
|
||||
}
|
||||
|
||||
holdNextPrompt(): void {
|
||||
if (this.nextHeldPrompt || this.activeHeldPrompt) {
|
||||
throw new Error("FakeOmp already has a held prompt");
|
||||
}
|
||||
let reject!: (error: Error) => void;
|
||||
const promise = new Promise<void>((_resolve, rejectPromise) => {
|
||||
reject = rejectPromise;
|
||||
});
|
||||
this.nextHeldPrompt = { promise, reject };
|
||||
}
|
||||
|
||||
async failHeldPrompt(error: Error): Promise<void> {
|
||||
const heldPrompt = this.activeHeldPrompt ?? this.nextHeldPrompt;
|
||||
if (!heldPrompt) {
|
||||
throw new Error("FakeOmp has no held prompt");
|
||||
}
|
||||
heldPrompt.reject(error);
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
}
|
||||
|
||||
async compact(customInstructions?: string): Promise<void> {
|
||||
this.compactRequests.push(customInstructions === undefined ? {} : { customInstructions });
|
||||
this.emit({ type: "compaction_start", reason: "manual" });
|
||||
if (this.emitCompactEnd) {
|
||||
this.emit({ type: "compaction_end", reason: "manual" });
|
||||
}
|
||||
if (this.compactError) {
|
||||
throw this.compactError;
|
||||
}
|
||||
}
|
||||
|
||||
async setAutoCompaction(enabled: boolean): Promise<void> {
|
||||
this.setAutoCompactionRequests.push(enabled);
|
||||
this.state = {
|
||||
...this.state,
|
||||
autoCompactionEnabled: enabled,
|
||||
};
|
||||
}
|
||||
|
||||
async abort(): Promise<void> {
|
||||
this.abortRequested = true;
|
||||
}
|
||||
|
||||
async getState(): Promise<OmpSessionState> {
|
||||
this.getStateRequestCount += 1;
|
||||
for (const waiter of this.stateRequestWaiters.splice(0)) {
|
||||
if (this.getStateRequestCount >= waiter.count) waiter.resolve();
|
||||
else this.stateRequestWaiters.push(waiter);
|
||||
}
|
||||
if (this.getStateError) {
|
||||
throw this.getStateError;
|
||||
}
|
||||
const report = this.stateReports.shift();
|
||||
if (report) {
|
||||
this.state = report;
|
||||
}
|
||||
return this.state;
|
||||
}
|
||||
|
||||
queueStateReports(states: OmpSessionState[]): void {
|
||||
this.stateReports.push(...states);
|
||||
}
|
||||
|
||||
waitForStateRequests(count: number): Promise<void> {
|
||||
if (this.getStateRequestCount >= count) return Promise.resolve();
|
||||
return new Promise((resolve) => this.stateRequestWaiters.push({ count, resolve }));
|
||||
}
|
||||
|
||||
async getMessages(): Promise<OmpAgentMessage[]> {
|
||||
return this.messages;
|
||||
}
|
||||
|
||||
async getAvailableModels(_timeoutMs?: number): Promise<OmpModel[]> {
|
||||
return this.models;
|
||||
}
|
||||
|
||||
async setModel(provider: string, modelId: string): Promise<OmpModel> {
|
||||
this.setModelRequests.push({ provider, modelId });
|
||||
if (!this.setModelResult) {
|
||||
throw new Error("FakeOmp setModel requires setModelResult to be scripted");
|
||||
}
|
||||
return this.setModelResult;
|
||||
}
|
||||
|
||||
async setThinkingLevel(level: OmpThinkingLevel): Promise<void> {
|
||||
this.setThinkingLevelRequests.push(level);
|
||||
}
|
||||
|
||||
async getSessionStats(): Promise<OmpSessionStats> {
|
||||
return this.stats;
|
||||
}
|
||||
|
||||
async setSubagentSubscription(level: FakeOmpSubagentSubscriptionLevel): Promise<void> {
|
||||
this.subagentSubscriptionRequests.push(level);
|
||||
for (const waiter of this.subscriptionWaiters.splice(0)) {
|
||||
if (this.subagentSubscriptionRequests.length >= waiter.count) waiter.resolve();
|
||||
else this.subscriptionWaiters.push(waiter);
|
||||
}
|
||||
const error = this.subagentSubscriptionErrors.get(level);
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
waitForSubagentSubscriptions(count: number): Promise<void> {
|
||||
if (this.subagentSubscriptionRequests.length >= count) return Promise.resolve();
|
||||
return new Promise((resolve) => this.subscriptionWaiters.push({ count, resolve }));
|
||||
}
|
||||
|
||||
async setHostTools(tools: OmpRpcHostToolDefinition[]): Promise<string[]> {
|
||||
this.hostToolSetRequests.push(tools);
|
||||
return tools.map((tool) => tool.name);
|
||||
}
|
||||
|
||||
async branch(entryId: string): Promise<{ text: string }> {
|
||||
this.branchRequests.push(entryId);
|
||||
if (this.branchResponse.cancelled === true) {
|
||||
throw new Error("OMP branch was cancelled");
|
||||
}
|
||||
if (typeof this.branchResponse.text !== "string") {
|
||||
throw new Error("FakeOmp branch response requires text");
|
||||
}
|
||||
this.activeBranchEntryId = entryId;
|
||||
return { text: this.branchResponse.text };
|
||||
}
|
||||
|
||||
async getBranchMessages(): Promise<Array<{ entryId: string; text: string }>> {
|
||||
return this.branchMessages;
|
||||
}
|
||||
|
||||
steer(message: string, images?: Array<{ type: "image"; data: string; mimeType: string }>): void {
|
||||
this.steerRequests.push({ message, imageCount: images?.length ?? 0 });
|
||||
}
|
||||
|
||||
followUp(
|
||||
message: string,
|
||||
images?: Array<{ type: "image"; data: string; mimeType: string }>,
|
||||
): void {
|
||||
this.followUpRequests.push({ message, imageCount: images?.length ?? 0 });
|
||||
}
|
||||
|
||||
sendHostToolResult(result: OmpRpcHostToolResult): void {
|
||||
this.hostToolResults.push(result);
|
||||
this.hostToolResultWaiters.shift()?.(result);
|
||||
}
|
||||
|
||||
sendHostToolUpdate(update: OmpRpcHostToolUpdate): void {
|
||||
this.hostToolUpdates.push(update);
|
||||
}
|
||||
|
||||
nextHostToolResult(): Promise<OmpRpcHostToolResult> {
|
||||
return new Promise((resolve) => this.hostToolResultWaiters.push(resolve));
|
||||
}
|
||||
|
||||
async getSubagents(): Promise<FakeOmpSubagentSnapshot[]> {
|
||||
return this.subagents;
|
||||
}
|
||||
|
||||
async getSubagentMessages(
|
||||
selector: FakeOmpSubagentMessagesSelector,
|
||||
): Promise<FakeOmpSubagentMessagesResult> {
|
||||
this.subagentMessageRequests.push(selector);
|
||||
const key = selector.sessionFile ?? selector.subagentId;
|
||||
if (!key) {
|
||||
throw new Error("FakeOmp getSubagentMessages requires a selector");
|
||||
}
|
||||
const results = this.subagentMessageResults.get(key);
|
||||
const result = results?.shift();
|
||||
if (!result) {
|
||||
throw new Error(`FakeOmp has no subagent messages queued for ${key}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
queueSubagentMessages(result: FakeOmpSubagentMessagesResult): void {
|
||||
const results = this.subagentMessageResults.get(result.sessionFile) ?? [];
|
||||
results.push(result);
|
||||
this.subagentMessageResults.set(result.sessionFile, results);
|
||||
}
|
||||
|
||||
async getCommands(): Promise<OmpRpcSlashCommand[]> {
|
||||
return this.commands;
|
||||
}
|
||||
|
||||
async handoff(customInstructions?: string): Promise<void> {
|
||||
this.handoffRequests.push(customInstructions ? { customInstructions } : {});
|
||||
}
|
||||
|
||||
respondToExtensionUiRequest(
|
||||
id: string,
|
||||
response: { value?: string; confirmed?: boolean; cancelled?: boolean },
|
||||
): void {
|
||||
this.extensionUiResponses.push({ id, response });
|
||||
}
|
||||
|
||||
cancelExtensionUiRequest(id: string): void {
|
||||
this.canceledExtensionUiRequests.push(id);
|
||||
this.respondToExtensionUiRequest(id, { cancelled: true });
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.closed = true;
|
||||
}
|
||||
|
||||
emit(event: OmpRuntimeEvent): void {
|
||||
for (const subscriber of this.subscribers) {
|
||||
subscriber(event);
|
||||
}
|
||||
}
|
||||
|
||||
finishTurn(message: OmpAgentMessage = { role: "assistant", content: [] }): void {
|
||||
this.messages = [...this.messages, message];
|
||||
this.emit({ type: "agent_end", messages: this.messages });
|
||||
}
|
||||
|
||||
beginTurn(): void {
|
||||
this.emit({ type: "turn_start" });
|
||||
}
|
||||
|
||||
acceptPrompt(text: string, entryId = "omp-user-1"): void {
|
||||
this.emit({
|
||||
type: "message_end",
|
||||
message: { role: "user", content: text, entryId } as OmpAgentMessage,
|
||||
});
|
||||
}
|
||||
|
||||
acceptCustomMessage(content: string): void {
|
||||
this.emit({
|
||||
type: "message_end",
|
||||
message: { role: "custom", content },
|
||||
});
|
||||
}
|
||||
|
||||
streamAssistantText(text: string, responseId = "omp-assistant-1"): void {
|
||||
const message: OmpAgentMessage = {
|
||||
role: "assistant",
|
||||
content: [],
|
||||
responseId,
|
||||
};
|
||||
this.emit({ type: "message_start", message });
|
||||
this.emit({
|
||||
type: "message_update",
|
||||
message,
|
||||
assistantMessageEvent: { type: "text_delta", delta: text },
|
||||
});
|
||||
}
|
||||
|
||||
requestToolApproval(input: {
|
||||
id: string;
|
||||
tool: "bash" | "edit" | "write";
|
||||
detail: string;
|
||||
}): void {
|
||||
let detailLabel = "Path";
|
||||
if (input.tool === "bash") {
|
||||
detailLabel = "Command";
|
||||
} else if (input.tool === "edit") {
|
||||
detailLabel = "File";
|
||||
}
|
||||
this.emit({
|
||||
type: "extension_ui_request",
|
||||
id: input.id,
|
||||
method: "select",
|
||||
title: `Allow tool: ${input.tool}\n${detailLabel}: ${input.detail}`,
|
||||
options: ["Approve", "Deny"],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
import { mkdtemp, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import pino from "pino";
|
||||
|
||||
import type {
|
||||
AgentPersistenceHandle,
|
||||
AgentPermissionResponse,
|
||||
AgentSessionConfig,
|
||||
AgentStreamEvent,
|
||||
AgentTimelineItem,
|
||||
} from "../../../agent-sdk-types.js";
|
||||
import type { PaseoToolCatalog } from "../../../tools/types.js";
|
||||
import { OmpAgentClient, OmpAgentSession, type OmpProviderIdleScheduler } from "../agent.js";
|
||||
import type { OmpRpcSlashCommand } from "../rpc-types.js";
|
||||
import { FakeOmp } from "./fake-omp.js";
|
||||
|
||||
const CWD = "/tmp/paseo-omp-agent-test";
|
||||
|
||||
interface OmpHistoryMessage {
|
||||
id: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface OmpResumeHistory {
|
||||
user: OmpHistoryMessage;
|
||||
assistant: OmpHistoryMessage;
|
||||
}
|
||||
|
||||
async function writeOmpHistory(history: OmpResumeHistory): Promise<string> {
|
||||
const directory = await mkdtemp(join(tmpdir(), "paseo-omp-resume-"));
|
||||
const sessionFile = join(directory, "session.jsonl");
|
||||
const entries = [
|
||||
{ type: "session", id: "session-root", parentId: null },
|
||||
{
|
||||
type: "message",
|
||||
id: history.user.id,
|
||||
parentId: "session-root",
|
||||
message: { role: "user", content: history.user.text },
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
id: history.assistant.id,
|
||||
parentId: history.user.id,
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: history.assistant.text }],
|
||||
responseId: history.assistant.id,
|
||||
},
|
||||
},
|
||||
];
|
||||
await writeFile(sessionFile, entries.map((entry) => JSON.stringify(entry)).join("\n"), "utf8");
|
||||
return sessionFile;
|
||||
}
|
||||
|
||||
export class OmpHarness {
|
||||
private readonly omp = new FakeOmp();
|
||||
private readonly client: OmpAgentClient;
|
||||
private readonly events: AgentStreamEvent[] = [];
|
||||
private session: OmpAgentSession | null = null;
|
||||
|
||||
constructor(options: { providerIdleScheduler?: OmpProviderIdleScheduler } = {}) {
|
||||
this.client = new OmpAgentClient({
|
||||
logger: pino({ level: "silent" }),
|
||||
runtime: this.omp,
|
||||
providerIdleScheduler: options.providerIdleScheduler,
|
||||
});
|
||||
}
|
||||
|
||||
queueCommands(commands: OmpRpcSlashCommand[]): void {
|
||||
this.omp.queueCommands(commands);
|
||||
}
|
||||
|
||||
failEventSubscription(error: Error): void {
|
||||
this.omp.failNextSubagentSubscription("events", error);
|
||||
}
|
||||
|
||||
async start(
|
||||
config: Partial<AgentSessionConfig> = {},
|
||||
paseoTools?: PaseoToolCatalog,
|
||||
): Promise<void> {
|
||||
const session = await this.client.createSession(
|
||||
{ provider: "omp", cwd: CWD, ...config },
|
||||
paseoTools ? { paseoTools } : undefined,
|
||||
);
|
||||
if (!(session instanceof OmpAgentSession)) {
|
||||
throw new Error("OMP client returned a non-OMP session");
|
||||
}
|
||||
this.session = session;
|
||||
this.session.subscribe((event) => this.events.push(event));
|
||||
}
|
||||
|
||||
async resume(
|
||||
history: OmpResumeHistory,
|
||||
overrides: Partial<AgentSessionConfig> = {},
|
||||
): Promise<void> {
|
||||
const sessionFile = await writeOmpHistory(history);
|
||||
const handle: AgentPersistenceHandle = {
|
||||
provider: "omp",
|
||||
sessionId: "omp-session-1",
|
||||
nativeHandle: sessionFile,
|
||||
metadata: { cwd: CWD },
|
||||
};
|
||||
const session = await this.client.resumeSession(handle, overrides);
|
||||
if (!(session instanceof OmpAgentSession)) {
|
||||
throw new Error("OMP client returned a non-OMP session");
|
||||
}
|
||||
this.session = session;
|
||||
this.session.subscribe((event) => this.events.push(event));
|
||||
}
|
||||
|
||||
launchConfiguration(): {
|
||||
cwd: string;
|
||||
protocolMode?: string;
|
||||
modeId?: string;
|
||||
session?: string;
|
||||
argv: string[];
|
||||
} {
|
||||
const launch = this.omp.recordedLaunches[0];
|
||||
if (!launch) throw new Error("OMP harness has not launched");
|
||||
return {
|
||||
cwd: launch.cwd,
|
||||
protocolMode: launch.protocolMode,
|
||||
modeId: launch.modeId,
|
||||
...(launch.session ? { session: launch.session } : {}),
|
||||
argv: launch.argv,
|
||||
};
|
||||
}
|
||||
|
||||
registeredHostTools() {
|
||||
return this.omp.latestSession().hostToolSetRequests;
|
||||
}
|
||||
|
||||
capabilities() {
|
||||
return this.client.capabilities;
|
||||
}
|
||||
|
||||
async runPrompt(
|
||||
input: string,
|
||||
output: string,
|
||||
providerStatesAfterEnd: Array<{ isStreaming: boolean; isCompacting: boolean }> = [],
|
||||
): Promise<unknown> {
|
||||
const session = this.requireSession();
|
||||
const promptStarted = this.omp.latestSession().nextPrompt();
|
||||
const run = session.run(input);
|
||||
await promptStarted;
|
||||
const runtime = this.omp.latestSession();
|
||||
runtime.beginTurn();
|
||||
runtime.acceptPrompt(input, "user-1");
|
||||
runtime.streamAssistantText(output);
|
||||
runtime.queueStateReports(
|
||||
providerStatesAfterEnd.map((state) => ({ ...runtime.state, ...state })),
|
||||
);
|
||||
runtime.finishTurn();
|
||||
return await run;
|
||||
}
|
||||
|
||||
async runPromptAfterExtensionNotice(input: string, output: string): Promise<unknown> {
|
||||
const session = this.requireSession();
|
||||
const promptStarted = this.omp.latestSession().nextPrompt();
|
||||
const run = session.run(input);
|
||||
await promptStarted;
|
||||
const runtime = this.omp.latestSession();
|
||||
runtime.beginTurn();
|
||||
runtime.acceptPrompt(input, "user-1");
|
||||
runtime.acceptCustomMessage("extension inventory changed");
|
||||
runtime.finishTurn({ role: "custom", content: "extension inventory changed" });
|
||||
runtime.beginTurn();
|
||||
runtime.streamAssistantText(output);
|
||||
runtime.finishTurn();
|
||||
return await run;
|
||||
}
|
||||
|
||||
async startPromptUntilProviderIdle(
|
||||
input: string,
|
||||
output: string,
|
||||
providerState: { isStreaming: boolean; isCompacting: boolean },
|
||||
): Promise<{ completion: Promise<unknown> }> {
|
||||
const session = this.requireSession();
|
||||
const promptStarted = this.omp.latestSession().nextPrompt();
|
||||
const run = session.run(input);
|
||||
await promptStarted;
|
||||
const runtime = this.omp.latestSession();
|
||||
runtime.beginTurn();
|
||||
runtime.acceptPrompt(input, "user-1");
|
||||
runtime.streamAssistantText(output);
|
||||
runtime.state = { ...runtime.state, ...providerState };
|
||||
runtime.finishTurn();
|
||||
return { completion: run };
|
||||
}
|
||||
|
||||
waitForProviderStateChecks(count: number): Promise<void> {
|
||||
return this.omp.latestSession().waitForStateRequests(count);
|
||||
}
|
||||
|
||||
reportProviderState(state: { isStreaming: boolean; isCompacting: boolean }): void {
|
||||
const runtime = this.omp.latestSession();
|
||||
runtime.state = { ...runtime.state, ...state };
|
||||
}
|
||||
|
||||
failProviderStateChecks(error: Error | null): void {
|
||||
this.omp.latestSession().getStateError = error;
|
||||
}
|
||||
|
||||
async runPromptAfterFalseLocalOnlyHint(input: string, output: string): Promise<unknown> {
|
||||
const session = this.requireSession();
|
||||
const runtime = this.omp.latestSession();
|
||||
runtime.promptAck = { agentInvoked: false };
|
||||
const promptStarted = runtime.nextPrompt();
|
||||
const run = session.run(input);
|
||||
await promptStarted;
|
||||
runtime.acceptPrompt(input, "user-1");
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
runtime.beginTurn();
|
||||
runtime.streamAssistantText(output);
|
||||
runtime.finishTurn();
|
||||
return await run;
|
||||
}
|
||||
|
||||
timeline(): AgentTimelineItem[] {
|
||||
return this.events.flatMap((event) => (event.type === "timeline" ? [event.item] : []));
|
||||
}
|
||||
|
||||
async history(): Promise<AgentTimelineItem[]> {
|
||||
const items: AgentTimelineItem[] = [];
|
||||
for await (const event of this.requireSession().streamHistory()) {
|
||||
if (event.type === "timeline") items.push(event.item);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
completedTurnCount(): number {
|
||||
return this.events.filter((event) => event.type === "turn_completed").length;
|
||||
}
|
||||
|
||||
requestToolApproval(input: {
|
||||
id: string;
|
||||
tool: "bash" | "edit" | "write";
|
||||
detail: string;
|
||||
}): void {
|
||||
this.omp.latestSession().requestToolApproval(input);
|
||||
}
|
||||
|
||||
pendingPermissions() {
|
||||
return this.requireSession().getPendingPermissions();
|
||||
}
|
||||
|
||||
async respondToPermission(id: string, response: AgentPermissionResponse): Promise<void> {
|
||||
await this.requireSession().respondToPermission(id, response);
|
||||
}
|
||||
|
||||
extensionUiResponses() {
|
||||
return this.omp.latestSession().extensionUiResponses;
|
||||
}
|
||||
|
||||
async availableModes() {
|
||||
return await this.requireSession().getAvailableModes();
|
||||
}
|
||||
|
||||
async commands() {
|
||||
return await this.requireSession().listCommands();
|
||||
}
|
||||
|
||||
async setMode(modeId: string) {
|
||||
return await this.requireSession().setMode(modeId);
|
||||
}
|
||||
|
||||
async rewind(messageId: string, restoredPrompt: string): Promise<void> {
|
||||
this.omp.latestSession().branchResponse = { text: restoredPrompt };
|
||||
await this.requireSession().revertConversation({ messageId });
|
||||
}
|
||||
|
||||
branchRequests(): string[] {
|
||||
return this.omp.latestSession().branchRequests;
|
||||
}
|
||||
|
||||
async interruptActiveTurn(message: string): Promise<void> {
|
||||
await this.requireSession().startTurn(message);
|
||||
await this.requireSession().interrupt();
|
||||
}
|
||||
|
||||
async requireStartTurn(message: string): Promise<void> {
|
||||
const promptStarted = this.omp.latestSession().nextPrompt();
|
||||
await this.requireSession().startTurn(message);
|
||||
await promptStarted;
|
||||
}
|
||||
|
||||
async interrupt(): Promise<void> {
|
||||
await this.requireSession().interrupt();
|
||||
}
|
||||
|
||||
wasAborted(): boolean {
|
||||
return this.omp.latestSession().abortRequested;
|
||||
}
|
||||
|
||||
runtime() {
|
||||
return this.omp.latestSession();
|
||||
}
|
||||
|
||||
runningToolCallIds(): string[] {
|
||||
const statusByCall = new Map<string, string>();
|
||||
for (const item of this.timeline()) {
|
||||
if (item.type === "tool_call") {
|
||||
statusByCall.set(item.callId, item.status);
|
||||
}
|
||||
}
|
||||
return [...statusByCall.entries()]
|
||||
.filter(([, status]) => status === "running")
|
||||
.map(([callId]) => callId);
|
||||
}
|
||||
|
||||
subagentUpserts(): Array<{ id: string; status: string }> {
|
||||
return this.events.flatMap((event) =>
|
||||
event.type === "provider_subagent" && event.event.type === "upsert"
|
||||
? [{ id: event.event.id, status: event.event.status }]
|
||||
: [],
|
||||
);
|
||||
}
|
||||
|
||||
canceledTurnCount(): number {
|
||||
return this.events.filter((event) => event.type === "turn_canceled").length;
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.requireSession().close();
|
||||
}
|
||||
|
||||
isClosed(): boolean {
|
||||
return this.omp.latestSession().closed;
|
||||
}
|
||||
|
||||
async waitForSubscriptionFallback(): Promise<string[]> {
|
||||
const runtime = this.omp.latestSession();
|
||||
await runtime.waitForSubagentSubscriptions(2);
|
||||
return runtime.subagentSubscriptionRequests;
|
||||
}
|
||||
|
||||
private requireSession(): OmpAgentSession {
|
||||
if (!this.session) throw new Error("OMP harness has not started");
|
||||
return this.session;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { parseToolResult } from "./tool-call-detail.js";
|
||||
import { mapOmpTodoReminderEvent, mapOmpTodoState, mapOmpTodoToolResult } from "./todo-mapper.js";
|
||||
|
||||
const TODO_PHASES = [
|
||||
{
|
||||
name: "Tasks",
|
||||
tasks: [
|
||||
{ content: "alpha task", status: "completed" },
|
||||
{ content: "beta task", status: "in_progress" },
|
||||
{ content: "gamma task", status: "pending" },
|
||||
],
|
||||
},
|
||||
] as const;
|
||||
|
||||
describe("OMP todo mapper", () => {
|
||||
test("maps todo tool results and collapses statuses to completed booleans", () => {
|
||||
expect(
|
||||
mapOmpTodoToolResult(
|
||||
parseToolResult({
|
||||
content: [],
|
||||
details: {
|
||||
phases: [
|
||||
{
|
||||
name: "Tasks",
|
||||
tasks: [
|
||||
{ content: "alpha task", status: "in_progress" },
|
||||
{ content: "beta task", status: "pending" },
|
||||
{ content: "gamma task", status: "pending" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
type: "todo",
|
||||
items: [
|
||||
{ text: "alpha task", completed: false },
|
||||
{ text: "beta task", completed: false },
|
||||
{ text: "gamma task", completed: false },
|
||||
],
|
||||
});
|
||||
|
||||
expect(
|
||||
mapOmpTodoToolResult(parseToolResult({ content: [], details: { phases: TODO_PHASES } })),
|
||||
).toEqual({
|
||||
type: "todo",
|
||||
items: [
|
||||
{ text: "alpha task", completed: true },
|
||||
{ text: "beta task", completed: false },
|
||||
{ text: "gamma task", completed: false },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("maps todo reminder events", () => {
|
||||
expect(
|
||||
mapOmpTodoReminderEvent({
|
||||
type: "todo_reminder",
|
||||
todos: [
|
||||
{ content: "beta task", status: "in_progress" },
|
||||
{ content: "gamma task", status: "pending" },
|
||||
],
|
||||
}),
|
||||
).toEqual({
|
||||
type: "todo",
|
||||
items: [
|
||||
{ text: "beta task", completed: false },
|
||||
{ text: "gamma task", completed: false },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("hydrates current todos from session state", () => {
|
||||
expect(
|
||||
mapOmpTodoState({
|
||||
model: null,
|
||||
thinkingLevel: "medium",
|
||||
isStreaming: false,
|
||||
isCompacting: false,
|
||||
sessionId: "session",
|
||||
messageCount: 0,
|
||||
queuedMessageCount: 0,
|
||||
todoPhases: TODO_PHASES,
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
type: "todo",
|
||||
items: [
|
||||
{ text: "alpha task", completed: true },
|
||||
{ text: "beta task", completed: false },
|
||||
{ text: "gamma task", completed: false },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("drops malformed todo inputs", () => {
|
||||
expect(mapOmpTodoReminderEvent({ type: "todo_reminder", todos: [{ content: 1 }] })).toBeNull();
|
||||
expect(
|
||||
mapOmpTodoToolResult({ details: { phases: [{ name: "Bad", tasks: [{}] }] } }),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { AgentTimelineItem } from "../../agent-sdk-types.js";
|
||||
import type { OmpSessionState } from "./rpc-types.js";
|
||||
import type { OmpToolResult } from "./tool-call-detail.js";
|
||||
import {
|
||||
OmpTodoPhaseSchema,
|
||||
OmpTodoReminderEventSchema,
|
||||
type OmpTodoItem,
|
||||
type OmpTodoPhase,
|
||||
} from "./rpc-types.js";
|
||||
|
||||
export function mapOmpTodoToolResult(result: OmpToolResult): AgentTimelineItem | null {
|
||||
const details = resultDetails(result);
|
||||
const phases = OmpTodoPhaseSchema.array().safeParse(details?.phases);
|
||||
return phases.success ? mapOmpTodoPhases(phases.data) : null;
|
||||
}
|
||||
|
||||
export function mapOmpTodoReminderEvent(event: unknown): AgentTimelineItem | null {
|
||||
const parsed = OmpTodoReminderEventSchema.safeParse(event);
|
||||
return parsed.success ? mapOmpTodoItems(parsed.data.todos) : null;
|
||||
}
|
||||
|
||||
export function mapOmpTodoState(state: OmpSessionState): AgentTimelineItem[] {
|
||||
const phases = OmpTodoPhaseSchema.array().safeParse(state.todoPhases);
|
||||
if (!phases.success) {
|
||||
return [];
|
||||
}
|
||||
const item = mapOmpTodoPhases(phases.data);
|
||||
return item ? [item] : [];
|
||||
}
|
||||
|
||||
export function mapOmpTodoPhases(phases: readonly OmpTodoPhase[]): AgentTimelineItem | null {
|
||||
const todos = phases.flatMap((phase) => phase.tasks);
|
||||
return mapOmpTodoItems(todos);
|
||||
}
|
||||
|
||||
function mapOmpTodoItems(items: readonly OmpTodoItem[]): AgentTimelineItem | null {
|
||||
if (items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
type: "todo",
|
||||
items: items.map((item) => ({
|
||||
text: item.content,
|
||||
completed: item.status === "completed",
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function resultDetails(result: OmpToolResult): Record<string, unknown> | null {
|
||||
if (typeof result === "string" || result === null) {
|
||||
return null;
|
||||
}
|
||||
return isRecord(result.details) ? result.details : null;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,473 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import type { ToolCallDetail } from "../../agent-sdk-types.js";
|
||||
|
||||
interface BashToolInput {
|
||||
command: string;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
interface ReadToolInput {
|
||||
path: string;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
interface EditToolInput {
|
||||
path: string;
|
||||
edits: Array<{
|
||||
oldText: string;
|
||||
newText: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface WriteToolInput {
|
||||
path: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface FindToolInput {
|
||||
pattern: string;
|
||||
path?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
interface GrepToolInput {
|
||||
pattern: string;
|
||||
path?: string;
|
||||
glob?: string;
|
||||
ignoreCase?: boolean;
|
||||
literal?: boolean;
|
||||
context?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
interface LsToolInput {
|
||||
path?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
interface OmpToolResultObject {
|
||||
output?: string;
|
||||
stdout?: string;
|
||||
text?: string;
|
||||
content?: OmpToolResultContent[];
|
||||
exitCode?: number;
|
||||
code?: number;
|
||||
details?: OmpToolResultDetails;
|
||||
}
|
||||
|
||||
interface OmpToolResultDetails {
|
||||
diff?: string;
|
||||
mode?: string;
|
||||
xdev?: unknown;
|
||||
}
|
||||
|
||||
interface OmpToolResultTextContent {
|
||||
type: "text";
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface OmpToolResultUnknownContent {
|
||||
type: string;
|
||||
}
|
||||
|
||||
type OmpToolResultContent = OmpToolResultTextContent | OmpToolResultUnknownContent;
|
||||
export type OmpToolResult = string | OmpToolResultObject | null;
|
||||
|
||||
interface OmpBashToolCall {
|
||||
kind: "bash";
|
||||
toolName: "bash";
|
||||
args: BashToolInput;
|
||||
}
|
||||
|
||||
interface OmpReadToolCall {
|
||||
kind: "read";
|
||||
toolName: "read";
|
||||
args: ReadToolInput;
|
||||
}
|
||||
|
||||
interface OmpEditToolCall {
|
||||
kind: "edit";
|
||||
toolName: "edit";
|
||||
args: EditToolInput;
|
||||
}
|
||||
|
||||
interface OmpWriteToolCall {
|
||||
kind: "write";
|
||||
toolName: "write";
|
||||
args: WriteToolInput;
|
||||
}
|
||||
|
||||
interface OmpFindToolCall {
|
||||
kind: "find";
|
||||
toolName: "find";
|
||||
args: FindToolInput;
|
||||
}
|
||||
|
||||
interface OmpGrepToolCall {
|
||||
kind: "grep";
|
||||
toolName: "grep";
|
||||
args: GrepToolInput;
|
||||
}
|
||||
|
||||
interface OmpLsToolCall {
|
||||
kind: "ls";
|
||||
toolName: "ls";
|
||||
args: LsToolInput;
|
||||
}
|
||||
|
||||
interface OmpUnknownToolCall {
|
||||
kind: "unknown";
|
||||
toolName: string;
|
||||
args: unknown;
|
||||
}
|
||||
|
||||
export type OmpTrackedToolCall =
|
||||
| OmpBashToolCall
|
||||
| OmpReadToolCall
|
||||
| OmpEditToolCall
|
||||
| OmpWriteToolCall
|
||||
| OmpFindToolCall
|
||||
| OmpGrepToolCall
|
||||
| OmpLsToolCall
|
||||
| OmpUnknownToolCall;
|
||||
|
||||
interface ToolCallOutputSummary {
|
||||
output?: string;
|
||||
exitCode?: number | null;
|
||||
}
|
||||
|
||||
const OmpToolResultTextContentSchema = z.object({
|
||||
type: z.literal("text"),
|
||||
text: z.string(),
|
||||
});
|
||||
|
||||
const OmpToolResultUnknownContentSchema = z
|
||||
.object({
|
||||
type: z.string(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const OmpToolResultContentSchema = z.union([
|
||||
OmpToolResultTextContentSchema,
|
||||
OmpToolResultUnknownContentSchema,
|
||||
]);
|
||||
|
||||
const OmpToolResultDetailsSchema = z
|
||||
.object({
|
||||
diff: z.string().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const XdevExecuteDetailsSchema = z.object({
|
||||
tool: z.string().trim().min(1),
|
||||
mode: z.literal("execute"),
|
||||
args: z.unknown().optional(),
|
||||
inner: z.unknown().optional(),
|
||||
});
|
||||
|
||||
const OmpToolResultObjectSchema = z
|
||||
.object({
|
||||
output: z.string().optional(),
|
||||
stdout: z.string().optional(),
|
||||
text: z.string().optional(),
|
||||
content: z.array(OmpToolResultContentSchema).optional(),
|
||||
exitCode: z.number().optional(),
|
||||
code: z.number().optional(),
|
||||
details: OmpToolResultDetailsSchema.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const OmpToolResultSchema = z.union([z.string(), OmpToolResultObjectSchema, z.null()]);
|
||||
|
||||
const BashToolInputSchema: z.ZodType<BashToolInput> = z.object({
|
||||
command: z.string(),
|
||||
timeout: z.number().optional(),
|
||||
});
|
||||
|
||||
const ReadToolInputSchema: z.ZodType<ReadToolInput> = z.object({
|
||||
path: z.string(),
|
||||
offset: z.number().optional(),
|
||||
limit: z.number().optional(),
|
||||
});
|
||||
|
||||
const EditToolInputSchema: z.ZodType<EditToolInput> = z.object({
|
||||
path: z.string(),
|
||||
edits: z.array(
|
||||
z.object({
|
||||
oldText: z.string(),
|
||||
newText: z.string(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const LegacyEditToolInputSchema = z.object({
|
||||
path: z.string(),
|
||||
old_string: z.string().optional(),
|
||||
oldString: z.string().optional(),
|
||||
new_string: z.string().optional(),
|
||||
newString: z.string().optional(),
|
||||
});
|
||||
|
||||
const WriteToolInputSchema: z.ZodType<WriteToolInput> = z.object({
|
||||
path: z.string(),
|
||||
content: z.string(),
|
||||
});
|
||||
|
||||
const FindToolInputSchema: z.ZodType<FindToolInput> = z.object({
|
||||
pattern: z.string(),
|
||||
path: z.string().optional(),
|
||||
limit: z.number().optional(),
|
||||
});
|
||||
|
||||
const GrepToolInputSchema: z.ZodType<GrepToolInput> = z.object({
|
||||
pattern: z.string(),
|
||||
path: z.string().optional(),
|
||||
glob: z.string().optional(),
|
||||
ignoreCase: z.boolean().optional(),
|
||||
literal: z.boolean().optional(),
|
||||
context: z.number().optional(),
|
||||
limit: z.number().optional(),
|
||||
});
|
||||
|
||||
const LsToolInputSchema: z.ZodType<LsToolInput> = z.object({
|
||||
path: z.string().optional(),
|
||||
limit: z.number().optional(),
|
||||
});
|
||||
|
||||
export function parseToolResult(rawResult: unknown): OmpToolResult {
|
||||
const parsed = OmpToolResultSchema.safeParse(rawResult);
|
||||
if (parsed.success) {
|
||||
return parsed.data;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function extractTextFromToolResult(result: OmpToolResult): string | undefined {
|
||||
if (typeof result === "string") {
|
||||
return result;
|
||||
}
|
||||
if (!result) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const directText = result.output ?? result.stdout ?? result.text;
|
||||
if (directText) {
|
||||
return directText;
|
||||
}
|
||||
if (!result.content) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const textParts: string[] = [];
|
||||
for (const block of result.content) {
|
||||
if (block.type === "text" && "text" in block) {
|
||||
textParts.push(block.text);
|
||||
}
|
||||
}
|
||||
|
||||
return textParts.length > 0 ? textParts.join("\n") : undefined;
|
||||
}
|
||||
|
||||
export function parseToolArgs(toolName: string, rawArgs: unknown): OmpTrackedToolCall {
|
||||
if (toolName === "edit") {
|
||||
return parseEditToolArgs(rawArgs);
|
||||
}
|
||||
const schema = SIMPLE_TOOL_SCHEMAS[toolName as SimpleToolKind];
|
||||
if (schema) {
|
||||
const parsed = schema.safeParse(rawArgs);
|
||||
if (parsed.success) {
|
||||
return {
|
||||
kind: toolName as SimpleToolKind,
|
||||
toolName,
|
||||
args: parsed.data,
|
||||
} as OmpTrackedToolCall;
|
||||
}
|
||||
}
|
||||
return { kind: "unknown", toolName, args: rawArgs ?? null };
|
||||
}
|
||||
|
||||
export function resolveToolCallName(toolCall: OmpTrackedToolCall, result?: OmpToolResult): string {
|
||||
if (toolCall.kind === "write" && result && typeof result !== "string") {
|
||||
const xdev = XdevExecuteDetailsSchema.safeParse(result.details?.xdev);
|
||||
if (xdev.success) {
|
||||
return xdev.data.tool;
|
||||
}
|
||||
}
|
||||
|
||||
return toolCall.toolName;
|
||||
}
|
||||
|
||||
export function mapToolDetail(
|
||||
toolCall: OmpTrackedToolCall,
|
||||
result?: OmpToolResult,
|
||||
): ToolCallDetail {
|
||||
const parsedResult = result ?? null;
|
||||
|
||||
switch (toolCall.kind) {
|
||||
case "bash": {
|
||||
const summary = resolveToolCallOutput(parsedResult);
|
||||
return {
|
||||
type: "shell",
|
||||
command: toolCall.args.command,
|
||||
output: summary.output,
|
||||
exitCode: summary.exitCode,
|
||||
};
|
||||
}
|
||||
case "read":
|
||||
return {
|
||||
type: "read",
|
||||
filePath: toolCall.args.path,
|
||||
content: extractTextFromToolResult(parsedResult),
|
||||
offset: toolCall.args.offset,
|
||||
limit: toolCall.args.limit,
|
||||
};
|
||||
case "edit": {
|
||||
const firstEdit = toolCall.args.edits[0];
|
||||
const unifiedDiff =
|
||||
parsedResult && typeof parsedResult !== "string" ? parsedResult.details?.diff : undefined;
|
||||
|
||||
return {
|
||||
type: "edit",
|
||||
filePath: toolCall.args.path,
|
||||
oldString: firstEdit?.oldText,
|
||||
newString: firstEdit?.newText,
|
||||
unifiedDiff,
|
||||
};
|
||||
}
|
||||
case "write":
|
||||
return mapWriteToolDetail(toolCall.args, parsedResult);
|
||||
case "find":
|
||||
return mapFindToolDetail(toolCall.args, parsedResult);
|
||||
case "grep":
|
||||
return mapGrepToolDetail(toolCall.args, parsedResult);
|
||||
case "ls":
|
||||
return mapLsToolDetail(toolCall.args, parsedResult);
|
||||
default:
|
||||
return {
|
||||
type: "unknown",
|
||||
input: toolCall.args,
|
||||
output: parsedResult,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function mapWriteToolDetail(args: WriteToolInput, result: OmpToolResult): ToolCallDetail {
|
||||
if (result && typeof result !== "string" && result.details && "xdev" in result.details) {
|
||||
const xdev = XdevExecuteDetailsSchema.safeParse(result.details.xdev);
|
||||
if (xdev.success) {
|
||||
return {
|
||||
type: "unknown",
|
||||
input: xdev.data.args ?? null,
|
||||
output: {
|
||||
...result,
|
||||
details: xdev.data.inner ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "unknown",
|
||||
input: args,
|
||||
output: result,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "write",
|
||||
filePath: args.path,
|
||||
content: args.content,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveToolCallOutput(result: OmpToolResult): ToolCallOutputSummary {
|
||||
if (typeof result === "string") {
|
||||
return { output: result };
|
||||
}
|
||||
if (!result) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const summary: ToolCallOutputSummary = {
|
||||
output: extractTextFromToolResult(result),
|
||||
};
|
||||
if (typeof result.exitCode === "number") {
|
||||
summary.exitCode = result.exitCode;
|
||||
return summary;
|
||||
}
|
||||
if (typeof result.code === "number") {
|
||||
summary.exitCode = result.code;
|
||||
return summary;
|
||||
}
|
||||
summary.exitCode = null;
|
||||
return summary;
|
||||
}
|
||||
|
||||
function normalizeLegacyEditArgs(rawArgs: unknown): EditToolInput | null {
|
||||
const parsed = LegacyEditToolInputSchema.safeParse(rawArgs);
|
||||
if (!parsed.success) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const oldText = parsed.data.old_string ?? parsed.data.oldString;
|
||||
const newText = parsed.data.new_string ?? parsed.data.newString;
|
||||
if (!oldText || newText === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
path: parsed.data.path,
|
||||
edits: [{ oldText, newText }],
|
||||
};
|
||||
}
|
||||
|
||||
function parseEditToolArgs(rawArgs: unknown): OmpTrackedToolCall {
|
||||
const parsed = EditToolInputSchema.safeParse(rawArgs);
|
||||
if (parsed.success) {
|
||||
return { kind: "edit", toolName: "edit", args: parsed.data };
|
||||
}
|
||||
const legacyArgs = normalizeLegacyEditArgs(rawArgs);
|
||||
if (legacyArgs) {
|
||||
return { kind: "edit", toolName: "edit", args: legacyArgs };
|
||||
}
|
||||
return { kind: "unknown", toolName: "edit", args: rawArgs ?? null };
|
||||
}
|
||||
|
||||
type SimpleToolKind = "bash" | "read" | "write" | "find" | "grep" | "ls";
|
||||
const SIMPLE_TOOL_SCHEMAS: {
|
||||
[K in SimpleToolKind]: { safeParse: (data: unknown) => { success: boolean; data?: unknown } };
|
||||
} = {
|
||||
bash: BashToolInputSchema,
|
||||
read: ReadToolInputSchema,
|
||||
write: WriteToolInputSchema,
|
||||
find: FindToolInputSchema,
|
||||
grep: GrepToolInputSchema,
|
||||
ls: LsToolInputSchema,
|
||||
};
|
||||
|
||||
function mapFindToolDetail(args: FindToolInput, result: OmpToolResult): ToolCallDetail {
|
||||
return {
|
||||
type: "search",
|
||||
query: args.pattern,
|
||||
toolName: "search",
|
||||
content: typeof result === "string" ? result : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function mapGrepToolDetail(args: GrepToolInput, result: OmpToolResult): ToolCallDetail {
|
||||
return {
|
||||
type: "search",
|
||||
query: args.pattern,
|
||||
toolName: "grep",
|
||||
content: typeof result === "string" ? result : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function mapLsToolDetail(args: LsToolInput, result: OmpToolResult): ToolCallDetail {
|
||||
return {
|
||||
type: "search",
|
||||
query: args.path ?? "ls",
|
||||
content: typeof result === "string" ? result : undefined,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { parseToolArgs } from "./tool-call-detail.js";
|
||||
import { resolveOmpEmittedToolCallId } from "./tool-call-id.js";
|
||||
|
||||
describe("OMP tool call ids", () => {
|
||||
test("uses stable synthetic ids only for subagent poll calls", () => {
|
||||
expect(
|
||||
resolveOmpEmittedToolCallId(
|
||||
"poll-1",
|
||||
parseToolArgs("subagent", { poll: ["job-b", "job-a"] }),
|
||||
),
|
||||
).toBe("omp-poll:job-a,job-b");
|
||||
expect(
|
||||
resolveOmpEmittedToolCallId(
|
||||
"poll-2",
|
||||
parseToolArgs("subagent", { poll: ["job-a", "job-b"] }),
|
||||
),
|
||||
).toBe("omp-poll:job-a,job-b");
|
||||
expect(
|
||||
resolveOmpEmittedToolCallId("poll-3", parseToolArgs("subagent", { poll: ["job-a"] })),
|
||||
).toBe("omp-poll:job-a");
|
||||
expect(
|
||||
resolveOmpEmittedToolCallId(
|
||||
"spawn-1",
|
||||
parseToolArgs("subagent", { spawn: [{ prompt: "go" }] }),
|
||||
),
|
||||
).toBe("spawn-1");
|
||||
expect(
|
||||
resolveOmpEmittedToolCallId("bash-1", parseToolArgs("bash", { command: "echo hi" })),
|
||||
).toBe("bash-1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { OmpTrackedToolCall } from "./tool-call-detail.js";
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readNonEmptyString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
export function readPollTargets(args: unknown): string[] | null {
|
||||
if (!isRecord(args) || !Array.isArray(args.poll)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const targets: string[] = [];
|
||||
for (const target of args.poll) {
|
||||
const value = readNonEmptyString(target);
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
targets.push(value);
|
||||
}
|
||||
if (targets.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return targets.toSorted();
|
||||
}
|
||||
|
||||
export function resolveOmpEmittedToolCallId(
|
||||
toolCallId: string,
|
||||
toolCall: OmpTrackedToolCall,
|
||||
): string {
|
||||
if (toolCall.toolName !== "subagent") {
|
||||
return toolCallId;
|
||||
}
|
||||
|
||||
const targets = readPollTargets(toolCall.args);
|
||||
if (!targets) {
|
||||
return toolCallId;
|
||||
}
|
||||
return `omp-poll:${targets.join(",")}`;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { parseToolArgs, parseToolResult } from "./tool-call-detail.js";
|
||||
import { mapOmpToolDetail } from "./tool-call-mapper.js";
|
||||
|
||||
describe("OMP tool call mapper", () => {
|
||||
test("maps OMP bash, read, hashline edit, and write calls", () => {
|
||||
expect(
|
||||
mapOmpToolDetail(
|
||||
parseToolArgs("bash", { command: "echo hi" }),
|
||||
parseToolResult({
|
||||
content: [{ type: "text", text: "hi\n\n\nWall time: 0.02 seconds" }],
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
type: "shell",
|
||||
command: "echo hi",
|
||||
output: "hi\n\n\nWall time: 0.02 seconds",
|
||||
exitCode: null,
|
||||
});
|
||||
expect(
|
||||
mapOmpToolDetail(
|
||||
parseToolArgs("read", { path: "fixture.txt" }),
|
||||
parseToolResult({
|
||||
content: [{ type: "text", text: "[fixture.txt#0063]\n1:alpha\n2:beta\n3:" }],
|
||||
details: { displayContent: { text: "alpha\nbeta\n" } },
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
type: "read",
|
||||
filePath: "fixture.txt",
|
||||
content: "alpha\nbeta\n",
|
||||
offset: undefined,
|
||||
limit: undefined,
|
||||
});
|
||||
expect(
|
||||
mapOmpToolDetail(
|
||||
parseToolArgs("edit", {
|
||||
input: "*** Begin Patch\n[fixture.txt#0063]\nSWAP 2.=2:\n+gamma\n*** End Patch\n",
|
||||
}),
|
||||
parseToolResult({
|
||||
content: [],
|
||||
details: {
|
||||
path: "fixture.txt",
|
||||
oldText: "alpha\nbeta\n",
|
||||
newText: "alpha\ngamma\n",
|
||||
diff: " 1|alpha\n-2|beta\n+2|gamma",
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
type: "edit",
|
||||
filePath: "fixture.txt",
|
||||
oldString: "alpha\nbeta\n",
|
||||
newString: "alpha\ngamma\n",
|
||||
unifiedDiff: " 1|alpha\n-2|beta\n+2|gamma",
|
||||
});
|
||||
expect(
|
||||
mapOmpToolDetail(
|
||||
parseToolArgs("write", { path: "created.txt", content: "hello write" }),
|
||||
null,
|
||||
),
|
||||
).toEqual({
|
||||
type: "write",
|
||||
filePath: "created.txt",
|
||||
content: "hello write",
|
||||
});
|
||||
});
|
||||
|
||||
test("maps task to sub-agent detail and suppresses todo raw cards", () => {
|
||||
expect(
|
||||
mapOmpToolDetail(
|
||||
parseToolArgs("task", {
|
||||
agent: "explore",
|
||||
description: "Inspect the target files",
|
||||
}),
|
||||
null,
|
||||
),
|
||||
).toEqual({
|
||||
type: "sub_agent",
|
||||
subAgentType: "explore",
|
||||
description: "Inspect the target files",
|
||||
log: "",
|
||||
});
|
||||
expect(mapOmpToolDetail(parseToolArgs("todo", { op: "view" }), null)).toBeNull();
|
||||
});
|
||||
|
||||
test("uses task result text and transcript path as the best static replay detail", () => {
|
||||
expect(
|
||||
mapOmpToolDetail(
|
||||
parseToolArgs("task", {
|
||||
agent: "explore",
|
||||
description: "Inspect the target files",
|
||||
}),
|
||||
parseToolResult({
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "done\ntranscript: /tmp/omp-task-static/Explore.jsonl",
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
type: "sub_agent",
|
||||
subAgentType: "explore",
|
||||
description: "Inspect the target files",
|
||||
childSessionId: "/tmp/omp-task-static/Explore.jsonl",
|
||||
log: "done\ntranscript: /tmp/omp-task-static/Explore.jsonl",
|
||||
});
|
||||
});
|
||||
|
||||
test("falls back to shared unknown detail for unmapped tools", () => {
|
||||
expect(mapOmpToolDetail(parseToolArgs("lsp", { op: "hover" }), null)).toEqual({
|
||||
type: "unknown",
|
||||
input: { op: "hover" },
|
||||
output: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import type { ToolCallDetail } from "../../agent-sdk-types.js";
|
||||
import {
|
||||
extractTextFromToolResult,
|
||||
mapToolDetail as mapOmpCoreToolDetail,
|
||||
type OmpToolResult,
|
||||
type OmpTrackedToolCall,
|
||||
} from "./tool-call-detail.js";
|
||||
|
||||
export function mapOmpToolDetail(
|
||||
toolCall: OmpTrackedToolCall,
|
||||
result: OmpToolResult,
|
||||
context?: {
|
||||
toolCallId: string;
|
||||
mapSubagentDetail?: (baseDetail: ToolCallDetail) => ToolCallDetail;
|
||||
},
|
||||
): ToolCallDetail | null {
|
||||
if (toolCall.toolName === "todo") {
|
||||
return null;
|
||||
}
|
||||
if (toolCall.toolName === "task") {
|
||||
const detail = mapOmpTaskDetail(toolCall.args, result);
|
||||
return context?.mapSubagentDetail?.(detail) ?? detail;
|
||||
}
|
||||
if (toolCall.toolName === "edit") {
|
||||
return mapOmpEditDetail(toolCall, result);
|
||||
}
|
||||
if (toolCall.toolName === "read") {
|
||||
return mapOmpReadDetail(toolCall, result);
|
||||
}
|
||||
return mapOmpCoreToolDetail(toolCall, result);
|
||||
}
|
||||
|
||||
function mapOmpTaskDetail(args: unknown, result: OmpToolResult): ToolCallDetail {
|
||||
const argRecord = isRecord(args) ? args : {};
|
||||
const resultText = extractTextFromToolResult(result);
|
||||
const childSessionId = readChildSessionId(result);
|
||||
return {
|
||||
type: "sub_agent",
|
||||
subAgentType: firstString(
|
||||
argRecord.agent,
|
||||
argRecord.subAgentType,
|
||||
argRecord.agentType,
|
||||
argRecord.type,
|
||||
),
|
||||
description: firstString(
|
||||
argRecord.description,
|
||||
argRecord.task,
|
||||
argRecord.prompt,
|
||||
argRecord.assignment,
|
||||
),
|
||||
...(childSessionId ? { childSessionId } : {}),
|
||||
log: resultText?.trim() ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function mapOmpEditDetail(
|
||||
toolCall: OmpTrackedToolCall,
|
||||
result: OmpToolResult,
|
||||
): ToolCallDetail | null {
|
||||
const fallback = mapOmpCoreToolDetail(toolCall, result);
|
||||
const details = resultDetails(result);
|
||||
const filePath =
|
||||
firstString(details?.path, details?.filePath) ?? readPatchInputPath(toolCall.args);
|
||||
if (!filePath) {
|
||||
return fallback;
|
||||
}
|
||||
return {
|
||||
type: "edit",
|
||||
filePath,
|
||||
oldString: firstString(details?.oldText, details?.old_string),
|
||||
newString: firstString(details?.newText, details?.new_string),
|
||||
unifiedDiff: firstString(details?.diff),
|
||||
};
|
||||
}
|
||||
|
||||
function mapOmpReadDetail(
|
||||
toolCall: OmpTrackedToolCall,
|
||||
result: OmpToolResult,
|
||||
): ToolCallDetail | null {
|
||||
const fallback = mapOmpCoreToolDetail(toolCall, result);
|
||||
if (!fallback || fallback.type !== "read") {
|
||||
return fallback;
|
||||
}
|
||||
const details = resultDetails(result);
|
||||
const displayContent = isRecord(details?.displayContent) ? details.displayContent : null;
|
||||
const displayText = firstString(displayContent?.text);
|
||||
if (!displayText) {
|
||||
return fallback;
|
||||
}
|
||||
return {
|
||||
...fallback,
|
||||
content: displayText,
|
||||
};
|
||||
}
|
||||
|
||||
function resultDetails(result: OmpToolResult): Record<string, unknown> | null {
|
||||
if (typeof result === "string" || result === null) {
|
||||
return null;
|
||||
}
|
||||
return isRecord(result.details) ? result.details : null;
|
||||
}
|
||||
|
||||
function readChildSessionId(result: OmpToolResult): string | undefined {
|
||||
const details = resultDetails(result);
|
||||
const direct = firstString(details?.sessionFile, details?.session_file, details?.childSessionId);
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
const text = extractTextFromToolResult(result);
|
||||
return text?.match(/(?:session|transcript)(?: file)?:\s*(?<path>\/\S+\.jsonl)/i)?.groups?.path;
|
||||
}
|
||||
|
||||
function readPatchInputPath(args: unknown): string | undefined {
|
||||
if (!isRecord(args)) {
|
||||
return undefined;
|
||||
}
|
||||
const input = args.input;
|
||||
if (typeof input !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const match = /^\[(?<path>.+?)#[^\]\n]+]/m.exec(input);
|
||||
return match?.groups?.path;
|
||||
}
|
||||
|
||||
function firstString(...values: unknown[]): string | undefined {
|
||||
for (const value of values) {
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { mapOmpUsage } from "./usage-mapper.js";
|
||||
|
||||
describe("OMP usage mapper", () => {
|
||||
test("adds OMP context usage to token and cost totals", () => {
|
||||
const usage = mapOmpUsage({
|
||||
stats: {
|
||||
tokens: { input: 28237, output: 548, cacheRead: 269824, cacheWrite: 0, total: 298609 },
|
||||
cost: 0.29253700000000005,
|
||||
},
|
||||
state: {
|
||||
model: null,
|
||||
thinkingLevel: "medium",
|
||||
isStreaming: false,
|
||||
isCompacting: false,
|
||||
sessionId: "session",
|
||||
messageCount: 0,
|
||||
queuedMessageCount: 0,
|
||||
contextUsage: { tokens: 23656, contextWindow: 272000, percent: 8.7 },
|
||||
},
|
||||
baseUsage: {
|
||||
inputTokens: 28237,
|
||||
cachedInputTokens: 269824,
|
||||
outputTokens: 548,
|
||||
totalCostUsd: 0.29253700000000005,
|
||||
},
|
||||
});
|
||||
|
||||
expect(usage).toEqual({
|
||||
inputTokens: 28237,
|
||||
cachedInputTokens: 269824,
|
||||
outputTokens: 548,
|
||||
totalCostUsd: 0.29253700000000005,
|
||||
contextWindowMaxTokens: 272000,
|
||||
contextWindowUsedTokens: 23656,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { AgentUsage } from "../../agent-sdk-types.js";
|
||||
import type { OmpSessionState, OmpSessionStats } from "./rpc-types.js";
|
||||
|
||||
export function mapOmpUsage(input: {
|
||||
stats: OmpSessionStats;
|
||||
state: OmpSessionState;
|
||||
baseUsage: AgentUsage | undefined;
|
||||
}): AgentUsage | undefined {
|
||||
const contextWindowUsedTokens = finiteNumber(input.state.contextUsage?.tokens);
|
||||
const contextWindowMaxTokens = finiteNumber(input.state.contextUsage?.contextWindow);
|
||||
if (contextWindowUsedTokens === undefined && contextWindowMaxTokens === undefined) {
|
||||
return input.baseUsage;
|
||||
}
|
||||
|
||||
return {
|
||||
...input.baseUsage,
|
||||
...(contextWindowMaxTokens !== undefined ? { contextWindowMaxTokens } : {}),
|
||||
...(contextWindowUsedTokens !== undefined ? { contextWindowUsedTokens } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function finiteNumber(value: number | null | undefined): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { parseToolArgs, parseToolResult } from "./tool-call-detail.js";
|
||||
import { OmpAvailableCommandsUpdateEventSchema } from "./rpc-types.js";
|
||||
import { mapOmpToolDetail } from "./tool-call-mapper.js";
|
||||
|
||||
describe("OMP 17 RPC compatibility", () => {
|
||||
test("parses source-attributed command updates", () => {
|
||||
const event = OmpAvailableCommandsUpdateEventSchema.parse({
|
||||
type: "available_commands_update",
|
||||
commands: [{ name: "prewalk", description: "Prewalk at the next action", source: "builtin" }],
|
||||
});
|
||||
|
||||
expect(event.commands).toEqual([
|
||||
{ name: "prewalk", description: "Prewalk at the next action", source: "builtin" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("maps subscribed custom tool events without assuming built-in names", () => {
|
||||
const event = {
|
||||
type: "tool_execution_start",
|
||||
toolCallId: "hub-call",
|
||||
toolName: "hub",
|
||||
args: { op: "list" },
|
||||
};
|
||||
|
||||
expect(mapOmpToolDetail(parseToolArgs(event.toolName, event.args), null)).toEqual({
|
||||
type: "unknown",
|
||||
input: { op: "list" },
|
||||
output: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("parses arbitrary custom tool results", () => {
|
||||
expect(
|
||||
parseToolResult({
|
||||
content: [{ type: "text", text: "No peers registered" }],
|
||||
details: { op: "list", peers: [] },
|
||||
}),
|
||||
).toEqual({
|
||||
content: [{ type: "text", text: "No peers registered" }],
|
||||
details: { op: "list", peers: [] },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -896,6 +896,7 @@ describe("PiRpcAgentSession", () => {
|
||||
error: "Pi exited",
|
||||
});
|
||||
});
|
||||
|
||||
test("completes locally handled slash commands when agentInvoked is false", async () => {
|
||||
const { pi, session, events } = await createSession();
|
||||
const fakeSession = pi.latestSession();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,6 @@ import pino from "pino";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { PiCliRuntime } from "./cli-runtime.js";
|
||||
import type { PiCommandsRpcType } from "./rpc-types.js";
|
||||
import type { PiRuntimeLaunch } from "./runtime.js";
|
||||
|
||||
type PiChild = ChildProcessWithoutNullStreams & {
|
||||
@@ -35,12 +34,12 @@ function createPiChild(): PiChild {
|
||||
function createRuntime(
|
||||
child: PiChild,
|
||||
launches: PiRuntimeLaunch[] = [],
|
||||
options?: { commandsRpcType?: PiCommandsRpcType },
|
||||
options?: { commandsRpcName?: string },
|
||||
): PiCliRuntime {
|
||||
return new PiCliRuntime({
|
||||
logger: pino({ level: "silent" }),
|
||||
command: ["pi"],
|
||||
commandsRpcType: options?.commandsRpcType,
|
||||
commandsRpcName: options?.commandsRpcName,
|
||||
spawnProcess: (launch) => {
|
||||
launches.push(launch);
|
||||
return child;
|
||||
@@ -150,7 +149,7 @@ describe("PiCliRuntime", () => {
|
||||
runtimeSettings: {
|
||||
command: {
|
||||
mode: "replace",
|
||||
argv: ["omp"],
|
||||
argv: ["custom-pi"],
|
||||
},
|
||||
},
|
||||
spawnProcess: (launch) => {
|
||||
@@ -159,13 +158,42 @@ describe("PiCliRuntime", () => {
|
||||
},
|
||||
});
|
||||
|
||||
await runtime.startSession({ cwd: "/workspace/project", session: "/tmp/omp-session.jsonl" });
|
||||
await runtime.startSession({ cwd: "/workspace/project", session: "/tmp/pi-session.jsonl" });
|
||||
|
||||
expect(launches).toEqual([
|
||||
expect.objectContaining({
|
||||
cwd: "/workspace/project",
|
||||
session: "/tmp/omp-session.jsonl",
|
||||
argv: ["omp", "--mode", "rpc", "--session", "/tmp/omp-session.jsonl"],
|
||||
session: "/tmp/pi-session.jsonl",
|
||||
argv: ["custom-pi", "--mode", "rpc", "--session", "/tmp/pi-session.jsonl"],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test("does not append rpc mode when the configured command already includes a mode flag", async () => {
|
||||
const child = createPiChild();
|
||||
replyToCommands(child, () => ({}));
|
||||
const launches: PiRuntimeLaunch[] = [];
|
||||
const runtime = new PiCliRuntime({
|
||||
logger: pino({ level: "silent" }),
|
||||
command: ["pi"],
|
||||
runtimeSettings: {
|
||||
command: {
|
||||
mode: "replace",
|
||||
argv: ["custom-pi", "--mode", "json"],
|
||||
},
|
||||
},
|
||||
spawnProcess: (launch) => {
|
||||
launches.push(launch);
|
||||
return child;
|
||||
},
|
||||
});
|
||||
|
||||
await runtime.startSession({ cwd: "/workspace/project" });
|
||||
|
||||
expect(launches).toEqual([
|
||||
expect.objectContaining({
|
||||
cwd: "/workspace/project",
|
||||
argv: ["custom-pi", "--mode", "json"],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
@@ -220,25 +248,6 @@ describe("PiCliRuntime", () => {
|
||||
expect(commandTypes).toEqual(["get_commands"]);
|
||||
});
|
||||
|
||||
test("lists commands through the OMP-compatible get_available_commands RPC", async () => {
|
||||
const child = createPiChild();
|
||||
const commandTypes: string[] = [];
|
||||
replyToCommands(child, (command) => {
|
||||
commandTypes.push(String(command.type));
|
||||
return {
|
||||
commands: [{ name: "skill:ctx-stats", description: "Show context stats", source: "skill" }],
|
||||
};
|
||||
});
|
||||
const session = await createRuntime(child, [], {
|
||||
commandsRpcType: "get_available_commands",
|
||||
}).startSession({ cwd: "/workspace/project" });
|
||||
|
||||
await expect(session.getCommands()).resolves.toEqual([
|
||||
{ name: "skill:ctx-stats", description: "Show context stats", source: "skill" },
|
||||
]);
|
||||
expect(commandTypes).toEqual(["get_available_commands"]);
|
||||
});
|
||||
|
||||
test("keeps unicode line separators inside one JSONL record", async () => {
|
||||
const child = createPiChild();
|
||||
replyToCommands(child, () => ({}));
|
||||
@@ -262,6 +271,17 @@ describe("PiCliRuntime", () => {
|
||||
await expect(state).rejects.toThrow("boom");
|
||||
});
|
||||
|
||||
test("rejects pending commands when the Pi session closes", async () => {
|
||||
const child = createPiChild();
|
||||
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
|
||||
|
||||
const state = session.getState();
|
||||
const rejection = expect(state).rejects.toThrow("Pi RPC session is closed");
|
||||
await session.close();
|
||||
|
||||
await rejection;
|
||||
});
|
||||
|
||||
test("disposes the Pi process", async () => {
|
||||
const child = createPiChild();
|
||||
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { type ChildProcess, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import { spawnProcess } from "../../../../utils/spawn.js";
|
||||
import { terminateWithTreeKill } from "../../../../utils/tree-kill.js";
|
||||
import type { ProviderRuntimeSettings } from "../../provider-launch-config.js";
|
||||
import { JsonlRpcProcess, type JsonlRpcLaunch } from "../jsonl-rpc-process.js";
|
||||
import {
|
||||
buildPiLaunch,
|
||||
type PiRuntime,
|
||||
@@ -13,11 +12,9 @@ import {
|
||||
} from "./runtime.js";
|
||||
import type {
|
||||
PiAgentMessage,
|
||||
PiCommandsRpcType,
|
||||
PiModel,
|
||||
PiPromptAck,
|
||||
PiRpcCommand,
|
||||
PiRpcResponse,
|
||||
PiRpcSlashCommand,
|
||||
PiRuntimeEvent,
|
||||
PiSessionState,
|
||||
@@ -27,54 +24,25 @@ import type {
|
||||
const DEFAULT_PI_COMMAND: [string, ...string[]] = [
|
||||
process.env.PI_COMMAND ?? process.env.PI_ACP_PI_COMMAND ?? "pi",
|
||||
];
|
||||
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||
const DEFAULT_COMMANDS_RPC_TYPE: PiCommandsRpcType = "get_commands";
|
||||
const STDERR_BUFFER_LIMIT = 8192;
|
||||
const GRACEFUL_SHUTDOWN_TIMEOUT_MS = 2_000;
|
||||
const FORCE_SHUTDOWN_TIMEOUT_MS = 1_000;
|
||||
|
||||
function assertChildWithPipes(
|
||||
child: ChildProcess,
|
||||
): asserts child is ChildProcessWithoutNullStreams {
|
||||
if (!child.stdin || !child.stdout || !child.stderr) {
|
||||
throw new Error("Pi process was spawned without stdio streams");
|
||||
}
|
||||
}
|
||||
|
||||
interface PendingRequest {
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (error: Error) => void;
|
||||
timer: NodeJS.Timeout;
|
||||
}
|
||||
const DEFAULT_COMMANDS_RPC_NAME = "get_commands";
|
||||
|
||||
export interface PiCliRuntimeOptions {
|
||||
logger: Logger;
|
||||
runtimeSettings?: ProviderRuntimeSettings;
|
||||
command?: [string, ...string[]];
|
||||
commandsRpcType?: PiCommandsRpcType;
|
||||
commandsRpcName?: string;
|
||||
spawnProcess?: (launch: PiRuntimeLaunch) => ChildProcessWithoutNullStreams;
|
||||
}
|
||||
|
||||
export class PiCliRuntime implements PiRuntime {
|
||||
private readonly command: [string, ...string[]];
|
||||
private readonly commandsRpcType: PiCommandsRpcType;
|
||||
private readonly spawnProcess: (launch: PiRuntimeLaunch) => ChildProcessWithoutNullStreams;
|
||||
private readonly commandsRpcName: string;
|
||||
private readonly spawnProcess?: (launch: PiRuntimeLaunch) => ChildProcessWithoutNullStreams;
|
||||
|
||||
constructor(private readonly options: PiCliRuntimeOptions) {
|
||||
this.command = options.command ?? DEFAULT_PI_COMMAND;
|
||||
this.commandsRpcType = options.commandsRpcType ?? DEFAULT_COMMANDS_RPC_TYPE;
|
||||
this.spawnProcess =
|
||||
options.spawnProcess ??
|
||||
((launch) => {
|
||||
const [command, ...args] = launch.argv;
|
||||
const child = spawnProcess(command, args, {
|
||||
cwd: launch.cwd,
|
||||
envOverlay: launch.env,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
assertChildWithPipes(child);
|
||||
return child;
|
||||
});
|
||||
this.commandsRpcName = options.commandsRpcName ?? DEFAULT_COMMANDS_RPC_NAME;
|
||||
this.spawnProcess = options.spawnProcess;
|
||||
}
|
||||
|
||||
async startSession(input: PiStartSessionInput): Promise<PiRuntimeSession> {
|
||||
@@ -83,47 +51,37 @@ export class PiCliRuntime implements PiRuntime {
|
||||
runtimeSettings: this.options.runtimeSettings,
|
||||
session: input,
|
||||
});
|
||||
return new PiCliRuntimeSession(
|
||||
launch,
|
||||
this.spawnProcess(launch),
|
||||
this.options.logger,
|
||||
this.commandsRpcType,
|
||||
);
|
||||
const [command, ...args] = launch.argv;
|
||||
const processLaunch: JsonlRpcLaunch = {
|
||||
command,
|
||||
args,
|
||||
cwd: launch.cwd,
|
||||
env: launch.env,
|
||||
};
|
||||
const spawn = this.spawnProcess;
|
||||
const processOptions = {
|
||||
launch: processLaunch,
|
||||
logger: this.options.logger,
|
||||
diagnosticName: "Pi RPC",
|
||||
...(spawn ? { spawn: () => spawn(launch) } : {}),
|
||||
};
|
||||
const process = new JsonlRpcProcess(processOptions);
|
||||
return new PiCliRuntimeSession(process, this.commandsRpcName);
|
||||
}
|
||||
}
|
||||
|
||||
class PiCliRuntimeSession implements PiRuntimeSession {
|
||||
private readonly pending = new Map<string, PendingRequest>();
|
||||
private readonly subscribers = new Set<(event: PiRuntimeEvent) => void>();
|
||||
private stderrBuffer = "";
|
||||
private nextRequestId = 1;
|
||||
private disposed = false;
|
||||
private stdoutBuffer = "";
|
||||
|
||||
constructor(
|
||||
_launch: PiRuntimeLaunch,
|
||||
private readonly child: ChildProcessWithoutNullStreams,
|
||||
private readonly logger: Logger,
|
||||
private readonly commandsRpcType: PiCommandsRpcType,
|
||||
private readonly process: JsonlRpcProcess,
|
||||
private readonly commandsRpcName: string,
|
||||
) {
|
||||
child.stdout.on("data", (chunk) => {
|
||||
this.handleStdoutChunk(chunk.toString());
|
||||
process.onMessage((message) => {
|
||||
this.emit(message as PiRuntimeEvent);
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
this.stderrBuffer += chunk.toString();
|
||||
if (this.stderrBuffer.length > STDERR_BUFFER_LIMIT) {
|
||||
this.stderrBuffer = this.stderrBuffer.slice(-STDERR_BUFFER_LIMIT);
|
||||
}
|
||||
});
|
||||
child.on("error", (error) => {
|
||||
this.failAll(error instanceof Error ? error : new Error(String(error)));
|
||||
});
|
||||
child.on("exit", (code, signal) => {
|
||||
const error = new Error(
|
||||
`Pi RPC process exited with code ${code ?? "null"} and signal ${signal ?? "null"}\n${this.stderrBuffer}`.trim(),
|
||||
);
|
||||
process.onExit(({ error }) => {
|
||||
this.emit({ type: "process_exit", error: error.message });
|
||||
this.failAll(error);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -138,18 +96,19 @@ class PiCliRuntimeSession implements PiRuntimeSession {
|
||||
message: string,
|
||||
images?: Array<{ type: "image"; data: string; mimeType: string }>,
|
||||
): Promise<PiPromptAck> {
|
||||
const data = await this.request({
|
||||
const { id: requestId, promise } = this.process.startRequest({
|
||||
type: "prompt",
|
||||
message,
|
||||
...(images?.length ? { images } : {}),
|
||||
});
|
||||
const data = await promise;
|
||||
if (typeof data === "object" && data !== null && !Array.isArray(data)) {
|
||||
const { agentInvoked } = data as Record<string, unknown>;
|
||||
if (typeof agentInvoked === "boolean") {
|
||||
return { agentInvoked };
|
||||
return { requestId, agentInvoked };
|
||||
}
|
||||
}
|
||||
return {};
|
||||
return { requestId };
|
||||
}
|
||||
|
||||
async compact(customInstructions?: string): Promise<void> {
|
||||
@@ -224,17 +183,21 @@ class PiCliRuntimeSession implements PiRuntimeSession {
|
||||
}
|
||||
|
||||
async getCommands(): Promise<PiRpcSlashCommand[]> {
|
||||
const data = (await this.request({ type: this.commandsRpcType })) as {
|
||||
const data = (await this.request({ type: this.commandsRpcName })) as {
|
||||
commands?: PiRpcSlashCommand[];
|
||||
};
|
||||
return data.commands ?? [];
|
||||
}
|
||||
|
||||
sendRawFrame(frame: object & { type: string }): void {
|
||||
this.process.send(frame);
|
||||
}
|
||||
|
||||
respondToExtensionUiRequest(
|
||||
id: string,
|
||||
response: { value?: string; confirmed?: boolean; cancelled?: boolean },
|
||||
): void {
|
||||
this.writeJsonLine({ type: "extension_ui_response", id, ...response });
|
||||
this.process.send({ type: "extension_ui_response", id, ...response });
|
||||
}
|
||||
|
||||
cancelExtensionUiRequest(id: string): void {
|
||||
@@ -242,106 +205,11 @@ class PiCliRuntimeSession implements PiRuntimeSession {
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this.disposed) return;
|
||||
this.disposed = true;
|
||||
try {
|
||||
this.child.stdin.end();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
const result = await terminateWithTreeKill(this.child, {
|
||||
gracefulTimeoutMs: GRACEFUL_SHUTDOWN_TIMEOUT_MS,
|
||||
forceTimeoutMs: FORCE_SHUTDOWN_TIMEOUT_MS,
|
||||
onForceSignal: () => {
|
||||
this.logger.warn(
|
||||
{ timeoutMs: GRACEFUL_SHUTDOWN_TIMEOUT_MS },
|
||||
"Pi RPC process did not exit after SIGTERM; sending SIGKILL",
|
||||
);
|
||||
},
|
||||
});
|
||||
if (result === "kill-timeout") {
|
||||
this.logger.warn(
|
||||
{ timeoutMs: FORCE_SHUTDOWN_TIMEOUT_MS },
|
||||
"Pi RPC process did not report exit after SIGKILL",
|
||||
);
|
||||
}
|
||||
await this.process.close(new Error("Pi RPC session is closed"));
|
||||
}
|
||||
|
||||
private request(command: PiRpcCommand, timeoutMs = DEFAULT_TIMEOUT_MS): Promise<unknown> {
|
||||
if (this.disposed) {
|
||||
return Promise.reject(new Error("Pi RPC session is closed"));
|
||||
}
|
||||
const id = `req_${this.nextRequestId}`;
|
||||
this.nextRequestId += 1;
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pending.delete(id);
|
||||
reject(
|
||||
new Error(`Pi RPC request timed out for ${command.type}\n${this.stderrBuffer}`.trim()),
|
||||
);
|
||||
}, timeoutMs);
|
||||
this.pending.set(id, { resolve, reject, timer });
|
||||
this.writeJsonLine({ ...command, id });
|
||||
});
|
||||
}
|
||||
|
||||
private writeJsonLine(value: unknown): void {
|
||||
if (this.disposed || this.child.stdin.destroyed || !this.child.stdin.writable) {
|
||||
return;
|
||||
}
|
||||
this.child.stdin.write(`${JSON.stringify(value)}\n`);
|
||||
}
|
||||
|
||||
private handleStdoutChunk(chunk: string): void {
|
||||
this.stdoutBuffer += chunk;
|
||||
for (;;) {
|
||||
const newlineIndex = this.stdoutBuffer.indexOf("\n");
|
||||
if (newlineIndex === -1) {
|
||||
break;
|
||||
}
|
||||
const line = this.stdoutBuffer.slice(0, newlineIndex).replace(/\r$/, "");
|
||||
this.stdoutBuffer = this.stdoutBuffer.slice(newlineIndex + 1);
|
||||
if (line.trim()) {
|
||||
this.handleLine(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleLine(line: string): void {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(line);
|
||||
} catch (error) {
|
||||
this.logger.warn({ error, line }, "Ignoring non-JSON Pi RPC stdout line");
|
||||
return;
|
||||
}
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return;
|
||||
}
|
||||
const message = parsed as Record<string, unknown>;
|
||||
if (message.type === "response") {
|
||||
this.handleResponse(message as unknown as PiRpcResponse);
|
||||
return;
|
||||
}
|
||||
this.emit(message as PiRuntimeEvent);
|
||||
}
|
||||
|
||||
private handleResponse(response: PiRpcResponse): void {
|
||||
const id = response.id;
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
const pending = this.pending.get(id);
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(pending.timer);
|
||||
this.pending.delete(id);
|
||||
if (!response.success) {
|
||||
pending.reject(new Error(response.error ?? `Pi RPC ${response.command} failed`));
|
||||
return;
|
||||
}
|
||||
pending.resolve(response.data);
|
||||
request(command: PiRpcCommand, timeoutMs?: number): Promise<unknown> {
|
||||
return this.process.request(command, timeoutMs);
|
||||
}
|
||||
|
||||
private emit(event: PiRuntimeEvent): void {
|
||||
@@ -349,16 +217,4 @@ class PiCliRuntimeSession implements PiRuntimeSession {
|
||||
subscriber(event);
|
||||
}
|
||||
}
|
||||
|
||||
private failAll(error: Error): void {
|
||||
if (this.disposed) {
|
||||
return;
|
||||
}
|
||||
this.disposed = true;
|
||||
for (const pending of this.pending.values()) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.reject(error);
|
||||
}
|
||||
this.pending.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { AgentStreamEvent } from "../../agent-sdk-types.js";
|
||||
import type { PiAgentMessage } from "./rpc-types.js";
|
||||
import { streamPiHistory, type PiCapturedUserMessageEntry } from "./history-mapper.js";
|
||||
import type { PiAgentMessage } from "./rpc-types.js";
|
||||
|
||||
async function collectHistory(
|
||||
messages: PiAgentMessage[],
|
||||
@@ -128,6 +128,18 @@ describe("Pi history mapper", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("replays non-notice custom messages as assistant text, matching the live path", async () => {
|
||||
await expect(
|
||||
collectHistory([{ role: "custom", content: "Extension command output" }]),
|
||||
).resolves.toEqual([
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "pi",
|
||||
item: { type: "assistant_message", text: "Extension command output" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("uses Pi tree entry ids for replayed user messages", async () => {
|
||||
await expect(
|
||||
collectHistory(
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
mapToolDetail,
|
||||
parseToolArgs,
|
||||
parseToolResult,
|
||||
resolveToolCallName,
|
||||
type PiToolResult,
|
||||
type PiTrackedToolCall,
|
||||
} from "./tool-call-mapper.js";
|
||||
|
||||
@@ -13,6 +15,19 @@ export interface PiCapturedUserMessageEntry {
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface PiHistoryMapperHooks {
|
||||
mapCustomMessage?: (
|
||||
text: string,
|
||||
provider: string,
|
||||
) => Extract<AgentStreamEvent, { type: "timeline" }> | null;
|
||||
resolveToolCallId?: (toolCallId: string, toolCall: PiTrackedToolCall) => string;
|
||||
mapToolDetail?: (
|
||||
toolCall: PiTrackedToolCall,
|
||||
result: PiToolResult,
|
||||
context: { toolCallId: string },
|
||||
) => ToolCallDetail | null;
|
||||
}
|
||||
|
||||
function isTextContentBlock(block: unknown): block is PiTextContent {
|
||||
return (
|
||||
typeof block === "object" &&
|
||||
@@ -37,116 +52,205 @@ export function getUserMessageText(content: string | (PiTextContent | PiImageCon
|
||||
return textParts.join("\n\n");
|
||||
}
|
||||
|
||||
export class PiHistoryMapper {
|
||||
private readonly pendingToolCalls = new Map<string, PiTrackedToolCall>();
|
||||
private userIndex = 0;
|
||||
private assistantIndex = 0;
|
||||
|
||||
constructor(
|
||||
private readonly provider: string,
|
||||
private readonly userEntries: readonly PiCapturedUserMessageEntry[] = [],
|
||||
private readonly hooks: PiHistoryMapperHooks = {},
|
||||
) {}
|
||||
|
||||
mapMessages(messages: readonly PiAgentMessage[]): AgentStreamEvent[] {
|
||||
const events: AgentStreamEvent[] = [];
|
||||
|
||||
for (const message of messages) {
|
||||
switch (message.role) {
|
||||
case "user":
|
||||
events.push(...this.mapUserMessage(message));
|
||||
break;
|
||||
case "custom":
|
||||
events.push(...this.mapCustomMessage(message));
|
||||
break;
|
||||
case "assistant":
|
||||
events.push(...this.mapAssistantMessage(message));
|
||||
break;
|
||||
case "toolResult": {
|
||||
const event = this.mapToolResultMessage(message);
|
||||
if (event) {
|
||||
events.push(event);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "bashExecution":
|
||||
events.push(this.mapBashExecutionMessage(message));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
private mapUserMessage(message: Extract<PiAgentMessage, { role: "user" }>): AgentStreamEvent[] {
|
||||
const text = getUserMessageText(message.content);
|
||||
this.userIndex += 1;
|
||||
if (!text) {
|
||||
return [];
|
||||
}
|
||||
const userEntry = this.userEntries[this.userIndex - 1];
|
||||
return [
|
||||
{
|
||||
type: "timeline",
|
||||
provider: this.provider,
|
||||
item: {
|
||||
type: "user_message",
|
||||
text,
|
||||
...(userEntry ? { messageId: userEntry.id } : {}),
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private mapCustomMessage(
|
||||
message: Extract<PiAgentMessage, { role: "custom" }>,
|
||||
): AgentStreamEvent[] {
|
||||
const text = getUserMessageText(message.content);
|
||||
const mappedEvent = text ? this.hooks.mapCustomMessage?.(text, this.provider) : null;
|
||||
if (mappedEvent) {
|
||||
return [mappedEvent];
|
||||
}
|
||||
return text
|
||||
? [
|
||||
{
|
||||
type: "timeline",
|
||||
provider: this.provider,
|
||||
item: { type: "assistant_message", text },
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}
|
||||
|
||||
private mapAssistantMessage(
|
||||
message: Extract<PiAgentMessage, { role: "assistant" }>,
|
||||
): AgentStreamEvent[] {
|
||||
const events: AgentStreamEvent[] = [];
|
||||
this.assistantIndex += 1;
|
||||
const messageId =
|
||||
message.responseId || `${this.provider}-history-assistant-${this.assistantIndex}`;
|
||||
for (const content of message.content) {
|
||||
if (content.type === "text" && content.text) {
|
||||
events.push({
|
||||
type: "timeline",
|
||||
provider: this.provider,
|
||||
item: { type: "assistant_message", text: content.text, messageId },
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (content.type === "thinking" && content.thinking) {
|
||||
events.push({
|
||||
type: "timeline",
|
||||
provider: this.provider,
|
||||
item: { type: "reasoning", text: content.thinking },
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (content.type === "toolCall") {
|
||||
const tracked = parseToolArgs(content.name, content.arguments);
|
||||
this.pendingToolCalls.set(content.id, tracked);
|
||||
const detail = this.mapToolDetail(content.id, tracked, null);
|
||||
if (!detail) {
|
||||
continue;
|
||||
}
|
||||
events.push({
|
||||
type: "timeline",
|
||||
provider: this.provider,
|
||||
item: {
|
||||
type: "tool_call",
|
||||
callId: this.resolveToolCallId(content.id, tracked),
|
||||
name: tracked.toolName,
|
||||
status: "running",
|
||||
detail,
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
private mapToolResultMessage(
|
||||
message: Extract<PiAgentMessage, { role: "toolResult" }>,
|
||||
): AgentStreamEvent | null {
|
||||
const tracked =
|
||||
this.pendingToolCalls.get(message.toolCallId) ?? parseToolArgs(message.toolName, null);
|
||||
this.pendingToolCalls.delete(message.toolCallId);
|
||||
const result = parseToolResult({ content: message.content, details: message.details });
|
||||
const detail = this.mapToolDetail(message.toolCallId, tracked, result);
|
||||
if (!detail) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
type: "timeline",
|
||||
provider: this.provider,
|
||||
item: toToolResultTimelineItem({
|
||||
callId: this.resolveToolCallId(message.toolCallId, tracked),
|
||||
name: resolveToolCallName(tracked, result),
|
||||
isError: Boolean(message.isError),
|
||||
detail,
|
||||
errorText: extractTextFromToolResult(result) ?? "Tool call failed",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
private mapBashExecutionMessage(
|
||||
message: Extract<PiAgentMessage, { role: "bashExecution" }>,
|
||||
): AgentStreamEvent {
|
||||
const detail: ToolCallDetail = {
|
||||
type: "shell",
|
||||
command: message.command,
|
||||
output: message.output,
|
||||
exitCode: message.exitCode ?? null,
|
||||
};
|
||||
return {
|
||||
type: "timeline",
|
||||
provider: this.provider,
|
||||
item: {
|
||||
type: "tool_call",
|
||||
callId: `pi-bash-${message.timestamp}`,
|
||||
name: "bash",
|
||||
status: message.cancelled ? "canceled" : "completed",
|
||||
detail,
|
||||
error: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private resolveToolCallId(toolCallId: string, toolCall: PiTrackedToolCall): string {
|
||||
return this.hooks.resolveToolCallId?.(toolCallId, toolCall) ?? toolCallId;
|
||||
}
|
||||
|
||||
private mapToolDetail(
|
||||
toolCallId: string,
|
||||
toolCall: PiTrackedToolCall,
|
||||
result: PiToolResult,
|
||||
): ToolCallDetail | null {
|
||||
const hook = this.hooks.mapToolDetail;
|
||||
return hook ? hook(toolCall, result, { toolCallId }) : mapToolDetail(toolCall, result);
|
||||
}
|
||||
}
|
||||
|
||||
export async function* streamPiHistory(
|
||||
provider: string,
|
||||
messages: PiAgentMessage[],
|
||||
userEntries: readonly PiCapturedUserMessageEntry[] = [],
|
||||
hooks: PiHistoryMapperHooks = {},
|
||||
): AsyncGenerator<AgentStreamEvent> {
|
||||
const pendingToolCalls = new Map<string, PiTrackedToolCall>();
|
||||
let userIndex = 0;
|
||||
let assistantIndex = 0;
|
||||
|
||||
for (const message of messages) {
|
||||
if (message.role === "user") {
|
||||
const text = getUserMessageText(message.content);
|
||||
if (text) {
|
||||
const userEntry = userEntries[userIndex];
|
||||
yield {
|
||||
type: "timeline",
|
||||
provider,
|
||||
item: {
|
||||
type: "user_message",
|
||||
text,
|
||||
...(userEntry ? { messageId: userEntry.id } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
userIndex += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message.role === "assistant") {
|
||||
assistantIndex += 1;
|
||||
const messageId = message.responseId || `${provider}-history-assistant-${assistantIndex}`;
|
||||
for (const content of message.content) {
|
||||
if (content.type === "text" && content.text) {
|
||||
yield {
|
||||
type: "timeline",
|
||||
provider,
|
||||
item: { type: "assistant_message", text: content.text, messageId },
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
if (content.type === "thinking" && content.thinking) {
|
||||
yield {
|
||||
type: "timeline",
|
||||
provider,
|
||||
item: { type: "reasoning", text: content.thinking },
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
if (content.type === "toolCall") {
|
||||
const tracked = parseToolArgs(content.name, content.arguments);
|
||||
pendingToolCalls.set(content.id, tracked);
|
||||
yield {
|
||||
type: "timeline",
|
||||
provider,
|
||||
item: {
|
||||
type: "tool_call",
|
||||
callId: content.id,
|
||||
name: tracked.toolName,
|
||||
status: "running",
|
||||
detail: mapToolDetail(tracked, null),
|
||||
error: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message.role === "toolResult") {
|
||||
const tracked =
|
||||
pendingToolCalls.get(message.toolCallId) ?? parseToolArgs(message.toolName, null);
|
||||
pendingToolCalls.delete(message.toolCallId);
|
||||
const result = parseToolResult({ content: message.content });
|
||||
const detail = mapToolDetail(tracked, result);
|
||||
yield {
|
||||
type: "timeline",
|
||||
provider,
|
||||
item: toToolResultTimelineItem({
|
||||
callId: message.toolCallId,
|
||||
name: tracked.toolName,
|
||||
isError: Boolean(message.isError),
|
||||
detail,
|
||||
errorText: extractTextFromToolResult(result) ?? "Tool call failed",
|
||||
}),
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message.role === "bashExecution") {
|
||||
const callId = `pi-bash-${message.timestamp}`;
|
||||
const detail: ToolCallDetail = {
|
||||
type: "shell",
|
||||
command: message.command,
|
||||
output: message.output,
|
||||
exitCode: message.exitCode ?? null,
|
||||
};
|
||||
yield {
|
||||
type: "timeline",
|
||||
provider,
|
||||
item: {
|
||||
type: "tool_call",
|
||||
callId,
|
||||
name: "bash",
|
||||
status: message.cancelled ? "canceled" : "completed",
|
||||
detail,
|
||||
error: null,
|
||||
},
|
||||
};
|
||||
const mapper = new PiHistoryMapper(provider, userEntries, hooks);
|
||||
for (const event of mapper.mapMessages(messages)) {
|
||||
if (event) {
|
||||
yield event;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,11 @@ export interface PiPromptAck {
|
||||
agentInvoked?: boolean;
|
||||
}
|
||||
|
||||
export interface PiPromptAck {
|
||||
requestId?: string;
|
||||
agentInvoked?: boolean;
|
||||
}
|
||||
|
||||
export interface PiTextContent {
|
||||
type: "text";
|
||||
text: string;
|
||||
@@ -53,6 +58,7 @@ export type PiAgentMessage =
|
||||
toolName: string;
|
||||
content: unknown;
|
||||
isError?: boolean;
|
||||
details?: unknown;
|
||||
}
|
||||
| {
|
||||
role: "bashExecution";
|
||||
@@ -88,6 +94,12 @@ export interface PiSessionState {
|
||||
sessionName?: string;
|
||||
messageCount: number;
|
||||
pendingMessageCount: number;
|
||||
contextUsage?: {
|
||||
tokens?: number | null;
|
||||
contextWindow?: number | null;
|
||||
percent?: number | null;
|
||||
};
|
||||
todoPhases?: unknown;
|
||||
}
|
||||
|
||||
export interface PiSessionStats {
|
||||
@@ -111,10 +123,9 @@ export interface PiRpcSlashCommand {
|
||||
description?: string;
|
||||
source: "extension" | "prompt" | "skill";
|
||||
sourceInfo?: Record<string, unknown>;
|
||||
input?: { hint?: string };
|
||||
}
|
||||
|
||||
export type PiCommandsRpcType = "get_commands" | "get_available_commands";
|
||||
|
||||
export type PiRpcCommand =
|
||||
| { id?: string; type: "prompt"; message: string; images?: PiImageContent[] }
|
||||
| { id?: string; type: "compact"; customInstructions?: string }
|
||||
@@ -126,7 +137,7 @@ export type PiRpcCommand =
|
||||
| { id?: string; type: "set_model"; provider: string; modelId: string }
|
||||
| { id?: string; type: "set_thinking_level"; level: PiThinkingLevel }
|
||||
| { id?: string; type: "get_session_stats" }
|
||||
| { id?: string; type: PiCommandsRpcType };
|
||||
| { id?: string; type: string };
|
||||
|
||||
export interface PiRpcResponse {
|
||||
id?: string;
|
||||
@@ -191,4 +202,8 @@ export type PiRuntimeEvent =
|
||||
| {
|
||||
type: "process_exit";
|
||||
error: string;
|
||||
}
|
||||
| {
|
||||
type: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
@@ -13,25 +13,31 @@ export interface PiRuntimeLaunch {
|
||||
cwd: string;
|
||||
argv: string[];
|
||||
env?: Record<string, string>;
|
||||
protocolMode?: "rpc" | "rpc-ui";
|
||||
model?: string;
|
||||
thinkingOptionId?: string;
|
||||
modeId?: string;
|
||||
session?: string;
|
||||
noSession?: boolean;
|
||||
systemPrompt?: string;
|
||||
mcpConfigPath?: string;
|
||||
extensionPaths?: string[];
|
||||
extraArgs?: string[];
|
||||
}
|
||||
|
||||
export interface PiStartSessionInput {
|
||||
cwd: string;
|
||||
env?: Record<string, string>;
|
||||
protocolMode?: "rpc" | "rpc-ui";
|
||||
model?: string;
|
||||
thinkingOptionId?: string;
|
||||
modeId?: string;
|
||||
session?: string;
|
||||
noSession?: boolean;
|
||||
systemPrompt?: string;
|
||||
mcpConfigPath?: string;
|
||||
extensionPaths?: string[];
|
||||
extraArgs?: string[];
|
||||
}
|
||||
|
||||
export interface PiRuntimeSession {
|
||||
@@ -50,6 +56,8 @@ export interface PiRuntimeSession {
|
||||
setThinkingLevel(level: string): Promise<void>;
|
||||
getSessionStats(): Promise<PiSessionStats>;
|
||||
getCommands(): Promise<PiRpcSlashCommand[]>;
|
||||
request(command: { type: string; [key: string]: unknown }, timeoutMs?: number): Promise<unknown>;
|
||||
sendRawFrame(frame: object & { type: string }): void;
|
||||
respondToExtensionUiRequest(
|
||||
id: string,
|
||||
response: { value?: string; confirmed?: boolean; cancelled?: boolean },
|
||||
@@ -73,30 +81,9 @@ export function buildPiLaunch(input: {
|
||||
: input.command;
|
||||
const argv = [...command];
|
||||
|
||||
if (!hasModeRpc(argv)) {
|
||||
argv.push("--mode", "rpc");
|
||||
}
|
||||
if (input.session.model) {
|
||||
argv.push("--model", input.session.model);
|
||||
}
|
||||
if (input.session.thinkingOptionId) {
|
||||
argv.push("--thinking", input.session.thinkingOptionId);
|
||||
}
|
||||
if (input.session.noSession) {
|
||||
argv.push("--no-session");
|
||||
} else if (input.session.session) {
|
||||
argv.push("--session", input.session.session);
|
||||
}
|
||||
const protocolMode = input.session.protocolMode ?? "rpc";
|
||||
const systemPrompt = input.session.systemPrompt?.trim();
|
||||
if (systemPrompt) {
|
||||
argv.push("--append-system-prompt", systemPrompt);
|
||||
}
|
||||
if (input.session.mcpConfigPath) {
|
||||
argv.push("--mcp-config", input.session.mcpConfigPath);
|
||||
}
|
||||
for (const extensionPath of input.session.extensionPaths ?? []) {
|
||||
argv.push("--extension", extensionPath);
|
||||
}
|
||||
appendPiLaunchArgs(argv, input.session, protocolMode, systemPrompt);
|
||||
|
||||
return {
|
||||
cwd: input.session.cwd,
|
||||
@@ -110,20 +97,57 @@ export function buildPiLaunch(input: {
|
||||
: undefined,
|
||||
model: input.session.model,
|
||||
thinkingOptionId: input.session.thinkingOptionId,
|
||||
protocolMode,
|
||||
modeId: input.session.modeId,
|
||||
session: input.session.session,
|
||||
noSession: input.session.noSession,
|
||||
systemPrompt,
|
||||
mcpConfigPath: input.session.mcpConfigPath,
|
||||
extensionPaths: input.session.extensionPaths,
|
||||
extraArgs: input.session.extraArgs,
|
||||
};
|
||||
}
|
||||
|
||||
function hasModeRpc(argv: string[]): boolean {
|
||||
function appendPiLaunchArgs(
|
||||
argv: string[],
|
||||
session: PiStartSessionInput,
|
||||
protocolMode: "rpc" | "rpc-ui",
|
||||
systemPrompt: string | undefined,
|
||||
): void {
|
||||
if (!hasModeFlag(argv)) {
|
||||
argv.push("--mode", protocolMode);
|
||||
}
|
||||
if (session.extraArgs?.length) {
|
||||
argv.push(...session.extraArgs);
|
||||
}
|
||||
if (session.model) {
|
||||
argv.push("--model", session.model);
|
||||
}
|
||||
if (session.thinkingOptionId) {
|
||||
argv.push("--thinking", session.thinkingOptionId);
|
||||
}
|
||||
if (session.noSession) {
|
||||
argv.push("--no-session");
|
||||
} else if (session.session) {
|
||||
argv.push("--session", session.session);
|
||||
}
|
||||
if (systemPrompt) {
|
||||
argv.push("--append-system-prompt", systemPrompt);
|
||||
}
|
||||
if (session.mcpConfigPath) {
|
||||
argv.push("--mcp-config", session.mcpConfigPath);
|
||||
}
|
||||
for (const extensionPath of session.extensionPaths ?? []) {
|
||||
argv.push("--extension", extensionPath);
|
||||
}
|
||||
}
|
||||
|
||||
function hasModeFlag(argv: string[]): boolean {
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
if (argv[i] === "--mode" && argv[i + 1] === "rpc") {
|
||||
if (argv[i] === "--mode") {
|
||||
return true;
|
||||
}
|
||||
if (argv[i] === "--mode=rpc") {
|
||||
if (argv[i]?.startsWith("--mode=")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Dirent } from "node:fs";
|
||||
import { open, readdir, readFile, stat } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -12,9 +13,14 @@ import { createRealpathAwarePathMatcher } from "../../../../utils/path.js";
|
||||
const PI_CONFIG_DIR_NAME = ".pi";
|
||||
const PI_AGENT_DIR_ENV = "PI_CODING_AGENT_DIR";
|
||||
const PI_SESSION_DIR_ENV = "PI_CODING_AGENT_SESSION_DIR";
|
||||
// Import listing intentionally bounds header parsing to this window. Sessions
|
||||
// with unusually large preambles may omit their first-prompt preview.
|
||||
const HEAD_BYTES = 64 * 1024;
|
||||
const TAIL_BYTES = 256 * 1024;
|
||||
const FULL_SCAN_LINE_LIMIT = 2_000;
|
||||
// Rank all discovered files cheaply, then parse only a bounded recent window.
|
||||
const IMPORT_CANDIDATE_OVERSCAN = 40;
|
||||
const IMPORT_CANDIDATE_MIN = 400;
|
||||
|
||||
interface PiSessionDescriptorOptions extends ListImportableSessionsOptions {
|
||||
sessionDir?: string;
|
||||
@@ -53,6 +59,10 @@ interface PiSessionDescriptor {
|
||||
model: string | null;
|
||||
thinkingOptionId: string | null;
|
||||
}
|
||||
interface RankedSessionFile {
|
||||
file: string;
|
||||
mtime: Date;
|
||||
}
|
||||
|
||||
export interface PiImportSessionConfig {
|
||||
model?: string;
|
||||
@@ -66,18 +76,23 @@ export async function listPiImportableSessions(
|
||||
const files = await walkJsonlFiles(sessionsDir);
|
||||
const matchesCwd = options.cwd ? createRealpathAwarePathMatcher(options.cwd) : null;
|
||||
const limit = options.limit ?? 20;
|
||||
const ranked = await rankSessionFilesByMtime(files);
|
||||
const candidateLimit = Math.max(limit * IMPORT_CANDIDATE_OVERSCAN, IMPORT_CANDIDATE_MIN);
|
||||
const sessions: ImportableProviderSession[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const session = await readPiImportableSession(file);
|
||||
for (const entry of ranked.slice(0, candidateLimit)) {
|
||||
const session = await readPiImportableSession(entry.file);
|
||||
if (!session) continue;
|
||||
if (matchesCwd && !matchesCwd(session.cwd)) continue;
|
||||
sessions.push(session);
|
||||
if (sessions.length >= limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return sessions
|
||||
.sort((left, right) => right.lastActivityAt.getTime() - left.lastActivityAt.getTime())
|
||||
.slice(0, limit);
|
||||
return sessions.sort(
|
||||
(left, right) => right.lastActivityAt.getTime() - left.lastActivityAt.getTime(),
|
||||
);
|
||||
}
|
||||
|
||||
export async function readPiImportSessionConfig(filePath: string): Promise<PiImportSessionConfig> {
|
||||
@@ -163,7 +178,7 @@ function resolveConfigPath(value: string, options: { baseDir: string; homeDir: s
|
||||
}
|
||||
|
||||
async function walkJsonlFiles(root: string): Promise<string[]> {
|
||||
let entries: import("node:fs").Dirent[];
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = await readdir(root, { withFileTypes: true });
|
||||
} catch {
|
||||
@@ -182,6 +197,18 @@ async function walkJsonlFiles(root: string): Promise<string[]> {
|
||||
return files.flat();
|
||||
}
|
||||
|
||||
async function rankSessionFilesByMtime(files: string[]): Promise<RankedSessionFile[]> {
|
||||
const ranked = await Promise.all(
|
||||
files.map(async (file) => {
|
||||
const mtime = await readFileMtime(file);
|
||||
return mtime ? { file, mtime } : null;
|
||||
}),
|
||||
);
|
||||
return ranked
|
||||
.filter((entry): entry is RankedSessionFile => entry !== null)
|
||||
.sort((left, right) => right.mtime.getTime() - left.mtime.getTime());
|
||||
}
|
||||
|
||||
async function readPiImportableSession(
|
||||
filePath: string,
|
||||
): Promise<ImportableProviderSession | null> {
|
||||
@@ -201,14 +228,14 @@ async function readPiImportableSession(
|
||||
}
|
||||
|
||||
async function readPiSessionDescriptor(filePath: string): Promise<PiSessionDescriptor | null> {
|
||||
const firstLine = await readFirstLine(filePath);
|
||||
if (!firstLine) return null;
|
||||
const header = parseSessionHeader(firstLine);
|
||||
const headChunk = await readHeadChunk(filePath);
|
||||
if (!headChunk) return null;
|
||||
const header = parseSessionHeader(headChunk.split(/\r?\n/u, 1)[0]?.trim() ?? "");
|
||||
if (!header) return null;
|
||||
|
||||
const tail = await readTail(filePath).catch(() => "");
|
||||
const tailInfo = parseSessionTail(tail);
|
||||
const headInfo = await scanSessionHead(filePath);
|
||||
const headInfo = parseSessionHeadFromChunk(headChunk);
|
||||
const title = tailInfo.title ?? headInfo.title ?? headInfo.firstUserMessage;
|
||||
const model = tailInfo.model ?? headInfo.model;
|
||||
const thinkingOptionId = tailInfo.thinkingOptionId ?? headInfo.thinkingOptionId;
|
||||
@@ -233,21 +260,54 @@ function toPiImportSessionConfig(descriptor: PiSessionDescriptor): PiImportSessi
|
||||
};
|
||||
}
|
||||
|
||||
async function readFirstLine(filePath: string): Promise<string | null> {
|
||||
async function readHeadChunk(filePath: string): Promise<string | null> {
|
||||
const handle = await open(filePath, "r").catch(() => null);
|
||||
if (!handle) return null;
|
||||
try {
|
||||
const buffer = Buffer.alloc(HEAD_BYTES);
|
||||
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
||||
if (bytesRead <= 0) return null;
|
||||
const chunk = buffer.subarray(0, bytesRead).toString("utf8");
|
||||
const newlineIndex = chunk.indexOf("\n");
|
||||
return (newlineIndex === -1 ? chunk : chunk.slice(0, newlineIndex)).trim();
|
||||
return buffer.subarray(0, bytesRead).toString("utf8");
|
||||
} finally {
|
||||
await handle.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function parseSessionHeadFromChunk(chunk: string): PiSessionHead {
|
||||
let title: string | null = null;
|
||||
let firstUserMessage: string | null = null;
|
||||
let model: string | null = null;
|
||||
let thinkingOptionId: string | null = null;
|
||||
let lineCount = 0;
|
||||
|
||||
for (const rawLine of chunk.split(/\r?\n/u)) {
|
||||
lineCount += 1;
|
||||
const entry = parseJsonRecord(rawLine.trim());
|
||||
if (!entry) continue;
|
||||
|
||||
if (entry.type === "session_info") {
|
||||
title = readNonEmptyString(entry.name) ?? title;
|
||||
}
|
||||
model = extractModel(entry) ?? model;
|
||||
thinkingOptionId = extractThinkingOptionId(entry) ?? thinkingOptionId;
|
||||
|
||||
if (!firstUserMessage && entry.type === "message" && isRecord(entry.message)) {
|
||||
if (entry.message.role === "user") {
|
||||
firstUserMessage = extractMessageText(entry.message.content);
|
||||
}
|
||||
}
|
||||
|
||||
if (title && firstUserMessage && model && thinkingOptionId) {
|
||||
break;
|
||||
}
|
||||
if (lineCount >= FULL_SCAN_LINE_LIMIT && firstUserMessage) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { title, firstUserMessage, model, thinkingOptionId };
|
||||
}
|
||||
|
||||
async function readTail(filePath: string): Promise<string> {
|
||||
const fileStats = await stat(filePath);
|
||||
const start = Math.max(0, fileStats.size - TAIL_BYTES);
|
||||
@@ -296,7 +356,6 @@ function parseSessionTail(tail: string): PiSessionTail {
|
||||
if (!title && entry.type === "session_info") {
|
||||
title = readNonEmptyString(entry.name);
|
||||
}
|
||||
|
||||
if (!model) {
|
||||
model = extractModel(entry);
|
||||
}
|
||||
@@ -330,49 +389,6 @@ function parseSessionTail(tail: string): PiSessionTail {
|
||||
};
|
||||
}
|
||||
|
||||
async function scanSessionHead(filePath: string): Promise<PiSessionHead> {
|
||||
let content: string;
|
||||
try {
|
||||
content = await readFile(filePath, "utf8");
|
||||
} catch {
|
||||
return { title: null, firstUserMessage: null, model: null, thinkingOptionId: null };
|
||||
}
|
||||
|
||||
let title: string | null = null;
|
||||
let firstUserMessage: string | null = null;
|
||||
let model: string | null = null;
|
||||
let thinkingOptionId: string | null = null;
|
||||
let lineCount = 0;
|
||||
|
||||
for (const rawLine of content.split(/\r?\n/u)) {
|
||||
lineCount += 1;
|
||||
const entry = parseJsonRecord(rawLine.trim());
|
||||
if (!entry) continue;
|
||||
|
||||
if (entry.type === "session_info") {
|
||||
title = readNonEmptyString(entry.name) ?? title;
|
||||
}
|
||||
|
||||
model = extractModel(entry) ?? model;
|
||||
thinkingOptionId = extractThinkingOptionId(entry) ?? thinkingOptionId;
|
||||
|
||||
if (!firstUserMessage && entry.type === "message" && isRecord(entry.message)) {
|
||||
if (entry.message.role === "user") {
|
||||
firstUserMessage = extractMessageText(entry.message.content);
|
||||
}
|
||||
}
|
||||
|
||||
if (title && firstUserMessage && model && thinkingOptionId) {
|
||||
break;
|
||||
}
|
||||
if (lineCount >= FULL_SCAN_LINE_LIMIT && firstUserMessage) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { title, firstUserMessage, model, thinkingOptionId };
|
||||
}
|
||||
|
||||
function extractModel(entry: Record<string, unknown>): string | null {
|
||||
if (entry.type === "model_change") {
|
||||
return buildModelId(entry.provider, entry.modelId);
|
||||
|
||||
@@ -15,11 +15,42 @@ import type {
|
||||
} from "../rpc-types.js";
|
||||
import { buildPiLaunch } from "../runtime.js";
|
||||
|
||||
type FakePiSubagentSubscriptionLevel = "off" | "progress" | "events";
|
||||
type FakePiSubagentStatus = "pending" | "running" | "completed" | "failed" | "aborted";
|
||||
|
||||
export interface FakePiSubagentSnapshot {
|
||||
id: string;
|
||||
index: number;
|
||||
agent: string;
|
||||
description?: string;
|
||||
status: FakePiSubagentStatus;
|
||||
task?: string;
|
||||
assignment?: string;
|
||||
sessionFile?: string;
|
||||
parentToolCallId?: string;
|
||||
lastUpdate?: number;
|
||||
}
|
||||
|
||||
export interface FakePiSubagentMessagesSelector {
|
||||
subagentId?: string;
|
||||
sessionFile?: string;
|
||||
fromByte?: number;
|
||||
}
|
||||
|
||||
export interface FakePiSubagentMessagesResult {
|
||||
sessionFile: string;
|
||||
fromByte: number;
|
||||
nextByte: number;
|
||||
reset: boolean;
|
||||
messages: PiAgentMessage[];
|
||||
}
|
||||
|
||||
export class FakePi implements PiRuntime {
|
||||
readonly recordedLaunches: PiRuntimeLaunch[] = [];
|
||||
private readonly sessions: FakePiSession[] = [];
|
||||
private readonly command: [string, ...string[]];
|
||||
private readonly queuedCommands: PiRpcSlashCommand[][] = [];
|
||||
private readonly queuedSessionSetups: Array<(session: FakePiSession) => void> = [];
|
||||
|
||||
constructor(command: [string, ...string[]] = ["pi"]) {
|
||||
this.command = command;
|
||||
@@ -33,6 +64,7 @@ export class FakePi implements PiRuntime {
|
||||
this.recordedLaunches.push(launch);
|
||||
const session = new FakePiSession(launch);
|
||||
session.commands = this.queuedCommands.shift() ?? [];
|
||||
this.queuedSessionSetups.shift()?.(session);
|
||||
this.sessions.push(session);
|
||||
return session;
|
||||
}
|
||||
@@ -41,6 +73,10 @@ export class FakePi implements PiRuntime {
|
||||
this.queuedCommands.push(commands);
|
||||
}
|
||||
|
||||
queueSessionSetup(setup: (session: FakePiSession) => void): void {
|
||||
this.queuedSessionSetups.push(setup);
|
||||
}
|
||||
|
||||
latestSession(): FakePiSession {
|
||||
const session = this.sessions.at(-1);
|
||||
if (!session) {
|
||||
@@ -54,9 +90,14 @@ export class FakePiSession implements PiRuntimeSession {
|
||||
readonly prompts: Array<{ message: string; imageCount: number }> = [];
|
||||
readonly compactRequests: Array<{ customInstructions?: string }> = [];
|
||||
readonly setAutoCompactionRequests: boolean[] = [];
|
||||
readonly subagentSubscriptionRequests: FakePiSubagentSubscriptionLevel[] = [];
|
||||
readonly subagentMessageRequests: FakePiSubagentMessagesSelector[] = [];
|
||||
readonly setModelRequests: Array<{ provider: string; modelId: string }> = [];
|
||||
readonly setThinkingLevelRequests: string[] = [];
|
||||
readonly treeNavigationRequests: string[] = [];
|
||||
readonly handoffRequests: Array<{ customInstructions?: string }> = [];
|
||||
readonly sessionNameRequests: string[] = [];
|
||||
readonly rawFrames: Array<object & { type: string }> = [];
|
||||
capturedUserEntries: Array<{ id: string; parentId: string | null; text: string }> = [];
|
||||
abortRequested = false;
|
||||
readonly canceledExtensionUiRequests: string[] = [];
|
||||
@@ -72,13 +113,19 @@ export class FakePiSession implements PiRuntimeSession {
|
||||
cost: 0,
|
||||
};
|
||||
commands: PiRpcSlashCommand[] = [];
|
||||
subagents: FakePiSubagentSnapshot[] = [];
|
||||
subagentSubscriptionError: Error | null = null;
|
||||
setSessionNameError: Error | null = null;
|
||||
compactError: Error | null = null;
|
||||
emitCompactEnd = true;
|
||||
getStateError: Error | null = null;
|
||||
promptAck: PiPromptAck = {};
|
||||
branchResponse: { text?: string; cancelled?: boolean } = { text: "" };
|
||||
readonly branchRequests: string[] = [];
|
||||
state: PiSessionState;
|
||||
|
||||
private readonly subscribers = new Set<(event: PiRuntimeEvent) => void>();
|
||||
private readonly subagentMessageResults = new Map<string, FakePiSubagentMessagesResult[]>();
|
||||
private nextHeldPrompt: { promise: Promise<void>; reject: (error: Error) => void } | null = null;
|
||||
private activeHeldPrompt: { promise: Promise<void>; reject: (error: Error) => void } | null =
|
||||
null;
|
||||
@@ -200,10 +247,83 @@ export class FakePiSession implements PiRuntimeSession {
|
||||
return this.stats;
|
||||
}
|
||||
|
||||
async setSubagentSubscription(level: FakePiSubagentSubscriptionLevel): Promise<void> {
|
||||
this.subagentSubscriptionRequests.push(level);
|
||||
if (this.subagentSubscriptionError) {
|
||||
throw this.subagentSubscriptionError;
|
||||
}
|
||||
}
|
||||
|
||||
async getSubagents(): Promise<FakePiSubagentSnapshot[]> {
|
||||
return this.subagents;
|
||||
}
|
||||
|
||||
async getSubagentMessages(
|
||||
selector: FakePiSubagentMessagesSelector,
|
||||
): Promise<FakePiSubagentMessagesResult> {
|
||||
this.subagentMessageRequests.push(selector);
|
||||
const key = selector.sessionFile ?? selector.subagentId;
|
||||
if (!key) {
|
||||
throw new Error("FakePi getSubagentMessages requires a selector");
|
||||
}
|
||||
const results = this.subagentMessageResults.get(key);
|
||||
const result = results?.shift();
|
||||
if (!result) {
|
||||
throw new Error(`FakePi has no subagent messages queued for ${key}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
queueSubagentMessages(result: FakePiSubagentMessagesResult): void {
|
||||
const results = this.subagentMessageResults.get(result.sessionFile) ?? [];
|
||||
results.push(result);
|
||||
this.subagentMessageResults.set(result.sessionFile, results);
|
||||
}
|
||||
|
||||
async getCommands(): Promise<PiRpcSlashCommand[]> {
|
||||
return this.commands;
|
||||
}
|
||||
|
||||
sendRawFrame(frame: object & { type: string }): void {
|
||||
this.rawFrames.push(frame);
|
||||
}
|
||||
|
||||
async request(command: { type: string; [key: string]: unknown }): Promise<unknown> {
|
||||
switch (command.type) {
|
||||
case "set_subagent_subscription":
|
||||
await this.setSubagentSubscription(command.level as FakePiSubagentSubscriptionLevel);
|
||||
return {};
|
||||
case "get_subagents":
|
||||
return { subagents: await this.getSubagents() };
|
||||
case "get_subagent_messages":
|
||||
return await this.getSubagentMessages({
|
||||
...(typeof command.subagentId === "string" ? { subagentId: command.subagentId } : {}),
|
||||
...(typeof command.sessionFile === "string" ? { sessionFile: command.sessionFile } : {}),
|
||||
...(typeof command.fromByte === "number" ? { fromByte: command.fromByte } : {}),
|
||||
});
|
||||
case "branch": {
|
||||
const entryId = typeof command.entryId === "string" ? command.entryId : "";
|
||||
this.branchRequests.push(entryId);
|
||||
return this.branchResponse;
|
||||
}
|
||||
case "handoff":
|
||||
this.handoffRequests.push(
|
||||
typeof command.customInstructions === "string"
|
||||
? { customInstructions: command.customInstructions }
|
||||
: {},
|
||||
);
|
||||
return {};
|
||||
case "set_session_name":
|
||||
this.sessionNameRequests.push(typeof command.name === "string" ? command.name : "");
|
||||
if (this.setSessionNameError) {
|
||||
throw this.setSessionNameError;
|
||||
}
|
||||
return {};
|
||||
default:
|
||||
throw new Error(`FakePi request does not implement ${command.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
respondToExtensionUiRequest(
|
||||
id: string,
|
||||
response: { value?: string; confirmed?: boolean; cancelled?: boolean },
|
||||
|
||||
@@ -37,6 +37,94 @@ describe("Pi tool call mapper", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("preserves ordinary writes as write details", () => {
|
||||
const toolCall = parseToolArgs("write", {
|
||||
path: "notes.txt",
|
||||
content: "unchanged\n",
|
||||
});
|
||||
|
||||
expect(mapToolDetail(toolCall, parseToolResult({ text: "Wrote notes.txt" }))).toEqual({
|
||||
type: "write",
|
||||
filePath: "notes.txt",
|
||||
content: "unchanged\n",
|
||||
});
|
||||
});
|
||||
|
||||
test("maps executed xdev writes to their wrapped tool detail", () => {
|
||||
const toolCall = parseToolArgs("write", {
|
||||
path: "xd://browser",
|
||||
content: "{}",
|
||||
});
|
||||
const result = parseToolResult({
|
||||
content: [{ type: "text", text: "Opened Example Domain" }],
|
||||
details: {
|
||||
xdev: {
|
||||
tool: "browser",
|
||||
mode: "execute",
|
||||
args: { action: "open", url: "https://example.com" },
|
||||
inner: { title: "Example Domain" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(mapToolDetail(toolCall, result)).toEqual({
|
||||
type: "unknown",
|
||||
input: { action: "open", url: "https://example.com" },
|
||||
output: {
|
||||
content: [{ type: "text", text: "Opened Example Domain" }],
|
||||
details: { title: "Example Domain" },
|
||||
},
|
||||
});
|
||||
expect(resolveToolCallName(toolCall, result)).toBe("browser");
|
||||
});
|
||||
|
||||
test("does not treat xdev help metadata as an executed inner tool", () => {
|
||||
const toolCall = parseToolArgs("write", {
|
||||
path: "xd://browser",
|
||||
content: "",
|
||||
});
|
||||
const result = parseToolResult({
|
||||
details: {
|
||||
xdev: {
|
||||
tool: "browser",
|
||||
mode: "help",
|
||||
inner: "Browser help",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(mapToolDetail(toolCall, result)).toEqual({
|
||||
type: "unknown",
|
||||
input: { path: "xd://browser", content: "" },
|
||||
output: result,
|
||||
});
|
||||
expect(resolveToolCallName(toolCall, result)).toBe("write");
|
||||
});
|
||||
|
||||
test("does not treat malformed xdev metadata as an executed inner tool", () => {
|
||||
const toolCall = parseToolArgs("write", {
|
||||
path: "xd://browser",
|
||||
content: "{}",
|
||||
});
|
||||
const result = parseToolResult({
|
||||
details: {
|
||||
xdev: {
|
||||
tool: "",
|
||||
mode: "execute",
|
||||
args: { action: "open" },
|
||||
inner: { title: "must not surface" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(mapToolDetail(toolCall, result)).toEqual({
|
||||
type: "unknown",
|
||||
input: { path: "xd://browser", content: "{}" },
|
||||
output: result,
|
||||
});
|
||||
expect(resolveToolCallName(toolCall, result)).toBe("write");
|
||||
});
|
||||
|
||||
test("preserves unknown tool input and parsed output", () => {
|
||||
const toolCall = parseToolArgs("custom_tool", { value: 42 });
|
||||
const result = parseToolResult({ text: "custom result" });
|
||||
|
||||
@@ -63,6 +63,7 @@ interface PiToolResultDetails {
|
||||
server?: string;
|
||||
tool?: string;
|
||||
mcpResult?: unknown;
|
||||
xdev?: unknown;
|
||||
}
|
||||
|
||||
interface PiToolResultTextContent {
|
||||
@@ -162,6 +163,13 @@ const PiToolResultDetailsSchema = z
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const XdevExecuteDetailsSchema = z.object({
|
||||
tool: z.string().trim().min(1),
|
||||
mode: z.literal("execute"),
|
||||
args: z.unknown().optional(),
|
||||
inner: z.unknown().optional(),
|
||||
});
|
||||
|
||||
const PiToolResultObjectSchema = z
|
||||
.object({
|
||||
output: z.string().optional(),
|
||||
@@ -293,6 +301,13 @@ function stripMcpProxyPrefix(toolName: string, serverName: string): string {
|
||||
}
|
||||
|
||||
export function resolveToolCallName(toolCall: PiTrackedToolCall, result?: PiToolResult): string {
|
||||
if (toolCall.kind === "write" && result && typeof result !== "string") {
|
||||
const xdev = XdevExecuteDetailsSchema.safeParse(result.details?.xdev);
|
||||
if (xdev.success) {
|
||||
return xdev.data.tool;
|
||||
}
|
||||
}
|
||||
|
||||
if (toolCall.toolName !== "mcp") {
|
||||
return toolCall.toolName;
|
||||
}
|
||||
@@ -357,11 +372,7 @@ export function mapToolDetail(toolCall: PiTrackedToolCall, result?: PiToolResult
|
||||
};
|
||||
}
|
||||
case "write":
|
||||
return {
|
||||
type: "write",
|
||||
filePath: toolCall.args.path,
|
||||
content: toolCall.args.content,
|
||||
};
|
||||
return mapWriteToolDetail(toolCall.args, parsedResult);
|
||||
case "find":
|
||||
return mapFindToolDetail(toolCall.args, parsedResult);
|
||||
case "grep":
|
||||
@@ -377,6 +388,34 @@ export function mapToolDetail(toolCall: PiTrackedToolCall, result?: PiToolResult
|
||||
}
|
||||
}
|
||||
|
||||
function mapWriteToolDetail(args: WriteToolInput, result: PiToolResult): ToolCallDetail {
|
||||
if (result && typeof result !== "string" && result.details && "xdev" in result.details) {
|
||||
const xdev = XdevExecuteDetailsSchema.safeParse(result.details.xdev);
|
||||
if (xdev.success) {
|
||||
return {
|
||||
type: "unknown",
|
||||
input: xdev.data.args ?? null,
|
||||
output: {
|
||||
...result,
|
||||
details: xdev.data.inner ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "unknown",
|
||||
input: args,
|
||||
output: result,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "write",
|
||||
filePath: args.path,
|
||||
content: args.content,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveToolCallOutput(result: PiToolResult): ToolCallOutputSummary {
|
||||
if (typeof result === "string") {
|
||||
return { output: result };
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
// These SDK internals are reachable through @modelcontextprotocol/sdk's wildcard export,
|
||||
// not a curated public subpath. Keep that package pinned exactly and re-verify these
|
||||
// paths on every SDK bump so native host-tool schemas stay byte-compatible.
|
||||
import {
|
||||
normalizeObjectSchema,
|
||||
type AnySchema,
|
||||
type ZodRawShapeCompat,
|
||||
} from "@modelcontextprotocol/sdk/server/zod-compat.js";
|
||||
import { toJsonSchemaCompat } from "@modelcontextprotocol/sdk/server/zod-json-schema-compat.js";
|
||||
|
||||
import type { PaseoToolDefinition, PaseoToolResult } from "./types.js";
|
||||
|
||||
const EMPTY_OBJECT_JSON_SCHEMA: Record<string, unknown> = {
|
||||
type: "object",
|
||||
properties: {},
|
||||
};
|
||||
|
||||
function formatStructuredContentForModel(structuredContent: unknown): string {
|
||||
if (
|
||||
!structuredContent ||
|
||||
typeof structuredContent !== "object" ||
|
||||
Array.isArray(structuredContent)
|
||||
) {
|
||||
return JSON.stringify(structuredContent, null, 2);
|
||||
}
|
||||
|
||||
const record = structuredContent as Record<string, unknown>;
|
||||
const summary: string[] = [];
|
||||
for (const [key, value] of Object.entries(record)) {
|
||||
if (!Array.isArray(value)) {
|
||||
continue;
|
||||
}
|
||||
summary.push(`${key}_count=${value.length}`);
|
||||
const ids = value
|
||||
.map((item) =>
|
||||
item && typeof item === "object" && !Array.isArray(item)
|
||||
? (item as Record<string, unknown>).id
|
||||
: null,
|
||||
)
|
||||
.filter((id): id is string => typeof id === "string" && id.length > 0);
|
||||
if (ids.length === value.length && ids.length > 0) {
|
||||
summary.push(`${key}_ids=${ids.join(",")}`);
|
||||
}
|
||||
}
|
||||
|
||||
const json = JSON.stringify(structuredContent, null, 2);
|
||||
return summary.length > 0 ? `${summary.join("\n")}\n\n${json}` : json;
|
||||
}
|
||||
|
||||
export function addModelVisibleStructuredContent(result: PaseoToolResult): PaseoToolResult {
|
||||
if (result.structuredContent === undefined || result.content.length > 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
return {
|
||||
...result,
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: formatStructuredContentForModel(result.structuredContent),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function serializePaseoToolInputParameters(
|
||||
tool: PaseoToolDefinition,
|
||||
): Record<string, unknown> {
|
||||
const schema = normalizeObjectSchema(
|
||||
tool.inputSchema as AnySchema | ZodRawShapeCompat | undefined,
|
||||
);
|
||||
return schema
|
||||
? toJsonSchemaCompat(schema, {
|
||||
strictUnions: true,
|
||||
pipeStrategy: "input",
|
||||
})
|
||||
: { ...EMPTY_OBJECT_JSON_SCHEMA };
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { z } from "zod";
|
||||
|
||||
export interface PaseoToolExecutionContext {
|
||||
signal?: AbortSignal;
|
||||
sendUpdate?: (update: PaseoToolResult) => void;
|
||||
}
|
||||
|
||||
export interface PaseoToolResult {
|
||||
|
||||
@@ -57,6 +57,14 @@ export const agentConfigs = {
|
||||
provider: "pi",
|
||||
thinkingOptionId: "medium",
|
||||
},
|
||||
omp: {
|
||||
provider: "omp",
|
||||
thinkingOptionId: "medium",
|
||||
modes: {
|
||||
full: "full", // launches omp with yolo approval mode
|
||||
ask: "ask", // launches omp with always-ask approval mode
|
||||
},
|
||||
},
|
||||
} as const satisfies Record<string, AgentTestConfig>;
|
||||
|
||||
export type AgentProvider = keyof typeof agentConfigs;
|
||||
@@ -92,4 +100,4 @@ export function getAskModeConfig(provider: AgentProvider) {
|
||||
/**
|
||||
* Helper to run a test for each provider.
|
||||
*/
|
||||
export const allProviders: AgentProvider[] = ["claude", "codex", "opencode", "pi"];
|
||||
export const allProviders: AgentProvider[] = ["claude", "codex", "opencode", "pi", "omp"];
|
||||
|
||||
@@ -0,0 +1,492 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import pino from "pino";
|
||||
import { afterEach, beforeAll, describe, expect, test } from "vitest";
|
||||
|
||||
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
|
||||
import {
|
||||
MIN_SUPPORTED_OMP_VERSION,
|
||||
formatOmpVersionSupport,
|
||||
} from "../agent/providers/omp/agent.js";
|
||||
import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||
import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||
import { createRealProviderClients, getRealProviderConfig } from "./real-provider-test-config.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const TIMEOUT_MS = 300_000;
|
||||
const MODEL = getRealProviderConfig("omp").model;
|
||||
const SHELL_TOOL_PATTERN = /(?:bash|shell|%!)/i;
|
||||
const roots = new Set<string>();
|
||||
|
||||
interface Harness {
|
||||
daemon: TestPaseoDaemon;
|
||||
client: DaemonClient;
|
||||
cwd: string;
|
||||
paseoHomeRoot: string;
|
||||
staticDir: string;
|
||||
}
|
||||
|
||||
async function preflight(): Promise<void> {
|
||||
const command = process.env.OMP_COMMAND?.trim() || "omp";
|
||||
let stdout: string;
|
||||
try {
|
||||
({ stdout } = await execFileAsync(command, ["--version"], {
|
||||
timeout: 10_000,
|
||||
}));
|
||||
} catch (error) {
|
||||
throw new Error(`OMP real E2E gate requires an executable OMP binary (${command})`, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
const support = formatOmpVersionSupport(stdout);
|
||||
if (!support.includes("supported")) {
|
||||
throw new Error(`OMP real E2E gate requires OMP >= ${MIN_SUPPORTED_OMP_VERSION}: ${support}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function createHarness(): Promise<Harness> {
|
||||
const cwd = mkdtempSync(path.join(tmpdir(), "paseo-real-omp-"));
|
||||
const paseoHomeRoot = mkdtempSync(path.join(tmpdir(), "paseo-real-omp-home-"));
|
||||
const staticDir = mkdtempSync(path.join(tmpdir(), "paseo-real-omp-static-"));
|
||||
for (const root of [cwd, paseoHomeRoot, staticDir]) roots.add(root);
|
||||
const logger = pino({ level: process.env.OMP_E2E_LOG_LEVEL ?? "silent" });
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
agentClients: createRealProviderClients(["omp"], logger),
|
||||
providerOverrides: { omp: { enabled: true } },
|
||||
logger,
|
||||
paseoHomeRoot,
|
||||
staticDir,
|
||||
cleanup: false,
|
||||
});
|
||||
const client = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${daemon.port}/ws`,
|
||||
appVersion: "0.1.45",
|
||||
});
|
||||
await client.connect();
|
||||
await client.fetchAgents({
|
||||
subscribe: { subscriptionId: `omp-real-${randomUUID()}` },
|
||||
});
|
||||
return { daemon, client, cwd, paseoHomeRoot, staticDir };
|
||||
}
|
||||
|
||||
async function closeHarness(harness: Harness): Promise<void> {
|
||||
await harness.client.close().catch(() => undefined);
|
||||
await harness.daemon.close().catch(() => undefined);
|
||||
for (const root of [harness.cwd, harness.paseoHomeRoot, harness.staticDir]) {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
roots.delete(root);
|
||||
}
|
||||
}
|
||||
|
||||
async function timeline(client: DaemonClient, agentId: string): Promise<AgentTimelineItem[]> {
|
||||
const result = await client.fetchAgentTimeline(agentId, {
|
||||
direction: "tail",
|
||||
limit: 0,
|
||||
projection: "canonical",
|
||||
});
|
||||
return result.entries.map((entry) => entry.item);
|
||||
}
|
||||
|
||||
function latestTools(items: AgentTimelineItem[]) {
|
||||
const latest = new Map<string, Extract<AgentTimelineItem, { type: "tool_call" }>>();
|
||||
for (const item of items) if (item.type === "tool_call") latest.set(item.callId, item);
|
||||
return [...latest.values()];
|
||||
}
|
||||
|
||||
function toolResult(item: Extract<AgentTimelineItem, { type: "tool_call" }>): string {
|
||||
if (item.detail.type === "shell") return item.detail.output ?? "";
|
||||
if (item.detail.type === "fetch") return item.detail.result ?? "";
|
||||
if (item.detail.type === "plain_text") return item.detail.text ?? "";
|
||||
return "";
|
||||
}
|
||||
|
||||
function completedTools(items: AgentTimelineItem[], pattern: RegExp) {
|
||||
return latestTools(items).filter(
|
||||
(item) => pattern.test(item.name) && item.status === "completed",
|
||||
);
|
||||
}
|
||||
|
||||
async function createAgent(harness: Harness, title: string, modeId = "full") {
|
||||
return await harness.client.createAgent({
|
||||
cwd: harness.cwd,
|
||||
title,
|
||||
provider: "omp",
|
||||
model: MODEL,
|
||||
modeId,
|
||||
});
|
||||
}
|
||||
|
||||
async function promptAndFinish(harness: Harness, agentId: string, prompt: string) {
|
||||
await harness.client.sendMessage(agentId, prompt);
|
||||
const finish = await harness.client.waitForFinish(agentId, TIMEOUT_MS);
|
||||
expect(finish.status).toBe("idle");
|
||||
const items = await timeline(harness.client, agentId);
|
||||
expect(items.some((item) => item.type === "error")).toBe(false);
|
||||
expect(latestTools(items).every((item) => item.status !== "running")).toBe(true);
|
||||
return items;
|
||||
}
|
||||
|
||||
async function waitForProviderSubagent(
|
||||
client: DaemonClient,
|
||||
parentAgentId: string,
|
||||
status: "running" | "completed",
|
||||
): Promise<Awaited<ReturnType<DaemonClient["listProviderSubagents"]>>["subagents"][number]> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const finish = (
|
||||
subagent: Awaited<ReturnType<DaemonClient["listProviderSubagents"]>>["subagents"][number],
|
||||
): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
unsubscribe();
|
||||
resolve(subagent);
|
||||
};
|
||||
const unsubscribe = client.on("agent.provider_subagents.update", (message) => {
|
||||
if (
|
||||
message.payload.kind === "upsert" &&
|
||||
message.payload.subagent.parentAgentId === parentAgentId &&
|
||||
message.payload.subagent.status === status
|
||||
) {
|
||||
finish(message.payload.subagent);
|
||||
}
|
||||
});
|
||||
const timeout = setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
unsubscribe();
|
||||
reject(new Error(`Timed out waiting for OMP provider subagent status ${status}`));
|
||||
}, TIMEOUT_MS);
|
||||
void client
|
||||
.listProviderSubagents(parentAgentId)
|
||||
.then(({ subagents }) => {
|
||||
const match = subagents.find((subagent) => subagent.status === status);
|
||||
if (match) finish(match);
|
||||
return undefined;
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (settled) return undefined;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
unsubscribe();
|
||||
reject(error);
|
||||
return undefined;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
beforeAll(preflight, 15_000);
|
||||
afterEach(() => {
|
||||
for (const root of roots) rmSync(root, { recursive: true, force: true });
|
||||
roots.clear();
|
||||
});
|
||||
|
||||
describe("daemon E2E (real OMP)", () => {
|
||||
test(
|
||||
"prompt, native tool, and resumed follow-up",
|
||||
async () => {
|
||||
const harness = await createHarness();
|
||||
try {
|
||||
const agent = await createAgent(harness, "prompt-tool-resume");
|
||||
await promptAndFinish(
|
||||
harness,
|
||||
agent.id,
|
||||
"Run exactly this bash command: printf 'OMP_FIRST\\n'",
|
||||
);
|
||||
const items = await promptAndFinish(harness, agent.id, "Reply exactly OMP_RESUMED");
|
||||
const outputs = completedTools(items, SHELL_TOOL_PATTERN).map(toolResult);
|
||||
expect(outputs).toEqual(expect.arrayContaining([expect.stringContaining("OMP_FIRST")]));
|
||||
const assistantText = items
|
||||
.filter((item) => item.type === "assistant_message")
|
||||
.map((item) => item.text);
|
||||
expect(assistantText).toEqual(
|
||||
expect.arrayContaining([expect.stringContaining("OMP_RESUMED")]),
|
||||
);
|
||||
} finally {
|
||||
await closeHarness(harness);
|
||||
}
|
||||
},
|
||||
TIMEOUT_MS,
|
||||
);
|
||||
|
||||
test(
|
||||
"ask mode preserves multiline approval details",
|
||||
async () => {
|
||||
const harness = await createHarness();
|
||||
try {
|
||||
const agent = await createAgent(harness, "multiline-approval", "ask");
|
||||
await harness.client.sendMessage(
|
||||
agent.id,
|
||||
"Run exactly this bash command: printf 'line one\\nline two\\n'",
|
||||
);
|
||||
const pending = await harness.client.waitForAgentUpsert(
|
||||
agent.id,
|
||||
(snapshot) => snapshot.pendingPermissions.length > 0,
|
||||
TIMEOUT_MS,
|
||||
);
|
||||
const permission = pending.pendingPermissions[0];
|
||||
if (permission.detail?.type !== "shell") {
|
||||
throw new Error("Expected OMP shell permission detail");
|
||||
}
|
||||
expect(permission.detail.command).toContain("line one");
|
||||
expect(permission.detail.command).toContain("line two");
|
||||
await harness.client.respondToPermission(agent.id, permission.id, {
|
||||
behavior: "allow",
|
||||
});
|
||||
expect((await harness.client.waitForFinish(agent.id, TIMEOUT_MS)).status).toBe("idle");
|
||||
const output = completedTools(
|
||||
await timeline(harness.client, agent.id),
|
||||
SHELL_TOOL_PATTERN,
|
||||
).map(toolResult);
|
||||
expect(output.join("\n")).toContain("line one");
|
||||
expect(output.join("\n")).toContain("line two");
|
||||
} finally {
|
||||
await closeHarness(harness);
|
||||
}
|
||||
},
|
||||
TIMEOUT_MS,
|
||||
);
|
||||
|
||||
test(
|
||||
"active steer and interrupt",
|
||||
async () => {
|
||||
const harness = await createHarness();
|
||||
try {
|
||||
const agent = await createAgent(harness, "interrupt-steer");
|
||||
await harness.client.sendMessage(
|
||||
agent.id,
|
||||
"Run exactly this bash command: sleep 30; printf 'SHOULD_NOT_FINISH\\n'",
|
||||
);
|
||||
await harness.client.waitForAgentUpsert(
|
||||
agent.id,
|
||||
(snapshot) => snapshot.status === "running",
|
||||
TIMEOUT_MS,
|
||||
);
|
||||
await harness.client.sendMessage(
|
||||
agent.id,
|
||||
"/steer stop sleeping and run exactly this bash command: printf 'OMP_ACTIVE_STEER\\n'",
|
||||
);
|
||||
expect((await harness.client.waitForFinish(agent.id, TIMEOUT_MS)).status).toBe("idle");
|
||||
let items = await timeline(harness.client, agent.id);
|
||||
expect(completedTools(items, SHELL_TOOL_PATTERN).map(toolResult).join("\n")).toContain(
|
||||
"OMP_ACTIVE_STEER",
|
||||
);
|
||||
|
||||
await harness.client.sendMessage(
|
||||
agent.id,
|
||||
"Run exactly this bash command: sleep 30; printf 'LATE\\n'",
|
||||
);
|
||||
await harness.client.waitForAgentUpsert(
|
||||
agent.id,
|
||||
(snapshot) => snapshot.status === "running",
|
||||
TIMEOUT_MS,
|
||||
);
|
||||
await harness.client.cancelAgent(agent.id);
|
||||
expect((await harness.client.waitForFinish(agent.id, TIMEOUT_MS)).status).not.toBe(
|
||||
"running",
|
||||
);
|
||||
items = await timeline(harness.client, agent.id);
|
||||
expect(
|
||||
completedTools(items, SHELL_TOOL_PATTERN).some((call) =>
|
||||
toolResult(call).includes("LATE"),
|
||||
),
|
||||
).toBe(false);
|
||||
} finally {
|
||||
await closeHarness(harness);
|
||||
}
|
||||
},
|
||||
TIMEOUT_MS,
|
||||
);
|
||||
|
||||
test(
|
||||
"todo lifecycle",
|
||||
async () => {
|
||||
const harness = await createHarness();
|
||||
try {
|
||||
const agent = await createAgent(harness, "todo-lifecycle");
|
||||
const items = await promptAndFinish(
|
||||
harness,
|
||||
agent.id,
|
||||
"Create exactly these two todos in order: verify first child, verify second child. Complete both, without spawning a task.",
|
||||
);
|
||||
const snapshots = items.filter((item) => item.type === "todo").map((item) => item.items);
|
||||
const hasFreshPair = (snapshot: { completed: boolean }[]): boolean =>
|
||||
snapshot.length === 2 && snapshot.every((entry) => !entry.completed);
|
||||
expect(snapshots.some(hasFreshPair)).toBe(true);
|
||||
expect(snapshots.at(-1)).toEqual([
|
||||
{ text: "verify first child", completed: true },
|
||||
{ text: "verify second child", completed: true },
|
||||
]);
|
||||
} finally {
|
||||
await closeHarness(harness);
|
||||
}
|
||||
},
|
||||
TIMEOUT_MS,
|
||||
);
|
||||
|
||||
test(
|
||||
"subagent lifecycle streams descriptors and timeline on the parent",
|
||||
async () => {
|
||||
const harness = await createHarness();
|
||||
try {
|
||||
const parent = await createAgent(harness, "subagent-track");
|
||||
const run = harness.client.sendMessage(
|
||||
parent.id,
|
||||
"Use task to create exactly one child named TrackChild. It must run exactly this bash command: printf 'TRACK_CHILD_OK\\n'. Wait for it.",
|
||||
);
|
||||
const started = await waitForProviderSubagent(harness.client, parent.id, "running");
|
||||
expect(started.parentAgentId).toBe(parent.id);
|
||||
await run;
|
||||
expect((await harness.client.waitForFinish(parent.id, TIMEOUT_MS)).status).toBe("idle");
|
||||
const { subagents } = await harness.client.listProviderSubagents(parent.id);
|
||||
expect(subagents).toHaveLength(1);
|
||||
expect(subagents[0].status).toBe("completed");
|
||||
const childTimeline = await harness.client.fetchProviderSubagentTimeline(
|
||||
parent.id,
|
||||
subagents[0].id,
|
||||
{
|
||||
direction: "tail",
|
||||
limit: 0,
|
||||
},
|
||||
);
|
||||
expect(childTimeline.rows.length).toBeGreaterThan(0);
|
||||
const childToolOutput = childTimeline.rows
|
||||
.map((row) => row.item)
|
||||
.filter((item) => item.type === "tool_call")
|
||||
.map(toolResult)
|
||||
.join("\n");
|
||||
expect(childToolOutput).toContain("TRACK_CHILD_OK");
|
||||
} finally {
|
||||
await closeHarness(harness);
|
||||
}
|
||||
},
|
||||
TIMEOUT_MS,
|
||||
);
|
||||
|
||||
test(
|
||||
"a completed child imports as a normal promptable OMP agent",
|
||||
async () => {
|
||||
const harness = await createHarness();
|
||||
try {
|
||||
const parent = await createAgent(harness, "child-import");
|
||||
await promptAndFinish(
|
||||
harness,
|
||||
parent.id,
|
||||
"Use task to create exactly one child named ImportChild. It must run exactly this bash command: printf 'IMPORT_CHILD_DONE\\n'. Wait for it.",
|
||||
);
|
||||
const completedChild = await waitForProviderSubagent(
|
||||
harness.client,
|
||||
parent.id,
|
||||
"completed",
|
||||
);
|
||||
expect(completedChild.parentAgentId).toBe(parent.id);
|
||||
const recent = await harness.client.fetchRecentProviderSessions({
|
||||
cwd: harness.cwd,
|
||||
providers: ["omp"],
|
||||
});
|
||||
const childSession = recent.entries.find(
|
||||
(entry) =>
|
||||
entry.providerId === "omp" &&
|
||||
(path.basename(entry.providerHandleId) === `${completedChild.id}.jsonl` ||
|
||||
entry.title?.includes("ImportChild") ||
|
||||
entry.firstPromptPreview?.includes("ImportChild") ||
|
||||
entry.lastPromptPreview?.includes("IMPORT_CHILD_DONE")),
|
||||
);
|
||||
if (!childSession) {
|
||||
throw new Error("Expected completed OMP child session to be importable");
|
||||
}
|
||||
const imported = await harness.client.importAgent({
|
||||
provider: "omp",
|
||||
sessionId: childSession.providerHandleId,
|
||||
cwd: harness.cwd,
|
||||
});
|
||||
expect(imported.id).not.toBe(parent.id);
|
||||
const items = await promptAndFinish(
|
||||
harness,
|
||||
imported.id,
|
||||
"Run exactly this bash command: printf 'IMPORTED_CHILD_RESUMED\\n'",
|
||||
);
|
||||
expect(completedTools(items, SHELL_TOOL_PATTERN).map(toolResult).join("\n")).toContain(
|
||||
"IMPORTED_CHILD_RESUMED",
|
||||
);
|
||||
} finally {
|
||||
await closeHarness(harness);
|
||||
}
|
||||
},
|
||||
TIMEOUT_MS,
|
||||
);
|
||||
|
||||
test(
|
||||
"native Paseo host tools execute through RPC-UI",
|
||||
async () => {
|
||||
const harness = await createHarness();
|
||||
try {
|
||||
const agent = await createAgent(harness, "native-host-tool");
|
||||
const items = await promptAndFinish(
|
||||
harness,
|
||||
agent.id,
|
||||
"Use the Paseo host tool list_agents exactly once. Find the agent titled native-host-tool in the result, then reply exactly HOST_TOOL_OK:<its id>.",
|
||||
);
|
||||
const hostTools = completedTools(items, /^list_agents$/i);
|
||||
expect(hostTools).toHaveLength(1);
|
||||
expect(
|
||||
items
|
||||
.filter((item) => item.type === "assistant_message")
|
||||
.map((item) => item.text.trim())
|
||||
.join("")
|
||||
.replace(/\s/g, ""),
|
||||
).toContain(`HOST_TOOL_OK:${agent.id}`);
|
||||
} finally {
|
||||
await closeHarness(harness);
|
||||
}
|
||||
},
|
||||
TIMEOUT_MS,
|
||||
);
|
||||
|
||||
test(
|
||||
"native branch rewinds conversation history and keeps the session usable",
|
||||
async () => {
|
||||
const harness = await createHarness();
|
||||
try {
|
||||
const agent = await createAgent(harness, "native-branch");
|
||||
await promptAndFinish(harness, agent.id, "Reply exactly OMP_BRANCH_FIRST");
|
||||
await promptAndFinish(harness, agent.id, "Reply exactly OMP_BRANCH_REMOVED");
|
||||
const before = await timeline(harness.client, agent.id);
|
||||
const firstMessage = before.find(
|
||||
(item) => item.type === "user_message" && item.text.includes("OMP_BRANCH_FIRST"),
|
||||
);
|
||||
if (firstMessage?.type !== "user_message" || !firstMessage.messageId) {
|
||||
throw new Error("Expected the first OMP prompt to have a provider message id");
|
||||
}
|
||||
|
||||
await harness.client.rewindAgent(agent.id, firstMessage.messageId, "conversation");
|
||||
const after = await timeline(harness.client, agent.id);
|
||||
expect(
|
||||
after.some(
|
||||
(item) => item.type === "user_message" && item.text.includes("OMP_BRANCH_REMOVED"),
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
const continued = await promptAndFinish(
|
||||
harness,
|
||||
agent.id,
|
||||
"Reply exactly OMP_BRANCH_CONTINUED",
|
||||
);
|
||||
expect(
|
||||
continued
|
||||
.filter((item) => item.type === "assistant_message")
|
||||
.map((item) => item.text)
|
||||
.join(""),
|
||||
).toContain("OMP_BRANCH_CONTINUED");
|
||||
} finally {
|
||||
await closeHarness(harness);
|
||||
}
|
||||
},
|
||||
TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
@@ -24,7 +24,11 @@ process.env.PASEO_SUPERVISED = "0";
|
||||
|
||||
const PI_TEST_TIMEOUT_MS = 240_000;
|
||||
const PI_REAL_TEST_MODEL = getRealProviderConfig("pi").model;
|
||||
const PI_FREE_COMPACTION_TEST_MODEL = "openrouter/openai/gpt-oss-20b:free";
|
||||
const PI_COMPACTION_TEST_MODEL = PI_REAL_TEST_MODEL.startsWith("openai-codex/")
|
||||
? "openai-codex/gpt-5.4-mini"
|
||||
: PI_REAL_TEST_MODEL;
|
||||
const PI_COMPACTION_RESERVE_TOKENS =
|
||||
PI_COMPACTION_TEST_MODEL === PI_REAL_TEST_MODEL ? 126_000 : 270_000;
|
||||
|
||||
type ToolCallItem = Extract<AgentTimelineItem, { type: "tool_call" }>;
|
||||
|
||||
@@ -146,7 +150,7 @@ test(
|
||||
cwd,
|
||||
title: "pi-compact-commands",
|
||||
provider: "pi",
|
||||
model: PI_FREE_COMPACTION_TEST_MODEL,
|
||||
model: PI_REAL_TEST_MODEL,
|
||||
});
|
||||
|
||||
const result = await client.listCommands({ agentId: agent.id });
|
||||
@@ -186,7 +190,7 @@ test(
|
||||
cwd,
|
||||
title: "pi-manual-compact",
|
||||
provider: "pi",
|
||||
model: PI_FREE_COMPACTION_TEST_MODEL,
|
||||
model: PI_COMPACTION_TEST_MODEL,
|
||||
});
|
||||
|
||||
await client.sendMessage(agent.id, "Reply exactly: compact-ready");
|
||||
@@ -219,10 +223,11 @@ test(
|
||||
items.some((item) => item.type === "user_message" && item.text.includes("/compact")),
|
||||
).toBe(false);
|
||||
expect(
|
||||
items.some(
|
||||
(item) => item.type === "assistant_message" && item.text.includes("Failed to compact"),
|
||||
),
|
||||
).toBe(false);
|
||||
items
|
||||
.filter((item) => item.type === "assistant_message")
|
||||
.map((item) => item.text)
|
||||
.filter((text) => text.includes("Failed to compact")),
|
||||
).toEqual([]);
|
||||
});
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
@@ -242,7 +247,7 @@ test(
|
||||
cwd,
|
||||
title: "pi-autocompact-toggle",
|
||||
provider: "pi",
|
||||
model: PI_FREE_COMPACTION_TEST_MODEL,
|
||||
model: PI_COMPACTION_TEST_MODEL,
|
||||
});
|
||||
|
||||
await client.sendMessage(agent.id, "/autocompact off");
|
||||
@@ -279,7 +284,7 @@ test(
|
||||
try {
|
||||
writePiCompactionSettings(cwd, {
|
||||
enabled: true,
|
||||
reserveTokens: 126_000,
|
||||
reserveTokens: PI_COMPACTION_RESERVE_TOKENS,
|
||||
keepRecentTokens: 1,
|
||||
});
|
||||
|
||||
@@ -288,7 +293,7 @@ test(
|
||||
cwd,
|
||||
title: "pi-auto-compact",
|
||||
provider: "pi",
|
||||
model: PI_FREE_COMPACTION_TEST_MODEL,
|
||||
model: PI_COMPACTION_TEST_MODEL,
|
||||
});
|
||||
|
||||
await client.sendMessage(agent.id, "Reply exactly: auto-compact-ready");
|
||||
@@ -524,7 +529,7 @@ test(
|
||||
|
||||
await client.sendMessage(
|
||||
agent.id,
|
||||
"Think step by step about what 7 * 13 equals, and give the final answer at the end.",
|
||||
"Work out 37 * 43 carefully, then answer with only the number.",
|
||||
);
|
||||
|
||||
const finish = await client.waitForFinish(agent.id, PI_TEST_TIMEOUT_MS);
|
||||
@@ -535,7 +540,14 @@ test(
|
||||
(item): item is Extract<AgentTimelineItem, { type: "reasoning" }> =>
|
||||
item.type === "reasoning" && item.text.trim().length > 0,
|
||||
);
|
||||
const assistantTexts = items
|
||||
.filter(
|
||||
(item): item is Extract<AgentTimelineItem, { type: "assistant_message" }> =>
|
||||
item.type === "assistant_message",
|
||||
)
|
||||
.map((item) => item.text);
|
||||
|
||||
expect(assistantTexts.join("\n")).toContain("1591");
|
||||
expect(reasoningItems.length).toBeGreaterThan(0);
|
||||
});
|
||||
} finally {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { existsSync, mkdtempSync, readFileSync } from "node:fs";
|
||||
import { homedir, tmpdir } from "node:os";
|
||||
import path, { join } from "node:path";
|
||||
|
||||
import type { Logger } from "pino";
|
||||
|
||||
@@ -9,10 +9,11 @@ import type { ProviderRuntimeSettings } from "../agent/provider-launch-config.js
|
||||
import { ClaudeAgentClient } from "../agent/providers/claude/agent.js";
|
||||
import { CodexAppServerAgentClient } from "../agent/providers/codex-app-server-agent.js";
|
||||
import { OpenCodeAgentClient } from "../agent/providers/opencode-agent.js";
|
||||
import { OmpAgentClient } from "../agent/providers/omp/agent.js";
|
||||
import { PiRpcAgentClient } from "../agent/providers/pi/agent.js";
|
||||
import { isCommandAvailable } from "../../executable-resolution/executable-resolution.js";
|
||||
|
||||
export const realProviders = ["claude", "codex", "opencode", "pi"] as const;
|
||||
export const realProviders = ["claude", "codex", "opencode", "pi", "omp"] as const;
|
||||
export type RealProvider = (typeof realProviders)[number];
|
||||
export type RealProviderConfig = Pick<
|
||||
AgentSessionConfig,
|
||||
@@ -21,10 +22,15 @@ export type RealProviderConfig = Pick<
|
||||
|
||||
const OPENROUTER_BASE_URL = "https://openrouter.ai/api";
|
||||
const OPENROUTER_OPENAI_BASE_URL = "https://openrouter.ai/api";
|
||||
const PI_AUTH_CONFIG_PATH = join(homedir(), ".pi", "agent", "auth.json");
|
||||
const CODEX_AUTH_CONFIG_PATH = join(homedir(), ".codex", "auth.json");
|
||||
const CLAUDE_REAL_TEST_MODEL = "haiku";
|
||||
const CODEX_REAL_TEST_MODEL = "~openai/gpt-latest";
|
||||
const OPENCODE_REAL_TEST_MODEL = "openrouter/google/gemini-2.5-flash-lite";
|
||||
const PI_REAL_TEST_MODEL = "openrouter/google/gemini-2.5-flash-lite";
|
||||
const PI_OPENROUTER_REAL_TEST_MODEL = "openrouter/google/gemini-2.5-flash-lite";
|
||||
const PI_CODEX_REAL_TEST_MODEL = "openai-codex/gpt-5.4";
|
||||
const OMP_OPENROUTER_REAL_TEST_MODEL = "openrouter/google/gemini-2.5-flash-lite";
|
||||
const OMP_CODEX_REAL_TEST_MODEL = "openai-codex/gpt-5.6-sol";
|
||||
|
||||
const availabilityCache = new Map<RealProvider, Promise<boolean>>();
|
||||
|
||||
@@ -52,13 +58,40 @@ export function getRealProviderConfig(provider: RealProvider): RealProviderConfi
|
||||
case "pi":
|
||||
return {
|
||||
provider,
|
||||
model: PI_REAL_TEST_MODEL,
|
||||
model: getPiRealTestModel(),
|
||||
thinkingOptionId: "medium",
|
||||
};
|
||||
case "omp":
|
||||
return {
|
||||
provider,
|
||||
model: getOmpRealTestModel(),
|
||||
thinkingOptionId: "medium",
|
||||
modeId: "full",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function getRealProviderRuntimeSettings(provider: RealProvider): ProviderRuntimeSettings {
|
||||
if (provider === "omp" || provider === "pi") {
|
||||
if (hasCodexAuthTokens()) {
|
||||
return {
|
||||
env: {
|
||||
// Clear stale shell-level keys so the local CLI auth stores win.
|
||||
OPENAI_API_KEY: "",
|
||||
},
|
||||
};
|
||||
}
|
||||
const apiKey = getOpenRouterApiKeyOrNull();
|
||||
if (apiKey) {
|
||||
return {
|
||||
env: {
|
||||
OPENROUTER_API_KEY: apiKey,
|
||||
OPENAI_API_KEY: "",
|
||||
},
|
||||
};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
const apiKey = getOpenRouterApiKey();
|
||||
switch (provider) {
|
||||
case "claude":
|
||||
@@ -95,11 +128,7 @@ export function getRealProviderRuntimeSettings(provider: RealProvider): Provider
|
||||
};
|
||||
}
|
||||
case "pi":
|
||||
return {
|
||||
env: {
|
||||
OPENROUTER_API_KEY: apiKey,
|
||||
},
|
||||
};
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,6 +149,8 @@ export function createRealProviderClient(provider: RealProvider, logger: Logger)
|
||||
return new OpenCodeAgentClient(logger, runtimeSettings);
|
||||
case "pi":
|
||||
return new PiRpcAgentClient({ logger, runtimeSettings });
|
||||
case "omp":
|
||||
return new OmpAgentClient({ logger, runtimeSettings });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,7 +170,7 @@ export function canRunRealProvider(provider: RealProvider): Promise<boolean> {
|
||||
}
|
||||
|
||||
const availability = (async () => {
|
||||
if (!getOpenRouterApiKeyOrNull()) {
|
||||
if (provider !== "omp" && provider !== "pi" && !getOpenRouterApiKeyOrNull()) {
|
||||
return false;
|
||||
}
|
||||
return await isCommandAvailable(getProviderBinary(provider));
|
||||
@@ -149,6 +180,22 @@ export function canRunRealProvider(provider: RealProvider): Promise<boolean> {
|
||||
return availability;
|
||||
}
|
||||
|
||||
function getOmpRealTestModel(): string {
|
||||
const configured = process.env.OMP_REAL_TEST_MODEL?.trim();
|
||||
if (configured) {
|
||||
return configured;
|
||||
}
|
||||
return hasCodexAuthTokens() ? OMP_CODEX_REAL_TEST_MODEL : OMP_OPENROUTER_REAL_TEST_MODEL;
|
||||
}
|
||||
|
||||
function getPiRealTestModel(): string {
|
||||
const configured = process.env.PI_REAL_TEST_MODEL?.trim();
|
||||
if (configured) {
|
||||
return configured;
|
||||
}
|
||||
return hasCodexAuthTokens() ? PI_CODEX_REAL_TEST_MODEL : PI_OPENROUTER_REAL_TEST_MODEL;
|
||||
}
|
||||
|
||||
function getOpenRouterApiKey(): string {
|
||||
const apiKey = getOpenRouterApiKeyOrNull();
|
||||
if (!apiKey) {
|
||||
@@ -158,13 +205,52 @@ function getOpenRouterApiKey(): string {
|
||||
}
|
||||
|
||||
function getOpenRouterApiKeyOrNull(): string | null {
|
||||
const value = process.env.OPENROUTER_API_KEY?.trim();
|
||||
const value = readPiOpenRouterApiKey() ?? process.env.OPENROUTER_API_KEY?.trim();
|
||||
return value && value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
function readPiOpenRouterApiKey(): string | null {
|
||||
const auth = readJsonFile(PI_AUTH_CONFIG_PATH);
|
||||
const value =
|
||||
auth && typeof auth === "object" && "openrouter" in auth ? auth.openrouter : undefined;
|
||||
if (!value || typeof value !== "object" || value === null || !("key" in value)) {
|
||||
return null;
|
||||
}
|
||||
return typeof value.key === "string" && value.key.trim().length > 0 ? value.key.trim() : null;
|
||||
}
|
||||
|
||||
function hasCodexAuthTokens(): boolean {
|
||||
const auth = readJsonFile(CODEX_AUTH_CONFIG_PATH);
|
||||
if (!auth || typeof auth !== "object" || !("tokens" in auth)) {
|
||||
return false;
|
||||
}
|
||||
const tokens = auth.tokens;
|
||||
if (!tokens || typeof tokens !== "object") {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
(typeof tokens.access_token === "string" && tokens.access_token.length > 0) ||
|
||||
(typeof tokens.refresh_token === "string" && tokens.refresh_token.length > 0)
|
||||
);
|
||||
}
|
||||
|
||||
function readJsonFile(filePath: string): unknown {
|
||||
if (!existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(readFileSync(filePath, "utf8")) as unknown;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getProviderBinary(provider: RealProvider): string {
|
||||
if (provider === "pi") {
|
||||
return process.env.PI_COMMAND ?? process.env.PI_ACP_PI_COMMAND ?? "pi";
|
||||
}
|
||||
if (provider === "omp") {
|
||||
return process.env.OMP_COMMAND ?? "omp";
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user