diff --git a/site/src/pages/AgentsPage/AgentDetail.stories.tsx b/site/src/pages/AgentsPage/AgentDetail.stories.tsx index d9589fb36e10e..1147c9bfb6348 100644 --- a/site/src/pages/AgentsPage/AgentDetail.stories.tsx +++ b/site/src/pages/AgentsPage/AgentDetail.stories.tsx @@ -653,7 +653,7 @@ export const WithSubagentCards: Story = { }, }; -/** Reasoning part without title renders inline (no disclosure). */ +/** Completed reasoning part renders inline. */ export const WithReasoningInline: Story = { parameters: { queries: buildQueries( @@ -687,7 +687,7 @@ export const WithReasoningInline: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); - // Reasoning text renders inline, not behind a disclosure. + // Reasoning text renders inline. expect(canvas.getByText("Reasoning body")).toBeInTheDocument(); expect(canvas.queryByRole("button", { name: "Thinking" })).toBeNull(); }, @@ -991,10 +991,9 @@ export const SidebarWithSingleRepo: Story = { }, }; /** - * Streaming reasoning part via WebSocket — renders collapsed and - * can be expanded on click. + * Streaming reasoning part via WebSocket — renders inline text. */ -export const StreamedReasoningCollapsed: Story = { +export const StreamedReasoning: Story = { parameters: { queries: buildQueries( { @@ -1015,7 +1014,6 @@ export const StreamedReasoningCollapsed: Story = { message_part: { part: { type: "reasoning", - title: "Plan migration", text: "Streaming reasoning body", }, }, @@ -1026,16 +1024,7 @@ export const StreamedReasoningCollapsed: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - const user = userEvent.setup(); - - const reasoningToggle = await canvas.findByRole("button", { - name: "Plan migration", - }); - expect(reasoningToggle).toHaveAttribute("aria-expanded", "false"); - - await user.click(reasoningToggle); - expect(reasoningToggle).toHaveAttribute("aria-expanded", "true"); await expect( canvas.findByText("Streaming reasoning body"), ).resolves.toBeInTheDocument(); diff --git a/site/src/pages/AgentsPage/AgentDetail/ConversationTimeline.tsx b/site/src/pages/AgentsPage/AgentDetail/ConversationTimeline.tsx index ee8b354ea6a27..8637cdf3048b9 100644 --- a/site/src/pages/AgentsPage/AgentDetail/ConversationTimeline.tsx +++ b/site/src/pages/AgentsPage/AgentDetail/ConversationTimeline.tsx @@ -17,7 +17,7 @@ import { TooltipContent, TooltipTrigger, } from "components/Tooltip/Tooltip"; -import { ChevronDownIcon, PencilIcon } from "lucide-react"; +import { PencilIcon } from "lucide-react"; import { type FC, Fragment, @@ -43,12 +43,10 @@ import type { const ReasoningDisclosure: FC<{ id: string; - title?: string; text: string; isStreaming?: boolean; urlTransform?: UrlTransform; -}> = ({ id, title, text, isStreaming = false, urlTransform }) => { - const [isOpen, setIsOpen] = useState(false); +}> = ({ id, text, isStreaming = false, urlTransform }) => { const { visibleText } = useSmoothStreamingText({ fullText: text, isStreaming, @@ -57,10 +55,8 @@ const ReasoningDisclosure: FC<{ }); const displayText = isStreaming ? visibleText : text; const hasText = displayText.trim().length > 0; - const label = title ?? "Thinking"; - const showStreamingPlaceholder = isStreaming && !hasText; - if (!title && hasText) { + if (hasText) { return (
); } - const labelContent = ( - - {showStreamingPlaceholder ? ( - Thinking... - ) : ( - label - )} - - ); return (
- {hasText ? ( - - ) : ( -
- {labelContent} -
- )} - {isOpen && hasText ? ( -
- - {displayText} - -
- ) : null} +
+ + {isStreaming ? Thinking... : "Thinking"} + +
); }; @@ -190,7 +151,6 @@ function renderBlockList({ - {block.fileName}: - {block.startLine === block.endLine - ? block.startLine - : `${block.startLine}\u2013${block.endLine}`} + {block.file_name}: + {block.start_line === block.end_line + ? block.start_line + : `${block.start_line}\u2013${block.end_line}`} - {block.text && ( - - {block.text} - - )}
); case "tool": { @@ -408,9 +363,9 @@ const ChatMessageItem = memo<{ ) : ( ), ) diff --git a/site/src/pages/AgentsPage/AgentDetail/blockUtils.test.ts b/site/src/pages/AgentsPage/AgentDetail/blockUtils.test.ts index 8347bb6316d26..16955b4b4557d 100644 --- a/site/src/pages/AgentsPage/AgentDetail/blockUtils.test.ts +++ b/site/src/pages/AgentsPage/AgentDetail/blockUtils.test.ts @@ -50,10 +50,8 @@ describe("appendTextBlock", () => { }); it("appends a new thinking block to an empty list", () => { - const result = appendTextBlock([], "thinking", "pondering", "Deep thought"); - expect(result).toEqual([ - { type: "thinking", text: "pondering", title: "Deep thought" }, - ]); + const result = appendTextBlock([], "thinking", "pondering"); + expect(result).toEqual([{ type: "thinking", text: "pondering" }]); }); it("merges consecutive response blocks", () => { @@ -63,29 +61,13 @@ describe("appendTextBlock", () => { expect(result[0]).toEqual({ type: "response", text: "aaabbb" }); }); - it("merges consecutive thinking blocks with compatible titles", () => { - const blocks: RenderBlock[] = [ - { type: "thinking", text: "part1", title: "Reasoning" }, - ]; - const result = appendTextBlock(blocks, "thinking", "part2", "Reasoning"); + it("merges consecutive thinking blocks", () => { + const blocks: RenderBlock[] = [{ type: "thinking", text: "part1" }]; + const result = appendTextBlock(blocks, "thinking", "part2"); expect(result).toHaveLength(1); expect(result[0]).toEqual({ type: "thinking", text: "part1part2", - title: "Reasoning", - }); - }); - - it("merges thinking blocks with different titles using the new title", () => { - const blocks: RenderBlock[] = [ - { type: "thinking", text: "part1", title: "Analyzing" }, - ]; - const result = appendTextBlock(blocks, "thinking", "part2", "Planning"); - expect(result).toHaveLength(1); - expect(result[0]).toEqual({ - type: "thinking", - text: "part1part2", - title: "Planning", }); }); @@ -96,7 +78,6 @@ describe("appendTextBlock", () => { expect(result[1]).toEqual({ type: "thinking", text: "hmm", - title: undefined, }); }); @@ -107,20 +88,6 @@ describe("appendTextBlock", () => { expect(result[1]).toEqual({ type: "response", text: "after tool" }); }); - it("uses the custom joinText function when merging", () => { - const blocks: RenderBlock[] = [{ type: "response", text: "line1" }]; - const join = (a: string, b: string) => `${a}\n${b}`; - const result = appendTextBlock( - blocks, - "response", - "line2", - undefined, - join, - ); - expect(result).toHaveLength(1); - expect(result[0]).toEqual({ type: "response", text: "line1\nline2" }); - }); - it("does not mutate the original blocks array", () => { const blocks: RenderBlock[] = [{ type: "response", text: "original" }]; const result = appendTextBlock(blocks, "response", " added"); @@ -128,28 +95,4 @@ describe("appendTextBlock", () => { expect((blocks[0] as { text: string }).text).toBe("original"); expect(result).not.toBe(blocks); }); - - it("merges thinking block and uses new title", () => { - const blocks: RenderBlock[] = [ - { type: "thinking", text: "a", title: "Think" }, - ]; - const result = appendTextBlock(blocks, "thinking", "b", "Thinking deeply"); - expect(result).toHaveLength(1); - expect(result[0]).toEqual({ - type: "thinking", - text: "ab", - title: "Thinking deeply", - }); - }); - - it("merges thinking blocks when both have no title", () => { - const blocks: RenderBlock[] = [{ type: "thinking", text: "a" }]; - const result = appendTextBlock(blocks, "thinking", "b"); - expect(result).toHaveLength(1); - expect(result[0]).toEqual({ - type: "thinking", - text: "ab", - title: undefined, - }); - }); }); diff --git a/site/src/pages/AgentsPage/AgentDetail/blockUtils.ts b/site/src/pages/AgentsPage/AgentDetail/blockUtils.ts index 2ae1e56f0276e..a66b26e454b02 100644 --- a/site/src/pages/AgentsPage/AgentDetail/blockUtils.ts +++ b/site/src/pages/AgentsPage/AgentDetail/blockUtils.ts @@ -1,13 +1,6 @@ import { asString } from "components/ai-elements/runtimeTypeUtils"; import type { RenderBlock } from "./types"; -const createBlock = ( - type: "response" | "thinking", - text: string, - title?: string, -): RenderBlock => - type === "thinking" ? { type, text, title } : { type, text }; - export const asNonEmptyString = (value: unknown): string | undefined => { const next = asString(value).trim(); return next.length > 0 ? next : undefined; @@ -16,18 +9,11 @@ export const asNonEmptyString = (value: unknown): string | undefined => { /** * Append a text or thinking block to a render block list, merging * with the previous block when the types match. - * - * @param joinText Controls how existing and new text are concatenated - * when merging into an existing block. Callers that process - * complete message blocks typically join with a newline, while - * streaming callers concatenate directly. */ export const appendTextBlock = ( blocks: RenderBlock[], type: "response" | "thinking", text: string, - title?: string, - joinText: (current: string, next: string) => string = (a, b) => `${a}${b}`, ): RenderBlock[] => { if (!text.trim()) { return blocks; @@ -35,15 +21,12 @@ export const appendTextBlock = ( const nextBlocks = [...blocks]; const last = nextBlocks[nextBlocks.length - 1]; if (last && last.type === type) { - nextBlocks[nextBlocks.length - 1] = createBlock( + nextBlocks[nextBlocks.length - 1] = { type, - joinText(last.text, text), - type === "thinking" && last.type === "thinking" - ? (title ?? last.title) - : undefined, - ); + text: `${last.text}${text}`, + }; return nextBlocks; } - nextBlocks.push(createBlock(type, text, title)); + nextBlocks.push({ type, text }); return nextBlocks; }; diff --git a/site/src/pages/AgentsPage/AgentDetail/messageParsing.test.ts b/site/src/pages/AgentsPage/AgentDetail/messageParsing.test.ts index cfdf46483cd29..0adebe26ac6ad 100644 --- a/site/src/pages/AgentsPage/AgentDetail/messageParsing.test.ts +++ b/site/src/pages/AgentsPage/AgentDetail/messageParsing.test.ts @@ -115,11 +115,11 @@ describe("parseMessageContent", () => { it("parses a reasoning block", () => { const result = parseMessageContent([ - { type: "reasoning", text: "Let me think...", title: "Reasoning" }, + { type: "reasoning", text: "Let me think..." }, ]); expect(result.reasoning).toBe("Let me think..."); expect(result.blocks).toEqual([ - { type: "thinking", text: "Let me think...", title: "Reasoning" }, + { type: "thinking", text: "Let me think..." }, ]); }); @@ -263,17 +263,15 @@ describe("parseMessageContent", () => { start_line: 10, end_line: 15, content: "some added code lines", - text: "Consider using a constant here.", }, ]); expect(result.blocks).toHaveLength(1); expect(result.blocks[0]).toEqual({ type: "file-reference", - fileName: "src/main.go", - startLine: 10, - endLine: 15, + file_name: "src/main.go", + start_line: 10, + end_line: 15, content: "some added code lines", - text: "Consider using a constant here.", }); }); @@ -283,12 +281,11 @@ describe("parseMessageContent", () => { type: "file-reference", file_name: "bare.ts", content: "bare content", - text: "No line info.", }, ]); - const ref = result.blocks[0] as { startLine: number; endLine: number }; - expect(ref.startLine).toBe(0); - expect(ref.endLine).toBe(0); + const ref = result.blocks[0] as { start_line: number; end_line: number }; + expect(ref.start_line).toBe(0); + expect(ref.end_line).toBe(0); }); it("does not affect markdown when file-reference blocks are present", () => { @@ -300,7 +297,6 @@ describe("parseMessageContent", () => { start_line: 1, end_line: 2, content: "nit code content", - text: "Nit.", }, ]); expect(result.markdown).toBe("Hello"); @@ -308,11 +304,10 @@ describe("parseMessageContent", () => { expect(result.blocks[0]).toEqual({ type: "response", text: "Hello" }); expect(result.blocks[1]).toEqual({ type: "file-reference", - fileName: "a.go", - startLine: 1, - endLine: 2, + file_name: "a.go", + start_line: 1, + end_line: 2, content: "nit code content", - text: "Nit.", }); }); diff --git a/site/src/pages/AgentsPage/AgentDetail/messageParsing.ts b/site/src/pages/AgentsPage/AgentDetail/messageParsing.ts index 0ea8e00df92af..c65bd5cb3190e 100644 --- a/site/src/pages/AgentsPage/AgentDetail/messageParsing.ts +++ b/site/src/pages/AgentsPage/AgentDetail/messageParsing.ts @@ -1,6 +1,6 @@ import type * as TypesGen from "api/typesGenerated"; import { asRecord, asString } from "components/ai-elements/runtimeTypeUtils"; -import { appendTextBlock, asNonEmptyString } from "./blockUtils"; +import { appendTextBlock } from "./blockUtils"; import type { MergedTool, ParsedMessageContent, @@ -18,9 +18,6 @@ const appendText = (current: string, next: string): string => { return `${current}${next}`; }; -export const asOptionalTitle = (value: unknown): string | undefined => - asNonEmptyString(value); - const isSubagentToolName = (name: string): boolean => name === "spawn_agent" || name === "wait_agent" || name === "message_agent"; @@ -72,15 +69,6 @@ const emptyParsedMessageContent = (): ParsedMessageContent => ({ sources: [], }); -/** Wraps appendTextBlock using the same direct concatenation as - * the streaming path so both produce identical markdown. */ -const appendParsedTextBlock = ( - blocks: RenderBlock[], - type: "response" | "thinking", - text: string, - title?: string, -): RenderBlock[] => appendTextBlock(blocks, type, text, title); - export const ensureToolBlock = ( blocks: RenderBlock[], id: string, @@ -140,7 +128,7 @@ export const parseMessageContent = (content: unknown): ParsedMessageContent => { for (const [index, block] of content.entries()) { if (typeof block === "string") { parsed.markdown = appendText(parsed.markdown, block); - parsed.blocks = appendParsedTextBlock(parsed.blocks, "response", block); + parsed.blocks = appendTextBlock(parsed.blocks, "response", block); continue; } @@ -153,23 +141,13 @@ export const parseMessageContent = (content: unknown): ParsedMessageContent => { case "text": { const text = asString(typedBlock.text); parsed.markdown = appendText(parsed.markdown, text); - parsed.blocks = appendParsedTextBlock( - parsed.blocks, - "response", - text, - ); + parsed.blocks = appendTextBlock(parsed.blocks, "response", text); break; } case "reasoning": { const text = asString(typedBlock.text); - const title = asOptionalTitle(typedBlock.title); parsed.reasoning = appendText(parsed.reasoning, text); - parsed.blocks = appendParsedTextBlock( - parsed.blocks, - "thinking", - text, - title, - ); + parsed.blocks = appendTextBlock(parsed.blocks, "thinking", text); break; } case "tool-call": { @@ -191,18 +169,16 @@ export const parseMessageContent = (content: unknown): ParsedMessageContent => { break; } case "file-reference": { - const text = asString(typedBlock.text); const fileName = asString(typedBlock.file_name); const startLine = Number(typedBlock.start_line) || 0; const endLine = Number(typedBlock.end_line) || startLine; - const contentStr = asString(typedBlock.content); + const content = asString(typedBlock.content); parsed.blocks.push({ type: "file-reference", - fileName, - startLine, - endLine, - content: contentStr, - text, + file_name: fileName, + start_line: startLine, + end_line: endLine, + content, }); break; } @@ -224,17 +200,18 @@ export const parseMessageContent = (content: unknown): ParsedMessageContent => { parsed.blocks = ensureToolBlock(parsed.blocks, id); break; } - case "file": - if ( - typedBlock.media_type && - (typedBlock.data || typedBlock.file_id) - ) { + case "file": { + const mediaType = asString(typedBlock.media_type); + const data = asString(typedBlock.data) || undefined; + const fileId = asString(typedBlock.file_id) || undefined; + if (mediaType && (data || fileId)) { parsed.blocks = [ ...parsed.blocks, - typedBlock as Extract, + { type: "file", media_type: mediaType, data, file_id: fileId }, ]; } break; + } case "source": { const url = asString(typedBlock.url); const title = asString(typedBlock.title); @@ -265,11 +242,7 @@ export const parseMessageContent = (content: unknown): ParsedMessageContent => { default: { const text = asString(typedBlock.text); parsed.markdown = appendText(parsed.markdown, text); - parsed.blocks = appendParsedTextBlock( - parsed.blocks, - "response", - text, - ); + parsed.blocks = appendTextBlock(parsed.blocks, "response", text); break; } } @@ -287,7 +260,7 @@ export const parseMessageContent = (content: unknown): ParsedMessageContent => { return { ...emptyParsedMessageContent(), markdown, - blocks: appendParsedTextBlock([], "response", markdown), + blocks: appendTextBlock([], "response", markdown), }; } @@ -300,7 +273,7 @@ export const parseMessageContent = (content: unknown): ParsedMessageContent => { return { ...emptyParsedMessageContent(), markdown, - blocks: appendParsedTextBlock([], "response", markdown), + blocks: appendTextBlock([], "response", markdown), }; }; diff --git a/site/src/pages/AgentsPage/AgentDetail/streamState.test.ts b/site/src/pages/AgentsPage/AgentDetail/streamState.test.ts index 387948d3245ef..2f3230240b1b0 100644 --- a/site/src/pages/AgentsPage/AgentDetail/streamState.test.ts +++ b/site/src/pages/AgentsPage/AgentDetail/streamState.test.ts @@ -56,15 +56,14 @@ describe("applyMessagePartToStreamState", () => { const result = applyMessagePartToStreamState(null, { type: "reasoning", text: "Let me reason...", - title: "Analysis", }); expect(result).not.toBeNull(); expect(result!.blocks).toEqual([ - { type: "thinking", text: "Let me reason...", title: "Analysis" }, + { type: "thinking", text: "Let me reason..." }, ]); }); - it("returns prev for reasoning part with no text and no title", () => { + it("returns prev for reasoning part with empty text", () => { const prev = createEmptyStreamState(); const result = applyMessagePartToStreamState(prev, { type: "reasoning", @@ -73,16 +72,6 @@ describe("applyMessagePartToStreamState", () => { expect(result).toBe(prev); }); - it("returns prev for reasoning part with only title and no text", () => { - const prev = createEmptyStreamState(); - const result = applyMessagePartToStreamState(prev, { - type: "reasoning", - text: "", - title: "Some Title", - }); - expect(result).toBe(prev); - }); - it("creates tool call entry from tool-call part", () => { const result = applyMessagePartToStreamState(null, { type: "tool-call", @@ -330,6 +319,40 @@ describe("applyMessagePartToStreamState", () => { expect(state).toBe(afterFirst); expect(state!.sources).toHaveLength(1); }); + + it("produces correct tool-result shape with is_error through buildStreamTools", () => { + let state: StreamState | null = null; + state = applyMessagePartToStreamState(state, { + type: "tool-call", + tool_name: "bash", + tool_call_id: "tc-1", + args: { command: "rm -rf /" }, + }); + state = applyMessagePartToStreamState(state, { + type: "tool-result", + tool_name: "bash", + tool_call_id: "tc-1", + result: { error: "permission denied" }, + is_error: true, + }); + expect(state).not.toBeNull(); + expect(state!.toolResults["tc-1"]).toMatchObject({ + id: "tc-1", + name: "bash", + result: { error: "permission denied" }, + isError: true, + }); + const tools = buildStreamTools(state); + expect(tools).toHaveLength(1); + expect(tools[0]).toEqual({ + id: "tc-1", + name: "bash", + args: { command: "rm -rf /" }, + result: { error: "permission denied" }, + isError: true, + status: "error", + }); + }); }); describe("buildStreamTools", () => { diff --git a/site/src/pages/AgentsPage/AgentDetail/streamState.ts b/site/src/pages/AgentsPage/AgentDetail/streamState.ts index 98f822d6b4622..3e04f20b0de04 100644 --- a/site/src/pages/AgentsPage/AgentDetail/streamState.ts +++ b/site/src/pages/AgentsPage/AgentDetail/streamState.ts @@ -1,10 +1,6 @@ import { asString } from "components/ai-elements/runtimeTypeUtils"; import { appendTextBlock } from "./blockUtils"; -import { - asOptionalTitle, - ensureToolBlock, - parseToolResultIsError, -} from "./messageParsing"; +import { ensureToolBlock, parseToolResultIsError } from "./messageParsing"; import { mergeStreamPayload } from "./streamingJson"; import type { MergedTool, RenderBlock, StreamState } from "./types"; @@ -17,9 +13,6 @@ export const createEmptyStreamState = (): StreamState => ({ sources: [], }); -/** Streaming variant — uses direct concatenation (the default joinText). */ -const appendStreamTextBlock = appendTextBlock; - export const applyMessagePartToStreamState = ( prev: StreamState | null, part: Record, @@ -35,7 +28,7 @@ export const applyMessagePartToStreamState = ( } return { ...nextState, - blocks: appendStreamTextBlock(nextState.blocks, "response", text), + blocks: appendTextBlock(nextState.blocks, "response", text), }; } case "reasoning": { @@ -43,15 +36,9 @@ export const applyMessagePartToStreamState = ( if (!text) { return prev; } - const title = asOptionalTitle(part.title); return { ...nextState, - blocks: appendStreamTextBlock( - nextState.blocks, - "thinking", - text, - title, - ), + blocks: appendTextBlock(nextState.blocks, "thinking", text), }; } case "tool-call": { @@ -137,17 +124,21 @@ export const applyMessagePartToStreamState = ( }, }; } - case "file": - if (!part.media_type || (!part.data && !part.file_id)) { + case "file": { + const mediaType = asString(part.media_type); + const data = asString(part.data) || undefined; + const fileId = asString(part.file_id) || undefined; + if (!mediaType || (!data && !fileId)) { return prev; } return { ...nextState, blocks: [ ...nextState.blocks, - part as Extract, + { type: "file", media_type: mediaType, data, file_id: fileId }, ], }; + } case "source": { const url = asString(part.url); const title = asString(part.title); diff --git a/site/src/pages/AgentsPage/AgentDetail/types.ts b/site/src/pages/AgentsPage/AgentDetail/types.ts index f8acfa1243972..aeae99bc7fa16 100644 --- a/site/src/pages/AgentsPage/AgentDetail/types.ts +++ b/site/src/pages/AgentsPage/AgentDetail/types.ts @@ -30,26 +30,13 @@ export type RenderBlock = | { type: "thinking"; text: string; - title?: string; } | { type: "tool"; id: string; } - | { - type: "file"; - media_type: string; - data?: string; // base64, absent when file_id is available - file_id?: string; - } - | { - type: "file-reference"; - fileName: string; - startLine: number; - endLine: number; - content: string; - text: string; - } + | TypesGen.ChatFilePart + | TypesGen.ChatFileReferencePart | { type: "sources"; sources: Array<{ url: string; title: string }>;