@botcord/daemon 0.2.77 → 0.2.79

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 (66) hide show
  1. package/dist/agent-discovery.d.ts +6 -0
  2. package/dist/agent-discovery.js +6 -0
  3. package/dist/attention-policy-fetcher.d.ts +14 -0
  4. package/dist/attention-policy-fetcher.js +59 -0
  5. package/dist/cloud-daemon.js +8 -0
  6. package/dist/cloud-gateway-runtime.d.ts +29 -0
  7. package/dist/cloud-gateway-runtime.js +122 -0
  8. package/dist/daemon-config-map.d.ts +6 -0
  9. package/dist/daemon-config-map.js +5 -4
  10. package/dist/daemon.d.ts +3 -0
  11. package/dist/daemon.js +32 -7
  12. package/dist/gateway/channels/botcord.js +29 -9
  13. package/dist/gateway/channels/login-session.d.ts +12 -0
  14. package/dist/gateway/channels/login-session.js +20 -2
  15. package/dist/gateway/channels/sanitize.d.ts +5 -18
  16. package/dist/gateway/channels/sanitize.js +5 -54
  17. package/dist/gateway/channels/text-split.d.ts +5 -11
  18. package/dist/gateway/channels/text-split.js +5 -31
  19. package/dist/gateway/dispatcher.d.ts +7 -1
  20. package/dist/gateway/dispatcher.js +88 -8
  21. package/dist/gateway/gateway.d.ts +16 -1
  22. package/dist/gateway/gateway.js +21 -0
  23. package/dist/gateway/policy-resolver.js +17 -9
  24. package/dist/gateway/runtimes/deepseek-tui.js +86 -19
  25. package/dist/gateway/types.d.ts +12 -57
  26. package/dist/gateway-control.js +18 -9
  27. package/dist/provision.d.ts +9 -3
  28. package/dist/provision.js +181 -9
  29. package/dist/room-recovery-context.d.ts +11 -0
  30. package/dist/room-recovery-context.js +97 -0
  31. package/dist/runtime-models.d.ts +17 -0
  32. package/dist/runtime-models.js +953 -0
  33. package/dist/runtime-route-options.d.ts +7 -0
  34. package/dist/runtime-route-options.js +45 -0
  35. package/package.json +2 -2
  36. package/src/__tests__/attention-policy-fetcher.test.ts +67 -0
  37. package/src/__tests__/cloud-gateway-runtime.test.ts +127 -0
  38. package/src/__tests__/daemon-config-map.test.ts +26 -1
  39. package/src/__tests__/gateway-control.test.ts +136 -0
  40. package/src/__tests__/policy-resolver.test.ts +20 -0
  41. package/src/__tests__/provision.test.ts +124 -0
  42. package/src/__tests__/runtime-discovery.test.ts +68 -9
  43. package/src/__tests__/runtime-models.test.ts +333 -0
  44. package/src/agent-discovery.ts +9 -0
  45. package/src/attention-policy-fetcher.ts +87 -0
  46. package/src/cloud-daemon.ts +8 -0
  47. package/src/cloud-gateway-runtime.ts +171 -0
  48. package/src/daemon-config-map.ts +17 -4
  49. package/src/daemon.ts +38 -9
  50. package/src/gateway/__tests__/botcord-channel.test.ts +97 -0
  51. package/src/gateway/__tests__/deepseek-tui-adapter.test.ts +207 -1
  52. package/src/gateway/__tests__/dispatcher.test.ts +56 -0
  53. package/src/gateway/channels/botcord.ts +32 -8
  54. package/src/gateway/channels/login-session.ts +20 -2
  55. package/src/gateway/channels/sanitize.ts +8 -66
  56. package/src/gateway/channels/text-split.ts +5 -27
  57. package/src/gateway/dispatcher.ts +123 -27
  58. package/src/gateway/gateway.ts +29 -0
  59. package/src/gateway/policy-resolver.ts +20 -9
  60. package/src/gateway/runtimes/deepseek-tui.ts +86 -19
  61. package/src/gateway/types.ts +31 -59
  62. package/src/gateway-control.ts +21 -9
  63. package/src/provision.ts +202 -11
  64. package/src/room-recovery-context.ts +131 -0
  65. package/src/runtime-models.ts +972 -0
  66. package/src/runtime-route-options.ts +52 -0
package/src/daemon.ts CHANGED
@@ -39,6 +39,7 @@ import { createDaemonSystemContextBuilder } from "./system-context.js";
39
39
  import { readWorkingMemorySnapshot } from "./working-memory.js";
40
40
  import { createRoomStaticContextBuilder } from "./room-context.js";
41
41
  import { createRoomContextFetcher } from "./room-context-fetcher.js";
42
+ import { createRecentRoomMessagesRecoveryBuilder } from "./room-recovery-context.js";
42
43
  import {
43
44
  buildLoopRiskPrompt,
44
45
  loopRiskSessionKey,
@@ -50,6 +51,7 @@ import { UserAuthManager } from "./user-auth.js";
50
51
  import { PolicyResolver, type DaemonAttentionPolicy } from "./gateway/policy-resolver.js";
51
52
  import { scanMention } from "./mention-scan.js";
52
53
  import { createDiagnosticBundle, uploadDiagnosticBundle } from "./diagnostics.js";
54
+ import { createAttentionPolicyFetcher } from "./attention-policy-fetcher.js";
53
55
 
54
56
  /**
55
57
  * Default hard cap for a single runtime turn. Long-running coding/research
@@ -364,6 +366,13 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
364
366
  fetchRoomInfo: roomContextFetcher,
365
367
  log: logger,
366
368
  });
369
+ const buildRuntimeRecoveryContext = createRecentRoomMessagesRecoveryBuilder({
370
+ credentialPathByAgentId,
371
+ ...(opts.credentialsPath ? { defaultCredentialsPath: opts.credentialsPath } : {}),
372
+ ...(opts.hubBaseUrl ? { hubBaseUrl: opts.hubBaseUrl } : {}),
373
+ limit: 20,
374
+ log: logger,
375
+ });
367
376
 
368
377
  // Cache one system-context builder per configured agentId. The gateway
369
378
  // calls this with each inbound message and we pick the right builder by
@@ -442,13 +451,20 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
442
451
  });
443
452
  };
444
453
 
445
- // Per-agent attention policy cache (PR3, design §4.2 / §5). Seeded from
446
- // the optional `defaultAttention` / `attentionKeywords` carried by
447
- // `provision_agent`, refreshed in-place by the `policy_updated` control
448
- // frame. PR2 will plug per-room overrides into `fetchEffective`; PR3
449
- // leaves it absent so the resolver collapses to per-agent state.
454
+ // Per-agent attention policy cache (design §4.2 / §5). It is seeded from
455
+ // `provision_agent` / `policy_updated` frames when available and falls back
456
+ // to Hub on cold misses, so daemon restarts preserve dashboard policy.
457
+ const fetchAttentionPolicy = createAttentionPolicyFetcher({
458
+ credentialPathByAgentId,
459
+ defaultCredentialsPath: opts.credentialsPath,
460
+ hubBaseUrl: opts.hubBaseUrl,
461
+ log: logger,
462
+ });
450
463
  const policyResolver = new PolicyResolver({
451
- fetchGlobal: async (_agentId: string) => undefined,
464
+ fetchGlobal: async (agentId: string) =>
465
+ fetchAttentionPolicy({ agentId, roomId: null }),
466
+ fetchEffective: async (agentId: string, roomId: string) =>
467
+ fetchAttentionPolicy({ agentId, roomId }),
452
468
  });
453
469
 
454
470
  // Display-name lookup for the mention text-fallback. Populated from boot
@@ -528,6 +544,7 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
528
544
  turnTimeoutMs: DEFAULT_TURN_TIMEOUT_MS,
529
545
  buildSystemContext,
530
546
  buildMemoryContext,
547
+ buildRuntimeRecoveryContext,
531
548
  onInbound,
532
549
  onOutbound,
533
550
  onRuntimeCircuitBreakerChange: pushLiveRuntimeSnapshot,
@@ -684,7 +701,7 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
684
701
  */
685
702
  export interface BootBackfillResult {
686
703
  credentialPathByAgentId: Map<string, string>;
687
- agentRuntimes: Record<string, { runtime?: string; cwd?: string; openclawGateway?: string; openclawAgent?: string; hermesProfile?: string }>;
704
+ agentRuntimes: Record<string, { runtime?: string; runtimeModel?: string; reasoningEffort?: string; thinking?: boolean; cwd?: string; openclawGateway?: string; openclawAgent?: string; hermesProfile?: string }>;
688
705
  }
689
706
 
690
707
  /**
@@ -703,13 +720,25 @@ export function backfillBootAgents(
703
720
  ): BootBackfillResult {
704
721
  const ensure = opts.ensure ?? ensureAgentWorkspace;
705
722
  const credentialPathByAgentId = new Map<string, string>();
706
- const agentRuntimes: Record<string, { runtime?: string; cwd?: string }> = {};
723
+ const agentRuntimes: Record<string, { runtime?: string; runtimeModel?: string; reasoningEffort?: string; thinking?: boolean; cwd?: string; openclawGateway?: string; openclawAgent?: string; hermesProfile?: string }> = {};
707
724
  const failed: string[] = [];
708
725
  for (const a of agents) {
709
726
  if (a.credentialsFile) credentialPathByAgentId.set(a.agentId, a.credentialsFile);
710
- if (a.runtime || a.cwd || a.openclawGateway || a.openclawAgent || a.hermesProfile) {
727
+ if (
728
+ a.runtime ||
729
+ a.runtimeModel ||
730
+ a.reasoningEffort ||
731
+ typeof a.thinking === "boolean" ||
732
+ a.cwd ||
733
+ a.openclawGateway ||
734
+ a.openclawAgent ||
735
+ a.hermesProfile
736
+ ) {
711
737
  agentRuntimes[a.agentId] = {
712
738
  ...(a.runtime ? { runtime: a.runtime } : {}),
739
+ ...(a.runtimeModel ? { runtimeModel: a.runtimeModel } : {}),
740
+ ...(a.reasoningEffort ? { reasoningEffort: a.reasoningEffort } : {}),
741
+ ...(typeof a.thinking === "boolean" ? { thinking: a.thinking } : {}),
713
742
  ...(a.cwd ? { cwd: a.cwd } : {}),
714
743
  ...(a.openclawGateway ? { openclawGateway: a.openclawGateway } : {}),
715
744
  ...(a.openclawAgent ? { openclawAgent: a.openclawAgent } : {}),
@@ -925,6 +925,103 @@ describe("createBotCordChannel — streamBlock()", () => {
925
925
  }
926
926
  });
927
927
 
928
+ it("normalizes current DeepSeek item.delta assistant text", async () => {
929
+ const fetchSpy = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
930
+ const realFetch = globalThis.fetch;
931
+ globalThis.fetch = fetchSpy as unknown as typeof fetch;
932
+ try {
933
+ const client = makeClient({
934
+ getHubUrl: vi.fn().mockReturnValue("https://hub.example.com"),
935
+ });
936
+ const channel = createBotCordChannel({
937
+ id: "botcord-main",
938
+ accountId: "ag_self",
939
+ agentId: "ag_self",
940
+ client,
941
+ hubBaseUrl: "https://hub.example.com",
942
+ });
943
+ await channel.streamBlock!({
944
+ traceId: "m_trace",
945
+ accountId: "ag_self",
946
+ conversationId: "rm_oc_1",
947
+ block: {
948
+ kind: "assistant_text",
949
+ seq: 6,
950
+ raw: {
951
+ event: "item.delta",
952
+ payload: { thread_id: "thr_1", turn_id: "turn_1", kind: "agent_message", delta: "hello" },
953
+ },
954
+ },
955
+ log: silentLog,
956
+ });
957
+ const [, init] = fetchSpy.mock.calls[0];
958
+ const body = JSON.parse(init.body as string);
959
+ expect(body.block).toEqual({
960
+ kind: "assistant",
961
+ seq: 6,
962
+ payload: { text: "hello" },
963
+ });
964
+ } finally {
965
+ globalThis.fetch = realFetch;
966
+ }
967
+ });
968
+
969
+ it("normalizes current DeepSeek item.started tool input", () => {
970
+ expect(
971
+ __normalizeBlockForHubForTests(
972
+ {
973
+ kind: "tool_use",
974
+ seq: 7,
975
+ raw: {
976
+ event: "item.started",
977
+ payload: {
978
+ item: { id: "item_tool", kind: "tool_call", status: "in_progress" },
979
+ tool: { id: "call_1", name: "web_search", input: { query: "上海天气" } },
980
+ },
981
+ },
982
+ },
983
+ 7,
984
+ ),
985
+ ).toEqual({
986
+ kind: "tool_call",
987
+ seq: 7,
988
+ payload: {
989
+ id: "call_1",
990
+ name: "web_search",
991
+ params: { query: "上海天气" },
992
+ status: "in_progress",
993
+ },
994
+ });
995
+ });
996
+
997
+ it("normalizes current DeepSeek agent_reasoning details", () => {
998
+ expect(
999
+ __normalizeBlockForHubForTests(
1000
+ {
1001
+ kind: "thinking",
1002
+ seq: 8,
1003
+ raw: {
1004
+ event: "item.completed",
1005
+ payload: {
1006
+ item: {
1007
+ id: "item_reasoning",
1008
+ kind: "agent_reasoning",
1009
+ status: "completed",
1010
+ summary: "I should answer briefly.",
1011
+ detail: "I should answer briefly.",
1012
+ },
1013
+ },
1014
+ },
1015
+ },
1016
+ 8,
1017
+ ),
1018
+ ).toEqual({
1019
+ kind: "thinking",
1020
+ seq: 8,
1021
+ payload: { details: "I should answer briefly." },
1022
+ });
1023
+ });
1024
+
928
1025
  it("marks DeepSeek terminal events for owner-chat stream cleanup", async () => {
929
1026
  const fetchSpy = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
930
1027
  const realFetch = globalThis.fetch;
@@ -106,7 +106,12 @@ async function startMockDeepseekServer(opts?: {
106
106
  };
107
107
  }
108
108
 
109
- function runAdapter(serverUrl: string, authToken: string, sessionId: string | null = null) {
109
+ function runAdapter(
110
+ serverUrl: string,
111
+ authToken: string,
112
+ sessionId: string | null = null,
113
+ extraArgs?: string[],
114
+ ) {
110
115
  const adapter = new DeepseekTuiAdapter({ serverUrl, authToken });
111
116
  const ctrl = new AbortController();
112
117
  const blocks: string[] = [];
@@ -118,6 +123,7 @@ function runAdapter(serverUrl: string, authToken: string, sessionId: string | nu
118
123
  cwd: tmpRoot,
119
124
  signal: ctrl.signal,
120
125
  trustLevel: "owner",
126
+ extraArgs,
121
127
  systemContext: "runtime memory",
122
128
  onBlock: (b) => blocks.push(b.kind),
123
129
  onStatus: (e) => {
@@ -169,6 +175,183 @@ describe("DeepseekTuiAdapter", () => {
169
175
  }
170
176
  });
171
177
 
178
+ it("parses current DeepSeek item.delta agent_message events as assistant text", async () => {
179
+ const server = await startMockDeepseekServer({
180
+ events: [
181
+ {
182
+ event: "turn.started",
183
+ data: {
184
+ seq: 1,
185
+ thread_id: "thr_test",
186
+ turn_id: "turn_test",
187
+ event: "turn.started",
188
+ payload: { turn: { status: "in_progress" } },
189
+ },
190
+ },
191
+ {
192
+ event: "item.started",
193
+ data: {
194
+ seq: 2,
195
+ thread_id: "thr_test",
196
+ turn_id: "turn_test",
197
+ item_id: "item_msg",
198
+ event: "item.started",
199
+ payload: { item: { id: "item_msg", kind: "agent_message", status: "in_progress" } },
200
+ },
201
+ },
202
+ {
203
+ event: "item.delta",
204
+ data: {
205
+ seq: 3,
206
+ thread_id: "thr_test",
207
+ turn_id: "turn_test",
208
+ item_id: "item_msg",
209
+ event: "item.delta",
210
+ payload: { kind: "agent_message", delta: "hello " },
211
+ },
212
+ },
213
+ {
214
+ event: "item.delta",
215
+ data: {
216
+ seq: 4,
217
+ thread_id: "thr_test",
218
+ turn_id: "turn_test",
219
+ item_id: "item_msg",
220
+ event: "item.delta",
221
+ payload: { kind: "agent_message", delta: "deepseek" },
222
+ },
223
+ },
224
+ {
225
+ event: "turn.completed",
226
+ data: {
227
+ seq: 5,
228
+ thread_id: "thr_test",
229
+ turn_id: "turn_test",
230
+ event: "turn.completed",
231
+ payload: { turn: { status: "completed" } },
232
+ },
233
+ },
234
+ ],
235
+ });
236
+ try {
237
+ const { result, blocks, status } = runAdapter(server.baseUrl, server.token);
238
+ const res = await result;
239
+ expect(res).toEqual({ text: "hello deepseek", newSessionId: server.threadId });
240
+ expect(blocks).toContain("assistant_text");
241
+ expect(status.at(-1)).toEqual({ phase: "stopped", label: undefined });
242
+ } finally {
243
+ await server.close();
244
+ }
245
+ });
246
+
247
+ it("parses current DeepSeek item.started/item.completed tool events", async () => {
248
+ const server = await startMockDeepseekServer({
249
+ events: [
250
+ {
251
+ event: "turn.started",
252
+ data: { thread_id: "thr_test", turn_id: "turn_test", event: "turn.started" },
253
+ },
254
+ {
255
+ event: "item.started",
256
+ data: {
257
+ thread_id: "thr_test",
258
+ turn_id: "turn_test",
259
+ event: "item.started",
260
+ payload: {
261
+ item: { id: "item_tool", kind: "tool_call", status: "in_progress" },
262
+ tool: { id: "call_1", name: "web_search", input: { query: "Shanghai weather" } },
263
+ },
264
+ },
265
+ },
266
+ {
267
+ event: "item.completed",
268
+ data: {
269
+ thread_id: "thr_test",
270
+ turn_id: "turn_test",
271
+ event: "item.completed",
272
+ payload: {
273
+ item: {
274
+ id: "item_tool",
275
+ kind: "tool_call",
276
+ status: "completed",
277
+ detail: "Found 5 result(s)",
278
+ },
279
+ },
280
+ },
281
+ },
282
+ {
283
+ event: "item.delta",
284
+ data: {
285
+ thread_id: "thr_test",
286
+ turn_id: "turn_test",
287
+ event: "item.delta",
288
+ payload: { kind: "agent_message", delta: "done" },
289
+ },
290
+ },
291
+ {
292
+ event: "turn.completed",
293
+ data: { thread_id: "thr_test", turn_id: "turn_test", event: "turn.completed" },
294
+ },
295
+ ],
296
+ });
297
+ try {
298
+ const { result, blocks, status } = runAdapter(server.baseUrl, server.token);
299
+ await expect(result).resolves.toMatchObject({ text: "done" });
300
+ expect(blocks).toEqual(expect.arrayContaining(["tool_use", "tool_result", "assistant_text"]));
301
+ expect(status).toContainEqual({ phase: "updated", label: "web_search" });
302
+ } finally {
303
+ await server.close();
304
+ }
305
+ });
306
+
307
+ it("emits current DeepSeek agent_reasoning completions as thinking blocks", async () => {
308
+ const server = await startMockDeepseekServer({
309
+ events: [
310
+ {
311
+ event: "turn.started",
312
+ data: { thread_id: "thr_test", turn_id: "turn_test", event: "turn.started" },
313
+ },
314
+ {
315
+ event: "item.completed",
316
+ data: {
317
+ thread_id: "thr_test",
318
+ turn_id: "turn_test",
319
+ event: "item.completed",
320
+ payload: {
321
+ item: {
322
+ id: "item_reasoning",
323
+ kind: "agent_reasoning",
324
+ status: "completed",
325
+ summary: "I should answer briefly.",
326
+ detail: "I should answer briefly.",
327
+ },
328
+ },
329
+ },
330
+ },
331
+ {
332
+ event: "item.delta",
333
+ data: {
334
+ thread_id: "thr_test",
335
+ turn_id: "turn_test",
336
+ event: "item.delta",
337
+ payload: { kind: "agent_message", delta: "hi" },
338
+ },
339
+ },
340
+ {
341
+ event: "turn.completed",
342
+ data: { thread_id: "thr_test", turn_id: "turn_test", event: "turn.completed" },
343
+ },
344
+ ],
345
+ });
346
+ try {
347
+ const { result, blocks } = runAdapter(server.baseUrl, server.token);
348
+ await expect(result).resolves.toMatchObject({ text: "hi" });
349
+ expect(blocks).toContain("thinking");
350
+ } finally {
351
+ await server.close();
352
+ }
353
+ });
354
+
172
355
  it("reuses an existing DeepSeek thread id and patches per-turn system context", async () => {
173
356
  const server = await startMockDeepseekServer({ threadId: "thr_existing" });
174
357
  try {
@@ -184,6 +367,29 @@ describe("DeepseekTuiAdapter", () => {
184
367
  }
185
368
  });
186
369
 
370
+ it("passes selected model and reasoning effort through HTTP payloads", async () => {
371
+ const server = await startMockDeepseekServer();
372
+ try {
373
+ const { result } = runAdapter(server.baseUrl, server.token, null, [
374
+ "--model",
375
+ "deepseek-v4-pro",
376
+ "--reasoning-effort",
377
+ "auto",
378
+ ]);
379
+ await result;
380
+ expect(server.calls.find((c) => c.method === "POST" && c.url === "/v1/threads")?.body).toMatchObject({
381
+ model: "deepseek-v4-pro",
382
+ reasoning_effort: "auto",
383
+ });
384
+ expect(server.calls.find((c) => c.method === "POST" && c.url.endsWith("/turns"))?.body).toMatchObject({
385
+ model: "deepseek-v4-pro",
386
+ reasoning_effort: "auto",
387
+ });
388
+ } finally {
389
+ await server.close();
390
+ }
391
+ });
392
+
187
393
  it("clears stale session ids when DeepSeek reports the thread missing", async () => {
188
394
  const server = await startMockDeepseekServer({ threadId: "thr_other" });
189
395
  try {
@@ -244,6 +244,7 @@ describe("Dispatcher", () => {
244
244
  turnTimeoutMs?: number;
245
245
  runtimeAuthFailureThreshold?: number;
246
246
  runtimeAuthFailureCooldownMs?: number;
247
+ buildRuntimeRecoveryContext?: (message: GatewayInboundMessage) => Promise<string | null> | string | null;
247
248
  }) {
248
249
  const { store, dir } = await makeStore();
249
250
  tempDirs.push(dir);
@@ -258,6 +259,7 @@ describe("Dispatcher", () => {
258
259
  turnTimeoutMs: args.turnTimeoutMs,
259
260
  runtimeAuthFailureThreshold: args.runtimeAuthFailureThreshold,
260
261
  runtimeAuthFailureCooldownMs: args.runtimeAuthFailureCooldownMs,
262
+ buildRuntimeRecoveryContext: args.buildRuntimeRecoveryContext,
261
263
  });
262
264
  return { dispatcher, channel, store };
263
265
  }
@@ -460,6 +462,60 @@ describe("Dispatcher", () => {
460
462
  expect(channel.sends[0].message.type).toBe("error");
461
463
  });
462
464
 
465
+ it("codex: retries a poisoned resumed session in a fresh session with recent room context", async () => {
466
+ let factoryCall = 0;
467
+ const recoveryRuntime: RuntimeAdapter = {
468
+ id: "codex",
469
+ run: vi.fn(async (opts: RuntimeRunOptions): Promise<RuntimeRunResult> => {
470
+ if ((recoveryRuntime.run as any).mock.calls.length === 1) {
471
+ expect(opts.sessionId).toBe("sid-1");
472
+ return {
473
+ text: "",
474
+ newSessionId: "sid-1",
475
+ error: "Codex context compaction failed: maximum context length exceeded",
476
+ };
477
+ }
478
+ expect(opts.sessionId).toBe(null);
479
+ expect(opts.text).toContain("[BotCord Runtime Recovery Notice]");
480
+ expect(opts.text).toContain("[Recent Room Messages]");
481
+ expect(opts.text).toContain("Alice: deploy is failing");
482
+ expect(opts.text).toContain("[Current User Turn]");
483
+ expect(opts.text).toContain("continue");
484
+ return { text: "recovered", newSessionId: "sid-2" };
485
+ }) as RuntimeAdapter["run"],
486
+ };
487
+ const runtimeFactory: RuntimeFactory = () => {
488
+ factoryCall += 1;
489
+ if (factoryCall === 1) {
490
+ return new FakeRuntime({ id: "codex", reply: "ok", newSessionId: "sid-1" });
491
+ }
492
+ return recoveryRuntime;
493
+ };
494
+ const { dispatcher, store, channel } = await scaffold({
495
+ config: baseConfig({ defaultRoute: { runtime: "codex", cwd: "/tmp/default" } }),
496
+ runtimeFactory,
497
+ buildRuntimeRecoveryContext: () =>
498
+ "[Recent Room Messages]\n- Alice: deploy is failing\n- Bot: I am checking logs",
499
+ });
500
+
501
+ await dispatcher.handle(
502
+ makeEnvelope({ id: "msg_1", conversation: { id: "rm_oc_recover", kind: "direct" } }),
503
+ );
504
+ expect(store.all()[0].runtimeSessionId).toBe("sid-1");
505
+
506
+ await dispatcher.handle(
507
+ makeEnvelope({
508
+ id: "msg_2",
509
+ text: "continue",
510
+ conversation: { id: "rm_oc_recover", kind: "direct" },
511
+ }),
512
+ );
513
+
514
+ expect(recoveryRuntime.run).toHaveBeenCalledTimes(2);
515
+ expect(store.all()[0].runtimeSessionId).toBe("sid-2");
516
+ expect(channel.sends.map((s) => s.message.text)).toEqual(["ok", "recovered"]);
517
+ });
518
+
463
519
  it("treats auth failure text as an error and does not persist the failed session", async () => {
464
520
  let callNo = 0;
465
521
  const runtimeFactory: RuntimeFactory = () => {
@@ -986,7 +986,7 @@ function normalizeBlockForHub(
986
986
  // Claude Code: {type:"assistant", message:{content:[{type:"text",text}]}}
987
987
  // Codex: {type:"item.completed", item:{type:"agent_message", text}}
988
988
  // DeepSeek: {event:"message.delta", payload:{content}} or
989
- // {event:"item.delta", payload:{payload:{kind:"agent_message", delta}}}
989
+ // {event:"item.delta", payload:{kind:"agent_message", delta}}
990
990
  let text = "";
991
991
  const contents = Array.isArray(raw?.message?.content) ? raw.message.content : [];
992
992
  for (const c of contents) {
@@ -999,10 +999,14 @@ function normalizeBlockForHub(
999
999
  if (
1000
1000
  !text &&
1001
1001
  raw?.event === "item.delta" &&
1002
- raw?.payload?.payload?.kind === "agent_message" &&
1003
- typeof raw?.payload?.payload?.delta === "string"
1002
+ (raw?.payload?.kind === "agent_message" || raw?.payload?.payload?.kind === "agent_message")
1004
1003
  ) {
1005
- text = raw.payload.payload.delta;
1004
+ text =
1005
+ typeof raw?.payload?.delta === "string"
1006
+ ? raw.payload.delta
1007
+ : typeof raw?.payload?.payload?.delta === "string"
1008
+ ? raw.payload.payload.delta
1009
+ : "";
1006
1010
  }
1007
1011
  return { kind: "assistant", seq, payload: { text } };
1008
1012
  }
@@ -1200,8 +1204,13 @@ function extractDeepseekToolCall(raw: any): { name: string; params?: unknown; id
1200
1204
  };
1201
1205
  }
1202
1206
 
1203
- if (payload.event === "item.started") {
1204
- const inner = payload.payload && typeof payload.payload === "object" ? payload.payload : {};
1207
+ if (raw?.event === "item.started" || payload.event === "item.started") {
1208
+ const inner =
1209
+ raw?.event === "item.started"
1210
+ ? payload
1211
+ : payload.payload && typeof payload.payload === "object"
1212
+ ? payload.payload
1213
+ : {};
1205
1214
  const item = inner.item && typeof inner.item === "object" ? inner.item : undefined;
1206
1215
  const tool = inner.tool && typeof inner.tool === "object" ? inner.tool : item?.tool;
1207
1216
  return {
@@ -1240,8 +1249,18 @@ function extractDeepseekToolResult(raw: any): { name?: string; result: string; i
1240
1249
  };
1241
1250
  }
1242
1251
 
1243
- if (payload.event === "item.completed" || payload.event === "item.failed") {
1244
- const inner = payload.payload && typeof payload.payload === "object" ? payload.payload : {};
1252
+ if (
1253
+ raw?.event === "item.completed" ||
1254
+ raw?.event === "item.failed" ||
1255
+ payload.event === "item.completed" ||
1256
+ payload.event === "item.failed"
1257
+ ) {
1258
+ const inner =
1259
+ raw?.event === "item.completed" || raw?.event === "item.failed"
1260
+ ? payload
1261
+ : payload.payload && typeof payload.payload === "object"
1262
+ ? payload.payload
1263
+ : {};
1245
1264
  const item = inner.item && typeof inner.item === "object" ? inner.item : undefined;
1246
1265
  const result =
1247
1266
  item?.output ??
@@ -1273,6 +1292,11 @@ function formatBlockDetails(raw: unknown): string {
1273
1292
  : typeof r.message === "string" ? r.message
1274
1293
  : typeof r.summary === "string" ? r.summary
1275
1294
  : typeof r.label === "string" ? r.label
1295
+ : typeof r.payload?.delta === "string" ? r.payload.delta
1296
+ : typeof r.payload?.item?.detail === "string" ? r.payload.item.detail
1297
+ : typeof r.payload?.item?.summary === "string" ? r.payload.item.summary
1298
+ : typeof r.payload?.payload?.item?.detail === "string" ? r.payload.payload.item.detail
1299
+ : typeof r.payload?.payload?.item?.summary === "string" ? r.payload.payload.item.summary
1276
1300
  : "";
1277
1301
  if (direct) return direct;
1278
1302
 
@@ -77,10 +77,28 @@ export class LoginSessionStore {
77
77
  return session;
78
78
  }
79
79
 
80
+ /**
81
+ * Distinguish whether `loginId` is unknown to the store ("missing") vs
82
+ * known-but-past-TTL ("expired"). When the entry is expired this also
83
+ * evicts it from the internal map so callers do not need to follow up
84
+ * with a separate `delete`. Use this when the caller wants to surface
85
+ * a precise error code to the user; prefer `get` when a single nullable
86
+ * result is enough.
87
+ */
88
+ resolve(loginId: string): { state: "live" | "expired" | "missing"; session?: LoginSession } {
89
+ const s = this.sessions.get(loginId);
90
+ if (!s) return { state: "missing" };
91
+ if (s.expiresAt <= this.now()) {
92
+ this.sessions.delete(loginId);
93
+ return { state: "expired" };
94
+ }
95
+ return { state: "live", session: s };
96
+ }
97
+
80
98
  /** Get a non-expired session by id, or `null` when missing/expired. */
81
99
  get(loginId: string): LoginSession | null {
82
- this.sweep();
83
- return this.sessions.get(loginId) ?? null;
100
+ const { state, session } = this.resolve(loginId);
101
+ return state === "live" && session ? session : null;
84
102
  }
85
103
 
86
104
  /**