mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
The OpenTUI renderer is meant to stand in for ink, so a divergence is a defect in the port rather than a difference of taste. This sweep drives both renderers through the same scripted sessions and closes what the frame-by-frame comparison surfaced. The ink theme was never applied at all, so ui.theme, /theme and the light/dark probe were inert here; the session list behind /resume and /delete was one hand-rolled line per row with no checkbox column and no preview, so a bulk delete could only ever remove the row under the cursor; several dialogs still read the cursor and the text buffer of the previous render, which loses keys whenever one stdin read carries more than one; tool cards dropped their inline arguments, folded the status glyph into the title and ignored the settings that collapse them; and a few settings had no read site in this renderer at all. ink's own behaviour is untouched — the ink-side edits only export what the port now shares instead of duplicating.
406 lines
13 KiB
TypeScript
406 lines
13 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2026 Qwen
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
initialStreamingModelState,
|
|
reduceStreamEvent,
|
|
reduceStreamEvents,
|
|
selectIsDone,
|
|
selectIsStreaming,
|
|
selectItemById,
|
|
selectItems,
|
|
} from './streaming-model.js';
|
|
import type {
|
|
HistoryItem,
|
|
StreamingModelState,
|
|
StreamEvent,
|
|
} from './streaming-model.js';
|
|
|
|
function reduceAll(events: StreamEvent[]): StreamingModelState {
|
|
return reduceStreamEvents(initialStreamingModelState, events);
|
|
}
|
|
|
|
describe('streamingModel', () => {
|
|
describe('text merging', () => {
|
|
it('merges consecutive text deltas into one streaming assistant item', () => {
|
|
const state = reduceAll([
|
|
{ type: 'text', delta: 'Hello ' },
|
|
{ type: 'text', delta: 'world' },
|
|
]);
|
|
const items = selectItems(state);
|
|
expect(items).toHaveLength(1);
|
|
expect(items[0]).toEqual({
|
|
kind: 'assistant',
|
|
id: 'assistant-0',
|
|
text: 'Hello world',
|
|
streaming: true,
|
|
});
|
|
expect(selectIsStreaming(state)).toBe(true);
|
|
});
|
|
|
|
it('starts a new assistant item after a tool interrupts the text stream', () => {
|
|
const state = reduceAll([
|
|
{ type: 'text', delta: 'before' },
|
|
{ type: 'tool-start', id: 't1', tool: 'Read', title: 'Read foo.ts' },
|
|
{ type: 'tool-end', id: 't1', success: true, summary: '1 line' },
|
|
{ type: 'text', delta: 'after' },
|
|
]);
|
|
const items = selectItems(state);
|
|
expect(items).toHaveLength(3);
|
|
expect(items[0]).toMatchObject({
|
|
kind: 'assistant',
|
|
text: 'before',
|
|
streaming: false,
|
|
});
|
|
expect(items[2]).toMatchObject({
|
|
kind: 'assistant',
|
|
id: 'assistant-1',
|
|
text: 'after',
|
|
streaming: true,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('thinking start / end', () => {
|
|
it('merges thinking deltas until thinking-end, then opens a new block', () => {
|
|
const state = reduceAll([
|
|
{ type: 'thinking', delta: 'consider ' },
|
|
{ type: 'thinking', delta: 'options' },
|
|
{ type: 'thinking-end' },
|
|
{ type: 'thinking', delta: 'second burst' },
|
|
]);
|
|
const items = selectItems(state);
|
|
expect(items).toHaveLength(2);
|
|
expect(items[0]).toEqual({
|
|
kind: 'thinking',
|
|
id: 'thinking-0',
|
|
text: 'consider options',
|
|
done: true,
|
|
});
|
|
expect(items[1]).toEqual({
|
|
kind: 'thinking',
|
|
id: 'thinking-1',
|
|
text: 'second burst',
|
|
done: false,
|
|
});
|
|
});
|
|
|
|
it('treats thinking-end without an active thinking block as a no-op', () => {
|
|
const state = reduceAll([{ type: 'thinking-end' }]);
|
|
expect(selectItems(state)).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('tool start / output / end', () => {
|
|
it('tracks the latest tool output snapshot by id and closes with success and summary', () => {
|
|
const state = reduceAll([
|
|
{ type: 'tool-start', id: 't1', tool: 'Bash', title: 'ls -la' },
|
|
{ type: 'tool-output', id: 't1', output: 'drwxr-xr-x\n' },
|
|
{ type: 'tool-output', id: 't1', output: 'drwxr-xr-x\ntotal 0\n' },
|
|
{ type: 'tool-end', id: 't1', success: true, summary: '2 lines' },
|
|
]);
|
|
const tool = selectItemById(state, 't1');
|
|
expect(tool).toEqual({
|
|
kind: 'tool',
|
|
id: 't1',
|
|
tool: 'Bash',
|
|
title: 'ls -la',
|
|
output: 'drwxr-xr-x\ntotal 0\n',
|
|
done: true,
|
|
success: true,
|
|
summary: '2 lines',
|
|
});
|
|
});
|
|
|
|
it('records failed tools', () => {
|
|
const state = reduceAll([
|
|
{ type: 'tool-start', id: 't1', tool: 'Bash', title: 'false' },
|
|
{ type: 'tool-end', id: 't1', success: false, summary: 'exit 1' },
|
|
]);
|
|
expect(selectItemById(state, 't1')).toMatchObject({
|
|
done: true,
|
|
success: false,
|
|
summary: 'exit 1',
|
|
});
|
|
});
|
|
|
|
it('ignores tool-output and tool-end for unknown ids', () => {
|
|
const before = reduceAll([
|
|
{ type: 'tool-start', id: 't1', tool: 'Bash', title: 'ls' },
|
|
]);
|
|
const after = reduceAll([
|
|
{ type: 'tool-start', id: 't1', tool: 'Bash', title: 'ls' },
|
|
{ type: 'tool-output', id: 'ghost', output: 'nope' },
|
|
{ type: 'tool-end', id: 'ghost', success: true, summary: 'nope' },
|
|
]);
|
|
expect(after.items).toEqual(before.items);
|
|
});
|
|
|
|
it('closes a trailing streaming assistant item on tool-start', () => {
|
|
const state = reduceAll([
|
|
{ type: 'text', delta: 'intro' },
|
|
{ type: 'tool-start', id: 't1', tool: 'Read', title: 'Read bar.ts' },
|
|
]);
|
|
expect(state.items[0]).toMatchObject({
|
|
kind: 'assistant',
|
|
streaming: false,
|
|
});
|
|
});
|
|
|
|
it('resets the existing item when a tool-start is replayed', () => {
|
|
// A retry/reconnect can re-emit a start whose id already exists; a
|
|
// duplicate item would strand later events (first id hit) and leave
|
|
// the ghost at done:false forever.
|
|
const state = reduceAll([
|
|
{ type: 'tool-start', id: 't1', tool: 'Bash', title: 'ls' },
|
|
{ type: 'tool-output', id: 't1', output: 'stale' },
|
|
{ type: 'tool-start', id: 't1', tool: 'Bash', title: 'ls -la' },
|
|
{ type: 'tool-output', id: 't1', output: 'fresh' },
|
|
{ type: 'tool-end', id: 't1', success: true, summary: 'ok' },
|
|
]);
|
|
expect(
|
|
selectItems(state).filter((item) => item.kind === 'tool'),
|
|
).toHaveLength(1);
|
|
expect(selectItemById(state, 't1')).toMatchObject({
|
|
title: 'ls -la',
|
|
output: 'fresh',
|
|
done: true,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('user events', () => {
|
|
it('produces a user item carrying text, promptId and sentToModel', () => {
|
|
const state = reduceAll([
|
|
{
|
|
type: 'user',
|
|
text: 'run the tests',
|
|
promptId: 'sess########7',
|
|
sentToModel: true,
|
|
},
|
|
]);
|
|
const items = selectItems(state);
|
|
expect(items).toHaveLength(1);
|
|
expect(items[0]).toEqual({
|
|
kind: 'user',
|
|
id: 'user-0',
|
|
text: 'run the tests',
|
|
promptId: 'sess########7',
|
|
sentToModel: true,
|
|
});
|
|
});
|
|
|
|
it('closes a trailing streaming assistant item', () => {
|
|
const state = reduceAll([
|
|
{ type: 'text', delta: 'intro' },
|
|
{ type: 'user', text: 'next turn' },
|
|
]);
|
|
expect(state.items[0]).toMatchObject({
|
|
kind: 'assistant',
|
|
streaming: false,
|
|
});
|
|
expect(state.items[1]).toMatchObject({ kind: 'user', text: 'next turn' });
|
|
});
|
|
});
|
|
|
|
describe('task progress / end', () => {
|
|
it('keeps only the tail of task progress lines', () => {
|
|
const events: StreamEvent[] = [
|
|
{
|
|
type: 'task-start',
|
|
id: 's1',
|
|
name: 'researcher',
|
|
description: 'bench ink frames',
|
|
},
|
|
];
|
|
for (const line of ['one', 'two', 'three', 'four', 'five']) {
|
|
events.push({ type: 'task-progress', id: 's1', line });
|
|
}
|
|
const task = selectItemById(reduceAll(events), 's1');
|
|
expect(task).toMatchObject({
|
|
kind: 'task',
|
|
progress: ['three', 'four', 'five'],
|
|
});
|
|
});
|
|
|
|
it('ignores task-progress and task-end for unknown ids', () => {
|
|
const start = {
|
|
type: 'task-start',
|
|
id: 's1',
|
|
name: 'n',
|
|
description: 'd',
|
|
} as const;
|
|
const before = reduceAll([start]);
|
|
const after = reduceAll([
|
|
start,
|
|
{ type: 'task-progress', id: 'ghost', line: 'nope' },
|
|
{ type: 'task-end', id: 'ghost' },
|
|
]);
|
|
expect(after.items).toEqual(before.items);
|
|
});
|
|
|
|
it('resets the existing item when a task-start is replayed', () => {
|
|
const state = reduceAll([
|
|
{ type: 'task-start', id: 's1', name: 'n', description: 'd' },
|
|
{ type: 'task-progress', id: 's1', line: 'stale' },
|
|
{ type: 'task-start', id: 's1', name: 'n2', description: 'd2' },
|
|
{ type: 'task-progress', id: 's1', line: 'fresh' },
|
|
]);
|
|
expect(
|
|
selectItems(state).filter((item) => item.kind === 'task'),
|
|
).toHaveLength(1);
|
|
expect(selectItemById(state, 's1')).toMatchObject({
|
|
name: 'n2',
|
|
progress: ['fresh'],
|
|
});
|
|
});
|
|
|
|
it('drops the task card once the subagent settles', () => {
|
|
const state = reduceAll([
|
|
{
|
|
type: 'task-start',
|
|
id: 's1',
|
|
name: 'researcher',
|
|
description: 'bench ink frames',
|
|
},
|
|
{ type: 'task-progress', id: 's1', line: '↳ done' },
|
|
{ type: 'task-end', id: 's1' },
|
|
]);
|
|
expect(selectItems(state)).toHaveLength(0);
|
|
expect(selectItemById(state, 's1')).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('done', () => {
|
|
it('finalizes the trailing assistant item and clears streaming', () => {
|
|
const state = reduceAll([
|
|
{ type: 'text', delta: 'all done ' },
|
|
{ type: 'text', delta: 'now' },
|
|
{ type: 'done' },
|
|
]);
|
|
expect(selectItems(state)[0]).toMatchObject({
|
|
kind: 'assistant',
|
|
text: 'all done now',
|
|
streaming: false,
|
|
});
|
|
expect(selectIsStreaming(state)).toBe(false);
|
|
expect(selectIsDone(state)).toBe(true);
|
|
});
|
|
|
|
it('accepts further events after done (multi-turn fold)', () => {
|
|
const state = reduceAll([
|
|
{ type: 'text', delta: 'turn one' },
|
|
{ type: 'done' },
|
|
{ type: 'text', delta: 'turn two' },
|
|
]);
|
|
expect(selectItems(state)).toHaveLength(2);
|
|
expect(selectIsStreaming(state)).toBe(true);
|
|
expect(selectIsDone(state)).toBe(false);
|
|
});
|
|
|
|
it('folds a full mixed scenario in order', () => {
|
|
const state = reduceAll([
|
|
{ type: 'thinking', delta: 'plan' },
|
|
{ type: 'thinking-end' },
|
|
{ type: 'text', delta: 'let me look' },
|
|
{ type: 'tool-start', id: 't1', tool: 'Read', title: 'Read a.ts' },
|
|
{ type: 'tool-output', id: 't1', output: 'contents' },
|
|
{ type: 'tool-end', id: 't1', success: true, summary: 'ok' },
|
|
{ type: 'text', delta: 'found it' },
|
|
{
|
|
type: 'task-start',
|
|
id: 's1',
|
|
name: 'worker',
|
|
description: 'verify',
|
|
},
|
|
{ type: 'task-progress', id: 's1', line: 'step' },
|
|
{ type: 'task-end', id: 's1' },
|
|
{ type: 'text', delta: 'summary' },
|
|
{ type: 'done' },
|
|
]);
|
|
expect(selectItems(state).map((item) => item.kind)).toEqual([
|
|
'thinking',
|
|
'assistant',
|
|
'tool',
|
|
'assistant',
|
|
'assistant',
|
|
]);
|
|
expect(selectItemById(state, 't1')).toMatchObject({ done: true });
|
|
expect(selectIsDone(state)).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('never mutates items held by earlier states on any updating branch', () => {
|
|
// Every branch that rewrites an existing item relies on
|
|
// replace-not-mutate discipline over objects shared by reference with
|
|
// every previously returned state, so fold one event per branch and
|
|
// deep-check each captured snapshot after the whole fold.
|
|
const scenarios: StreamEvent[][] = [
|
|
[
|
|
{ type: 'text', delta: 'a' },
|
|
{ type: 'text', delta: 'b' },
|
|
],
|
|
[
|
|
{ type: 'thinking', delta: 'a' },
|
|
{ type: 'thinking', delta: 'b' },
|
|
],
|
|
[
|
|
{ type: 'tool-start', id: 't1', tool: 'Bash', title: 'ls' },
|
|
{ type: 'tool-output', id: 't1', output: 'x' },
|
|
],
|
|
[
|
|
{ type: 'tool-start', id: 't1', tool: 'Bash', title: 'ls' },
|
|
{ type: 'tool-end', id: 't1', success: true, summary: 'ok' },
|
|
],
|
|
[
|
|
{ type: 'task-start', id: 's1', name: 'n', description: 'd' },
|
|
{ type: 'task-progress', id: 's1', line: 'step' },
|
|
],
|
|
[
|
|
{ type: 'task-start', id: 's1', name: 'n', description: 'd' },
|
|
{ type: 'task-end', id: 's1' },
|
|
],
|
|
// closeTrailingAssistant rewrites the streaming assistant when a
|
|
// start/user/done event lands — pin the rewrite through a captured
|
|
// snapshot that must survive the fold.
|
|
[
|
|
{ type: 'text', delta: 'a' },
|
|
{ type: 'tool-start', id: 't1', tool: 'Bash', title: 'ls' },
|
|
],
|
|
[{ type: 'text', delta: 'a' }, { type: 'done' }],
|
|
// thinking-end rewrites the trailing thinking item to done.
|
|
[{ type: 'thinking', delta: 'a' }, { type: 'thinking-end' }],
|
|
];
|
|
for (const events of scenarios) {
|
|
let state = initialStreamingModelState;
|
|
const captured: Array<{
|
|
items: readonly HistoryItem[];
|
|
clone: HistoryItem[];
|
|
}> = [];
|
|
for (const event of events) {
|
|
state = reduceStreamEvent(state, event);
|
|
captured.push({
|
|
items: state.items,
|
|
clone: structuredClone([...state.items]),
|
|
});
|
|
}
|
|
for (const { items, clone } of captured) {
|
|
expect([...items]).toEqual(clone);
|
|
}
|
|
}
|
|
});
|
|
|
|
it('never mutates the previous state', () => {
|
|
const before = reduceAll([{ type: 'text', delta: 'a' }]);
|
|
const beforeItems = selectItems(before);
|
|
reduceStreamEvent(before, { type: 'text', delta: 'b' });
|
|
expect(selectItems(before)).toBe(beforeItems);
|
|
expect(beforeItems).toHaveLength(1);
|
|
expect(beforeItems[0]).toMatchObject({ text: 'a' });
|
|
});
|
|
});
|