Compare commits

...
Author SHA1 Message Date
paseo-bot[bot] 0fe2e844dc ci: re-run after a macOS file-observer flake
server-tests (macos-14, file observation) failed on file-observer/index.test.ts 'classifies a removed file as deleted', which this branch does not touch.
2026-09-23 22:09:06 +02:00
paseo-bot[bot] 4a97a60e5a test(server): stub the background fetch in the no-upstream origin/main test
The test's checkout has a remote URL, which starts the background fetch,
and it was the only one not stubbing runGitFetch, so it ran real git.
A remote-ref event that lands during a fetch is buffered until the fetch
ends, so on a loaded runner the second refresh arrived after the 1s
waitFor. Under local CPU load it failed 12 of 20 runs before, 0 of 20
after.
2026-09-23 21:52:12 +02:00
paseo-bot[bot] 48b16353e9 Merge remote-tracking branch 'origin/main' into diagnose-omp-duplicate-prompt-echo-4960-2 2026-09-23 21:50:58 +02:00
paseo-bot[bot] 24b5fd5010 fix(omp): claim the oldest pending correlation and drop refused prompts
OMP echoes prompts in the order Paseo sent them, so the oldest unclaimed
correlation with the echoed text belongs to the echo. Claiming the newest
swapped client ids when a prompt timed out after OMP recorded it and the
same text was resubmitted: each echo reconciled to the other submission.

A prompt OMP answers with a failure response is never echoed, so its
correlation is dropped on dispatch. JsonlRpcProcess now rejects those
responses with JsonlRpcRequestRejectedError, which keeps a timeout
(where OMP may still carry the prompt out) distinguishable from a refusal.
2026-09-23 21:44:08 +02:00
paseo-bot 9b088df7bc fix(omp): claim the newest pending correlation for a prompt echo
Paseo runs one foreground turn at a time, so two unclaimed correlations
with the same text mean the older prompt's turn ended without an echo.
The newest one belongs to the submission OMP is echoing now.

This replaces dropping a correlation on dispatch failure, which was
wrong for a request that timed out after OMP had already accepted the
prompt: OMP still echoes it, and the echo needs its id.

Tests wait on observable session state instead of a fixed number of
event-loop cycles.
2026-09-22 20:31:34 +02:00
paseo-bot bcb9724907 fix(omp): drop a prompt's client correlation when its dispatch fails 2026-09-22 20:13:33 +02:00
paseo-bot 163b6df4b3 fix(omp): keep a prompt's client correlation until OMP echoes it
An OMP prompt's client correlation lived on the active turn, so any turn
completion that landed before OMP echoed the prompt cleared it. The echo
then arrived untagged, the manager could not match it to the submission
it belonged to, and recorded it as a second user row.

Two paths reach it: an interrupt-and-replace where OMP flushes the
aborted turn's agent_end after the replacement turn started, and a
custom message that completes a turn before the prompt is echoed.

Correlations now belong to the dispatched prompt and are claimed by the
echo that emits them, so the same text on different turns stays separate
and a re-emitted frame cannot steal a later submission's id.
2026-09-22 20:01:27 +02:00
8 changed files with 325 additions and 16 deletions
@@ -4,7 +4,11 @@ import { PassThrough } from "node:stream";
import pino from "pino";
import { describe, expect, test } from "vitest";
import { JsonlRpcProcess, type JsonlRpcExit } from "./jsonl-rpc-process.js";
import {
JsonlRpcProcess,
JsonlRpcRequestRejectedError,
type JsonlRpcExit,
} from "./jsonl-rpc-process.js";
const CHILD_SOURCE = String.raw`
const readline = require("node:readline");
@@ -154,13 +158,13 @@ describe("JsonlRpcProcess", () => {
}
});
test("rejects unsuccessful responses", async () => {
test("rejects unsuccessful responses as rejected requests", async () => {
const transport = startProcess();
try {
await expect(transport.request({ type: "fail" })).rejects.toThrow(
"child rejected the request",
);
const request = transport.request({ type: "fail" });
await expect(request).rejects.toThrow("child rejected the request");
await expect(request).rejects.toBeInstanceOf(JsonlRpcRequestRejectedError);
} finally {
await transport.close();
}
@@ -173,9 +177,12 @@ describe("JsonlRpcProcess", () => {
try {
child.stderr.write("still waiting");
await expect(transport.request({ type: "hang" })).rejects.toThrow(
const request = transport.request({ type: "hang" });
await expect(request).rejects.toThrow(
/JSONL RPC request timed out phase=hang elapsedMs=\d+ timeoutMs=50\nstill waiting/,
);
// A timed-out request may still be carried out, so it is not a rejection.
await expect(request).rejects.not.toBeInstanceOf(JsonlRpcRequestRejectedError);
} finally {
await transport.close();
}
@@ -40,6 +40,9 @@ interface PendingRequest {
timer: NodeJS.Timeout | null;
}
/** The process answered the request with a failure, so it did not carry it out. */
export class JsonlRpcRequestRejectedError extends Error {}
export interface JsonlRpcExit {
code: number | null;
signal: NodeJS.Signals | null;
@@ -283,7 +286,7 @@ export class JsonlRpcProcess {
this.pending.delete(response.id);
if (!response.success) {
pending.reject(
new Error(
new JsonlRpcRequestRejectedError(
response.error ?? `${this.diagnosticName} ${response.command ?? "request"} failed`,
),
);
@@ -8,6 +8,7 @@ import type { OmpNoTurnScheduler, OmpProviderIdleScheduler } from "./agent.js";
import type { OmpUsagePollScheduler } from "./usage-poller.js";
import { resolveOmpProviderParams } from "./provider-config.js";
import { OmpHarness } from "./test-utils/omp-harness.js";
import { JsonlRpcRequestRejectedError } from "../jsonl-rpc-process.js";
const TURN_LIFECYCLE_EVENTS = new Set<AgentStreamEvent["type"]>([
"turn_started",
@@ -740,6 +741,221 @@ describe("OMP agent client and session", () => {
]);
});
test("a replacement prompt keeps its client correlation when OMP ends the aborted turn late", async () => {
const omp = new OmpHarness();
await omp.start();
await omp.requireStartTurnFromClient("first prompt", "client-a");
const runtime = omp.runtime();
runtime.beginTurn();
runtime.acceptPrompt("first prompt", "omp-user-a");
runtime.streamAssistantText("working on it", "omp-assistant-a");
await omp.waitForUserMessages(1);
// OMP takes no steer, so Paseo interrupts and replaces the foreground turn.
await omp.interrupt();
await omp.requireStartTurnFromClient("second prompt", "client-b");
runtime.beginTurn();
// OMP flushes the aborted turn's terminal event after the replacement started.
runtime.emit({
type: "agent_end",
messages: [{ role: "assistant", content: [], responseId: "omp-assistant-a" }],
});
await omp.waitForEvent("turn_completed");
runtime.acceptPrompt("second prompt", "omp-user-b");
runtime.streamAssistantText("second answer", "omp-assistant-b");
runtime.finishTurn();
await omp.waitForUserMessages(2);
expect(omp.timeline().filter((item) => item.type === "user_message")).toEqual([
{
type: "user_message",
text: "first prompt",
messageId: "omp-user-a",
clientMessageId: "client-a",
},
{
type: "user_message",
text: "second prompt",
messageId: "omp-user-b",
clientMessageId: "client-b",
},
]);
});
test("a prompt echoed after a hidden custom message completes the turn keeps its client correlation", async () => {
const omp = new OmpHarness();
await omp.start();
await omp.requireStartTurnFromClient("do the thing", "client-a");
const runtime = omp.runtime();
runtime.beginTurn();
runtime.emit({
type: "message_end",
message: { role: "custom", content: "xd://inventory", display: false },
});
await omp.waitForEvent("turn_completed");
runtime.acceptPrompt("do the thing", "omp-user-a");
runtime.streamAssistantText("done", "omp-assistant-a");
runtime.finishTurn();
await omp.waitForUserMessages(1);
expect(omp.timeline().filter((item) => item.type === "user_message")).toEqual([
{
type: "user_message",
text: "do the thing",
messageId: "omp-user-a",
clientMessageId: "client-a",
},
]);
});
test("a re-emitted echo does not consume a later submission's client correlation", async () => {
const omp = new OmpHarness();
await omp.start();
await omp.requireStartTurnFromClient("again", "client-a");
const runtime = omp.runtime();
runtime.beginTurn();
runtime.acceptPrompt("again", "omp-user-a");
runtime.streamAssistantText("first answer", "omp-assistant-a");
runtime.finishTurn();
await omp.waitForUserMessages(1);
await omp.requireStartTurnFromClient("again", "client-b");
runtime.beginTurn();
// OMP re-sends the first turn's entry before echoing the new submission.
runtime.acceptPrompt("again", "omp-user-a");
runtime.acceptPrompt("again", "omp-user-b");
runtime.streamAssistantText("second answer", "omp-assistant-b");
runtime.finishTurn();
await omp.waitForUserMessages(2);
expect(omp.timeline().filter((item) => item.type === "user_message")).toEqual([
{ type: "user_message", text: "again", messageId: "omp-user-a", clientMessageId: "client-a" },
{ type: "user_message", text: "again", messageId: "omp-user-b", clientMessageId: "client-b" },
]);
});
test("a prompt OMP echoes after its dispatch failed keeps its client correlation", async () => {
const omp = new OmpHarness();
await omp.start();
const runtime = omp.runtime();
// The prompt reached OMP, but Paseo's request failed before it was answered.
runtime.promptError = new Error("OMP prompt request timed out");
await omp.requireStartTurnFromClient("slow prompt", "client-a");
await omp.waitForEvent("turn_failed");
runtime.promptError = null;
// OMP accepted it anyway and echoes it once it gets to the turn.
runtime.beginTurn();
runtime.acceptPrompt("slow prompt", "omp-user-a");
runtime.streamAssistantText("late answer", "omp-assistant-a");
runtime.finishTurn();
await omp.waitForUserMessages(1);
expect(omp.timeline().filter((item) => item.type === "user_message")).toEqual([
{
type: "user_message",
text: "slow prompt",
messageId: "omp-user-a",
clientMessageId: "client-a",
},
]);
});
test("a timed-out prompt OMP echoes late keeps its own correlation when the same text is resubmitted", async () => {
const omp = new OmpHarness();
await omp.start();
const runtime = omp.runtime();
// OMP recorded the prompt, but Paseo's request timed out before it was answered.
runtime.promptError = new Error("OMP prompt request timed out");
await omp.requireStartTurnFromClient("retry me", "client-a");
await omp.waitForEvent("turn_failed");
runtime.promptError = null;
await omp.requireStartTurnFromClient("retry me", "client-b");
// OMP echoes both prompts in the order it received them.
runtime.beginTurn();
runtime.acceptPrompt("retry me", "omp-user-a");
runtime.acceptPrompt("retry me", "omp-user-b");
runtime.streamAssistantText("done", "omp-assistant-b");
runtime.finishTurn();
await omp.waitForUserMessages(2);
expect(omp.timeline().filter((item) => item.type === "user_message")).toEqual([
{
type: "user_message",
text: "retry me",
messageId: "omp-user-a",
clientMessageId: "client-a",
},
{
type: "user_message",
text: "retry me",
messageId: "omp-user-b",
clientMessageId: "client-b",
},
]);
});
test("a prompt OMP rejects leaves no correlation for a later submission", async () => {
const omp = new OmpHarness();
await omp.start();
const runtime = omp.runtime();
runtime.promptError = new JsonlRpcRequestRejectedError("OMP refused the prompt");
await omp.requireStartTurnFromClient("retry me", "client-a");
await omp.waitForEvent("turn_failed");
runtime.promptError = null;
await omp.requireStartTurnFromClient("retry me", "client-b");
runtime.beginTurn();
runtime.acceptPrompt("retry me", "omp-user-b");
runtime.streamAssistantText("done", "omp-assistant-b");
runtime.finishTurn();
await omp.waitForUserMessages(1);
expect(omp.timeline().filter((item) => item.type === "user_message")).toEqual([
{
type: "user_message",
text: "retry me",
messageId: "omp-user-b",
clientMessageId: "client-b",
},
]);
});
test("the same prompt text on two turns correlates to its own submission", async () => {
const omp = new OmpHarness();
await omp.start();
await omp.requireStartTurnFromClient("again", "client-a");
const runtime = omp.runtime();
runtime.beginTurn();
runtime.acceptPrompt("again", "omp-user-a");
runtime.streamAssistantText("first answer", "omp-assistant-a");
runtime.finishTurn();
await omp.waitForUserMessages(1);
await omp.requireStartTurnFromClient("again", "client-b");
runtime.beginTurn();
runtime.acceptPrompt("again", "omp-user-b");
runtime.streamAssistantText("second answer", "omp-assistant-b");
runtime.finishTurn();
await omp.waitForUserMessages(2);
expect(omp.timeline().filter((item) => item.type === "user_message")).toEqual([
{ type: "user_message", text: "again", messageId: "omp-user-a", clientMessageId: "client-a" },
{ type: "user_message", text: "again", messageId: "omp-user-b", clientMessageId: "client-b" },
]);
});
test("re-emitted user message_end frames dedupe by native entry id", async () => {
const omp = new OmpHarness();
await omp.start();
@@ -72,6 +72,7 @@ import { getUserMessageText } from "./message-history.js";
import { mapOmpSystemNoticeToNotification } from "./system-notice.js";
import { materializeProviderImage } from "../provider-image-output.js";
import { OmpCliRuntime } from "./cli-runtime.js";
import { JsonlRpcRequestRejectedError } from "../jsonl-rpc-process.js";
import { listOmpImportableSessions, readOmpImportSessionConfig } from "./session-descriptor.js";
import type { OmpRuntime, OmpRuntimeSession, OmpStartSessionInput } from "./runtime.js";
import type {
@@ -322,6 +323,9 @@ function renderTextOnlyImageHint(image: { data: string; mimeType: string }): str
}
}
// Bounds the dispatched prompts whose client correlation is still unclaimed.
const MAX_PENDING_CLIENT_CORRELATIONS = 16;
function convertPromptInput(
prompt: AgentPromptInput,
options: { model: OmpModel | null | undefined },
@@ -855,7 +859,12 @@ export class OmpAgentSession implements AgentSession {
private activeAskUserDialog: ActiveAskUserDialog | null = null;
private pendingCombinedAskUserResponse: PendingCombinedAskUserResponse | null = null;
private activeTurnId: string | null = null;
private activeClientMessageId: string | null = null;
// A dispatched prompt owns its client correlation, not the turn that carried
// it: OMP echoes the prompt as a user message that can land after Paseo has
// already completed the turn (a replacement turn, or a turn completed by a
// custom message). Correlations are consumed by the echo they belong to, so
// the same text submitted on different turns stays separate.
private readonly pendingClientCorrelations: Array<{ text: string; clientMessageId: string }> = [];
private activeAssistantMessageId: string | null = null;
private activeTurnTerminalAssistantMessage: OmpAgentMessage | null = null;
private activeTurnStarted = false;
@@ -962,9 +971,11 @@ export class OmpAgentSession implements AgentSession {
const payload = convertPromptInput(prompt, { model: this.state.model });
const turnId = randomUUID();
if (options?.clientMessageId) {
this.rememberClientCorrelation(payload.text, options.clientMessageId);
}
this.live = true;
this.activeTurnId = turnId;
this.activeClientMessageId = options?.clientMessageId ?? null;
this.activeAssistantMessageId = null;
this.activeTurnTerminalAssistantMessage = null;
this.activeTurnStarted = false;
@@ -994,12 +1005,16 @@ export class OmpAgentSession implements AgentSession {
return;
}
} catch (error) {
if (error instanceof JsonlRpcRequestRejectedError) {
// OMP refused the prompt, so no echo will ever claim it. Any other
// failure (a timeout) leaves the prompt with OMP, which still echoes it.
this.forgetClientCorrelation(options?.clientMessageId);
}
if (this.activeTurnId !== turnId) {
return;
}
this.usagePoller.stopTurn();
this.activeTurnId = null;
this.activeClientMessageId = null;
this.activeTurnStarted = false;
this.activeTurnHasUserMessage = false;
this.activeAssistantMessageId = null;
@@ -1132,7 +1147,6 @@ export class OmpAgentSession implements AgentSession {
this.terminalizeActiveWork();
this.usagePoller.stopTurn();
this.activeTurnId = null;
this.activeClientMessageId = null;
this.activeTurnStarted = false;
this.activeTurnHasUserMessage = false;
this.activeAssistantMessageId = null;
@@ -1293,6 +1307,36 @@ export class OmpAgentSession implements AgentSession {
return this.activeTurnId ?? undefined;
}
private rememberClientCorrelation(text: string, clientMessageId: string): void {
this.pendingClientCorrelations.push({ text, clientMessageId });
// OMP never echoes some prompts (a local-only command, a session that dies
// mid-turn). Keep the queue bounded so those cannot accumulate.
if (this.pendingClientCorrelations.length > MAX_PENDING_CLIENT_CORRELATIONS) {
this.pendingClientCorrelations.shift();
}
}
private forgetClientCorrelation(clientMessageId: string | undefined): void {
const index = this.pendingClientCorrelations.findIndex(
(entry) => entry.clientMessageId === clientMessageId,
);
if (index !== -1) {
this.pendingClientCorrelations.splice(index, 1);
}
}
// OMP echoes prompts in the order Paseo sent them, and a prompt OMP refused
// is forgotten on dispatch, so the oldest unclaimed correlation with the
// echoed text belongs to this echo.
private takeClientCorrelation(text: string): string | null {
const index = this.pendingClientCorrelations.findIndex((entry) => entry.text === text);
if (index === -1) {
return null;
}
const [entry] = this.pendingClientCorrelations.splice(index, 1);
return entry?.clientMessageId ?? null;
}
private scheduleNoTurnPromptCompletion(turnId: string): void {
this.cancelNoTurnPromptCompletion();
const abort = new AbortController();
@@ -1346,6 +1390,7 @@ export class OmpAgentSession implements AgentSession {
const outputs = this.pendingNoTurnOutputs.filter((output) => output.turnId === turnId);
this.clearNoTurnBuffers();
if (promptText) {
const clientMessageId = this.takeClientCorrelation(promptText);
this.emit({
type: "timeline",
provider: this.provider,
@@ -1353,7 +1398,7 @@ export class OmpAgentSession implements AgentSession {
item: {
type: "user_message",
text: promptText,
...(this.activeClientMessageId ? { clientMessageId: this.activeClientMessageId } : {}),
...(clientMessageId ? { clientMessageId } : {}),
},
});
}
@@ -1791,7 +1836,6 @@ export class OmpAgentSession implements AgentSession {
}
const turnId = this.activeTurnId;
this.activeTurnId = null;
this.activeClientMessageId = null;
this.activeTurnStarted = false;
this.activeTurnHasUserMessage = false;
this.activeTurnTerminalAssistantMessage = null;
@@ -2048,7 +2092,6 @@ export class OmpAgentSession implements AgentSession {
}
const nativeMessage = event.message as OmpAgentMessage & { id?: unknown; entryId?: unknown };
const messageId = readNativeMessageId(nativeMessage);
const clientMessageId = this.activeClientMessageId;
const emitUserMessage = (resolvedMessageId?: string): void => {
if (resolvedMessageId) {
// OMP re-emits user message_end frames for entries it has already
@@ -2059,6 +2102,9 @@ export class OmpAgentSession implements AgentSession {
}
this.emittedUserMessageIds.add(resolvedMessageId);
}
// Claimed only once a row is really emitted, so a re-emitted frame cannot
// consume the correlation of a later submission with the same text.
const clientMessageId = this.takeClientCorrelation(text);
this.emit({
type: "timeline",
provider: this.provider,
@@ -2132,7 +2178,6 @@ export class OmpAgentSession implements AgentSession {
private completeTurn(turnId: string | undefined, messages: OmpAgentMessage[]): void {
this.activeTurnId = null;
this.activeClientMessageId = null;
this.activeAssistantMessageId = null;
this.activeTurnTerminalAssistantMessage = null;
this.activeTurnStarted = false;
@@ -44,6 +44,7 @@ export interface OmpStartSessionInput {
export interface OmpRuntimeSession {
onEvent(callback: (event: OmpRuntimeEvent) => void): () => void;
// Rejects with JsonlRpcRequestRejectedError when OMP refused the prompt.
prompt(
message: string,
images?: Array<{ type: "image"; data: string; mimeType: string }>,
@@ -133,6 +133,7 @@ export class FakeOmpSession implements OmpRuntimeSession {
compactError: Error | null = null;
emitCompactEnd = true;
getStateError: Error | null = null;
promptError: Error | null = null;
promptAck: OmpPromptAck = {};
branchResponse: { text?: string; cancelled?: boolean } = { text: "" };
branchMessages: Array<{ entryId: string; text: string }> = [];
@@ -182,6 +183,9 @@ export class FakeOmpSession implements OmpRuntimeSession {
}
this.prompts.push({ message, imageCount: images?.length ?? 0 });
this.promptWaiters.shift()?.();
if (this.promptError) {
throw this.promptError;
}
const heldPrompt = this.nextHeldPrompt;
if (heldPrompt) {
this.nextHeldPrompt = null;
@@ -466,6 +466,33 @@ export class OmpHarness {
await promptStarted;
}
// Drains the microtask queue until the session has reached the state the test
// is waiting on, so no test depends on how many async steps a path takes.
async waitUntil(condition: () => boolean, description: string): Promise<void> {
for (let index = 0; index < 100; index += 1) {
if (condition()) return;
await waitForImmediate();
}
throw new Error(`OMP harness timed out waiting for ${description}`);
}
async waitForUserMessages(count: number): Promise<void> {
await this.waitUntil(
() => this.timeline().filter((item) => item.type === "user_message").length >= count,
`${count} user message(s)`,
);
}
async waitForEvent(type: AgentStreamEvent["type"]): Promise<void> {
await this.waitUntil(() => this.eventTypes().includes(type), `a ${type} event`);
}
async requireStartTurnFromClient(message: string, clientMessageId: string): Promise<void> {
const promptStarted = this.omp.latestSession().nextPrompt();
await this.requireSession().startTurn(message, { clientMessageId });
await promptStarted;
}
async interrupt(): Promise<void> {
await this.requireSession().interrupt();
}
@@ -1006,7 +1006,13 @@ describe("WorkspaceGitService checkout observation", () => {
branchMergeRef: null,
upstreamStatus: null,
}));
const service = createService(watcher, { getCheckoutSnapshotFacts });
// A remote URL starts the background fetch; keep it off the real git binary.
const runGitFetch = vi.fn(async () => ({
changes: [],
nonRemoteRefsChanged: false,
error: null,
}));
const service = createService(watcher, { getCheckoutSnapshotFacts, runGitFetch });
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
await vi.waitFor(() => {
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(1);