From e936692152ea52fd3ade4dca42a33028bc55185e Mon Sep 17 00:00:00 2001 From: Marc Liu Date: Sun, 23 Aug 2026 01:21:39 -0400 Subject: [PATCH 1/3] feat(daemon): stamp ACP command-identity digest on sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Persist a digest of the ACP launch command in session metadata and start a fresh ACP conversation when it changes, so switching the agent command no longer resumes a session created by a different agent. Only the digest is stored — the raw command line never reaches session metadata. Extracted from #2711 (ACP split 8/10). --- packages/daemon/src/lib/acp/acp-command.ts | 1 - .../daemon/src/lib/acp/acp-query-runner.ts | 23 ++++- .../unit/lib/acp/acp-query-runner.test.ts | 88 +++++++++++++++++++ packages/shared/src/types.ts | 1 + 4 files changed, 111 insertions(+), 2 deletions(-) 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..3df6909e82 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'; @@ -456,6 +456,27 @@ 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 (session.acpSessionId && storedIdentity !== undefined && storedIdentity !== commandIdentity) { + session.acpSessionId = undefined; + session.metadata = { + ...session.metadata, + acpCommandIdentity: commandIdentity, + acpInstructionsSent: undefined, + acpContextUsageEstimate: undefined, + }; + this.ctx.db.updateSession(session.id, { + acpSessionId: undefined, + metadata: session.metadata, + }); + } else if (storedIdentity !== commandIdentity) { + session.metadata = { + ...session.metadata, + acpCommandIdentity: commandIdentity, + }; + this.ctx.db.updateSession(session.id, { metadata: session.metadata }); + } const preCleanupAuth = { ANTHROPIC_AUTH_TOKEN: process.env.ANTHROPIC_AUTH_TOKEN, CLAUDE_CODE_OAUTH_TOKEN: process.env.CLAUDE_CODE_OAUTH_TOKEN, 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..508dacb415 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, @@ -844,6 +845,93 @@ describe('AcpQueryRunner', () => { }); }); + 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: undefined, + metadata: expect.objectContaining({ + acpCommandIdentity: getAcpCommandIdentityDigest('mock-acp --stdio'), + acpContextUsageEstimate: 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('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 () => { const client = createMockClient(); client.canLoadSession.mockImplementation(() => true); 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?: { From e67cb62fafef208e68d6d25e2da3800c83677139 Mon Sep 17 00:00:00 2001 From: Marc Liu Date: Sun, 23 Aug 2026 01:38:39 -0400 Subject: [PATCH 2/3] style: wrap stamping condition to satisfy biome width --- packages/daemon/src/lib/acp/acp-query-runner.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/daemon/src/lib/acp/acp-query-runner.ts b/packages/daemon/src/lib/acp/acp-query-runner.ts index 3df6909e82..1ab794fa95 100644 --- a/packages/daemon/src/lib/acp/acp-query-runner.ts +++ b/packages/daemon/src/lib/acp/acp-query-runner.ts @@ -458,7 +458,11 @@ export class AcpQueryRunner { const { command, args } = parseAcpCommand(acpCommand); const commandIdentity = getAcpCommandIdentityDigest(acpCommand); const storedIdentity = session.metadata?.acpCommandIdentity; - if (session.acpSessionId && storedIdentity !== undefined && storedIdentity !== commandIdentity) { + if ( + session.acpSessionId && + storedIdentity !== undefined && + storedIdentity !== commandIdentity + ) { session.acpSessionId = undefined; session.metadata = { ...session.metadata, From cc39f567e345f1eb168fa360b8a36e16d9e7e33f Mon Sep 17 00:00:00 2001 From: Marc Liu Date: Sun, 23 Aug 2026 01:51:37 -0400 Subject: [PATCH 3/3] fix(daemon): defer ACP identity rotation persist until the new session exists Keep the old persisted ACP session id until the replacement command actually establishes a session, so a command change whose agent fails to start can be reverted and the previous conversation resumed. The stamped identity metadata now rides the first session-id persist instead of an eager update that erased the resume handle up front. --- .../daemon/src/lib/acp/acp-query-runner.ts | 37 +++++++++--------- .../unit/lib/acp/acp-query-runner.test.ts | 39 ++++++++++++++++++- 2 files changed, 56 insertions(+), 20 deletions(-) diff --git a/packages/daemon/src/lib/acp/acp-query-runner.ts b/packages/daemon/src/lib/acp/acp-query-runner.ts index 1ab794fa95..4e87942905 100644 --- a/packages/daemon/src/lib/acp/acp-query-runner.ts +++ b/packages/daemon/src/lib/acp/acp-query-runner.ts @@ -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[]; @@ -458,28 +460,20 @@ export class AcpQueryRunner { const { command, args } = parseAcpCommand(acpCommand); const commandIdentity = getAcpCommandIdentityDigest(acpCommand); const storedIdentity = session.metadata?.acpCommandIdentity; - if ( - session.acpSessionId && - storedIdentity !== undefined && - storedIdentity !== commandIdentity - ) { - session.acpSessionId = undefined; - session.metadata = { - ...session.metadata, - acpCommandIdentity: commandIdentity, - acpInstructionsSent: undefined, - acpContextUsageEstimate: undefined, - }; - this.ctx.db.updateSession(session.id, { - acpSessionId: undefined, - metadata: session.metadata, - }); - } else if (storedIdentity !== commandIdentity) { + 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.ctx.db.updateSession(session.id, { metadata: session.metadata }); + this.pendingAcpIdentityMetadata = true; } const preCleanupAuth = { ANTHROPIC_AUTH_TOKEN: process.env.ANTHROPIC_AUTH_TOKEN, @@ -1070,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 508dacb415..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 @@ -842,6 +842,9 @@ 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'), + }), }); }); @@ -872,12 +875,15 @@ describe('AcpQueryRunner', () => { expect(client.resumeSession).not.toHaveBeenCalled(); expect(client.createSession).toHaveBeenCalledWith('/tmp/acp-session', []); expect(ctx.db.updateSession).toHaveBeenCalledWith('session-1', { - acpSessionId: undefined, + 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([ { @@ -920,6 +926,34 @@ describe('AcpQueryRunner', () => { ); }); + 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(); @@ -1000,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'), + }), }); });