Compare commits

..
Author SHA1 Message Date
paseo-bot[bot]andOuyang Xingyuan 71d613d341 test(server): reproduce false ACP cancellation settlement
Refs #3256. A real ACP subprocess that withholds its prompt response reproduces settled cancellation followed by a foreground-turn-active rejection. The settling control passes; the ownership regression intentionally fails.

Co-authored-by: Ouyang Xingyuan <galaxyroaming@qq.com>
2026-10-20 08:17:44 +02:00
13 changed files with 129 additions and 335 deletions
-4
View File
@@ -50,10 +50,6 @@ export interface PluginLifecycleEvents {
agent: PluginHookAgent;
turnId: string | null;
outcome: PluginTurnOutcome;
/**
* @deprecated Always empty. Fetch history on demand with
* context.paseo.agents.ref(agent.id).timeline.refetch({ direction: "tail", limit: 20 }).
*/
timeline: readonly AgentTimelineItem[];
};
"agent.permission_requested": { agent: PluginHookAgent; request: AgentPermissionRequest };
@@ -0,0 +1,81 @@
import { mkdtemp, writeFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { createRequire } from "node:module";
import { pathToFileURL } from "node:url";
import { expect, test } from "vitest";
import { AgentManager } from "./agent-manager.js";
import { buildProviderRegistry } from "./provider-registry.js";
import { AgentStorage } from "./agent-storage.js";
import { createTestLogger } from "../../test-utils/test-logger.js";
// A real ACP subprocess: cancel is delivered, but the prompt response is
// deliberately withheld. This models the reported protocol condition rather
// than a particular provider's reason for failing to finish its prompt.
const agentSource = `
import { Readable, Writable } from 'node:stream';
const { AgentSideConnection, PROTOCOL_VERSION, ndJsonStream } = await import(process.env.ACP_SDK_URL);
let finishPrompt;
new AgentSideConnection(() => ({
async initialize() { return { protocolVersion: PROTOCOL_VERSION, agentCapabilities: {} }; },
async newSession() { return { sessionId: 'cancel-ownership-session' }; },
async authenticate() {},
prompt() { return new Promise(resolve => { finishPrompt = resolve; }); },
async cancel() {
if (process.env.SETTLE_ON_CANCEL === 'true') finishPrompt({ stopReason: 'cancelled' });
},
}), ndJsonStream(Writable.toWeb(process.stdout), Readable.toWeb(process.stdin)));
`;
for (const settles of [true, false]) {
test(`cancel preserves truthful ownership when ACP prompt settles=${settles}`, async () => {
const dir = await mkdtemp(path.join(tmpdir(), "acp-cancel-ownership-"));
const script = path.join(dir, "agent.mjs");
await writeFile(script, agentSource);
const logger = createTestLogger();
const registry = buildProviderRegistry(logger, {
providerOverrides: {
"cancel-probe": {
extends: "acp",
label: "Cancel probe",
command: [process.execPath, script],
env: {
ACP_SDK_URL: pathToFileURL(
createRequire(import.meta.url).resolve("@agentclientprotocol/sdk"),
).href,
SETTLE_ON_CANCEL: String(settles),
},
},
},
});
const manager = new AgentManager({
clients: { "cancel-probe": registry["cancel-probe"].createClient(logger) },
registry: new AgentStorage(path.join(dir, "agents"), logger),
logger,
});
let agentId: string | undefined;
try {
const agent = await manager.createAgent({ provider: "cancel-probe", cwd: dir }, undefined, {
workspaceId: undefined,
});
agentId = agent.id;
const first = manager.runAgent(agent.id, "hold this turn until cancellation");
await manager.waitForAgentRunStart(agent.id);
const cancellation = await manager.cancelAgentRun(agent.id);
// Record the consumer-visible symptom before the regression assertion.
if (!settles && cancellation.status === "settled") {
await first;
expect(manager.getAgent(agent.id)?.lifecycle).toBe("idle");
await expect(manager.runAgent(agent.id, "try again")).rejects.toThrow(
"A foreground turn is already active",
);
}
expect(cancellation).toEqual({ status: settles ? "settled" : "refused" });
if (settles) await first;
else void first.catch(() => undefined);
} finally {
if (agentId) await manager.closeAgent(agentId);
await rm(dir, { recursive: true, force: true });
}
});
}
@@ -3,7 +3,7 @@ import {
formatAgentMessage,
formatSystemNotificationPrompt,
} from "./agent-messages/index.js";
import { expect, test, vi, onTestFinished } from "vitest";
import { expect, test, vi } from "vitest";
import { spawn } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
@@ -11892,102 +11892,3 @@ test.each([false, true])(
}
},
);
test.each([
{
event: {
type: "turn_completed",
provider: "codex",
turnId: "hook-turn",
} satisfies AgentStreamEvent,
outcome: { kind: "completed" },
},
{
event: {
type: "turn_failed",
provider: "codex",
turnId: "hook-turn",
error: "Failed",
code: "test",
} satisfies AgentStreamEvent,
outcome: { kind: "failed", error: { message: "Failed", code: "test" } },
},
{
event: {
type: "turn_canceled",
provider: "codex",
turnId: "hook-turn",
reason: "Stopped",
} satisfies AgentStreamEvent,
outcome: { kind: "canceled", reason: "Stopped" },
},
])(
"lifecycle hooks receive no conversation for $event.type and stream dispatch never reads timeline items",
async ({ event: terminalEvent, outcome }) => {
class CountingTimelineStore extends InMemoryAgentTimelineStore {
itemReads = 0;
override getItems(agentId: string): AgentTimelineItem[] {
this.itemReads += 1;
return super.getItems(agentId);
}
}
const workdir = mkdtempSync(join(tmpdir(), "agent-hook-payload-"));
const client = new SessionRecordingAgentClient();
const timelineStore = new CountingTimelineStore();
const events: Array<{ name: string; event: unknown }> = [];
const manager = new AgentManager({
clients: { codex: client },
registry: new AgentStorage(join(workdir, "agents"), logger),
logger,
timelineStore,
pluginLifecycle: {
before: async (_name, request) => request,
emit: (name, event) => {
events.push({ name, event });
},
},
});
onTestFinished(async () => {
await manager.closeAgent(manager.listAgents()[0]!.id);
await manager.flush();
rmSync(workdir, { recursive: true, force: true });
});
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
await manager.appendTimelineItem(agent.id, {
type: "assistant_message",
text: "A long conversation".repeat(10_000),
});
expect(manager.getTimeline(agent.id)).toHaveLength(1);
const readsBeforeDispatch = timelineStore.itemReads;
const session = client.sessions[0]!;
session.pushEvent({ type: "turn_started", provider: "codex", turnId: "hook-turn" });
await expect
.poll(() => events.filter(({ name }) => name === "agent.turn_started").length)
.toBe(1);
expect(timelineStore.itemReads).toBe(readsBeforeDispatch);
session.pushEvent(terminalEvent);
await expect
.poll(() => events.filter(({ name }) => name === "agent.turn_ended"))
.toEqual([
{
name: "agent.turn_ended",
event: {
agent: {
id: agent.id,
workspaceId: null,
parentAgentId: null,
provider: "codex",
cwd: workdir,
title: null,
},
turnId: "hook-turn",
outcome,
timeline: [],
},
},
]);
expect(timelineStore.itemReads).toBe(readsBeforeDispatch);
},
);
@@ -326,7 +326,6 @@ export interface AgentManagerOptions {
registry?: AgentStorage;
onAgentAttention?: AgentAttentionCallback;
onWorkspaceStateMayHaveChanged?: (params: { cwd: string }) => void;
timelineStore?: InMemoryAgentTimelineStore;
durableTimelineStore?: AgentTimelineStore;
terminalManager?: TerminalManager | null;
mcpBaseUrl?: string;
@@ -742,7 +741,7 @@ export class AgentManager {
private readonly providerEnabled = new Map<AgentProvider, boolean>();
private readonly providerDefinitions = new Map<AgentProvider, ProviderEnabledFlag>();
private readonly agents = new Map<string, LiveManagedAgent>();
private readonly timelineStore: InMemoryAgentTimelineStore;
private readonly timelineStore = new InMemoryAgentTimelineStore();
private readonly providerSubagents = new ProviderSubagentStore();
private readonly agentsAwaitingInitialSnapshotPersist = new Set<string>();
private readonly sessionEventTails = new Map<string, Promise<void>>();
@@ -779,8 +778,7 @@ export class AgentManager {
constructor(options: AgentManagerOptions) {
this.pluginLifecycle = options.pluginLifecycle;
this.timelineStore = options.timelineStore ?? new InMemoryAgentTimelineStore();
this.idFactory = options.idFactory ?? (() => randomUUID());
this.idFactory = options?.idFactory ?? (() => randomUUID());
this.registry = options?.registry;
this.durableTimelineStore = options?.durableTimelineStore;
this.onAgentAttention = options?.onAgentAttention;
@@ -5166,6 +5164,7 @@ export class AgentManager {
this.pluginLifecycle,
describeHookAgent({ ...agent, title: agent.config.title }),
event,
this.timelineStore.getItems(agentId),
);
}
}
@@ -63,15 +63,6 @@ class FakeSttProvider implements SpeechToTextProvider {
}
}
class SyncFinalSession extends FakeRealtimeSession {
override commit(): void {
this.commitCalls += 1;
const segmentId = `seg-${this.commitCalls}`;
this.emitCommitted(segmentId);
this.emitTranscript(segmentId, `transcript ${this.commitCalls}`, true);
}
}
const buildPcmBase64 = (sampleValue: number, sampleCount: number): string => {
const samples = new Int16Array(sampleCount);
samples.fill(sampleValue);
@@ -542,33 +533,6 @@ describe("DictationStreamManager (provider-agnostic provider)", () => {
vi.useRealTimers();
}
});
it("emits one final transcript when synchronous STT events re-enter finalization", async () => {
const session = new SyncFinalSession();
const emitted: Array<{ type: string; payload: unknown }> = [];
const manager = new DictationStreamManager({
logger: pino({ level: "silent" }),
emit: (msg) => emitted.push(msg),
sessionId: "s1",
stt: new FakeSttProvider(session),
autoCommitSeconds: 0,
});
await manager.handleStart("d-sync-final", "audio/pcm;rate=24000;bits=16");
await manager.handleChunk({
dictationId: "d-sync-final",
seq: 0,
audioBase64: buildPcmBase64(2000, 2400),
format: "audio/pcm;rate=24000;bits=16",
});
await manager.handleFinish("d-sync-final", 0);
await tick();
const finals = emitted.filter((msg) => msg.type === "dictation_stream_final");
expect(finals).toHaveLength(1);
expect((finals[0]?.payload as { text?: string } | undefined)?.text).toBe("transcript 1");
});
});
it("cancellation during STT bootstrap closes the producer and never acknowledges a late connection", async () => {
@@ -146,12 +146,10 @@ export default function contribute(server) {
{ hook: "agent.permission_resolved", event: { resolution: { behavior: "deny" } } },
{
hook: "agent.turn_ended",
event: { outcome: { kind: "completed" }, timeline: [] },
event: { outcome: { kind: "completed" }, timeline: expect.any(Array) },
},
{ hook: "agent.turn_started", event: { agent: { id: agent.id } } },
]);
const page = await client.fetchAgentTimeline(agent.id, { direction: "tail", limit: 20 });
expect(page.entries.length).toBeGreaterThan(0);
await client.archiveAgent(agent.id);
await expect
.poll(async () => {
@@ -1,4 +1,4 @@
import type { AgentStreamEvent } from "../../agent/agent-sdk-types.js";
import type { AgentStreamEvent, AgentTimelineItem } from "../../agent/agent-sdk-types.js";
import { z } from "zod";
import { CreateAgentRequestMessageSchema } from "@getpaseo/protocol/messages";
import type {
@@ -94,6 +94,7 @@ export function publishAgentStream(
lifecycle: PluginLifecycle,
agent: PluginHookAgent,
event: AgentStreamEvent,
timeline: readonly AgentTimelineItem[],
): void {
if (event.type === "turn_started") {
lifecycle.emit("agent.turn_started", { agent, turnId: event.turnId ?? null });
@@ -101,21 +102,21 @@ export function publishAgentStream(
lifecycle.emit("agent.turn_ended", {
agent,
turnId: event.turnId ?? null,
timeline: [],
timeline,
outcome: { kind: "completed" },
});
} else if (event.type === "turn_failed") {
lifecycle.emit("agent.turn_ended", {
agent,
turnId: event.turnId ?? null,
timeline: [],
timeline,
outcome: { kind: "failed", error: { message: event.error, code: event.code } },
});
} else if (event.type === "turn_canceled") {
lifecycle.emit("agent.turn_ended", {
agent,
turnId: event.turnId ?? null,
timeline: [],
timeline,
outcome: { kind: "canceled", reason: event.reason },
});
} else if (event.type === "permission_requested") {
@@ -1,67 +0,0 @@
import { describe, expect, it } from "vitest";
import { SherpaParakeetRealtimeTranscriptionSession } from "./sherpa-parakeet-realtime-session.js";
import type { SherpaOfflineRecognizerEngine } from "./sherpa-offline-recognizer.js";
function pcm16(value: number): Buffer {
const samples = new Int16Array([value]);
return Buffer.from(samples.buffer);
}
function createEngine(): SherpaOfflineRecognizerEngine {
return {
sampleRate: 16000,
createStream() {
return {
samples: new Float32Array(0),
acceptWaveform(input: { samples: Float32Array }) {
this.samples = input.samples;
},
free() {
// no-op
},
};
},
acceptWaveform(stream: { samples: Float32Array }, _sampleRate: number, samples: Float32Array) {
stream.samples = samples;
},
recognizer: {
decode() {
// no-op
},
getResult(stream: { samples: Float32Array }) {
const firstSample = Math.abs(stream.samples[0] ?? 0);
return { text: firstSample > 0.4 ? "second" : "first" };
},
},
} as unknown as SherpaOfflineRecognizerEngine;
}
describe("SherpaParakeetRealtimeTranscriptionSession", () => {
it("keeps audio appended after commit for the next segment", async () => {
const session = new SherpaParakeetRealtimeTranscriptionSession({
engine: createEngine(),
minDecodeIntervalMs: Number.MAX_SAFE_INTEGER,
});
const transcripts: string[] = [];
session.on("transcript", (event) => {
if (event.isFinal) {
transcripts.push(event.transcript);
}
});
await session.connect();
session.appendPcm16(pcm16(0));
session.commit();
session.appendPcm16(pcm16(24000));
await expect.poll(() => transcripts.length).toBe(1);
session.commit();
await expect.poll(() => transcripts.length).toBe(2);
expect(transcripts).toEqual(["first", "second"]);
});
});
@@ -58,19 +58,20 @@ export class SherpaParakeetRealtimeTranscriptionSession
return;
}
const segmentId = this.currentSegmentId;
const previousSegmentId = this.previousSegmentId;
const committedPcm16 = this.pcm16;
this.previousSegmentId = segmentId;
this.currentSegmentId = uuidv4();
this.lastPartialText = "";
this.pcm16 = Buffer.alloc(0);
this.emit("committed", { segmentId, previousSegmentId });
void (async () => {
try {
const finalText = await this.decodePcm16(committedPcm16);
await this.maybeDecode(true);
const finalText = this.lastPartialText;
const segmentId = this.currentSegmentId!;
const previousSegmentId = this.previousSegmentId;
this.emit("committed", { segmentId, previousSegmentId });
this.emit("transcript", { segmentId, transcript: finalText, isFinal: true });
this.previousSegmentId = segmentId;
this.currentSegmentId = uuidv4();
this.lastPartialText = "";
this.pcm16 = Buffer.alloc(0);
} catch (err) {
this.emit("error", err instanceof Error ? err : new Error(String(err)));
}
@@ -109,16 +110,12 @@ export class SherpaParakeetRealtimeTranscriptionSession
this.decoding = true;
try {
const segmentId = this.currentSegmentId;
const text = await this.decodePcm16(this.pcm16);
const text = await this.decodeNow();
this.lastDecodeAt = Date.now();
if (!this.connected || this.currentSegmentId !== segmentId) {
return;
}
if (text !== this.lastPartialText) {
this.lastPartialText = text;
this.emit("transcript", {
segmentId,
segmentId: this.currentSegmentId,
transcript: text,
isFinal: false,
});
@@ -132,12 +129,12 @@ export class SherpaParakeetRealtimeTranscriptionSession
}
}
private async decodePcm16(pcm16: Buffer): Promise<string> {
if (pcm16.length === 0) {
private async decodeNow(): Promise<string> {
if (this.pcm16.length === 0) {
return "";
}
const peak = pcm16lePeakAbs(pcm16);
const peak = pcm16lePeakAbs(this.pcm16);
const peakFloat = peak / 32768.0;
const targetPeak = 0.6;
const maxGain = 50;
@@ -146,7 +143,7 @@ export class SherpaParakeetRealtimeTranscriptionSession
const stream = this.engine.createStream();
try {
const floatSamples = pcm16leToFloat32(pcm16, gain);
const floatSamples = pcm16leToFloat32(this.pcm16, gain);
this.engine.acceptWaveform(stream, this.engine.sampleRate, floatSamples);
this.engine.recognizer.decode(stream);
const result = this.engine.recognizer.getResult(stream);
@@ -1,66 +0,0 @@
import { createServer, type ServerResponse } from "node:http";
import { once } from "node:events";
import { expect, test } from "vitest";
import pino from "pino";
import { OpenAISTT } from "./stt.js";
test.each(["before", "after"])(
"retains later PCM when stop is %s the first response",
async (stop) => {
const uploads: Buffer[] = [];
const responses: ServerResponse[] = [];
// A transcription service fake on the SDK's HTTP interface. The SDK, WAV encoding,
// temporary files, session buffer, and event handling all run unchanged.
const server = createServer(async (request, response) => {
const chunks: Buffer[] = [];
for await (const chunk of request) chunks.push(Buffer.from(chunk));
const body = Buffer.concat(chunks);
const wav = body.subarray(body.indexOf(Buffer.from("RIFF")));
uploads.push(wav.subarray(44, 44 + wav.readUInt32LE(40)));
responses.push(response);
});
server.listen(0, "127.0.0.1");
await once(server, "listening");
const address = server.address();
if (!address || typeof address === "string") throw new Error("Missing server address");
const logger = pino({ level: "silent" });
const session = new OpenAISTT(
{ apiKey: "test", baseUrl: `http://127.0.0.1:${address.port}/v1` },
logger,
).createSession({ logger });
const transcripts: string[] = [];
session.on("transcript", (event) => {
if (event.isFinal) transcripts.push(event.transcript);
});
const finish = (index: number, text: string) => {
responses[index]!.writeHead(200, { "Content-Type": "application/json" });
responses[index]!.end(JSON.stringify({ text }));
};
try {
await session.connect();
session.appendPcm16(Buffer.from([1, 0, 2, 0]));
session.commit();
await expect.poll(() => uploads.length).toBe(1);
session.appendPcm16(Buffer.from([3, 0, 4, 0]));
if (stop === "after") {
finish(0, "first");
await expect.poll(() => transcripts.length).toBe(1);
}
session.commit();
await expect.poll(() => uploads.length, { timeout: 1000 }).toBe(2);
expect(uploads.map((pcm) => Array.from(pcm))).toEqual([
[1, 0, 2, 0],
[3, 0, 4, 0],
]);
if (stop === "before") finish(0, "first");
finish(1, "second");
await expect.poll(() => transcripts.length).toBe(2);
} finally {
session.close();
for (const response of responses)
if (!response.writableEnded) response.end(JSON.stringify({ text: "" }));
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
},
);
@@ -124,15 +124,11 @@ export class OpenAISTT implements SpeechToTextProvider {
const committedId = segmentId;
const prev = previousSegmentId;
const committedPcm16 = pcm16;
previousSegmentId = committedId;
segmentId = v4();
pcm16 = Buffer.alloc(0);
emitter.emit("committed", { segmentId: committedId, previousSegmentId: prev });
void (async () => {
try {
if (committedPcm16.length === 0) {
if (pcm16.length === 0) {
emitter.emit("transcript", {
segmentId: committedId,
transcript: "",
@@ -143,7 +139,7 @@ export class OpenAISTT implements SpeechToTextProvider {
return;
}
const wav = convertPCMToWavBuffer(committedPcm16);
const wav = convertPCMToWavBuffer(pcm16);
const result = await transcribeAudio(
wav,
"audio/wav",
@@ -163,6 +159,10 @@ export class OpenAISTT implements SpeechToTextProvider {
});
} catch (err) {
emitter.emit("error", err);
} finally {
previousSegmentId = committedId;
segmentId = v4();
pcm16 = Buffer.alloc(0);
}
})();
},
@@ -21,10 +21,9 @@ export default function contribute(server: PluginServerContext) {
if (event.outcome.kind === "canceled") {
return;
}
const agent = context.paseo.agents.ref(event.agent.id);
const page = await agent.timeline.refetch({ direction: "tail", limit: 20 });
const text = latestOutputText(page.entries.map(({ item }) => item));
const text = latestOutputText(event.timeline);
if (/out of credits/i.test(text)) {
const agent = context.paseo.agents.ref(event.agent.id);
console.log("Sending a follow-up after out of credits", event.agent.id);
await agent.send("Try again.");
}
+13 -22
View File
@@ -654,11 +654,9 @@ export default function contribute(server: PluginServerContext) {
return;
}
const agent = context.paseo.agents.ref(event.agent.id);
const page = await agent.timeline.refetch({ direction: "tail", limit: 20 });
const text = latestOutputText(page.entries.map(({ item }) => item));
const text = latestOutputText(event.timeline);
if (/out of credits/i.test(text)) {
await agent.send("Try again.");
await context.paseo.agents.ref(event.agent.id).send("Try again.");
}
});
@@ -672,9 +670,6 @@ Turn ends: "out of credits"
→ a new turn starts
```
The handler fetches only the last 20 timeline items. Output before that page is not inspected.
Use `cursor` and `direction` to page further when your plugin needs more history.
This sends a new message with the existing SDK. Persistent matches keep sending follow-ups;
add limits or delays in your plugin when needed. Attachments and tool effects are not replayed.
@@ -726,26 +721,22 @@ plans, and mode changes; requesting permission does not end the turn.
### Events
| Name | Event fields | Trigger |
| ---------------------------- | ------------------------------------------------------- | -------------------------------------------------- |
| `agent.created` | `agent` | Ordinary creation finishes; excludes import/resume |
| `agent.closed` | `agent` | A live agent runtime closes |
| `agent.turn_started` | `agent`, `turnId` | Live turn starts |
| `agent.turn_ended` | `agent`, `turnId`, `outcome`, deprecated `timeline: []` | Live turn completes, fails, or is canceled |
| `agent.permission_requested` | `agent`, `request` | Permission or question becomes pending |
| `agent.permission_resolved` | `agent`, `requestId`, `resolution` | Pending request is answered or cleared |
| `agent.archived` | `agent`, `archivedAt` | Archive state is saved |
| `workspace.created` | `workspace` | Record created; directory available |
| `workspace.archived` | `workspace` | Archive state is saved |
| Name | Event fields | Trigger |
| ---------------------------- | ---------------------------------------- | -------------------------------------------------- |
| `agent.created` | `agent` | Ordinary creation finishes; excludes import/resume |
| `agent.closed` | `agent` | A live agent runtime closes |
| `agent.turn_started` | `agent`, `turnId` | Live turn starts |
| `agent.turn_ended` | `agent`, `turnId`, `outcome`, `timeline` | Live turn completes, fails, or is canceled |
| `agent.permission_requested` | `agent`, `request` | Permission or question becomes pending |
| `agent.permission_resolved` | `agent`, `requestId`, `resolution` | Pending request is answered or cleared |
| `agent.archived` | `agent`, `archivedAt` | Archive state is saved |
| `workspace.created` | `workspace` | Record created; directory available |
| `workspace.archived` | `workspace` | Archive state is saved |
Agent events exclude the daemon's private disposable helpers. Public background workspaces and
their ordinary agents run the same lifecycle hooks as other workspaces. Archive events can precede
runtime/worktree cleanup; observe lifecycle completion separately when cleanup matters.
`agent.turn_ended.timeline` is deprecated and always empty. Fetch history on demand with
`context.paseo.agents.ref(event.agent.id).timeline.refetch({ direction: "tail", limit: 20 })`.
The hook payload size does not grow with conversation history.
**Shared payload shapes** (`@getpaseo/plugin/server`):
```ts