dsh-conversation-overview 1.4.2 → 1.5.0
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 +198 -0
- package/README.md +9 -2
- package/client.js +662 -99
- package/package.json +1 -1
package/.scratch/test.mjs
CHANGED
|
@@ -1355,4 +1355,202 @@ assert.ok(T.vikingLabel("viking://user/alice/memories/preferences/pnpm脚本运
|
|
|
1355
1355
|
|
|
1356
1356
|
console.log(`\nchecked ${logs.length} session log(s):`);
|
|
1357
1357
|
for (const log of logs) console.log(` ${log}`);
|
|
1358
|
+
|
|
1359
|
+
/* ---- 13. 子代理位置 / 步骤窗口 / 子代理概览导航 / 结论页渲染一致性 ---------- */
|
|
1360
|
+
{
|
|
1361
|
+
/* A) 团队委派挂到「委派」这条 todo 下:IDF 词重叠 + 当时进行中的 todo 偏好 */
|
|
1362
|
+
const todos = [
|
|
1363
|
+
{ content: "侦察项目与 Litho(deepwiki-rs) 运行方式、校验 LLM 通道", status: "completed" },
|
|
1364
|
+
{ content: "建立 Litho staging 副本与 litho.toml(含多端聚合知识注入)", status: "in_progress" },
|
|
1365
|
+
{ content: "委派两路代码分析(直播域 / 平台与边界层)产出知识底稿", status: "in_progress" },
|
|
1366
|
+
{ content: "编写多端聚合知识文档并注入 Litho", status: "pending" },
|
|
1367
|
+
{ content: "启动 Litho 生成文档(4175s,7 域 + 48 图)", status: "pending" },
|
|
1368
|
+
{ content: "编写 6、多端聚合与集成口子.md + 附录A + 00-README", status: "pending" },
|
|
1369
|
+
{ content: "质量校验:Mermaid 语法、清单链接、事实校对与编者补注", status: "pending" },
|
|
1370
|
+
{ content: "交付:工作区 litho-docs/ 已就绪,等待 Downloads 写入授权", status: "pending" },
|
|
1371
|
+
];
|
|
1372
|
+
const todoHistory = [{ turn: 1, time: 100, todos }];
|
|
1373
|
+
const members = [
|
|
1374
|
+
{ id: "m1", name: "live-domain-analyst", description: "直播域技术底稿分析(推拉流/TRTC/TIM/多人直播/信令/课件/成员)", phase: "active", time: 150 },
|
|
1375
|
+
{ id: "m2", name: "platform-analyst", description: "平台层与多端边界分析(环境矩阵/鉴权/接口域/聚合口子)", phase: "active", time: 160 },
|
|
1376
|
+
];
|
|
1377
|
+
const teamTasks = [
|
|
1378
|
+
{ id: "t1", subject: "直播域技术底稿(供多端聚合文档与精度校验)", status: "completed", ownerId: "main-session", time: 200 },
|
|
1379
|
+
{ id: "t2", subject: "平台层与多端边界底稿(环境/鉴权/接口域/聚合口子)", status: "completed", ownerId: "m2", time: 210 },
|
|
1380
|
+
];
|
|
1381
|
+
const plan = T.planTodoAttachments(todos, members, teamTasks, [], todoHistory);
|
|
1382
|
+
assert.deepEqual([...plan.keys()], [2], "two delegates and the unowned task all land under the delegation todo");
|
|
1383
|
+
assert.deepEqual(plan.get(2).filter((item) => item.kind === "member").map((item) => item.record.id), ["m1", "m2"], "both members nested");
|
|
1384
|
+
assert.deepEqual(plan.get(2).filter((item) => item.kind === "task").map((item) => item.record.id), ["t1"], "the main-session task rides along");
|
|
1385
|
+
/* the same delegate without the in-progress hint still matches by rare words */
|
|
1386
|
+
assert.equal([...T.planTodoAttachments(todos, members, teamTasks, []).keys()].join(","), "2", "text evidence alone is enough");
|
|
1387
|
+
/* a delegate whose text shares nothing with the plan stays flat */
|
|
1388
|
+
assert.equal(T.planTodoAttachments(todos, [{ id: "m9", name: "unrelated", description: "zzz", phase: "active", time: 300 }], [], [], todoHistory).size, 0, "no token overlap, no attachment");
|
|
1389
|
+
assert.equal(T.planTokens("直播域技术底稿").has("直播"), true, "CJK bigrams are tokens");
|
|
1390
|
+
assert.equal(T.planTokens("live-domain-analyst").has("analyst"), true, "latin words are tokens");
|
|
1391
|
+
|
|
1392
|
+
/* B) 收起状态只按「有门禁的步骤」取窗口:审批不再吃掉槽位、也不会留下孤立首行 */
|
|
1393
|
+
const flat = [];
|
|
1394
|
+
for (let index = 0; index < 9; index += 1) {
|
|
1395
|
+
flat.push({ name: "bash", callId: `c${index}`, done: true, time: 1000 + index * 10, parentCallId: undefined });
|
|
1396
|
+
flat.push({ name: "approval", callId: `approval-${index}`, parentCallId: `c${index}`, done: true, time: 1005 + index * 10, milestone: true });
|
|
1397
|
+
}
|
|
1398
|
+
/* boundary: an approval whose parent was evicted */
|
|
1399
|
+
const orphan = { name: "approval", callId: "approval-orphan", parentCallId: "c0", done: true, time: 2000, milestone: true };
|
|
1400
|
+
const windowed = T.recentStepWindow([...flat, orphan]);
|
|
1401
|
+
assert.equal(windowed.filter((step) => step.name === "bash").length, 6, "six gated tool steps");
|
|
1402
|
+
assert.deepEqual(windowed.map((step) => step.callId).slice(0, 2), ["c3", "approval-3"], "the window opens on a gated tool call, never an orphan approval");
|
|
1403
|
+
assert.equal(windowed.some((step) => step.callId === "approval-orphan"), false, "an approval without its call stays out of the window");
|
|
1404
|
+
/* buildState uses the same rule end to end */
|
|
1405
|
+
const ev = (type, data, seq, time) => ({ type, data, seq, time });
|
|
1406
|
+
const folder = T.createFolder();
|
|
1407
|
+
folder.sessionId = "s-window";
|
|
1408
|
+
for (let index = 0; index < 9; index += 1) {
|
|
1409
|
+
T.foldEntry(folder, { type: "durable", event: ev("tool/call", { callId: `c${index}`, name: "bash", arguments: "{}" }, 10 + index * 3, 1000 + index * 10) }, "/w");
|
|
1410
|
+
T.foldEntry(folder, { type: "durable", event: ev("approval/asked", { id: `ap${index}`, callId: `c${index}`, toolName: "bash" }, 11 + index * 3, 1002 + index * 10) }, "/w");
|
|
1411
|
+
T.foldEntry(folder, { type: "durable", event: ev("approval/decided", { id: `ap${index}`, outcome: "allowed-once" }, 12 + index * 3, 1004 + index * 10) }, "/w");
|
|
1412
|
+
}
|
|
1413
|
+
folder.changed = false;
|
|
1414
|
+
const state = T.buildState(folder, { hasMore: false, entries: [] }, 0, "all");
|
|
1415
|
+
assert.equal(state.steps.filter((step) => step.name === "bash").length, 6, "session window keeps six tool steps");
|
|
1416
|
+
const windowCalls = new Set(state.steps.filter((step) => typeof step.callId === "string").map((step) => step.callId));
|
|
1417
|
+
assert.equal(state.steps[0].name, "bash", "first visible step is a tool call, never an orphan approval");
|
|
1418
|
+
for (const step of state.steps) {
|
|
1419
|
+
if (step.name === "approval") assert.ok(windowCalls.has(step.parentCallId), `approval ${step.callId} nested under a visible call`);
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
/* C) 代理身份的子代理项 → 该子代理的概览;子代理页返回主代理 */
|
|
1423
|
+
const localStorageStub = { store: new Map(), getItem: (key) => (localStorageStub.store.has(key) ? localStorageStub.store.get(key) : null), setItem: (key, value) => localStorageStub.store.set(key, String(value)) };
|
|
1424
|
+
globalThis.window.localStorage = localStorageStub;
|
|
1425
|
+
const opened = [];
|
|
1426
|
+
const activated = [];
|
|
1427
|
+
T.__setRuntime({
|
|
1428
|
+
t: (key) => key,
|
|
1429
|
+
ctx: {
|
|
1430
|
+
sidebarRight: { openResource: () => {} },
|
|
1431
|
+
uiWorkspace: { openSession: (id) => opened.push(id) },
|
|
1432
|
+
sessions: { binding: (id) => ({ sessionId: id }), list: null },
|
|
1433
|
+
uiConversation: { binding: (id) => ({ activate: (view) => activated.push(`${id}:${view}`) }) },
|
|
1434
|
+
},
|
|
1435
|
+
});
|
|
1436
|
+
assert.equal(T.openSessionOverview("child-1"), true, "subagent item opens its session");
|
|
1437
|
+
assert.deepEqual(opened, ["child-1"], "workspace moved to the child session");
|
|
1438
|
+
assert.equal(localStorageStub.getItem("dsh.conversation.child-1"), JSON.stringify({ view: "overview" }), "child session preference selects the overview");
|
|
1439
|
+
assert.deepEqual(activated, ["child-1:overview"], "conversation view activated immediately");
|
|
1440
|
+
assert.equal(T.openSessionOverview("parent-1"), true, "return-to-main navigates back");
|
|
1441
|
+
assert.deepEqual(opened, ["child-1", "parent-1"]);
|
|
1442
|
+
assert.equal(localStorageStub.getItem("dsh.conversation.parent-1"), JSON.stringify({ view: "overview" }), "parent lands on its overview too");
|
|
1443
|
+
assert.ok(clientSource.includes("openSessionOverview(child.id)"), "role list items use the overview navigation");
|
|
1444
|
+
assert.ok(clientSource.includes("openSessionOverview(role.parentId)"), "subagent bar returns to the main agent's overview");
|
|
1445
|
+
assert.ok(clientSource.includes('"role.openParent": "返回主代理"'), "return button says 返回主代理");
|
|
1446
|
+
delete globalThis.window.localStorage;
|
|
1447
|
+
T.__setRuntime(null);
|
|
1448
|
+
|
|
1449
|
+
/* D) 结论页 = 对话里的渲染:宿主 MarkdownText + 对话排版壳 + 文件 chip/图片解析 */
|
|
1450
|
+
assert.ok(clientSource.includes(".dsov-docChat{display:flex;flex-direction:column;font-size:var(--dsh-content-font-size,14px);line-height:calc(24px + var(--dsh-content-font-delta,0px))"), "document page borrows the chat's typographic wrapper");
|
|
1451
|
+
assert.ok(clientSource.includes("fileMentions: docFileMentions"), "inline file paths become the chat's file chips");
|
|
1452
|
+
/* host components are React.memo objects: a typeof-function test silently disabled
|
|
1453
|
+
the whole host-renderer path (raw markdown links, wrong type scale in the tab) */
|
|
1454
|
+
assert.ok(clientSource.includes("primitives.MarkdownText !== undefined && primitives.MarkdownText !== null"), "memo/forwardRef host components are accepted");
|
|
1455
|
+
assert.ok(clientSource.includes("primitives.MarkdownDelegateProvider"), "chat link delegates are borrowed");
|
|
1456
|
+
assert.ok(clientSource.includes("openDocLinkedFile") && clientSource.includes("openDocExternalLink"), "file/external link delegates are wired");
|
|
1457
|
+
assert.ok(clientSource.includes("pathImages: docImages"), "local images resolve through the host file API");
|
|
1458
|
+
const produced = ["/w/src/a.md", "/w/src/b.vue"];
|
|
1459
|
+
const docKey = T.rememberContextPreview("s-doc", "conclusion|3", "第 3 轮结论", "改动落在 `b.vue` 与 `src/a.md`。", "conclusion", produced);
|
|
1460
|
+
const docEntry = T.readContextPreview(docKey);
|
|
1461
|
+
assert.deepEqual(docEntry.files, produced, "document snapshot keeps the turn's produced files");
|
|
1462
|
+
assert.equal(T.rememberContextPreview("s-doc", "ref|1", "runtime-context", "plain", undefined).includes("ref|1"), true, "reference snapshots carry no file list");
|
|
1463
|
+
|
|
1464
|
+
/* E) 概览必须等到会话 binding 才渲染,并在空窗口时主动拉一页 */
|
|
1465
|
+
assert.ok(clientSource.includes("function useSessionBinding"), "binding resolution is reactive");
|
|
1466
|
+
assert.ok(clientSource.includes("React.useSyncExternalStore(subscribe, readRoster, readRoster)"), "session roster re-renders the surface");
|
|
1467
|
+
assert.ok(clientSource.includes("hydratedFor.current === id"), "empty window hydration is guarded per session");
|
|
1468
|
+
T.__setRuntime({ t: (key) => key, ctx: { sessions: { list: { subscribe: () => () => {}, getSnapshot: () => ({ ids: ["s1"], byId: { s1: { id: "s1", blank: false } } }) } } } });
|
|
1469
|
+
assert.equal(T.sessionListRow("s1").blank, false, "roster row is readable");
|
|
1470
|
+
assert.equal(T.sessionListRow("missing"), null, "unknown session has no row");
|
|
1471
|
+
T.__setRuntime(null);
|
|
1472
|
+
globalThis.window.localStorage = undefined;
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
|
|
1476
|
+
/* ---- 14. 目标模式:折叠 + 投影合并 + 可选服务反射访问 ---------------------- */
|
|
1477
|
+
{
|
|
1478
|
+
const ev = (type, data, seq, time) => ({ type, data, seq, time });
|
|
1479
|
+
const W = "/w";
|
|
1480
|
+
const win = { hasMore: false, entries: [] };
|
|
1481
|
+
const folder = T.createFolder();
|
|
1482
|
+
folder.sessionId = "s-goal";
|
|
1483
|
+
const goal = { id: "goal-1", revision: 1, objective: "给列表加下载按钮", phase: "active", maxGoalRounds: 256 };
|
|
1484
|
+
T.foldEvent(folder, ev("turn/start", { turn: 1 }, 1, 100), W);
|
|
1485
|
+
T.foldEvent(folder, ev("goal/change", { operation: "create", goal, roundsStarted: 0, createdAt: 110, updatedAt: 110 }, 2, 110), W);
|
|
1486
|
+
T.foldEvent(folder, ev("user/message", { source: { kind: "goal", goalId: "goal-1", revision: 1, round: 1 }, content: [{ type: "text", text: "<goal_round>Objective: 给列表加下载按钮" }] }, 3, 120), W);
|
|
1487
|
+
T.foldEvent(folder, ev("tool/call", { callId: "c1", name: "bash", arguments: "{}" }, 4, 130), W);
|
|
1488
|
+
T.foldEvent(folder, ev("turn/end", { turn: 1 }, 5, 140), W);
|
|
1489
|
+
folder.changed = false;
|
|
1490
|
+
const mid = T.buildState(folder, win, 0, "latest");
|
|
1491
|
+
assert.ok(mid.goal !== null, "goal state is exposed");
|
|
1492
|
+
assert.equal(mid.goal.objective, "给列表加下载按钮");
|
|
1493
|
+
assert.equal(mid.goal.phase, "active", "folded phase");
|
|
1494
|
+
assert.equal(mid.goal.maxGoalRounds, 256, "round budget folded");
|
|
1495
|
+
assert.equal(mid.goal.roundsStarted, 1, "admitted goal round bumps the counter");
|
|
1496
|
+
assert.equal(mid.goal.createdAt, 110, "creation time folded");
|
|
1497
|
+
T.foldEvent(folder, ev("goal/change", { operation: "complete", goal: { ...goal, revision: 2, phase: "complete" }, roundsStarted: 1, createdAt: 110, updatedAt: 200 }, 6, 200), W);
|
|
1498
|
+
folder.changed = false;
|
|
1499
|
+
assert.equal(T.buildState(folder, win, 0, "latest").goal.phase, "complete", "later change wins");
|
|
1500
|
+
/* projection merge */
|
|
1501
|
+
const merged = T.mergeGoalState(
|
|
1502
|
+
{ objective: "给列表加下载按钮", phase: "active", maxGoalRounds: 256, roundsStarted: 1, blockedReason: "", createdAt: 110, updatedAt: 150, turn: 1 },
|
|
1503
|
+
{ objective: "给列表加下载按钮", phase: "blocked", blockedReason: "缺少凭据", maxGoalRounds: 256, roundsStarted: 3, createdAt: 110, updatedAt: 180 }
|
|
1504
|
+
);
|
|
1505
|
+
assert.equal(merged.phase, "blocked", "live projection owns the phase");
|
|
1506
|
+
assert.equal(merged.roundsStarted, 3, "live round counter wins");
|
|
1507
|
+
assert.equal(merged.blockedReason, "缺少凭据");
|
|
1508
|
+
assert.equal(merged.updatedAt, 180);
|
|
1509
|
+
const historyOnly = T.mergeGoalState({ objective: "老目标", phase: "complete", maxGoalRounds: 10, roundsStarted: 2, blockedReason: "", createdAt: 1, updatedAt: 2, turn: 3 }, null);
|
|
1510
|
+
assert.equal(historyOnly.objective, "老目标", "historical goal survives without a projection");
|
|
1511
|
+
assert.equal(T.mergeGoalState(null, null), null, "no goal anywhere");
|
|
1512
|
+
assert.equal(T.mergeGoalState({ objective: " " }, null), null, "blank objective is not a goal");
|
|
1513
|
+
|
|
1514
|
+
/* hostService: cordis contexts throw for non-injected services; ctx.get is the way in */
|
|
1515
|
+
const throwing = {
|
|
1516
|
+
get: (name) => (name === "uiWorkspace" ? { openSession: () => true } : undefined),
|
|
1517
|
+
get uiWorkspace() {
|
|
1518
|
+
throw new Error("cannot get property without inject");
|
|
1519
|
+
},
|
|
1520
|
+
};
|
|
1521
|
+
T.__setRuntime({ t: (key) => key, ctx: throwing });
|
|
1522
|
+
assert.equal(T.hostService("uiWorkspace") !== null, true, "ctx.get serves the optional service");
|
|
1523
|
+
assert.equal(T.hostService("uiConversation"), null, "absent service reads as null");
|
|
1524
|
+
assert.equal(T.hostService("remote"), null, "desktop remote absent → null, not a throw");
|
|
1525
|
+
|
|
1526
|
+
/* remote.session is a cordis ACCESSOR: reading it undeclared throws, and the artifact
|
|
1527
|
+
render path reads it — it must be mounted through an inject and degrade to null. */
|
|
1528
|
+
assert.equal(T.hostSessionRemote(), null, "no mount → no desktop route");
|
|
1529
|
+
const scopedSession = { canOpenWorkspacePath: async () => ({ ok: true, value: true }) };
|
|
1530
|
+
T.mountDesktopRemote({ inject: (deps, callback) => { callback({ "remote.session": scopedSession }); return Promise.resolve(); } });
|
|
1531
|
+
assert.equal(T.hostSessionRemote(), scopedSession, "declared inject resolves the desktop namespace");
|
|
1532
|
+
T.mountDesktopRemote({ get: () => ({ get session() { throw new Error('cannot get property "remote.session" without inject'); } }) });
|
|
1533
|
+
assert.equal(T.hostSessionRemote(), null, "a guarded accessor degrades instead of throwing");
|
|
1534
|
+
T.mountDesktopRemote({
|
|
1535
|
+
inject: (deps, callback) => {
|
|
1536
|
+
callback({ remote: { get session() { throw new Error('cannot get property "remote.session" without inject'); } } });
|
|
1537
|
+
return Promise.resolve();
|
|
1538
|
+
},
|
|
1539
|
+
});
|
|
1540
|
+
assert.equal(T.hostSessionRemote(), null, "nested accessor guard is survived too");
|
|
1541
|
+
T.mountDesktopRemote({
|
|
1542
|
+
inject: (deps, callback) => {
|
|
1543
|
+
callback({
|
|
1544
|
+
get "remote.session"() {
|
|
1545
|
+
throw new Error('cannot get property "remote.session" without inject');
|
|
1546
|
+
},
|
|
1547
|
+
});
|
|
1548
|
+
return Promise.resolve();
|
|
1549
|
+
},
|
|
1550
|
+
});
|
|
1551
|
+
assert.equal(T.hostSessionRemote(), null, "flat accessor guard is survived as well");
|
|
1552
|
+
assert.equal(T.desktopCanOpen() instanceof Promise, true, "capability probe stays async and safe");
|
|
1553
|
+
T.__setRuntime(null);
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1358
1556
|
console.log("\nALL TESTS PASSED");
|
package/README.md
CHANGED
|
@@ -8,8 +8,8 @@ DeepSeek Harness(DSH)插件:在对话/轨迹旁增加「概览」标签页
|
|
|
8
8
|
|
|
9
9
|
## 功能特性
|
|
10
10
|
|
|
11
|
-
-
|
|
12
|
-
- **产物**:生成/交付文件列表、按轮次的变更审阅入口、系统定位与「打开方式」;本轮结束可自动把结论作为 Markdown
|
|
11
|
+
- **进展**:会话目标横幅(目标模式、自动续跑轮次/上限、阶段与受阻原因)与任务计划进度;本轮关键步骤(可展开全部、展开查看命令与输出/耗时/错误、等待确认高亮、里程碑可置顶,折叠窗口按「有门禁的步骤」取最近 6 条,不会把孤立审批留在顶部);团队成员与子代理按对应待办归属;双击待办生成重跑提示词;主代理/子代理一键切到对方的概览。
|
|
12
|
+
- **产物**:生成/交付文件列表、按轮次的变更审阅入口、系统定位与「打开方式」;本轮结束可自动把结论作为 Markdown 文档开在新标签(计划内容亦有快照,刷新后正文仍在)——文档页用与对话完全相同的排版组件渲染(字号阶梯、行高、图片、文件 chip 一致),文件链接点击即在侧边栏打开预览,外链走新标签。
|
|
13
13
|
- **引用**:上下文注入、OpenViking 记忆(侧边栏内预览)、技能、文件与会话引用;类型筛选、隐藏常见项、排序(最近/类型/轮次/次数)。
|
|
14
14
|
- **统计与时间轴**:轮次统计胶囊(用时/调用/命令/改动文件/失败,失败可点击查看详情);打开时间轴拖到任意时间点,回看当时的进展、产物与引用。
|
|
15
15
|
- **Markdown 预览**:结论与记忆文档支持 Mermaid 图渲染、代码块复制、脚注与自动换行。
|
|
@@ -141,6 +141,13 @@ node .scratch/test.mjs ~/.dsh/sessions/<workspace>/<session-id>/session.v4.jsonl
|
|
|
141
141
|
|
|
142
142
|
> 这些断言同时充当回归护栏:改动 `client.js` 的折叠或显隐逻辑后先跑 `npm test`,比在浏览器里逐个点要快得多。
|
|
143
143
|
|
|
144
|
+
## 版本更新
|
|
145
|
+
|
|
146
|
+
- **1.5.0**:会话目标横幅(目标模式、自动续跑轮次/上限、阶段与受阻原因);文档页改用与对话相同的 Markdown 组件与排版壳(文件 chip 点击开侧边栏预览、外链新标签、多段正文正确分段);团队委派按 IDF 词重叠归属到对应待办;关键步骤折叠窗口按「有门禁的步骤」取最近 6 条;代理身份互跳对方概览;等待会话绑定期间不再出现空白概览。
|
|
147
|
+
- **1.4.2**:补齐「开发与测试」文档,测试脚本随包发布(`npm test` 可直接运行)。
|
|
148
|
+
- **1.4.1**:修复刷新后结论标签退化为失效空页;区分「分叉会话」与「子代理会话」,不再误显示子代理徽标。
|
|
149
|
+
- **1.4.0**:结论标签页、Mermaid/Markdown 预览、步骤详情展开、计划快照、子代理角色导航。
|
|
150
|
+
|
|
144
151
|
## License
|
|
145
152
|
|
|
146
153
|
MIT © 2026
|