remote-codex 0.11.50 → 0.11.52
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 +31 -0
- package/apps/supervisor-api/dist/index.js +2132 -417
- package/apps/supervisor-web/dist/assets/index-BBq6mr8o.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-C5pxOmDJ.js +3974 -0
- package/apps/supervisor-web/dist/index.html +3 -3
- 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 +165 -6
- package/packages/acp/src/catalog-runtime.ts +234 -25
- 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 +471 -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/codex/src/appServerManager.test.ts +2 -2
- package/packages/db/src/repositories.ts +3 -2
- package/packages/shared/src/index.ts +32 -1
- package/apps/supervisor-web/dist/assets/index-BXypG9kl.js +0 -22
- package/apps/supervisor-web/dist/assets/thread-ui-gcslNXur.js +0 -3968
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
|
|
3
|
+
import type { AgentProviderCapabilities } from '../../agent-runtime/src/types';
|
|
4
|
+
import {
|
|
5
|
+
REMOTE_CODEX_HARNESS_EXTENSION_PROTOCOL,
|
|
6
|
+
type HarnessExtensionCallEnvelope,
|
|
7
|
+
type HarnessExtensionDescriptor,
|
|
8
|
+
type HarnessExtensionErrorEnvelope,
|
|
9
|
+
type HarnessExtensionEventEnvelope,
|
|
10
|
+
createHarnessExtensionCall,
|
|
11
|
+
harnessExtensionMethodName,
|
|
12
|
+
} from './extensions';
|
|
13
|
+
|
|
14
|
+
interface HarnessExtensionTransport {
|
|
15
|
+
request(
|
|
16
|
+
method: string,
|
|
17
|
+
params: unknown,
|
|
18
|
+
signal: AbortSignal,
|
|
19
|
+
): Promise<unknown>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface RegisteredExtension {
|
|
23
|
+
ownerId: string;
|
|
24
|
+
descriptor: HarnessExtensionDescriptor;
|
|
25
|
+
transport: HarnessExtensionTransport;
|
|
26
|
+
wireMethods: Record<string, string>;
|
|
27
|
+
capabilityPatch: AgentProviderCapabilityPatch | null;
|
|
28
|
+
paramMappers: Record<
|
|
29
|
+
string,
|
|
30
|
+
(envelope: HarnessExtensionCallEnvelope) => unknown
|
|
31
|
+
>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type AgentProviderCapabilityPatch = {
|
|
35
|
+
[Section in keyof AgentProviderCapabilities]?: Partial<
|
|
36
|
+
AgentProviderCapabilities[Section]
|
|
37
|
+
>;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
interface CachedOperation {
|
|
41
|
+
fingerprint: string;
|
|
42
|
+
promise: Promise<unknown>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export class HarnessExtensionInvocationError extends Error {
|
|
46
|
+
constructor(public readonly payload: HarnessExtensionErrorEnvelope) {
|
|
47
|
+
super(payload.message);
|
|
48
|
+
this.name = 'HarnessExtensionInvocationError';
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function extensionKey(extensionId: string, version: number) {
|
|
53
|
+
return `${extensionId}@${version}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function operationFingerprint(input: {
|
|
57
|
+
extensionId: string;
|
|
58
|
+
extensionVersion: number;
|
|
59
|
+
method: string;
|
|
60
|
+
params: unknown;
|
|
61
|
+
}) {
|
|
62
|
+
return JSON.stringify(input);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export class HarnessExtensionRegistry extends EventEmitter {
|
|
66
|
+
private readonly extensions = new Map<string, RegisteredExtension>();
|
|
67
|
+
private readonly operations = new Map<string, CachedOperation>();
|
|
68
|
+
private readonly eventSequences = new Set<string>();
|
|
69
|
+
|
|
70
|
+
register(input: {
|
|
71
|
+
ownerId: string;
|
|
72
|
+
descriptor: HarnessExtensionDescriptor;
|
|
73
|
+
transport: HarnessExtensionTransport;
|
|
74
|
+
wireMethods?: Record<string, string>;
|
|
75
|
+
capabilityPatch?: AgentProviderCapabilityPatch;
|
|
76
|
+
paramMappers?: Record<
|
|
77
|
+
string,
|
|
78
|
+
(envelope: HarnessExtensionCallEnvelope) => unknown
|
|
79
|
+
>;
|
|
80
|
+
}) {
|
|
81
|
+
const key = extensionKey(input.descriptor.id, input.descriptor.version);
|
|
82
|
+
const existing = this.extensions.get(key);
|
|
83
|
+
if (existing && existing.ownerId !== input.ownerId) {
|
|
84
|
+
throw new Error(
|
|
85
|
+
`Harness extension ${key} is already owned by ${existing.ownerId}.`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
if (new Set(input.descriptor.methods).size !== input.descriptor.methods.length) {
|
|
89
|
+
throw new Error(`Harness extension ${key} declares duplicate methods.`);
|
|
90
|
+
}
|
|
91
|
+
if (new Set(input.descriptor.events).size !== input.descriptor.events.length) {
|
|
92
|
+
throw new Error(`Harness extension ${key} declares duplicate events.`);
|
|
93
|
+
}
|
|
94
|
+
this.extensions.set(key, {
|
|
95
|
+
ownerId: input.ownerId,
|
|
96
|
+
descriptor: structuredClone(input.descriptor),
|
|
97
|
+
transport: input.transport,
|
|
98
|
+
wireMethods: { ...(input.wireMethods ?? {}) },
|
|
99
|
+
capabilityPatch: input.capabilityPatch
|
|
100
|
+
? structuredClone(input.capabilityPatch)
|
|
101
|
+
: null,
|
|
102
|
+
paramMappers: { ...(input.paramMappers ?? {}) },
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
unregisterOwner(ownerId: string) {
|
|
107
|
+
for (const [key, extension] of this.extensions) {
|
|
108
|
+
if (extension.ownerId === ownerId) {
|
|
109
|
+
this.extensions.delete(key);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
list() {
|
|
115
|
+
return [...this.extensions.values()].map((extension) => ({
|
|
116
|
+
ownerId: extension.ownerId,
|
|
117
|
+
descriptor: structuredClone(extension.descriptor),
|
|
118
|
+
}));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
supports(extensionId: string, version: number, method: string) {
|
|
122
|
+
return this.extensions
|
|
123
|
+
.get(extensionKey(extensionId, version))
|
|
124
|
+
?.descriptor.methods.includes(method) ?? false;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
effectiveCapabilities(base: AgentProviderCapabilities) {
|
|
128
|
+
const effective = structuredClone(base);
|
|
129
|
+
for (const extension of this.extensions.values()) {
|
|
130
|
+
if (!extension.capabilityPatch) continue;
|
|
131
|
+
for (const [section, patch] of Object.entries(extension.capabilityPatch)) {
|
|
132
|
+
Object.assign(
|
|
133
|
+
effective[section as keyof AgentProviderCapabilities],
|
|
134
|
+
patch,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return effective;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
invoke<T = unknown>(input: {
|
|
142
|
+
extensionId: string;
|
|
143
|
+
extensionVersion: number;
|
|
144
|
+
method: string;
|
|
145
|
+
operationId: string;
|
|
146
|
+
idempotencyKey: string;
|
|
147
|
+
params: unknown;
|
|
148
|
+
timeoutMs?: number;
|
|
149
|
+
signal?: AbortSignal;
|
|
150
|
+
}): Promise<T> {
|
|
151
|
+
const key = extensionKey(input.extensionId, input.extensionVersion);
|
|
152
|
+
const extension = this.extensions.get(key);
|
|
153
|
+
if (!extension || !extension.descriptor.methods.includes(input.method)) {
|
|
154
|
+
return Promise.reject(this.error(input, {
|
|
155
|
+
code: 'extension_method_unavailable',
|
|
156
|
+
message: `Harness extension method is unavailable: ${key}/${input.method}`,
|
|
157
|
+
retryable: false,
|
|
158
|
+
}));
|
|
159
|
+
}
|
|
160
|
+
if (input.signal?.aborted) {
|
|
161
|
+
return Promise.reject(this.error(input, {
|
|
162
|
+
code: 'extension_cancelled',
|
|
163
|
+
message: 'Harness extension request was cancelled before dispatch.',
|
|
164
|
+
retryable: true,
|
|
165
|
+
}));
|
|
166
|
+
}
|
|
167
|
+
const fingerprint = operationFingerprint(input);
|
|
168
|
+
const cached = this.operations.get(input.idempotencyKey);
|
|
169
|
+
if (cached) {
|
|
170
|
+
if (cached.fingerprint !== fingerprint) {
|
|
171
|
+
return Promise.reject(this.error(input, {
|
|
172
|
+
code: 'idempotency_conflict',
|
|
173
|
+
message: 'Harness extension idempotency key was reused for another operation.',
|
|
174
|
+
retryable: false,
|
|
175
|
+
}));
|
|
176
|
+
}
|
|
177
|
+
return cached.promise as Promise<T>;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const envelope = createHarnessExtensionCall(input);
|
|
181
|
+
const controller = new AbortController();
|
|
182
|
+
const timeoutMs = input.timeoutMs ?? 30_000;
|
|
183
|
+
let timer: NodeJS.Timeout | null = null;
|
|
184
|
+
let abort: (() => void) | null = null;
|
|
185
|
+
const request = new Promise<T>((resolve, reject) => {
|
|
186
|
+
abort = () => {
|
|
187
|
+
controller.abort(input.signal?.reason);
|
|
188
|
+
reject(this.error(input, {
|
|
189
|
+
code: 'extension_cancelled',
|
|
190
|
+
message: 'Harness extension request was cancelled.',
|
|
191
|
+
retryable: true,
|
|
192
|
+
}));
|
|
193
|
+
};
|
|
194
|
+
input.signal?.addEventListener('abort', abort, { once: true });
|
|
195
|
+
timer = setTimeout(() => {
|
|
196
|
+
controller.abort(new Error('Harness extension request timed out.'));
|
|
197
|
+
reject(this.error(input, {
|
|
198
|
+
code: 'extension_timeout',
|
|
199
|
+
message: `Harness extension request timed out after ${timeoutMs}ms.`,
|
|
200
|
+
retryable: true,
|
|
201
|
+
}));
|
|
202
|
+
}, timeoutMs);
|
|
203
|
+
void Promise.resolve().then(() => extension.transport.request(
|
|
204
|
+
extension.wireMethods[input.method] ?? harnessExtensionMethodName(
|
|
205
|
+
input.extensionId,
|
|
206
|
+
input.extensionVersion,
|
|
207
|
+
input.method,
|
|
208
|
+
),
|
|
209
|
+
extension.paramMappers[input.method]?.(envelope) ?? envelope,
|
|
210
|
+
controller.signal,
|
|
211
|
+
)).then((value) => resolve(value as T), (cause) => reject(
|
|
212
|
+
cause instanceof HarnessExtensionInvocationError
|
|
213
|
+
? cause
|
|
214
|
+
: this.error(input, {
|
|
215
|
+
code: 'extension_request_failed',
|
|
216
|
+
message: cause instanceof Error ? cause.message : String(cause),
|
|
217
|
+
retryable: false,
|
|
218
|
+
}),
|
|
219
|
+
));
|
|
220
|
+
}).finally(() => {
|
|
221
|
+
if (timer) clearTimeout(timer);
|
|
222
|
+
if (abort) input.signal?.removeEventListener('abort', abort);
|
|
223
|
+
});
|
|
224
|
+
this.operations.set(input.idempotencyKey, { fingerprint, promise: request });
|
|
225
|
+
request.catch(() => {
|
|
226
|
+
if (this.operations.get(input.idempotencyKey)?.promise === request) {
|
|
227
|
+
this.operations.delete(input.idempotencyKey);
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
while (this.operations.size > 256) {
|
|
231
|
+
this.operations.delete(this.operations.keys().next().value!);
|
|
232
|
+
}
|
|
233
|
+
return request;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
handleEvent(ownerId: string, event: HarnessExtensionEventEnvelope) {
|
|
237
|
+
if (event.protocol !== REMOTE_CODEX_HARNESS_EXTENSION_PROTOCOL) {
|
|
238
|
+
throw new Error('Harness extension event protocol is unsupported.');
|
|
239
|
+
}
|
|
240
|
+
const extension = this.extensions.get(
|
|
241
|
+
extensionKey(event.extensionId, event.extensionVersion),
|
|
242
|
+
);
|
|
243
|
+
if (!extension || extension.ownerId !== ownerId) {
|
|
244
|
+
throw new Error('Harness extension event owner does not match registration.');
|
|
245
|
+
}
|
|
246
|
+
if (!extension.descriptor.events.includes(event.event)) {
|
|
247
|
+
throw new Error(`Harness extension event is not declared: ${event.event}`);
|
|
248
|
+
}
|
|
249
|
+
if (event.sequence !== null) {
|
|
250
|
+
const sequenceKey = [
|
|
251
|
+
ownerId,
|
|
252
|
+
event.extensionId,
|
|
253
|
+
event.extensionVersion,
|
|
254
|
+
event.providerSessionId,
|
|
255
|
+
event.event,
|
|
256
|
+
event.sequence,
|
|
257
|
+
].join('\0');
|
|
258
|
+
if (this.eventSequences.has(sequenceKey)) {
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
this.eventSequences.add(sequenceKey);
|
|
262
|
+
}
|
|
263
|
+
this.emit('event', structuredClone(event));
|
|
264
|
+
return true;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
private error(
|
|
268
|
+
input: {
|
|
269
|
+
extensionId: string;
|
|
270
|
+
extensionVersion: number;
|
|
271
|
+
method: string;
|
|
272
|
+
operationId: string;
|
|
273
|
+
},
|
|
274
|
+
error: { code: string; message: string; retryable: boolean },
|
|
275
|
+
) {
|
|
276
|
+
return new HarnessExtensionInvocationError({
|
|
277
|
+
protocol: REMOTE_CODEX_HARNESS_EXTENSION_PROTOCOL,
|
|
278
|
+
extensionId: input.extensionId,
|
|
279
|
+
extensionVersion: input.extensionVersion,
|
|
280
|
+
method: input.method,
|
|
281
|
+
operationId: input.operationId,
|
|
282
|
+
...error,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
REMOTE_CODEX_HARNESS_EXTENSION_PROTOCOL,
|
|
5
|
+
createHarnessExtensionCall,
|
|
6
|
+
harnessExtensionMethodName,
|
|
7
|
+
} from './extensions';
|
|
8
|
+
|
|
9
|
+
describe('harness extension contract', () => {
|
|
10
|
+
it('creates versioned, idempotent extension calls', () => {
|
|
11
|
+
expect(createHarnessExtensionCall({
|
|
12
|
+
extensionId: 'codex.session',
|
|
13
|
+
extensionVersion: 1,
|
|
14
|
+
method: 'compact',
|
|
15
|
+
operationId: 'operation-1',
|
|
16
|
+
idempotencyKey: 'thread-1:compact:operation-1',
|
|
17
|
+
params: { providerSessionId: 'session-1' },
|
|
18
|
+
})).toEqual({
|
|
19
|
+
protocol: REMOTE_CODEX_HARNESS_EXTENSION_PROTOCOL,
|
|
20
|
+
extensionId: 'codex.session',
|
|
21
|
+
extensionVersion: 1,
|
|
22
|
+
method: 'compact',
|
|
23
|
+
operationId: 'operation-1',
|
|
24
|
+
idempotencyKey: 'thread-1:compact:operation-1',
|
|
25
|
+
params: { providerSessionId: 'session-1' },
|
|
26
|
+
});
|
|
27
|
+
expect(harnessExtensionMethodName('codex.session', 1, 'compact')).toBe(
|
|
28
|
+
'remoteCodex/codex.session/v1/compact',
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('rejects ambiguous names and missing idempotency fields', () => {
|
|
33
|
+
expect(() => harnessExtensionMethodName('Codex Session', 1, 'compact')).toThrow(
|
|
34
|
+
/lowercase extension identifier/,
|
|
35
|
+
);
|
|
36
|
+
expect(() => createHarnessExtensionCall({
|
|
37
|
+
extensionId: 'codex.session',
|
|
38
|
+
extensionVersion: 1,
|
|
39
|
+
method: 'compact',
|
|
40
|
+
operationId: '',
|
|
41
|
+
idempotencyKey: '',
|
|
42
|
+
params: {},
|
|
43
|
+
})).toThrow(/operation id is required/);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
export const REMOTE_CODEX_HARNESS_EXTENSION_META_KEY =
|
|
2
|
+
'remoteCodex.harnessExtensions';
|
|
3
|
+
export const REMOTE_CODEX_HARNESS_EXTENSION_PROTOCOL =
|
|
4
|
+
'remote-codex.harness-extension/v1';
|
|
5
|
+
export const REMOTE_CODEX_HARNESS_EXTENSION_VERSION = 1;
|
|
6
|
+
export const REMOTE_CODEX_HARNESS_EXTENSION_EVENT_METHOD =
|
|
7
|
+
'remoteCodex/harness-extension/event';
|
|
8
|
+
|
|
9
|
+
export type HarnessExtensionStability = 'experimental' | 'stable';
|
|
10
|
+
|
|
11
|
+
export interface HarnessExtensionDescriptor {
|
|
12
|
+
id: string;
|
|
13
|
+
version: number;
|
|
14
|
+
stability: HarnessExtensionStability;
|
|
15
|
+
methods: string[];
|
|
16
|
+
events: string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface HarnessExtensionCallEnvelope<TParams = unknown> {
|
|
20
|
+
protocol: typeof REMOTE_CODEX_HARNESS_EXTENSION_PROTOCOL;
|
|
21
|
+
extensionId: string;
|
|
22
|
+
extensionVersion: number;
|
|
23
|
+
method: string;
|
|
24
|
+
operationId: string;
|
|
25
|
+
idempotencyKey: string;
|
|
26
|
+
params: TParams;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface HarnessExtensionEventEnvelope<TPayload = unknown> {
|
|
30
|
+
protocol: typeof REMOTE_CODEX_HARNESS_EXTENSION_PROTOCOL;
|
|
31
|
+
extensionId: string;
|
|
32
|
+
extensionVersion: number;
|
|
33
|
+
event: string;
|
|
34
|
+
operationId: string | null;
|
|
35
|
+
providerSessionId: string;
|
|
36
|
+
providerTurnId: string | null;
|
|
37
|
+
providerItemId: string | null;
|
|
38
|
+
sequence: number | null;
|
|
39
|
+
payload: TPayload;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface HarnessExtensionErrorEnvelope {
|
|
43
|
+
protocol: typeof REMOTE_CODEX_HARNESS_EXTENSION_PROTOCOL;
|
|
44
|
+
extensionId: string;
|
|
45
|
+
extensionVersion: number;
|
|
46
|
+
method: string;
|
|
47
|
+
operationId: string;
|
|
48
|
+
code: string;
|
|
49
|
+
message: string;
|
|
50
|
+
retryable: boolean;
|
|
51
|
+
details?: Record<string, unknown>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const extensionSegmentPattern = /^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/;
|
|
55
|
+
|
|
56
|
+
function assertExtensionSegment(value: string, label: string) {
|
|
57
|
+
if (!extensionSegmentPattern.test(value)) {
|
|
58
|
+
throw new Error(`${label} must be a lowercase extension identifier.`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function harnessExtensionMethodName(
|
|
63
|
+
extensionId: string,
|
|
64
|
+
version: number,
|
|
65
|
+
method: string,
|
|
66
|
+
) {
|
|
67
|
+
assertExtensionSegment(extensionId, 'Extension id');
|
|
68
|
+
assertExtensionSegment(method, 'Extension method');
|
|
69
|
+
if (!Number.isInteger(version) || version < 1) {
|
|
70
|
+
throw new Error('Extension version must be a positive integer.');
|
|
71
|
+
}
|
|
72
|
+
return `remoteCodex/${extensionId}/v${version}/${method}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function createHarnessExtensionCall<TParams>(input: {
|
|
76
|
+
extensionId: string;
|
|
77
|
+
extensionVersion: number;
|
|
78
|
+
method: string;
|
|
79
|
+
operationId: string;
|
|
80
|
+
idempotencyKey: string;
|
|
81
|
+
params: TParams;
|
|
82
|
+
}): HarnessExtensionCallEnvelope<TParams> {
|
|
83
|
+
harnessExtensionMethodName(
|
|
84
|
+
input.extensionId,
|
|
85
|
+
input.extensionVersion,
|
|
86
|
+
input.method,
|
|
87
|
+
);
|
|
88
|
+
if (!input.operationId.trim()) {
|
|
89
|
+
throw new Error('Extension operation id is required.');
|
|
90
|
+
}
|
|
91
|
+
if (!input.idempotencyKey.trim()) {
|
|
92
|
+
throw new Error('Extension idempotency key is required.');
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
protocol: REMOTE_CODEX_HARNESS_EXTENSION_PROTOCOL,
|
|
96
|
+
extensionId: input.extensionId,
|
|
97
|
+
extensionVersion: input.extensionVersion,
|
|
98
|
+
method: input.method,
|
|
99
|
+
operationId: input.operationId,
|
|
100
|
+
idempotencyKey: input.idempotencyKey,
|
|
101
|
+
params: input.params,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import type { AgentProviderCapabilities } from '../../agent-runtime/src/index';
|
|
4
|
+
import type { AcpNegotiatedCapabilitySnapshot } from './capabilities';
|
|
5
|
+
import { acpCapabilities } from './runtimeAdapter';
|
|
6
|
+
import { assertAcpHarnessContract } from './harness-contract';
|
|
7
|
+
|
|
8
|
+
function negotiated(name: string): AcpNegotiatedCapabilitySnapshot {
|
|
9
|
+
return {
|
|
10
|
+
protocolVersion: 1,
|
|
11
|
+
agentInfo: { name, title: null, version: 'test' },
|
|
12
|
+
agentCapabilities: {},
|
|
13
|
+
harnessExtensions: [],
|
|
14
|
+
legacyExtensions: { steering: null, goal: null },
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function capabilities(
|
|
19
|
+
patch: Partial<AgentProviderCapabilities>,
|
|
20
|
+
): AgentProviderCapabilities {
|
|
21
|
+
const value = structuredClone(acpCapabilities);
|
|
22
|
+
for (const section of Object.keys(patch) as Array<keyof AgentProviderCapabilities>) {
|
|
23
|
+
Object.assign(value[section], patch[section]);
|
|
24
|
+
}
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('ACP harness contract kit', () => {
|
|
29
|
+
it('validates the Codex extension profile', () => {
|
|
30
|
+
expect(assertAcpHarnessContract({
|
|
31
|
+
negotiated: negotiated('@agentclientprotocol/codex-acp'),
|
|
32
|
+
effectiveCapabilities: capabilities({
|
|
33
|
+
sessions: { ...acpCapabilities.sessions, list: true, load: true, resume: true },
|
|
34
|
+
turns: { ...acpCapabilities.turns, steer: true, compact: true },
|
|
35
|
+
controls: { ...acpCapabilities.controls, goals: true, performanceMode: true },
|
|
36
|
+
}),
|
|
37
|
+
expectedAgentName: '@agentclientprotocol/codex-acp',
|
|
38
|
+
required: [
|
|
39
|
+
'sessions.list',
|
|
40
|
+
'sessions.load',
|
|
41
|
+
'sessions.resume',
|
|
42
|
+
'turns.steer',
|
|
43
|
+
'turns.compact',
|
|
44
|
+
'controls.goals',
|
|
45
|
+
'controls.performanceMode',
|
|
46
|
+
],
|
|
47
|
+
unsupported: ['branching.fork', 'branching.hardRollback'],
|
|
48
|
+
})).toMatchObject({ protocolVersion: 1 });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('validates a portable fork profile without Codex-only compact', () => {
|
|
52
|
+
expect(assertAcpHarnessContract({
|
|
53
|
+
negotiated: negotiated('@agentclientprotocol/claude-agent-acp'),
|
|
54
|
+
effectiveCapabilities: capabilities({
|
|
55
|
+
sessions: { ...acpCapabilities.sessions, list: true, load: true, resume: true },
|
|
56
|
+
turns: { ...acpCapabilities.turns, steer: true },
|
|
57
|
+
branching: { ...acpCapabilities.branching, fork: true },
|
|
58
|
+
controls: { ...acpCapabilities.controls, goals: true },
|
|
59
|
+
}),
|
|
60
|
+
expectedAgentName: '@agentclientprotocol/claude-agent-acp',
|
|
61
|
+
required: [
|
|
62
|
+
'sessions.list',
|
|
63
|
+
'sessions.load',
|
|
64
|
+
'sessions.resume',
|
|
65
|
+
'turns.steer',
|
|
66
|
+
'branching.fork',
|
|
67
|
+
'controls.goals',
|
|
68
|
+
],
|
|
69
|
+
unsupported: ['turns.compact', 'branching.hardRollback'],
|
|
70
|
+
})).toMatchObject({ protocolVersion: 1 });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('fails closed when a required capability disappears', () => {
|
|
74
|
+
expect(() => assertAcpHarnessContract({
|
|
75
|
+
negotiated: negotiated('minimal-agent'),
|
|
76
|
+
effectiveCapabilities: capabilities({}),
|
|
77
|
+
expectedAgentName: 'minimal-agent',
|
|
78
|
+
required: ['sessions.resume'],
|
|
79
|
+
})).toThrow(/missing=sessions\.resume/);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { AgentProviderCapabilities } from '../../agent-runtime/src/index';
|
|
2
|
+
import type { AcpNegotiatedCapabilitySnapshot } from './capabilities';
|
|
3
|
+
|
|
4
|
+
export type AcpHarnessContractCapability =
|
|
5
|
+
| 'sessions.list'
|
|
6
|
+
| 'sessions.load'
|
|
7
|
+
| 'sessions.resume'
|
|
8
|
+
| 'sessions.close'
|
|
9
|
+
| 'sessions.delete'
|
|
10
|
+
| 'turns.steer'
|
|
11
|
+
| 'turns.compact'
|
|
12
|
+
| 'branching.fork'
|
|
13
|
+
| 'branching.hardRollback'
|
|
14
|
+
| 'controls.performanceMode'
|
|
15
|
+
| 'controls.goals';
|
|
16
|
+
|
|
17
|
+
function capabilityValue(
|
|
18
|
+
capabilities: AgentProviderCapabilities,
|
|
19
|
+
capability: AcpHarnessContractCapability,
|
|
20
|
+
) {
|
|
21
|
+
const [section, key] = capability.split('.') as [
|
|
22
|
+
keyof AgentProviderCapabilities,
|
|
23
|
+
string,
|
|
24
|
+
];
|
|
25
|
+
return capabilities[section][
|
|
26
|
+
key as keyof AgentProviderCapabilities[typeof section]
|
|
27
|
+
] === true;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function assertAcpHarnessContract(input: {
|
|
31
|
+
negotiated: AcpNegotiatedCapabilitySnapshot | null;
|
|
32
|
+
effectiveCapabilities: AgentProviderCapabilities;
|
|
33
|
+
expectedAgentName: string;
|
|
34
|
+
required: AcpHarnessContractCapability[];
|
|
35
|
+
unsupported?: AcpHarnessContractCapability[];
|
|
36
|
+
}) {
|
|
37
|
+
const actualAgentName = input.negotiated?.agentInfo?.name ?? null;
|
|
38
|
+
if (actualAgentName !== input.expectedAgentName) {
|
|
39
|
+
throw new Error(
|
|
40
|
+
`ACP harness contract expected ${input.expectedAgentName}, received ` +
|
|
41
|
+
`${actualAgentName ?? 'unknown'}.`,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
const missing = input.required.filter(
|
|
45
|
+
(capability) => !capabilityValue(input.effectiveCapabilities, capability),
|
|
46
|
+
);
|
|
47
|
+
const unexpectedlySupported = (input.unsupported ?? []).filter(
|
|
48
|
+
(capability) => capabilityValue(input.effectiveCapabilities, capability),
|
|
49
|
+
);
|
|
50
|
+
if (missing.length > 0 || unexpectedlySupported.length > 0) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
`ACP harness contract mismatch: missing=${missing.join(',') || 'none'}; ` +
|
|
53
|
+
`unexpected=${unexpectedlySupported.join(',') || 'none'}.`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
agentName: actualAgentName,
|
|
58
|
+
protocolVersion: input.negotiated!.protocolVersion,
|
|
59
|
+
required: [...input.required],
|
|
60
|
+
unsupported: [...(input.unsupported ?? [])],
|
|
61
|
+
};
|
|
62
|
+
}
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
export * from './agent-catalog';
|
|
2
|
+
export * from './capabilities';
|
|
2
3
|
export * from './catalog-runtime';
|
|
4
|
+
export * from './extensions';
|
|
5
|
+
export * from './harness-contract';
|
|
6
|
+
export * from './extension-registry';
|
|
3
7
|
export * from './item-mapper';
|
|
8
|
+
export * from './prompt-content';
|
|
4
9
|
export * from './runtimeAdapter';
|
|
10
|
+
export * from './session-hydrator';
|
|
5
11
|
export * from './terminal-service';
|
|
12
|
+
export * from './workspace-boundary';
|
|
@@ -23,6 +23,8 @@ export interface AcpMappedSessionUpdate {
|
|
|
23
23
|
usage: { used: number; size: number; cost?: acp.Cost | null } | null;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
export type AcpMappingMode = 'hydrate' | 'live';
|
|
27
|
+
|
|
26
28
|
interface StoredToolCall extends acp.ToolCall {
|
|
27
29
|
title: string;
|
|
28
30
|
}
|
|
@@ -227,12 +229,24 @@ export class AcpTurnItemMapper {
|
|
|
227
229
|
constructor(
|
|
228
230
|
readonly turnId: string,
|
|
229
231
|
initialItems: AgentHistoryItem[] = [],
|
|
232
|
+
readonly mode: AcpMappingMode = 'live',
|
|
230
233
|
) {
|
|
231
234
|
for (const item of initialItems) {
|
|
232
235
|
this.upsert(item);
|
|
233
236
|
}
|
|
234
237
|
}
|
|
235
238
|
|
|
239
|
+
appendUserMessage(content: acp.ContentBlock, itemId: string) {
|
|
240
|
+
const current = this.items.get(itemId);
|
|
241
|
+
const item: AgentHistoryItem = {
|
|
242
|
+
...(current ?? { id: itemId, kind: 'userMessage' as const }),
|
|
243
|
+
text: `${current?.text ?? ''}${acpContentBlockText(content)}`,
|
|
244
|
+
status: modeStatus(this.mode),
|
|
245
|
+
};
|
|
246
|
+
this.upsert(item);
|
|
247
|
+
return item;
|
|
248
|
+
}
|
|
249
|
+
|
|
236
250
|
turn(status: AgentTurn['status'] = 'inProgress', error: string | null = null): AgentTurn {
|
|
237
251
|
return {
|
|
238
252
|
providerTurnId: this.turnId,
|
|
@@ -257,7 +271,8 @@ export class AcpTurnItemMapper {
|
|
|
257
271
|
case 'agent_message_chunk': {
|
|
258
272
|
result.itemUpdates.push(...this.finishThought());
|
|
259
273
|
const delta = acpContentBlockText(update.content);
|
|
260
|
-
const itemId = this.currentAgentMessageId ??
|
|
274
|
+
const itemId = this.currentAgentMessageId ?? messageId(update) ??
|
|
275
|
+
`${this.turnId}:agent:${++this.agentMessageIndex}`;
|
|
261
276
|
this.currentAgentMessageId = itemId;
|
|
262
277
|
const current = this.items.get(itemId);
|
|
263
278
|
const item: AgentHistoryItem = {
|
|
@@ -272,7 +287,8 @@ export class AcpTurnItemMapper {
|
|
|
272
287
|
case 'agent_thought_chunk': {
|
|
273
288
|
result.itemUpdates.push(...this.finishAgentMessage());
|
|
274
289
|
const delta = acpContentBlockText(update.content);
|
|
275
|
-
const itemId = this.currentThoughtId ??
|
|
290
|
+
const itemId = this.currentThoughtId ?? messageId(update) ??
|
|
291
|
+
`${this.turnId}:thought:${++this.thoughtIndex}`;
|
|
276
292
|
this.currentThoughtId = itemId;
|
|
277
293
|
const current = this.items.get(itemId);
|
|
278
294
|
const item: AgentHistoryItem = {
|
|
@@ -471,3 +487,13 @@ export class AcpTurnItemMapper {
|
|
|
471
487
|
return [...this.finishAgentMessage(), ...this.finishThought()];
|
|
472
488
|
}
|
|
473
489
|
}
|
|
490
|
+
|
|
491
|
+
function messageId(update: { messageId?: unknown }) {
|
|
492
|
+
return typeof update.messageId === 'string' && update.messageId.trim()
|
|
493
|
+
? update.messageId.trim()
|
|
494
|
+
: null;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function modeStatus(mode: AcpMappingMode) {
|
|
498
|
+
return mode === 'hydrate' ? 'completed' : 'running';
|
|
499
|
+
}
|