@co0ontty/wand 1.61.0 → 1.62.0-beta.g33bc822

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.
@@ -1,6 +1,6 @@
1
1
  {
2
- "commit": "676ee8ed1518463d31f33f9d6679d7700aac3eb7",
3
- "builtAt": "2026-06-13T13:58:28.141Z",
4
- "version": "1.61.0",
5
- "channel": "stable"
2
+ "commit": "33bc822b420b2e9276b9dbb72d9831452338b632",
3
+ "builtAt": "2026-06-14T01:57:00.598Z",
4
+ "version": "1.62.0-beta.g33bc822",
5
+ "channel": "beta"
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
- // 三个端点构成"排队消息条"的后端操作面:reorder(拖拽换序)、单条删除、全部清空。
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
- // 当成普通排队再发一遍(重复发送)。客户端按 index 乐观删除,服务端这里
636
- // 没有 index,只能按文本删第一处匹配(排队文本入队时已 trim,promote 重发
637
- // 也会 trim,所以精确匹配可靠;重复文本极少见且语义等价)。
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);
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
- // 新规则:当类型不一致时,把新 block **追加**到 merged 末尾而非覆盖 prev
1509
- // 既兼容 a)(同位置同类型仍按累积取大),又兼容 b)(拼接的新类型 block
1510
- // 进入末尾),还能挡住 b 早期版本里"短回退"的异常 frame(blocks.length
1511
- // < prev.length 时直接拒绝)。
1512
- if (blocks.length < prev.length)
1513
- return;
1514
- const merged = [];
1515
- const appendix = [];
1516
- for (let i = 0; i < blocks.length; i++) {
1517
- const a = prev[i];
1518
- const b = blocks[i];
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
- if (!a && b) {
1524
- merged.push(b);
1525
- continue;
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
- if (a && b) {
1528
- if (a.type === b.type) {
1529
- // 同类型:取信息量大者,避免短回退覆盖已经累积的内容。
1530
- merged.push(blockVolume(b) >= blockVolume(a) ? b : a);
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
- // 类型变了:保留 prev[i],把 incoming block 追加到末尾。
1534
- merged.push(a);
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 = () => {