diff --git a/packages/server/src/server/agent/providers/claude/agent.redesign.test.ts b/packages/server/src/server/agent/providers/claude/agent.redesign.test.ts index 5393926520..c54b42af8e 100644 --- a/packages/server/src/server/agent/providers/claude/agent.redesign.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.redesign.test.ts @@ -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, 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( diff --git a/packages/server/src/server/agent/providers/claude/agent.test.ts b/packages/server/src/server/agent/providers/claude/agent.test.ts index 758fad2e8f..6385f925a8 100644 --- a/packages/server/src/server/agent/providers/claude/agent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.test.ts @@ -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"); diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 749bbe016f..59aff18503 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -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 { diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index 9986357cf4..f0a7feca14 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -1281,6 +1281,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 +4634,7 @@ describe("Codex app-server provider", () => { await expect(child).resolves.toMatchObject({ type: "provider_subagent", provider: "codex", - turnId: "codex-turn-0", + turnId: expect.stringMatching(/^codex-turn-[0-9a-f]+-0$/), event: { type: "upsert", id: "legacy-only-child-thread", @@ -4621,7 +4644,7 @@ describe("Codex app-server provider", () => { await expect(spawn).resolves.toMatchObject({ type: "timeline", provider: "codex", - turnId: "codex-turn-0", + turnId: expect.stringMatching(/^codex-turn-[0-9a-f]+-0$/), item: { type: "tool_call", callId: "spawn-legacy-only-child", diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index cc667b2494..26f36e7e74 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -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 = Promise.resolve(); diff --git a/packages/server/src/server/agent/providers/opencode-agent.test.ts b/packages/server/src/server/agent/providers/opencode-agent.test.ts index 8beed24be0..7bb6055761 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.test.ts @@ -32,6 +32,15 @@ import type { const EXPECTED_STARTUP_BUDGET_MS = 30_000; const EXPECTED_EVENT_READINESS_BUDGET_MS = 45_000; +/** Matches the ordinal of an OpenCode turn id; the instance nonce in the middle is random. */ +function openCodeTurnId(ordinal: number) { + return expect.stringMatching(new RegExp(`^opencode-turn-[0-9a-f]+-${ordinal}$`)); +} + +function isOpenCodeTurn(turnId: string | undefined, ordinal: number): boolean { + return new RegExp(`^opencode-turn-[0-9a-f]+-${ordinal}$`).test(turnId ?? ""); +} + function tmpCwd(): string { const dir = mkdtempSync(path.join(os.tmpdir(), "opencode-agent-test-")); try { @@ -1638,10 +1647,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 +1842,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 +2382,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 +2679,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 +2744,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 +2764,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 +2881,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 +2896,7 @@ 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" && isOpenCodeTurn(event.turnId, 1), ); expect(autonomousCompletion?.type).toBe("turn_completed"); if (autonomousCompletion?.type === "turn_completed") { @@ -2867,7 +2905,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 +2913,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 +2992,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 +3000,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 +3096,7 @@ describe("OpenCode adapter startTurn error handling", () => { expect(events).toContainEqual({ type: "turn_started", provider: "opencode", - turnId: "opencode-turn-1", + turnId: openCodeTurnId(1), }), ); @@ -3767,7 +3805,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 +4060,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 +4760,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 +4798,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 +4812,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 +4919,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 +4965,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 +5041,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 +5156,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 +5212,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), }), ); }); diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 6fef290d91..58a65dbdb8 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -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(); 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 { diff --git a/packages/server/src/server/agent/turn-ids.ts b/packages/server/src/server/agent/turn-ids.ts new file mode 100644 index 0000000000..f3d553b0e3 --- /dev/null +++ b/packages/server/src/server/agent/turn-ids.ts @@ -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++}`; +} diff --git a/packages/server/src/server/test-utils/fake-agent-client.ts b/packages/server/src/server/test-utils/fake-agent-client.ts index 4304a767e1..8a3006d420 100644 --- a/packages/server/src/server/test-utils/fake-agent-client.ts +++ b/packages/server/src/server/test-utils/fake-agent-client.ts @@ -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 | 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); diff --git a/public-docs/plugins/hooks.md b/public-docs/plugins/hooks.md index b535821e62..cbf9f7f679 100644 --- a/public-docs/plugins/hooks.md +++ b/public-docs/plugins/hooks.md @@ -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` | diff --git a/public-docs/plugins/hooks.zh-CN.md b/public-docs/plugins/hooks.zh-CN.md index eecf370a29..08a937e595 100644 --- a/public-docs/plugins/hooks.zh-CN.md +++ b/public-docs/plugins/hooks.zh-CN.md @@ -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` |