diff --git a/src/agent/__tests__/agent-streaming-progress.test.ts b/src/agent/__tests__/agent-streaming-progress.test.ts new file mode 100644 index 0000000..5b60824 --- /dev/null +++ b/src/agent/__tests__/agent-streaming-progress.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, test } from "bun:test"; +import { z } from "zod"; + +import type { AssistantMessage } from "@/foundation"; +import { Model } from "@/foundation/models/model"; +import type { ModelProvider, ModelProviderInvokeParams } from "@/foundation/models/model-provider"; + +import { Agent } from "../agent"; +import type { AgentProgressThinkingEvent } from "../agent-event"; + +function createTextStreamingProvider(): ModelProvider { + const finalMessage: AssistantMessage = { + role: "assistant", + content: [{ type: "text", text: "Hello, world!" }], + }; + + return { + // eslint-disable-next-line no-unused-vars + invoke: async (_params: ModelProviderInvokeParams) => finalMessage, + // eslint-disable-next-line no-unused-vars + async *stream(_params: ModelProviderInvokeParams) { + const snapshots: AssistantMessage[] = [ + { + role: "assistant", + content: [{ type: "text", text: "Hello" }], + streaming: true, + }, + { + role: "assistant", + content: [{ type: "text", text: "Hello, world" }], + streaming: true, + }, + { + role: "assistant", + content: [{ type: "text", text: "Hello, world!" }], + }, + ]; + for (const snapshot of snapshots) { + yield snapshot; + } + }, + }; +} + +function createToolStreamingProvider(): ModelProvider { + let callCount = 0; + + const toolMessage: AssistantMessage = { + role: "assistant", + content: [ + { type: "text", text: "Let me help" }, + { + type: "tool_use", + id: "t1", + name: "bash", + input: { command: "ls" }, + }, + ], + }; + + const doneMessage: AssistantMessage = { + role: "assistant", + content: [{ type: "text", text: "Done." }], + }; + + return { + // eslint-disable-next-line no-unused-vars + invoke: async (_params: ModelProviderInvokeParams) => toolMessage, + // eslint-disable-next-line no-unused-vars + async *stream(_params: ModelProviderInvokeParams) { + callCount++; + if (callCount === 1) { + yield { + role: "assistant" as const, + content: [ + { type: "text" as const, text: "Let me help" }, + { + type: "tool_use" as const, + id: "t1", + name: "bash", + input: { command: "ls" }, + }, + ], + streaming: true, + }; + yield toolMessage; + } else { + yield doneMessage; + } + }, + }; +} + +describe("Agent streaming progress events", () => { + test("yields thinking progress events with text and delta", async () => { + const provider = createTextStreamingProvider(); + const model = new Model("test-model", provider); + const agent = new Agent({ model, prompt: "You are a test assistant.", tools: [] }); + + const events: AgentProgressThinkingEvent[] = []; + for await (const event of agent.stream({ + role: "user", + content: [{ type: "text", text: "Hi" }], + })) { + if (event.type === "progress" && event.subtype === "thinking") { + events.push(event); + } + } + + expect(events.length).toBe(2); + + expect(events[0]).toMatchObject({ + type: "progress", + subtype: "thinking", + text: "Hello", + delta: "Hello", + }); + + expect(events[1]).toMatchObject({ + type: "progress", + subtype: "thinking", + text: "Hello, world", + delta: ", world", + }); + }); + + test("emits final message event with complete content", async () => { + const provider = createTextStreamingProvider(); + const model = new Model("test-model", provider); + const agent = new Agent({ model, prompt: "You are a test assistant.", tools: [] }); + + let finalMessage: AssistantMessage | null = null; + for await (const event of agent.stream({ + role: "user", + content: [{ type: "text", text: "Hi" }], + })) { + if (event.type === "message" && event.message.role === "assistant") { + finalMessage = event.message as AssistantMessage; + } + } + + expect(finalMessage).toBeDefined(); + expect(finalMessage!.role).toBe("assistant"); + + const textBlock = finalMessage!.content.find( + (block) => block.type === "text", + ); + expect(textBlock).toBeDefined(); + expect((textBlock as { text: string }).text).toBe("Hello, world!"); + }); + + test("yields tool progress events without text fields", async () => { + const provider = createToolStreamingProvider(); + const model = new Model("test-model", provider); + + const bashTool = { + name: "bash", + description: "Run bash", + parameters: z.object({ command: z.string() }), + invoke: async () => "done", + }; + + const agent = new Agent({ model, prompt: "You are a test assistant.", tools: [bashTool] }); + + const toolProgressEvents: { name?: string; text?: string; delta?: string }[] = []; + for await (const event of agent.stream({ + role: "user", + content: [{ type: "text", text: "Hi" }], + })) { + if (event.type === "progress" && event.subtype === "tool") { + toolProgressEvents.push(event as unknown as { name?: string; text?: string; delta?: string }); + } + } + + expect(toolProgressEvents.length).toBeGreaterThanOrEqual(1); + + for (const toolEvent of toolProgressEvents) { + expect(toolEvent.name).toBe("bash"); + expect(toolEvent).not.toHaveProperty("text"); + expect(toolEvent).not.toHaveProperty("delta"); + } + }); +}); diff --git a/src/agent/agent-event.ts b/src/agent/agent-event.ts index 4814f73..f22a647 100644 --- a/src/agent/agent-event.ts +++ b/src/agent/agent-event.ts @@ -18,10 +18,18 @@ export interface AgentMessageEvent { /** * Fired while the current model snapshot has only text and/or thinking * content — i.e. no `tool_use` entries yet. + * + * When the model is producing text tokens, `text` carries the accumulated + * output so far and `delta` carries the incremental fragment added since the + * previous event. */ export interface AgentProgressThinkingEvent { type: "progress"; subtype: "thinking"; + /** Accumulated text output so far (empty string until the first text token). */ + text: string; + /** Incremental text fragment added since the previous progress event. */ + delta: string; } /** diff --git a/src/agent/agent.ts b/src/agent/agent.ts index 4535d82..4577100 100644 --- a/src/agent/agent.ts +++ b/src/agent/agent.ts @@ -49,6 +49,7 @@ export class Agent { private readonly _context: AgentContext; private _streaming = false; private _abortController: AbortController | null = null; + private _lastProgressText = ""; readonly name?: string; readonly model: Model; @@ -178,6 +179,7 @@ export class Agent { } private async *_think(): AsyncGenerator { + this._lastProgressText = ""; const modelContext: ModelContext = { prompt: this.prompt, messages: this.messages, @@ -209,7 +211,13 @@ export class Agent { (c): c is ToolUseContent => c.type === "tool_use", ); if (toolUses.length === 0) { - return { type: "progress", subtype: "thinking" }; + const textParts = snapshot.content + .filter((c) => c.type === "text") + .map((c) => (c as { text: string }).text); + const accumulated = textParts.join(""); + const delta = accumulated.slice(this._lastProgressText.length); + this._lastProgressText = accumulated; + return { type: "progress", subtype: "thinking", text: accumulated, delta }; } const last = toolUses[toolUses.length - 1]!; return { type: "progress", subtype: "tool", name: last.name, input: last.input }; diff --git a/src/cli/tui/app.tsx b/src/cli/tui/app.tsx index 4b8ad6c..896372e 100644 --- a/src/cli/tui/app.tsx +++ b/src/cli/tui/app.tsx @@ -11,6 +11,7 @@ import { Header } from "./components/header"; import { InputBox } from "./components/input-box"; import { MessageHistoryItem } from "./components/message-history"; import { StreamingIndicator } from "./components/streaming-indicator"; +import { StreamingMessage } from "./components/streaming-message"; import { TodoPanel } from "./components/todo-panel"; import { useAgentLoop } from "./hooks/use-agent-loop"; import { useApprovalManager } from "./hooks/use-approval-manager"; @@ -29,7 +30,7 @@ export function App({ commands: SlashCommand[]; supportProjectWideAllow?: boolean; }) { - const { streaming, messages, onSubmit, abort } = useAgentLoop(); + const { streaming, messages, streamingText, onSubmit, abort } = useAgentLoop(); const { approvalRequest, respondToApproval } = useApprovalManager(); const { askUserQuestionRequest, respondWithAnswers } = useAskUserQuestionManager(); const { latestTodos, todoSnapshots } = useMemo(() => buildTodoViewState(messages), [messages]); @@ -45,6 +46,11 @@ export function App({ useFlushToScrollback(messages, flushedRef, write); + // Show streaming text in Ink mode (not print mode) + const showStreamingText = streaming && !!streamingText; + // Only show the shimmer indicator when there is no streaming text to display + const showShimmer = streaming && !streamingText && !approvalRequest && !askUserQuestionRequest; + return ( {messages.length === 0 &&
} @@ -57,7 +63,8 @@ export function App({ todoSnapshots={todoSnapshots} /> )} - {approvalRequest || askUserQuestionRequest ? null : ( + {showStreamingText && } + {showShimmer && ( )} {!hideTodos && } diff --git a/src/cli/tui/components/streaming-message.tsx b/src/cli/tui/components/streaming-message.tsx new file mode 100644 index 0000000..c0c34f7 --- /dev/null +++ b/src/cli/tui/components/streaming-message.tsx @@ -0,0 +1,40 @@ +import { Box, Text } from "ink"; +import { marked } from "marked"; +import TerminalRenderer from "marked-terminal"; +import { memo, useMemo } from "react"; + +import { currentTheme } from "../themes"; + +marked.setOptions({ + renderer: new TerminalRenderer() as never, +}); + +/** + * Renders streaming text output from the model in real time. + * + * In **Ink mode** the accumulated text is rendered as Markdown inside the + * React tree. This component is shown *while* the model is producing text + * tokens and is replaced by the final {@link MessageHistoryItem} once the + * assistant turn completes. + */ +export const StreamingMessage = memo(function StreamingMessage({ + text, +}: { + text: string; +}) { + const rendered = useMemo(() => { + if (!text) return ""; + return marked(text).trimEnd(); + }, [text]); + + if (!text) return null; + + return ( + + + + {rendered} + + + ); +}); diff --git a/src/cli/tui/hooks/use-agent-loop.ts b/src/cli/tui/hooks/use-agent-loop.ts index 28dcddd..80ba282 100644 --- a/src/cli/tui/hooks/use-agent-loop.ts +++ b/src/cli/tui/hooks/use-agent-loop.ts @@ -11,6 +11,7 @@ type AgentLoopState = { agent: Agent; streaming: boolean; messages: NonSystemMessage[]; + streamingText: string; // eslint-disable-next-line no-unused-vars onSubmit: (submission: PromptSubmission) => Promise; abort: () => void; @@ -30,6 +31,7 @@ export function AgentLoopProvider({ }) { const [streaming, setStreaming] = useState(false); const [messages, setMessages] = useState([]); + const [streamingText, setStreamingText] = useState(""); const streamingRef = useRef(streaming); const pendingMessagesRef = useRef([]); @@ -116,6 +118,7 @@ export function AgentLoopProvider({ } setStreaming(true); + setStreamingText(""); try { agent.setRequestedSkillName(requestedSkillName); @@ -125,18 +128,22 @@ export function AgentLoopProvider({ const stream = agent.stream(userMessage); for await (const event of stream) { if (event.type === "message") { + // Clear streaming text when a completed message arrives + setStreamingText(""); enqueueMessage(event.message); + } else if (event.type === "progress" && event.subtype === "thinking") { + setStreamingText(event.text); } - // progress events intentionally ignored: the UI shows a generic - // "Thinking..." shimmer driven by the `streaming` boolean, and - // MessageHistory is the single source of truth for tool calls. + // tool progress events are handled by StreamingIndicator } + } catch (error) { if (isAbortError(error)) return; throw error; } finally { agent.setRequestedSkillName(null); flushPendingMessages(); + setStreamingText(""); setStreaming(false); } }, @@ -148,11 +155,12 @@ export function AgentLoopProvider({ agent, streaming, messages, + streamingText, onSubmit, abort, tokenCount, }), - [abort, agent, messages, onSubmit, streaming, tokenCount], + [abort, agent, messages, onSubmit, streaming, streamingText, tokenCount], ); return createElement(AgentLoopContext.Provider, { value }, children); diff --git a/src/community/openai/model-provider.ts b/src/community/openai/model-provider.ts index cfa1bec..babe622 100644 --- a/src/community/openai/model-provider.ts +++ b/src/community/openai/model-provider.ts @@ -93,7 +93,7 @@ export class OpenAIModelProvider implements ModelProvider { messages: convertToOpenAIMessages(messages), tools: tools ? convertToOpenAITools(tools) : undefined, temperature: 0, - top_p: 0, + top_p: 0.1, ...options, }; }