From a04fc0a0b578ae77d624bfbd61c418b9e8e9514a Mon Sep 17 00:00:00 2001 From: Adolfo Reyna Date: Fri, 15 May 2026 14:20:08 -0400 Subject: [PATCH] Add streaming terminal chat UX --- README.md | 4 +++ src/agent.ts | 15 +++++++-- src/index.ts | 44 +++++++++++++++++++++++---- src/model/openai.ts | 74 ++++++++++++++++++++++++++++++++++++++++----- src/types.ts | 5 +++ 5 files changed, 127 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 8acb9a1..650fbb5 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,9 @@ Type a message, "/help" for commands, or "/exit" to quit. you> ``` +Interactive responses stream into the terminal as they are generated, and the +session keeps conversation history until you reset or exit. + You can also keep using one-shot mode when scripting: ```bash @@ -78,6 +81,7 @@ npm run dev -- "/tools" The core provides: - `/help` — show available commands +- `/history` — show how many conversation items are currently in memory - `/reset` — clear the current conversation history - `/exit` — leave the interactive session diff --git a/src/agent.ts b/src/agent.ts index f5d4f98..e1feaec 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -8,6 +8,10 @@ export type AgentOptions = { events?: EventBus; }; +export type RunTurnOptions = { + onTextDelta?: (delta: string) => void; +}; + export class Agent { private readonly history: AgentMessage[] = []; @@ -17,7 +21,7 @@ export class Agent { private readonly options: AgentOptions ) {} - async runTurn(prompt: string): Promise { + async runTurn(prompt: string, runOptions: RunTurnOptions = {}): Promise { const events = this.options.events; const maxTurns = this.options.maxTurns ?? 20; @@ -26,7 +30,14 @@ export class Agent { for (let turn = 0; turn < maxTurns; turn += 1) { await events?.emit("agent.turn.started", { turn: turn + 1 }); - const result = await this.model.respond(this.history, this.tools.list()); + const result = + runOptions.onTextDelta && this.model.respondStream + ? await this.model.respondStream( + this.history, + this.tools.list(), + runOptions.onTextDelta + ) + : await this.model.respond(this.history, this.tools.list()); this.history.push(...(result.output as AgentMessage[])); if (result.toolCalls.length === 0) { diff --git a/src/index.ts b/src/index.ts index 76d2db9..28b00db 100644 --- a/src/index.ts +++ b/src/index.ts @@ -72,6 +72,14 @@ await commands.register({ } }); +await commands.register({ + name: "history", + description: "Show how many conversation items are in memory.", + execute() { + return `Conversation items in memory: ${currentAgent?.getHistory().length ?? 0}`; + } +}); + await loadExtensions(resolve(cwd, "extensions"), registry, commands, events, cwd, promptFragments); const systemPrompt = promptFragments.join("\n\n"); @@ -117,15 +125,39 @@ async function handleCommand(prompt: string): Promise { return true; } -async function handlePrompt(prompt: string): Promise { +async function handlePrompt(prompt: string, interactive = false): Promise { if (!prompt.trim()) return; if (await handleCommand(prompt)) { return; } - const answer = await getAgent().runTurn(prompt); - console.log(`assistant> ${answer}`); + if (!interactive) { + const answer = await getAgent().runTurn(prompt); + console.log(answer); + return; + } + + let wroteAssistantText = false; + let streamedText = ""; + + output.write("\x1b[36massistant>\x1b[0m "); + + const answer = await getAgent().runTurn(prompt, { + onTextDelta(delta) { + wroteAssistantText = true; + streamedText += delta; + output.write(delta); + } + }); + + if (!wroteAssistantText) { + output.write(answer); + } else if (streamedText !== answer && !answer.startsWith(streamedText)) { + output.write(answer.slice(streamedText.length)); + } + + output.write("\n\n"); } if (initialPrompt) { @@ -137,15 +169,15 @@ const rl = createInterface({ input, output }); console.log("Simple Agent Harness"); console.log('Type a message, "/help" for commands, or "/exit" to quit.'); -output.write("you> "); +output.write("\x1b[32myou>\x1b[0m "); for await (const prompt of rl) { if (prompt.trim() === "/exit") { break; } - await handlePrompt(prompt); - output.write("you> "); + await handlePrompt(prompt, true); + output.write("\x1b[32myou>\x1b[0m "); } rl.close(); diff --git a/src/model/openai.ts b/src/model/openai.ts index 72eb613..b46dd88 100644 --- a/src/model/openai.ts +++ b/src/model/openai.ts @@ -37,13 +37,73 @@ export class OpenAIResponsesClient implements ModelClient { })) }); - const toolCalls: ToolCall[] = response.output - .filter((item): item is OpenAI.Responses.ResponseFunctionToolCall => item.type === "function_call") - .map((item) => ({ - id: item.call_id, - name: item.name, - arguments: item.arguments - })); + const toolCalls: ToolCall[] = response.output.flatMap((item) => + item.type === "function_call" + ? [ + { + id: item.call_id, + name: item.name, + arguments: item.arguments + } + ] + : [] + ); + + await this.events?.emit("model.response", { + outputItems: response.output.length, + toolCalls: toolCalls.map((toolCall) => toolCall.name), + hasText: response.output_text.length > 0 + }); + + return { + output: response.output, + outputText: response.output_text, + toolCalls + }; + } + + async respondStream( + input: AgentMessage[], + tools: ToolDefinition[], + onTextDelta: (delta: string) => void + ): Promise { + await this.events?.emit("model.request", { + model: this.model, + messages: input.length, + tools: tools.map((tool) => tool.name) + }); + + const stream = this.client.responses.stream({ + model: this.model, + instructions: this.instructions, + input, + tools: tools.map((tool) => ({ + type: "function", + name: tool.name, + description: tool.description, + parameters: tool.parameters, + strict: true + })) + }); + + for await (const event of stream) { + if (event.type === "response.output_text.delta") { + onTextDelta(event.delta); + } + } + + const response = await stream.finalResponse(); + const toolCalls: ToolCall[] = response.output.flatMap((item) => + item.type === "function_call" + ? [ + { + id: item.call_id, + name: item.name, + arguments: item.arguments + } + ] + : [] + ); await this.events?.emit("model.response", { outputItems: response.output.length, diff --git a/src/types.ts b/src/types.ts index 561f649..1a4f733 100644 --- a/src/types.ts +++ b/src/types.ts @@ -29,6 +29,11 @@ export type ModelTurn = { export interface ModelClient { respond(input: AgentMessage[], tools: ToolDefinition[]): Promise; + respondStream?( + input: AgentMessage[], + tools: ToolDefinition[], + onTextDelta: (delta: string) => void + ): Promise; } export type CommandContext = {