Compare commits

...
Author SHA1 Message Date
Mohamed Boudra e050bbef4b test(server): require one turn's events to share an identical turn id
Matching only the ordinal let two events of one turn pass with different
nonces. The session matcher binds the nonce from the first id it sees, so
every later id with the same ordinal must be the exact same string.
2026-11-02 04:41:24 +01:00
Mohamed Boudra af0b7a39a8 fix(server): keep earlier replies visible after an idle agent is reopened
Idle runtime collection closes an agent's provider session but the daemon
keeps the earlier turns' timeline rows and their turn ids. The next prompt
reopens the agent as a new provider session instance, and Claude, Codex and
OpenCode each restarted their turn ordinal at the same value, so the new turn
reused the previous turn's id. Turn collapsing reads a user message with the
current turn's id as a steer into that turn, so both turns folded together
and the earlier reply was hidden behind the summary line.

Each provider session instance now mixes a random nonce into the ids it
issues, which makes turn ids unique for the agent's lifetime without
persisting a counter or threading state from the daemon into providers.
2026-11-02 04:36:52 +01:00
12 changed files with 196 additions and 63 deletions
@@ -1183,7 +1183,6 @@ test("reuses one autonomous run for unbound stream_event bursts with no foregrou
const session = await createSession();
const internal: {
turnState: "idle" | "foreground" | "autonomous";
nextTurnOrdinal: number;
routeSdkMessageFromPump: (
message: Record<string, unknown>,
activeQuery: QueryMock,
@@ -1206,8 +1205,7 @@ test("reuses one autonomous run for unbound stream_event bursts with no foregrou
);
const firstRunId = internal.autonomousTurn?.id ?? null;
expect(firstRunId).toBe("autonomous-turn-1");
expect(internal.nextTurnOrdinal).toBe(2);
expect(firstRunId).toMatch(/^autonomous-turn-[0-9a-f]+-1$/);
await internal.routeSdkMessageFromPump(
{
@@ -1220,7 +1218,6 @@ test("reuses one autonomous run for unbound stream_event bursts with no foregrou
queryMock,
);
expect(internal.autonomousTurn?.id).toBe(firstRunId);
expect(internal.nextTurnOrdinal).toBe(2);
await internal.routeSdkMessageFromPump(
{
@@ -1360,7 +1357,7 @@ test("a main-session frame opens no turn until Claude starts one", async () => {
// session start a turn of its own, with no prompt from the user.
await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock);
await internal.routeSdkMessageFromPump(unboundTextDelta("picking this up"), queryMock);
expect(internal.autonomousTurn?.id).toBe("autonomous-turn-1");
expect(internal.autonomousTurn?.id).toMatch(/^autonomous-turn-[0-9a-f]+-1$/);
await internal.routeSdkMessageFromPump(successResult(), queryMock);
expect(internal.autonomousTurn).toBeNull();
@@ -1410,7 +1407,7 @@ test("Claude waking up after a helper finishes gets an autonomous turn that its
expect(internal.autonomousTurn).toBeNull();
await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock);
expect(internal.autonomousTurn?.id).toBe("autonomous-turn-1");
expect(internal.autonomousTurn?.id).toMatch(/^autonomous-turn-[0-9a-f]+-1$/);
expect(internal.turnState).toBe("autonomous");
await internal.routeSdkMessageFromPump(unboundTextDelta("the helper is done"), queryMock);
@@ -1446,7 +1443,7 @@ test("a steer folded into a turn keeps one turn, and a turn Claude starts after
// A message that arrived while Claude wrote its final answer runs as a turn of its own.
await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock);
expect(internal.autonomousTurn?.id).toBe("autonomous-turn-2");
expect(internal.autonomousTurn?.id).toMatch(/^autonomous-turn-[0-9a-f]+-2$/);
await internal.routeSdkMessageFromPump(successResult(), queryMock);
expect(internal.autonomousTurn).toBeNull();
expect(internal.turnState).toBe("idle");
@@ -1483,7 +1480,7 @@ test("an idle session state closes an autonomous turn that no result closed", as
session.subscribe((event) => events.push(event));
await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock);
expect(internal.autonomousTurn?.id).toBe("autonomous-turn-1");
expect(internal.autonomousTurn?.id).toMatch(/^autonomous-turn-[0-9a-f]+-1$/);
// Safety net: Claude says nothing at all is running any more.
await internal.routeSdkMessageFromPump(sessionState("idle"), queryMock);
@@ -1685,7 +1682,7 @@ test("a new turn clears an interrupt flag no result will consume", async () => {
await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock);
expect(internal.pendingInterruptAbort).toBe(false);
expect(internal.autonomousTurn?.id).toBe("autonomous-turn-1");
expect(internal.autonomousTurn?.id).toMatch(/^autonomous-turn-[0-9a-f]+-1$/);
// This turn's own failure is reported, not swallowed as the interrupted turn's leftover.
await internal.routeSdkMessageFromPump(
@@ -887,6 +887,30 @@ describe("ClaudeAgentSession features", () => {
await session.close();
});
test("issues turn ids that stay unique after the session is reopened", async () => {
// Idle runtime collection closes the provider session; the next prompt reopens it from
// persistence while the daemon keeps the earlier turns' rows and their turn ids.
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-turn-ids-"));
vi.stubEnv("CLAUDE_CONFIG_DIR", path.join(tempRoot, "claude-config"));
const client = new ClaudeAgentClient({
logger,
queryFactory: createQueryMock().queryFactory,
resolveBinary: async () => "/test/claude/bin",
});
const handle = { provider: "claude" as const, sessionId: "reopened-session" };
const first = await client.resumeSession(handle, { cwd: process.cwd() });
const { turnId: firstTurnId } = await first.startTurn("first prompt");
await first.close();
const second = await client.resumeSession(handle, { cwd: process.cwd() });
const { turnId: secondTurnId } = await second.startTurn("second prompt");
await second.close();
vi.unstubAllEnvs();
await fs.rm(tempRoot, { recursive: true, force: true });
expect(secondTurnId).not.toBe(firstTurnId);
});
test("keeps an extraArgs settings file when Paseo sets Claude flag settings", async () => {
const settingsDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-settings-"));
const settingsPath = path.join(settingsDir, "settings.json");
@@ -1,5 +1,6 @@
import { setImmediate } from "node:timers/promises";
import { ClaudeRuntimeSafety } from "./runtime-safety.js";
import { createTurnIdSequence } from "../../turn-ids.js";
import { validateProviderOptions } from "../../provider-options.js";
import type { ChildProcess } from "node:child_process";
import { randomUUID } from "node:crypto";
@@ -2371,7 +2372,7 @@ class ClaudeAgentSession implements AgentSession {
private historySessionId: string | null = null;
private replayingHistory = false;
private turnState: TurnState = "idle";
private nextTurnOrdinal = 1;
private readonly nextTurnId = createTurnIdSequence(1);
private cancelCurrentTurn: (() => void) | null = null;
private cachedRuntimeInfo: AgentRuntimeInfo | null = null;
private lastOptionsModel: string | null = null;
@@ -3940,7 +3941,7 @@ class ClaudeAgentSession implements AgentSession {
}
private createTurnId(owner: "foreground" | "autonomous"): string {
return `${owner}-turn-${this.nextTurnOrdinal++}`;
return this.nextTurnId(owner);
}
private isTerminalTurnEvent(event: AgentStreamEvent): boolean {
@@ -1,4 +1,4 @@
import { describe, expect, test, vi } from "vitest";
import { beforeEach, describe, expect, test, vi } from "vitest";
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { EventEmitter } from "node:events";
import { type Dirent, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
@@ -100,6 +100,12 @@ import { createTestLogger } from "../../../test-utils/test-logger.js";
import { asInternals as castInternals, createStub } from "../../test-utils/class-mocks.js";
import { AgentManager } from "../agent-manager.js";
import { buildProviderRegistry } from "../provider-registry.js";
import { sessionTurnIds } from "./test-utils/turn-ids.js";
let codexTurnId = sessionTurnIds("codex");
beforeEach(() => {
codexTurnId = sessionTurnIds("codex");
});
interface CollaborationModeRecord {
name: string;
@@ -1281,6 +1287,29 @@ describe("Codex app-server provider", () => {
}
});
test("issues turn ids that stay unique after the thread is reopened", async () => {
// Idle runtime collection closes the provider session; the next prompt reopens it from
// persistence while the daemon keeps the earlier turns' rows and their turn ids.
const beforeReopen = createFakeCodexAppServer();
const firstSession =
await createProviderWithFakeAppServer(beforeReopen).createSession(createConfig());
const { turnId: firstTurnId } = await firstSession.startTurn("first turn");
const saved = waitForNextEvent(firstSession, "thread_started");
beforeReopen.startsTurn({ threadId: "thread-1" });
await saved;
const handle = firstSession.describePersistence()!;
await firstSession.close();
const afterReopen = createFakeCodexAppServer();
const resumed = await createProviderWithFakeAppServer(afterReopen).resumeSession(handle);
try {
const { turnId: secondTurnId } = await resumed.startTurn("turn after reopen");
expect(secondTurnId).not.toBe(firstTurnId);
} finally {
await resumed.close();
}
});
test("keeps the default effort when a session without one is resumed after a restart", async () => {
const beforeRestart = createFakeCodexAppServer();
const firstSession = await createProviderWithFakeAppServer(beforeRestart).createSession(
@@ -4611,7 +4640,7 @@ describe("Codex app-server provider", () => {
await expect(child).resolves.toMatchObject({
type: "provider_subagent",
provider: "codex",
turnId: "codex-turn-0",
turnId: codexTurnId(0),
event: {
type: "upsert",
id: "legacy-only-child-thread",
@@ -4621,7 +4650,7 @@ describe("Codex app-server provider", () => {
await expect(spawn).resolves.toMatchObject({
type: "timeline",
provider: "codex",
turnId: "codex-turn-0",
turnId: codexTurnId(0),
item: {
type: "tool_call",
callId: "spawn-legacy-only-child",
@@ -1,4 +1,5 @@
import { validateProviderOptions } from "../provider-options.js";
import { createTurnIdSequence } from "../turn-ids.js";
import {
getAgentStreamEventTurnId,
type AgentPermissionAction,
@@ -3462,7 +3463,7 @@ export class CodexAppServerAgentSession implements AgentSession {
private threadRollbackAvailable = true;
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
private startupEvents: AgentStreamEvent[] | null = null;
private nextTurnOrdinal = 0;
private readonly nextTurnId = createTurnIdSequence(0);
private activeForegroundTurnId: string | null = null;
private activeClientMessageId: string | null = null;
private cachedRuntimeInfo: AgentRuntimeInfo | null = null;
@@ -5627,7 +5628,7 @@ export class CodexAppServerAgentSession implements AgentSession {
}
private createTurnId(): string {
return `codex-turn-${this.nextTurnOrdinal++}`;
return this.nextTurnId("codex");
}
private pendingNotifications: Promise<void> = Promise.resolve();
@@ -1,4 +1,4 @@
import { describe, expect, test, vi } from "vitest";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
@@ -25,6 +25,7 @@ import type {
AssistantMessageTimelineItem,
AgentTimelineItem,
} from "../agent-sdk-types.js";
import { sessionTurnIds } from "./test-utils/turn-ids.js";
// Deliberately an independent literal rather than the production constant these tests
// guard: deriving the boundary from OPENCODE_SERVER_STARTUP_TIMEOUT_MS would keep the
@@ -32,6 +33,11 @@ import type {
const EXPECTED_STARTUP_BUDGET_MS = 30_000;
const EXPECTED_EVENT_READINESS_BUDGET_MS = 45_000;
let openCodeTurnId = sessionTurnIds("opencode");
beforeEach(() => {
openCodeTurnId = sessionTurnIds("opencode");
});
function tmpCwd(): string {
const dir = mkdtempSync(path.join(os.tmpdir(), "opencode-agent-test-"));
try {
@@ -1638,10 +1644,10 @@ describe("OpenCode adapter startTurn error handling", () => {
item: { type: "user_message", clientMessageId: "client-message-1" },
});
expect(turn.events.map((event) => ("turnId" in event ? event.turnId : undefined))).toEqual([
"opencode-turn-0",
"opencode-turn-0",
"opencode-turn-0",
"opencode-turn-0",
openCodeTurnId(0),
openCodeTurnId(0),
openCodeTurnId(0),
openCodeTurnId(0),
]);
});
@@ -1833,7 +1839,7 @@ describe("OpenCode adapter startTurn error handling", () => {
type: "error",
message: "Provider retry (attempt 1): model does not exist",
},
turnId: "opencode-turn-0",
turnId: openCodeTurnId(0),
});
expect(events.some((event) => event.type === "turn_failed")).toBe(false);
await session.close();
@@ -2373,6 +2379,35 @@ describe("OpenCode adapter startTurn error handling", () => {
}
});
test("issues turn ids that stay unique after the session is reopened", async () => {
// Idle runtime collection closes the provider session; the next prompt reopens it from
// persistence while the daemon keeps the earlier turns' rows and their turn ids.
const runtime = new TestOpenCodeHarness();
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
const cwd = tmpCwd();
const handle = { provider: "opencode" as const, sessionId: "ses_reopened", metadata: { cwd } };
const turnIds: string[] = [];
try {
for (let instance = 0; instance < 2; instance += 1) {
const openCode = new TestOpenCodeClient();
openCode.sessionPromptAsyncEvents = [];
runtime.enqueueClient(openCode);
const session = await client.resumeSession(handle);
try {
turnIds.push((await session.startTurn("prompt")).turnId);
} finally {
await session.close();
}
}
} finally {
rmSync(cwd, { recursive: true, force: true });
}
expect(turnIds[1]).not.toBe(turnIds[0]);
});
test("uses OpenCode native steer admission and reconciles the user echo", async () => {
const { parent: session, openCode } = await createParentSession("ses_native_steer");
openCode.sessionPromptAsyncEvents = [];
@@ -2641,11 +2676,11 @@ describe("OpenCode adapter startTurn error handling", () => {
expect(
turnEventSignatures(events.filter((event) => event.type !== "provider_subagent")),
).toEqual([
["turn_started", "opencode-turn-0"],
["turn_canceled", "opencode-turn-0"],
["turn_started", "opencode-turn-1"],
["timeline", "opencode-turn-1"],
["turn_completed", "opencode-turn-1"],
["turn_started", openCodeTurnId(0)],
["turn_canceled", openCodeTurnId(0)],
["turn_started", openCodeTurnId(1)],
["timeline", openCodeTurnId(1)],
["turn_completed", openCodeTurnId(1)],
]);
} finally {
settleRetry.resolve();
@@ -2706,7 +2741,7 @@ describe("OpenCode adapter startTurn error handling", () => {
expect(events).toContainEqual(
expect.objectContaining({
type: "timeline",
turnId: "opencode-turn-1",
turnId: openCodeTurnId(1),
item: expect.objectContaining({ text: "Wake output streams live." }),
}),
),
@@ -2726,11 +2761,11 @@ describe("OpenCode adapter startTurn error handling", () => {
});
await vi.waitFor(() =>
expect(events).toContainEqual(
expect.objectContaining({ type: "turn_completed", turnId: "opencode-turn-1" }),
expect.objectContaining({ type: "turn_completed", turnId: openCodeTurnId(1) }),
),
);
await expect(session.startTurn("after the wake")).resolves.toEqual({
turnId: "opencode-turn-2",
turnId: openCodeTurnId(2),
});
} finally {
settleAbort.resolve();
@@ -2843,13 +2878,13 @@ describe("OpenCode adapter startTurn error handling", () => {
expect(events).toContainEqual({
type: "turn_started",
provider: "opencode",
turnId: "opencode-turn-1",
turnId: openCodeTurnId(1),
});
await vi.waitFor(() =>
expect(events).toContainEqual({
type: "turn_completed",
provider: "opencode",
turnId: "opencode-turn-1",
turnId: openCodeTurnId(1),
usage: expect.objectContaining({
inputTokens: 7,
outputTokens: 3,
@@ -2858,7 +2893,8 @@ describe("OpenCode adapter startTurn error handling", () => {
}),
);
const autonomousCompletion = events.find(
(event) => event.type === "turn_completed" && event.turnId === "opencode-turn-1",
(event) =>
event.type === "turn_completed" && openCodeTurnId(1).asymmetricMatch(event.turnId),
);
expect(autonomousCompletion?.type).toBe("turn_completed");
if (autonomousCompletion?.type === "turn_completed") {
@@ -2867,7 +2903,7 @@ describe("OpenCode adapter startTurn error handling", () => {
expect(events).toContainEqual(
expect.objectContaining({
type: "timeline",
turnId: "opencode-turn-1",
turnId: openCodeTurnId(1),
item: expect.objectContaining({
type: "assistant_message",
text: "Autonomous wake completed.",
@@ -2875,7 +2911,7 @@ describe("OpenCode adapter startTurn error handling", () => {
}),
);
await expect(session.startTurn("after autonomous wake")).resolves.toEqual({
turnId: "opencode-turn-2",
turnId: openCodeTurnId(2),
});
} finally {
settleAbort.resolve();
@@ -2954,7 +2990,7 @@ describe("OpenCode adapter startTurn error handling", () => {
type: "turn_canceled",
provider: "opencode",
reason: "interrupted",
turnId: "opencode-turn-0",
turnId: openCodeTurnId(0),
});
openCode.emitEvent({
@@ -2962,7 +2998,7 @@ describe("OpenCode adapter startTurn error handling", () => {
properties: { sessionID: "ses_retried_stop" },
});
await expect(session.startTurn("replacement")).resolves.toEqual({
turnId: "opencode-turn-1",
turnId: openCodeTurnId(1),
});
expect(openCode.calls.sessionPromptAsync).toHaveLength(2);
} finally {
@@ -3058,7 +3094,7 @@ describe("OpenCode adapter startTurn error handling", () => {
expect(events).toContainEqual({
type: "turn_started",
provider: "opencode",
turnId: "opencode-turn-1",
turnId: openCodeTurnId(1),
}),
);
@@ -3767,7 +3803,7 @@ describe("OpenCode adapter startTurn error handling", () => {
];
const replacement = session.startTurn("second");
await vi.advanceTimersByTimeAsync(10_000);
await expect(replacement).resolves.toEqual({ turnId: "opencode-turn-1" });
await expect(replacement).resolves.toEqual({ turnId: openCodeTurnId(1) });
expect(countEvents(events, "turn_canceled")).toBe(1);
} finally {
vi.useRealTimers();
@@ -4022,7 +4058,7 @@ describe("OpenCode adapter startTurn error handling", () => {
expect(openCode.calls.sessionStatus).toHaveLength(1);
releaseIdle.resolve();
await expect(replacement).resolves.toEqual({ turnId: "opencode-turn-1" });
await expect(replacement).resolves.toEqual({ turnId: openCodeTurnId(1) });
expect(openCode.calls.sessionPromptAsync).toHaveLength(2);
expect(openCode.calls.sessionStatus.length).toBeGreaterThanOrEqual(2);
} finally {
@@ -4722,9 +4758,9 @@ describe("OpenCode provider subagent contract", () => {
"turn_completed",
]);
expect(events.map((event) => ("turnId" in event ? event.turnId : undefined))).toEqual([
"opencode-turn-0",
"opencode-turn-0",
"opencode-turn-0",
openCodeTurnId(0),
openCodeTurnId(0),
openCodeTurnId(0),
]);
expect(events).toContainEqual(
expect.objectContaining({
@@ -4760,7 +4796,7 @@ describe("OpenCode provider subagent contract", () => {
expect(events).toContainEqual({
type: "turn_started",
provider: "opencode",
turnId: "opencode-turn-0",
turnId: openCodeTurnId(0),
});
});
for (const event of assistantTurnEvents({
@@ -4774,9 +4810,9 @@ describe("OpenCode provider subagent contract", () => {
expect(childClient.calls.sessionStatus).toEqual([{ directory: "/workspace/repo" }]);
expect(turnEventSignatures(events)).toEqual([
["turn_started", "opencode-turn-0"],
["timeline", "opencode-turn-0"],
["turn_completed", "opencode-turn-0"],
["turn_started", openCodeTurnId(0)],
["timeline", openCodeTurnId(0)],
["turn_completed", openCodeTurnId(0)],
]);
expect(events).toContainEqual(
expect.objectContaining({
@@ -4881,9 +4917,9 @@ describe("OpenCode provider subagent contract", () => {
await vi.waitFor(() => {
expect(turnEventSignatures(events)).toEqual([
["turn_started", "opencode-turn-0"],
["timeline", "opencode-turn-0"],
["turn_completed", "opencode-turn-0"],
["turn_started", openCodeTurnId(0)],
["timeline", openCodeTurnId(0)],
["turn_completed", openCodeTurnId(0)],
]);
});
expect(events).toContainEqual(
@@ -4927,9 +4963,9 @@ describe("OpenCode provider subagent contract", () => {
await vi.waitFor(() => {
expect(turnEventSignatures(events)).toEqual([
["turn_started", "opencode-turn-0"],
["timeline", "opencode-turn-0"],
["turn_completed", "opencode-turn-0"],
["turn_started", openCodeTurnId(0)],
["timeline", openCodeTurnId(0)],
["turn_completed", openCodeTurnId(0)],
]);
});
} finally {
@@ -5003,7 +5039,7 @@ describe("OpenCode provider subagent contract", () => {
});
await vi.waitFor(() => {
expect(events).toEqual([
{ type: "turn_started", provider: "opencode", turnId: "opencode-turn-0" },
{ type: "turn_started", provider: "opencode", turnId: openCodeTurnId(0) },
]);
});
@@ -5118,12 +5154,12 @@ describe("OpenCode provider subagent contract", () => {
await streamDrained.promise;
expect(events.filter((event) => event.type !== "provider_subagent")).toEqual([
{ type: "turn_started", provider: "opencode", turnId: "opencode-turn-0" },
{ type: "turn_started", provider: "opencode", turnId: openCodeTurnId(0) },
{
type: "turn_canceled",
provider: "opencode",
reason: "interrupted",
turnId: "opencode-turn-0",
turnId: openCodeTurnId(0),
},
]);
} finally {
@@ -5174,7 +5210,7 @@ describe("OpenCode provider subagent contract", () => {
expect.objectContaining({
type: "permission_requested",
request: expect.objectContaining({ id: "perm_child_external" }),
turnId: "opencode-turn-0",
turnId: openCodeTurnId(0),
}),
);
});
@@ -1,4 +1,5 @@
import { validateProviderOptions } from "../provider-options.js";
import { createTurnIdSequence } from "../turn-ids.js";
import {
createOpencodeClient,
type AssistantMessage as OpenCodeAssistantMessage,
@@ -3405,7 +3406,7 @@ class OpenCodeAgentSession implements AgentSession {
private partTypes = new Map<string, string>();
private availableModesCache: AgentMode[] | null = null;
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
private nextTurnOrdinal = 0;
private readonly nextTurnId = createTurnIdSequence(0);
private turnState: OpenCodeTurnState = { status: "idle" };
/**
* Settlement of every session-scoped abort issued so far. It outlives the stop
@@ -4882,7 +4883,7 @@ class OpenCodeAgentSession implements AgentSession {
private createTurnId(): string {
this.gapRepairRevision += 1;
return `opencode-turn-${this.nextTurnOrdinal++}`;
return this.nextTurnId("opencode");
}
private traceOpenCode(msg: OpenCodeTraceMessage, data: OpenCodeTraceData = {}): void {
@@ -0,0 +1,30 @@
/** A Vitest asymmetric matcher for one provider turn id. */
export interface TurnIdMatcher {
asymmetricMatch(actual: unknown): boolean;
toString(): string;
toAsymmetricMatcher(): string;
}
/**
* Matches the turn ids one provider session issues. Each session mixes a random nonce into its
* ids, so the first id seen binds that nonce and every later match must be the exact id
* `<owner>-turn-<nonce>-<ordinal>`. Two events of one turn therefore match only when their ids
* are identical. Create one per test.
*/
export function sessionTurnIds(owner: string): (ordinal: number) => TurnIdMatcher {
let nonce: string | null = null;
const pattern = new RegExp(`^${owner}-turn-([0-9a-f]+)-(\\d+)$`);
return (ordinal) => {
const describe = () => `${owner}-turn-${nonce ?? "<nonce>"}-${ordinal}`;
return {
asymmetricMatch(actual) {
const match = typeof actual === "string" ? pattern.exec(actual) : null;
if (!match || Number(match[2]) !== ordinal) return false;
nonce ??= match[1]!;
return match[1] === nonce;
},
toString: describe,
toAsymmetricMatcher: describe,
};
};
}
@@ -0,0 +1,13 @@
import { randomBytes } from "node:crypto";
/**
* Turn ids stay unique for an agent's whole life. The daemon keeps every turn's rows while
* idle runtime collection closes the provider session, and the next prompt reopens the agent
* as a new session instance whose ordinals start over. Each instance therefore mixes its own
* nonce into the ids it issues; the ordinal keeps the sequence readable in logs and tests.
*/
export function createTurnIdSequence(firstOrdinal: number): (owner: string) => string {
const instance = randomBytes(6).toString("hex");
let ordinal = firstOrdinal;
return (owner) => `${owner}-turn-${instance}-${ordinal++}`;
}
@@ -22,6 +22,7 @@ import type {
FetchCatalogOptions,
} from "../agent/agent-sdk-types.js";
import type { AgentPermissionRequest, AgentPermissionResponse } from "../agent/agent-sdk-types.js";
import { createTurnIdSequence } from "../agent/turn-ids.js";
import { isLikelyExternalToolName } from "@getpaseo/protocol/tool-name-normalization";
const TEST_CAPABILITIES: AgentCapabilityFlags = {
@@ -342,7 +343,7 @@ class FakeAgentSession implements AgentSession {
private permissionGate: Deferred<AgentPermissionResponse> | null = null;
private readonly historyPath: string;
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
private nextTurnOrdinal = 0;
private readonly nextTurnId = createTurnIdSequence(0);
private activeForegroundTurnId: string | null = null;
readonly steerActiveTurn: AgentSession["steerActiveTurn"];
@@ -455,7 +456,7 @@ class FakeAgentSession implements AgentSession {
throw new Error("A foreground turn is already active");
}
const turnId = `fake-turn-${this.nextTurnOrdinal++}`;
const turnId = this.nextTurnId("fake");
this.activeForegroundTurnId = turnId;
this.onStartTurn?.(prompt, this.config);
+1 -1
View File
@@ -94,7 +94,7 @@ type PluginTurnOutcome =
| Field | Shape / meaning |
| ------------ | -------------------------------------------------------------------------------------- |
| `turnId` | Provider-reported `string` or `null`; can repeat after a session reopens |
| `turnId` | Provider-reported `string` or `null`; unique for the agent's lifetime |
| `timeline` | Deprecated, always `[]`; fetch history with the SDK. |
| `request` | SDK `AgentPermissionRequest`; `kind` is `tool`, `plan`, `question`, `mode`, or `other` |
| `resolution` | SDK `AgentPermissionResponse` |
+1 -1
View File
@@ -92,7 +92,7 @@ type PluginTurnOutcome =
| 字段 | 结构 / 含义 |
| ------------ | ---------------------------------------------------------------------------------------- |
| `turnId` | Provider 报告的 `string` 或 `null`;会话重新打开后可能重复 |
| `turnId` | Provider 报告的 `string` 或 `null`;在 agent 的整个生命周期内唯一 |
| `timeline` | 已弃用,始终为 `[]`;通过 SDK 获取历史。 |
| `request` | SDK 的 `AgentPermissionRequest`;`kind` 为 `tool`、`plan`、`question`、`mode` 或 `other` |
| `resolution` | SDK 的 `AgentPermissionResponse` |