Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 95 additions & 1 deletion backend/src/acpx/session-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
})

Expand Down Expand Up @@ -257,4 +258,97 @@ describe('AcpxSessionManager', () => {
expect(details.messages[0].content).toContain('Assistant: Hi')
})
})

describe('continueSession', () => {
it('creates a new session using --from <acpxSessionId> and returns a UUID', async () => {
vi.mocked(spawn)
// continueSession spawn
.mockReturnValueOnce(
makeSpawnMock({ stdout: ['session-id: continued-456'] }) as ReturnType<typeof spawn>
)

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<typeof spawn>
)

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<typeof spawn>
)

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<typeof spawn>
)

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<typeof spawn>
)

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<typeof spawn>
)

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<typeof spawn>
)

const mgr = new AcpxSessionManager('opencode', 'OpenCode', 'opencode')
const sessionId = await mgr.newSession(null)

expect(mgr.getAgentSessionId(sessionId)).toBeNull()
})
})
})
75 changes: 73 additions & 2 deletions backend/src/acpx/session-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
}

/**
Expand Down Expand Up @@ -95,6 +95,40 @@ export class AcpxSessionManager implements SessionAdapter {
return sessionId
}

async continueSession(
fromAgentSessionId: string,
project: SessionProjectContext | null
): Promise<string> {
const sessionId = randomUUID()
const cwd = project?.path ?? process.cwd()

// Create a new acpx session that inherits context from the source session:
// acpx <agentCommand> sessions new --from <acpx-session-id> --cwd <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
}
Comment thread
vriesdemichael marked this conversation as resolved.

async sendMessage(sessionId: string, text: string): Promise<void> {
const session = this.sessions.get(sessionId)
if (!session) throw new Error(`Session not found: ${sessionId}`)
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -197,6 +236,38 @@ export class AcpxSessionManager implements SessionAdapter {
})
}

/**
* Run `acpx <agentCommand> sessions new --from <fromAcpxSessionId> --cwd <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<string | null> {
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 <agentCommand> prompt "<text>"`
* and call `onLine` for each parsed ACP NDJSON line.
Expand Down
Loading
Loading