From 7f1779e4e33968d7258a96b82be5db90caa168ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20de=20Vries?= Date: Fri, 3 Apr 2026 00:45:20 +0200 Subject: [PATCH 1/2] feat(sessions): replace sendHandoff resume path with acpx native session continuation Add AcpxSessionManager.continueSession() which shells out to 'acpx sessions new --from ' to create a new session that inherits context from the source session natively. Add getAgentSessionId() to expose the underlying acpx session ID so the registry can pass the correct --from reference when the source session is a live (non-history) session. Add AgentRegistry.resumeSession() which prefers native continuation when the target adapter supports it and falls back to newSession + sendHandoff for adapters that do not. Update POST /sessions/:id/resume to delegate entirely to registry.resumeSession(), simplifying the route handler. Move session/resume from the unknown to implemented list in ENDPOINT_SUPPORT for AcpxSessionManager. closes #78 --- backend/src/acpx/session-manager.test.ts | 97 +++++++++++++++++++++++- backend/src/acpx/session-manager.ts | 68 ++++++++++++++++- backend/src/agents/registry.ts | 38 ++++++++++ backend/src/agents/types.ts | 17 +++++ backend/src/routes/sessions.test.ts | 73 ++++++++---------- backend/src/routes/sessions.ts | 13 ++-- 6 files changed, 256 insertions(+), 50 deletions(-) diff --git a/backend/src/acpx/session-manager.test.ts b/backend/src/acpx/session-manager.test.ts index 553dce5..021f2a7 100644 --- a/backend/src/acpx/session-manager.test.ts +++ b/backend/src/acpx/session-manager.test.ts @@ -96,12 +96,13 @@ describe('AcpxSessionManager', () => { }) describe('getEndpointSupport', () => { - it('reports session/new, session/prompt, session/update as implemented', () => { + it('reports session/new, session/prompt, session/update, session/resume as implemented', () => { const mgr = new AcpxSessionManager('opencode', 'OpenCode', 'opencode') const support = mgr.getEndpointSupport() expect(support.implemented).toContain('session/new') expect(support.implemented).toContain('session/prompt') expect(support.implemented).toContain('session/update') + expect(support.implemented).toContain('session/resume') }) }) @@ -257,4 +258,98 @@ describe('AcpxSessionManager', () => { expect(details.messages[0].content).toContain('Assistant: Hi') }) }) + + describe('continueSession', () => { + it('creates a new session using --from and returns a UUID', async () => { + vi.mocked(spawn) + // continueSession spawn + .mockReturnValueOnce( + makeSpawnMock({ stdout: ['session-id: continued-456'] }) as ReturnType + ) + + const mgr = new AcpxSessionManager('opencode', 'OpenCode', 'opencode') + const sessionId = await mgr.continueSession('source-acpx-id', null) + + expect(sessionId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i) + }) + + it('registers the continued session so ownsSession returns true', async () => { + vi.mocked(spawn).mockReturnValueOnce( + makeSpawnMock({ stdout: ['session-id: continued-456'] }) as ReturnType + ) + + const mgr = new AcpxSessionManager('opencode', 'OpenCode', 'opencode') + const sessionId = await mgr.continueSession('source-acpx-id', null) + + expect(mgr.ownsSession(sessionId)).toBe(true) + }) + + it('passes --from flag to acpx when continuing', async () => { + vi.mocked(spawn).mockReturnValueOnce( + makeSpawnMock({ stdout: [] }) as ReturnType + ) + + const mgr = new AcpxSessionManager('opencode', 'OpenCode', 'opencode') + await mgr.continueSession('my-source-session-id', null) + + const spawnCall = vi.mocked(spawn).mock.calls[0]! + expect(spawnCall[1]).toContain('--from') + expect(spawnCall[1]).toContain('my-source-session-id') + }) + + it('still creates a session when acpx fails (no acpxSessionId)', async () => { + vi.mocked(spawn).mockReturnValueOnce( + makeSpawnMock({ exitCode: 1 }) as ReturnType + ) + + const mgr = new AcpxSessionManager('opencode', 'OpenCode', 'opencode') + const sessionId = await mgr.continueSession('source-id', null) + + expect(sessionId).toBeTruthy() + expect(mgr.ownsSession(sessionId)).toBe(true) + }) + + it('uses the project path as cwd when a project is provided', async () => { + vi.mocked(spawn).mockReturnValueOnce( + makeSpawnMock({ stdout: [] }) as ReturnType + ) + + const mgr = new AcpxSessionManager('opencode', 'OpenCode', 'opencode') + const project = { id: 'p1', name: 'My Project', path: '/my/project' } + await mgr.continueSession('source-id', project) + + const spawnCall = vi.mocked(spawn).mock.calls[0]! + expect(spawnCall[1]).toContain('--cwd') + expect(spawnCall[1]).toContain('/my/project') + }) + }) + + describe('getAgentSessionId', () => { + it('returns the acpx session id for a known session', async () => { + vi.mocked(spawn).mockReturnValueOnce( + makeSpawnMock({ stdout: ['session-id: acpx-xyz'] }) as ReturnType + ) + + const mgr = new AcpxSessionManager('opencode', 'OpenCode', 'opencode') + const sessionId = await mgr.newSession(null) + + expect(mgr.getAgentSessionId(sessionId)).toBe('acpx-xyz') + }) + + it('returns null for an unknown session id', () => { + const mgr = new AcpxSessionManager('opencode', 'OpenCode', 'opencode') + expect(mgr.getAgentSessionId('unknown-id')).toBeNull() + }) + + it('returns null when acpx session creation failed and no acpxSessionId was stored', async () => { + vi.mocked(spawn).mockReturnValueOnce( + makeSpawnMock({ exitCode: 1 }) as ReturnType + ) + + const mgr = new AcpxSessionManager('opencode', 'OpenCode', 'opencode') + const sessionId = await mgr.newSession(null) + + expect(mgr.getAgentSessionId(sessionId)).toBeNull() + }) + }) }) diff --git a/backend/src/acpx/session-manager.ts b/backend/src/acpx/session-manager.ts index 57bfe26..565725b 100644 --- a/backend/src/acpx/session-manager.ts +++ b/backend/src/acpx/session-manager.ts @@ -38,8 +38,8 @@ interface AcpxSessionState { const ENDPOINT_SUPPORT: BackendEndpointSupport = { source: 'connection', - implemented: ['session/new', 'session/prompt', 'session/update'], - unknown: ['session/load', 'session/resume', 'session/fork'], + implemented: ['session/new', 'session/prompt', 'session/update', 'session/resume'], + unknown: ['session/load', 'session/fork'], } /** @@ -95,6 +95,33 @@ export class AcpxSessionManager implements SessionAdapter { return sessionId } + async continueSession( + fromAcpxSessionId: string, + project: SessionProjectContext | null + ): Promise { + const sessionId = randomUUID() + const cwd = project?.path ?? process.cwd() + + // Create a new acpx session that inherits context from the source session: + // acpx sessions new --from --cwd + const acpxSessionId = await this.runAcpxContinueSession(fromAcpxSessionId, cwd) + + this.sessions.set(sessionId, { + id: sessionId, + acpxSessionId, + agentCommand: this.agentCommand, + cwd, + title: 'New chat', + createdAt: new Date(), + updatedAt: new Date(), + project, + messages: [], + proc: null, + }) + + return sessionId + } + async sendMessage(sessionId: string, text: string): Promise { const session = this.sessions.get(sessionId) if (!session) throw new Error(`Session not found: ${sessionId}`) @@ -140,6 +167,11 @@ export class AcpxSessionManager implements SessionAdapter { await this.sendMessage(sessionId, handoffPrompt) } + getAgentSessionId(internalSessionId: string): string | null { + const session = this.sessions.get(internalSessionId) + return session?.acpxSessionId ?? null + } + closeSession(sessionId: string): void { const session = this.sessions.get(sessionId) if (session?.proc && !session.proc.killed) { @@ -197,6 +229,38 @@ export class AcpxSessionManager implements SessionAdapter { }) } + /** + * Run `acpx sessions new --from --cwd ` and parse + * the new session id from stdout. Returns null when acpx outputs no recognisable session id. + */ + private async runAcpxContinueSession( + fromAcpxSessionId: string, + cwd: string + ): Promise { + return new Promise((resolve) => { + const proc = spawn( + 'acpx', + [this.agentCommand, 'sessions', 'new', '--from', fromAcpxSessionId, '--cwd', cwd], + { + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env }, + } + ) + + let output = '' + proc.stdout?.on('data', (chunk: Buffer) => { + output += chunk.toString() + }) + + proc.on('close', () => { + const match = /session[- ]id:\s*(\S+)/i.exec(output) ?? /^(\S+)$/m.exec(output.trim()) + resolve(match ? (match[1] ?? null) : null) + }) + + proc.on('error', () => resolve(null)) + }) + } + /** * Stream a prompt through `acpx --format json prompt ""` * and call `onLine` for each parsed ACP NDJSON line. diff --git a/backend/src/agents/registry.ts b/backend/src/agents/registry.ts index 103c9cd..fa10bbb 100644 --- a/backend/src/agents/registry.ts +++ b/backend/src/agents/registry.ts @@ -174,6 +174,44 @@ export class AgentRegistry { return sessionId } + /** + * Resume a source session on the target agent. Prefers native `continueSession` (acpx + * `--from`) when both the source and target adapters support it, otherwise falls back to + * creating a blank session and injecting the prior transcript via `sendHandoff`. + * + * Returns the new internal session ID. + */ + async resumeSession( + sourceSessionId: string, + sourceSession: SessionDetails, + targetAgentId: string, + project: SessionProjectContext | null + ): Promise { + const targetAdapter = this.requireAdapter(targetAgentId) + + // Attempt native continuation when the target adapter supports it + if (targetAdapter.continueSession) { + // For live source sessions, look up the underlying agent session ID from the source adapter. + // For history sessions (not owned by any adapter) fall back to the session's own id + // which is the original agent-side ID. + const sourceAdapter = this.findAdapterForSession(sourceSessionId) + const fromId = sourceAdapter?.getAgentSessionId + ? sourceAdapter.getAgentSessionId(sourceSessionId) + : sourceSessionId + + if (fromId) { + return await targetAdapter.continueSession(fromId, project) + } + } + + // Fallback: blank new session + transcript handoff + const newSessionId = await targetAdapter.newSession(project) + if (sourceSession.messages.length > 0) { + await targetAdapter.sendHandoff(newSessionId, sourceSession.messages) + } + return newSessionId + } + listSessions(): SessionSummary[] { const liveSessions = this.agents.flatMap((agent) => agent.adapter?.listSessions() ?? []) diff --git a/backend/src/agents/types.ts b/backend/src/agents/types.ts index 2428a4b..cb6d735 100644 --- a/backend/src/agents/types.ts +++ b/backend/src/agents/types.ts @@ -295,6 +295,23 @@ export interface SessionAdapter { * ACP agent that advertises the `loadSession` capability. */ loadSession?(acpSessionId: string, project: SessionProjectContext | null): Promise + /** + * Continue a prior session natively using the agent's own session continuation mechanism + * (e.g. `acpx sessions new --from `). Returns the new internal frontend + * session ID. Only available on adapters that support native session continuation. + * Callers should fall back to `newSession` + `sendHandoff` when this is absent. + */ + continueSession?( + fromAcpxSessionId: string, + project: SessionProjectContext | null + ): Promise + /** + * Return the underlying agent-side session ID (e.g. the acpx session ID) for a given + * internal frontend session ID. Used by the registry when building a `--from` reference + * for `continueSession`. Returns null when the session is not owned by this adapter or + * when no underlying session ID is available. + */ + getAgentSessionId?(internalSessionId: string): string | null sendMessage(sessionId: string, text: string): Promise /** * Send a structured handoff prompt containing prior conversation history. diff --git a/backend/src/routes/sessions.test.ts b/backend/src/routes/sessions.test.ts index ed37f1b..1083dca 100644 --- a/backend/src/routes/sessions.test.ts +++ b/backend/src/routes/sessions.test.ts @@ -52,6 +52,7 @@ function createRegistryStub(overrides?: Partial): AgentRegistry { createSession: vi.fn(async () => 'session-1'), sendMessage: vi.fn(async () => undefined), sendHandoff: vi.fn(async () => undefined), + resumeSession: vi.fn(async () => 'new-session-id'), closeSession: vi.fn(() => false), ...overrides, } as unknown as AgentRegistry @@ -227,7 +228,7 @@ describe('sessions routes', () => { it('creates a new live session on the target agent and returns 201', async () => { const registry = createRegistryStub({ getSession: makeGetSession(historySessionEmpty), - createSession: vi.fn(async () => 'new-session-id'), + resumeSession: vi.fn(async () => 'new-session-id'), }) const app = new Hono().route('/api', sessionsRoutes(registry)) @@ -242,18 +243,23 @@ describe('sessions routes', () => { }) expect(res.status).toBe(201) - expect(vi.mocked(registry.createSession)).toHaveBeenCalledWith('copilot', { - id: 'repo-1', - name: 'ACP Frontend', - path: '/work/acp-frontend', - }) + expect(vi.mocked(registry.resumeSession)).toHaveBeenCalledWith( + 'history-session-1', + historySessionEmpty, + 'copilot', + { + id: 'repo-1', + name: 'ACP Frontend', + path: '/work/acp-frontend', + } + ) await expect(res.json()).resolves.toMatchObject({ id: 'new-session-id', source: 'live' }) }) it('inherits the source session project when projectId is omitted', async () => { const registry = createRegistryStub({ getSession: makeGetSession(historySessionEmpty), - createSession: vi.fn(async () => 'new-session-id'), + resumeSession: vi.fn(async () => 'new-session-id'), }) const app = new Hono().route('/api', sessionsRoutes(registry)) @@ -264,18 +270,23 @@ describe('sessions routes', () => { }) expect(res.status).toBe(201) - expect(vi.mocked(registry.createSession)).toHaveBeenCalledWith('copilot', { - id: 'repo-1', - name: 'ACP Frontend', - path: '/work/acp-frontend', - }) + expect(vi.mocked(registry.resumeSession)).toHaveBeenCalledWith( + 'history-session-1', + historySessionEmpty, + 'copilot', + { + id: 'repo-1', + name: 'ACP Frontend', + path: '/work/acp-frontend', + } + ) }) it('uses sourceAgentId to disambiguate cross-provider lookups', async () => { const getSessionSpy = makeGetSession(historySessionEmpty) const registry = createRegistryStub({ getSession: getSessionSpy, - createSession: vi.fn(async () => 'new-session-id'), + resumeSession: vi.fn(async () => 'new-session-id'), }) const app = new Hono().route('/api', sessionsRoutes(registry)) @@ -319,7 +330,7 @@ describe('sessions routes', () => { it('returns 503 when the target agent is unavailable', async () => { const registry = createRegistryStub({ getSession: makeGetSession(historySessionEmpty), - createSession: vi.fn(async () => { + resumeSession: vi.fn(async () => { throw new RegistryError('agent_unavailable', 'Agent unavailable: copilot') }), }) @@ -338,12 +349,11 @@ describe('sessions routes', () => { expect(res.status).toBe(503) }) - it('calls sendHandoff with source messages when the session has history', async () => { - const sendHandoffSpy = vi.fn(async () => undefined) + it('delegates full resume logic to registry.resumeSession', async () => { + const resumeSessionSpy = vi.fn(async () => 'new-session-id') const registry = createRegistryStub({ getSession: makeGetSession(historySession), - createSession: vi.fn(async () => 'new-session-id'), - sendHandoff: sendHandoffSpy, + resumeSession: resumeSessionSpy, }) const app = new Hono().route('/api', sessionsRoutes(registry)) @@ -353,29 +363,12 @@ describe('sessions routes', () => { body: JSON.stringify({ agentId: 'copilot', sourceAgentId: 'gemini-cli' }), }) - expect(sendHandoffSpy).toHaveBeenCalledWith( - 'new-session-id', - historySession.messages, - 'copilot' + expect(resumeSessionSpy).toHaveBeenCalledWith( + 'history-session-1', + historySession, + 'copilot', + expect.objectContaining({ id: 'repo-1' }) ) }) - - it('skips sendHandoff when the source session has no messages', async () => { - const sendHandoffSpy = vi.fn(async () => undefined) - const registry = createRegistryStub({ - getSession: makeGetSession(historySessionEmpty), - createSession: vi.fn(async () => 'new-session-id'), - sendHandoff: sendHandoffSpy, - }) - const app = new Hono().route('/api', sessionsRoutes(registry)) - - await app.request('/api/sessions/history-session-1/resume', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ agentId: 'copilot', sourceAgentId: 'gemini-cli' }), - }) - - expect(sendHandoffSpy).not.toHaveBeenCalled() - }) }) }) diff --git a/backend/src/routes/sessions.ts b/backend/src/routes/sessions.ts index 46295a9..86d2829 100644 --- a/backend/src/routes/sessions.ts +++ b/backend/src/routes/sessions.ts @@ -96,13 +96,12 @@ export function sessionsRoutes(registry: AgentRegistry): Hono { } try { - const newSessionId = await registry.createSession(agentId, projectResult.project) - - // Forward the prior conversation to the new session via an EmbeddedResource - // content block so the target agent receives it as structured context. - if (sourceSession.messages.length > 0) { - await registry.sendHandoff(newSessionId, sourceSession.messages, agentId) - } + const newSessionId = await registry.resumeSession( + sessionId, + sourceSession, + agentId, + projectResult.project + ) return c.json(registry.getSession(newSessionId), 201) } catch (error) { From b4dbeb5fb4706ef396638202457935d6edc4dde8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20de=20Vries?= Date: Fri, 3 Apr 2026 01:05:11 +0200 Subject: [PATCH 2/2] fix(sessions): address Copilot review on acpx session continuation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename continueSession param fromAcpxSessionId → fromAgentSessionId (types.ts, session-manager.ts) - Throw in continueSession when acpxSessionId is null instead of silently continuing without context - resumeSession only uses native continuation with a reliable agent-side id (history or live with getAgentSessionId) - Fix test mocks: use constructor functions for AcpxSessionManager mockImplementationOnce - Fix continueSession cwd test: provide stdout with session id to avoid spurious throw --- backend/src/acpx/session-manager.test.ts | 13 +- backend/src/acpx/session-manager.ts | 15 +- backend/src/agents/registry.test.ts | 178 +++++++++++++++++++++++ backend/src/agents/registry.ts | 22 ++- backend/src/agents/types.ts | 4 +- 5 files changed, 212 insertions(+), 20 deletions(-) diff --git a/backend/src/acpx/session-manager.test.ts b/backend/src/acpx/session-manager.test.ts index 021f2a7..238808c 100644 --- a/backend/src/acpx/session-manager.test.ts +++ b/backend/src/acpx/session-manager.test.ts @@ -286,7 +286,7 @@ describe('AcpxSessionManager', () => { it('passes --from flag to acpx when continuing', async () => { vi.mocked(spawn).mockReturnValueOnce( - makeSpawnMock({ stdout: [] }) as ReturnType + makeSpawnMock({ stdout: ['session-id: cont-999'] }) as ReturnType ) const mgr = new AcpxSessionManager('opencode', 'OpenCode', 'opencode') @@ -297,21 +297,20 @@ describe('AcpxSessionManager', () => { expect(spawnCall[1]).toContain('my-source-session-id') }) - it('still creates a session when acpx fails (no acpxSessionId)', async () => { + it('throws when acpx fails to return a session id', async () => { vi.mocked(spawn).mockReturnValueOnce( makeSpawnMock({ exitCode: 1 }) as ReturnType ) const mgr = new AcpxSessionManager('opencode', 'OpenCode', 'opencode') - const sessionId = await mgr.continueSession('source-id', null) - - expect(sessionId).toBeTruthy() - expect(mgr.ownsSession(sessionId)).toBe(true) + await expect(mgr.continueSession('source-id', null)).rejects.toThrow( + 'Native continuation is unavailable' + ) }) it('uses the project path as cwd when a project is provided', async () => { vi.mocked(spawn).mockReturnValueOnce( - makeSpawnMock({ stdout: [] }) as ReturnType + makeSpawnMock({ stdout: ['session-id: cwd-test'] }) as ReturnType ) const mgr = new AcpxSessionManager('opencode', 'OpenCode', 'opencode') diff --git a/backend/src/acpx/session-manager.ts b/backend/src/acpx/session-manager.ts index 565725b..217efa3 100644 --- a/backend/src/acpx/session-manager.ts +++ b/backend/src/acpx/session-manager.ts @@ -96,7 +96,7 @@ export class AcpxSessionManager implements SessionAdapter { } async continueSession( - fromAcpxSessionId: string, + fromAgentSessionId: string, project: SessionProjectContext | null ): Promise { const sessionId = randomUUID() @@ -104,7 +104,14 @@ export class AcpxSessionManager implements SessionAdapter { // Create a new acpx session that inherits context from the source session: // acpx sessions new --from --cwd - const acpxSessionId = await this.runAcpxContinueSession(fromAcpxSessionId, cwd) + const acpxSessionId = await this.runAcpxContinueSession(fromAgentSessionId, cwd) + + if (!acpxSessionId) { + throw new Error( + `acpx failed to create a continued session from '${fromAgentSessionId}' — ` + + `no session id was returned. Native continuation is unavailable.` + ) + } this.sessions.set(sessionId, { id: sessionId, @@ -234,13 +241,13 @@ export class AcpxSessionManager implements SessionAdapter { * the new session id from stdout. Returns null when acpx outputs no recognisable session id. */ private async runAcpxContinueSession( - fromAcpxSessionId: string, + fromAgentSessionId: string, cwd: string ): Promise { return new Promise((resolve) => { const proc = spawn( 'acpx', - [this.agentCommand, 'sessions', 'new', '--from', fromAcpxSessionId, '--cwd', cwd], + [this.agentCommand, 'sessions', 'new', '--from', fromAgentSessionId, '--cwd', cwd], { stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env }, diff --git a/backend/src/agents/registry.test.ts b/backend/src/agents/registry.test.ts index 218c0ee..a6690b4 100644 --- a/backend/src/agents/registry.test.ts +++ b/backend/src/agents/registry.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { SessionDetails } from './types.js' const listProjectsMock = vi.fn() const toSessionProjectContextMock = vi.fn((project) => ({ @@ -271,6 +272,183 @@ describe('AgentRegistry.listSessions', () => { }) }) +describe('AgentRegistry.resumeSession', () => { + const sourceSession: SessionDetails = { + id: 'hist-session-1', + title: 'Old chat', + updatedAt: '2026-03-29T10:00:00.000Z', + agentId: 'opencode', + project: { id: 'repo-1', name: 'Proj', path: '/proj' }, + source: 'history', + messages: [ + { id: 'm1', role: 'user', content: 'Hello' }, + { id: 'm2', role: 'assistant', content: 'Hi' }, + ], + modelState: null, + } + + function makeAdapterMock(overrides?: Record) { + return { + agentId: 'opencode', + agentName: 'OpenCode', + events: { on: vi.fn(), emit: vi.fn(), removeAllListeners: vi.fn() }, + getEndpointSupport: vi.fn(() => ({ source: 'connection', implemented: [], unknown: [] })), + ownsSession: vi.fn(() => false), + newSession: vi.fn(async () => 'new-session-id'), + sendHandoff: vi.fn(async () => undefined), + continueSession: vi.fn(async () => 'continued-session-id'), + getAgentSessionId: vi.fn(() => 'acpx-session-abc'), + listSessions: vi.fn(() => []), + ...overrides, + } + } + + beforeEach(() => { + vi.clearAllMocks() + readBackendConfigMock.mockReturnValue([ + { + id: 'opencode', + name: 'OpenCode', + enabled: true, + commandCandidates: ['opencode'], + command: 'opencode', + args: [], + }, + ]) + listProjectsMock.mockReturnValue([]) + getHistoryHintsForProviderMock.mockReturnValue({ + historyPathHints: [], + cliHistoryPathHints: [], + }) + }) + + it('uses native continueSession for a history source session', async () => { + const adapter = makeAdapterMock() + const { AcpxSessionManager } = await import('../acpx/session-manager.js') + vi.mocked(AcpxSessionManager).mockImplementationOnce(function (this: unknown) { + return adapter as never + }) + + const { AgentRegistry } = await import('./registry.js') + const registry = new AgentRegistry() + + const newId = await registry.resumeSession('hist-session-1', sourceSession, 'opencode', null) + + expect(adapter.continueSession).toHaveBeenCalledWith('hist-session-1', null) + expect(newId).toBe('continued-session-id') + expect(adapter.newSession).not.toHaveBeenCalled() + expect(adapter.sendHandoff).not.toHaveBeenCalled() + }) + + it('falls back to newSession+sendHandoff when continueSession throws', async () => { + const adapter = makeAdapterMock({ + continueSession: vi.fn(async () => { + throw new Error('acpx returned no session id') + }), + }) + const { AcpxSessionManager } = await import('../acpx/session-manager.js') + vi.mocked(AcpxSessionManager).mockImplementationOnce(function (this: unknown) { + return adapter as never + }) + + const { AgentRegistry } = await import('./registry.js') + const registry = new AgentRegistry() + + const newId = await registry.resumeSession('hist-session-1', sourceSession, 'opencode', null) + + expect(adapter.newSession).toHaveBeenCalledWith(null) + expect(adapter.sendHandoff).toHaveBeenCalledWith('new-session-id', sourceSession.messages) + expect(newId).toBe('new-session-id') + }) + + it('falls back to newSession+sendHandoff when no continueSession method exists', async () => { + const adapter = makeAdapterMock({ continueSession: undefined }) + const { AcpxSessionManager } = await import('../acpx/session-manager.js') + vi.mocked(AcpxSessionManager).mockImplementationOnce(function (this: unknown) { + return adapter as never + }) + + const { AgentRegistry } = await import('./registry.js') + const registry = new AgentRegistry() + + const newId = await registry.resumeSession('hist-session-1', sourceSession, 'opencode', null) + + expect(adapter.newSession).toHaveBeenCalledWith(null) + expect(adapter.sendHandoff).toHaveBeenCalledWith('new-session-id', sourceSession.messages) + expect(newId).toBe('new-session-id') + }) + + it('falls back when source is live and adapter has no getAgentSessionId', async () => { + // Simulate a live source session owned by an adapter that lacks getAgentSessionId + const liveSourceSession: SessionDetails = { ...sourceSession, source: 'live' } + const sourceAdapter = makeAdapterMock({ + ownsSession: vi.fn((id: string) => id === 'live-session-1'), + getAgentSessionId: undefined, + }) + const targetAdapter = makeAdapterMock() + + const { AcpxSessionManager } = await import('../acpx/session-manager.js') + vi.mocked(AcpxSessionManager) + .mockImplementationOnce(function (this: unknown) { + return sourceAdapter as never + }) // first agent built = source/target (same in this test) + .mockImplementationOnce(function (this: unknown) { + return targetAdapter as never + }) + + readBackendConfigMock.mockReturnValue([ + { + id: 'opencode-source', + name: 'OpenCode Source', + enabled: true, + commandCandidates: ['opencode'], + command: 'opencode', + args: [], + }, + { + id: 'opencode', + name: 'OpenCode', + enabled: true, + commandCandidates: ['opencode'], + command: 'opencode', + args: [], + }, + ]) + + const { AgentRegistry } = await import('./registry.js') + const registry = new AgentRegistry() + + const newId = await registry.resumeSession( + 'live-session-1', + liveSourceSession, + 'opencode', + null + ) + + // Native continuation must NOT have been attempted since no agent-side id was available + expect(targetAdapter.continueSession).not.toHaveBeenCalled() + expect(targetAdapter.newSession).toHaveBeenCalled() + expect(newId).toBe('new-session-id') + }) + + it('skips sendHandoff when the source session has no messages', async () => { + const emptySession: SessionDetails = { ...sourceSession, messages: [] } + const adapter = makeAdapterMock({ continueSession: undefined }) + const { AcpxSessionManager } = await import('../acpx/session-manager.js') + vi.mocked(AcpxSessionManager).mockImplementationOnce(function (this: unknown) { + return adapter as never + }) + + const { AgentRegistry } = await import('./registry.js') + const registry = new AgentRegistry() + + await registry.resumeSession('hist-session-1', emptySession, 'opencode', null) + + expect(adapter.newSession).toHaveBeenCalled() + expect(adapter.sendHandoff).not.toHaveBeenCalled() + }) +}) + describe('AgentRegistry.listBackends', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/backend/src/agents/registry.ts b/backend/src/agents/registry.ts index fa10bbb..b523fcb 100644 --- a/backend/src/agents/registry.ts +++ b/backend/src/agents/registry.ts @@ -189,18 +189,26 @@ export class AgentRegistry { ): Promise { const targetAdapter = this.requireAdapter(targetAgentId) - // Attempt native continuation when the target adapter supports it + // Attempt native continuation only when we can provide a valid agent-side source session ID. if (targetAdapter.continueSession) { - // For live source sessions, look up the underlying agent session ID from the source adapter. - // For history sessions (not owned by any adapter) fall back to the session's own id - // which is the original agent-side ID. const sourceAdapter = this.findAdapterForSession(sourceSessionId) - const fromId = sourceAdapter?.getAgentSessionId - ? sourceAdapter.getAgentSessionId(sourceSessionId) + + // History sessions are not owned by a live adapter, so their session id is already the + // original agent-side id. Live sessions must come from the owning adapter, and only + // adapters that expose `getAgentSessionId` can safely provide an id for native + // continuation. If the source is a live session but the adapter does not implement + // `getAgentSessionId`, we have no reliable agent-side id and must fall back. + const fromId = sourceAdapter + ? (sourceAdapter.getAgentSessionId?.(sourceSessionId) ?? null) : sourceSessionId if (fromId) { - return await targetAdapter.continueSession(fromId, project) + try { + return await targetAdapter.continueSession(fromId, project) + } catch { + // Native continuation failed (e.g. acpx returned no session id) — fall through to + // the sendHandoff fallback below so the user still gets a working session. + } } } diff --git a/backend/src/agents/types.ts b/backend/src/agents/types.ts index cb6d735..ea8f1b6 100644 --- a/backend/src/agents/types.ts +++ b/backend/src/agents/types.ts @@ -297,12 +297,12 @@ export interface SessionAdapter { loadSession?(acpSessionId: string, project: SessionProjectContext | null): Promise /** * Continue a prior session natively using the agent's own session continuation mechanism - * (e.g. `acpx sessions new --from `). Returns the new internal frontend + * (e.g. `agent sessions new --from `). Returns the new internal frontend * session ID. Only available on adapters that support native session continuation. * Callers should fall back to `newSession` + `sendHandoff` when this is absent. */ continueSession?( - fromAcpxSessionId: string, + fromAgentSessionId: string, project: SessionProjectContext | null ): Promise /**