From af0b7a39a8ef3940ef3e356b277f8c66813388bf Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 2 Nov 2026 04:36:52 +0100 Subject: [PATCH] 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. --- .../providers/claude/agent.redesign.test.ts | 15 +-- .../agent/providers/claude/agent.test.ts | 24 ++++ .../server/agent/providers/claude/agent.ts | 5 +- .../providers/codex-app-server-agent.test.ts | 27 +++- .../agent/providers/codex-app-server-agent.ts | 5 +- .../agent/providers/opencode-agent.test.ts | 118 ++++++++++++------ .../server/agent/providers/opencode-agent.ts | 5 +- packages/server/src/server/agent/turn-ids.ts | 13 ++ .../server/test-utils/fake-agent-client.ts | 5 +- public-docs/plugins/hooks.md | 2 +- public-docs/plugins/hooks.zh-CN.md | 2 +- 11 files changed, 160 insertions(+), 61 deletions(-) create mode 100644 packages/server/src/server/agent/turn-ids.ts 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` |