@inetafrica/open-claudia 2.15.0 → 3.0.0

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 (151) hide show
  1. package/.env.example +30 -4
  2. package/CHANGELOG.md +12 -0
  3. package/README.md +87 -66
  4. package/bin/agent.js +44 -18
  5. package/bin/cli.js +30 -28
  6. package/bin/dream.js +5 -11
  7. package/bin/loopback-client.js +29 -5
  8. package/bin/schedule.js +19 -5
  9. package/bin/tool.js +23 -5
  10. package/bot-agent.js +5 -2350
  11. package/bot.js +66 -3
  12. package/channels/telegram/adapter.js +6 -1
  13. package/channels/voice/adapter.js +1 -0
  14. package/channels/voice/manage.js +43 -5
  15. package/config-dir.js +3 -11
  16. package/core/actions.js +149 -40
  17. package/core/approvals.js +136 -29
  18. package/core/auth-flow.js +103 -19
  19. package/core/config-dir.js +19 -0
  20. package/core/config.js +115 -69
  21. package/core/doctor.js +111 -57
  22. package/core/dream.js +219 -62
  23. package/core/enforcer.js +0 -0
  24. package/core/entities.js +2 -1
  25. package/core/fsutil.js +21 -4
  26. package/core/handlers.js +503 -217
  27. package/core/ideas.js +2 -1
  28. package/core/jobs.js +589 -72
  29. package/core/lessons.js +3 -2
  30. package/core/loopback.js +42 -6
  31. package/core/memory-mutation-queue.js +241 -0
  32. package/core/migration-backup.js +1060 -0
  33. package/core/pack-review.js +295 -88
  34. package/core/packs.js +4 -3
  35. package/core/persona.js +2 -1
  36. package/core/process-tree.js +19 -2
  37. package/core/provider-migration.js +39 -0
  38. package/core/provider-status.js +80 -0
  39. package/core/providers/claude-events.js +117 -0
  40. package/core/providers/claude-hook.js +114 -0
  41. package/core/providers/claude.js +296 -0
  42. package/core/providers/codex-events.js +121 -0
  43. package/core/providers/codex-hook.js +280 -0
  44. package/core/providers/codex.js +286 -0
  45. package/core/providers/contract.js +153 -0
  46. package/core/providers/env.js +148 -0
  47. package/core/providers/events.js +170 -0
  48. package/core/providers/hook-command.js +22 -0
  49. package/core/providers/index.js +240 -0
  50. package/core/providers/utility-policy.js +269 -0
  51. package/core/recall/classic.js +2 -2
  52. package/core/recall/discoverer.js +71 -22
  53. package/core/recall/metrics.js +27 -1
  54. package/core/recall/tuning.js +4 -1
  55. package/core/recall/warm-walker.js +151 -108
  56. package/core/recall-filter.js +86 -61
  57. package/core/router.js +79 -7
  58. package/core/run-context.js +185 -0
  59. package/core/runner.js +1414 -1290
  60. package/core/scheduler.js +515 -210
  61. package/core/side-chat.js +346 -0
  62. package/core/state.js +1084 -97
  63. package/core/subagent.js +72 -98
  64. package/core/system-prompt.js +462 -279
  65. package/core/tool-guard.js +73 -39
  66. package/core/tools.js +22 -5
  67. package/core/transcripts.js +30 -1
  68. package/core/usage-log.js +19 -4
  69. package/core/utility-agent.js +654 -0
  70. package/core/web-auth.js +29 -13
  71. package/docs/CHANNEL_DESIGN.md +181 -0
  72. package/docs/MULTI_CHANNEL_PLAN.md +254 -0
  73. package/docs/PROVIDER_MIGRATION.md +67 -0
  74. package/health.js +50 -39
  75. package/package.json +48 -4
  76. package/setup.js +198 -38
  77. package/soul.md +39 -0
  78. package/test-ability-extraction.js +5 -0
  79. package/test-approval-async.js +63 -4
  80. package/test-cursor-removal.js +334 -0
  81. package/test-enforcer.js +70 -27
  82. package/test-fixtures/current-provider-parity.json +132 -0
  83. package/test-fixtures/fake-agent-cli.js +477 -0
  84. package/test-fixtures/migrations/claude-only/jobs.json +3 -0
  85. package/test-fixtures/migrations/claude-only/sessions.json +5 -0
  86. package/test-fixtures/migrations/claude-only/state.json +5 -0
  87. package/test-fixtures/migrations/cursor-selected/crons.json +3 -0
  88. package/test-fixtures/migrations/cursor-selected/jobs.json +3 -0
  89. package/test-fixtures/migrations/cursor-selected/sessions.json +5 -0
  90. package/test-fixtures/migrations/cursor-selected/state.json +6 -0
  91. package/test-fixtures/migrations/dual-provider/jobs.json +3 -0
  92. package/test-fixtures/migrations/dual-provider/sessions.json +7 -0
  93. package/test-fixtures/migrations/dual-provider/state.json +10 -0
  94. package/test-fixtures/migrations/malformed-partial/jobs.json +1 -0
  95. package/test-fixtures/migrations/malformed-partial/sessions.json +1 -0
  96. package/test-fixtures/migrations/malformed-partial/sessions.json.bak +3 -0
  97. package/test-fixtures/migrations/malformed-partial/state.json +1 -0
  98. package/test-fixtures/migrations/malformed-partial/state.json.bak +5 -0
  99. package/test-fixtures/migrations/missing-project/jobs.json +3 -0
  100. package/test-fixtures/migrations/missing-project/sessions.json +3 -0
  101. package/test-fixtures/migrations/missing-project/state.json +4 -0
  102. package/test-fixtures/migrations/multi-user/jobs.json +4 -0
  103. package/test-fixtures/migrations/multi-user/sessions.json +4 -0
  104. package/test-fixtures/migrations/multi-user/state.json +6 -0
  105. package/test-fixtures/provider-event-samples.json +17 -0
  106. package/test-learning-e2e.js +5 -0
  107. package/test-memory-mutation-queue.js +191 -0
  108. package/test-provider-boot-matrix.js +399 -0
  109. package/test-provider-bootstrap.js +158 -0
  110. package/test-provider-capabilities.js +232 -0
  111. package/test-provider-claude.js +206 -0
  112. package/test-provider-codex.js +228 -0
  113. package/test-provider-compaction.js +371 -0
  114. package/test-provider-core-prompt.js +264 -0
  115. package/test-provider-coupling-audit.js +90 -0
  116. package/test-provider-dream.js +312 -0
  117. package/test-provider-enforcer.js +252 -0
  118. package/test-provider-env-isolation.js +150 -0
  119. package/test-provider-events.js +141 -0
  120. package/test-provider-fixture.js +332 -0
  121. package/test-provider-gateway.js +508 -0
  122. package/test-provider-language.js +89 -0
  123. package/test-provider-migration-backup.js +424 -0
  124. package/test-provider-pack-review.js +436 -0
  125. package/test-provider-parity-e2e.js +537 -0
  126. package/test-provider-prompt-parity.js +89 -0
  127. package/test-provider-recall.js +271 -0
  128. package/test-provider-registry.js +251 -0
  129. package/test-provider-resume-parity.js +41 -0
  130. package/test-provider-run-lifecycle.js +349 -0
  131. package/test-provider-runner.js +271 -0
  132. package/test-provider-scheduler.js +689 -0
  133. package/test-provider-session-commands.js +185 -0
  134. package/test-provider-session-history.js +205 -0
  135. package/test-provider-side-chat.js +337 -0
  136. package/test-provider-state-migration.js +316 -0
  137. package/test-provider-stream-decoder.js +69 -0
  138. package/test-provider-subagent.js +228 -0
  139. package/test-provider-tool-hooks.js +360 -0
  140. package/test-recall-discoverer.js +1 -0
  141. package/test-recall-engine.js +3 -3
  142. package/test-recall-evolution.js +18 -0
  143. package/test-runner-watchdog-static.js +16 -8
  144. package/test-single-runtime.js +35 -0
  145. package/test-telegram-poll-recovery.js +56 -0
  146. package/test-tool-guard.js +56 -0
  147. package/test-tools.js +19 -0
  148. package/test-unified-identity.js +3 -1
  149. package/test-usage-accounting.js +92 -20
  150. package/test-utility-provider-policy.js +486 -0
  151. package/web.js +130 -35
@@ -0,0 +1,337 @@
1
+ #!/usr/bin/env node
2
+
3
+ "use strict";
4
+
5
+ const assert = require("assert");
6
+ const crypto = require("crypto");
7
+ const fs = require("fs");
8
+ const os = require("os");
9
+ const path = require("path");
10
+ const { createClaudeProvider } = require("./core/providers/claude");
11
+ const { createCodexProvider } = require("./core/providers/codex");
12
+ const { captureRunContext } = require("./core/run-context");
13
+ const {
14
+ ACTIVE_TASK_DESCRIPTION_MAX,
15
+ SideChatCoordinator,
16
+ createSideChatPromptBuilder,
17
+ createSideChatRequest,
18
+ readRuntimeMode,
19
+ } = require("./core/side-chat");
20
+ const {
21
+ createSideChatQueueItem,
22
+ createSideChatService,
23
+ finalizeSideChatQueueItem,
24
+ shouldStartSideChat,
25
+ } = require("./core/runner");
26
+
27
+ const fixture = path.join(__dirname, "test-fixtures", "fake-agent-cli.js");
28
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "open-claudia-side-chat-"));
29
+ const project = path.join(root, "project-a");
30
+ fs.mkdirSync(project);
31
+
32
+ function providerEnv(kind, capturePath) {
33
+ const env = {
34
+ HOME: root,
35
+ PATH: process.env.PATH || path.dirname(process.execPath),
36
+ FAKE_AGENT_KIND: kind,
37
+ FAKE_AGENT_CAPTURE: capturePath,
38
+ FAKE_AGENT_TEXT: `${kind} isolated side response`,
39
+ FAKE_AGENT_CHUNK_MODE: "split",
40
+ FAKE_AGENT_FINAL_NEWLINE: "0",
41
+ };
42
+ if (kind === "claude") env.ANTHROPIC_API_KEY = "fixture-claude-presence-only";
43
+ else env.OPENAI_API_KEY = "fixture-openai-presence-only";
44
+ return env;
45
+ }
46
+
47
+ function makeProvider(kind, capturePath) {
48
+ const common = {
49
+ hookTransport: false,
50
+ resolveExecutable: () => fixture,
51
+ defaultModel: `${kind}-fixture-model`,
52
+ buildEnv: () => providerEnv(kind, capturePath),
53
+ getAuthStatus: () => ({ state: "authenticated", reason: null }),
54
+ };
55
+ return kind === "claude" ? createClaudeProvider(common) : createCodexProvider(common);
56
+ }
57
+
58
+ function mainState(provider) {
59
+ return {
60
+ userId: "fixture-user",
61
+ currentSession: { name: "project-a", dir: project },
62
+ settings: {
63
+ backend: provider,
64
+ model: `${provider}-fixture-model`,
65
+ effort: null,
66
+ budget: null,
67
+ permissionMode: null,
68
+ worktree: false,
69
+ },
70
+ activeSessions: { "project-a": { claude: "claude-main-session", codex: "codex-main-thread" } },
71
+ providerSettings: {
72
+ claude: { model: "claude-fixture-model", permissionMode: "plan" },
73
+ codex: { model: "codex-fixture-model", permissionMode: "plan" },
74
+ },
75
+ sessionUsage: { turns: 7, inputTokens: 123, outputTokens: 45 },
76
+ lastCompactedAt: 999,
77
+ messageQueue: [],
78
+ isCompacting: false,
79
+ };
80
+ }
81
+
82
+ function captureMainRun(state, prompt, runId) {
83
+ return captureRunContext({
84
+ state,
85
+ prompt,
86
+ cwd: project,
87
+ replyToMsgId: `message-${runId}`,
88
+ opts: { purpose: "foreground" },
89
+ channelId: "fixture-channel",
90
+ canonicalUserId: state.userId,
91
+ runId,
92
+ store: {
93
+ adapter: { id: "telegram-fixture", type: "telegram" },
94
+ channelId: "fixture-channel",
95
+ canonicalUserId: state.userId,
96
+ userId: state.userId,
97
+ },
98
+ });
99
+ }
100
+
101
+ async function providerProbe(kind) {
102
+ const capturePath = path.join(root, `${kind}-capture.json`);
103
+ const provider = makeProvider(kind, capturePath);
104
+ const state = mainState(kind);
105
+ const before = structuredClone(state);
106
+ const incoming = captureMainRun(state, `${kind} side question`, `${kind}-incoming`);
107
+ const active = captureMainRun(state, `heavy ${kind} task sk-ant-side-secret ${"x".repeat(1000)}`, `${kind}-main`);
108
+ const request = createSideChatRequest({
109
+ runContext: incoming,
110
+ activeRunContext: active,
111
+ store: { marker: `${kind}-origin-store` },
112
+ token: `${kind}-token`,
113
+ });
114
+
115
+ assert.ok(Object.isFrozen(request));
116
+ assert.ok(Object.isFrozen(request.runContext));
117
+ assert.strictEqual(request.runContext.provider, kind);
118
+ assert.strictEqual(request.runContext.project.name, "project-a");
119
+ assert.strictEqual(request.runContext.origin.transport, "telegram");
120
+ assert.strictEqual(request.runContext.sessionId, null);
121
+ assert.strictEqual(request.runContext.resumeSessionId, null);
122
+ assert.strictEqual(request.runContext.continueSession, false);
123
+ assert.strictEqual(request.runContext.fresh, true);
124
+ assert.strictEqual(request.runContext.purpose, "side-chat");
125
+ assert.ok(request.activeTaskDescription.length <= ACTIVE_TASK_DESCRIPTION_MAX);
126
+ assert.ok(!request.activeTaskDescription.includes("sk-ant-side-secret"));
127
+ assert.match(request.activeTaskDescription, /REDACTED/);
128
+
129
+ // Switching every mutable main selector after admission must not retarget the
130
+ // side response or make it attach to either native main session.
131
+ state.settings.backend = kind === "claude" ? "codex" : "claude";
132
+ state.currentSession = { name: "project-b", dir: path.join(root, "project-b") };
133
+ state.activeSessions["project-a"].claude = "changed-claude-session";
134
+ state.activeSessions["project-a"].codex = "changed-codex-thread";
135
+
136
+ const forbiddenEffects = [];
137
+ const service = createSideChatService({
138
+ resolveProvider: (id) => {
139
+ assert.strictEqual(id, kind);
140
+ return provider;
141
+ },
142
+ buildCoreInstructions: () => "SIDE CORE SENTINEL",
143
+ persistTranscript: () => forbiddenEffects.push("transcript"),
144
+ persistUsage: () => forbiddenEffects.push("usage"),
145
+ persistSession: () => forbiddenEffects.push("session"),
146
+ deliver: () => forbiddenEffects.push("delivery"),
147
+ memoryReview: () => forbiddenEffects.push("review"),
148
+ compact: () => forbiddenEffects.push("compact"),
149
+ });
150
+ const result = await service.run(request);
151
+ assert.strictEqual(result.ok, true);
152
+ assert.strictEqual(result.provider, kind);
153
+ assert.strictEqual(result.purpose, "side-chat");
154
+ assert.strictEqual(result.text, `${kind} isolated side response`);
155
+ assert.deepStrictEqual(forbiddenEffects, []);
156
+
157
+ const capture = JSON.parse(fs.readFileSync(capturePath, "utf8"));
158
+ const argvText = capture.argv.join("\n");
159
+ assert.ok(!argvText.includes("claude-main-session"));
160
+ assert.ok(!argvText.includes("codex-main-thread"));
161
+ assert.ok(!argvText.includes("changed-claude-session"));
162
+ assert.ok(!argvText.includes("changed-codex-thread"));
163
+ const expectedBundle = await createSideChatPromptBuilder(() => "SIDE CORE SENTINEL")(
164
+ request.originalMessage,
165
+ { runContext: request.runContext, provider },
166
+ );
167
+ const protectedPrompt = `${expectedBundle.dynamicContext}\n\n${expectedBundle.userPrompt}`;
168
+ assert.match(protectedPrompt, /side question/);
169
+ assert.match(protectedPrompt, /heavy .* task/);
170
+ assert.ok(protectedPrompt.length < 12000, "active-task context stays bounded");
171
+ assert.ok(!argvText.includes("side question"), "private side question is absent from argv");
172
+ assert.deepStrictEqual(capture.stdin, {
173
+ present: true,
174
+ bytes: Buffer.byteLength(protectedPrompt),
175
+ sha256: crypto.createHash("sha256").update(protectedPrompt).digest("hex"),
176
+ }, "side-chat fixture captures only protected prompt size/hash");
177
+ if (kind === "claude") {
178
+ assert.strictEqual(capture.envPresence.ANTHROPIC_API_KEY, true);
179
+ assert.strictEqual(capture.envPresence.OPENAI_API_KEY, false);
180
+ assert.ok(capture.argv.includes("--no-session-persistence"));
181
+ assert.ok(!capture.argv.includes("--resume"));
182
+ assert.ok(!capture.argv.includes("--continue"));
183
+ assert.deepStrictEqual(capture.argv.slice(capture.argv.indexOf("--permission-mode"), capture.argv.indexOf("--permission-mode") + 2), ["--permission-mode", "plan"]);
184
+ } else {
185
+ assert.strictEqual(capture.envPresence.OPENAI_API_KEY, true);
186
+ assert.strictEqual(capture.envPresence.ANTHROPIC_API_KEY, false);
187
+ assert.strictEqual(capture.envPresence.CLAUDE_CODE_OAUTH_TOKEN, false);
188
+ assert.strictEqual(capture.argv[0], "exec");
189
+ assert.notStrictEqual(capture.argv[1], "resume");
190
+ assert.ok(capture.argv.includes("--ephemeral"));
191
+ assert.deepStrictEqual(capture.argv.slice(capture.argv.indexOf("--sandbox"), capture.argv.indexOf("--sandbox") + 2), ["--sandbox", "read-only"]);
192
+ }
193
+
194
+ const expected = structuredClone(before);
195
+ expected.settings.backend = state.settings.backend;
196
+ expected.currentSession = state.currentSession;
197
+ expected.activeSessions = state.activeSessions;
198
+ assert.deepStrictEqual(state, expected, "side execution must not mutate main state");
199
+ }
200
+
201
+ function deferred() {
202
+ let resolve;
203
+ const promise = new Promise((res) => { resolve = res; });
204
+ return { promise, resolve };
205
+ }
206
+
207
+ async function coordinatorProbe() {
208
+ const state = mainState("claude");
209
+ const active = captureMainRun(state, "long main operation", "active-main");
210
+ const first = captureMainRun(state, "what is its status?", "side-one");
211
+ const second = captureMainRun(state, "also include tests", "side-two");
212
+ const gate = deferred();
213
+ const runs = [];
214
+ const deliveries = [];
215
+ const enqueued = [];
216
+ const tokens = ["token-one", "token-two"];
217
+ let clock = 1000;
218
+ const coordinator = new SideChatCoordinator({
219
+ tokenFactory: () => tokens.shift(),
220
+ now: () => clock,
221
+ runSideChat: async (request) => {
222
+ runs.push(request);
223
+ await gate.promise;
224
+ return { ok: true, text: "fresh side answer", diagnostic: null };
225
+ },
226
+ deliver: async (payload) => { deliveries.push(payload); return { ok: true }; },
227
+ enqueueMain: (request, store, lineage) => {
228
+ enqueued.push({ request, store, lineage });
229
+ return { queued: true, position: 1 };
230
+ },
231
+ });
232
+
233
+ const firstPending = coordinator.respond({ runContext: first, activeRunContext: active, store: { id: "origin-one" } });
234
+ await new Promise((resolve) => setImmediate(resolve));
235
+ assert.strictEqual(runs.length, 1);
236
+ assert.strictEqual(coordinator.isActive("fixture-user"), true);
237
+
238
+ const busy = await coordinator.respond({ runContext: second, activeRunContext: active, store: { id: "origin-two" } });
239
+ assert.strictEqual(busy.status, "busy");
240
+ assert.strictEqual(runs.length, 1, "only one side provider run is allowed per user");
241
+ assert.strictEqual(deliveries.length, 1);
242
+ assert.strictEqual(deliveries[0].action.callbackData, "sc:q:token-two");
243
+
244
+ gate.resolve();
245
+ const firstResult = await firstPending;
246
+ assert.strictEqual(firstResult.ok, true);
247
+ assert.strictEqual(coordinator.isActive("fixture-user"), false);
248
+ assert.strictEqual(deliveries.length, 2);
249
+ assert.strictEqual(deliveries[1].text, "fresh side answer");
250
+ assert.strictEqual(deliveries[1].action.callbackData, "sc:q:token-one");
251
+
252
+ coordinator.noteMainResult(active, { ok: true, sessionId: "main-minted-session" });
253
+ const denied = await coordinator.enqueue("token-one", "different-user");
254
+ assert.strictEqual(denied.status, "forbidden");
255
+ assert.strictEqual(enqueued.length, 0);
256
+ const accepted = await coordinator.enqueue("token-one", "fixture-user");
257
+ assert.strictEqual(accepted.status, "queued");
258
+ assert.strictEqual(enqueued.length, 1);
259
+ assert.strictEqual(enqueued[0].request.originalRunContext.runId, "side-one");
260
+ assert.strictEqual(enqueued[0].request.originalRunContext.provider, "claude");
261
+ assert.strictEqual(enqueued[0].request.originalRunContext.project.name, "project-a");
262
+ assert.strictEqual(enqueued[0].request.originalRunContext.origin.transport, "telegram");
263
+ assert.deepStrictEqual(enqueued[0].store, { id: "origin-one" });
264
+ assert.deepStrictEqual(enqueued[0].lineage, { mainRunId: "active-main", mainSessionId: "main-minted-session" });
265
+ const replay = await coordinator.enqueue("token-one", "fixture-user");
266
+ assert.strictEqual(replay.status, "expired");
267
+ clock += 20 * 60 * 1000;
268
+ const expired = await coordinator.enqueue("token-two", "fixture-user");
269
+ assert.strictEqual(expired.status, "expired");
270
+
271
+ const deliveryAttempts = [];
272
+ const failingDelivery = new SideChatCoordinator({
273
+ tokenFactory: () => "delivery-failure-token",
274
+ runSideChat: async () => ({ ok: true, text: "must be delivered once" }),
275
+ deliver: async (payload) => { deliveryAttempts.push(payload); throw new Error("fixture delivery failed"); },
276
+ enqueueMain: () => ({ queued: true }),
277
+ });
278
+ const failed = await failingDelivery.respond({ runContext: first, activeRunContext: active });
279
+ assert.strictEqual(failed.ok, false);
280
+ assert.strictEqual(deliveryAttempts.length, 1, "delivery failure must not trigger a second send");
281
+ assert.strictEqual(failingDelivery.isActive("fixture-user"), false);
282
+ }
283
+
284
+ function modeAndLineageProbe() {
285
+ const modeDir = path.join(root, "mode");
286
+ fs.mkdirSync(modeDir);
287
+ const modeFile = path.join(modeDir, ".bot-mode");
288
+ assert.strictEqual(readRuntimeMode(modeDir), "direct");
289
+ fs.writeFileSync(modeFile, "invalid\n");
290
+ assert.strictEqual(readRuntimeMode(modeDir), "direct");
291
+ fs.writeFileSync(modeFile, "direct\n");
292
+ assert.strictEqual(readRuntimeMode(modeDir), "direct");
293
+ fs.writeFileSync(modeFile, "agent\n");
294
+ assert.strictEqual(readRuntimeMode(modeDir), "agent");
295
+
296
+ const state = mainState("claude");
297
+ state.runningProcess = { pid: 123 };
298
+ const incoming = captureMainRun(state, "queue me", "lineage-incoming");
299
+ const active = captureMainRun(state, "fresh main", "lineage-main");
300
+ state.activeRunContext = active;
301
+ assert.strictEqual(shouldStartSideChat(state, incoming, {}, { enabled: () => false }), false);
302
+ assert.strictEqual(shouldStartSideChat(state, incoming, {}, { enabled: () => true }), true);
303
+ assert.strictEqual(shouldStartSideChat(state, incoming, { sideChat: false }, { enabled: () => true }), false);
304
+
305
+ const request = createSideChatRequest({ runContext: incoming, activeRunContext: active, token: "lineage-token" });
306
+ const pending = createSideChatQueueItem(request, { id: "origin" }, {
307
+ mainRunId: active.runId,
308
+ mainSessionId: null,
309
+ }, state, { resolve() {}, reject() {}, promise: Promise.resolve() });
310
+ assert.strictEqual(pending.runContext, null, "pre-completion action remains a pending intent, not a mutable admitted run");
311
+ assert.strictEqual(finalizeSideChatQueueItem(pending, active, { ok: true, sessionId: "fresh-main-minted" }), true);
312
+ assert.ok(Object.isFrozen(pending.runContext));
313
+ assert.strictEqual(pending.runContext.provider, "claude");
314
+ assert.strictEqual(pending.runContext.project.name, "project-a");
315
+ assert.strictEqual(pending.runContext.resumeSessionId, "fresh-main-minted");
316
+ assert.strictEqual(pending.runContext.fresh, false);
317
+
318
+ const afterCompletion = createSideChatQueueItem(request, null, {
319
+ mainRunId: active.runId,
320
+ mainSessionId: "already-minted",
321
+ }, { ...state, activeRunContext: null }, { promise: Promise.resolve() });
322
+ assert.strictEqual(afterCompletion.runContext.resumeSessionId, "already-minted");
323
+ assert.ok(Object.isFrozen(afterCompletion.runContext));
324
+ }
325
+
326
+ async function main() {
327
+ await providerProbe("claude");
328
+ await providerProbe("codex");
329
+ await coordinatorProbe();
330
+ modeAndLineageProbe();
331
+ console.log("provider side chat OK");
332
+ }
333
+
334
+ main().catch((error) => {
335
+ console.error(error.stack || error.message);
336
+ process.exitCode = 1;
337
+ }).finally(() => fs.rmSync(root, { recursive: true, force: true }));
@@ -0,0 +1,316 @@
1
+ #!/usr/bin/env node
2
+
3
+ "use strict";
4
+
5
+ const assert = require("assert");
6
+ const fs = require("fs");
7
+ const os = require("os");
8
+ const path = require("path");
9
+ const { spawnSync } = require("child_process");
10
+
11
+ const root = __dirname;
12
+ const fixturesRoot = path.join(root, "test-fixtures", "migrations");
13
+ const apiConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), "open-claudia-state-api-"));
14
+ process.env.OPEN_CLAUDIA_CONFIG_DIR = apiConfigDir;
15
+ const fixtureNames = [
16
+ "claude-only",
17
+ "dual-provider",
18
+ "cursor-selected",
19
+ "multi-user",
20
+ "missing-project",
21
+ ];
22
+
23
+ // Keep state/config imports after the fixture-independent environment setup in
24
+ // the child integration probe below. The pure migration API must not write.
25
+ const stateApi = require("./core/state");
26
+ const migration = require("./core/migration-backup");
27
+
28
+ function readFixture(name) {
29
+ return JSON.parse(fs.readFileSync(path.join(fixturesRoot, name, "state.json"), "utf8"));
30
+ }
31
+
32
+ function onlyUser(document) {
33
+ const entries = Object.entries(document.users || {});
34
+ assert.strictEqual(entries.length, 1);
35
+ return entries[0][1];
36
+ }
37
+
38
+ function fixtureMigrationProbe() {
39
+ for (const name of fixtureNames) {
40
+ const raw = readFixture(name);
41
+ const migrated = stateApi.migrateStateDocument(raw, {
42
+ defaultUserId: "telegram:fixture",
43
+ canonicalizeUserId: (value) => String(value),
44
+ });
45
+ assert.strictEqual(migrated.schemaVersion, stateApi.STATE_SCHEMA_VERSION, `${name}: schema version`);
46
+ assert.deepStrictEqual(
47
+ stateApi.migrateStateDocument(migrated, {
48
+ defaultUserId: "telegram:fixture",
49
+ canonicalizeUserId: (value) => String(value),
50
+ }),
51
+ migrated,
52
+ `${name}: migration is idempotent`,
53
+ );
54
+ }
55
+
56
+ const claude = onlyUser(stateApi.migrateStateDocument(readFixture("claude-only"), {
57
+ defaultUserId: "telegram:fixture",
58
+ canonicalizeUserId: (value) => String(value),
59
+ }));
60
+ assert.strictEqual(claude.activeSessions.alpha.claude, "claude-session-alpha");
61
+ assert.strictEqual(claude.providerSettings.claude.model, "claude-sonnet");
62
+ assert.strictEqual(claude.settings.backend, "claude");
63
+ assert.ok(!Object.prototype.hasOwnProperty.call(claude.settings, "model"));
64
+
65
+ const dual = stateApi.migrateStateDocument(readFixture("dual-provider"), {
66
+ defaultUserId: "telegram:fixture",
67
+ canonicalizeUserId: (value) => String(value),
68
+ }).users["owner@example.test"];
69
+ assert.deepStrictEqual(dual.activeSessions.parity, { claude: "claude-session-parity" });
70
+ assert.strictEqual(dual.legacyProviderState.unresolvedSessionPointers.codex, "codex-thread-parity",
71
+ "legacy process-global Codex pointers are not guessed into currentSession");
72
+ assert.strictEqual(dual.providerSettings.codex.model, "gpt-fixture");
73
+
74
+ const cursor = onlyUser(stateApi.migrateStateDocument(readFixture("cursor-selected"), {
75
+ defaultUserId: "telegram:fixture",
76
+ canonicalizeUserId: (value) => String(value),
77
+ }));
78
+ assert.strictEqual(cursor.settings.backend, null);
79
+ assert.deepStrictEqual(cursor.activeSessions, { "legacy-cursor": { claude: "claude-neighbor" } });
80
+ assert.strictEqual(cursor.legacyProviderState.archivedSessionPointers.cursor, "cursor-session-unresolved");
81
+ assert.strictEqual(cursor.legacyProviderState.archivedActiveSessions["legacy-cursor"].cursor, "cursor-session-unresolved");
82
+ assert.strictEqual(cursor.legacyProviderState.archivedProviderSettings.cursor.model, "cursor-model");
83
+ assert.strictEqual(cursor.legacyProviderState.providerSelectionRequired, true);
84
+
85
+ const missing = onlyUser(stateApi.migrateStateDocument(readFixture("missing-project"), {
86
+ defaultUserId: "telegram:fixture",
87
+ canonicalizeUserId: (value) => String(value),
88
+ }));
89
+ assert.deepStrictEqual(missing.activeSessions, {});
90
+ assert.strictEqual(missing.legacyProviderState.unresolvedSessionPointers.claude, "orphan-session");
91
+
92
+ const multi = stateApi.migrateStateDocument(readFixture("multi-user"), {
93
+ defaultUserId: "telegram:fixture",
94
+ canonicalizeUserId: (value) => String(value),
95
+ });
96
+ assert.strictEqual(multi.users["telegram:100"].activeSessions.one.claude, "session-one");
97
+ assert.deepStrictEqual(multi.users["person@example.test"].activeSessions, {});
98
+ assert.strictEqual(multi.users["person@example.test"].legacyProviderState.unresolvedSessionPointers.codex, "thread-two");
99
+ }
100
+
101
+ function helperAndBoundProbe() {
102
+ const usageBySession = {};
103
+ for (let index = 0; index < stateApi.MAX_USAGE_ENTRIES + 15; index += 1) {
104
+ usageBySession[`project-${index}\0claude\0session-${index}`] = { turns: index };
105
+ }
106
+ const migrated = stateApi.migrateStateDocument({
107
+ users: {
108
+ owner: {
109
+ currentSession: { name: "alpha", dir: "/workspace/alpha" },
110
+ activeSessions: { alpha: { claude: "claude-a", codex: "codex-a" } },
111
+ providerSettings: { claude: { model: "sonnet" }, codex: { model: "gpt" } },
112
+ settings: { backend: "codex", compactWindow: 123, recallEngine: "classic" },
113
+ usageBySession,
114
+ },
115
+ },
116
+ }, { canonicalizeUserId: (value) => String(value) });
117
+ const saved = migrated.users.owner;
118
+ assert.ok(Object.keys(saved.usageBySession).length <= stateApi.MAX_USAGE_ENTRIES);
119
+ assert.ok(saved.usageBySession[`project-${stateApi.MAX_USAGE_ENTRIES + 14}\0claude\0session-${stateApi.MAX_USAGE_ENTRIES + 14}`]);
120
+
121
+ const recentUsageState = { usageBySession: structuredClone(saved.usageBySession) };
122
+ const oldestRetainedKey = Object.keys(recentUsageState.usageBySession)[0];
123
+ const [oldestProject, oldestProvider, oldestSession] = oldestRetainedKey.split("\0");
124
+ stateApi.getUsageForSession(recentUsageState, oldestProject, oldestProvider, oldestSession);
125
+ stateApi.getUsageForSession(recentUsageState, "new-project", "claude", "new-session");
126
+ assert.ok(recentUsageState.usageBySession[oldestRetainedKey], "access refreshes an active usage entry before bounding");
127
+
128
+ const state = {
129
+ settings: { backend: "codex" },
130
+ currentSession: { name: "alpha", dir: "/workspace/alpha" },
131
+ activeSessions: structuredClone(saved.activeSessions),
132
+ providerSettings: structuredClone(saved.providerSettings),
133
+ usageBySession: {},
134
+ };
135
+ assert.strictEqual(stateApi.getActiveProvider(state), "codex");
136
+ assert.strictEqual(stateApi.getProjectKey(state), "alpha");
137
+ assert.strictEqual(stateApi.getProviderSession(state, "claude", "alpha"), "claude-a");
138
+ assert.strictEqual(stateApi.getProviderSession(state, "codex", "alpha"), "codex-a");
139
+ stateApi.setProviderSession(state, "codex", "alpha", "codex-next");
140
+ assert.strictEqual(stateApi.getProviderSession(state, "codex", "alpha"), "codex-next");
141
+ stateApi.clearProviderSession(state, "codex", "alpha");
142
+ assert.strictEqual(stateApi.getProviderSession(state, "codex", "alpha"), null);
143
+ assert.strictEqual(stateApi.getProviderSettings(state, "claude").model, "sonnet");
144
+ assert.strictEqual(stateApi.getActiveUsage(state).turns, 0);
145
+ assert.ok(Object.keys(state.usageBySession)[0].includes("alpha\0codex\0new"));
146
+
147
+ const legacyRuntime = onlyUser(stateApi.migrateStateDocument({
148
+ currentSession: { name: "alpha", dir: "/workspace/alpha" },
149
+ codexSessionId: "codex-a",
150
+ codexUsageSnapshots: { "codex-a": { input_tokens: 50, output_tokens: 5 } },
151
+ sessionUsage: { turns: 4, inputTokens: 40, outputTokens: 8 },
152
+ lastCompactedAt: 123456,
153
+ settings: {
154
+ backend: "codex",
155
+ model: "gpt-fixture",
156
+ effort: "high",
157
+ budget: 2,
158
+ permissionMode: "plan",
159
+ worktree: true,
160
+ compactWindow: 200000,
161
+ recallEngine: "discoverer",
162
+ showToolTrace: true,
163
+ },
164
+ }, { defaultUserId: "owner", canonicalizeUserId: (value) => String(value) }));
165
+ assert.deepStrictEqual(legacyRuntime.legacyProviderState.unresolvedCodexUsageSnapshots["codex-a"], {
166
+ input_tokens: 50,
167
+ output_tokens: 5,
168
+ });
169
+ assert.ok(!Object.prototype.hasOwnProperty.call(legacyRuntime, "codexUsageSnapshots"));
170
+ assert.deepStrictEqual(legacyRuntime.legacyProviderState.unresolvedUsage, {
171
+ turns: 4,
172
+ inputTokens: 40,
173
+ outputTokens: 8,
174
+ });
175
+ assert.strictEqual(legacyRuntime.legacyProviderState.unresolvedSessionPointers.codex, "codex-a");
176
+ assert.ok(!Object.keys(legacyRuntime.usageBySession).some((key) => key.includes("codex-a")));
177
+ assert.deepStrictEqual(legacyRuntime.providerSettings.codex, {
178
+ model: "gpt-fixture",
179
+ effort: "high",
180
+ budget: 2,
181
+ permissionMode: "plan",
182
+ worktree: true,
183
+ });
184
+ assert.strictEqual(legacyRuntime.settings.compactWindow, 200000);
185
+ assert.strictEqual(legacyRuntime.settings.recallEngine, "discoverer");
186
+ assert.strictEqual(legacyRuntime.settings.showToolTrace, true);
187
+ assert.strictEqual(legacyRuntime.lastCompactedAt, 123456);
188
+
189
+ const provenCodexOwner = onlyUser(stateApi.migrateStateDocument({
190
+ currentSession: { name: "other", dir: "/workspace/other" },
191
+ activeSessions: { proven: { codex: "codex-proven" } },
192
+ codexSessionId: "codex-proven",
193
+ codexUsageSnapshots: { "codex-proven": { input_tokens: 7 } },
194
+ sessionUsage: { turns: 2 },
195
+ settings: { backend: "codex" },
196
+ }, { defaultUserId: "owner", canonicalizeUserId: (value) => String(value) }));
197
+ assert.strictEqual(provenCodexOwner.activeSessions.proven.codex, "codex-proven");
198
+ assert.strictEqual(provenCodexOwner.usageBySession["proven\0codex\0codex-proven"].turns, 2);
199
+ assert.strictEqual(
200
+ provenCodexOwner.usageBySession["proven\0codex\0codex-proven"].providerUsageSnapshot.input_tokens,
201
+ 7,
202
+ );
203
+ assert.throws(
204
+ () => stateApi.migrateStateDocument({ schemaVersion: stateApi.STATE_SCHEMA_VERSION + 1, users: {} }),
205
+ (error) => error.code === "UNSUPPORTED_STATE_SCHEMA",
206
+ );
207
+ }
208
+
209
+ function atomicIntegrationProbe() {
210
+ const temp = fs.mkdtempSync(path.join(os.tmpdir(), "open-claudia-state-migration-"));
211
+ try {
212
+ const configDir = path.join(temp, "config");
213
+ fs.mkdirSync(configDir, { recursive: true });
214
+ fs.cpSync(path.join(fixturesRoot, "claude-only"), configDir, { recursive: true });
215
+ const original = fs.readFileSync(path.join(configDir, "state.json"));
216
+ const childScript = [
217
+ "const state = require('./core/state');",
218
+ "state.currentState();",
219
+ "if (!state.saveState({ strict: true })) process.exit(2);",
220
+ ].join("\n");
221
+ const env = {
222
+ ...process.env,
223
+ OPEN_CLAUDIA_CONFIG_DIR: configDir,
224
+ TELEGRAM_CHAT_ID: "fixture",
225
+ CHANNELS: "telegram",
226
+ OPEN_CLAUDIA_TEST: "1",
227
+ };
228
+ const first = spawnSync(process.execPath, ["-e", childScript], { cwd: root, env, encoding: "utf8" });
229
+ assert.strictEqual(first.status, 0, first.stderr || first.stdout);
230
+
231
+ const live = JSON.parse(fs.readFileSync(path.join(configDir, "state.json"), "utf8"));
232
+ assert.strictEqual(live.schemaVersion, stateApi.STATE_SCHEMA_VERSION);
233
+ assert.strictEqual(live.users["telegram:fixture"].activeSessions.alpha.claude, "claude-session-alpha");
234
+ const journal = migration.readMigrationJournal(configDir);
235
+ assert.ok(journal.components.state, "state component must activate after the atomic schema write");
236
+ const snapshotDir = path.join(migration.migrationRoot(configDir), ...journal.snapshot.split("/"));
237
+ migration.validateMigrationSnapshot(snapshotDir);
238
+ assert.deepStrictEqual(fs.readFileSync(path.join(snapshotDir, "originals", "state.json")), original);
239
+
240
+ // A restart between component migrations must keep the original rollback
241
+ // snapshot even though state.json now intentionally differs from it.
242
+ const resumed = migration.ensureMigrationSnapshot({
243
+ configDir,
244
+ sources: migration.defaultMigrationSources({ configDir }),
245
+ });
246
+ assert.strictEqual(resumed.snapshotDir, snapshotDir);
247
+ assert.strictEqual(resumed.reused, true);
248
+
249
+ const afterFirst = fs.readFileSync(path.join(configDir, "state.json"));
250
+ const second = spawnSync(process.execPath, ["-e", childScript], { cwd: root, env, encoding: "utf8" });
251
+ assert.strictEqual(second.status, 0, second.stderr || second.stdout);
252
+ assert.deepStrictEqual(fs.readFileSync(path.join(configDir, "state.json")), afterFirst);
253
+ assert.strictEqual(migration.readMigrationJournal(configDir).snapshot, journal.snapshot);
254
+ } finally {
255
+ fs.rmSync(temp, { recursive: true, force: true });
256
+ }
257
+ }
258
+
259
+ function interruptedActivationProbe() {
260
+ const temp = fs.mkdtempSync(path.join(os.tmpdir(), "open-claudia-state-interrupt-"));
261
+ try {
262
+ const configDir = path.join(temp, "config");
263
+ fs.mkdirSync(configDir, { recursive: true });
264
+ fs.cpSync(path.join(fixturesRoot, "dual-provider"), configDir, { recursive: true });
265
+ const original = fs.readFileSync(path.join(configDir, "state.json"));
266
+ const env = {
267
+ ...process.env,
268
+ OPEN_CLAUDIA_CONFIG_DIR: configDir,
269
+ TELEGRAM_CHAT_ID: "fixture",
270
+ CHANNELS: "telegram",
271
+ OPEN_CLAUDIA_TEST: "1",
272
+ };
273
+ const interrupted = spawnSync(process.execPath, ["-e", [
274
+ "const state = require('./core/state');",
275
+ "state.currentState();",
276
+ "try {",
277
+ " state.saveState({ strict: true, faultInjector(point) { if (point === 'after-state-write') throw Object.assign(new Error('stop after rename'), { code: 'FIXTURE_INTERRUPTION' }); } });",
278
+ " process.exit(3);",
279
+ "} catch (error) { if (error.code !== 'FIXTURE_INTERRUPTION') throw error; }",
280
+ ].join("\n")], { cwd: root, env, encoding: "utf8" });
281
+ assert.strictEqual(interrupted.status, 0, interrupted.stderr || interrupted.stdout);
282
+ const pending = migration.readMigrationJournal(configDir);
283
+ assert.ok(pending.pendingComponents.state);
284
+ assert.strictEqual(pending.components.state, null);
285
+ assert.strictEqual(JSON.parse(fs.readFileSync(path.join(configDir, "state.json"), "utf8")).schemaVersion, stateApi.STATE_SCHEMA_VERSION);
286
+
287
+ // This is the same pre-state-import check bot.js performs on restart.
288
+ const resumedSnapshot = migration.ensureMigrationSnapshot({
289
+ configDir,
290
+ sources: migration.defaultMigrationSources({ configDir }),
291
+ });
292
+ assert.strictEqual(resumedSnapshot.journal.snapshot, pending.snapshot);
293
+ const snapshotDir = resumedSnapshot.snapshotDir;
294
+ assert.deepStrictEqual(fs.readFileSync(path.join(snapshotDir, "originals", "state.json")), original);
295
+
296
+ const completed = spawnSync(process.execPath, ["-e", [
297
+ "const state = require('./core/state');",
298
+ "state.currentState();",
299
+ "state.saveState({ strict: true });",
300
+ ].join("\n")], { cwd: root, env, encoding: "utf8" });
301
+ assert.strictEqual(completed.status, 0, completed.stderr || completed.stdout);
302
+ const journal = migration.readMigrationJournal(configDir);
303
+ assert.ok(journal.components.state);
304
+ assert.ok(!journal.pendingComponents.state);
305
+ assert.strictEqual(journal.snapshot, pending.snapshot);
306
+ } finally {
307
+ fs.rmSync(temp, { recursive: true, force: true });
308
+ }
309
+ }
310
+
311
+ fixtureMigrationProbe();
312
+ helperAndBoundProbe();
313
+ atomicIntegrationProbe();
314
+ interruptedActivationProbe();
315
+ fs.rmSync(apiConfigDir, { recursive: true, force: true });
316
+ console.log("provider state migration OK");