dsh-better-display 0.3.7 → 0.3.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -1
- package/DESIGN.md +43 -3
- package/README.en.md +9 -1
- package/README.md +9 -1
- package/docs/dependency-update-policy.md +9 -0
- package/docs/extension-points.md +172 -0
- package/docs/official-rendering-bridge.md +59 -18
- package/docs/openapi.yaml +147 -0
- package/docs/runbooks/blank-reader.md +39 -0
- package/lib/client.js +1629 -586
- package/lib/client.js.map +1 -1
- package/lib/dsh-better-display.js +535 -57
- package/lib/types/client/AssistantNode.d.ts +20 -0
- package/lib/types/client/AssistantNode.d.ts.map +1 -0
- package/lib/types/client/AssistantNode.js +46 -0
- package/lib/types/client/AssistantNode.js.map +1 -0
- package/lib/types/client/Blocks.d.ts.map +1 -1
- package/lib/types/client/Blocks.js +33 -10
- package/lib/types/client/Blocks.js.map +1 -1
- package/lib/types/client/ChoreographedFlow.d.ts.map +1 -1
- package/lib/types/client/ChoreographedFlow.js +2 -0
- package/lib/types/client/ChoreographedFlow.js.map +1 -1
- package/lib/types/client/ClosedProcessSummary.d.ts.map +1 -1
- package/lib/types/client/ClosedProcessSummary.js +3 -1
- package/lib/types/client/ClosedProcessSummary.js.map +1 -1
- package/lib/types/client/McpAppFrame.d.ts.map +1 -1
- package/lib/types/client/McpAppFrame.js +5 -11
- package/lib/types/client/McpAppFrame.js.map +1 -1
- package/lib/types/client/OfficialContent.d.ts +17 -3
- package/lib/types/client/OfficialContent.d.ts.map +1 -1
- package/lib/types/client/OfficialContent.js +33 -7
- package/lib/types/client/OfficialContent.js.map +1 -1
- package/lib/types/client/Reader.d.ts.map +1 -1
- package/lib/types/client/Reader.js +76 -73
- package/lib/types/client/Reader.js.map +1 -1
- package/lib/types/client/SettingsSection.d.ts.map +1 -1
- package/lib/types/client/SettingsSection.js +0 -5
- package/lib/types/client/SettingsSection.js.map +1 -1
- package/lib/types/client/StickyLane.d.ts +12 -0
- package/lib/types/client/StickyLane.d.ts.map +1 -1
- package/lib/types/client/StickyLane.js +78 -2
- package/lib/types/client/StickyLane.js.map +1 -1
- package/lib/types/client/ToolActivity.d.ts.map +1 -1
- package/lib/types/client/ToolActivity.js +10 -5
- package/lib/types/client/ToolActivity.js.map +1 -1
- package/lib/types/client/deliverables.d.ts.map +1 -1
- package/lib/types/client/deliverables.js +2 -0
- package/lib/types/client/deliverables.js.map +1 -1
- package/lib/types/client/index.d.ts +3 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +15 -1
- package/lib/types/client/index.js.map +1 -1
- package/lib/types/client/live-turn.d.ts +17 -5
- package/lib/types/client/live-turn.d.ts.map +1 -1
- package/lib/types/client/live-turn.js +106 -76
- package/lib/types/client/live-turn.js.map +1 -1
- package/lib/types/client/motion.d.ts +26 -2
- package/lib/types/client/motion.d.ts.map +1 -1
- package/lib/types/client/motion.js +144 -10
- package/lib/types/client/motion.js.map +1 -1
- package/lib/types/client/official-slots.d.ts +9 -0
- package/lib/types/client/official-slots.d.ts.map +1 -1
- package/lib/types/client/official-slots.js +22 -4
- package/lib/types/client/official-slots.js.map +1 -1
- package/lib/types/client/open-file.d.ts.map +1 -1
- package/lib/types/client/open-file.js +1 -6
- package/lib/types/client/open-file.js.map +1 -1
- package/lib/types/client/projection.d.ts +15 -2
- package/lib/types/client/projection.d.ts.map +1 -1
- package/lib/types/client/projection.js +16 -4
- package/lib/types/client/projection.js.map +1 -1
- package/lib/types/client/rewind-cut.d.ts +131 -0
- package/lib/types/client/rewind-cut.d.ts.map +1 -0
- package/lib/types/client/rewind-cut.js +321 -0
- package/lib/types/client/rewind-cut.js.map +1 -0
- package/lib/types/client/styles.d.ts +3 -0
- package/lib/types/client/styles.d.ts.map +1 -0
- package/lib/types/client/styles.js +25 -0
- package/lib/types/client/styles.js.map +1 -0
- package/lib/types/client/text-transform.d.ts +93 -0
- package/lib/types/client/text-transform.d.ts.map +1 -0
- package/lib/types/client/text-transform.js +183 -0
- package/lib/types/client/text-transform.js.map +1 -0
- package/lib/types/client/timeline.d.ts.map +1 -1
- package/lib/types/client/timeline.js.map +1 -1
- package/lib/types/client/tool-activity.d.ts +11 -4
- package/lib/types/client/tool-activity.d.ts.map +1 -1
- package/lib/types/client/tool-activity.js +54 -9
- package/lib/types/client/tool-activity.js.map +1 -1
- package/lib/types/client/turn-slots.d.ts +29 -0
- package/lib/types/client/turn-slots.d.ts.map +1 -0
- package/lib/types/client/turn-slots.js +62 -0
- package/lib/types/client/turn-slots.js.map +1 -0
- package/lib/types/client/types.d.ts +18 -0
- package/lib/types/client/types.d.ts.map +1 -1
- package/lib/types/dsh-better-display.d.ts +4 -1
- package/lib/types/dsh-better-display.d.ts.map +1 -1
- package/lib/types/dsh-better-display.js +55 -43
- package/lib/types/dsh-better-display.js.map +1 -1
- package/lib/types/flags.d.ts +7 -0
- package/lib/types/flags.d.ts.map +1 -0
- package/lib/types/flags.js +37 -0
- package/lib/types/flags.js.map +1 -0
- package/lib/types/log.d.ts +17 -0
- package/lib/types/log.d.ts.map +1 -0
- package/lib/types/log.js +122 -0
- package/lib/types/log.js.map +1 -0
- package/lib/types/metrics.d.ts +18 -0
- package/lib/types/metrics.d.ts.map +1 -0
- package/lib/types/metrics.js +35 -0
- package/lib/types/metrics.js.map +1 -0
- package/lib/types/reveal.d.ts +12 -0
- package/lib/types/reveal.d.ts.map +1 -0
- package/lib/types/reveal.js +147 -0
- package/lib/types/reveal.js.map +1 -0
- package/lib/types/workspace-path.d.ts +3 -0
- package/lib/types/workspace-path.d.ts.map +1 -0
- package/lib/types/workspace-path.js +5 -0
- package/lib/types/workspace-path.js.map +1 -0
- package/package.json +36 -5
- package/scripts/changelog.mjs +20 -0
- package/scripts/check-agents-md.mjs +67 -0
- package/scripts/check-bundle-size.mjs +38 -0
- package/scripts/check-dead-flags.mjs +55 -0
- package/scripts/check-todo-debt.mjs +69 -0
- package/scripts/security-audit-report.mjs +92 -0
- package/scripts/verify-commentary-visible.mjs +627 -0
- package/src/client/AssistantNode.tsx +72 -0
- package/src/client/Blocks.tsx +49 -10
- package/src/client/ChoreographedFlow.tsx +3 -1
- package/src/client/ClosedProcessSummary.tsx +3 -1
- package/src/client/McpAppFrame.tsx +5 -13
- package/src/client/OfficialContent.tsx +47 -9
- package/src/client/Reader.module.css +103 -21
- package/src/client/Reader.tsx +83 -88
- package/src/client/SettingsSection.tsx +1 -7
- package/src/client/StickyLane.tsx +81 -2
- package/src/client/ToolActivity.tsx +10 -6
- package/src/client/deliverables.ts +1 -0
- package/src/client/index.tsx +18 -2
- package/src/client/live-turn.ts +100 -69
- package/src/client/motion.tsx +142 -7
- package/src/client/official-slots.tsx +23 -4
- package/src/client/open-file.ts +2 -8
- package/src/client/projection.ts +16 -4
- package/src/client/rewind-cut.ts +340 -0
- package/src/client/styles.ts +22 -0
- package/src/client/text-transform.ts +265 -0
- package/src/client/timeline.ts +0 -2
- package/src/client/tool-activity.ts +67 -9
- package/src/client/turn-slots.ts +80 -0
- package/src/client/types.ts +19 -0
- package/src/dsh-better-display.ts +57 -42
- package/src/flags.ts +40 -0
- package/src/log.ts +137 -0
- package/src/metrics.ts +50 -0
- package/src/reveal.ts +90 -0
- package/src/workspace-path.ts +4 -0
- package/tests/browser-commentary.tsx +191 -0
- package/tests/chat-flow-hook.test.ts +1 -1
- package/tests/cloud-gate.test.ts +1 -1
- package/tests/context-tool-update.test.ts +1 -1
- package/tests/conversation-scroll.test.ts +1 -1
- package/tests/deliverables.test.ts +1 -1
- package/tests/entry.test.ts +1 -1
- package/tests/fixtures/reader-lane-owners.tsx +46 -0
- package/tests/fixtures/reader-official-users.tsx +234 -0
- package/tests/fixtures/reader-theme-overlap.html +80 -0
- package/tests/fixtures/reader-user-bubble.tsx +222 -0
- package/tests/flags.test.ts +66 -0
- package/tests/fold-choreography.test.ts +32 -8
- package/tests/fold-intensity.test.ts +11 -6
- package/tests/footer-precedence.test.ts +9 -1
- package/tests/history-word-regression.test.ts +1 -1
- package/tests/live-turn.test.ts +125 -54
- package/tests/log.test.ts +99 -0
- package/tests/mcp-apps.test.ts +1 -1
- package/tests/message-chrome.test.ts +1 -1
- package/tests/metrics.test.ts +52 -0
- package/tests/official-slots.test.ts +41 -2
- package/tests/open-file.test.ts +1 -1
- package/tests/pending-submission.test.ts +12 -1
- package/tests/pinned-selection.test.ts +16 -0
- package/tests/platform-media.test.ts +1 -1
- package/tests/projection.test.ts +66 -3
- package/tests/reader-glass-css.test.ts +28 -0
- package/tests/reader-official-users.test.ts +91 -0
- package/tests/reader-sticky-surface.test.ts +377 -0
- package/tests/reader-theme-lanes.test.ts +54 -0
- package/tests/reader-theme-overlap.test.ts +151 -0
- package/tests/reader-user-bubble.test.ts +137 -0
- package/tests/reasoning-follow.test.ts +1 -1
- package/tests/retiring-content.test.ts +229 -0
- package/tests/reveal.test.ts +128 -0
- package/tests/rewind-cut.test.ts +1090 -0
- package/tests/settings-copy.test.ts +1 -1
- package/tests/skill-status.test.ts +1 -1
- package/tests/stock-install.test.ts +9 -1
- package/tests/stream-buffer.test.ts +1 -1
- package/tests/styles.test.ts +75 -0
- package/tests/terminal-input-preview.test.ts +113 -0
- package/tests/text-transform.test.ts +272 -0
- package/tests/timeline.test.ts +1 -1
- package/tests/tool-clock.test.ts +1 -1
- package/tests/tool-diff.test.ts +1 -1
- package/tests/waiting-clock.test.ts +1 -1
- package/tests/word-timeline.test.ts +1 -1
- package/tsdown.config.ts +49 -1
package/tests/live-turn.test.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { test } from '
|
|
1
|
+
import { test } from 'vitest';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
3
|
import type { AssistantChatData, ChatConversationViewNode } from '@deepseek-ai/dsh-client-ui-chat/client';
|
|
4
4
|
import type { AssistantBlock, TurnLocation } from '@deepseek-ai/dsh-client-ui-conversation/client';
|
|
5
5
|
import { assistantSegments, processExpanded } from '../src/client/projection.ts';
|
|
6
6
|
import type { TurnBoundary } from '../src/client/projection.ts';
|
|
7
|
-
import {
|
|
7
|
+
import { planProcessSeams, presentLiveTurn, segmentLiveTurn } from '../src/client/live-turn.ts';
|
|
8
8
|
import type { LiveStep } from '../src/client/live-turn.ts';
|
|
9
9
|
import { readerFlow } from '../src/client/tool-activity.ts';
|
|
10
10
|
|
|
@@ -31,69 +31,96 @@ function kinds(items: ReturnType<typeof presentLiveTurn>) {
|
|
|
31
31
|
return items.map(item => item.kind === 'fold' ? ['fold', item.summary, item.steps.map(step => step.key)] : [item.kind, item.key]);
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
test('
|
|
35
|
-
|
|
36
|
-
assert.
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
34
|
+
test('narration is never folded; earlier process runs collapse even across narration', () => {
|
|
35
|
+
const beforeSecondReasoning = presentLiveTurn([reasoning('1'), body('2')], open);
|
|
36
|
+
assert.deepEqual(kinds(beforeSecondReasoning), [['open', '1'], ['open', '2']]);
|
|
37
|
+
|
|
38
|
+
// The first reasoning run folds in place before the narration; the narration
|
|
39
|
+
// itself stays open, and the latest reasoning stays open.
|
|
40
|
+
const afterSecondReasoning = presentLiveTurn([reasoning('1'), body('2'), reasoning('3')], open);
|
|
41
|
+
assert.deepEqual(kinds(afterSecondReasoning), [
|
|
42
|
+
['fold', '思考×1', ['1']],
|
|
43
|
+
['open', '2'],
|
|
44
|
+
['open', '3'],
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
const mixed = presentLiveTurn([reasoning('1'), tool('2'), body('3'), reasoning('4'), tool('5'), reasoning('6')], open);
|
|
48
|
+
assert.deepEqual(kinds(mixed), [
|
|
49
|
+
['fold', '思考×1 · 工具×1', ['1', '2']],
|
|
50
|
+
['open', '3'],
|
|
51
|
+
['fold', '思考×1 · 工具×1', ['4', '5']],
|
|
52
|
+
['open', '6'],
|
|
53
|
+
]);
|
|
54
|
+
assert.equal(mixed.some(item => item.kind === 'fold' && item.steps.some(step => step.kind === 'body')), false);
|
|
55
|
+
for (const item of mixed) {
|
|
56
|
+
if (item.kind === 'fold') assert.equal(item.summary.includes('输出'), false);
|
|
57
|
+
}
|
|
40
58
|
});
|
|
41
59
|
|
|
42
|
-
test('a second reasoning folds
|
|
43
|
-
const first =
|
|
44
|
-
assert.
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
assert.equal(
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
assert.deepEqual(
|
|
57
|
-
assert.
|
|
58
|
-
|
|
59
|
-
const
|
|
60
|
-
assert.deepEqual(
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
assert.deepEqual(latest.open.map(step => step.key), ['6', '7']);
|
|
60
|
+
test('a second reasoning folds the foldable run before it, never narration', () => {
|
|
61
|
+
const first = presentLiveTurn([reasoning('1'), tool('2')], open);
|
|
62
|
+
assert.deepEqual(kinds(first), [['open', '1'], ['open', '2']]);
|
|
63
|
+
|
|
64
|
+
const second = presentLiveTurn([reasoning('1'), tool('2'), reasoning('3')], open);
|
|
65
|
+
assert.deepEqual(kinds(second), [['fold', '思考×1 · 工具×1', ['1', '2']], ['open', '3']]);
|
|
66
|
+
if (second[0]?.kind !== 'fold') throw new Error('missing fold');
|
|
67
|
+
if (second[0].steps[0]?.kind !== 'reasoning') throw new Error('folded reasoning missing');
|
|
68
|
+
assert.equal(second[0].steps[0].blocks[0]?.kind === 'reasoning' && second[0].steps[0].blocks[0].text, '思考 1');
|
|
69
|
+
assert.equal(second[0].summary.includes('思考 1'), false);
|
|
70
|
+
|
|
71
|
+
const grown = presentLiveTurn([reasoning('1'), tool('2'), reasoning('3'), tool('4'), reasoning('5')], open);
|
|
72
|
+
if (grown[0]?.kind !== 'fold') throw new Error('missing grown fold');
|
|
73
|
+
assert.deepEqual(grown[0].steps.map(step => step.key), ['1', '2', '3', '4']);
|
|
74
|
+
assert.deepEqual(grown.filter(item => item.kind === 'open').map(item => item.key), ['5']);
|
|
75
|
+
assert.equal(grown[0].summary, '思考×2 · 工具×2');
|
|
76
|
+
|
|
77
|
+
const narrationBetween = presentLiveTurn([reasoning('1'), body('2'), reasoning('3'), tool('4'), reasoning('5')], open);
|
|
78
|
+
assert.deepEqual(kinds(narrationBetween), [
|
|
79
|
+
['fold', '思考×1', ['1']],
|
|
80
|
+
['open', '2'],
|
|
81
|
+
['fold', '思考×1 · 工具×1', ['3', '4']],
|
|
82
|
+
['open', '5'],
|
|
83
|
+
]);
|
|
67
84
|
});
|
|
68
85
|
|
|
69
86
|
test('fold presentation keeps prior step keys so the reader can collapse the same process fragments in place', () => {
|
|
70
|
-
const chain = [reasoning('1'),
|
|
87
|
+
const chain = [reasoning('1'), tool('2')];
|
|
71
88
|
const before = presentLiveTurn(chain, open);
|
|
72
|
-
assert.deepEqual(before.map(item => item.key), ['1', '2'
|
|
89
|
+
assert.deepEqual(before.map(item => item.key), ['1', '2']);
|
|
73
90
|
|
|
74
|
-
const second = presentLiveTurn([...chain, reasoning('
|
|
91
|
+
const second = presentLiveTurn([...chain, reasoning('3')], open);
|
|
75
92
|
assert.equal(second[0]?.kind, 'fold');
|
|
76
93
|
if (second[0]?.kind !== 'fold') throw new Error('missing fold');
|
|
77
94
|
assert.equal(second[0].key, 'live-fold:1');
|
|
78
95
|
assert.deepEqual(second[0].steps.map(step => step.key), before.map(item => item.key));
|
|
79
|
-
assert.deepEqual(second.filter(item => item.kind === 'open').map(item => item.key), ['
|
|
96
|
+
assert.deepEqual(second.filter(item => item.kind === 'open').map(item => item.key), ['3']);
|
|
80
97
|
|
|
81
|
-
const third = presentLiveTurn([...chain, reasoning('
|
|
98
|
+
const third = presentLiveTurn([...chain, reasoning('3'), tool('4'), reasoning('5')], open);
|
|
82
99
|
if (third[0]?.kind !== 'fold') throw new Error('missing grown fold');
|
|
83
100
|
assert.equal(third[0].key, 'live-fold:1');
|
|
84
|
-
assert.deepEqual(third[0].steps.map(step => step.key), ['1', '2', '3', '4'
|
|
101
|
+
assert.deepEqual(third[0].steps.map(step => step.key), ['1', '2', '3', '4']);
|
|
85
102
|
});
|
|
86
103
|
|
|
87
|
-
test('presentLiveTurn keeps
|
|
88
|
-
const items = presentLiveTurn([reasoning('1'),
|
|
104
|
+
test('presentLiveTurn keeps one fold box per foldable run and never hides narration', () => {
|
|
105
|
+
const items = presentLiveTurn([reasoning('1'), tool('2'), reasoning('3'), tool('4'), reasoning('5')], open);
|
|
89
106
|
const folds = items.filter(item => item.kind === 'fold');
|
|
90
107
|
assert.equal(folds.length, 1);
|
|
91
108
|
if (folds[0]?.kind !== 'fold') throw new Error('missing fold');
|
|
92
|
-
assert.equal(folds[0].steps.length,
|
|
93
|
-
assert.deepEqual(items.filter(item => item.kind === 'open').map(item => item.key), ['
|
|
109
|
+
assert.equal(folds[0].steps.length, 4);
|
|
110
|
+
assert.deepEqual(items.filter(item => item.kind === 'open').map(item => item.key), ['5']);
|
|
111
|
+
|
|
112
|
+
const withNarration = presentLiveTurn([reasoning('1'), tool('2'), body('3'), reasoning('4'), tool('5'), reasoning('6')], open);
|
|
113
|
+
const narratedFolds = withNarration.filter(item => item.kind === 'fold');
|
|
114
|
+
assert.equal(narratedFolds.length, 2);
|
|
115
|
+
assert.deepEqual(withNarration.filter(item => item.kind === 'open').map(item => item.key), ['3', '6']);
|
|
116
|
+
assert.deepEqual(narratedFolds.map(fold => fold.kind === 'fold' ? fold.steps.map(step => step.key) : []), [['1', '2'], ['4', '5']]);
|
|
117
|
+
for (const fold of narratedFolds) {
|
|
118
|
+
if (fold.kind !== 'fold') continue;
|
|
119
|
+
assert.equal(fold.steps.some(step => step.kind === 'body'), false);
|
|
120
|
+
}
|
|
94
121
|
});
|
|
95
122
|
|
|
96
|
-
test('mid-turn user insert resets the chain;
|
|
123
|
+
test('mid-turn user insert resets the chain; narration stays open across the reset', () => {
|
|
97
124
|
const before = presentLiveTurn([reasoning('1'), body('2'), tool('3'), user('insert'), body('4'), tool('5')], open);
|
|
98
125
|
assert.deepEqual(kinds(before), [
|
|
99
126
|
['open', '1'], ['open', '2'], ['open', '3'],
|
|
@@ -105,28 +132,30 @@ test('mid-turn user insert resets the chain; only the next reasoning folds post-
|
|
|
105
132
|
assert.deepEqual(kinds(after), [
|
|
106
133
|
['open', '1'], ['open', '2'], ['open', '3'],
|
|
107
134
|
['user', 'insert'],
|
|
108
|
-
['
|
|
135
|
+
['open', '4'],
|
|
136
|
+
['fold', '工具×1', ['5']],
|
|
109
137
|
['open', '6'],
|
|
110
138
|
]);
|
|
111
139
|
const folds = after.filter(item => item.kind === 'fold');
|
|
112
140
|
assert.equal(folds.length, 1);
|
|
113
141
|
if (folds[0]?.kind !== 'fold') throw new Error('missing insert fold');
|
|
114
|
-
assert.ok(!folds[0].steps.some(step => ['1', '2', '3', 'insert'].includes(step.key)));
|
|
142
|
+
assert.ok(!folds[0].steps.some(step => ['1', '2', '3', '4', 'insert'].includes(step.key)));
|
|
115
143
|
});
|
|
116
144
|
|
|
117
|
-
test('
|
|
145
|
+
test('every prior process run folds in its own place; narration stays open between runs', () => {
|
|
118
146
|
const items = presentLiveTurn([
|
|
119
|
-
reasoning('1'),
|
|
120
|
-
|
|
121
|
-
|
|
147
|
+
reasoning('1'), tool('2'), reasoning('3'),
|
|
148
|
+
body('4'),
|
|
149
|
+
tool('5'), reasoning('6'), tool('7'), reasoning('8'),
|
|
122
150
|
], open);
|
|
123
151
|
const folds = items.filter(item => item.kind === 'fold');
|
|
124
152
|
assert.equal(folds.length, 2);
|
|
125
|
-
if (folds[0]?.kind !== 'fold' || folds[1]?.kind !== 'fold') throw new Error('expected one box per
|
|
153
|
+
if (folds[0]?.kind !== 'fold' || folds[1]?.kind !== 'fold') throw new Error('expected one box per run');
|
|
126
154
|
assert.deepEqual(folds[0].steps.map(step => step.key), ['1', '2', '3']);
|
|
127
|
-
assert.deepEqual(folds[1].steps.map(step => step.key), ['5', '6']);
|
|
128
|
-
assert.equal(folds[0].summary, '思考×
|
|
129
|
-
assert.equal(folds[1].summary, '
|
|
155
|
+
assert.deepEqual(folds[1].steps.map(step => step.key), ['5', '6', '7']);
|
|
156
|
+
assert.equal(folds[0].summary, '思考×2 · 工具×1');
|
|
157
|
+
assert.equal(folds[1].summary, '思考×1 · 工具×2');
|
|
158
|
+
assert.deepEqual(items.filter(item => item.kind === 'open').map(item => item.key), ['4', '8']);
|
|
130
159
|
});
|
|
131
160
|
|
|
132
161
|
test('successful turn close disables live fold so the existing final-answer rules still apply', () => {
|
|
@@ -216,3 +245,45 @@ test('disabling auto-fold keeps all steps open as plain streaming steps', () =>
|
|
|
216
245
|
assert.deepEqual(items.map(item => item.key), ['1', '2', '3', '4', '5', '6']);
|
|
217
246
|
assert.deepEqual(items.map(item => item.kind), ['open', 'open', 'open', 'open', 'open', 'open']);
|
|
218
247
|
});
|
|
248
|
+
|
|
249
|
+
/** Issue #73: body, then a process run, then another body, then a later reasoning. */
|
|
250
|
+
function issue73Steps(): LiveStep[] {
|
|
251
|
+
return [
|
|
252
|
+
body('body1'),
|
|
253
|
+
reasoning('r1'), reasoning('r2'),
|
|
254
|
+
tool('t1'), tool('t2'), tool('t3'),
|
|
255
|
+
other('rec1'),
|
|
256
|
+
body('body2'),
|
|
257
|
+
reasoning('rLatest'),
|
|
258
|
+
];
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
test('open turn does not seam a run the live fold already summarises (#73)', () => {
|
|
262
|
+
const steps = issue73Steps();
|
|
263
|
+
const live = presentLiveTurn(steps, open, true);
|
|
264
|
+
const folds = live.filter(item => item.kind === 'fold');
|
|
265
|
+
assert.equal(folds.length, 1);
|
|
266
|
+
if (folds[0]?.kind !== 'fold') throw new Error('missing live fold');
|
|
267
|
+
assert.equal(folds[0].summary, '思考×2 · 工具×3 · 记录×1');
|
|
268
|
+
assert.deepEqual(folds[0].steps.map(step => step.key), ['r1', 'r2', 't1', 't2', 't3', 'rec1']);
|
|
269
|
+
|
|
270
|
+
const seams = planProcessSeams(steps, live, false);
|
|
271
|
+
assert.equal(seams.before.has('body2'), false);
|
|
272
|
+
assert.equal(seams.after.has('body1'), false);
|
|
273
|
+
assert.equal(seams.before.size, 0);
|
|
274
|
+
assert.equal(seams.after.size, 0);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test('closed turn keeps the seam between narration blocks and the previous body spacing (#73)', () => {
|
|
278
|
+
const steps = issue73Steps();
|
|
279
|
+
const live = presentLiveTurn(steps, completed, true);
|
|
280
|
+
assert.equal(live.some(item => item.kind === 'fold'), false);
|
|
281
|
+
|
|
282
|
+
const seams = planProcessSeams(steps, live, false);
|
|
283
|
+
assert.equal(seams.before.get('body2'), '思考×2 · 工具×3 · 记录×1');
|
|
284
|
+
assert.equal(seams.after.has('body1'), true);
|
|
285
|
+
|
|
286
|
+
const expanded = planProcessSeams(steps, live, true);
|
|
287
|
+
assert.equal(expanded.before.size, 0);
|
|
288
|
+
assert.equal(expanded.after.size, 0);
|
|
289
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { IncomingMessage, ServerResponse } from 'node:http';
|
|
3
|
+
import { Socket } from 'node:net';
|
|
4
|
+
import { test } from 'vitest';
|
|
5
|
+
import { createLogger, redact, REDACTED, requestIdOf } from '../src/log.js';
|
|
6
|
+
import { apply } from '../src/dsh-better-display.js';
|
|
7
|
+
import { ROUTE_REVEAL } from '../src/metrics.js';
|
|
8
|
+
|
|
9
|
+
const SECRET = 's3cr3t-token-value';
|
|
10
|
+
|
|
11
|
+
test('logger redacts authorization headers, tokens, and secret-looking query values', () => {
|
|
12
|
+
const lines: string[] = [];
|
|
13
|
+
const logger = createLogger({ write: line => lines.push(line), clock: () => new Date('2026-10-20T00:00:00.000Z') });
|
|
14
|
+
logger.info('http request', {
|
|
15
|
+
headers: {
|
|
16
|
+
authorization: `Bearer ${SECRET}`,
|
|
17
|
+
'x-api-key': SECRET,
|
|
18
|
+
'x-request-id': 'req-1',
|
|
19
|
+
accept: 'application/json',
|
|
20
|
+
},
|
|
21
|
+
url: `/better-display/skill-status?cwd=/work&token=${SECRET}&api_key=${SECRET}&password=${SECRET}`,
|
|
22
|
+
token: SECRET,
|
|
23
|
+
access_token: SECRET,
|
|
24
|
+
});
|
|
25
|
+
const record = JSON.parse(lines[0] ?? '{}') as {
|
|
26
|
+
level: string;
|
|
27
|
+
msg: string;
|
|
28
|
+
time: string;
|
|
29
|
+
headers: { authorization: string; 'x-api-key': string; 'x-request-id': string; accept: string };
|
|
30
|
+
url: string;
|
|
31
|
+
token: string;
|
|
32
|
+
access_token: string;
|
|
33
|
+
};
|
|
34
|
+
const text = lines.join('\n');
|
|
35
|
+
assert.equal(record.level, 'info');
|
|
36
|
+
assert.equal(record.msg, 'http request');
|
|
37
|
+
assert.equal(record.time, '2026-10-20T00:00:00.000Z');
|
|
38
|
+
assert.equal(record.headers.authorization, REDACTED);
|
|
39
|
+
assert.equal(record.headers['x-api-key'], REDACTED);
|
|
40
|
+
assert.equal(record.token, REDACTED);
|
|
41
|
+
assert.equal(record.access_token, REDACTED);
|
|
42
|
+
assert.equal(record.headers['x-request-id'], 'req-1');
|
|
43
|
+
assert.equal(record.headers.accept, 'application/json');
|
|
44
|
+
assert.match(record.url, /cwd=\/work/);
|
|
45
|
+
assert.match(record.url, /token=\[REDACTED\]/);
|
|
46
|
+
assert.match(record.url, /api_key=\[REDACTED\]/);
|
|
47
|
+
assert.match(record.url, /password=\[REDACTED\]/);
|
|
48
|
+
assert.equal(text.includes(SECRET), false);
|
|
49
|
+
assert.deepEqual(redact({ authorization: `Bearer ${SECRET}`, accept: 'application/json' }), {
|
|
50
|
+
authorization: REDACTED,
|
|
51
|
+
accept: 'application/json',
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('request ids keep a safe X-Request-ID and replace anything else', () => {
|
|
56
|
+
const kept = requestIdOf({ headers: { 'x-request-id': 'req.trace_1:part-a' } } as IncomingMessage);
|
|
57
|
+
assert.equal(kept, 'req.trace_1:part-a');
|
|
58
|
+
const firstOfMany = requestIdOf({ headers: { 'x-request-id': ['batch-7', 'batch-8'] } } as IncomingMessage);
|
|
59
|
+
assert.equal(firstOfMany, 'batch-7');
|
|
60
|
+
const generated = requestIdOf({ headers: { 'x-request-id': 'bad id\ninjected' } } as IncomingMessage);
|
|
61
|
+
assert.notEqual(generated, 'bad id\ninjected');
|
|
62
|
+
assert.match(generated, /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
|
|
63
|
+
assert.match(requestIdOf({ headers: {} } as IncomingMessage), /^[0-9a-f-]{36}$/);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('reveal handler logs the request id and does not log the secret query', () => {
|
|
67
|
+
const lines: string[] = [];
|
|
68
|
+
const original = console.log;
|
|
69
|
+
console.log = (line?: unknown) => { lines.push(String(line)); };
|
|
70
|
+
try {
|
|
71
|
+
const routes: { path: string; handler: (req: IncomingMessage, res: ServerResponse) => void }[] = [];
|
|
72
|
+
apply({
|
|
73
|
+
webServer: {
|
|
74
|
+
register(route: { path: string; handler: (req: IncomingMessage, res: ServerResponse) => void }) {
|
|
75
|
+
routes.push(route);
|
|
76
|
+
return () => undefined;
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
effect(run: () => void) { run(); },
|
|
80
|
+
} as never, { flags: { readerEnabled: true } });
|
|
81
|
+
const reveal = routes.find(route => route.path === ROUTE_REVEAL);
|
|
82
|
+
assert.ok(reveal);
|
|
83
|
+
const req = new IncomingMessage(new Socket());
|
|
84
|
+
req.method = 'GET';
|
|
85
|
+
req.url = `${ROUTE_REVEAL}?cwd=/work&token=${SECRET}`;
|
|
86
|
+
req.headers = { authorization: `Bearer ${SECRET}`, 'x-request-id': 'req-reveal-1' };
|
|
87
|
+
const res = new ServerResponse(req);
|
|
88
|
+
res.end = (() => res) as ServerResponse['end'];
|
|
89
|
+
reveal.handler(req, res);
|
|
90
|
+
assert.equal(res.statusCode, 405);
|
|
91
|
+
assert.equal(res.getHeader('X-Request-ID'), 'req-reveal-1');
|
|
92
|
+
const text = lines.join('\n');
|
|
93
|
+
assert.match(text, /\[my-plugins\/dsh-better-display\] loaded/);
|
|
94
|
+
assert.match(text, /req-reveal-1/);
|
|
95
|
+
assert.equal(text.includes(SECRET), false);
|
|
96
|
+
} finally {
|
|
97
|
+
console.log = original;
|
|
98
|
+
}
|
|
99
|
+
});
|
package/tests/mcp-apps.test.ts
CHANGED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { IncomingMessage, ServerResponse } from 'node:http';
|
|
3
|
+
import { Socket } from 'node:net';
|
|
4
|
+
import { test } from 'vitest';
|
|
5
|
+
import { apply } from '../src/dsh-better-display.js';
|
|
6
|
+
import { metricsPayload, recordRouteDuration, recordRouteHit, ROUTE_METRICS, ROUTE_REVEAL, ROUTE_SKILL_STATUS } from '../src/metrics.js';
|
|
7
|
+
|
|
8
|
+
test('route hit counter increments', () => {
|
|
9
|
+
const before = metricsPayload();
|
|
10
|
+
const hitsBefore = before.counters.dsh_better_display_route_hits_total[ROUTE_REVEAL] ?? 0;
|
|
11
|
+
const totalBefore = before.timingsMs[ROUTE_REVEAL]?.totalMs ?? 0;
|
|
12
|
+
const next = recordRouteHit(ROUTE_REVEAL);
|
|
13
|
+
recordRouteDuration(ROUTE_REVEAL, 12.4);
|
|
14
|
+
const after = metricsPayload();
|
|
15
|
+
assert.equal(next, hitsBefore + 1);
|
|
16
|
+
assert.equal(after.counters.dsh_better_display_route_hits_total[ROUTE_REVEAL], hitsBefore + 1);
|
|
17
|
+
assert.equal(after.timingsMs[ROUTE_REVEAL]?.totalMs, totalBefore + 12);
|
|
18
|
+
assert.ok((after.timingsMs[ROUTE_REVEAL]?.maxMs ?? 0) >= 12);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('GET /better-display/metrics returns the incremented route counter', () => {
|
|
22
|
+
const routes: { path: string; handler: (req: IncomingMessage, res: ServerResponse) => void }[] = [];
|
|
23
|
+
apply({
|
|
24
|
+
webServer: {
|
|
25
|
+
register(route: { path: string; handler: (req: IncomingMessage, res: ServerResponse) => void }) {
|
|
26
|
+
routes.push(route);
|
|
27
|
+
return () => undefined;
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
effect(run: () => void) { run(); },
|
|
31
|
+
} as never, { flags: { readerEnabled: true } });
|
|
32
|
+
assert.deepEqual(routes.map(route => route.path).sort(), [ROUTE_METRICS, ROUTE_REVEAL, ROUTE_SKILL_STATUS].sort());
|
|
33
|
+
const metrics = routes.find(route => route.path === ROUTE_METRICS);
|
|
34
|
+
assert.ok(metrics);
|
|
35
|
+
const before = metricsPayload().counters.dsh_better_display_route_hits_total[ROUTE_METRICS] ?? 0;
|
|
36
|
+
const req = new IncomingMessage(new Socket());
|
|
37
|
+
req.method = 'GET';
|
|
38
|
+
req.url = ROUTE_METRICS;
|
|
39
|
+
req.headers = { 'x-request-id': 'req-metrics-1' };
|
|
40
|
+
const res = new ServerResponse(req);
|
|
41
|
+
let body = '';
|
|
42
|
+
res.end = ((chunk?: string) => {
|
|
43
|
+
body = chunk ?? '';
|
|
44
|
+
return res;
|
|
45
|
+
}) as ServerResponse['end'];
|
|
46
|
+
metrics.handler(req, res);
|
|
47
|
+
assert.equal(res.statusCode, 200);
|
|
48
|
+
assert.equal(res.getHeader('content-type'), 'application/json');
|
|
49
|
+
assert.equal(res.getHeader('X-Request-ID'), 'req-metrics-1');
|
|
50
|
+
const parsed = JSON.parse(body) as { counters: { dsh_better_display_route_hits_total: Record<string, number> } };
|
|
51
|
+
assert.equal(parsed.counters.dsh_better_display_route_hits_total[ROUTE_METRICS], before + 1);
|
|
52
|
+
});
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
|
-
import test from '
|
|
2
|
+
import { test } from 'vitest';
|
|
3
3
|
import { memo } from 'react';
|
|
4
4
|
import { SlotCore } from '@deepseek-ai/dsh-client-ui-slots';
|
|
5
|
-
import { mirrorOfficialSlot, readerTailMatch, type CompositionRegistry } from '../src/client/official-slots.js';
|
|
5
|
+
import { acceptMirroredChatNode, mirrorOfficialSlot, readerTailMatch, type CompositionRegistry } from '../src/client/official-slots.js';
|
|
6
6
|
|
|
7
7
|
function registry(kind: 'keyed' | 'chain' | 'list' = 'keyed') {
|
|
8
8
|
const core = new SlotCore();
|
|
@@ -96,6 +96,45 @@ test('new registrations do not remount existing cards; winner changes and unload
|
|
|
96
96
|
assert.equal(core.entriesOfSlot('reader').length, 0);
|
|
97
97
|
});
|
|
98
98
|
|
|
99
|
+
test('a lower-priority assistant-step replacement is mirrored and the official renderer is not', () => {
|
|
100
|
+
const { core, slots, register } = registry();
|
|
101
|
+
const official = memo(() => null);
|
|
102
|
+
const foreign = memo(() => null);
|
|
103
|
+
const tool = memo(() => null);
|
|
104
|
+
const user = memo(() => null);
|
|
105
|
+
register({ name: 'source', key: 'assistant-step' }, official);
|
|
106
|
+
register({ name: 'source', key: 'assistant-step', priority: -10 }, foreign);
|
|
107
|
+
register({ name: 'source', key: 'tool-call', priority: -10 }, tool);
|
|
108
|
+
register({ name: 'source', key: 'turn-process' }, memo(() => null));
|
|
109
|
+
register({ name: 'source', key: 'user' }, user);
|
|
110
|
+
const winner = core.entriesOfSlot('source').find(entry => entry.options.key === 'assistant-step');
|
|
111
|
+
assert.ok(winner);
|
|
112
|
+
assert.equal(acceptMirroredChatNode(winner), true);
|
|
113
|
+
assert.equal(winner.component, foreign);
|
|
114
|
+
const stop = mirrorOfficialSlot(slots, 'source', 'reader', 'reader.children', acceptMirroredChatNode);
|
|
115
|
+
const mirrored = core.entriesOfSlot('reader');
|
|
116
|
+
assert.deepEqual(mirrored.map(entry => entry.options.key).sort(), ['assistant-step', 'user']);
|
|
117
|
+
assert.equal(mirrored.find(entry => entry.options.key === 'assistant-step')?.component, foreign);
|
|
118
|
+
assert.equal(mirrored.find(entry => entry.options.key === 'assistant-step')?.options.priority, -10);
|
|
119
|
+
assert.equal(mirrored.some(entry => entry.component === official || entry.component === tool), false);
|
|
120
|
+
stop();
|
|
121
|
+
assert.equal(core.entriesOfSlot('reader').length, 0);
|
|
122
|
+
assert.equal(core.entriesOfSlot('source').find(entry => entry.options.key === 'assistant-step')?.component, foreign);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('the official assistant-step priority stays on the source slot', () => {
|
|
126
|
+
const { core, slots, register } = registry();
|
|
127
|
+
const official = memo(() => null);
|
|
128
|
+
register({ name: 'source', key: 'assistant-step', priority: 0 }, official);
|
|
129
|
+
const entry = core.entriesOfSlot('source')[0];
|
|
130
|
+
assert.ok(entry);
|
|
131
|
+
assert.equal(acceptMirroredChatNode(entry), false);
|
|
132
|
+
const stop = mirrorOfficialSlot(slots, 'source', 'reader', 'reader.children', acceptMirroredChatNode);
|
|
133
|
+
assert.equal(core.entriesOfSlot('reader').length, 0);
|
|
134
|
+
assert.equal(core.entriesOfSlot('source')[0]?.component, official);
|
|
135
|
+
stop();
|
|
136
|
+
});
|
|
137
|
+
|
|
99
138
|
test('partial setup rolls back its own registrations on a nested contract failure', () => {
|
|
100
139
|
const { core, slots, register } = registry();
|
|
101
140
|
register({ name: 'source', key: 'nested', children: { detail: { kind: 'single', scope: 'session' } } }, () => null);
|
package/tests/open-file.test.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { test } from '
|
|
1
|
+
import { test } from 'vitest';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
3
|
import { readFileSync } from 'node:fs';
|
|
4
4
|
import { dirname, resolve } from 'node:path';
|
|
@@ -35,6 +35,17 @@ test('pendingSubmissions that is not an array is treated as empty', () => {
|
|
|
35
35
|
assert.deepEqual(asReadonlyArray([{ requestId: 'a' }]), [{ requestId: 'a' }]);
|
|
36
36
|
});
|
|
37
37
|
|
|
38
|
+
test('pending submission bubble keeps the host font metrics', () => {
|
|
39
|
+
const source = readFileSync(resolve(root, 'src/client/Reader.tsx'), 'utf8');
|
|
40
|
+
const css = readFileSync(resolve(root, 'src/client/Reader.module.css'), 'utf8');
|
|
41
|
+
assert.match(source, /data-reader-pending-submission[\s\S]*className=\{css\.user\}/);
|
|
42
|
+
const user = css.match(/\.user\s*\{[^}]*\}/)?.[0] ?? '';
|
|
43
|
+
assert.match(user, /font-size:\s*var\(--dsh-content-font-size,\s*14px\)/);
|
|
44
|
+
assert.match(user, /line-height:\s*calc\(22px \+ var\(--dsh-content-font-delta,\s*0px\)\)/);
|
|
45
|
+
assert.match(user, /font-weight:\s*400/);
|
|
46
|
+
assert.match(user, /white-space:\s*pre-wrap/);
|
|
47
|
+
});
|
|
48
|
+
|
|
38
49
|
test('Reader and committed client bundle do not read unguarded submission.images.length', () => {
|
|
39
50
|
const source = readFileSync(resolve(root, 'src/client/Reader.tsx'), 'utf8');
|
|
40
51
|
assert.match(source, /pendingSubmissionImages/);
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
|
|
4
|
+
const read = (path: string) => readFileSync(new URL(path, import.meta.url), 'utf8');
|
|
5
|
+
|
|
6
|
+
describe('pinned selection', () => {
|
|
7
|
+
it('covers every Reader body marker, so final formatting keeps a live selection', () => {
|
|
8
|
+
const reader = [read('../src/client/Reader.tsx'), read('../src/client/AssistantNode.tsx')].join('\n');
|
|
9
|
+
const motion = read('../src/client/motion.tsx');
|
|
10
|
+
const selector = /usePinnedSelection\([^)]*selector = '([^']+)'/.exec(motion)?.[1] ?? '';
|
|
11
|
+
const markers = ['data-reader-answer', 'data-reader-body'].filter(marker => new RegExp(`<article[^>]*\\b${marker}\\b`).test(reader));
|
|
12
|
+
expect(markers.length).toBeGreaterThan(0);
|
|
13
|
+
for (const marker of markers) expect(selector).toContain(`[${marker}]`);
|
|
14
|
+
expect(selector).toContain('[data-reader-process]');
|
|
15
|
+
});
|
|
16
|
+
});
|
package/tests/projection.test.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { test } from '
|
|
1
|
+
import { test } from 'vitest';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
3
|
import type { AssistantBlock, ToolCallBlock, TurnLocation } from '@deepseek-ai/dsh-client-ui-conversation/client';
|
|
4
4
|
import type { AssistantChatData, ChatConversationViewNode } from '@deepseek-ai/dsh-client-ui-chat/client';
|
|
5
5
|
import { assistantSegments, boundaryOf, forkAnchorSeq, groupNodes, hasProcessContent, hasVisibleBody, isEarlierNarration, processChoiceKey, processExpanded, terminalLabel, toolFailed } from '../src/client/projection.ts';
|
|
6
|
+
import { visibleChatKeys } from '../src/client/rewind-cut.ts';
|
|
6
7
|
import type { TurnBoundary } from '../src/client/projection.ts';
|
|
7
8
|
import { activityPhase, activitySummary, inputFields, readerFlow } from '../src/client/tool-activity.ts';
|
|
8
9
|
|
|
@@ -70,14 +71,15 @@ test('reading during a run does not keep the completed process open; reopening a
|
|
|
70
71
|
assert.equal(processExpanded(choices[processChoiceKey('turn:2', completed)], completed), false);
|
|
71
72
|
});
|
|
72
73
|
|
|
73
|
-
test('body-only steps
|
|
74
|
+
test('body-only steps never advertise process content, even when they are earlier narration', () => {
|
|
74
75
|
const node = (data: AssistantChatData) => ({ kind: 'assistant-step', visibility: 'visible', data }) as ChatConversationViewNode;
|
|
75
76
|
const closing = node(assistant({ step: 2 }));
|
|
76
77
|
assert.equal(hasProcessContent(closing, active), false);
|
|
77
78
|
assert.equal(hasProcessContent(closing, completed), false);
|
|
78
79
|
assert.equal(hasProcessContent(node(assistant({ step: 2, blocks: [{ kind: 'reasoning', text: '' }, text] })), completed), false);
|
|
79
80
|
assert.equal(hasProcessContent(node(assistant({ step: 2, blocks: [{ kind: 'reasoning', text: '真实思考' }, text] })), active), true);
|
|
80
|
-
|
|
81
|
+
// Earlier narration stays visible, so it is not a thinking/process disclosure.
|
|
82
|
+
assert.equal(hasProcessContent(node(assistant({ step: 1 })), completed), false);
|
|
81
83
|
assert.equal(hasProcessContent({ ...closing, visibility: 'hidden' }, completed), false);
|
|
82
84
|
});
|
|
83
85
|
|
|
@@ -175,6 +177,67 @@ test('a nonzero terminal exit is a failure even when the tool transport is non-e
|
|
|
175
177
|
assert.equal(activityPhase({}, true), 'interrupted');
|
|
176
178
|
});
|
|
177
179
|
|
|
180
|
+
test('a supplementary rewind does not restore a tool draft from a cut step', () => {
|
|
181
|
+
const liveDraft = { kind: 'tool-call' as const, callId: 'live-tool', name: 'bash', argsRaw: '{"command":"pwd"}' };
|
|
182
|
+
const earlyCutDraft = { kind: 'tool-call' as const, callId: 'early-cut', name: 'bash', argsRaw: '{"command":"echo EARLY"}' };
|
|
183
|
+
const cutDraft = { kind: 'tool-call' as const, callId: 'cut-tool', name: 'bash', argsRaw: '{"command":"echo WITHDRAWN_TOOL"}' };
|
|
184
|
+
const orphanDraft = { kind: 'tool-call' as const, callId: 'orphan-cut', name: 'bash', argsRaw: '{"command":"echo ORPHAN"}' };
|
|
185
|
+
const turn = {
|
|
186
|
+
turn: 1,
|
|
187
|
+
steps: [
|
|
188
|
+
{ step: 0, start: { seq: 2 }, data: new Map([['assistant-step', { blocks: [liveDraft, earlyCutDraft] }]]) },
|
|
189
|
+
{ step: 1, start: { seq: 4 }, data: new Map([['assistant-step', { blocks: [cutDraft, orphanDraft] }]]) },
|
|
190
|
+
],
|
|
191
|
+
} as unknown as TurnLocation;
|
|
192
|
+
const rows = [
|
|
193
|
+
{ key: 'user', kind: 'user', anchorSeq: 1, visibility: 'visible', location: { kind: 'turn', turn }, data: {} },
|
|
194
|
+
{ key: 'supplement', kind: 'steering', anchorSeq: 3, visibility: 'visible', location: { kind: 'turn', turn }, data: {} },
|
|
195
|
+
{ key: 'early-tool', kind: 'tool-call', anchorSeq: 4, visibility: 'visible', location: { kind: 'turn', turn }, data: { root: { callId: 'early-cut', name: 'bash', argsRaw: '{}' } } },
|
|
196
|
+
{ key: 'tool', kind: 'tool-call', anchorSeq: 5, visibility: 'visible', location: { kind: 'turn', turn }, data: { root: { callId: 'cut-tool', name: 'bash', argsRaw: '{}' } } },
|
|
197
|
+
{ key: 'cmd', kind: 'command', anchorSeq: 6, visibility: 'visible', location: { kind: 'none' }, data: { name: 'rewind-plugin', args: '@3 chat', outcome: { kind: 'success', sourceEventSeq: 7 } } },
|
|
198
|
+
] as unknown as ChatConversationViewNode[];
|
|
199
|
+
const byKey = new Map(rows.map(node => [node.key, node]));
|
|
200
|
+
const order = rows.map(node => node.key);
|
|
201
|
+
const visible = visibleChatKeys({ order, nodes: byKey });
|
|
202
|
+
assert.deepEqual([...visible], ['user']);
|
|
203
|
+
const group = groupNodes(visible, key => byKey.get(key))[0];
|
|
204
|
+
if (!group) throw new Error('missing turn group');
|
|
205
|
+
const mainKeys = byKey.get(group.keys[0] ?? '')?.kind === 'user' ? group.keys.slice(1) : group.keys;
|
|
206
|
+
const flow = readerFlow({ ...group, keys: mainKeys }, turn, key => byKey.get(key), order);
|
|
207
|
+
const toolKeys = flow.filter(entry => entry.kind === 'tool').map(entry => entry.key);
|
|
208
|
+
assert.deepEqual(toolKeys, ['reader-tool:live-tool']);
|
|
209
|
+
|
|
210
|
+
const hiddenDraft = { kind: 'tool-call' as const, callId: 'hidden-call', name: 'write', argsRaw: '{"file_path":"/work/view.html"}' };
|
|
211
|
+
const hiddenTurn = {
|
|
212
|
+
turn: 2,
|
|
213
|
+
steps: [{ step: 0, start: { seq: 1 }, data: new Map([['assistant-step', { blocks: [hiddenDraft] }]]) }],
|
|
214
|
+
} as unknown as TurnLocation;
|
|
215
|
+
const hiddenNode = {
|
|
216
|
+
key: 'hidden-tool', kind: 'tool-call', anchorSeq: 2, visibility: 'hidden',
|
|
217
|
+
location: { kind: 'turn', turn: hiddenTurn },
|
|
218
|
+
data: { root: { callId: 'hidden-call', name: 'write', argsRaw: '{}' } },
|
|
219
|
+
} as unknown as ChatConversationViewNode;
|
|
220
|
+
const hiddenOrder = ['hidden-tool'];
|
|
221
|
+
const hiddenFlow = readerFlow(
|
|
222
|
+
{ key: 'turn:2', turn: 2, keys: [] },
|
|
223
|
+
hiddenTurn,
|
|
224
|
+
key => key === hiddenNode.key ? hiddenNode : undefined,
|
|
225
|
+
hiddenOrder,
|
|
226
|
+
);
|
|
227
|
+
assert.equal(hiddenFlow[0]?.kind === 'tool' && hiddenFlow[0].key, 'reader-tool:hidden-call');
|
|
228
|
+
|
|
229
|
+
const previewRows = [
|
|
230
|
+
{ key: 'user', kind: 'user', anchorSeq: 1, visibility: 'visible', location: { kind: 'turn', turn }, data: {} },
|
|
231
|
+
{ key: 'preview', kind: 'command', anchorSeq: 8, visibility: 'visible', location: { kind: 'none' }, data: { name: 'rewind-plugin', args: 'preview @3 both', outcome: { kind: 'success', sourceEventSeq: 9 } } },
|
|
232
|
+
] as unknown as ChatConversationViewNode[];
|
|
233
|
+
const previewByKey = new Map(previewRows.map(node => [node.key, node]));
|
|
234
|
+
const previewOrder = previewRows.map(node => node.key);
|
|
235
|
+
const previewFlow = readerFlow({ ...group, keys: mainKeys }, turn, key => previewByKey.get(key) ?? byKey.get(key), previewOrder);
|
|
236
|
+
const previewTools = previewFlow.filter(entry => entry.kind === 'tool').map(entry => entry.key);
|
|
237
|
+
assert.ok(previewTools.includes('reader-tool:cut-tool'));
|
|
238
|
+
assert.ok(previewTools.includes('reader-tool:live-tool'));
|
|
239
|
+
});
|
|
240
|
+
|
|
178
241
|
test('fork anchor prefers the durable closing seq and never yields a missing anchor', () => {
|
|
179
242
|
assert.equal(forkAnchorSeq([{ seq: 42 }]), 42);
|
|
180
243
|
assert.equal(forkAnchorSeq([null, undefined, {}, { seq: '42' as unknown as number }, { seq: 7 }]), 7);
|