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
@@ -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
  }
@@ -142,8 +152,8 @@ export class AcpCatalogRuntimeAdapter extends EventEmitter implements AgentRunti
142
152
  readonly managementSchema = catalogManagementSchema;
143
153
  readonly installation: AgentBackendInstallationDto = {
144
154
  packageName: null,
145
- installed: false,
146
- installedVersion: null,
155
+ installed: true,
156
+ installedVersion: 'Built in · 0 ACP agents ready',
147
157
  latestVersion: null,
148
158
  installCommand: null,
149
159
  updateCommand: null,
@@ -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 { ...this.status };
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
- const cacheKey = `${agentId}\0${cwd}`;
223
- const cached = this.modelCache.get(cacheKey);
224
- if (cached && Date.now() - cached.at < 30_000) {
225
- return cached.models;
226
- }
227
- const agent = await this.agentFor(agentId);
228
- let models = await agent.inspectModelOptions(cwd);
229
- if (models.length === 1 && models[0]?.model === 'default') {
230
- const commandModels = await this.catalog.listCommandModels(agentId);
231
- if (commandModels.length > 0) {
232
- models = commandModels;
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;
233
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
+ }
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
- this.modelCache.set(cacheKey, { at: Date.now(), models });
236
- return models;
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
- const agent = await this.agentFor(agentId);
283
- const response = await agent.startSession(delegatedSessionInput(input));
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: response.model ?? input.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
- const agent = await this.agentFor(owner.agentId);
298
- const response = await agent.resumeSession({
299
- ...input,
300
- providerSessionId: owner.rawId,
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: owner.rawId,
506
+ sessionId: sessionOwner.rawId,
343
507
  }
344
508
  : request.params;
345
509
  const mapping = agent.mapProviderRequest({
@@ -393,10 +557,9 @@ export class AcpCatalogRuntimeAdapter extends EventEmitter implements AgentRunti
393
557
  const entries = await this.catalog.list({ force });
394
558
  const ready = entries.filter((entry) => entry.availability === 'ready');
395
559
  const baseInstalled = entries.filter((entry) => entry.availability !== 'base_missing');
396
- this.installation.installed = baseInstalled.length > 0;
397
- this.installation.installedVersion = ready.length > 0
398
- ? `${ready.length} ACP agent${ready.length === 1 ? '' : 's'} ready`
399
- : null;
560
+ this.installation.installed = true;
561
+ this.installation.installedVersion =
562
+ `Built in · ${ready.length} ACP agent${ready.length === 1 ? '' : 's'} ready`;
400
563
  this.installation.lastError = ready.length > 0
401
564
  ? null
402
565
  : baseInstalled.length > 0
@@ -416,6 +579,7 @@ export class AcpCatalogRuntimeAdapter extends EventEmitter implements AgentRunti
416
579
  const existing = this.agents.get(agentId);
417
580
  if (existing) {
418
581
  await existing.start();
582
+ this.mergeAgentCapabilities(existing);
419
583
  return existing;
420
584
  }
421
585
 
@@ -436,6 +600,7 @@ export class AcpCatalogRuntimeAdapter extends EventEmitter implements AgentRunti
436
600
  this.agents.set(agentId, agent);
437
601
  try {
438
602
  await agent.start();
603
+ this.mergeAgentCapabilities(agent);
439
604
  return agent;
440
605
  } catch (error) {
441
606
  this.agents.delete(agentId);
@@ -443,6 +608,40 @@ export class AcpCatalogRuntimeAdapter extends EventEmitter implements AgentRunti
443
608
  }
444
609
  }
445
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
+
446
645
  private async createAgent(entry: AcpAgentCatalogEntry) {
447
646
  const env = entry.id === 'codex'
448
647
  ? await loadCodexAcpEnvironment(
@@ -462,9 +661,18 @@ export class AcpCatalogRuntimeAdapter extends EventEmitter implements AgentRunti
462
661
  : {}),
463
662
  });
464
663
  agent.on('event', (event: AgentRuntimeEvent) => {
664
+ const providerSessionId = encodeSessionId(entry.id, event.providerSessionId);
465
665
  this.emit('event', {
466
666
  ...event,
467
- providerSessionId: encodeSessionId(entry.id, event.providerSessionId),
667
+ providerSessionId,
668
+ ...(event.type === 'goal.updated'
669
+ ? {
670
+ goal: {
671
+ ...event.goal,
672
+ providerSessionId,
673
+ },
674
+ }
675
+ : {}),
468
676
  } satisfies AgentRuntimeEvent);
469
677
  });
470
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
+ });