From fa1a001b0ee2d03a0d7c784dbb6802717a48dace Mon Sep 17 00:00:00 2001 From: Test User Date: Fri, 19 Jun 2026 14:39:21 +0800 Subject: [PATCH] fix(tui): keep streaming transcript entries in order around tool calls Reset currentAssistantEntryId in onModelToolCall/onToolStart so text that arrives after a tool call within the same streaming response is rendered in a new assistant entry positioned after the tool entry. When currentAssistantEntryId is null at commitAssistantMessage time, find the last transient assistant entry for the current turn and apply the final message content there, avoiding a duplicate assistant entry. Fixes #71 --- src/runtime/local-opentui-bridge.test.ts | 195 +++++++++++++++++++++++ src/runtime/local-opentui-bridge.ts | 56 +++++-- 2 files changed, 242 insertions(+), 9 deletions(-) diff --git a/src/runtime/local-opentui-bridge.test.ts b/src/runtime/local-opentui-bridge.test.ts index eebdbc4..387d18e 100644 --- a/src/runtime/local-opentui-bridge.test.ts +++ b/src/runtime/local-opentui-bridge.test.ts @@ -6,6 +6,33 @@ function createBridge(): LocalOpenTuiTranscriptBridge { return new LocalOpenTuiTranscriptBridge(); } +function createUiFactoryInput(): Parameters< + ReturnType +>[0] { + return { + workspaceRoot: "/tmp", + model: "test-model", + provider: "test-provider", + approvalMode: "interactive", + nonInteractiveApproval: "deny", + maxContextTokens: 8192, + sessionSummary: "", + pluginIds: [], + commands: [], + delegationPresetNames: [], + useAlternateScreen: true, + workspaceTrusted: true, + activeTeammateName: null, + getTeammateSnapshot: () => null, + getTeammateSummary: () => null, + onInterrupt: async () => false, + onOpenTeammate: async () => false, + onInterruptTeammate: async () => false, + onTrustWorkspace: async () => {}, + onSubmit: async () => "continue" as const, + }; +} + describe("LocalOpenTuiTranscriptBridge", () => { describe("reconcileWithSessionMessages with reasoning", () => { it("splits assistant messages with reasoning into two entries", () => { @@ -101,4 +128,172 @@ describe("LocalOpenTuiTranscriptBridge", () => { expect(entries[0]?.content).toBe("Planning the next tool call."); }); }); + + describe("streaming order", () => { + it("keeps tool-call entries after the assistant text that triggered them", () => { + const bridge = createBridge(); + const ui = bridge.createInteractiveUiFactory()(createUiFactoryInput()); + + bridge.submitUserTurn({ content: "hello" }); + + ui.onModelStreamReset(); + ui.onModelTextDelta({ text: "Let me search" }); + ui.onModelToolCall({ toolName: "search", rawArgs: '{"q":"x"}' }); + ui.onAssistantMessage({ text: "Let me search" }); + + const entries = bridge.getEntries(); + const roles = entries.map((entry) => entry.role); + + expect(roles).toEqual(["user", "assistant", "tool"]); + }); + + it("creates a new assistant entry after a tool call when more text streams in", () => { + const bridge = createBridge(); + const ui = bridge.createInteractiveUiFactory()(createUiFactoryInput()); + + bridge.submitUserTurn({ content: "hello" }); + + ui.onModelStreamReset(); + ui.onModelTextDelta({ text: "Before tool. " }); + ui.onModelToolCall({ toolName: "read_file", rawArgs: '{"path":"x"}' }); + ui.onModelTextDelta({ text: "After tool." }); + ui.onAssistantMessage({ text: "Before tool. After tool." }); + + const entries = bridge.getEntries(); + const roles = entries.map((entry) => entry.role); + + // The streaming text after the tool call should not be merged into the + // pre-tool assistant entry. + expect(roles).toEqual(["user", "assistant", "tool", "assistant"]); + }); + + it("commits final assistant text to the streaming entry even after tool-call reset", () => { + const bridge = createBridge(); + const ui = bridge.createInteractiveUiFactory()(createUiFactoryInput()); + + bridge.submitUserTurn({ content: "hello" }); + + ui.onModelStreamReset(); + ui.onModelTextDelta({ text: "Streaming text" }); + ui.onModelToolCall({ toolName: "search", rawArgs: "{}" }); + ui.onAssistantMessage({ text: "Final text" }); + + const entries = bridge.getEntries(); + const assistantEntries = entries.filter( + (entry) => entry.role === "assistant", + ); + + expect(assistantEntries).toHaveLength(1); + expect(assistantEntries[0]?.content).toBe("Final text"); + }); + + it("handles multiple consecutive tool calls without creating duplicate assistant entries", () => { + const bridge = createBridge(); + const ui = bridge.createInteractiveUiFactory()(createUiFactoryInput()); + + bridge.submitUserTurn({ content: "hello" }); + + ui.onModelStreamReset(); + ui.onModelTextDelta({ text: "Use tools" }); + ui.onModelToolCall({ toolName: "tool_a", rawArgs: "{}" }); + ui.onModelToolCall({ toolName: "tool_b", rawArgs: "{}" }); + ui.onAssistantMessage({ text: "Use tools" }); + + const entries = bridge.getEntries(); + const roles = entries.map((entry) => entry.role); + + expect(roles).toEqual(["user", "assistant", "tool", "tool"]); + }); + + it("resets streaming state between separate model requests", () => { + const bridge = createBridge(); + const ui = bridge.createInteractiveUiFactory()(createUiFactoryInput()); + + bridge.submitUserTurn({ content: "hello" }); + + // First step + ui.onModelStreamReset(); + ui.onModelTextDelta({ text: "First step." }); + ui.onModelToolCall({ toolName: "search", rawArgs: "{}" }); + ui.onAssistantMessage({ text: "First step." }); + + // Tool execution + ui.onToolStart({ toolName: "search", rawArgs: "{}" }); + ui.onToolResult({ + toolName: "search", + toolCallId: "c1", + result: { ok: true, summary: "done" }, + }); + + // Second step + ui.onModelStreamReset(); + ui.onModelTextDelta({ text: "Second step." }); + ui.onAssistantMessage({ text: "Second step." }); + + const entries = bridge.getEntries(); + const roles = entries.map((entry) => entry.role); + + expect(roles).toEqual(["user", "assistant", "tool", "assistant"]); + }); + + it("preserves user entries across multiple turns without tool calls", () => { + const bridge = createBridge(); + const ui = bridge.createInteractiveUiFactory()(createUiFactoryInput()); + + // Turn 1 + bridge.submitUserTurn({ content: "介绍一下这个项目" }); + ui.onModelStreamReset(); + ui.onModelTextDelta({ text: "这是 Step Realtime CLI 项目。" }); + ui.onAssistantMessage({ text: "这是 Step Realtime CLI 项目。" }); + ui.endRun(true); + + // Turn 2 + bridge.submitUserTurn({ content: "你刚才做了什么" }); + ui.onModelStreamReset(); + ui.onModelTextDelta({ text: "我刚才读取了 README。" }); + ui.onAssistantMessage({ text: "我刚才读取了 README。" }); + ui.endRun(true); + + const entries = bridge.getEntries(); + const roles = entries.map((entry) => entry.role); + const contents = entries.map((entry) => entry.content); + + expect(roles).toEqual(["user", "assistant", "user", "assistant"]); + expect(contents).toContain("介绍一下这个项目"); + expect(contents).toContain("你刚才做了什么"); + }); + + it("does not leave an orphaned pre-tool fragment after settle", () => { + const bridge = createBridge(); + const ui = bridge.createInteractiveUiFactory()(createUiFactoryInput()); + + const turnId = bridge.submitUserTurn({ content: "hello" }); + + ui.onModelStreamReset(); + ui.onModelTextDelta({ text: "Before tool. " }); + ui.onModelToolCall({ toolName: "read_file", rawArgs: '{"path":"x"}' }); + ui.onModelTextDelta({ text: "After tool." }); + ui.onAssistantMessage({ text: "Before tool. After tool." }); + + ui.endRun(true); + + // Settle the turn with the authoritative session message. + bridge.reconcileWithSessionMessages( + [ + { + role: "assistant", + content: "Before tool. After tool.", + }, + ], + turnId, + ); + + const entries = bridge.getEntries(); + const assistantContents = entries + .filter((entry) => entry.role === "assistant") + .map((entry) => entry.content); + + expect(assistantContents).toEqual(["Before tool. After tool."]); + }); + }); }); diff --git a/src/runtime/local-opentui-bridge.ts b/src/runtime/local-opentui-bridge.ts index 47a9d3f..f8c3502 100644 --- a/src/runtime/local-opentui-bridge.ts +++ b/src/runtime/local-opentui-bridge.ts @@ -456,6 +456,11 @@ export class LocalOpenTuiTranscriptBridge implements StepCliTuiTranscriptControl }, onModelToolCall: ({ toolName, rawArgs }) => { this.discardEmptyAssistantEntry(); + // Reset the streaming assistant entry ID so that any text deltas + // arriving *after* this tool call (within the same streaming + // response) create a new assistant entry positioned after the + // tool entry, instead of appending to the pre-tool entry. + this.currentAssistantEntryId = null; const entryId = this.findCurrentToolEntryId(toolName); if (entryId) { this.updateLocalEntry(entryId, (entry) => ({ @@ -480,6 +485,7 @@ export class LocalOpenTuiTranscriptBridge implements StepCliTuiTranscriptControl }, onToolStart: (info) => { this.discardEmptyAssistantEntry(); + this.currentAssistantEntryId = null; const toolName = readString((info as { toolName?: unknown }).toolName); const entryId = this.findCurrentToolEntryId(toolName); if (entryId) { @@ -577,6 +583,19 @@ export class LocalOpenTuiTranscriptBridge implements StepCliTuiTranscriptControl return; } + // currentAssistantEntryId may be null because onModelToolCall reset it + // after the streaming assistant text was finalized. Find the last + // transient assistant entry for the current turn and update it with the + // final message content. + const lastStreamingId = this.findLastStreamingAssistantEntryId(); + if (lastStreamingId) { + this.updateLocalEntry(lastStreamingId, (entry) => ({ + ...entry, + content: message, + })); + return; + } + this.appendTransientEntry({ id: randomUUID(), role: "assistant", @@ -707,6 +726,26 @@ export class LocalOpenTuiTranscriptBridge implements StepCliTuiTranscriptControl this.emit(); } + /** + * Find the last transient assistant entry for the current turn. Used by + * `commitAssistantMessage` when `currentAssistantEntryId` was reset (e.g. + * by `onModelToolCall`) to locate the streaming entry that should receive + * the final message content. + */ + private findLastStreamingAssistantEntryId(): string | null { + for (let i = this.localEntries.length - 1; i >= 0; i -= 1) { + const entry = this.localEntries[i]; + if ( + entry.role === "assistant" && + entry.kind === "transient" && + entry.turnId === this.currentTurnId + ) { + return entry.id; + } + } + return null; + } + private emit(): void { const entries = this.getEntries(); for (const listener of this.listeners) { @@ -816,15 +855,14 @@ function doesSessionAssistantCoverLocalAssistant( return false; } - if (normalizedSession === normalizedLocal) { - return true; - } - - if (!normalizedSession.startsWith(normalizedLocal)) { - return false; - } - - return normalizedSession.slice(normalizedLocal.length).startsWith("\n["); + // A session message covers the local entry when it is identical, or when it + // starts with the local content. The latter handles split streaming entries + // (e.g. text before/after a tool call) whose combined content is later + // settled as a single authoritative session message. + return ( + normalizedSession === normalizedLocal || + normalizedSession.startsWith(normalizedLocal) + ); } function matchCoveredOptimisticUserTurnIds(