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
|
@@ -18,6 +18,8 @@ import {
|
|
|
18
18
|
type InterruptAgentTurnInput,
|
|
19
19
|
type ReadAgentSessionOptions,
|
|
20
20
|
type ResumeAgentSessionInput,
|
|
21
|
+
type SendAgentInputInput,
|
|
22
|
+
type SetAgentGoalInput,
|
|
21
23
|
type StartAgentSessionInput,
|
|
22
24
|
type StartAgentSessionResult,
|
|
23
25
|
type StartAgentTurnInput,
|
|
@@ -28,6 +30,7 @@ import {
|
|
|
28
30
|
acpAgentMetadata,
|
|
29
31
|
type AcpAgentCatalogEntry,
|
|
30
32
|
} from './agent-catalog';
|
|
33
|
+
import { snapshotAcpAgentCapabilities } from './capabilities';
|
|
31
34
|
import { AcpRuntimeAdapter } from './runtimeAdapter';
|
|
32
35
|
import { loadCodexAcpEnvironment } from './codex-environment';
|
|
33
36
|
|
|
@@ -90,7 +93,12 @@ const catalogCapabilities: AgentProviderCapabilities = {
|
|
|
90
93
|
|
|
91
94
|
const catalogManagementSchema: AgentRuntimeManagementSchema = {
|
|
92
95
|
hostConfigFiles: [],
|
|
93
|
-
toolboxItems: [
|
|
96
|
+
toolboxItems: [
|
|
97
|
+
{ action: 'fast', command: '/fast', label: 'Fast mode' },
|
|
98
|
+
{ action: 'compact', command: '/compact', label: 'Compact context' },
|
|
99
|
+
{ action: 'goal', command: '/goal', label: 'Goal' },
|
|
100
|
+
{ action: 'fork', command: '/fork', label: 'Fork' },
|
|
101
|
+
],
|
|
94
102
|
hookCommandTemplates: [],
|
|
95
103
|
providerConfigFormat: 'none',
|
|
96
104
|
mcpConfigFormat: 'none',
|
|
@@ -117,6 +125,7 @@ function decodeScopedId(value: string | number) {
|
|
|
117
125
|
function scopedSession(agentId: string, session: AgentSessionDetail): AgentSessionDetail {
|
|
118
126
|
return {
|
|
119
127
|
...session,
|
|
128
|
+
agentId,
|
|
120
129
|
providerSessionId: encodeSessionId(agentId, session.providerSessionId),
|
|
121
130
|
};
|
|
122
131
|
}
|
|
@@ -124,6 +133,7 @@ function scopedSession(agentId: string, session: AgentSessionDetail): AgentSessi
|
|
|
124
133
|
function scopedSummary(agentId: string, session: AgentSessionSummary): AgentSessionSummary {
|
|
125
134
|
return {
|
|
126
135
|
...session,
|
|
136
|
+
agentId,
|
|
127
137
|
providerSessionId: encodeSessionId(agentId, session.providerSessionId),
|
|
128
138
|
};
|
|
129
139
|
}
|
|
@@ -154,6 +164,11 @@ export class AcpCatalogRuntimeAdapter extends EventEmitter implements AgentRunti
|
|
|
154
164
|
private readonly catalog: AcpAgentCatalog;
|
|
155
165
|
private readonly agents = new Map<string, AcpRuntimeAdapter>();
|
|
156
166
|
private readonly modelCache = new Map<string, { at: number; models: AgentModel[] }>();
|
|
167
|
+
private readonly operationalMetrics = {
|
|
168
|
+
sessionStartFailures: 0,
|
|
169
|
+
resumeFailures: 0,
|
|
170
|
+
capabilityProbeFailures: 0,
|
|
171
|
+
};
|
|
157
172
|
private status: AgentRuntimeStatus = {
|
|
158
173
|
state: 'stopped',
|
|
159
174
|
transport: 'stdio',
|
|
@@ -172,7 +187,10 @@ export class AcpCatalogRuntimeAdapter extends EventEmitter implements AgentRunti
|
|
|
172
187
|
}
|
|
173
188
|
|
|
174
189
|
getStatus() {
|
|
175
|
-
return {
|
|
190
|
+
return {
|
|
191
|
+
...this.status,
|
|
192
|
+
operationalMetrics: { ...this.operationalMetrics },
|
|
193
|
+
};
|
|
176
194
|
}
|
|
177
195
|
|
|
178
196
|
async start() {
|
|
@@ -189,6 +207,7 @@ export class AcpCatalogRuntimeAdapter extends EventEmitter implements AgentRunti
|
|
|
189
207
|
async stop() {
|
|
190
208
|
await Promise.allSettled([...this.agents.values()].map((agent) => agent.stop()));
|
|
191
209
|
this.agents.clear();
|
|
210
|
+
this.recomputeAgentCapabilities();
|
|
192
211
|
this.status = {
|
|
193
212
|
...this.status,
|
|
194
213
|
state: 'stopped',
|
|
@@ -219,21 +238,65 @@ export class AcpCatalogRuntimeAdapter extends EventEmitter implements AgentRunti
|
|
|
219
238
|
}
|
|
220
239
|
|
|
221
240
|
async listModelsForAgent(agentId: string, cwd: string) {
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
241
|
+
try {
|
|
242
|
+
const cacheKey = `${agentId}\0${cwd}`;
|
|
243
|
+
const cached = this.modelCache.get(cacheKey);
|
|
244
|
+
if (cached && Date.now() - cached.at < 30_000) {
|
|
245
|
+
return cached.models;
|
|
246
|
+
}
|
|
247
|
+
const agent = await this.agentFor(agentId);
|
|
248
|
+
let models = await agent.inspectModelOptions(cwd);
|
|
249
|
+
if (agent.capabilities.controls.performanceMode) {
|
|
250
|
+
models = models.map((model) => ({
|
|
251
|
+
...model,
|
|
252
|
+
supportsPerformanceMode: true,
|
|
253
|
+
}));
|
|
254
|
+
}
|
|
255
|
+
if (models.length === 1 && models[0]?.model === 'default') {
|
|
256
|
+
const commandModels = await this.catalog.listCommandModels(agentId);
|
|
257
|
+
if (commandModels.length > 0) {
|
|
258
|
+
models = commandModels;
|
|
259
|
+
}
|
|
233
260
|
}
|
|
261
|
+
this.modelCache.set(cacheKey, { at: Date.now(), models });
|
|
262
|
+
return models;
|
|
263
|
+
} catch (error) {
|
|
264
|
+
this.operationalMetrics.capabilityProbeFailures += 1;
|
|
265
|
+
throw error;
|
|
234
266
|
}
|
|
235
|
-
|
|
236
|
-
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async getAgentCapabilitySnapshot(agentId: string) {
|
|
270
|
+
const entry = (await this.refreshCatalog()).find((candidate) => candidate.id === agentId);
|
|
271
|
+
if (!entry) {
|
|
272
|
+
throw new AgentRuntimeError(`Unknown ACP agent: ${agentId}`, 'acp', 'request_failed');
|
|
273
|
+
}
|
|
274
|
+
if (entry.availability !== 'ready') {
|
|
275
|
+
return snapshotAcpAgentCapabilities({
|
|
276
|
+
agentId,
|
|
277
|
+
availability: entry.availability,
|
|
278
|
+
effectiveCapabilities: this.capabilities,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
const agent = await this.agentFor(agentId);
|
|
282
|
+
return snapshotAcpAgentCapabilities({
|
|
283
|
+
agentId,
|
|
284
|
+
availability: entry.availability,
|
|
285
|
+
negotiated: agent.getProtocolSnapshot(),
|
|
286
|
+
effectiveCapabilities: agent.capabilities,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
getScopedCapabilities(input: {
|
|
291
|
+
agentId?: string | null;
|
|
292
|
+
providerSessionId?: string | null;
|
|
293
|
+
}) {
|
|
294
|
+
const sessionOwner = input.providerSessionId
|
|
295
|
+
? decodeScopedId(input.providerSessionId)
|
|
296
|
+
: null;
|
|
297
|
+
const agentId = sessionOwner?.agentId ?? input.agentId ?? null;
|
|
298
|
+
const child = agentId ? this.agents.get(agentId) : null;
|
|
299
|
+
return structuredClone(child?.capabilities ?? catalogCapabilities);
|
|
237
300
|
}
|
|
238
301
|
|
|
239
302
|
async installModel(modelId: string) {
|
|
@@ -250,6 +313,27 @@ export class AcpCatalogRuntimeAdapter extends EventEmitter implements AgentRunti
|
|
|
250
313
|
return sessions.flat();
|
|
251
314
|
}
|
|
252
315
|
|
|
316
|
+
async listImportSessions(agentId?: string | null) {
|
|
317
|
+
const entries = agentId
|
|
318
|
+
? (await this.refreshCatalog()).filter((entry) => entry.id === agentId)
|
|
319
|
+
: [];
|
|
320
|
+
const sessions = await Promise.all(
|
|
321
|
+
entries.map(async (entry) => {
|
|
322
|
+
if (entry.availability !== 'ready') {
|
|
323
|
+
return [];
|
|
324
|
+
}
|
|
325
|
+
try {
|
|
326
|
+
const agent = await this.agentFor(entry.id);
|
|
327
|
+
return (await agent.listSessions()).map((session) =>
|
|
328
|
+
scopedSummary(entry.id, session));
|
|
329
|
+
} catch {
|
|
330
|
+
return [];
|
|
331
|
+
}
|
|
332
|
+
}),
|
|
333
|
+
);
|
|
334
|
+
return sessions.flat();
|
|
335
|
+
}
|
|
336
|
+
|
|
253
337
|
async listLoadedSessions() {
|
|
254
338
|
const sessions = await Promise.all(
|
|
255
339
|
[...this.agents.entries()].map(async ([agentId, agent]) =>
|
|
@@ -279,14 +363,30 @@ export class AcpCatalogRuntimeAdapter extends EventEmitter implements AgentRunti
|
|
|
279
363
|
if (!agentId) {
|
|
280
364
|
throw new AgentRuntimeError('Select an ACP agent before creating the thread.', 'acp');
|
|
281
365
|
}
|
|
282
|
-
|
|
283
|
-
|
|
366
|
+
let agent: AcpRuntimeAdapter;
|
|
367
|
+
let response: StartAgentSessionResult;
|
|
368
|
+
try {
|
|
369
|
+
agent = await this.agentFor(agentId);
|
|
370
|
+
response = await agent.startSession(delegatedSessionInput(input));
|
|
371
|
+
} catch (error) {
|
|
372
|
+
this.operationalMetrics.sessionStartFailures += 1;
|
|
373
|
+
throw error;
|
|
374
|
+
}
|
|
375
|
+
const probedDefaultModel = this.modelCache
|
|
376
|
+
.get(`${agentId}\0${input.cwd}`)
|
|
377
|
+
?.models.find((model) => model.isDefault)?.model ?? null;
|
|
378
|
+
const resolvedModel = response.model && response.model !== 'default'
|
|
379
|
+
? response.model
|
|
380
|
+
: input.model !== 'default'
|
|
381
|
+
? input.model
|
|
382
|
+
: probedDefaultModel ?? input.model;
|
|
383
|
+
this.refreshAgentSessionCapabilities(agentId, agent);
|
|
284
384
|
const session = scopedSession(agentId, response.session);
|
|
285
385
|
return {
|
|
286
386
|
...response,
|
|
287
387
|
agentId,
|
|
288
388
|
providerSessionId: session.providerSessionId,
|
|
289
|
-
model:
|
|
389
|
+
model: resolvedModel,
|
|
290
390
|
reasoningEffort: response.reasoningEffort ?? input.reasoningEffort ?? null,
|
|
291
391
|
session,
|
|
292
392
|
};
|
|
@@ -294,11 +394,19 @@ export class AcpCatalogRuntimeAdapter extends EventEmitter implements AgentRunti
|
|
|
294
394
|
|
|
295
395
|
async resumeSession(input: ResumeAgentSessionInput): Promise<StartAgentSessionResult> {
|
|
296
396
|
const owner = this.requireSessionOwner(input.providerSessionId);
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
397
|
+
let agent: AcpRuntimeAdapter;
|
|
398
|
+
let response: StartAgentSessionResult;
|
|
399
|
+
try {
|
|
400
|
+
agent = await this.agentFor(owner.agentId);
|
|
401
|
+
response = await agent.resumeSession({
|
|
402
|
+
...input,
|
|
403
|
+
providerSessionId: owner.rawId,
|
|
404
|
+
});
|
|
405
|
+
} catch (error) {
|
|
406
|
+
this.operationalMetrics.resumeFailures += 1;
|
|
407
|
+
throw error;
|
|
408
|
+
}
|
|
409
|
+
this.refreshAgentSessionCapabilities(owner.agentId, agent);
|
|
302
410
|
const session = scopedSession(owner.agentId, response.session);
|
|
303
411
|
return {
|
|
304
412
|
...response,
|
|
@@ -327,6 +435,55 @@ export class AcpCatalogRuntimeAdapter extends EventEmitter implements AgentRunti
|
|
|
327
435
|
});
|
|
328
436
|
}
|
|
329
437
|
|
|
438
|
+
async sendInput(input: SendAgentInputInput) {
|
|
439
|
+
const owner = this.requireSessionOwner(input.providerSessionId);
|
|
440
|
+
const agent = await this.agentFor(owner.agentId);
|
|
441
|
+
return agent.sendInput({
|
|
442
|
+
...input,
|
|
443
|
+
providerSessionId: owner.rawId,
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
async compactSession(providerSessionId: string) {
|
|
448
|
+
const owner = this.requireSessionOwner(providerSessionId);
|
|
449
|
+
const agent = await this.agentFor(owner.agentId);
|
|
450
|
+
return agent.compactSession(owner.rawId);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
async forkSession(input: { providerSessionId: string; atTurnId?: string | null }) {
|
|
454
|
+
const owner = this.requireSessionOwner(input.providerSessionId);
|
|
455
|
+
const agent = await this.agentFor(owner.agentId);
|
|
456
|
+
return scopedSession(owner.agentId, await agent.forkSession({
|
|
457
|
+
...input,
|
|
458
|
+
providerSessionId: owner.rawId,
|
|
459
|
+
}));
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
async getGoal(providerSessionId: string) {
|
|
463
|
+
const owner = this.requireSessionOwner(providerSessionId);
|
|
464
|
+
const agent = await this.agentFor(owner.agentId);
|
|
465
|
+
return agent.getGoal(owner.rawId);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
async setGoal(input: SetAgentGoalInput) {
|
|
469
|
+
const owner = this.requireSessionOwner(input.providerSessionId);
|
|
470
|
+
const agent = await this.agentFor(owner.agentId);
|
|
471
|
+
const goal = await agent.setGoal({
|
|
472
|
+
...input,
|
|
473
|
+
providerSessionId: owner.rawId,
|
|
474
|
+
});
|
|
475
|
+
return {
|
|
476
|
+
...goal,
|
|
477
|
+
providerSessionId: encodeSessionId(owner.agentId, goal.providerSessionId),
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
async clearGoal(providerSessionId: string) {
|
|
482
|
+
const owner = this.requireSessionOwner(providerSessionId);
|
|
483
|
+
const agent = await this.agentFor(owner.agentId);
|
|
484
|
+
return agent.clearGoal(owner.rawId);
|
|
485
|
+
}
|
|
486
|
+
|
|
330
487
|
mapProviderRequest(
|
|
331
488
|
request: AgentProviderRequest,
|
|
332
489
|
options: { approvalMode: 'yolo' | 'guarded' },
|
|
@@ -336,10 +493,17 @@ export class AcpCatalogRuntimeAdapter extends EventEmitter implements AgentRunti
|
|
|
336
493
|
if (!owner || !agent) {
|
|
337
494
|
return null;
|
|
338
495
|
}
|
|
496
|
+
const scopedProviderSessionId = request.params && typeof request.params === 'object'
|
|
497
|
+
? String((request.params as Record<string, unknown>).sessionId ?? '')
|
|
498
|
+
: '';
|
|
499
|
+
const sessionOwner = decodeScopedId(scopedProviderSessionId);
|
|
500
|
+
if (!sessionOwner || sessionOwner.agentId !== owner.agentId || !sessionOwner.rawId) {
|
|
501
|
+
return null;
|
|
502
|
+
}
|
|
339
503
|
const params = request.params && typeof request.params === 'object'
|
|
340
504
|
? {
|
|
341
505
|
...(request.params as Record<string, unknown>),
|
|
342
|
-
sessionId:
|
|
506
|
+
sessionId: sessionOwner.rawId,
|
|
343
507
|
}
|
|
344
508
|
: request.params;
|
|
345
509
|
const mapping = agent.mapProviderRequest({
|
|
@@ -415,6 +579,7 @@ export class AcpCatalogRuntimeAdapter extends EventEmitter implements AgentRunti
|
|
|
415
579
|
const existing = this.agents.get(agentId);
|
|
416
580
|
if (existing) {
|
|
417
581
|
await existing.start();
|
|
582
|
+
this.mergeAgentCapabilities(existing);
|
|
418
583
|
return existing;
|
|
419
584
|
}
|
|
420
585
|
|
|
@@ -435,6 +600,7 @@ export class AcpCatalogRuntimeAdapter extends EventEmitter implements AgentRunti
|
|
|
435
600
|
this.agents.set(agentId, agent);
|
|
436
601
|
try {
|
|
437
602
|
await agent.start();
|
|
603
|
+
this.mergeAgentCapabilities(agent);
|
|
438
604
|
return agent;
|
|
439
605
|
} catch (error) {
|
|
440
606
|
this.agents.delete(agentId);
|
|
@@ -442,6 +608,40 @@ export class AcpCatalogRuntimeAdapter extends EventEmitter implements AgentRunti
|
|
|
442
608
|
}
|
|
443
609
|
}
|
|
444
610
|
|
|
611
|
+
private mergeAgentCapabilities(agent: AcpRuntimeAdapter) {
|
|
612
|
+
void agent;
|
|
613
|
+
this.recomputeAgentCapabilities();
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
private refreshAgentSessionCapabilities(
|
|
617
|
+
agentId: string,
|
|
618
|
+
agent: AcpRuntimeAdapter,
|
|
619
|
+
) {
|
|
620
|
+
for (const key of this.modelCache.keys()) {
|
|
621
|
+
if (key.startsWith(`${agentId}\0`)) {
|
|
622
|
+
this.modelCache.delete(key);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
this.mergeAgentCapabilities(agent);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
private recomputeAgentCapabilities() {
|
|
629
|
+
const next = structuredClone(catalogCapabilities);
|
|
630
|
+
for (const child of this.agents.values()) {
|
|
631
|
+
next.turns.steer ||= child.capabilities.turns.steer;
|
|
632
|
+
next.turns.compact ||= child.capabilities.turns.compact;
|
|
633
|
+
next.branching.fork ||= child.capabilities.branching.fork;
|
|
634
|
+
next.controls.goals ||= child.capabilities.controls.goals;
|
|
635
|
+
next.controls.performanceMode ||= child.capabilities.controls.performanceMode;
|
|
636
|
+
next.management.mcpStatus ||= child.capabilities.management.mcpStatus;
|
|
637
|
+
next.management.skills ||= child.capabilities.management.skills;
|
|
638
|
+
next.management.hooks ||= child.capabilities.management.hooks;
|
|
639
|
+
}
|
|
640
|
+
for (const section of Object.keys(next) as Array<keyof AgentProviderCapabilities>) {
|
|
641
|
+
Object.assign(this.capabilities[section], next[section]);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
445
645
|
private async createAgent(entry: AcpAgentCatalogEntry) {
|
|
446
646
|
const env = entry.id === 'codex'
|
|
447
647
|
? await loadCodexAcpEnvironment(
|
|
@@ -461,9 +661,18 @@ export class AcpCatalogRuntimeAdapter extends EventEmitter implements AgentRunti
|
|
|
461
661
|
: {}),
|
|
462
662
|
});
|
|
463
663
|
agent.on('event', (event: AgentRuntimeEvent) => {
|
|
664
|
+
const providerSessionId = encodeSessionId(entry.id, event.providerSessionId);
|
|
464
665
|
this.emit('event', {
|
|
465
666
|
...event,
|
|
466
|
-
providerSessionId
|
|
667
|
+
providerSessionId,
|
|
668
|
+
...(event.type === 'goal.updated'
|
|
669
|
+
? {
|
|
670
|
+
goal: {
|
|
671
|
+
...event.goal,
|
|
672
|
+
providerSessionId,
|
|
673
|
+
},
|
|
674
|
+
}
|
|
675
|
+
: {}),
|
|
467
676
|
} satisfies AgentRuntimeEvent);
|
|
468
677
|
});
|
|
469
678
|
agent.on('provider-request', (request: AgentProviderRequest) => {
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
REMOTE_CODEX_HARNESS_EXTENSION_PROTOCOL,
|
|
5
|
+
type HarnessExtensionEventEnvelope,
|
|
6
|
+
} from './extensions';
|
|
7
|
+
import {
|
|
8
|
+
HarnessExtensionInvocationError,
|
|
9
|
+
HarnessExtensionRegistry,
|
|
10
|
+
} from './extension-registry';
|
|
11
|
+
|
|
12
|
+
const descriptor = {
|
|
13
|
+
id: 'fixture.session',
|
|
14
|
+
version: 1,
|
|
15
|
+
stability: 'experimental' as const,
|
|
16
|
+
methods: ['compact'],
|
|
17
|
+
events: ['checkpoint'],
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
describe('HarnessExtensionRegistry', () => {
|
|
21
|
+
it('enforces one owner and deduplicates idempotent calls', async () => {
|
|
22
|
+
const registry = new HarnessExtensionRegistry();
|
|
23
|
+
const request = vi.fn(async () => ({ compacted: true }));
|
|
24
|
+
registry.register({
|
|
25
|
+
ownerId: 'owner-1',
|
|
26
|
+
descriptor,
|
|
27
|
+
transport: { request },
|
|
28
|
+
capabilityPatch: {
|
|
29
|
+
turns: { compact: true },
|
|
30
|
+
controls: { goals: true },
|
|
31
|
+
},
|
|
32
|
+
paramMappers: {
|
|
33
|
+
compact: (envelope) => ({
|
|
34
|
+
sessionId: (envelope.params as { providerSessionId: string }).providerSessionId,
|
|
35
|
+
}),
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
expect(() => registry.register({
|
|
39
|
+
ownerId: 'owner-2',
|
|
40
|
+
descriptor,
|
|
41
|
+
transport: { request },
|
|
42
|
+
})).toThrow(/already owned/);
|
|
43
|
+
const input = {
|
|
44
|
+
extensionId: 'fixture.session',
|
|
45
|
+
extensionVersion: 1,
|
|
46
|
+
method: 'compact',
|
|
47
|
+
operationId: 'operation-1',
|
|
48
|
+
idempotencyKey: 'thread-1:compact:1',
|
|
49
|
+
params: { providerSessionId: 'session-1' },
|
|
50
|
+
};
|
|
51
|
+
const [first, second] = await Promise.all([
|
|
52
|
+
registry.invoke(input),
|
|
53
|
+
registry.invoke(input),
|
|
54
|
+
]);
|
|
55
|
+
expect(first).toEqual({ compacted: true });
|
|
56
|
+
expect(second).toEqual(first);
|
|
57
|
+
expect(request).toHaveBeenCalledTimes(1);
|
|
58
|
+
expect(request).toHaveBeenCalledWith(
|
|
59
|
+
'remoteCodex/fixture.session/v1/compact',
|
|
60
|
+
{ sessionId: 'session-1' },
|
|
61
|
+
expect.any(AbortSignal),
|
|
62
|
+
);
|
|
63
|
+
expect(registry.effectiveCapabilities({
|
|
64
|
+
sessions: { list: true, read: true, resume: true, importLocal: false },
|
|
65
|
+
turns: { start: true, streamInput: false, steer: false, interrupt: true, compact: false },
|
|
66
|
+
branching: { fork: false, hardRollback: false, resumeAt: false, rewindFiles: false },
|
|
67
|
+
controls: {
|
|
68
|
+
planMode: true,
|
|
69
|
+
permissionRequests: true,
|
|
70
|
+
sandboxMode: true,
|
|
71
|
+
performanceMode: false,
|
|
72
|
+
goals: false,
|
|
73
|
+
},
|
|
74
|
+
management: {
|
|
75
|
+
models: false,
|
|
76
|
+
mcpStatus: false,
|
|
77
|
+
skills: false,
|
|
78
|
+
hooks: false,
|
|
79
|
+
hookTrust: false,
|
|
80
|
+
hostConfigFiles: false,
|
|
81
|
+
providerSettings: false,
|
|
82
|
+
},
|
|
83
|
+
usage: { contextWindow: true, tokenUsage: true, costUsd: false },
|
|
84
|
+
})).toMatchObject({
|
|
85
|
+
turns: { compact: true },
|
|
86
|
+
controls: { goals: true },
|
|
87
|
+
});
|
|
88
|
+
await expect(registry.invoke({
|
|
89
|
+
...input,
|
|
90
|
+
params: { providerSessionId: 'session-2' },
|
|
91
|
+
})).rejects.toMatchObject({
|
|
92
|
+
payload: { code: 'idempotency_conflict', retryable: false },
|
|
93
|
+
});
|
|
94
|
+
await expect(registry.invoke({
|
|
95
|
+
...input,
|
|
96
|
+
extensionVersion: 2,
|
|
97
|
+
idempotencyKey: 'thread-1:compact:v2',
|
|
98
|
+
})).rejects.toMatchObject({
|
|
99
|
+
payload: { code: 'extension_method_unavailable', retryable: false },
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('times out, aborts transport, and permits an explicit retry', async () => {
|
|
104
|
+
const registry = new HarnessExtensionRegistry();
|
|
105
|
+
let calls = 0;
|
|
106
|
+
registry.register({
|
|
107
|
+
ownerId: 'owner-1',
|
|
108
|
+
descriptor,
|
|
109
|
+
transport: {
|
|
110
|
+
request: async (_method, _params, signal) => {
|
|
111
|
+
calls += 1;
|
|
112
|
+
await new Promise((_resolve, reject) =>
|
|
113
|
+
signal.addEventListener('abort', () => reject(signal.reason), { once: true }));
|
|
114
|
+
return {};
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
const input = {
|
|
119
|
+
extensionId: 'fixture.session',
|
|
120
|
+
extensionVersion: 1,
|
|
121
|
+
method: 'compact',
|
|
122
|
+
operationId: 'operation-timeout',
|
|
123
|
+
idempotencyKey: 'thread-1:compact:timeout',
|
|
124
|
+
params: {},
|
|
125
|
+
timeoutMs: 5,
|
|
126
|
+
};
|
|
127
|
+
await expect(registry.invoke(input)).rejects.toBeInstanceOf(
|
|
128
|
+
HarnessExtensionInvocationError,
|
|
129
|
+
);
|
|
130
|
+
await expect(registry.invoke(input)).rejects.toMatchObject({
|
|
131
|
+
payload: { code: 'extension_timeout', retryable: true },
|
|
132
|
+
});
|
|
133
|
+
expect(calls).toBe(2);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('cancels promptly even when transport ignores abort and does not dispatch pre-aborted calls', async () => {
|
|
137
|
+
const registry = new HarnessExtensionRegistry();
|
|
138
|
+
const request = vi.fn(async () => new Promise<never>(() => {}));
|
|
139
|
+
registry.register({
|
|
140
|
+
ownerId: 'owner-1',
|
|
141
|
+
descriptor,
|
|
142
|
+
transport: { request },
|
|
143
|
+
});
|
|
144
|
+
const preAborted = new AbortController();
|
|
145
|
+
preAborted.abort(new Error('already cancelled'));
|
|
146
|
+
const input = {
|
|
147
|
+
extensionId: 'fixture.session',
|
|
148
|
+
extensionVersion: 1,
|
|
149
|
+
method: 'compact',
|
|
150
|
+
operationId: 'operation-cancel',
|
|
151
|
+
idempotencyKey: 'thread-1:compact:cancel',
|
|
152
|
+
params: {},
|
|
153
|
+
timeoutMs: 10_000,
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
await expect(registry.invoke({
|
|
157
|
+
...input,
|
|
158
|
+
signal: preAborted.signal,
|
|
159
|
+
})).rejects.toMatchObject({
|
|
160
|
+
payload: { code: 'extension_cancelled', retryable: true },
|
|
161
|
+
});
|
|
162
|
+
expect(request).not.toHaveBeenCalled();
|
|
163
|
+
|
|
164
|
+
const controller = new AbortController();
|
|
165
|
+
const pending = registry.invoke({ ...input, signal: controller.signal });
|
|
166
|
+
await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(1));
|
|
167
|
+
controller.abort(new Error('cancel now'));
|
|
168
|
+
await expect(pending).rejects.toMatchObject({
|
|
169
|
+
payload: { code: 'extension_cancelled', retryable: true },
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('validates and deduplicates declared extension events', () => {
|
|
174
|
+
const registry = new HarnessExtensionRegistry();
|
|
175
|
+
registry.register({
|
|
176
|
+
ownerId: 'owner-1',
|
|
177
|
+
descriptor,
|
|
178
|
+
transport: { request: async () => ({}) },
|
|
179
|
+
});
|
|
180
|
+
const events: HarnessExtensionEventEnvelope[] = [];
|
|
181
|
+
registry.on('event', (event) => events.push(event));
|
|
182
|
+
const event: HarnessExtensionEventEnvelope = {
|
|
183
|
+
protocol: REMOTE_CODEX_HARNESS_EXTENSION_PROTOCOL,
|
|
184
|
+
extensionId: 'fixture.session',
|
|
185
|
+
extensionVersion: 1,
|
|
186
|
+
event: 'checkpoint',
|
|
187
|
+
operationId: 'operation-1',
|
|
188
|
+
providerSessionId: 'session-1',
|
|
189
|
+
providerTurnId: 'turn-1',
|
|
190
|
+
providerItemId: 'checkpoint-1',
|
|
191
|
+
sequence: 1,
|
|
192
|
+
payload: { status: 'completed' },
|
|
193
|
+
};
|
|
194
|
+
expect(registry.handleEvent('owner-1', event)).toBe(true);
|
|
195
|
+
expect(registry.handleEvent('owner-1', event)).toBe(false);
|
|
196
|
+
expect(events).toHaveLength(1);
|
|
197
|
+
});
|
|
198
|
+
});
|