@coralai/sps-cli 0.65.33 → 0.65.35

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.
Files changed (67) hide show
  1. package/dist/chat-protocol/events.d.ts +131 -0
  2. package/dist/chat-protocol/events.d.ts.map +1 -0
  3. package/dist/chat-protocol/events.js +134 -0
  4. package/dist/chat-protocol/events.js.map +1 -0
  5. package/dist/chat-protocol/form.d.ts +66 -0
  6. package/dist/chat-protocol/form.d.ts.map +1 -0
  7. package/dist/chat-protocol/form.js +25 -0
  8. package/dist/chat-protocol/form.js.map +1 -0
  9. package/dist/chat-protocol/formValues.d.ts +60 -0
  10. package/dist/chat-protocol/formValues.d.ts.map +1 -0
  11. package/dist/chat-protocol/formValues.js +114 -0
  12. package/dist/chat-protocol/formValues.js.map +1 -0
  13. package/dist/chat-protocol/index.d.ts +25 -0
  14. package/dist/chat-protocol/index.d.ts.map +1 -0
  15. package/dist/chat-protocol/index.js +19 -0
  16. package/dist/chat-protocol/index.js.map +1 -0
  17. package/dist/chat-protocol/reduce.d.ts +33 -0
  18. package/dist/chat-protocol/reduce.d.ts.map +1 -0
  19. package/dist/chat-protocol/reduce.js +204 -0
  20. package/dist/chat-protocol/reduce.js.map +1 -0
  21. package/dist/chat-protocol/selectors.d.ts +31 -0
  22. package/dist/chat-protocol/selectors.d.ts.map +1 -0
  23. package/dist/chat-protocol/selectors.js +40 -0
  24. package/dist/chat-protocol/selectors.js.map +1 -0
  25. package/dist/chat-protocol/types.d.ts +85 -0
  26. package/dist/chat-protocol/types.d.ts.map +1 -0
  27. package/dist/chat-protocol/types.js +9 -0
  28. package/dist/chat-protocol/types.js.map +1 -0
  29. package/dist/chat-protocol/typewriter.d.ts +25 -0
  30. package/dist/chat-protocol/typewriter.d.ts.map +1 -0
  31. package/dist/chat-protocol/typewriter.js +36 -0
  32. package/dist/chat-protocol/typewriter.js.map +1 -0
  33. package/dist/console/chatEventLog.d.ts +47 -0
  34. package/dist/console/chatEventLog.d.ts.map +1 -0
  35. package/dist/console/chatEventLog.js +79 -0
  36. package/dist/console/chatEventLog.js.map +1 -0
  37. package/dist/console/routes/chat.d.ts +3 -0
  38. package/dist/console/routes/chat.d.ts.map +1 -1
  39. package/dist/console/routes/chat.js +178 -114
  40. package/dist/console/routes/chat.js.map +1 -1
  41. package/dist/console-assets/assets/{ShowcasePage-NmXybDqC.js → ShowcasePage-DYxYvjDm.js} +1 -1
  42. package/dist/console-assets/assets/{index-DCAnGMiQ.js → index-DoXihmot.js} +219 -219
  43. package/dist/console-assets/assets/index-Q5jKI6ZL.css +1 -0
  44. package/dist/console-assets/index.html +2 -2
  45. package/dist/core/activityStream.d.ts +1 -1
  46. package/dist/core/activityStream.d.ts.map +1 -1
  47. package/dist/core/activityStream.js +7 -2
  48. package/dist/core/activityStream.js.map +1 -1
  49. package/dist/core/agents/AgentBackend.d.ts +11 -1
  50. package/dist/core/agents/AgentBackend.d.ts.map +1 -1
  51. package/dist/core/agents/AgentBackend.js.map +1 -1
  52. package/dist/core/interactions/formSpec.d.ts +57 -0
  53. package/dist/core/interactions/formSpec.d.ts.map +1 -0
  54. package/dist/core/interactions/formSpec.js +123 -0
  55. package/dist/core/interactions/formSpec.js.map +1 -0
  56. package/dist/openai-agent/interactions/broker.d.ts +13 -10
  57. package/dist/openai-agent/interactions/broker.d.ts.map +1 -1
  58. package/dist/openai-agent/interactions/broker.js +48 -154
  59. package/dist/openai-agent/interactions/broker.js.map +1 -1
  60. package/dist/openai-agent/runWorker.d.ts.map +1 -1
  61. package/dist/openai-agent/runWorker.js +0 -6
  62. package/dist/openai-agent/runWorker.js.map +1 -1
  63. package/dist/providers/mcp/spsMcpServer.d.ts.map +1 -1
  64. package/dist/providers/mcp/spsMcpServer.js +6 -4
  65. package/dist/providers/mcp/spsMcpServer.js.map +1 -1
  66. package/package.json +1 -1
  67. package/dist/console-assets/assets/index-cxN7rD-p.css +0 -1
@@ -0,0 +1 @@
1
+ {"version":3,"file":"chatEventLog.js","sourceRoot":"","sources":["../../src/console/chatEventLog.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AASH,gDAAgD;AAChD,MAAM,sBAAsB,GAAG,GAAG,CAAC;AACnC,wCAAwC;AACxC,MAAM,YAAY,GAAG,GAAG,CAAC;AAOzB,MAAM,IAAI,GAAG,IAAI,GAAG,EAAsB,CAAC;AAE3C,SAAS,KAAK,CAAC,SAAiB;IAC9B,MAAM,QAAQ,GAAG,IAAI,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC;IACrC,IAAI,QAAQ,EAAE,CAAC;QACb,qCAAqC;QACrC,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC;QACvB,IAAI,CAAC,GAAG,CAAC,SAAS,EAAE,QAAQ,CAAC,CAAC;QAC9B,OAAO,QAAQ,CAAC;IAClB,CAAC;IACD,MAAM,OAAO,GAAe,EAAE,IAAI,EAAE,CAAC,EAAE,MAAM,EAAE,EAAE,EAAE,CAAC;IACpD,IAAI,CAAC,GAAG,CAAC,SAAS,EAAE,OAAO,CAAC,CAAC;IAC7B,OAAO,IAAI,CAAC,IAAI,GAAG,YAAY,EAAE,CAAC;QAChC,MAAM,MAAM,GAAG,IAAI,CAAC,IAAI,EAAE,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC;QACxC,IAAI,MAAM,KAAK,SAAS;YAAE,MAAM;QAChC,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;IACtB,CAAC;IACD,OAAO,OAAO,CAAC;AACjB,CAAC;AAED,oBAAoB;AACpB,MAAM,UAAU,WAAW,CAAC,SAAiB,EAAE,KAAa,EAAE,IAAa;IACzE,MAAM,GAAG,GAAG,KAAK,CAAC,SAAS,CAAC,CAAC;IAC7B,MAAM,EAAE,GAAG,GAAG,CAAC,IAAI,EAAE,CAAC;IACtB,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,EAAE,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC;IACrC,IAAI,GAAG,CAAC,MAAM,CAAC,MAAM,GAAG,sBAAsB,EAAE,CAAC;QAC/C,GAAG,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,MAAM,GAAG,sBAAsB,CAAC,CAAC;IACnE,CAAC;IACD,OAAO,EAAE,CAAC;AACZ,CAAC;AAQD;;;;;;GAMG;AACH,MAAM,UAAU,WAAW,CAAC,SAAiB,EAAE,WAAmB;IAChE,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC;IAChC,IAAI,CAAC,GAAG;QAAE,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,CAAC;IACpC,uCAAuC;IACvC,IAAI,WAAW,IAAI,GAAG,CAAC,IAAI;QAAE,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,CAAC;IACvD,MAAM,MAAM,GAAG,GAAG,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,GAAG,WAAW,CAAC,CAAC;IAC5D,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,EAAE,EAAE,EAAE,CAAC;IAC/D,IAAI,MAAM,CAAC,CAAC,CAAE,CAAC,EAAE,GAAG,WAAW,GAAG,CAAC;QAAE,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,CAAC;IAC/D,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,EAAE,CAAC;AACpC,CAAC;AAED,wBAAwB;AACxB,MAAM,UAAU,cAAc,CAAC,SAAiB;IAC9C,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC;AACzB,CAAC;AAED,iBAAiB;AACjB,MAAM,UAAU,iBAAiB;IAC/B,IAAI,CAAC,KAAK,EAAE,CAAC;AACf,CAAC"}
@@ -11,6 +11,7 @@
11
11
  * - 干掉原 console/sse/eventBus.ts(所有 chat-specific publish 挪到 chatBus 里)
12
12
  */
13
13
  import { Hono } from 'hono';
14
+ import type { InteractionRespond } from '../../chat-protocol/index.js';
14
15
  import type { Logger } from '../../core/logger.js';
15
16
  import { DaemonClient } from '../../daemon/daemonClient.js';
16
17
  import type { ChatService } from '../../services/ChatService.js';
@@ -34,6 +35,8 @@ export type ChatMessageBlock = {
34
35
  type: 'interaction';
35
36
  id: string;
36
37
  kind: string;
38
+ /** 答案怎么送回去(见 chat-protocol/form 的 InteractionRespond);旧数据缺省按 endpoint。 */
39
+ respond?: InteractionRespond;
37
40
  title: string;
38
41
  schema: unknown;
39
42
  revision: number;
@@ -1 +1 @@
1
- {"version":3,"file":"chat.d.ts","sourceRoot":"","sources":["../../../src/console/routes/chat.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAUH,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAS5B,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,sBAAsB,CAAC;AACnD,OAAO,EAAE,YAAY,EAAE,MAAM,8BAA8B,CAAC;AAO5D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,+BAA+B,CAAC;AAuBjE,KAAK,gBAAgB,GAAG,IAAI,CAC1B,YAAY,EACV,eAAe,GACf,UAAU,GACV,SAAS,GACT,aAAa,GACb,uBAAuB,GACvB,UAAU,GACV,mBAAmB,GACnB,mBAAmB,CACtB,CAAC;AAEF,MAAM,WAAW,qBAAqB;IACpC,YAAY,CAAC,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,CAAC;IACtC,kBAAkB,CAAC,EAAE,MAAM,gBAAgB,CAAC;CAC7C;AAkDD,MAAM,MAAM,gBAAgB,GACxB;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAC9B;IAAE,IAAI,EAAE,UAAU,CAAC;IAAC,EAAE,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,OAAO,CAAC;IAAC,SAAS,CAAC,EAAE,OAAO,CAAA;CAAE,GACnH;IACE,IAAI,EAAE,aAAa,CAAC;IACpB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,OAAO,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,SAAS,GAAG,UAAU,GAAG,WAAW,CAAC;IAC7C,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB,CAAC;AAEN,MAAM,WAAW,WAAW;IAC1B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,GAAG,WAAW,GAAG,OAAO,CAAC;IACrC,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,gBAAgB,EAAE,CAAC;IAC5B,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,CAAC,EAAE,WAAW,GAAG,UAAU,GAAG,OAAO,CAAC;IAC5C,oCAAoC;IACpC,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;CACxB;AAoBD,6CAA6C;AAC7C,MAAM,MAAM,UAAU,GAAG,SAAS,GAAG,MAAM,GAAG,OAAO,CAAC;AACtD,MAAM,WAAW,QAAQ;IACvB,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,EAAE,UAAU,CAAC;IACnB,GAAG,EAAE,MAAM,CAAC;IACZ,EAAE,EAAE,MAAM,CAAC;CACZ;AAoFD;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,CAAC,EAAE,OAAO,GAAG,OAAO,CAGpD;AAgmCD,wBAAgB,eAAe,CAC7B,GAAG,EAAE,MAAM,EACX,IAAI,EAAE,WAAW,EACjB,YAAY,GAAE,qBAA0B,GACvC,IAAI,CA8kBN;AAED,wBAAgB,qBAAqB,IAAI,IAAI,CA6G5C"}
1
+ {"version":3,"file":"chat.d.ts","sourceRoot":"","sources":["../../../src/console/routes/chat.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAUH,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAW5B,OAAO,KAAK,EAAY,kBAAkB,EAAE,MAAM,8BAA8B,CAAC;AAMjF,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,sBAAsB,CAAC;AACnD,OAAO,EAAE,YAAY,EAAE,MAAM,8BAA8B,CAAC;AAO5D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,+BAA+B,CAAC;AAuBjE,KAAK,gBAAgB,GAAG,IAAI,CAC1B,YAAY,EACV,eAAe,GACf,UAAU,GACV,SAAS,GACT,aAAa,GACb,uBAAuB,GACvB,UAAU,GACV,mBAAmB,GACnB,mBAAmB,CACtB,CAAC;AAEF,MAAM,WAAW,qBAAqB;IACpC,YAAY,CAAC,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,CAAC;IACtC,kBAAkB,CAAC,EAAE,MAAM,gBAAgB,CAAC;CAC7C;AAmED,MAAM,MAAM,gBAAgB,GACxB;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAC9B;IAAE,IAAI,EAAE,UAAU,CAAC;IAAC,EAAE,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,OAAO,CAAC;IAAC,SAAS,CAAC,EAAE,OAAO,CAAA;CAAE,GACnH;IACE,IAAI,EAAE,aAAa,CAAC;IACpB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,0EAA0E;IAC1E,OAAO,CAAC,EAAE,kBAAkB,CAAC;IAC7B,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,OAAO,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,SAAS,GAAG,UAAU,GAAG,WAAW,CAAC;IAC7C,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB,CAAC;AAEN,MAAM,WAAW,WAAW;IAC1B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,GAAG,WAAW,GAAG,OAAO,CAAC;IACrC,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,gBAAgB,EAAE,CAAC;IAC5B,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,CAAC,EAAE,WAAW,GAAG,UAAU,GAAG,OAAO,CAAC;IAC5C,oCAAoC;IACpC,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;CACxB;AAoBD,6CAA6C;AAC7C,MAAM,MAAM,UAAU,GAAG,SAAS,GAAG,MAAM,GAAG,OAAO,CAAC;AACtD,MAAM,WAAW,QAAQ;IACvB,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,EAAE,UAAU,CAAC;IACnB,GAAG,EAAE,MAAM,CAAC;IACZ,EAAE,EAAE,MAAM,CAAC;CACZ;AAoFD;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,CAAC,EAAE,OAAO,GAAG,OAAO,CAGpD;AAunCD,wBAAgB,eAAe,CAC7B,GAAG,EAAE,MAAM,EACX,IAAI,EAAE,WAAW,EACjB,YAAY,GAAE,qBAA0B,GACvC,IAAI,CA+kBN;AAED,wBAAgB,qBAAqB,IAAI,IAAI,CA+I5C"}
@@ -17,23 +17,19 @@ import { uniqueTmpPath } from '../../infra/filesystem.js';
17
17
  import { basename, dirname, resolve } from 'node:path';
18
18
  import { fileURLToPath } from 'node:url';
19
19
  import { spsMcpStdioConfig } from '../../providers/mcp/spsMcpStdioConfig.js';
20
- import { Agent, run } from '@openai/agents';
21
20
  import { Hono } from 'hono';
22
21
  import { ensureDaemon } from '../../commands/agentDaemon.js';
23
22
  import { jsonlExists, readSessionModel, recoverTrailingText, sanitizeToolInput } from '../../core/claudeJsonl.js';
24
- import { readAgentsMd } from '../../core/agents/agentsMd.js';
23
+ import { appendFrame, dropSessionLog, framesSince } from '../chatEventLog.js';
24
+ import { expandChoicesSpec, expandFormSpec, } from '../../core/interactions/formSpec.js';
25
+ import { INTERACTION_TOOL_NAMES } from '../../providers/mcp/interactionTools.js';
25
26
  import { readProjectAgentConfig } from '../../core/agents/projectAgentConfig.js';
26
27
  import { resolveWorkerModelEnv } from '../../core/agents/workerModelEnv.js';
27
28
  import { readModelSettingsFile, writeModelSettingsFile } from '../../core/agents/modelSettingsFile.js';
28
29
  import { loadAgentsConfig } from '../../core/intel/agentsConfig.js';
29
- import { RuntimeSession } from '../../core/intel/RuntimeSession.js';
30
30
  import { DaemonClient } from '../../daemon/daemonClient.js';
31
31
  import { resolveEligibleSkillNames, SkillsManager } from '../../core/skills/index.js';
32
- import { chatTools } from '../../providers/llm/chatTools.js';
33
- import { skillTools } from '../../providers/llm/skillTools.js';
34
- import { createReviewerModel } from '../../providers/llm/modelFactory.js';
35
32
  import { resolveAgentProvider } from '../../providers/llm/providers.js';
36
- import { workerTools } from '../../providers/llm/workerTools.js';
37
33
  import { toHttpStatus, toProblemJson } from '../../shared/errors.js';
38
34
  import { chatAttachmentsDirFor, chatSessionsDir, chatWorkspacesRoot } from '../../shared/runtimePaths.js';
39
35
  import { workspacesRoot } from '../../core/workspacePaths.js';
@@ -106,6 +102,22 @@ function resolveChatModel(agentKind, cwd) {
106
102
  */
107
103
  const chatBus = new EventEmitter();
108
104
  chatBus.setMaxListeners(0);
105
+ /**
106
+ * 发布一条 chat 事件:**先记进会话事件日志拿到帧号,再广播**。
107
+ *
108
+ * 帧号是 SSE 的 `id:`,浏览器重连会自动带 `Last-Event-ID` 回来,服务端据此逐事件补发
109
+ * (见 `console/chatEventLog`)。所有对外事件都必须走这里 —— 绕过它的事件不会被记录,
110
+ * 重连时就是一个静默的洞。
111
+ */
112
+ function publish(event, data) {
113
+ const sessionId = typeof data.sessionId === 'string' ? data.sessionId : '';
114
+ if (!sessionId) {
115
+ chatBus.emit(event, data);
116
+ return;
117
+ }
118
+ const frameId = appendFrame(sessionId, event, data);
119
+ chatBus.emit(event, { ...data, frameId });
120
+ }
109
121
  const activeStreams = new Map();
110
122
  /**
111
123
  * startRun 失败 → 用户可读文案。daemon 总闸拒绝(code='CAPACITY')带排队深度 + 建议重试秒数
@@ -183,7 +195,7 @@ function startTurn(sessionId, assistantId) {
183
195
  const t = activeStreams.get(sessionId);
184
196
  if (!t)
185
197
  return;
186
- chatBus.emit('chat.message.heartbeat', { sessionId, assistantId, turnId: assistantId, seq: t.seq, ts: new Date().toISOString() });
198
+ publish('chat.message.heartbeat', { sessionId, assistantId, turnId: assistantId, seq: t.seq, ts: new Date().toISOString() });
187
199
  }, HEARTBEAT_MS);
188
200
  if (typeof heartbeat.unref === 'function')
189
201
  heartbeat.unref();
@@ -209,11 +221,12 @@ function appendInteractionBlock(sessionId, assistantId, blocks, event) {
209
221
  kind: event.kind,
210
222
  title: event.title,
211
223
  schema: event.schema,
224
+ respond: event.respond ?? 'endpoint',
212
225
  revision: event.revision ?? 1,
213
226
  status: 'pending',
214
227
  };
215
228
  blocks.push(block);
216
- chatBus.emit('chat.message.chunk.interaction', {
229
+ publish('chat.message.chunk.interaction', {
217
230
  sessionId,
218
231
  assistantId,
219
232
  turnId: assistantId,
@@ -222,6 +235,89 @@ function appendInteractionBlock(sessionId, assistantId, blocks, event) {
222
235
  });
223
236
  return block;
224
237
  }
238
+ /**
239
+ * 交互工具的**工具块抑制器**。
240
+ *
241
+ * 🔴 为什么要抑制:`present_form` / `present_choices` 的 UI **就是交互块本身**,再画一条
242
+ * 工具调用小条是多余的,而且有两个真实的误导:
243
+ * - openai 路上这次工具调用会**真的挂起**(run.waiting_for_input),挂起期间工具块是非终态,
244
+ * 前端按非终态渲染成转圈 —— 用户填表那几十秒里它一直在转,看着像 agent 还在想;
245
+ * - 恢复后该块落成 `interrupted`,前端显示【已中断】,而这其实是**正常**的挂起-恢复。
246
+ *
247
+ * **两条后端路径现在走同一条**:收集输入类交互(表单/选择器)在两边都是"工具即刻返回、
248
+ * 回合收尾、用户确认后那句指令作为新的用户消息进来"。所以这里统一把 tool_use **转成**
249
+ * 交互块,答案走 `respond:'message'`。
250
+ *
251
+ * (openai 那边曾经把 run 挂起等答案 —— 那是单轮进程在假装自己有状态,比暖进程更脆
252
+ * 却拿不到任何好处;已拆除,见 openai-agent/interactions/broker.ts 的 tools()。)
253
+ *
254
+ * ⚠️ 审批 / 对账不走这里:它们是**执行门槛**,必须真的把 run 卡住,仍由 worker 发权威的
255
+ * `interaction` 事件、答案走交互端点恢复。
256
+ */
257
+ function createInteractionToolSink(sessionId, assistantId, blocks) {
258
+ /** toolCallId → 工具名;首帧参数常为空,要等 tool_update 才凑得出 spec。 */
259
+ const tracked = new Map();
260
+ const emitted = new Set();
261
+ const tryEmit = (id, title, input) => {
262
+ if (emitted.has(id))
263
+ return;
264
+ const spec = toInteractionSpec(title, input);
265
+ if (!spec)
266
+ return; // 参数还没到齐,等下一帧
267
+ emitted.add(id);
268
+ appendInteractionBlock(sessionId, assistantId, blocks, {
269
+ interactionId: id,
270
+ kind: baseToolName(title) === 'present_choices' ? 'choices' : 'form',
271
+ title: spec.title,
272
+ schema: spec,
273
+ respond: 'message',
274
+ revision: 1,
275
+ });
276
+ };
277
+ return {
278
+ onToolUse: (id, title, input) => {
279
+ if (!isInteractionTool(title))
280
+ return false;
281
+ tracked.set(id, title);
282
+ tryEmit(id, title, input);
283
+ return true;
284
+ },
285
+ onToolUpdate: (id, input) => {
286
+ const title = tracked.get(id);
287
+ if (!title)
288
+ return false;
289
+ tryEmit(id, title, input);
290
+ return true;
291
+ },
292
+ };
293
+ }
294
+ /** 工具名去掉 MCP 前缀(claude 侧是 `mcp__sps__present_form`,openai broker 侧是裸名)。 */
295
+ function baseToolName(title) {
296
+ return title.startsWith('mcp__sps__') ? title.slice('mcp__sps__'.length) : title;
297
+ }
298
+ /** 这个工具是不是"要往返 chat UI"的交互工具(present_form / present_choices)。 */
299
+ function isInteractionTool(title) {
300
+ return INTERACTION_TOOL_NAMES.has(baseToolName(title));
301
+ }
302
+ /**
303
+ * 把交互工具的原始入参归一成规范 `FormSpec`(见 `core/interactions/formSpec`)。
304
+ *
305
+ * 🔴 此前 `template:'image'` 是客户端展开的 —— console 一份、openai broker 一份,注释互相
306
+ * 提醒"要手工同步";coral 与 gameforge 接生图表单就是第三、第四份。展开挪到这里之后,
307
+ * 发出去的事件永远携带完整字段表,产品端零模板知识。省 token 的初衷不变:模板名只在
308
+ * 模型 ↔ 工具之间传,展开发生在工具调用被透传给前端的那一刻。
309
+ *
310
+ * 选择器也走同一套 schema(只有 radio 的表单),让产品只维护一个渲染器。
311
+ * 展不开(参数还没到 / 无效)→ null,调用方等 tool_update 的完整参数。
312
+ */
313
+ function toInteractionSpec(toolTitle, input) {
314
+ const base = baseToolName(toolTitle);
315
+ if (base === 'present_form')
316
+ return expandFormSpec(input);
317
+ if (base === 'present_choices')
318
+ return expandChoicesSpec(input);
319
+ return null;
320
+ }
225
321
  function findInteractionBlock(session, interactionId) {
226
322
  for (let index = session.messages.length - 1; index >= 0; index--) {
227
323
  const message = session.messages[index];
@@ -306,13 +402,6 @@ function reconcileStaleTurns(log) {
306
402
  }
307
403
  async function streamAssistantResponse(sessionId, assistantId, userContent, log, attachments, dependencies = {}) {
308
404
  const selected = readSession(sessionId);
309
- // 旧 OpenAI 会话没有 agentMode,继续走历史 SDK 会话文件;只有显式 daemon 的
310
- // 新会话才迁入统一宿主,避免升级后丢掉既有上下文。
311
- if (selected?.agentKind === 'openai' &&
312
- (selected.agentMode ?? 'sdk') === 'sdk') {
313
- await streamOpenaiChat(sessionId, assistantId, userContent, log);
314
- return;
315
- }
316
405
  const backend = selected?.agentKind === 'openai' ? 'openai' : 'claude';
317
406
  if (!(await (dependencies.ensureDaemon ?? ensureDaemon)())) {
318
407
  persistAndEmitComplete(sessionId, {
@@ -571,6 +660,7 @@ async function streamAssistantResponse(sessionId, assistantId, userContent, log,
571
660
  const subscription = client.subscribeRunResilient(slot);
572
661
  // 复用 POST 时 startTurn 预登记的进行中回合(blocks 引用,后续就地追加);未预登记则退化自建(防御)。
573
662
  const blocks = activeStreams.get(sessionId)?.blocks ?? startTurn(sessionId, assistantId);
663
+ const interactionSink = createInteractionToolSink(sessionId, assistantId, blocks);
574
664
  let accumulatedText = '';
575
665
  let truncated = false;
576
666
  const consume = (async () => {
@@ -614,7 +704,7 @@ async function streamAssistantResponse(sessionId, assistantId, userContent, log,
614
704
  last.text += piece;
615
705
  else
616
706
  blocks.push({ type: 'text', text: piece });
617
- chatBus.emit('chat.message.chunk.text', {
707
+ publish('chat.message.chunk.text', {
618
708
  sessionId,
619
709
  assistantId,
620
710
  turnId: assistantId,
@@ -628,6 +718,9 @@ async function streamAssistantResponse(sessionId, assistantId, userContent, log,
628
718
  }
629
719
  else if (evt.event === 'tool_use') {
630
720
  const input = sanitizeToolInput(evt.input);
721
+ // 交互工具由 sink 接管(转成交互块 / 丢弃),不再画工具小条。
722
+ if (interactionSink.onToolUse(evt.id, evt.title, input))
723
+ continue;
631
724
  blocks.push({
632
725
  type: 'tool_use',
633
726
  id: evt.id,
@@ -637,7 +730,7 @@ async function streamAssistantResponse(sessionId, assistantId, userContent, log,
637
730
  input,
638
731
  locations: evt.locations,
639
732
  });
640
- chatBus.emit('chat.message.chunk.tool_use', {
733
+ publish('chat.message.chunk.tool_use', {
641
734
  sessionId,
642
735
  assistantId,
643
736
  turnId: assistantId,
@@ -651,8 +744,11 @@ async function streamAssistantResponse(sessionId, assistantId, userContent, log,
651
744
  });
652
745
  }
653
746
  else if (evt.event === 'tool_update') {
654
- const tool = blocks.find((b) => b.type === 'tool_use' && b.id === evt.id);
655
747
  const lateInput = evt.input != null ? sanitizeToolInput(evt.input) : undefined;
748
+ // 交互工具的完整参数常常**迟到**(流式首帧 rawInput 为 {}),交互块可能要到这一帧才发得出。
749
+ if (interactionSink.onToolUpdate(evt.id, lateInput))
750
+ continue;
751
+ const tool = blocks.find((b) => b.type === 'tool_use' && b.id === evt.id);
656
752
  if (tool && tool.type === 'tool_use') {
657
753
  tool.status = evt.status;
658
754
  // 完整参数迟到时补上:空对象 `{}` 也算"待补"(流式首帧 rawInput 为 {},完整值在 update 里)。
@@ -661,7 +757,7 @@ async function streamAssistantResponse(sessionId, assistantId, userContent, log,
661
757
  if (evt.locations != null && tool.locations == null)
662
758
  tool.locations = evt.locations;
663
759
  }
664
- chatBus.emit('chat.message.chunk.tool_update', {
760
+ publish('chat.message.chunk.tool_update', {
665
761
  sessionId,
666
762
  assistantId,
667
763
  turnId: assistantId,
@@ -829,6 +925,9 @@ function openaiResumeEnsureArgs(sessionId, session) {
829
925
  async function streamInteractionResponse(sessionId, assistantId, client, slot, afterSequence, log) {
830
926
  const blocks = activeStreams.get(sessionId)?.blocks ??
831
927
  startTurn(sessionId, assistantId);
928
+ // 交互恢复路只发生在 openai(durable interactions:审批/对账)。恢复后的回合里若再弹
929
+ // 表单/选择器,同样收成交互块(与首轮同一条规则)。
930
+ const interactionSink = createInteractionToolSink(sessionId, assistantId, blocks);
832
931
  const subscription = client.subscribeRunResilient(slot, afterSequence);
833
932
  let accumulatedText = '';
834
933
  let truncated = false;
@@ -854,7 +953,7 @@ async function streamInteractionResponse(sessionId, assistantId, client, slot, a
854
953
  last.text += piece;
855
954
  else
856
955
  blocks.push({ type: 'text', text: piece });
857
- chatBus.emit('chat.message.chunk.text', {
956
+ publish('chat.message.chunk.text', {
858
957
  sessionId,
859
958
  assistantId,
860
959
  turnId: assistantId,
@@ -866,6 +965,9 @@ async function streamInteractionResponse(sessionId, assistantId, client, slot, a
866
965
  }
867
966
  else if (evt.event === 'tool_use') {
868
967
  const input = sanitizeToolInput(evt.input);
968
+ // 交互工具由 sink 接管(本函数是 openai 的恢复路,sink 只做丢弃)。
969
+ if (interactionSink.onToolUse(evt.id, evt.title, input))
970
+ continue;
869
971
  blocks.push({
870
972
  type: 'tool_use',
871
973
  id: evt.id,
@@ -875,7 +977,7 @@ async function streamInteractionResponse(sessionId, assistantId, client, slot, a
875
977
  input,
876
978
  locations: evt.locations,
877
979
  });
878
- chatBus.emit('chat.message.chunk.tool_use', {
980
+ publish('chat.message.chunk.tool_use', {
879
981
  sessionId,
880
982
  assistantId,
881
983
  turnId: assistantId,
@@ -889,8 +991,10 @@ async function streamInteractionResponse(sessionId, assistantId, client, slot, a
889
991
  });
890
992
  }
891
993
  else if (evt.event === 'tool_update') {
892
- const tool = blocks.find((block) => block.type === 'tool_use' && block.id === evt.id);
893
994
  const input = evt.input == null ? undefined : sanitizeToolInput(evt.input);
995
+ if (interactionSink.onToolUpdate(evt.id, input))
996
+ continue;
997
+ const tool = blocks.find((block) => block.type === 'tool_use' && block.id === evt.id);
894
998
  if (tool?.type === 'tool_use') {
895
999
  tool.status = evt.status;
896
1000
  if (input !== undefined &&
@@ -902,7 +1006,7 @@ async function streamInteractionResponse(sessionId, assistantId, client, slot, a
902
1006
  tool.locations = evt.locations;
903
1007
  }
904
1008
  }
905
- chatBus.emit('chat.message.chunk.tool_update', {
1009
+ publish('chat.message.chunk.tool_update', {
906
1010
  sessionId,
907
1011
  assistantId,
908
1012
  turnId: assistantId,
@@ -1051,77 +1155,6 @@ function loadBaseSkill(baseSkill, cwd, log) {
1051
1155
  log?.warn(`baseSkill "${chosen}" 加载失败 —— 本会话不注入基座 skill(检查 HQ/项目 skills 里是否存在)`);
1052
1156
  return '';
1053
1157
  }
1054
- async function streamOpenaiChat(sessionId, assistantId, userContent, log) {
1055
- // 复用 POST 时 startTurn 预登记的进行中回合(blocks 引用);未预登记则退化自建(防御)。
1056
- const blocks = activeStreams.get(sessionId)?.blocks ?? startTurn(sessionId, assistantId);
1057
- let acc = '';
1058
- try {
1059
- const model = createReviewerModel(loadAgentsConfig().reviewer);
1060
- // 设了工作目录 → 额外挂工作区文件工具(读/写/列/bash),让它能在已有项目里直接干活。
1061
- const sess = readSession(sessionId);
1062
- const cwd = sess?.cwd ?? undefined;
1063
- // per-session 注入角色:有工作目录就读它的 AGENTS.md 当 instructions(项目定义角色),否则用通用兜底。
1064
- // pinnedSkills:开局强制预载的 skill 正文(确定性,不赌模型自觉),prepend 在角色指令前。
1065
- const instructions = loadPinnedSkills(sess?.pinnedSkills, cwd) + (readAgentsMd(cwd) ?? loadBaseSkill(sess?.baseSkill, cwd, log));
1066
- // skill 发现/加载(list/search/load,经 SkillsManager 读 HQ)始终在;有 cwd 再加工作区文件/bash。
1067
- const tools = [...chatTools(), ...skillTools(cwd), ...(cwd ? workerTools(cwd) : [])];
1068
- const agent = new Agent({ name: 'chat', instructions, model, tools });
1069
- // 会话历史持久化(跨消息记忆),后端 = chat-sessions/<id>.openai-session.json。
1070
- const session = new RuntimeSession(resolve(chatSessionsDir(), `${sessionId}.openai-session.json`), `chat-${sessionId}`);
1071
- const r = await run(agent, userContent, { stream: true, session });
1072
- // 迭代完整事件流:raw 文本 delta → 流式文本块;run_item(tool_called/tool_output)→ tool_use 块。
1073
- for await (const ev of r) {
1074
- if (ev.type === 'raw_model_stream_event') {
1075
- const data = ev.data;
1076
- if (typeof data?.delta === 'string' && data.delta && (data.type ?? '').includes('output_text')) {
1077
- const piece = data.delta;
1078
- acc += piece;
1079
- const last = blocks[blocks.length - 1];
1080
- if (last?.type === 'text')
1081
- last.text += piece;
1082
- else
1083
- blocks.push({ type: 'text', text: piece });
1084
- chatBus.emit('chat.message.chunk.text', { sessionId, assistantId, turnId: assistantId, seq: bumpSeq(sessionId), text: piece });
1085
- }
1086
- }
1087
- else if (ev.type === 'run_item_stream_event') {
1088
- const evName = ev.name;
1089
- const raw = ev.item?.rawItem;
1090
- if (evName === 'tool_called' && raw?.type === 'function_call') {
1091
- const id = raw.callId ?? `tool-${blocks.length}`;
1092
- let parsedArgs;
1093
- try {
1094
- parsedArgs = raw.arguments ? JSON.parse(raw.arguments) : undefined;
1095
- }
1096
- catch {
1097
- parsedArgs = raw.arguments;
1098
- }
1099
- const input = sanitizeToolInput(parsedArgs);
1100
- blocks.push({ type: 'tool_use', id, title: raw.name ?? 'tool', kind: 'function', status: 'in_progress', input });
1101
- chatBus.emit('chat.message.chunk.tool_use', { sessionId, assistantId, turnId: assistantId, seq: bumpSeq(sessionId), id, title: raw.name ?? 'tool', kind: 'function', status: 'in_progress', input });
1102
- }
1103
- else if (evName === 'tool_output') {
1104
- const id = raw?.callId;
1105
- const b = blocks.find((x) => x.type === 'tool_use' && (id ? x.id === id : x.status === 'in_progress'));
1106
- if (b && b.type === 'tool_use') {
1107
- b.status = 'completed';
1108
- chatBus.emit('chat.message.chunk.tool_update', { sessionId, assistantId, turnId: assistantId, seq: bumpSeq(sessionId), id: b.id, status: 'completed' });
1109
- }
1110
- }
1111
- }
1112
- }
1113
- await r.completed;
1114
- if (!acc.trim())
1115
- acc = String(r.finalOutput ?? ''); // 兜底:无 raw 文本 delta 时
1116
- persistAndEmitComplete(sessionId, { id: assistantId, role: 'assistant', content: acc.trim(), blocks: blocks.length ? blocks : undefined, ts: new Date().toISOString(), status: 'complete' }, log);
1117
- }
1118
- catch (err) {
1119
- persistAndEmitComplete(sessionId, { id: assistantId, role: 'error', content: `openai chat failed: ${err instanceof Error ? err.message : String(err)}`, ts: new Date().toISOString(), status: 'error' }, log);
1120
- }
1121
- finally {
1122
- endTurn(sessionId); // 安全网:complete/error path 已 endTurn;此处兜底停心跳/撤快照
1123
- }
1124
- }
1125
1158
  /**
1126
1159
  * v0.51.8:在 user 消息末尾追加附件清单。Worker(Claude Code)能用 Read 工具
1127
1160
  * 自己拉文件内容;图片 / PDF / 文本 / 等都原生支持。
@@ -1179,7 +1212,7 @@ function persistAndEmitComplete(sessionId, msg, log) {
1179
1212
  writeSession(session);
1180
1213
  }
1181
1214
  // 明确的终止事件(渐进增强:老客户端仍靠 complete 落定;新字段 turnId/final/seq/text 供补偿与判序)。
1182
- chatBus.emit('chat.message.complete', {
1215
+ publish('chat.message.complete', {
1183
1216
  sessionId,
1184
1217
  assistantId: turnId,
1185
1218
  turnId,
@@ -1190,7 +1223,7 @@ function persistAndEmitComplete(sessionId, msg, log) {
1190
1223
  });
1191
1224
  // 回合异常:再发一个明确的 error 事件(带 turnId),不静默中断。
1192
1225
  if (msg.role === 'error') {
1193
- chatBus.emit('chat.message.error', { sessionId, assistantId: turnId, turnId, final: true, seq, message: msg });
1226
+ publish('chat.message.error', { sessionId, assistantId: turnId, turnId, final: true, seq, message: msg });
1194
1227
  log.warn(`chat[${sessionId}]: ${msg.content}`);
1195
1228
  }
1196
1229
  else {
@@ -1237,7 +1270,8 @@ export function createChatRoute(log, chat, dependencies = {}) {
1237
1270
  const agentKind = body.agentKind === 'openai' ? 'openai' : 'claude';
1238
1271
  const configuredWorker = loadAgentsConfig().worker;
1239
1272
  // 新建会话默认 daemon:openai 走真正的独立进程 agent(sps openai-agent,带 sandbox + 工具 + 技能)。
1240
- // 旧会话(已持久 agentMode='sdk')不受影响,dispatch 侧仍按各自持久值走。
1273
+ // 注:chat 侧已不按 agentMode 分流(阶段 3 收敛,openai 只剩统一宿主一条路);
1274
+ // 该字段仅作为会话元数据保留,dispatch 侧仍按各自持久值走。
1241
1275
  const agentMode = body.agentMode ??
1242
1276
  (configuredWorker.agent === agentKind ? configuredWorker.mode : 'daemon');
1243
1277
  const r = await chat.create({
@@ -1256,7 +1290,7 @@ export function createChatRoute(log, chat, dependencies = {}) {
1256
1290
  });
1257
1291
  if (!r.ok)
1258
1292
  return c.json(toProblemJson(r.error), toHttpStatus(r.error));
1259
- chatBus.emit('chat.session.created', { sessionId: r.value.id });
1293
+ publish('chat.session.created', { sessionId: r.value.id });
1260
1294
  return c.json(r.value, 201);
1261
1295
  });
1262
1296
  app.get('/sessions/:id', async (c) => {
@@ -1297,7 +1331,8 @@ export function createChatRoute(log, chat, dependencies = {}) {
1297
1331
  catch {
1298
1332
  /* non-fatal */
1299
1333
  }
1300
- chatBus.emit('chat.session.deleted', { sessionId: id });
1334
+ publish('chat.session.deleted', { sessionId: id });
1335
+ dropSessionLog(id); // 事件日志跟着会话走,别留孤儿
1301
1336
  return c.body(null, 204);
1302
1337
  });
1303
1338
  app.post('/sessions/:id/interactions/:interactionId', async (c) => {
@@ -1307,8 +1342,9 @@ export function createChatRoute(log, chat, dependencies = {}) {
1307
1342
  if (!session) {
1308
1343
  return c.json({ type: 'not-found', title: 'Session not found', status: 404 }, 404);
1309
1344
  }
1310
- if (session.agentKind !== 'openai' ||
1311
- (session.agentMode ?? 'sdk') !== 'daemon') {
1345
+ // 阶段 3 之后 openai chat 只有一条路(统一宿主),`agentMode` 不再选择代码路径,
1346
+ // 因此这里只看后端 —— 否则历史上 agentMode='sdk' 的会话会被挡在交互之外。
1347
+ if (session.agentKind !== 'openai') {
1312
1348
  return c.json({
1313
1349
  type: 'validation',
1314
1350
  title: 'Session does not support durable interactions',
@@ -1385,7 +1421,7 @@ export function createChatRoute(log, chat, dependencies = {}) {
1385
1421
  latestFound.block.status = settled;
1386
1422
  latestFound.block.revision += 1;
1387
1423
  writeSession(latest);
1388
- chatBus.emit('chat.message.interaction.updated', {
1424
+ publish('chat.message.interaction.updated', {
1389
1425
  sessionId: id,
1390
1426
  interactionId,
1391
1427
  status: settled,
@@ -1412,7 +1448,7 @@ export function createChatRoute(log, chat, dependencies = {}) {
1412
1448
  latestFound.block.revision += 1;
1413
1449
  writeSession(latest);
1414
1450
  }
1415
- chatBus.emit('chat.message.interaction.updated', {
1451
+ publish('chat.message.interaction.updated', {
1416
1452
  sessionId: id,
1417
1453
  interactionId,
1418
1454
  status: 'answered',
@@ -1422,7 +1458,7 @@ export function createChatRoute(log, chat, dependencies = {}) {
1422
1458
  const assistantId = randomUUID();
1423
1459
  activeSessions.add(slot);
1424
1460
  startTurn(id, assistantId);
1425
- chatBus.emit('chat.message.pending', {
1461
+ publish('chat.message.pending', {
1426
1462
  sessionId: id,
1427
1463
  assistantId,
1428
1464
  turnId: assistantId,
@@ -1480,7 +1516,7 @@ export function createChatRoute(log, chat, dependencies = {}) {
1480
1516
  latestFound.block.status = 'answered';
1481
1517
  latestFound.block.revision += 1;
1482
1518
  writeSession(latest);
1483
- chatBus.emit('chat.message.interaction.updated', {
1519
+ publish('chat.message.interaction.updated', {
1484
1520
  sessionId: id,
1485
1521
  interactionId,
1486
1522
  status: 'answered',
@@ -1506,7 +1542,7 @@ export function createChatRoute(log, chat, dependencies = {}) {
1506
1542
  latestFound.block.revision += 1;
1507
1543
  writeSession(latest);
1508
1544
  }
1509
- chatBus.emit('chat.message.interaction.updated', {
1545
+ publish('chat.message.interaction.updated', {
1510
1546
  sessionId: id,
1511
1547
  interactionId,
1512
1548
  status: 'cancelled',
@@ -1582,7 +1618,7 @@ export function createChatRoute(log, chat, dependencies = {}) {
1582
1618
  // · form/choices → "以对话作答":这里把 UI 块落定为 cancelled(worker 侧会同步取消
1583
1619
  // store 记录并把用户原话喂回 run,见 broker.resolveWithChatReply),表单按钮不再假活;
1584
1620
  // · approval(危险操作审批/账本对账)→ 安全门槛不许绕,409 让用户先作答。
1585
- if (session.agentKind === 'openai' && (session.agentMode ?? 'sdk') === 'daemon') {
1621
+ if (session.agentKind === 'openai') {
1586
1622
  for (const msg of session.messages) {
1587
1623
  for (const block of msg.blocks ?? []) {
1588
1624
  if (block.type !== 'interaction' || block.status !== 'pending')
@@ -1602,7 +1638,7 @@ export function createChatRoute(log, chat, dependencies = {}) {
1602
1638
  }
1603
1639
  block.status = 'cancelled';
1604
1640
  block.revision += 1;
1605
- chatBus.emit('chat.message.interaction.updated', {
1641
+ publish('chat.message.interaction.updated', {
1606
1642
  sessionId: id,
1607
1643
  interactionId: block.id,
1608
1644
  status: 'cancelled',
@@ -1625,12 +1661,12 @@ export function createChatRoute(log, chat, dependencies = {}) {
1625
1661
  if (session.messages.length === 1)
1626
1662
  session.title = formatTitle(body.content);
1627
1663
  writeSession(session);
1628
- chatBus.emit('chat.message', { sessionId: id, message: userMsg });
1664
+ publish('chat.message', { sessionId: id, message: userMsg });
1629
1665
  const assistantId = randomUUID();
1630
1666
  // v0.57:接受即预登记回合(activeStreams + 心跳 + 持久 lastTurn=running),消除
1631
1667
  // "lastTurn=running 但无 activeStreams"的竞态,让断线补偿/轮询从此刻起就确定。
1632
1668
  startTurn(id, assistantId);
1633
- chatBus.emit('chat.message.pending', {
1669
+ publish('chat.message.pending', {
1634
1670
  sessionId: id,
1635
1671
  assistantId,
1636
1672
  turnId: assistantId,
@@ -1656,11 +1692,17 @@ export function createChatStreamRoute() {
1656
1692
  start(controller) {
1657
1693
  const enc = new TextEncoder();
1658
1694
  let closed = false;
1695
+ /**
1696
+ * 写一帧。带 `frameId` 的写 `id:` —— 浏览器会记住它,重连时自动带
1697
+ * `Last-Event-ID` 回来,服务端据此逐事件补发(见下方的续传段)。
1698
+ * 补偿类帧(snapshot/resume 等)不带帧号,不参与游标。
1699
+ */
1659
1700
  const send = (event, data) => {
1660
1701
  if (closed)
1661
1702
  return;
1703
+ const frameId = data && typeof data === 'object' ? data.frameId : undefined;
1662
1704
  try {
1663
- controller.enqueue(enc.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`));
1705
+ controller.enqueue(enc.encode(`${typeof frameId === 'number' ? `id: ${frameId}\n` : ''}event: ${event}\ndata: ${JSON.stringify(data)}\n\n`));
1664
1706
  }
1665
1707
  catch {
1666
1708
  closed = true;
@@ -1676,6 +1718,7 @@ export function createChatStreamRoute() {
1676
1718
  'chat.message.interaction.updated',
1677
1719
  'chat.message.complete',
1678
1720
  'chat.message.error', // v0.57:明确的回合异常终止(带 turnId)
1721
+ 'chat.resync.required', // 阶段 2:游标失效,客户端须靠快照/补拉重建
1679
1722
  'chat.message.heartbeat', // v0.57:流式进行中心跳(带 turnId),区分"还在生成"vs"卡死"
1680
1723
  ];
1681
1724
  const handlers = [];
@@ -1690,18 +1733,39 @@ export function createChatStreamRoute() {
1690
1733
  chatBus.on(event, fn);
1691
1734
  handlers.push({ event, fn });
1692
1735
  }
1736
+ // ── 逐事件续传(阶段 2)──
1737
+ // 浏览器重连自动带 Last-Event-ID;断点还在窗口内就精确补发,滚出窗口才退回快照重建。
1738
+ // 放在订阅之后:JS 单线程,这中间不会有事件被 emit,既不漏也不重。
1739
+ const lastEventHeader = c.req.header('Last-Event-ID') ?? c.req.query('lastEventId') ?? '';
1740
+ const lastEventId = Number.parseInt(lastEventHeader, 10);
1741
+ let resumed = false;
1742
+ if (Number.isFinite(lastEventId) && lastEventId > 0) {
1743
+ const replay = framesSince(sessionId, lastEventId);
1744
+ if (replay.kind === 'frames') {
1745
+ // 🔴 补发时必须带上帧号:日志里存的是**发布前**的 data(没有 frameId),
1746
+ // 直接发会让 `id:` 缺失、客户端游标停在断点不前进,下次重连又从头补一遍。
1747
+ for (const frame of replay.frames) {
1748
+ send(frame.event, { ...frame.data, frameId: frame.id });
1749
+ }
1750
+ resumed = true;
1751
+ }
1752
+ else {
1753
+ // 🔴 有洞的序列比没有更危险(客户端会以为自己是完整的)——明确要求重建。
1754
+ send('chat.resync.required', { sessionId, reason: 'cursor-out-of-window' });
1755
+ }
1756
+ }
1693
1757
  // ── 断线补偿(v0.57 核心)──
1694
1758
  // 新连接(刷新/切会话/重连)时,后端据"进行中快照 + 持久 lastTurn"主动补偿,
1695
1759
  // 让"回合已结束/仍在进行"成为确定事实,客户端无需超时猜测。同步执行(JS 单线程):
1696
1760
  // 订阅 chatBus 与本段之间不会有事件被 emit → 既不漏也不重。
1697
- const active = activeStreams.get(sessionId);
1761
+ const active = resumed ? null : activeStreams.get(sessionId);
1698
1762
  if (active) {
1699
1763
  // 回合仍进行中(本进程)→ pending + 旧 snapshot(兼容老客户端)+ 新 resume(带 turnId/seq/status)。
1700
1764
  send('chat.message.pending', { sessionId, assistantId: active.assistantId, turnId: active.turnId, seq: active.seq, ts: active.ts });
1701
1765
  send('chat.message.snapshot', { sessionId, assistantId: active.assistantId, ts: active.ts, blocks: active.blocks });
1702
1766
  send('chat.message.resume', { sessionId, assistantId: active.assistantId, turnId: active.turnId, ts: active.ts, blocks: active.blocks, status: 'running', seq: active.seq });
1703
1767
  }
1704
- else {
1768
+ else if (!resumed) {
1705
1769
  // 无在途回合 → 查持久 lastTurn(轮询/补偿权威),确定性补发终止信号。
1706
1770
  const s = readSession(sessionId);
1707
1771
  const lt = s ? deriveLastTurn(s) : null;