dsh-better-display 0.3.8 → 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 +11 -0
- 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 +48 -19
- package/docs/openapi.yaml +147 -0
- package/docs/runbooks/blank-reader.md +39 -0
- package/lib/client.js +1417 -490
- package/lib/client.js.map +1 -1
- package/lib/dsh-better-display.js +378 -144
- 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 +43 -71
- 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.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 +6 -0
- 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 +136 -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 -7
- package/lib/types/client/official-slots.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/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 +9 -2
- package/lib/types/client/tool-activity.d.ts.map +1 -1
- package/lib/types/client/tool-activity.js +42 -2
- 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 +56 -7
- 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/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 +68 -18
- package/src/client/Reader.tsx +36 -76
- package/src/client/SettingsSection.tsx +1 -7
- package/src/client/StickyLane.tsx +81 -2
- package/src/client/ToolActivity.tsx +1 -1
- package/src/client/index.tsx +8 -1
- package/src/client/live-turn.ts +100 -69
- package/src/client/motion.tsx +135 -7
- package/src/client/official-slots.tsx +23 -7
- package/src/client/projection.ts +16 -4
- package/src/client/rewind-cut.ts +340 -0
- package/src/client/text-transform.ts +265 -0
- package/src/client/timeline.ts +0 -2
- package/src/client/tool-activity.ts +49 -2
- package/src/client/turn-slots.ts +80 -0
- package/src/client/types.ts +19 -0
- package/src/dsh-better-display.ts +58 -7
- package/src/flags.ts +40 -0
- package/src/log.ts +137 -0
- package/src/metrics.ts +50 -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 +64 -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 +1 -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 +1 -1
- package/tests/reader-official-users.test.ts +11 -1
- 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 +98 -6
- package/tests/reveal.test.ts +3 -3
- 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 +1 -1
- package/tests/stream-buffer.test.ts +1 -1
- package/tests/styles.test.ts +30 -3
- package/tests/terminal-input-preview.test.ts +1 -1
- 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
|
@@ -0,0 +1,1090 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, resolve } from 'node:path';
|
|
4
|
+
import { test } from 'vitest';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import {
|
|
7
|
+
anchorSeqOf,
|
|
8
|
+
coalesceCutSpans,
|
|
9
|
+
cutContains,
|
|
10
|
+
hiddenRewindSeqs,
|
|
11
|
+
rewindCutSpans,
|
|
12
|
+
rewindTargetOfArgs,
|
|
13
|
+
visibleChatKeys,
|
|
14
|
+
visibleChatSignature,
|
|
15
|
+
type CutChatNode
|
|
16
|
+
} from '../src/client/rewind-cut.js';
|
|
17
|
+
import { reconcileTurnSlots } from '../src/client/turn-slots.js';
|
|
18
|
+
|
|
19
|
+
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
20
|
+
|
|
21
|
+
// Baseline first: the pure rule must go RED on the reported symptom before the
|
|
22
|
+
// Reader is wired to it, and the wiring/bundle assertions stay RED until then.
|
|
23
|
+
|
|
24
|
+
/** The pre-fix rule the Reader used: only the host's own hidden flag filtered rows. */
|
|
25
|
+
function legacyVisibleKeys(chat: {
|
|
26
|
+
order: readonly string[];
|
|
27
|
+
nodes: { get(key: string): CutChatNode | undefined };
|
|
28
|
+
}): readonly string[] {
|
|
29
|
+
return chat.order.filter(key => chat.nodes.get(key)?.visibility !== 'hidden');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function chatOf(nodes: readonly CutChatNode[]): {
|
|
33
|
+
order: readonly string[];
|
|
34
|
+
nodes: { get(key: string): CutChatNode | undefined };
|
|
35
|
+
} {
|
|
36
|
+
const map = new Map(nodes.map(node => [node.key, node]));
|
|
37
|
+
return { order: nodes.map(node => node.key), nodes: map };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A command node as the chat snapshot carries it (`commandFromDone`).
|
|
42
|
+
*
|
|
43
|
+
* `name` defaults to the spelling the client projection actually publishes —
|
|
44
|
+
* `rewind-plugin`, the plugin's registered command id — because that is what
|
|
45
|
+
* the live chat snapshot carries. The shorter `rewind` is what older session
|
|
46
|
+
* logs recorded; both must be recognised.
|
|
47
|
+
*/
|
|
48
|
+
function rewindCommand(
|
|
49
|
+
key: string,
|
|
50
|
+
seq: number,
|
|
51
|
+
args: string | null,
|
|
52
|
+
marker: number | undefined,
|
|
53
|
+
outcomeKind = 'success',
|
|
54
|
+
name = 'rewind-plugin'
|
|
55
|
+
): CutChatNode {
|
|
56
|
+
return {
|
|
57
|
+
key,
|
|
58
|
+
kind: 'command',
|
|
59
|
+
anchorSeq: seq,
|
|
60
|
+
visibility: 'visible',
|
|
61
|
+
data: {
|
|
62
|
+
kind: 'command',
|
|
63
|
+
seq,
|
|
64
|
+
time: 1,
|
|
65
|
+
commandId: key,
|
|
66
|
+
name,
|
|
67
|
+
args,
|
|
68
|
+
outcome: {
|
|
69
|
+
kind: outcomeKind,
|
|
70
|
+
...(marker === undefined ? {} : { sourceEventSeq: marker })
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function message(
|
|
77
|
+
key: string,
|
|
78
|
+
kind: 'user' | 'steering' | 'assistant-step' | 'tool-call' | 'turn-tail',
|
|
79
|
+
seq: number
|
|
80
|
+
): CutChatNode {
|
|
81
|
+
return {
|
|
82
|
+
key,
|
|
83
|
+
kind,
|
|
84
|
+
anchorSeq: seq,
|
|
85
|
+
visibility: 'visible',
|
|
86
|
+
data: { seq, time: 1, content: [] }
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The reported scenario: user@1, assistant@2, tool@3, then `/rewind @1 chat`
|
|
92
|
+
* executed at seq 5 with marker seq 6. Everything from the target onward must
|
|
93
|
+
* disappear from the Reader, including the `/rewind` command row itself.
|
|
94
|
+
*/
|
|
95
|
+
function rewoundSession() {
|
|
96
|
+
return chatOf([
|
|
97
|
+
message('user-1', 'user', 1),
|
|
98
|
+
message('assistant-2', 'assistant-step', 2),
|
|
99
|
+
message('tool-3', 'tool-call', 3),
|
|
100
|
+
rewindCommand('command-5', 5, '@1 chat', 6),
|
|
101
|
+
message('turn-tail-4', 'turn-tail', 4)
|
|
102
|
+
]);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
test('a rewind withdraws the target and every row after it, command row included', () => {
|
|
106
|
+
const chat = rewoundSession();
|
|
107
|
+
const visible = visibleChatKeys(chat);
|
|
108
|
+
assert.deepEqual(visible, []);
|
|
109
|
+
assert.deepEqual(rewindCutSpans(chat), [{ start: 1, end: 6 }]);
|
|
110
|
+
for (const seq of [1, 2, 3, 4, 5])
|
|
111
|
+
assert.equal(hiddenRewindSeqs(chat).has(seq), true, `seq ${seq} must be withdrawn`);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('the pre-fix visibility rule reproduces the reported defect', () => {
|
|
115
|
+
// Regression evidence for the fix, not a wish: with only the host's hidden
|
|
116
|
+
// flag honoured, the withdrawn answer, tool row and `/rewind` command row all
|
|
117
|
+
// survived — the exact symptom reported (the command is recorded, no rollback).
|
|
118
|
+
const chat = rewoundSession();
|
|
119
|
+
assert.deepEqual(legacyVisibleKeys(chat), [
|
|
120
|
+
'user-1',
|
|
121
|
+
'assistant-2',
|
|
122
|
+
'tool-3',
|
|
123
|
+
'command-5',
|
|
124
|
+
'turn-tail-4'
|
|
125
|
+
]);
|
|
126
|
+
assert.notDeepEqual(legacyVisibleKeys(chat), visibleChatKeys(chat));
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('a row before the target is never withdrawn', () => {
|
|
130
|
+
const chat = chatOf([
|
|
131
|
+
message('user-0', 'user', 1),
|
|
132
|
+
message('user-1', 'user', 2),
|
|
133
|
+
message('assistant-2', 'assistant-step', 3),
|
|
134
|
+
rewindCommand('command-4', 4, '@2 chat', 5)
|
|
135
|
+
]);
|
|
136
|
+
assert.deepEqual(visibleChatKeys(chat), ['user-0']);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('only a successful, marker-carrying rewind cuts; failures and previews stay readable', () => {
|
|
140
|
+
const failed = chatOf([
|
|
141
|
+
message('user-1', 'user', 1),
|
|
142
|
+
rewindCommand('command-2', 2, '@1 chat', undefined, 'error')
|
|
143
|
+
]);
|
|
144
|
+
assert.deepEqual(rewindCutSpans(failed), []);
|
|
145
|
+
assert.deepEqual(visibleChatKeys(failed), ['user-1', 'command-2']);
|
|
146
|
+
|
|
147
|
+
// A success without the marker seq (older host output) must not cut a guessed range.
|
|
148
|
+
const markerless = chatOf([
|
|
149
|
+
message('user-1', 'user', 1),
|
|
150
|
+
rewindCommand('command-2', 2, '@1 chat', undefined)
|
|
151
|
+
]);
|
|
152
|
+
assert.deepEqual(rewindCutSpans(markerless), []);
|
|
153
|
+
assert.deepEqual(visibleChatKeys(markerless), ['user-1', 'command-2']);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('preview and candidate probe rows never surface in the transcript', () => {
|
|
157
|
+
const chat = chatOf([
|
|
158
|
+
message('user-1', 'user', 1),
|
|
159
|
+
rewindCommand('preview-2', 2, 'preview @1 both', undefined),
|
|
160
|
+
rewindCommand('candidates-3', 3, '__candidates', undefined)
|
|
161
|
+
]);
|
|
162
|
+
assert.deepEqual(visibleChatKeys(chat), ['user-1']);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('separate rewinds keep separate spans so later traffic is not swallowed', () => {
|
|
166
|
+
// rewind#1 cut [1, 4]; new user@5 survives; rewind#2 cut [5, 8].
|
|
167
|
+
const spans = coalesceCutSpans([
|
|
168
|
+
{ start: 1, end: 4 },
|
|
169
|
+
{ start: 5, end: 8 }
|
|
170
|
+
]);
|
|
171
|
+
assert.deepEqual(spans, [
|
|
172
|
+
{ start: 1, end: 4 },
|
|
173
|
+
{ start: 5, end: 8 }
|
|
174
|
+
]);
|
|
175
|
+
assert.equal(cutContains(spans, 4), true);
|
|
176
|
+
assert.equal(cutContains(spans, 5), true);
|
|
177
|
+
// A gap between spans must not be collapsed into one range.
|
|
178
|
+
assert.deepEqual(
|
|
179
|
+
coalesceCutSpans([
|
|
180
|
+
{ start: 1, end: 4 },
|
|
181
|
+
{ start: 7, end: 9 }
|
|
182
|
+
]),
|
|
183
|
+
[
|
|
184
|
+
{ start: 1, end: 4 },
|
|
185
|
+
{ start: 7, end: 9 }
|
|
186
|
+
]
|
|
187
|
+
);
|
|
188
|
+
assert.equal(
|
|
189
|
+
cutContains(
|
|
190
|
+
coalesceCutSpans([
|
|
191
|
+
{ start: 1, end: 4 },
|
|
192
|
+
{ start: 7, end: 9 }
|
|
193
|
+
]),
|
|
194
|
+
5
|
|
195
|
+
),
|
|
196
|
+
false
|
|
197
|
+
);
|
|
198
|
+
// The cut stops at the marker. 4.1 is past end 4; the next turn's process
|
|
199
|
+
// row lives in that gap (`startSeq - 0.1`).
|
|
200
|
+
const gapped = coalesceCutSpans([
|
|
201
|
+
{ start: 1, end: 4 },
|
|
202
|
+
{ start: 7, end: 9 }
|
|
203
|
+
]);
|
|
204
|
+
assert.equal(cutContains(gapped, 4), true);
|
|
205
|
+
assert.equal(cutContains(gapped, 4.1), false);
|
|
206
|
+
assert.equal(cutContains(gapped, 5.0), false);
|
|
207
|
+
assert.equal(cutContains(gapped, 6.9), false);
|
|
208
|
+
assert.equal(cutContains(gapped, 7.0), true);
|
|
209
|
+
// Overlapping spans still merge.
|
|
210
|
+
assert.deepEqual(
|
|
211
|
+
coalesceCutSpans([
|
|
212
|
+
{ start: 1, end: 6 },
|
|
213
|
+
{ start: 4, end: 9 }
|
|
214
|
+
]),
|
|
215
|
+
[{ start: 1, end: 9 }]
|
|
216
|
+
);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test('malformed command payloads are skipped instead of throwing', () => {
|
|
220
|
+
const chat = chatOf([
|
|
221
|
+
{ key: 'broken', kind: 'command', anchorSeq: 1, data: 'not an object' },
|
|
222
|
+
{
|
|
223
|
+
key: 'no-name',
|
|
224
|
+
kind: 'command',
|
|
225
|
+
anchorSeq: 2,
|
|
226
|
+
data: {
|
|
227
|
+
args: '@1 chat',
|
|
228
|
+
outcome: { kind: 'success', sourceEventSeq: 3 }
|
|
229
|
+
}
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
key: 'bad-marker',
|
|
233
|
+
kind: 'command',
|
|
234
|
+
anchorSeq: 3,
|
|
235
|
+
data: {
|
|
236
|
+
name: 'rewind-plugin',
|
|
237
|
+
args: '@1 chat',
|
|
238
|
+
outcome: { kind: 'success', sourceEventSeq: 'x' }
|
|
239
|
+
}
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
key: 'bad-args',
|
|
243
|
+
kind: 'command',
|
|
244
|
+
anchorSeq: 4,
|
|
245
|
+
data: {
|
|
246
|
+
name: 'rewind',
|
|
247
|
+
args: null,
|
|
248
|
+
outcome: { kind: 'success', sourceEventSeq: 9 }
|
|
249
|
+
}
|
|
250
|
+
},
|
|
251
|
+
message('user-1', 'user', 1)
|
|
252
|
+
]);
|
|
253
|
+
assert.deepEqual(rewindCutSpans(chat), []);
|
|
254
|
+
// bad-args succeeded and carries a marker, so the command row itself leaves
|
|
255
|
+
// (same as the plugin). It still opens no span: user-1 stays.
|
|
256
|
+
assert.deepEqual(visibleChatKeys(chat), ['broken', 'no-name', 'bad-marker', 'user-1']);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test('anchorSeq falls back to the payload seq and target parsing is locale-independent', () => {
|
|
260
|
+
assert.equal(anchorSeqOf({ key: 'k', kind: 'user', data: { seq: 7 } }), 7);
|
|
261
|
+
assert.equal(anchorSeqOf({ key: 'k', anchorSeq: 9, data: { seq: 7 } }), 9);
|
|
262
|
+
assert.equal(anchorSeqOf({ key: 'k', anchorSeq: -1, data: {} }), undefined);
|
|
263
|
+
assert.equal(rewindTargetOfArgs('@12 chat'), 12);
|
|
264
|
+
assert.equal(rewindTargetOfArgs('preview @3 both'), 3);
|
|
265
|
+
assert.equal(rewindTargetOfArgs('chat'), undefined);
|
|
266
|
+
assert.equal(rewindTargetOfArgs(undefined), undefined);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test('an unfiltered snapshot keeps its original order reference', () => {
|
|
270
|
+
const chat = chatOf([message('user-1', 'user', 1), message('assistant-2', 'assistant-step', 2)]);
|
|
271
|
+
assert.equal(visibleChatKeys(chat), chat.order);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test('the Reader renders from the rewind cut instead of the raw snapshot order', () => {
|
|
275
|
+
// The seam that was missing: the pure rule above is useless unless the Reader
|
|
276
|
+
// actually filters its rows with it. Pin the wiring so a refactor cannot drop
|
|
277
|
+
// it and silently restore the reported defect.
|
|
278
|
+
const source = readFileSync(resolve(root, 'src/client/Reader.tsx'), 'utf8');
|
|
279
|
+
assert.match(source, /visibleChatKeys/);
|
|
280
|
+
// Quote-agnostic: the source is formatted by the project's own toolchain, so a
|
|
281
|
+
// double-quoted import is still the same wiring.
|
|
282
|
+
assert.ok(source.includes("from './rewind-cut.js'") || source.includes('from "./rewind-cut.js"'));
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test('the Reader no longer drives its groups and waiting clock from the raw order', () => {
|
|
286
|
+
// The raw snapshot order still feeds the pending-submission de-dup (a withdrawn
|
|
287
|
+
// message is still *durable*, so its echo must not reappear as a local one),
|
|
288
|
+
// but every render-facing consumer reads the filtered key list.
|
|
289
|
+
const source = readFileSync(resolve(root, 'src/client/Reader.tsx'), 'utf8');
|
|
290
|
+
assert.match(source, /groupNodes\(\s*renderKeys,/);
|
|
291
|
+
// The call may be formatted across lines, so match the argument rather than
|
|
292
|
+
// the argument-plus-comma.
|
|
293
|
+
assert.match(source, /waitingAnchor\(\s*renderKeys/);
|
|
294
|
+
assert.doesNotMatch(source, /groupNodes\(order,/);
|
|
295
|
+
assert.doesNotMatch(source, /waitingAnchor\(order,/);
|
|
296
|
+
assert.match(source, /observedInputIds\(order,/);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test('the client entry the bundle is built from still mounts the rewind cut', () => {
|
|
300
|
+
// lib/ is rebuilt at integration, so this pins the source the bundler reads
|
|
301
|
+
// rather than a stale prebuilt client.js.
|
|
302
|
+
const index = readFileSync(resolve(root, 'src/client/index.tsx'), 'utf8');
|
|
303
|
+
assert.match(index, /from ['"]\.\/Reader\.js['"]/);
|
|
304
|
+
const reader = readFileSync(resolve(root, 'src/client/Reader.tsx'), 'utf8');
|
|
305
|
+
assert.match(reader, /visibleChatKeys/);
|
|
306
|
+
assert.match(reader, /visibleChatSignature/);
|
|
307
|
+
assert.match(reader, /chatSeatProps\(/);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test('the Reader subscribes to the visible set instead of reference-stable snapshot fields', () => {
|
|
311
|
+
// The subscription seam: a rewind upserts an EXISTING command row in place, so
|
|
312
|
+
// `snapshot.order` and `snapshot.nodes` keep their identity and a selector on
|
|
313
|
+
// either never fires. The Reader must depend on the value-compared signature.
|
|
314
|
+
const source = readFileSync(resolve(root, 'src/client/Reader.tsx'), 'utf8');
|
|
315
|
+
// Matched across formatting: the formatter breaks the arrow onto its own line.
|
|
316
|
+
assert.match(source, /useChat\([\s\S]{0,40}?visibleChatSignature\(snapshot\)/);
|
|
317
|
+
assert.ok(source.includes('[order, nodes, rewindSignature]'));
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test('a string signature is stable enough for an Object.is-compared selector', () => {
|
|
321
|
+
const chat = chatOf([message('user-1', 'user', 1)]);
|
|
322
|
+
assert.equal(typeof visibleChatSignature(chat), 'string');
|
|
323
|
+
assert.equal(visibleChatSignature(chat), visibleChatSignature(chat));
|
|
324
|
+
assert.equal(visibleChatSignature(chat), 'user-1');
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test('the signature flips the moment the rewind marker lands', () => {
|
|
328
|
+
// Before the marker the command row is an ordinary record; after it, the cut
|
|
329
|
+
// exists and the same row must be gone. Nothing about the row's KEY changes,
|
|
330
|
+
// which is exactly why the memo needed a value signal rather than an identity.
|
|
331
|
+
const before = chatOf([
|
|
332
|
+
message('user-1', 'user', 1),
|
|
333
|
+
rewindCommand('command-2', 2, '@1 chat', undefined)
|
|
334
|
+
]);
|
|
335
|
+
const after = chatOf([message('user-1', 'user', 1), rewindCommand('command-2', 2, '@1 chat', 3)]);
|
|
336
|
+
assert.equal(visibleChatSignature(before), 'user-1' + '\u0000' + 'command-2');
|
|
337
|
+
assert.equal(visibleChatSignature(after), '');
|
|
338
|
+
assert.notEqual(visibleChatSignature(before), visibleChatSignature(after));
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test('a withdrawn turn retires its own synthetic rows, which the host anchors before the cut', () => {
|
|
342
|
+
// Measured live on the host (session 92667917, turn 1): the turn opens at 5,
|
|
343
|
+
// its user message lands at 9, and its process row is anchored at
|
|
344
|
+
// `controlAnchorSeq - 0.1` = 4.9 — BELOW the rewind target. Containment alone
|
|
345
|
+
// therefore kept the stopped-turn notice on screen even after every content
|
|
346
|
+
// row had been withdrawn: the second reported symptom.
|
|
347
|
+
const turn = {
|
|
348
|
+
turn: 1,
|
|
349
|
+
start: { seq: 5 },
|
|
350
|
+
end: { seq: 18 },
|
|
351
|
+
status: 'closed'
|
|
352
|
+
};
|
|
353
|
+
const chat = chatOf([
|
|
354
|
+
{
|
|
355
|
+
key: 'input-message',
|
|
356
|
+
kind: 'user',
|
|
357
|
+
anchorSeq: 9,
|
|
358
|
+
visibility: 'visible',
|
|
359
|
+
location: { kind: 'step', turn, step: 1 },
|
|
360
|
+
data: { seq: 9 }
|
|
361
|
+
},
|
|
362
|
+
{
|
|
363
|
+
key: 'turn-process1',
|
|
364
|
+
kind: 'turn-process',
|
|
365
|
+
anchorSeq: 4.9,
|
|
366
|
+
visibility: 'visible',
|
|
367
|
+
location: { kind: 'turn', turn },
|
|
368
|
+
data: { controlAnchorSeq: 5, processStartSeq: 5 }
|
|
369
|
+
},
|
|
370
|
+
{
|
|
371
|
+
key: 'turn-tail1',
|
|
372
|
+
kind: 'turn-tail',
|
|
373
|
+
anchorSeq: 18.1,
|
|
374
|
+
visibility: 'visible',
|
|
375
|
+
location: { kind: 'turn', turn },
|
|
376
|
+
data: { turn: 1, seq: 18 }
|
|
377
|
+
},
|
|
378
|
+
{
|
|
379
|
+
key: 'preview',
|
|
380
|
+
kind: 'command',
|
|
381
|
+
anchorSeq: 20,
|
|
382
|
+
visibility: 'visible',
|
|
383
|
+
location: { kind: 'session' },
|
|
384
|
+
data: {
|
|
385
|
+
kind: 'command',
|
|
386
|
+
seq: 20,
|
|
387
|
+
name: 'rewind-plugin',
|
|
388
|
+
args: ' preview @9 both',
|
|
389
|
+
outcome: { kind: 'success' }
|
|
390
|
+
}
|
|
391
|
+
},
|
|
392
|
+
{
|
|
393
|
+
key: 'executed',
|
|
394
|
+
kind: 'command',
|
|
395
|
+
anchorSeq: 22,
|
|
396
|
+
visibility: 'visible',
|
|
397
|
+
location: { kind: 'session' },
|
|
398
|
+
data: {
|
|
399
|
+
kind: 'command',
|
|
400
|
+
seq: 22,
|
|
401
|
+
name: 'rewind-plugin',
|
|
402
|
+
args: ' @9 chat',
|
|
403
|
+
outcome: { kind: 'success', sourceEventSeq: 23 }
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
]);
|
|
407
|
+
assert.deepEqual(rewindCutSpans(chat), [{ start: 9, end: 23 }]);
|
|
408
|
+
assert.deepEqual(visibleChatKeys(chat), []);
|
|
409
|
+
assert.equal(visibleChatSignature(chat), '');
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
test('a later turn keeps its rows even though it shares the withdrawn turn number', () => {
|
|
413
|
+
// Guard against over-reach: the turn rule reads a turn IDENTITY, and a row of
|
|
414
|
+
// a different turn must survive no matter where it sits relative to the cut.
|
|
415
|
+
const withdrawnTurn = {
|
|
416
|
+
turn: 1,
|
|
417
|
+
start: { seq: 5 },
|
|
418
|
+
end: { seq: 18 },
|
|
419
|
+
status: 'closed'
|
|
420
|
+
};
|
|
421
|
+
const openTurn = {
|
|
422
|
+
turn: 2,
|
|
423
|
+
start: { seq: 30 },
|
|
424
|
+
end: { seq: 40 },
|
|
425
|
+
status: 'closed'
|
|
426
|
+
};
|
|
427
|
+
const chat = chatOf([
|
|
428
|
+
{
|
|
429
|
+
key: 'user-1',
|
|
430
|
+
kind: 'user',
|
|
431
|
+
anchorSeq: 9,
|
|
432
|
+
visibility: 'visible',
|
|
433
|
+
location: { kind: 'step', turn: withdrawnTurn, step: 1 },
|
|
434
|
+
data: { seq: 9 }
|
|
435
|
+
},
|
|
436
|
+
{
|
|
437
|
+
key: 'process-2',
|
|
438
|
+
kind: 'turn-process',
|
|
439
|
+
anchorSeq: 29.9,
|
|
440
|
+
visibility: 'visible',
|
|
441
|
+
location: { kind: 'turn', turn: openTurn },
|
|
442
|
+
data: { controlAnchorSeq: 30 }
|
|
443
|
+
},
|
|
444
|
+
rewindCommand('command-22', 22, ' @9 chat', 23)
|
|
445
|
+
]);
|
|
446
|
+
assert.deepEqual(visibleChatKeys(chat), ['process-2']);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Genuine host records, copied verbatim from a real session log
|
|
451
|
+
* (`.../session-0be7b55b.../session.v4.jsonl.zstd`, two `/rewind` runs at
|
|
452
|
+
* 2026-10-14). The synthetic cases above encode our reading of the contract;
|
|
453
|
+
* this one refuses to drift from what the host actually writes.
|
|
454
|
+
*/
|
|
455
|
+
interface RealEventData {
|
|
456
|
+
readonly commandId?: string;
|
|
457
|
+
readonly name?: string;
|
|
458
|
+
readonly args?: string | null;
|
|
459
|
+
readonly kind?: string;
|
|
460
|
+
readonly text?: string;
|
|
461
|
+
readonly sourceEventSeq?: number;
|
|
462
|
+
readonly source?: {
|
|
463
|
+
readonly kind: string;
|
|
464
|
+
readonly rpcId?: string;
|
|
465
|
+
readonly clientTimeZone?: string;
|
|
466
|
+
};
|
|
467
|
+
readonly role?: string;
|
|
468
|
+
readonly id?: string;
|
|
469
|
+
readonly content?: readonly {
|
|
470
|
+
readonly type: string;
|
|
471
|
+
readonly text: string;
|
|
472
|
+
}[];
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
interface RealEvent {
|
|
476
|
+
readonly type: 'command/run' | 'command/done' | 'user/message';
|
|
477
|
+
readonly seq: number;
|
|
478
|
+
readonly time: number;
|
|
479
|
+
readonly data: RealEventData;
|
|
480
|
+
readonly surfaceOp?:
|
|
481
|
+
| 'append'
|
|
482
|
+
| {
|
|
483
|
+
readonly op: string;
|
|
484
|
+
readonly startSeq: number;
|
|
485
|
+
readonly endSeq: number;
|
|
486
|
+
};
|
|
487
|
+
readonly sourceEventSeqs?: readonly number[];
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const REAL_REWIND_EVENTS: readonly RealEvent[] = [
|
|
491
|
+
{
|
|
492
|
+
type: 'command/run',
|
|
493
|
+
seq: 821,
|
|
494
|
+
time: 1791938178593,
|
|
495
|
+
data: {
|
|
496
|
+
commandId: 'cmd-8c119757-12',
|
|
497
|
+
name: 'rewind',
|
|
498
|
+
args: ' preview @802 both',
|
|
499
|
+
source: { kind: 'user' }
|
|
500
|
+
}
|
|
501
|
+
},
|
|
502
|
+
{
|
|
503
|
+
type: 'command/done',
|
|
504
|
+
seq: 822,
|
|
505
|
+
time: 1791938178678,
|
|
506
|
+
data: {
|
|
507
|
+
commandId: 'cmd-8c119757-12',
|
|
508
|
+
kind: 'success',
|
|
509
|
+
text: 'Rewind to seq 802, removing 2 node(s) from the model context (conversation log kept).\nNo restorable changes after the target.\nimpact=0'
|
|
510
|
+
}
|
|
511
|
+
},
|
|
512
|
+
{
|
|
513
|
+
type: 'command/run',
|
|
514
|
+
seq: 823,
|
|
515
|
+
time: 1791938179674,
|
|
516
|
+
data: {
|
|
517
|
+
commandId: 'cmd-8c119757-13',
|
|
518
|
+
name: 'rewind',
|
|
519
|
+
args: ' @802 chat',
|
|
520
|
+
source: { kind: 'user' }
|
|
521
|
+
}
|
|
522
|
+
},
|
|
523
|
+
{
|
|
524
|
+
type: 'user/message',
|
|
525
|
+
seq: 824,
|
|
526
|
+
time: 1791938179739,
|
|
527
|
+
data: {
|
|
528
|
+
content: [{ type: 'text', text: '(empty message)' }],
|
|
529
|
+
source: { kind: 'dsh-rewind' },
|
|
530
|
+
role: 'user',
|
|
531
|
+
id: '6b05f62f-f478-45fb-8cb8-50dcda7759b7'
|
|
532
|
+
},
|
|
533
|
+
sourceEventSeqs: [802, 803],
|
|
534
|
+
surfaceOp: { op: 'replace', startSeq: 802, endSeq: 803 }
|
|
535
|
+
},
|
|
536
|
+
{
|
|
537
|
+
type: 'command/done',
|
|
538
|
+
seq: 825,
|
|
539
|
+
time: 1791938179740,
|
|
540
|
+
data: {
|
|
541
|
+
commandId: 'cmd-8c119757-13',
|
|
542
|
+
kind: 'success',
|
|
543
|
+
text: 'Withdrawn seq 802 and everything after it (conversation returned to earlier).',
|
|
544
|
+
sourceEventSeq: 824
|
|
545
|
+
}
|
|
546
|
+
},
|
|
547
|
+
{
|
|
548
|
+
type: 'user/message',
|
|
549
|
+
seq: 830,
|
|
550
|
+
time: 1791938231664,
|
|
551
|
+
data: {
|
|
552
|
+
content: [{ type: 'text', text: '继续' }],
|
|
553
|
+
source: {
|
|
554
|
+
kind: 'user',
|
|
555
|
+
rpcId: '9a813c7f-1613-405b-a37e-57b25086b3ec',
|
|
556
|
+
clientTimeZone: 'Asia/Shanghai'
|
|
557
|
+
},
|
|
558
|
+
role: 'user',
|
|
559
|
+
id: '2c5e83ac-55cf-4a84-9daa-f25af2546614'
|
|
560
|
+
},
|
|
561
|
+
surfaceOp: 'append'
|
|
562
|
+
},
|
|
563
|
+
{
|
|
564
|
+
type: 'command/run',
|
|
565
|
+
seq: 850,
|
|
566
|
+
time: 1791938272620,
|
|
567
|
+
data: {
|
|
568
|
+
commandId: 'cmd-8c119757-18',
|
|
569
|
+
name: 'rewind',
|
|
570
|
+
args: ' preview @830 both',
|
|
571
|
+
source: { kind: 'user' }
|
|
572
|
+
}
|
|
573
|
+
},
|
|
574
|
+
{
|
|
575
|
+
type: 'command/done',
|
|
576
|
+
seq: 851,
|
|
577
|
+
time: 1791938272691,
|
|
578
|
+
data: {
|
|
579
|
+
commandId: 'cmd-8c119757-18',
|
|
580
|
+
kind: 'success',
|
|
581
|
+
text: 'Rewind to seq 830, removing 1 node(s) from the model context (conversation log kept).\nNo restorable changes after the target.\nimpact=0'
|
|
582
|
+
}
|
|
583
|
+
},
|
|
584
|
+
{
|
|
585
|
+
type: 'command/run',
|
|
586
|
+
seq: 852,
|
|
587
|
+
time: 1791938273661,
|
|
588
|
+
data: {
|
|
589
|
+
commandId: 'cmd-8c119757-19',
|
|
590
|
+
name: 'rewind',
|
|
591
|
+
args: ' @830 chat',
|
|
592
|
+
source: { kind: 'user' }
|
|
593
|
+
}
|
|
594
|
+
},
|
|
595
|
+
{
|
|
596
|
+
type: 'user/message',
|
|
597
|
+
seq: 853,
|
|
598
|
+
time: 1791938273719,
|
|
599
|
+
data: {
|
|
600
|
+
content: [{ type: 'text', text: '(empty message)' }],
|
|
601
|
+
source: { kind: 'dsh-rewind' },
|
|
602
|
+
role: 'user',
|
|
603
|
+
id: '2ce7c1c6-0c54-451c-864c-7026fd5bb92d'
|
|
604
|
+
},
|
|
605
|
+
sourceEventSeqs: [830],
|
|
606
|
+
surfaceOp: { op: 'replace', startSeq: 830, endSeq: 830 }
|
|
607
|
+
},
|
|
608
|
+
{
|
|
609
|
+
type: 'command/done',
|
|
610
|
+
seq: 854,
|
|
611
|
+
time: 1791938273720,
|
|
612
|
+
data: {
|
|
613
|
+
commandId: 'cmd-8c119757-19',
|
|
614
|
+
kind: 'success',
|
|
615
|
+
text: 'Withdrawn seq 830 and everything after it (conversation returned to earlier).',
|
|
616
|
+
sourceEventSeq: 853
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
] as const;
|
|
620
|
+
|
|
621
|
+
/** Project the real records the way the host's command definition does. */
|
|
622
|
+
function chatFromRealEvents(events: typeof REAL_REWIND_EVENTS): {
|
|
623
|
+
order: readonly string[];
|
|
624
|
+
nodes: { get(key: string): CutChatNode | undefined };
|
|
625
|
+
} {
|
|
626
|
+
const nodes: CutChatNode[] = [];
|
|
627
|
+
const started = new Map<
|
|
628
|
+
string,
|
|
629
|
+
{ seq: number; time: number; name: string; args: string | null }
|
|
630
|
+
>();
|
|
631
|
+
for (const event of events) {
|
|
632
|
+
const commandId = event.data.commandId;
|
|
633
|
+
if (commandId === undefined) continue;
|
|
634
|
+
if (event.type === 'command/run') {
|
|
635
|
+
started.set(commandId, {
|
|
636
|
+
seq: event.seq,
|
|
637
|
+
time: event.time,
|
|
638
|
+
name: event.data.name ?? '',
|
|
639
|
+
args: event.data.args ?? null
|
|
640
|
+
});
|
|
641
|
+
nodes.push({
|
|
642
|
+
key: `command:${commandId}`,
|
|
643
|
+
kind: 'command',
|
|
644
|
+
anchorSeq: event.seq,
|
|
645
|
+
visibility: 'visible',
|
|
646
|
+
data: {
|
|
647
|
+
kind: 'command',
|
|
648
|
+
seq: event.seq,
|
|
649
|
+
time: event.time,
|
|
650
|
+
commandId,
|
|
651
|
+
name: event.data.name,
|
|
652
|
+
args: event.data.args ?? null,
|
|
653
|
+
outcome: null
|
|
654
|
+
}
|
|
655
|
+
});
|
|
656
|
+
continue;
|
|
657
|
+
}
|
|
658
|
+
if (event.type === 'command/done') {
|
|
659
|
+
// `commandFromDone` keeps the RUN seq as the node's seq/anchorSeq.
|
|
660
|
+
const previous = started.get(commandId);
|
|
661
|
+
const marker = 'sourceEventSeq' in event.data ? event.data.sourceEventSeq : undefined;
|
|
662
|
+
nodes.push({
|
|
663
|
+
key: `command:${commandId}`,
|
|
664
|
+
kind: 'command',
|
|
665
|
+
anchorSeq: previous?.seq ?? event.seq,
|
|
666
|
+
visibility: 'visible',
|
|
667
|
+
data: {
|
|
668
|
+
kind: 'command',
|
|
669
|
+
seq: previous?.seq ?? event.seq,
|
|
670
|
+
time: previous?.time ?? event.time,
|
|
671
|
+
commandId,
|
|
672
|
+
name: previous?.name ?? null,
|
|
673
|
+
args: previous?.args ?? null,
|
|
674
|
+
outcome: {
|
|
675
|
+
kind: event.data.kind,
|
|
676
|
+
...(marker === undefined ? {} : { sourceEventSeq: marker })
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
});
|
|
680
|
+
continue;
|
|
681
|
+
}
|
|
682
|
+
// Only an append surface event materialises as a chat node; the replace marker does not.
|
|
683
|
+
if (event.surfaceOp === 'append') {
|
|
684
|
+
nodes.push({
|
|
685
|
+
key: `user:${event.seq}`,
|
|
686
|
+
kind: 'user',
|
|
687
|
+
anchorSeq: event.seq,
|
|
688
|
+
visibility: 'visible',
|
|
689
|
+
data: { seq: event.seq, time: event.time, content: event.data.content }
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
return chatOf(nodes);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
test('the genuine host records from a real session produce the expected two cuts', () => {
|
|
697
|
+
const chat = chatFromRealEvents(REAL_REWIND_EVENTS);
|
|
698
|
+
// rewind#1 withdrew 802; the later user message at 830 survived it; rewind#2
|
|
699
|
+
// then withdrew 830 itself.
|
|
700
|
+
assert.deepEqual(rewindCutSpans(chat), [
|
|
701
|
+
{ start: 802, end: 824 },
|
|
702
|
+
{ start: 830, end: 853 }
|
|
703
|
+
]);
|
|
704
|
+
// Every row in this window is inside one of the two cuts.
|
|
705
|
+
assert.deepEqual(visibleChatKeys(chat), []);
|
|
706
|
+
// The preview commands (821, 850) are not executed rewinds: they contribute
|
|
707
|
+
// no span of their own, and the two `command/done` successes with no marker
|
|
708
|
+
// (822, 851) are not cut sources either.
|
|
709
|
+
const previewOnly = chatFromRealEvents(
|
|
710
|
+
REAL_REWIND_EVENTS.filter(
|
|
711
|
+
event =>
|
|
712
|
+
event.seq !== 823 &&
|
|
713
|
+
event.seq !== 824 &&
|
|
714
|
+
event.seq !== 825 &&
|
|
715
|
+
event.seq !== 852 &&
|
|
716
|
+
event.seq !== 853 &&
|
|
717
|
+
event.seq !== 854
|
|
718
|
+
) as typeof REAL_REWIND_EVENTS
|
|
719
|
+
);
|
|
720
|
+
assert.deepEqual(rewindCutSpans(previewOnly), []);
|
|
721
|
+
// The replace markers (824, 853) never materialise as chat nodes, but they are
|
|
722
|
+
// the cuts' upper bounds — which is exactly what swallows the `/rewind` rows
|
|
723
|
+
// anchored just before them.
|
|
724
|
+
const cuts = rewindCutSpans(chat);
|
|
725
|
+
assert.equal(cutContains(cuts, 824), true);
|
|
726
|
+
assert.equal(cutContains(cuts, 853), true);
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
/**
|
|
730
|
+
* The name the LIVE chat snapshot carries. The session log records
|
|
731
|
+
* `command/run.name: "rewind"`, but the plugin registers its command as
|
|
732
|
+
* `rewind-plugin` (`ENGINE_COMMANDS = new Set(["rewind-plugin", "rewind"])` in
|
|
733
|
+
* dsh-rewind-plugin's `src/client/hidden.ts`), and that is what the client
|
|
734
|
+
* projection publishes. Reading only the log spelling is the defect this test
|
|
735
|
+
* pins: the cut silently computed to nothing on every real session.
|
|
736
|
+
*/
|
|
737
|
+
test('the command name the live snapshot publishes (rewind-plugin) still cuts', () => {
|
|
738
|
+
const chat = chatOf([
|
|
739
|
+
message('user-1', 'user', 1),
|
|
740
|
+
message('assistant-2', 'assistant-step', 2),
|
|
741
|
+
rewindCommand('command-3', 3, ' @1 chat', 4)
|
|
742
|
+
]);
|
|
743
|
+
assert.equal(
|
|
744
|
+
(chat.nodes.get('command-3')?.data as { name?: string } | undefined)?.name,
|
|
745
|
+
'rewind-plugin'
|
|
746
|
+
);
|
|
747
|
+
assert.deepEqual(rewindCutSpans(chat), [{ start: 1, end: 4 }]);
|
|
748
|
+
assert.deepEqual(visibleChatKeys(chat), []);
|
|
749
|
+
// The candidate probe and the impact preview carry the same engine name.
|
|
750
|
+
const probes = chatOf([
|
|
751
|
+
message('user-1', 'user', 1),
|
|
752
|
+
rewindCommand('preview-2', 2, ' preview @1 both', undefined, 'success'),
|
|
753
|
+
rewindCommand('candidates-3', 3, '__candidates', undefined, 'success')
|
|
754
|
+
]);
|
|
755
|
+
assert.deepEqual(visibleChatKeys(probes), ['user-1']);
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
test('the pre-fix name predicate reproduces the reported defect', () => {
|
|
759
|
+
// Regression evidence, not a wish: matching only the log spelling
|
|
760
|
+
// (`name === 'rewind'`) finds no cut source in a live snapshot, so every
|
|
761
|
+
// withdrawn row survives and the command is merely recorded.
|
|
762
|
+
const chat = chatOf([
|
|
763
|
+
message('user-1', 'user', 1),
|
|
764
|
+
message('assistant-2', 'assistant-step', 2),
|
|
765
|
+
rewindCommand('command-3', 3, ' @1 chat', 4)
|
|
766
|
+
]);
|
|
767
|
+
const legacyCutSpans = (c: {
|
|
768
|
+
order: readonly string[];
|
|
769
|
+
nodes: { get(key: string): CutChatNode | undefined };
|
|
770
|
+
}) =>
|
|
771
|
+
c.order.flatMap(key => {
|
|
772
|
+
const node = c.nodes.get(key);
|
|
773
|
+
const data = node?.data as
|
|
774
|
+
| {
|
|
775
|
+
name?: string;
|
|
776
|
+
args?: unknown;
|
|
777
|
+
outcome?: { kind?: string; sourceEventSeq?: number };
|
|
778
|
+
}
|
|
779
|
+
| undefined;
|
|
780
|
+
if (node?.kind !== 'command' || data?.name !== 'rewind') return [];
|
|
781
|
+
if (data.outcome?.kind !== 'success') return [];
|
|
782
|
+
const marker = data.outcome.sourceEventSeq;
|
|
783
|
+
const match = typeof data.args === 'string' ? data.args.match(/@(\d+)/) : null;
|
|
784
|
+
return marker === undefined || match === null
|
|
785
|
+
? []
|
|
786
|
+
: [{ start: Number(match[1]), end: marker }];
|
|
787
|
+
});
|
|
788
|
+
assert.deepEqual(legacyCutSpans(chat), []);
|
|
789
|
+
assert.notDeepEqual(legacyCutSpans(chat), rewindCutSpans(chat));
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
test('fractional synthetic anchors inside a cut are withdrawn', () => {
|
|
793
|
+
// The live snapshot anchors the turn's process disclosure at
|
|
794
|
+
// `controlAnchorSeq - 0.1` and the turn tail at `seq + 0.1`
|
|
795
|
+
// (`CHAT_SYNTHETIC_SEQ_OFFSETS`). An integer-only comparison lets the last
|
|
796
|
+
// synthetic row of a withdrawn turn survive on its own, which is exactly what
|
|
797
|
+
// the smoke test showed: the user bubble and every tool row vanished, and one
|
|
798
|
+
// lone process cell stayed behind.
|
|
799
|
+
const chat = chatOf([
|
|
800
|
+
message('user-1', 'user', 1),
|
|
801
|
+
{
|
|
802
|
+
key: 'process-9',
|
|
803
|
+
kind: 'turn-process',
|
|
804
|
+
anchorSeq: 9.9,
|
|
805
|
+
visibility: 'visible',
|
|
806
|
+
data: { turn: 1, controlAnchorSeq: 10 }
|
|
807
|
+
},
|
|
808
|
+
message('assistant-10', 'assistant-step', 10),
|
|
809
|
+
rewindCommand('command-11', 11, ' @1 chat', 12)
|
|
810
|
+
]);
|
|
811
|
+
assert.deepEqual(rewindCutSpans(chat), [{ start: 1, end: 12 }]);
|
|
812
|
+
assert.equal(cutContains(rewindCutSpans(chat), 9.9), true);
|
|
813
|
+
assert.deepEqual(visibleChatKeys(chat), []);
|
|
814
|
+
// `anchorSeqOf` reports the render position, fractional included: it is the
|
|
815
|
+
// range check's input, not an identity check.
|
|
816
|
+
assert.equal(anchorSeqOf({ key: 'p', kind: 'turn-process', anchorSeq: 9.9 }), 9.9);
|
|
817
|
+
assert.equal(anchorSeqOf({ key: 'p', kind: 'turn-process', anchorSeq: -1 }), undefined);
|
|
818
|
+
assert.equal(anchorSeqOf({ key: 'p', kind: 'user', data: { seq: 7 } }), 7);
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
test('a fractional anchor before the cut start is not swept in', () => {
|
|
822
|
+
const chat = chatOf([
|
|
823
|
+
message('user-1', 'user', 1),
|
|
824
|
+
{
|
|
825
|
+
key: 'tail-0',
|
|
826
|
+
kind: 'turn-tail',
|
|
827
|
+
anchorSeq: 0.1,
|
|
828
|
+
visibility: 'visible',
|
|
829
|
+
data: { seq: 0 }
|
|
830
|
+
},
|
|
831
|
+
message('assistant-2', 'assistant-step', 2),
|
|
832
|
+
rewindCommand('command-3', 3, ' @2 chat', 4)
|
|
833
|
+
]);
|
|
834
|
+
assert.deepEqual(rewindCutSpans(chat), [{ start: 2, end: 4 }]);
|
|
835
|
+
assert.deepEqual(visibleChatKeys(chat), ['user-1', 'tail-0']);
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
test('a withdrawn turn folds away instead of vanishing in one frame', () => {
|
|
839
|
+
// The third reported symptom: the rows were removed from the visible order and
|
|
840
|
+
// unmounted in the same commit, so nothing scrolled and the transcript snapped.
|
|
841
|
+
// The reader now renders every turn through a retiring lane that keeps a
|
|
842
|
+
// departing turn mounted until its collapse settles.
|
|
843
|
+
const motion = readFileSync(resolve(root, 'src/client/motion.tsx'), 'utf8');
|
|
844
|
+
assert.ok(motion.includes('export function RetiringTurns'));
|
|
845
|
+
// The lane must be able to release a slot when the collapse ends, otherwise a
|
|
846
|
+
// departing turn would stay mounted forever at zero height.
|
|
847
|
+
assert.ok(motion.includes('onRetired?: () => void'));
|
|
848
|
+
assert.ok(motion.includes('retired.current?.()'));
|
|
849
|
+
// Reduced motion and a hidden page settle immediately. A playing collapse
|
|
850
|
+
// still dies on the #60 retiring deadline if WAAPI never finishes.
|
|
851
|
+
assert.match(motion, /!enabled \|\| document\.hidden/);
|
|
852
|
+
assert.match(motion, /220 \+ 240/);
|
|
853
|
+
assert.match(motion, /visibilitychange/);
|
|
854
|
+
const reader = readFileSync(resolve(root, 'src/client/Reader.tsx'), 'utf8');
|
|
855
|
+
assert.ok(reader.includes('<RetiringTurns'));
|
|
856
|
+
assert.ok(reader.includes('RetiringTurns,'));
|
|
857
|
+
// The map was the seam that unmounted turns outright.
|
|
858
|
+
assert.doesNotMatch(reader, /\{groups\.map\(/);
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
test('the collapsing lane keeps the between-turns margin it dislodged', () => {
|
|
862
|
+
// Wrapping a turn adds a level of nesting, which breaks the sibling selector
|
|
863
|
+
// `.turn + .turn`. The margin has to be re-expressed one level in or every
|
|
864
|
+
// turn loses its 12px separation.
|
|
865
|
+
const css = readFileSync(resolve(root, 'src/client/Reader.module.css'), 'utf8');
|
|
866
|
+
assert.ok(css.includes('.retiringContent + .retiringContent > .turn { margin-top: 12px; }'));
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
test('the reading column restores focus without drawing a ring around the transcript', () => {
|
|
870
|
+
// The column is the last-resort focus target when a cut unmounts. In keyboard
|
|
871
|
+
// modality a programmatic focus matches :focus-visible, and the host painted
|
|
872
|
+
// the UA `auto 2px` outline around the whole column.
|
|
873
|
+
const reader = readFileSync(resolve(root, 'src/client/Reader.tsx'), 'utf8');
|
|
874
|
+
assert.match(reader, /className=\{css\.column\} data-chat-flow="" tabIndex=\{-1\}/);
|
|
875
|
+
const css = readFileSync(resolve(root, 'src/client/Reader.module.css'), 'utf8');
|
|
876
|
+
const rules = [...css.matchAll(/([^{}]+)\{([^}]*)\}/g)].map(([, selector, body]) => ({
|
|
877
|
+
selectors: selector!.replace(/\/\*[\s\S]*?\*\//g, '').split(',').map(part => part.trim()),
|
|
878
|
+
body: body!,
|
|
879
|
+
}));
|
|
880
|
+
for (const pseudo of [':focus', ':focus-visible']) {
|
|
881
|
+
const rule = rules.find(({ selectors }) => selectors.includes(`.column${pseudo}`));
|
|
882
|
+
assert.ok(rule, `.column${pseudo} has its own rule`);
|
|
883
|
+
assert.match(rule.body, /outline:\s*none/);
|
|
884
|
+
}
|
|
885
|
+
// Only the column itself: descendants keep their own focus rings.
|
|
886
|
+
const suppressed = rules.filter(({ body }) => /outline:\s*none/.test(body)).flatMap(({ selectors }) => selectors);
|
|
887
|
+
assert.ok(suppressed.every(selector => !/^\.column[\s>+~]|\.column\s.*:focus/.test(selector)));
|
|
888
|
+
assert.match(css, /\.root button:focus-visible, \.root summary:focus-visible \{ outline: 2px solid/);
|
|
889
|
+
});
|
|
890
|
+
|
|
891
|
+
test('streaming, re-render, and reconnect do not withdraw rows', () => {
|
|
892
|
+
const base = [message('user-1', 'user', 1), message('assistant-2', 'assistant-step', 2)];
|
|
893
|
+
const streaming = chatOf(base);
|
|
894
|
+
assert.equal(visibleChatKeys(streaming), streaming.order);
|
|
895
|
+
assert.deepEqual(rewindCutSpans(streaming), []);
|
|
896
|
+
|
|
897
|
+
// A text delta rewrites node data. The row set, and therefore the signature, stays.
|
|
898
|
+
const rewritten = chatOf(
|
|
899
|
+
base.map(node =>
|
|
900
|
+
node.key === 'assistant-2'
|
|
901
|
+
? { ...node, data: { seq: 2, time: 1, content: [{ type: 'text', text: 'more tokens' }] } }
|
|
902
|
+
: node
|
|
903
|
+
)
|
|
904
|
+
);
|
|
905
|
+
assert.equal(visibleChatSignature(streaming), visibleChatSignature(rewritten));
|
|
906
|
+
|
|
907
|
+
// Reconnect builds a fresh snapshot of the same rows.
|
|
908
|
+
const reconnected = chatOf(base.map(node => ({ ...node })));
|
|
909
|
+
assert.equal(visibleChatSignature(streaming), visibleChatSignature(reconnected));
|
|
910
|
+
assert.deepEqual([...visibleChatKeys(reconnected)], ['user-1', 'assistant-2']);
|
|
911
|
+
|
|
912
|
+
// The model is still writing. A rewind command that has not succeeded is just a row.
|
|
913
|
+
const inflight = chatOf([
|
|
914
|
+
...base,
|
|
915
|
+
{
|
|
916
|
+
key: 'command-3',
|
|
917
|
+
kind: 'command',
|
|
918
|
+
anchorSeq: 3,
|
|
919
|
+
visibility: 'visible',
|
|
920
|
+
data: { kind: 'command', seq: 3, name: 'rewind-plugin', args: '@1 chat', outcome: null }
|
|
921
|
+
},
|
|
922
|
+
message('assistant-4', 'assistant-step', 4)
|
|
923
|
+
]);
|
|
924
|
+
assert.deepEqual(rewindCutSpans(inflight), []);
|
|
925
|
+
assert.deepEqual(
|
|
926
|
+
[...visibleChatKeys(inflight)],
|
|
927
|
+
['user-1', 'assistant-2', 'command-3', 'assistant-4']
|
|
928
|
+
);
|
|
929
|
+
});
|
|
930
|
+
|
|
931
|
+
test('a non-rewind command that mentions an @seq does not cut', () => {
|
|
932
|
+
const chat = chatOf([
|
|
933
|
+
message('user-1', 'user', 1),
|
|
934
|
+
{
|
|
935
|
+
key: 'command-2',
|
|
936
|
+
kind: 'command',
|
|
937
|
+
anchorSeq: 2,
|
|
938
|
+
visibility: 'visible',
|
|
939
|
+
data: {
|
|
940
|
+
name: 'checkpoint',
|
|
941
|
+
args: '@1 chat',
|
|
942
|
+
outcome: { kind: 'success', sourceEventSeq: 3 }
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
]);
|
|
946
|
+
assert.deepEqual(rewindCutSpans(chat), []);
|
|
947
|
+
assert.deepEqual([...visibleChatKeys(chat)], ['user-1', 'command-2']);
|
|
948
|
+
});
|
|
949
|
+
|
|
950
|
+
test('a successful preview that carries a marker does not roll the transcript back', () => {
|
|
951
|
+
const chat = chatOf([
|
|
952
|
+
message('user-1', 'user', 1),
|
|
953
|
+
message('assistant-2', 'assistant-step', 2),
|
|
954
|
+
rewindCommand('preview-3', 3, 'preview @1 both', 4, 'success')
|
|
955
|
+
]);
|
|
956
|
+
assert.deepEqual(rewindCutSpans(chat), []);
|
|
957
|
+
assert.deepEqual([...visibleChatKeys(chat)], ['user-1', 'assistant-2']);
|
|
958
|
+
});
|
|
959
|
+
|
|
960
|
+
test('a rewind whose target is after its marker does not cut', () => {
|
|
961
|
+
const chat = chatOf([message('user-1', 'user', 1), rewindCommand('command-2', 2, '@9 chat', 3)]);
|
|
962
|
+
assert.deepEqual(rewindCutSpans(chat), []);
|
|
963
|
+
// The malformed command row is still an executed rewind, so it leaves.
|
|
964
|
+
// The earlier message does not.
|
|
965
|
+
assert.deepEqual([...visibleChatKeys(chat)], ['user-1']);
|
|
966
|
+
});
|
|
967
|
+
|
|
968
|
+
test('a mid-turn steering rewind keeps the earlier user message', () => {
|
|
969
|
+
const turn = { turn: 1, start: { seq: 5 }, end: { seq: 20 }, status: 'closed' };
|
|
970
|
+
const located = (
|
|
971
|
+
key: string,
|
|
972
|
+
kind: 'user' | 'steering' | 'assistant-step' | 'turn-process',
|
|
973
|
+
seq: number
|
|
974
|
+
): CutChatNode => ({
|
|
975
|
+
key,
|
|
976
|
+
kind,
|
|
977
|
+
anchorSeq: seq,
|
|
978
|
+
visibility: 'visible',
|
|
979
|
+
location: { kind: kind === 'turn-process' ? 'turn' : 'step', turn, step: 1 },
|
|
980
|
+
data: { seq }
|
|
981
|
+
});
|
|
982
|
+
const chat = chatOf([
|
|
983
|
+
located('user-9', 'user', 9),
|
|
984
|
+
located('assistant-12', 'assistant-step', 12),
|
|
985
|
+
located('process-1', 'turn-process', 4.9),
|
|
986
|
+
located('steering-15', 'steering', 15),
|
|
987
|
+
located('assistant-18', 'assistant-step', 18),
|
|
988
|
+
rewindCommand('command-19', 19, ' @15 chat', 20)
|
|
989
|
+
]);
|
|
990
|
+
assert.deepEqual(rewindCutSpans(chat), [{ start: 15, end: 20 }]);
|
|
991
|
+
assert.deepEqual([...visibleChatKeys(chat)], ['user-9', 'assistant-12', 'process-1']);
|
|
992
|
+
});
|
|
993
|
+
|
|
994
|
+
test("the next turn's process row, anchored just after the marker, stays", () => {
|
|
995
|
+
const next = { turn: 2, start: { seq: 5 }, end: { seq: 8 }, status: 'open' };
|
|
996
|
+
const chat = chatOf([
|
|
997
|
+
message('user-1', 'user', 1),
|
|
998
|
+
rewindCommand('command-3', 3, ' @1 chat', 4),
|
|
999
|
+
{
|
|
1000
|
+
key: 'process-2',
|
|
1001
|
+
kind: 'turn-process',
|
|
1002
|
+
anchorSeq: 4.9,
|
|
1003
|
+
visibility: 'visible',
|
|
1004
|
+
location: { kind: 'turn', turn: next },
|
|
1005
|
+
data: { controlAnchorSeq: 5 }
|
|
1006
|
+
},
|
|
1007
|
+
{
|
|
1008
|
+
key: 'user-5',
|
|
1009
|
+
kind: 'user',
|
|
1010
|
+
anchorSeq: 5,
|
|
1011
|
+
visibility: 'visible',
|
|
1012
|
+
location: { kind: 'step', turn: next, step: 1 },
|
|
1013
|
+
data: { seq: 5 }
|
|
1014
|
+
}
|
|
1015
|
+
]);
|
|
1016
|
+
assert.equal(cutContains(rewindCutSpans(chat), 4.9), false);
|
|
1017
|
+
assert.deepEqual([...visibleChatKeys(chat)], ['process-2', 'user-5']);
|
|
1018
|
+
});
|
|
1019
|
+
|
|
1020
|
+
test('re-rendering or streaming a turn does not retire it; a rewind does', () => {
|
|
1021
|
+
const first = { key: 'turn:1', text: 'hello' };
|
|
1022
|
+
const second = { key: 'turn:2', text: 'world' };
|
|
1023
|
+
const initial = reconcileTurnSlots([], [first, second]);
|
|
1024
|
+
assert.deepEqual(
|
|
1025
|
+
initial.map(slot => [slot.group.key, slot.live]),
|
|
1026
|
+
[
|
|
1027
|
+
['turn:1', true],
|
|
1028
|
+
['turn:2', true]
|
|
1029
|
+
]
|
|
1030
|
+
);
|
|
1031
|
+
|
|
1032
|
+
// Same keys, new objects: a stream tick or a parent re-render.
|
|
1033
|
+
const streamed = reconcileTurnSlots(initial, [
|
|
1034
|
+
{ key: 'turn:1', text: 'hello' },
|
|
1035
|
+
{ key: 'turn:2', text: 'world plus tokens' }
|
|
1036
|
+
]);
|
|
1037
|
+
assert.deepEqual(
|
|
1038
|
+
streamed.map(slot => slot.live),
|
|
1039
|
+
[true, true]
|
|
1040
|
+
);
|
|
1041
|
+
assert.equal(streamed[1]?.group.text, 'world plus tokens');
|
|
1042
|
+
|
|
1043
|
+
// Reconnect mounts from the rows that are actually present. Nothing is pre-retired.
|
|
1044
|
+
const reconnected = reconcileTurnSlots([], [{ key: 'turn:1', text: 'hello' }]);
|
|
1045
|
+
assert.deepEqual(
|
|
1046
|
+
reconnected.map(slot => [slot.group.key, slot.live]),
|
|
1047
|
+
[['turn:1', true]]
|
|
1048
|
+
);
|
|
1049
|
+
|
|
1050
|
+
// A rewind drops turn 2. It stays at its old index, and only that slot retires.
|
|
1051
|
+
const rewound = reconcileTurnSlots(streamed, [{ key: 'turn:1', text: 'hello' }]);
|
|
1052
|
+
assert.deepEqual(
|
|
1053
|
+
rewound.map(slot => [slot.group.key, slot.live]),
|
|
1054
|
+
[
|
|
1055
|
+
['turn:1', true],
|
|
1056
|
+
['turn:2', false]
|
|
1057
|
+
]
|
|
1058
|
+
);
|
|
1059
|
+
assert.equal(rewound[1]?.group.text, 'world plus tokens');
|
|
1060
|
+
});
|
|
1061
|
+
|
|
1062
|
+
test('loading older history follows the incoming turn order', () => {
|
|
1063
|
+
const keep = { key: 'turn:20' };
|
|
1064
|
+
const older = { key: 'turn:19' };
|
|
1065
|
+
const current = reconcileTurnSlots([], [keep]);
|
|
1066
|
+
const prepended = reconcileTurnSlots(current, [older, keep]);
|
|
1067
|
+
assert.deepEqual(prepended.map(slot => [slot.group.key, slot.live]), [
|
|
1068
|
+
['turn:19', true],
|
|
1069
|
+
['turn:20', true],
|
|
1070
|
+
]);
|
|
1071
|
+
assert.equal(reconcileTurnSlots(prepended, [older, keep]), prepended);
|
|
1072
|
+
|
|
1073
|
+
const tail = { key: 'turn:21' };
|
|
1074
|
+
const withTail = reconcileTurnSlots([], [keep, tail]);
|
|
1075
|
+
const olderBesideDeparting = reconcileTurnSlots(withTail, [older, keep]);
|
|
1076
|
+
assert.deepEqual(olderBesideDeparting.map(slot => [slot.group.key, slot.live]), [
|
|
1077
|
+
['turn:19', true],
|
|
1078
|
+
['turn:20', true],
|
|
1079
|
+
['turn:21', false],
|
|
1080
|
+
]);
|
|
1081
|
+
|
|
1082
|
+
const leading = { key: 'turn:1' };
|
|
1083
|
+
const following = { key: 'turn:2' };
|
|
1084
|
+
const opened = reconcileTurnSlots([], [leading, following]);
|
|
1085
|
+
const droppedHead = reconcileTurnSlots(opened, [following]);
|
|
1086
|
+
assert.deepEqual(droppedHead.map(slot => [slot.group.key, slot.live]), [
|
|
1087
|
+
['turn:1', false],
|
|
1088
|
+
['turn:2', true],
|
|
1089
|
+
]);
|
|
1090
|
+
});
|