Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ describe('chat surface Storybook contract', () => {
'EmptyChat',
'StreamingResponse',
'WithToolActivity',
'Processing',
'BranchedConversation',
'ComposerPendingAndDisabled',
'ImportActions',
Expand Down
11 changes: 9 additions & 2 deletions apps/desktop/src/main/__tests__/streaming-handoff.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,15 @@ describe('single live-turn handoff', () => {
}],
});

assert.ok(markup.indexOf('先检查') < markup.indexOf('data-trow="group"'));
assert.match(markup, /最终答案/);
// #1307: the render-layer fold (foldTimeline) keeps answer text as the
// grouping boundary and leaves a pure-thinking run bare, so the reasoning
// renders as the 深度思考 disclosure above the answer while the tool folds
// into one collapsed "Processing" block below it (its body is not in the
// static markup; the summary line carries the tool roll-up).
assert.equal((markup.match(/data-processing="block"/g) ?? []).length, 1);
assert.ok(markup.indexOf('深度思考') >= 0);
assert.ok(markup.indexOf('深度思考') < markup.indexOf('最终答案'));
assert.ok(markup.indexOf('最终答案') < markup.indexOf('运行 1 条命令'));
assert.equal((markup.match(/data-turn-id=/g) ?? []).length, 1);
});

Expand Down
72 changes: 71 additions & 1 deletion packages/ui/src/__tests__/materialize.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import assert from 'node:assert/strict';
import { describe, test } from 'node:test';
import type { AttachmentRef, StoredMessage } from '@maka/core';
import { materializeChat } from '../materialize.js';
import {
materializeChat,
materializeTurns,
overlayLiveTurn,
type TurnTimelineItem,
} from '../materialize.js';

const imageAttachment: AttachmentRef = {
kind: 'image',
Expand Down Expand Up @@ -74,3 +79,68 @@ describe('materializeChat attachments', () => {
);
});
});

// ── #1307: the timeline model stays flat (fold is a render concern) ──────────

function userMsg(turnId: string, ts: number, text: string): StoredMessage {
return { type: 'user', id: `u-${turnId}`, turnId, ts, text };
}

function shellRunResult(revision: number) {
return {
kind: 'shell_run' as const,
ref: 'maka://runtime/background-tasks/pty-1',
mode: 'pty' as const,
status: 'running' as const,
cwd: '/repo',
cmd: 'job',
startedAt: 1,
updatedAt: revision,
revision,
output: {
mode: 'pty' as const,
screen: 'ready',
scrollback: '',
cols: 80,
rows: 24,
cursor: { x: 0, y: 0, visible: true },
alternateScreen: false,
truncated: false,
redacted: false,
},
};
}

describe('flat timeline under tool projection (#1307 P1 regression)', () => {
test('shell-run folding away a turn’s only tool leaves a flat thinking-only timeline', () => {
// Turn t1 owns the Bash ShellRun parent; the live turn t2's ONLY tool is a
// Read carrying a shell_run result with the same ref, so foldShellRunTurns
// merges it into t1's Bash and drops it from t2 entirely. With the fold
// living in the model this used to strand an illegal thinking-only
// "processing" block with an empty summary; the flat model simply drops
// the emptied tools group.
const settled = materializeTurns([
{ type: 'tool_call', id: 'bash-1', turnId: 't1', ts: 1, toolName: 'Bash', args: { command: 'job', pty: true } },
{ type: 'tool_result', id: 'r-bash-1', turnId: 't1', ts: 2, toolUseId: 'bash-1', isError: false, content: shellRunResult(1) },
userMsg('t2', 3, 'q'),
]);
const turns = overlayLiveTurn(settled, {
turnId: 't2',
phase: 'streamed',
steps: [{
stepId: 'a1',
thinking: { text: 'watching the background job', truncated: false, complete: false },
tools: [{
toolUseId: 'read-1',
toolName: 'Read',
stepId: 'a1',
status: 'completed',
args: {},
result: shellRunResult(2),
}],
}],
});
const liveTurn = turns.find((turn) => turn.turnId === 't2');
assert.deepEqual(liveTurn?.timeline.map((item: TurnTimelineItem) => item.kind), ['thinking']);
});
});
54 changes: 54 additions & 0 deletions packages/ui/src/__tests__/processing-block.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { strict as assert } from 'node:assert';
import { describe, it } from 'node:test';
import { createElement, type ReactNode } from 'react';
import { renderToStaticMarkup as renderReactToStaticMarkup } from 'react-dom/server';
import { LocaleProvider } from '../locale-context.js';
import type { ToolActivityItem, TurnViewModel } from '../materialize.js';
import { TurnView } from '../chat-turn.js';

function renderToStaticMarkup(node: ReactNode): string {
return renderReactToStaticMarkup(createElement(LocaleProvider, {
locale: 'zh',
children: node,
}));
}

function turnWithTools(tools: ToolActivityItem[]): TurnViewModel {
return {
turnId: 'turn-1',
status: 'completed',
partialOutputRetained: false,
tools,
notes: [],
timeline: [
{ kind: 'thinking', text: 'reasoning', messageId: 'a1' },
{ kind: 'tools', items: tools },
],
startedAt: 1,
};
}

describe('ProcessingBlock disclosure wiring (#1307)', () => {
it('a waiting_permission tool inside the block forces the disclosure open', () => {
const markup = renderToStaticMarkup(createElement(TurnView, {
turn: turnWithTools([
{ toolUseId: 'w1', toolName: 'Write', activityKind: 'edit', status: 'waiting_permission', args: {}, intent: '写入配置' },
]),
}));
// The folded run renders as one Processing block whose panel is OPEN —
// the nested tool trow (and thereby the actionable permission row) is in
// the static markup, not hidden behind the collapsed summary.
assert.match(markup, /data-processing="block"/);
assert.match(markup, /data-trow="group"/);
});

it('ordinary settled work stays collapsed (no panel content in static markup)', () => {
const markup = renderToStaticMarkup(createElement(TurnView, {
turn: turnWithTools([
{ toolUseId: 'r1', toolName: 'Read', activityKind: 'read', status: 'completed', args: {} },
]),
}));
assert.match(markup, /data-processing="block"/);
assert.doesNotMatch(markup, /data-trow="group"/);
});
});
95 changes: 95 additions & 0 deletions packages/ui/src/__tests__/timeline-fold.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import assert from 'node:assert/strict';
import { describe, test } from 'node:test';
import type { ToolActivityItem, TurnTimelineItem } from '../materialize.js';
import { foldTimeline, type FoldedTimelineEntry } from '../timeline-fold.js';

function thinking(messageId: string, live?: boolean): TurnTimelineItem {
return { kind: 'thinking', text: `reasoning ${messageId}`, messageId, ...(live !== undefined ? { live } : {}) };
}

function text(messageId: string, body = `answer ${messageId}`): TurnTimelineItem {
return { kind: 'text', text: body, messageId };
}

function tool(id: string, toolName = 'Read'): ToolActivityItem {
return { toolUseId: id, toolName, status: 'completed', args: {} };
}

function tools(...items: ToolActivityItem[]): TurnTimelineItem {
return { kind: 'tools', items };
}

function kinds(entries: readonly FoldedTimelineEntry[]): string[] {
return entries.map((entry) => entry.kind);
}

function childKinds(entry: FoldedTimelineEntry | undefined): string[] {
return entry?.kind === 'processing' ? entry.children.map((child) => child.kind) : [];
}

describe('foldTimeline (#1307)', () => {
test('leaves a pure-thinking run bare instead of folding it', () => {
const folded = foldTimeline([thinking('a1')]);
// No tools in the run → no processing block; the 深度思考 disclosure
// renders the reasoning directly.
assert.deepEqual(kinds(folded), ['thinking']);
});

test('folds a pure-tools run into one processing block', () => {
const folded = foldTimeline([tools(tool('c1'))]);
assert.deepEqual(kinds(folded), ['processing']);
assert.deepEqual(childKinds(folded[0]), ['tools']);
});

test('keeps interleaved thinking + tools inside one block, in order', () => {
const folded = foldTimeline([thinking('a1'), tools(tool('c1'))]);
assert.deepEqual(kinds(folded), ['processing']);
assert.deepEqual(childKinds(folded[0]), ['thinking', 'tools']);
});

test('answer text is a boundary: runs around each text fold independently', () => {
const folded = foldTimeline([
thinking('a1'),
text('a1', 'step one'),
tools(tool('c1')),
thinking('a2'),
text('a2', 'step two'),
tools(tool('c2')),
]);
// thinking (pure run stays bare), text, processing[tools, thinking],
// text, processing[tools]
assert.deepEqual(kinds(folded), ['thinking', 'text', 'processing', 'text', 'processing']);
assert.deepEqual(childKinds(folded[2]), ['tools', 'thinking']);
assert.deepEqual(childKinds(folded[4]), ['tools']);
assert.equal((folded[1] as { text: string }).text, 'step one');
assert.equal((folded[3] as { text: string }).text, 'step two');
});

test('block ids derive from the preceding text and are stable across tool projection', () => {
const before = foldTimeline([
text('a1'),
thinking('a2'),
tools(tool('c1'), tool('c2')),
]);
// Shell-run folding can project the FIRST tool out of the group; the block
// id must not change (a first-child-derived key would remount the
// disclosure and drop a manual open/close).
const after = foldTimeline([
text('a1'),
thinking('a2'),
tools(tool('c2')),
]);
assert.equal(before[1]?.kind, 'processing');
assert.equal(after[1]?.kind, 'processing');
assert.equal(
before[1]?.kind === 'processing' ? before[1].id : undefined,
after[1]?.kind === 'processing' ? after[1].id : undefined,
);
assert.equal(before[1]?.kind === 'processing' ? before[1].id : undefined, 'a1');
});

test('a block that opens the turn uses the stable "start" id', () => {
const folded = foldTimeline([tools(tool('c1')), text('a1')]);
assert.equal(folded[0]?.kind === 'processing' ? folded[0].id : undefined, 'start');
});
});
111 changes: 110 additions & 1 deletion packages/ui/src/__tests__/tool-trow-summary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,14 @@ import { createElement, type ReactNode } from 'react';
import { renderToStaticMarkup as renderReactToStaticMarkup } from 'react-dom/server';
import { LocaleProvider } from '../locale-context.js';
import { ToolTrow } from '../tool-activity.js';
import { summarizeTrowTools } from '../tool-activity/trow-summary.js';
import {
isProcessingRunning,
processingNeedsAttention,
summarizeProcessing,
summarizeTrowTools,
} from '../tool-activity/trow-summary.js';
import type { ToolActivityItem } from '../materialize.js';
import type { FoldedTimelineChild } from '../timeline-fold.js';

const toolActivitySource = readFileSync(
join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'src', 'tool-activity.tsx'),
Expand Down Expand Up @@ -101,3 +107,106 @@ describe('tool trow summary aggregation', () => {
assert.equal((toolActivitySource.match(/\bSETTLE_FADE\b/g) ?? []).length, 2);
});
});

function thinking(live?: boolean): FoldedTimelineChild {
return { kind: 'thinking', text: 'reasoning', messageId: 'a1', ...(live !== undefined ? { live } : {}) };
}

function tools(items: ToolActivityItem[]): FoldedTimelineChild {
return { kind: 'tools', items };
}

describe('processing block summary (#1307)', () => {
it('settled summary rolls up tool activity only — folded reasoning is not counted', () => {
const children = [
thinking(),
tools([
{ toolUseId: 'r1', toolName: 'Read', activityKind: 'read', status: 'completed', args: {} },
{ toolUseId: 'g1', toolName: 'Grep', activityKind: 'search', status: 'errored', args: {} },
]),
thinking(),
];
// 只汇总工具桶 + 标红失败计数(沿用 summarizeTrowTools 文案),不出现「思考 N 次」。
assert.equal(summarizeProcessing(children, {}), '读取 1 个文件,搜索 1 次,1 个失败');
});

it('shows the running tool intent as the live current activity and appends the failed count', () => {
const children = [
tools([
{ toolUseId: 'r1', toolName: 'Read', activityKind: 'read', status: 'errored', args: {} },
{ toolUseId: 'b1', toolName: 'Bash', activityKind: 'command', status: 'running', args: {}, intent: '运行测试' },
]),
];
// 运行中显示当前活动(带「正在」前缀);区块内已有失败工具时,失败计数
// 不等 settle 才出现——摘要行是折叠错误的唯一信号。
assert.equal(summarizeProcessing(children, { live: true }), '正在运行测试,1 个失败');
});

it('live summary without failures stays a bare current-activity line', () => {
const children = [
tools([{ toolUseId: 'b1', toolName: 'Bash', activityKind: 'command', status: 'running', args: {}, intent: '运行测试' }]),
];
assert.equal(summarizeProcessing(children, { live: true }), '正在运行测试');
});

it('live summary falls back to the reasoning label when tools are done and thinking still streams', () => {
const children = [
tools([{ toolUseId: 'r1', toolName: 'Read', activityKind: 'read', status: 'completed', args: {} }]),
thinking(true),
];
assert.equal(summarizeProcessing(children, { live: true }), '正在深度思考');
});

it('live summary picks the LAST live entry in timeline order, skipping settled thinking', () => {
// A settled reasoning block after a still-running tool must not steal the
// current-activity line: the last LIVE entry is the running tool.
const children = [
tools([{ toolUseId: 'b1', toolName: 'Bash', activityKind: 'command', status: 'running', args: {}, intent: '运行测试' }]),
thinking(false),
];
assert.equal(summarizeProcessing(children, { live: true }), '正在运行测试');
// And a LATER streaming thinking block outranks an earlier running tool.
const laterThinking = [
tools([{ toolUseId: 'b1', toolName: 'Bash', activityKind: 'command', status: 'running', args: {}, intent: '运行测试' }]),
thinking(true),
];
assert.equal(summarizeProcessing(laterThinking, { live: true }), '正在深度思考');
});

it('localizes the connector-tool fallback in the live current activity', () => {
// A load_tools call with no intent/displayName must read as the localized
// 「加载工具组」, not the raw tool name (resolveToolDisplayName fallback).
const children = [
tools([{ toolUseId: 'l1', toolName: 'load_tools', status: 'running', args: {} }]),
];
assert.equal(summarizeProcessing(children, { live: true }), '正在加载工具组');
});

it('is running while any tool is in flight or reasoning is still streaming', () => {
assert.equal(isProcessingRunning([thinking(true)]), true);
assert.equal(isProcessingRunning([thinking(false)]), false);
assert.equal(
isProcessingRunning([tools([{ toolUseId: 'r1', toolName: 'Read', status: 'running', args: {} }])]),
true,
);
assert.equal(
isProcessingRunning([tools([{ toolUseId: 'r1', toolName: 'Read', status: 'completed', args: {} }])]),
false,
);
});

it('needs attention (force-open) only for a waiting_permission prompt, not an error', () => {
assert.equal(
processingNeedsAttention([tools([{ toolUseId: 'w1', toolName: 'Write', status: 'waiting_permission', args: {} }])]),
true,
);
// Errored tools stay collapsed — the summary line carries the failure count.
assert.equal(
processingNeedsAttention([
thinking(),
tools([{ toolUseId: 'e1', toolName: 'Bash', status: 'errored', args: {} }]),
]),
false,
);
});
});
Loading
Loading