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(