From 26d92d73fff9dd5df1f39003d692315e6ca02734 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Mon, 9 Nov 2026 21:36:10 -0300 Subject: [PATCH] fix(server): imported ACP history keeps the agent's thinking as reasoning The ACP adapter stores replayed thought chunks as assistant messages, so an imported thread showed them as replies grouped under "Worked for". The snapshot now lists which messages are thoughts and the importer writes them as run-less reasoning items. Co-Authored-By: Alan Justino --- .../Adapters/AcpAdapterV2.test.ts | 11 ++++- .../src/project/AgentSessionImporter.test.ts | 14 +++++- .../src/project/AgentSessionImporter.ts | 44 +++++++++++++++++-- packages/provider-acp/src/server/adapter.ts | 13 ++++++ .../src/server/ProviderAdapter.ts | 2 + 5 files changed, 79 insertions(+), 5 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index 89b3187ffbeca..f3f5481db96f1 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -2620,12 +2620,21 @@ describe("AcpAdapterV2", () => { content: { type: "text", text: "after plan" }, }, }); + yield* sessionUpdateHandler!({ + sessionId: "mock-session-1", + update: { + sessionUpdate: "agent_thought_chunk", + content: { type: "text", text: "weighing it" }, + }, + }); const snapshot = yield* runtime.readThreadSnapshot({ providerThread }); assert.deepEqual( snapshot.messages.map((message) => message.text), - ["before plan", "after plan"], + ["before plan", "after plan", "weighing it"], ); + // Thoughts replay as assistant text; the snapshot says which ones they are. + assert.deepEqual(snapshot.reasoningMessageIds, [snapshot.messages[2]!.id]); }).pipe(Effect.provide(layerTest), Effect.scoped), ); diff --git a/apps/server/src/project/AgentSessionImporter.test.ts b/apps/server/src/project/AgentSessionImporter.test.ts index f10b7477bd877..f50e6021c6d6c 100644 --- a/apps/server/src/project/AgentSessionImporter.test.ts +++ b/apps/server/src/project/AgentSessionImporter.test.ts @@ -489,8 +489,10 @@ it.effect( providerThread, providerTurns: [], runtimeRequests: [], + reasoningMessageIds: [replayed("assistant", "Check the build log").id], messages: [ replayed("user", "Fix the build"), + replayed("assistant", "Check the build log"), replayed("assistant", "Fixed it"), replayed("assistant", " "), ], @@ -524,7 +526,7 @@ it.effect( ).toBeUndefined(); failRead = false; - expect(yield* importHistory).toBe(2); + expect(yield* importHistory).toBe(3); expect(resolved).toEqual([ CODEX_MODEL_SELECTION.instanceId, CODEX_MODEL_SELECTION.instanceId, @@ -551,6 +553,16 @@ it.effect( ["user_message", null, "Fix the build"], ["assistant_message", null, "Fixed it"], ]); + // Replayed thinking is a reasoning item, not an assistant reply. + const activity = yield* projections.getTimelinePage(threadId, { + view: "activity", + limit: 10, + }); + expect( + activity.items.flatMap(({ item }) => + item.type === "reasoning" ? [[item.runId, item.text]] : [], + ), + ).toEqual([[null, "Check the build log"]]); }).pipe(Effect.scoped, Effect.provide(persistence)), ); diff --git a/apps/server/src/project/AgentSessionImporter.ts b/apps/server/src/project/AgentSessionImporter.ts index f4119d87a5739..fd830cf7d76c4 100644 --- a/apps/server/src/project/AgentSessionImporter.ts +++ b/apps/server/src/project/AgentSessionImporter.ts @@ -107,10 +107,17 @@ function dateTime(value: string): DateTime.Utc { return DateTime.makeUnsafe(value); } +/** A native-history entry; `reasoning` is the agent's thinking, shown apart from its replies. */ +interface NativeHistoryMessage { + readonly role: AgentSessionScanner.AgentSessionThreadMessage["role"] | "reasoning"; + readonly text: string; + readonly createdAt: string; +} + function messageEvents(input: { readonly threadId: ThreadId; readonly index: number; - readonly message: AgentSessionScanner.AgentSessionThreadMessage; + readonly message: NativeHistoryMessage; }): ReadonlyArray { const ordinal = input.index + 1; const suffix = String(input.index).padStart(6, "0"); @@ -119,6 +126,36 @@ function messageEvents(input: { `${IMPORT_EVENT_PREFIX}:turn-item:${input.threadId}:${suffix}`, ); const at = dateTime(input.message.createdAt); + if (input.message.role === "reasoning") { + const reasoning: OrchestrationV2TurnItem = { + id: turnItemId, + threadId: input.threadId, + runId: null, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal, + status: "completed", + title: null, + startedAt: at, + completedAt: at, + updatedAt: at, + type: "reasoning", + text: input.message.text, + streaming: false, + }; + return [ + { + id: EventId.make(`${IMPORT_EVENT_PREFIX}:turn-item:${input.threadId}:${suffix}`), + type: "turn-item.updated", + threadId: input.threadId, + occurredAt: at, + payload: reasoning, + }, + ]; + } const message: OrchestrationV2ConversationMessage = { createdBy: input.message.role === "user" ? "user" : "agent", creationSource: "server", @@ -494,7 +531,7 @@ const make = Effect.gen(function* () { ); // Thread commands write whole thread rows, so hold their lock from the // re-read through the write to keep both this marker and their changes. - const commit = (messages: ReadonlyArray) => + const commit = (messages: ReadonlyArray) => threadCommands.withLock( input.threadId, Effect.gen(function* () { @@ -554,11 +591,12 @@ const make = Effect.gen(function* () { Effect.timeout(NATIVE_HISTORY_READ_TIMEOUT), Effect.scoped, ); + const reasoningIds = new Set(snapshot.reasoningMessageIds ?? []); const messages = snapshot.messages.flatMap((message) => (message.role === "user" || message.role === "assistant") && message.text.trim() !== "" ? [ { - role: message.role, + role: reasoningIds.has(message.id) ? ("reasoning" as const) : message.role, text: message.text, createdAt: DateTime.formatIso(message.createdAt), }, diff --git a/packages/provider-acp/src/server/adapter.ts b/packages/provider-acp/src/server/adapter.ts index 730c5d589aff1..e4bd599249988 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -1641,6 +1641,8 @@ type PendingRuntimeRequest = { interface SnapshotMessageState { readonly order: Array; readonly messages: Map; + /** Keys of `messages` loaded from thought chunks; they are stored as assistant text. */ + readonly reasoningKeys?: ReadonlySet; loadingRole: "user" | "assistant" | "thought" | null; loadingMessageId: string | null; loadingIndex: number; @@ -4548,6 +4550,11 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( return { order: current.order.includes(key) ? current.order : [...current.order, key], messages, + ...(role === "thought" + ? { reasoningKeys: new Set([...(current.reasoningKeys ?? []), key]) } + : current.reasoningKeys === undefined + ? {} + : { reasoningKeys: current.reasoningKeys }), loadingRole: role, loadingMessageId: sourceMessageId ?? (startsNew ? null : current.loadingMessageId), loadingIndex, @@ -9279,6 +9286,12 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( const message = state.messages.get(key); return message === undefined ? [] : [message]; }), + reasoningMessageIds: state.order.flatMap((key) => { + const message = state.messages.get(key); + return message !== undefined && state.reasoningKeys?.has(key) === true + ? [message.id] + : []; + }), runtimeRequests: [], providerPayload: { protocol: ACP_PROTOCOL, sessionId }, }; diff --git a/packages/provider-core/src/server/ProviderAdapter.ts b/packages/provider-core/src/server/ProviderAdapter.ts index e94740e34712f..8e65b18ed5b64 100644 --- a/packages/provider-core/src/server/ProviderAdapter.ts +++ b/packages/provider-core/src/server/ProviderAdapter.ts @@ -517,6 +517,8 @@ export interface ProviderAdapterV2ThreadSnapshot { readonly providerThread: OrchestrationV2ProviderThread; readonly providerTurns: ReadonlyArray; readonly messages: ReadonlyArray; + /** Ids in `messages` that hold the agent's reasoning, not its reply. */ + readonly reasoningMessageIds?: ReadonlyArray; readonly runtimeRequests: ReadonlyArray; readonly providerPayload?: unknown; }