diff --git a/packages/daemon/src/lib/acp/acp-command.ts b/packages/daemon/src/lib/acp/acp-command.ts index 599c844027..ea1d8c4e12 100644 --- a/packages/daemon/src/lib/acp/acp-command.ts +++ b/packages/daemon/src/lib/acp/acp-command.ts @@ -3,7 +3,6 @@ import { getAcpCommandIdentity } from '@hyperneo/shared/acp'; export { getAcpCommandIdentity, parseAcpCommand } from '@hyperneo/shared/acp'; -/* @public - consumed by ACP provider sync in a later stack PR */ export function getAcpCommandIdentityDigest(commandLine: string): string { return createHash('sha256').update(getAcpCommandIdentity(commandLine)).digest('hex'); } diff --git a/packages/daemon/src/lib/acp/acp-query-runner.ts b/packages/daemon/src/lib/acp/acp-query-runner.ts index 2671b6719a..4e87942905 100644 --- a/packages/daemon/src/lib/acp/acp-query-runner.ts +++ b/packages/daemon/src/lib/acp/acp-query-runner.ts @@ -34,7 +34,7 @@ import { resolveSpaceMcpSessionPolicy, } from '../space/runtime/space-mcp-session-policy'; import { AcpClient, type AcpClientOptions } from './acp-client'; -import { parseAcpCommand } from './acp-command'; +import { getAcpCommandIdentityDigest, parseAcpCommand } from './acp-command'; import { AcpQueryAdapter } from './acp-query-adapter'; import { AcpMcpProxyBridge, shouldProxy } from './mcp-proxy-bridge'; @@ -352,6 +352,8 @@ function acpInstructionBlocks(queryOptions: Options): AcpContentBlock[] { type AcpClientFactory = (options: AcpClientOptions) => AcpClient; export class AcpQueryRunner { + private pendingAcpIdentityMetadata = false; + private _lastConsumedUserMessage: { uuid: string; content: string | MessageContent[]; @@ -456,6 +458,23 @@ export class AcpQueryRunner { throw new Error('Set HYPERNEO_ACP_COMMAND to enable ACP agents.'); } const { command, args } = parseAcpCommand(acpCommand); + const commandIdentity = getAcpCommandIdentityDigest(acpCommand); + const storedIdentity = session.metadata?.acpCommandIdentity; + if (storedIdentity !== commandIdentity) { + if (session.acpSessionId && storedIdentity !== undefined) { + session.acpSessionId = undefined; + session.metadata = { + ...session.metadata, + acpInstructionsSent: undefined, + acpContextUsageEstimate: undefined, + }; + } + session.metadata = { + ...session.metadata, + acpCommandIdentity: commandIdentity, + }; + this.pendingAcpIdentityMetadata = true; + } const preCleanupAuth = { ANTHROPIC_AUTH_TOKEN: process.env.ANTHROPIC_AUTH_TOKEN, CLAUDE_CODE_OAUTH_TOKEN: process.env.CLAUDE_CODE_OAUTH_TOKEN, @@ -1045,8 +1064,13 @@ export class AcpQueryRunner { private persistAcpSessionId(acpSessionId: string | undefined): void { const { session, db } = this.ctx; - if (session.acpSessionId === acpSessionId) return; + if (session.acpSessionId === acpSessionId && !this.pendingAcpIdentityMetadata) return; session.acpSessionId = acpSessionId; + if (this.pendingAcpIdentityMetadata) { + this.pendingAcpIdentityMetadata = false; + db.updateSession(session.id, { acpSessionId, metadata: session.metadata }); + return; + } db.updateSession(session.id, { acpSessionId }); } diff --git a/packages/daemon/tests/unit/lib/acp/acp-query-runner.test.ts b/packages/daemon/tests/unit/lib/acp/acp-query-runner.test.ts index cf3587dee6..be17bf1337 100644 --- a/packages/daemon/tests/unit/lib/acp/acp-query-runner.test.ts +++ b/packages/daemon/tests/unit/lib/acp/acp-query-runner.test.ts @@ -11,6 +11,7 @@ import type { QueryOptionsBuilder } from '../../../../src/lib/agent/query-option import type { AskUserQuestionHandler } from '../../../../src/lib/agent/ask-user-question-handler'; import type { QueryRunnerContext } from '../../../../src/lib/agent/query-runner'; import type { AcpClient, AcpClientOptions } from '../../../../src/lib/acp/acp-client'; +import { getAcpCommandIdentityDigest } from '../../../../src/lib/acp/acp-command'; import { AcpQueryRunner, convertMcpServersForAcp, @@ -841,7 +842,128 @@ describe('AcpQueryRunner', () => { expect(ctx.session.acpSessionId).toBe('acp-session-1'); expect(ctx.db.updateSession).toHaveBeenCalledWith('session-1', { acpSessionId: 'acp-session-1', + metadata: expect.objectContaining({ + acpCommandIdentity: getAcpCommandIdentityDigest('mock-acp --stdio'), + }), + }); + }); + + test('creates a new ACP session when the persisted command identity changes', async () => { + const client = createMockClient(); + client.canLoadSession.mockImplementation(() => true); + const { runner, ctx } = createRunnerFixture({ + client, + session: { + acpSessionId: 'persisted-acp-session', + metadata: { + acpCommandIdentity: getAcpCommandIdentityDigest('other-acp --stdio'), + acpInstructionsSent: true, + acpContextUsageEstimate: 12000, + }, + } as Partial, + queryOptions: { + cwd: '/tmp/acp-session', + mcpServers: {}, + systemPrompt: { type: 'preset', preset: 'none', append: 'Follow current rules.' }, + }, + }); + + await runner.start(); + await ctx.queryPromise; + + expect(client.loadSession).not.toHaveBeenCalled(); + expect(client.resumeSession).not.toHaveBeenCalled(); + expect(client.createSession).toHaveBeenCalledWith('/tmp/acp-session', []); + expect(ctx.db.updateSession).toHaveBeenCalledWith('session-1', { + acpSessionId: 'acp-session-1', + metadata: expect.objectContaining({ + acpCommandIdentity: getAcpCommandIdentityDigest('mock-acp --stdio'), + acpContextUsageEstimate: undefined, + }), }); + for (const call of ctx.db.updateSession.mock.calls) { + expect(call[1]).not.toMatchObject({ acpSessionId: undefined }); + } + expect(ctx.session.metadata.acpInstructionsSent).toBe(true); + expect(client.sendPrompt.mock.calls[0][0]).toEqual([ + { + type: 'text', + text: 'HyperNeo session instructions:\n\nFollow current rules.', + }, + { type: 'text', text: 'hello' }, + ]); + }); + + test('preserves an existing session whose command identity matches', async () => { + const client = createMockClient(); + client.canLoadSession.mockImplementation(() => true); + client.loadSession.mockImplementation(async (sessionId: string) => ({ + sessionId, + configOptions: [], + })); + const { runner, ctx } = createRunnerFixture({ + client, + session: { + acpSessionId: 'persisted-acp-session', + metadata: { + messageCount: 2, + acpCommandIdentity: getAcpCommandIdentityDigest('mock-acp --stdio'), + }, + } as Partial, + }); + + await runner.start(); + await ctx.queryPromise; + + expect(client.loadSession).toHaveBeenCalledWith( + 'persisted-acp-session', + '/tmp/acp-session', + [] + ); + expect(client.createSession).not.toHaveBeenCalled(); + expect(ctx.session.metadata.acpCommandIdentity).toBe( + getAcpCommandIdentityDigest('mock-acp --stdio') + ); + }); + + test('keeps the old ACP session id when the replacement command fails to start', async () => { + const client = createMockClient(); + client.initialize.mockImplementation(async () => { + throw new Error('replacement agent unavailable'); + }); + const { runner, ctx } = createRunnerFixture({ + client, + session: { + acpSessionId: 'persisted-acp-session', + metadata: { + acpCommandIdentity: getAcpCommandIdentityDigest('other-acp --stdio'), + }, + } as Partial, + }); + + await runner.start(); + await ctx.queryPromise; + + for (const call of ctx.db.updateSession.mock.calls) { + expect(call[1]).not.toMatchObject({ acpSessionId: undefined }); + expect(call[1]).not.toHaveProperty('metadata'); + } + expect(ctx.session.acpSessionId).toBe(undefined); + expect(ctx.session.metadata.acpCommandIdentity).toBe( + getAcpCommandIdentityDigest('mock-acp --stdio') + ); + }); + + test('persists only a digest of the ACP command identity', async () => { + process.env.HYPERNEO_ACP_COMMAND = 'devin acp --token topsecret'; + const { runner, ctx } = createRunnerFixture(); + + await runner.start(); + await ctx.queryPromise; + + const identity = ctx.session.metadata.acpCommandIdentity as string; + expect(identity).toBe(getAcpCommandIdentityDigest('devin acp --token topsecret')); + expect(identity).not.toContain('topsecret'); }); test('loads an existing ACP session instead of creating a new one', async () => { @@ -912,6 +1034,9 @@ describe('AcpQueryRunner', () => { expect(ctx.session.acpSessionId).toBe('resumed-acp-session'); expect(ctx.db.updateSession).toHaveBeenCalledWith('session-1', { acpSessionId: 'resumed-acp-session', + metadata: expect.objectContaining({ + acpCommandIdentity: getAcpCommandIdentityDigest('mock-acp --stdio'), + }), }); }); diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 92514f4704..d6b6e8e068 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -317,6 +317,7 @@ export interface SessionMetadata { costBaseline?: number; refusalRewindTargetUuid?: string | null; acpInstructionsSent?: boolean; + acpCommandIdentity?: string; pastSdkSessionIds?: string[]; acpContextUsageEstimate?: number; worktreeChoice?: {