@inetafrica/open-claudia 2.14.8 → 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.
- package/.env.example +30 -4
- package/CHANGELOG.md +21 -0
- package/README.md +87 -66
- package/bin/agent.js +44 -18
- package/bin/cli.js +30 -28
- package/bin/dream.js +5 -11
- package/bin/loopback-client.js +29 -5
- package/bin/schedule.js +19 -5
- package/bin/tool.js +23 -5
- package/bot-agent.js +5 -2350
- package/bot.js +105 -7
- package/channels/telegram/adapter.js +6 -1
- package/channels/voice/adapter.js +1 -0
- package/channels/voice/manage.js +43 -5
- package/config-dir.js +3 -11
- package/core/actions.js +149 -40
- package/core/approvals.js +136 -29
- package/core/auth-flow.js +103 -19
- package/core/config-dir.js +19 -0
- package/core/config.js +127 -69
- package/core/doctor.js +111 -57
- package/core/dream.js +219 -62
- package/core/enforcer.js +0 -0
- package/core/entities.js +2 -1
- package/core/fsutil.js +55 -0
- package/core/handlers.js +503 -217
- package/core/ideas.js +2 -1
- package/core/identity.js +8 -11
- package/core/io.js +24 -3
- package/core/jobs.js +589 -72
- package/core/lessons.js +3 -2
- package/core/loopback.js +45 -8
- package/core/memory-mutation-queue.js +241 -0
- package/core/migration-backup.js +1060 -0
- package/core/pack-review.js +295 -88
- package/core/packs.js +4 -3
- package/core/persona.js +2 -1
- package/core/process-tree.js +19 -2
- package/core/provider-migration.js +39 -0
- package/core/provider-status.js +80 -0
- package/core/providers/claude-events.js +117 -0
- package/core/providers/claude-hook.js +114 -0
- package/core/providers/claude.js +296 -0
- package/core/providers/codex-events.js +121 -0
- package/core/providers/codex-hook.js +280 -0
- package/core/providers/codex.js +286 -0
- package/core/providers/contract.js +153 -0
- package/core/providers/env.js +148 -0
- package/core/providers/events.js +170 -0
- package/core/providers/hook-command.js +22 -0
- package/core/providers/index.js +240 -0
- package/core/providers/utility-policy.js +269 -0
- package/core/recall/classic.js +2 -2
- package/core/recall/discoverer.js +71 -22
- package/core/recall/metrics.js +27 -1
- package/core/recall/tuning.js +4 -1
- package/core/recall/warm-walker.js +151 -108
- package/core/recall-filter.js +86 -61
- package/core/router.js +79 -7
- package/core/run-context.js +185 -0
- package/core/runner.js +1415 -1282
- package/core/scheduler.js +515 -210
- package/core/side-chat.js +346 -0
- package/core/state.js +1096 -95
- package/core/subagent.js +72 -98
- package/core/system-prompt.js +462 -279
- package/core/tool-guard.js +73 -39
- package/core/tools.js +22 -5
- package/core/transcripts.js +30 -1
- package/core/usage-log.js +19 -4
- package/core/utility-agent.js +654 -0
- package/core/web-auth.js +29 -13
- package/core/web-sessions.js +78 -0
- package/docs/CHANNEL_DESIGN.md +181 -0
- package/docs/MULTI_CHANNEL_PLAN.md +254 -0
- package/docs/PROVIDER_MIGRATION.md +67 -0
- package/health.js +50 -39
- package/package.json +51 -4
- package/setup.js +198 -38
- package/soul.md +39 -0
- package/test-ability-extraction.js +5 -0
- package/test-approval-async.js +63 -4
- package/test-cursor-removal.js +334 -0
- package/test-delivery-contract.js +88 -0
- package/test-enforcer.js +70 -27
- package/test-fixtures/current-provider-parity.json +132 -0
- package/test-fixtures/fake-agent-cli.js +477 -0
- package/test-fixtures/migrations/claude-only/jobs.json +3 -0
- package/test-fixtures/migrations/claude-only/sessions.json +5 -0
- package/test-fixtures/migrations/claude-only/state.json +5 -0
- package/test-fixtures/migrations/cursor-selected/crons.json +3 -0
- package/test-fixtures/migrations/cursor-selected/jobs.json +3 -0
- package/test-fixtures/migrations/cursor-selected/sessions.json +5 -0
- package/test-fixtures/migrations/cursor-selected/state.json +6 -0
- package/test-fixtures/migrations/dual-provider/jobs.json +3 -0
- package/test-fixtures/migrations/dual-provider/sessions.json +7 -0
- package/test-fixtures/migrations/dual-provider/state.json +10 -0
- package/test-fixtures/migrations/malformed-partial/jobs.json +1 -0
- package/test-fixtures/migrations/malformed-partial/sessions.json +1 -0
- package/test-fixtures/migrations/malformed-partial/sessions.json.bak +3 -0
- package/test-fixtures/migrations/malformed-partial/state.json +1 -0
- package/test-fixtures/migrations/malformed-partial/state.json.bak +5 -0
- package/test-fixtures/migrations/missing-project/jobs.json +3 -0
- package/test-fixtures/migrations/missing-project/sessions.json +3 -0
- package/test-fixtures/migrations/missing-project/state.json +4 -0
- package/test-fixtures/migrations/multi-user/jobs.json +4 -0
- package/test-fixtures/migrations/multi-user/sessions.json +4 -0
- package/test-fixtures/migrations/multi-user/state.json +6 -0
- package/test-fixtures/provider-event-samples.json +17 -0
- package/test-learning-e2e.js +5 -0
- package/test-memory-mutation-queue.js +191 -0
- package/test-provider-boot-matrix.js +399 -0
- package/test-provider-bootstrap.js +158 -0
- package/test-provider-capabilities.js +232 -0
- package/test-provider-claude.js +206 -0
- package/test-provider-codex.js +228 -0
- package/test-provider-compaction.js +371 -0
- package/test-provider-core-prompt.js +264 -0
- package/test-provider-coupling-audit.js +90 -0
- package/test-provider-dream.js +312 -0
- package/test-provider-enforcer.js +252 -0
- package/test-provider-env-isolation.js +150 -0
- package/test-provider-events.js +141 -0
- package/test-provider-fixture.js +332 -0
- package/test-provider-gateway.js +508 -0
- package/test-provider-language.js +89 -0
- package/test-provider-migration-backup.js +424 -0
- package/test-provider-pack-review.js +436 -0
- package/test-provider-parity-e2e.js +537 -0
- package/test-provider-prompt-parity.js +89 -0
- package/test-provider-recall.js +271 -0
- package/test-provider-registry.js +251 -0
- package/test-provider-resume-parity.js +41 -0
- package/test-provider-run-lifecycle.js +349 -0
- package/test-provider-runner.js +271 -0
- package/test-provider-scheduler.js +689 -0
- package/test-provider-session-commands.js +185 -0
- package/test-provider-session-history.js +205 -0
- package/test-provider-side-chat.js +337 -0
- package/test-provider-state-migration.js +316 -0
- package/test-provider-stream-decoder.js +69 -0
- package/test-provider-subagent.js +228 -0
- package/test-provider-tool-hooks.js +360 -0
- package/test-recall-discoverer.js +1 -0
- package/test-recall-engine.js +3 -3
- package/test-recall-evolution.js +18 -0
- package/test-run-lock.js +63 -0
- package/test-runner-watchdog-static.js +16 -8
- package/test-single-runtime.js +35 -0
- package/test-telegram-poll-recovery.js +56 -0
- package/test-tool-guard.js +56 -0
- package/test-tools.js +19 -0
- package/test-unified-identity.js +3 -1
- package/test-usage-accounting.js +92 -20
- package/test-utility-provider-policy.js +486 -0
- package/test-web-sessions.js +74 -0
- package/web.js +159 -45
|
@@ -0,0 +1,349 @@
|
|
|
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 { EventEmitter } = require("events");
|
|
10
|
+
const { PassThrough } = require("stream");
|
|
11
|
+
const { createClaudeProvider } = require("./core/providers/claude");
|
|
12
|
+
const { createClaudeParserState } = require("./core/providers/claude-events");
|
|
13
|
+
const { executeProviderInvocation } = require("./core/runner");
|
|
14
|
+
|
|
15
|
+
function deferred() {
|
|
16
|
+
let resolve;
|
|
17
|
+
let reject;
|
|
18
|
+
const promise = new Promise((res, rej) => { resolve = res; reject = rej; });
|
|
19
|
+
return { promise, resolve, reject };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function nextTurn() {
|
|
23
|
+
return new Promise((resolve) => setImmediate(resolve));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function fakeChild() {
|
|
27
|
+
const child = new EventEmitter();
|
|
28
|
+
child.pid = 4242;
|
|
29
|
+
child.stdout = new PassThrough();
|
|
30
|
+
child.stderr = new PassThrough();
|
|
31
|
+
child.stdin = new PassThrough();
|
|
32
|
+
child.exitCode = null;
|
|
33
|
+
child.signalCode = null;
|
|
34
|
+
child.kill = (signal = "SIGTERM") => {
|
|
35
|
+
child.signalCode = signal;
|
|
36
|
+
setImmediate(() => child.emit("close", null, signal));
|
|
37
|
+
return true;
|
|
38
|
+
};
|
|
39
|
+
return child;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function provider() {
|
|
43
|
+
return createClaudeProvider({
|
|
44
|
+
hookTransport: false,
|
|
45
|
+
resolveExecutable: () => "/fixture/claude",
|
|
46
|
+
buildEnv: () => ({ PATH: "/fixture" }),
|
|
47
|
+
getAuthStatus: () => ({ state: "authenticated", reason: null }),
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function invocation() {
|
|
52
|
+
return {
|
|
53
|
+
binary: "/fixture/claude",
|
|
54
|
+
args: ["fixture"],
|
|
55
|
+
env: { PATH: "/fixture" },
|
|
56
|
+
stdin: null,
|
|
57
|
+
parserState: createClaudeParserState(),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function context() {
|
|
62
|
+
return Object.freeze({
|
|
63
|
+
runId: "lifecycle-run",
|
|
64
|
+
canonicalUserId: "fixture-user",
|
|
65
|
+
project: Object.freeze({ name: "fixture-project", dir: "/fixture/project" }),
|
|
66
|
+
provider: "claude",
|
|
67
|
+
providerSettings: Object.freeze({ model: "fixture-model" }),
|
|
68
|
+
sessionId: null,
|
|
69
|
+
purpose: "foreground",
|
|
70
|
+
channelId: "fixture-channel",
|
|
71
|
+
origin: Object.freeze({ transport: "telegram", channelId: "fixture-channel" }),
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function assertPending(promise, message) {
|
|
76
|
+
const marker = Symbol("pending");
|
|
77
|
+
const observed = await Promise.race([promise, nextTurn().then(() => marker)]);
|
|
78
|
+
assert.strictEqual(observed, marker, message);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function main() {
|
|
82
|
+
const child = fakeChild();
|
|
83
|
+
const cleanupGate = deferred();
|
|
84
|
+
const transcriptGate = deferred();
|
|
85
|
+
const usageGate = deferred();
|
|
86
|
+
const sessionGate = deferred();
|
|
87
|
+
const deliveryGate = deferred();
|
|
88
|
+
const calls = [];
|
|
89
|
+
|
|
90
|
+
const run = executeProviderInvocation({
|
|
91
|
+
runContext: context(),
|
|
92
|
+
provider: provider(),
|
|
93
|
+
invocation: invocation(),
|
|
94
|
+
cwd: "/fixture/project",
|
|
95
|
+
spawnProcess: () => child,
|
|
96
|
+
timeoutMs: 10000,
|
|
97
|
+
cleanup: async () => { calls.push("cleanup"); await cleanupGate.promise; },
|
|
98
|
+
persistTranscript: async () => { calls.push("transcript"); await transcriptGate.promise; },
|
|
99
|
+
persistUsage: async () => { calls.push("usage"); await usageGate.promise; },
|
|
100
|
+
persistSession: async () => { calls.push("session"); await sessionGate.promise; },
|
|
101
|
+
deliver: async () => { calls.push("delivery"); await deliveryGate.promise; return { ok: true }; },
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
child.stdout.write(`${JSON.stringify({ type: "system", subtype: "init", session_id: "terminal-session" })}\n`);
|
|
105
|
+
child.stdout.write(`${JSON.stringify({ type: "assistant", message: { content: [{ type: "text", text: "terminal text" }] } })}\n`);
|
|
106
|
+
child.stdout.write(`${JSON.stringify({ type: "result", subtype: "success", session_id: "terminal-session", result: "terminal text", usage: { input_tokens: 1, output_tokens: 1 } })}\n`);
|
|
107
|
+
await nextTurn();
|
|
108
|
+
await assertPending(run, "terminal provider event must not resolve the run");
|
|
109
|
+
|
|
110
|
+
child.stdout.end();
|
|
111
|
+
await nextTurn();
|
|
112
|
+
await assertPending(run, "stdout EOF must not resolve before child close");
|
|
113
|
+
|
|
114
|
+
child.exitCode = 0;
|
|
115
|
+
child.emit("close", 0, null);
|
|
116
|
+
await nextTurn();
|
|
117
|
+
assert.deepStrictEqual(calls, ["cleanup"]);
|
|
118
|
+
await assertPending(run, "cleanup is part of terminal settlement");
|
|
119
|
+
|
|
120
|
+
cleanupGate.resolve();
|
|
121
|
+
await nextTurn();
|
|
122
|
+
assert.deepStrictEqual(calls, ["cleanup", "transcript"]);
|
|
123
|
+
await assertPending(run, "transcript persistence is required");
|
|
124
|
+
|
|
125
|
+
transcriptGate.resolve();
|
|
126
|
+
await nextTurn();
|
|
127
|
+
assert.deepStrictEqual(calls, ["cleanup", "transcript", "usage"]);
|
|
128
|
+
await assertPending(run, "usage persistence is required");
|
|
129
|
+
|
|
130
|
+
usageGate.resolve();
|
|
131
|
+
await nextTurn();
|
|
132
|
+
assert.deepStrictEqual(calls, ["cleanup", "transcript", "usage", "session"]);
|
|
133
|
+
await assertPending(run, "session persistence is required");
|
|
134
|
+
|
|
135
|
+
sessionGate.resolve();
|
|
136
|
+
await nextTurn();
|
|
137
|
+
assert.deepStrictEqual(calls, ["cleanup", "transcript", "usage", "session", "delivery"]);
|
|
138
|
+
await assertPending(run, "required delivery is part of terminal settlement");
|
|
139
|
+
|
|
140
|
+
deliveryGate.resolve();
|
|
141
|
+
const result = await run;
|
|
142
|
+
assert.strictEqual(result.ok, true);
|
|
143
|
+
assert.strictEqual(result.status, "succeeded");
|
|
144
|
+
assert.strictEqual(result.text, "terminal text");
|
|
145
|
+
assert.strictEqual(result.sessionId, "terminal-session");
|
|
146
|
+
assert.strictEqual(result.exitCode, 0);
|
|
147
|
+
assert.strictEqual(result.signal, null);
|
|
148
|
+
assert.strictEqual(result.terminalSeen, true);
|
|
149
|
+
assert.strictEqual(result.stdoutEnded, true);
|
|
150
|
+
assert.deepStrictEqual(calls, ["cleanup", "transcript", "usage", "session", "delivery"]);
|
|
151
|
+
|
|
152
|
+
async function completedRun({
|
|
153
|
+
exitCode = 0,
|
|
154
|
+
signal = null,
|
|
155
|
+
terminal = true,
|
|
156
|
+
duplicate = false,
|
|
157
|
+
persistenceFailure = null,
|
|
158
|
+
deliveryFailure = false,
|
|
159
|
+
} = {}) {
|
|
160
|
+
const proc = fakeChild();
|
|
161
|
+
let transcriptCalls = 0;
|
|
162
|
+
let deliveryCalls = 0;
|
|
163
|
+
const promise = executeProviderInvocation({
|
|
164
|
+
runContext: context(),
|
|
165
|
+
provider: provider(),
|
|
166
|
+
invocation: invocation(),
|
|
167
|
+
cwd: "/fixture/project",
|
|
168
|
+
spawnProcess: () => proc,
|
|
169
|
+
timeoutMs: 10000,
|
|
170
|
+
cleanup: async () => {},
|
|
171
|
+
persistTranscript: async () => {
|
|
172
|
+
transcriptCalls += 1;
|
|
173
|
+
if (persistenceFailure === "transcript") throw new Error("transcript failed");
|
|
174
|
+
},
|
|
175
|
+
persistUsage: async () => {
|
|
176
|
+
if (persistenceFailure === "usage") throw new Error("usage failed");
|
|
177
|
+
},
|
|
178
|
+
persistSession: async () => {
|
|
179
|
+
if (persistenceFailure === "session") throw new Error("session failed");
|
|
180
|
+
},
|
|
181
|
+
deliver: async () => {
|
|
182
|
+
deliveryCalls += 1;
|
|
183
|
+
if (deliveryFailure) throw new Error("delivery failed");
|
|
184
|
+
return { ok: true };
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
proc.stdout.write(`${JSON.stringify({ type: "system", subtype: "init", session_id: "fixture-session" })}\n`);
|
|
188
|
+
proc.stdout.write(`${JSON.stringify({ type: "assistant", message: { content: [{ type: "text", text: "fixture" }] } })}\n`);
|
|
189
|
+
if (terminal) {
|
|
190
|
+
const event = `${JSON.stringify({ type: "result", subtype: "success", session_id: "fixture-session", result: "fixture" })}\n`;
|
|
191
|
+
proc.stdout.write(event);
|
|
192
|
+
if (duplicate) proc.stdout.write(event);
|
|
193
|
+
}
|
|
194
|
+
proc.stdout.end();
|
|
195
|
+
proc.exitCode = exitCode;
|
|
196
|
+
proc.signalCode = signal;
|
|
197
|
+
proc.emit("close", exitCode, signal);
|
|
198
|
+
const settled = await promise;
|
|
199
|
+
return { settled, transcriptCalls, deliveryCalls };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const duplicate = await completedRun({ duplicate: true });
|
|
203
|
+
assert.strictEqual(duplicate.settled.ok, true);
|
|
204
|
+
assert.strictEqual(duplicate.transcriptCalls, 1, "duplicate terminal events persist once");
|
|
205
|
+
assert.strictEqual(duplicate.deliveryCalls, 1, "duplicate terminal events deliver once");
|
|
206
|
+
|
|
207
|
+
const nonZero = await completedRun({ exitCode: 23 });
|
|
208
|
+
assert.strictEqual(nonZero.settled.ok, false);
|
|
209
|
+
assert.strictEqual(nonZero.settled.exitCode, 23);
|
|
210
|
+
assert.match(nonZero.settled.error.message, /code 23/i);
|
|
211
|
+
|
|
212
|
+
const signalled = await completedRun({ exitCode: null, signal: "SIGTERM" });
|
|
213
|
+
assert.strictEqual(signalled.settled.ok, false);
|
|
214
|
+
assert.strictEqual(signalled.settled.signal, "SIGTERM");
|
|
215
|
+
|
|
216
|
+
const missingTerminal = await completedRun({ terminal: false });
|
|
217
|
+
assert.strictEqual(missingTerminal.settled.ok, false);
|
|
218
|
+
assert.match(missingTerminal.settled.error.message, /terminal/i);
|
|
219
|
+
|
|
220
|
+
{
|
|
221
|
+
const proc = fakeChild();
|
|
222
|
+
const malformedRun = executeProviderInvocation({
|
|
223
|
+
runContext: context(),
|
|
224
|
+
provider: provider(),
|
|
225
|
+
invocation: invocation(),
|
|
226
|
+
cwd: "/fixture/project",
|
|
227
|
+
spawnProcess: () => proc,
|
|
228
|
+
cleanup: async () => {},
|
|
229
|
+
});
|
|
230
|
+
proc.stdout.write("{not-json}\n");
|
|
231
|
+
proc.stdout.write(`${JSON.stringify({ type: "result", subtype: "success", session_id: "fixture-session", result: "fixture" })}\n`);
|
|
232
|
+
proc.stdout.end();
|
|
233
|
+
proc.emit("close", 0, null);
|
|
234
|
+
const malformed = await malformedRun;
|
|
235
|
+
assert.strictEqual(malformed.ok, false);
|
|
236
|
+
assert.match(malformed.error.message, /Malformed provider JSONL record/);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
{
|
|
240
|
+
// stdin EPIPE (child exits before the prompt pipe flushes) must neither
|
|
241
|
+
// crash the process as an uncaughtException nor fail a run whose child
|
|
242
|
+
// completed successfully — the exit code decides.
|
|
243
|
+
const proc = fakeChild();
|
|
244
|
+
const stdinRun = executeProviderInvocation({
|
|
245
|
+
runContext: context(),
|
|
246
|
+
provider: provider(),
|
|
247
|
+
invocation: { ...invocation(), stdin: "prompt payload" },
|
|
248
|
+
cwd: "/fixture/project",
|
|
249
|
+
spawnProcess: () => proc,
|
|
250
|
+
cleanup: async () => {},
|
|
251
|
+
});
|
|
252
|
+
proc.stdin.emit("error", Object.assign(new Error("write EPIPE"), { code: "EPIPE" }));
|
|
253
|
+
proc.stdout.write(`${JSON.stringify({ type: "system", subtype: "init", session_id: "fixture-session" })}\n`);
|
|
254
|
+
proc.stdout.write(`${JSON.stringify({ type: "result", subtype: "success", session_id: "fixture-session", result: "fixture" })}\n`);
|
|
255
|
+
proc.stdout.end();
|
|
256
|
+
proc.emit("close", 0, null);
|
|
257
|
+
const stdinResult = await stdinRun;
|
|
258
|
+
assert.strictEqual(stdinResult.ok, true, "stdin EPIPE must not fail a run the child completed successfully");
|
|
259
|
+
assert.strictEqual(stdinResult.status, "succeeded");
|
|
260
|
+
assert.match(stdinResult.stderr, /\[stdin\] write EPIPE/, "stdin failure must surface as a diagnostic in stderr");
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
for (const stage of ["transcript", "usage", "session"]) {
|
|
264
|
+
const failed = await completedRun({ persistenceFailure: stage });
|
|
265
|
+
assert.strictEqual(failed.settled.ok, false, `${stage} persistence failure must fail the RunResult`);
|
|
266
|
+
assert.strictEqual(failed.settled.persistence[stage].ok, false);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const deliveryFailed = await completedRun({ deliveryFailure: true });
|
|
270
|
+
assert.strictEqual(deliveryFailed.settled.ok, false);
|
|
271
|
+
assert.strictEqual(deliveryFailed.settled.delivery.ok, false);
|
|
272
|
+
|
|
273
|
+
const spawnFailure = await executeProviderInvocation({
|
|
274
|
+
runContext: context(),
|
|
275
|
+
provider: provider(),
|
|
276
|
+
invocation: invocation(),
|
|
277
|
+
cwd: "/fixture/project",
|
|
278
|
+
spawnProcess: () => { throw new Error("spawn fixture failed"); },
|
|
279
|
+
cleanup: async () => {},
|
|
280
|
+
});
|
|
281
|
+
assert.strictEqual(spawnFailure.ok, false);
|
|
282
|
+
assert.match(spawnFailure.error.message, /spawn fixture failed/);
|
|
283
|
+
|
|
284
|
+
const timeoutChild = fakeChild();
|
|
285
|
+
const timeoutRun = executeProviderInvocation({
|
|
286
|
+
runContext: context(),
|
|
287
|
+
provider: provider(),
|
|
288
|
+
invocation: invocation(),
|
|
289
|
+
cwd: "/fixture/project",
|
|
290
|
+
spawnProcess: () => timeoutChild,
|
|
291
|
+
timeoutMs: 20,
|
|
292
|
+
killTree: (_pid, signal) => timeoutChild.kill(signal),
|
|
293
|
+
killGraceMs: 5,
|
|
294
|
+
cleanup: async () => {},
|
|
295
|
+
});
|
|
296
|
+
const timedOut = await timeoutRun;
|
|
297
|
+
assert.strictEqual(timedOut.ok, false);
|
|
298
|
+
assert.strictEqual(timedOut.timedOut, true);
|
|
299
|
+
|
|
300
|
+
const processTreeRoot = fs.mkdtempSync(path.join(os.tmpdir(), "open-claudia-run-tree-"));
|
|
301
|
+
const descendantCapture = path.join(processTreeRoot, "descendant.json");
|
|
302
|
+
let descendantPid = null;
|
|
303
|
+
try {
|
|
304
|
+
const realTreeRun = executeProviderInvocation({
|
|
305
|
+
runContext: context(),
|
|
306
|
+
provider: provider(),
|
|
307
|
+
invocation: {
|
|
308
|
+
binary: path.join(__dirname, "test-fixtures", "fake-agent-cli.js"),
|
|
309
|
+
args: ["fixture"],
|
|
310
|
+
env: {
|
|
311
|
+
PATH: process.env.PATH || "",
|
|
312
|
+
HOME: processTreeRoot,
|
|
313
|
+
FAKE_AGENT_KIND: "claude",
|
|
314
|
+
FAKE_AGENT_SCENARIO: "forced-termination",
|
|
315
|
+
FAKE_AGENT_DESCENDANT_CAPTURE: descendantCapture,
|
|
316
|
+
FAKE_AGENT_DESCENDANT_DETACHED: "1",
|
|
317
|
+
FAKE_AGENT_DESCENDANT_IGNORE_SIGTERM: "1",
|
|
318
|
+
FAKE_AGENT_LEAVE_DESCENDANT: "1",
|
|
319
|
+
},
|
|
320
|
+
stdin: null,
|
|
321
|
+
parserState: createClaudeParserState(),
|
|
322
|
+
},
|
|
323
|
+
cwd: processTreeRoot,
|
|
324
|
+
timeoutMs: 120,
|
|
325
|
+
killGraceMs: 250,
|
|
326
|
+
cleanup: async () => {},
|
|
327
|
+
});
|
|
328
|
+
const treeResult = await realTreeRun;
|
|
329
|
+
assert.strictEqual(treeResult.timedOut, true);
|
|
330
|
+
descendantPid = JSON.parse(fs.readFileSync(descendantCapture, "utf8")).pid;
|
|
331
|
+
assert.throws(
|
|
332
|
+
() => process.kill(descendantPid, 0),
|
|
333
|
+
(error) => error.code === "ESRCH",
|
|
334
|
+
"timeout result must not settle while a detached TERM-resistant descendant survives",
|
|
335
|
+
);
|
|
336
|
+
} finally {
|
|
337
|
+
if (descendantPid) {
|
|
338
|
+
try { process.kill(descendantPid, "SIGKILL"); } catch (_) {}
|
|
339
|
+
}
|
|
340
|
+
fs.rmSync(processTreeRoot, { recursive: true, force: true });
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
console.log("provider run lifecycle OK");
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
main().catch((error) => {
|
|
347
|
+
console.error(error.stack || error.message);
|
|
348
|
+
process.exit(1);
|
|
349
|
+
});
|
|
@@ -0,0 +1,271 @@
|
|
|
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 { createClaudeProvider } = require("./core/providers/claude");
|
|
10
|
+
const { createCodexProvider } = require("./core/providers/codex");
|
|
11
|
+
const { captureRunContext } = require("./core/run-context");
|
|
12
|
+
const runner = require("./core/runner");
|
|
13
|
+
const { capturedTranscriptMetadata, createRunnerService } = runner;
|
|
14
|
+
|
|
15
|
+
const fixture = path.join(__dirname, "test-fixtures", "fake-agent-cli.js");
|
|
16
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "open-claudia-provider-runner-"));
|
|
17
|
+
const projectA = path.join(root, "project-a");
|
|
18
|
+
const projectB = path.join(root, "project-b");
|
|
19
|
+
fs.mkdirSync(projectA);
|
|
20
|
+
fs.mkdirSync(projectB);
|
|
21
|
+
|
|
22
|
+
function deferred() {
|
|
23
|
+
let resolve;
|
|
24
|
+
const promise = new Promise((res) => { resolve = res; });
|
|
25
|
+
return { promise, resolve };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function providerEnv(kind, capturePath, extra = {}) {
|
|
29
|
+
return {
|
|
30
|
+
HOME: root,
|
|
31
|
+
PATH: process.env.PATH || path.dirname(process.execPath),
|
|
32
|
+
FAKE_AGENT_KIND: kind,
|
|
33
|
+
FAKE_AGENT_CAPTURE: capturePath,
|
|
34
|
+
FAKE_AGENT_TOOL: "1",
|
|
35
|
+
FAKE_AGENT_CHUNK_MODE: "split",
|
|
36
|
+
FAKE_AGENT_FINAL_NEWLINE: "0",
|
|
37
|
+
...extra,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function makeProviders(captures, controls = {}) {
|
|
42
|
+
return {
|
|
43
|
+
claude: createClaudeProvider({
|
|
44
|
+
hookTransport: false,
|
|
45
|
+
resolveExecutable: () => fixture,
|
|
46
|
+
defaultModel: "claude-fixture-model",
|
|
47
|
+
buildEnv: () => providerEnv("claude", captures.claude, controls.claude),
|
|
48
|
+
getAuthStatus: () => ({ state: "authenticated", reason: null }),
|
|
49
|
+
}),
|
|
50
|
+
codex: createCodexProvider({
|
|
51
|
+
hookTransport: false,
|
|
52
|
+
resolveExecutable: () => fixture,
|
|
53
|
+
defaultModel: "codex-fixture-model",
|
|
54
|
+
buildEnv: () => providerEnv("codex", captures.codex, controls.codex),
|
|
55
|
+
getAuthStatus: () => ({ state: "authenticated", reason: null }),
|
|
56
|
+
}),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function state() {
|
|
61
|
+
return {
|
|
62
|
+
userId: "fixture-user",
|
|
63
|
+
currentSession: { name: "project-a", dir: projectA },
|
|
64
|
+
settings: {
|
|
65
|
+
backend: "claude",
|
|
66
|
+
model: "claude-fixture-model",
|
|
67
|
+
effort: null,
|
|
68
|
+
budget: null,
|
|
69
|
+
permissionMode: "plan",
|
|
70
|
+
worktree: false,
|
|
71
|
+
},
|
|
72
|
+
activeSessions: { "project-a": {} },
|
|
73
|
+
providerSettings: {
|
|
74
|
+
claude: { model: "claude-fixture-model", effort: null, budget: null, permissionMode: "plan", worktree: false },
|
|
75
|
+
codex: { model: "codex-fixture-model", effort: null, budget: null, permissionMode: "plan", worktree: false },
|
|
76
|
+
},
|
|
77
|
+
lastInputWasVoice: false,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function simpleBundle(prompt, { runContext, provider }) {
|
|
82
|
+
return {
|
|
83
|
+
coreInstructions: "RUNNER CORE SENTINEL",
|
|
84
|
+
dynamicContext: `RUNNER DYNAMIC ${runContext.project.name} ${provider.id}`,
|
|
85
|
+
userPrompt: prompt,
|
|
86
|
+
transcriptPaths: { transcriptsDir: path.join(root, "transcripts") },
|
|
87
|
+
recallMetadata: { status: "included", omitted: false, packDirs: [] },
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function main() {
|
|
92
|
+
assert.ok(fs.existsSync(fixture));
|
|
93
|
+
assert.strictEqual(Object.prototype.hasOwnProperty.call(runner, "runClaude"), false, "deprecated foreground alias must be removed");
|
|
94
|
+
assert.strictEqual(Object.prototype.hasOwnProperty.call(runner, "runClaudeCapture"), false, "deprecated capture alias must be removed");
|
|
95
|
+
assert.strictEqual(Object.prototype.hasOwnProperty.call(runner, "runClaudeSilent"), false, "deprecated silent alias must be removed");
|
|
96
|
+
const mutable = state();
|
|
97
|
+
const captured = captureRunContext({
|
|
98
|
+
state: mutable,
|
|
99
|
+
prompt: "capture",
|
|
100
|
+
cwd: projectA,
|
|
101
|
+
replyToMsgId: "message-1",
|
|
102
|
+
opts: {
|
|
103
|
+
purpose: "foreground",
|
|
104
|
+
imagePaths: ["/fixture/image.png"],
|
|
105
|
+
origin: { transport: "telegram", channelId: "fixture-channel" },
|
|
106
|
+
},
|
|
107
|
+
channelId: "fixture-channel",
|
|
108
|
+
canonicalUserId: "fixture-user",
|
|
109
|
+
runId: "captured-run",
|
|
110
|
+
});
|
|
111
|
+
assert.strictEqual(captured.runId, "captured-run");
|
|
112
|
+
assert.strictEqual(captured.provider, "claude");
|
|
113
|
+
assert.strictEqual(captured.project.dir, projectA);
|
|
114
|
+
assert.strictEqual(captured.purpose, "foreground");
|
|
115
|
+
assert.deepStrictEqual(captured.imagePaths, ["/fixture/image.png"]);
|
|
116
|
+
assert.ok(Object.isFrozen(captured));
|
|
117
|
+
assert.ok(Object.isFrozen(captured.project));
|
|
118
|
+
assert.ok(Object.isFrozen(captured.providerSettings));
|
|
119
|
+
assert.ok(Object.isFrozen(captured.origin));
|
|
120
|
+
assert.ok(Object.isFrozen(captured.imagePaths));
|
|
121
|
+
assert.throws(() => { captured.providerSettings.model = "changed"; }, TypeError);
|
|
122
|
+
assert.deepStrictEqual(capturedTranscriptMetadata(captured), {
|
|
123
|
+
provider: "claude",
|
|
124
|
+
project: "project-a",
|
|
125
|
+
projectPath: projectA,
|
|
126
|
+
runId: "captured-run",
|
|
127
|
+
purpose: "foreground",
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const captures = {
|
|
131
|
+
claude: path.join(root, "claude-capture.json"),
|
|
132
|
+
codex: path.join(root, "codex-capture.json"),
|
|
133
|
+
};
|
|
134
|
+
const providers = makeProviders(captures);
|
|
135
|
+
const persisted = [];
|
|
136
|
+
const delivered = [];
|
|
137
|
+
const service = createRunnerService({
|
|
138
|
+
getState: () => mutable,
|
|
139
|
+
getStore: () => ({ channelId: "fixture-channel", transport: "telegram" }),
|
|
140
|
+
resolveProvider: (id) => providers[id],
|
|
141
|
+
buildTurnPrompt: simpleBundle,
|
|
142
|
+
persistTranscript: async (result, runContext) => persisted.push(["transcript", runContext.provider, runContext.project.name, result.sessionId]),
|
|
143
|
+
persistUsage: async (result, runContext) => persisted.push(["usage", runContext.provider, result.usageScope]),
|
|
144
|
+
persistSession: async (result, runContext) => persisted.push(["session", runContext.provider, runContext.project.name, result.sessionId]),
|
|
145
|
+
deliver: async (result, runContext) => {
|
|
146
|
+
delivered.push([runContext.provider, runContext.project.name, result.text]);
|
|
147
|
+
return { ok: true };
|
|
148
|
+
},
|
|
149
|
+
cleanup: async () => {},
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const claude = await service.runAgentCapture("CLAUDE USER SENTINEL", projectA, { fresh: true });
|
|
153
|
+
assert.strictEqual(claude.ok, true);
|
|
154
|
+
assert.strictEqual(claude.provider, "claude");
|
|
155
|
+
assert.strictEqual(claude.text, "fixture says café 👋");
|
|
156
|
+
assert.strictEqual(claude.sessionId, "claude-fresh-session");
|
|
157
|
+
assert.ok(claude.tools.some((event) => event.type === "tool_start" && event.name === "Read"));
|
|
158
|
+
const claudeCapture = JSON.parse(fs.readFileSync(captures.claude, "utf8"));
|
|
159
|
+
assert.ok(claudeCapture.argv.includes("--append-system-prompt"));
|
|
160
|
+
assert.ok(claudeCapture.argv.includes("RUNNER CORE SENTINEL"));
|
|
161
|
+
assert.ok(claudeCapture.argv.at(-1).includes("RUNNER DYNAMIC project-a claude"));
|
|
162
|
+
assert.ok(claudeCapture.argv.at(-1).includes("CLAUDE USER SENTINEL"));
|
|
163
|
+
|
|
164
|
+
mutable.settings = { ...mutable.settings, backend: "codex", model: "codex-fixture-model" };
|
|
165
|
+
mutable.activeSessions["project-a"].codex = "codex-resume-thread";
|
|
166
|
+
const codex = await service.runAgentCapture("CODEX USER SENTINEL", projectA, {
|
|
167
|
+
resumeSessionId: "codex-resume-thread",
|
|
168
|
+
});
|
|
169
|
+
assert.strictEqual(codex.ok, true);
|
|
170
|
+
assert.strictEqual(codex.provider, "codex");
|
|
171
|
+
assert.strictEqual(codex.sessionId, "codex-resume-thread");
|
|
172
|
+
assert.strictEqual(codex.text, "fixture says café 👋");
|
|
173
|
+
assert.ok(codex.tools.some((event) => event.type === "tool_start" && event.name === "Shell"));
|
|
174
|
+
const codexCapture = JSON.parse(fs.readFileSync(captures.codex, "utf8"));
|
|
175
|
+
assert.deepStrictEqual(codexCapture.argv.slice(0, 2), ["exec", "resume"]);
|
|
176
|
+
assert.ok(codexCapture.argv.some((arg) => arg.includes("developer_instructions=")));
|
|
177
|
+
assert.ok(codexCapture.argv.at(-1).includes("RUNNER DYNAMIC project-a codex"));
|
|
178
|
+
assert.ok(codexCapture.argv.at(-1).includes("CODEX USER SENTINEL"));
|
|
179
|
+
|
|
180
|
+
const foreground = await service.runAgent("DELIVERY SENTINEL", projectA, "message-2", { fresh: true });
|
|
181
|
+
assert.strictEqual(foreground.ok, true);
|
|
182
|
+
assert.deepStrictEqual(delivered.at(-1), ["codex", "project-a", "fixture says café 👋"]);
|
|
183
|
+
assert.ok(persisted.some((entry) => entry[0] === "transcript" && entry[1] === "claude"));
|
|
184
|
+
assert.ok(persisted.some((entry) => entry[0] === "transcript" && entry[1] === "codex"));
|
|
185
|
+
|
|
186
|
+
// Admission must happen synchronously. Mutating every live routing field
|
|
187
|
+
// while prompt construction awaits cannot alter the admitted provider,
|
|
188
|
+
// project, settings, session, cwd, or parser.
|
|
189
|
+
const gate = deferred();
|
|
190
|
+
const raceCapture = path.join(root, "race-capture.json");
|
|
191
|
+
const raceProviders = makeProviders({ claude: raceCapture, codex: captures.codex });
|
|
192
|
+
mutable.currentSession = { name: "project-a", dir: projectA };
|
|
193
|
+
mutable.settings = { ...mutable.settings, backend: "claude", model: "claude-fixture-model", effort: "high" };
|
|
194
|
+
mutable.providerSettings.claude = { ...mutable.providerSettings.claude, model: "claude-fixture-model", effort: "high" };
|
|
195
|
+
mutable.activeSessions["project-a"].claude = "claude-race-session";
|
|
196
|
+
const raceService = createRunnerService({
|
|
197
|
+
getState: () => mutable,
|
|
198
|
+
getStore: () => ({ channelId: "fixture-channel", transport: "telegram" }),
|
|
199
|
+
resolveProvider: (id) => raceProviders[id],
|
|
200
|
+
async buildTurnPrompt(prompt, opts) {
|
|
201
|
+
await gate.promise;
|
|
202
|
+
return simpleBundle(prompt, opts);
|
|
203
|
+
},
|
|
204
|
+
persistTranscript: async () => {},
|
|
205
|
+
persistUsage: async () => {},
|
|
206
|
+
persistSession: async () => {},
|
|
207
|
+
cleanup: async () => {},
|
|
208
|
+
});
|
|
209
|
+
const racing = raceService.runAgentCapture("RACE SENTINEL", projectA);
|
|
210
|
+
mutable.currentSession = { name: "project-b", dir: projectB };
|
|
211
|
+
mutable.settings = { ...mutable.settings, backend: "codex", model: "codex-fixture-model", effort: "low" };
|
|
212
|
+
mutable.providerSettings.codex = { ...mutable.providerSettings.codex, model: "codex-fixture-model", effort: "low" };
|
|
213
|
+
mutable.activeSessions["project-a"].claude = "changed-claude-session";
|
|
214
|
+
mutable.activeSessions["project-a"].codex = "changed-codex-thread";
|
|
215
|
+
gate.resolve();
|
|
216
|
+
const race = await racing;
|
|
217
|
+
assert.strictEqual(race.ok, true);
|
|
218
|
+
assert.strictEqual(race.provider, "claude");
|
|
219
|
+
assert.strictEqual(race.project.name, "project-a");
|
|
220
|
+
assert.strictEqual(race.admittedSessionId, "claude-race-session");
|
|
221
|
+
const raceArgv = JSON.parse(fs.readFileSync(raceCapture, "utf8")).argv;
|
|
222
|
+
assert.ok(raceArgv.includes("claude-fixture-model"));
|
|
223
|
+
assert.ok(raceArgv.includes("claude-race-session"));
|
|
224
|
+
assert.ok(!raceArgv.includes("codex-fixture-model"));
|
|
225
|
+
assert.ok(!raceArgv.includes("changed-claude-session"));
|
|
226
|
+
|
|
227
|
+
let forbiddenSpawns = 0;
|
|
228
|
+
let failedDeliveries = 0;
|
|
229
|
+
mutable.currentSession = { name: "project-a", dir: projectA };
|
|
230
|
+
mutable.settings = { ...mutable.settings, backend: "claude", model: "claude-fixture-model" };
|
|
231
|
+
const failClosed = createRunnerService({
|
|
232
|
+
getState: () => mutable,
|
|
233
|
+
getStore: () => ({ channelId: "fixture-channel", transport: "telegram" }),
|
|
234
|
+
resolveProvider: (id) => providers[id],
|
|
235
|
+
async buildTurnPrompt() { throw new Error("mandatory prompt fixture failed"); },
|
|
236
|
+
spawnProcess() { forbiddenSpawns += 1; throw new Error("must not spawn"); },
|
|
237
|
+
persistTranscript: async () => {},
|
|
238
|
+
persistUsage: async () => {},
|
|
239
|
+
persistSession: async () => {},
|
|
240
|
+
deliver: async (result) => { failedDeliveries += 1; return { ok: !result.ok }; },
|
|
241
|
+
});
|
|
242
|
+
const promptFailure = await failClosed.runAgent("RAW PROMPT MUST NOT SPAWN", projectA, "message-fail");
|
|
243
|
+
assert.strictEqual(promptFailure.ok, false);
|
|
244
|
+
assert.match(promptFailure.error.message, /mandatory prompt fixture failed/);
|
|
245
|
+
assert.strictEqual(forbiddenSpawns, 0, "mandatory prompt failure must abort before spawn");
|
|
246
|
+
assert.strictEqual(failedDeliveries, 1, "foreground preflight failure still settles required delivery");
|
|
247
|
+
|
|
248
|
+
const authCapture = path.join(root, "auth-capture.json");
|
|
249
|
+
const authProviders = makeProviders(
|
|
250
|
+
{ claude: authCapture, codex: captures.codex },
|
|
251
|
+
{ claude: { FAKE_AGENT_SCENARIO: "auth-failure" } },
|
|
252
|
+
);
|
|
253
|
+
const authService = createRunnerService({
|
|
254
|
+
getState: () => mutable,
|
|
255
|
+
getStore: () => ({ channelId: "fixture-channel", transport: "telegram" }),
|
|
256
|
+
resolveProvider: (id) => authProviders[id],
|
|
257
|
+
buildTurnPrompt: simpleBundle,
|
|
258
|
+
cleanup: async () => {},
|
|
259
|
+
});
|
|
260
|
+
const authFailure = await authService.runAgentCapture("AUTH SENTINEL", projectA, { fresh: true });
|
|
261
|
+
assert.strictEqual(authFailure.ok, false);
|
|
262
|
+
assert.strictEqual(authFailure.error.code, "PROVIDER_UNAUTHENTICATED");
|
|
263
|
+
assert.match(authFailure.diagnostic, /Authentication failed/i);
|
|
264
|
+
|
|
265
|
+
console.log("provider runner OK");
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
main().catch((error) => {
|
|
269
|
+
console.error(error.stack || error.message);
|
|
270
|
+
process.exitCode = 1;
|
|
271
|
+
}).finally(() => fs.rmSync(root, { recursive: true, force: true }));
|