remote-codex 0.11.49 → 0.11.51

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 (38) hide show
  1. package/README.md +38 -5
  2. package/apps/supervisor-api/dist/index.js +2137 -422
  3. package/apps/supervisor-web/dist/assets/index-7LiZQ3aJ.js +22 -0
  4. package/apps/supervisor-web/dist/assets/{index-Dy8PgXgw.css → index-Bg8FdhS1.css} +1 -1
  5. package/apps/supervisor-web/dist/assets/{thread-ui-gcslNXur.js → thread-ui-BbtcUwps.js} +25 -19
  6. package/apps/supervisor-web/dist/index.html +3 -3
  7. package/bin/remote-codex.mjs +4 -2
  8. package/package.json +7 -1
  9. package/packages/acp/src/agent-catalog.test.ts +25 -0
  10. package/packages/acp/src/agent-catalog.ts +3 -1
  11. package/packages/acp/src/capabilities.ts +139 -0
  12. package/packages/acp/src/capability-parity.test.ts +99 -0
  13. package/packages/acp/src/catalog-runtime.test.ts +200 -6
  14. package/packages/acp/src/catalog-runtime.ts +239 -31
  15. package/packages/acp/src/extension-registry.test.ts +198 -0
  16. package/packages/acp/src/extension-registry.ts +285 -0
  17. package/packages/acp/src/extensions.test.ts +45 -0
  18. package/packages/acp/src/extensions.ts +103 -0
  19. package/packages/acp/src/harness-contract.test.ts +81 -0
  20. package/packages/acp/src/harness-contract.ts +62 -0
  21. package/packages/acp/src/index.ts +7 -0
  22. package/packages/acp/src/item-mapper.ts +28 -2
  23. package/packages/acp/src/prompt-content.test.ts +90 -0
  24. package/packages/acp/src/prompt-content.ts +99 -0
  25. package/packages/acp/src/runtimeAdapter.test.ts +458 -5
  26. package/packages/acp/src/runtimeAdapter.ts +791 -60
  27. package/packages/acp/src/session-hydrator.test.ts +135 -0
  28. package/packages/acp/src/session-hydrator.ts +147 -0
  29. package/packages/acp/src/terminal-service.test.ts +21 -2
  30. package/packages/acp/src/terminal-service.ts +23 -3
  31. package/packages/acp/src/test/fixtures/fake-acp-agent.mjs +514 -0
  32. package/packages/acp/src/workspace-boundary.test.ts +32 -0
  33. package/packages/acp/src/workspace-boundary.ts +47 -0
  34. package/packages/agent-runtime/src/types.ts +61 -1
  35. package/packages/db/src/repositories.ts +3 -2
  36. package/packages/shared/src/index.ts +32 -1
  37. package/scripts/service-manager.mjs +13 -0
  38. package/apps/supervisor-web/dist/assets/index-DZI1aSXo.js +0 -22
@@ -10,16 +10,16 @@
10
10
  <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
11
11
  <link rel="manifest" href="/site.webmanifest" />
12
12
  <title>Remote Codex</title>
13
- <script type="module" crossorigin src="/assets/index-DZI1aSXo.js"></script>
13
+ <script type="module" crossorigin src="/assets/index-7LiZQ3aJ.js"></script>
14
14
  <link rel="modulepreload" crossorigin href="/assets/react-vendor-Dfg_6BLf.js">
15
15
  <link rel="modulepreload" crossorigin href="/assets/ui-vendor-CuR8GHb0.js">
16
16
  <link rel="modulepreload" crossorigin href="/assets/graph-vendor-DVQUpZ8C.js">
17
17
  <link rel="modulepreload" crossorigin href="/assets/terminal-vendor-C5bTa-Ka.js">
18
18
  <link rel="modulepreload" crossorigin href="/assets/markdown-vendor-BG6PurxI.js">
19
- <link rel="modulepreload" crossorigin href="/assets/thread-ui-gcslNXur.js">
19
+ <link rel="modulepreload" crossorigin href="/assets/thread-ui-BbtcUwps.js">
20
20
  <link rel="stylesheet" crossorigin href="/assets/graph-vendor-C5ap-Sga.css">
21
21
  <link rel="stylesheet" crossorigin href="/assets/terminal-vendor-Beg8tuEN.css">
22
- <link rel="stylesheet" crossorigin href="/assets/index-Dy8PgXgw.css">
22
+ <link rel="stylesheet" crossorigin href="/assets/index-Bg8FdhS1.css">
23
23
  </head>
24
24
  <body class="bg-stone-950">
25
25
  <div id="root"></div>
@@ -347,6 +347,7 @@ function resolveSupervisorLaunch() {
347
347
  function relaySupervisorProcessEnv(extra = {}) {
348
348
  return {
349
349
  ...process.env,
350
+ APP_VERSION: process.env.APP_VERSION ?? readPackageVersion(),
350
351
  REMOTE_CODEX_MODE: 'relay',
351
352
  REMOTE_CODEX_PACKAGE_ROOT: process.env.REMOTE_CODEX_PACKAGE_ROOT ?? packageRoot,
352
353
  REMOTE_CODEX_DISABLE_BUILD_RESTART:
@@ -408,7 +409,7 @@ async function startRelaySupervisorWindowsUnlocked() {
408
409
  REMOTE_CODEX_LIFECYCLE_CONTROL_TOKEN: controlToken,
409
410
  REMOTE_CODEX_LIFECYCLE_INSTANCE_ID: instanceId,
410
411
  REMOTE_CODEX_ENABLED_AGENT_PROVIDERS:
411
- process.env.REMOTE_CODEX_ENABLED_AGENT_PROVIDERS ?? 'codex',
412
+ process.env.REMOTE_CODEX_ENABLED_AGENT_PROVIDERS ?? 'codex,acp',
412
413
  }),
413
414
  stdio: ['ignore', logFd, logFd],
414
415
  });
@@ -1437,7 +1438,8 @@ Recommended environment:
1437
1438
  CODEX_COMMAND
1438
1439
  Codex executable. Default codex.
1439
1440
  REMOTE_CODEX_ENABLED_AGENT_PROVIDERS
1440
- Comma-separated provider ids, for example codex,claude.
1441
+ Comma-separated provider ids. Defaults to codex,claude,opencode,acp on Unix
1442
+ and codex,acp on Windows.
1441
1443
  REMOTE_CODEX_RELAY_SUPERVISOR_TMUX
1442
1444
  Set to 0/false/no/off to disable default tmux management.
1443
1445
  REMOTE_CODEX_RELAY_SUPERVISOR_TMUX_SESSION
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "remote-codex",
3
- "version": "0.11.49",
3
+ "version": "0.11.51",
4
4
  "description": "Local web supervisor for Codex workspaces and threads.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -57,12 +57,17 @@
57
57
  "prepack": "pnpm build:package",
58
58
  "build": "pnpm -r --if-present build",
59
59
  "build:package": "pnpm --filter @remote-codex/shared build && pnpm --filter @remote-codex/plugin-runtime build && pnpm --filter @remote-codex/plugin-terminal build && pnpm --filter @remote-codex/supervisor-api build && pnpm --filter @remote-codex/relay-server build && pnpm --filter @remote-codex/supervisor-web build",
60
+ "build:mobile-web-deps": "pnpm --filter @remote-codex/shared build && pnpm --filter @remote-codex/plugin-runtime build && pnpm --filter @remote-codex/plugin-terminal build",
60
61
  "lint": "pnpm -r --if-present lint",
61
62
  "typecheck": "pnpm -r --if-present typecheck",
62
63
  "test": "cross-env NODE_ENV=test pnpm -r --if-present test",
63
64
  "test:e2e": "playwright test",
64
65
  "verify:package": "node scripts/verify-package-install.mjs",
65
66
  "verify:relay": "node scripts/verify-relay-supervisor-smoke.mjs",
67
+ "verify:mobile:inputs": "node scripts/verify-mobile-build-inputs.mjs",
68
+ "verify:mobile:parity-gate": "node scripts/collect-mobile-verification-evidence.mjs --simulator-only --output .local/mobile-parity/verification.json",
69
+ "verify:mobile:release-gate": "node scripts/collect-mobile-verification-evidence.mjs",
70
+ "build:android:release": "bash scripts/build-android-release-apk.sh",
66
71
  "build:windows-device-manager": "dotnet publish apps/windows-device-manager/RemoteCodex.DeviceManager.csproj -c Release -r win-x64 --self-contained true -o artifacts/windows-device-manager/win-x64",
67
72
  "db:migrate": "pnpm --filter @remote-codex/db db:migrate",
68
73
  "release:mobile": "node scripts/publish-mobile-release-assets.mjs",
@@ -113,6 +118,7 @@
113
118
  "tsx": "^4.19.3",
114
119
  "typescript": "^5.8.3",
115
120
  "typescript-eslint": "^8.29.1",
121
+ "yaml": "^2.8.1",
116
122
  "vite": "^6.3.2",
117
123
  "vitest": "^3.1.1"
118
124
  }
@@ -20,6 +20,7 @@ function definition(
20
20
  serverCommand: input.serverCommand ?? process.execPath,
21
21
  serverProbeCommand: input.serverProbeCommand ?? `${nodeCommand} --version`,
22
22
  installCommand: input.installCommand ?? null,
23
+ modelListCommand: input.modelListCommand ?? null,
23
24
  };
24
25
  }
25
26
 
@@ -54,4 +55,28 @@ describe('AcpAgentCatalog', () => {
54
55
  expect(entries[1]?.statusMessage).toContain('Install the base agent first');
55
56
  expect(entries[2]?.statusMessage).toContain('Install its ACP adapter');
56
57
  });
58
+
59
+ it('parses both plain provider model ids and bulleted model lists', async () => {
60
+ const plain = new AcpAgentCatalog({
61
+ definitions: [definition({
62
+ id: 'plain',
63
+ modelListCommand: `${nodeCommand} -e "process.stdout.write('provider/first\\nprovider/second\\n')"`,
64
+ })],
65
+ });
66
+ const bulleted = new AcpAgentCatalog({
67
+ definitions: [definition({
68
+ id: 'bulleted',
69
+ modelListCommand: `${nodeCommand} -e "process.stdout.write('Default model: model-one\\n\\nAvailable models:\\n * model-one (default)\\n - model-two\\n')"`,
70
+ })],
71
+ });
72
+
73
+ await expect(plain.listCommandModels('plain')).resolves.toMatchObject([
74
+ { model: 'provider/first', isDefault: false },
75
+ { model: 'provider/second', isDefault: false },
76
+ ]);
77
+ await expect(bulleted.listCommandModels('bulleted')).resolves.toMatchObject([
78
+ { model: 'model-one', isDefault: true },
79
+ { model: 'model-two', isDefault: false },
80
+ ]);
81
+ });
57
82
  });
@@ -107,6 +107,7 @@ const builtinAcpAgents: AcpAgentDefinition[] = [
107
107
  serverCommand: 'opencode acp',
108
108
  serverProbeCommand: 'opencode acp --help',
109
109
  installCommand: null,
110
+ modelListCommand: 'opencode models',
110
111
  },
111
112
  {
112
113
  id: 'deepseek',
@@ -269,7 +270,8 @@ export class AcpAgentCatalog {
269
270
  const output = `${result.stdout}\n${result.stderr}`;
270
271
  const defaultModel = output.match(/^Default model:\s*(\S+)/m)?.[1] ?? null;
271
272
  const models = output.split(/\r?\n/).flatMap((line) => {
272
- const match = line.match(/^\s*[-*]\s+(\S+?)(?:\s+\(default\))?\s*$/);
273
+ const match = line.match(/^\s*[-*]\s+(\S+?)(?:\s+\(default\))?\s*$/)
274
+ ?? line.match(/^\s*([A-Za-z0-9._-]+\/[A-Za-z0-9._:/-]+)\s*$/);
273
275
  if (!match?.[1]) {
274
276
  return [];
275
277
  }
@@ -0,0 +1,139 @@
1
+ import type * as acp from '@agentclientprotocol/sdk';
2
+
3
+ import type { AgentProviderCapabilities } from '../../agent-runtime/src/types';
4
+ import type { AcpAgentOptionMetadataDto } from '../../shared/src/index';
5
+ import {
6
+ REMOTE_CODEX_HARNESS_EXTENSION_META_KEY,
7
+ type HarnessExtensionDescriptor,
8
+ } from './extensions';
9
+
10
+ export interface AcpNegotiatedCapabilitySnapshot {
11
+ protocolVersion: number;
12
+ agentInfo: {
13
+ name: string;
14
+ title: string | null;
15
+ version: string | null;
16
+ } | null;
17
+ agentCapabilities: acp.AgentCapabilities;
18
+ harnessExtensions: HarnessExtensionDescriptor[];
19
+ legacyExtensions: {
20
+ steering: { supported: boolean } | null;
21
+ goal: {
22
+ version: string | number | null;
23
+ controlMethod: string | null;
24
+ actions: string[];
25
+ } | null;
26
+ };
27
+ }
28
+
29
+ export interface AcpAgentCapabilitySnapshot {
30
+ provider: 'acp';
31
+ agentId: string;
32
+ availability: AcpAgentOptionMetadataDto['availability'];
33
+ negotiated: AcpNegotiatedCapabilitySnapshot | null;
34
+ effectiveCapabilities: AgentProviderCapabilities | null;
35
+ }
36
+
37
+ function isRecord(value: unknown): value is Record<string, unknown> {
38
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
39
+ }
40
+
41
+ function stringValue(value: unknown) {
42
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
43
+ }
44
+
45
+ function harnessExtensions(value: unknown): HarnessExtensionDescriptor[] {
46
+ if (!Array.isArray(value)) {
47
+ return [];
48
+ }
49
+ return value.flatMap((candidate) => {
50
+ if (!isRecord(candidate)) {
51
+ return [];
52
+ }
53
+ const id = stringValue(candidate.id);
54
+ const version = candidate.version;
55
+ const stability = candidate.stability;
56
+ const methods = Array.isArray(candidate.methods)
57
+ ? candidate.methods.filter((item): item is string => typeof item === 'string')
58
+ : [];
59
+ const events = Array.isArray(candidate.events)
60
+ ? candidate.events.filter((item): item is string => typeof item === 'string')
61
+ : [];
62
+ if (
63
+ !id ||
64
+ !Number.isInteger(version) ||
65
+ Number(version) < 1 ||
66
+ (stability !== 'experimental' && stability !== 'stable')
67
+ ) {
68
+ return [];
69
+ }
70
+ return [{
71
+ id,
72
+ version: Number(version),
73
+ stability,
74
+ methods,
75
+ events,
76
+ }];
77
+ });
78
+ }
79
+
80
+ function legacyExtensions(meta: Record<string, unknown>) {
81
+ const steering = isRecord(meta.steering)
82
+ ? { supported: meta.steering.supported === true }
83
+ : null;
84
+ const goal = isRecord(meta.goal)
85
+ ? {
86
+ version:
87
+ typeof meta.goal.version === 'string' || typeof meta.goal.version === 'number'
88
+ ? meta.goal.version
89
+ : null,
90
+ controlMethod: stringValue(meta.goal.controlMethod),
91
+ actions: Array.isArray(meta.goal.actions)
92
+ ? meta.goal.actions.filter((item): item is string => typeof item === 'string')
93
+ : [],
94
+ }
95
+ : null;
96
+ return { steering, goal };
97
+ }
98
+
99
+ export function snapshotAcpInitializeResponse(
100
+ response: acp.InitializeResponse | null,
101
+ ): AcpNegotiatedCapabilitySnapshot | null {
102
+ if (!response) {
103
+ return null;
104
+ }
105
+ const meta = isRecord(response._meta) ? response._meta : {};
106
+ return {
107
+ protocolVersion: response.protocolVersion,
108
+ agentInfo: response.agentInfo
109
+ ? {
110
+ name: response.agentInfo.name,
111
+ title: response.agentInfo.title ?? null,
112
+ version: response.agentInfo.version ?? null,
113
+ }
114
+ : null,
115
+ agentCapabilities: structuredClone(response.agentCapabilities ?? {}),
116
+ harnessExtensions: harnessExtensions(
117
+ meta[REMOTE_CODEX_HARNESS_EXTENSION_META_KEY],
118
+ ),
119
+ legacyExtensions: legacyExtensions(meta),
120
+ };
121
+ }
122
+
123
+ export function snapshotAcpAgentCapabilities(input: {
124
+ agentId: string;
125
+ availability: AcpAgentOptionMetadataDto['availability'];
126
+ negotiated?: AcpNegotiatedCapabilitySnapshot | null;
127
+ effectiveCapabilities: AgentProviderCapabilities;
128
+ }): AcpAgentCapabilitySnapshot {
129
+ const ready = input.availability === 'ready';
130
+ return {
131
+ provider: 'acp',
132
+ agentId: input.agentId,
133
+ availability: input.availability,
134
+ negotiated: ready ? input.negotiated ?? null : null,
135
+ effectiveCapabilities: ready
136
+ ? structuredClone(input.effectiveCapabilities)
137
+ : null,
138
+ };
139
+ }
@@ -0,0 +1,99 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { codexCapabilities } from '../../codex/src/runtimeAdapter';
4
+ import type { AgentProviderCapabilities } from '../../agent-runtime/src/types';
5
+ import { snapshotAcpAgentCapabilities } from './capabilities';
6
+ import {
7
+ acpCapabilities,
8
+ applyNegotiatedAcpCapabilities,
9
+ } from './runtimeAdapter';
10
+
11
+ interface CapabilityDifference {
12
+ capability: string;
13
+ nativeCodex: boolean;
14
+ codexAcp: boolean;
15
+ decision: 'approved-baseline-gap';
16
+ }
17
+
18
+ function capabilityFlags(
19
+ value: AgentProviderCapabilities,
20
+ prefix = '',
21
+ ): Map<string, boolean> {
22
+ const flags = new Map<string, boolean>();
23
+ for (const [key, child] of Object.entries(value)) {
24
+ const path = prefix ? `${prefix}.${key}` : key;
25
+ if (typeof child === 'boolean') {
26
+ flags.set(path, child);
27
+ continue;
28
+ }
29
+ for (const [nestedKey, nestedValue] of Object.entries(child)) {
30
+ if (typeof nestedValue === 'boolean') {
31
+ flags.set(`${path}.${nestedKey}`, nestedValue);
32
+ }
33
+ }
34
+ }
35
+ return flags;
36
+ }
37
+
38
+ function capabilityDifferences(): CapabilityDifference[] {
39
+ const nativeFlags = capabilityFlags(codexCapabilities);
40
+ const acpFlags = capabilityFlags(applyNegotiatedAcpCapabilities(
41
+ structuredClone(acpCapabilities),
42
+ {
43
+ loadSession: true,
44
+ promptCapabilities: { image: true, embeddedContext: true },
45
+ sessionCapabilities: {
46
+ list: {},
47
+ resume: {},
48
+ close: {},
49
+ delete: {},
50
+ },
51
+ },
52
+ ));
53
+ return [...nativeFlags.entries()].flatMap(([capability, nativeCodex]) => {
54
+ const codexAcp = acpFlags.get(capability);
55
+ return codexAcp === undefined || codexAcp === nativeCodex
56
+ ? []
57
+ : [{
58
+ capability,
59
+ nativeCodex,
60
+ codexAcp,
61
+ decision: 'approved-baseline-gap' as const,
62
+ }];
63
+ });
64
+ }
65
+
66
+ describe('native Codex and Codex ACP capability baseline', () => {
67
+ it('requires every current capability difference to remain explicit', () => {
68
+ expect(capabilityDifferences()).toEqual([
69
+ { capability: 'sessions.importLocal', nativeCodex: true, codexAcp: false, decision: 'approved-baseline-gap' },
70
+ { capability: 'turns.steer', nativeCodex: true, codexAcp: false, decision: 'approved-baseline-gap' },
71
+ { capability: 'turns.compact', nativeCodex: true, codexAcp: false, decision: 'approved-baseline-gap' },
72
+ { capability: 'branching.fork', nativeCodex: true, codexAcp: false, decision: 'approved-baseline-gap' },
73
+ { capability: 'branching.hardRollback', nativeCodex: true, codexAcp: false, decision: 'approved-baseline-gap' },
74
+ { capability: 'controls.sandboxMode', nativeCodex: false, codexAcp: true, decision: 'approved-baseline-gap' },
75
+ { capability: 'controls.performanceMode', nativeCodex: true, codexAcp: false, decision: 'approved-baseline-gap' },
76
+ { capability: 'controls.goals', nativeCodex: true, codexAcp: false, decision: 'approved-baseline-gap' },
77
+ { capability: 'management.models', nativeCodex: true, codexAcp: false, decision: 'approved-baseline-gap' },
78
+ { capability: 'management.mcpStatus', nativeCodex: true, codexAcp: false, decision: 'approved-baseline-gap' },
79
+ { capability: 'management.skills', nativeCodex: true, codexAcp: false, decision: 'approved-baseline-gap' },
80
+ { capability: 'management.hooks', nativeCodex: true, codexAcp: false, decision: 'approved-baseline-gap' },
81
+ { capability: 'management.hookTrust', nativeCodex: true, codexAcp: false, decision: 'approved-baseline-gap' },
82
+ { capability: 'management.hostConfigFiles', nativeCodex: true, codexAcp: false, decision: 'approved-baseline-gap' },
83
+ ]);
84
+ });
85
+
86
+ it('does not present unavailable ACP agents as effective capability owners', () => {
87
+ expect(snapshotAcpAgentCapabilities({
88
+ agentId: 'codex',
89
+ availability: 'adapter_missing',
90
+ effectiveCapabilities: acpCapabilities,
91
+ })).toEqual({
92
+ provider: 'acp',
93
+ agentId: 'codex',
94
+ availability: 'adapter_missing',
95
+ negotiated: null,
96
+ effectiveCapabilities: null,
97
+ });
98
+ });
99
+ });
@@ -1,6 +1,6 @@
1
1
  import path from 'node:path';
2
2
 
3
- import { afterEach, describe, expect, it } from 'vitest';
3
+ import { afterEach, describe, expect, it, vi } from 'vitest';
4
4
 
5
5
  import type {
6
6
  AgentProviderRequest,
@@ -16,13 +16,63 @@ const runtimes: AcpCatalogRuntimeAdapter[] = [];
16
16
 
17
17
  afterEach(async () => {
18
18
  await Promise.all(runtimes.splice(0).map((runtime) => runtime.stop()));
19
+ vi.unstubAllEnvs();
19
20
  });
20
21
 
21
22
  describe('AcpCatalogRuntimeAdapter', () => {
23
+ it('keeps the built-in ACP runtime selectable when every base agent is missing', async () => {
24
+ const runtime = new AcpCatalogRuntimeAdapter({
25
+ catalog: new AcpAgentCatalog({
26
+ definitions: [{
27
+ id: 'missing-agent',
28
+ displayName: 'Missing Agent',
29
+ description: 'Missing ACP fixture',
30
+ transport: 'native',
31
+ baseCommand: 'remote-codex-missing-acp-agent',
32
+ baseProbeCommand: 'remote-codex-missing-acp-agent --version',
33
+ serverCommand: 'remote-codex-missing-acp-agent acp',
34
+ serverProbeCommand: 'remote-codex-missing-acp-agent acp --help',
35
+ installCommand: null,
36
+ }],
37
+ }),
38
+ });
39
+ runtimes.push(runtime);
40
+
41
+ const agents = await runtime.listModels();
42
+
43
+ expect(agents).toMatchObject([{
44
+ id: 'missing-agent',
45
+ acpAgent: { availability: 'base_missing' },
46
+ }]);
47
+ expect(runtime.installation).toMatchObject({
48
+ installed: true,
49
+ installedVersion: 'Built in · 0 ACP agents ready',
50
+ lastError: 'No supported base agent was detected.',
51
+ });
52
+ expect(runtime.getStatus()).toMatchObject({
53
+ state: 'degraded',
54
+ lastError: 'No supported base agent was detected.',
55
+ });
56
+ expect(await runtime.getAgentCapabilitySnapshot('missing-agent')).toEqual({
57
+ provider: 'acp',
58
+ agentId: 'missing-agent',
59
+ availability: 'base_missing',
60
+ negotiated: null,
61
+ effectiveCapabilities: null,
62
+ });
63
+ await expect(
64
+ runtime.listModelsForAgent('missing-agent', process.cwd()),
65
+ ).rejects.toThrow(/Install the base agent first/);
66
+ expect(runtime.getStatus().operationalMetrics).toEqual({
67
+ sessionStartFailures: 0,
68
+ resumeFailures: 0,
69
+ capabilityProbeFailures: 1,
70
+ });
71
+ });
72
+
22
73
  it('selects a concrete agent and scopes its provider session id', async () => {
23
- const fixture = path.resolve(
24
- 'node_modules/@agentclientprotocol/sdk/dist/examples/agent.js',
25
- );
74
+ const fixture = path.resolve('src/test/fixtures/fake-acp-agent.mjs');
75
+ vi.stubEnv('REMOTE_CODEX_FAKE_ACP_FORK', '1');
26
76
  const runtime = new AcpCatalogRuntimeAdapter({
27
77
  catalog: new AcpAgentCatalog({
28
78
  definitions: [{
@@ -68,7 +118,21 @@ describe('AcpCatalogRuntimeAdapter', () => {
68
118
  agentId: 'fixture-agent',
69
119
  });
70
120
  expect(session.agentId).toBe('fixture-agent');
71
- expect(session.model).toBe('default');
121
+ expect(session.model).toBe('fixture-model');
122
+ expect(await runtime.getAgentCapabilitySnapshot('fixture-agent')).toMatchObject({
123
+ provider: 'acp',
124
+ agentId: 'fixture-agent',
125
+ availability: 'ready',
126
+ effectiveCapabilities: {
127
+ sessions: {
128
+ list: true,
129
+ load: true,
130
+ resume: true,
131
+ close: true,
132
+ delete: true,
133
+ },
134
+ },
135
+ });
72
136
 
73
137
  const completed = new Promise<Extract<AgentRuntimeEvent, { type: 'turn.completed' }>>(
74
138
  (resolve, reject) => {
@@ -92,8 +156,138 @@ describe('AcpCatalogRuntimeAdapter', () => {
92
156
  expect(event.turn.items.map((item) => item.kind)).toEqual(expect.arrayContaining([
93
157
  'userMessage',
94
158
  'agentMessage',
95
- 'fileRead',
96
159
  'fileChange',
160
+ 'commandExecution',
97
161
  ]));
162
+ const forked = await runtime.forkSession({
163
+ providerSessionId: session.providerSessionId,
164
+ });
165
+ expect(acpSessionId.decode(forked.providerSessionId)).toMatchObject({
166
+ agentId: 'fixture-agent',
167
+ });
168
+ expect(forked.agentId).toBe('fixture-agent');
169
+ }, 15_000);
170
+
171
+ it('delegates Codex child controls while preserving scoped goal identity', async () => {
172
+ const fixture = path.resolve('src/test/fixtures/fake-acp-agent.mjs');
173
+ vi.stubEnv('REMOTE_CODEX_FAKE_ACP_AGENT_KIND', 'codex');
174
+ vi.stubEnv('REMOTE_CODEX_FAKE_ACP_SKIP_PERMISSION', '1');
175
+ const runtime = new AcpCatalogRuntimeAdapter({
176
+ catalog: new AcpAgentCatalog({
177
+ definitions: [{
178
+ id: 'codex',
179
+ displayName: 'Codex fixture',
180
+ description: 'Codex ACP fixture',
181
+ transport: 'adapter',
182
+ baseCommand: process.execPath,
183
+ baseProbeCommand: `"${process.execPath}" --version`,
184
+ serverCommand: `"${process.execPath}" "${fixture}"`,
185
+ serverProbeCommand: `"${process.execPath}" --version`,
186
+ installCommand: null,
187
+ }],
188
+ }),
189
+ startupTimeoutMs: 5_000,
190
+ });
191
+ runtimes.push(runtime);
192
+ await runtime.start();
193
+ await expect(runtime.listImportSessions('codex')).resolves.toEqual([]);
194
+ expect(runtime.managementSchema.toolboxItems.map((item) => item.command)).toEqual([
195
+ '/fast',
196
+ '/compact',
197
+ '/goal',
198
+ '/fork',
199
+ ]);
200
+ expect(await runtime.listModelsForAgent('codex', process.cwd())).toEqual(
201
+ expect.arrayContaining([
202
+ expect.objectContaining({ supportsPerformanceMode: true }),
203
+ ]),
204
+ );
205
+ expect(runtime.capabilities).toMatchObject({
206
+ turns: { steer: true, compact: true },
207
+ controls: { goals: true },
208
+ });
209
+ expect(runtime.getScopedCapabilities({ agentId: 'codex' })).toMatchObject({
210
+ turns: { steer: true, compact: true },
211
+ controls: { goals: true },
212
+ });
213
+ expect(runtime.getScopedCapabilities({ agentId: 'unstarted-agent' })).toMatchObject({
214
+ turns: { steer: false, compact: false },
215
+ controls: { goals: false, performanceMode: false },
216
+ });
217
+ const session = await runtime.startSession({
218
+ cwd: process.cwd(),
219
+ agentId: 'codex',
220
+ model: 'fixture-model',
221
+ approvalMode: 'yolo',
222
+ });
223
+ expect(runtime.capabilities).toMatchObject({
224
+ turns: { steer: true, compact: true },
225
+ controls: { goals: true },
226
+ });
227
+ const goal = await runtime.setGoal({
228
+ providerSessionId: session.providerSessionId,
229
+ objective: 'Scoped catalog goal',
230
+ });
231
+ expect(goal).toMatchObject({
232
+ providerSessionId: session.providerSessionId,
233
+ objective: 'Scoped catalog goal',
234
+ });
235
+ await expect(runtime.compactSession(session.providerSessionId)).resolves.toBeUndefined();
236
+ expect(await runtime.clearGoal(session.providerSessionId)).toBe(true);
237
+
238
+ await runtime.stop();
239
+ expect(runtime.capabilities).toMatchObject({
240
+ turns: { steer: false, compact: false },
241
+ controls: { goals: false, performanceMode: false },
242
+ });
243
+ }, 15_000);
244
+
245
+ it('keeps request and session scopes distinct for guarded permissions', async () => {
246
+ const fixture = path.resolve('src/test/fixtures/fake-acp-agent.mjs');
247
+ const runtime = new AcpCatalogRuntimeAdapter({
248
+ catalog: new AcpAgentCatalog({
249
+ definitions: [{
250
+ id: 'guarded-agent',
251
+ displayName: 'Guarded fixture',
252
+ description: 'Guarded ACP fixture',
253
+ transport: 'native',
254
+ baseCommand: process.execPath,
255
+ baseProbeCommand: `"${process.execPath}" --version`,
256
+ serverCommand: `"${process.execPath}" "${fixture}"`,
257
+ serverProbeCommand: `"${process.execPath}" --version`,
258
+ installCommand: null,
259
+ }],
260
+ }),
261
+ startupTimeoutMs: 5_000,
262
+ });
263
+ runtimes.push(runtime);
264
+ const permission = new Promise<void>((resolve, reject) => {
265
+ const timer = setTimeout(() => reject(new Error('Guarded permission timed out.')), 10_000);
266
+ runtime.on('provider-request', (request: AgentProviderRequest) => {
267
+ const mapping = runtime.mapProviderRequest(request, { approvalMode: 'guarded' });
268
+ if (!mapping?.pendingRequest) return;
269
+ clearTimeout(timer);
270
+ expect(acpSessionId.decode(mapping.providerSessionId)).toMatchObject({
271
+ agentId: 'guarded-agent',
272
+ });
273
+ const result = runtime.buildProviderRequestResponse(
274
+ mapping.pendingRequest,
275
+ { answers: { permission: { answers: ['Allow once'] } } },
276
+ );
277
+ runtime.respondToProviderRequest(mapping.providerRequestId, result);
278
+ resolve();
279
+ });
280
+ });
281
+ const session = await runtime.startSession({
282
+ cwd: process.cwd(),
283
+ agentId: 'guarded-agent',
284
+ model: 'fixture-model',
285
+ approvalMode: 'guarded',
286
+ });
287
+ await runtime.startTurn({
288
+ providerSessionId: session.providerSessionId,
289
+ prompt: 'Request guarded fixture permission.',
290
+ });
291
+ await permission;
98
292
  }, 15_000);
99
293
  });