From 3045a44da4bf5dcf4ccb98b331a06d4290ad46bb Mon Sep 17 00:00:00 2001 From: Travelguest Date: Sun, 12 Apr 2026 11:34:41 +0800 Subject: [PATCH 1/3] feat: streaming token output with Print Mode rendering - Extend AgentProgressThinkingEvent with `text` (accumulated) and `delta` (incremental) fields so consumers can render tokens in real time. - Update Agent._deriveProgress() to extract text from streaming snapshots and compute delta between consecutive progress events. - Add StreamingMessage component for Ink-mode real-time Markdown rendering of streaming text tokens. - Update use-agent-loop to consume progress events: - Ink Mode: update streamingText React state for re-rendering - Print Mode: write delta directly to stdout for instant output - Update App.tsx to show StreamingMessage while text is streaming, replacing the generic shimmer indicator. - Add AgentLoopProvider `printMode` prop to toggle between modes. - Add 3 unit tests for streaming progress events (thinking text+delta, final message, tool progress without text fields). --- .../agent-streaming-progress.test.ts | 184 ++++++++++++++++++ src/agent/agent-event.ts | 8 + src/agent/agent.ts | 10 +- src/cli/tui/app.tsx | 11 +- src/cli/tui/components/streaming-message.tsx | 40 ++++ src/cli/tui/hooks/use-agent-loop.ts | 34 +++- 6 files changed, 279 insertions(+), 8 deletions(-) create mode 100644 src/agent/__tests__/agent-streaming-progress.test.ts create mode 100644 src/cli/tui/components/streaming-message.tsx 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..9422b0e --- /dev/null +++ b/src/agent/__tests__/agent-streaming-progress.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, test } from "bun:test"; +import { Agent } from "../agent"; +import type { AgentProgressThinkingEvent } from "../agent-event"; +import { Model } from "@/foundation/models/model"; +import type { + ModelProvider, + ModelProviderInvokeParams, +} from "@/foundation/models/model-provider"; +import type { AssistantMessage } from "@/foundation"; +import { z } from "zod"; + +function createTextStreamingProvider(): ModelProvider { + const finalMessage: AssistantMessage = { + role: "assistant", + content: [{ type: "text", text: "Hello, world!" }], + }; + + return { + invoke: async (_params: ModelProviderInvokeParams) => finalMessage, + 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 { + invoke: async (_params: ModelProviderInvokeParams) => toolMessage, + 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 { + // Second call: return a text-only message to end the loop + 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: any[] = []; + for await (const event of agent.stream({ + role: "user", + content: [{ type: "text", text: "Hi" }], + })) { + events.push(event); + } + + const thinkingEvents = events.filter( + (e) => e.type === "progress" && e.subtype === "thinking", + ) as AgentProgressThinkingEvent[]; + + expect(thinkingEvents.length).toBe(2); + + expect(thinkingEvents[0]).toMatchObject({ + type: "progress", + subtype: "thinking", + text: "Hello", + delta: "Hello", + }); + + expect(thinkingEvents[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: [] }); + + const events: any[] = []; + for await (const event of agent.stream({ + role: "user", + content: [{ type: "text", text: "Hi" }], + })) { + events.push(event); + } + + const messageEvent = events.find((e) => e.type === "message"); + expect(messageEvent).toBeDefined(); + expect(messageEvent.message.role).toBe("assistant"); + + const textBlock = messageEvent.message.content.find( + (block: any) => block.type === "text", + ); + expect(textBlock).toBeDefined(); + expect(textBlock.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 events: any[] = []; + for await (const event of agent.stream({ + role: "user", + content: [{ type: "text", text: "Hi" }], + })) { + events.push(event); + } + + const toolProgressEvents = events.filter( + (e) => e.type === "progress" && e.subtype === "tool", + ); + + 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..02ea232 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, printMode, 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 && !printMode; + // 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..bc1a568 --- /dev/null +++ b/src/cli/tui/components/streaming-message.tsx @@ -0,0 +1,40 @@ +import { Box, Text } from "ink"; +import { memo, useMemo } from "react"; +import { marked } from "marked"; +import TerminalRenderer from "marked-terminal"; + +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..b5b20cc 100644 --- a/src/cli/tui/hooks/use-agent-loop.ts +++ b/src/cli/tui/hooks/use-agent-loop.ts @@ -11,6 +11,8 @@ type AgentLoopState = { agent: Agent; streaming: boolean; messages: NonSystemMessage[]; + streamingText: string; + printMode: boolean; // eslint-disable-next-line no-unused-vars onSubmit: (submission: PromptSubmission) => Promise; abort: () => void; @@ -22,14 +24,17 @@ const AgentLoopContext = createContext(null); export function AgentLoopProvider({ agent, commands = [], + printMode = false, children, }: { agent: Agent; commands?: SlashCommand[]; + printMode?: boolean; children: ReactNode; }) { const [streaming, setStreaming] = useState(false); const [messages, setMessages] = useState([]); + const [streamingText, setStreamingText] = useState(""); const streamingRef = useRef(streaming); const pendingMessagesRef = useRef([]); @@ -116,6 +121,7 @@ export function AgentLoopProvider({ } setStreaming(true); + setStreamingText(""); try { agent.setRequestedSkillName(requestedSkillName); @@ -125,11 +131,26 @@ 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") { + if (printMode) { + // Print Mode: write delta directly to stdout for instant output + if (event.delta) { + process.stdout.write(event.delta); + } + } else { + // Ink Mode: update React state for re-rendering + 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 + } + + if (printMode) { + // Ensure a newline after print-mode streaming completes + process.stdout.write("\n"); } } catch (error) { if (isAbortError(error)) return; @@ -137,10 +158,11 @@ export function AgentLoopProvider({ } finally { agent.setRequestedSkillName(null); flushPendingMessages(); + setStreamingText(""); setStreaming(false); } }, - [agent, commands, enqueueMessage, flushPendingMessages], + [agent, commands, enqueueMessage, flushPendingMessages, printMode], ); const value = useMemo( @@ -148,11 +170,13 @@ export function AgentLoopProvider({ agent, streaming, messages, + streamingText, + printMode, onSubmit, abort, tokenCount, }), - [abort, agent, messages, onSubmit, streaming, tokenCount], + [abort, agent, messages, onSubmit, streaming, streamingText, printMode, tokenCount], ); return createElement(AgentLoopContext.Provider, { value }, children); From 21b634a29e931661913efbeda4b403a297b51d6a Mon Sep 17 00:00:00 2001 From: Travelguest Date: Sun, 12 Apr 2026 11:49:14 +0800 Subject: [PATCH 2/3] fix: change top_p from 0 to 0.1 for DeepSeek API compatibility DeepSeek's OpenAI-compatible API rejects top_p=0 with: 'Invalid top_p value, the valid range of top_p is (0, 1.0]' top_p=0.1 is the closest valid value that preserves near-deterministic behavior while satisfying the API constraint. --- src/community/openai/model-provider.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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, }; } From 57dceece5b835d4daa95f641465afb2c5a9abc2a Mon Sep 17 00:00:00 2001 From: Travelguest Date: Sun, 12 Apr 2026 12:33:50 +0800 Subject: [PATCH 3/3] refactor: remove printMode in favor of Ink-only streaming Print Mode (direct stdout.write) conflicts with Ink's terminal control and provides no real benefit over Ink Mode's real-time Markdown rendering. A proper non-interactive mode (like Claude Code's -p flag) would bypass Ink entirely rather than mixing stdout.write within the React tree. Removed: printMode prop, stdout.write branches, printMode state. --- .../agent-streaming-progress.test.ts | 61 +++++++++---------- src/cli/tui/app.tsx | 4 +- src/cli/tui/components/streaming-message.tsx | 2 +- src/cli/tui/hooks/use-agent-loop.ts | 22 +------ 4 files changed, 36 insertions(+), 53 deletions(-) diff --git a/src/agent/__tests__/agent-streaming-progress.test.ts b/src/agent/__tests__/agent-streaming-progress.test.ts index 9422b0e..5b60824 100644 --- a/src/agent/__tests__/agent-streaming-progress.test.ts +++ b/src/agent/__tests__/agent-streaming-progress.test.ts @@ -1,13 +1,12 @@ 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"; -import { Model } from "@/foundation/models/model"; -import type { - ModelProvider, - ModelProviderInvokeParams, -} from "@/foundation/models/model-provider"; -import type { AssistantMessage } from "@/foundation"; -import { z } from "zod"; function createTextStreamingProvider(): ModelProvider { const finalMessage: AssistantMessage = { @@ -16,7 +15,9 @@ function createTextStreamingProvider(): ModelProvider { }; 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[] = [ { @@ -63,7 +64,9 @@ function createToolStreamingProvider(): ModelProvider { }; 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) { @@ -82,7 +85,6 @@ function createToolStreamingProvider(): ModelProvider { }; yield toolMessage; } else { - // Second call: return a text-only message to end the loop yield doneMessage; } }, @@ -95,28 +97,26 @@ describe("Agent streaming progress events", () => { const model = new Model("test-model", provider); const agent = new Agent({ model, prompt: "You are a test assistant.", tools: [] }); - const events: any[] = []; + const events: AgentProgressThinkingEvent[] = []; for await (const event of agent.stream({ role: "user", content: [{ type: "text", text: "Hi" }], })) { - events.push(event); + if (event.type === "progress" && event.subtype === "thinking") { + events.push(event); + } } - const thinkingEvents = events.filter( - (e) => e.type === "progress" && e.subtype === "thinking", - ) as AgentProgressThinkingEvent[]; + expect(events.length).toBe(2); - expect(thinkingEvents.length).toBe(2); - - expect(thinkingEvents[0]).toMatchObject({ + expect(events[0]).toMatchObject({ type: "progress", subtype: "thinking", text: "Hello", delta: "Hello", }); - expect(thinkingEvents[1]).toMatchObject({ + expect(events[1]).toMatchObject({ type: "progress", subtype: "thinking", text: "Hello, world", @@ -129,23 +129,24 @@ describe("Agent streaming progress events", () => { const model = new Model("test-model", provider); const agent = new Agent({ model, prompt: "You are a test assistant.", tools: [] }); - const events: any[] = []; + let finalMessage: AssistantMessage | null = null; for await (const event of agent.stream({ role: "user", content: [{ type: "text", text: "Hi" }], })) { - events.push(event); + if (event.type === "message" && event.message.role === "assistant") { + finalMessage = event.message as AssistantMessage; + } } - const messageEvent = events.find((e) => e.type === "message"); - expect(messageEvent).toBeDefined(); - expect(messageEvent.message.role).toBe("assistant"); + expect(finalMessage).toBeDefined(); + expect(finalMessage!.role).toBe("assistant"); - const textBlock = messageEvent.message.content.find( - (block: any) => block.type === "text", + const textBlock = finalMessage!.content.find( + (block) => block.type === "text", ); expect(textBlock).toBeDefined(); - expect(textBlock.text).toBe("Hello, world!"); + expect((textBlock as { text: string }).text).toBe("Hello, world!"); }); test("yields tool progress events without text fields", async () => { @@ -161,18 +162,16 @@ describe("Agent streaming progress events", () => { const agent = new Agent({ model, prompt: "You are a test assistant.", tools: [bashTool] }); - const events: any[] = []; + const toolProgressEvents: { name?: string; text?: string; delta?: string }[] = []; for await (const event of agent.stream({ role: "user", content: [{ type: "text", text: "Hi" }], })) { - events.push(event); + if (event.type === "progress" && event.subtype === "tool") { + toolProgressEvents.push(event as unknown as { name?: string; text?: string; delta?: string }); + } } - const toolProgressEvents = events.filter( - (e) => e.type === "progress" && e.subtype === "tool", - ); - expect(toolProgressEvents.length).toBeGreaterThanOrEqual(1); for (const toolEvent of toolProgressEvents) { diff --git a/src/cli/tui/app.tsx b/src/cli/tui/app.tsx index 02ea232..896372e 100644 --- a/src/cli/tui/app.tsx +++ b/src/cli/tui/app.tsx @@ -30,7 +30,7 @@ export function App({ commands: SlashCommand[]; supportProjectWideAllow?: boolean; }) { - const { streaming, messages, streamingText, printMode, 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]); @@ -47,7 +47,7 @@ export function App({ useFlushToScrollback(messages, flushedRef, write); // Show streaming text in Ink mode (not print mode) - const showStreamingText = streaming && streamingText && !printMode; + const showStreamingText = streaming && !!streamingText; // Only show the shimmer indicator when there is no streaming text to display const showShimmer = streaming && !streamingText && !approvalRequest && !askUserQuestionRequest; diff --git a/src/cli/tui/components/streaming-message.tsx b/src/cli/tui/components/streaming-message.tsx index bc1a568..c0c34f7 100644 --- a/src/cli/tui/components/streaming-message.tsx +++ b/src/cli/tui/components/streaming-message.tsx @@ -1,7 +1,7 @@ import { Box, Text } from "ink"; -import { memo, useMemo } from "react"; import { marked } from "marked"; import TerminalRenderer from "marked-terminal"; +import { memo, useMemo } from "react"; import { currentTheme } from "../themes"; diff --git a/src/cli/tui/hooks/use-agent-loop.ts b/src/cli/tui/hooks/use-agent-loop.ts index b5b20cc..80ba282 100644 --- a/src/cli/tui/hooks/use-agent-loop.ts +++ b/src/cli/tui/hooks/use-agent-loop.ts @@ -12,7 +12,6 @@ type AgentLoopState = { streaming: boolean; messages: NonSystemMessage[]; streamingText: string; - printMode: boolean; // eslint-disable-next-line no-unused-vars onSubmit: (submission: PromptSubmission) => Promise; abort: () => void; @@ -24,12 +23,10 @@ const AgentLoopContext = createContext(null); export function AgentLoopProvider({ agent, commands = [], - printMode = false, children, }: { agent: Agent; commands?: SlashCommand[]; - printMode?: boolean; children: ReactNode; }) { const [streaming, setStreaming] = useState(false); @@ -135,23 +132,11 @@ export function AgentLoopProvider({ setStreamingText(""); enqueueMessage(event.message); } else if (event.type === "progress" && event.subtype === "thinking") { - if (printMode) { - // Print Mode: write delta directly to stdout for instant output - if (event.delta) { - process.stdout.write(event.delta); - } - } else { - // Ink Mode: update React state for re-rendering - setStreamingText(event.text); - } + setStreamingText(event.text); } // tool progress events are handled by StreamingIndicator } - if (printMode) { - // Ensure a newline after print-mode streaming completes - process.stdout.write("\n"); - } } catch (error) { if (isAbortError(error)) return; throw error; @@ -162,7 +147,7 @@ export function AgentLoopProvider({ setStreaming(false); } }, - [agent, commands, enqueueMessage, flushPendingMessages, printMode], + [agent, commands, enqueueMessage, flushPendingMessages], ); const value = useMemo( @@ -171,12 +156,11 @@ export function AgentLoopProvider({ streaming, messages, streamingText, - printMode, onSubmit, abort, tokenCount, }), - [abort, agent, messages, onSubmit, streaming, streamingText, printMode, tokenCount], + [abort, agent, messages, onSubmit, streaming, streamingText, tokenCount], ); return createElement(AgentLoopContext.Provider, { value }, children);