@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,537 @@
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 repoRoot = __dirname;
12
+ const fixture = path.join(repoRoot, "test-fixtures", "fake-agent-cli.js");
13
+
14
+ const SUPPORT_SUITES = Object.freeze({
15
+ prompt: ["test-provider-core-prompt.js", "provider core prompt OK"],
16
+ lifecycle: ["test-provider-run-lifecycle.js", "provider run lifecycle OK"],
17
+ gateway: ["test-provider-gateway.js", "provider gateway OK"],
18
+ sessions: ["test-provider-session-commands.js", "provider session commands OK"],
19
+ history: ["test-provider-session-history.js", "provider session history OK"],
20
+ compaction: ["test-provider-compaction.js", "provider compaction OK"],
21
+ scheduler: ["test-provider-scheduler.js", "provider scheduler OK"],
22
+ sideChat: ["test-provider-side-chat.js", "provider side chat OK"],
23
+ recall: ["test-provider-recall.js", "provider recall OK"],
24
+ review: ["test-provider-pack-review.js", "provider pack review OK"],
25
+ mutationQueue: ["test-memory-mutation-queue.js", "memory mutation queue OK"],
26
+ dream: ["test-provider-dream.js", "provider dream OK"],
27
+ subagent: ["test-provider-subagent.js", "provider subagent OK"],
28
+ enforcer: ["test-provider-enforcer.js", "provider enforcer OK"],
29
+ environment: ["test-provider-env-isolation.js", "provider env isolation OK"],
30
+ hooks: ["test-provider-tool-hooks.js", "provider tool hooks OK"],
31
+ migration: ["test-provider-migration-backup.js", "provider migration backup OK"],
32
+ cursorRemoval: ["test-cursor-removal.js", "cursor removal OK"],
33
+ bootstrap: ["test-provider-boot-matrix.js", "provider boot matrix OK"],
34
+ });
35
+
36
+ const SCENARIOS = Object.freeze([
37
+ [1, "fresh prompt context and mandatory fail-closed construction", ["prompt"]],
38
+ [2, "split/coalesced JSONL and one terminal result", ["lifecycle"]],
39
+ [3, "tool progress and final delivery", ["gateway"]],
40
+ [4, "immutable admission across mutable switches", ["gateway"]],
41
+ [5, "terminal lifecycle and failure settlement", ["lifecycle"]],
42
+ [6, "fresh, resume, and continue", ["sessions"]],
43
+ [7, "project and provider switching", ["sessions"]],
44
+ [8, "restart state restoration", ["sessions"]],
45
+ [9, "new/end/sessions and callback rejection", ["sessions", "history"]],
46
+ [10, "compaction summary/seed rollback safety", ["compaction"]],
47
+ [11, "provider-pinned wakeups and recurring jobs", ["scheduler"]],
48
+ [12, "file/image/voice gateway ingress", ["gateway"]],
49
+ [13, "nonblocking side chat without main mutation", ["sideChat"]],
50
+ [14, "recall filter and discoverer", ["recall"]],
51
+ [15, "post-turn review and serialized memory writes", ["review", "mutationQueue"]],
52
+ [16, "dream and introspection", ["dream"]],
53
+ [17, "sub-agent timeout and process-tree cleanup", ["subagent"]],
54
+ [18, "external-person enforcer", ["enforcer"]],
55
+ [19, "provider auth failure and recovery", ["lifecycle"]],
56
+ [20, "credential environment isolation", ["environment"]],
57
+ [21, "mandatory tool-hook initialization", ["hooks"]],
58
+ [22, "verified migration, archival ordering, and rollback", ["migration", "cursorRemoval"]],
59
+ [23, "no-provider setup/status boot without model calls", ["bootstrap"]],
60
+ ].map(([id, name, evidence]) => Object.freeze({ id, name, evidence: Object.freeze(evidence) })));
61
+
62
+ function deferred() {
63
+ let resolve;
64
+ let reject;
65
+ const promise = new Promise((res, rej) => { resolve = res; reject = rej; });
66
+ return { promise, resolve, reject };
67
+ }
68
+
69
+ function timeoutAfter(promise, ms, label) {
70
+ let timer;
71
+ return Promise.race([
72
+ promise,
73
+ new Promise((_, reject) => {
74
+ timer = setTimeout(() => reject(new Error(`timed out waiting for ${label}`)), ms);
75
+ }),
76
+ ]).finally(() => clearTimeout(timer));
77
+ }
78
+
79
+ async function assertPending(promise, label) {
80
+ let settled = false;
81
+ promise.then(() => { settled = true; }, () => { settled = true; });
82
+ await new Promise((resolve) => setImmediate(resolve));
83
+ assert.strictEqual(settled, false, label);
84
+ }
85
+
86
+ function stageGate(name, calls, action = null) {
87
+ const entered = deferred();
88
+ const release = deferred();
89
+ return {
90
+ entered,
91
+ release,
92
+ async hook(...args) {
93
+ calls.push(name);
94
+ entered.resolve(args);
95
+ await release.promise;
96
+ if (action) return action(...args);
97
+ return undefined;
98
+ },
99
+ };
100
+ }
101
+
102
+ function providerFactory(kind, runtime) {
103
+ const ownCredentials = kind === "claude"
104
+ ? { ANTHROPIC_API_KEY: "fixture-anthropic-present", CLAUDE_CODE_OAUTH_TOKEN: "fixture-claude-present" }
105
+ : { OPENAI_API_KEY: "fixture-openai-present" };
106
+ const options = {
107
+ hookTransport: false,
108
+ resolveExecutable: () => fixture,
109
+ defaultModel: `${kind}-parity-model`,
110
+ buildEnv: () => ({
111
+ HOME: runtime.root,
112
+ PATH: process.env.PATH || path.dirname(process.execPath),
113
+ FAKE_AGENT_KIND: kind,
114
+ FAKE_AGENT_CAPTURE: runtime.capture,
115
+ FAKE_AGENT_SCENARIO: runtime.scenario || "success",
116
+ FAKE_AGENT_TEXT: "provider-neutral parity reply",
117
+ FAKE_AGENT_TOOL: runtime.tool === false ? "0" : "1",
118
+ FAKE_AGENT_CHUNK_MODE: runtime.chunkMode || "lines",
119
+ FAKE_AGENT_CHUNK_DELAY_MS: "5",
120
+ FAKE_AGENT_FINAL_NEWLINE: runtime.finalNewline === false ? "0" : "1",
121
+ FAKE_AGENT_LINE_ENDING: runtime.lineEnding || "lf",
122
+ FAKE_AGENT_DUPLICATE_TERMINAL: runtime.duplicateTerminal ? "1" : "0",
123
+ FAKE_AGENT_CLOSE_DELAY_MS: String(runtime.closeDelayMs || 0),
124
+ ...ownCredentials,
125
+ }),
126
+ getAuthStatus: () => ({ state: "authenticated", reason: null }),
127
+ };
128
+ if (kind === "claude") return require("./core/providers/claude").createClaudeProvider(options);
129
+ return require("./core/providers/codex").createCodexProvider(options);
130
+ }
131
+
132
+ function promptBundle(prompt, { runContext }) {
133
+ return {
134
+ coreInstructions: "OPEN CLAUDIA CORE POLICY SENTINEL",
135
+ dynamicContext: [
136
+ `DYNAMIC PROJECT ${runContext.project.name}`,
137
+ "MEMORY RECALL SENTINEL",
138
+ "SPEAKER AND DELIVERY SENTINEL",
139
+ ].join("\n"),
140
+ userPrompt: prompt,
141
+ transcriptPaths: { transcriptsDir: path.join(runContext.project.dir, ".transcripts") },
142
+ recallMetadata: { status: "included", omitted: false, packDirs: ["fixture-pack"] },
143
+ };
144
+ }
145
+
146
+ function captureRecord(filePath) {
147
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
148
+ }
149
+
150
+ function invocationHasResume(kind, argv) {
151
+ return kind === "claude" ? argv.includes("--resume") : argv[0] === "exec" && argv[1] === "resume";
152
+ }
153
+
154
+ function invocationHasContinue(kind, argv) {
155
+ return kind === "claude" ? argv.includes("--continue") : argv[0] === "exec" && argv[1] === "resume";
156
+ }
157
+
158
+ async function runProviderWorkflow(kind) {
159
+ assert.ok(["claude", "codex"].includes(kind));
160
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), `open-claudia-parity-${kind}-`));
161
+ const projectA = path.join(root, "project-a");
162
+ const projectB = path.join(root, "project-b");
163
+ fs.mkdirSync(projectA);
164
+ fs.mkdirSync(projectB);
165
+ const runtime = {
166
+ root,
167
+ capture: path.join(root, "fresh.json"),
168
+ scenario: "success",
169
+ chunkMode: "split",
170
+ finalNewline: false,
171
+ duplicateTerminal: true,
172
+ closeDelayMs: 40,
173
+ };
174
+ const provider = providerFactory(kind, runtime);
175
+ const otherKind = kind === "claude" ? "codex" : "claude";
176
+ const state = {
177
+ userId: "fixture-user",
178
+ currentSession: { name: "project-a", dir: projectA },
179
+ settings: { backend: kind, model: `${kind}-parity-model`, permissionMode: "plan" },
180
+ activeSessions: { "project-a": { [kind]: `${kind}-existing-session` } },
181
+ providerSettings: {
182
+ [kind]: {
183
+ model: `${kind}-parity-model`,
184
+ effort: "medium",
185
+ budget: null,
186
+ permissionMode: "plan",
187
+ worktree: false,
188
+ },
189
+ },
190
+ lastInputWasVoice: false,
191
+ };
192
+ let wrongProviderLookups = 0;
193
+ const resolveProvider = (id) => {
194
+ if (id !== kind) {
195
+ wrongProviderLookups += 1;
196
+ throw Object.assign(new Error(`provider ${id} is not installed in ${kind}-only matrix`), { code: "UNKNOWN_PROVIDER" });
197
+ }
198
+ return provider;
199
+ };
200
+
201
+ const calls = [];
202
+ const normalizedEvents = [];
203
+ const deliveries = [];
204
+ const cleanup = stageGate("cleanup", calls);
205
+ const transcript = stageGate("transcript", calls);
206
+ const usage = stageGate("usage", calls);
207
+ const session = stageGate("session", calls, (result, runContext) => {
208
+ state.activeSessions[runContext.project.name] ||= {};
209
+ state.activeSessions[runContext.project.name][runContext.provider] = result.sessionId;
210
+ });
211
+ const delivery = stageGate("delivery", calls, (result) => {
212
+ deliveries.push({ ok: result.ok, text: result.text });
213
+ return { ok: true };
214
+ });
215
+ const service = require("./core/runner").createRunnerService({
216
+ getState: () => state,
217
+ getStore: () => ({ channelId: "fixture-channel", transport: "telegram" }),
218
+ resolveProvider,
219
+ buildTurnPrompt: promptBundle,
220
+ cleanup: cleanup.hook,
221
+ persistTranscript: transcript.hook,
222
+ persistUsage: usage.hook,
223
+ persistSession: session.hook,
224
+ deliver: delivery.hook,
225
+ onEvent: (event) => normalizedEvents.push(event.type),
226
+ });
227
+
228
+ const freshPromise = service.runAgent("FRESH USER SENTINEL", projectA, "message-1", {
229
+ fresh: true,
230
+ runId: `${kind}-fresh-run`,
231
+ });
232
+ await timeoutAfter(cleanup.entered.promise, 5000, `${kind} cleanup admission`);
233
+ await assertPending(freshPromise, `${kind}: RunResult waits for cleanup`);
234
+ cleanup.release.resolve();
235
+ await timeoutAfter(transcript.entered.promise, 2000, `${kind} transcript persistence`);
236
+ await assertPending(freshPromise, `${kind}: RunResult waits for transcript persistence`);
237
+ transcript.release.resolve();
238
+ await timeoutAfter(usage.entered.promise, 2000, `${kind} usage persistence`);
239
+ await assertPending(freshPromise, `${kind}: RunResult waits for usage persistence`);
240
+ usage.release.resolve();
241
+ await timeoutAfter(session.entered.promise, 2000, `${kind} session persistence`);
242
+ await assertPending(freshPromise, `${kind}: RunResult waits for session persistence`);
243
+ session.release.resolve();
244
+ await timeoutAfter(delivery.entered.promise, 2000, `${kind} delivery settlement`);
245
+ await assertPending(freshPromise, `${kind}: RunResult waits for required delivery`);
246
+ delivery.release.resolve();
247
+ const fresh = await timeoutAfter(freshPromise, 2000, `${kind} fresh terminal result`);
248
+
249
+ assert.strictEqual(fresh.ok, true);
250
+ assert.strictEqual(fresh.status, "succeeded");
251
+ assert.strictEqual(fresh.text, "provider-neutral parity reply");
252
+ assert.strictEqual(fresh.terminalSeen, true);
253
+ assert.strictEqual(fresh.stdoutEnded, true);
254
+ assert.strictEqual(normalizedEvents.filter((type) => type === "result").length, 1, `${kind}: duplicate terminal event is normalized once`);
255
+ assert.deepStrictEqual(normalizedEvents.filter((type) => type === "tool_start" || type === "tool_end"), ["tool_start", "tool_end"]);
256
+ assert.deepStrictEqual(calls, ["cleanup", "transcript", "usage", "session", "delivery"]);
257
+ assert.deepStrictEqual(deliveries, [{ ok: true, text: "provider-neutral parity reply" }]);
258
+
259
+ const freshCapture = captureRecord(runtime.capture);
260
+ const argvText = freshCapture.argv.join("\n");
261
+ assert.match(argvText, /OPEN CLAUDIA CORE POLICY SENTINEL/);
262
+ assert.match(argvText, /DYNAMIC PROJECT project-a/);
263
+ assert.match(argvText, /MEMORY RECALL SENTINEL/);
264
+ assert.match(argvText, /FRESH USER SENTINEL/);
265
+ assert.strictEqual(invocationHasResume(kind, freshCapture.argv), false);
266
+ const ownCredentialPresent = kind === "claude"
267
+ ? freshCapture.envPresence.ANTHROPIC_API_KEY && freshCapture.envPresence.CLAUDE_CODE_OAUTH_TOKEN
268
+ : freshCapture.envPresence.OPENAI_API_KEY;
269
+ const crossCredentialAbsent = kind === "claude"
270
+ ? !freshCapture.envPresence.OPENAI_API_KEY
271
+ : !freshCapture.envPresence.ANTHROPIC_API_KEY && !freshCapture.envPresence.CLAUDE_CODE_OAUTH_TOKEN;
272
+ assert.strictEqual(ownCredentialPresent, true);
273
+ assert.strictEqual(crossCredentialAbsent, true);
274
+ for (const key of ["TELEGRAM_BOT_TOKEN", "KAZEE_BOT_TOKEN", "BOT_CONTROL_TOKEN", "OC_SEND_TOKEN"]) {
275
+ assert.strictEqual(freshCapture.envPresence[key], false, `${kind}: ${key} must be absent`);
276
+ }
277
+
278
+ const captureEvents = [];
279
+ const captureService = require("./core/runner").createRunnerService({
280
+ getState: () => state,
281
+ getStore: () => ({ channelId: "fixture-channel", transport: "telegram" }),
282
+ resolveProvider,
283
+ buildTurnPrompt: promptBundle,
284
+ onEvent: (event) => captureEvents.push(event.type),
285
+ });
286
+
287
+ runtime.capture = path.join(root, "coalesced.json");
288
+ runtime.chunkMode = "coalesce";
289
+ runtime.lineEnding = "crlf";
290
+ runtime.finalNewline = false;
291
+ runtime.duplicateTerminal = false;
292
+ runtime.closeDelayMs = 0;
293
+ const coalesced = await captureService.runAgentCapture("COALESCED SENTINEL", projectA, { fresh: true });
294
+ assert.strictEqual(coalesced.ok, true);
295
+ assert.strictEqual(captureEvents.filter((type) => type === "result").length, 1);
296
+
297
+ runtime.capture = path.join(root, "resume.json");
298
+ runtime.chunkMode = "lines";
299
+ runtime.lineEnding = "lf";
300
+ const resume = await captureService.runAgentCapture("RESUME SENTINEL", projectA, {
301
+ resumeSessionId: fresh.sessionId,
302
+ });
303
+ assert.strictEqual(resume.ok, true);
304
+ assert.strictEqual(resume.sessionId, fresh.sessionId);
305
+ assert.strictEqual(invocationHasResume(kind, captureRecord(runtime.capture).argv), true);
306
+
307
+ runtime.capture = path.join(root, "continue.json");
308
+ const continued = await captureService.runAgentCapture("CONTINUE SENTINEL", projectA, {
309
+ continueSession: true,
310
+ });
311
+ assert.strictEqual(continued.ok, true);
312
+ assert.strictEqual(invocationHasContinue(kind, captureRecord(runtime.capture).argv), true);
313
+
314
+ runtime.capture = path.join(root, "race.json");
315
+ const promptEntered = deferred();
316
+ const promptRelease = deferred();
317
+ const raceService = require("./core/runner").createRunnerService({
318
+ getState: () => state,
319
+ getStore: () => ({ channelId: "fixture-channel", transport: "telegram" }),
320
+ resolveProvider,
321
+ async buildTurnPrompt(prompt, context) {
322
+ promptEntered.resolve(context.runContext);
323
+ await promptRelease.promise;
324
+ return promptBundle(prompt, context);
325
+ },
326
+ });
327
+ const expectedSession = state.activeSessions["project-a"][kind];
328
+ const racePromise = raceService.runAgentCapture("RACE SENTINEL", projectA);
329
+ const admitted = await timeoutAfter(promptEntered.promise, 2000, `${kind} immutable prompt wait`);
330
+ state.settings = { backend: otherKind, model: `${otherKind}-changed-model`, permissionMode: "plan" };
331
+ state.currentSession = { name: "project-b", dir: projectB };
332
+ state.activeSessions["project-a"][kind] = `${kind}-changed-after-admission`;
333
+ promptRelease.resolve();
334
+ const raced = await timeoutAfter(racePromise, 5000, `${kind} immutable race result`);
335
+ assert.strictEqual(raced.ok, true);
336
+ assert.strictEqual(raced.provider, kind);
337
+ assert.strictEqual(raced.project.name, "project-a");
338
+ assert.strictEqual(raced.admittedSessionId, expectedSession);
339
+ assert.strictEqual(admitted.providerSettings.model, `${kind}-parity-model`);
340
+ const raceArgv = captureRecord(runtime.capture).argv;
341
+ assert.ok(raceArgv.includes(expectedSession));
342
+ assert.ok(!raceArgv.includes(`${kind}-changed-after-admission`));
343
+ assert.strictEqual(wrongProviderLookups, 0);
344
+
345
+ state.settings = { backend: kind, model: `${kind}-parity-model`, permissionMode: "plan" };
346
+ state.currentSession = { name: "project-a", dir: projectA };
347
+ state.activeSessions["project-a"][kind] = expectedSession;
348
+
349
+ let mandatoryFailureSpawns = 0;
350
+ let mandatoryFailureDeliveries = 0;
351
+ const promptFailureService = require("./core/runner").createRunnerService({
352
+ getState: () => state,
353
+ resolveProvider,
354
+ buildTurnPrompt() {
355
+ throw Object.assign(new Error("mandatory prompt construction failed"), { code: "MANDATORY_PROMPT_FAILED" });
356
+ },
357
+ spawnProcess() { mandatoryFailureSpawns += 1; throw new Error("must not spawn"); },
358
+ deliver: async () => { mandatoryFailureDeliveries += 1; return { ok: true }; },
359
+ });
360
+ const promptFailure = await promptFailureService.runAgent("RAW USER FALLBACK MUST NOT RUN", projectA, null, { fresh: true });
361
+ assert.strictEqual(promptFailure.ok, false);
362
+ assert.strictEqual(promptFailure.error.code, "MANDATORY_PROMPT_FAILED");
363
+ assert.strictEqual(mandatoryFailureSpawns, 0);
364
+ assert.strictEqual(mandatoryFailureDeliveries, 1);
365
+
366
+ runtime.capture = path.join(root, "auth-failure.json");
367
+ runtime.scenario = "auth-failure";
368
+ const authFailure = await captureService.runAgentCapture("AUTH FAILURE SENTINEL", projectA, { fresh: true });
369
+ assert.strictEqual(authFailure.ok, false);
370
+ assert.strictEqual(authFailure.error.code, "PROVIDER_UNAUTHENTICATED");
371
+ assert.match(authFailure.diagnostic, /authentication failed/i);
372
+ runtime.scenario = "success";
373
+
374
+ let hookFailureSpawns = 0;
375
+ const hookFailureService = require("./core/runner").createRunnerService({
376
+ getState: () => state,
377
+ resolveProvider,
378
+ buildTurnPrompt: promptBundle,
379
+ buildInvocation() {
380
+ throw Object.assign(new Error("mandatory provider tool hook failed to initialize"), { code: "TOOL_HOOK_INIT_FAILED" });
381
+ },
382
+ spawnProcess() { hookFailureSpawns += 1; throw new Error("must not spawn"); },
383
+ });
384
+ const hookFailure = await hookFailureService.runAgentCapture("HOOK FAILURE SENTINEL", projectA, { fresh: true });
385
+ assert.strictEqual(hookFailure.ok, false);
386
+ assert.strictEqual(hookFailure.error.code, "TOOL_HOOK_INIT_FAILED");
387
+ assert.strictEqual(hookFailureSpawns, 0);
388
+
389
+ const semantic = {
390
+ promptPolicyInjected: true,
391
+ splitAndCoalesced: true,
392
+ normalizedTerminalResults: 1,
393
+ toolProgress: ["tool_start", "tool_end"],
394
+ finalDeliveries: deliveries.length,
395
+ immutableAdmission: true,
396
+ lifecycleOrder: calls,
397
+ freshResumeContinue: true,
398
+ promptFailureCode: promptFailure.error.code,
399
+ authFailureCode: authFailure.error.code,
400
+ credentialBoundary: true,
401
+ hookFailureCode: hookFailure.error.code,
402
+ };
403
+ const nativeDifferences = {
404
+ provider: kind,
405
+ sessionId: fresh.sessionId,
406
+ toolName: fresh.tools.find((event) => event.type === "tool_start")?.name || null,
407
+ budgetSupport: provider.capabilities.budget.support,
408
+ maxTurnsSupport: provider.capabilities.maxTurns.support,
409
+ };
410
+ fs.rmSync(root, { recursive: true, force: true });
411
+ return { provider: kind, scenarios: [1, 2, 3, 4, 5, 6, 19, 20, 21], semantic, nativeDifferences };
412
+ }
413
+
414
+ function isolatedEnv(root, provider = null) {
415
+ const env = {
416
+ PATH: process.env.PATH || path.dirname(process.execPath),
417
+ HOME: path.join(root, "home"),
418
+ TMPDIR: process.env.TMPDIR || os.tmpdir(),
419
+ LANG: process.env.LANG || "C.UTF-8",
420
+ LC_ALL: process.env.LC_ALL || "C.UTF-8",
421
+ OPEN_CLAUDIA_TEST: "1",
422
+ OPEN_CLAUDIA_CONFIG_DIR: path.join(root, "config"),
423
+ WORKSPACE: path.join(root, "workspace"),
424
+ CHANNELS: "",
425
+ TELEGRAM_CHAT_ID: "fixture",
426
+ CLAUDE_PATH: path.join(root, "unavailable-claude"),
427
+ CODEX_PATH: path.join(root, "unavailable-codex"),
428
+ };
429
+ if (provider === "claude") env.CLAUDE_PATH = fixture;
430
+ if (provider === "codex") env.CODEX_PATH = fixture;
431
+ if (provider) env.PARITY_PROVIDER_ONLY = provider;
432
+ return env;
433
+ }
434
+
435
+ function runProviderChild(kind) {
436
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), `open-claudia-parity-child-${kind}-`));
437
+ try {
438
+ fs.mkdirSync(path.join(root, "home"));
439
+ fs.mkdirSync(path.join(root, "workspace"));
440
+ const child = spawnSync(process.execPath, [__filename, "--provider", kind], {
441
+ cwd: repoRoot,
442
+ env: isolatedEnv(root, kind),
443
+ encoding: "utf8",
444
+ timeout: 20 * 60 * 1000,
445
+ maxBuffer: 4 * 1024 * 1024,
446
+ });
447
+ assert.ifError(child.error);
448
+ assert.strictEqual(child.status, 0, child.stderr || child.stdout);
449
+ const marker = child.stdout.split(/\r?\n/).find((line) => line.startsWith("PARITY_PROVIDER:"));
450
+ assert.ok(marker, `${kind}: provider ledger missing`);
451
+ return JSON.parse(marker.slice("PARITY_PROVIDER:".length));
452
+ } finally {
453
+ fs.rmSync(root, { recursive: true, force: true });
454
+ }
455
+ }
456
+
457
+ function runSupportSuites(provider) {
458
+ assert.ok(["claude", "codex"].includes(provider));
459
+ const passed = new Set();
460
+ for (const [key, [script, marker]] of Object.entries(SUPPORT_SUITES)) {
461
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), `open-claudia-parity-suite-${key}-`));
462
+ try {
463
+ fs.mkdirSync(path.join(root, "home"));
464
+ fs.mkdirSync(path.join(root, "workspace"));
465
+ const result = spawnSync(process.execPath, [script], {
466
+ cwd: repoRoot,
467
+ env: isolatedEnv(root, provider),
468
+ encoding: "utf8",
469
+ timeout: 90000,
470
+ maxBuffer: 8 * 1024 * 1024,
471
+ });
472
+ assert.ifError(result.error);
473
+ assert.strictEqual(result.status, 0, `${script}\n${result.stderr || result.stdout}`);
474
+ assert.ok(`${result.stdout}\n${result.stderr}`.includes(marker), `${script}: missing ${marker}`);
475
+ passed.add(key);
476
+ } finally {
477
+ fs.rmSync(root, { recursive: true, force: true });
478
+ }
479
+ }
480
+ return passed;
481
+ }
482
+
483
+ async function childMain(kind) {
484
+ const otherKind = kind === "claude" ? "codex" : "claude";
485
+ const { providerExecutableStatus } = require("./core/config");
486
+ const selectedStatus = providerExecutableStatus(kind);
487
+ const otherStatus = providerExecutableStatus(otherKind);
488
+ assert.strictEqual(selectedStatus.state, "available", `${kind} fixture must be the only discoverable provider`);
489
+ assert.strictEqual(otherStatus.state, "missing", `${otherKind} must be unavailable in the ${kind}-only matrix`);
490
+ const ledger = await runProviderWorkflow(kind);
491
+ const passed = runSupportSuites(kind);
492
+ for (const scenario of SCENARIOS) {
493
+ for (const evidence of scenario.evidence) {
494
+ assert.ok(passed.has(evidence), `${kind} scenario ${scenario.id} missing ${evidence} evidence`);
495
+ }
496
+ }
497
+ ledger.directScenarios = ledger.scenarios;
498
+ ledger.scenarios = SCENARIOS.map((scenario) => scenario.id);
499
+ ledger.supportEvidence = [...passed].sort();
500
+ ledger.discovery = { selected: selectedStatus.state, other: otherStatus.state };
501
+ process.stdout.write(`PARITY_PROVIDER:${JSON.stringify(ledger)}\n`);
502
+ }
503
+
504
+ function parentMain() {
505
+ assert.ok(fs.existsSync(fixture));
506
+ assert.deepStrictEqual(SCENARIOS.map((scenario) => scenario.id), Array.from({ length: 23 }, (_, index) => index + 1));
507
+ for (const scenario of SCENARIOS) {
508
+ assert.ok(scenario.evidence.length > 0, `scenario ${scenario.id} requires evidence`);
509
+ for (const key of scenario.evidence) assert.ok(SUPPORT_SUITES[key], `scenario ${scenario.id}: unknown evidence ${key}`);
510
+ }
511
+
512
+ const claude = runProviderChild("claude");
513
+ const codex = runProviderChild("codex");
514
+ assert.deepStrictEqual(claude.scenarios, SCENARIOS.map((scenario) => scenario.id));
515
+ assert.deepStrictEqual(claude.scenarios, codex.scenarios);
516
+ assert.deepStrictEqual(claude.directScenarios, codex.directScenarios);
517
+ assert.deepStrictEqual(claude.supportEvidence, codex.supportEvidence);
518
+ assert.deepStrictEqual(claude.discovery, { selected: "available", other: "missing" });
519
+ assert.deepStrictEqual(codex.discovery, { selected: "available", other: "missing" });
520
+ assert.deepStrictEqual(claude.semantic, codex.semantic, "provider-neutral foreground ledgers must match");
521
+ assert.notStrictEqual(claude.nativeDifferences.provider, codex.nativeDifferences.provider);
522
+ assert.notStrictEqual(claude.nativeDifferences.sessionId, codex.nativeDifferences.sessionId);
523
+ assert.notStrictEqual(claude.nativeDifferences.toolName, codex.nativeDifferences.toolName);
524
+ assert.strictEqual(claude.nativeDifferences.budgetSupport, "native");
525
+ assert.strictEqual(codex.nativeDifferences.budgetSupport, "unsupported");
526
+
527
+ console.log("provider parity e2e OK");
528
+ }
529
+
530
+ if (process.argv[2] === "--provider") {
531
+ childMain(process.argv[3]).catch((error) => {
532
+ console.error(error.stack || error.message);
533
+ process.exitCode = 1;
534
+ });
535
+ } else {
536
+ parentMain();
537
+ }
@@ -0,0 +1,89 @@
1
+ #!/usr/bin/env node
2
+
3
+ const assert = require("assert");
4
+ const fs = require("fs");
5
+ const os = require("os");
6
+ const path = require("path");
7
+ const { spawnSync } = require("child_process");
8
+
9
+ const baselinePath = path.join(__dirname, "test-fixtures", "current-provider-parity.json");
10
+ assert.ok(fs.existsSync(baselinePath), "current provider-parity baseline fixture must exist");
11
+ const baseline = JSON.parse(fs.readFileSync(baselinePath, "utf8"));
12
+
13
+ async function probe() {
14
+ const { buildTurnPrompt } = require("./core/system-prompt");
15
+ const { getProvider } = require("./core/providers");
16
+ const { currentState } = require("./core/state");
17
+ const state = currentState();
18
+ state.currentSession = { name: "parity-project", dir: process.env.WORKSPACE };
19
+ state.settings = { ...state.settings, backend: "codex" };
20
+ state.activeSessions["parity-project"] = { codex: "parity-thread" };
21
+ const bundle = await buildTurnPrompt(baseline.prompt.sentinel, {
22
+ provider: getProvider("codex"),
23
+ runContext: {
24
+ canonicalUserId: state.userId,
25
+ project: { ...state.currentSession },
26
+ provider: "codex",
27
+ providerSettings: { ...state.settings },
28
+ sessionId: state.activeSessions["parity-project"].codex,
29
+ channelId: "parity-channel",
30
+ origin: { transport: "telegram", channelId: "parity-channel" },
31
+ },
32
+ });
33
+ process.stdout.write(`${JSON.stringify({ bundle })}\n`);
34
+ }
35
+
36
+ if (process.argv[2] === "--probe") {
37
+ probe().catch((error) => {
38
+ console.error(error.stack || error.message);
39
+ process.exit(1);
40
+ });
41
+ } else {
42
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "open-claudia-prompt-parity-"));
43
+ const workspace = path.join(root, "workspace");
44
+ const configDir = path.join(root, "config");
45
+ fs.mkdirSync(workspace);
46
+ fs.mkdirSync(configDir);
47
+ fs.writeFileSync(path.join(configDir, "soul.md"), "# Soul\n\nOpen Claudia prompt parity fixture policy.\n");
48
+ const env = {
49
+ HOME: root,
50
+ PATH: path.dirname(process.execPath),
51
+ TMPDIR: process.env.TMPDIR || os.tmpdir(),
52
+ TZ: "UTC",
53
+ OPEN_CLAUDIA_TEST: "1",
54
+ OPEN_CLAUDIA_CONFIG_DIR: configDir,
55
+ RECALL_DEBUG: "0",
56
+ WORKSPACE: workspace,
57
+ CLAUDE_PATH: process.execPath,
58
+ };
59
+
60
+ try {
61
+ const child = spawnSync(process.execPath, [__filename, "--probe"], {
62
+ cwd: __dirname,
63
+ env,
64
+ encoding: "utf8",
65
+ timeout: 15000,
66
+ maxBuffer: 1024 * 1024,
67
+ });
68
+ assert.ifError(child.error);
69
+ assert.strictEqual(child.status, 0, child.stderr || "prompt probe failed");
70
+ const lines = child.stdout.trim().split("\n").filter(Boolean);
71
+ const observed = JSON.parse(lines.at(-1));
72
+
73
+ assert.strictEqual(baseline.prompt.invertInTask, 8);
74
+ assert.strictEqual(observed.bundle.userPrompt, baseline.prompt.sentinel);
75
+ assert.ok(observed.bundle.coreInstructions.includes("Open Claudia"));
76
+ assert.ok(observed.bundle.dynamicContext.includes("## Runtime state (current turn)"));
77
+ assert.ok(observed.bundle.dynamicContext.includes("## Project Transcript Memory"));
78
+ assert.ok(observed.bundle.dynamicContext.includes("Codex exposes its native exec tools"));
79
+ assert.ok(observed.bundle.recallMetadata);
80
+ assert.notStrictEqual(
81
+ `${observed.bundle.dynamicContext}\n\n${observed.bundle.userPrompt}`,
82
+ baseline.prompt.sentinel,
83
+ "Codex must receive the complete dynamic context, not only the raw user sentinel",
84
+ );
85
+ console.log("provider prompt parity OK");
86
+ } finally {
87
+ fs.rmSync(root, { recursive: true, force: true });
88
+ }
89
+ }