diff --git a/apps/cli/src/agent/acp/AcpBackend.ts b/apps/cli/src/agent/acp/AcpBackend.ts index d219cd87b0..8e51b4c559 100644 --- a/apps/cli/src/agent/acp/AcpBackend.ts +++ b/apps/cli/src/agent/acp/AcpBackend.ts @@ -766,6 +766,7 @@ export class AcpBackend implements AgentBackend { private readonly sessionUpdateShapeLogger = createEventShapeLoggerForLog({ logger, scope: 'acp-backend' }); private connection: AcpClientConnection | null = null; private acpSessionId: string | null = null; + private agentCapabilities: InitializeResponse['agentCapabilities'] | null = null; private disposed = false; private replayCapture: AcpReplayCapture | null = null; /** Sole tool lifecycle/merge/timeout/finalization owner. */ @@ -956,6 +957,7 @@ export class AcpBackend implements AgentBackend { this.process = null; this.connection = null; this.acpSessionId = null; + this.agentCapabilities = null; connection?.close(); @@ -1577,6 +1579,8 @@ export class AcpBackend implements AgentBackend { logger.debug(`[AcpBackend] Initialize completed`); + this.agentCapabilities = (initResponse as InitializeResponse | null)?.agentCapabilities ?? null; + if (this.options.authentication) { const advertisedMethodIds = new Set(); const methods = (initResponse as InitializeResponse | null)?.authMethods ?? []; @@ -1642,6 +1646,14 @@ export class AcpBackend implements AgentBackend { } } + /** + * Whether the connected agent advertised ACP `session/load` support in its + * initialize capabilities. False until initialize completes. + */ + supportsSessionLoad(): boolean { + return this.agentCapabilities?.loadSession === true; + } + async startSession(initialPrompt?: string): Promise { if (this.disposed) { throw new Error('Backend has been disposed'); diff --git a/apps/cli/src/agent/acp/__tests__/AcpBackend.loadSessionCapability.test.ts b/apps/cli/src/agent/acp/__tests__/AcpBackend.loadSessionCapability.test.ts new file mode 100644 index 0000000000..09aa7cd290 --- /dev/null +++ b/apps/cli/src/agent/acp/__tests__/AcpBackend.loadSessionCapability.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest'; + +import { AcpBackend } from '../AcpBackend'; +import { writeAcpTestAgentScript } from '../testkit/subprocessHarness'; +import { withTempDir } from '@/testkit/fs/tempDir'; + +function writeCapabilityAgentScript(params: { dir: string; declareLoadSession: boolean }): string { + return writeAcpTestAgentScript({ + dir: params.dir, + fileName: params.declareLoadSession ? 'fake-acp-load-capable.mjs' : 'fake-acp-load-incapable.mjs', + source: ` + import readline from 'node:readline'; + const rl = readline.createInterface({ input: process.stdin }); + const send = (value) => process.stdout.write(JSON.stringify(value) + '\\n'); + rl.on('line', (line) => { + const request = JSON.parse(line); + if (request.method === 'initialize') { + send({ + jsonrpc: '2.0', + id: request.id, + result: { + protocolVersion: 1, + authMethods: [], + agentCapabilities: { loadSession: ${params.declareLoadSession} }, + }, + }); + return; + } + if (request.method === 'session/new') { + send({ jsonrpc: '2.0', id: request.id, result: { sessionId: 'fresh-session' } }); + return; + } + send({ jsonrpc: '2.0', id: request.id, result: {} }); + }); + `, + }); +} + +describe('AcpBackend session load capability', () => { + it('reports session load support when the agent advertises loadSession', async () => { + await withTempDir('happier-acp-load-capable-', async (dir) => { + const scriptPath = writeCapabilityAgentScript({ dir, declareLoadSession: true }); + const backend = new AcpBackend({ + agentName: 'test', + cwd: dir, + command: process.execPath, + args: [scriptPath], + }); + try { + await backend.startSession(); + expect(backend.supportsSessionLoad()).toBe(true); + } finally { + await backend.dispose(); + } + }); + }, 20_000); + + it('reports no session load support when the agent omits the capability', async () => { + await withTempDir('happier-acp-load-incapable-', async (dir) => { + const scriptPath = writeCapabilityAgentScript({ dir, declareLoadSession: false }); + const backend = new AcpBackend({ + agentName: 'test', + cwd: dir, + command: process.execPath, + args: [scriptPath], + }); + try { + await backend.startSession(); + expect(backend.supportsSessionLoad()).toBe(false); + } finally { + await backend.dispose(); + } + }); + }, 20_000); + + it('reports no session load support before initialize completes', () => { + const backend = new AcpBackend({ + agentName: 'test', + cwd: '/tmp', + command: process.execPath, + args: ['-e', ''], + }); + expect(backend.supportsSessionLoad()).toBe(false); + }); +}); diff --git a/apps/cli/src/agent/acp/catalog/configured/createConfiguredAcpRuntime.ts b/apps/cli/src/agent/acp/catalog/configured/createConfiguredAcpRuntime.ts index 69a0f1b69d..eb5da306dd 100644 --- a/apps/cli/src/agent/acp/catalog/configured/createConfiguredAcpRuntime.ts +++ b/apps/cli/src/agent/acp/catalog/configured/createConfiguredAcpRuntime.ts @@ -14,6 +14,7 @@ import { getSessionNotificationTitle } from '@/agent/runtime/readyNotificationCo import type { SessionProviderInputConsumer } from '@/agent/runtime/sessionInput/types'; import { createConfiguredAcpBackend } from './createConfiguredAcpBackend'; +import { createConfiguredAcpSessionIdentityPublication } from './createConfiguredAcpSessionIdentityPublication'; import type { ResolvedConfiguredAcpBackend } from './resolveConfiguredAcpBackendFromAccountSettings'; type CreateConfiguredAcpRuntimeParams = Readonly<{ @@ -52,6 +53,11 @@ export function createConfiguredAcpRuntime(params: CreateConfiguredAcpRuntimePar } }; + // The backend is created lazily by ensureBackend (below) and only learns + // whether the adapter supports session/load once the ACP initialize + // handshake completes; the identity publication re-checks on every bind. + let sessionLoadSupportProbe: { supportsSessionLoad?: () => boolean } | null = null; + return createAcpRuntime({ provider: `acp:${params.backend.backendId}`, directory: params.directory, @@ -61,10 +67,10 @@ export function createConfiguredAcpRuntime(params: CreateConfiguredAcpRuntimePar mcpServers: params.mcpServers, permissionHandler: params.permissionHandler, onThinkingChange: params.onThinkingChange, - sessionIdentity: { - kind: 'runtime-only', - reason: 'vendor-resume-unsupported', - }, + sessionIdentity: createConfiguredAcpSessionIdentityPublication({ + session: params.session, + isSessionLoadSupported: () => sessionLoadSupportProbe?.supportsSessionLoad?.() === true, + }), memoryRecallGuidance: params.memoryRecallGuidance, hooks: { onPermissionRequest: (evt) => { @@ -86,6 +92,7 @@ export function createConfiguredAcpRuntime(params: CreateConfiguredAcpRuntimePar permissionHandler: params.permissionHandler, ...(permissionMode ? { permissionMode } : {}), }); + sessionLoadSupportProbe = backend as unknown as { supportsSessionLoad?: () => boolean }; logger.debug(`[${params.loggerLabel}] Backend created`); return backend as unknown as AgentBackend; }, diff --git a/apps/cli/src/agent/acp/catalog/configured/createConfiguredAcpSessionIdentityPublication.test.ts b/apps/cli/src/agent/acp/catalog/configured/createConfiguredAcpSessionIdentityPublication.test.ts new file mode 100644 index 0000000000..3364da9d8e --- /dev/null +++ b/apps/cli/src/agent/acp/catalog/configured/createConfiguredAcpSessionIdentityPublication.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; + +import { createConfiguredAcpSessionIdentityPublication } from './createConfiguredAcpSessionIdentityPublication'; + +function createFakeSession(initial: Record = {}) { + let metadata: Record = { ...initial }; + const updateCalls: Array> = []; + return { + session: { + sessionId: 'happier-session-1', + getMetadataSnapshot: () => metadata as never, + updateMetadata: (updater: (value: Record) => Record) => { + metadata = updater(metadata); + updateCalls.push(metadata); + }, + }, + getMetadata: () => metadata, + updateCalls, + }; +} + +describe('createConfiguredAcpSessionIdentityPublication', () => { + it('publishes the bound ACP session id to customAcpSessionId metadata when the adapter supports session load', async () => { + const fake = createFakeSession(); + const publication = createConfiguredAcpSessionIdentityPublication({ + session: fake.session as never, + isSessionLoadSupported: () => true, + }); + + expect(publication.kind).toBe('persist-bound'); + if (publication.kind !== 'persist-bound') throw new Error('expected persist-bound'); + await publication.persistBound({ generation: 0, operation: 'create', vendorSessionId: 'acp-session-1' }); + + expect(fake.getMetadata().customAcpSessionId).toBe('acp-session-1'); + }); + + it('publishes nothing when the adapter does not support session load', async () => { + const fake = createFakeSession(); + const publication = createConfiguredAcpSessionIdentityPublication({ + session: fake.session as never, + isSessionLoadSupported: () => false, + }); + + if (publication.kind !== 'persist-bound') throw new Error('expected persist-bound'); + await publication.persistBound({ generation: 0, operation: 'create', vendorSessionId: 'acp-session-1' }); + + expect(fake.updateCalls).toHaveLength(0); + expect(fake.getMetadata().customAcpSessionId).toBeUndefined(); + }); + + it('re-evaluates adapter support on every publication so capability is read after initialize', async () => { + const fake = createFakeSession(); + let supported = false; + const publication = createConfiguredAcpSessionIdentityPublication({ + session: fake.session as never, + isSessionLoadSupported: () => supported, + }); + + if (publication.kind !== 'persist-bound') throw new Error('expected persist-bound'); + await publication.persistBound({ generation: 0, operation: 'create', vendorSessionId: 'acp-session-1' }); + expect(fake.getMetadata().customAcpSessionId).toBeUndefined(); + + supported = true; + await publication.persistBound({ generation: 1, operation: 'create', vendorSessionId: 'acp-session-2' }); + expect(fake.getMetadata().customAcpSessionId).toBe('acp-session-2'); + }); +}); diff --git a/apps/cli/src/agent/acp/catalog/configured/createConfiguredAcpSessionIdentityPublication.ts b/apps/cli/src/agent/acp/catalog/configured/createConfiguredAcpSessionIdentityPublication.ts new file mode 100644 index 0000000000..2914e12ab0 --- /dev/null +++ b/apps/cli/src/agent/acp/catalog/configured/createConfiguredAcpSessionIdentityPublication.ts @@ -0,0 +1,32 @@ +import type { AcpSessionIdentityPublication } from '@/agent/acp/runtime/sessionIdentityBinding'; +import type { ApiSessionClient } from '@/api/session/sessionClient'; +import { createVendorResumeIdMetadataPublisher } from '@/session/metadata/createVendorResumeIdMetadataPublisher'; + +/** + * Session identity publication for configured (user-defined) ACP backends. + * + * Unlike built-in catalog agents, a configured backend is an arbitrary ACP + * adapter: some implement `session/load` (vendor resume), many do not. The ACP + * `initialize` handshake advertises this via `agentCapabilities.loadSession`, + * so publication is gated on the adapter's declared capability at bind time: + * adapters without load support publish nothing, their sessions carry no + * `customAcpSessionId`, and the runtime_checked resume policy keeps resume + * unavailable for them instead of failing against an adapter that cannot load. + */ +export function createConfiguredAcpSessionIdentityPublication(params: Readonly<{ + session: ApiSessionClient; + isSessionLoadSupported: () => boolean; +}>): AcpSessionIdentityPublication { + const publisher = createVendorResumeIdMetadataPublisher({ + agentId: 'customAcp', + getMetadataSnapshot: () => params.session.getMetadataSnapshot(), + updateMetadata: (updater) => params.session.updateMetadata(updater), + }); + return { + kind: 'persist-bound', + persistBound: async (event) => { + if (!params.isSessionLoadSupported()) return; + await publisher.persistBound(event); + }, + }; +} diff --git a/apps/cli/src/agent/acp/catalog/runCatalogDefinedAcpAgent.ts b/apps/cli/src/agent/acp/catalog/runCatalogDefinedAcpAgent.ts index 9e57a20bf9..fa56bb055f 100644 --- a/apps/cli/src/agent/acp/catalog/runCatalogDefinedAcpAgent.ts +++ b/apps/cli/src/agent/acp/catalog/runCatalogDefinedAcpAgent.ts @@ -1,6 +1,6 @@ import React from 'react'; -import type { AgentId } from '@happier-dev/agents'; +import type { AgentId, VendorResumeSupportLevel } from '@happier-dev/agents'; import { AGENTS_CORE, getProviderCliRuntimeSpec } from '@happier-dev/agents'; import type { Credentials } from '@/persistence'; @@ -67,7 +67,7 @@ export async function runCatalogDefinedAcpAgent( messageBuffer, mcpServers, permissionHandler, - sessionIdentity: AGENTS_CORE[agentId].resume.vendorResume === 'unsupported' + sessionIdentity: (AGENTS_CORE[agentId].resume.vendorResume as VendorResumeSupportLevel) === 'unsupported' ? { kind: 'runtime-only', reason: 'vendor-resume-unsupported' } : { kind: 'manifest-metadata' }, onThinkingChange: setThinking, diff --git a/apps/cli/src/agent/acp/runtime/createCatalogProviderAcpRuntime.ts b/apps/cli/src/agent/acp/runtime/createCatalogProviderAcpRuntime.ts index 0b7937381f..089bc5d491 100644 --- a/apps/cli/src/agent/acp/runtime/createCatalogProviderAcpRuntime.ts +++ b/apps/cli/src/agent/acp/runtime/createCatalogProviderAcpRuntime.ts @@ -14,7 +14,7 @@ import { } from '@/settings/notifications/permissionRequestPush'; import { createAgentSessionMediaPersister } from '@/session/sessionMedia/createAgentSessionMediaPersister'; import { createSessionMediaAccessPolicy } from '@/session/sessionMedia/createSessionMediaAccessPolicy'; -import { AGENTS_CORE, getProviderCliRuntimeSpec, isAgentMediaCapabilitySupported } from '@happier-dev/agents'; +import { AGENTS_CORE, getProviderCliRuntimeSpec, isAgentMediaCapabilitySupported, type VendorResumeSupportLevel } from '@happier-dev/agents'; import { getSessionNotificationTitle } from '@/agent/runtime/readyNotificationContext'; import type { SessionProviderInputConsumer } from '@/agent/runtime/sessionInput/types'; import { createVendorResumeIdMetadataPublisher } from '@/session/metadata/createVendorResumeIdMetadataPublisher'; @@ -125,7 +125,7 @@ export function createCatalogProviderAcpRuntime { expect(typeof res.backends.kiro.supportsVendorResume).toBe('boolean'); expect(res.backends.customAcp).toMatchObject({ available: true, - supportsVendorResume: false, + supportsVendorResume: true, }); expect(res.backends.pi).toBeTruthy(); expect(typeof res.backends.pi.supportsVendorResume).toBe('boolean'); diff --git a/apps/cli/src/daemon/sessions/runtimeSnapshot/resolveSessionRuntimeSnapshot.test.ts b/apps/cli/src/daemon/sessions/runtimeSnapshot/resolveSessionRuntimeSnapshot.test.ts index 6710476b96..fae5fbe3d6 100644 --- a/apps/cli/src/daemon/sessions/runtimeSnapshot/resolveSessionRuntimeSnapshot.test.ts +++ b/apps/cli/src/daemon/sessions/runtimeSnapshot/resolveSessionRuntimeSnapshot.test.ts @@ -285,6 +285,39 @@ describe('resolveSessionRuntimeSnapshot', () => { expect(result.spawnOptions.resume).toBeUndefined(); }); + it('inherits the configured-backend vendor resume id from customAcp metadata for configured ACP attaches', () => { + const result = resolveSessionRuntimeSnapshot({ + incomingOptions: baseIncomingOptions({ + backendTarget: { kind: 'configuredAcpBackend', backendId: 'custom-kiro' }, + }), + persistedMetadata: { + flavor: 'acp:custom-kiro', + customAcpSessionId: 'acp-session-1', + }, + }); + + expect(result.snapshot.vendorResumeId).toEqual({ + value: 'acp-session-1', + updatedAt: null, + }); + expect(result.spawnOptions.resume).toBe('acp-session-1'); + }); + + it('drops the resume id when the persisted customAcp metadata belongs to a different configured backend', () => { + const result = resolveSessionRuntimeSnapshot({ + incomingOptions: baseIncomingOptions({ + backendTarget: { kind: 'configuredAcpBackend', backendId: 'custom-kiro' }, + }), + persistedMetadata: { + flavor: 'acp:other-backend', + customAcpSessionId: 'acp-session-1', + }, + }); + + expect(result.snapshot.vendorResumeId).toBeNull(); + expect(result.spawnOptions.resume).toBeUndefined(); + }); + it('preserves incoming controls without timestamps when no persisted or tracked snapshot exists', () => { const result = resolveSessionRuntimeSnapshot({ incomingOptions: baseIncomingOptions({ diff --git a/apps/cli/src/daemon/sessions/runtimeSnapshot/resolveSessionRuntimeSnapshot.ts b/apps/cli/src/daemon/sessions/runtimeSnapshot/resolveSessionRuntimeSnapshot.ts index c07bda07c4..0488afea2d 100644 --- a/apps/cli/src/daemon/sessions/runtimeSnapshot/resolveSessionRuntimeSnapshot.ts +++ b/apps/cli/src/daemon/sessions/runtimeSnapshot/resolveSessionRuntimeSnapshot.ts @@ -81,8 +81,16 @@ function readSessionId(options: SpawnSessionOptions): string | null { return normalizeNonEmptyString(options.existingSessionId) ?? normalizeNonEmptyString(options.sessionId); } -function readAgentIdFromOptions(options: SpawnSessionOptions | null | undefined): AgentId | null { - const rawAgentId = options?.backendTarget?.kind === 'builtInAgent' ? options.backendTarget.agentId : null; +function readConfiguredAcpBackendIdFromFlavor(metadata: unknown): string | null { + const flavor = (metadata as { flavor?: unknown } | null)?.flavor; + if (typeof flavor !== 'string') return null; + const trimmed = flavor.trim(); + if (!trimmed.toLowerCase().startsWith('acp:')) return null; + const backendId = trimmed.slice(4).trim(); + return backendId.length > 0 ? backendId : null; +} + +function readAgentIdFromOptions(options: SpawnSessionOptions | null | undefined): AgentId | null { const rawAgentId = options?.backendTarget?.kind === 'builtInAgent' ? options.backendTarget.agentId : null; return typeof rawAgentId === 'string' && (AGENT_IDS as readonly string[]).includes(rawAgentId) ? rawAgentId as AgentId : null; @@ -255,13 +263,19 @@ function chooseVendorResumeId(params: ResolveSessionRuntimeSnapshotParams): Sess if (incomingResume) { return { value: incomingResume, updatedAt: null }; } - if (params.incomingOptions.backendTarget?.kind === 'configuredAcpBackend') { - return null; - } const agentId = readAgentIdFromOptions(params.incomingOptions) ?? readAgentIdFromOptions(params.trackedSpawnOptions) ?? inferAgentIdFromSessionMetadata(metadata); + if (params.incomingOptions.backendTarget?.kind === 'configuredAcpBackend') { + // A configured-backend session must never inherit a built-in agent's resume id from + // contradictory persisted metadata, nor a different configured backend's session id + // (metadata flavor `acp:` must match the spawn target's backend id). + const flavorBackendId = readConfiguredAcpBackendIdFromFlavor(metadata); + if (agentId !== 'customAcp' || (flavorBackendId !== null && flavorBackendId !== params.incomingOptions.backendTarget.backendId)) { + return null; + } + } const metadataVendorResumeId = resolveVendorResumeIdFromSessionMetadata(agentId, metadata); const value = normalizeNonEmptyString(params.trackedSpawnOptions?.resume) diff --git a/apps/cli/src/session/metadata/createVendorResumeIdMetadataPublisher.test.ts b/apps/cli/src/session/metadata/createVendorResumeIdMetadataPublisher.test.ts index 36a6e8a51e..4a5edeae0e 100644 --- a/apps/cli/src/session/metadata/createVendorResumeIdMetadataPublisher.test.ts +++ b/apps/cli/src/session/metadata/createVendorResumeIdMetadataPublisher.test.ts @@ -138,8 +138,8 @@ describe('createVendorResumeIdMetadataPublisher', () => { expect(updateMetadata).toHaveBeenCalledTimes(2); }); - it('rejects an empty bound identity and an agent without a vendor resume field', async () => { - const updateMetadata = vi.fn(async () => {}); + it('rejects an empty bound identity and publishes customAcpSessionId for configured ACP backends', async () => { + const updateMetadata = vi.fn(async (_updater: (metadata: Metadata) => Metadata) => {}); const publisher = createVendorResumeIdMetadataPublisher({ agentId: 'qwen', getMetadataSnapshot: () => createTestMetadata(), @@ -152,10 +152,21 @@ describe('createVendorResumeIdMetadataPublisher', () => { })).rejects.toThrow(/bound vendor session identity/i); expect(updateMetadata).not.toHaveBeenCalled(); - expect(() => createVendorResumeIdMetadataPublisher({ + // customAcp now declares customAcpSessionId for capability-gated configured-backend + // resume, so publisher construction no longer throws for it. + const customAcpPublisher = createVendorResumeIdMetadataPublisher({ agentId: 'customAcp', getMetadataSnapshot: () => createTestMetadata(), updateMetadata, - })).toThrow(/does not declare a vendor resume metadata field/i); + }); + await customAcpPublisher.persistBound({ + generation: 0, + operation: 'create', + vendorSessionId: 'acp-session-1', + }); + expect(updateMetadata).toHaveBeenCalledTimes(1); + const updater = updateMetadata.mock.calls[0]?.[0]; + expect(typeof updater).toBe('function'); + expect(updater?.(createTestMetadata())).toMatchObject({ customAcpSessionId: 'acp-session-1' }); }); }); diff --git a/packages/agents/src/manifest.ts b/packages/agents/src/manifest.ts index 0cf6c093af..a8fc13b8fe 100644 --- a/packages/agents/src/manifest.ts +++ b/packages/agents/src/manifest.ts @@ -359,7 +359,11 @@ export const AGENTS_CORE = { flavorAliases: ['custom-acp'], cloudConnect: null, connectedServices: null, - resume: { vendorResume: 'unsupported' }, + resume: { + vendorResume: 'experimental', + vendorResumeIdField: 'customAcpSessionId', + experimentalResumePolicy: 'runtime_checked', + }, sessionStorage: { direct: true, persisted: true }, sessionCapabilities: { sessionListing: 'unsupported', diff --git a/packages/agents/src/sessionControls/vendorResumePolicy.test.ts b/packages/agents/src/sessionControls/vendorResumePolicy.test.ts index 4982530c17..a91ef01dfc 100644 --- a/packages/agents/src/sessionControls/vendorResumePolicy.test.ts +++ b/packages/agents/src/sessionControls/vendorResumePolicy.test.ts @@ -35,6 +35,36 @@ describe('vendorResumePolicy', () => { expect(resolveVendorResumeIdFromSessionMetadata('grok', { grokSessionId: ' grok-session ' })).toBe('grok-session'); }); + it('gates configured ACP backend resume on a runtime-persisted session id', () => { + expect(AGENTS_CORE.customAcp.resume).toEqual({ + vendorResume: 'experimental', + vendorResumeIdField: 'customAcpSessionId', + experimentalResumePolicy: 'runtime_checked', + }); + expect(resolveVendorResumeIdFromSessionMetadata('customAcp', { customAcpSessionId: ' acp-session ' })).toBe('acp-session'); + expect(resolveVendorResumeIdFromSessionMetadata('customAcp', { customAcpSessionId: ' ' })).toBeNull(); + }); + + it('allows configured ACP backend sessions with a persisted session id and lets runtime load failures surface later', () => { + expect( + evaluateVendorResumeEligibility({ + agentId: 'customAcp', + metadata: { customAcpSessionId: 'acp-session' }, + accountSettings: {}, + }), + ).toEqual({ eligible: true, vendorResumeId: 'acp-session' }); + }); + + it('rejects configured ACP backend resume when the adapter never persisted a session id', () => { + expect( + evaluateVendorResumeEligibility({ + agentId: 'customAcp', + metadata: { flavor: 'acp:some-adapter' }, + accountSettings: {}, + }), + ).toEqual({ eligible: false, reasonCode: 'vendor_resume_id_missing' }); + }); + it('prefers vendor session ids from agentRuntimeDescriptorV1 over legacy top-level metadata', () => { expect(resolveVendorResumeIdFromSessionMetadata('codex', { agentRuntimeDescriptorV1: { diff --git a/packages/agents/src/sessionControls/vendorResumePolicy.ts b/packages/agents/src/sessionControls/vendorResumePolicy.ts index f11078b3ca..1e57127a4e 100644 --- a/packages/agents/src/sessionControls/vendorResumePolicy.ts +++ b/packages/agents/src/sessionControls/vendorResumePolicy.ts @@ -1,5 +1,5 @@ import { buildBackendTargetKey } from '@happier-dev/protocol'; -import type { AgentId } from '../types.js'; +import type { AgentId, VendorResumeSupportLevel } from '../types.js'; import { isAbsolutePathLike } from '../path/isAbsolutePathLike.js'; import { AGENTS_CORE } from '../manifest.js'; import { isCodexVendorResumeBackendEnabled } from '../providerSettings/definitions/codex.js'; @@ -103,11 +103,14 @@ export function evaluateVendorResumeEligibility(input: Readonly<{ } const resumeConfig = AGENTS_CORE[input.agentId]?.resume; - if (!resumeConfig || resumeConfig.vendorResume === 'unsupported') { + // Widened via `as`: the literal union in AGENTS_CORE only reflects the agents declared + // today, while this guard must keep holding for future agents that declare 'unsupported'. + const vendorResume = resumeConfig?.vendorResume as VendorResumeSupportLevel | undefined; + if (!resumeConfig || vendorResume === 'unsupported') { return { eligible: false, reasonCode: 'agent_unsupported' }; } - if (resumeConfig.vendorResume === 'experimental') { + if (vendorResume === 'experimental') { const experimentalResumePolicy = 'experimentalResumePolicy' in resumeConfig ? resumeConfig.experimentalResumePolicy : undefined; diff --git a/packages/agents/src/types.ts b/packages/agents/src/types.ts index 604f86359b..2912f171ef 100644 --- a/packages/agents/src/types.ts +++ b/packages/agents/src/types.ts @@ -76,7 +76,8 @@ export type VendorResumeIdField = | 'piSessionId' | 'copilotSessionId' | 'cursorSessionId' - | 'grokSessionId'; + | 'grokSessionId' + | 'customAcpSessionId'; export type CloudVendorKey = 'openai' | 'anthropic' | 'gemini'; export type CloudConnectTargetStatus = 'wired' | 'experimental';