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; }