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.
- package/README.md +38 -5
- package/apps/supervisor-api/dist/index.js +2137 -422
- package/apps/supervisor-web/dist/assets/index-7LiZQ3aJ.js +22 -0
- package/apps/supervisor-web/dist/assets/{index-Dy8PgXgw.css → index-Bg8FdhS1.css} +1 -1
- package/apps/supervisor-web/dist/assets/{thread-ui-gcslNXur.js → thread-ui-BbtcUwps.js} +25 -19
- package/apps/supervisor-web/dist/index.html +3 -3
- package/bin/remote-codex.mjs +4 -2
- package/package.json +7 -1
- package/packages/acp/src/agent-catalog.test.ts +25 -0
- package/packages/acp/src/agent-catalog.ts +3 -1
- package/packages/acp/src/capabilities.ts +139 -0
- package/packages/acp/src/capability-parity.test.ts +99 -0
- package/packages/acp/src/catalog-runtime.test.ts +200 -6
- package/packages/acp/src/catalog-runtime.ts +239 -31
- package/packages/acp/src/extension-registry.test.ts +198 -0
- package/packages/acp/src/extension-registry.ts +285 -0
- package/packages/acp/src/extensions.test.ts +45 -0
- package/packages/acp/src/extensions.ts +103 -0
- package/packages/acp/src/harness-contract.test.ts +81 -0
- package/packages/acp/src/harness-contract.ts +62 -0
- package/packages/acp/src/index.ts +7 -0
- package/packages/acp/src/item-mapper.ts +28 -2
- package/packages/acp/src/prompt-content.test.ts +90 -0
- package/packages/acp/src/prompt-content.ts +99 -0
- package/packages/acp/src/runtimeAdapter.test.ts +458 -5
- package/packages/acp/src/runtimeAdapter.ts +791 -60
- package/packages/acp/src/session-hydrator.test.ts +135 -0
- package/packages/acp/src/session-hydrator.ts +147 -0
- package/packages/acp/src/terminal-service.test.ts +21 -2
- package/packages/acp/src/terminal-service.ts +23 -3
- package/packages/acp/src/test/fixtures/fake-acp-agent.mjs +514 -0
- package/packages/acp/src/workspace-boundary.test.ts +32 -0
- package/packages/acp/src/workspace-boundary.ts +47 -0
- package/packages/agent-runtime/src/types.ts +61 -1
- package/packages/db/src/repositories.ts +3 -2
- package/packages/shared/src/index.ts +32 -1
- package/scripts/service-manager.mjs +13 -0
- package/apps/supervisor-web/dist/assets/index-DZI1aSXo.js +0 -22
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
6
|
+
|
|
7
|
+
import { buildAcpPromptContent } from './prompt-content';
|
|
8
|
+
|
|
9
|
+
const directories: string[] = [];
|
|
10
|
+
|
|
11
|
+
afterEach(async () => {
|
|
12
|
+
await Promise.all(directories.splice(0).map((directory) =>
|
|
13
|
+
fs.rm(directory, { recursive: true, force: true })));
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe('ACP prompt content', () => {
|
|
17
|
+
it('maps workspace photos and files into typed ACP blocks', async () => {
|
|
18
|
+
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'remote-codex-acp-prompt-'));
|
|
19
|
+
directories.push(workspace);
|
|
20
|
+
await fs.writeFile(path.join(workspace, 'pixel.png'), Buffer.from([0x89, 0x50, 0x4e, 0x47]));
|
|
21
|
+
await fs.writeFile(path.join(workspace, 'notes.txt'), 'fixture notes');
|
|
22
|
+
|
|
23
|
+
const blocks = await buildAcpPromptContent({
|
|
24
|
+
prompt: 'Inspect [PHOTO ./pixel.png] and [FILE ./notes.txt] now.',
|
|
25
|
+
workspacePath: workspace,
|
|
26
|
+
promptCapabilities: { image: true },
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
expect(blocks).toMatchObject([
|
|
30
|
+
{ type: 'text', text: 'Inspect ' },
|
|
31
|
+
{ type: 'image', mimeType: 'image/png', data: 'iVBORw==' },
|
|
32
|
+
{ type: 'text', text: ' and ' },
|
|
33
|
+
{ type: 'resource_link', name: 'notes.txt' },
|
|
34
|
+
{ type: 'text', text: ' now.' },
|
|
35
|
+
]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('rejects unsupported images and paths outside the workspace', async () => {
|
|
39
|
+
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'remote-codex-acp-prompt-'));
|
|
40
|
+
directories.push(workspace);
|
|
41
|
+
await fs.writeFile(path.join(workspace, 'pixel.png'), Buffer.from([0x89, 0x50, 0x4e, 0x47]));
|
|
42
|
+
|
|
43
|
+
await expect(buildAcpPromptContent({
|
|
44
|
+
prompt: '[PHOTO ./pixel.png]',
|
|
45
|
+
workspacePath: workspace,
|
|
46
|
+
promptCapabilities: {},
|
|
47
|
+
})).rejects.toThrow(/does not support image prompts/);
|
|
48
|
+
await expect(buildAcpPromptContent({
|
|
49
|
+
prompt: '[FILE ../outside.txt]',
|
|
50
|
+
workspacePath: workspace,
|
|
51
|
+
promptCapabilities: {},
|
|
52
|
+
})).rejects.toThrow(/must stay inside the session workspace/);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('validates audio, embedded resources, and baseline resource links', async () => {
|
|
56
|
+
const blocks = await buildAcpPromptContent({
|
|
57
|
+
prompt: '',
|
|
58
|
+
workspacePath: process.cwd(),
|
|
59
|
+
promptCapabilities: { audio: true, embeddedContext: true },
|
|
60
|
+
content: [
|
|
61
|
+
{ type: 'audio', data: 'YXVkaW8=', mimeType: 'audio/wav' },
|
|
62
|
+
{
|
|
63
|
+
type: 'resource',
|
|
64
|
+
resource: { uri: 'file:///workspace/context.txt', text: 'Context' },
|
|
65
|
+
},
|
|
66
|
+
{ type: 'resource_link', name: 'Docs', uri: 'https://example.test/docs' },
|
|
67
|
+
],
|
|
68
|
+
});
|
|
69
|
+
expect(blocks).toMatchObject([
|
|
70
|
+
{ type: 'audio', mimeType: 'audio/wav' },
|
|
71
|
+
{ type: 'resource', resource: { text: 'Context' } },
|
|
72
|
+
{ type: 'resource_link', name: 'Docs' },
|
|
73
|
+
]);
|
|
74
|
+
await expect(buildAcpPromptContent({
|
|
75
|
+
prompt: '',
|
|
76
|
+
workspacePath: process.cwd(),
|
|
77
|
+
promptCapabilities: {},
|
|
78
|
+
content: [{ type: 'audio', data: 'YXVkaW8=', mimeType: 'audio/wav' }],
|
|
79
|
+
})).rejects.toThrow(/does not support audio prompts/);
|
|
80
|
+
await expect(buildAcpPromptContent({
|
|
81
|
+
prompt: '',
|
|
82
|
+
workspacePath: process.cwd(),
|
|
83
|
+
promptCapabilities: {},
|
|
84
|
+
content: [{
|
|
85
|
+
type: 'resource',
|
|
86
|
+
resource: { uri: 'file:///workspace/context.txt', text: 'Context' },
|
|
87
|
+
}],
|
|
88
|
+
})).rejects.toThrow(/does not support embedded context/);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
|
|
5
|
+
import type * as acp from '@agentclientprotocol/sdk';
|
|
6
|
+
import type { AgentPromptContentBlock } from '../../agent-runtime/src/types';
|
|
7
|
+
import { resolveAcpWorkspacePath } from './workspace-boundary';
|
|
8
|
+
|
|
9
|
+
const attachmentTokenPattern = /\[(PHOTO|FILE)\s+([^\]]+)\]/g;
|
|
10
|
+
const maxEmbeddedImageBytes = 20 * 1024 * 1024;
|
|
11
|
+
|
|
12
|
+
function imageMimeType(filePath: string) {
|
|
13
|
+
switch (path.extname(filePath).toLowerCase()) {
|
|
14
|
+
case '.jpg':
|
|
15
|
+
case '.jpeg':
|
|
16
|
+
return 'image/jpeg';
|
|
17
|
+
case '.gif':
|
|
18
|
+
return 'image/gif';
|
|
19
|
+
case '.webp':
|
|
20
|
+
return 'image/webp';
|
|
21
|
+
case '.png':
|
|
22
|
+
default:
|
|
23
|
+
return 'image/png';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function buildAcpPromptContent(input: {
|
|
28
|
+
prompt: string;
|
|
29
|
+
workspacePath: string;
|
|
30
|
+
promptCapabilities: acp.PromptCapabilities | null | undefined;
|
|
31
|
+
content?: AgentPromptContentBlock[];
|
|
32
|
+
}): Promise<acp.ContentBlock[]> {
|
|
33
|
+
if (input.content) {
|
|
34
|
+
return input.content.map((block) => {
|
|
35
|
+
if (block.type === 'image' && input.promptCapabilities?.image !== true) {
|
|
36
|
+
throw new Error('The selected ACP agent does not support image prompts.');
|
|
37
|
+
}
|
|
38
|
+
if (block.type === 'audio' && input.promptCapabilities?.audio !== true) {
|
|
39
|
+
throw new Error('The selected ACP agent does not support audio prompts.');
|
|
40
|
+
}
|
|
41
|
+
if (block.type === 'resource' && input.promptCapabilities?.embeddedContext !== true) {
|
|
42
|
+
throw new Error('The selected ACP agent does not support embedded context.');
|
|
43
|
+
}
|
|
44
|
+
return structuredClone(block) as acp.ContentBlock;
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
const matches = [...input.prompt.matchAll(attachmentTokenPattern)];
|
|
48
|
+
if (matches.length === 0) {
|
|
49
|
+
return [{ type: 'text', text: input.prompt }];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const blocks: acp.ContentBlock[] = [];
|
|
53
|
+
let cursor = 0;
|
|
54
|
+
for (const match of matches) {
|
|
55
|
+
const start = match.index ?? 0;
|
|
56
|
+
const preceding = input.prompt.slice(cursor, start);
|
|
57
|
+
if (preceding) {
|
|
58
|
+
blocks.push({ type: 'text', text: preceding });
|
|
59
|
+
}
|
|
60
|
+
const kind = match[1];
|
|
61
|
+
const requestedPath = match[2]?.trim() ?? '';
|
|
62
|
+
const requestedAssetPath = path.isAbsolute(requestedPath)
|
|
63
|
+
? requestedPath
|
|
64
|
+
: path.resolve(input.workspacePath, requestedPath);
|
|
65
|
+
const assetPath = await resolveAcpWorkspacePath(
|
|
66
|
+
input.workspacePath,
|
|
67
|
+
requestedAssetPath,
|
|
68
|
+
);
|
|
69
|
+
const uri = pathToFileURL(assetPath).toString();
|
|
70
|
+
if (kind === 'PHOTO') {
|
|
71
|
+
if (input.promptCapabilities?.image !== true) {
|
|
72
|
+
throw new Error('The selected ACP agent does not support image prompts.');
|
|
73
|
+
}
|
|
74
|
+
const stat = await fs.stat(assetPath);
|
|
75
|
+
if (!stat.isFile() || stat.size > maxEmbeddedImageBytes) {
|
|
76
|
+
throw new Error('ACP image attachment is missing or exceeds 20 MiB.');
|
|
77
|
+
}
|
|
78
|
+
blocks.push({
|
|
79
|
+
type: 'image',
|
|
80
|
+
data: (await fs.readFile(assetPath)).toString('base64'),
|
|
81
|
+
mimeType: imageMimeType(assetPath),
|
|
82
|
+
uri,
|
|
83
|
+
});
|
|
84
|
+
} else {
|
|
85
|
+
await fs.access(assetPath);
|
|
86
|
+
blocks.push({
|
|
87
|
+
type: 'resource_link',
|
|
88
|
+
name: path.basename(assetPath),
|
|
89
|
+
uri,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
cursor = start + match[0].length;
|
|
93
|
+
}
|
|
94
|
+
const trailing = input.prompt.slice(cursor);
|
|
95
|
+
if (trailing) {
|
|
96
|
+
blocks.push({ type: 'text', text: trailing });
|
|
97
|
+
}
|
|
98
|
+
return blocks;
|
|
99
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import os from 'node:os';
|
|
1
3
|
import path from 'node:path';
|
|
2
4
|
|
|
3
|
-
import { afterEach, describe, expect, it } from 'vitest';
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
4
6
|
|
|
5
7
|
import type {
|
|
6
8
|
AgentProviderRequest,
|
|
@@ -9,6 +11,7 @@ import type {
|
|
|
9
11
|
import { AcpRuntimeAdapter } from './runtimeAdapter';
|
|
10
12
|
|
|
11
13
|
const adapters: AcpRuntimeAdapter[] = [];
|
|
14
|
+
const fixture = path.resolve('src/test/fixtures/fake-acp-agent.mjs');
|
|
12
15
|
|
|
13
16
|
afterEach(async () => {
|
|
14
17
|
await Promise.all(adapters.splice(0).map((adapter) => adapter.stop()));
|
|
@@ -16,9 +19,6 @@ afterEach(async () => {
|
|
|
16
19
|
|
|
17
20
|
describe('AcpRuntimeAdapter', () => {
|
|
18
21
|
it('runs a stdio ACP turn and resolves protocol permissions', async () => {
|
|
19
|
-
const fixture = path.resolve(
|
|
20
|
-
'node_modules/@agentclientprotocol/sdk/dist/examples/agent.js',
|
|
21
|
-
);
|
|
22
22
|
const adapter = new AcpRuntimeAdapter({
|
|
23
23
|
command: `"${process.execPath}" "${fixture}"`,
|
|
24
24
|
startupTimeoutMs: 5_000,
|
|
@@ -70,10 +70,463 @@ describe('AcpRuntimeAdapter', () => {
|
|
|
70
70
|
expect(event.turn.items.map((item) => item.kind)).toEqual(expect.arrayContaining([
|
|
71
71
|
'userMessage',
|
|
72
72
|
'agentMessage',
|
|
73
|
-
'
|
|
73
|
+
'reasoning',
|
|
74
74
|
'fileChange',
|
|
75
|
+
'commandExecution',
|
|
76
|
+
'plan',
|
|
75
77
|
]));
|
|
76
78
|
expect(events.some((candidate) => candidate.type === 'output.delta')).toBe(true);
|
|
79
|
+
expect(events.some((candidate) => candidate.type === 'usage.updated')).toBe(true);
|
|
77
80
|
expect(await adapter.listLoadedSessions()).toContain(started.providerSessionId);
|
|
78
81
|
}, 15_000);
|
|
82
|
+
|
|
83
|
+
it('lists and resumes fixture sessions across ACP process restarts', async () => {
|
|
84
|
+
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'remote-codex-fake-acp-'));
|
|
85
|
+
const statePath = path.join(directory, 'state.json');
|
|
86
|
+
const command = `"${process.execPath}" "${fixture}"`;
|
|
87
|
+
const first = new AcpRuntimeAdapter({
|
|
88
|
+
command,
|
|
89
|
+
env: { REMOTE_CODEX_FAKE_ACP_STATE: statePath },
|
|
90
|
+
startupTimeoutMs: 5_000,
|
|
91
|
+
});
|
|
92
|
+
adapters.push(first);
|
|
93
|
+
first.on('provider-request', (request) => {
|
|
94
|
+
const mapping = first.mapProviderRequest(request as AgentProviderRequest, {
|
|
95
|
+
approvalMode: 'yolo',
|
|
96
|
+
});
|
|
97
|
+
if (mapping?.autoApprovedResult) {
|
|
98
|
+
first.respondToProviderRequest(mapping.providerRequestId, mapping.autoApprovedResult);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
await first.start();
|
|
102
|
+
const started = await first.startSession({
|
|
103
|
+
cwd: process.cwd(),
|
|
104
|
+
model: 'fixture-fast',
|
|
105
|
+
reasoningEffort: 'high',
|
|
106
|
+
approvalMode: 'yolo',
|
|
107
|
+
sandboxMode: 'workspace-write',
|
|
108
|
+
});
|
|
109
|
+
await new Promise<void>((resolve, reject) => {
|
|
110
|
+
const timer = setTimeout(() => reject(new Error('Fixture turn timed out.')), 10_000);
|
|
111
|
+
first.on('event', (event: AgentRuntimeEvent) => {
|
|
112
|
+
if (event.type === 'turn.completed') {
|
|
113
|
+
clearTimeout(timer);
|
|
114
|
+
resolve();
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
void first.startTurn({
|
|
118
|
+
providerSessionId: started.providerSessionId,
|
|
119
|
+
prompt: 'Persist this fixture turn.',
|
|
120
|
+
}).catch(reject);
|
|
121
|
+
});
|
|
122
|
+
await first.stop();
|
|
123
|
+
adapters.splice(adapters.indexOf(first), 1);
|
|
124
|
+
|
|
125
|
+
const second = new AcpRuntimeAdapter({
|
|
126
|
+
command,
|
|
127
|
+
env: { REMOTE_CODEX_FAKE_ACP_STATE: statePath },
|
|
128
|
+
startupTimeoutMs: 5_000,
|
|
129
|
+
});
|
|
130
|
+
adapters.push(second);
|
|
131
|
+
const replayEvents: AgentRuntimeEvent[] = [];
|
|
132
|
+
second.on('event', (event) => replayEvents.push(event as AgentRuntimeEvent));
|
|
133
|
+
await second.start();
|
|
134
|
+
expect(second.getProtocolSnapshot()).toMatchObject({
|
|
135
|
+
protocolVersion: 1,
|
|
136
|
+
harnessExtensions: [{
|
|
137
|
+
id: 'fixture.session',
|
|
138
|
+
version: 1,
|
|
139
|
+
methods: ['compact'],
|
|
140
|
+
}],
|
|
141
|
+
legacyExtensions: {
|
|
142
|
+
steering: { supported: true },
|
|
143
|
+
goal: { controlMethod: 'fixture/goal/control' },
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
expect(await second.listSessions()).toMatchObject([{
|
|
147
|
+
providerSessionId: started.providerSessionId,
|
|
148
|
+
cwd: process.cwd(),
|
|
149
|
+
title: 'Fixture session',
|
|
150
|
+
}]);
|
|
151
|
+
const imported = await second.readSession(started.providerSessionId);
|
|
152
|
+
expect(imported.turns).toHaveLength(1);
|
|
153
|
+
expect(imported.turns[0]?.items).toEqual(expect.arrayContaining([
|
|
154
|
+
expect.objectContaining({ kind: 'userMessage', text: 'Persist this fixture turn.' }),
|
|
155
|
+
expect.objectContaining({ kind: 'agentMessage' }),
|
|
156
|
+
]));
|
|
157
|
+
const resumed = await second.resumeSession({
|
|
158
|
+
providerSessionId: started.providerSessionId,
|
|
159
|
+
model: 'fixture-fast',
|
|
160
|
+
});
|
|
161
|
+
await expect(second.resumeSession({
|
|
162
|
+
providerSessionId: started.providerSessionId,
|
|
163
|
+
model: 'missing-fixture-model',
|
|
164
|
+
})).rejects.toThrow(/unknown model option/);
|
|
165
|
+
expect((await second.resumeSession({
|
|
166
|
+
providerSessionId: started.providerSessionId,
|
|
167
|
+
})).model).toBe('fixture-fast');
|
|
168
|
+
expect(resumed.session).toMatchObject({
|
|
169
|
+
providerSessionId: started.providerSessionId,
|
|
170
|
+
status: 'idle',
|
|
171
|
+
turns: [{
|
|
172
|
+
status: 'completed',
|
|
173
|
+
items: [
|
|
174
|
+
{ kind: 'userMessage', text: 'Persist this fixture turn.' },
|
|
175
|
+
{ kind: 'reasoning' },
|
|
176
|
+
{ kind: 'commandExecution', status: 'failed' },
|
|
177
|
+
{ kind: 'plan' },
|
|
178
|
+
{ kind: 'agentMessage', text: 'FAKE_ACP_PARTIAL_1' },
|
|
179
|
+
],
|
|
180
|
+
}],
|
|
181
|
+
historyCoverage: {
|
|
182
|
+
source: 'providerReplay',
|
|
183
|
+
completeness: 'unknown',
|
|
184
|
+
replayedTurnCount: 1,
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
const hydratedTurnCount = resumed.session.turns.length;
|
|
188
|
+
expect(replayEvents).toEqual([]);
|
|
189
|
+
const nextCompleted = new Promise<void>((resolve, reject) => {
|
|
190
|
+
const timer = setTimeout(() => reject(new Error('Second fixture turn timed out.')), 10_000);
|
|
191
|
+
second.on('event', (event: AgentRuntimeEvent) => {
|
|
192
|
+
if (event.type === 'turn.completed') {
|
|
193
|
+
clearTimeout(timer);
|
|
194
|
+
resolve();
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
second.on('provider-request', (request) => {
|
|
199
|
+
const mapping = second.mapProviderRequest(request as AgentProviderRequest, {
|
|
200
|
+
approvalMode: 'yolo',
|
|
201
|
+
});
|
|
202
|
+
if (mapping?.autoApprovedResult) {
|
|
203
|
+
second.respondToProviderRequest(mapping.providerRequestId, mapping.autoApprovedResult);
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
await second.startTurn({
|
|
207
|
+
providerSessionId: started.providerSessionId,
|
|
208
|
+
prompt: 'Run another fixture turn.',
|
|
209
|
+
});
|
|
210
|
+
await nextCompleted;
|
|
211
|
+
expect(resumed.session.turns).toHaveLength(hydratedTurnCount);
|
|
212
|
+
expect(await second.listLoadedSessions()).toContain(started.providerSessionId);
|
|
213
|
+
await second.deleteSession(started.providerSessionId);
|
|
214
|
+
expect(await second.listSessions()).toEqual([]);
|
|
215
|
+
}, 20_000);
|
|
216
|
+
|
|
217
|
+
it('settles active turns and pending permissions when the runtime stops', async () => {
|
|
218
|
+
const adapter = new AcpRuntimeAdapter({
|
|
219
|
+
command: `"${process.execPath}" "${fixture}"`,
|
|
220
|
+
startupTimeoutMs: 5_000,
|
|
221
|
+
});
|
|
222
|
+
adapters.push(adapter);
|
|
223
|
+
const events: AgentRuntimeEvent[] = [];
|
|
224
|
+
const permissionRequested = new Promise<void>((resolve) => {
|
|
225
|
+
adapter.once('provider-request', () => resolve());
|
|
226
|
+
});
|
|
227
|
+
adapter.on('event', (event) => events.push(event as AgentRuntimeEvent));
|
|
228
|
+
await adapter.start();
|
|
229
|
+
const session = await adapter.startSession({
|
|
230
|
+
cwd: process.cwd(),
|
|
231
|
+
model: 'fixture-model',
|
|
232
|
+
approvalMode: 'guarded',
|
|
233
|
+
});
|
|
234
|
+
await adapter.startTurn({
|
|
235
|
+
providerSessionId: session.providerSessionId,
|
|
236
|
+
prompt: 'Wait for guarded permission.',
|
|
237
|
+
});
|
|
238
|
+
await permissionRequested;
|
|
239
|
+
await adapter.stop();
|
|
240
|
+
|
|
241
|
+
expect(events.find((event) => event.type === 'turn.completed')).toMatchObject({
|
|
242
|
+
type: 'turn.completed',
|
|
243
|
+
turn: {
|
|
244
|
+
status: 'interrupted',
|
|
245
|
+
error: { message: 'ACP runtime stopped.' },
|
|
246
|
+
},
|
|
247
|
+
});
|
|
248
|
+
}, 15_000);
|
|
249
|
+
|
|
250
|
+
it('shares negotiated steering, goal, and session fork across non-Codex ACP agents', async () => {
|
|
251
|
+
const adapter = new AcpRuntimeAdapter({
|
|
252
|
+
command: `"${process.execPath}" "${fixture}"`,
|
|
253
|
+
env: {
|
|
254
|
+
REMOTE_CODEX_FAKE_ACP_FORK: '1',
|
|
255
|
+
REMOTE_CODEX_FAKE_ACP_SKIP_PERMISSION: '1',
|
|
256
|
+
},
|
|
257
|
+
startupTimeoutMs: 5_000,
|
|
258
|
+
});
|
|
259
|
+
adapters.push(adapter);
|
|
260
|
+
await adapter.start();
|
|
261
|
+
expect(adapter.capabilities).toMatchObject({
|
|
262
|
+
turns: { steer: true, compact: false },
|
|
263
|
+
branching: { fork: true, hardRollback: false },
|
|
264
|
+
controls: { goals: true },
|
|
265
|
+
});
|
|
266
|
+
const session = await adapter.startSession({
|
|
267
|
+
cwd: process.cwd(),
|
|
268
|
+
model: 'fixture-model',
|
|
269
|
+
approvalMode: 'yolo',
|
|
270
|
+
});
|
|
271
|
+
const completed = new Promise<void>((resolve, reject) => {
|
|
272
|
+
const timer = setTimeout(() => reject(new Error('Portable ACP turn timed out.')), 10_000);
|
|
273
|
+
adapter.on('event', (event: AgentRuntimeEvent) => {
|
|
274
|
+
if (event.type === 'turn.completed') {
|
|
275
|
+
clearTimeout(timer);
|
|
276
|
+
resolve();
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
await adapter.startTurn({
|
|
281
|
+
providerSessionId: session.providerSessionId,
|
|
282
|
+
prompt: 'Preserve this portable context.',
|
|
283
|
+
});
|
|
284
|
+
await completed;
|
|
285
|
+
|
|
286
|
+
const forked = await adapter.forkSession({
|
|
287
|
+
providerSessionId: session.providerSessionId,
|
|
288
|
+
});
|
|
289
|
+
expect(forked.providerSessionId).not.toBe(session.providerSessionId);
|
|
290
|
+
expect(forked.turns).toHaveLength(1);
|
|
291
|
+
await expect(adapter.setGoal({
|
|
292
|
+
providerSessionId: forked.providerSessionId,
|
|
293
|
+
objective: 'Portable goal',
|
|
294
|
+
})).resolves.toMatchObject({ objective: 'Portable goal' });
|
|
295
|
+
await expect(adapter.clearGoal(forked.providerSessionId)).resolves.toBe(true);
|
|
296
|
+
}, 15_000);
|
|
297
|
+
|
|
298
|
+
it('derives lifecycle capabilities from the negotiated child agent', async () => {
|
|
299
|
+
const adapter = new AcpRuntimeAdapter({
|
|
300
|
+
command: `"${process.execPath}" "${fixture}"`,
|
|
301
|
+
env: { REMOTE_CODEX_FAKE_ACP_CAPABILITY_PROFILE: 'minimal' },
|
|
302
|
+
startupTimeoutMs: 5_000,
|
|
303
|
+
});
|
|
304
|
+
adapters.push(adapter);
|
|
305
|
+
await adapter.start();
|
|
306
|
+
|
|
307
|
+
expect(adapter.capabilities.sessions).toMatchObject({
|
|
308
|
+
list: false,
|
|
309
|
+
load: false,
|
|
310
|
+
resume: false,
|
|
311
|
+
close: false,
|
|
312
|
+
delete: false,
|
|
313
|
+
});
|
|
314
|
+
expect(adapter.getProtocolSnapshot()).toMatchObject({
|
|
315
|
+
agentCapabilities: {
|
|
316
|
+
loadSession: false,
|
|
317
|
+
promptCapabilities: {},
|
|
318
|
+
},
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it('does not create model-probe sessions when the agent cannot clean them up', async () => {
|
|
323
|
+
const adapter = new AcpRuntimeAdapter({
|
|
324
|
+
command: `"${process.execPath}" "${fixture}"`,
|
|
325
|
+
env: { REMOTE_CODEX_FAKE_ACP_NO_SESSION_CLEANUP: '1' },
|
|
326
|
+
startupTimeoutMs: 5_000,
|
|
327
|
+
});
|
|
328
|
+
adapters.push(adapter);
|
|
329
|
+
await adapter.start();
|
|
330
|
+
|
|
331
|
+
await expect(adapter.inspectModelOptions(process.cwd())).resolves.toEqual([
|
|
332
|
+
expect.objectContaining({ model: 'default', isDefault: true }),
|
|
333
|
+
]);
|
|
334
|
+
await expect(adapter.listSessions()).resolves.toEqual([]);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it('does not treat session close as deletion for model probes', async () => {
|
|
338
|
+
const adapter = new AcpRuntimeAdapter({
|
|
339
|
+
command: `"${process.execPath}" "${fixture}"`,
|
|
340
|
+
env: { REMOTE_CODEX_FAKE_ACP_NO_SESSION_DELETE: '1' },
|
|
341
|
+
startupTimeoutMs: 5_000,
|
|
342
|
+
});
|
|
343
|
+
adapters.push(adapter);
|
|
344
|
+
await adapter.start();
|
|
345
|
+
|
|
346
|
+
expect(adapter.capabilities.sessions).toMatchObject({ close: true, delete: false });
|
|
347
|
+
await expect(adapter.inspectModelOptions(process.cwd())).resolves.toEqual([
|
|
348
|
+
expect.objectContaining({ model: 'default', isDefault: true }),
|
|
349
|
+
]);
|
|
350
|
+
await expect(adapter.listSessions()).resolves.toEqual([]);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it('deletes temporary model-probe sessions', async () => {
|
|
354
|
+
const adapter = new AcpRuntimeAdapter({
|
|
355
|
+
command: `"${process.execPath}" "${fixture}"`,
|
|
356
|
+
startupTimeoutMs: 5_000,
|
|
357
|
+
});
|
|
358
|
+
adapters.push(adapter);
|
|
359
|
+
await adapter.start();
|
|
360
|
+
|
|
361
|
+
await expect(adapter.listSessions()).resolves.toEqual([]);
|
|
362
|
+
await expect(adapter.inspectModelOptions(process.cwd())).resolves.toEqual(
|
|
363
|
+
expect.arrayContaining([
|
|
364
|
+
expect.objectContaining({ model: 'fixture-model', isDefault: true }),
|
|
365
|
+
expect.objectContaining({ model: 'fixture-fast', isDefault: false }),
|
|
366
|
+
]),
|
|
367
|
+
);
|
|
368
|
+
await expect(adapter.listSessions()).resolves.toEqual([]);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it.each([
|
|
372
|
+
{ version: '2', actions: 'set,clear' },
|
|
373
|
+
{ version: 'unknown', actions: 'set,clear' },
|
|
374
|
+
{ version: '1', actions: 'set' },
|
|
375
|
+
])('fails closed for incompatible goal metadata: $version/$actions', async ({
|
|
376
|
+
version,
|
|
377
|
+
actions,
|
|
378
|
+
}) => {
|
|
379
|
+
const adapter = new AcpRuntimeAdapter({
|
|
380
|
+
command: `"${process.execPath}" "${fixture}"`,
|
|
381
|
+
env: {
|
|
382
|
+
REMOTE_CODEX_FAKE_ACP_GOAL_VERSION: version,
|
|
383
|
+
REMOTE_CODEX_FAKE_ACP_GOAL_ACTIONS: actions,
|
|
384
|
+
},
|
|
385
|
+
startupTimeoutMs: 5_000,
|
|
386
|
+
});
|
|
387
|
+
adapters.push(adapter);
|
|
388
|
+
await adapter.start();
|
|
389
|
+
|
|
390
|
+
expect(adapter.capabilities.controls.goals).toBe(false);
|
|
391
|
+
expect(adapter.listHarnessExtensions()).not.toEqual(expect.arrayContaining([
|
|
392
|
+
expect.objectContaining({
|
|
393
|
+
descriptor: expect.objectContaining({ id: 'acp.goal' }),
|
|
394
|
+
}),
|
|
395
|
+
]));
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it('cleans compact control listeners when the hidden turn cannot start', async () => {
|
|
399
|
+
const adapter = new AcpRuntimeAdapter({
|
|
400
|
+
command: `"${process.execPath}" "${fixture}"`,
|
|
401
|
+
env: {
|
|
402
|
+
REMOTE_CODEX_FAKE_ACP_AGENT_KIND: 'codex',
|
|
403
|
+
REMOTE_CODEX_FAKE_ACP_SKIP_PERMISSION: '1',
|
|
404
|
+
},
|
|
405
|
+
startupTimeoutMs: 5_000,
|
|
406
|
+
});
|
|
407
|
+
adapters.push(adapter);
|
|
408
|
+
await adapter.start();
|
|
409
|
+
const session = await adapter.startSession({
|
|
410
|
+
cwd: process.cwd(),
|
|
411
|
+
model: 'fixture-model',
|
|
412
|
+
approvalMode: 'yolo',
|
|
413
|
+
});
|
|
414
|
+
const baselineListeners = adapter.listenerCount('event');
|
|
415
|
+
vi.spyOn(adapter, 'startTurn').mockRejectedValueOnce(
|
|
416
|
+
new Error('fixture compact start failure'),
|
|
417
|
+
);
|
|
418
|
+
|
|
419
|
+
await expect(adapter.compactSession(session.providerSessionId)).rejects.toThrow(
|
|
420
|
+
/fixture compact start failure/,
|
|
421
|
+
);
|
|
422
|
+
expect(adapter.listenerCount('event')).toBe(baselineListeners);
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it('invokes negotiated extensions and emits their events on the runtime event path', async () => {
|
|
426
|
+
const adapter = new AcpRuntimeAdapter({
|
|
427
|
+
command: `"${process.execPath}" "${fixture}"`,
|
|
428
|
+
startupTimeoutMs: 5_000,
|
|
429
|
+
});
|
|
430
|
+
adapters.push(adapter);
|
|
431
|
+
const events: AgentRuntimeEvent[] = [];
|
|
432
|
+
adapter.on('event', (event) => events.push(event as AgentRuntimeEvent));
|
|
433
|
+
await adapter.start();
|
|
434
|
+
const session = await adapter.startSession({
|
|
435
|
+
cwd: process.cwd(),
|
|
436
|
+
model: 'fixture-model',
|
|
437
|
+
approvalMode: 'yolo',
|
|
438
|
+
});
|
|
439
|
+
expect(adapter.listHarnessExtensions()).toEqual(expect.arrayContaining([
|
|
440
|
+
expect.objectContaining({
|
|
441
|
+
ownerId: 'acp-agent',
|
|
442
|
+
descriptor: expect.objectContaining({ id: 'fixture.session', methods: ['compact'] }),
|
|
443
|
+
}),
|
|
444
|
+
]));
|
|
445
|
+
await expect(adapter.invokeHarnessExtension({
|
|
446
|
+
extensionId: 'fixture.session',
|
|
447
|
+
extensionVersion: 1,
|
|
448
|
+
method: 'compact',
|
|
449
|
+
operationId: 'operation-compact',
|
|
450
|
+
idempotencyKey: 'session-compact-1',
|
|
451
|
+
params: {
|
|
452
|
+
providerSessionId: session.providerSessionId,
|
|
453
|
+
providerTurnId: 'turn-compact',
|
|
454
|
+
},
|
|
455
|
+
})).resolves.toEqual({
|
|
456
|
+
compacted: true,
|
|
457
|
+
operationId: 'operation-compact',
|
|
458
|
+
});
|
|
459
|
+
expect(events).toContainEqual(expect.objectContaining({
|
|
460
|
+
type: 'harness.extension',
|
|
461
|
+
providerSessionId: session.providerSessionId,
|
|
462
|
+
providerTurnId: 'turn-compact',
|
|
463
|
+
extensionId: 'fixture.session',
|
|
464
|
+
event: 'checkpoint',
|
|
465
|
+
}));
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
it('adapts Codex legacy steering, goal, and compact controls on one ACP owner', async () => {
|
|
469
|
+
const adapter = new AcpRuntimeAdapter({
|
|
470
|
+
command: `"${process.execPath}" "${fixture}"`,
|
|
471
|
+
env: {
|
|
472
|
+
REMOTE_CODEX_FAKE_ACP_AGENT_KIND: 'codex',
|
|
473
|
+
REMOTE_CODEX_FAKE_ACP_SKIP_PERMISSION: '1',
|
|
474
|
+
REMOTE_CODEX_FAKE_ACP_STREAM_DELAY_MS: '100',
|
|
475
|
+
},
|
|
476
|
+
startupTimeoutMs: 5_000,
|
|
477
|
+
});
|
|
478
|
+
adapters.push(adapter);
|
|
479
|
+
await adapter.start();
|
|
480
|
+
expect(adapter.capabilities).toMatchObject({
|
|
481
|
+
turns: { steer: true, compact: true },
|
|
482
|
+
controls: { goals: true, performanceMode: false },
|
|
483
|
+
branching: { fork: false, hardRollback: false },
|
|
484
|
+
});
|
|
485
|
+
const session = await adapter.startSession({
|
|
486
|
+
cwd: process.cwd(),
|
|
487
|
+
model: 'fixture-model',
|
|
488
|
+
approvalMode: 'yolo',
|
|
489
|
+
performanceMode: 'fast',
|
|
490
|
+
});
|
|
491
|
+
expect(adapter.capabilities.controls.performanceMode).toBe(true);
|
|
492
|
+
const steeredTurn = new Promise<Extract<AgentRuntimeEvent, { type: 'turn.completed' }>>(
|
|
493
|
+
(resolve, reject) => {
|
|
494
|
+
const timer = setTimeout(() => reject(new Error('Steered fixture turn timed out.')), 10_000);
|
|
495
|
+
adapter.on('event', (event: AgentRuntimeEvent) => {
|
|
496
|
+
if (event.type === 'turn.completed') {
|
|
497
|
+
clearTimeout(timer);
|
|
498
|
+
resolve(event);
|
|
499
|
+
}
|
|
500
|
+
});
|
|
501
|
+
},
|
|
502
|
+
);
|
|
503
|
+
const running = await adapter.startTurn({
|
|
504
|
+
providerSessionId: session.providerSessionId,
|
|
505
|
+
prompt: 'Start a steerable fixture turn.',
|
|
506
|
+
});
|
|
507
|
+
await expect(adapter.sendInput({
|
|
508
|
+
providerSessionId: session.providerSessionId,
|
|
509
|
+
providerTurnId: running.providerTurnId,
|
|
510
|
+
prompt: 'Apply the steer.',
|
|
511
|
+
})).resolves.not.toBeNull();
|
|
512
|
+
expect((await steeredTurn).turn.items
|
|
513
|
+
.filter((item) => item.kind === 'agentMessage')
|
|
514
|
+
.map((item) => item.text)
|
|
515
|
+
.join(''))
|
|
516
|
+
.toContain('STEERED');
|
|
517
|
+
const goal = await adapter.setGoal({
|
|
518
|
+
providerSessionId: session.providerSessionId,
|
|
519
|
+
objective: 'Finish the fixture goal',
|
|
520
|
+
});
|
|
521
|
+
expect(goal).toMatchObject({
|
|
522
|
+
objective: 'Finish the fixture goal',
|
|
523
|
+
status: 'active',
|
|
524
|
+
});
|
|
525
|
+
expect(await adapter.getGoal(session.providerSessionId)).toMatchObject({
|
|
526
|
+
objective: 'Finish the fixture goal',
|
|
527
|
+
});
|
|
528
|
+
expect(await adapter.clearGoal(session.providerSessionId)).toBe(true);
|
|
529
|
+
expect(await adapter.getGoal(session.providerSessionId)).toBeNull();
|
|
530
|
+
await expect(adapter.compactSession(session.providerSessionId)).resolves.toBeUndefined();
|
|
531
|
+
}, 15_000);
|
|
79
532
|
});
|