@coralai/sps-cli 0.65.33 → 0.65.34

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 (50) 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/index.d.ts +21 -0
  10. package/dist/chat-protocol/index.d.ts.map +1 -0
  11. package/dist/chat-protocol/index.js +17 -0
  12. package/dist/chat-protocol/index.js.map +1 -0
  13. package/dist/chat-protocol/reduce.d.ts +33 -0
  14. package/dist/chat-protocol/reduce.d.ts.map +1 -0
  15. package/dist/chat-protocol/reduce.js +204 -0
  16. package/dist/chat-protocol/reduce.js.map +1 -0
  17. package/dist/chat-protocol/types.d.ts +85 -0
  18. package/dist/chat-protocol/types.d.ts.map +1 -0
  19. package/dist/chat-protocol/types.js +9 -0
  20. package/dist/chat-protocol/types.js.map +1 -0
  21. package/dist/chat-protocol/typewriter.d.ts +25 -0
  22. package/dist/chat-protocol/typewriter.d.ts.map +1 -0
  23. package/dist/chat-protocol/typewriter.js +36 -0
  24. package/dist/chat-protocol/typewriter.js.map +1 -0
  25. package/dist/console/chatEventLog.d.ts +47 -0
  26. package/dist/console/chatEventLog.d.ts.map +1 -0
  27. package/dist/console/chatEventLog.js +79 -0
  28. package/dist/console/chatEventLog.js.map +1 -0
  29. package/dist/console/routes/chat.d.ts +3 -0
  30. package/dist/console/routes/chat.d.ts.map +1 -1
  31. package/dist/console/routes/chat.js +173 -114
  32. package/dist/console/routes/chat.js.map +1 -1
  33. package/dist/console-assets/assets/{ShowcasePage-NmXybDqC.js → ShowcasePage-x-qRvSx_.js} +1 -1
  34. package/dist/console-assets/assets/{index-DCAnGMiQ.js → index-Ifjq4orx.js} +216 -216
  35. package/dist/console-assets/index.html +1 -1
  36. package/dist/core/activityStream.d.ts +1 -1
  37. package/dist/core/activityStream.d.ts.map +1 -1
  38. package/dist/core/activityStream.js +7 -2
  39. package/dist/core/activityStream.js.map +1 -1
  40. package/dist/core/agents/AgentBackend.d.ts +11 -1
  41. package/dist/core/agents/AgentBackend.d.ts.map +1 -1
  42. package/dist/core/agents/AgentBackend.js.map +1 -1
  43. package/dist/core/interactions/formSpec.d.ts +57 -0
  44. package/dist/core/interactions/formSpec.d.ts.map +1 -0
  45. package/dist/core/interactions/formSpec.js +123 -0
  46. package/dist/core/interactions/formSpec.js.map +1 -0
  47. package/dist/openai-agent/interactions/broker.d.ts.map +1 -1
  48. package/dist/openai-agent/interactions/broker.js +38 -77
  49. package/dist/openai-agent/interactions/broker.js.map +1 -1
  50. package/package.json +1 -1
@@ -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,85 @@ 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
+ * - claude(MCP):工具立即返回、回合不挂起 → 这里把 tool_use **转成**交互块,
249
+ * 答案走 `respond:'message'`(作为新的用户消息发回);
250
+ * - openai(broker):worker 自己会发权威的 `interaction` 事件 → 这里只**丢掉**工具块,
251
+ * 不重复造一个(此前两个块都发,就是截图里"一条转圈的工具条 + 一张面板")。
252
+ */
253
+ function createInteractionToolSink(sessionId, assistantId, blocks, backend) {
254
+ /** toolCallId → 工具名;首帧参数常为空,要等 tool_update 才凑得出 spec。 */
255
+ const tracked = new Map();
256
+ const emitted = new Set();
257
+ const tryEmit = (id, title, input) => {
258
+ if (backend !== 'claude' || emitted.has(id))
259
+ return;
260
+ const spec = toInteractionSpec(title, input);
261
+ if (!spec)
262
+ return; // 参数还没到齐,等下一帧
263
+ emitted.add(id);
264
+ appendInteractionBlock(sessionId, assistantId, blocks, {
265
+ interactionId: id,
266
+ kind: baseToolName(title) === 'present_choices' ? 'choices' : 'form',
267
+ title: spec.title,
268
+ schema: spec,
269
+ respond: 'message',
270
+ revision: 1,
271
+ });
272
+ };
273
+ return {
274
+ onToolUse: (id, title, input) => {
275
+ if (!isInteractionTool(title))
276
+ return false;
277
+ tracked.set(id, title);
278
+ tryEmit(id, title, input);
279
+ return true;
280
+ },
281
+ onToolUpdate: (id, input) => {
282
+ const title = tracked.get(id);
283
+ if (!title)
284
+ return false;
285
+ tryEmit(id, title, input);
286
+ return true;
287
+ },
288
+ };
289
+ }
290
+ /** 工具名去掉 MCP 前缀(claude 侧是 `mcp__sps__present_form`,openai broker 侧是裸名)。 */
291
+ function baseToolName(title) {
292
+ return title.startsWith('mcp__sps__') ? title.slice('mcp__sps__'.length) : title;
293
+ }
294
+ /** 这个工具是不是"要往返 chat UI"的交互工具(present_form / present_choices)。 */
295
+ function isInteractionTool(title) {
296
+ return INTERACTION_TOOL_NAMES.has(baseToolName(title));
297
+ }
298
+ /**
299
+ * 把交互工具的原始入参归一成规范 `FormSpec`(见 `core/interactions/formSpec`)。
300
+ *
301
+ * 🔴 此前 `template:'image'` 是客户端展开的 —— console 一份、openai broker 一份,注释互相
302
+ * 提醒"要手工同步";coral 与 gameforge 接生图表单就是第三、第四份。展开挪到这里之后,
303
+ * 发出去的事件永远携带完整字段表,产品端零模板知识。省 token 的初衷不变:模板名只在
304
+ * 模型 ↔ 工具之间传,展开发生在工具调用被透传给前端的那一刻。
305
+ *
306
+ * 选择器也走同一套 schema(只有 radio 的表单),让产品只维护一个渲染器。
307
+ * 展不开(参数还没到 / 无效)→ null,调用方等 tool_update 的完整参数。
308
+ */
309
+ function toInteractionSpec(toolTitle, input) {
310
+ const base = baseToolName(toolTitle);
311
+ if (base === 'present_form')
312
+ return expandFormSpec(input);
313
+ if (base === 'present_choices')
314
+ return expandChoicesSpec(input);
315
+ return null;
316
+ }
225
317
  function findInteractionBlock(session, interactionId) {
226
318
  for (let index = session.messages.length - 1; index >= 0; index--) {
227
319
  const message = session.messages[index];
@@ -306,13 +398,6 @@ function reconcileStaleTurns(log) {
306
398
  }
307
399
  async function streamAssistantResponse(sessionId, assistantId, userContent, log, attachments, dependencies = {}) {
308
400
  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
401
  const backend = selected?.agentKind === 'openai' ? 'openai' : 'claude';
317
402
  if (!(await (dependencies.ensureDaemon ?? ensureDaemon)())) {
318
403
  persistAndEmitComplete(sessionId, {
@@ -571,6 +656,7 @@ async function streamAssistantResponse(sessionId, assistantId, userContent, log,
571
656
  const subscription = client.subscribeRunResilient(slot);
572
657
  // 复用 POST 时 startTurn 预登记的进行中回合(blocks 引用,后续就地追加);未预登记则退化自建(防御)。
573
658
  const blocks = activeStreams.get(sessionId)?.blocks ?? startTurn(sessionId, assistantId);
659
+ const interactionSink = createInteractionToolSink(sessionId, assistantId, blocks, backend);
574
660
  let accumulatedText = '';
575
661
  let truncated = false;
576
662
  const consume = (async () => {
@@ -614,7 +700,7 @@ async function streamAssistantResponse(sessionId, assistantId, userContent, log,
614
700
  last.text += piece;
615
701
  else
616
702
  blocks.push({ type: 'text', text: piece });
617
- chatBus.emit('chat.message.chunk.text', {
703
+ publish('chat.message.chunk.text', {
618
704
  sessionId,
619
705
  assistantId,
620
706
  turnId: assistantId,
@@ -628,6 +714,9 @@ async function streamAssistantResponse(sessionId, assistantId, userContent, log,
628
714
  }
629
715
  else if (evt.event === 'tool_use') {
630
716
  const input = sanitizeToolInput(evt.input);
717
+ // 交互工具由 sink 接管(转成交互块 / 丢弃),不再画工具小条。
718
+ if (interactionSink.onToolUse(evt.id, evt.title, input))
719
+ continue;
631
720
  blocks.push({
632
721
  type: 'tool_use',
633
722
  id: evt.id,
@@ -637,7 +726,7 @@ async function streamAssistantResponse(sessionId, assistantId, userContent, log,
637
726
  input,
638
727
  locations: evt.locations,
639
728
  });
640
- chatBus.emit('chat.message.chunk.tool_use', {
729
+ publish('chat.message.chunk.tool_use', {
641
730
  sessionId,
642
731
  assistantId,
643
732
  turnId: assistantId,
@@ -651,8 +740,11 @@ async function streamAssistantResponse(sessionId, assistantId, userContent, log,
651
740
  });
652
741
  }
653
742
  else if (evt.event === 'tool_update') {
654
- const tool = blocks.find((b) => b.type === 'tool_use' && b.id === evt.id);
655
743
  const lateInput = evt.input != null ? sanitizeToolInput(evt.input) : undefined;
744
+ // 交互工具的完整参数常常**迟到**(流式首帧 rawInput 为 {}),交互块可能要到这一帧才发得出。
745
+ if (interactionSink.onToolUpdate(evt.id, lateInput))
746
+ continue;
747
+ const tool = blocks.find((b) => b.type === 'tool_use' && b.id === evt.id);
656
748
  if (tool && tool.type === 'tool_use') {
657
749
  tool.status = evt.status;
658
750
  // 完整参数迟到时补上:空对象 `{}` 也算"待补"(流式首帧 rawInput 为 {},完整值在 update 里)。
@@ -661,7 +753,7 @@ async function streamAssistantResponse(sessionId, assistantId, userContent, log,
661
753
  if (evt.locations != null && tool.locations == null)
662
754
  tool.locations = evt.locations;
663
755
  }
664
- chatBus.emit('chat.message.chunk.tool_update', {
756
+ publish('chat.message.chunk.tool_update', {
665
757
  sessionId,
666
758
  assistantId,
667
759
  turnId: assistantId,
@@ -829,6 +921,8 @@ function openaiResumeEnsureArgs(sessionId, session) {
829
921
  async function streamInteractionResponse(sessionId, assistantId, client, slot, afterSequence, log) {
830
922
  const blocks = activeStreams.get(sessionId)?.blocks ??
831
923
  startTurn(sessionId, assistantId);
924
+ // 交互恢复路只发生在 openai(durable interactions),sink 在这里只负责丢弃工具块。
925
+ const interactionSink = createInteractionToolSink(sessionId, assistantId, blocks, 'openai');
832
926
  const subscription = client.subscribeRunResilient(slot, afterSequence);
833
927
  let accumulatedText = '';
834
928
  let truncated = false;
@@ -854,7 +948,7 @@ async function streamInteractionResponse(sessionId, assistantId, client, slot, a
854
948
  last.text += piece;
855
949
  else
856
950
  blocks.push({ type: 'text', text: piece });
857
- chatBus.emit('chat.message.chunk.text', {
951
+ publish('chat.message.chunk.text', {
858
952
  sessionId,
859
953
  assistantId,
860
954
  turnId: assistantId,
@@ -866,6 +960,9 @@ async function streamInteractionResponse(sessionId, assistantId, client, slot, a
866
960
  }
867
961
  else if (evt.event === 'tool_use') {
868
962
  const input = sanitizeToolInput(evt.input);
963
+ // 交互工具由 sink 接管(本函数是 openai 的恢复路,sink 只做丢弃)。
964
+ if (interactionSink.onToolUse(evt.id, evt.title, input))
965
+ continue;
869
966
  blocks.push({
870
967
  type: 'tool_use',
871
968
  id: evt.id,
@@ -875,7 +972,7 @@ async function streamInteractionResponse(sessionId, assistantId, client, slot, a
875
972
  input,
876
973
  locations: evt.locations,
877
974
  });
878
- chatBus.emit('chat.message.chunk.tool_use', {
975
+ publish('chat.message.chunk.tool_use', {
879
976
  sessionId,
880
977
  assistantId,
881
978
  turnId: assistantId,
@@ -889,8 +986,10 @@ async function streamInteractionResponse(sessionId, assistantId, client, slot, a
889
986
  });
890
987
  }
891
988
  else if (evt.event === 'tool_update') {
892
- const tool = blocks.find((block) => block.type === 'tool_use' && block.id === evt.id);
893
989
  const input = evt.input == null ? undefined : sanitizeToolInput(evt.input);
990
+ if (interactionSink.onToolUpdate(evt.id, input))
991
+ continue;
992
+ const tool = blocks.find((block) => block.type === 'tool_use' && block.id === evt.id);
894
993
  if (tool?.type === 'tool_use') {
895
994
  tool.status = evt.status;
896
995
  if (input !== undefined &&
@@ -902,7 +1001,7 @@ async function streamInteractionResponse(sessionId, assistantId, client, slot, a
902
1001
  tool.locations = evt.locations;
903
1002
  }
904
1003
  }
905
- chatBus.emit('chat.message.chunk.tool_update', {
1004
+ publish('chat.message.chunk.tool_update', {
906
1005
  sessionId,
907
1006
  assistantId,
908
1007
  turnId: assistantId,
@@ -1051,77 +1150,6 @@ function loadBaseSkill(baseSkill, cwd, log) {
1051
1150
  log?.warn(`baseSkill "${chosen}" 加载失败 —— 本会话不注入基座 skill(检查 HQ/项目 skills 里是否存在)`);
1052
1151
  return '';
1053
1152
  }
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
1153
  /**
1126
1154
  * v0.51.8:在 user 消息末尾追加附件清单。Worker(Claude Code)能用 Read 工具
1127
1155
  * 自己拉文件内容;图片 / PDF / 文本 / 等都原生支持。
@@ -1179,7 +1207,7 @@ function persistAndEmitComplete(sessionId, msg, log) {
1179
1207
  writeSession(session);
1180
1208
  }
1181
1209
  // 明确的终止事件(渐进增强:老客户端仍靠 complete 落定;新字段 turnId/final/seq/text 供补偿与判序)。
1182
- chatBus.emit('chat.message.complete', {
1210
+ publish('chat.message.complete', {
1183
1211
  sessionId,
1184
1212
  assistantId: turnId,
1185
1213
  turnId,
@@ -1190,7 +1218,7 @@ function persistAndEmitComplete(sessionId, msg, log) {
1190
1218
  });
1191
1219
  // 回合异常:再发一个明确的 error 事件(带 turnId),不静默中断。
1192
1220
  if (msg.role === 'error') {
1193
- chatBus.emit('chat.message.error', { sessionId, assistantId: turnId, turnId, final: true, seq, message: msg });
1221
+ publish('chat.message.error', { sessionId, assistantId: turnId, turnId, final: true, seq, message: msg });
1194
1222
  log.warn(`chat[${sessionId}]: ${msg.content}`);
1195
1223
  }
1196
1224
  else {
@@ -1237,7 +1265,8 @@ export function createChatRoute(log, chat, dependencies = {}) {
1237
1265
  const agentKind = body.agentKind === 'openai' ? 'openai' : 'claude';
1238
1266
  const configuredWorker = loadAgentsConfig().worker;
1239
1267
  // 新建会话默认 daemon:openai 走真正的独立进程 agent(sps openai-agent,带 sandbox + 工具 + 技能)。
1240
- // 旧会话(已持久 agentMode='sdk')不受影响,dispatch 侧仍按各自持久值走。
1268
+ // 注:chat 侧已不按 agentMode 分流(阶段 3 收敛,openai 只剩统一宿主一条路);
1269
+ // 该字段仅作为会话元数据保留,dispatch 侧仍按各自持久值走。
1241
1270
  const agentMode = body.agentMode ??
1242
1271
  (configuredWorker.agent === agentKind ? configuredWorker.mode : 'daemon');
1243
1272
  const r = await chat.create({
@@ -1256,7 +1285,7 @@ export function createChatRoute(log, chat, dependencies = {}) {
1256
1285
  });
1257
1286
  if (!r.ok)
1258
1287
  return c.json(toProblemJson(r.error), toHttpStatus(r.error));
1259
- chatBus.emit('chat.session.created', { sessionId: r.value.id });
1288
+ publish('chat.session.created', { sessionId: r.value.id });
1260
1289
  return c.json(r.value, 201);
1261
1290
  });
1262
1291
  app.get('/sessions/:id', async (c) => {
@@ -1297,7 +1326,8 @@ export function createChatRoute(log, chat, dependencies = {}) {
1297
1326
  catch {
1298
1327
  /* non-fatal */
1299
1328
  }
1300
- chatBus.emit('chat.session.deleted', { sessionId: id });
1329
+ publish('chat.session.deleted', { sessionId: id });
1330
+ dropSessionLog(id); // 事件日志跟着会话走,别留孤儿
1301
1331
  return c.body(null, 204);
1302
1332
  });
1303
1333
  app.post('/sessions/:id/interactions/:interactionId', async (c) => {
@@ -1307,8 +1337,9 @@ export function createChatRoute(log, chat, dependencies = {}) {
1307
1337
  if (!session) {
1308
1338
  return c.json({ type: 'not-found', title: 'Session not found', status: 404 }, 404);
1309
1339
  }
1310
- if (session.agentKind !== 'openai' ||
1311
- (session.agentMode ?? 'sdk') !== 'daemon') {
1340
+ // 阶段 3 之后 openai chat 只有一条路(统一宿主),`agentMode` 不再选择代码路径,
1341
+ // 因此这里只看后端 —— 否则历史上 agentMode='sdk' 的会话会被挡在交互之外。
1342
+ if (session.agentKind !== 'openai') {
1312
1343
  return c.json({
1313
1344
  type: 'validation',
1314
1345
  title: 'Session does not support durable interactions',
@@ -1385,7 +1416,7 @@ export function createChatRoute(log, chat, dependencies = {}) {
1385
1416
  latestFound.block.status = settled;
1386
1417
  latestFound.block.revision += 1;
1387
1418
  writeSession(latest);
1388
- chatBus.emit('chat.message.interaction.updated', {
1419
+ publish('chat.message.interaction.updated', {
1389
1420
  sessionId: id,
1390
1421
  interactionId,
1391
1422
  status: settled,
@@ -1412,7 +1443,7 @@ export function createChatRoute(log, chat, dependencies = {}) {
1412
1443
  latestFound.block.revision += 1;
1413
1444
  writeSession(latest);
1414
1445
  }
1415
- chatBus.emit('chat.message.interaction.updated', {
1446
+ publish('chat.message.interaction.updated', {
1416
1447
  sessionId: id,
1417
1448
  interactionId,
1418
1449
  status: 'answered',
@@ -1422,7 +1453,7 @@ export function createChatRoute(log, chat, dependencies = {}) {
1422
1453
  const assistantId = randomUUID();
1423
1454
  activeSessions.add(slot);
1424
1455
  startTurn(id, assistantId);
1425
- chatBus.emit('chat.message.pending', {
1456
+ publish('chat.message.pending', {
1426
1457
  sessionId: id,
1427
1458
  assistantId,
1428
1459
  turnId: assistantId,
@@ -1480,7 +1511,7 @@ export function createChatRoute(log, chat, dependencies = {}) {
1480
1511
  latestFound.block.status = 'answered';
1481
1512
  latestFound.block.revision += 1;
1482
1513
  writeSession(latest);
1483
- chatBus.emit('chat.message.interaction.updated', {
1514
+ publish('chat.message.interaction.updated', {
1484
1515
  sessionId: id,
1485
1516
  interactionId,
1486
1517
  status: 'answered',
@@ -1506,7 +1537,7 @@ export function createChatRoute(log, chat, dependencies = {}) {
1506
1537
  latestFound.block.revision += 1;
1507
1538
  writeSession(latest);
1508
1539
  }
1509
- chatBus.emit('chat.message.interaction.updated', {
1540
+ publish('chat.message.interaction.updated', {
1510
1541
  sessionId: id,
1511
1542
  interactionId,
1512
1543
  status: 'cancelled',
@@ -1582,7 +1613,7 @@ export function createChatRoute(log, chat, dependencies = {}) {
1582
1613
  // · form/choices → "以对话作答":这里把 UI 块落定为 cancelled(worker 侧会同步取消
1583
1614
  // store 记录并把用户原话喂回 run,见 broker.resolveWithChatReply),表单按钮不再假活;
1584
1615
  // · approval(危险操作审批/账本对账)→ 安全门槛不许绕,409 让用户先作答。
1585
- if (session.agentKind === 'openai' && (session.agentMode ?? 'sdk') === 'daemon') {
1616
+ if (session.agentKind === 'openai') {
1586
1617
  for (const msg of session.messages) {
1587
1618
  for (const block of msg.blocks ?? []) {
1588
1619
  if (block.type !== 'interaction' || block.status !== 'pending')
@@ -1602,7 +1633,7 @@ export function createChatRoute(log, chat, dependencies = {}) {
1602
1633
  }
1603
1634
  block.status = 'cancelled';
1604
1635
  block.revision += 1;
1605
- chatBus.emit('chat.message.interaction.updated', {
1636
+ publish('chat.message.interaction.updated', {
1606
1637
  sessionId: id,
1607
1638
  interactionId: block.id,
1608
1639
  status: 'cancelled',
@@ -1625,12 +1656,12 @@ export function createChatRoute(log, chat, dependencies = {}) {
1625
1656
  if (session.messages.length === 1)
1626
1657
  session.title = formatTitle(body.content);
1627
1658
  writeSession(session);
1628
- chatBus.emit('chat.message', { sessionId: id, message: userMsg });
1659
+ publish('chat.message', { sessionId: id, message: userMsg });
1629
1660
  const assistantId = randomUUID();
1630
1661
  // v0.57:接受即预登记回合(activeStreams + 心跳 + 持久 lastTurn=running),消除
1631
1662
  // "lastTurn=running 但无 activeStreams"的竞态,让断线补偿/轮询从此刻起就确定。
1632
1663
  startTurn(id, assistantId);
1633
- chatBus.emit('chat.message.pending', {
1664
+ publish('chat.message.pending', {
1634
1665
  sessionId: id,
1635
1666
  assistantId,
1636
1667
  turnId: assistantId,
@@ -1656,11 +1687,17 @@ export function createChatStreamRoute() {
1656
1687
  start(controller) {
1657
1688
  const enc = new TextEncoder();
1658
1689
  let closed = false;
1690
+ /**
1691
+ * 写一帧。带 `frameId` 的写 `id:` —— 浏览器会记住它,重连时自动带
1692
+ * `Last-Event-ID` 回来,服务端据此逐事件补发(见下方的续传段)。
1693
+ * 补偿类帧(snapshot/resume 等)不带帧号,不参与游标。
1694
+ */
1659
1695
  const send = (event, data) => {
1660
1696
  if (closed)
1661
1697
  return;
1698
+ const frameId = data && typeof data === 'object' ? data.frameId : undefined;
1662
1699
  try {
1663
- controller.enqueue(enc.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`));
1700
+ controller.enqueue(enc.encode(`${typeof frameId === 'number' ? `id: ${frameId}\n` : ''}event: ${event}\ndata: ${JSON.stringify(data)}\n\n`));
1664
1701
  }
1665
1702
  catch {
1666
1703
  closed = true;
@@ -1676,6 +1713,7 @@ export function createChatStreamRoute() {
1676
1713
  'chat.message.interaction.updated',
1677
1714
  'chat.message.complete',
1678
1715
  'chat.message.error', // v0.57:明确的回合异常终止(带 turnId)
1716
+ 'chat.resync.required', // 阶段 2:游标失效,客户端须靠快照/补拉重建
1679
1717
  'chat.message.heartbeat', // v0.57:流式进行中心跳(带 turnId),区分"还在生成"vs"卡死"
1680
1718
  ];
1681
1719
  const handlers = [];
@@ -1690,18 +1728,39 @@ export function createChatStreamRoute() {
1690
1728
  chatBus.on(event, fn);
1691
1729
  handlers.push({ event, fn });
1692
1730
  }
1731
+ // ── 逐事件续传(阶段 2)──
1732
+ // 浏览器重连自动带 Last-Event-ID;断点还在窗口内就精确补发,滚出窗口才退回快照重建。
1733
+ // 放在订阅之后:JS 单线程,这中间不会有事件被 emit,既不漏也不重。
1734
+ const lastEventHeader = c.req.header('Last-Event-ID') ?? c.req.query('lastEventId') ?? '';
1735
+ const lastEventId = Number.parseInt(lastEventHeader, 10);
1736
+ let resumed = false;
1737
+ if (Number.isFinite(lastEventId) && lastEventId > 0) {
1738
+ const replay = framesSince(sessionId, lastEventId);
1739
+ if (replay.kind === 'frames') {
1740
+ // 🔴 补发时必须带上帧号:日志里存的是**发布前**的 data(没有 frameId),
1741
+ // 直接发会让 `id:` 缺失、客户端游标停在断点不前进,下次重连又从头补一遍。
1742
+ for (const frame of replay.frames) {
1743
+ send(frame.event, { ...frame.data, frameId: frame.id });
1744
+ }
1745
+ resumed = true;
1746
+ }
1747
+ else {
1748
+ // 🔴 有洞的序列比没有更危险(客户端会以为自己是完整的)——明确要求重建。
1749
+ send('chat.resync.required', { sessionId, reason: 'cursor-out-of-window' });
1750
+ }
1751
+ }
1693
1752
  // ── 断线补偿(v0.57 核心)──
1694
1753
  // 新连接(刷新/切会话/重连)时,后端据"进行中快照 + 持久 lastTurn"主动补偿,
1695
1754
  // 让"回合已结束/仍在进行"成为确定事实,客户端无需超时猜测。同步执行(JS 单线程):
1696
1755
  // 订阅 chatBus 与本段之间不会有事件被 emit → 既不漏也不重。
1697
- const active = activeStreams.get(sessionId);
1756
+ const active = resumed ? null : activeStreams.get(sessionId);
1698
1757
  if (active) {
1699
1758
  // 回合仍进行中(本进程)→ pending + 旧 snapshot(兼容老客户端)+ 新 resume(带 turnId/seq/status)。
1700
1759
  send('chat.message.pending', { sessionId, assistantId: active.assistantId, turnId: active.turnId, seq: active.seq, ts: active.ts });
1701
1760
  send('chat.message.snapshot', { sessionId, assistantId: active.assistantId, ts: active.ts, blocks: active.blocks });
1702
1761
  send('chat.message.resume', { sessionId, assistantId: active.assistantId, turnId: active.turnId, ts: active.ts, blocks: active.blocks, status: 'running', seq: active.seq });
1703
1762
  }
1704
- else {
1763
+ else if (!resumed) {
1705
1764
  // 无在途回合 → 查持久 lastTurn(轮询/补偿权威),确定性补发终止信号。
1706
1765
  const s = readSession(sessionId);
1707
1766
  const lt = s ? deriveLastTurn(s) : null;