diff --git a/backend/src/acpx/session-manager.test.ts b/backend/src/acpx/session-manager.test.ts index 553dce5..238808c 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,97 @@ 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: ['session-id: cont-999'] }) 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('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') + 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: ['session-id: cwd-test'] }) 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..217efa3 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,40 @@ export class AcpxSessionManager implements SessionAdapter { return sessionId } + async continueSession( + fromAgentSessionId: 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(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, + 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 +174,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 +236,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( + fromAgentSessionId: string, + cwd: string + ): Promise { + return new Promise((resolve) => { + const proc = spawn( + 'acpx', + [this.agentCommand, 'sessions', 'new', '--from', fromAgentSessionId, '--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.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 103c9cd..b523fcb 100644 --- a/backend/src/agents/registry.ts +++ b/backend/src/agents/registry.ts @@ -174,6 +174,52 @@ 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 only when we can provide a valid agent-side source session ID. + if (targetAdapter.continueSession) { + const sourceAdapter = this.findAdapterForSession(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) { + 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. + } + } + } + + // 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..ea8f1b6 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. `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?( + fromAgentSessionId: 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) {