Skip to content
Open
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
183 changes: 183 additions & 0 deletions src/agent/__tests__/agent-streaming-progress.test.ts
Original file line number Diff line number Diff line change
@@ -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");
}
});
});
8 changes: 8 additions & 0 deletions src/agent/agent-event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down
10 changes: 9 additions & 1 deletion src/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -178,6 +179,7 @@ export class Agent {
}

private async *_think(): AsyncGenerator<AgentEvent, AssistantMessage> {
this._lastProgressText = "";
const modelContext: ModelContext = {
prompt: this.prompt,
messages: this.messages,
Expand Down Expand Up @@ -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 };
Expand Down
11 changes: 9 additions & 2 deletions src/cli/tui/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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]);
Expand All @@ -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 (
<Box flexDirection="column" width="100%">
{messages.length === 0 && <Header />}
Expand All @@ -57,7 +63,8 @@ export function App({
todoSnapshots={todoSnapshots}
/>
)}
{approvalRequest || askUserQuestionRequest ? null : (
{showStreamingText && <StreamingMessage text={streamingText} />}
{showShimmer && (
<StreamingIndicator streaming={streaming} nextTodo={nextTodo} />
)}
{!hideTodos && <TodoPanel todos={latestTodos} />}
Expand Down
40 changes: 40 additions & 0 deletions src/cli/tui/components/streaming-message.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Box columnGap={1}>
<Text color={currentTheme.colors.highlightedText}>⏺</Text>
<Box flexDirection="column" rowGap={0}>
<Text>{rendered}</Text>
</Box>
</Box>
);
});
16 changes: 12 additions & 4 deletions src/cli/tui/hooks/use-agent-loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
abort: () => void;
Expand All @@ -30,6 +31,7 @@ export function AgentLoopProvider({
}) {
const [streaming, setStreaming] = useState(false);
const [messages, setMessages] = useState<NonSystemMessage[]>([]);
const [streamingText, setStreamingText] = useState("");

const streamingRef = useRef(streaming);
const pendingMessagesRef = useRef<NonSystemMessage[]>([]);
Expand Down Expand Up @@ -116,6 +118,7 @@ export function AgentLoopProvider({
}

setStreaming(true);
setStreamingText("");

try {
agent.setRequestedSkillName(requestedSkillName);
Expand All @@ -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);
}
},
Expand All @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion src/community/openai/model-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}
Expand Down
Loading