dsh-conversation-overview 1.4.1 → 1.4.2
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/.scratch/test.mjs +1358 -0
- package/README.md +59 -4
- package/package.json +6 -2
|
@@ -0,0 +1,1358 @@
|
|
|
1
|
+
/* Offline structural + fold test for dsh-conversation-overview.
|
|
2
|
+
* Loads client.js with a stubbed module loader and React, then:
|
|
3
|
+
* 1. checks zh/en dictionary key parity
|
|
4
|
+
* 2. exercises apply() against a mock ctx and asserts registrations
|
|
5
|
+
* 3. folds real session-log events and asserts the derived summary
|
|
6
|
+
* 4. unit-checks mention/mutation/address helpers
|
|
7
|
+
*/
|
|
8
|
+
import { execFileSync } from "node:child_process";
|
|
9
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import assert from "node:assert/strict";
|
|
13
|
+
import { createRequire } from "node:module";
|
|
14
|
+
|
|
15
|
+
const require_ = createRequire(import.meta.url);
|
|
16
|
+
const clientSource = readFileSync(new URL("../client.js", import.meta.url), "utf8");
|
|
17
|
+
|
|
18
|
+
/* ---- stub environment -------------------------------------------------- */
|
|
19
|
+
let captured = null;
|
|
20
|
+
const localStorageStub = (() => {
|
|
21
|
+
const store = new Map();
|
|
22
|
+
return {
|
|
23
|
+
getItem: (key) => (store.has(key) ? store.get(key) : null),
|
|
24
|
+
setItem: (key, value) => store.set(key, String(value)),
|
|
25
|
+
removeItem: (key) => store.delete(key),
|
|
26
|
+
clear: () => store.clear(),
|
|
27
|
+
};
|
|
28
|
+
})();
|
|
29
|
+
globalThis.window = {
|
|
30
|
+
localStorage: localStorageStub,
|
|
31
|
+
__ModuleLoader__: {
|
|
32
|
+
load(registration) {
|
|
33
|
+
captured = registration;
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
globalThis.document = undefined; // apply() guards on typeof document
|
|
38
|
+
|
|
39
|
+
let hookStates = [];
|
|
40
|
+
let hookIndex = 0;
|
|
41
|
+
/* index-only reset models a re-render of the same tree; full reset starts a fresh instance */
|
|
42
|
+
function resetHooks(full = false) {
|
|
43
|
+
if (full) hookStates = [];
|
|
44
|
+
hookIndex = 0;
|
|
45
|
+
}
|
|
46
|
+
const ReactStub = {
|
|
47
|
+
createElement: (type, props, ...children) => ({ type, props, children }),
|
|
48
|
+
Component: class {},
|
|
49
|
+
useMemo: () => null,
|
|
50
|
+
useEffect: () => {},
|
|
51
|
+
useState: (init) => {
|
|
52
|
+
const i = hookIndex++;
|
|
53
|
+
if (!(i in hookStates)) hookStates[i] = typeof init === "function" ? init() : init;
|
|
54
|
+
return [hookStates[i], (v) => { hookStates[i] = typeof v === "function" ? v(hookStates[i]) : v; }];
|
|
55
|
+
},
|
|
56
|
+
useRef: (init) => ({ current: init === undefined ? null : init }),
|
|
57
|
+
useCallback: (fn) => fn,
|
|
58
|
+
useId: () => "stub",
|
|
59
|
+
useSyncExternalStore: () => null,
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const jsxStub = {
|
|
63
|
+
jsx: (type, props) => ({ type, props }),
|
|
64
|
+
jsxs: (type, props) => ({ type, props }),
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
eval(clientSource); // registers into globalThis.window.__ModuleLoader__
|
|
68
|
+
const mod = captured.factory((specifier) => {
|
|
69
|
+
if (specifier === "react") return ReactStub;
|
|
70
|
+
if (specifier === "react/jsx-runtime") return jsxStub;
|
|
71
|
+
throw new Error(`unexpected require: ${specifier}`);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
assert.deepEqual(mod.inject, ["slots", "locale", "sessions", "sidebarRight", "sidebarRightTabs", "uiSession"]);
|
|
75
|
+
assert.equal(typeof mod.apply, "function");
|
|
76
|
+
const T = mod._test;
|
|
77
|
+
|
|
78
|
+
/* ---- 1. dictionary parity --------------------------------------------- */
|
|
79
|
+
const zhKeys = Object.keys(T.zh).sort();
|
|
80
|
+
const enKeys = Object.keys(T.en).sort();
|
|
81
|
+
assert.deepEqual(enKeys, zhKeys, "zh/en dictionaries must share the key set");
|
|
82
|
+
|
|
83
|
+
/* ---- 2. apply() registrations + config gating --------------------------- */
|
|
84
|
+
const requireStub = (specifier) => {
|
|
85
|
+
if (specifier === "react") return ReactStub;
|
|
86
|
+
if (specifier === "react/jsx-runtime") return jsxStub;
|
|
87
|
+
throw new Error(`unexpected require: ${specifier}`);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
function materialize(globalConfig, options = {}) {
|
|
91
|
+
if (globalConfig === undefined) delete globalThis.__DSH_CONVERSATION_OVERVIEW_CONFIG__;
|
|
92
|
+
else globalThis.__DSH_CONVERSATION_OVERVIEW_CONFIG__ = globalConfig;
|
|
93
|
+
const instance = captured.factory(requireStub);
|
|
94
|
+
instance._test.clearOverlayConfig(); // isolate scenarios from any persisted browser storage
|
|
95
|
+
const registrations = [];
|
|
96
|
+
const disposals = [];
|
|
97
|
+
const cleanups = [];
|
|
98
|
+
let dictionary = null;
|
|
99
|
+
const docStub = options.doc
|
|
100
|
+
? {
|
|
101
|
+
documentElement: { dataset: {} },
|
|
102
|
+
querySelector: () => null,
|
|
103
|
+
createElement: () => ({ dataset: {}, remove() {} }),
|
|
104
|
+
head: { appendChild() {} },
|
|
105
|
+
addEventListener() {},
|
|
106
|
+
removeEventListener() {},
|
|
107
|
+
}
|
|
108
|
+
: undefined;
|
|
109
|
+
const mockCtx = {
|
|
110
|
+
locale: {
|
|
111
|
+
register(ns, dicts) {
|
|
112
|
+
dictionary = { ns, dicts };
|
|
113
|
+
return () => {};
|
|
114
|
+
},
|
|
115
|
+
bind: () => (key) => key,
|
|
116
|
+
subscribe: () => () => {},
|
|
117
|
+
},
|
|
118
|
+
effect(fn) {
|
|
119
|
+
const dispose = fn();
|
|
120
|
+
if (typeof dispose === "function") cleanups.push(dispose);
|
|
121
|
+
return typeof dispose === "function" ? dispose : () => {};
|
|
122
|
+
},
|
|
123
|
+
inject(list, fn) {
|
|
124
|
+
try {
|
|
125
|
+
return Promise.resolve(fn({}));
|
|
126
|
+
} catch (error) {
|
|
127
|
+
return Promise.reject(error);
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
slots: {
|
|
131
|
+
inject(owner, cb) {
|
|
132
|
+
const dispose = cb();
|
|
133
|
+
return () => {
|
|
134
|
+
disposals.push(owner);
|
|
135
|
+
if (typeof dispose === "function") dispose();
|
|
136
|
+
};
|
|
137
|
+
},
|
|
138
|
+
register(regOptions, Component) {
|
|
139
|
+
registrations.push({ options: regOptions, Component });
|
|
140
|
+
return () => {
|
|
141
|
+
disposals.push(`register:${regOptions.name}`);
|
|
142
|
+
};
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
sidebarRightTabs: {
|
|
146
|
+
register(def) {
|
|
147
|
+
registrations.push({ tabType: def });
|
|
148
|
+
return () => {
|
|
149
|
+
disposals.push(`tabType:${def.kind}`);
|
|
150
|
+
};
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
sidebarRight: {},
|
|
154
|
+
sessions: { binding: () => null, list: { getSnapshot: () => ({ byId: {} }) } },
|
|
155
|
+
};
|
|
156
|
+
/* the doc stub stays installed for the scenario; the next materialize replaces it */
|
|
157
|
+
globalThis.document = docStub;
|
|
158
|
+
instance.apply(mockCtx);
|
|
159
|
+
return { instance, registrations, disposals, cleanups, docStub, dictionary: () => dictionary };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const findReg = (scenario, name) => scenario.registrations.find((r) => r.options && r.options.name === name);
|
|
163
|
+
const tabTypesOf = (scenario) => scenario.registrations.filter((r) => r.tabType).map((r) => r.tabType);
|
|
164
|
+
const bodiesOf = (scenario, name) => scenario.registrations.filter((r) => r.options && r.options.name === name);
|
|
165
|
+
|
|
166
|
+
/* scenario A: no boot global -> defaults, every surface registered */
|
|
167
|
+
const both = materialize(undefined, { doc: true });
|
|
168
|
+
assert.ok(both.dictionary() && both.dictionary().ns === "conversationOverview", "dictionary registered");
|
|
169
|
+
const view = findReg(both, "conversation.view");
|
|
170
|
+
assert.ok(view, "conversation.view registered");
|
|
171
|
+
assert.equal(view.options.id, "overview");
|
|
172
|
+
assert.equal(view.options.order, 5);
|
|
173
|
+
assert.equal(view.options.label(), "view.tab");
|
|
174
|
+
assert.equal(typeof view.Component, "function");
|
|
175
|
+
const tabTypes = tabTypesOf(both);
|
|
176
|
+
const tabType = tabTypes.find((d) => d.kind === "conversation-overview");
|
|
177
|
+
assert.ok(tabType, "sidebar tab type registered");
|
|
178
|
+
assert.equal(tabType.keepMounted, true);
|
|
179
|
+
assert.equal(tabType.guide.length, 1);
|
|
180
|
+
assert.equal(tabType.guide[0].id, "overview");
|
|
181
|
+
assert.equal(typeof tabType.guide[0].icon, "function");
|
|
182
|
+
assert.equal(tabType.title(), "panel.title");
|
|
183
|
+
const vikingType = tabTypes.find((d) => d.kind === "viking-memory");
|
|
184
|
+
assert.ok(vikingType, "viking tab type registered");
|
|
185
|
+
assert.deepEqual(vikingType.patterns, ["dsh-resource://viking-memory/**"]);
|
|
186
|
+
assert.equal(vikingType.priority, "builtin");
|
|
187
|
+
assert.equal(vikingType.canOpen("dsh-resource://viking-memory/viking%3A%2F%2Fuser%2Fa.md"), true);
|
|
188
|
+
assert.equal(vikingType.canOpen("dsh-resource://file/x"), false);
|
|
189
|
+
assert.equal(vikingType.title("dsh-resource://viking-memory/viking%3A%2F%2Fuser%2Fnotes%2Fx.md"), "x");
|
|
190
|
+
const paneBodies = bodiesOf(both, "sidebar.right.pane.tab");
|
|
191
|
+
assert.ok(paneBodies.some((b) => b.options.key === "dsh-conversation-overview/overview"), "overview body registered");
|
|
192
|
+
const vikingBody = paneBodies.find((b) => b.options.key === "dsh-conversation-overview/viking");
|
|
193
|
+
assert.ok(vikingBody, "viking body registered");
|
|
194
|
+
assert.equal(typeof vikingBody.Component, "function");
|
|
195
|
+
const rowConfig = findReg(both, "plugins.row.config");
|
|
196
|
+
assert.ok(rowConfig, "settings form registered");
|
|
197
|
+
assert.equal(rowConfig.options.key, "dsh-conversation-overview#conversation-overview");
|
|
198
|
+
assert.equal(typeof rowConfig.Component, "function");
|
|
199
|
+
const settingsPage = findReg(both, "settings.section");
|
|
200
|
+
assert.ok(settingsPage, "settings page registered");
|
|
201
|
+
assert.equal(settingsPage.options.id, "conversation-overview");
|
|
202
|
+
assert.equal(settingsPage.options.label(), "settings.nav");
|
|
203
|
+
assert.equal(typeof settingsPage.Component, "function");
|
|
204
|
+
/* quick nav attribute lifecycle */
|
|
205
|
+
assert.equal(both.docStub.documentElement.dataset.dsovQuicknav, "on", "quick nav forced on by default");
|
|
206
|
+
both.instance._test.setOverlayConfig({ quickNav: false });
|
|
207
|
+
assert.equal(both.docStub.documentElement.dataset.dsovQuicknav, "off", "quick nav toggle applies live");
|
|
208
|
+
for (const cleanup of both.cleanups) cleanup();
|
|
209
|
+
assert.equal(both.docStub.documentElement.dataset.dsovQuicknav, undefined, "quick nav attribute removed on dispose");
|
|
210
|
+
|
|
211
|
+
/* scenario B: placement=conversation -> no overview sidebar surfaces, viking tab kept */
|
|
212
|
+
const conv = materialize({ placement: "conversation" });
|
|
213
|
+
assert.ok(findReg(conv, "conversation.view"), "view kept for conversation placement");
|
|
214
|
+
assert.ok(!tabTypesOf(conv).some((d) => d.kind === "conversation-overview"), "overview sidebar tab type skipped");
|
|
215
|
+
assert.ok(tabTypesOf(conv).some((d) => d.kind === "viking-memory"), "viking tab type kept");
|
|
216
|
+
assert.ok(!bodiesOf(conv, "sidebar.right.pane.tab").some((b) => b.options.key === "dsh-conversation-overview/overview"), "overview body skipped");
|
|
217
|
+
assert.ok(bodiesOf(conv, "sidebar.right.pane.tab").some((b) => b.options.key === "dsh-conversation-overview/viking"), "viking body kept");
|
|
218
|
+
assert.ok(findReg(conv, "plugins.row.config"), "settings form kept");
|
|
219
|
+
|
|
220
|
+
/* scenario C: placement=sidebar -> no conversation view */
|
|
221
|
+
const side = materialize({ placement: "sidebar" });
|
|
222
|
+
assert.ok(!findReg(side, "conversation.view"), "view skipped for sidebar placement");
|
|
223
|
+
assert.ok(tabTypesOf(side).some((d) => d.kind === "conversation-overview"), "sidebar tab type kept");
|
|
224
|
+
assert.ok(bodiesOf(side, "sidebar.right.pane.tab").some((b) => b.options.key === "dsh-conversation-overview/overview"), "sidebar body kept");
|
|
225
|
+
assert.ok(findReg(side, "plugins.row.config"), "settings form kept");
|
|
226
|
+
|
|
227
|
+
/* scenario D: live config changes dispose and re-register surfaces */
|
|
228
|
+
const live = materialize({});
|
|
229
|
+
live.instance._test.setOverlayConfig({ placement: "conversation" });
|
|
230
|
+
assert.ok(live.disposals.includes("sidebar.right.pane.tab"), "sidebar body disposed on placement change");
|
|
231
|
+
assert.ok(live.disposals.includes("tabType:conversation-overview"), "sidebar tab type disposed on placement change");
|
|
232
|
+
assert.ok(!live.disposals.includes("conversation.view"), "view kept on placement change");
|
|
233
|
+
assert.ok(!live.disposals.includes("tabType:viking-memory"), "viking tab type untouched by placement change");
|
|
234
|
+
const before = live.registrations.length;
|
|
235
|
+
live.instance._test.setFormConfig({ placement: "both" });
|
|
236
|
+
assert.ok(live.registrations.length > before, "sidebar surfaces re-registered");
|
|
237
|
+
const afterResync = live.registrations.length;
|
|
238
|
+
live.instance._test.setFormConfig({ placement: "both" });
|
|
239
|
+
assert.equal(live.registrations.length, afterResync, "idempotent update adds nothing");
|
|
240
|
+
/* host form value outranks the overlay */
|
|
241
|
+
live.instance._test.setOverlayConfig({ placement: "sidebar" });
|
|
242
|
+
assert.equal(live.registrations.length, afterResync, "overlay ignored once the host form is ready");
|
|
243
|
+
|
|
244
|
+
/* scenario E: normalizeConfig units */
|
|
245
|
+
assert.deepEqual(T.normalizeConfig(undefined), {
|
|
246
|
+
showProgress: true,
|
|
247
|
+
showArtifacts: true,
|
|
248
|
+
showReferences: true,
|
|
249
|
+
showStats: true,
|
|
250
|
+
timeMode: "clock",
|
|
251
|
+
stepAnchors: false,
|
|
252
|
+
placement: "both",
|
|
253
|
+
sectionFullHeight: false,
|
|
254
|
+
todoPrompt: true,
|
|
255
|
+
todoPromptTemplate: "",
|
|
256
|
+
quickNav: true,
|
|
257
|
+
quickNavLink: true,
|
|
258
|
+
vikingPreview: true,
|
|
259
|
+
conclusionTab: true,
|
|
260
|
+
vikingUrl: "http://127.0.0.1:1933",
|
|
261
|
+
vikingAccount: "",
|
|
262
|
+
vikingUser: "",
|
|
263
|
+
});
|
|
264
|
+
assert.equal(T.normalizeConfig({ showProgress: false, placement: "sidebar", quickNav: false }).quickNav, false);
|
|
265
|
+
assert.equal(T.normalizeConfig({ vikingPreview: false }).vikingPreview, false);
|
|
266
|
+
assert.equal(T.normalizeConfig({ sectionFullHeight: true }).sectionFullHeight, true);
|
|
267
|
+
assert.equal(T.normalizeConfig({ sectionFullHeight: "yes" }).sectionFullHeight, false);
|
|
268
|
+
assert.equal(T.normalizeConfig({ todoPrompt: false }).todoPrompt, false);
|
|
269
|
+
assert.equal(T.normalizeConfig({ showStats: false }).showStats, false);
|
|
270
|
+
assert.equal(T.normalizeConfig({ timeMode: "elapsed" }).timeMode, "elapsed");
|
|
271
|
+
assert.equal(T.normalizeConfig({ timeMode: "bogus" }).timeMode, "clock");
|
|
272
|
+
assert.equal(T.normalizeConfig({ stepAnchors: true }).stepAnchors, true, "stepAnchors opt-in");
|
|
273
|
+
assert.ok(T.compareVersions("v1.2.0", "1.1.0") > 0, "newer release detected");
|
|
274
|
+
assert.equal(T.compareVersions("1.1.0", "v1.1.0"), 0, "same version equal");
|
|
275
|
+
assert.ok(T.compareVersions("1.0.9", "1.1.0") < 0, "older release not flagged");
|
|
276
|
+
|
|
277
|
+
/* failed steps keep a short error excerpt for the stats-bar drill-down */
|
|
278
|
+
{
|
|
279
|
+
const errFolder = T.createFolder();
|
|
280
|
+
const call = { type: "tool/call", seq: 2, time: 200, data: { callId: "e1", name: "bash", arguments: JSON.stringify({ command: "npm publish" }) } };
|
|
281
|
+
const result = { type: "tool/result", seq: 3, time: 260, data: { message: { source: { callId: "e1" }, isError: true, content: [{ type: "text", text: "npm ERR! 403 Forbidden" }] } } };
|
|
282
|
+
const okCall = { type: "tool/call", seq: 4, time: 300, data: { callId: "e2", name: "write", arguments: JSON.stringify({ file_path: "/w/a.txt", content: "x" }) } };
|
|
283
|
+
const okResult = { type: "tool/result", seq: 5, time: 320, data: { message: { source: { callId: "e2" }, content: [{ type: "text", text: "ok" }] } } };
|
|
284
|
+
T.foldEntry(errFolder, { type: "durable", event: { type: "turn/start", seq: 1, time: 100, data: { turn: 1 } } });
|
|
285
|
+
for (const event of [call, result, okCall, okResult]) T.foldEntry(errFolder, { type: "durable", event });
|
|
286
|
+
errFolder.changed = false;
|
|
287
|
+
const errState = T.buildState(errFolder, { hasMore: false, entries: [] }, 0, "all");
|
|
288
|
+
const failedStep = errState.stepsAll.find((step) => step.callId === "e1");
|
|
289
|
+
const okStep = errState.stepsAll.find((step) => step.callId === "e2");
|
|
290
|
+
assert.equal(failedStep.error, true, "failed step flagged");
|
|
291
|
+
assert.ok(String(failedStep.err).includes("403 Forbidden"), "failure excerpt captured");
|
|
292
|
+
assert.equal(okStep.err, undefined, "success keeps no excerpt");
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/* mermaid fences become renderable blocks; exit_plan_mode becomes a plan artifact */
|
|
296
|
+
{
|
|
297
|
+
const md = T.mdToHtml("# t\n\n```mermaid\ngraph TD;A-->B;\n```\n\n```js\nx\n```");
|
|
298
|
+
assert.ok(md.includes('<div class="dsov-mermaid" data-src="graph TD;A-->B;">'), "mermaid block emitted with escaped source");
|
|
299
|
+
assert.ok(md.includes('<pre class="dsov-md-pre"><code>graph TD;A-->B;</code></pre>'), "mermaid fallback pre kept inside block");
|
|
300
|
+
assert.ok(md.includes('<pre class="dsov-md-pre"><code>x</code></pre>'), "non-mermaid fence unchanged");
|
|
301
|
+
assert.ok(!md.includes('data-src="x"'), "plain fence not marked as diagram");
|
|
302
|
+
assert.equal(T.planTitleOf("# 引用板块:动画 + 来源确认\n\n内容"), "引用板块:动画 + 来源确认", "plan title from heading");
|
|
303
|
+
assert.equal(T.planTitleOf("无标题计划\n步骤"), "无标题计划", "plan title from first line");
|
|
304
|
+
assert.equal(T.planTitleOf(""), "", "empty plan title");
|
|
305
|
+
|
|
306
|
+
const planFolder = T.createFolder();
|
|
307
|
+
planFolder.sessionId = "sess-plan";
|
|
308
|
+
T.foldEntry(planFolder, { type: "durable", event: { type: "turn/start", seq: 1, time: 100, data: { turn: 1 } } });
|
|
309
|
+
T.foldEntry(planFolder, { type: "durable", event: { type: "tool/call", seq: 2, time: 200, data: { callId: "p1", name: "exit_plan_mode", arguments: JSON.stringify({ plan: "# 我的计划\n\n步骤一" }) } } });
|
|
310
|
+
T.foldEntry(planFolder, { type: "durable", event: { type: "tool/result", seq: 3, time: 300, data: { message: { source: { callId: "p1" }, content: [{ type: "text", text: "Plan approved" }] } } } });
|
|
311
|
+
planFolder.changed = false;
|
|
312
|
+
const planState = T.buildState(planFolder, { hasMore: false, entries: [] }, 0, "all");
|
|
313
|
+
const plan = planState.artifacts.find((a) => a.plan === true);
|
|
314
|
+
assert.ok(plan !== undefined, "plan artifact recorded");
|
|
315
|
+
assert.equal(plan.planTitle, "我的计划", "plan title stored");
|
|
316
|
+
assert.equal(plan.path, "plan:我的计划", "plan pseudo path");
|
|
317
|
+
assert.equal(typeof plan.previewKey, "string", "plan preview captured");
|
|
318
|
+
assert.equal(plan.planCallId, "p1", "plan call id stored for the native viewer");
|
|
319
|
+
|
|
320
|
+
const badFolder = T.createFolder();
|
|
321
|
+
badFolder.sessionId = "sess-plan";
|
|
322
|
+
T.foldEntry(badFolder, { type: "durable", event: { type: "turn/start", seq: 1, time: 100, data: { turn: 1 } } });
|
|
323
|
+
T.foldEntry(badFolder, { type: "durable", event: { type: "tool/call", seq: 2, time: 200, data: { callId: "p2", name: "exit_plan_mode", arguments: JSON.stringify({ plan: "# x" }) } } });
|
|
324
|
+
T.foldEntry(badFolder, { type: "durable", event: { type: "tool/result", seq: 3, time: 300, data: { message: { source: { callId: "p2" }, isError: true, content: [{ type: "text", text: "rejected" }] } } } });
|
|
325
|
+
badFolder.changed = false;
|
|
326
|
+
const badState = T.buildState(badFolder, { hasMore: false, entries: [] }, 0, "all");
|
|
327
|
+
assert.equal(badState.artifacts.length, 0, "failed plan commits no artifact");
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
assert.equal(T.normalizeConfig({}).stepAnchors, false, "stepAnchors defaults off");
|
|
332
|
+
assert.equal(T.normalizeConfig({ todoPromptTemplate: "重跑 {index}: {content}\n保留换行" }).todoPromptTemplate, "重跑 {index}: {content}\n保留换行");
|
|
333
|
+
assert.equal(T.normalizeConfig({ todoPromptTemplate: 42 }).todoPromptTemplate, "");
|
|
334
|
+
assert.equal(T.normalizeConfig({ vikingUrl: "http://x:1/" }).vikingUrl, "http://x:1");
|
|
335
|
+
assert.equal(T.normalizeConfig({ vikingAccount: " " }).vikingAccount, "");
|
|
336
|
+
assert.deepEqual(T.normalizeConfig({ placement: "nope", showArtifacts: "x", showReferences: null }).placement, "both");
|
|
337
|
+
|
|
338
|
+
/* tree helpers: expand component nodes and collect matches */
|
|
339
|
+
function expand(node) {
|
|
340
|
+
if (Array.isArray(node)) return node.map(expand);
|
|
341
|
+
if (node && typeof node === "object") {
|
|
342
|
+
if (typeof node.type === "function" && node.props) return expand(node.type(node.props));
|
|
343
|
+
return { type: node.type, props: node.props, children: expand(node.children) };
|
|
344
|
+
}
|
|
345
|
+
return node;
|
|
346
|
+
}
|
|
347
|
+
function collect(tree, pred, out = [], seen = new Set()) {
|
|
348
|
+
if (Array.isArray(tree)) {
|
|
349
|
+
tree.forEach((n) => collect(n, pred, out, seen));
|
|
350
|
+
return out;
|
|
351
|
+
}
|
|
352
|
+
if (tree && typeof tree === "object") {
|
|
353
|
+
if (seen.has(tree)) return out;
|
|
354
|
+
seen.add(tree);
|
|
355
|
+
if (pred(tree)) out.push(tree);
|
|
356
|
+
collect(tree.children, pred, out, seen);
|
|
357
|
+
}
|
|
358
|
+
return out;
|
|
359
|
+
}
|
|
360
|
+
const selectButtons = (tree) => collect(tree, (n) => n.type === "button" && n.props && n.props.className === "dsov-set-select");
|
|
361
|
+
const menuItems = (tree) => collect(tree, (n) => n.type === "button" && n.props && n.props.className === "dsov-set-menuItem");
|
|
362
|
+
const textInputs = (tree) => collect(tree, (n) => n.type === "input" && n.props && n.props.className === "dsov-set-input");
|
|
363
|
+
const selectLabel = (button) => {
|
|
364
|
+
const node = collect(button, (n) => n.props && n.props.className === "dsov-set-selectLabel")[0];
|
|
365
|
+
return node ? node.children[0] : undefined;
|
|
366
|
+
};
|
|
367
|
+
function renderForm(host, props) {
|
|
368
|
+
resetHooks();
|
|
369
|
+
return expand(host.instance._test.SettingsForm(props));
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/* scenario F: SettingsForm renders summary and page states */
|
|
373
|
+
const formHost = materialize(undefined);
|
|
374
|
+
const keyT = (key) => key;
|
|
375
|
+
resetHooks(true);
|
|
376
|
+
assert.ok(expand(formHost.instance._test.SettingsForm({ view: "summary", t: keyT })), "summary view renders");
|
|
377
|
+
const mutations = [];
|
|
378
|
+
const formProps = {
|
|
379
|
+
view: "page",
|
|
380
|
+
t: keyT,
|
|
381
|
+
form: {
|
|
382
|
+
state: { status: "ready", value: { showProgress: false, showArtifacts: true, showReferences: true, placement: "sidebar" }, writable: true, revision: 7 },
|
|
383
|
+
mutate: (ops, revision) => { mutations.push({ ops, revision }); return Promise.resolve(true); },
|
|
384
|
+
},
|
|
385
|
+
};
|
|
386
|
+
const pageForm = renderForm(formHost, formProps);
|
|
387
|
+
const selects = selectButtons(pageForm);
|
|
388
|
+
assert.equal(selects.length, 13, "sections (4) + time mode + milestone pin + todo prompt + conclusion tab + quick nav + link + placement + full height + viking preview");
|
|
389
|
+
assert.equal(selectLabel(selects[0]), "settings.off", "showProgress=false reflected");
|
|
390
|
+
assert.equal(selectLabel(selects[1]), "settings.on", "showArtifacts=true reflected");
|
|
391
|
+
assert.equal(selectLabel(selects[3]), "settings.on", "showStats defaults on");
|
|
392
|
+
assert.equal(selectLabel(selects[4]), "settings.timeMode.clock", "timeMode defaults to clock time");
|
|
393
|
+
assert.equal(selectLabel(selects[5]), "settings.off", "stepAnchors defaults off");
|
|
394
|
+
assert.equal(selectLabel(selects[6]), "settings.todoPromptOn", "todoPrompt defaults to the prompt mode");
|
|
395
|
+
assert.equal(selectLabel(selects[7]), "settings.conclusionTabOn", "conclusionTab defaults to the new-tab mode");
|
|
396
|
+
assert.equal(selectLabel(selects[8]), "settings.on", "quickNav defaults on");
|
|
397
|
+
assert.equal(selectLabel(selects[9]), "settings.on", "quickNavLink defaults on");
|
|
398
|
+
assert.equal(selectLabel(selects[10]), "settings.placement.sidebar", "placement=sidebar reflected");
|
|
399
|
+
assert.equal(selectLabel(selects[11]), "settings.off", "sectionFullHeight defaults off, grouped with placement");
|
|
400
|
+
const areas = collect(pageForm, (n) => n.type === "textarea" && n.props && n.props.className === "dsov-set-textarea");
|
|
401
|
+
assert.equal(areas.length, 1, "prompt template textarea visible while todoPrompt is on");
|
|
402
|
+
assert.equal(areas[0].props.placeholder, "settings.todoPromptPlaceholder", "the old notes became the placeholder");
|
|
403
|
+
const formInputs = textInputs(pageForm);
|
|
404
|
+
assert.equal(formInputs.length, 3, "viking url/account/user inputs");
|
|
405
|
+
assert.deepEqual(formInputs.map((i) => i.props.value), ["http://127.0.0.1:1933", "", ""]);
|
|
406
|
+
/* open the placement menu and pick an option */
|
|
407
|
+
selects[10].props.onClick();
|
|
408
|
+
const opened = renderForm(formHost, formProps);
|
|
409
|
+
const items = menuItems(opened);
|
|
410
|
+
assert.ok(items.length >= 3, "placement menu rendered");
|
|
411
|
+
const convItem = items.find((n) => n.children[0] === "settings.placement.conversation");
|
|
412
|
+
assert.ok(convItem, "conversation option present");
|
|
413
|
+
convItem.props.onClick();
|
|
414
|
+
assert.deepEqual(mutations.at(-1), { ops: [{ op: "set", path: ["placement"], value: "conversation" }], revision: 7 }, "select writes one field op");
|
|
415
|
+
/* boolean row writes true/false */
|
|
416
|
+
resetHooks();
|
|
417
|
+
const reopened = renderForm(formHost, formProps);
|
|
418
|
+
const selects2 = selectButtons(reopened);
|
|
419
|
+
selects2[1].props.onClick();
|
|
420
|
+
const opened2 = renderForm(formHost, formProps);
|
|
421
|
+
const offItem = menuItems(opened2).find((n) => n.children[0] === "settings.off");
|
|
422
|
+
offItem.props.onClick();
|
|
423
|
+
assert.deepEqual(mutations.at(-1), { ops: [{ op: "set", path: ["showArtifacts"], value: false }], revision: 7 }, "boolean select maps to false");
|
|
424
|
+
/* same option click is a no-op */
|
|
425
|
+
const beforeCount = mutations.length;
|
|
426
|
+
resetHooks();
|
|
427
|
+
const reopened3 = renderForm(formHost, formProps);
|
|
428
|
+
selectButtons(reopened3)[1].props.onClick();
|
|
429
|
+
const opened3 = renderForm(formHost, formProps);
|
|
430
|
+
menuItems(opened3).find((n) => n.children[0] === "settings.on").props.onClick();
|
|
431
|
+
assert.equal(mutations.length, beforeCount, "selecting the current value writes nothing");
|
|
432
|
+
/* text input commits on blur */
|
|
433
|
+
resetHooks();
|
|
434
|
+
const textForm = renderForm(formHost, formProps);
|
|
435
|
+
const user2 = textInputs(textForm)[2];
|
|
436
|
+
user2.props.onFocus();
|
|
437
|
+
user2.props.onChange({ target: { value: "alice" } });
|
|
438
|
+
const editing = renderForm(formHost, formProps);
|
|
439
|
+
assert.equal(textInputs(editing)[2].props.value, "alice", "draft shown while focused");
|
|
440
|
+
textInputs(editing)[2].props.onBlur();
|
|
441
|
+
assert.deepEqual(mutations.at(-1), { ops: [{ op: "set", path: ["vikingUser"], value: "alice" }], revision: 7 }, "blur commits the text field");
|
|
442
|
+
|
|
443
|
+
/* prompt template textarea commits on blur */
|
|
444
|
+
resetHooks();
|
|
445
|
+
const areaForm = renderForm(formHost, formProps);
|
|
446
|
+
const area1 = collect(areaForm, (n) => n.type === "textarea" && n.props && n.props.className === "dsov-set-textarea")[0];
|
|
447
|
+
area1.props.onFocus();
|
|
448
|
+
area1.props.onChange({ target: { value: "重跑 {index}: {content}" } });
|
|
449
|
+
const areaEditing = renderForm(formHost, formProps);
|
|
450
|
+
collect(areaEditing, (n) => n.type === "textarea")[0].props.onBlur();
|
|
451
|
+
assert.deepEqual(mutations.at(-1), { ops: [{ op: "set", path: ["todoPromptTemplate"], value: "重跑 {index}: {content}" }], revision: 7 }, "blur commits the template");
|
|
452
|
+
|
|
453
|
+
/* todoPrompt off hides the textarea */
|
|
454
|
+
resetHooks();
|
|
455
|
+
const promptOffProps = { ...formProps, form: { ...formProps.form, state: { ...formProps.form.state, value: { ...formProps.form.state.value, todoPrompt: false } } } };
|
|
456
|
+
const promptOffForm = renderForm(formHost, promptOffProps);
|
|
457
|
+
assert.equal(collect(promptOffForm, (n) => n.type === "textarea").length, 0, "template editor hidden while todoPrompt is off");
|
|
458
|
+
|
|
459
|
+
/* scenario G: SettingsForm overlay path without a host form */
|
|
460
|
+
const localHost = materialize(undefined);
|
|
461
|
+
const localProps = { view: "page", t: keyT };
|
|
462
|
+
resetHooks(true);
|
|
463
|
+
const localForm = renderForm(localHost, localProps);
|
|
464
|
+
const localSelects = selectButtons(localForm);
|
|
465
|
+
assert.equal(localSelects.length, 13, "fallback form renders all controls");
|
|
466
|
+
assert.equal(selectLabel(localSelects[10]), "settings.placement.both", "default placement shown");
|
|
467
|
+
localSelects[8].props.onClick();
|
|
468
|
+
const localOpened = renderForm(localHost, localProps);
|
|
469
|
+
menuItems(localOpened).find((n) => n.children[0] === "settings.off").props.onClick();
|
|
470
|
+
const afterOverlay = renderForm(localHost, localProps);
|
|
471
|
+
assert.equal(selectLabel(selectButtons(afterOverlay)[8]), "settings.off", "overlay edit reflected on rerender");
|
|
472
|
+
/* placement through the overlay too */
|
|
473
|
+
selectButtons(afterOverlay)[10].props.onClick();
|
|
474
|
+
const localOpened2 = renderForm(localHost, localProps);
|
|
475
|
+
menuItems(localOpened2).find((n) => n.children[0] === "settings.placement.sidebar").props.onClick();
|
|
476
|
+
const afterOverlay2 = renderForm(localHost, localProps);
|
|
477
|
+
assert.equal(selectLabel(selectButtons(afterOverlay2)[10]), "settings.placement.sidebar", "overlay placement edit persisted");
|
|
478
|
+
assert.ok(localHost.disposals.includes("conversation.view"), "overlay placement change disposed the conversation view");
|
|
479
|
+
|
|
480
|
+
/* viking preview toggle gates the text rows */
|
|
481
|
+
assert.equal(selectLabel(selectButtons(afterOverlay2)[12]), "settings.on", "vikingPreview defaults on");
|
|
482
|
+
assert.equal(textInputs(afterOverlay2).length, 3, "viking text rows visible while enabled");
|
|
483
|
+
selectButtons(afterOverlay2)[12].props.onClick();
|
|
484
|
+
const vikingOpened = renderForm(localHost, localProps);
|
|
485
|
+
menuItems(vikingOpened).find((n) => n.children[0] === "settings.off").props.onClick();
|
|
486
|
+
const vikingOff = renderForm(localHost, localProps);
|
|
487
|
+
assert.equal(selectLabel(selectButtons(vikingOff)[12]), "settings.off", "vikingPreview toggled off");
|
|
488
|
+
assert.equal(textInputs(vikingOff).length, 0, "viking text rows hidden while disabled");
|
|
489
|
+
const offNote = collect(vikingOff, (n) => n.type === "div" && n.props && n.props.className === "dsov-set-note" && n.children[0] === "settings.vikingOffNote");
|
|
490
|
+
assert.ok(offNote.length > 0, "off note rendered");
|
|
491
|
+
selectButtons(vikingOff)[12].props.onClick();
|
|
492
|
+
menuItems(renderForm(localHost, localProps)).find((n) => n.children[0] === "settings.on").props.onClick();
|
|
493
|
+
assert.equal(textInputs(renderForm(localHost, localProps)).length, 3, "text rows return after re-enabling");
|
|
494
|
+
|
|
495
|
+
/* scenario H: standalone settings page renders */
|
|
496
|
+
resetHooks(true);
|
|
497
|
+
const page = expand(localHost.instance._test.SettingsSection());
|
|
498
|
+
assert.ok(page && page.props.className === "dsov-settingsPage", "settings section page renders");
|
|
499
|
+
assert.ok(selectButtons(page).length === 13, "settings page embeds the form");
|
|
500
|
+
|
|
501
|
+
/* ---- 3. fold real session events ---------------------------------------- */
|
|
502
|
+
function loadEvents(file) {
|
|
503
|
+
const out = execFileSync("zstdcat", [file], { encoding: "utf8", maxBuffer: 512 * 1024 * 1024 });
|
|
504
|
+
const events = [];
|
|
505
|
+
for (const line of out.split("\n")) {
|
|
506
|
+
const trimmed = line.trim();
|
|
507
|
+
if (trimmed.length === 0) continue;
|
|
508
|
+
try {
|
|
509
|
+
events.push(JSON.parse(trimmed));
|
|
510
|
+
} catch {
|
|
511
|
+
/* header/partial line */
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
return events;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function foldFolder(events) {
|
|
518
|
+
const folder = T.createFolder();
|
|
519
|
+
for (const event of events) T.foldEntry(folder, { type: "durable", event });
|
|
520
|
+
folder.changed = false;
|
|
521
|
+
return folder;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const FOLD_WINDOW = { hasMore: false, entries: [] };
|
|
525
|
+
|
|
526
|
+
function foldAll(events) {
|
|
527
|
+
return T.buildState(foldFolder(events), FOLD_WINDOW, 0, "all");
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/* No arguments: take the two most recent real session logs from the local DSH
|
|
531
|
+
store, so the suite runs without hunting for paths by hand. */
|
|
532
|
+
function discoverLogs(limit = 2) {
|
|
533
|
+
const root = join(homedir(), ".dsh", "sessions");
|
|
534
|
+
if (!existsSync(root)) return [];
|
|
535
|
+
const found = [];
|
|
536
|
+
for (const workspace of readdirSync(root, { withFileTypes: true })) {
|
|
537
|
+
if (!workspace.isDirectory()) continue;
|
|
538
|
+
const workspaceDir = join(root, workspace.name);
|
|
539
|
+
for (const session of readdirSync(workspaceDir, { withFileTypes: true })) {
|
|
540
|
+
if (!session.isDirectory()) continue;
|
|
541
|
+
const file = join(workspaceDir, session.name, "session.v4.jsonl.zstd");
|
|
542
|
+
if (!existsSync(file)) continue;
|
|
543
|
+
found.push({ file, mtime: statSync(file).mtimeMs });
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
return found
|
|
547
|
+
.sort((left, right) => right.mtime - left.mtime)
|
|
548
|
+
.slice(0, limit)
|
|
549
|
+
.map((row) => row.file);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const logs = process.argv.slice(2).length > 0 ? process.argv.slice(2) : discoverLogs();
|
|
553
|
+
assert.ok(
|
|
554
|
+
logs.length > 0,
|
|
555
|
+
"no session log found: pass one or more session.v4.jsonl.zstd paths, or run where ~/.dsh/sessions exists"
|
|
556
|
+
);
|
|
557
|
+
for (const log of logs) {
|
|
558
|
+
const events = loadEvents(log);
|
|
559
|
+
const state = foldAll(events);
|
|
560
|
+
const counts = {
|
|
561
|
+
todoWrites: events.filter((e) => e.type === "todo/write").length,
|
|
562
|
+
presented: events.filter((e) => e.type === "deliverables/presented").length,
|
|
563
|
+
changes: events.filter((e) => e.type === "workspace/changes").length,
|
|
564
|
+
injections: events.filter((e) => e.type === "user/message" && e.data && e.data.source && e.data.source.kind !== "user").length,
|
|
565
|
+
skills: events.filter((e) => e.type === "tool/call" && e.data && e.data.name === "skill").length,
|
|
566
|
+
userMsgs: events.filter((e) => e.type === "user/message" && e.data && e.data.source && e.data.source.kind === "user").length,
|
|
567
|
+
};
|
|
568
|
+
console.log(`\n### ${log.split("/").slice(-2).join("/")} events=${events.length}`);
|
|
569
|
+
console.log(" log counts:", JSON.stringify(counts));
|
|
570
|
+
console.log(" todos:", state.todos ? state.todos.length : null, "artifacts:", state.artifacts.length, "references:", state.references.length, "changeTurns:", state.changeTurns.length);
|
|
571
|
+
if (counts.todoWrites > 0) assert.ok(Array.isArray(state.todos) && state.todos.length >= 0, "todos folded");
|
|
572
|
+
if (counts.presented > 0) assert.ok(state.artifacts.some((a) => a.presented), "presented artifacts folded");
|
|
573
|
+
if (counts.injections > 0) assert.ok(state.references.some((r) => r.kind !== "file"), "context references folded");
|
|
574
|
+
if (counts.skills > 0) assert.ok(state.references.some((r) => r.kind === "skill"), "skill references folded");
|
|
575
|
+
/* viking:// memory refs are split into clickable memory-file rows */
|
|
576
|
+
const memFiles = state.references.filter((r) => r.kind === "memory-file");
|
|
577
|
+
for (const ref of memFiles) {
|
|
578
|
+
assert.ok(typeof ref.uri === "string" && ref.uri.startsWith("viking://"), `memory-file uri: ${ref.uri}`);
|
|
579
|
+
assert.equal(ref.label, T.vikingLabel(ref.uri), "memory-file label is the tree-aware name");
|
|
580
|
+
}
|
|
581
|
+
const vikingInjections = events.filter(
|
|
582
|
+
(e) => e.type === "user/message" && e.data && e.data.source && String(e.data.source.kind || "").startsWith("plugin:openviking") && JSON.stringify(e.data.content || "").includes("viking://")
|
|
583
|
+
);
|
|
584
|
+
const vikingReads = events.filter((e) => e.type === "tool/call" && e.data && /mcp__openviking__/i.test(String(e.data.name)) && JSON.stringify(e.data.arguments || "").includes("viking://"));
|
|
585
|
+
if (vikingInjections.length + vikingReads.length > 0) assert.ok(memFiles.length > 0, "viking uris folded into memory-file refs");
|
|
586
|
+
console.log(" memory-file refs:", memFiles.length, memFiles.slice(0, 3).map((r) => r.label).join(", "));
|
|
587
|
+
assert.equal(state.changeTurns.length, counts.changes, "workspace/changes turns folded");
|
|
588
|
+
for (const turn of state.changeTurns) {
|
|
589
|
+
assert.ok(Number.isSafeInteger(turn.seq) && Number.isSafeInteger(turn.turn) && turn.turn >= 1, `turn coords: ${JSON.stringify(turn)}`);
|
|
590
|
+
}
|
|
591
|
+
if (state.changeTurns.length > 0) console.log(" changeTurns:", JSON.stringify(state.changeTurns.slice(0, 4)));
|
|
592
|
+
/* per-turn views */
|
|
593
|
+
const folder2 = foldFolder(events);
|
|
594
|
+
const turnStarts = events.filter((e) => e.type === "turn/start").map((e) => e.data.turn);
|
|
595
|
+
assert.deepEqual(state.turns, [...new Set(turnStarts)].filter((n) => Number.isSafeInteger(n) && n >= 1).sort((a, b) => a - b), "turns tracked from turn/start");
|
|
596
|
+
assert.equal(state.latestTurn, state.turns.length > 0 ? Math.max(...state.turns) : null, "latestTurn is the max");
|
|
597
|
+
if (state.latestTurn !== null) {
|
|
598
|
+
const latest = T.buildState(folder2, FOLD_WINDOW, 0, "latest");
|
|
599
|
+
assert.equal(latest.scope, "latest");
|
|
600
|
+
assert.ok(latest.references.length <= state.references.length, "latest turn refs subset of all");
|
|
601
|
+
for (const change of latest.changeTurns) assert.equal(change.turn, state.latestTurn, "latest view changeTurns belong to the latest turn");
|
|
602
|
+
const scoped = T.buildState(folder2, FOLD_WINDOW, 0, `turn:${state.turns[0]}`);
|
|
603
|
+
assert.equal(scoped.scope, `turn:${state.turns[0]}`);
|
|
604
|
+
assert.ok(scoped.references.length + scoped.artifacts.length <= state.references.length + state.artifacts.length, "turn view is a slice");
|
|
605
|
+
console.log(" latest turn:", state.latestTurn, "refs:", latest.references.length, "arts:", latest.artifacts.length, "| all refs:", state.references.length, "arts:", state.artifacts.length, "| turns:", state.turns.join(","));
|
|
606
|
+
}
|
|
607
|
+
for (const ref of state.references.slice(0, 8)) {
|
|
608
|
+
console.log(` ref [${ref.kind}${ref.form ? "/" + ref.form : ""}] ${ref.label} x${ref.count}${ref.detail && ref.detail.length ? " | " + ref.detail.slice(0, 3).join(", ") : ""}`);
|
|
609
|
+
}
|
|
610
|
+
for (const artifact of state.artifacts.slice(0, 8)) {
|
|
611
|
+
console.log(` art [${artifact.presented ? "presented" : "produced"}] ${artifact.path}`);
|
|
612
|
+
}
|
|
613
|
+
if (state.todos) for (const todo of state.todos.slice(0, 8)) console.log(` todo [${todo.status}] ${todo.content}`);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/* ---- 4. helper unit checks ---------------------------------------------- */
|
|
617
|
+
const m1 = T.mentionsOfUserText('看 @src/main.ts 和 @"path with space/a.md",还有 email a@b.com 与 @[标题](dsh-session:abc) 以及 `@code/x` 与 @https://x.com');
|
|
618
|
+
assert.deepEqual(m1.files.sort(), ["path with space/a.md", "src/main.ts"], `mention files: ${JSON.stringify(m1.files)}`);
|
|
619
|
+
assert.deepEqual(m1.sessions, ["标题"]);
|
|
620
|
+
assert.equal(T.mutationPath("write", JSON.stringify({ file_path: "/a/b.ts", content: "x" })), "/a/b.ts");
|
|
621
|
+
assert.equal(T.mutationPath("write", JSON.stringify({ file_path: "/a/b.ts" })), null);
|
|
622
|
+
assert.equal(T.mutationPath("edit", JSON.stringify({ file_path: "/a/b.ts", old_string: "x", new_string: "y" })), "/a/b.ts");
|
|
623
|
+
assert.equal(T.mutationPath("str_replace_editor", JSON.stringify({ command: "create", path: "/a/c.py", file_text: "t" })), "/a/c.py");
|
|
624
|
+
assert.equal(T.mutationPath("bash", JSON.stringify({ command: "rm x" })), null);
|
|
625
|
+
assert.equal(T.fileAddressFor("s-1", "/work/proj", "/work/proj/a b.ts"), "dsh-resource://file/session/s-1/a%20b.ts");
|
|
626
|
+
assert.equal(T.fileAddressFor("s-1", "/work/proj", "/elsewhere/x.ts"), "dsh-resource://file/session/s-1//elsewhere/x.ts");
|
|
627
|
+
assert.equal(T.fileAddressFor("s-1", "/work/proj", "rel/y.ts"), "dsh-resource://file/session/s-1/rel/y.ts");
|
|
628
|
+
assert.equal(T.contextLabel({ kind: "plugin:openviking-memory", plugin: "openviking-memory" }, "plugin:openviking-memory"), "openviking-memory");
|
|
629
|
+
const detail = T.contextDetail({ kind: "plugin:openviking-memory" }, [{ type: "text", text: '<memory uri="viking://user/a/b.md" /> see viking://user/c.md' }]);
|
|
630
|
+
assert.deepEqual(detail, ["viking://user/a/b.md", "viking://user/c.md"]);
|
|
631
|
+
const punctuated = T.contextDetail({ kind: "plugin:openviking-memory" }, [{ type: "text", text: "见 viking://user/c/plan.md。还有 viking://user/c/x.md," }]);
|
|
632
|
+
assert.deepEqual(punctuated, ["viking://user/c/plan.md", "viking://user/c/x.md"], "trailing punctuation stripped from extracted uris");
|
|
633
|
+
/* quoted extraction recovers URIs containing spaces; truncated bare prefixes are dropped */
|
|
634
|
+
const spacedText = '<memory uri="viking://user/alice/memories/preferences/alice/大 json 前端渲染方案.md" type="preferences" score="0.83" />';
|
|
635
|
+
assert.deepEqual(T.extractVikingUris(spacedText), ["viking://user/alice/memories/preferences/alice/大 json 前端渲染方案.md"], "quoted uri keeps inner spaces");
|
|
636
|
+
assert.equal(T.vikingName(T.extractVikingUris(spacedText)[0]), "大 json 前端渲染方案", "display name uses the full segment");
|
|
637
|
+
assert.deepEqual(T.extractVikingUris('uri="viking://a/b c.md" 以及 viking://a/b'), ["viking://a/b c.md"], "space-truncated bare prefix dropped");
|
|
638
|
+
assert.deepEqual(T.extractVikingUris("viking://a/dir viking://a/dir/sub.md"), ["viking://a/dir", "viking://a/dir/sub.md"], "slash-child uri is not a space prefix");
|
|
639
|
+
|
|
640
|
+
/* ---- 5. file-type classification + changes helpers ---------------------- */
|
|
641
|
+
assert.equal(T.classifyFileType("src/client.js"), "javascript");
|
|
642
|
+
assert.equal(T.classifyFileType("/a/b/client.ts"), "typescript");
|
|
643
|
+
assert.equal(T.classifyFileType("README.md"), "markdown");
|
|
644
|
+
assert.equal(T.classifyFileType("icon.svg"), "image");
|
|
645
|
+
assert.equal(T.classifyFileType("package.json"), "node"); // host FILE_NAME_TYPES: package.json gets the node icon
|
|
646
|
+
assert.equal(T.classifyFileType("data.json"), "json");
|
|
647
|
+
assert.equal(T.classifyFileType("Dockerfile"), "docker");
|
|
648
|
+
assert.equal(T.classifyFileType("app.vue"), "vue");
|
|
649
|
+
assert.equal(T.classifyFileType("data.xlsx"), "excel");
|
|
650
|
+
assert.equal(T.classifyFileType("notes.unknownext"), "other");
|
|
651
|
+
assert.ok(
|
|
652
|
+
T.isChangesSummary({ turn: 1, total: 1, added: 3, deleted: 1, files: [{ path: "a.ts", display: "a.ts", added: 3, deleted: 1 }] }),
|
|
653
|
+
"valid summary accepted"
|
|
654
|
+
);
|
|
655
|
+
assert.ok(!T.isChangesSummary({ turn: 0, total: 0, added: 0, deleted: 0, files: [] }), "turn 0 rejected");
|
|
656
|
+
assert.ok(!T.isChangesSummary({ turn: 1, total: 1, added: 0, deleted: 0, files: [{ path: "", display: "x", added: 0, deleted: 0 }] }), "bad file rejected");
|
|
657
|
+
assert.equal(T.changesReviewAddress("s 1", 55, 2), "dsh-resource://changes-review/session/s%201/55/2");
|
|
658
|
+
assert.equal(T.absPath("/work/proj", "rel/a.ts"), "/work/proj/rel/a.ts");
|
|
659
|
+
assert.equal(T.absPath("/work/proj", "/abs/b.ts"), "/abs/b.ts");
|
|
660
|
+
assert.equal(T.absPath("/work/proj/", "rel/a.ts"), "/work/proj/rel/a.ts");
|
|
661
|
+
|
|
662
|
+
/* ---- 6. viking helpers + markdown renderer ------------------------------ */
|
|
663
|
+
assert.equal(T.vikingName("viking://user/a/notes/plan.md"), "plan");
|
|
664
|
+
assert.equal(T.vikingName("viking://user/a/dir/"), "dir");
|
|
665
|
+
assert.equal(T.vikingName("viking://x"), "x");
|
|
666
|
+
assert.equal(T.vikingName("viking://"), "viking:");
|
|
667
|
+
const vAddr = T.vikingAddress("viking://user/a b.md");
|
|
668
|
+
assert.equal(vAddr, "dsh-resource://viking-memory/viking%3A%2F%2Fuser%2Fa%20b.md");
|
|
669
|
+
assert.equal(T.parseVikingAddress(vAddr), "viking://user/a b.md");
|
|
670
|
+
assert.equal(T.parseVikingAddress("dsh-resource://viking-memory/https%3A%2F%2Fx"), undefined);
|
|
671
|
+
assert.equal(T.parseVikingAddress("dsh-resource://file/x"), undefined);
|
|
672
|
+
assert.equal(T.parseVikingAddress("dsh-resource://viking-memory/%E0%A4%A"), undefined);
|
|
673
|
+
const html = T.mdToHtml("# Title\n\ntext **bold** and <script>alert(1)</script>\n\n- a\n- b\n\n1. one\n2. two\n\n> quote\n\n```\ncode <b>\n```\n\n[link](https://example.com)");
|
|
674
|
+
assert.ok(html.includes('<h2 class="dsov-md-h">Title</h2>'), `heading rendered: ${html.slice(0, 80)}`);
|
|
675
|
+
assert.ok(html.includes("<strong>bold</strong>"), "bold rendered");
|
|
676
|
+
assert.ok(html.includes("<script>alert(1)</script>"), "html escaped");
|
|
677
|
+
assert.ok(!html.includes("<script>"), "no raw script");
|
|
678
|
+
assert.ok(html.includes('<ul class="dsov-md-list"><li>a</li><li>b</li></ul>'), "unordered list");
|
|
679
|
+
assert.ok(html.includes('<ol class="dsov-md-list"><li>one</li>'), "ordered list");
|
|
680
|
+
assert.ok(html.includes('<blockquote class="dsov-md-quote">quote</blockquote>'), "blockquote");
|
|
681
|
+
assert.ok(html.includes("<pre") && html.includes("code <b>"), "fenced code escaped");
|
|
682
|
+
assert.ok(html.includes('<a href="https://example.com" target="_blank" rel="noreferrer noopener">link</a>'), "external link");
|
|
683
|
+
assert.ok(!T.mdToHtml("a<b").includes("<b>"), "bare angle brackets escaped");
|
|
684
|
+
|
|
685
|
+
/* ---- 7. frontmatter, breadcrumbs, reference sorting ---------------------- */
|
|
686
|
+
const sample = [
|
|
687
|
+
"---",
|
|
688
|
+
'title: "前端文件加密检测能力实现"',
|
|
689
|
+
"usage_scenario:",
|
|
690
|
+
' - "验收文件上传安全能力是否满足客户要求"',
|
|
691
|
+
' - "新增类似文件检测场景时复用detectFileEncrypted逻辑"',
|
|
692
|
+
"keywords:",
|
|
693
|
+
' - "文件加密检测"',
|
|
694
|
+
' - "detectFileEncrypted"',
|
|
695
|
+
"---",
|
|
696
|
+
"",
|
|
697
|
+
"项目已实现前端文件加密检测核心能力。",
|
|
698
|
+
].join("\n");
|
|
699
|
+
const fm = T.parseFrontmatter(sample);
|
|
700
|
+
assert.deepEqual(fm.fields.map((f) => f.key), ["title", "usage_scenario", "keywords"]);
|
|
701
|
+
assert.equal(T.frontmatterTitle(fm.fields), "前端文件加密检测能力实现");
|
|
702
|
+
assert.deepEqual(fm.fields[1].items, ["验收文件上传安全能力是否满足客户要求", "新增类似文件检测场景时复用detectFileEncrypted逻辑"]);
|
|
703
|
+
assert.deepEqual(fm.fields[2].items, ["文件加密检测", "detectFileEncrypted"]);
|
|
704
|
+
assert.equal(fm.body.trim(), "项目已实现前端文件加密检测核心能力。");
|
|
705
|
+
const noFm = T.parseFrontmatter("# plain\n\ntext");
|
|
706
|
+
assert.deepEqual(noFm.fields, []);
|
|
707
|
+
assert.equal(noFm.body, "# plain\n\ntext");
|
|
708
|
+
const skillFm = T.parseFrontmatter("---\nname: xunshan-api-migration\ndescription: Migrate or replace an API\n call spanning lines.\ntags:\n- api\n- typescript\n---\nbody");
|
|
709
|
+
assert.equal(T.frontmatterTitle(skillFm.fields), "xunshan-api-migration");
|
|
710
|
+
assert.equal(skillFm.fields.find((f) => f.key === "description").value, "Migrate or replace an API call spanning lines.");
|
|
711
|
+
assert.deepEqual(skillFm.fields.find((f) => f.key === "tags").items, ["api", "typescript"]);
|
|
712
|
+
const rendered = T.renderFrontmatterFields(fm, keyT);
|
|
713
|
+
const flat = JSON.stringify(rendered, (key, value) => (typeof value === "function" ? "[fn]" : value));
|
|
714
|
+
assert.ok(flat.includes("viking.field.scenarios"), "scenarios block rendered");
|
|
715
|
+
assert.ok(flat.includes("viking.field.keywords"), "keywords block rendered");
|
|
716
|
+
assert.ok(flat.includes("文件加密检测"), "keyword value rendered");
|
|
717
|
+
assert.ok(!flat.includes('"title"'), "title consumed as heading, not a field row");
|
|
718
|
+
|
|
719
|
+
/* breadcrumbs */
|
|
720
|
+
const segs = T.vikingSegments("viking://user/alice/memories/x.md");
|
|
721
|
+
assert.deepEqual(segs.map((s) => s.name), ["user", "alice", "memories", "x.md"]);
|
|
722
|
+
assert.equal(segs[0].uri, "viking://user/");
|
|
723
|
+
assert.equal(segs[2].uri, "viking://user/alice/memories/");
|
|
724
|
+
assert.equal(segs[3].leaf, true);
|
|
725
|
+
assert.equal(segs[3].uri, "viking://user/alice/memories/x.md");
|
|
726
|
+
const dirSegs = T.vikingSegments("viking://user/alice/skills/");
|
|
727
|
+
assert.equal(dirSegs.at(-1).name, "skills");
|
|
728
|
+
assert.equal(dirSegs.at(-1).uri, "viking://user/alice/skills");
|
|
729
|
+
assert.equal(T.formatBytes(6067), "5.9 KB");
|
|
730
|
+
assert.equal(T.formatBytes(512), "512 B");
|
|
731
|
+
|
|
732
|
+
/* reference sorting */
|
|
733
|
+
const refs = [
|
|
734
|
+
{ kind: "context", label: "c", count: 3, time: 30, seq: 3 },
|
|
735
|
+
{ kind: "memory-file", label: "m", count: 1, time: 10, seq: 1 },
|
|
736
|
+
{ kind: "file", label: "f", count: 2, time: 20, seq: 2 },
|
|
737
|
+
];
|
|
738
|
+
assert.deepEqual(T.sortReferences(refs, "time").map((r) => r.label), ["c", "m", "f"], "time mode keeps incoming order");
|
|
739
|
+
assert.deepEqual(T.sortReferences(refs, "kind").map((r) => r.label), ["c", "m", "f"], "removed kind mode falls back to the incoming order");
|
|
740
|
+
assert.deepEqual(T.sortReferences(refs, "count").map((r) => r.label), ["c", "f", "m"], "count mode ranks by count desc");
|
|
741
|
+
assert.equal(T.getRefSort(), "time", "default sort mode");
|
|
742
|
+
T.setRefSort("count");
|
|
743
|
+
assert.equal(T.getRefSort(), "count");
|
|
744
|
+
T.setRefSort("bogus");
|
|
745
|
+
assert.equal(T.getRefSort(), "count", "invalid mode ignored");
|
|
746
|
+
T.setRefSort("time");
|
|
747
|
+
assert.equal(T.getRefSort(), "time");
|
|
748
|
+
T.setRefSort("kind");
|
|
749
|
+
assert.equal(T.getRefSort(), "time", "kind sort mode was removed and is rejected");
|
|
750
|
+
T.setRefSort("count");
|
|
751
|
+
T.setRefSort("");
|
|
752
|
+
assert.equal(T.getRefSort(), "", "empty string clears the sort");
|
|
753
|
+
assert.deepEqual(T.sortReferences(refs, "").map((r) => r.label), ["c", "m", "f"], "cleared sort keeps incoming order");
|
|
754
|
+
T.setRefSort("");
|
|
755
|
+
assert.equal(T.getRefSort(), "", "clearing twice is a no-op");
|
|
756
|
+
T.setRefSort("time");
|
|
757
|
+
assert.equal(T.getRefSort(), "time", "sort re-arms after clearing");
|
|
758
|
+
|
|
759
|
+
/* ---- 8. per-turn folding + context previews ------------------------------ */
|
|
760
|
+
{
|
|
761
|
+
const ev = (type, data, seq, time) => ({ type, data, seq, time });
|
|
762
|
+
const folder = T.createFolder();
|
|
763
|
+
folder.sessionId = "s-test";
|
|
764
|
+
const W = "/w";
|
|
765
|
+
T.foldEvent(folder, ev("turn/start", { turn: 1 }, 1, 100), W);
|
|
766
|
+
T.foldEvent(folder, ev("user/message", { source: { kind: "runtime-context", sections: [{ name: "Policy" }] }, content: [{ type: "text", text: "current policy text" }] }, 2, 101), W);
|
|
767
|
+
T.foldEvent(folder, ev("todo/write", { todos: [{ content: "a", status: "in_progress" }] }, 3, 102), W);
|
|
768
|
+
T.foldEvent(folder, ev("turn/start", { turn: 2 }, 4, 200), W);
|
|
769
|
+
T.foldEvent(folder, ev("tool/call", { callId: "c1", name: "write", arguments: JSON.stringify({ file_path: "out.md", content: "x" }) }, 5, 201), W);
|
|
770
|
+
T.foldEvent(folder, ev("tool/result", { message: { source: { callId: "c1" }, content: [{ type: "text", text: "ok" }] } }, 6, 202), W);
|
|
771
|
+
T.foldEvent(folder, ev("tool/call", { callId: "c2", name: "skill", arguments: JSON.stringify({ name: "demo-skill" }) }, 7, 203), W);
|
|
772
|
+
T.foldEvent(folder, ev("tool/result", { message: { source: { callId: "c2" }, content: [{ type: "text", text: "# Demo skill content" }] } }, 8, 204), W);
|
|
773
|
+
T.foldEvent(folder, ev("todo/write", { todos: [{ content: "a", status: "completed" }] }, 9, 205), W);
|
|
774
|
+
const win = { hasMore: false, entries: [] };
|
|
775
|
+
const all = T.buildState(folder, win, 0, "all");
|
|
776
|
+
const latest = T.buildState(folder, win, 0, "latest");
|
|
777
|
+
const t1 = T.buildState(folder, win, 0, "turn:1");
|
|
778
|
+
const t2 = T.buildState(folder, win, 0, "turn:2");
|
|
779
|
+
assert.deepEqual(all.turns, [1, 2], "turn list");
|
|
780
|
+
assert.equal(all.latestTurn, 2);
|
|
781
|
+
assert.equal(all.references.length, 2, "all: runtime-context + skill refs");
|
|
782
|
+
assert.equal(all.artifacts.length, 1, "all: one produced artifact");
|
|
783
|
+
assert.equal(latest.scope, "latest");
|
|
784
|
+
assert.equal(latest.references.length, 1, "latest turn holds only its own refs");
|
|
785
|
+
assert.equal(latest.references[0].kind, "skill", "latest ref is the skill call");
|
|
786
|
+
assert.equal(latest.artifacts.length, 1, "latest turn holds its artifact");
|
|
787
|
+
assert.deepEqual(latest.todos.map((x) => x.status), ["completed"], "latest todos are current");
|
|
788
|
+
assert.equal(t1.references.length, 1, "turn 1 holds the injection ref");
|
|
789
|
+
assert.equal(t1.references[0].kind, "context");
|
|
790
|
+
assert.equal(t1.artifacts.length, 0, "turn 1 has no artifacts");
|
|
791
|
+
assert.deepEqual(t1.todos.map((x) => x.status), ["in_progress"], "turn 1 todos as-of that turn");
|
|
792
|
+
assert.equal(t2.artifacts.length, 1);
|
|
793
|
+
assert.deepEqual(t2.todos.map((x) => x.status), ["completed"]);
|
|
794
|
+
assert.equal(T.normalizeSelection("bogus"), "latest");
|
|
795
|
+
assert.equal(T.normalizeSelection("turn:x"), "latest");
|
|
796
|
+
assert.equal(T.normalizeSelection("turn:3"), "turn:3");
|
|
797
|
+
assert.equal(T.normalizeSelection("all"), "all");
|
|
798
|
+
/* context preview snapshots */
|
|
799
|
+
const ctxRef = t1.references[0];
|
|
800
|
+
assert.ok(typeof ctxRef.previewKey === "string" && ctxRef.previewKey.startsWith("s-test|"), `previewKey: ${ctxRef.previewKey}`);
|
|
801
|
+
const entry = T.readContextPreview(ctxRef.previewKey);
|
|
802
|
+
assert.ok(entry !== null, "preview snapshot stored");
|
|
803
|
+
assert.equal(entry.title, "runtime-context");
|
|
804
|
+
assert.ok(entry.text.includes("current policy text"), "snapshot text");
|
|
805
|
+
assert.ok(latest.references[0].previewKey.startsWith("s-test|skill|demo-skill"), "skill result preview");
|
|
806
|
+
assert.ok(T.readContextPreview(latest.references[0].previewKey).text.includes("Demo skill content"));
|
|
807
|
+
assert.equal(T.parseContextAddress(T.contextPreviewAddress(ctxRef.previewKey)), ctxRef.previewKey, "address roundtrip");
|
|
808
|
+
assert.equal(T.parseContextAddress("dsh-resource://overview-context/bogus%zz"), undefined, "bad encoding rejected");
|
|
809
|
+
assert.equal(T.readContextPreview("missing|key"), null);
|
|
810
|
+
/* no sessionId → no preview capture */
|
|
811
|
+
const anon = T.createFolder();
|
|
812
|
+
T.foldEvent(anon, ev("turn/start", { turn: 1 }, 1, 100), W);
|
|
813
|
+
T.foldEvent(anon, ev("user/message", { source: { kind: "runtime-context" }, content: [{ type: "text", text: "x" }] }, 2, 101), W);
|
|
814
|
+
const anonState = T.buildState(anon, win, 0, "all");
|
|
815
|
+
assert.equal(anonState.references[0].previewKey, undefined, "anonymous folder captures no preview");
|
|
816
|
+
/* vikingParentUri */
|
|
817
|
+
assert.equal(T.vikingParentUri("viking://user/a/b.md"), "viking://user/a/");
|
|
818
|
+
assert.equal(T.vikingParentUri("viking://user/a/"), "viking://user/");
|
|
819
|
+
assert.equal(T.vikingParentUri("viking://user"), null);
|
|
820
|
+
assert.equal(T.vikingParentUri("viking://"), null);
|
|
821
|
+
assert.equal(T.vikingParentUri("not-a-uri"), null);
|
|
822
|
+
/* a fresh turn without todo/write shows no inherited plan */
|
|
823
|
+
T.foldEvent(folder, ev("turn/start", { turn: 3 }, 20, 400), W);
|
|
824
|
+
const l3 = T.buildState(folder, win, 0, "latest");
|
|
825
|
+
assert.equal(l3.latestTurn, 3);
|
|
826
|
+
assert.equal(l3.todos, null, "new turn without todos shows no plan instead of inheriting");
|
|
827
|
+
assert.equal(l3.references.length, 0, "new turn has no refs of its own");
|
|
828
|
+
assert.equal(l3.artifacts.length, 0);
|
|
829
|
+
assert.deepEqual(T.buildState(folder, win, 0, "turn:2").todos.map((x) => x.status), ["completed"], "older turn keeps its own plan");
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/* ---- 9. ubiquitous reference detection ----------------------------------- */
|
|
833
|
+
{
|
|
834
|
+
const ev = (type, data, seq, time) => ({ type, data, seq, time });
|
|
835
|
+
const W = "/w";
|
|
836
|
+
const win = { hasMore: false, entries: [] };
|
|
837
|
+
const folder = T.createFolder();
|
|
838
|
+
folder.sessionId = "s-ubiq";
|
|
839
|
+
T.foldEvent(folder, ev("turn/start", { turn: 1 }, 1, 100), W);
|
|
840
|
+
T.foldEvent(folder, ev("user/message", { source: { kind: "runtime-context", sections: [{ name: "Policy" }] }, content: [{ type: "text", text: "shared ctx t1" }] }, 2, 101), W);
|
|
841
|
+
T.foldEvent(folder, ev("user/message", { source: { kind: "skill-catalog", entries: [{ name: "only-turn1" }] } }, 3, 102), W);
|
|
842
|
+
T.foldEvent(folder, ev("turn/start", { turn: 2 }, 4, 200), W);
|
|
843
|
+
T.foldEvent(folder, ev("user/message", { source: { kind: "runtime-context", sections: [{ name: "Policy" }] }, content: [{ type: "text", text: "shared ctx t2" }] }, 5, 201), W);
|
|
844
|
+
T.foldEvent(folder, ev("user/message", { source: { kind: "skill-catalog", entries: [{ name: "only-turn2" }] } }, 6, 202), W);
|
|
845
|
+
const all = T.buildState(folder, win, 0, "all");
|
|
846
|
+
assert.equal(all.references.length, 3, "shared ref merged, two distinct catalogs");
|
|
847
|
+
const shared = all.references.find((ref) => ref.refTurns === 2);
|
|
848
|
+
assert.ok(shared !== undefined, "shared ref tracked across both turns");
|
|
849
|
+
assert.equal(shared.ubiquitous, true, "ref present in every turn is ubiquitous");
|
|
850
|
+
assert.equal(shared.label, "runtime-context");
|
|
851
|
+
for (const ref of all.references.filter((r) => r !== shared)) {
|
|
852
|
+
assert.equal(ref.ubiquitous, false, `turn-specific ref not ubiquitous: ${ref.label}`);
|
|
853
|
+
assert.equal(ref.refTurns, 1);
|
|
854
|
+
}
|
|
855
|
+
const latest = T.buildState(folder, win, 0, "latest");
|
|
856
|
+
assert.equal(latest.references.length, 2, "latest turn sees shared + own catalog");
|
|
857
|
+
assert.equal(latest.references.filter((ref) => ref.ubiquitous).length, 1, "one common ref in the latest view");
|
|
858
|
+
/* preview snapshot follows the latest injection */
|
|
859
|
+
assert.ok(T.readContextPreview(shared.previewKey).text.includes("shared ctx t2"), "preview text updated on re-injection");
|
|
860
|
+
/* hide-common store */
|
|
861
|
+
T.setHideCommonRefs(true);
|
|
862
|
+
assert.equal(T.getHideCommonRefs(), true);
|
|
863
|
+
T.setHideCommonRefs(false);
|
|
864
|
+
assert.equal(T.getHideCommonRefs(), false);
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/* ---- 10. key-step stream + quick-nav linkage ----------------------------- */
|
|
868
|
+
{
|
|
869
|
+
const ev = (type, data, seq, time) => ({ type, data, seq, time });
|
|
870
|
+
const W = "/w";
|
|
871
|
+
const win = { hasMore: false, entries: [] };
|
|
872
|
+
/* stepDetail extraction */
|
|
873
|
+
assert.equal(T.stepDetail("read", { file_path: "/tmp/a.txt" }), "/tmp/a.txt");
|
|
874
|
+
assert.equal(T.stepDetail("bash", { command: "node x.js\n--flag" }), "node x.js --flag");
|
|
875
|
+
assert.equal(T.stepDetail("web_search", { queries: ["dsh plugin", "second"] }), "dsh plugin");
|
|
876
|
+
assert.equal(T.stepDetail("todo_write", { todos: [] }), "");
|
|
877
|
+
assert.equal(T.stepDetail("x", "not-an-object"), "");
|
|
878
|
+
assert.equal(T.stepFull("bash", { command: "node x.js\n--flag" }), "node x.js\n--flag", "full text keeps raw whitespace");
|
|
879
|
+
assert.equal(T.stepFull("y".repeat(1), { command: "z".repeat(5000) }).length, 4000, "full text capped at 4000");
|
|
880
|
+
assert.equal(T.stepFull("todo_write", { todos: [] }), "");
|
|
881
|
+
assert.ok(T.stepDetail("bash", { command: "y".repeat(200) }).endsWith("…"), "long detail truncated");
|
|
882
|
+
|
|
883
|
+
const folder = T.createFolder();
|
|
884
|
+
folder.sessionId = "s-steps";
|
|
885
|
+
T.foldEvent(folder, ev("turn/start", { turn: 1 }, 1, 100), W);
|
|
886
|
+
T.foldEvent(folder, ev("tool/call", { callId: "c1", name: "read", arguments: JSON.stringify({ file_path: "/tmp/a.txt" }) }, 2, 101), W);
|
|
887
|
+
T.foldEvent(folder, ev("tool/result", { message: { source: { callId: "c1" }, content: [{ type: "text", text: "file body" }] } }, 3, 102), W);
|
|
888
|
+
T.foldEvent(folder, ev("tool/call", { callId: "c2", name: "bash", arguments: JSON.stringify({ command: "node test.mjs" }) }, 4, 103), W);
|
|
889
|
+
T.foldEvent(folder, ev("tool/result", { message: { source: { callId: "c2" }, isError: true, content: [{ type: "text", text: "boom" }] } }, 5, 104), W);
|
|
890
|
+
T.foldEvent(folder, ev("tool/call", { callId: "c3", name: "grep", arguments: JSON.stringify({ pattern: "dsov" }) }, 6, 105), W);
|
|
891
|
+
const latest = T.buildState(folder, win, 0, "latest");
|
|
892
|
+
assert.equal(latest.todos, null, "no plan in this turn");
|
|
893
|
+
assert.equal(latest.steps.length, 3, "all three tool calls recorded");
|
|
894
|
+
assert.equal(latest.stepsAll.length, 3, "full retained window mirrors the small turn");
|
|
895
|
+
/* chronological: oldest on top, newest at the bottom */
|
|
896
|
+
assert.equal(latest.steps[0].name, "read");
|
|
897
|
+
assert.equal(latest.steps[0].done, true);
|
|
898
|
+
assert.ok(latest.steps[0].error === undefined || latest.steps[0].error === false);
|
|
899
|
+
assert.equal(latest.steps[1].name, "bash");
|
|
900
|
+
assert.equal(latest.steps[1].done, true);
|
|
901
|
+
assert.equal(latest.steps[1].error, true, "failed result flags the step");
|
|
902
|
+
assert.equal(latest.steps[2].name, "grep");
|
|
903
|
+
assert.equal(latest.steps[2].done, false, "running step stays open");
|
|
904
|
+
assert.equal(latest.steps[2].detail, "dsov");
|
|
905
|
+
/* per-turn stats bar counters */
|
|
906
|
+
assert.equal(latest.stats.durationMs, 5, "turn duration spans first to last event");
|
|
907
|
+
assert.equal(latest.stats.toolCalls, 3);
|
|
908
|
+
assert.equal(latest.stats.commands, 1, "bash counted as command");
|
|
909
|
+
assert.equal(latest.stats.errors, 1, "failed result counted");
|
|
910
|
+
assert.equal(latest.stats.files, 0);
|
|
911
|
+
assert.equal(T.formatDuration(45000), "45s");
|
|
912
|
+
assert.equal(T.formatDuration(75000), "1m 15s");
|
|
913
|
+
assert.equal(T.formatDuration(3725000), "1h 02m");
|
|
914
|
+
const all = T.buildState(folder, win, 0, "all");
|
|
915
|
+
assert.equal(all.steps.length, 3, "global step stream mirrors the turn");
|
|
916
|
+
/* a later turn with its own todos hides steps (todos win) */
|
|
917
|
+
T.foldEvent(folder, ev("turn/start", { turn: 2 }, 7, 200), W);
|
|
918
|
+
T.foldEvent(folder, ev("tool/result", { message: { source: { callId: "c3" }, content: [{ type: "text", text: "hits" }] } }, 8, 201), W);
|
|
919
|
+
T.foldEvent(folder, ev("todo/write", { todos: [{ content: "plan item", status: "pending" }] }, 9, 202), W);
|
|
920
|
+
const l2 = T.buildState(folder, win, 0, "latest");
|
|
921
|
+
assert.equal(l2.todos.length, 1, "turn 2 has its own plan");
|
|
922
|
+
assert.equal(l2.steps.length, 0, "cross-turn result does not add steps to turn 2");
|
|
923
|
+
assert.equal(l2.stats.toolCalls, 0, "turn 2 stats stay per-turn");
|
|
924
|
+
assert.equal(l2.stats.durationMs, 2, "turn 2 spans its own events");
|
|
925
|
+
const all2 = T.buildState(folder, win, 0, "all");
|
|
926
|
+
assert.equal(all2.stats.toolCalls, 3, "folder totals aggregate every turn");
|
|
927
|
+
assert.equal(all2.stats.durationMs, 102, "folder spans the whole session window");
|
|
928
|
+
const l1 = T.buildState(folder, win, 0, "turn:1");
|
|
929
|
+
assert.equal(l1.steps.length, 3, "turn 1 keeps its three steps");
|
|
930
|
+
assert.equal(l1.steps.find((s) => s.name === "grep").done, true, "late result closes the running step in turn 1");
|
|
931
|
+
|
|
932
|
+
/* nav-scope linkage store */
|
|
933
|
+
const before = T.readNavScope();
|
|
934
|
+
T.requestNavScope("turn:5");
|
|
935
|
+
const after = T.readNavScope();
|
|
936
|
+
assert.equal(after.rev, before.rev + 1, "request bumps the revision");
|
|
937
|
+
assert.equal(after.selection, "turn:5");
|
|
938
|
+
|
|
939
|
+
/* todo → composer rerun prompt template */
|
|
940
|
+
const prompt = T.rerunPrompt(3, "运行测试");
|
|
941
|
+
assert.ok(prompt.startsWith("请查看当前任务的完整 TODO 计划,重新执行以下指定步骤:"), "template head");
|
|
942
|
+
assert.ok(prompt.includes("**目标步骤:3. 运行测试**"), "numbered step interpolated");
|
|
943
|
+
assert.ok(prompt.endsWith("**请直接执行,不要只提供方案。**"), "template tail");
|
|
944
|
+
assert.equal(prompt.split("\n").length, 13, "13 template lines");
|
|
945
|
+
for (const n of [1, 2, 3, 4, 5, 6]) assert.ok(prompt.includes(`\n${n}. `), `requirement line ${n}`);
|
|
946
|
+
assert.equal(T.rerunPrompt(2, "写文档", "重跑第 {index} 步:{content}({index})"), "重跑第 2 步:写文档(2)", "custom template placeholders substituted");
|
|
947
|
+
assert.ok(T.rerunPrompt(1, "x", " ").startsWith("请查看当前任务的完整 TODO 计划"), "blank template falls back to the default");
|
|
948
|
+
assert.equal(T.teamStepKey("spawn_teammate", { name: "a", description: "d" }), "a", "spawn key is the teammate name");
|
|
949
|
+
assert.equal(T.teamStepKey("team_task_create", { subject: "s" }), "s", "task key is the subject");
|
|
950
|
+
assert.equal(T.teamStepKey("subagent_fork", { description: "d", prompt: "p" }), "d", "subagent key is the description");
|
|
951
|
+
assert.equal(T.teamStepKey("bash", { command: "ls" }), undefined, "other tools carry no team key");
|
|
952
|
+
|
|
953
|
+
/* approval asked/decided fold into a milestone step; turnEnd/todoHistory back the timeline */
|
|
954
|
+
{
|
|
955
|
+
const f = T.createFolder();
|
|
956
|
+
f.sessionId = "s";
|
|
957
|
+
T.foldEntry(f, { type: "durable", event: { type: "turn/start", seq: 1, time: 1000, data: { turn: 1 } } }, "/w");
|
|
958
|
+
T.foldEntry(f, { type: "durable", event: { type: "approval/asked", seq: 2, time: 1100, data: { id: "ap1", toolName: "plugin_manager", reason: "escalate" } } }, "/w");
|
|
959
|
+
let st = T.buildState(f, { hasMore: false, entries: [] }, 0, "latest");
|
|
960
|
+
let ap = st.stepsAll.find((s) => s.name === "approval");
|
|
961
|
+
assert.ok(ap, "approval asked folds into a step");
|
|
962
|
+
assert.equal(ap.done, false, "pending until decided");
|
|
963
|
+
assert.equal(ap.detail, "plugin_manager");
|
|
964
|
+
T.foldEntry(f, { type: "durable", event: { type: "approval/decided", seq: 3, time: 1200, data: { id: "ap1", outcome: "allowed-once" } } }, "/w");
|
|
965
|
+
st = T.buildState(f, { hasMore: false, entries: [] }, 0, "latest");
|
|
966
|
+
ap = st.stepsAll.find((s) => s.name === "approval");
|
|
967
|
+
assert.equal(ap.done, true, "decided marks the step done");
|
|
968
|
+
assert.equal(ap.detail, "plugin_manager · allowed-once", "outcome appended");
|
|
969
|
+
assert.notEqual(ap.error, true, "allowed is not an error");
|
|
970
|
+
T.foldEntry(f, { type: "durable", event: { type: "approval/asked", seq: 4, time: 1300, data: { id: "ap2", toolName: "bash", reason: "rm" } } }, "/w");
|
|
971
|
+
T.foldEntry(f, { type: "durable", event: { type: "approval/decided", seq: 5, time: 1400, data: { id: "ap2", outcome: "denied" } } }, "/w");
|
|
972
|
+
st = T.buildState(f, { hasMore: false, entries: [] }, 0, "latest");
|
|
973
|
+
const denied = st.stepsAll.find((s) => s.callId === "approval-ap2");
|
|
974
|
+
assert.equal(denied.error, true, "denied approval flags error");
|
|
975
|
+
assert.equal(st.turnEnd, 1400, "turnEnd exposed for the timeline");
|
|
976
|
+
assert.ok(Array.isArray(st.todoHistory), "todoHistory exposed for scrubbing");
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
/* viking ref naming: skills by folder name, dirs vs files distinguished */
|
|
980
|
+
assert.equal(T.vikingLabel("viking://agent/skills/xunshan-api-migration/SKILL.md"), "xunshan-api-migration", "SKILL.md reads as the skill name");
|
|
981
|
+
/* labels are composed with the runtime locale prefix, so assert on the stable parts */
|
|
982
|
+
assert.ok(T.vikingLabel("viking://user/alice/memories/entities/软件/会议助手.md").endsWith("会议助手"), "entity memory keeps its leaf name");
|
|
983
|
+
assert.ok(T.vikingLabel("viking://user/alice/memories/entities/软件/会议助手.md").includes(" · "), "entity memory is prefixed by its tree");
|
|
984
|
+
assert.ok(T.vikingLabel("viking://user/alice/peers/deepseek-workspace/memories/events/2026/10/29").endsWith("2026-10-29"), "a dated memory folder reads as its day, not its last segment");
|
|
985
|
+
assert.ok(T.vikingLabel("viking://user/alice/peers/deepseek-workspace/memories/events/2026/10/29") !== "29", "the bare day number never becomes the label");
|
|
986
|
+
assert.equal(
|
|
987
|
+
T.vikingLabel("viking://user/alice/memories/events/2026/10/29/四项前端优化回归验证通过.md"),
|
|
988
|
+
`四项前端优化回归验证通过 · 2026-10-29`,
|
|
989
|
+
"a dated memory file reads as its title plus the day"
|
|
990
|
+
);
|
|
991
|
+
assert.ok(T.vikingLabel("viking://user/alice/memories/preferences/pnpm脚本运行方式.md").endsWith("pnpm脚本运行方式"), "preferences keep their own name");
|
|
992
|
+
assert.equal(T.vikingRefKind("viking://agent/skills/foo/SKILL.md"), "skill", "SKILL.md folds as a skill ref");
|
|
993
|
+
assert.equal(T.vikingRefKind("viking://agent/skills/foo"), "skill", "skill folder folds as a skill ref");
|
|
994
|
+
assert.equal(T.vikingRefKind("viking://user/alice/memories/x.md"), "memory-file", "memory paths stay memory-file");
|
|
995
|
+
assert.equal(T.vikingIsDir("viking://resources/projects/demo/"), true, "trailing slash is a dir");
|
|
996
|
+
assert.equal(T.vikingIsDir("viking://resources/projects/demo"), true, "extensionless leaf is a dir");
|
|
997
|
+
assert.equal(T.vikingIsDir("viking://resources/projects/demo.md"), false, "dotted leaf is a file");
|
|
998
|
+
|
|
999
|
+
/* compact-checkpoint folds into an anchored key step, not a context reference */
|
|
1000
|
+
{
|
|
1001
|
+
const f = T.createFolder();
|
|
1002
|
+
f.sessionId = "s";
|
|
1003
|
+
T.foldEntry(f, { type: "durable", event: { type: "turn/start", seq: 1, time: 1000, data: { turn: 1 } } }, "/w");
|
|
1004
|
+
T.foldEntry(
|
|
1005
|
+
f,
|
|
1006
|
+
{
|
|
1007
|
+
type: "durable",
|
|
1008
|
+
event: {
|
|
1009
|
+
type: "user/message",
|
|
1010
|
+
seq: 2,
|
|
1011
|
+
time: 2000,
|
|
1012
|
+
data: { source: { kind: "compact-checkpoint", compactionId: "abc" }, content: [{ type: "text", text: "x".repeat(4096) }] },
|
|
1013
|
+
surfaceOp: { op: "replace", startSeq: 1, endSeq: 1 },
|
|
1014
|
+
},
|
|
1015
|
+
},
|
|
1016
|
+
"/w"
|
|
1017
|
+
);
|
|
1018
|
+
let st = T.buildState(f, { hasMore: false, entries: [] }, 0, "latest");
|
|
1019
|
+
const compact = st.stepsAll.find((step) => step.name === "compact-checkpoint");
|
|
1020
|
+
assert.ok(compact, "compaction folded as a step");
|
|
1021
|
+
assert.equal(compact.teamKey, "compact:abc", "anchored through a unique teamKey");
|
|
1022
|
+
assert.equal(compact.done, true, "compaction step is instantly complete");
|
|
1023
|
+
assert.equal(compact.detail, "≈4 KB", "detail reports the summary size");
|
|
1024
|
+
/* push the compaction out of the recent window: without pinning it scrolls away */
|
|
1025
|
+
for (let i = 0; i < 7; i += 1) {
|
|
1026
|
+
T.foldEntry(f, { type: "durable", event: { type: "tool/call", seq: 10 + i * 2, time: 3000 + i, data: { callId: `x${i}`, name: "read", arguments: "{}" } } }, "/w");
|
|
1027
|
+
T.foldEntry(f, { type: "durable", event: { type: "tool/result", seq: 11 + i * 2, time: 3001 + i, data: { message: { source: { callId: `x${i}` }, content: [] } } } }, "/w");
|
|
1028
|
+
}
|
|
1029
|
+
st = T.buildState(f, { hasMore: false, entries: [] }, 0, "latest");
|
|
1030
|
+
assert.equal(st.steps.some((step) => step.name === "compact-checkpoint"), false, "milestone steps do not pin by default");
|
|
1031
|
+
assert.ok(st.milestones.some((step) => step.name === "compact-checkpoint"), "milestone lane retains the compaction for optional pinning");
|
|
1032
|
+
assert.equal(st.references.length, 0, "no duplicate context reference for the checkpoint");
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
/* planTodoAttachments: delegates nest under the todo they textually match */
|
|
1036
|
+
{
|
|
1037
|
+
const todos = [
|
|
1038
|
+
{ content: "Audit sanitization coverage", status: "in_progress" },
|
|
1039
|
+
{ content: "Write docs", status: "pending" },
|
|
1040
|
+
{ content: "Ship release", status: "pending" },
|
|
1041
|
+
];
|
|
1042
|
+
const members = [{ id: "m1", name: "sanitization-auditor", description: "", phase: "active", time: 5 }];
|
|
1043
|
+
const tasks = [
|
|
1044
|
+
{ id: "t1", subject: "Audit sanitization coverage", status: "in_progress", ownerId: "m1", time: 6 },
|
|
1045
|
+
{ id: "t2", subject: "Unrelated chore", status: "pending", time: 7 },
|
|
1046
|
+
];
|
|
1047
|
+
const subs = [{ childId: "s1", label: "write docs helper", status: "", mode: "", time: 8 }];
|
|
1048
|
+
const plan = T.planTodoAttachments(todos, members, tasks, subs);
|
|
1049
|
+
assert.equal(plan.size, 2, "two todos gathered a delegate; the unmatched task stays flat");
|
|
1050
|
+
const first = plan.get(0);
|
|
1051
|
+
assert.equal(first.length, 1);
|
|
1052
|
+
assert.equal(first[0].kind, "member", "member matched through its owned task subject");
|
|
1053
|
+
assert.equal(first[0].owned.length, 1, "the owned task travels with the member");
|
|
1054
|
+
assert.equal(first[0].owned[0].id, "t1");
|
|
1055
|
+
const second = plan.get(1);
|
|
1056
|
+
assert.equal(second.length, 1);
|
|
1057
|
+
assert.equal(second[0].kind, "sub", "subagent matched by label containment");
|
|
1058
|
+
assert.equal(second[0].record.childId, "s1");
|
|
1059
|
+
assert.equal(plan.has(2), false, "todo without a delegate gets no attachment");
|
|
1060
|
+
assert.deepEqual([...T.planTodoAttachments([], members, tasks, subs).keys()], [], "no todos, no attachments");
|
|
1061
|
+
}
|
|
1062
|
+
assert.equal(T.insertIntoComposer("x"), "failed", "without composer or clipboard the helper reports failure");
|
|
1063
|
+
|
|
1064
|
+
/* reference type filter store + display grouping */
|
|
1065
|
+
assert.deepEqual(T.getRefTypes(), [], "type filter starts empty (no stored value in Node)");
|
|
1066
|
+
T.setRefTypes(["skill", "memory-file", "skill"]);
|
|
1067
|
+
assert.deepEqual(T.getRefTypes(), ["memory-file", "skill"], "deduped and sorted");
|
|
1068
|
+
T.setRefTypes([]);
|
|
1069
|
+
assert.deepEqual(T.getRefTypes(), [], "cleared");
|
|
1070
|
+
assert.equal(T.refDisplayType({ kind: "memory-file" }), "memory", "memory-file groups with memory");
|
|
1071
|
+
assert.equal(T.refDisplayType({ kind: "skill" }), "skill");
|
|
1072
|
+
assert.equal(T.refDisplayType(null), "");
|
|
1073
|
+
|
|
1074
|
+
/* URI-guard notices are meta text: no refs extracted from them */
|
|
1075
|
+
const guardText = '[OpenViking memory plugin] URI guard: this shell command contains the viking:// URI viking://user/c/plan.md。还有.\nExample: mcp__openviking__read(uris="viking://user/c/plan.md。还有")';
|
|
1076
|
+
assert.deepEqual(T.contextDetail({ kind: "plugin:openviking-memory" }, [{ type: "text", text: guardText }]), [], "guard message yields no references");
|
|
1077
|
+
assert.deepEqual(T.extractVikingUris('Example: read(uris="viking://user/c/plan.md。还有")'), ["viking://user/c/plan.md"], "quoted capture trims at CJK punctuation");
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
/* scenario 11: agent-team folding (members, shared board tasks, subagents) */
|
|
1081
|
+
{
|
|
1082
|
+
const ev = (type, data, seq, time) => ({ type, data, seq, time });
|
|
1083
|
+
const W = "/w";
|
|
1084
|
+
const win = { hasMore: false, entries: [] };
|
|
1085
|
+
const folder = T.createFolder();
|
|
1086
|
+
folder.sessionId = "s-team";
|
|
1087
|
+
T.foldEvent(folder, ev("turn/start", { turn: 1 }, 1, 100), W);
|
|
1088
|
+
T.foldEvent(folder, ev("tool/call", { callId: "c-spawn", name: "spawn_teammate", arguments: JSON.stringify({ name: "verifier", description: "验证者", prompt: "p" }) }, 9, 99), W);
|
|
1089
|
+
T.foldEvent(folder, ev("team/task", { teamId: "t", task: { id: "task-1", revision: 1, subject: "验证", description: "desc", status: "pending", blockedBy: [], writeScopes: ["a.mjs"] } }, 2, 101), W);
|
|
1090
|
+
T.foldEvent(folder, ev("team/member", { teamId: "t", member: { id: "m1", name: "verifier", description: "验证者", provider: "spawn", context: "fresh", phase: "provisioning" } }, 3, 102), W);
|
|
1091
|
+
T.foldEvent(folder, ev("team/member", { teamId: "t", member: { id: "m1", name: "verifier", description: "验证者", provider: "spawn", context: "fresh", phase: "active" } }, 4, 103), W);
|
|
1092
|
+
T.foldEvent(folder, ev("team/task", { teamId: "t", task: { id: "task-1", revision: 2, subject: "验证", description: "desc", status: "in_progress", ownerId: "m1", blockedBy: [], writeScopes: ["a.mjs"] } }, 5, 104), W);
|
|
1093
|
+
T.foldEvent(folder, ev("subagent/catalog", { childId: "m1", label: "验证者", mode: "continuable" }, 6, 105), W);
|
|
1094
|
+
T.foldEvent(folder, ev("subagent/catalog", { childId: "x9", label: "研究子代理", mode: "continuable" }, 7, 106), W);
|
|
1095
|
+
const latest = T.buildState(folder, win, 0, "latest");
|
|
1096
|
+
assert.equal(latest.team.members.length, 1, "member upserted once");
|
|
1097
|
+
const spawnStep = latest.steps.find((step) => step.name === "spawn_teammate");
|
|
1098
|
+
assert.ok(spawnStep !== undefined, "spawn step recorded");
|
|
1099
|
+
assert.equal(spawnStep.teamKey, "verifier", "spawn step carries the teammate name for nesting");
|
|
1100
|
+
/* anchor pinning: later filler calls must not evict the spawn anchor from the view */
|
|
1101
|
+
for (let i = 0; i < 7; i += 1) {
|
|
1102
|
+
T.foldEvent(folder, ev("tool/call", { callId: `c-fill-${i}`, name: "bash", arguments: JSON.stringify({ command: `echo ${i}` }) }, 20 + i, 200 + i), W);
|
|
1103
|
+
T.foldEvent(folder, ev("tool/result", { callId: `c-fill-${i}`, result: "ok", isError: false }, 40 + i, 201 + i), W);
|
|
1104
|
+
}
|
|
1105
|
+
const pinned = T.buildState(folder, win, 0, "latest");
|
|
1106
|
+
assert.equal(pinned.steps.length, 7, "six recent steps plus the pinned spawn anchor");
|
|
1107
|
+
assert.equal(pinned.steps[0].name, "spawn_teammate", "anchor stays first, chronologically");
|
|
1108
|
+
assert.equal(pinned.steps[0].teamKey, "verifier");
|
|
1109
|
+
assert.equal(pinned.stepsAll.length, 8, "full window keeps spawn plus all seven fillers under the raised cap");
|
|
1110
|
+
assert.equal(pinned.stepsAll[0].name, "spawn_teammate", "window order stays chronological");
|
|
1111
|
+
assert.equal(latest.team.members[0].phase, "active", "latest phase wins");
|
|
1112
|
+
assert.equal(latest.team.members[0].firstTime, 102, "first observation time kept");
|
|
1113
|
+
assert.equal(latest.team.tasks.length, 1);
|
|
1114
|
+
assert.equal(latest.team.tasks[0].status, "in_progress", "latest revision wins");
|
|
1115
|
+
assert.equal(latest.team.tasks[0].ownerId, "m1");
|
|
1116
|
+
assert.equal(latest.team.tasks[0].revision, 2);
|
|
1117
|
+
assert.deepEqual(latest.team.subagents.map((sub) => sub.childId), ["x9"], "team members are filtered out of the subagent list");
|
|
1118
|
+
/* turn isolation: turn 2 has no team activity */
|
|
1119
|
+
T.foldEvent(folder, ev("turn/start", { turn: 2 }, 8, 200), W);
|
|
1120
|
+
const l2 = T.buildState(folder, win, 0, "latest");
|
|
1121
|
+
assert.deepEqual(l2.team, { members: [], tasks: [], subagents: [] }, "turn 2 sees no team records");
|
|
1122
|
+
const all = T.buildState(folder, win, 0, "all");
|
|
1123
|
+
assert.equal(all.team.tasks.length, 1, "folder level keeps the task");
|
|
1124
|
+
const empty = T.buildState(T.createFolder(), win, 0, "latest");
|
|
1125
|
+
assert.deepEqual(empty.team, { members: [], tasks: [], subagents: [] }, "empty state team shape");
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
|
|
1129
|
+
/* ---- 11. 「新标签打开」落到宿主自己的 Markdown 文档页 --------------------- */
|
|
1130
|
+
{
|
|
1131
|
+
/* the plan banner's 「打开」 reveals the plan plugin's preview page; the same
|
|
1132
|
+
plugin also serves caller-supplied Markdown under a temporary address, so our
|
|
1133
|
+
documents open on the same page instead of a lookalike. */
|
|
1134
|
+
const calls = [];
|
|
1135
|
+
const sidebar = { openResource: (address, options) => calls.push({ address, options }) };
|
|
1136
|
+
const runtimeOf = (right) => ({ t: (key) => key, ctx: { sidebarRight: right, sessions: { subagentAddress: () => undefined } } });
|
|
1137
|
+
const sessionId = "s-doc";
|
|
1138
|
+
const text = "## 本轮结论\n\n这段完整答复应当作为文档由宿主的文档页呈现。";
|
|
1139
|
+
const previewKey = T.rememberContextPreview(sessionId, "conclusion|7", "第 7 轮结论", text, "conclusion");
|
|
1140
|
+
const artifact = { kind: "conclusion", title: "第 7 轮结论", turn: 7, previewKey, path: "conclusion:7" };
|
|
1141
|
+
|
|
1142
|
+
assert.equal(T.documentAddress(sessionId, "conclusion:7"), "dsh-resource://plan-review/s-doc/conclusion%3A7", "document address");
|
|
1143
|
+
assert.ok(/^dsh-resource:\/\/plan-review\/[^/?#]+\/[^/?#]+$/.test(T.documentAddress(sessionId, "conclusion:7")), "plan plugin accepts the address shape");
|
|
1144
|
+
assert.equal(T.documentAddress("", "x"), undefined, "empty session makes no address");
|
|
1145
|
+
assert.equal(T.documentAddress(sessionId, ""), undefined, "empty key makes no address");
|
|
1146
|
+
assert.equal(T.openNativeDocument(sessionId, "conclusion:7", "t", ""), false, "empty markdown never opens");
|
|
1147
|
+
|
|
1148
|
+
T.__setRuntime(runtimeOf(sidebar));
|
|
1149
|
+
T.openArtifact({ sessionId }, artifact, null);
|
|
1150
|
+
assert.equal(calls.length, 1, "one page per click");
|
|
1151
|
+
/* the plugin's own tab is restorable (its body is mirrored to localStorage);
|
|
1152
|
+
the host plan-review page keeps its payload in tab params only, and a reload
|
|
1153
|
+
would leave the expired 「计划」 page behind — so it is the fallback now. */
|
|
1154
|
+
assert.ok(calls[0].address.startsWith("dsh-resource://overview-context/"), `restorable preview tab: ${calls[0].address}`);
|
|
1155
|
+
assert.equal(calls[0].options, undefined, "no ephemeral payload needed: the body resolves from the store");
|
|
1156
|
+
|
|
1157
|
+
/* sidebar that cannot open tabs at all: the host document page is the last resort */
|
|
1158
|
+
calls.length = 0;
|
|
1159
|
+
const throwing = {
|
|
1160
|
+
openResource: (address, options) => {
|
|
1161
|
+
calls.push({ address, options });
|
|
1162
|
+
if (address.startsWith("dsh-resource://overview-context/")) throw new Error("sidebarRight: no registered tab type claims it");
|
|
1163
|
+
},
|
|
1164
|
+
};
|
|
1165
|
+
T.__setRuntime(runtimeOf(throwing));
|
|
1166
|
+
T.openArtifact({ sessionId }, artifact, null);
|
|
1167
|
+
assert.equal(calls.length, 2, "plugin tab attempt then the host document page");
|
|
1168
|
+
assert.ok(calls[1].address.startsWith("dsh-resource://plan-review/"), `document fallback: ${calls[1].address}`);
|
|
1169
|
+
assert.equal(calls[1].options.params.planReview.markdown, text, "fallback still hands the markdown over");
|
|
1170
|
+
|
|
1171
|
+
/* plan artifacts: the banner's own address first, the document page next */
|
|
1172
|
+
calls.length = 0;
|
|
1173
|
+
T.__setRuntime(runtimeOf(sidebar));
|
|
1174
|
+
const planPreview = T.rememberContextPreview(sessionId, "plan|9", "P", "# P", "plan");
|
|
1175
|
+
T.openArtifact({ sessionId }, { plan: true, planCallId: "call-9", planTitle: "P", previewKey: planPreview }, null);
|
|
1176
|
+
assert.equal(calls.length, 1, "plan opens one page");
|
|
1177
|
+
assert.equal(calls[0].address, "dsh-resource://plan/s-doc/call-9", "plan page is the banner address");
|
|
1178
|
+
|
|
1179
|
+
/* the fallback page copies the host Markdown document page's geometry */
|
|
1180
|
+
assert.ok(clientSource.includes(".dsov-docPage{box-sizing:border-box;height:100%;padding:20px 24px 40px"), "document page padding 20/24/40");
|
|
1181
|
+
assert.ok(clientSource.includes(".dsov-docDocument{min-width:0;color:var(--dsw-alias-label-primary);overflow-wrap:anywhere;font-size:14px;line-height:1.75"), "document body type");
|
|
1182
|
+
assert.ok(!clientSource.includes(".dsov-docPanel{"), "old panel shell gone");
|
|
1183
|
+
assert.ok(clientSource.includes('"sidebar.right.pane.tab.title"') && clientSource.includes("ContextTabTitle"), "tab chip carries the file glyph");
|
|
1184
|
+
T.__setRuntime(null);
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
|
|
1188
|
+
/* ---- 12. 「本轮结论」只在本轮结束后出现 -------------------------------- */
|
|
1189
|
+
{
|
|
1190
|
+
const ev = (type, data, seq, time) => ({ type, data, seq, time });
|
|
1191
|
+
const W = "/w";
|
|
1192
|
+
const win = { hasMore: false, entries: [] };
|
|
1193
|
+
const folder = T.createFolder();
|
|
1194
|
+
folder.sessionId = "s-timing";
|
|
1195
|
+
const fold = (entry) => T.foldEvent(folder, entry, W);
|
|
1196
|
+
const MID = "第一段中间答复:这一轮还没结束,不应该出现在产物里的结论文档。";
|
|
1197
|
+
const FINAL = "本轮收尾答复:这才是完成后应该作为结论展示的内容。";
|
|
1198
|
+
fold(ev("turn/start", { turn: 1 }, 1, 100));
|
|
1199
|
+
fold(ev("assistant/message", { message: { content: [{ type: "text", text: MID }] } }, 2, 110));
|
|
1200
|
+
const mid = T.buildState(folder, win, 0, "latest");
|
|
1201
|
+
assert.equal(mid.conclusion, null, "running turn publishes no conclusion");
|
|
1202
|
+
assert.equal(mid.artifacts.length, 0, "no conclusion row while the turn runs");
|
|
1203
|
+
assert.equal(T.buildState(folder, win, 0, "all").conclusion, null, "all scope also waits for the turn to end");
|
|
1204
|
+
fold(ev("tool/call", { callId: "c1", name: "bash", arguments: "{}" }, 3, 120));
|
|
1205
|
+
fold(ev("tool/result", { message: { source: { callId: "c1" }, content: [{ type: "text", text: "ok" }] } }, 4, 130));
|
|
1206
|
+
assert.equal(T.buildState(folder, win, 0, "latest").conclusion, null, "tool work mid-turn publishes nothing");
|
|
1207
|
+
fold(ev("assistant/message", { message: { content: [{ type: "text", text: FINAL }] } }, 5, 140));
|
|
1208
|
+
assert.equal(T.buildState(folder, win, 0, "latest").conclusion, null, "the closing reply alone is not enough");
|
|
1209
|
+
fold(ev("turn/end", { turn: 1 }, 6, 150));
|
|
1210
|
+
const done = T.buildState(folder, win, 0, "latest");
|
|
1211
|
+
assert.ok(done.conclusion !== null, "turn/end publishes the conclusion");
|
|
1212
|
+
assert.equal(T.readContextPreview(done.conclusion.previewKey).text, FINAL, "the closing reply becomes the conclusion, not an earlier one");
|
|
1213
|
+
assert.equal(done.conclusion.ended, true);
|
|
1214
|
+
assert.equal(done.artifacts[0].kind, "conclusion", "conclusion leads the artifacts");
|
|
1215
|
+
assert.equal(T.buildState(folder, win, 0, "all").conclusion.turn, 1, "all scope keeps the completed turn");
|
|
1216
|
+
/* a later running turn must not overwrite the last completed conclusion */
|
|
1217
|
+
fold(ev("turn/start", { turn: 2 }, 7, 200));
|
|
1218
|
+
fold(ev("assistant/message", { message: { content: [{ type: "text", text: "第二轮刚开始的中间答复,不该顶掉第一轮的结论。" }] } }, 8, 210));
|
|
1219
|
+
assert.equal(T.buildState(folder, win, 0, "latest").conclusion, null, "turn 2 has no conclusion yet");
|
|
1220
|
+
assert.equal(T.buildState(folder, win, 0, "all").conclusion.turn, 1, "all scope keeps the last completed conclusion");
|
|
1221
|
+
/* a short closing line is still the conclusion when nothing followed it */
|
|
1222
|
+
fold(ev("tool/call", { callId: "c2", name: "bash", arguments: "{}" }, 9, 220));
|
|
1223
|
+
fold(ev("tool/result", { message: { source: { callId: "c2" }, content: [{ type: "text", text: "ok" }] } }, 10, 230));
|
|
1224
|
+
fold(ev("assistant/message", { message: { content: [{ type: "text", text: "完成。" }] } }, 11, 240));
|
|
1225
|
+
assert.equal(T.buildState(folder, win, 0, "latest").conclusion, null, "still waiting for turn/end");
|
|
1226
|
+
fold(ev("turn/end", { turn: 2 }, 12, 250));
|
|
1227
|
+
const shortDone = T.buildState(folder, win, 0, "latest").conclusion;
|
|
1228
|
+
assert.equal(T.readContextPreview(shortDone.previewKey).text, "完成。", "a short closing line still becomes the conclusion");
|
|
1229
|
+
/* a turn that never emits turn/end never publishes anything */
|
|
1230
|
+
fold(ev("turn/start", { turn: 3 }, 13, 300));
|
|
1231
|
+
fold(ev("assistant/message", { message: { content: [{ type: "text", text: "第三轮还在跑,没有 turn/end 就不该有结论。" }] } }, 14, 310));
|
|
1232
|
+
assert.equal(T.buildState(folder, win, 0, "latest").conclusion, null, "no turn/end, no conclusion");
|
|
1233
|
+
assert.equal(T.buildState(folder, win, 0, "all").conclusion.turn, 2, "an unfinished turn never overrides the completed one");
|
|
1234
|
+
/* a provisional short line (tool work came after it) is not turned into a document */
|
|
1235
|
+
const folder2 = T.createFolder();
|
|
1236
|
+
folder2.sessionId = "s-timing2";
|
|
1237
|
+
const fold2 = (entry) => T.foldEvent(folder2, entry, W);
|
|
1238
|
+
fold2(ev("turn/start", { turn: 1 }, 1, 100));
|
|
1239
|
+
fold2(ev("assistant/message", { message: { content: [{ type: "text", text: "先跑一下测试。" }] } }, 2, 110));
|
|
1240
|
+
fold2(ev("tool/call", { callId: "c9", name: "bash", arguments: "{}" }, 3, 120));
|
|
1241
|
+
fold2(ev("tool/result", { message: { source: { callId: "c9" }, content: [{ type: "text", text: "ok" }] } }, 4, 130));
|
|
1242
|
+
fold2(ev("turn/end", { turn: 1 }, 5, 140));
|
|
1243
|
+
assert.equal(T.buildState(folder2, win, 0, "latest").conclusion, null, "a status line followed by tool work is not a document");
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
/* --- turn-conclusion tab: only a live-observed finish may auto-open --- */
|
|
1247
|
+
{
|
|
1248
|
+
const guard = { ready: false, opened: new Set(), sawRunning: false };
|
|
1249
|
+
const ended = (turn) => ({ turn, ended: true, previewKey: `k${turn}`, title: `T${turn}` });
|
|
1250
|
+
/* first paint already carries the latest conclusion (small session, single flush) */
|
|
1251
|
+
assert.equal(T.conclusionAutoOpenKey(guard, ended(3), "s1", false), null, "conclusion present at first paint never opens");
|
|
1252
|
+
/* refresh with a late flush: first paint has no conclusion, it arrives afterwards */
|
|
1253
|
+
const refresh = { ready: false, opened: new Set(), sawRunning: false };
|
|
1254
|
+
assert.equal(T.conclusionAutoOpenKey(refresh, null, "s1", false), null, "first paint without a conclusion marks ready");
|
|
1255
|
+
assert.equal(T.conclusionAutoOpenKey(refresh, ended(4), "s1", false), null, "replayed conclusion after the first paint never opens");
|
|
1256
|
+
assert.equal(T.conclusionAutoOpenKey(refresh, ended(4), "s1", true), null, "the same replay stays closed even if the session runs later");
|
|
1257
|
+
/* live: mounted idle on turn 4, a new turn 5 starts and finishes */
|
|
1258
|
+
const live = { ready: false, opened: new Set(), sawRunning: false };
|
|
1259
|
+
assert.equal(T.conclusionAutoOpenKey(live, null, "s1", false), null, "idle first paint");
|
|
1260
|
+
assert.equal(T.conclusionAutoOpenKey(live, ended(5), "s1", true), "s1|5", "a watched finish opens its conclusion once");
|
|
1261
|
+
assert.equal(T.conclusionAutoOpenKey(live, ended(5), "s1", true), null, "the same turn never opens twice");
|
|
1262
|
+
/* a wrap-up message without turn/end is not a conclusion record at all */
|
|
1263
|
+
assert.equal(T.conclusionAutoOpenKey({ ready: true, opened: new Set(), sawRunning: true }, null, "s1", true), null, "no record, no tab");
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
/* --- subagent surfaces only for live subagent work or real subagent calls --- */
|
|
1267
|
+
{
|
|
1268
|
+
/* the reported bug: a compaction milestone carries a teamKey, which used to be
|
|
1269
|
+
read as "a subagent took part" in single-agent conversations */
|
|
1270
|
+
const compactOnly = { team: { members: [], tasks: [], subagents: [] }, stepsAll: [{ name: "compact", teamKey: "compact:1", milestone: true }] };
|
|
1271
|
+
assert.equal(T.subagentWorkInScope(compactOnly), false, "a compaction milestone alone is not subagent work");
|
|
1272
|
+
assert.equal(T.subagentWorkInScope({ team: { members: [], tasks: [], subagents: [{ childId: "c1", label: "cordis" }] }, stepsAll: [] }), true, "a subagent-catalog record keeps the identity row");
|
|
1273
|
+
assert.equal(T.subagentWorkInScope({ team: { members: [], subagents: [] }, stepsAll: [{ name: "bash" }] }), false, "plain tool work is not subagent work");
|
|
1274
|
+
assert.equal(T.subagentWorkInScope({ team: { members: [], subagents: [] }, stepsAll: [{ name: "subagent_fork" }] }), true, "a subagent call in scope counts");
|
|
1275
|
+
assert.equal(T.subagentWorkInScope({ team: { members: [], subagents: [] }, stepsAll: [{ name: "team_task_create" }] }), true, "a team task call counts");
|
|
1276
|
+
assert.equal(T.subagentWorkInScope({ team: { members: [{ id: "m1" }], subagents: [] }, stepsAll: [] }), true, "agent-team members count");
|
|
1277
|
+
assert.equal(T.subagentWorkInScope(null), false, "no summary, no work");
|
|
1278
|
+
}
|
|
1279
|
+
{
|
|
1280
|
+
|
|
1281
|
+
}
|
|
1282
|
+
/* --- document tabs survive a reload (conclusion / plan previews) --- */
|
|
1283
|
+
{
|
|
1284
|
+
const key = "s-doc-restore|conclusion|9";
|
|
1285
|
+
T.persistDocPreview(key, { title: "第 9 轮结论", text: "# 结论\n\n正文", kind: "conclusion" });
|
|
1286
|
+
assert.equal(T.readDocPreviewStore().some((item) => item[0] === key), true, "document snapshot written to localStorage");
|
|
1287
|
+
/* a fresh page has an empty in-memory map: reading the tab body must revive it */
|
|
1288
|
+
const revived = T.readContextPreview(key);
|
|
1289
|
+
assert.ok(revived !== null && revived.text.includes("正文") && revived.title === "第 9 轮结论", "restored tab rehydrates from localStorage");
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
/* --- session role: forks are not subagents --- */
|
|
1293
|
+
{
|
|
1294
|
+
const mk = (byId, ids, address, catalog) => ({
|
|
1295
|
+
t: (key) => key,
|
|
1296
|
+
ctx: {
|
|
1297
|
+
sessions: {
|
|
1298
|
+
list: { getSnapshot: () => ({ ids, byId, phase: "ready", projectionsBySession: catalog === undefined ? {} : { "s-main": { values: { subagentCatalog: catalog } } } }) },
|
|
1299
|
+
subagentAddress: (id) => (typeof address === "function" ? address(id) : address),
|
|
1300
|
+
},
|
|
1301
|
+
},
|
|
1302
|
+
});
|
|
1303
|
+
/* a branched/forked conversation: parentSessionId set, origin undefined — the host
|
|
1304
|
+
still returns a subagent address for it, which used to show a 「子代理」 badge */
|
|
1305
|
+
const forked = mk(
|
|
1306
|
+
{ "s-branch": { sessionId: "s-branch", parentSessionId: "s-main", origin: undefined, title: "分叉会话" }, "s-main": { sessionId: "s-main", title: "主会话" } },
|
|
1307
|
+
["s-main", "s-branch"],
|
|
1308
|
+
{ parentSessionId: "s-main", childSessionId: "s-branch", mode: "fork" }
|
|
1309
|
+
);
|
|
1310
|
+
T.__setRuntime(forked);
|
|
1311
|
+
assert.equal(T.sessionRoleOf("s-branch").kind, "main", "a fork is a main session, not a subagent");
|
|
1312
|
+
/* a real subagent session */
|
|
1313
|
+
const sub = mk(
|
|
1314
|
+
{ "s-sub": { sessionId: "s-sub", parentSessionId: "s-main", origin: "subagent", title: "子任务" }, "s-main": { sessionId: "s-main", title: "主会话" } },
|
|
1315
|
+
["s-main", "s-sub"],
|
|
1316
|
+
{ parentSessionId: "s-main", childSessionId: "s-sub", mode: "fork" }
|
|
1317
|
+
);
|
|
1318
|
+
T.__setRuntime(sub);
|
|
1319
|
+
const role = T.sessionRoleOf("s-sub");
|
|
1320
|
+
assert.equal(role.kind, "sub", "an origin=subagent session is a subagent");
|
|
1321
|
+
assert.equal(role.parentId, "s-main", "subagent keeps its parent link");
|
|
1322
|
+
assert.equal(role.parentTitle, "主会话", "parent title comes from the list row title");
|
|
1323
|
+
/* children: only origin=subagent rows resolve as children (not forks), catalog still counts */
|
|
1324
|
+
const parent = mk(
|
|
1325
|
+
{
|
|
1326
|
+
"s-main": { sessionId: "s-main", title: "主会话" },
|
|
1327
|
+
"s-sub": { sessionId: "s-sub", parentSessionId: "s-main", origin: "subagent", title: "子任务" },
|
|
1328
|
+
"s-branch": { sessionId: "s-branch", parentSessionId: "s-main", origin: undefined, title: "分叉" },
|
|
1329
|
+
},
|
|
1330
|
+
["s-main", "s-sub", "s-branch"],
|
|
1331
|
+
undefined,
|
|
1332
|
+
[{ id: "s-cat", title: "catalog 子代理", mode: "fork" }]
|
|
1333
|
+
);
|
|
1334
|
+
T.__setRuntime(parent);
|
|
1335
|
+
const childIds = T.sessionRoleOf("s-main").children.map((child) => child.id).sort();
|
|
1336
|
+
assert.deepEqual(childIds, ["s-cat", "s-sub"], "forked sessions are not children; catalog entries are");
|
|
1337
|
+
/* live store rows use parentId + displayTitle (not parentSessionId/title) */
|
|
1338
|
+
const live = mk(
|
|
1339
|
+
{
|
|
1340
|
+
"s-main": { id: "s-main", displayTitle: "主会话", running: false },
|
|
1341
|
+
"s-sub2": { id: "s-sub2", displayTitle: "子任务二", parentId: "s-main", origin: "subagent", running: true },
|
|
1342
|
+
"s-fork2": { id: "s-fork2", displayTitle: "分叉二", parentId: "s-main", running: false },
|
|
1343
|
+
},
|
|
1344
|
+
["s-main", "s-sub2", "s-fork2"],
|
|
1345
|
+
() => undefined
|
|
1346
|
+
);
|
|
1347
|
+
T.__setRuntime(live);
|
|
1348
|
+
const liveRole = T.sessionRoleOf("s-sub2");
|
|
1349
|
+
assert.equal(liveRole.kind, "sub", "store-row subagent (parentId + origin) classifies as subagent");
|
|
1350
|
+
assert.equal(liveRole.parentTitle, "主会话", "parent displayTitle resolves");
|
|
1351
|
+
assert.deepEqual(T.sessionRoleOf("s-main").children.map((child) => child.id), ["s-sub2"], "fork row is not a child; subagent row is");
|
|
1352
|
+
assert.equal(T.sessionRoleOf("s-fork2").kind, "main", "fork row without origin stays main");
|
|
1353
|
+
T.__setRuntime({ t: (key) => key, ctx: { sessions: { list: { getSnapshot: () => null }, subagentAddress: () => undefined } } });
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
console.log(`\nchecked ${logs.length} session log(s):`);
|
|
1357
|
+
for (const log of logs) console.log(` ${log}`);
|
|
1358
|
+
console.log("\nALL TESTS PASSED");
|