@kodelyth/acpx 2026.5.39 → 2026.5.42

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 (47) hide show
  1. package/AGENTS.md +54 -0
  2. package/CLAUDE.md +54 -0
  3. package/dist/index.js +14 -0
  4. package/dist/process-reaper-DdVqzAA_.js +370 -0
  5. package/dist/register.runtime.js +53 -0
  6. package/dist/runtime-D9qhNKmy.js +741 -0
  7. package/dist/runtime-api.js +4 -0
  8. package/dist/service-CXeUME_-.js +1483 -0
  9. package/dist/setup-api.js +16 -0
  10. package/index.test.ts +119 -0
  11. package/index.ts +19 -0
  12. package/klaw.plugin.json +12 -27
  13. package/package.json +2 -2
  14. package/register.runtime.test.ts +104 -0
  15. package/register.runtime.ts +86 -0
  16. package/runtime-api.ts +49 -0
  17. package/setup-api.ts +18 -0
  18. package/src/acpx-runtime-compat.d.ts +65 -0
  19. package/src/claude-agent-acp-completion.test.ts +187 -0
  20. package/src/codex-auth-bridge.test.ts +688 -0
  21. package/src/codex-auth-bridge.ts +780 -0
  22. package/src/codex-trust-config.ts +297 -0
  23. package/src/config-schema.ts +118 -0
  24. package/src/config.test.ts +285 -0
  25. package/src/config.ts +281 -0
  26. package/src/manifest.test.ts +21 -0
  27. package/src/process-lease.test.ts +89 -0
  28. package/src/process-lease.ts +179 -0
  29. package/src/process-reaper.test.ts +330 -0
  30. package/src/process-reaper.ts +434 -0
  31. package/src/runtime-internals/error-format.mjs +6 -0
  32. package/src/runtime-internals/mcp-command-line.mjs +123 -0
  33. package/src/runtime-internals/mcp-command-line.test.ts +59 -0
  34. package/src/runtime-internals/mcp-proxy.mjs +121 -0
  35. package/src/runtime-internals/mcp-proxy.test.ts +130 -0
  36. package/src/runtime.test.ts +1817 -0
  37. package/src/runtime.ts +1261 -0
  38. package/src/service.test.ts +802 -0
  39. package/src/service.ts +630 -0
  40. package/tsconfig.json +16 -0
  41. package/index.js +0 -7
  42. package/register.runtime.js +0 -7
  43. package/runtime-api.js +0 -7
  44. package/setup-api.js +0 -7
  45. /package/{error-format.mjs → dist/error-format.mjs} +0 -0
  46. /package/{mcp-command-line.mjs → dist/mcp-command-line.mjs} +0 -0
  47. /package/{mcp-proxy.mjs → dist/mcp-proxy.mjs} +0 -0
@@ -0,0 +1,802 @@
1
+ import fs from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+
6
+ const { runtimeRegistry } = vi.hoisted(() => ({
7
+ runtimeRegistry: new Map<string, { runtime: unknown; healthy?: () => boolean }>(),
8
+ }));
9
+ const { prepareAcpxCodexAuthConfigMock } = vi.hoisted(() => ({
10
+ prepareAcpxCodexAuthConfigMock: vi.fn(
11
+ async ({ pluginConfig }: { pluginConfig: unknown }) => pluginConfig,
12
+ ),
13
+ }));
14
+ const { cleanupKlawOwnedAcpxProcessTreeMock } = vi.hoisted(() => ({
15
+ cleanupKlawOwnedAcpxProcessTreeMock: vi.fn(
16
+ async (): Promise<{
17
+ inspectedPids: number[];
18
+ terminatedPids: number[];
19
+ skippedReason?: string;
20
+ }> => ({
21
+ inspectedPids: [],
22
+ terminatedPids: [],
23
+ }),
24
+ ),
25
+ }));
26
+ const { reapStaleKlawOwnedAcpxOrphansMock } = vi.hoisted(() => ({
27
+ reapStaleKlawOwnedAcpxOrphansMock: vi.fn(
28
+ async (): Promise<{
29
+ inspectedPids: number[];
30
+ terminatedPids: number[];
31
+ skippedReason?: string;
32
+ }> => ({
33
+ inspectedPids: [],
34
+ terminatedPids: [],
35
+ }),
36
+ ),
37
+ }));
38
+ const { acpxRuntimeConstructorMock, createAgentRegistryMock, createFileSessionStoreMock } =
39
+ vi.hoisted(() => ({
40
+ acpxRuntimeConstructorMock: vi.fn(function AcpxRuntime(options: unknown) {
41
+ return {
42
+ cancel: vi.fn(async () => {}),
43
+ close: vi.fn(async () => {}),
44
+ doctor: vi.fn(async () => ({ ok: true, message: "ok" })),
45
+ ensureSession: vi.fn(async () => ({
46
+ backend: "acpx",
47
+ runtimeSessionName: "agent:codex:acp:test",
48
+ sessionKey: "agent:codex:acp:test",
49
+ })),
50
+ getCapabilities: vi.fn(async () => ({ controls: [] })),
51
+ getStatus: vi.fn(async () => ({ summary: "ready" })),
52
+ isHealthy: vi.fn(() => true),
53
+ prepareFreshSession: vi.fn(async () => {}),
54
+ probeAvailability: vi.fn(async () => {}),
55
+ runTurn: vi.fn(async function* () {}),
56
+ setConfigOption: vi.fn(async () => {}),
57
+ setMode: vi.fn(async () => {}),
58
+ __options: options,
59
+ };
60
+ }),
61
+ createAgentRegistryMock: vi.fn(() => ({})),
62
+ createFileSessionStoreMock: vi.fn(() => ({})),
63
+ }));
64
+
65
+ vi.mock("../runtime-api.js", () => ({
66
+ getAcpRuntimeBackend: (id: string) => runtimeRegistry.get(id),
67
+ registerAcpRuntimeBackend: (entry: { id: string; runtime: unknown; healthy?: () => boolean }) => {
68
+ runtimeRegistry.set(entry.id, entry);
69
+ },
70
+ unregisterAcpRuntimeBackend: (id: string) => {
71
+ runtimeRegistry.delete(id);
72
+ },
73
+ }));
74
+
75
+ vi.mock("./runtime.js", () => ({
76
+ ACPX_BACKEND_ID: "acpx",
77
+ AcpxRuntime: acpxRuntimeConstructorMock,
78
+ createAgentRegistry: createAgentRegistryMock,
79
+ createFileSessionStore: createFileSessionStoreMock,
80
+ }));
81
+
82
+ vi.mock("./codex-auth-bridge.js", () => ({
83
+ prepareAcpxCodexAuthConfig: prepareAcpxCodexAuthConfigMock,
84
+ }));
85
+
86
+ vi.mock("./process-reaper.js", () => ({
87
+ cleanupKlawOwnedAcpxProcessTree: cleanupKlawOwnedAcpxProcessTreeMock,
88
+ reapStaleKlawOwnedAcpxOrphans: reapStaleKlawOwnedAcpxOrphansMock,
89
+ }));
90
+
91
+ import { getAcpRuntimeBackend } from "../runtime-api.js";
92
+ import type { KlawPluginServiceContext } from "../runtime-api.js";
93
+ import { createAcpxRuntimeService } from "./service.js";
94
+
95
+ const tempDirs: string[] = [];
96
+ const previousEnv = {
97
+ KLAW_ACPX_RUNTIME_STARTUP_PROBE: process.env.KLAW_ACPX_RUNTIME_STARTUP_PROBE,
98
+ KLAW_SKIP_ACPX_RUNTIME: process.env.KLAW_SKIP_ACPX_RUNTIME,
99
+ KLAW_SKIP_ACPX_RUNTIME_PROBE: process.env.KLAW_SKIP_ACPX_RUNTIME_PROBE,
100
+ };
101
+
102
+ function restoreEnv(name: keyof typeof previousEnv): void {
103
+ const value = previousEnv[name];
104
+ if (value === undefined) {
105
+ delete process.env[name];
106
+ } else {
107
+ process.env[name] = value;
108
+ }
109
+ }
110
+
111
+ async function makeTempDir(): Promise<string> {
112
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "klaw-acpx-service-"));
113
+ tempDirs.push(dir);
114
+ return dir;
115
+ }
116
+
117
+ afterEach(async () => {
118
+ runtimeRegistry.clear();
119
+ prepareAcpxCodexAuthConfigMock.mockClear();
120
+ cleanupKlawOwnedAcpxProcessTreeMock.mockClear();
121
+ reapStaleKlawOwnedAcpxOrphansMock.mockClear();
122
+ acpxRuntimeConstructorMock.mockClear();
123
+ createAgentRegistryMock.mockClear();
124
+ createFileSessionStoreMock.mockClear();
125
+ restoreEnv("KLAW_ACPX_RUNTIME_STARTUP_PROBE");
126
+ restoreEnv("KLAW_SKIP_ACPX_RUNTIME");
127
+ restoreEnv("KLAW_SKIP_ACPX_RUNTIME_PROBE");
128
+ for (const dir of tempDirs.splice(0)) {
129
+ await fs.rm(dir, { recursive: true, force: true });
130
+ }
131
+ });
132
+
133
+ function createServiceContext(workspaceDir: string): KlawPluginServiceContext {
134
+ return {
135
+ workspaceDir,
136
+ stateDir: path.join(workspaceDir, ".klaw-plugin-state"),
137
+ config: {},
138
+ logger: {
139
+ info: vi.fn(),
140
+ warn: vi.fn(),
141
+ error: vi.fn(),
142
+ debug: vi.fn(),
143
+ },
144
+ };
145
+ }
146
+
147
+ function createMockRuntime(overrides: Record<string, unknown> = {}) {
148
+ return {
149
+ ensureSession: vi.fn(),
150
+ runTurn: vi.fn(),
151
+ cancel: vi.fn(),
152
+ close: vi.fn(),
153
+ probeAvailability: vi.fn(async () => {}),
154
+ isHealthy: vi.fn(() => true),
155
+ doctor: vi.fn(async () => ({ ok: true, message: "ok" })),
156
+ ...overrides,
157
+ };
158
+ }
159
+
160
+ function createStartupTraceRecorder() {
161
+ const measured: string[] = [];
162
+ const details: Array<{
163
+ name: string;
164
+ metrics: ReadonlyArray<readonly [string, number | string]>;
165
+ }> = [];
166
+ return {
167
+ measured,
168
+ details,
169
+ startupTrace: {
170
+ measure: async <T>(name: string, run: () => T | Promise<T>): Promise<T> => {
171
+ measured.push(name);
172
+ return await run();
173
+ },
174
+ detail: (name: string, metrics: ReadonlyArray<readonly [string, number | string]>) => {
175
+ details.push({ name, metrics });
176
+ },
177
+ },
178
+ };
179
+ }
180
+
181
+ function readFirstRuntimeFactoryInput(runtimeFactory: { mock: { calls: Array<Array<unknown>> } }) {
182
+ const [call] = runtimeFactory.mock.calls;
183
+ if (!call) {
184
+ throw new Error("Expected runtimeFactory to be called");
185
+ }
186
+ const [input] = call;
187
+ if (typeof input !== "object" || input === null) {
188
+ throw new Error("Expected runtimeFactory to be called with an options object");
189
+ }
190
+ return input as {
191
+ pluginConfig: {
192
+ timeoutSeconds?: number;
193
+ probeAgent?: string;
194
+ };
195
+ };
196
+ }
197
+
198
+ describe("createAcpxRuntimeService", () => {
199
+ it("registers and unregisters the embedded backend", async () => {
200
+ const workspaceDir = await makeTempDir();
201
+ const ctx = createServiceContext(workspaceDir);
202
+ const runtime = createMockRuntime();
203
+ const service = createAcpxRuntimeService({
204
+ runtimeFactory: () => runtime as never,
205
+ });
206
+
207
+ await service.start(ctx);
208
+
209
+ expect(getAcpRuntimeBackend("acpx")?.runtime).toBe(runtime);
210
+
211
+ await service.stop?.(ctx);
212
+
213
+ expect(getAcpRuntimeBackend("acpx")).toBeUndefined();
214
+ });
215
+
216
+ it("skips the startup probe and does not advertise backend health when explicitly disabled", async () => {
217
+ process.env.KLAW_ACPX_RUNTIME_STARTUP_PROBE = "0";
218
+ delete process.env.KLAW_SKIP_ACPX_RUNTIME_PROBE;
219
+ const workspaceDir = await makeTempDir();
220
+ const stateDir = path.join(workspaceDir, "custom-state");
221
+ const ctx = createServiceContext(workspaceDir);
222
+ const probeAvailability = vi.fn(async () => {
223
+ await fs.access(stateDir);
224
+ });
225
+ const runtime = createMockRuntime({
226
+ doctor: async () => ({ ok: true, message: "ok" }),
227
+ isHealthy: () => false,
228
+ probeAvailability,
229
+ });
230
+ const service = createAcpxRuntimeService({
231
+ pluginConfig: { stateDir },
232
+ runtimeFactory: () => runtime as never,
233
+ });
234
+
235
+ await service.start(ctx);
236
+
237
+ await fs.access(stateDir);
238
+ expect(probeAvailability).not.toHaveBeenCalled();
239
+ expect(getAcpRuntimeBackend("acpx")?.healthy).toBeUndefined();
240
+
241
+ await service.stop?.(ctx);
242
+ });
243
+
244
+ it("waits for the embedded runtime startup probe before resolving by default", async () => {
245
+ delete process.env.KLAW_ACPX_RUNTIME_STARTUP_PROBE;
246
+ const workspaceDir = await makeTempDir();
247
+ const ctx = createServiceContext(workspaceDir);
248
+ let releaseProbe!: () => void;
249
+ const probeStarted = vi.fn();
250
+ const probeAvailability = vi.fn(
251
+ () =>
252
+ new Promise<void>((resolve) => {
253
+ probeStarted();
254
+ releaseProbe = resolve;
255
+ }),
256
+ );
257
+ const runtime = createMockRuntime({
258
+ probeAvailability,
259
+ isHealthy: () => true,
260
+ });
261
+ const service = createAcpxRuntimeService({
262
+ runtimeFactory: () => runtime as never,
263
+ });
264
+
265
+ const startPromise = service.start(ctx) as Promise<void>;
266
+ await vi.waitFor(() => {
267
+ expect(probeStarted).toHaveBeenCalledOnce();
268
+ });
269
+
270
+ let resolved = false;
271
+ void startPromise.then(() => {
272
+ resolved = true;
273
+ });
274
+ await Promise.resolve();
275
+
276
+ expect(resolved).toBe(false);
277
+ releaseProbe();
278
+ await startPromise;
279
+
280
+ expect(resolved).toBe(true);
281
+ expect(ctx.logger.info).toHaveBeenCalledWith("embedded acpx runtime backend ready");
282
+
283
+ await service.stop?.(ctx);
284
+ });
285
+
286
+ it("emits ACPX-owned startup trace subspans", async () => {
287
+ delete process.env.KLAW_ACPX_RUNTIME_STARTUP_PROBE;
288
+ const workspaceDir = await makeTempDir();
289
+ const ctx = createServiceContext(workspaceDir);
290
+ const trace = createStartupTraceRecorder();
291
+ ctx.startupTrace = trace.startupTrace;
292
+ const runtime = createMockRuntime();
293
+ const service = createAcpxRuntimeService({
294
+ runtimeFactory: () => runtime as never,
295
+ });
296
+
297
+ await service.start(ctx);
298
+
299
+ expect(trace.measured).toEqual([
300
+ "config.resolve",
301
+ "config.prepare-codex-auth",
302
+ "filesystem.prepare",
303
+ "gateway-instance-id",
304
+ "process-leases.reap",
305
+ "runtime.create",
306
+ "backend.register",
307
+ "probe.availability",
308
+ ]);
309
+ expect(trace.details).toEqual([
310
+ {
311
+ name: "probe-policy",
312
+ metrics: [
313
+ ["startupProbeEnabledCount", 1],
314
+ ["probeAgent", "default"],
315
+ ],
316
+ },
317
+ {
318
+ name: "probe.result",
319
+ metrics: [["healthyCount", 1]],
320
+ },
321
+ ]);
322
+
323
+ await service.stop?.(ctx);
324
+ });
325
+
326
+ it("reaps stale ACPX process leases from the generated wrapper root at startup", async () => {
327
+ const workspaceDir = await makeTempDir();
328
+ const ctx = createServiceContext(workspaceDir);
329
+ const runtime = createMockRuntime();
330
+ const processCleanupDeps = { sleep: vi.fn(async () => {}) };
331
+ await fs.mkdir(path.join(ctx.stateDir, "acpx"), { recursive: true });
332
+ await fs.writeFile(path.join(ctx.stateDir, "gateway-instance-id"), "gw-test\n");
333
+ await fs.writeFile(
334
+ path.join(ctx.stateDir, "acpx", "process-leases.json"),
335
+ JSON.stringify({
336
+ version: 1,
337
+ leases: [
338
+ {
339
+ leaseId: "lease-1",
340
+ gatewayInstanceId: "gw-test",
341
+ sessionKey: "agent:codex:acp:test",
342
+ wrapperRoot: path.join(ctx.stateDir, "acpx"),
343
+ wrapperPath: path.join(ctx.stateDir, "acpx", "codex-acp-wrapper.mjs"),
344
+ rootPid: 101,
345
+ commandHash: "hash",
346
+ startedAt: 1,
347
+ state: "open",
348
+ },
349
+ ],
350
+ }),
351
+ );
352
+ cleanupKlawOwnedAcpxProcessTreeMock.mockResolvedValueOnce({
353
+ inspectedPids: [101, 102],
354
+ terminatedPids: [101, 102],
355
+ });
356
+ const service = createAcpxRuntimeService({
357
+ runtimeFactory: () => runtime as never,
358
+ processCleanupDeps,
359
+ });
360
+
361
+ await service.start(ctx);
362
+
363
+ expect(cleanupKlawOwnedAcpxProcessTreeMock).toHaveBeenCalledWith({
364
+ rootPid: 101,
365
+ expectedLeaseId: "lease-1",
366
+ expectedGatewayInstanceId: "gw-test",
367
+ wrapperRoot: path.join(ctx.stateDir, "acpx"),
368
+ deps: processCleanupDeps,
369
+ });
370
+ expect(ctx.logger.info).toHaveBeenCalledWith("reaped 2 stale Klaw-owned ACPX processes");
371
+
372
+ await service.stop?.(ctx);
373
+ });
374
+
375
+ it("runs wrapper-root orphan cleanup before dropping pending ACPX leases", async () => {
376
+ const workspaceDir = await makeTempDir();
377
+ const ctx = createServiceContext(workspaceDir);
378
+ const runtime = createMockRuntime();
379
+ const processCleanupDeps = { sleep: vi.fn(async () => {}) };
380
+ const wrapperRoot = path.join(ctx.stateDir, "acpx");
381
+ await fs.mkdir(wrapperRoot, { recursive: true });
382
+ await fs.writeFile(path.join(ctx.stateDir, "gateway-instance-id"), "gw-test\n");
383
+ await fs.writeFile(
384
+ path.join(wrapperRoot, "process-leases.json"),
385
+ JSON.stringify({
386
+ version: 1,
387
+ leases: [
388
+ {
389
+ leaseId: "lease-pending",
390
+ gatewayInstanceId: "gw-test",
391
+ sessionKey: "agent:codex:acp:test",
392
+ wrapperRoot,
393
+ wrapperPath: path.join(wrapperRoot, "codex-acp-wrapper.mjs"),
394
+ rootPid: 0,
395
+ commandHash: "hash",
396
+ startedAt: 1,
397
+ state: "open",
398
+ },
399
+ ],
400
+ }),
401
+ );
402
+ reapStaleKlawOwnedAcpxOrphansMock.mockResolvedValueOnce({
403
+ inspectedPids: [201, 202],
404
+ terminatedPids: [201, 202],
405
+ });
406
+ const service = createAcpxRuntimeService({
407
+ runtimeFactory: () => runtime as never,
408
+ processCleanupDeps,
409
+ });
410
+
411
+ await service.start(ctx);
412
+
413
+ expect(cleanupKlawOwnedAcpxProcessTreeMock).not.toHaveBeenCalled();
414
+ expect(reapStaleKlawOwnedAcpxOrphansMock).toHaveBeenCalledWith({
415
+ wrapperRoot,
416
+ deps: processCleanupDeps,
417
+ });
418
+ expect(ctx.logger.info).toHaveBeenCalledWith("reaped 2 stale Klaw-owned ACPX processes");
419
+ const leaseFile = JSON.parse(
420
+ await fs.readFile(path.join(wrapperRoot, "process-leases.json"), "utf8"),
421
+ );
422
+ expect(leaseFile.leases[0].state).toBe("closed");
423
+
424
+ await service.stop?.(ctx);
425
+ });
426
+
427
+ it("keeps startup quiet when no process leases are open", async () => {
428
+ const workspaceDir = await makeTempDir();
429
+ const ctx = createServiceContext(workspaceDir);
430
+ const runtime = createMockRuntime();
431
+ const service = createAcpxRuntimeService({
432
+ runtimeFactory: () => runtime as never,
433
+ });
434
+
435
+ await service.start(ctx);
436
+
437
+ expect(cleanupKlawOwnedAcpxProcessTreeMock).not.toHaveBeenCalled();
438
+ expect(ctx.logger.warn).not.toHaveBeenCalled();
439
+
440
+ await service.stop?.(ctx);
441
+ });
442
+
443
+ it("registers the backend lazily without importing ACPX runtime when startup probe is disabled", async () => {
444
+ process.env.KLAW_ACPX_RUNTIME_STARTUP_PROBE = "0";
445
+ delete process.env.KLAW_SKIP_ACPX_RUNTIME_PROBE;
446
+ const workspaceDir = await makeTempDir();
447
+ const ctx = createServiceContext(workspaceDir);
448
+ const service = createAcpxRuntimeService();
449
+
450
+ await service.start(ctx);
451
+
452
+ const backend = getAcpRuntimeBackend("acpx");
453
+ if (!backend) {
454
+ throw new Error("expected ACPX runtime backend");
455
+ }
456
+ const backendRuntime = backend.runtime as {
457
+ ensureSession(input: { agent: string; mode: string; sessionKey: string }): Promise<unknown>;
458
+ };
459
+ expect(typeof backendRuntime.ensureSession).toBe("function");
460
+ expect(backend.healthy).toBeUndefined();
461
+ expect(acpxRuntimeConstructorMock).not.toHaveBeenCalled();
462
+
463
+ await backendRuntime.ensureSession({
464
+ agent: "codex",
465
+ mode: "oneshot",
466
+ sessionKey: "agent:codex:acp:test",
467
+ });
468
+
469
+ expect(acpxRuntimeConstructorMock).toHaveBeenCalledOnce();
470
+ expect(backend.healthy).toBeUndefined();
471
+
472
+ await service.stop?.(ctx);
473
+ });
474
+
475
+ it("adapts lazy runTurn-only default runtimes for startTurn callers", async () => {
476
+ process.env.KLAW_ACPX_RUNTIME_STARTUP_PROBE = "0";
477
+ const workspaceDir = await makeTempDir();
478
+ const ctx = createServiceContext(workspaceDir);
479
+ const runTurn = vi.fn(async function* () {
480
+ yield {
481
+ type: "text_delta" as const,
482
+ stream: "output" as const,
483
+ text: "legacy progress",
484
+ };
485
+ yield {
486
+ type: "done" as const,
487
+ stopReason: "end_turn",
488
+ };
489
+ });
490
+ acpxRuntimeConstructorMock.mockImplementationOnce(function AcpxRuntime(options: unknown) {
491
+ return {
492
+ ...createMockRuntime({
493
+ runTurn,
494
+ }),
495
+ getCapabilities: vi.fn(async () => ({ controls: [] })),
496
+ getStatus: vi.fn(async () => ({ summary: "ready" })),
497
+ prepareFreshSession: vi.fn(async () => {}),
498
+ setConfigOption: vi.fn(async () => {}),
499
+ setMode: vi.fn(async () => {}),
500
+ __options: options,
501
+ };
502
+ });
503
+ const service = createAcpxRuntimeService();
504
+
505
+ await service.start(ctx);
506
+
507
+ const backend = getAcpRuntimeBackend("acpx");
508
+ if (!backend) {
509
+ throw new Error("expected ACPX runtime backend");
510
+ }
511
+ const backendRuntime = backend.runtime as {
512
+ startTurn(input: {
513
+ handle: { sessionKey: string; backend: string; runtimeSessionName: string };
514
+ text: string;
515
+ mode: string;
516
+ requestId: string;
517
+ }): {
518
+ events: AsyncIterable<unknown>;
519
+ result: Promise<unknown>;
520
+ };
521
+ };
522
+ const turn = backendRuntime.startTurn({
523
+ handle: {
524
+ sessionKey: "agent:codex:acp:test",
525
+ backend: "acpx",
526
+ runtimeSessionName: "agent:codex:acp:test",
527
+ },
528
+ text: "hello",
529
+ mode: "prompt",
530
+ requestId: "turn-1",
531
+ });
532
+ await expect(turn.result).resolves.toEqual({
533
+ status: "completed",
534
+ stopReason: "end_turn",
535
+ });
536
+ const events = [];
537
+ for await (const event of turn.events) {
538
+ events.push(event);
539
+ }
540
+
541
+ expect(events).toEqual([
542
+ {
543
+ type: "text_delta",
544
+ stream: "output",
545
+ text: "legacy progress",
546
+ },
547
+ ]);
548
+ expect(runTurn).toHaveBeenCalledOnce();
549
+
550
+ await service.stop?.(ctx);
551
+ });
552
+
553
+ it("passes the plugin timeout to the default acpx runtime constructor", async () => {
554
+ process.env.KLAW_ACPX_RUNTIME_STARTUP_PROBE = "0";
555
+ const workspaceDir = await makeTempDir();
556
+ const ctx = createServiceContext(workspaceDir);
557
+ const service = createAcpxRuntimeService({
558
+ pluginConfig: { timeoutSeconds: 0.001 },
559
+ });
560
+
561
+ await service.start(ctx);
562
+
563
+ const backend = getAcpRuntimeBackend("acpx");
564
+ if (!backend) {
565
+ throw new Error("expected ACPX runtime backend");
566
+ }
567
+ const backendRuntime = backend.runtime as {
568
+ ensureSession(input: { agent: string; mode: string; sessionKey: string }): Promise<unknown>;
569
+ };
570
+
571
+ await backendRuntime.ensureSession({
572
+ agent: "codex",
573
+ mode: "oneshot",
574
+ sessionKey: "agent:codex:acp:test",
575
+ });
576
+
577
+ const [options] = acpxRuntimeConstructorMock.mock.calls[0] ?? [];
578
+ expect(options).toHaveProperty("timeoutMs", 1);
579
+
580
+ await service.stop?.(ctx);
581
+ });
582
+
583
+ it("runs the embedded runtime probe at startup when explicitly enabled and reports health", async () => {
584
+ process.env.KLAW_ACPX_RUNTIME_STARTUP_PROBE = "1";
585
+ const workspaceDir = await makeTempDir();
586
+ const ctx = createServiceContext(workspaceDir);
587
+ const probeAvailability = vi.fn(async () => {});
588
+ const runtime = createMockRuntime({
589
+ probeAvailability,
590
+ isHealthy: () => true,
591
+ });
592
+ const service = createAcpxRuntimeService({
593
+ runtimeFactory: () => runtime as never,
594
+ });
595
+
596
+ await service.start(ctx);
597
+
598
+ expect(probeAvailability).toHaveBeenCalledOnce();
599
+ expect(getAcpRuntimeBackend("acpx")?.healthy?.()).toBe(true);
600
+
601
+ await service.stop?.(ctx);
602
+ });
603
+
604
+ it("bounds the opt-in embedded runtime startup probe wait with the configured timeout", async () => {
605
+ process.env.KLAW_ACPX_RUNTIME_STARTUP_PROBE = "1";
606
+ const workspaceDir = await makeTempDir();
607
+ const ctx = createServiceContext(workspaceDir);
608
+ const probeAvailability = vi.fn(() => new Promise<void>(() => {}));
609
+ const runtime = createMockRuntime({
610
+ probeAvailability,
611
+ isHealthy: () => false,
612
+ });
613
+ const service = createAcpxRuntimeService({
614
+ pluginConfig: { timeoutSeconds: 0.001 },
615
+ runtimeFactory: () => runtime as never,
616
+ });
617
+
618
+ await service.start(ctx);
619
+
620
+ expect(probeAvailability).toHaveBeenCalledOnce();
621
+ expect(getAcpRuntimeBackend("acpx")?.healthy?.()).toBe(false);
622
+ expect(ctx.logger.warn).toHaveBeenCalledWith(
623
+ "embedded acpx runtime setup failed: embedded acpx runtime backend startup probe timed out after 0.001s",
624
+ );
625
+
626
+ await service.stop?.(ctx);
627
+ });
628
+
629
+ it("passes the default runtime timeout to the embedded runtime factory", async () => {
630
+ const workspaceDir = await makeTempDir();
631
+ const ctx = createServiceContext(workspaceDir);
632
+ const runtime = createMockRuntime();
633
+ const runtimeFactory = vi.fn(() => runtime as never);
634
+ const service = createAcpxRuntimeService({
635
+ runtimeFactory,
636
+ });
637
+
638
+ await service.start(ctx);
639
+
640
+ expect(readFirstRuntimeFactoryInput(runtimeFactory).pluginConfig.timeoutSeconds).toBe(120);
641
+
642
+ await service.stop?.(ctx);
643
+ });
644
+
645
+ it("forwards a configured probeAgent to the runtime factory so the probe does not hardcode the default", async () => {
646
+ const workspaceDir = await makeTempDir();
647
+ const ctx = createServiceContext(workspaceDir);
648
+ const runtime = {
649
+ ensureSession: vi.fn(),
650
+ runTurn: vi.fn(),
651
+ cancel: vi.fn(),
652
+ close: vi.fn(),
653
+ probeAvailability: vi.fn(async () => {}),
654
+ isHealthy: vi.fn(() => true),
655
+ doctor: vi.fn(async () => ({ ok: true, message: "ok" })),
656
+ };
657
+ const runtimeFactory = vi.fn(() => runtime as never);
658
+ const service = createAcpxRuntimeService({
659
+ pluginConfig: { probeAgent: "opencode" },
660
+ runtimeFactory,
661
+ });
662
+
663
+ await service.start(ctx);
664
+
665
+ expect(readFirstRuntimeFactoryInput(runtimeFactory).pluginConfig.probeAgent).toBe("opencode");
666
+
667
+ await service.stop?.(ctx);
668
+ });
669
+
670
+ it("uses the first allowed ACP agent as the default probe agent", async () => {
671
+ const workspaceDir = await makeTempDir();
672
+ const ctx = createServiceContext(workspaceDir);
673
+ ctx.config = {
674
+ acp: {
675
+ allowedAgents: [" OpenCode ", "codex"],
676
+ },
677
+ };
678
+ const runtime = createMockRuntime();
679
+ const runtimeFactory = vi.fn(() => runtime as never);
680
+ const service = createAcpxRuntimeService({
681
+ runtimeFactory,
682
+ });
683
+
684
+ await service.start(ctx);
685
+
686
+ expect(readFirstRuntimeFactoryInput(runtimeFactory).pluginConfig.probeAgent).toBe("opencode");
687
+
688
+ await service.stop?.(ctx);
689
+ });
690
+
691
+ it("keeps explicit probeAgent ahead of acp.allowedAgents", async () => {
692
+ const workspaceDir = await makeTempDir();
693
+ const ctx = createServiceContext(workspaceDir);
694
+ ctx.config = {
695
+ acp: {
696
+ allowedAgents: ["opencode"],
697
+ },
698
+ };
699
+ const runtime = createMockRuntime();
700
+ const runtimeFactory = vi.fn(() => runtime as never);
701
+ const service = createAcpxRuntimeService({
702
+ pluginConfig: { probeAgent: "codex" },
703
+ runtimeFactory,
704
+ });
705
+
706
+ await service.start(ctx);
707
+
708
+ expect(readFirstRuntimeFactoryInput(runtimeFactory).pluginConfig.probeAgent).toBe("codex");
709
+
710
+ await service.stop?.(ctx);
711
+ });
712
+
713
+ it("warns when legacy compatibility config is explicitly ignored", async () => {
714
+ const workspaceDir = await makeTempDir();
715
+ const ctx = createServiceContext(workspaceDir);
716
+ const runtime = createMockRuntime();
717
+ const service = createAcpxRuntimeService({
718
+ pluginConfig: {
719
+ queueOwnerTtlSeconds: 30,
720
+ strictWindowsCmdWrapper: false,
721
+ },
722
+ runtimeFactory: () => runtime as never,
723
+ });
724
+
725
+ await service.start(ctx);
726
+
727
+ expect(ctx.logger.warn).toHaveBeenCalledWith(
728
+ "embedded acpx runtime ignores legacy compatibility config: queueOwnerTtlSeconds, strictWindowsCmdWrapper=false",
729
+ );
730
+
731
+ await service.stop?.(ctx);
732
+ });
733
+
734
+ it("lets the skip env override the opt-in embedded runtime startup probe without advertising health", async () => {
735
+ process.env.KLAW_ACPX_RUNTIME_STARTUP_PROBE = "1";
736
+ process.env.KLAW_SKIP_ACPX_RUNTIME_PROBE = "1";
737
+ const workspaceDir = await makeTempDir();
738
+ const ctx = createServiceContext(workspaceDir);
739
+ const probeAvailability = vi.fn(async () => {});
740
+ const runtime = createMockRuntime({
741
+ doctor: async () => ({ ok: false, message: "nope" }),
742
+ isHealthy: () => false,
743
+ probeAvailability,
744
+ });
745
+ const service = createAcpxRuntimeService({
746
+ runtimeFactory: () => runtime as never,
747
+ });
748
+
749
+ await service.start(ctx);
750
+
751
+ expect(probeAvailability).not.toHaveBeenCalled();
752
+ expect(getAcpRuntimeBackend("acpx")?.runtime).toBe(runtime);
753
+ expect(getAcpRuntimeBackend("acpx")?.healthy).toBeUndefined();
754
+
755
+ await service.stop?.(ctx);
756
+ });
757
+
758
+ it("formats non-string doctor details without losing object payloads", async () => {
759
+ process.env.KLAW_ACPX_RUNTIME_STARTUP_PROBE = "1";
760
+ const workspaceDir = await makeTempDir();
761
+ const ctx = createServiceContext(workspaceDir);
762
+ const runtime = createMockRuntime({
763
+ doctor: async () => ({
764
+ ok: false,
765
+ message: "probe failed",
766
+ details: [{ code: "ACP_CLOSED", agent: "codex" }, new Error("stdin closed")],
767
+ }),
768
+ isHealthy: () => false,
769
+ });
770
+ const service = createAcpxRuntimeService({
771
+ runtimeFactory: () => runtime as never,
772
+ });
773
+
774
+ await service.start(ctx);
775
+
776
+ expect(ctx.logger.warn).toHaveBeenCalledWith(
777
+ 'embedded acpx runtime backend probe failed: probe failed ({"code":"ACP_CLOSED","agent":"codex"}; stdin closed)',
778
+ );
779
+
780
+ await service.stop?.(ctx);
781
+ });
782
+
783
+ it("can skip the embedded runtime backend via env", async () => {
784
+ process.env.KLAW_SKIP_ACPX_RUNTIME = "1";
785
+ const workspaceDir = await makeTempDir();
786
+ const ctx = createServiceContext(workspaceDir);
787
+ const runtimeFactory = vi.fn(() => {
788
+ throw new Error("runtime factory should not run when ACPX is skipped");
789
+ });
790
+ const service = createAcpxRuntimeService({
791
+ runtimeFactory: runtimeFactory as never,
792
+ });
793
+
794
+ await service.start(ctx);
795
+
796
+ expect(runtimeFactory).not.toHaveBeenCalled();
797
+ expect(getAcpRuntimeBackend("acpx")).toBeUndefined();
798
+ expect(ctx.logger.info).toHaveBeenCalledWith(
799
+ "skipping embedded acpx runtime backend (KLAW_SKIP_ACPX_RUNTIME=1)",
800
+ );
801
+ });
802
+ });