@co0ontty/wand 1.61.0 → 1.62.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/dist/build-info.json +3 -3
- package/dist/server-session-routes.js +22 -1
- package/dist/structured-session-manager.d.ts +7 -0
- package/dist/structured-session-manager.js +126 -37
- package/dist/web-ui/content/scripts.js +24 -24
- package/dist/web-ui/embedded-assets.d.ts +1 -1
- package/dist/web-ui/embedded-assets.js +2 -2
- package/package.json +1 -1
package/dist/build-info.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"commit": "
|
|
3
|
-
"builtAt": "2026-06-
|
|
4
|
-
"version": "1.
|
|
2
|
+
"commit": "33bc822b420b2e9276b9dbb72d9831452338b632",
|
|
3
|
+
"builtAt": "2026-06-14T01:57:11.796Z",
|
|
4
|
+
"version": "1.62.0",
|
|
5
5
|
"channel": "stable"
|
|
6
6
|
}
|
|
@@ -252,7 +252,7 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
|
|
|
252
252
|
}
|
|
253
253
|
});
|
|
254
254
|
// ── Structured queued-messages management ──
|
|
255
|
-
//
|
|
255
|
+
// 这些端点构成"排队消息条"的后端操作面:reorder、立即发送、单条删除、全部清空。
|
|
256
256
|
// 全部走乐观更新模型,失败时前端会回滚到上一次 WS 推送的 queuedMessages。
|
|
257
257
|
app.patch("/api/structured-sessions/:id/queued", express.json(), (req, res) => {
|
|
258
258
|
const rawOrder = req.body?.order;
|
|
@@ -282,6 +282,27 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
|
|
|
282
282
|
res.status(400).json({ error: getErrorMessage(error, "无法删除排队消息。") });
|
|
283
283
|
}
|
|
284
284
|
});
|
|
285
|
+
app.post("/api/structured-sessions/:id/queued/:index/promote", express.json(), async (req, res) => {
|
|
286
|
+
const index = Number(req.params.index);
|
|
287
|
+
if (!Number.isInteger(index)) {
|
|
288
|
+
res.status(400).json({ error: "下标无效。" });
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
const expectedText = typeof req.body?.expectedText === "string" ? req.body.expectedText : undefined;
|
|
292
|
+
const idempotencyKey = typeof req.body?.idempotencyKey === "string" ? req.body.idempotencyKey : undefined;
|
|
293
|
+
try {
|
|
294
|
+
const snapshot = await structured.promoteQueuedMessage(req.params.id, index, expectedText, idempotencyKey);
|
|
295
|
+
res.json(snapshot);
|
|
296
|
+
}
|
|
297
|
+
catch (error) {
|
|
298
|
+
const errorCode = error?.code;
|
|
299
|
+
const status = errorCode === "duplicate_idempotency_key" ? 409 : 400;
|
|
300
|
+
res.status(status).json({
|
|
301
|
+
error: getErrorMessage(error, "无法立即发送排队消息。"),
|
|
302
|
+
errorCode,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
});
|
|
285
306
|
app.delete("/api/structured-sessions/:id/queued", (req, res) => {
|
|
286
307
|
try {
|
|
287
308
|
const snapshot = structured.clearQueuedMessages(req.params.id);
|
|
@@ -90,6 +90,7 @@ export declare class StructuredSessionManager {
|
|
|
90
90
|
interrupt?: boolean;
|
|
91
91
|
idempotencyKey?: string;
|
|
92
92
|
preserveQueue?: boolean;
|
|
93
|
+
queueAlreadyRemoved?: boolean;
|
|
93
94
|
}): Promise<SessionSnapshot>;
|
|
94
95
|
/**
|
|
95
96
|
* Reorder the pending queued messages. `order` is a permutation of the current
|
|
@@ -102,6 +103,12 @@ export declare class StructuredSessionManager {
|
|
|
102
103
|
reorderQueuedMessages(sessionId: string, order: number[]): SessionSnapshot;
|
|
103
104
|
/** Remove a single queued message by index. */
|
|
104
105
|
deleteQueuedMessage(sessionId: string, index: number): SessionSnapshot;
|
|
106
|
+
/**
|
|
107
|
+
* Remove one queued message by index before sending it. Keeping this operation
|
|
108
|
+
* on the server prevents clients from re-sending the text while the original
|
|
109
|
+
* queue entry remains available for the automatic flush path.
|
|
110
|
+
*/
|
|
111
|
+
promoteQueuedMessage(sessionId: string, index: number, expectedText?: string, idempotencyKey?: string): Promise<SessionSnapshot>;
|
|
105
112
|
/** Clear all queued messages. No-op when queue is already empty. */
|
|
106
113
|
clearQueuedMessages(sessionId: string): SessionSnapshot;
|
|
107
114
|
/** Update the selected model for a structured session. Takes effect on the next spawn. */
|
|
@@ -632,17 +632,18 @@ export class StructuredSessionManager {
|
|
|
632
632
|
// 「立即发送」排队条某一条:interrupt 把它作为新输入重发,但该条仍留在
|
|
633
633
|
// queuedMessages 里。必须在这里把它从队列摘掉一次,否则 preserveQueue 会
|
|
634
634
|
// 原样保留整条队列,待 interruptPrompt 跑完 flushNextQueuedMessage 会把它
|
|
635
|
-
//
|
|
636
|
-
//
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
635
|
+
// 当成普通排队再发一遍(重复发送)。旧客户端没有走 promote endpoint,
|
|
636
|
+
// 服务端只能按文本删第一处匹配;新客户端会带 queueAlreadyRemoved 跳过这里。
|
|
637
|
+
if (!opts.queueAlreadyRemoved) {
|
|
638
|
+
const queue = session.queuedMessages ?? [];
|
|
639
|
+
const removeAt = queue.indexOf(prompt);
|
|
640
|
+
if (removeAt !== -1) {
|
|
641
|
+
const trimmedQueue = queue.slice(0, removeAt).concat(queue.slice(removeAt + 1));
|
|
642
|
+
session = { ...session, queuedMessages: trimmedQueue };
|
|
643
|
+
this.sessions.set(id, session);
|
|
644
|
+
this.storage.saveSession(session);
|
|
645
|
+
this.emitStructuredSnapshot(session);
|
|
646
|
+
}
|
|
646
647
|
}
|
|
647
648
|
}
|
|
648
649
|
else {
|
|
@@ -811,6 +812,44 @@ export class StructuredSessionManager {
|
|
|
811
812
|
this.emitStructuredSnapshot(updated);
|
|
812
813
|
return updated;
|
|
813
814
|
}
|
|
815
|
+
/**
|
|
816
|
+
* Remove one queued message by index before sending it. Keeping this operation
|
|
817
|
+
* on the server prevents clients from re-sending the text while the original
|
|
818
|
+
* queue entry remains available for the automatic flush path.
|
|
819
|
+
*/
|
|
820
|
+
async promoteQueuedMessage(sessionId, index, expectedText, idempotencyKey) {
|
|
821
|
+
const session = this.requireSession(sessionId);
|
|
822
|
+
if (idempotencyKey && this.seenIdempotencyKeys.has(`${sessionId}:${idempotencyKey}`)) {
|
|
823
|
+
return session;
|
|
824
|
+
}
|
|
825
|
+
const queue = session.queuedMessages ?? [];
|
|
826
|
+
if (!Number.isInteger(index) || index < 0 || index >= queue.length) {
|
|
827
|
+
throw new Error("队列中没有该条消息(可能已被处理)。");
|
|
828
|
+
}
|
|
829
|
+
if (expectedText !== undefined && queue[index] !== expectedText) {
|
|
830
|
+
throw new Error("排队消息已变化,请按最新顺序重试。");
|
|
831
|
+
}
|
|
832
|
+
const prompt = queue[index];
|
|
833
|
+
const remaining = queue.slice(0, index).concat(queue.slice(index + 1));
|
|
834
|
+
const inFlight = session.status === "running" && session.structuredState?.inFlight === true;
|
|
835
|
+
const updated = { ...session, queuedMessages: remaining };
|
|
836
|
+
this.sessions.set(sessionId, updated);
|
|
837
|
+
this.storage.saveSession(updated);
|
|
838
|
+
this.emitStructuredSnapshot(updated);
|
|
839
|
+
try {
|
|
840
|
+
return await this.sendMessage(sessionId, prompt, {
|
|
841
|
+
interrupt: inFlight,
|
|
842
|
+
preserveQueue: inFlight,
|
|
843
|
+
queueAlreadyRemoved: true,
|
|
844
|
+
idempotencyKey,
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
catch {
|
|
848
|
+
// Once the item has been promoted it must not return to the queue: the
|
|
849
|
+
// send path may have already persisted its user turn before a runner error.
|
|
850
|
+
return this.requireSession(sessionId);
|
|
851
|
+
}
|
|
852
|
+
}
|
|
814
853
|
/** Clear all queued messages. No-op when queue is already empty. */
|
|
815
854
|
clearQueuedMessages(sessionId) {
|
|
816
855
|
const session = this.requireSession(sessionId);
|
|
@@ -1505,39 +1544,89 @@ export class StructuredSessionManager {
|
|
|
1505
1544
|
// 二条事件里的 tool_use 直接被丢掉——表现是 Agent / Read 等 tool_use 永远
|
|
1506
1545
|
// 不出现在 messages 里,subagent 多角色无法关联 agentType 到父 Task。
|
|
1507
1546
|
//
|
|
1508
|
-
//
|
|
1509
|
-
//
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
if (a && !b) {
|
|
1520
|
-
merged.push(a);
|
|
1521
|
-
continue;
|
|
1547
|
+
// 先判定 incoming 是不是 prev 的"累积超集"(mode a):长度不短于 prev,
|
|
1548
|
+
// 且前 prev.length 个 block 类型逐位一致。是 → 走逐位取大 + 末尾追加。
|
|
1549
|
+
let cumulative = blocks.length >= prev.length;
|
|
1550
|
+
if (cumulative) {
|
|
1551
|
+
for (let i = 0; i < prev.length; i++) {
|
|
1552
|
+
const a = prev[i];
|
|
1553
|
+
const b = blocks[i];
|
|
1554
|
+
if (a && b && a.type !== b.type) {
|
|
1555
|
+
cumulative = false;
|
|
1556
|
+
break;
|
|
1557
|
+
}
|
|
1522
1558
|
}
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1559
|
+
}
|
|
1560
|
+
if (cumulative) {
|
|
1561
|
+
const merged = [];
|
|
1562
|
+
const appendix = [];
|
|
1563
|
+
for (let i = 0; i < blocks.length; i++) {
|
|
1564
|
+
const a = prev[i];
|
|
1565
|
+
const b = blocks[i];
|
|
1566
|
+
if (a && !b) {
|
|
1567
|
+
merged.push(a);
|
|
1568
|
+
continue;
|
|
1569
|
+
}
|
|
1570
|
+
if (!a && b) {
|
|
1571
|
+
merged.push(b);
|
|
1572
|
+
continue;
|
|
1573
|
+
}
|
|
1574
|
+
if (a && b) {
|
|
1575
|
+
if (a.type === b.type) {
|
|
1576
|
+
// 同类型:取信息量大者,避免短回退覆盖已经累积的内容。
|
|
1577
|
+
merged.push(blockVolume(b) >= blockVolume(a) ? b : a);
|
|
1578
|
+
}
|
|
1579
|
+
else {
|
|
1580
|
+
// 类型变了:保留 prev[i],把 incoming block 追加到末尾。
|
|
1581
|
+
merged.push(a);
|
|
1582
|
+
appendix.push(b);
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1526
1585
|
}
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1586
|
+
for (const b of appendix)
|
|
1587
|
+
merged.push(b);
|
|
1588
|
+
blocksByKey.set(key, merged);
|
|
1589
|
+
return;
|
|
1590
|
+
}
|
|
1591
|
+
// mode b(拼接/splice):incoming 不是累积超集——SDK 把同一 msg.id 的
|
|
1592
|
+
// thinking / text / 多个 tool_use 拆成一条条「只带新 block」的事件发出
|
|
1593
|
+
// (新版 claude 连发 4 个 TaskCreate 就是这样)。老逻辑 `blocks.length <
|
|
1594
|
+
// prev.length 直接 return` 会把这些单 block 事件整段丢弃,导致 TaskCreate /
|
|
1595
|
+
// Agent / Read 等永远进不了 messages。这里保留 prev 全部,按 block 身份
|
|
1596
|
+
// 增量合并:tool_use 用 id 去重(已存在则取信息量大的就地更新,否则追加);
|
|
1597
|
+
// text / thinking 仅在没有完全相同内容时追加,挡住「短回退」的重复 frame。
|
|
1598
|
+
const merged = [...prev];
|
|
1599
|
+
const idIndex = new Map();
|
|
1600
|
+
merged.forEach((b, i) => {
|
|
1601
|
+
const anyB = b;
|
|
1602
|
+
if (b.type === "tool_use" && typeof anyB.id === "string")
|
|
1603
|
+
idIndex.set(anyB.id, i);
|
|
1604
|
+
});
|
|
1605
|
+
for (const b of blocks) {
|
|
1606
|
+
const anyB = b;
|
|
1607
|
+
if (b.type === "tool_use" && typeof anyB.id === "string") {
|
|
1608
|
+
const at = idIndex.get(anyB.id);
|
|
1609
|
+
if (at !== undefined) {
|
|
1610
|
+
if (blockVolume(b) >= blockVolume(merged[at]))
|
|
1611
|
+
merged[at] = b;
|
|
1531
1612
|
}
|
|
1532
1613
|
else {
|
|
1533
|
-
|
|
1534
|
-
merged.push(
|
|
1535
|
-
appendix.push(b);
|
|
1614
|
+
idIndex.set(anyB.id, merged.length);
|
|
1615
|
+
merged.push(b);
|
|
1536
1616
|
}
|
|
1617
|
+
continue;
|
|
1537
1618
|
}
|
|
1619
|
+
if (b.type === "tool_result") {
|
|
1620
|
+
merged.push(b);
|
|
1621
|
+
continue;
|
|
1622
|
+
}
|
|
1623
|
+
// text / thinking:同类型且文本完全一致视为重复回退,跳过。
|
|
1624
|
+
const dup = merged.some((x) => x.type === b.type
|
|
1625
|
+
&& x.text === anyB.text
|
|
1626
|
+
&& x.thinking === anyB.thinking);
|
|
1627
|
+
if (!dup)
|
|
1628
|
+
merged.push(b);
|
|
1538
1629
|
}
|
|
1539
|
-
for (const b of appendix)
|
|
1540
|
-
merged.push(b);
|
|
1541
1630
|
blocksByKey.set(key, merged);
|
|
1542
1631
|
};
|
|
1543
1632
|
const rebuildTurnBlocks = () => {
|