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
8 changes: 8 additions & 0 deletions packages/sdk-typescript/src/daemon/ui/transcript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,10 @@ function applyDaemonTranscriptEvent(
break;
case 'status':
case 'debug':
appendStatusBlock(next, event.type, event.text, event, {
clearActiveText: event.clearActiveText,
});
Comment on lines +344 to +346

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] The clearActiveText: false opt-out skips the entire clearActiveText(state) call — including state.activeUserBlockId = undefined. All three new dispatch sites pass the flag unconditionally, also when idle. Pre-PR, an idle status dispatch reset activeUserBlockId; post-PR, running /stats, /about or /context (or clicking the status-bar context indicator) while idle leaves the local command echo block as the active user block indefinitely.

Failure scenario: Web Shell client B is idle; its user runs /stats → echo block E becomes activeUserBlockId; the result dispatch leaves the pointer at E. A peer client (TUI or a second web tab on the same daemon session) then submits a prompt: the bridge echo arrives as a mergeable user.text.delta (no sourceRecordIds, no qwenDiscreteMessage), canMergeTextDelta passes, and the peer's prompt text is appended onto E — rendering /stats<peer prompt> in one user block. Since applyTurnCollapse bounds turns by user messages, the peer's entire turn (assistant text, tool steps, token usage) groups under the /stats echo's turn, corrupting turn boundaries and per-turn metrics. Verified by a reducer probe at the reviewed commit: the PR arm merged into one user block (/statsfix the bug) where the pre-PR control arm produced separate user blocks.

Suggested fix — probe-verified (flips the repro back to separate blocks while keeping this PR's reducer tests green): in appendStatusBlock, keep the assistant/thought block but drop the user pointer on the opt-out path:

  appendBlock(state, block);
  if (opts.clearActiveText !== false) clearActiveText(state);
  else state.activeUserBlockId = undefined;

(Alternative: pass clearActiveText: false from App.tsx only while streaming — idle dispatches have no streaming block to protect.)

中文说明

clearActiveText: false 选项跳过了整个 clearActiveText(state) 调用——包括 state.activeUserBlockId = undefined。三处新的 dispatch 都无条件传入该标志,空闲时也是如此。本 PR 之前,空闲时的 status dispatch 会重置 activeUserBlockId;现在,空闲时运行 /stats/about/context(或点击状态栏的 context 指示器)会让本地命令回显块无限期地保持为活跃用户块。

失败场景:Web Shell 客户端 B 空闲时运行 /stats → 回显块 E 成为 activeUserBlockId;结果 dispatch 使指针一直停留在 E。此时同一 daemon 会话上的对端客户端(TUI 或第二个网页标签页)提交提示词:桥的回显以可合并的 user.text.delta 到达(无 sourceRecordIds、无 qwenDiscreteMessage),canMergeTextDelta 通过,对端的提示词文本被追加到 E 上——一个用户块渲染出 /stats<对端提示词>。由于 applyTurnCollapse 以用户消息为回合边界,对端的整个回合(assistant 文本、工具步骤、token 用量)都会归入 /stats 回显所在的回合,破坏回合边界与逐回合统计。已在被审提交上用 reducer 探针验证:PR 分支合并为一个用户块(/statsfix the bug),而 PR 前的对照组产生独立的用户块。

建议修复(已用探针验证——复现恢复为独立块,且本 PR 的 reducer 测试仍全绿):在 appendStatusBlock 中保留 assistant/thought 块,但在 opt-out 路径上清掉用户指针:

  appendBlock(state, block);
  if (opts.clearActiveText !== false) clearActiveText(state);
  else state.activeUserBlockId = undefined;

(备选方案:仅在流式时才从 App.tsx 传 clearActiveText: false——空闲 dispatch 没有需要保护的流式块。)

— qwen3.8-max via Qwen Code /review (v0.21.5)

break;
case 'error':
appendStatusBlock(next, event.type, event.text, event);
break;
Expand Down Expand Up @@ -1256,6 +1260,10 @@ function appendStatusBlock(
};
appendBlock(state, block);
if (opts.clearActiveText !== false) clearActiveText(state);
// Opt-out only protects the streaming assistant/thought block; the user
// pointer must still reset, otherwise a later mergeable user.text.delta
// (e.g. a peer client's prompt echo) appends onto the command echo block.
else state.activeUserBlockId = undefined;
}

function appendPromptCancelledBlock(
Expand Down
7 changes: 7 additions & 0 deletions packages/sdk-typescript/src/daemon/ui/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,13 @@ export interface DaemonUiStatusEvent extends DaemonUiEventBase {
text: string;
source?: string;
data?: unknown;
/**
* Client-dispatch opt-out: `false` inserts the status block without
* finalizing the active assistant/thought block, so read-only command
* output dispatched mid-turn does not split a streaming answer or orphan
* its usage frames. Daemon-emitted events leave this unset.
*/
clearActiveText?: boolean;
}

export interface DaemonUiErrorEvent extends DaemonUiEventBase {
Expand Down
112 changes: 112 additions & 0 deletions packages/sdk-typescript/test/daemon-ui-transcript.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,115 @@ describe('daemon transcript rewind', () => {
expect(state.activeAssistantBlockId).toBeUndefined();
});
});

describe('status event while an assistant block is streaming', () => {
it('finalizes the active assistant block by default', () => {
const state = reduceDaemonTranscriptEvents(
createDaemonTranscriptState({ now: 1 }),
[
{ type: 'user.text.delta', text: 'question' },
{ type: 'assistant.text.delta', text: 'answering' },
{ type: 'status', text: 'mid-stream status' },
{ type: 'assistant.text.delta', text: ' more' },
{ type: 'assistant.done' },
],
{ now: 1 },
);

expect(state.blocks.map((block) => block.kind)).toEqual([
'user',
'assistant',
'status',
'assistant',
]);
});

it('keeps the assistant block active when clearActiveText is false', () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R4-3: The clearActiveText: false opt-out is only tested with an active assistant block; the thought-block half of the documented contract (types.ts: "without finalizing the active assistant/thought block") is unpinned — all three new reducer tests use assistant.text.delta only. — Failure scenario: named surviving mutation — adding clearActiveThought(state); to the reducer's opt-out else-branch keeps the entire sdk-typescript suite green (verified by applying the mutation: 1449 tests still pass), while a mid-turn status insertion would then finalize a streaming thought block (a thinking model still streaming thought when the user runs /stats), splitting it around the command output — the exact regression the flag exists to prevent. Suggested fix: add a mirror test with thought events.

it('keeps the thought block active when clearActiveText is false', () => {
  // user.text.delta → thought.text.delta → { type: 'status', clearActiveText: false }
  // → thought.text.delta → assistant.done
  // assert: a single thought block with merged text, still active after the status event
});
中文说明

clearActiveText: false 选项目前只用「活跃的 assistant 块」场景测试过;文档约定(types.ts:"不会收尾活跃的 assistant/thought 块")中 thought 块那一半没有任何测试固定——三条新 reducer 测试全部只使用 assistant.text.delta。失败场景:已点名的可存活变异——在 reducer 的 opt-out else 分支中加入 clearActiveThought(state);,整个 sdk-typescript 套件仍然全绿(已实际施加该变异验证:1449 个测试全部通过),而回合中插入 status 块此时会收尾正在流式的 thought 块(思考模型仍在流式输出 thought 时用户运行 /stats),把思考内容切成围绕命令输出的碎片——这正是该标志要避免的回归。建议修复:补充一个 thought 事件的镜像测试。

— qwen3.8-max via Qwen Code /review (v0.21.5)

const state = reduceDaemonTranscriptEvents(
createDaemonTranscriptState({ now: 1 }),
[
{ type: 'user.text.delta', text: 'question' },
{ type: 'assistant.text.delta', text: 'answering' },
{ type: 'status', text: 'mid-stream status', clearActiveText: false },
{ type: 'assistant.text.delta', text: ' more' },
{
type: 'assistant.usage',
usage: { inputTokens: 3, outputTokens: 5 },
},
{ type: 'assistant.done' },
],
{ now: 1 },
);

expect(state.blocks.map((block) => block.kind)).toEqual([
'user',
'assistant',
'status',
]);
const assistant = state.blocks[1];
if (assistant.kind !== 'assistant') throw new Error('expected assistant');
expect(assistant.text).toBe('answering more');
expect(assistant.usage).toEqual({
inputTokens: 3,
outputTokens: 5,
cachedTokens: 0,
});
});

it('resets the active user block even when clearActiveText is false', () => {
let state = reduceDaemonTranscriptEvents(
createDaemonTranscriptState({ now: 1 }),
[{ type: 'user.text.delta', text: '/stats' }],
{ now: 1 },
);
state = reduceDaemonTranscriptEvents(
state,
[{ type: 'status', text: 'stats output', clearActiveText: false }],
{ now: 1 },
);

expect(state.activeUserBlockId).toBeUndefined();

// A peer client's prompt echo must open its own user block instead of
// merging into the local command echo.
state = reduceDaemonTranscriptEvents(
state,
[{ type: 'user.text.delta', text: 'fix the bug' }],
{ now: 1 },
);

expect(state.blocks.map((block) => block.kind)).toEqual([
'user',
'status',
'user',
]);
expect(
state.blocks.map((block) => ('text' in block ? block.text : '')),
).toEqual(['/stats', 'stats output', 'fix the bug']);
});
});

describe('status event while a thought block is streaming', () => {
it('keeps the thought block active when clearActiveText is false', () => {
const state = reduceDaemonTranscriptEvents(
createDaemonTranscriptState({ now: 1 }),
[
{ type: 'user.text.delta', text: 'question' },
{ type: 'thought.text.delta', text: 'thinking' },
{ type: 'status', text: 'mid-stream status', clearActiveText: false },
{ type: 'thought.text.delta', text: ' more' },
{ type: 'assistant.done' },
],
{ now: 1 },
);

expect(state.blocks.map((block) => block.kind)).toEqual([
'user',
'thought',
'status',
]);
const thought = state.blocks[1];
if (thought.kind !== 'thought') throw new Error('expected thought');
expect(thought.text).toBe('thinking more');
});
});
Loading
Loading