@botcord/daemon 0.2.78 → 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 (50) hide show
  1. package/dist/attention-policy-fetcher.d.ts +14 -0
  2. package/dist/attention-policy-fetcher.js +59 -0
  3. package/dist/cloud-daemon.js +8 -0
  4. package/dist/cloud-gateway-runtime.d.ts +29 -0
  5. package/dist/cloud-gateway-runtime.js +122 -0
  6. package/dist/daemon.js +21 -6
  7. package/dist/gateway/channels/botcord.js +29 -9
  8. package/dist/gateway/channels/login-session.d.ts +12 -0
  9. package/dist/gateway/channels/login-session.js +20 -2
  10. package/dist/gateway/channels/sanitize.d.ts +5 -18
  11. package/dist/gateway/channels/sanitize.js +5 -54
  12. package/dist/gateway/channels/text-split.d.ts +5 -11
  13. package/dist/gateway/channels/text-split.js +5 -31
  14. package/dist/gateway/dispatcher.d.ts +7 -1
  15. package/dist/gateway/dispatcher.js +88 -8
  16. package/dist/gateway/gateway.d.ts +16 -1
  17. package/dist/gateway/gateway.js +21 -0
  18. package/dist/gateway/policy-resolver.js +17 -9
  19. package/dist/gateway/runtimes/deepseek-tui.js +31 -12
  20. package/dist/gateway/types.d.ts +12 -57
  21. package/dist/gateway-control.js +18 -9
  22. package/dist/provision.d.ts +7 -3
  23. package/dist/provision.js +115 -8
  24. package/dist/room-recovery-context.d.ts +11 -0
  25. package/dist/room-recovery-context.js +97 -0
  26. package/package.json +2 -2
  27. package/src/__tests__/attention-policy-fetcher.test.ts +67 -0
  28. package/src/__tests__/cloud-gateway-runtime.test.ts +127 -0
  29. package/src/__tests__/gateway-control.test.ts +136 -0
  30. package/src/__tests__/policy-resolver.test.ts +20 -0
  31. package/src/__tests__/provision.test.ts +65 -0
  32. package/src/attention-policy-fetcher.ts +87 -0
  33. package/src/cloud-daemon.ts +8 -0
  34. package/src/cloud-gateway-runtime.ts +171 -0
  35. package/src/daemon.ts +23 -6
  36. package/src/gateway/__tests__/botcord-channel.test.ts +97 -0
  37. package/src/gateway/__tests__/deepseek-tui-adapter.test.ts +177 -0
  38. package/src/gateway/__tests__/dispatcher.test.ts +56 -0
  39. package/src/gateway/channels/botcord.ts +32 -8
  40. package/src/gateway/channels/login-session.ts +20 -2
  41. package/src/gateway/channels/sanitize.ts +8 -66
  42. package/src/gateway/channels/text-split.ts +5 -27
  43. package/src/gateway/dispatcher.ts +123 -27
  44. package/src/gateway/gateway.ts +29 -0
  45. package/src/gateway/policy-resolver.ts +20 -9
  46. package/src/gateway/runtimes/deepseek-tui.ts +37 -12
  47. package/src/gateway/types.ts +31 -59
  48. package/src/gateway-control.ts +21 -9
  49. package/src/provision.ts +133 -7
  50. package/src/room-recovery-context.ts +131 -0
@@ -1736,6 +1736,71 @@ describe("update_agent handler", () => {
1736
1736
  });
1737
1737
  });
1738
1738
 
1739
+ it("updates runtime selectors in credentials and managed route", async () => {
1740
+ await withSandboxHome(async ({ tmp, fs, path: nodePath }) => {
1741
+ mockState.cfg = {
1742
+ defaultRoute: {
1743
+ adapter: "codex",
1744
+ cwd: tmp,
1745
+ extraArgs: ["--skip-git-repo-check"],
1746
+ },
1747
+ routes: [],
1748
+ streamBlocks: true,
1749
+ };
1750
+ const credDir = nodePath.join(tmp, ".botcord", "credentials");
1751
+ fs.mkdirSync(credDir, { recursive: true });
1752
+ fs.writeFileSync(
1753
+ nodePath.join(credDir, "ag_runtime.json"),
1754
+ JSON.stringify({
1755
+ version: 1,
1756
+ hubUrl: "https://hub.example",
1757
+ agentId: "ag_runtime",
1758
+ keyId: "k_runtime",
1759
+ privateKey: Buffer.alloc(32, 24).toString("base64"),
1760
+ savedAt: new Date().toISOString(),
1761
+ }),
1762
+ );
1763
+
1764
+ const gw = makeFakeGateway();
1765
+ const provisioner = createProvisioner({
1766
+ gateway: gw as unknown as Parameters<typeof createProvisioner>[0]["gateway"],
1767
+ });
1768
+ const ack = await provisioner({
1769
+ id: "req_update_runtime",
1770
+ type: CONTROL_FRAME_TYPES.UPDATE_AGENT,
1771
+ params: {
1772
+ agentId: "ag_runtime",
1773
+ runtime: "codex",
1774
+ runtimeModel: "gpt-5.2",
1775
+ reasoningEffort: "high",
1776
+ thinking: true,
1777
+ },
1778
+ });
1779
+
1780
+ expect(ack.ok).toBe(true);
1781
+ expect((ack.result as { changed: boolean }).changed).toBe(true);
1782
+
1783
+ const saved = JSON.parse(
1784
+ fs.readFileSync(nodePath.join(credDir, "ag_runtime.json"), "utf8"),
1785
+ ) as Record<string, unknown>;
1786
+ expect(saved.runtime).toBe("codex");
1787
+ expect(saved.runtimeModel).toBe("gpt-5.2");
1788
+ expect(saved.reasoningEffort).toBe("high");
1789
+ expect(saved.thinking).toBe(true);
1790
+ expect(gw.listManagedRoutes()[0]).toMatchObject({
1791
+ match: { accountId: "ag_runtime" },
1792
+ runtime: "codex",
1793
+ extraArgs: [
1794
+ "--skip-git-repo-check",
1795
+ "--model",
1796
+ "gpt-5.2",
1797
+ "-c",
1798
+ 'model_reasoning_effort="high"',
1799
+ ],
1800
+ });
1801
+ });
1802
+ });
1803
+
1739
1804
  it("rejects update_agent without agentId", async () => {
1740
1805
  const gw = makeFakeGateway();
1741
1806
  const provisioner = createProvisioner({
@@ -0,0 +1,87 @@
1
+ import {
2
+ BotCordClient,
3
+ defaultCredentialsFile,
4
+ loadStoredCredentials,
5
+ updateCredentialsToken,
6
+ } from "@botcord/protocol-core";
7
+ import type { DaemonAttentionPolicy } from "./gateway/policy-resolver.js";
8
+
9
+ interface CachedClient {
10
+ client: BotCordClient;
11
+ credentialsPath: string;
12
+ }
13
+
14
+ export interface AttentionPolicyFetcherOptions {
15
+ credentialPathByAgentId: Map<string, string>;
16
+ defaultCredentialsPath?: string;
17
+ hubBaseUrl?: string;
18
+ log?: {
19
+ warn: (msg: string, meta?: Record<string, unknown>) => void;
20
+ };
21
+ }
22
+
23
+ export type AttentionPolicyFetcher = (args: {
24
+ agentId: string;
25
+ roomId?: string | null;
26
+ }) => Promise<DaemonAttentionPolicy | undefined>;
27
+
28
+ export function createAttentionPolicyFetcher(
29
+ opts: AttentionPolicyFetcherOptions,
30
+ ): AttentionPolicyFetcher {
31
+ const clients = new Map<string, CachedClient>();
32
+
33
+ function getClient(agentId: string): BotCordClient | null {
34
+ const existing = clients.get(agentId);
35
+ if (existing) return existing.client;
36
+
37
+ const credentialsPath =
38
+ opts.credentialPathByAgentId.get(agentId) ??
39
+ opts.defaultCredentialsPath ??
40
+ defaultCredentialsFile(agentId);
41
+
42
+ try {
43
+ const creds = loadStoredCredentials(credentialsPath);
44
+ const client = new BotCordClient({
45
+ hubUrl: opts.hubBaseUrl ?? creds.hubUrl,
46
+ agentId: creds.agentId,
47
+ keyId: creds.keyId,
48
+ privateKey: creds.privateKey,
49
+ ...(creds.token ? { token: creds.token } : {}),
50
+ ...(creds.tokenExpiresAt !== undefined
51
+ ? { tokenExpiresAt: creds.tokenExpiresAt }
52
+ : {}),
53
+ });
54
+ client.onTokenRefresh = (token, expiresAt) => {
55
+ try {
56
+ updateCredentialsToken(credentialsPath, token, expiresAt);
57
+ } catch {
58
+ // Persistence failures are non-fatal; the next refresh retries.
59
+ }
60
+ };
61
+ clients.set(agentId, { client, credentialsPath });
62
+ return client;
63
+ } catch (err) {
64
+ opts.log?.warn("daemon.attention-policy.client-init-failed", {
65
+ agentId,
66
+ credentialsPath,
67
+ error: err instanceof Error ? err.message : String(err),
68
+ });
69
+ return null;
70
+ }
71
+ }
72
+
73
+ return async ({ agentId, roomId }) => {
74
+ const client = getClient(agentId);
75
+ if (!client) return undefined;
76
+ try {
77
+ return await client.getAttentionPolicy({ roomId });
78
+ } catch (err) {
79
+ opts.log?.warn("daemon.attention-policy.fetch-failed", {
80
+ agentId,
81
+ roomId: roomId ?? null,
82
+ error: err instanceof Error ? err.message : String(err),
83
+ });
84
+ return undefined;
85
+ }
86
+ };
87
+ }
@@ -32,6 +32,7 @@ import { createDaemonSystemContextBuilder } from "./system-context.js";
32
32
  import { readWorkingMemorySnapshot } from "./working-memory.js";
33
33
  import { createRoomStaticContextBuilder } from "./room-context.js";
34
34
  import { createRoomContextFetcher } from "./room-context-fetcher.js";
35
+ import { createRecentRoomMessagesRecoveryBuilder } from "./room-recovery-context.js";
35
36
  import { composeBotCordUserTurn } from "./turn-text.js";
36
37
  import { PolicyResolver, type DaemonAttentionPolicy } from "./gateway/policy-resolver.js";
37
38
  import { scanMention } from "./mention-scan.js";
@@ -143,6 +144,12 @@ export async function startCloudDaemon(
143
144
  fetchRoomInfo: roomContextFetcher,
144
145
  log: logger,
145
146
  });
147
+ const buildRuntimeRecoveryContext = createRecentRoomMessagesRecoveryBuilder({
148
+ credentialPathByAgentId,
149
+ hubBaseUrl: cloudCfg.hubUrl,
150
+ limit: 20,
151
+ log: logger,
152
+ });
146
153
 
147
154
  type PerAgentBuilder = (
148
155
  msg: GatewayInboundMessage,
@@ -258,6 +265,7 @@ export async function startCloudDaemon(
258
265
  turnTimeoutMs: DEFAULT_TURN_TIMEOUT_MS,
259
266
  buildSystemContext,
260
267
  buildMemoryContext,
268
+ buildRuntimeRecoveryContext,
261
269
  onInbound,
262
270
  onTurnComplete,
263
271
  composeUserTurn: composeBotCordUserTurn,
@@ -0,0 +1,171 @@
1
+ import {
2
+ RUNTIME_FRAME_TYPES,
3
+ type GatewayInboundFrame,
4
+ } from "@botcord/protocol-core";
5
+
6
+ import type {
7
+ ChannelAdapter,
8
+ ChannelSendContext,
9
+ ChannelSendResult,
10
+ ChannelStatusSnapshot,
11
+ Gateway,
12
+ GatewayInboundMessage,
13
+ GatewayLogger,
14
+ } from "./gateway/index.js";
15
+
16
+ export interface CloudGatewayRuntimeResult {
17
+ accepted: boolean;
18
+ eventId: string;
19
+ gatewayId: string;
20
+ agentId: string;
21
+ conversationId: string;
22
+ turnId: string;
23
+ outbound?: {
24
+ finalText: string;
25
+ providerMessageId?: string | null;
26
+ };
27
+ error?: {
28
+ code: string;
29
+ message: string;
30
+ };
31
+ }
32
+
33
+ /**
34
+ * Execute one ingress-originated runtime frame through the normal Gateway
35
+ * dispatcher while keeping provider I/O outside the cloud sandbox.
36
+ *
37
+ * The temporary channel is scoped to this call and implements only the
38
+ * dispatcher-facing send/status surface. Its send() method captures the
39
+ * final runtime reply; the Hub relay converts that into
40
+ * gateway_outbound_complete for gateway-ingress, which then calls the real
41
+ * provider API.
42
+ */
43
+ export async function handleCloudGatewayRuntimeInbound(
44
+ gateway: Gateway,
45
+ frame: GatewayInboundFrame,
46
+ log?: GatewayLogger,
47
+ ): Promise<CloudGatewayRuntimeResult> {
48
+ if (frame.type !== RUNTIME_FRAME_TYPES.GATEWAY_INBOUND) {
49
+ return rejected(frame, "bad_frame_type", `unsupported frame type "${frame.type}"`);
50
+ }
51
+ if (!frame.gateway_id || !frame.agent_id || !frame.event_id) {
52
+ return rejected(frame, "bad_frame", "gateway_id, agent_id and event_id are required");
53
+ }
54
+ if (frame.message.accountId !== frame.agent_id) {
55
+ return rejected(frame, "account_mismatch", "message.accountId does not match frame.agent_id");
56
+ }
57
+ if (frame.message.channel !== frame.gateway_id) {
58
+ return rejected(frame, "channel_mismatch", "message.channel does not match frame.gateway_id");
59
+ }
60
+
61
+ let accepted = false;
62
+ let outboundText: string | null = null;
63
+ let providerMessageId: string | null | undefined;
64
+ const channel = createRuntimeRelayChannel({
65
+ id: frame.gateway_id,
66
+ provider: frame.provider,
67
+ accountId: frame.agent_id,
68
+ onSend: async (ctx) => {
69
+ outboundText = ctx.message.text ?? "";
70
+ providerMessageId = ctx.message.traceId ?? null;
71
+ return { providerMessageId };
72
+ },
73
+ });
74
+
75
+ const message: GatewayInboundMessage = {
76
+ ...frame.message,
77
+ raw: {
78
+ source_type: "cloud_gateway_ingress",
79
+ provider: frame.provider,
80
+ event_id: frame.event_id,
81
+ gateway_id: frame.gateway_id,
82
+ },
83
+ };
84
+
85
+ try {
86
+ await gateway.injectInboundThrough(message, channel, {
87
+ accept: async () => {
88
+ accepted = true;
89
+ },
90
+ });
91
+ } catch (err) {
92
+ const message = err instanceof Error ? err.message : String(err);
93
+ log?.warn("cloud gateway runtime dispatch failed", {
94
+ eventId: frame.event_id,
95
+ gatewayId: frame.gateway_id,
96
+ agentId: frame.agent_id,
97
+ error: message,
98
+ });
99
+ return rejected(frame, "dispatch_failed", message);
100
+ }
101
+
102
+ return {
103
+ accepted,
104
+ eventId: frame.event_id,
105
+ gatewayId: frame.gateway_id,
106
+ agentId: frame.agent_id,
107
+ conversationId: frame.message.conversation.id,
108
+ turnId: `turn_${frame.event_id}`,
109
+ ...(outboundText !== null
110
+ ? {
111
+ outbound: {
112
+ finalText: outboundText,
113
+ providerMessageId: providerMessageId ?? null,
114
+ },
115
+ }
116
+ : {}),
117
+ ...(!accepted
118
+ ? { error: { code: "not_accepted", message: "dispatcher did not accept inbound" } }
119
+ : {}),
120
+ };
121
+ }
122
+
123
+ function createRuntimeRelayChannel(opts: {
124
+ id: string;
125
+ provider: string;
126
+ accountId: string;
127
+ onSend: (ctx: ChannelSendContext) => Promise<ChannelSendResult>;
128
+ }): ChannelAdapter {
129
+ let lastSendAt: number | undefined;
130
+ return {
131
+ id: opts.id,
132
+ type: opts.provider,
133
+ async start() {
134
+ return undefined;
135
+ },
136
+ async stop() {
137
+ return undefined;
138
+ },
139
+ async send(ctx) {
140
+ lastSendAt = Date.now();
141
+ return opts.onSend(ctx);
142
+ },
143
+ status(): ChannelStatusSnapshot {
144
+ return {
145
+ channel: opts.id,
146
+ accountId: opts.accountId,
147
+ running: true,
148
+ connected: true,
149
+ authorized: true,
150
+ provider: opts.provider as ChannelStatusSnapshot["provider"],
151
+ ...(lastSendAt ? { lastSendAt } : {}),
152
+ };
153
+ },
154
+ };
155
+ }
156
+
157
+ function rejected(
158
+ frame: Partial<GatewayInboundFrame>,
159
+ code: string,
160
+ message: string,
161
+ ): CloudGatewayRuntimeResult {
162
+ return {
163
+ accepted: false,
164
+ eventId: frame.event_id ?? "",
165
+ gatewayId: frame.gateway_id ?? "",
166
+ agentId: frame.agent_id ?? "",
167
+ conversationId: frame.message?.conversation.id ?? "",
168
+ turnId: frame.event_id ? `turn_${frame.event_id}` : "turn_unknown",
169
+ error: { code, message },
170
+ };
171
+ }
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,
@@ -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;