diff --git a/src/browser/features/Tools/AdvisorToolCall.test.tsx b/src/browser/features/Tools/AdvisorToolCall.test.tsx index 0aca0cf9ea..ec11f862d0 100644 --- a/src/browser/features/Tools/AdvisorToolCall.test.tsx +++ b/src/browser/features/Tools/AdvisorToolCall.test.tsx @@ -20,6 +20,13 @@ const useAdvisorToolLivePhaseMock = mock( ): WorkspaceStoreModule.AdvisorLivePhaseState | undefined => undefined ); +const useAdvisorToolLiveReasoningMock = mock( + ( + _workspaceId: string | undefined, + _toolCallId: string | undefined + ): WorkspaceStoreModule.AdvisorLiveReasoningState | null => null +); + /* eslint-disable @typescript-eslint/no-require-imports */ const actualWorkspaceStore = require("@/browser/stores/WorkspaceStore?real=1") as typeof WorkspaceStoreModule; @@ -29,6 +36,7 @@ void mock.module("@/browser/stores/WorkspaceStore", () => ({ ...actualWorkspaceStore, useAdvisorToolLiveOutput: useAdvisorToolLiveOutputMock, useAdvisorToolLivePhase: useAdvisorToolLivePhaseMock, + useAdvisorToolLiveReasoning: useAdvisorToolLiveReasoningMock, })); void mock.module("./Shared/ElapsedTimeDisplay", () => ({ @@ -77,6 +85,7 @@ describe("AdvisorToolCall", () => { useAdvisorToolLiveOutputMock.mockReset(); useAdvisorToolLivePhaseMock.mockReset(); + useAdvisorToolLiveReasoningMock.mockReset(); }); afterEach(() => { @@ -165,6 +174,29 @@ describe("AdvisorToolCall", () => { expect(view.getByText("Streamed partial advice")).toBeTruthy(); }); + test("renders live advisor reasoning separately from live advice", () => { + useAdvisorToolLivePhaseMock.mockReturnValue({ + phase: "waiting_for_response", + timestamp: 1, + }); + useAdvisorToolLiveReasoningMock.mockReturnValue({ + text: "Considering the risky edge case", + timestamp: 2, + }); + useAdvisorToolLiveOutputMock.mockReturnValue({ + text: "Prefer the safer path", + timestamp: 3, + }); + + const view = renderAdvisorToolCall({ status: "executing" }); + + expect(useAdvisorToolLiveReasoningMock).toHaveBeenCalledWith("workspace-1", "advisor-call-1"); + expect(view.getByText("Thinking")).toBeTruthy(); + expect(view.getByText("Considering the risky edge case")).toBeTruthy(); + expect(view.getByText("Advice")).toBeTruthy(); + expect(view.getByText("Prefer the safer path")).toBeTruthy(); + }); + test("collapses back to the settled default when execution completes", () => { useAdvisorToolLivePhaseMock.mockReturnValue({ phase: "waiting_for_response", diff --git a/src/browser/features/Tools/AdvisorToolCall.tsx b/src/browser/features/Tools/AdvisorToolCall.tsx index 0422329bc9..972fa18183 100644 --- a/src/browser/features/Tools/AdvisorToolCall.tsx +++ b/src/browser/features/Tools/AdvisorToolCall.tsx @@ -7,6 +7,7 @@ import { type AdvisorLivePhaseState, useAdvisorToolLiveOutput, useAdvisorToolLivePhase, + useAdvisorToolLiveReasoning, } from "@/browser/stores/WorkspaceStore"; import { formatModelDisplayName } from "@/common/utils/ai/modelDisplay"; import { getModelName } from "@/common/utils/ai/models"; @@ -258,9 +259,14 @@ const AdvisorToolCallContent: React.FC = ({ // Streamed chunks need expansion to be visible. // Remount on settle so completed rows keep their collapsed default. const { expanded, toggleExpanded } = useToolExpansion(isExecutingWithoutResult); - const livePhase = useAdvisorToolLivePhase(workspaceId, toolCallId); - const liveOutput = useAdvisorToolLiveOutput(workspaceId, toolCallId); + const liveWorkspaceId = isExecutingWithoutResult ? workspaceId : undefined; + const liveToolCallId = isExecutingWithoutResult ? toolCallId : undefined; + const livePhase = useAdvisorToolLivePhase(liveWorkspaceId, liveToolCallId); + const liveOutput = useAdvisorToolLiveOutput(liveWorkspaceId, liveToolCallId); + const liveReasoning = useAdvisorToolLiveReasoning(liveWorkspaceId, liveToolCallId); const liveAdviceText = isExecutingWithoutResult && liveOutput?.text ? liveOutput.text : undefined; + const liveReasoningText = + isExecutingWithoutResult && liveReasoning?.text ? liveReasoning.text : undefined; const detailsText = advisorResult?.type === "advice" ? advisorResult.advice @@ -331,6 +337,15 @@ const AdvisorToolCallContent: React.FC = ({ )} + {advisorResult === null && liveReasoningText && ( + + Thinking +
+ +
+
+ )} + {advisorResult === null && liveAdviceText && ( Advice diff --git a/src/browser/stores/WorkspaceStore.test.ts b/src/browser/stores/WorkspaceStore.test.ts index 3c8f8718e8..5f7352bf6e 100644 --- a/src/browser/stores/WorkspaceStore.test.ts +++ b/src/browser/stores/WorkspaceStore.test.ts @@ -532,6 +532,19 @@ const advisorOutputEvent = ( timestamp: number ): WorkspaceChatMessage => ({ type: "advisor-output", workspaceId, toolCallId, text, timestamp }); +const advisorReasoningOutputEvent = ( + workspaceId: string, + toolCallId: string, + text: string, + timestamp: number +): WorkspaceChatMessage => ({ + type: "advisor-reasoning-output", + workspaceId, + toolCallId, + text, + timestamp, +}); + const taskCreatedEvent = ( workspaceId: string, toolCallId: string, @@ -1808,22 +1821,13 @@ describe("WorkspaceStore", () => { }); it("stays in starting state when a streaming lifecycle event lands before the stream is interruptible", async () => { - // Regression guard for the starting -> streaming flash: the backend can emit a - // "streaming" lifecycle event a frame before stream-start makes the stream - // interruptible. In that window canInterrupt is still false, so isStreamStarting - // must remain true (keyed off the pending start, not the lifecycle phase). - // Otherwise shouldShowStreamingBarrier (isStreamStarting || canInterrupt) drops - // to false for a frame and the streaming barrier unmounts then remounts. const workspaceId = "stream-starting-lifecycle-gap"; mockChatStreamFor(workspaceId, async function* () { yield { type: "caught-up" }; await Promise.resolve(); - // Trailing user message with no assistant reply -> optimistic pending start. yield createUserMessageEvent("lifecycle-gap-user", "hello", 1, 1_000); await Promise.resolve(); - // Stream reports "streaming" but stream-start (which populates the - // interruptible active stream) has not been applied yet. yield { type: "stream-lifecycle", workspaceId, @@ -1846,7 +1850,6 @@ describe("WorkspaceStore", () => { const state = store.getWorkspaceState(workspaceId); expect(state.canInterrupt).toBe(false); - // The key assertion: the barrier-visibility flag stays true across the gap. expect(state.isStreamStarting).toBe(true); }); @@ -4651,6 +4654,70 @@ describe("WorkspaceStore", () => { }); }); + describe("advisor-reasoning-output events", () => { + it("accumulates live advisor reasoning while the advisor tool is running", async () => { + const workspaceId = "advisor-reasoning-workspace-1"; + + mockChatScript([ + caughtUpEvent(), + Promise.resolve(), + advisorReasoningOutputEvent(workspaceId, "call-advisor-reasoning-1", "thinking ", 1), + advisorReasoningOutputEvent(workspaceId, "call-advisor-reasoning-1", "through risk", 2), + ]); + + createAndAddWorkspace(store, workspaceId); + + const hasLiveReasoning = await waitUntil( + () => + store.getAdvisorToolLiveReasoning(workspaceId, "call-advisor-reasoning-1")?.text === + "thinking through risk" + ); + expect(hasLiveReasoning).toBe(true); + + const live = store.getAdvisorToolLiveReasoning(workspaceId, "call-advisor-reasoning-1"); + expect(live).toEqual({ text: "thinking through risk", timestamp: 2 }); + expect(store.getAdvisorToolLiveReasoning(workspaceId, "call-advisor-reasoning-1")).toBe(live); + }); + + it("clears live advisor reasoning on advisor tool-call-end", async () => { + const workspaceId = "advisor-reasoning-workspace-2"; + let releaseToolEnd: (() => void) | undefined; + const waitForToolEnd = new Promise((resolve) => { + releaseToolEnd = resolve; + }); + + mockChatScript([ + caughtUpEvent(), + Promise.resolve(), + advisorReasoningOutputEvent(workspaceId, "call-advisor-reasoning-2", "partial thought", 1), + waitForToolEnd, + toolCallEndEvent( + workspaceId, + "call-advisor-reasoning-2", + "advisor", + { type: "advice", advice: "final advice" }, + { messageId: "m-advisor-reasoning-2", timestamp: 2 } + ), + ]); + + createAndAddWorkspace(store, workspaceId); + + const hasLiveReasoning = await waitUntil( + () => + store.getAdvisorToolLiveReasoning(workspaceId, "call-advisor-reasoning-2")?.text === + "partial thought" + ); + expect(hasLiveReasoning).toBe(true); + + releaseToolEnd?.(); + + const clearedLiveReasoning = await waitUntil( + () => store.getAdvisorToolLiveReasoning(workspaceId, "call-advisor-reasoning-2") === null + ); + expect(clearedLiveReasoning).toBe(true); + }); + }); + describe("task-created events", () => { it("exposes live taskId while the task tool is running", async () => { const workspaceId = "task-created-workspace-1"; diff --git a/src/browser/stores/WorkspaceStore.ts b/src/browser/stores/WorkspaceStore.ts index d60a4a844d..9261d175a4 100644 --- a/src/browser/stores/WorkspaceStore.ts +++ b/src/browser/stores/WorkspaceStore.ts @@ -44,6 +44,7 @@ import { isInitOutput, isInitStart, isAdvisorOutputEvent, + isAdvisorReasoningOutputEvent, isAdvisorPhaseEvent, isBashOutputEvent, isTaskCreatedEvent, @@ -231,11 +232,14 @@ export interface AdvisorLivePhaseState { timestamp: number; } -export interface AdvisorLiveOutputState { +export interface AdvisorLiveTextState { text: string; timestamp: number; } +export type AdvisorLiveOutputState = AdvisorLiveTextState; +export type AdvisorLiveReasoningState = AdvisorLiveTextState; + interface WorkspaceChatTransientState { caughtUp: boolean; isHydratingTranscript: boolean; @@ -245,6 +249,7 @@ interface WorkspaceChatTransientState { queuedMessage: QueuedMessage | null; liveBashOutput: Map; liveAdvisorOutput: Map; + liveAdvisorReasoning: Map; liveAdvisorPhase: Map; liveTaskIds: Map; autoRetryStatus: AutoRetryStatus | null; @@ -342,6 +347,27 @@ function getBufferedActiveStreamStart( }; } +function appendAdvisorLiveText( + liveTextByToolCallId: Map, + toolCallId: string, + chunk: string, + timestamp: number +): boolean { + const prev = liveTextByToolCallId.get(toolCallId); + const appendedText = `${prev?.text ?? ""}${chunk}`; + const text = + appendedText.length > ADVISOR_LIVE_OUTPUT_MAX_CHARS + ? appendedText.slice(-ADVISOR_LIVE_OUTPUT_MAX_CHARS) + : appendedText; + + if (prev?.text === text && prev.timestamp === timestamp) { + return false; + } + + liveTextByToolCallId.set(toolCallId, { text, timestamp }); + return true; +} + function createInitialChatTransientState(): WorkspaceChatTransientState { return { caughtUp: false, @@ -352,6 +378,7 @@ function createInitialChatTransientState(): WorkspaceChatTransientState { queuedMessage: null, liveBashOutput: new Map(), liveAdvisorOutput: new Map(), + liveAdvisorReasoning: new Map(), liveAdvisorPhase: new Map(), liveTaskIds: new Map(), autoRetryStatus: null, @@ -854,6 +881,7 @@ export class WorkspaceStore { // Cleanup ephemeral advisor/task state once the actual tool result is available. if (toolCallEnd.toolName === "advisor") { transient?.liveAdvisorOutput.delete(toolCallEnd.toolCallId); + transient?.liveAdvisorReasoning.delete(toolCallEnd.toolCallId); transient?.liveAdvisorPhase.delete(toolCallEnd.toolCallId); } if (toolCallEnd.toolName === "task") { @@ -1600,7 +1628,13 @@ export class WorkspaceStore { ): void { const transient = this.chatTransientState.get(workspaceId); if (!transient) return; - if (transient.liveBashOutput.size === 0 && transient.liveAdvisorOutput.size === 0) return; + if ( + transient.liveBashOutput.size === 0 && + transient.liveAdvisorOutput.size === 0 && + transient.liveAdvisorReasoning.size === 0 + ) { + return; + } const activeBashToolCallIds = new Set(); const activeAdvisorToolCallIds = new Set(); @@ -1621,6 +1655,12 @@ export class WorkspaceStore { } } + for (const toolCallId of Array.from(transient.liveAdvisorReasoning.keys())) { + if (!activeAdvisorToolCallIds.has(toolCallId)) { + transient.liveAdvisorReasoning.delete(toolCallId); + } + } + for (const toolCallId of Array.from(transient.liveAdvisorOutput.keys())) { if (!activeAdvisorToolCallIds.has(toolCallId)) { transient.liveAdvisorOutput.delete(toolCallId); @@ -1662,6 +1702,15 @@ export class WorkspaceStore { return state ?? null; } + getAdvisorToolLiveReasoning( + workspaceId: string, + toolCallId: string + ): AdvisorLiveReasoningState | null { + const state = this.chatTransientState.get(workspaceId)?.liveAdvisorReasoning.get(toolCallId); + + return state ?? null; + } + getAdvisorToolLivePhase( workspaceId: string, toolCallId: string @@ -1804,18 +1853,8 @@ export class WorkspaceStore { aggregatorRecency === null ? (activity?.recency ?? null) : Math.max(aggregatorRecency, activity?.recency ?? aggregatorRecency); - // User rationale: a brand-new chat should show its startup barrier immediately instead of - // flashing "Catching up"/"No Messages Yet" while the very first send is still in flight. - // The aggregator owns both normal user-message startup and the optimistic new-chat handoff, - // so the workspace only needs to ask whether the active transcript still has a pending start. - // A turn is "starting" from the optimistic pending-start until the stream - // becomes interruptible. Keying off pendingStreamStartTime — which every - // terminal handler (end/abort/error) clears — keeps the streaming barrier - // continuously mounted across the starting -> streaming handoff with no gap. - // Keying off streamLifecycle.phase instead would risk both a flash (a - // "streaming" lifecycle event can land a frame before stream-start populates - // the active stream, leaving neither flag set) and a stuck barrier - // (handleStreamEnd leaves the lifecycle snapshot non-idle). + // Keep the startup barrier mounted until stream-start makes the turn interruptible; + // stream-lifecycle can report "streaming" slightly earlier. const isStreamStarting = isActiveWorkspace && !canInterrupt && @@ -1930,9 +1969,7 @@ export class WorkspaceStore { ? true : (activity?.streaming ?? hasInterruptibleActiveStream); const activePendingStreamStartTime = isActiveWorkspace ? pendingStreamStartTime : null; - // Mirror getWorkspaceState's starting derivation: pendingStreamStartTime is the - // gap-free, terminal-cleared signal that a turn is in flight but not yet - // interruptible. See the rationale in getWorkspaceState above. + // Match getWorkspaceState so the shell does not flash between lifecycle and stream-start. const isStreamStarting = isActiveWorkspace && !canInterrupt && @@ -3869,6 +3906,7 @@ export class WorkspaceStore { data.type in this.bufferedEventHandlers || data.type === "bash-output" || data.type === "advisor-output" || + data.type === "advisor-reasoning-output" || data.type === "advisor-phase" || data.type === "task-created" ); @@ -3949,6 +3987,7 @@ export class WorkspaceStore { // replaces history, reports no active stream, or reports a different stream ID. transient.liveBashOutput.clear(); transient.liveAdvisorOutput.clear(); + transient.liveAdvisorReasoning.clear(); transient.liveAdvisorPhase.clear(); transient.liveTaskIds.clear(); } @@ -4140,19 +4179,35 @@ export class WorkspaceStore { if (data.text.length === 0) return; const transient = this.assertChatTransientState(workspaceId); - const prev = transient.liveAdvisorOutput.get(data.toolCallId); - const appendedText = `${prev?.text ?? ""}${data.text}`; - const text = - appendedText.length > ADVISOR_LIVE_OUTPUT_MAX_CHARS - ? appendedText.slice(-ADVISOR_LIVE_OUTPUT_MAX_CHARS) - : appendedText; + if ( + !appendAdvisorLiveText( + transient.liveAdvisorOutput, + data.toolCallId, + data.text, + data.timestamp + ) + ) { + return; + } + + this.scheduleStreamingStateBump(workspaceId); + return; + } - if (prev?.text === text && prev.timestamp === data.timestamp) return; + if (isAdvisorReasoningOutputEvent(data)) { + if (data.text.length === 0) return; - transient.liveAdvisorOutput.set(data.toolCallId, { - text, - timestamp: data.timestamp, - }); + const transient = this.assertChatTransientState(workspaceId); + if ( + !appendAdvisorLiveText( + transient.liveAdvisorReasoning, + data.toolCallId, + data.text, + data.timestamp + ) + ) { + return; + } this.scheduleStreamingStateBump(workspaceId); return; @@ -4436,6 +4491,27 @@ export function useAdvisorToolLiveOutput( ); } +/** + * Hook to get UI-only live reasoning for a running advisor tool call. + */ +export function useAdvisorToolLiveReasoning( + workspaceId: string | undefined, + toolCallId: string | undefined +): AdvisorLiveReasoningState | null { + const store = getStoreInstance(); + + return useSyncExternalStore( + (listener) => { + if (!workspaceId) return () => undefined; + return store.subscribeKey(workspaceId, listener); + }, + () => { + if (!workspaceId || !toolCallId) return null; + return store.getAdvisorToolLiveReasoning(workspaceId, toolCallId); + } + ); +} + /** * Hook to get UI-only live advisor phase for a running advisor tool call. */ diff --git a/src/common/orpc/schemas.ts b/src/common/orpc/schemas.ts index 651822c8dc..70b66f69ab 100644 --- a/src/common/orpc/schemas.ts +++ b/src/common/orpc/schemas.ts @@ -230,6 +230,7 @@ export { BashOutputEventSchema, TaskCreatedEventSchema, AdvisorOutputEventSchema, + AdvisorReasoningOutputEventSchema, AdvisorPhaseEventSchema, UpdateStatusSchema, UsageDeltaEventSchema, diff --git a/src/common/orpc/schemas/stream.ts b/src/common/orpc/schemas/stream.ts index 7eb0c9d4c4..f7c14846e1 100644 --- a/src/common/orpc/schemas/stream.ts +++ b/src/common/orpc/schemas/stream.ts @@ -391,6 +391,20 @@ export const AdvisorOutputEventSchema = z.object({ timestamp: z.number().meta({ description: "When output was received (Date.now())" }), }); +/** + * UI-only incremental reasoning from the advisor tool. + * + * This is intentionally NOT part of the tool result returned to the model. + * It is streamed over workspace.onChat so users can see advisor thinking while it is generated. + */ +export const AdvisorReasoningOutputEventSchema = z.object({ + type: z.literal("advisor-reasoning-output"), + workspaceId: z.string(), + toolCallId: z.string(), + text: z.string(), + timestamp: z.number().meta({ description: "When reasoning output was received (Date.now())" }), +}); + /** * UI-only notification that a task tool call has created a child workspace. * @@ -603,6 +617,7 @@ export const WorkspaceChatMessageSchema = z.discriminatedUnion("type", [ ToolCallEndEventSchema, BashOutputEventSchema, AdvisorOutputEventSchema, + AdvisorReasoningOutputEventSchema, TaskCreatedEventSchema, AdvisorPhaseEventSchema, // Reasoning events diff --git a/src/common/orpc/types.ts b/src/common/orpc/types.ts index 0a84a40ba7..99eb2afd5e 100644 --- a/src/common/orpc/types.ts +++ b/src/common/orpc/types.ts @@ -11,6 +11,7 @@ import type { ToolCallDeltaEvent, ToolCallEndEvent, AdvisorOutputEvent, + AdvisorReasoningOutputEvent, AdvisorPhaseEvent, BashOutputEvent, TaskCreatedEvent, @@ -109,6 +110,12 @@ export function isAdvisorOutputEvent(msg: WorkspaceChatMessage): msg is AdvisorO return (msg as { type?: string }).type === "advisor-output"; } +export function isAdvisorReasoningOutputEvent( + msg: WorkspaceChatMessage +): msg is AdvisorReasoningOutputEvent { + return (msg as { type?: string }).type === "advisor-reasoning-output"; +} + export function isTaskCreatedEvent(msg: WorkspaceChatMessage): msg is TaskCreatedEvent { return (msg as { type?: string }).type === "task-created"; } diff --git a/src/common/types/stream.ts b/src/common/types/stream.ts index 16c9548c68..06c0abb148 100644 --- a/src/common/types/stream.ts +++ b/src/common/types/stream.ts @@ -26,6 +26,7 @@ import type { ToolCallStartEventSchema, BashOutputEventSchema, AdvisorOutputEventSchema, + AdvisorReasoningOutputEventSchema, TaskCreatedEventSchema, AdvisorPhaseEventSchema, UsageDeltaEventSchema, @@ -66,6 +67,7 @@ export type ErrorEvent = z.infer; export type BashOutputEvent = z.infer; export type AdvisorOutputEvent = z.infer; +export type AdvisorReasoningOutputEvent = z.infer; export type TaskCreatedEvent = z.infer; export type AdvisorPhaseEvent = z.infer; export type ToolCallStartEvent = z.infer; diff --git a/src/node/acp/agent.ts b/src/node/acp/agent.ts index 982d22ab31..9d38445e48 100644 --- a/src/node/acp/agent.ts +++ b/src/node/acp/agent.ts @@ -1518,6 +1518,7 @@ export class MuxAgent implements Agent { event.type === "usage-delta" || event.type === "session-usage-delta" || event.type === "advisor-output" || + event.type === "advisor-reasoning-output" || event.type === "bash-output" || event.type === "init-output" || // Drop replay history messages under saturation, but keep live message diff --git a/src/node/acp/streamTranslator.ts b/src/node/acp/streamTranslator.ts index 4838262cdd..1603f5a175 100644 --- a/src/node/acp/streamTranslator.ts +++ b/src/node/acp/streamTranslator.ts @@ -181,19 +181,9 @@ export class StreamTranslator { ]; } - case "advisor-output": { - return [ - { - sessionUpdate: "tool_call_update", - toolCallId: event.toolCallId, - status: "in_progress", - content: [textToolContent(event.text)], - _meta: { - source: "advisor-output", - timestamp: event.timestamp, - }, - }, - ]; + case "advisor-output": + case "advisor-reasoning-output": { + return this.translateAdvisorTextToolCallUpdate(event); } case "error": @@ -445,6 +435,23 @@ export class StreamTranslator { return [textToolContent(text)]; } + private translateAdvisorTextToolCallUpdate( + event: Extract + ): SessionUpdate[] { + return [ + { + sessionUpdate: "tool_call_update", + toolCallId: event.toolCallId, + status: "in_progress", + content: [textToolContent(event.text)], + _meta: { + source: event.type, + timestamp: event.timestamp, + }, + }, + ]; + } + private translatePlanUpdate( sessionId: string, toolName: string, diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index a2c4b335ca..8bc7d21370 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -3474,11 +3474,8 @@ export class AgentSession { this.activeStreamHadPostCompactionInjection = postCompactionAttachments !== null && postCompactionAttachments.length > 0; - // Enforce thinking policy for the specified model (single source of truth) - // This ensures model-specific requirements are met regardless of where the request originates. - // Apply the per-model minimum thinking floor here so every client (desktop, mobile, ACP) - // honors it: a stored/below-floor "off" is clamped up to the model's minimum (default medium). - // config may be a partial mock in tests, so read loadConfigOrDefault defensively. + // Apply per-model thinking floors once so desktop, mobile, and ACP requests match. + // Tests may provide partial config mocks, so read overrides only when available. const maybeConfig = this.config as Config & { loadConfigOrDefault?: () => { minThinkingLevelByModel?: Record; @@ -4363,6 +4360,10 @@ export class AgentSession { this.markActiveStreamHadAnyOutput(); this.emitChatEvent(payload); }); + forward("advisor-reasoning-output", (payload) => { + this.markActiveStreamHadAnyOutput(); + this.emitChatEvent(payload); + }); forward("task-created", (payload) => { this.emitChatEvent(payload); }); diff --git a/src/node/services/tools/advisor.test.ts b/src/node/services/tools/advisor.test.ts index 2237e148d2..c6aff9fd89 100644 --- a/src/node/services/tools/advisor.test.ts +++ b/src/node/services/tools/advisor.test.ts @@ -232,7 +232,8 @@ describe("advisor tool", () => { { type: "text-delta", delta: "with " }, { type: "text-delta", textDelta: "the risky edge first." }, { type: "text-delta", text: "" }, - { type: "reasoning-delta", delta: "hidden reasoning" }, + { type: "reasoning", text: "hidden reasoning" }, + { type: "reasoning-delta", delta: " plus delta" }, ], usage: { inputTokens: 40, @@ -270,6 +271,22 @@ describe("advisor tool", () => { .filter((call) => call[0].type === "advisor-output") .map((call) => call[0]) ).toHaveLength(3); + expect(emitChatEvent).toHaveBeenCalledWith( + expect.objectContaining({ + type: "advisor-reasoning-output", + workspaceId: "workspace-1", + toolCallId: "test-call-id", + text: "hidden reasoning", + }) + ); + expect(emitChatEvent).toHaveBeenCalledWith( + expect.objectContaining({ + type: "advisor-reasoning-output", + workspaceId: "workspace-1", + toolCallId: "test-call-id", + text: " plus delta", + }) + ); }); it("falls back to the raw transcript when there is no question or same-step snapshot", async () => { diff --git a/src/node/services/tools/advisor.ts b/src/node/services/tools/advisor.ts index 83940bcec9..0f6685c500 100644 --- a/src/node/services/tools/advisor.ts +++ b/src/node/services/tools/advisor.ts @@ -13,7 +13,11 @@ import { buildProviderOptions } from "@/common/utils/ai/providerOptions"; import { extractChunkDeltaText } from "@/common/utils/ai/streamChunks"; import { getErrorMessage } from "@/common/utils/errors"; import { sanitizeErrorMessageForDisplay } from "@/common/utils/providerOutputSanitization"; -import type { AdvisorOutputEvent, AdvisorPhaseEvent } from "@/common/types/stream"; +import type { + AdvisorOutputEvent, + AdvisorPhaseEvent, + AdvisorReasoningOutputEvent, +} from "@/common/types/stream"; import { AdvisorToolInputSchema, TOOL_DEFINITIONS } from "@/common/utils/tools/toolDefinitions"; import type { AdvisorToolCallSnapshot, ToolConfiguration } from "@/common/utils/tools/tools"; import { log } from "@/node/services/log"; @@ -78,20 +82,35 @@ function buildAdvisorHandoffMessage( }; } -function getAdvisorTextDelta(chunk: unknown): string | undefined { +const ADVISOR_DELTA_TEXT_FIELDS = ["text", "delta", "textDelta"] as const; +const ADVISOR_TEXT_DELTA_TYPES = ["text-delta", "text"] as const; +const ADVISOR_REASONING_DELTA_TYPES = ["reasoning-delta", "reasoning"] as const; + +function getAdvisorChunkDelta( + chunk: unknown, + acceptedTypes: readonly string[] +): string | undefined { if (typeof chunk !== "object" || chunk === null) { return undefined; } const record = chunk as Record; - if (record.type !== "text-delta" && record.type !== "text") { + if (typeof record.type !== "string" || !acceptedTypes.includes(record.type)) { return undefined; } - const text = extractChunkDeltaText(record, ["text", "delta", "textDelta"]); + const text = extractChunkDeltaText(record, ADVISOR_DELTA_TEXT_FIELDS); return text.length > 0 ? text : undefined; } +function getAdvisorTextDelta(chunk: unknown): string | undefined { + return getAdvisorChunkDelta(chunk, ADVISOR_TEXT_DELTA_TYPES); +} + +function getAdvisorReasoningDelta(chunk: unknown): string | undefined { + return getAdvisorChunkDelta(chunk, ADVISOR_REASONING_DELTA_TYPES); +} + export function createAdvisorTool(config: ToolConfiguration): Tool { assert(config.advisorRuntime, "advisorRuntime must be set when advisor tool is registered"); @@ -171,6 +190,21 @@ export function createAdvisorTool(config: ToolConfiguration): Tool { } satisfies AdvisorOutputEvent); }; + const emitAdvisorReasoningOutput = (text: string): void => { + assert(text.length > 0, "advisor reasoning output chunks must be non-empty"); + if (!config.emitChatEvent || !config.workspaceId || !toolCallId) { + return; + } + + config.emitChatEvent({ + type: "advisor-reasoning-output", + workspaceId: config.workspaceId, + toolCallId, + text, + timestamp: Date.now(), + } satisfies AdvisorReasoningOutputEvent); + }; + emitAdvisorPhase("preparing_context"); if (runtime.maxUsesPerTurn !== null && usesThisTurn >= runtime.maxUsesPerTurn) { @@ -216,6 +250,12 @@ export function createAdvisorTool(config: ToolConfiguration): Tool { advisorStreamError = error; }, onChunk: ({ chunk }) => { + const reasoningText = getAdvisorReasoningDelta(chunk); + if (reasoningText != null) { + emitAdvisorReasoningOutput(reasoningText); + return; + } + const text = getAdvisorTextDelta(chunk); if (text == null) { return;