Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions src/browser/features/Tools/AdvisorToolCall.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -29,6 +36,7 @@ void mock.module("@/browser/stores/WorkspaceStore", () => ({
...actualWorkspaceStore,
useAdvisorToolLiveOutput: useAdvisorToolLiveOutputMock,
useAdvisorToolLivePhase: useAdvisorToolLivePhaseMock,
useAdvisorToolLiveReasoning: useAdvisorToolLiveReasoningMock,
}));

void mock.module("./Shared/ElapsedTimeDisplay", () => ({
Expand Down Expand Up @@ -77,6 +85,7 @@ describe("AdvisorToolCall", () => {

useAdvisorToolLiveOutputMock.mockReset();
useAdvisorToolLivePhaseMock.mockReset();
useAdvisorToolLiveReasoningMock.mockReset();
});

afterEach(() => {
Expand Down Expand Up @@ -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",
Expand Down
19 changes: 17 additions & 2 deletions src/browser/features/Tools/AdvisorToolCall.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -258,9 +259,14 @@ const AdvisorToolCallContent: React.FC<AdvisorToolCallContentProps> = ({
// 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
Expand Down Expand Up @@ -331,6 +337,15 @@ const AdvisorToolCallContent: React.FC<AdvisorToolCallContentProps> = ({
</>
)}

{advisorResult === null && liveReasoningText && (
<DetailSection>
<DetailLabel>Thinking</DetailLabel>
<div className="bg-code-bg text-secondary rounded px-3 py-2 text-[12px] leading-relaxed">
<MarkdownRenderer content={liveReasoningText} preserveLineBreaks />
</div>
</DetailSection>
)}

{advisorResult === null && liveAdviceText && (
<DetailSection>
<DetailLabel>Advice</DetailLabel>
Expand Down
87 changes: 77 additions & 10 deletions src/browser/stores/WorkspaceStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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);
});

Expand Down Expand Up @@ -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<void>((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";
Expand Down
Loading
Loading