Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions packages/server/src/server/agent/providers/omp/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,111 @@ describe("OMP agent client and session", () => {
await expect(completion).resolves.toMatchObject({ finalText: "first done" });
});

// Repro/regression for https://github.com/getpaseo/paseo/issues/2232:
// OMP can end the parent model turn (agent_end + isStreaming=false) while
// internal `task` subagents are still writing. Paseo must not complete the
// turn / flip idle until those children finish.
test("stays active while OMP internal task subagents are still running", async () => {
const scheduler = new ManualIdleScheduler();
const omp = new OmpHarness({ providerIdleScheduler: scheduler });
await omp.start();

await omp.requireStartTurn("critically audit the entire repo");
const runtime = omp.runtime();
runtime.beginTurn();
runtime.acceptPrompt("critically audit the entire repo", "user-audit");
runtime.streamAssistantText("spawning fan-out workers");
runtime.emit({
type: "subagent_lifecycle",
payload: {
id: "ApiBudgetAudit",
agent: "ApiBudgetAudit",
description: "audit API budget",
status: "started",
parentToolCallId: "task-1",
index: 0,
},
});
// Parent model loop is idle; child work continues (issue #2232 shape).
runtime.state = { ...runtime.state, isStreaming: false, isCompacting: false };
runtime.finishTurn({
role: "assistant",
content: [{ type: "text", text: "spawning fan-out workers" }],
});

await omp.waitForProviderStateChecks(1);
await scheduler.waitForWaits(1);
expect(omp.completedTurnCount()).toBe(0);
expect(omp.subagentUpserts()).toContainEqual({ id: "ApiBudgetAudit", status: "running" });

// Still busy after another idle poll while the child is running.
scheduler.retry();
await omp.waitForProviderStateChecks(2);
await scheduler.waitForWaits(2);
expect(omp.completedTurnCount()).toBe(0);

runtime.emit({
type: "subagent_lifecycle",
payload: {
id: "ApiBudgetAudit",
agent: "ApiBudgetAudit",
status: "completed",
parentToolCallId: "task-1",
index: 0,
},
});
scheduler.retry();
await omp.waitForProviderStateChecks(3);
// getState waiters resolve before completeTurn runs on the same tick.
await new Promise<void>((resolve) => setImmediate(resolve));
expect(omp.completedTurnCount()).toBe(1);
expect(omp.subagentUpserts()).toContainEqual({ id: "ApiBudgetAudit", status: "completed" });
});

test("stays active when get_subagents reports running children without prior events", async () => {
const scheduler = new ManualIdleScheduler();
const omp = new OmpHarness({ providerIdleScheduler: scheduler });
await omp.start();

await omp.requireStartTurn("fan out");
const runtime = omp.runtime();
runtime.beginTurn();
runtime.acceptPrompt("fan out", "user-fanout");
runtime.streamAssistantText("delegating");
runtime.subagents = [
{
id: "PipelineFeedAudit",
index: 0,
agent: "PipelineFeedAudit",
status: "running",
parentToolCallId: "task-2",
},
];
runtime.state = { ...runtime.state, isStreaming: false, isCompacting: false };
runtime.finishTurn({
role: "assistant",
content: [{ type: "text", text: "delegating" }],
});

await omp.waitForProviderStateChecks(1);
await scheduler.waitForWaits(1);
expect(omp.completedTurnCount()).toBe(0);

runtime.subagents = [
{
id: "PipelineFeedAudit",
index: 0,
agent: "PipelineFeedAudit",
status: "completed",
parentToolCallId: "task-2",
},
];
scheduler.retry();
await omp.waitForProviderStateChecks(2);
await new Promise<void>((resolve) => setImmediate(resolve));
expect(omp.completedTurnCount()).toBe(1);
});

test("stays active when OMP state checks fail", async () => {
const scheduler = new ManualIdleScheduler();
const omp = new OmpHarness({ providerIdleScheduler: scheduler });
Expand Down
24 changes: 23 additions & 1 deletion packages/server/src/server/agent/providers/omp/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2166,7 +2166,11 @@ export class OmpAgentSession implements AgentSession {
try {
const state = await this.runtimeSession.getState();
this.state = state;
if (!state.isStreaming && !state.isCompacting) {
// Parent model loop idle is not enough: OMP-internal `task` children can
// keep running after agent_end / isStreaming=false (hub waits, fan-out).
// Completing the Paseo turn here marks the agent idle while work continues
// (https://github.com/getpaseo/paseo/issues/2232).
if (!state.isStreaming && !state.isCompacting && !(await this.hasRunningOmpSubagents())) {
this.completeTurn(turnId, messages);
return;
}
Expand All @@ -2177,6 +2181,24 @@ export class OmpAgentSession implements AgentSession {
}
}

/**
* Keep the event index in sync with OMP's `get_subagents` while waiting for
* idle. Events alone can miss frames; the index alone can go stale without a
* terminal lifecycle event. Reconcile every poll so either path can settle.
*/
private async hasRunningOmpSubagents(): Promise<boolean> {
try {
const snapshots = await this.runtimeSession.getSubagents();
for (const event of this.subagentIndex.reconcileSnapshots(this.runtimeSession, snapshots)) {
this.emit(event);
}
} catch (error) {
// Older OMP binaries may not expose get_subagents; event index is best-effort.
this.logger.debug({ err: error }, "OMP get_subagents unavailable during idle gate");
}
return this.subagentIndex.hasRunning(this.runtimeSession);
}

private async refreshState(): Promise<void> {
this.state = await this.runtimeSession.getState();
}
Expand Down
7 changes: 7 additions & 0 deletions packages/server/src/server/agent/providers/omp/cli-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
OmpRuntimeEventSchema,
OmpSessionStateSchema,
OmpSessionStatsSchema,
OmpSubagentsResultSchema,
type OmpThinkingLevel,
type OmpAgentMessage,
type OmpModel,
Expand All @@ -35,6 +36,7 @@ import {
type OmpRuntimeEvent,
type OmpSessionState,
type OmpSessionStats,
type OmpSubagentSnapshot,
type OmpSubagentSubscriptionLevel,
} from "./rpc-types.js";

Expand Down Expand Up @@ -202,6 +204,11 @@ class OmpCliRuntimeSession implements OmpRuntimeSession {
await this.request({ type: "set_subagent_subscription", level });
}

async getSubagents(): Promise<OmpSubagentSnapshot[]> {
const data = OmpSubagentsResultSchema.parse(await this.request({ type: "get_subagents" }));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Bound get_subagents fallback latency

When running against OMP builds that predate get_subagents, this call does not fall back promptly: the OMP RPC docs note that unknown-command responses omit the request id, while JsonlRpcProcess only resolves pending requests by id and otherwise waits for its default 30s timeout. In that environment, every otherwise-idle turn now waits about 30 seconds before turn_completed is emitted, making normal prompts appear stuck; use a short timeout/probe and cache unsupported runtimes before gating completion on this RPC. Source checked: https://github.com/can1357/oh-my-pi/blob/main/docs/rpc.md#requestresponse-correlation

Useful? React with 👍 / 👎.

return data.subagents ?? [];
}

async setHostTools(tools: OmpRpcHostToolDefinition[]): Promise<string[]> {
const data = OmpHostToolsResultSchema.parse(
await this.request({ type: "set_host_tools", tools }),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -644,4 +644,57 @@ describe("OMP history mapper", () => {
]),
);
});

test("discovers child transcripts when parent session file is missing", async () => {
const dir = mkdtempSync(join(tmpdir(), "omp-orphan-parent-"));
const parentFile = join(dir, "parent.jsonl");
const parentStem = parentFile.slice(0, -".jsonl".length);
const childFile = join(parentStem, "ApiBudgetAudit.jsonl");
mkdirSync(parentStem, { recursive: true });
writeFileSync(
childFile,
[
{ type: "session", id: "child-root", parentId: null, timestamp: "2026-07-19T20:00:00Z" },
{
type: "message",
id: "child-answer",
parentId: "child-root",
timestamp: "2026-07-19T20:00:01Z",
message: { role: "assistant", content: [{ type: "text", text: "audit ok" }] },
},
]
.map((entry) => JSON.stringify(entry))
.join("\n"),
);

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).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: "upsert",
id: "ApiBudgetAudit",
status: "running",
}),
expect.objectContaining({
type: "timeline",
id: "ApiBudgetAudit",
item: expect.objectContaining({
type: "assistant_message",
text: "audit ok",
}),
}),
expect.objectContaining({
type: "upsert",
id: "ApiBudgetAudit",
status: "completed",
}),
]),
);
});
});
40 changes: 39 additions & 1 deletion packages/server/src/server/agent/providers/omp/history.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { readFile } from "node:fs/promises";
import { readdir, readFile, stat } 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";
Expand Down Expand Up @@ -63,6 +63,12 @@ export async function* streamOmpHistory(input: {
);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
// Parent `.jsonl` can be missing while OMP still keeps child transcripts under
// the stem artifacts directory (issue #2232). Surface those children instead
// of returning an empty history.
for (const transcript of await discoverOrphanSubagentTranscripts(input.sessionFile)) {
yield* replaySubagentTranscript(transcript, input.provider, visitedSessionFiles);
}
return;
}
throw error;
Expand Down Expand Up @@ -290,6 +296,38 @@ function stripExtension(filePath: string): string {
return extension ? filePath.slice(0, -extension.length) : filePath;
}

/**
* When the parent session file is gone but `<stem>/` still holds child
* `*.jsonl` files, treat those as completed provider subagent transcripts.
*/
async function discoverOrphanSubagentTranscripts(
parentSessionFile: string,
): Promise<OmpSubagentTranscript[]> {
const artifactsDir = stripExtension(parentSessionFile);
let names: string[];
try {
const dirStat = await stat(artifactsDir);
if (!dirStat.isDirectory()) {
return [];
}
names = await readdir(artifactsDir);
} catch {
return [];
}
return names
.filter((name) => name.endsWith(".jsonl") && !name.startsWith("."))
.map((name) => {
const id = basename(name, ".jsonl");
return {
id,
title: id,
toolCallId: "",
sessionFile: join(artifactsDir, name),
status: "completed" as const,
};
});
}

export async function readActiveOmpEntryChain(
sessionFile: string,
activeEntryId?: string,
Expand Down
33 changes: 20 additions & 13 deletions packages/server/src/server/agent/providers/omp/rpc-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,7 @@ export const OmpRpcCommandSchema = z.discriminatedUnion("type", [
type: z.literal("set_subagent_subscription"),
level: OmpSubagentSubscriptionLevelSchema,
}),
z.object({ ...OmpCommandBase, type: z.literal("get_subagents") }),
z.object({
...OmpCommandBase,
type: z.literal("set_host_tools"),
Expand Down Expand Up @@ -561,6 +562,24 @@ export const OmpCommandsResultSchema = z
export const OmpHostToolsResultSchema = z
.object({ toolNames: z.array(z.string()).optional() })
.passthrough();
export const OmpSubagentSnapshotSchema = z
.object({
id: z.string(),
index: z.number().int().nonnegative().optional(),
agent: z.string(),
description: z.string().optional(),
status: OmpSubagentStatusSchema,
task: z.string().optional(),
assignment: z.string().optional(),
sessionFile: z.string().optional(),
parentToolCallId: z.string().optional(),
detached: z.boolean().optional(),
lastUpdate: z.number().optional(),
})
.passthrough();
export const OmpSubagentsResultSchema = z
.object({ subagents: z.array(OmpSubagentSnapshotSchema).optional() })
.passthrough();
export const OmpBranchResultSchema = z
.object({ text: z.string().optional(), cancelled: z.boolean().optional() })
.passthrough();
Expand Down Expand Up @@ -589,6 +608,7 @@ 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 OmpSubagentSnapshot = z.infer<typeof OmpSubagentSnapshotSchema>;
export type OmpSubagentLifecyclePayload = z.infer<typeof OmpSubagentLifecyclePayloadSchema>;
export type OmpSubagentProgressPayload = z.infer<typeof OmpSubagentProgressPayloadSchema>;
export type OmpSubagentEventPayload = z.infer<typeof OmpSubagentEventPayloadSchema>;
Expand All @@ -612,19 +632,6 @@ export type OmpAvailableCommandsUpdateEvent = z.infer<typeof OmpAvailableCommand
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;
Expand Down
3 changes: 3 additions & 0 deletions packages/server/src/server/agent/providers/omp/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
OmpRuntimeEvent,
OmpSessionState,
OmpSessionStats,
OmpSubagentSnapshot,
OmpSubagentSubscriptionLevel,
OmpThinkingLevel,
} from "./rpc-types.js";
Expand Down Expand Up @@ -58,6 +59,8 @@ export interface OmpRuntimeSession {
getSessionStats(): Promise<OmpSessionStats>;
getCommands(): Promise<OmpRpcSlashCommand[]>;
setSubagentSubscription(level: OmpSubagentSubscriptionLevel): Promise<void>;
/** OMP source-of-truth list of internal task children (may throw on old binaries). */
getSubagents(): Promise<OmpSubagentSnapshot[]>;
setHostTools(tools: OmpRpcHostToolDefinition[]): Promise<string[]>;
sendHostToolResult(result: OmpRpcHostToolResult): void;
sendHostToolUpdate(update: OmpRpcHostToolUpdate): void;
Expand Down
Loading
Loading