Skip to content
Merged
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
195 changes: 195 additions & 0 deletions src/runtime/local-opentui-bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,33 @@ function createBridge(): LocalOpenTuiTranscriptBridge {
return new LocalOpenTuiTranscriptBridge();
}

function createUiFactoryInput(): Parameters<
ReturnType<LocalOpenTuiTranscriptBridge["createInteractiveUiFactory"]>
>[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", () => {
Expand Down Expand Up @@ -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."]);
});
});
});
56 changes: 47 additions & 9 deletions src/runtime/local-opentui-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => ({
Expand All @@ -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) {
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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(
Expand Down
Loading