@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.
- package/.env.example +30 -4
- package/CHANGELOG.md +12 -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 +66 -3
- 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 +115 -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 +21 -4
- package/core/handlers.js +503 -217
- package/core/ideas.js +2 -1
- package/core/jobs.js +589 -72
- package/core/lessons.js +3 -2
- package/core/loopback.js +42 -6
- 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 +1414 -1290
- package/core/scheduler.js +515 -210
- package/core/side-chat.js +346 -0
- package/core/state.js +1084 -97
- 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/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 +48 -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-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-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/web.js +130 -35
|
@@ -0,0 +1,332 @@
|
|
|
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 { spawn } = require("child_process");
|
|
8
|
+
|
|
9
|
+
const fixture = path.join(__dirname, "test-fixtures", "fake-agent-cli.js");
|
|
10
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "open-claudia-provider-fixture-"));
|
|
11
|
+
const activeChildren = new Set();
|
|
12
|
+
let sequence = 0;
|
|
13
|
+
|
|
14
|
+
function fixturePath(label) {
|
|
15
|
+
sequence += 1;
|
|
16
|
+
return path.join(tmp, `${String(sequence).padStart(2, "0")}-${label}.json`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function minimalChildEnv(controls) {
|
|
20
|
+
const env = {};
|
|
21
|
+
for (const key of ["PATH", "TMPDIR", "TMP", "TEMP", "SystemRoot", "COMSPEC", "PATHEXT"]) {
|
|
22
|
+
if (process.env[key] != null) env[key] = process.env[key];
|
|
23
|
+
}
|
|
24
|
+
env.HOME = tmp;
|
|
25
|
+
return { ...env, ...controls };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function runFixture({ kind, args = [], controls = {}, onSpawn, direct = false, timeoutMs = 3000 } = {}) {
|
|
29
|
+
const capturePath = controls.FAKE_AGENT_CAPTURE || fixturePath(`${kind || "unknown"}-capture`);
|
|
30
|
+
const env = minimalChildEnv({
|
|
31
|
+
FAKE_AGENT_KIND: kind,
|
|
32
|
+
FAKE_AGENT_CAPTURE: capturePath,
|
|
33
|
+
...controls,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
return new Promise((resolve, reject) => {
|
|
37
|
+
const child = spawn(direct ? fixture : process.execPath, direct ? args : [fixture, ...args], {
|
|
38
|
+
cwd: __dirname,
|
|
39
|
+
env,
|
|
40
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
41
|
+
});
|
|
42
|
+
activeChildren.add(child);
|
|
43
|
+
const stdoutChunks = [];
|
|
44
|
+
const stderrChunks = [];
|
|
45
|
+
let settled = false;
|
|
46
|
+
let forceTimer = null;
|
|
47
|
+
const deadline = setTimeout(() => {
|
|
48
|
+
child.kill("SIGTERM");
|
|
49
|
+
forceTimer = setTimeout(() => child.kill("SIGKILL"), 100);
|
|
50
|
+
}, timeoutMs);
|
|
51
|
+
child.stdout.on("data", (chunk) => stdoutChunks.push(Buffer.from(chunk)));
|
|
52
|
+
child.stderr.on("data", (chunk) => stderrChunks.push(Buffer.from(chunk)));
|
|
53
|
+
child.on("error", (error) => {
|
|
54
|
+
if (settled) return;
|
|
55
|
+
settled = true;
|
|
56
|
+
clearTimeout(deadline);
|
|
57
|
+
if (forceTimer) clearTimeout(forceTimer);
|
|
58
|
+
activeChildren.delete(child);
|
|
59
|
+
reject(error);
|
|
60
|
+
});
|
|
61
|
+
child.on("close", (code, signal) => {
|
|
62
|
+
if (settled) return;
|
|
63
|
+
settled = true;
|
|
64
|
+
clearTimeout(deadline);
|
|
65
|
+
if (forceTimer) clearTimeout(forceTimer);
|
|
66
|
+
activeChildren.delete(child);
|
|
67
|
+
resolve({
|
|
68
|
+
code,
|
|
69
|
+
signal,
|
|
70
|
+
closedAt: Date.now(),
|
|
71
|
+
stdout: Buffer.concat(stdoutChunks).toString("utf8"),
|
|
72
|
+
stdoutChunks,
|
|
73
|
+
stderr: Buffer.concat(stderrChunks).toString("utf8"),
|
|
74
|
+
capturePath,
|
|
75
|
+
capture: fs.existsSync(capturePath)
|
|
76
|
+
? JSON.parse(fs.readFileSync(capturePath, "utf8"))
|
|
77
|
+
: null,
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
if (onSpawn) {
|
|
81
|
+
try {
|
|
82
|
+
onSpawn(child, stdoutChunks);
|
|
83
|
+
} catch (error) {
|
|
84
|
+
child.kill("SIGKILL");
|
|
85
|
+
reject(error);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function cleanup() {
|
|
92
|
+
for (const child of activeChildren) child.kill("SIGKILL");
|
|
93
|
+
activeChildren.clear();
|
|
94
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function parseJsonLines(text) {
|
|
98
|
+
return text
|
|
99
|
+
.split(/\r?\n/)
|
|
100
|
+
.filter((line) => line.trim())
|
|
101
|
+
.map((line) => JSON.parse(line));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function waitForFile(filePath, timeoutMs = 1000) {
|
|
105
|
+
const started = Date.now();
|
|
106
|
+
return new Promise((resolve, reject) => {
|
|
107
|
+
const poll = () => {
|
|
108
|
+
if (fs.existsSync(filePath)) return resolve();
|
|
109
|
+
if (Date.now() - started >= timeoutMs) {
|
|
110
|
+
return reject(new Error(`timed out waiting for ${path.basename(filePath)}`));
|
|
111
|
+
}
|
|
112
|
+
setTimeout(poll, 5);
|
|
113
|
+
};
|
|
114
|
+
poll();
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function main() {
|
|
119
|
+
assert.ok(fs.existsSync(fixture), "fake provider CLI fixture must exist");
|
|
120
|
+
if (process.platform !== "win32") {
|
|
121
|
+
fs.accessSync(fixture, fs.constants.X_OK);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Capture argv exactly, but reduce all requested environment inspection to
|
|
125
|
+
// boolean key presence. Credential values must never reach the capture file.
|
|
126
|
+
const secretValues = ["claude-secret-value", "openai-secret-value"];
|
|
127
|
+
const captured = await runFixture({
|
|
128
|
+
kind: "claude",
|
|
129
|
+
args: ["-p", "--resume", "claude-resume-1", "hello"],
|
|
130
|
+
controls: {
|
|
131
|
+
FAKE_AGENT_ENV_KEYS: "CLAUDE_CODE_OAUTH_TOKEN,OPENAI_API_KEY,ABSENT_PROVIDER_KEY",
|
|
132
|
+
CLAUDE_CODE_OAUTH_TOKEN: secretValues[0],
|
|
133
|
+
OPENAI_API_KEY: secretValues[1],
|
|
134
|
+
},
|
|
135
|
+
direct: process.platform !== "win32",
|
|
136
|
+
});
|
|
137
|
+
assert.strictEqual(captured.code, 0);
|
|
138
|
+
assert.deepStrictEqual(captured.capture.argv, ["-p", "--resume", "claude-resume-1", "hello"]);
|
|
139
|
+
assert.deepStrictEqual(captured.capture.envPresence, {
|
|
140
|
+
ABSENT_PROVIDER_KEY: false,
|
|
141
|
+
CLAUDE_CODE_OAUTH_TOKEN: true,
|
|
142
|
+
OPENAI_API_KEY: true,
|
|
143
|
+
});
|
|
144
|
+
const captureText = fs.readFileSync(captured.capturePath, "utf8");
|
|
145
|
+
for (const secret of secretValues) {
|
|
146
|
+
assert.ok(!captureText.includes(secret), "capture must never contain environment values");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Claude success includes a fresh session, tool start/end, usage, and result.
|
|
150
|
+
const claude = await runFixture({
|
|
151
|
+
kind: "claude",
|
|
152
|
+
controls: {
|
|
153
|
+
FAKE_AGENT_TOOL: "1",
|
|
154
|
+
FAKE_AGENT_SESSION_ID: "claude-fresh-1",
|
|
155
|
+
FAKE_AGENT_TEXT: "fixture says café 👋",
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
const claudeEvents = parseJsonLines(claude.stdout);
|
|
159
|
+
assert.strictEqual(claudeEvents[0].type, "system");
|
|
160
|
+
assert.strictEqual(claudeEvents[0].session_id, "claude-fresh-1");
|
|
161
|
+
assert.ok(claudeEvents.some((event) => event.type === "assistant"
|
|
162
|
+
&& event.message.content.some((block) => block.type === "tool_use")));
|
|
163
|
+
assert.ok(claudeEvents.some((event) => event.type === "user"
|
|
164
|
+
&& event.message.content.some((block) => block.type === "tool_result")));
|
|
165
|
+
const claudeResult = claudeEvents.find((event) => event.type === "result");
|
|
166
|
+
assert.strictEqual(claudeResult.session_id, "claude-fresh-1");
|
|
167
|
+
assert.strictEqual(claudeResult.result, "fixture says café 👋");
|
|
168
|
+
assert.deepStrictEqual(claudeResult.usage, {
|
|
169
|
+
input_tokens: 12,
|
|
170
|
+
cache_read_input_tokens: 3,
|
|
171
|
+
cache_creation_input_tokens: 2,
|
|
172
|
+
output_tokens: 7,
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// Codex success uses its native JSONL vocabulary and keeps a resumed thread id.
|
|
176
|
+
const codex = await runFixture({
|
|
177
|
+
kind: "codex",
|
|
178
|
+
args: ["exec", "resume", "codex-resume-1", "hello"],
|
|
179
|
+
controls: {
|
|
180
|
+
FAKE_AGENT_TOOL: "1",
|
|
181
|
+
FAKE_AGENT_SESSION_ID: "codex-resume-1",
|
|
182
|
+
FAKE_AGENT_TEXT: "codex fixture result",
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
const codexEvents = parseJsonLines(codex.stdout);
|
|
186
|
+
assert.deepStrictEqual(codexEvents[0], { type: "thread.started", thread_id: "codex-resume-1" });
|
|
187
|
+
assert.ok(codexEvents.some((event) => event.type === "item.started"
|
|
188
|
+
&& event.item.type === "command_execution"));
|
|
189
|
+
assert.ok(codexEvents.some((event) => event.type === "item.completed"
|
|
190
|
+
&& event.item.type === "command_execution" && event.item.exit_code === 0));
|
|
191
|
+
assert.ok(codexEvents.some((event) => event.type === "item.completed"
|
|
192
|
+
&& event.item.type === "agent_message" && event.item.text === "codex fixture result"));
|
|
193
|
+
assert.deepStrictEqual(codexEvents.at(-1), {
|
|
194
|
+
type: "turn.completed",
|
|
195
|
+
usage: { input_tokens: 12, cached_input_tokens: 3, output_tokens: 7 },
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// Resume parsing remains valid when Codex configuration overrides precede
|
|
199
|
+
// its positional thread id (the supported shape introduced in Task 7).
|
|
200
|
+
const optionAwareResume = await runFixture({
|
|
201
|
+
kind: "codex",
|
|
202
|
+
args: ["exec", "resume", "-c", 'sandbox_mode="read-only"', "codex-option-resume-1", "hello"],
|
|
203
|
+
});
|
|
204
|
+
assert.deepStrictEqual(parseJsonLines(optionAwareResume.stdout)[0], {
|
|
205
|
+
type: "thread.started",
|
|
206
|
+
thread_id: "codex-option-resume-1",
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// Transport controls cover a byte split inside a multibyte character,
|
|
210
|
+
// coalesced JSONL, CRLF, and both trailing-newline states.
|
|
211
|
+
const split = await runFixture({
|
|
212
|
+
kind: "claude",
|
|
213
|
+
controls: {
|
|
214
|
+
FAKE_AGENT_TEXT: "split 👋 event",
|
|
215
|
+
FAKE_AGENT_CHUNK_MODE: "split",
|
|
216
|
+
FAKE_AGENT_CHUNK_DELAY_MS: "20",
|
|
217
|
+
},
|
|
218
|
+
});
|
|
219
|
+
assert.strictEqual(split.capture.writePlan.mode, "split");
|
|
220
|
+
assert.strictEqual(split.capture.writePlan.writes.length, 2);
|
|
221
|
+
assert.strictEqual(split.capture.writePlan.splitInsideUtf8, true,
|
|
222
|
+
"split mode must cut inside the multibyte marker");
|
|
223
|
+
assert.strictEqual(split.capture.writePlan.writes[0], split.capture.writePlan.splitOffset);
|
|
224
|
+
assert.strictEqual(parseJsonLines(split.stdout).find((event) => event.type === "result").result, "split 👋 event");
|
|
225
|
+
|
|
226
|
+
const coalesced = await runFixture({
|
|
227
|
+
kind: "codex",
|
|
228
|
+
controls: {
|
|
229
|
+
FAKE_AGENT_CHUNK_MODE: "coalesce",
|
|
230
|
+
FAKE_AGENT_LINE_ENDING: "crlf",
|
|
231
|
+
FAKE_AGENT_FINAL_NEWLINE: "0",
|
|
232
|
+
},
|
|
233
|
+
});
|
|
234
|
+
assert.ok(coalesced.stdout.includes("\r\n"), "CRLF mode must use CRLF framing");
|
|
235
|
+
assert.ok(!coalesced.stdout.endsWith("\n"), "final-newline control must omit the last newline");
|
|
236
|
+
assert.ok(parseJsonLines(coalesced.stdout).length >= 3, "coalesced mode must carry several JSONL records");
|
|
237
|
+
assert.strictEqual(coalesced.capture.writePlan.writes.length, 1,
|
|
238
|
+
"coalesced mode must perform one stdout write");
|
|
239
|
+
assert.strictEqual(coalesced.capture.writePlan.lineEnding, "crlf");
|
|
240
|
+
assert.strictEqual(coalesced.capture.writePlan.finalNewline, false);
|
|
241
|
+
|
|
242
|
+
const newline = await runFixture({
|
|
243
|
+
kind: "claude",
|
|
244
|
+
controls: { FAKE_AGENT_FINAL_NEWLINE: "1" },
|
|
245
|
+
});
|
|
246
|
+
assert.ok(newline.stdout.endsWith("\n"), "fixture must also support a final newline");
|
|
247
|
+
|
|
248
|
+
// Provider-auth failures and arbitrary non-zero exits are deterministic.
|
|
249
|
+
for (const kind of ["claude", "codex"]) {
|
|
250
|
+
const auth = await runFixture({
|
|
251
|
+
kind,
|
|
252
|
+
controls: { FAKE_AGENT_SCENARIO: "auth-failure" },
|
|
253
|
+
});
|
|
254
|
+
assert.strictEqual(auth.code, 1);
|
|
255
|
+
assert.match(auth.stderr, /authentication failed/i);
|
|
256
|
+
const authEvents = parseJsonLines(auth.stdout);
|
|
257
|
+
if (kind === "claude") {
|
|
258
|
+
assert.ok(authEvents.some((event) => event.type === "result"
|
|
259
|
+
&& event.subtype === "error_during_execution" && event.is_error === true));
|
|
260
|
+
assert.ok(!authEvents.some((event) => event.subtype === "success"));
|
|
261
|
+
} else {
|
|
262
|
+
assert.ok(authEvents.some((event) => event.type === "error"
|
|
263
|
+
&& /authentication failed/i.test(event.message)));
|
|
264
|
+
assert.ok(authEvents.some((event) => event.type === "turn.failed"));
|
|
265
|
+
assert.ok(!authEvents.some((event) => event.type === "turn.completed"));
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const nonZero = await runFixture({
|
|
270
|
+
kind: "claude",
|
|
271
|
+
controls: {
|
|
272
|
+
FAKE_AGENT_SCENARIO: "non-zero",
|
|
273
|
+
FAKE_AGENT_EXIT_CODE: "23",
|
|
274
|
+
},
|
|
275
|
+
});
|
|
276
|
+
assert.strictEqual(nonZero.code, 23);
|
|
277
|
+
|
|
278
|
+
// A terminal event can precede child close; lifecycle tests depend on this.
|
|
279
|
+
let terminalAt = null;
|
|
280
|
+
let eofAt = null;
|
|
281
|
+
let aliveAtTerminal = false;
|
|
282
|
+
const delayed = await runFixture({
|
|
283
|
+
kind: "codex",
|
|
284
|
+
controls: { FAKE_AGENT_CLOSE_DELAY_MS: "80" },
|
|
285
|
+
onSpawn(child) {
|
|
286
|
+
let observed = "";
|
|
287
|
+
child.stdout.once("end", () => { eofAt = Date.now(); });
|
|
288
|
+
child.stdout.on("data", (chunk) => {
|
|
289
|
+
observed += chunk.toString("utf8");
|
|
290
|
+
if (terminalAt == null && observed.includes('"type":"turn.completed"')) {
|
|
291
|
+
terminalAt = Date.now();
|
|
292
|
+
aliveAtTerminal = child.exitCode === null && child.signalCode === null;
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
},
|
|
296
|
+
});
|
|
297
|
+
assert.strictEqual(delayed.code, 0);
|
|
298
|
+
assert.ok(terminalAt, "delayed-close scenario must emit a terminal event");
|
|
299
|
+
assert.ok(eofAt, "delayed-close scenario must explicitly close stdout before process close");
|
|
300
|
+
assert.strictEqual(aliveAtTerminal, true, "child must still be alive when terminal output arrives");
|
|
301
|
+
assert.ok(eofAt >= terminalAt, "stdout EOF must follow the terminal event");
|
|
302
|
+
assert.ok(delayed.closedAt - eofAt >= 65, "delayed-close control must hold after stdout EOF");
|
|
303
|
+
|
|
304
|
+
// Forced termination waits after output, records only the signal name, and
|
|
305
|
+
// then re-raises it so callers observe a genuinely signalled child.
|
|
306
|
+
const signalPath = fixturePath("signal");
|
|
307
|
+
let signalChild;
|
|
308
|
+
const forcedPromise = runFixture({
|
|
309
|
+
kind: "claude",
|
|
310
|
+
controls: {
|
|
311
|
+
FAKE_AGENT_SCENARIO: "forced-termination",
|
|
312
|
+
FAKE_AGENT_SIGNAL_CAPTURE: signalPath,
|
|
313
|
+
},
|
|
314
|
+
onSpawn(child) {
|
|
315
|
+
signalChild = child;
|
|
316
|
+
child.stdout.once("data", () => child.kill("SIGTERM"));
|
|
317
|
+
},
|
|
318
|
+
});
|
|
319
|
+
const forced = await forcedPromise;
|
|
320
|
+
assert.strictEqual(forced.code, null);
|
|
321
|
+
assert.strictEqual(forced.signal, "SIGTERM");
|
|
322
|
+
await waitForFile(signalPath);
|
|
323
|
+
assert.deepStrictEqual(JSON.parse(fs.readFileSync(signalPath, "utf8")), { signal: "SIGTERM" });
|
|
324
|
+
assert.ok(signalChild.pid > 0);
|
|
325
|
+
|
|
326
|
+
console.log("provider fixture OK");
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
main().catch((error) => {
|
|
330
|
+
console.error(error.stack || error.message);
|
|
331
|
+
process.exitCode = 1;
|
|
332
|
+
}).finally(cleanup);
|