From a3aaba851582f0cc3fed98e9df186d65ec6ffbcf Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:01:38 -0400 Subject: [PATCH 01/16] section 1: Phase 1: One owner for reading a session's latest assistant --- backend/src/services/schedules.ts | 41 ++------ backend/src/services/session-reply.ts | 38 ++++++++ backend/test/services/session-reply.test.ts | 102 ++++++++++++++++++++ 3 files changed, 148 insertions(+), 33 deletions(-) create mode 100644 backend/src/services/session-reply.ts create mode 100644 backend/test/services/session-reply.test.ts diff --git a/backend/src/services/schedules.ts b/backend/src/services/schedules.ts index e97294ff3..06db18c0a 100644 --- a/backend/src/services/schedules.ts +++ b/backend/src/services/schedules.ts @@ -7,7 +7,7 @@ import { type ScheduleRunTriggerSource, type UpdateScheduleJobRequest, } from '@opencode-manager/shared/types' -import { assistantText, mcpStatusByName, openCodeLocation, sessionIDFromEvent, type SessionMessageAssistant, type SessionMessageInfo } from '@opencode-manager/shared/opencode' +import { mcpStatusByName, openCodeLocation, sessionIDFromEvent } from '@opencode-manager/shared/opencode' import { buildSchedulePermissionRuleset } from '@opencode-manager/shared/schemas' import { getRepoById } from '../db/queries' import type { ScheduleJobWithRepo } from '../db/schedules' @@ -46,6 +46,7 @@ import { computeNextRunAtForJob, } from './schedule-config' import { resolveOpenCodeModel } from './opencode-models' +import { isSessionBusy, readLatestAssistantReply, type AssistantReplyState } from './session-reply' import type { OpenCodeClient } from './opencode/client' import type { ScheduleWorktreeManager } from './schedule-worktree' import type { Repo } from '../types/repo' @@ -165,26 +166,6 @@ function buildRunStartedLog(input: { ].join('\n') } -function getAssistantMessageState(messages: SessionMessageInfo[]): { - responseText: string | null - errorText: string | null - completed: boolean -} | null { - const assistantMessage = messages.find( - (message): message is SessionMessageAssistant => message.type === 'assistant', - ) - - if (!assistantMessage) { - return null - } - - return { - responseText: assistantText(assistantMessage.content, { stripThink: true }) || null, - errorText: assistantMessage.error?.message ?? null, - completed: Boolean(assistantMessage.time.completed), - } -} - function getSessionErrorText(event: SSEEvent): string | null { if (event.type !== 'session.execution.failed') { return null @@ -1184,7 +1165,7 @@ export class ScheduleService { return { kind: 'busy' } } - const assistantState = getAssistantMessageState(await this.listSessionMessages(sessionId)) + const assistantState = await this.readAssistantReply(sessionId) if (assistantState?.completed || assistantState?.errorText) { return { kind: 'settled', responseText: assistantState.responseText, errorText: assistantState.errorText } } @@ -1209,9 +1190,9 @@ export class ScheduleService { const signal = await sessionMonitor.nextSignal() if (signal.errorText || signal.disposed) { - const messages = await this.listSessionMessages(sessionId) + const assistantState = await this.readAssistantReply(sessionId) return { - responseText: getAssistantMessageState(messages)?.responseText ?? null, + responseText: assistantState?.responseText ?? null, errorText: signal.errorText ?? SESSION_STOPPED_ERROR, } } @@ -1230,14 +1211,9 @@ export class ScheduleService { } } - private async listSessionMessages(sessionId: string): Promise { + private async readAssistantReply(sessionId: string): Promise { try { - const response = await this.openCodeClient.api.message.list({ - sessionID: sessionId, - order: 'desc', - limit: 20, - }) - return response.data + return await readLatestAssistantReply(this.openCodeClient, sessionId) } catch (error) { throw new ScheduleServiceError(getErrorMessage(error) || 'Failed to fetch session messages', 502) } @@ -1245,8 +1221,7 @@ export class ScheduleService { private async isSessionActive(sessionId: string): Promise { try { - const active = await this.openCodeClient.api.session.active() - return sessionId in active + return await isSessionBusy(this.openCodeClient, sessionId) } catch (error) { throw new ScheduleServiceError(getErrorMessage(error) || 'Failed to fetch active sessions', 502) } diff --git a/backend/src/services/session-reply.ts b/backend/src/services/session-reply.ts new file mode 100644 index 000000000..12d47aa66 --- /dev/null +++ b/backend/src/services/session-reply.ts @@ -0,0 +1,38 @@ +import { assistantText, type SessionMessageAssistant, type SessionMessageInfo } from '@opencode-manager/shared/opencode' +import type { OpenCodeClient } from './opencode/client' + +export interface AssistantReplyState { + responseText: string | null + errorText: string | null + completed: boolean +} + +export function getLatestAssistantReplyState(messages: SessionMessageInfo[]): AssistantReplyState | null { + const assistantMessage = messages.find( + (message): message is SessionMessageAssistant => message.type === 'assistant', + ) + + if (!assistantMessage) { + return null + } + + return { + responseText: assistantText(assistantMessage.content, { stripThink: true }) || null, + errorText: assistantMessage.error?.message ?? null, + completed: Boolean(assistantMessage.time.completed), + } +} + +export async function readLatestAssistantReply(client: OpenCodeClient, sessionId: string): Promise { + const response = await client.api.message.list({ + sessionID: sessionId, + order: 'desc', + limit: 20, + }) + return getLatestAssistantReplyState(response.data) +} + +export async function isSessionBusy(client: OpenCodeClient, sessionId: string): Promise { + const active = await client.api.session.active() + return sessionId in active +} diff --git a/backend/test/services/session-reply.test.ts b/backend/test/services/session-reply.test.ts new file mode 100644 index 000000000..252b40dd3 --- /dev/null +++ b/backend/test/services/session-reply.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it, vi } from 'vitest' +import type { SessionMessageInfo } from '@opencode-manager/shared/opencode' +import type { OpenCodeClient } from '../../src/services/opencode/client' +import { + getLatestAssistantReplyState, + isSessionBusy, + readLatestAssistantReply, +} from '../../src/services/session-reply' +import { assistantMessage } from '../helpers/stub-schedule-api' + +function createFakeClient(messages: SessionMessageInfo[], active: Record = {}): OpenCodeClient { + return { + api: { + message: { + list: vi.fn(async () => ({ data: messages, cursor: {} })), + }, + session: { + active: vi.fn(async () => active), + }, + }, + forwardRaw: vi.fn(), + } as unknown as OpenCodeClient +} + +describe('getLatestAssistantReplyState', () => { + it('returns the newest assistant text with think blocks stripped', () => { + const state = getLatestAssistantReplyState([ + assistantMessage('', { + completed: true, + content: [ + { type: 'text', text: '\u003cthink\u003eprivate reasoning\u003c/think\u003e\nFinal answer' }, + { type: 'text', text: 'Second paragraph.' }, + ], + }), + ]) + + expect(state).toEqual({ + responseText: 'Final answer\n\nSecond paragraph.', + errorText: null, + completed: true, + }) + }) + + it('surfaces the assistant error message', () => { + const state = getLatestAssistantReplyState([ + assistantMessage('', { error: 'Provider exploded' }), + ]) + + expect(state).toEqual({ + responseText: null, + errorText: 'Provider exploded', + completed: false, + }) + }) + + it('reports an incomplete assistant message as not completed', () => { + const state = getLatestAssistantReplyState([assistantMessage('Partial output')]) + + expect(state).toEqual({ + responseText: 'Partial output', + errorText: null, + completed: false, + }) + }) + + it('returns null when no assistant message is present', () => { + expect(getLatestAssistantReplyState([])).toBeNull() + }) +}) + +describe('readLatestAssistantReply', () => { + it('reads the newest assistant reply through the client', async () => { + const client = createFakeClient([assistantMessage('From the client.', { completed: true })]) + + await expect(readLatestAssistantReply(client, 'ses-1')).resolves.toEqual({ + responseText: 'From the client.', + errorText: null, + completed: true, + }) + expect(client.api.message.list).toHaveBeenCalledWith({ + sessionID: 'ses-1', + order: 'desc', + limit: 20, + }) + }) + + it('returns null when the session has no assistant message', async () => { + await expect(readLatestAssistantReply(createFakeClient([]), 'ses-1')).resolves.toBeNull() + }) +}) + +describe('isSessionBusy', () => { + it('is true when the session appears in the active set', async () => { + const client = createFakeClient([], { 'ses-1': { type: 'running' } }) + + await expect(isSessionBusy(client, 'ses-1')).resolves.toBe(true) + }) + + it('is false when the session is absent from the active set', async () => { + await expect(isSessionBusy(createFakeClient([], {}), 'ses-1')).resolves.toBe(false) + }) +}) From 66e6c20f8e418c9b35c10fa4311dadc88a8830a9 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:08:12 -0400 Subject: [PATCH 02/16] section 2: Phase 2: Session permission mode storage, resolution and API --- .../025-session-permission-modes.ts | 15 +++ backend/src/db/migrations/index.ts | 2 + backend/src/db/session-permission-modes.ts | 38 ++++++ backend/src/index.ts | 4 + .../src/routes/session-permission-modes.ts | 40 +++++++ .../src/services/session-permission-modes.ts | 104 ++++++++++++++++ .../helpers/fake-session-permission-client.ts | 26 ++++ backend/test/routes/internal-settings.test.ts | 12 ++ .../routes/session-permission-modes.test.ts | 101 ++++++++++++++++ .../services/session-permission-modes.test.ts | 112 ++++++++++++++++++ shared/src/schemas/index.ts | 1 + shared/src/schemas/session-permissions.ts | 21 ++++ shared/src/schemas/settings.ts | 13 ++ 13 files changed, 489 insertions(+) create mode 100644 backend/src/db/migrations/025-session-permission-modes.ts create mode 100644 backend/src/db/session-permission-modes.ts create mode 100644 backend/src/routes/session-permission-modes.ts create mode 100644 backend/src/services/session-permission-modes.ts create mode 100644 backend/test/helpers/fake-session-permission-client.ts create mode 100644 backend/test/routes/session-permission-modes.test.ts create mode 100644 backend/test/services/session-permission-modes.test.ts create mode 100644 shared/src/schemas/session-permissions.ts diff --git a/backend/src/db/migrations/025-session-permission-modes.ts b/backend/src/db/migrations/025-session-permission-modes.ts new file mode 100644 index 000000000..a952f392a --- /dev/null +++ b/backend/src/db/migrations/025-session-permission-modes.ts @@ -0,0 +1,15 @@ +import type { Migration } from '../migration-runner' +import { ensureSessionPermissionModesTable } from '../session-permission-modes' + +const migration: Migration = { + version: 25, + name: 'session-permission-modes', + up(db) { + ensureSessionPermissionModesTable(db) + }, + down(db) { + db.run('DROP TABLE IF EXISTS session_permission_modes') + }, +} + +export default migration diff --git a/backend/src/db/migrations/index.ts b/backend/src/db/migrations/index.ts index b0b66cd14..cbbed1f23 100644 --- a/backend/src/db/migrations/index.ts +++ b/backend/src/db/migrations/index.ts @@ -23,6 +23,7 @@ import migration021 from './021-drop-schedule-run-workspace-id' import migration022 from './022-schedule-runs-session-index' import migration023 from './023-schedule-mcp-servers' import migration024 from './024-schedule-runs-viewed-at' +import migration025 from './025-session-permission-modes' export const allMigrations: Migration[] = [ migration001, @@ -49,4 +50,5 @@ export const allMigrations: Migration[] = [ migration022, migration023, migration024, + migration025, ] diff --git a/backend/src/db/session-permission-modes.ts b/backend/src/db/session-permission-modes.ts new file mode 100644 index 000000000..2669bb2f0 --- /dev/null +++ b/backend/src/db/session-permission-modes.ts @@ -0,0 +1,38 @@ +import type { Database } from 'bun:sqlite' +import type { SessionPermissionMode } from '@opencode-manager/shared/schemas' + +export function ensureSessionPermissionModesTable(db: Database): void { + db.run(` + CREATE TABLE IF NOT EXISTS session_permission_modes ( + session_id TEXT PRIMARY KEY, + mode TEXT NOT NULL CHECK(mode IN ('ask','auto')), + updated_at INTEGER NOT NULL + ) + `) +} + +export function getSessionPermissionMode(db: Database, sessionId: string): SessionPermissionMode | null { + const row = db + .prepare('SELECT mode FROM session_permission_modes WHERE session_id = ?') + .get(sessionId) as { mode: SessionPermissionMode } | undefined + return row ? row.mode : null +} + +export function setSessionPermissionMode(db: Database, sessionId: string, mode: SessionPermissionMode): void { + db.prepare(` + INSERT INTO session_permission_modes(session_id, mode, updated_at) + VALUES(?,?,?) + ON CONFLICT(session_id) DO UPDATE SET mode=excluded.mode, updated_at=excluded.updated_at + `).run(sessionId, mode, Date.now()) +} + +export function insertSessionPermissionModeIfAbsent(db: Database, sessionId: string, mode: SessionPermissionMode): void { + db.prepare(` + INSERT OR IGNORE INTO session_permission_modes(session_id, mode, updated_at) + VALUES(?,?,?) + `).run(sessionId, mode, Date.now()) +} + +export function deleteSessionPermissionMode(db: Database, sessionId: string): void { + db.prepare('DELETE FROM session_permission_modes WHERE session_id = ?').run(sessionId) +} diff --git a/backend/src/index.ts b/backend/src/index.ts index 88cfe0f1b..2d64f8037 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -36,6 +36,7 @@ import { createAuth } from './auth' import { createAuthMiddleware } from './auth/middleware' import { createPromptTemplateRoutes } from './routes/prompt-templates' import { createSessionPinRoutes } from './routes/session-pins' +import { createSessionPermissionModeRoutes } from './routes/session-permission-modes' import { createLogRoutes } from './routes/logs' import { createInternalRoutes } from './routes/internal' import { sweepStaleUploadSessions } from './routes/internal/repo-mirror-helpers' @@ -44,6 +45,7 @@ import { createAuthenticatedOpenCodeProxyRoutes } from './routes/opencode-auth-p import { sseAggregator } from './services/sse-aggregator' import { ensureDirectoryExists, writeFileContent, fileExists } from './services/file-operations' import { SettingsService } from './services/settings' +import { SessionPermissionModeService } from './services/session-permission-modes' import { opencodeServerManager } from './services/opencode-single-server' import { createOpenCodeClient } from './services/opencode/client' import { NotificationService } from './services/notification' @@ -227,6 +229,7 @@ const scheduleService = new ScheduleService(db, openCodeClient, scheduleWorktree const scheduleRunnerInstance = new ScheduleRunner(scheduleService) const notificationService = new NotificationService(db) +const sessionPermissionModeService = new SessionPermissionModeService(db, openCodeClient, settingsServiceForSchedules) if (ENV.VAPID.PUBLIC_KEY && ENV.VAPID.PRIVATE_KEY) { if (!ENV.VAPID.SUBJECT) { @@ -283,6 +286,7 @@ protectedApi.route('/ssh', createSSHRoutes(gitAuthService)) protectedApi.route('/notifications', createNotificationRoutes(notificationService)) protectedApi.route('/prompt-templates', createPromptTemplateRoutes(db)) protectedApi.route('/session-pins', createSessionPinRoutes(db)) +protectedApi.route('/session-permission-modes', createSessionPermissionModeRoutes(sessionPermissionModeService)) protectedApi.route('/schedules', createScheduleRoutes(scheduleService)) protectedApi.route('/logs', createLogRoutes()) diff --git a/backend/src/routes/session-permission-modes.ts b/backend/src/routes/session-permission-modes.ts new file mode 100644 index 000000000..2c90974df --- /dev/null +++ b/backend/src/routes/session-permission-modes.ts @@ -0,0 +1,40 @@ +import { Hono } from 'hono' +import { SetSessionPermissionModeRequestSchema } from '@opencode-manager/shared/schemas' +import { SessionPermissionModeError, type SessionPermissionModeService } from '../services/session-permission-modes' +import { handleServiceError } from '../utils/route-helpers' + +export function createSessionPermissionModeRoutes(service: SessionPermissionModeService) { + const app = new Hono() + + app.get('/:sessionId', async (c) => { + try { + const state = await service.getEffectiveMode(c.req.param('sessionId')) + return c.json(state) + } catch (error) { + return handleServiceError(c, error, 'Failed to read session permission mode', SessionPermissionModeError) + } + }) + + app.put('/:sessionId', async (c) => { + let body: unknown + try { + body = await c.req.json() + } catch { + return c.json({ error: 'Invalid request' }, 400) + } + + const parsed = SetSessionPermissionModeRequestSchema.safeParse(body) + if (!parsed.success) { + return c.json({ error: 'Invalid request' }, 400) + } + + try { + const state = await service.setMode(c.req.param('sessionId'), parsed.data.mode, parsed.data.directory) + return c.json(state) + } catch (error) { + return handleServiceError(c, error, 'Failed to set session permission mode', SessionPermissionModeError) + } + }) + + return app +} diff --git a/backend/src/services/session-permission-modes.ts b/backend/src/services/session-permission-modes.ts new file mode 100644 index 000000000..9b693f8d5 --- /dev/null +++ b/backend/src/services/session-permission-modes.ts @@ -0,0 +1,104 @@ +import type { Database } from 'bun:sqlite' +import type { + SessionPermissionMode, + SessionPermissionModeState, +} from '@opencode-manager/shared/schemas' +import { getScheduleRunBySessionId } from '../db/schedules' +import { getSessionPermissionMode, setSessionPermissionMode } from '../db/session-permission-modes' +import type { OpenCodeClient } from './opencode/client' +import type { SettingsService } from './settings' + +const MAX_PARENT_HOPS = 10 + +export class SessionPermissionModeError extends Error { + status: number + + constructor(message: string, status: number) { + super(message) + this.status = status + } +} + +export class SessionPermissionModeService { + private readonly parentBySession = new Map() + + constructor( + private readonly db: Database, + private readonly openCodeClient: OpenCodeClient, + private readonly settingsService: SettingsService, + ) {} + + rememberParent(sessionId: string, parentId: string | null | undefined): void { + this.parentBySession.set(sessionId, parentId ?? null) + } + + async resolveRootSessionId(sessionId: string): Promise { + let current = sessionId + + for (let hops = 0; hops < MAX_PARENT_HOPS; hops += 1) { + const parentId = await this.getParentId(current) + if (!parentId) { + return current + } + current = parentId + } + + return current + } + + async getEffectiveMode(sessionId: string): Promise { + let rootSessionId: string + + try { + rootSessionId = await this.resolveRootSessionId(sessionId) + } catch { + return { sessionId, rootSessionId: sessionId, mode: 'ask', inherited: false } + } + + const inherited = rootSessionId !== sessionId + + if (getScheduleRunBySessionId(this.db, rootSessionId)) { + return { sessionId, rootSessionId, mode: 'ask', inherited } + } + + const stored = getSessionPermissionMode(this.db, rootSessionId) + return { sessionId, rootSessionId, mode: stored ?? 'ask', inherited } + } + + async setMode(sessionId: string, mode: SessionPermissionMode, directory: string): Promise { + void directory + + const rootSessionId = await this.resolveRootSessionId(sessionId) + if (rootSessionId !== sessionId) { + throw new SessionPermissionModeError( + 'Child sessions inherit the permission mode of their parent session', + 400, + ) + } + + if (getScheduleRunBySessionId(this.db, rootSessionId)) { + throw new SessionPermissionModeError( + 'Scheduled runs use their own permission configuration', + 409, + ) + } + + setSessionPermissionMode(this.db, sessionId, mode) + return { sessionId, rootSessionId, mode, inherited: false } + } + + defaultMode(): SessionPermissionMode { + return this.settingsService.getSettings().preferences.sessionDefaults?.permissionMode ?? 'ask' + } + + private async getParentId(sessionId: string): Promise { + if (this.parentBySession.has(sessionId)) { + return this.parentBySession.get(sessionId) ?? null + } + + const session = await this.openCodeClient.api.session.get({ sessionID: sessionId }) + const parentId = session.parentID ?? null + this.parentBySession.set(sessionId, parentId) + return parentId + } +} diff --git a/backend/test/helpers/fake-session-permission-client.ts b/backend/test/helpers/fake-session-permission-client.ts new file mode 100644 index 000000000..96a441433 --- /dev/null +++ b/backend/test/helpers/fake-session-permission-client.ts @@ -0,0 +1,26 @@ +import type { OpenCodeClient } from '../../src/services/opencode/client' + +export interface FakeSessionPermissionClientOptions { + parents?: Record + failSessionGet?: boolean +} + +export function createFakeSessionPermissionClient( + options: FakeSessionPermissionClientOptions = {}, +): OpenCodeClient { + const parents = options.parents ?? {} + + return { + api: { + session: { + get: async ({ sessionID }: { sessionID: string }) => { + if (options.failSessionGet) { + throw new Error('upstream unavailable') + } + return { parentID: parents[sessionID] ?? undefined } + }, + }, + }, + forwardRaw: async () => new Response(), + } as unknown as OpenCodeClient +} diff --git a/backend/test/routes/internal-settings.test.ts b/backend/test/routes/internal-settings.test.ts index 276970d91..e502fcc40 100644 --- a/backend/test/routes/internal-settings.test.ts +++ b/backend/test/routes/internal-settings.test.ts @@ -153,6 +153,18 @@ describe('internal/settings routes', () => { expect(res.status).toBe(400) }) + it('PATCH /api/internal/settings with { sessionDefaults: { permissionMode: "auto" } } returns 400 (strict reject)', async () => { + const res = await app.request('/api/internal/settings', { + method: 'PATCH', + body: JSON.stringify({ sessionDefaults: { permissionMode: 'auto' } }), + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${token}`, + }, + }) + expect(res.status).toBe(400) + }) + it('PATCH /api/internal/settings with { theme: "rainbow" } returns 400 (enum reject)', async () => { const res = await app.request('/api/internal/settings', { method: 'PATCH', diff --git a/backend/test/routes/session-permission-modes.test.ts b/backend/test/routes/session-permission-modes.test.ts new file mode 100644 index 000000000..05ae248f9 --- /dev/null +++ b/backend/test/routes/session-permission-modes.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { Hono } from 'hono' +import { Database } from 'bun:sqlite' +import { migrate } from '../../src/db/migration-runner' +import { allMigrations } from '../../src/db/migrations' +import { SettingsService } from '../../src/services/settings' +import { SessionPermissionModeService } from '../../src/services/session-permission-modes' +import { createSessionPermissionModeRoutes } from '../../src/routes/session-permission-modes' +import { createFakeSessionPermissionClient } from '../helpers/fake-session-permission-client' + +function createTestApp(db: Database, parents: Record = {}): Hono { + const app = new Hono() + const service = new SessionPermissionModeService(db, createFakeSessionPermissionClient({ parents }), new SettingsService(db)) + app.route('/session-permission-modes', createSessionPermissionModeRoutes(service)) + return app +} + +function createTestDb(): Database { + const db = new Database(':memory:') + migrate(db, allMigrations) + return db +} + +describe('session permission mode routes', () => { + let db: Database + + beforeEach(() => { + db = createTestDb() + }) + + afterEach(() => { + db.close() + }) + + it('GET returns ask for a session with no stored mode', async () => { + const app = createTestApp(db) + + const res = await app.request('/session-permission-modes/ses_root') + expect(res.status).toBe(200) + await expect(res.json()).resolves.toEqual({ + sessionId: 'ses_root', + rootSessionId: 'ses_root', + mode: 'ask', + inherited: false, + }) + }) + + it('PUT stores a mode and GET reads it back', async () => { + const app = createTestApp(db) + + const putRes = await app.request('/session-permission-modes/ses_root', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ directory: '/abs/repo', mode: 'auto' }), + }) + expect(putRes.status).toBe(200) + await expect(putRes.json()).resolves.toEqual({ + sessionId: 'ses_root', + rootSessionId: 'ses_root', + mode: 'auto', + inherited: false, + }) + + const getRes = await app.request('/session-permission-modes/ses_root') + const body = await getRes.json() as { mode: string } + expect(body.mode).toBe('auto') + }) + + it('PUT rejects an invalid mode with 400', async () => { + const app = createTestApp(db) + + const res = await app.request('/session-permission-modes/ses_root', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ directory: '/abs/repo', mode: 'always' }), + }) + expect(res.status).toBe(400) + }) + + it('PUT rejects malformed JSON with 400', async () => { + const app = createTestApp(db) + + const res = await app.request('/session-permission-modes/ses_root', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: '{not json', + }) + expect(res.status).toBe(400) + }) + + it('PUT rejects setting the mode of a child session with 400', async () => { + const app = createTestApp(db, { ses_child: 'ses_root', ses_root: null }) + + const res = await app.request('/session-permission-modes/ses_child', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ directory: '/abs/repo', mode: 'auto' }), + }) + expect(res.status).toBe(400) + }) +}) diff --git a/backend/test/services/session-permission-modes.test.ts b/backend/test/services/session-permission-modes.test.ts new file mode 100644 index 000000000..d7072af7b --- /dev/null +++ b/backend/test/services/session-permission-modes.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { Database } from 'bun:sqlite' +import { migrate } from '../../src/db/migration-runner' +import { allMigrations } from '../../src/db/migrations' +import { createRepo } from '../../src/db/queries' +import { createScheduleRun, updateScheduleRunMetadata } from '../../src/db/schedules' +import { getSessionPermissionMode, setSessionPermissionMode } from '../../src/db/session-permission-modes' +import { SettingsService } from '../../src/services/settings' +import { SessionPermissionModeService } from '../../src/services/session-permission-modes' +import { createFakeSessionPermissionClient } from '../helpers/fake-session-permission-client' + +function createTestDb(): Database { + const db = new Database(':memory:') + migrate(db, allMigrations) + return db +} + +describe('SessionPermissionModeService', () => { + let db: Database + + beforeEach(() => { + db = createTestDb() + }) + + afterEach(() => { + db.close() + }) + + it('treats a root session with no stored row as ask', async () => { + const service = new SessionPermissionModeService(db, createFakeSessionPermissionClient(), new SettingsService(db)) + + await expect(service.getEffectiveMode('ses_root')).resolves.toEqual({ + sessionId: 'ses_root', + rootSessionId: 'ses_root', + mode: 'ask', + inherited: false, + }) + }) + + it('applies a stored auto root mode to its child and marks it inherited', async () => { + setSessionPermissionMode(db, 'ses_root', 'auto') + const client = createFakeSessionPermissionClient({ parents: { ses_child: 'ses_root', ses_root: null } }) + const service = new SessionPermissionModeService(db, client, new SettingsService(db)) + + await expect(service.getEffectiveMode('ses_child')).resolves.toEqual({ + sessionId: 'ses_child', + rootSessionId: 'ses_root', + mode: 'auto', + inherited: true, + }) + }) + + it('always reports ask for a schedule-run session even when a mode is stored', async () => { + createRepo(db, { + localPath: 'repo-one', + sourcePath: '/abs/repo', + defaultBranch: 'main', + cloneStatus: 'ready', + clonedAt: Date.now(), + isLocal: true, + }) + const run = createScheduleRun(db, { + jobId: 1, + repoId: 1, + triggerSource: 'schedule', + status: 'running', + startedAt: Date.now(), + createdAt: Date.now(), + }) + updateScheduleRunMetadata(db, 1, 1, run.id, { sessionId: 'ses_scheduled' }) + setSessionPermissionMode(db, 'ses_scheduled', 'auto') + + const service = new SessionPermissionModeService(db, createFakeSessionPermissionClient(), new SettingsService(db)) + + await expect(service.getEffectiveMode('ses_scheduled')).resolves.toEqual({ + sessionId: 'ses_scheduled', + rootSessionId: 'ses_scheduled', + mode: 'ask', + inherited: false, + }) + }) + + it('rejects setMode for a child session', async () => { + const client = createFakeSessionPermissionClient({ parents: { ses_child: 'ses_root', ses_root: null } }) + const service = new SessionPermissionModeService(db, client, new SettingsService(db)) + + await expect(service.setMode('ses_child', 'auto', '/abs/repo')).rejects.toMatchObject({ status: 400 }) + expect(getSessionPermissionMode(db, 'ses_child')).toBeNull() + expect(getSessionPermissionMode(db, 'ses_root')).toBeNull() + }) + + it('fails closed to ask when parent resolution fails', async () => { + const service = new SessionPermissionModeService(db, createFakeSessionPermissionClient({ failSessionGet: true }), new SettingsService(db)) + + await expect(service.getEffectiveMode('ses_root')).resolves.toEqual({ + sessionId: 'ses_root', + rootSessionId: 'ses_root', + mode: 'ask', + inherited: false, + }) + }) + + it('reads the default mode from session settings', () => { + const settingsService = new SettingsService(db) + const service = new SessionPermissionModeService(db, createFakeSessionPermissionClient(), settingsService) + + expect(service.defaultMode()).toBe('ask') + + settingsService.updateSettings({ sessionDefaults: { permissionMode: 'auto' } }) + expect(service.defaultMode()).toBe('auto') + }) +}) diff --git a/shared/src/schemas/index.ts b/shared/src/schemas/index.ts index 67c74b355..0476031eb 100644 --- a/shared/src/schemas/index.ts +++ b/shared/src/schemas/index.ts @@ -11,4 +11,5 @@ export * from './schedule' export * from './skills' export * from './internal-assistant' export * from './session-pins' +export * from './session-permissions' export * from './logs' diff --git a/shared/src/schemas/session-permissions.ts b/shared/src/schemas/session-permissions.ts new file mode 100644 index 000000000..c9c172056 --- /dev/null +++ b/shared/src/schemas/session-permissions.ts @@ -0,0 +1,21 @@ +import { z } from "zod"; + +export const SessionPermissionModeSchema = z.enum(["ask", "auto"]); + +export type SessionPermissionMode = z.infer; + +export const SetSessionPermissionModeRequestSchema = z.object({ + directory: z.string().min(1), + mode: SessionPermissionModeSchema, +}); + +export type SetSessionPermissionModeRequest = z.infer; + +export const SessionPermissionModeStateSchema = z.object({ + sessionId: z.string(), + rootSessionId: z.string(), + mode: SessionPermissionModeSchema, + inherited: z.boolean(), +}); + +export type SessionPermissionModeState = z.infer; diff --git a/shared/src/schemas/settings.ts b/shared/src/schemas/settings.ts index 828642670..276f8b66b 100644 --- a/shared/src/schemas/settings.ts +++ b/shared/src/schemas/settings.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { NotificationPreferencesSchema, DEFAULT_NOTIFICATION_PREFERENCES } from "./notifications"; import { OPENCODE_CONFIG_SOURCE_NAMES } from "../config/defaults"; import { ColorThemeIdSchema, MANAGER_COLOR_THEME_ID } from "../themes"; +import { SessionPermissionModeSchema } from "./session-permissions"; export const CustomCommandSchema = z.object({ name: z.string(), @@ -142,6 +143,16 @@ export const DEFAULT_SANDBOX_PREFERENCES: SandboxPreferences = { gitCredentials: false, }; +export const SessionDefaultsSchema = z.object({ + permissionMode: SessionPermissionModeSchema, +}); + +export type SessionDefaults = z.infer; + +export const DEFAULT_SESSION_DEFAULTS: SessionDefaults = { + permissionMode: 'ask', +}; + export const UserPreferencesSchema = z.object({ theme: z.enum(["dark", "light", "system"]), colorTheme: ColorThemeIdSchema.optional(), @@ -168,6 +179,7 @@ export const UserPreferencesSchema = z.object({ repoSortMode: z.enum(['recent', 'manual', 'name']).optional(), serverEnvVars: z.array(ServerEnvVarSchema).optional(), sandbox: SandboxPreferencesSchema.optional(), + sessionDefaults: SessionDefaultsSchema.optional(), disabledDefaultServerEnvVars: z.array(z.string()).optional(), }); @@ -220,6 +232,7 @@ export const DEFAULT_USER_PREFERENCES = { repoSortMode: 'recent' as const, serverEnvVars: [] as ServerEnvVar[], sandbox: DEFAULT_SANDBOX_PREFERENCES, + sessionDefaults: DEFAULT_SESSION_DEFAULTS, }; export const SettingsResponseSchema = z.object({ From f85531f2d396eb554b413f049f4f4dd0c7039b4f Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:18:14 -0400 Subject: [PATCH 03/16] section 3: Phase 3: Auto-accept engine, default stamping and notificati --- backend/src/index.ts | 11 + backend/src/services/notification.ts | 11 + .../src/services/session-permission-modes.ts | 126 +++++++++-- .../helpers/fake-session-permission-client.ts | 50 ++++- .../services/notification-service.test.ts | 22 ++ .../services/session-permission-modes.test.ts | 196 +++++++++++++++++- 6 files changed, 390 insertions(+), 26 deletions(-) diff --git a/backend/src/index.ts b/backend/src/index.ts index 2d64f8037..6bbbe7cb1 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -231,6 +231,17 @@ const scheduleRunnerInstance = new ScheduleRunner(scheduleService) const notificationService = new NotificationService(db) const sessionPermissionModeService = new SessionPermissionModeService(db, openCodeClient, settingsServiceForSchedules) +sseAggregator.onEvent((directory, event) => { + sessionPermissionModeService.handleEvent(directory, event).catch((err) => { + logger.error('Session permission mode event handling error:', err) + }) +}) + +notificationService.addEventSuppressor(async (event, sessionId) => { + if (event.type !== 'permission.asked' || !sessionId) return false + return (await sessionPermissionModeService.getEffectiveMode(sessionId)).mode === 'auto' +}) + if (ENV.VAPID.PUBLIC_KEY && ENV.VAPID.PRIVATE_KEY) { if (!ENV.VAPID.SUBJECT) { logger.warn('VAPID_SUBJECT is not set — push notifications require a mailto: subject (e.g. mailto:you@example.com)') diff --git a/backend/src/services/notification.ts b/backend/src/services/notification.ts index d64e2ab2c..76f20038c 100644 --- a/backend/src/services/notification.ts +++ b/backend/src/services/notification.ts @@ -139,9 +139,12 @@ export function buildEventNotificationPayload( }; } +type EventSuppressor = (event: SSEEvent, sessionId: string | undefined) => Promise; + export class NotificationService { private vapidConfig: VapidConfig | null = null; private settingsService: SettingsService; + private eventSuppressors: EventSuppressor[] = []; constructor(private db: Database) { this.settingsService = new SettingsService(db); @@ -178,6 +181,10 @@ export class NotificationService { return this.vapidConfig?.publicKey ?? null; } + addEventSuppressor(suppressor: EventSuppressor): void { + this.eventSuppressors.push(suppressor); + } + isConfigured(): boolean { return this.vapidConfig !== null; } @@ -321,6 +328,10 @@ export class NotificationService { if (sessionId && sseAggregator.isSessionBeingViewed(sessionId)) return; if (sessionId && sseAggregator.isSubagentSession(sessionId)) return; + for (const suppressor of this.eventSuppressors) { + if (await suppressor(event, sessionId)) return; + } + if (!this.isConfigured()) return; const userIds = this.getAllUserIds(); diff --git a/backend/src/services/session-permission-modes.ts b/backend/src/services/session-permission-modes.ts index 9b693f8d5..cee972fc6 100644 --- a/backend/src/services/session-permission-modes.ts +++ b/backend/src/services/session-permission-modes.ts @@ -3,9 +3,17 @@ import type { SessionPermissionMode, SessionPermissionModeState, } from '@opencode-manager/shared/schemas' +import { openCodeLocation, type PermissionRequest } from '@opencode-manager/shared/opencode' import { getScheduleRunBySessionId } from '../db/schedules' -import { getSessionPermissionMode, setSessionPermissionMode } from '../db/session-permission-modes' +import { + deleteSessionPermissionMode, + getSessionPermissionMode, + insertSessionPermissionModeIfAbsent, + setSessionPermissionMode, +} from '../db/session-permission-modes' +import { logger } from '../utils/logger' import type { OpenCodeClient } from './opencode/client' +import type { SSEEvent } from './sse-aggregator' import type { SettingsService } from './settings' const MAX_PARENT_HOPS = 10 @@ -46,28 +54,36 @@ export class SessionPermissionModeService { return current } - async getEffectiveMode(sessionId: string): Promise { - let rootSessionId: string - - try { - rootSessionId = await this.resolveRootSessionId(sessionId) - } catch { - return { sessionId, rootSessionId: sessionId, mode: 'ask', inherited: false } - } - - const inherited = rootSessionId !== sessionId - - if (getScheduleRunBySessionId(this.db, rootSessionId)) { - return { sessionId, rootSessionId, mode: 'ask', inherited } + async handleEvent(directory: string, event: SSEEvent): Promise { + switch (event.type) { + case 'session.created': { + const { sessionID, parentID } = event.data + this.rememberParent(sessionID, parentID) + if (!parentID && this.defaultMode() === 'auto') { + insertSessionPermissionModeIfAbsent(this.db, sessionID, 'auto') + } + return + } + case 'permission.asked': { + const { sessionID, id } = event.data + await this.autoAcceptRequest(sessionID, id) + return + } + case 'session.deleted': { + const { sessionID } = event.data + this.parentBySession.delete(sessionID) + deleteSessionPermissionMode(this.db, sessionID) + return + } } + } - const stored = getSessionPermissionMode(this.db, rootSessionId) - return { sessionId, rootSessionId, mode: stored ?? 'ask', inherited } + async getEffectiveMode(sessionId: string): Promise { + const rootSessionId = await this.resolveRootSessionIdOrNull(sessionId) + return this.effectiveModeForRoot(sessionId, rootSessionId) } async setMode(sessionId: string, mode: SessionPermissionMode, directory: string): Promise { - void directory - const rootSessionId = await this.resolveRootSessionId(sessionId) if (rootSessionId !== sessionId) { throw new SessionPermissionModeError( @@ -84,6 +100,9 @@ export class SessionPermissionModeService { } setSessionPermissionMode(this.db, sessionId, mode) + if (mode === 'auto') { + await this.acceptPendingRequests(sessionId, directory) + } return { sessionId, rootSessionId, mode, inherited: false } } @@ -91,6 +110,77 @@ export class SessionPermissionModeService { return this.settingsService.getSettings().preferences.sessionDefaults?.permissionMode ?? 'ask' } + private effectiveModeForRoot( + sessionId: string, + rootSessionId: string | null, + ): SessionPermissionModeState { + if (!rootSessionId) { + return { sessionId, rootSessionId: sessionId, mode: 'ask', inherited: false } + } + + const inherited = rootSessionId !== sessionId + + if (getScheduleRunBySessionId(this.db, rootSessionId)) { + return { sessionId, rootSessionId, mode: 'ask', inherited } + } + + const stored = getSessionPermissionMode(this.db, rootSessionId) + return { sessionId, rootSessionId, mode: stored ?? 'ask', inherited } + } + + private async acceptPendingRequests(rootSessionId: string, directory: string): Promise { + let requests: PermissionRequest[] + try { + const result = await this.openCodeClient.api.permission.request.list(openCodeLocation(directory)) + requests = result.data + } catch (error) { + logger.error(`Failed to list pending permission requests for session ${rootSessionId}:`, error) + return + } + + for (const request of requests) { + await this.autoAcceptRequest(request.sessionID, request.id, rootSessionId) + } + } + + private async autoAcceptRequest( + sessionID: string, + requestID: string, + expectedRootSessionId?: string, + ): Promise { + const rootSessionId = await this.resolveRootSessionIdOrNull(sessionID) + if (!rootSessionId) { + return + } + + if (expectedRootSessionId !== undefined && rootSessionId !== expectedRootSessionId) { + return + } + + if (this.effectiveModeForRoot(sessionID, rootSessionId).mode !== 'auto') { + return + } + + await this.replyOnce(sessionID, requestID) + } + + private async replyOnce(sessionID: string, requestID: string): Promise { + try { + await this.openCodeClient.api.permission.reply({ sessionID, requestID, decision: 'once' }) + logger.info(`Auto-accepted permission request ${requestID} for session ${sessionID}`) + } catch (error) { + logger.error(`Failed to auto-accept permission request ${requestID} for session ${sessionID}:`, error) + } + } + + private async resolveRootSessionIdOrNull(sessionId: string): Promise { + try { + return await this.resolveRootSessionId(sessionId) + } catch { + return null + } + } + private async getParentId(sessionId: string): Promise { if (this.parentBySession.has(sessionId)) { return this.parentBySession.get(sessionId) ?? null diff --git a/backend/test/helpers/fake-session-permission-client.ts b/backend/test/helpers/fake-session-permission-client.ts index 96a441433..93bf422f8 100644 --- a/backend/test/helpers/fake-session-permission-client.ts +++ b/backend/test/helpers/fake-session-permission-client.ts @@ -1,26 +1,62 @@ +import { vi } from 'vitest' import type { OpenCodeClient } from '../../src/services/opencode/client' +export interface FakePendingPermissionRequest { + id: string + sessionID: string +} + export interface FakeSessionPermissionClientOptions { parents?: Record failSessionGet?: boolean + pendingRequests?: Record + getSession?: (sessionID: string) => Promise<{ parentID?: string | null }> + listRequests?: (directory: string) => Promise +} + +export interface FakeSessionPermissionClient extends OpenCodeClient { + replyPermission: ReturnType } export function createFakeSessionPermissionClient( options: FakeSessionPermissionClientOptions = {}, -): OpenCodeClient { +): FakeSessionPermissionClient { const parents = options.parents ?? {} + const pendingRequests = options.pendingRequests ?? {} + const replyPermission = vi.fn(async () => {}) + + const getSession = + options.getSession ?? + (async (sessionID: string) => { + if (options.failSessionGet) { + throw new Error('upstream unavailable') + } + return { parentID: parents[sessionID] ?? undefined } + }) + + const listRequests = + options.listRequests ?? + (async (directory: string) => pendingRequests[directory] ?? []) return { + replyPermission, api: { session: { - get: async ({ sessionID }: { sessionID: string }) => { - if (options.failSessionGet) { - throw new Error('upstream unavailable') - } - return { parentID: parents[sessionID] ?? undefined } + get: async ({ sessionID }: { sessionID: string }) => getSession(sessionID), + }, + permission: { + request: { + list: async (input?: { location?: { directory?: string } }) => { + const directory = input?.location?.directory ?? '' + return { + location: { directory }, + data: await listRequests(directory), + } + }, }, + reply: replyPermission, }, }, forwardRaw: async () => new Response(), - } as unknown as OpenCodeClient + } as unknown as FakeSessionPermissionClient } diff --git a/backend/test/services/notification-service.test.ts b/backend/test/services/notification-service.test.ts index eb0cace1f..32a769d58 100644 --- a/backend/test/services/notification-service.test.ts +++ b/backend/test/services/notification-service.test.ts @@ -158,4 +158,26 @@ describe('NotificationService.handleSSEEvent session routing', () => { expect(send).not.toHaveBeenCalled() }) + + it('suppresses an event when a registered suppressor resolves true', async () => { + const service = createService() + const send = vi.spyOn(service, 'sendToUser').mockResolvedValue(sendResult) + const suppressor = vi.fn(async () => true) + service.addEventSuppressor(suppressor) + + await service.handleSSEEvent(DIRECTORY, permissionAskedEvent('ses_perm')) + + expect(suppressor).toHaveBeenCalledWith(expect.objectContaining({ type: 'permission.asked' }), 'ses_perm') + expect(send).not.toHaveBeenCalled() + }) + + it('keeps notifying when every registered suppressor resolves false', async () => { + const service = createService() + const send = vi.spyOn(service, 'sendToUser').mockResolvedValue(sendResult) + service.addEventSuppressor(async () => false) + + await service.handleSSEEvent(DIRECTORY, permissionAskedEvent('ses_perm')) + + expect(send).toHaveBeenCalledTimes(1) + }) }) diff --git a/backend/test/services/session-permission-modes.test.ts b/backend/test/services/session-permission-modes.test.ts index d7072af7b..73b00fee1 100644 --- a/backend/test/services/session-permission-modes.test.ts +++ b/backend/test/services/session-permission-modes.test.ts @@ -7,7 +7,24 @@ import { createScheduleRun, updateScheduleRunMetadata } from '../../src/db/sched import { getSessionPermissionMode, setSessionPermissionMode } from '../../src/db/session-permission-modes' import { SettingsService } from '../../src/services/settings' import { SessionPermissionModeService } from '../../src/services/session-permission-modes' -import { createFakeSessionPermissionClient } from '../helpers/fake-session-permission-client' +import type { SSEEvent } from '../../src/services/sse-aggregator' +import { + createFakeSessionPermissionClient, + type FakePendingPermissionRequest, +} from '../helpers/fake-session-permission-client' + +const DIRECTORY = '/abs/repo' + +function createDeferred(): { + promise: Promise + resolve: (value: T) => void +} { + let resolve!: (value: T) => void + const promise = new Promise((res) => { + resolve = res + }) + return { promise, resolve } +} function createTestDb(): Database { const db = new Database(':memory:') @@ -15,6 +32,36 @@ function createTestDb(): Database { return db } +function permissionAskedEvent(sessionID: string, id: string): SSEEvent { + return { + id: `evt_${id}`, + created: Date.now(), + type: 'permission.asked', + location: { directory: DIRECTORY }, + data: { id, sessionID, action: 'shell', resources: ['ls'] }, + } as unknown as SSEEvent +} + +function sessionCreatedEvent(sessionID: string, parentID?: string): SSEEvent { + return { + id: `evt_created_${sessionID}`, + created: Date.now(), + type: 'session.created', + location: { directory: DIRECTORY }, + data: { sessionID, parentID }, + } as unknown as SSEEvent +} + +function sessionDeletedEvent(sessionID: string): SSEEvent { + return { + id: `evt_deleted_${sessionID}`, + created: Date.now(), + type: 'session.deleted', + location: { directory: DIRECTORY }, + data: { sessionID }, + } as unknown as SSEEvent +} + describe('SessionPermissionModeService', () => { let db: Database @@ -109,4 +156,151 @@ describe('SessionPermissionModeService', () => { settingsService.updateSettings({ sessionDefaults: { permissionMode: 'auto' } }) expect(service.defaultMode()).toBe('auto') }) + + it('auto-accepts a permission request for an auto root session', async () => { + setSessionPermissionMode(db, 'ses_root', 'auto') + const client = createFakeSessionPermissionClient({ parents: { ses_root: null } }) + const service = new SessionPermissionModeService(db, client, new SettingsService(db)) + + await service.handleEvent(DIRECTORY, permissionAskedEvent('ses_root', 'perm-1')) + + expect(client.replyPermission).toHaveBeenCalledTimes(1) + expect(client.replyPermission).toHaveBeenCalledWith({ + sessionID: 'ses_root', + requestID: 'perm-1', + decision: 'once', + }) + }) + + it('auto-accepts a permission request for a child of an auto root session', async () => { + setSessionPermissionMode(db, 'ses_root', 'auto') + const client = createFakeSessionPermissionClient({ parents: { ses_child: 'ses_root', ses_root: null } }) + const service = new SessionPermissionModeService(db, client, new SettingsService(db)) + + await service.handleEvent(DIRECTORY, permissionAskedEvent('ses_child', 'perm-2')) + + expect(client.replyPermission).toHaveBeenCalledTimes(1) + expect(client.replyPermission).toHaveBeenCalledWith({ + sessionID: 'ses_child', + requestID: 'perm-2', + decision: 'once', + }) + }) + + it('leaves an ask session permission request unanswered', async () => { + const client = createFakeSessionPermissionClient({ parents: { ses_root: null } }) + const service = new SessionPermissionModeService(db, client, new SettingsService(db)) + + await service.handleEvent(DIRECTORY, permissionAskedEvent('ses_root', 'perm-3')) + + expect(client.replyPermission).not.toHaveBeenCalled() + }) + + it('stamps the default auto mode on a new root session but not on a child', async () => { + const settingsService = new SettingsService(db) + settingsService.updateSettings({ sessionDefaults: { permissionMode: 'auto' } }) + const service = new SessionPermissionModeService(db, createFakeSessionPermissionClient(), settingsService) + + await service.handleEvent(DIRECTORY, sessionCreatedEvent('ses_root')) + await service.handleEvent(DIRECTORY, sessionCreatedEvent('ses_child', 'ses_root')) + + expect(getSessionPermissionMode(db, 'ses_root')).toBe('auto') + expect(getSessionPermissionMode(db, 'ses_child')).toBeNull() + }) + + it('does not stamp a new root session when the default mode is ask', async () => { + const service = new SessionPermissionModeService(db, createFakeSessionPermissionClient(), new SettingsService(db)) + + await service.handleEvent(DIRECTORY, sessionCreatedEvent('ses_root')) + + expect(getSessionPermissionMode(db, 'ses_root')).toBeNull() + }) + + it('clears the stored mode when the session is deleted', async () => { + setSessionPermissionMode(db, 'ses_root', 'auto') + const service = new SessionPermissionModeService(db, createFakeSessionPermissionClient(), new SettingsService(db)) + + await service.handleEvent(DIRECTORY, sessionDeletedEvent('ses_root')) + + expect(getSessionPermissionMode(db, 'ses_root')).toBeNull() + }) + + it('replies to pending requests for the switched root only', async () => { + const client = createFakeSessionPermissionClient({ + parents: { ses_root: null, ses_other: null }, + pendingRequests: { + [DIRECTORY]: [ + { id: 'perm-root', sessionID: 'ses_root' }, + { id: 'perm-other', sessionID: 'ses_other' }, + ], + }, + }) + const service = new SessionPermissionModeService(db, client, new SettingsService(db)) + + await service.setMode('ses_root', 'auto', DIRECTORY) + + expect(client.replyPermission).toHaveBeenCalledTimes(1) + expect(client.replyPermission).toHaveBeenCalledWith({ + sessionID: 'ses_root', + requestID: 'perm-root', + decision: 'once', + }) + }) + + it('does not approve pending requests when the root switches to ask while the listing is in flight', async () => { + const listDeferred = createDeferred() + const listStarted = createDeferred() + const client = createFakeSessionPermissionClient({ + parents: { ses_root: null }, + listRequests: () => { + listStarted.resolve() + return listDeferred.promise + }, + }) + const service = new SessionPermissionModeService(db, client, new SettingsService(db)) + + const autoSwitch = service.setMode('ses_root', 'auto', DIRECTORY) + await listStarted.promise + await service.setMode('ses_root', 'ask', DIRECTORY) + listDeferred.resolve([{ id: 'perm-root', sessionID: 'ses_root' }]) + await autoSwitch + + expect(getSessionPermissionMode(db, 'ses_root')).toBe('ask') + expect(client.replyPermission).not.toHaveBeenCalled() + }) + + it('does not approve a pending child request when the root switches to ask while child resolution is in flight', async () => { + const childDeferred = createDeferred<{ parentID?: string }>() + const childResolutionStarted = createDeferred() + const client = createFakeSessionPermissionClient({ + parents: { ses_root: null }, + pendingRequests: { [DIRECTORY]: [{ id: 'perm-child', sessionID: 'ses_child' }] }, + getSession: (sessionID) => { + if (sessionID === 'ses_child') { + childResolutionStarted.resolve() + return childDeferred.promise + } + return Promise.resolve({ parentID: null }) + }, + }) + const service = new SessionPermissionModeService(db, client, new SettingsService(db)) + + const autoSwitch = service.setMode('ses_root', 'auto', DIRECTORY) + await childResolutionStarted.promise + await service.setMode('ses_root', 'ask', DIRECTORY) + childDeferred.resolve({ parentID: 'ses_root' }) + await autoSwitch + + expect(getSessionPermissionMode(db, 'ses_root')).toBe('ask') + expect(client.replyPermission).not.toHaveBeenCalled() + }) + + it('does not list pending requests when switching to ask', async () => { + const client = createFakeSessionPermissionClient({ parents: { ses_root: null } }) + const service = new SessionPermissionModeService(db, client, new SettingsService(db)) + + await service.setMode('ses_root', 'ask', DIRECTORY) + + expect(client.replyPermission).not.toHaveBeenCalled() + }) }) From dd4db97c0b39df55bcb72a702b37d6c2bb6f85ed Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:26:33 -0400 Subject: [PATCH 04/16] section 4: Phase 4: Permission mode toggle in the composer and default --- .../src/api/sessionPermissionModes.test.ts | 41 +++++++ frontend/src/api/sessionPermissionModes.ts | 23 ++++ .../message/PromptInput.command.test.tsx | 4 + .../message/PromptInput.mention.test.tsx | 4 + .../message/PromptInput.stt.test.tsx | 4 + .../src/components/message/PromptInput.tsx | 3 + .../session/PermissionModeToggle.test.tsx | 113 ++++++++++++++++++ .../session/PermissionModeToggle.tsx | 49 ++++++++ .../components/settings/GeneralSettings.tsx | 3 + .../SessionAutomationSettings.test.tsx | 60 ++++++++++ .../settings/SessionAutomationSettings.tsx | 41 +++++++ .../src/hooks/useSessionPermissionMode.ts | 29 +++++ .../SessionDetail.assistant-loading.test.tsx | 4 + .../__tests__/SessionDetail.polling.test.tsx | 1 + 14 files changed, 379 insertions(+) create mode 100644 frontend/src/api/sessionPermissionModes.test.ts create mode 100644 frontend/src/api/sessionPermissionModes.ts create mode 100644 frontend/src/components/session/PermissionModeToggle.test.tsx create mode 100644 frontend/src/components/session/PermissionModeToggle.tsx create mode 100644 frontend/src/components/settings/SessionAutomationSettings.test.tsx create mode 100644 frontend/src/components/settings/SessionAutomationSettings.tsx create mode 100644 frontend/src/hooks/useSessionPermissionMode.ts diff --git a/frontend/src/api/sessionPermissionModes.test.ts b/frontend/src/api/sessionPermissionModes.test.ts new file mode 100644 index 000000000..4d3ea48a2 --- /dev/null +++ b/frontend/src/api/sessionPermissionModes.test.ts @@ -0,0 +1,41 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getSessionPermissionMode, setSessionPermissionMode } from './sessionPermissionModes' + +describe('sessionPermissionModes', () => { + const fetchMock = vi.fn() + + beforeEach(() => { + fetchMock.mockReset() + vi.stubGlobal('fetch', fetchMock) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('calls GET /api/session-permission-modes/:id and returns the state', async () => { + const state = { sessionId: 'ses_1', rootSessionId: 'ses_1', mode: 'ask', inherited: false } + fetchMock.mockResolvedValue(new Response(JSON.stringify(state), { status: 200 })) + + const result = await getSessionPermissionMode('ses_1') + + expect(result).toEqual(state) + expect(fetchMock.mock.calls[0][0]).toEqual(expect.stringContaining('/api/session-permission-modes/ses_1')) + expect(fetchMock.mock.calls[0][1].method).toBeUndefined() + }) + + it('calls PUT /api/session-permission-modes/:id with the mode and directory', async () => { + const input = { directory: '/repo', mode: 'auto' as const } + const state = { sessionId: 'ses_1', rootSessionId: 'ses_1', mode: 'auto' as const, inherited: false } + fetchMock.mockResolvedValue(new Response(JSON.stringify(state), { status: 200 })) + + const result = await setSessionPermissionMode('ses_1', input) + + expect(result).toEqual(state) + expect(fetchMock.mock.calls[0][0]).toEqual(expect.stringContaining('/api/session-permission-modes/ses_1')) + const options = fetchMock.mock.calls[0][1] + expect(options.method).toBe('PUT') + expect(options.headers['Content-Type']).toBe('application/json') + expect(JSON.parse(options.body)).toEqual(input) + }) +}) diff --git a/frontend/src/api/sessionPermissionModes.ts b/frontend/src/api/sessionPermissionModes.ts new file mode 100644 index 000000000..0be4a141d --- /dev/null +++ b/frontend/src/api/sessionPermissionModes.ts @@ -0,0 +1,23 @@ +import { fetchWrapper } from './fetchWrapper' +import { API_BASE_URL } from '@/config' +import type { SessionPermissionModeState, SetSessionPermissionModeRequest } from '@opencode-manager/shared/schemas' + +export async function getSessionPermissionMode(sessionId: string): Promise { + return fetchWrapper( + `${API_BASE_URL}/api/session-permission-modes/${encodeURIComponent(sessionId)}`, + ) +} + +export async function setSessionPermissionMode( + sessionId: string, + input: SetSessionPermissionModeRequest, +): Promise { + return fetchWrapper( + `${API_BASE_URL}/api/session-permission-modes/${encodeURIComponent(sessionId)}`, + { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(input), + }, + ) +} diff --git a/frontend/src/components/message/PromptInput.command.test.tsx b/frontend/src/components/message/PromptInput.command.test.tsx index a3d63c333..ddbb1ee59 100644 --- a/frontend/src/components/message/PromptInput.command.test.tsx +++ b/frontend/src/components/message/PromptInput.command.test.tsx @@ -82,6 +82,10 @@ vi.mock('@/components/model/ModelQuickSelect', () => ({ ), })) +vi.mock('@/components/session/PermissionModeToggle', () => ({ + PermissionModeToggle: () => null, +})) + vi.mock('@/components/ui/session-status-indicator', () => ({ SessionStatusIndicator: () =>
SessionStatus
, })) diff --git a/frontend/src/components/message/PromptInput.mention.test.tsx b/frontend/src/components/message/PromptInput.mention.test.tsx index b4394cf1d..764199732 100644 --- a/frontend/src/components/message/PromptInput.mention.test.tsx +++ b/frontend/src/components/message/PromptInput.mention.test.tsx @@ -73,6 +73,10 @@ vi.mock('@/components/model/ModelQuickSelect', () => ({ ModelQuickSelect: ({ children }: { children: React.ReactNode }) =>
{children}
, })) +vi.mock('@/components/session/PermissionModeToggle', () => ({ + PermissionModeToggle: () => null, +})) + vi.mock('@/components/ui/session-status-indicator', () => ({ SessionStatusIndicator: () =>
SessionStatus
, })) diff --git a/frontend/src/components/message/PromptInput.stt.test.tsx b/frontend/src/components/message/PromptInput.stt.test.tsx index 52772e9e8..555d658ea 100644 --- a/frontend/src/components/message/PromptInput.stt.test.tsx +++ b/frontend/src/components/message/PromptInput.stt.test.tsx @@ -100,6 +100,10 @@ vi.mock('@/components/model/ModelQuickSelect', () => ({ ModelQuickSelect: ({ children }: { children: React.ReactNode }) =>
{children}
, })) +vi.mock('@/components/session/PermissionModeToggle', () => ({ + PermissionModeToggle: () => null, +})) + vi.mock('@/components/ui/session-status-indicator', () => ({ SessionStatusIndicator: () =>
SessionStatus
, })) diff --git a/frontend/src/components/message/PromptInput.tsx b/frontend/src/components/message/PromptInput.tsx index c43720b36..bf734a905 100644 --- a/frontend/src/components/message/PromptInput.tsx +++ b/frontend/src/components/message/PromptInput.tsx @@ -27,6 +27,7 @@ import { SessionStatusIndicator } from '@/components/ui/session-status-indicator import { ModelQuickSelect } from '@/components/model/ModelQuickSelect' import { AgentQuickSelect } from '@/components/agent/AgentQuickSelect' import { VoiceStatusOverlay, type VoiceStatusOverlayState } from './VoiceStatusOverlay' +import { PermissionModeToggle } from '@/components/session/PermissionModeToggle' import { detectMentionTrigger, parsePromptToInput, getFilename, filterAgentsByQuery } from '@/lib/promptParser' import { getNextPrimaryAgentId } from '@/lib/primaryAgents' import { randomId } from '@/lib/utils' @@ -1345,6 +1346,7 @@ return ( open={isModelPickerOpen} onOpenChange={setIsModelPickerOpen} /> + {directory && } ) : ( <> @@ -1354,6 +1356,7 @@ return ( onAgentChange={handleAgentChange} isBashMode={isBashMode} /> + {directory && } {isSessionActive && (
diff --git a/frontend/src/components/session/PermissionModeToggle.test.tsx b/frontend/src/components/session/PermissionModeToggle.test.tsx new file mode 100644 index 000000000..32a4ba825 --- /dev/null +++ b/frontend/src/components/session/PermissionModeToggle.test.tsx @@ -0,0 +1,113 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { PermissionModeToggle } from './PermissionModeToggle' +import { useSessionPermissionMode, useSetSessionPermissionMode } from '@/hooks/useSessionPermissionMode' +import type { SessionPermissionModeState } from '@opencode-manager/shared/schemas' + +vi.mock('@/hooks/useSessionPermissionMode') + +const baseState: SessionPermissionModeState = { + sessionId: 'ses_1', + rootSessionId: 'ses_1', + mode: 'ask', + inherited: false, +} + +function mockHooks(overrides: { + data?: SessionPermissionModeState | undefined + state?: Partial + mutate?: ReturnType + isPending?: boolean + isError?: boolean +} = {}) { + const data = 'data' in overrides ? overrides.data : { ...baseState, ...overrides.state } + vi.mocked(useSessionPermissionMode).mockReturnValue({ + data, + isError: overrides.isError ?? false, + } as ReturnType) + vi.mocked(useSetSessionPermissionMode).mockReturnValue({ + mutate: overrides.mutate ?? vi.fn(), + isPending: overrides.isPending ?? false, + } as unknown as ReturnType) +} + +describe('PermissionModeToggle', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders the ask state', () => { + mockHooks() + render() + + expect(screen.getByRole('button', { name: 'Permissions: ask every time' })).toBeEnabled() + }) + + it('switches to accept-everything with the directory when clicked', async () => { + const user = userEvent.setup() + const mutate = vi.fn() + mockHooks({ mutate }) + render() + + await user.click(screen.getByRole('button', { name: 'Permissions: ask every time' })) + + expect(mutate).toHaveBeenCalledWith({ directory: '/repo', mode: 'auto' }) + }) + + it('switches back to ask when already accepting everything', async () => { + const user = userEvent.setup() + const mutate = vi.fn() + mockHooks({ state: { mode: 'auto' }, mutate }) + render() + + await user.click(screen.getByRole('button', { name: 'Permissions: accept everything' })) + + expect(mutate).toHaveBeenCalledWith({ directory: '/repo', mode: 'ask' }) + }) + + it('is disabled and labelled as inherited for child sessions', () => { + mockHooks({ state: { inherited: true, mode: 'auto' } }) + render() + + const button = screen.getByRole('button', { name: 'Inherited from parent session' }) + expect(button).toBeDisabled() + }) + + it('cannot mutate while the mode read is still loading', async () => { + const user = userEvent.setup() + const mutate = vi.fn() + mockHooks({ data: undefined, mutate }) + render() + + const button = screen.getByRole('button', { name: 'Permissions: loading' }) + expect(button).toBeDisabled() + + await user.click(button) + expect(mutate).not.toHaveBeenCalled() + }) + + it('cannot mutate and reports unavailable when the mode read failed', async () => { + const user = userEvent.setup() + const mutate = vi.fn() + mockHooks({ data: undefined, isError: true, mutate }) + render() + + const button = screen.getByRole('button', { name: 'Permissions: unavailable' }) + expect(button).toBeDisabled() + + await user.click(button) + expect(mutate).not.toHaveBeenCalled() + }) + + it('switches a loaded auto mode back to ask when clicked', async () => { + const user = userEvent.setup() + const mutate = vi.fn() + mockHooks({ data: { ...baseState, mode: 'auto' }, mutate }) + render() + + await user.click(screen.getByRole('button', { name: 'Permissions: accept everything' })) + + expect(mutate).toHaveBeenCalledWith({ directory: '/repo', mode: 'ask' }) + }) +}) diff --git a/frontend/src/components/session/PermissionModeToggle.tsx b/frontend/src/components/session/PermissionModeToggle.tsx new file mode 100644 index 000000000..2aab24602 --- /dev/null +++ b/frontend/src/components/session/PermissionModeToggle.tsx @@ -0,0 +1,49 @@ +import { Shield, ShieldCheck } from 'lucide-react' +import { useSessionPermissionMode, useSetSessionPermissionMode } from '@/hooks/useSessionPermissionMode' + +interface PermissionModeToggleProps { + sessionID: string + directory: string +} + +export function PermissionModeToggle({ sessionID, directory }: PermissionModeToggleProps) { + const { data, isError } = useSessionPermissionMode(sessionID) + const setMode = useSetSessionPermissionMode(sessionID) + + const loaded = data !== undefined + const isAuto = data?.mode === 'auto' + const inherited = data?.inherited ?? false + const disabled = !loaded || inherited || setMode.isPending + + const label = !loaded + ? isError + ? 'Permissions: unavailable' + : 'Permissions: loading' + : inherited + ? 'Inherited from parent session' + : isAuto + ? 'Permissions: accept everything' + : 'Permissions: ask every time' + + const handleClick = () => { + if (disabled) return + setMode.mutate({ directory, mode: isAuto ? 'ask' : 'auto' }) + } + + return ( + + ) +} diff --git a/frontend/src/components/settings/GeneralSettings.tsx b/frontend/src/components/settings/GeneralSettings.tsx index 02df3caf9..9e83a9bf9 100644 --- a/frontend/src/components/settings/GeneralSettings.tsx +++ b/frontend/src/components/settings/GeneralSettings.tsx @@ -5,6 +5,7 @@ import { Loader2 } from 'lucide-react' import { Label } from '@/components/ui/label' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Switch } from '@/components/ui/switch' +import { SessionAutomationSettings } from './SessionAutomationSettings' export function GeneralSettings() { const { preferences, isLoading, updateSettings, isUpdating } = useSettings() @@ -179,6 +180,8 @@ export function GeneralSettings() { Saving...
)} + + ) } diff --git a/frontend/src/components/settings/SessionAutomationSettings.test.tsx b/frontend/src/components/settings/SessionAutomationSettings.test.tsx new file mode 100644 index 000000000..36296dd91 --- /dev/null +++ b/frontend/src/components/settings/SessionAutomationSettings.test.tsx @@ -0,0 +1,60 @@ +import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { SessionAutomationSettings } from './SessionAutomationSettings' +import { useSettings } from '@/hooks/useSettings' +import type { UserPreferences } from '@/api/types/settings' +import { createUseSettingsMock } from '@/test/test-utils' + +vi.mock('@/hooks/useSettings') + +const basePreferences: UserPreferences = { + theme: 'dark', + mode: 'build', + autoScroll: true, + expandDiffs: true, + expandToolCalls: false, + showReasoning: false, + simpleChatMode: false, + keyboardShortcuts: {}, + customCommands: [], +} + +function mockUseSettings(overrides: Partial> = {}) { + vi.mocked(useSettings).mockReturnValue(createUseSettingsMock({ preferences: basePreferences, ...overrides })) +} + +describe('SessionAutomationSettings', () => { + beforeAll(() => { + Element.prototype.hasPointerCapture ??= () => false + Element.prototype.setPointerCapture ??= () => {} + Element.prototype.releasePointerCapture ??= () => {} + }) + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('defaults the select to ask every time', () => { + mockUseSettings() + render() + + expect(screen.getByRole('heading', { name: 'Sessions' })).toBeInTheDocument() + expect(screen.getByRole('combobox', { name: 'Default permission mode for new sessions' })).toHaveTextContent('Ask every time') + }) + + it('persists the chosen default permission mode', async () => { + const user = userEvent.setup() + const updateSettings = vi.fn() + mockUseSettings({ + preferences: { ...basePreferences, sessionDefaults: { permissionMode: 'ask' } }, + updateSettings, + }) + render() + + await user.click(screen.getByRole('combobox', { name: 'Default permission mode for new sessions' })) + await user.click(screen.getByRole('option', { name: 'Accept everything' })) + + expect(updateSettings).toHaveBeenCalledWith({ sessionDefaults: { permissionMode: 'auto' } }) + }) +}) diff --git a/frontend/src/components/settings/SessionAutomationSettings.tsx b/frontend/src/components/settings/SessionAutomationSettings.tsx new file mode 100644 index 000000000..fae9986df --- /dev/null +++ b/frontend/src/components/settings/SessionAutomationSettings.tsx @@ -0,0 +1,41 @@ +import { useSettings } from '@/hooks/useSettings' +import { Label } from '@/components/ui/label' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import type { SessionPermissionMode } from '@opencode-manager/shared/schemas' + +export function SessionAutomationSettings() { + const { preferences, updateSettings } = useSettings() + const permissionMode = preferences?.sessionDefaults?.permissionMode ?? 'ask' + + return ( +
+

Sessions

+ +
+
+ +

+ Accept everything answers every "ask" automatically, never overrides deny rules, and only applies to sessions created after the change. +

+
+ +
+
+ ) +} diff --git a/frontend/src/hooks/useSessionPermissionMode.ts b/frontend/src/hooks/useSessionPermissionMode.ts new file mode 100644 index 000000000..133a52e1b --- /dev/null +++ b/frontend/src/hooks/useSessionPermissionMode.ts @@ -0,0 +1,29 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { getSessionPermissionMode, setSessionPermissionMode } from '@/api/sessionPermissionModes' +import { showToast } from '@/lib/toast' +import type { SessionPermissionModeState, SetSessionPermissionModeRequest } from '@opencode-manager/shared/schemas' + +export function sessionPermissionModeQueryKey(sessionId: string) { + return ['session-permission-mode', sessionId] as const +} + +export function useSessionPermissionMode(sessionId: string) { + return useQuery({ + queryKey: sessionPermissionModeQueryKey(sessionId), + queryFn: () => getSessionPermissionMode(sessionId), + staleTime: 30000, + }) +} + +export function useSetSessionPermissionMode(sessionId: string) { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (input: SetSessionPermissionModeRequest) => setSessionPermissionMode(sessionId, input), + onSuccess: (state: SessionPermissionModeState) => { + queryClient.setQueryData(sessionPermissionModeQueryKey(sessionId), state) + }, + onError: () => { + showToast.error('Failed to update permission mode') + }, + }) +} diff --git a/frontend/src/pages/__tests__/SessionDetail.assistant-loading.test.tsx b/frontend/src/pages/__tests__/SessionDetail.assistant-loading.test.tsx index 87a940012..6ded7cb5c 100644 --- a/frontend/src/pages/__tests__/SessionDetail.assistant-loading.test.tsx +++ b/frontend/src/pages/__tests__/SessionDetail.assistant-loading.test.tsx @@ -124,6 +124,10 @@ vi.mock('@/components/session/SessionList', () => ({ SessionList: vi.fn(() => null), })) +vi.mock('@/components/session/PermissionModeToggle', () => ({ + PermissionModeToggle: vi.fn(() => null), +})) + vi.mock('@/components/file-browser/FileBrowserSheet', () => ({ FileBrowserSheet: vi.fn(() => null), })) diff --git a/frontend/src/pages/__tests__/SessionDetail.polling.test.tsx b/frontend/src/pages/__tests__/SessionDetail.polling.test.tsx index c899bbf2e..5cf9ad1b1 100644 --- a/frontend/src/pages/__tests__/SessionDetail.polling.test.tsx +++ b/frontend/src/pages/__tests__/SessionDetail.polling.test.tsx @@ -127,6 +127,7 @@ vi.mock('@/api/repos', () => ({ vi.mock('@/components/session/BackgroundWorkBar', () => ({ BackgroundWorkBar: vi.fn(() => null) })) vi.mock('@/components/session/SessionList', () => ({ SessionList: vi.fn(() => null) })) +vi.mock('@/components/session/PermissionModeToggle', () => ({ PermissionModeToggle: vi.fn(() => null) })) vi.mock('@/components/file-browser/FileBrowserSheet', () => ({ FileBrowserSheet: vi.fn(() => null) })) vi.mock('@/components/repo/RepoMcpDialog', () => ({ RepoMcpDialog: vi.fn(() => null) })) vi.mock('@/components/repo/ResetPermissionsDialog', () => ({ ResetPermissionsDialog: vi.fn(() => null) })) From 74b00e37f0c09db62146d0de9e3908517eca61f1 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:32:49 -0400 Subject: [PATCH 05/16] section 5: Phase 5: Session goal storage and lifecycle API --- .../src/db/migrations/026-session-goals.ts | 15 ++ backend/src/db/migrations/index.ts | 2 + backend/src/db/session-goals.ts | 252 ++++++++++++++++++ backend/src/index.ts | 4 + backend/src/routes/session-goals.ts | 70 +++++ backend/src/services/session-goals.ts | 132 +++++++++ .../test/helpers/fake-session-goal-client.ts | 38 +++ backend/test/routes/session-goals.test.ts | 146 ++++++++++ backend/test/services/session-goals.test.ts | 174 ++++++++++++ shared/src/schemas/index.ts | 1 + shared/src/schemas/session-goals.ts | 58 ++++ shared/src/schemas/settings.ts | 8 +- 12 files changed, 898 insertions(+), 2 deletions(-) create mode 100644 backend/src/db/migrations/026-session-goals.ts create mode 100644 backend/src/db/session-goals.ts create mode 100644 backend/src/routes/session-goals.ts create mode 100644 backend/src/services/session-goals.ts create mode 100644 backend/test/helpers/fake-session-goal-client.ts create mode 100644 backend/test/routes/session-goals.test.ts create mode 100644 backend/test/services/session-goals.test.ts create mode 100644 shared/src/schemas/session-goals.ts diff --git a/backend/src/db/migrations/026-session-goals.ts b/backend/src/db/migrations/026-session-goals.ts new file mode 100644 index 000000000..bde0fe612 --- /dev/null +++ b/backend/src/db/migrations/026-session-goals.ts @@ -0,0 +1,15 @@ +import type { Migration } from '../migration-runner' +import { ensureSessionGoalsTable } from '../session-goals' + +const migration: Migration = { + version: 26, + name: 'session-goals', + up(db) { + ensureSessionGoalsTable(db) + }, + down(db) { + db.run('DROP TABLE IF EXISTS session_goals') + }, +} + +export default migration diff --git a/backend/src/db/migrations/index.ts b/backend/src/db/migrations/index.ts index cbbed1f23..db2c76c74 100644 --- a/backend/src/db/migrations/index.ts +++ b/backend/src/db/migrations/index.ts @@ -24,6 +24,7 @@ import migration022 from './022-schedule-runs-session-index' import migration023 from './023-schedule-mcp-servers' import migration024 from './024-schedule-runs-viewed-at' import migration025 from './025-session-permission-modes' +import migration026 from './026-session-goals' export const allMigrations: Migration[] = [ migration001, @@ -51,4 +52,5 @@ export const allMigrations: Migration[] = [ migration023, migration024, migration025, + migration026, ] diff --git a/backend/src/db/session-goals.ts b/backend/src/db/session-goals.ts new file mode 100644 index 000000000..070977c78 --- /dev/null +++ b/backend/src/db/session-goals.ts @@ -0,0 +1,252 @@ +import type { Database } from 'bun:sqlite' +import type { + SessionGoalStatus, + SessionGoalStopReason, + SessionGoalTurnState, + SessionGoalVerdict, +} from '@opencode-manager/shared/schemas' + +export interface SessionGoalRecord { + id: number + sessionId: string + directory: string + objective: string + status: SessionGoalStatus + stopReason: SessionGoalStopReason | null + turnState: SessionGoalTurnState + continuationCount: number + maxContinuations: number + tokenBudget: number | null + tokensAtStart: number + tokensUsed: number + consecutiveBlocked: number + lastVerdict: SessionGoalVerdict | null + lastReason: string | null + createdAt: number + updatedAt: number + finishedAt: number | null +} + +export interface InsertSessionGoalInput { + sessionId: string + directory: string + objective: string + maxContinuations: number + tokenBudget: number | null + tokensAtStart: number +} + +export interface SessionGoalPatch { + status?: SessionGoalStatus + stopReason?: SessionGoalStopReason | null + turnState?: SessionGoalTurnState + continuationCount?: number + tokensUsed?: number + consecutiveBlocked?: number + lastVerdict?: SessionGoalVerdict | null + lastReason?: string | null + finishedAt?: number | null +} + +interface SessionGoalRow { + id: number + session_id: string + directory: string + objective: string + status: SessionGoalStatus + stop_reason: SessionGoalStopReason | null + turn_state: SessionGoalTurnState + continuation_count: number + max_continuations: number + token_budget: number | null + tokens_at_start: number + tokens_used: number + consecutive_blocked: number + last_verdict: SessionGoalVerdict | null + last_reason: string | null + created_at: number + updated_at: number + finished_at: number | null +} + +const SESSION_GOAL_COLUMNS = ` + id, session_id, directory, objective, status, stop_reason, turn_state, + continuation_count, max_continuations, token_budget, tokens_at_start, tokens_used, + consecutive_blocked, last_verdict, last_reason, created_at, updated_at, finished_at +` + +export function ensureSessionGoalsTable(db: Database): void { + db.run(` + CREATE TABLE IF NOT EXISTS session_goals ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + directory TEXT NOT NULL, + objective TEXT NOT NULL, + status TEXT NOT NULL CHECK(status IN ('active','paused','completed','blocked','stopped')), + stop_reason TEXT, + turn_state TEXT NOT NULL DEFAULT 'waiting', + continuation_count INTEGER NOT NULL DEFAULT 0, + max_continuations INTEGER NOT NULL, + token_budget INTEGER, + tokens_at_start INTEGER NOT NULL DEFAULT 0, + tokens_used INTEGER NOT NULL DEFAULT 0, + consecutive_blocked INTEGER NOT NULL DEFAULT 0, + last_verdict TEXT, + last_reason TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + finished_at INTEGER + ) + `) + db.run(` + CREATE UNIQUE INDEX IF NOT EXISTS idx_session_goals_open + ON session_goals(session_id) + WHERE status IN ('active','paused') + `) + db.run(` + CREATE INDEX IF NOT EXISTS idx_session_goals_session + ON session_goals(session_id, created_at DESC) + `) +} + +export function insertSessionGoal(db: Database, input: InsertSessionGoalInput): SessionGoalRecord | null { + const now = Date.now() + try { + const result = db.prepare(` + INSERT INTO session_goals( + session_id, directory, objective, status, stop_reason, turn_state, + continuation_count, max_continuations, token_budget, tokens_at_start, tokens_used, + consecutive_blocked, last_verdict, last_reason, created_at, updated_at, finished_at + ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + `).run( + input.sessionId, + input.directory, + input.objective, + 'active', + null, + 'waiting', + 0, + input.maxContinuations, + input.tokenBudget, + input.tokensAtStart, + 0, + 0, + null, + null, + now, + now, + null, + ) + return getSessionGoalById(db, Number(result.lastInsertRowid)) + } catch (error) { + if (isUniqueConstraintError(error)) { + return null + } + throw error + } +} + +export function getSessionGoalById(db: Database, id: number): SessionGoalRecord | null { + const row = db + .prepare(`SELECT ${SESSION_GOAL_COLUMNS} FROM session_goals WHERE id = ?`) + .get(id) as SessionGoalRow | undefined + return row ? mapSessionGoalRow(row) : null +} + +export function getLatestSessionGoal(db: Database, sessionId: string): SessionGoalRecord | null { + const row = db + .prepare(`SELECT ${SESSION_GOAL_COLUMNS} FROM session_goals WHERE session_id = ? ORDER BY created_at DESC, id DESC LIMIT 1`) + .get(sessionId) as SessionGoalRow | undefined + return row ? mapSessionGoalRow(row) : null +} + +export function listOpenSessionGoals(db: Database): SessionGoalRecord[] { + const rows = db + .prepare(`SELECT ${SESSION_GOAL_COLUMNS} FROM session_goals WHERE status IN ('active','paused') ORDER BY created_at ASC`) + .all() as SessionGoalRow[] + return rows.map(mapSessionGoalRow) +} + +export function transitionSessionGoal( + db: Database, + id: number, + fromStatuses: SessionGoalStatus[], + patch: SessionGoalPatch, +): SessionGoalRecord | null { + const assignments: string[] = ['updated_at = ?'] + const values: (string | number | null)[] = [Date.now()] + + if (patch.status !== undefined) { + assignments.push('status = ?') + values.push(patch.status) + } + if (patch.stopReason !== undefined) { + assignments.push('stop_reason = ?') + values.push(patch.stopReason) + } + if (patch.turnState !== undefined) { + assignments.push('turn_state = ?') + values.push(patch.turnState) + } + if (patch.continuationCount !== undefined) { + assignments.push('continuation_count = ?') + values.push(patch.continuationCount) + } + if (patch.tokensUsed !== undefined) { + assignments.push('tokens_used = ?') + values.push(patch.tokensUsed) + } + if (patch.consecutiveBlocked !== undefined) { + assignments.push('consecutive_blocked = ?') + values.push(patch.consecutiveBlocked) + } + if (patch.lastVerdict !== undefined) { + assignments.push('last_verdict = ?') + values.push(patch.lastVerdict) + } + if (patch.lastReason !== undefined) { + assignments.push('last_reason = ?') + values.push(patch.lastReason) + } + if (patch.finishedAt !== undefined) { + assignments.push('finished_at = ?') + values.push(patch.finishedAt) + } + + const placeholders = fromStatuses.map(() => '?').join(', ') + const result = db + .prepare(`UPDATE session_goals SET ${assignments.join(', ')} WHERE id = ? AND status IN (${placeholders})`) + .run(...values, id, ...fromStatuses) + + if (result.changes === 0) { + return null + } + return getSessionGoalById(db, id) +} + +function mapSessionGoalRow(row: SessionGoalRow): SessionGoalRecord { + return { + id: row.id, + sessionId: row.session_id, + directory: row.directory, + objective: row.objective, + status: row.status, + stopReason: row.stop_reason, + turnState: row.turn_state, + continuationCount: row.continuation_count, + maxContinuations: row.max_continuations, + tokenBudget: row.token_budget, + tokensAtStart: row.tokens_at_start, + tokensUsed: row.tokens_used, + consecutiveBlocked: row.consecutive_blocked, + lastVerdict: row.last_verdict, + lastReason: row.last_reason, + createdAt: row.created_at, + updatedAt: row.updated_at, + finishedAt: row.finished_at, + } +} + +function isUniqueConstraintError(error: unknown): boolean { + return error instanceof Error && /UNIQUE constraint failed/i.test(error.message) +} diff --git a/backend/src/index.ts b/backend/src/index.ts index 6bbbe7cb1..d8ba72502 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -37,6 +37,7 @@ import { createAuthMiddleware } from './auth/middleware' import { createPromptTemplateRoutes } from './routes/prompt-templates' import { createSessionPinRoutes } from './routes/session-pins' import { createSessionPermissionModeRoutes } from './routes/session-permission-modes' +import { createSessionGoalRoutes } from './routes/session-goals' import { createLogRoutes } from './routes/logs' import { createInternalRoutes } from './routes/internal' import { sweepStaleUploadSessions } from './routes/internal/repo-mirror-helpers' @@ -46,6 +47,7 @@ import { sseAggregator } from './services/sse-aggregator' import { ensureDirectoryExists, writeFileContent, fileExists } from './services/file-operations' import { SettingsService } from './services/settings' import { SessionPermissionModeService } from './services/session-permission-modes' +import { SessionGoalService } from './services/session-goals' import { opencodeServerManager } from './services/opencode-single-server' import { createOpenCodeClient } from './services/opencode/client' import { NotificationService } from './services/notification' @@ -230,6 +232,7 @@ const scheduleRunnerInstance = new ScheduleRunner(scheduleService) const notificationService = new NotificationService(db) const sessionPermissionModeService = new SessionPermissionModeService(db, openCodeClient, settingsServiceForSchedules) +const sessionGoalService = new SessionGoalService(db, openCodeClient, settingsServiceForSchedules) sseAggregator.onEvent((directory, event) => { sessionPermissionModeService.handleEvent(directory, event).catch((err) => { @@ -298,6 +301,7 @@ protectedApi.route('/notifications', createNotificationRoutes(notificationServic protectedApi.route('/prompt-templates', createPromptTemplateRoutes(db)) protectedApi.route('/session-pins', createSessionPinRoutes(db)) protectedApi.route('/session-permission-modes', createSessionPermissionModeRoutes(sessionPermissionModeService)) +protectedApi.route('/session-goals', createSessionGoalRoutes(sessionGoalService)) protectedApi.route('/schedules', createScheduleRoutes(scheduleService)) protectedApi.route('/logs', createLogRoutes()) diff --git a/backend/src/routes/session-goals.ts b/backend/src/routes/session-goals.ts new file mode 100644 index 000000000..30b0a1f19 --- /dev/null +++ b/backend/src/routes/session-goals.ts @@ -0,0 +1,70 @@ +import { Hono } from 'hono' +import { StartSessionGoalRequestSchema } from '@opencode-manager/shared/schemas' +import { SessionGoalError, type SessionGoalService } from '../services/session-goals' +import { handleServiceError, parseId } from '../utils/route-helpers' + +export function createSessionGoalRoutes(service: SessionGoalService) { + const app = new Hono() + + app.get('/', (c) => { + try { + const sessionId = c.req.query('sessionId') + if (!sessionId) { + return c.json({ error: 'Invalid request' }, 400) + } + return c.json({ goal: service.getLatest(sessionId) }) + } catch (error) { + return handleServiceError(c, error, 'Failed to read session goal', SessionGoalError) + } + }) + + app.post('/', async (c) => { + let body: unknown + try { + body = await c.req.json() + } catch { + return c.json({ error: 'Invalid request' }, 400) + } + + const parsed = StartSessionGoalRequestSchema.safeParse(body) + if (!parsed.success) { + return c.json({ error: 'Invalid request' }, 400) + } + + try { + const goal = await service.start(parsed.data) + return c.json({ goal }, 201) + } catch (error) { + return handleServiceError(c, error, 'Failed to start session goal', SessionGoalError) + } + }) + + app.post('/:id/pause', (c) => { + try { + const goal = service.pause(parseId(c.req.param('id'), 'goal id', SessionGoalError)) + return c.json({ goal }) + } catch (error) { + return handleServiceError(c, error, 'Failed to pause session goal', SessionGoalError) + } + }) + + app.post('/:id/resume', (c) => { + try { + const goal = service.resume(parseId(c.req.param('id'), 'goal id', SessionGoalError)) + return c.json({ goal }) + } catch (error) { + return handleServiceError(c, error, 'Failed to resume session goal', SessionGoalError) + } + }) + + app.post('/:id/cancel', (c) => { + try { + const goal = service.cancel(parseId(c.req.param('id'), 'goal id', SessionGoalError)) + return c.json({ goal }) + } catch (error) { + return handleServiceError(c, error, 'Failed to cancel session goal', SessionGoalError) + } + }) + + return app +} diff --git a/backend/src/services/session-goals.ts b/backend/src/services/session-goals.ts new file mode 100644 index 000000000..076865600 --- /dev/null +++ b/backend/src/services/session-goals.ts @@ -0,0 +1,132 @@ +import type { Database } from 'bun:sqlite' +import { + DEFAULT_SESSION_DEFAULTS, + type SessionGoal, + type SessionGoalStatus, + type StartSessionGoalRequest, +} from '@opencode-manager/shared/schemas' +import { + getLatestSessionGoal, + getSessionGoalById, + insertSessionGoal, + transitionSessionGoal, + type SessionGoalPatch, + type SessionGoalRecord, +} from '../db/session-goals' +import { logger } from '../utils/logger' +import type { OpenCodeClient } from './opencode/client' +import type { SettingsService } from './settings' + +export class SessionGoalError extends Error { + status: number + + constructor(message: string, status: number) { + super(message) + this.status = status + } +} + +export function sessionTokenTotal(tokens: { input: number; output: number; reasoning: number }): number { + return tokens.input + tokens.output + tokens.reasoning +} + +export function toSessionGoal(record: SessionGoalRecord): SessionGoal { + return { + id: record.id, + sessionId: record.sessionId, + directory: record.directory, + objective: record.objective, + status: record.status, + stopReason: record.stopReason, + turnState: record.turnState, + continuationCount: record.continuationCount, + maxContinuations: record.maxContinuations, + tokenBudget: record.tokenBudget, + tokensUsed: record.tokensUsed, + consecutiveBlocked: record.consecutiveBlocked, + lastVerdict: record.lastVerdict, + lastReason: record.lastReason, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + finishedAt: record.finishedAt, + } +} + +export class SessionGoalService { + constructor( + private readonly db: Database, + private readonly openCodeClient: OpenCodeClient, + private readonly settingsService: SettingsService, + ) {} + + async start(input: StartSessionGoalRequest): Promise { + const defaults = this.settingsService.getSettings().preferences.sessionDefaults + const maxContinuations = + input.maxContinuations ?? defaults?.goalMaxContinuations ?? DEFAULT_SESSION_DEFAULTS.goalMaxContinuations + const tokenBudget = input.tokenBudget ?? defaults?.goalTokenBudget ?? null + const tokensAtStart = await this.readSessionTokenTotal(input.sessionId) + + const record = insertSessionGoal(this.db, { + sessionId: input.sessionId, + directory: input.directory, + objective: input.objective, + maxContinuations, + tokenBudget, + tokensAtStart, + }) + + if (!record) { + throw new SessionGoalError('This session already has an open goal', 409) + } + + return toSessionGoal(record) + } + + getLatest(sessionId: string): SessionGoal | null { + const record = getLatestSessionGoal(this.db, sessionId) + return record ? toSessionGoal(record) : null + } + + pause(id: number): SessionGoal { + return toSessionGoal(this.transition(id, ['active'], { status: 'paused', stopReason: 'user_paused' })) + } + + resume(id: number): SessionGoal { + return toSessionGoal(this.transition(id, ['paused'], { + status: 'active', + stopReason: null, + turnState: 'running', + finishedAt: null, + })) + } + + cancel(id: number): SessionGoal { + return toSessionGoal(this.transition(id, ['active', 'paused'], { + status: 'stopped', + stopReason: 'cancelled', + finishedAt: Date.now(), + })) + } + + private transition(id: number, fromStatuses: SessionGoalStatus[], patch: SessionGoalPatch): SessionGoalRecord { + const record = transitionSessionGoal(this.db, id, fromStatuses, patch) + if (record) { + return record + } + + if (!getSessionGoalById(this.db, id)) { + throw new SessionGoalError('Session goal not found', 404) + } + throw new SessionGoalError('Session goal cannot transition from its current state', 409) + } + + private async readSessionTokenTotal(sessionId: string): Promise { + try { + const session = await this.openCodeClient.api.session.get({ sessionID: sessionId }) + return sessionTokenTotal(session.tokens) + } catch (error) { + logger.error(`Failed to read token usage for session ${sessionId}:`, error) + return 0 + } + } +} diff --git a/backend/test/helpers/fake-session-goal-client.ts b/backend/test/helpers/fake-session-goal-client.ts new file mode 100644 index 000000000..8158fbbca --- /dev/null +++ b/backend/test/helpers/fake-session-goal-client.ts @@ -0,0 +1,38 @@ +import type { OpenCodeClient } from '../../src/services/opencode/client' + +export interface FakeSessionGoalTokens { + input: number + output: number + reasoning: number +} + +export interface FakeSessionGoalClientOptions { + tokens?: Record + failSessionGet?: boolean +} + +export function createFakeSessionGoalClient(options: FakeSessionGoalClientOptions = {}): OpenCodeClient { + const tokens = options.tokens ?? {} + + return { + api: { + session: { + get: async ({ sessionID }: { sessionID: string }) => { + if (options.failSessionGet) { + throw new Error('upstream unavailable') + } + const usage = tokens[sessionID] ?? { input: 0, output: 0, reasoning: 0 } + return { + tokens: { + input: usage.input, + output: usage.output, + reasoning: usage.reasoning, + cache: { read: 0, write: 0 }, + }, + } + }, + }, + }, + forwardRaw: async () => new Response(), + } as unknown as OpenCodeClient +} diff --git a/backend/test/routes/session-goals.test.ts b/backend/test/routes/session-goals.test.ts new file mode 100644 index 000000000..47883590e --- /dev/null +++ b/backend/test/routes/session-goals.test.ts @@ -0,0 +1,146 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { Hono } from 'hono' +import { Database } from 'bun:sqlite' +import { migrate } from '../../src/db/migration-runner' +import { allMigrations } from '../../src/db/migrations' +import { SessionGoalService } from '../../src/services/session-goals' +import { SettingsService } from '../../src/services/settings' +import { createSessionGoalRoutes } from '../../src/routes/session-goals' +import { createFakeSessionGoalClient } from '../helpers/fake-session-goal-client' + +const DIRECTORY = '/abs/repo' + +function createTestApp(db: Database): Hono { + const app = new Hono() + const service = new SessionGoalService(db, createFakeSessionGoalClient(), new SettingsService(db)) + app.route('/session-goals', createSessionGoalRoutes(service)) + return app +} + +function createTestDb(): Database { + const db = new Database(':memory:') + migrate(db, allMigrations) + return db +} + +function startBody(overrides: Record = {}): string { + return JSON.stringify({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it', ...overrides }) +} + +describe('session goal routes', () => { + let db: Database + + beforeEach(() => { + db = createTestDb() + }) + + afterEach(() => { + db.close() + }) + + it('GET returns a null goal for a session with no goal', async () => { + const app = createTestApp(db) + + const res = await app.request('/session-goals?sessionId=ses_1') + + expect(res.status).toBe(200) + await expect(res.json()).resolves.toEqual({ goal: null }) + }) + + it('GET rejects a missing sessionId with 400', async () => { + const app = createTestApp(db) + + const res = await app.request('/session-goals') + + expect(res.status).toBe(400) + }) + + it('POST creates a goal and GET reads it back', async () => { + const app = createTestApp(db) + + const postRes = await app.request('/session-goals', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: startBody(), + }) + expect(postRes.status).toBe(201) + const created = await postRes.json() as { goal: { id: number; status: string } } + expect(created.goal.status).toBe('active') + + const getRes = await app.request('/session-goals?sessionId=ses_1') + const body = await getRes.json() as { goal: { id: number } } + expect(body.goal.id).toBe(created.goal.id) + }) + + it('POST rejects an invalid body with 400', async () => { + const app = createTestApp(db) + + const res = await app.request('/session-goals', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sessionId: 'ses_1', directory: DIRECTORY, objective: '' }), + }) + + expect(res.status).toBe(400) + }) + + it('POST rejects a second open goal with 409', async () => { + const app = createTestApp(db) + await app.request('/session-goals', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: startBody(), + }) + + const res = await app.request('/session-goals', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: startBody(), + }) + + expect(res.status).toBe(409) + }) + + it('POST pause and cancel change the goal state', async () => { + const app = createTestApp(db) + const created = await (await app.request('/session-goals', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: startBody(), + })).json() as { goal: { id: number } } + + const pauseRes = await app.request(`/session-goals/${created.goal.id}/pause`, { method: 'POST' }) + expect(pauseRes.status).toBe(200) + const paused = await pauseRes.json() as { goal: { status: string; stopReason: string } } + expect(paused.goal).toMatchObject({ status: 'paused', stopReason: 'user_paused' }) + + const cancelRes = await app.request(`/session-goals/${created.goal.id}/cancel`, { method: 'POST' }) + expect(cancelRes.status).toBe(200) + const cancelled = await cancelRes.json() as { goal: { status: string; stopReason: string } } + expect(cancelled.goal).toMatchObject({ status: 'stopped', stopReason: 'cancelled' }) + }) + + it('POST resume reopens a paused goal', async () => { + const app = createTestApp(db) + const created = await (await app.request('/session-goals', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: startBody(), + })).json() as { goal: { id: number } } + await app.request(`/session-goals/${created.goal.id}/pause`, { method: 'POST' }) + + const resumeRes = await app.request(`/session-goals/${created.goal.id}/resume`, { method: 'POST' }) + + expect(resumeRes.status).toBe(200) + const resumed = await resumeRes.json() as { goal: { status: string } } + expect(resumed.goal.status).toBe('active') + }) + + it('POST pause of a missing goal returns 404', async () => { + const app = createTestApp(db) + + const res = await app.request('/session-goals/999/pause', { method: 'POST' }) + + expect(res.status).toBe(404) + }) +}) diff --git a/backend/test/services/session-goals.test.ts b/backend/test/services/session-goals.test.ts new file mode 100644 index 000000000..0439aa266 --- /dev/null +++ b/backend/test/services/session-goals.test.ts @@ -0,0 +1,174 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { Database } from 'bun:sqlite' +import { migrate } from '../../src/db/migration-runner' +import { allMigrations } from '../../src/db/migrations' +import { getSessionGoalById, listOpenSessionGoals } from '../../src/db/session-goals' +import { SessionGoalService } from '../../src/services/session-goals' +import { SettingsService } from '../../src/services/settings' +import { createFakeSessionGoalClient } from '../helpers/fake-session-goal-client' + +const DIRECTORY = '/abs/repo' + +function createTestDb(): Database { + const db = new Database(':memory:') + migrate(db, allMigrations) + return db +} + +function createService( + db: Database, + options: Parameters[0] = {}, + settingsService: SettingsService = new SettingsService(db), +): SessionGoalService { + return new SessionGoalService(db, createFakeSessionGoalClient(options), settingsService) +} + +describe('SessionGoalService', () => { + let db: Database + + beforeEach(() => { + db = createTestDb() + }) + + afterEach(() => { + db.close() + }) + + it('starts an active goal with the settings defaults and the current token count', async () => { + const settingsService = new SettingsService(db) + settingsService.updateSettings({ + sessionDefaults: { + permissionMode: 'ask', + goalMaxContinuations: 7, + goalTokenBudget: 1234, + goalAuditorModel: 'provider/model', + }, + }) + const service = createService(db, { tokens: { ses_1: { input: 100, output: 50, reasoning: 10 } } }, settingsService) + + const goal = await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship the feature' }) + + expect(goal).toMatchObject({ + sessionId: 'ses_1', + directory: DIRECTORY, + objective: 'Ship the feature', + status: 'active', + stopReason: null, + turnState: 'waiting', + continuationCount: 0, + maxContinuations: 7, + tokenBudget: 1234, + tokensUsed: 0, + consecutiveBlocked: 0, + lastVerdict: null, + lastReason: null, + finishedAt: null, + }) + expect(getSessionGoalById(db, goal.id)?.tokensAtStart).toBe(160) + }) + + it('lets explicit input override the settings defaults', async () => { + const settingsService = new SettingsService(db) + settingsService.updateSettings({ + sessionDefaults: { permissionMode: 'ask', goalMaxContinuations: 7, goalTokenBudget: 1234 }, + }) + const service = createService(db, {}, settingsService) + + const goal = await service.start({ + sessionId: 'ses_1', + directory: DIRECTORY, + objective: 'Ship the feature', + maxContinuations: 3, + tokenBudget: 99, + }) + + expect(goal.maxContinuations).toBe(3) + expect(goal.tokenBudget).toBe(99) + }) + + it('falls back to the default max continuations when settings do not set it', async () => { + const settingsService = new SettingsService(db) + settingsService.updateSettings({ sessionDefaults: { permissionMode: 'ask' } }) + const service = createService(db, {}, settingsService) + + const goal = await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) + + expect(goal.maxContinuations).toBe(20) + expect(goal.tokenBudget).toBeNull() + }) + + it('uses zero tokens when the session lookup fails', async () => { + const service = createService(db, { failSessionGet: true }) + + const goal = await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) + + expect(getSessionGoalById(db, goal.id)?.tokensAtStart).toBe(0) + }) + + it('rejects a second open goal for the same session with 409', async () => { + const service = createService(db) + + await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'First' }) + + await expect( + service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Second' }), + ).rejects.toMatchObject({ status: 409 }) + expect(listOpenSessionGoals(db)).toHaveLength(1) + }) + + it('allows a new goal once the previous one is cancelled', async () => { + const service = createService(db) + const first = await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'First' }) + + service.cancel(first.id) + const second = await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Second' }) + + expect(second.status).toBe('active') + expect(listOpenSessionGoals(db)).toHaveLength(1) + }) + + it('pauses and resumes a goal', async () => { + const service = createService(db) + const started = await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) + + const paused = service.pause(started.id) + expect(paused.status).toBe('paused') + expect(paused.stopReason).toBe('user_paused') + + const resumed = service.resume(started.id) + expect(resumed.status).toBe('active') + expect(resumed.stopReason).toBeNull() + expect(resumed.turnState).toBe('running') + }) + + it('cancels a paused goal and rejects cancelling a finished goal', async () => { + const service = createService(db) + const started = await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) + service.pause(started.id) + + const cancelled = service.cancel(started.id) + expect(cancelled.status).toBe('stopped') + expect(cancelled.stopReason).toBe('cancelled') + expect(cancelled.finishedAt).not.toBeNull() + + expect(() => service.cancel(started.id)).toThrowError( + expect.objectContaining({ status: 409 }) as Error, + ) + }) + + it('rejects pausing a missing goal with 404', async () => { + const service = createService(db) + + expect(() => service.pause(999)).toThrowError(expect.objectContaining({ status: 404 }) as Error) + }) + + it('returns the latest goal for a session', async () => { + const service = createService(db) + + expect(service.getLatest('ses_1')).toBeNull() + + const started = await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) + + expect(service.getLatest('ses_1')?.id).toBe(started.id) + }) +}) diff --git a/shared/src/schemas/index.ts b/shared/src/schemas/index.ts index 0476031eb..c106483d6 100644 --- a/shared/src/schemas/index.ts +++ b/shared/src/schemas/index.ts @@ -12,4 +12,5 @@ export * from './skills' export * from './internal-assistant' export * from './session-pins' export * from './session-permissions' +export * from './session-goals' export * from './logs' diff --git a/shared/src/schemas/session-goals.ts b/shared/src/schemas/session-goals.ts new file mode 100644 index 000000000..3268b0f77 --- /dev/null +++ b/shared/src/schemas/session-goals.ts @@ -0,0 +1,58 @@ +import { z } from "zod"; + +export const SessionGoalStatusSchema = z.enum(["active", "paused", "completed", "blocked", "stopped"]); + +export type SessionGoalStatus = z.infer; + +export const SessionGoalStopReasonSchema = z.enum([ + "cancelled", + "user_paused", + "continuation_limit", + "token_budget", + "turn_error", + "interrupted", + "audit_failed", + "session_deleted", +]); + +export type SessionGoalStopReason = z.infer; + +export const SessionGoalVerdictSchema = z.enum(["continue", "done", "blocked"]); + +export type SessionGoalVerdict = z.infer; + +export const SessionGoalTurnStateSchema = z.enum(["waiting", "running"]); + +export type SessionGoalTurnState = z.infer; + +export const SessionGoalSchema = z.object({ + id: z.number().int(), + sessionId: z.string(), + directory: z.string(), + objective: z.string(), + status: SessionGoalStatusSchema, + stopReason: SessionGoalStopReasonSchema.nullable(), + turnState: SessionGoalTurnStateSchema, + continuationCount: z.number().int(), + maxContinuations: z.number().int(), + tokenBudget: z.number().int().nullable(), + tokensUsed: z.number().int(), + consecutiveBlocked: z.number().int(), + lastVerdict: SessionGoalVerdictSchema.nullable(), + lastReason: z.string().nullable(), + createdAt: z.number(), + updatedAt: z.number(), + finishedAt: z.number().nullable(), +}); + +export type SessionGoal = z.infer; + +export const StartSessionGoalRequestSchema = z.object({ + sessionId: z.string().min(1), + directory: z.string().min(1), + objective: z.string().trim().min(1).max(20000), + maxContinuations: z.number().int().min(1).max(200).optional(), + tokenBudget: z.number().int().positive().optional(), +}); + +export type StartSessionGoalRequest = z.infer; diff --git a/shared/src/schemas/settings.ts b/shared/src/schemas/settings.ts index 276f8b66b..bdd87b53d 100644 --- a/shared/src/schemas/settings.ts +++ b/shared/src/schemas/settings.ts @@ -145,13 +145,17 @@ export const DEFAULT_SANDBOX_PREFERENCES: SandboxPreferences = { export const SessionDefaultsSchema = z.object({ permissionMode: SessionPermissionModeSchema, + goalMaxContinuations: z.number().int().min(1).max(200).optional(), + goalTokenBudget: z.number().int().positive().optional(), + goalAuditorModel: z.string().optional(), }); export type SessionDefaults = z.infer; -export const DEFAULT_SESSION_DEFAULTS: SessionDefaults = { +export const DEFAULT_SESSION_DEFAULTS = { permissionMode: 'ask', -}; + goalMaxContinuations: 20, +} satisfies SessionDefaults; export const UserPreferencesSchema = z.object({ theme: z.enum(["dark", "light", "system"]), From 40bbe5aff3f395d411a2142cdd180b285d46beff Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:08:18 -0400 Subject: [PATCH 06/16] section 6: Phase 6: Goal audit loop: quiet-period audit, continuation a --- backend/src/index.ts | 4 + backend/src/services/session-goal-audit.ts | 138 ++++ backend/src/services/session-goals.ts | 461 ++++++++++- .../test/helpers/fake-session-goal-client.ts | 191 ++++- backend/test/routes/session-goals.test.ts | 6 +- .../test/services/session-goal-audit.test.ts | 86 ++ backend/test/services/session-goals.test.ts | 733 +++++++++++++++++- 7 files changed, 1594 insertions(+), 25 deletions(-) create mode 100644 backend/src/services/session-goal-audit.ts create mode 100644 backend/test/services/session-goal-audit.test.ts diff --git a/backend/src/index.ts b/backend/src/index.ts index d8ba72502..1fbca0b84 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -233,11 +233,15 @@ const scheduleRunnerInstance = new ScheduleRunner(scheduleService) const notificationService = new NotificationService(db) const sessionPermissionModeService = new SessionPermissionModeService(db, openCodeClient, settingsServiceForSchedules) const sessionGoalService = new SessionGoalService(db, openCodeClient, settingsServiceForSchedules) +sessionGoalService.loadOpenGoals() sseAggregator.onEvent((directory, event) => { sessionPermissionModeService.handleEvent(directory, event).catch((err) => { logger.error('Session permission mode event handling error:', err) }) + sessionGoalService.handleEvent(directory, event).catch((err) => { + logger.error('Session goal event handling error:', err) + }) }) notificationService.addEventSuppressor(async (event, sessionId) => { diff --git a/backend/src/services/session-goal-audit.ts b/backend/src/services/session-goal-audit.ts new file mode 100644 index 000000000..83e96dbec --- /dev/null +++ b/backend/src/services/session-goal-audit.ts @@ -0,0 +1,138 @@ +import { SessionGoalVerdictSchema, type SessionGoalVerdict } from '@opencode-manager/shared/schemas' + +export const GOAL_AUDIT_REPLY_MAX_CHARS = 20000 +export const GOAL_AUDIT_REASON_MAX_CHARS = 500 +export const GOAL_AUDIT_UNPARSED_REASON = 'Auditor response was not understood' + +export interface GoalAuditPromptInput { + objective: string + reply: string | null +} + +export interface GoalContinuationPromptInput { + objective: string + reason: string +} + +export interface GoalVerdict { + verdict: SessionGoalVerdict + reason: string +} + +export function buildGoalAuditPrompt({ objective, reply }: GoalAuditPromptInput): string { + return [ + 'You audit whether an autonomous coding goal has been achieved.', + 'You can only see the goal objective and the agent\'s latest reply. You cannot run tools or read files.', + '', + 'Goal objective:', + objective, + '', + 'Agent\'s latest reply:', + truncateReply(reply), + '', + 'Decide the goal state:', + '- "done": the objective is verifiably achieved based on the reply.', + '- "blocked": the agent needs a user decision or access it cannot obtain.', + '- "continue": anything else, including when the reply is not enough to confirm completion.', + '', + 'Respond with only this JSON and nothing else:', + '{"verdict":"continue"|"done"|"blocked","reason":""}', + ].join('\n') +} + +export function buildGoalContinuationPrompt({ objective, reason }: GoalContinuationPromptInput): string { + return [ + `Continue working toward this goal: ${objective}`, + '', + `Progress check: ${reason}`, + '', + 'Keep working until the goal is achieved. If you need a user decision or access you cannot obtain, stop and state exactly what you need.', + ].join('\n') +} + +export function parseGoalVerdict(text: string): GoalVerdict | null { + const block = extractFirstJsonObject(text) + if (!block) { + return null + } + + let parsed: unknown + try { + parsed = JSON.parse(block) + } catch { + return null + } + + if (typeof parsed !== 'object' || parsed === null) { + return null + } + + const candidate = parsed as { verdict?: unknown; reason?: unknown } + const verdict = SessionGoalVerdictSchema.safeParse(candidate.verdict) + if (!verdict.success) { + return null + } + + if (typeof candidate.reason !== 'string') { + return null + } + + const reason = candidate.reason.trim() + if (!reason) { + return null + } + + return { verdict: verdict.data, reason: reason.slice(0, GOAL_AUDIT_REASON_MAX_CHARS) } +} + +function truncateReply(reply: string | null): string { + if (!reply) { + return '(no reply yet)' + } + if (reply.length <= GOAL_AUDIT_REPLY_MAX_CHARS) { + return reply + } + return `${reply.slice(0, GOAL_AUDIT_REPLY_MAX_CHARS)}\n\n[reply truncated]` +} + +function extractFirstJsonObject(text: string): string | null { + const start = text.indexOf('{') + if (start === -1) { + return null + } + + let depth = 0 + let inString = false + let escaped = false + + for (let index = start; index < text.length; index += 1) { + const char = text[index] + + if (inString) { + if (escaped) { + escaped = false + } else if (char === '\\') { + escaped = true + } else if (char === '"') { + inString = false + } + continue + } + + if (char === '"') { + inString = true + continue + } + + if (char === '{') { + depth += 1 + } else if (char === '}') { + depth -= 1 + if (depth === 0) { + return text.slice(start, index + 1) + } + } + } + + return null +} diff --git a/backend/src/services/session-goals.ts b/backend/src/services/session-goals.ts index 076865600..95a259084 100644 --- a/backend/src/services/session-goals.ts +++ b/backend/src/services/session-goals.ts @@ -3,19 +3,43 @@ import { DEFAULT_SESSION_DEFAULTS, type SessionGoal, type SessionGoalStatus, + type SessionGoalStopReason, + type SessionGoalTurnState, type StartSessionGoalRequest, } from '@opencode-manager/shared/schemas' +import { parseOpenCodeModelRef, sessionIDFromEvent, type ModelRef } from '@opencode-manager/shared/opencode' import { getLatestSessionGoal, getSessionGoalById, insertSessionGoal, + listOpenSessionGoals, transitionSessionGoal, type SessionGoalPatch, type SessionGoalRecord, } from '../db/session-goals' import { logger } from '../utils/logger' +import { + buildGoalAuditPrompt, + buildGoalContinuationPrompt, + GOAL_AUDIT_UNPARSED_REASON, + parseGoalVerdict, + type GoalVerdict, +} from './session-goal-audit' +import { isSessionBusy, readLatestAssistantReply, type AssistantReplyState } from './session-reply' import type { OpenCodeClient } from './opencode/client' import type { SettingsService } from './settings' +import type { SSEEvent } from './sse-aggregator' + +const DEFAULT_QUIET_MS = 3000 +const BLOCKED_LIMIT = 3 +const AUDITOR_ATTEMPTS = 2 + +type PrerequisiteResult = { ok: true; value: T } | { ok: false } + +export interface SessionGoalServiceOptions { + quietMs?: number + onOutcome?: (goal: SessionGoal) => void +} export class SessionGoalError extends Error { status: number @@ -53,18 +77,37 @@ export function toSessionGoal(record: SessionGoalRecord): SessionGoal { } export class SessionGoalService { + private readonly goalBySession = new Map() + private readonly auditTimers = new Map>() + private readonly auditing = new Set() + private readonly auditRequested = new Set() + private readonly auditGeneration = new Map() + private readonly quietMs: number + private readonly onOutcome: (goal: SessionGoal) => void + constructor( private readonly db: Database, private readonly openCodeClient: OpenCodeClient, private readonly settingsService: SettingsService, - ) {} + options: SessionGoalServiceOptions = {}, + ) { + this.quietMs = options.quietMs ?? DEFAULT_QUIET_MS + this.onOutcome = options.onOutcome ?? (() => {}) + } + + loadOpenGoals(): void { + this.goalBySession.clear() + for (const record of listOpenSessionGoals(this.db)) { + this.goalBySession.set(record.sessionId, record.id) + } + } async start(input: StartSessionGoalRequest): Promise { const defaults = this.settingsService.getSettings().preferences.sessionDefaults const maxContinuations = input.maxContinuations ?? defaults?.goalMaxContinuations ?? DEFAULT_SESSION_DEFAULTS.goalMaxContinuations const tokenBudget = input.tokenBudget ?? defaults?.goalTokenBudget ?? null - const tokensAtStart = await this.readSessionTokenTotal(input.sessionId) + const tokensAtStart = await this.readSessionTokenTotalOrDefault(input.sessionId) const record = insertSessionGoal(this.db, { sessionId: input.sessionId, @@ -79,6 +122,7 @@ export class SessionGoalService { throw new SessionGoalError('This session already has an open goal', 409) } + this.goalBySession.set(record.sessionId, record.id) return toSessionGoal(record) } @@ -88,42 +132,431 @@ export class SessionGoalService { } pause(id: number): SessionGoal { - return toSessionGoal(this.transition(id, ['active'], { status: 'paused', stopReason: 'user_paused' })) + return this.transitionWithOutcome(id, ['active'], { status: 'paused', stopReason: 'user_paused' }) } resume(id: number): SessionGoal { - return toSessionGoal(this.transition(id, ['paused'], { + const record = this.transition(id, ['paused'], { status: 'active', stopReason: null, turnState: 'running', finishedAt: null, - })) + }) + + const goal = toSessionGoal(record) + void this.scheduleAuditWhenIdle(goal) + return goal } cancel(id: number): SessionGoal { - return toSessionGoal(this.transition(id, ['active', 'paused'], { + return this.transitionWithOutcome(id, ['active', 'paused'], { status: 'stopped', stopReason: 'cancelled', finishedAt: Date.now(), - })) + }) + } + + async handleEvent(directory: string, event: SSEEvent): Promise { + const sessionId = sessionIDFromEvent(event) + if (!sessionId) { + return + } + + const goalId = this.goalBySession.get(sessionId) + if (goalId === undefined) { + return + } + + switch (event.type) { + case 'session.execution.started': + this.clearAuditTimer(sessionId) + this.applyTransition(goalId, ['active'], { turnState: 'running' }) + return + case 'session.idle': + case 'session.execution.succeeded': + this.scheduleAuditFromEvent(sessionId, goalId) + return + case 'session.status': + if (event.data.status.type === 'idle') { + this.scheduleAuditFromEvent(sessionId, goalId) + } + return + case 'session.execution.failed': + this.clearAuditTimer(sessionId) + this.finishGoal(goalId, 'stopped', 'turn_error') + return + case 'session.execution.interrupted': + this.clearAuditTimer(sessionId) + this.finishGoal(goalId, 'paused', 'interrupted') + return + case 'session.deleted': + this.clearAuditTimer(sessionId) + this.finishGoal(goalId, 'stopped', 'session_deleted', ['active', 'paused']) + return + default: + return + } + } + + private scheduleAuditFromEvent(sessionId: string, goalId: number): void { + const record = getSessionGoalById(this.db, goalId) + if (!record || record.status !== 'active' || record.turnState !== 'running') { + return + } + this.scheduleAudit(sessionId, goalId, this.quietMs) + } + + private async scheduleAuditWhenIdle(goal: SessionGoal): Promise { + const generation = this.currentAuditGeneration(goal.sessionId) + const busy = await this.readPrerequisite(goal.id, 'busy check', () => + isSessionBusy(this.openCodeClient, goal.sessionId), + ) + + if (!busy.ok) { + this.failAudit(goal.id, goal.sessionId, generation) + return + } + if (!this.isAuditCurrent(goal.id, goal.sessionId, generation)) { + return + } + if (busy.value) { + return + } + this.scheduleAudit(goal.sessionId, goal.id, 0) + } + + private scheduleAudit(sessionId: string, goalId: number, delay: number): void { + this.clearAuditTimer(sessionId) + const timer = setTimeout(() => { + this.auditTimers.delete(sessionId) + void this.runAudit(goalId).catch((error) => { + logger.error(`Session goal audit failed for goal ${goalId}:`, error) + }) + }, delay) + this.auditTimers.set(sessionId, timer) + } + + private clearAuditTimer(sessionId: string): void { + const timer = this.auditTimers.get(sessionId) + if (timer !== undefined) { + clearTimeout(timer) + this.auditTimers.delete(sessionId) + } + } + + private async runAudit(goalId: number): Promise { + if (this.auditing.has(goalId)) { + this.auditRequested.add(goalId) + return + } + + this.auditing.add(goalId) + try { + let pending = true + while (pending) { + this.auditRequested.delete(goalId) + await this.performAudit(goalId) + pending = this.auditRequested.has(goalId) + } + } finally { + this.auditing.delete(goalId) + } + } + + private async performAudit(goalId: number): Promise { + const goal = getSessionGoalById(this.db, goalId) + if (!goal || goal.status !== 'active' || goal.turnState !== 'running') { + return + } + + const sessionId = goal.sessionId + const generation = this.currentAuditGeneration(sessionId) + + const busy = await this.readPrerequisite(goalId, 'busy check', () => + isSessionBusy(this.openCodeClient, sessionId), + ) + if (!busy.ok) { + this.failAudit(goalId, sessionId, generation) + return + } + if (!this.isAuditCurrent(goalId, sessionId, generation) || busy.value) { + return + } + + const total = await this.readPrerequisite(goalId, 'token read', () => + this.readSessionTokenTotal(sessionId), + ) + if (!total.ok) { + this.failAudit(goalId, sessionId, generation) + return + } + if (!this.isAuditCurrent(goalId, sessionId, generation)) { + return + } + + const tokensUsed = Math.max(0, total.value - goal.tokensAtStart) + const updated = this.applyTransition(goalId, ['active'], { tokensUsed }) + if (!updated) { + return + } + + if (updated.tokenBudget !== null && tokensUsed >= updated.tokenBudget) { + this.finishGoal(goalId, 'stopped', 'token_budget') + return + } + + const reply = await this.readPrerequisite(goalId, 'reply read', () => + readLatestAssistantReply(this.openCodeClient, sessionId), + ) + if (!reply.ok) { + this.failAudit(goalId, sessionId, generation) + return + } + if (!this.isAuditCurrent(goalId, sessionId, generation)) { + return + } + + if (reply.value?.errorText) { + this.finishGoal(goalId, 'stopped', 'turn_error') + return + } + + const verdict = await this.evaluateAudit(updated, reply.value) + if (!this.isAuditCurrent(goalId, sessionId, generation)) { + return + } + if (!verdict) { + this.finishGoal(goalId, 'paused', 'audit_failed') + return + } + + await this.applyVerdict(goalId, sessionId, generation, updated, verdict) + } + + private async evaluateAudit(goal: SessionGoalRecord, reply: AssistantReplyState | null): Promise { + const prompt = buildGoalAuditPrompt({ objective: goal.objective, reply: reply?.responseText ?? null }) + const model = this.resolveAuditorModel() + + for (let attempt = 0; attempt < AUDITOR_ATTEMPTS; attempt += 1) { + try { + const result = await this.openCodeClient.api.generate.text(model ? { prompt, model } : { prompt }) + return parseGoalVerdict(result.text) ?? { verdict: 'continue', reason: GOAL_AUDIT_UNPARSED_REASON } + } catch (error) { + logger.error(`Goal auditor call failed for goal ${goal.id} (attempt ${attempt + 1}):`, error) + } + } + + return null + } + + private resolveAuditorModel(): ModelRef | undefined { + const configured = this.settingsService.getSettings().preferences.sessionDefaults?.goalAuditorModel + return configured ? parseOpenCodeModelRef(configured) : undefined + } + + private async applyVerdict( + goalId: number, + sessionId: string, + generation: number, + goal: SessionGoalRecord, + verdict: GoalVerdict, + ): Promise { + if (!this.isAuditCurrent(goalId, sessionId, generation)) { + return + } + + if (verdict.verdict === 'done') { + this.finishGoal(goalId, 'completed', null, ['active'], { + lastVerdict: 'done', + lastReason: verdict.reason, + }) + return + } + + if (verdict.verdict === 'blocked') { + const consecutiveBlocked = goal.consecutiveBlocked + 1 + const patch: SessionGoalPatch = { + consecutiveBlocked, + lastVerdict: 'blocked', + lastReason: verdict.reason, + } + + if (consecutiveBlocked >= BLOCKED_LIMIT) { + this.finishGoal(goalId, 'blocked', null, ['active'], patch) + return + } + + await this.continueGoal(goalId, sessionId, generation, goal, verdict.reason, patch) + return + } + + await this.continueGoal(goalId, sessionId, generation, goal, verdict.reason, { + consecutiveBlocked: 0, + lastVerdict: 'continue', + lastReason: verdict.reason, + }) + } + + private async continueGoal( + goalId: number, + sessionId: string, + generation: number, + goal: SessionGoalRecord, + reason: string, + patch: SessionGoalPatch, + ): Promise { + if (!this.isAuditCurrent(goalId, sessionId, generation)) { + return + } + + if (goal.continuationCount >= goal.maxContinuations) { + this.finishGoal(goalId, 'stopped', 'continuation_limit', ['active'], patch) + return + } + + const record = this.applyTransition(goalId, ['active'], { + ...patch, + continuationCount: goal.continuationCount + 1, + turnState: 'waiting', + }) + if (!record) { + return + } + + try { + await this.openCodeClient.api.session.prompt({ + sessionID: record.sessionId, + text: buildGoalContinuationPrompt({ objective: record.objective, reason }), + }) + } catch (error) { + logger.error(`Failed to send continuation for goal ${goalId}:`, error) + if (this.isAuditCurrent(goalId, sessionId, generation, 'waiting')) { + this.finishGoal(goalId, 'stopped', 'turn_error') + } + } + } + + private finishGoal( + goalId: number, + status: SessionGoalStatus, + stopReason: SessionGoalStopReason | null, + fromStatuses: SessionGoalStatus[] = ['active'], + patch: SessionGoalPatch = {}, + ): void { + const record = this.applyTransition(goalId, fromStatuses, { + ...patch, + status, + stopReason, + finishedAt: status === 'paused' ? null : Date.now(), + }) + if (record) { + this.onOutcome(toSessionGoal(record)) + } + } + + private failAudit(goalId: number, sessionId: string, generation: number): void { + if (!this.isAuditCurrent(goalId, sessionId, generation)) { + return + } + this.finishGoal(goalId, 'paused', 'audit_failed') + } + + private async readPrerequisite( + goalId: number, + label: string, + read: () => Promise, + ): Promise> { + for (let attempt = 0; attempt < AUDITOR_ATTEMPTS; attempt += 1) { + try { + return { ok: true, value: await read() } + } catch (error) { + logger.error(`Session goal ${goalId} ${label} failed (attempt ${attempt + 1}):`, error) + } + } + return { ok: false } + } + + private isAuditCurrent( + goalId: number, + sessionId: string, + generation: number, + turnState: SessionGoalTurnState = 'running', + ): boolean { + if (this.currentAuditGeneration(sessionId) !== generation) { + return false + } + const record = getSessionGoalById(this.db, goalId) + return Boolean(record && record.status === 'active' && record.turnState === turnState) + } + + private currentAuditGeneration(sessionId: string): number { + return this.auditGeneration.get(sessionId) ?? 0 + } + + private applyTransition( + goalId: number, + fromStatuses: SessionGoalStatus[], + patch: SessionGoalPatch, + ): SessionGoalRecord | null { + const record = transitionSessionGoal(this.db, goalId, fromStatuses, patch) + if (!record) { + return null + } + + this.syncGoalIndex(record) + this.maybeInvalidateAudits(record, patch) + return record + } + + private transitionWithOutcome( + id: number, + fromStatuses: SessionGoalStatus[], + patch: SessionGoalPatch, + ): SessionGoal { + const goal = toSessionGoal(this.transition(id, fromStatuses, patch)) + this.onOutcome(goal) + return goal } private transition(id: number, fromStatuses: SessionGoalStatus[], patch: SessionGoalPatch): SessionGoalRecord { const record = transitionSessionGoal(this.db, id, fromStatuses, patch) - if (record) { - return record + if (!record) { + if (!getSessionGoalById(this.db, id)) { + throw new SessionGoalError('Session goal not found', 404) + } + throw new SessionGoalError('Session goal cannot transition from its current state', 409) } - if (!getSessionGoalById(this.db, id)) { - throw new SessionGoalError('Session goal not found', 404) + this.syncGoalIndex(record) + this.maybeInvalidateAudits(record, patch) + return record + } + + private syncGoalIndex(record: SessionGoalRecord): void { + if (record.status === 'active' || record.status === 'paused') { + this.goalBySession.set(record.sessionId, record.id) + } else { + this.goalBySession.delete(record.sessionId) + this.clearAuditTimer(record.sessionId) } - throw new SessionGoalError('Session goal cannot transition from its current state', 409) + } + + private maybeInvalidateAudits(record: SessionGoalRecord, patch: SessionGoalPatch): void { + if (patch.status === undefined && patch.turnState !== 'running') { + return + } + this.auditGeneration.set(record.sessionId, this.currentAuditGeneration(record.sessionId) + 1) + this.auditRequested.delete(record.id) } private async readSessionTokenTotal(sessionId: string): Promise { + const session = await this.openCodeClient.api.session.get({ sessionID: sessionId }) + return sessionTokenTotal(session.tokens) + } + + private async readSessionTokenTotalOrDefault(sessionId: string): Promise { try { - const session = await this.openCodeClient.api.session.get({ sessionID: sessionId }) - return sessionTokenTotal(session.tokens) + return await this.readSessionTokenTotal(sessionId) } catch (error) { logger.error(`Failed to read token usage for session ${sessionId}:`, error) return 0 diff --git a/backend/test/helpers/fake-session-goal-client.ts b/backend/test/helpers/fake-session-goal-client.ts index 8158fbbca..1e8103057 100644 --- a/backend/test/helpers/fake-session-goal-client.ts +++ b/backend/test/helpers/fake-session-goal-client.ts @@ -1,3 +1,4 @@ +import type { ModelRef, SessionMessageInfo } from '@opencode-manager/shared/opencode' import type { OpenCodeClient } from '../../src/services/opencode/client' export interface FakeSessionGoalTokens { @@ -9,19 +10,78 @@ export interface FakeSessionGoalTokens { export interface FakeSessionGoalClientOptions { tokens?: Record failSessionGet?: boolean + messages?: Record + busySessions?: string[] + auditorReplies?: string[] + failAuditorCalls?: number + failSessionPrompt?: boolean } -export function createFakeSessionGoalClient(options: FakeSessionGoalClientOptions = {}): OpenCodeClient { - const tokens = options.tokens ?? {} +export interface FakeSessionGoalPromptCall { + sessionID: string + text: string +} - return { +export interface FakeSessionGoalAuditorCall { + prompt: string + model: ModelRef | null +} + +export interface FakeSessionGoalAuditorDeferral { + resolve(text: string): void + reject(error?: unknown): void +} + +export interface FakeSessionGoalPromptDeferral { + resolve(): void + reject(error?: unknown): void +} + +export interface FakeSessionGoalClient { + client: OpenCodeClient + promptCalls: FakeSessionGoalPromptCall[] + auditorCalls: FakeSessionGoalAuditorCall[] + pendingAuditorCalls: FakeSessionGoalAuditorDeferral[] + pendingSessionPromptCalls: FakeSessionGoalPromptDeferral[] + readonly maxConcurrentAuditorCalls: number + setTokens(sessionId: string, tokens: FakeSessionGoalTokens): void + setMessages(sessionId: string, messages: SessionMessageInfo[]): void + setBusy(sessionId: string, busy: boolean): void + setAuditorReplies(replies: string[]): void + failNextAuditorCalls(count: number): void + deferNextAuditorCalls(count: number): void + deferNextSessionPrompts(count: number): void + setFailSessionGet(fail: boolean): void + setFailSessionActive(fail: boolean): void + setFailMessageList(fail: boolean): void +} + +export function createFakeSessionGoalClient(options: FakeSessionGoalClientOptions = {}): FakeSessionGoalClient { + const tokens = new Map(Object.entries(options.tokens ?? {})) + const messages = new Map(Object.entries(options.messages ?? {})) + const busySessions = new Set(options.busySessions ?? []) + const promptCalls: FakeSessionGoalPromptCall[] = [] + const auditorCalls: FakeSessionGoalAuditorCall[] = [] + const pendingAuditorCalls: FakeSessionGoalAuditorDeferral[] = [] + const pendingSessionPromptCalls: FakeSessionGoalPromptDeferral[] = [] + let auditorReplies = [...(options.auditorReplies ?? [])] + let auditorFailures = options.failAuditorCalls ?? 0 + let deferAuditorCount = 0 + let deferPromptCount = 0 + let sessionGetFails = options.failSessionGet ?? false + let sessionActiveFails = false + let messageListFails = false + let activeAuditorCalls = 0 + let maxConcurrentAuditorCalls = 0 + + const client = { api: { session: { get: async ({ sessionID }: { sessionID: string }) => { - if (options.failSessionGet) { + if (sessionGetFails) { throw new Error('upstream unavailable') } - const usage = tokens[sessionID] ?? { input: 0, output: 0, reasoning: 0 } + const usage = tokens.get(sessionID) ?? { input: 0, output: 0, reasoning: 0 } return { tokens: { input: usage.input, @@ -31,8 +91,129 @@ export function createFakeSessionGoalClient(options: FakeSessionGoalClientOption }, } }, + active: async () => { + if (sessionActiveFails) { + throw new Error('active lookup failed') + } + const active: Record = {} + for (const sessionId of busySessions) { + active[sessionId] = { type: 'running' } + } + return active + }, + prompt: async (input: { sessionID: string; text: string }) => { + if (options.failSessionPrompt) { + throw new Error('prompt rejected') + } + promptCalls.push({ sessionID: input.sessionID, text: input.text }) + if (deferPromptCount > 0) { + deferPromptCount -= 1 + await new Promise((resolve, reject) => { + pendingSessionPromptCalls.push({ resolve, reject }) + }) + } + return { id: `inbox_${promptCalls.length}` } + }, + }, + message: { + list: async ({ sessionID }: { sessionID: string }) => { + if (messageListFails) { + throw new Error('message list failed') + } + return { + data: messages.get(sessionID) ?? [], + cursor: {}, + } + }, + }, + generate: { + text: async (input: { prompt: string; model?: ModelRef | null }) => { + auditorCalls.push({ prompt: input.prompt, model: input.model ?? null }) + activeAuditorCalls += 1 + maxConcurrentAuditorCalls = Math.max(maxConcurrentAuditorCalls, activeAuditorCalls) + try { + if (deferAuditorCount > 0) { + deferAuditorCount -= 1 + const text = await new Promise((resolve, reject) => { + pendingAuditorCalls.push({ resolve, reject }) + }) + return { text } + } + if (auditorFailures > 0) { + auditorFailures -= 1 + throw new Error('auditor unavailable') + } + const text = auditorReplies.shift() ?? '{"verdict":"continue","reason":"still working"}' + return { text } + } finally { + activeAuditorCalls -= 1 + } + }, }, }, forwardRaw: async () => new Response(), } as unknown as OpenCodeClient + + return { + client, + promptCalls, + auditorCalls, + pendingAuditorCalls, + pendingSessionPromptCalls, + get maxConcurrentAuditorCalls() { + return maxConcurrentAuditorCalls + }, + setTokens: (sessionId, next) => { + tokens.set(sessionId, next) + }, + setMessages: (sessionId, next) => { + messages.set(sessionId, next) + }, + setBusy: (sessionId, next) => { + if (next) { + busySessions.add(sessionId) + } else { + busySessions.delete(sessionId) + } + }, + setAuditorReplies: (replies) => { + auditorReplies = [...replies] + }, + failNextAuditorCalls: (count) => { + auditorFailures = count + }, + deferNextAuditorCalls: (count) => { + deferAuditorCount = count + }, + deferNextSessionPrompts: (count) => { + deferPromptCount = count + }, + setFailSessionGet: (fail) => { + sessionGetFails = fail + }, + setFailSessionActive: (fail) => { + sessionActiveFails = fail + }, + setFailMessageList: (fail) => { + messageListFails = fail + }, + } +} + +export function fakeAssistantMessage( + text: string, + options: { error?: string; completed?: boolean } = {}, +): SessionMessageInfo { + return { + id: 'msg_assistant', + type: 'assistant', + time: { + created: 0, + ...(options.completed === false ? {} : { completed: 1 }), + }, + agent: 'build', + model: { providerID: 'test', id: 'model' }, + content: [{ type: 'text', text }], + ...(options.error ? { error: { type: 'unknown', message: options.error } } : {}), + } as unknown as SessionMessageInfo } diff --git a/backend/test/routes/session-goals.test.ts b/backend/test/routes/session-goals.test.ts index 47883590e..82debcf27 100644 --- a/backend/test/routes/session-goals.test.ts +++ b/backend/test/routes/session-goals.test.ts @@ -12,7 +12,11 @@ const DIRECTORY = '/abs/repo' function createTestApp(db: Database): Hono { const app = new Hono() - const service = new SessionGoalService(db, createFakeSessionGoalClient(), new SettingsService(db)) + const service = new SessionGoalService( + db, + createFakeSessionGoalClient({ busySessions: ['ses_1'] }).client, + new SettingsService(db), + ) app.route('/session-goals', createSessionGoalRoutes(service)) return app } diff --git a/backend/test/services/session-goal-audit.test.ts b/backend/test/services/session-goal-audit.test.ts new file mode 100644 index 000000000..c51f74bc0 --- /dev/null +++ b/backend/test/services/session-goal-audit.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect } from 'vitest' +import { + buildGoalAuditPrompt, + buildGoalContinuationPrompt, + GOAL_AUDIT_REASON_MAX_CHARS, + GOAL_AUDIT_REPLY_MAX_CHARS, + parseGoalVerdict, +} from '../../src/services/session-goal-audit' + +describe('parseGoalVerdict', () => { + it('parses a plain JSON verdict', () => { + expect(parseGoalVerdict('{"verdict":"done","reason":"The feature shipped."}')).toEqual({ + verdict: 'done', + reason: 'The feature shipped.', + }) + }) + + it('parses a fenced JSON verdict with surrounding prose', () => { + const text = [ + 'Here is my decision:', + '```json', + '{"verdict":"blocked","reason":"Needs a database decision."}', + '```', + 'Let me know.', + ].join('\n') + + expect(parseGoalVerdict(text)).toEqual({ + verdict: 'blocked', + reason: 'Needs a database decision.', + }) + }) + + it('returns null for invalid JSON', () => { + expect(parseGoalVerdict('not json at all')).toBeNull() + expect(parseGoalVerdict('{"verdict":')).toBeNull() + }) + + it('returns null for an unknown verdict', () => { + expect(parseGoalVerdict('{"verdict":"maybe","reason":"unsure"}')).toBeNull() + }) + + it('returns null for a missing or empty reason', () => { + expect(parseGoalVerdict('{"verdict":"done"}')).toBeNull() + expect(parseGoalVerdict('{"verdict":"done","reason":" "}')).toBeNull() + }) + + it('caps the reason length', () => { + const longReason = 'a'.repeat(GOAL_AUDIT_REASON_MAX_CHARS + 50) + const parsed = parseGoalVerdict(`{"verdict":"continue","reason":"${longReason}"}`) + + expect(parsed?.verdict).toBe('continue') + expect(parsed?.reason).toHaveLength(GOAL_AUDIT_REASON_MAX_CHARS) + }) +}) + +describe('buildGoalAuditPrompt', () => { + it('includes the objective and reply and asks for JSON only', () => { + const prompt = buildGoalAuditPrompt({ objective: 'Ship the feature', reply: 'I updated the route.' }) + + expect(prompt).toContain('Ship the feature') + expect(prompt).toContain('I updated the route.') + expect(prompt).toContain('{"verdict":"continue"|"done"|"blocked","reason":""}') + }) + + it('marks a missing reply', () => { + expect(buildGoalAuditPrompt({ objective: 'Ship it', reply: null })).toContain('(no reply yet)') + }) + + it('truncates a long reply with a marker', () => { + const reply = 'x'.repeat(GOAL_AUDIT_REPLY_MAX_CHARS + 100) + const prompt = buildGoalAuditPrompt({ objective: 'Ship it', reply }) + + expect(prompt).toContain('[reply truncated]') + expect(prompt).not.toContain('x'.repeat(GOAL_AUDIT_REPLY_MAX_CHARS + 1)) + }) +}) + +describe('buildGoalContinuationPrompt', () => { + it('includes the objective and the progress reason', () => { + const prompt = buildGoalContinuationPrompt({ objective: 'Ship the feature', reason: 'Tests still fail.' }) + + expect(prompt).toContain('Ship the feature') + expect(prompt).toContain('Tests still fail.') + expect(prompt).toContain('state exactly what you need') + }) +}) diff --git a/backend/test/services/session-goals.test.ts b/backend/test/services/session-goals.test.ts index 0439aa266..0331a2dd5 100644 --- a/backend/test/services/session-goals.test.ts +++ b/backend/test/services/session-goals.test.ts @@ -1,11 +1,17 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { Database } from 'bun:sqlite' import { migrate } from '../../src/db/migration-runner' import { allMigrations } from '../../src/db/migrations' import { getSessionGoalById, listOpenSessionGoals } from '../../src/db/session-goals' -import { SessionGoalService } from '../../src/services/session-goals' +import { SessionGoalService, type SessionGoalServiceOptions } from '../../src/services/session-goals' import { SettingsService } from '../../src/services/settings' -import { createFakeSessionGoalClient } from '../helpers/fake-session-goal-client' +import type { SSEEvent } from '../../src/services/sse-aggregator' +import { + createFakeSessionGoalClient, + fakeAssistantMessage, + type FakeSessionGoalClient, + type FakeSessionGoalClientOptions, +} from '../helpers/fake-session-goal-client' const DIRECTORY = '/abs/repo' @@ -17,20 +23,93 @@ function createTestDb(): Database { function createService( db: Database, - options: Parameters[0] = {}, + options: FakeSessionGoalClientOptions = {}, settingsService: SettingsService = new SettingsService(db), + serviceOptions: SessionGoalServiceOptions = {}, ): SessionGoalService { - return new SessionGoalService(db, createFakeSessionGoalClient(options), settingsService) + return new SessionGoalService(db, createFakeSessionGoalClient(options).client, settingsService, serviceOptions) +} + +function executionStarted(sessionID: string): SSEEvent { + return { + id: `evt_started_${sessionID}`, + created: 0, + type: 'session.execution.started', + location: { directory: DIRECTORY }, + data: { sessionID }, + } as unknown as SSEEvent +} + +function executionSucceeded(sessionID: string): SSEEvent { + return { + id: `evt_succeeded_${sessionID}`, + created: 0, + type: 'session.execution.succeeded', + location: { directory: DIRECTORY }, + data: { sessionID }, + } as unknown as SSEEvent +} + +function sessionIdle(sessionID: string): SSEEvent { + return { + id: `evt_idle_${sessionID}`, + created: 0, + type: 'session.idle', + location: { directory: DIRECTORY }, + data: { sessionID }, + } as unknown as SSEEvent +} + +function sessionStatusIdle(sessionID: string): SSEEvent { + return { + id: `evt_status_${sessionID}`, + created: 0, + type: 'session.status', + location: { directory: DIRECTORY }, + data: { sessionID, status: { type: 'idle' } }, + } as unknown as SSEEvent +} + +function executionFailed(sessionID: string): SSEEvent { + return { + id: `evt_failed_${sessionID}`, + created: 0, + type: 'session.execution.failed', + location: { directory: DIRECTORY }, + data: { sessionID, error: { type: 'unknown', message: 'boom' } }, + } as unknown as SSEEvent +} + +function executionInterrupted(sessionID: string): SSEEvent { + return { + id: `evt_interrupted_${sessionID}`, + created: 0, + type: 'session.execution.interrupted', + location: { directory: DIRECTORY }, + data: { sessionID, reason: 'user' }, + } as unknown as SSEEvent +} + +function sessionDeleted(sessionID: string): SSEEvent { + return { + id: `evt_deleted_${sessionID}`, + created: 0, + type: 'session.deleted', + location: { directory: DIRECTORY }, + data: { sessionID }, + } as unknown as SSEEvent } describe('SessionGoalService', () => { let db: Database beforeEach(() => { + vi.useFakeTimers() db = createTestDb() }) afterEach(() => { + vi.useRealTimers() db.close() }) @@ -162,6 +241,89 @@ describe('SessionGoalService', () => { expect(() => service.pause(999)).toThrowError(expect.objectContaining({ status: 404 }) as Error) }) + it('emits exactly one outcome when pausing an active goal', async () => { + const outcomes: string[] = [] + const service = createService(db, {}, new SettingsService(db), { + onOutcome: (goal) => outcomes.push(goal.status), + }) + const started = await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) + + const paused = service.pause(started.id) + + expect(paused.status).toBe('paused') + expect(outcomes).toEqual(['paused']) + }) + + it('emits exactly one outcome when cancelling an active goal', async () => { + const outcomes: string[] = [] + const service = createService(db, {}, new SettingsService(db), { + onOutcome: (goal) => outcomes.push(goal.status), + }) + const started = await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) + + const cancelled = service.cancel(started.id) + + expect(cancelled.status).toBe('stopped') + expect(outcomes).toEqual(['stopped']) + }) + + it('emits exactly one outcome when cancelling a paused goal', async () => { + const outcomes: string[] = [] + const service = createService(db, {}, new SettingsService(db), { + onOutcome: (goal) => outcomes.push(goal.status), + }) + const started = await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) + service.pause(started.id) + outcomes.length = 0 + + service.cancel(started.id) + + expect(outcomes).toEqual(['stopped']) + }) + + it('does not emit an outcome when a pause transition is rejected', async () => { + const outcomes: string[] = [] + const service = createService(db, {}, new SettingsService(db), { + onOutcome: (goal) => outcomes.push(goal.status), + }) + const started = await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) + service.pause(started.id) + outcomes.length = 0 + + expect(() => service.pause(started.id)).toThrowError(expect.objectContaining({ status: 409 }) as Error) + + expect(outcomes).toEqual([]) + }) + + it('does not emit an outcome when a cancel transition is rejected', async () => { + const outcomes: string[] = [] + const service = createService(db, {}, new SettingsService(db), { + onOutcome: (goal) => outcomes.push(goal.status), + }) + const started = await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) + service.cancel(started.id) + outcomes.length = 0 + + expect(() => service.cancel(started.id)).toThrowError(expect.objectContaining({ status: 409 }) as Error) + + expect(outcomes).toEqual([]) + }) + + it('does not emit an outcome when resuming a paused goal', async () => { + const outcomes: string[] = [] + const service = createService(db, {}, new SettingsService(db), { + onOutcome: (goal) => outcomes.push(goal.status), + }) + const started = await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) + service.pause(started.id) + outcomes.length = 0 + + service.resume(started.id) + await vi.runAllTimersAsync() + + expect(outcomes).toEqual([]) + }) + it('returns the latest goal for a session', async () => { const service = createService(db) @@ -171,4 +333,565 @@ describe('SessionGoalService', () => { expect(service.getLatest('ses_1')?.id).toBe(started.id) }) + + it('loads open goals so events are handled after a restart', async () => { + const started = await createService(db).start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) + const fake = createFakeSessionGoalClient() + const restarted = new SessionGoalService(db, fake.client, new SettingsService(db), { quietMs: 0 }) + + restarted.loadOpenGoals() + await restarted.handleEvent(DIRECTORY, executionStarted('ses_1')) + await restarted.handleEvent(DIRECTORY, sessionIdle('ses_1')) + await vi.runAllTimersAsync() + + expect(fake.auditorCalls).toHaveLength(1) + expect(getSessionGoalById(db, started.id)?.turnState).toBe('waiting') + }) +}) + +describe('SessionGoalService audit loop', () => { + let db: Database + let fake: FakeSessionGoalClient + let service: SessionGoalService + + beforeEach(() => { + vi.useFakeTimers() + db = createTestDb() + fake = createFakeSessionGoalClient() + service = new SessionGoalService(db, fake.client, new SettingsService(db), { quietMs: 0 }) + }) + + afterEach(() => { + vi.useRealTimers() + db.close() + }) + + async function runTurn(sessionId: string): Promise { + await service.handleEvent(DIRECTORY, executionStarted(sessionId)) + await service.handleEvent(DIRECTORY, sessionIdle(sessionId)) + await vi.runAllTimersAsync() + } + + it('completes the goal when the auditor says done', async () => { + fake.setMessages('ses_1', [fakeAssistantMessage('The feature is shipped and tested.')]) + fake.setAuditorReplies(['{"verdict":"done","reason":"Objective achieved."}']) + const started = await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) + + await runTurn('ses_1') + + const latest = service.getLatest('ses_1') + expect(latest?.status).toBe('completed') + expect(latest?.stopReason).toBeNull() + expect(latest?.lastVerdict).toBe('done') + expect(latest?.lastReason).toBe('Objective achieved.') + expect(latest?.finishedAt).not.toBeNull() + expect(fake.promptCalls).toHaveLength(0) + expect(fake.auditorCalls[0]?.prompt).toContain('Ship it') + expect(fake.auditorCalls[0]?.prompt).toContain('The feature is shipped and tested.') + expect(getSessionGoalById(db, started.id)?.turnState).toBe('running') + }) + + it('sends one continuation prompt and increments the count on continue', async () => { + fake.setMessages('ses_1', [fakeAssistantMessage('Working on the route.')]) + fake.setAuditorReplies(['{"verdict":"continue","reason":"Tests still fail."}']) + await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) + + await runTurn('ses_1') + + const latest = service.getLatest('ses_1') + expect(latest?.status).toBe('active') + expect(latest?.turnState).toBe('waiting') + expect(latest?.continuationCount).toBe(1) + expect(fake.promptCalls).toHaveLength(1) + expect(fake.promptCalls[0]?.sessionID).toBe('ses_1') + expect(fake.promptCalls[0]?.text).toContain('Ship it') + expect(fake.promptCalls[0]?.text).toContain('Tests still fail.') + }) + + it('stops the goal at the continuation cap', async () => { + fake.setMessages('ses_1', [fakeAssistantMessage('Still working.')]) + await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it', maxContinuations: 1 }) + + await runTurn('ses_1') + await runTurn('ses_1') + + const latest = service.getLatest('ses_1') + expect(latest?.status).toBe('stopped') + expect(latest?.stopReason).toBe('continuation_limit') + expect(latest?.continuationCount).toBe(1) + expect(fake.promptCalls).toHaveLength(1) + }) + + it('continues twice and blocks on the third blocked verdict', async () => { + fake.setMessages('ses_1', [fakeAssistantMessage('I need a decision.')]) + fake.setAuditorReplies([ + '{"verdict":"blocked","reason":"Needs a decision."}', + '{"verdict":"blocked","reason":"Needs a decision."}', + '{"verdict":"blocked","reason":"Needs a decision."}', + ]) + await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) + + await runTurn('ses_1') + expect(service.getLatest('ses_1')?.status).toBe('active') + expect(service.getLatest('ses_1')?.consecutiveBlocked).toBe(1) + + await runTurn('ses_1') + expect(service.getLatest('ses_1')?.status).toBe('active') + expect(service.getLatest('ses_1')?.consecutiveBlocked).toBe(2) + + await runTurn('ses_1') + const latest = service.getLatest('ses_1') + expect(latest?.status).toBe('blocked') + expect(latest?.consecutiveBlocked).toBe(3) + expect(fake.promptCalls).toHaveLength(2) + }) + + it('resets the blocked counter on a continue verdict', async () => { + fake.setMessages('ses_1', [fakeAssistantMessage('Back to work.')]) + fake.setAuditorReplies([ + '{"verdict":"blocked","reason":"Needs a decision."}', + '{"verdict":"continue","reason":"Unblocked."}', + '{"verdict":"blocked","reason":"Needs a decision."}', + ]) + await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) + + await runTurn('ses_1') + await runTurn('ses_1') + expect(service.getLatest('ses_1')?.consecutiveBlocked).toBe(0) + + await runTurn('ses_1') + expect(service.getLatest('ses_1')?.consecutiveBlocked).toBe(1) + }) + + it('stops on a token budget overrun before calling the auditor', async () => { + fake.setMessages('ses_1', [fakeAssistantMessage('Still working.')]) + await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it', tokenBudget: 100 }) + fake.setTokens('ses_1', { input: 250, output: 0, reasoning: 0 }) + + await runTurn('ses_1') + + const latest = service.getLatest('ses_1') + expect(latest?.status).toBe('stopped') + expect(latest?.stopReason).toBe('token_budget') + expect(latest?.tokensUsed).toBe(250) + expect(fake.auditorCalls).toHaveLength(0) + }) + + it('stops with turn_error when the execution fails', async () => { + await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) + + await service.handleEvent(DIRECTORY, executionFailed('ses_1')) + + const latest = service.getLatest('ses_1') + expect(latest?.status).toBe('stopped') + expect(latest?.stopReason).toBe('turn_error') + expect(fake.auditorCalls).toHaveLength(0) + }) + + it('stops with turn_error when the latest reply carries an error', async () => { + fake.setMessages('ses_1', [fakeAssistantMessage('Partial output', { error: 'provider crashed' })]) + await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) + + await runTurn('ses_1') + + const latest = service.getLatest('ses_1') + expect(latest?.status).toBe('stopped') + expect(latest?.stopReason).toBe('turn_error') + expect(fake.auditorCalls).toHaveLength(0) + }) + + it('pauses with audit_failed after two auditor failures', async () => { + fake.setMessages('ses_1', [fakeAssistantMessage('Still working.')]) + fake.failNextAuditorCalls(2) + await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) + + await runTurn('ses_1') + + const latest = service.getLatest('ses_1') + expect(latest?.status).toBe('paused') + expect(latest?.stopReason).toBe('audit_failed') + expect(latest?.finishedAt).toBeNull() + expect(fake.auditorCalls).toHaveLength(2) + }) + + it('treats an unparseable verdict as continue', async () => { + fake.setMessages('ses_1', [fakeAssistantMessage('Still working.')]) + fake.setAuditorReplies(['I am not sure.']) + await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) + + await runTurn('ses_1') + + const latest = service.getLatest('ses_1') + expect(latest?.status).toBe('active') + expect(latest?.lastVerdict).toBe('continue') + expect(latest?.lastReason).toBe('Auditor response was not understood') + expect(fake.promptCalls).toHaveLength(1) + }) + + it('does not audit an idle event while the turn is waiting', async () => { + await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) + + await service.handleEvent(DIRECTORY, sessionIdle('ses_1')) + await vi.runAllTimersAsync() + + expect(fake.auditorCalls).toHaveLength(0) + }) + + it('audits on execution.succeeded and on a session.status idle event', async () => { + fake.setMessages('ses_1', [fakeAssistantMessage('Done.')]) + fake.setAuditorReplies(['{"verdict":"continue","reason":"keep going"}', '{"verdict":"done","reason":"finished"}']) + await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) + + await service.handleEvent(DIRECTORY, executionStarted('ses_1')) + await service.handleEvent(DIRECTORY, executionSucceeded('ses_1')) + await vi.runAllTimersAsync() + expect(fake.auditorCalls).toHaveLength(1) + + await service.handleEvent(DIRECTORY, executionStarted('ses_1')) + await service.handleEvent(DIRECTORY, sessionStatusIdle('ses_1')) + await vi.runAllTimersAsync() + + expect(fake.auditorCalls).toHaveLength(2) + expect(service.getLatest('ses_1')?.status).toBe('completed') + }) + + it('ignores events for a session without an open goal', async () => { + await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) + + await service.handleEvent(DIRECTORY, executionStarted('ses_other')) + await service.handleEvent(DIRECTORY, executionFailed('ses_other')) + await vi.runAllTimersAsync() + + expect(service.getLatest('ses_1')?.status).toBe('active') + expect(fake.auditorCalls).toHaveLength(0) + }) + + it('schedules an audit immediately when a paused goal resumes', async () => { + fake.setMessages('ses_1', [fakeAssistantMessage('Back.')]) + fake.setAuditorReplies(['{"verdict":"done","reason":"finished"}']) + const started = await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) + service.pause(started.id) + + service.resume(started.id) + await vi.runAllTimersAsync() + + expect(fake.auditorCalls).toHaveLength(1) + expect(service.getLatest('ses_1')?.status).toBe('completed') + }) + + it('does not schedule an audit on resume while the session is busy', async () => { + const started = await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) + service.pause(started.id) + fake.setBusy('ses_1', true) + + service.resume(started.id) + await vi.runAllTimersAsync() + + expect(fake.auditorCalls).toHaveLength(0) + expect(service.getLatest('ses_1')?.status).toBe('active') + }) + + it('pauses with interrupted when the execution is interrupted', async () => { + await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) + + await service.handleEvent(DIRECTORY, executionInterrupted('ses_1')) + + const latest = service.getLatest('ses_1') + expect(latest?.status).toBe('paused') + expect(latest?.stopReason).toBe('interrupted') + expect(latest?.finishedAt).toBeNull() + }) + + it('stops with session_deleted when the session is deleted', async () => { + const started = await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) + service.pause(started.id) + + await service.handleEvent(DIRECTORY, sessionDeleted('ses_1')) + + const latest = service.getLatest('ses_1') + expect(latest?.status).toBe('stopped') + expect(latest?.stopReason).toBe('session_deleted') + }) + + it('stops with turn_error when the continuation prompt fails', async () => { + const failing = createFakeSessionGoalClient({ failSessionPrompt: true }) + const failingService = new SessionGoalService(db, failing.client, new SettingsService(db), { quietMs: 0 }) + failing.setMessages('ses_1', [fakeAssistantMessage('Working.')]) + failing.setAuditorReplies(['{"verdict":"continue","reason":"keep going"}']) + await failingService.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) + + await failingService.handleEvent(DIRECTORY, executionStarted('ses_1')) + await failingService.handleEvent(DIRECTORY, sessionIdle('ses_1')) + await vi.runAllTimersAsync() + + const latest = failingService.getLatest('ses_1') + expect(latest?.status).toBe('stopped') + expect(latest?.stopReason).toBe('turn_error') + }) + + it('discards a late continuation failure after a new execution starts', async () => { + const outcomes: string[] = [] + const tracked = new SessionGoalService(db, fake.client, new SettingsService(db), { + quietMs: 0, + onOutcome: (goal) => outcomes.push(goal.status), + }) + fake.setMessages('ses_1', [fakeAssistantMessage('Working.')]) + fake.setAuditorReplies(['{"verdict":"continue","reason":"keep going"}']) + fake.deferNextSessionPrompts(1) + await tracked.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) + + await tracked.handleEvent(DIRECTORY, executionStarted('ses_1')) + await tracked.handleEvent(DIRECTORY, sessionIdle('ses_1')) + await vi.runAllTimersAsync() + expect(fake.pendingSessionPromptCalls).toHaveLength(1) + expect(tracked.getLatest('ses_1')?.turnState).toBe('waiting') + + await tracked.handleEvent(DIRECTORY, executionStarted('ses_1')) + fake.pendingSessionPromptCalls[0]?.reject(new Error('transport dropped')) + await vi.runAllTimersAsync() + + const latest = tracked.getLatest('ses_1') + expect(latest?.status).toBe('active') + expect(latest?.turnState).toBe('running') + expect(latest?.stopReason).toBeNull() + expect(outcomes).toEqual([]) + }) + + it('discards a late continuation failure across pause and resume', async () => { + const outcomes: string[] = [] + const tracked = new SessionGoalService(db, fake.client, new SettingsService(db), { + quietMs: 0, + onOutcome: (goal) => outcomes.push(goal.status), + }) + fake.setMessages('ses_1', [fakeAssistantMessage('Working.')]) + fake.setAuditorReplies(['{"verdict":"continue","reason":"keep going"}']) + fake.deferNextSessionPrompts(1) + const started = await tracked.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) + + await tracked.handleEvent(DIRECTORY, executionStarted('ses_1')) + await tracked.handleEvent(DIRECTORY, sessionIdle('ses_1')) + await vi.runAllTimersAsync() + expect(fake.pendingSessionPromptCalls).toHaveLength(1) + + tracked.pause(started.id) + tracked.resume(started.id) + await vi.runAllTimersAsync() + expect(outcomes).toEqual(['paused']) + outcomes.length = 0 + + fake.pendingSessionPromptCalls[0]?.reject(new Error('transport dropped')) + await vi.runAllTimersAsync() + + const latest = tracked.getLatest('ses_1') + expect(latest?.status).toBe('active') + expect(latest?.stopReason).toBeNull() + expect(outcomes).toEqual([]) + }) + + it('passes the configured auditor model to the auditor call', async () => { + const settingsService = new SettingsService(db) + settingsService.updateSettings({ sessionDefaults: { permissionMode: 'ask', goalAuditorModel: 'anthropic/claude-x' } }) + const configured = new SessionGoalService(db, fake.client, settingsService, { quietMs: 0 }) + fake.setMessages('ses_1', [fakeAssistantMessage('Done.')]) + fake.setAuditorReplies(['{"verdict":"done","reason":"finished"}']) + await configured.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) + + await configured.handleEvent(DIRECTORY, executionStarted('ses_1')) + await configured.handleEvent(DIRECTORY, sessionIdle('ses_1')) + await vi.runAllTimersAsync() + + expect(fake.auditorCalls[0]?.model).toEqual({ providerID: 'anthropic', id: 'claude-x' }) + }) + + it('calls onOutcome for terminal transitions', async () => { + const outcomes: string[] = [] + const tracked = new SessionGoalService(db, fake.client, new SettingsService(db), { + quietMs: 0, + onOutcome: (goal) => outcomes.push(goal.status), + }) + fake.setMessages('ses_1', [fakeAssistantMessage('Done.')]) + fake.setAuditorReplies(['{"verdict":"done","reason":"finished"}']) + await tracked.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) + + await tracked.handleEvent(DIRECTORY, executionStarted('ses_1')) + await tracked.handleEvent(DIRECTORY, sessionIdle('ses_1')) + await vi.runAllTimersAsync() + + expect(outcomes).toEqual(['completed']) + }) + + it('pauses with audit_failed when the busy check fails', async () => { + const outcomes: string[] = [] + const tracked = new SessionGoalService(db, fake.client, new SettingsService(db), { + quietMs: 0, + onOutcome: (goal) => outcomes.push(goal.status), + }) + fake.setMessages('ses_1', [fakeAssistantMessage('Working.')]) + await tracked.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) + fake.setFailSessionActive(true) + + await tracked.handleEvent(DIRECTORY, executionStarted('ses_1')) + await tracked.handleEvent(DIRECTORY, sessionIdle('ses_1')) + await vi.runAllTimersAsync() + + const latest = tracked.getLatest('ses_1') + expect(latest?.status).toBe('paused') + expect(latest?.stopReason).toBe('audit_failed') + expect(fake.auditorCalls).toHaveLength(0) + expect(fake.promptCalls).toHaveLength(0) + expect(outcomes).toEqual(['paused']) + }) + + it('pauses with audit_failed when the token lookup fails without resetting usage', async () => { + fake.setMessages('ses_1', [fakeAssistantMessage('Working.')]) + fake.setTokens('ses_1', { input: 50, output: 0, reasoning: 0 }) + fake.setAuditorReplies(['{"verdict":"continue","reason":"keep going"}']) + await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it', tokenBudget: 100 }) + + fake.setTokens('ses_1', { input: 100, output: 0, reasoning: 0 }) + await runTurn('ses_1') + expect(service.getLatest('ses_1')?.tokensUsed).toBe(50) + + fake.setTokens('ses_1', { input: 250, output: 0, reasoning: 0 }) + fake.setFailSessionGet(true) + await runTurn('ses_1') + + const latest = service.getLatest('ses_1') + expect(latest?.status).toBe('paused') + expect(latest?.stopReason).toBe('audit_failed') + expect(latest?.tokensUsed).toBe(50) + expect(fake.promptCalls).toHaveLength(1) + expect(fake.auditorCalls).toHaveLength(1) + }) + + it('pauses with audit_failed when the reply lookup fails', async () => { + fake.setMessages('ses_1', [fakeAssistantMessage('Working.')]) + await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) + fake.setFailMessageList(true) + + await runTurn('ses_1') + + const latest = service.getLatest('ses_1') + expect(latest?.status).toBe('paused') + expect(latest?.stopReason).toBe('audit_failed') + expect(fake.auditorCalls).toHaveLength(0) + expect(fake.promptCalls).toHaveLength(0) + }) + + it('pauses with audit_failed when the resume busy check fails', async () => { + const outcomes: string[] = [] + const tracked = new SessionGoalService(db, fake.client, new SettingsService(db), { + quietMs: 0, + onOutcome: (goal) => outcomes.push(goal.status), + }) + const started = await tracked.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) + tracked.pause(started.id) + outcomes.length = 0 + fake.setFailSessionActive(true) + + tracked.resume(started.id) + await vi.runAllTimersAsync() + + const latest = tracked.getLatest('ses_1') + expect(latest?.status).toBe('paused') + expect(latest?.stopReason).toBe('audit_failed') + expect(fake.auditorCalls).toHaveLength(0) + expect(outcomes).toEqual(['paused']) + }) + + it('discards a stale continue verdict when a new execution starts mid-audit', async () => { + fake.setMessages('ses_1', [fakeAssistantMessage('Working.')]) + fake.deferNextAuditorCalls(1) + await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) + + await service.handleEvent(DIRECTORY, executionStarted('ses_1')) + await service.handleEvent(DIRECTORY, sessionIdle('ses_1')) + await vi.runAllTimersAsync() + expect(fake.pendingAuditorCalls).toHaveLength(1) + + await service.handleEvent(DIRECTORY, executionStarted('ses_1')) + fake.setBusy('ses_1', true) + fake.pendingAuditorCalls[0]?.resolve('{"verdict":"continue","reason":"keep going"}') + await vi.runAllTimersAsync() + + const latest = service.getLatest('ses_1') + expect(latest?.status).toBe('active') + expect(latest?.continuationCount).toBe(0) + expect(latest?.turnState).toBe('running') + expect(fake.promptCalls).toHaveLength(0) + }) + + it('discards a stale done verdict when the goal is paused mid-audit', async () => { + fake.setMessages('ses_1', [fakeAssistantMessage('Done.')]) + fake.deferNextAuditorCalls(1) + const started = await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) + + await service.handleEvent(DIRECTORY, executionStarted('ses_1')) + await service.handleEvent(DIRECTORY, sessionIdle('ses_1')) + await vi.runAllTimersAsync() + expect(fake.pendingAuditorCalls).toHaveLength(1) + + service.pause(started.id) + fake.pendingAuditorCalls[0]?.resolve('{"verdict":"done","reason":"finished"}') + await vi.runAllTimersAsync() + + const latest = service.getLatest('ses_1') + expect(latest?.status).toBe('paused') + expect(latest?.stopReason).toBe('user_paused') + expect(latest?.lastVerdict).toBeNull() + }) + + it('retains a newer idle audit while an older audit is in flight', async () => { + fake.setMessages('ses_1', [fakeAssistantMessage('Turn 1.')]) + fake.setAuditorReplies(['{"verdict":"continue","reason":"second"}']) + fake.deferNextAuditorCalls(1) + await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) + + await service.handleEvent(DIRECTORY, executionStarted('ses_1')) + await service.handleEvent(DIRECTORY, sessionIdle('ses_1')) + await vi.runAllTimersAsync() + expect(fake.pendingAuditorCalls).toHaveLength(1) + + await service.handleEvent(DIRECTORY, executionStarted('ses_1')) + fake.setMessages('ses_1', [fakeAssistantMessage('Turn 2.')]) + await service.handleEvent(DIRECTORY, sessionIdle('ses_1')) + await vi.runAllTimersAsync() + expect(fake.pendingAuditorCalls).toHaveLength(1) + + fake.pendingAuditorCalls[0]?.resolve('{"verdict":"continue","reason":"stale"}') + await vi.runAllTimersAsync() + + expect(fake.auditorCalls).toHaveLength(2) + expect(fake.maxConcurrentAuditorCalls).toBe(1) + expect(fake.promptCalls).toHaveLength(1) + expect(fake.promptCalls[0]?.text).toContain('second') + const latest = service.getLatest('ses_1') + expect(latest?.continuationCount).toBe(1) + expect(latest?.turnState).toBe('waiting') + }) + + it('discards an in-flight audit result across pause and resume', async () => { + fake.setMessages('ses_1', [fakeAssistantMessage('Working.')]) + fake.setAuditorReplies(['{"verdict":"continue","reason":"fresh"}']) + fake.deferNextAuditorCalls(1) + const started = await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) + + await service.handleEvent(DIRECTORY, executionStarted('ses_1')) + await service.handleEvent(DIRECTORY, sessionIdle('ses_1')) + await vi.runAllTimersAsync() + expect(fake.pendingAuditorCalls).toHaveLength(1) + + service.pause(started.id) + service.resume(started.id) + await vi.runAllTimersAsync() + + fake.pendingAuditorCalls[0]?.resolve('{"verdict":"done","reason":"stale"}') + await vi.runAllTimersAsync() + + const latest = service.getLatest('ses_1') + expect(latest?.status).toBe('active') + expect(latest?.lastVerdict).toBe('continue') + expect(fake.auditorCalls).toHaveLength(2) + expect(fake.promptCalls).toHaveLength(1) + }) }) From 4e9f27ab0b72e6ad3cadb07b56a89e9389c0512f Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:22:08 -0400 Subject: [PATCH 07/16] section 7: Phase 7: Goal recovery on restart and outcome notifications --- backend/src/index.ts | 17 +- backend/src/services/notification.ts | 96 +++++++++- backend/src/services/session-goals.ts | 87 ++++++++- .../test/helpers/fake-session-goal-client.ts | 12 ++ .../services/notification-service.test.ts | 154 ++++++++++++++++ backend/test/services/session-goals.test.ts | 174 +++++++++++++++++- shared/src/schemas/notifications.ts | 2 + 7 files changed, 535 insertions(+), 7 deletions(-) diff --git a/backend/src/index.ts b/backend/src/index.ts index 1fbca0b84..f33e8a8a4 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -232,7 +232,13 @@ const scheduleRunnerInstance = new ScheduleRunner(scheduleService) const notificationService = new NotificationService(db) const sessionPermissionModeService = new SessionPermissionModeService(db, openCodeClient, settingsServiceForSchedules) -const sessionGoalService = new SessionGoalService(db, openCodeClient, settingsServiceForSchedules) +const sessionGoalService = new SessionGoalService(db, openCodeClient, settingsServiceForSchedules, { + onOutcome: (goal) => { + void notificationService.notifyGoalOutcome(goal).catch((error) => { + logger.error('Goal outcome notification error:', error) + }) + }, +}) sessionGoalService.loadOpenGoals() sseAggregator.onEvent((directory, event) => { @@ -249,6 +255,11 @@ notificationService.addEventSuppressor(async (event, sessionId) => { return (await sessionPermissionModeService.getEffectiveMode(sessionId)).mode === 'auto' }) +notificationService.addEventSuppressor(async (event, sessionId) => { + if (event.type !== 'session.idle' || !sessionId) return false + return sessionGoalService.hasActiveGoal(sessionId) +}) + if (ENV.VAPID.PUBLIC_KEY && ENV.VAPID.PRIVATE_KEY) { if (!ENV.VAPID.SUBJECT) { logger.warn('VAPID_SUBJECT is not set — push notifications require a mailto: subject (e.g. mailto:you@example.com)') @@ -272,6 +283,10 @@ sseAggregator.setPendingActionsFetcher(openCodeClient) sseAggregator.setPasswordResolver(() => new SettingsService(db).getOpenCodeServerPassword()) sseAggregator.start() +void sessionGoalService.recoverOpenGoals().catch((error) => { + logger.error('Session goal recovery error:', error) +}) + sseAggregator.setScheduledSessionsResolver( () => scheduleService.getActiveRunSessions(), ) diff --git a/backend/src/services/notification.ts b/backend/src/services/notification.ts index 76f20038c..d2884d0be 100644 --- a/backend/src/services/notification.ts +++ b/backend/src/services/notification.ts @@ -6,6 +6,9 @@ import type { PushNotificationPayload } from "@opencode-manager/shared/types"; import { NotificationEventType, DEFAULT_NOTIFICATION_PREFERENCES, + type SessionGoal, + type SessionGoalStatus, + type SessionGoalStopReason, } from "@opencode-manager/shared/schemas"; import { getPermissionLabel, @@ -77,6 +80,55 @@ const RUN_OUTCOME_EVENTS = new Set([ NotificationEventType.SESSION_FAILED, ]); +const GOAL_OUTCOME_TITLES: Record = { + active: "Goal active", + paused: "Goal paused", + completed: "Goal completed", + blocked: "Goal blocked", + stopped: "Goal stopped", +}; + +const GOAL_STOP_REASON_LABELS: Record = { + cancelled: "Cancelled", + user_paused: "Paused by user", + continuation_limit: "Continuation limit reached", + token_budget: "Token budget reached", + turn_error: "Turn failed", + interrupted: "Interrupted", + audit_failed: "Audit failed", + session_deleted: "Session deleted", +}; + +function truncateNotificationBody(rawBody: string): string { + return rawBody.length > MAX_BODY_LENGTH + ? `${rawBody.slice(0, MAX_BODY_LENGTH - 1)}…` + : rawBody; +} + +function truncateWithEllipsis(text: string, maxLength: number): string { + if (maxLength <= 0) return ""; + if (text.length <= maxLength) return text; + if (maxLength === 1) return "…"; + return `${text.slice(0, maxLength - 1)}…`; +} + +function buildGoalOutcomeBody(goal: SessionGoal, repoName: string | undefined): string { + const reason = goal.stopReason + ? GOAL_STOP_REASON_LABELS[goal.stopReason] + : goal.lastReason?.trim() || undefined; + const prefix = repoName ? `${repoName} · ` : ""; + const separator = " — "; + + const reasonBudget = Math.max(0, MAX_BODY_LENGTH - prefix.length - separator.length); + const boundedReason = reason ? truncateWithEllipsis(reason, reasonBudget) : ""; + const suffix = boundedReason ? `${separator}${boundedReason}` : ""; + + const objectiveBudget = Math.max(0, MAX_BODY_LENGTH - prefix.length - suffix.length); + const objective = truncateWithEllipsis(goal.objective, objectiveBudget); + + return truncateNotificationBody(`${prefix}${objective}${suffix}`); +} + function resolveEventSessionId(event: SSEEvent): string | undefined { if (event.type === NotificationEventType.FORM_CREATED) { return event.data.form.sessionID; @@ -117,10 +169,7 @@ export function buildEventNotificationPayload( const rawBody = context.repoName ? `${context.repoName} · ${detail}` : detail; - const body = - rawBody.length > MAX_BODY_LENGTH - ? `${rawBody.slice(0, MAX_BODY_LENGTH - 1)}…` - : rawBody; + const body = truncateNotificationBody(rawBody); return { title, @@ -364,6 +413,45 @@ export class NotificationService { } } + async notifyGoalOutcome(goal: SessionGoal): Promise { + if (!this.isConfigured()) return; + + const userIds = this.getAllUserIds(); + if (userIds.length === 0) return; + + const repo = goal.directory + ? await this.resolveRepoForDirectory(goal.directory) + : null; + const repoName = repo ? getRepoName(repo) : undefined; + + const payload: PushNotificationPayload = { + title: GOAL_OUTCOME_TITLES[goal.status], + body: buildGoalOutcomeBody(goal, repoName), + tag: `session-goal-${goal.id}`, + timestamp: Date.now(), + renotify: true, + data: { + eventType: "session.goal.outcome", + sessionId: goal.sessionId, + directory: goal.directory, + repoId: repo?.id, + repoName, + url: buildNotificationUrl(repo, goal.sessionId), + }, + }; + + for (const userId of userIds) { + const settings = this.settingsService.getSettings(userId); + const notifPrefs = + settings.preferences.notifications ?? DEFAULT_NOTIFICATION_PREFERENCES; + + if (!notifPrefs.enabled) continue; + if (notifPrefs.events.goalOutcome === false) continue; + + await this.sendToUser(userId, payload); + } + } + async sendTestNotification(userId: string): Promise { await this.sendToUser(userId, { title: "Test Notification", diff --git a/backend/src/services/session-goals.ts b/backend/src/services/session-goals.ts index 95a259084..b7c0a45d6 100644 --- a/backend/src/services/session-goals.ts +++ b/backend/src/services/session-goals.ts @@ -7,7 +7,12 @@ import { type SessionGoalTurnState, type StartSessionGoalRequest, } from '@opencode-manager/shared/schemas' -import { parseOpenCodeModelRef, sessionIDFromEvent, type ModelRef } from '@opencode-manager/shared/opencode' +import { + isSessionNotFoundError, + parseOpenCodeModelRef, + sessionIDFromEvent, + type ModelRef, +} from '@opencode-manager/shared/opencode' import { getLatestSessionGoal, getSessionGoalById, @@ -33,6 +38,7 @@ import type { SSEEvent } from './sse-aggregator' const DEFAULT_QUIET_MS = 3000 const BLOCKED_LIMIT = 3 const AUDITOR_ATTEMPTS = 2 +const RECOVERY_RETRY_MS = 5000 type PrerequisiteResult = { ok: true; value: T } | { ok: false } @@ -79,6 +85,7 @@ export function toSessionGoal(record: SessionGoalRecord): SessionGoal { export class SessionGoalService { private readonly goalBySession = new Map() private readonly auditTimers = new Map>() + private readonly recoveryTimers = new Map>() private readonly auditing = new Set() private readonly auditRequested = new Set() private readonly auditGeneration = new Map() @@ -102,6 +109,76 @@ export class SessionGoalService { } } + async recoverOpenGoals(): Promise { + for (const record of listOpenSessionGoals(this.db)) { + if (record.status !== 'active' || record.turnState !== 'running') { + continue + } + try { + await this.recoverRunningGoal(record) + } catch (error) { + logger.error(`Session goal recovery failed for goal ${record.id}:`, error) + } + } + } + + private async recoverRunningGoal(record: SessionGoalRecord): Promise { + const generation = this.currentAuditGeneration(record.sessionId) + try { + await this.openCodeClient.api.session.get({ sessionID: record.sessionId }) + } catch (error) { + if (isSessionNotFoundError(error)) { + this.finishGoal(record.id, 'stopped', 'session_deleted') + return + } + logger.error(`Session goal recovery could not read session ${record.sessionId}:`, error) + this.scheduleRecoveryRetry(record.sessionId, record.id, generation) + return + } + + const busy = await this.readPrerequisite(record.id, 'busy check', () => + isSessionBusy(this.openCodeClient, record.sessionId), + ) + if (!busy.ok) { + this.scheduleRecoveryRetry(record.sessionId, record.id, generation) + return + } + if (busy.value) { + return + } + this.scheduleAudit(record.sessionId, record.id, 0) + } + + private scheduleRecoveryRetry(sessionId: string, goalId: number, generation: number): void { + this.clearRecoveryTimer(sessionId) + const timer = setTimeout(() => { + this.recoveryTimers.delete(sessionId) + void this.retryRecovery(sessionId, goalId, generation).catch((error) => { + logger.error(`Session goal recovery retry failed for goal ${goalId}:`, error) + }) + }, RECOVERY_RETRY_MS) + this.recoveryTimers.set(sessionId, timer) + } + + private async retryRecovery(sessionId: string, goalId: number, generation: number): Promise { + const record = getSessionGoalById(this.db, goalId) + if (!record || record.status !== 'active' || record.turnState !== 'running') { + return + } + if (this.currentAuditGeneration(sessionId) !== generation) { + return + } + await this.recoverRunningGoal(record) + } + + private clearRecoveryTimer(sessionId: string): void { + const timer = this.recoveryTimers.get(sessionId) + if (timer !== undefined) { + clearTimeout(timer) + this.recoveryTimers.delete(sessionId) + } + } + async start(input: StartSessionGoalRequest): Promise { const defaults = this.settingsService.getSettings().preferences.sessionDefaults const maxContinuations = @@ -131,6 +208,10 @@ export class SessionGoalService { return record ? toSessionGoal(record) : null } + hasActiveGoal(sessionId: string): boolean { + return getLatestSessionGoal(this.db, sessionId)?.status === 'active' + } + pause(id: number): SessionGoal { return this.transitionWithOutcome(id, ['active'], { status: 'paused', stopReason: 'user_paused' }) } @@ -226,6 +307,7 @@ export class SessionGoalService { } private scheduleAudit(sessionId: string, goalId: number, delay: number): void { + this.clearRecoveryTimer(sessionId) this.clearAuditTimer(sessionId) const timer = setTimeout(() => { this.auditTimers.delete(sessionId) @@ -539,6 +621,9 @@ export class SessionGoalService { this.goalBySession.delete(record.sessionId) this.clearAuditTimer(record.sessionId) } + if (record.status !== 'active') { + this.clearRecoveryTimer(record.sessionId) + } } private maybeInvalidateAudits(record: SessionGoalRecord, patch: SessionGoalPatch): void { diff --git a/backend/test/helpers/fake-session-goal-client.ts b/backend/test/helpers/fake-session-goal-client.ts index 1e8103057..7acbd4835 100644 --- a/backend/test/helpers/fake-session-goal-client.ts +++ b/backend/test/helpers/fake-session-goal-client.ts @@ -54,12 +54,14 @@ export interface FakeSessionGoalClient { setFailSessionGet(fail: boolean): void setFailSessionActive(fail: boolean): void setFailMessageList(fail: boolean): void + setSessionMissing(sessionId: string, missing: boolean): void } export function createFakeSessionGoalClient(options: FakeSessionGoalClientOptions = {}): FakeSessionGoalClient { const tokens = new Map(Object.entries(options.tokens ?? {})) const messages = new Map(Object.entries(options.messages ?? {})) const busySessions = new Set(options.busySessions ?? []) + const missingSessions = new Set() const promptCalls: FakeSessionGoalPromptCall[] = [] const auditorCalls: FakeSessionGoalAuditorCall[] = [] const pendingAuditorCalls: FakeSessionGoalAuditorDeferral[] = [] @@ -78,6 +80,9 @@ export function createFakeSessionGoalClient(options: FakeSessionGoalClientOption api: { session: { get: async ({ sessionID }: { sessionID: string }) => { + if (missingSessions.has(sessionID)) { + throw Object.assign(new Error('Session not found'), { _tag: 'SessionNotFoundError' }) + } if (sessionGetFails) { throw new Error('upstream unavailable') } @@ -197,6 +202,13 @@ export function createFakeSessionGoalClient(options: FakeSessionGoalClientOption setFailMessageList: (fail) => { messageListFails = fail }, + setSessionMissing: (sessionId, missing) => { + if (missing) { + missingSessions.add(sessionId) + } else { + missingSessions.delete(sessionId) + } + }, } } diff --git a/backend/test/services/notification-service.test.ts b/backend/test/services/notification-service.test.ts index 32a769d58..14f8bc894 100644 --- a/backend/test/services/notification-service.test.ts +++ b/backend/test/services/notification-service.test.ts @@ -5,9 +5,12 @@ import { allMigrations } from '../../src/db/migrations' import { createRepo } from '../../src/db/queries' import { createScheduleRun, updateScheduleRunMetadata } from '../../src/db/schedules' import { NotificationService } from '../../src/services/notification' +import { SessionGoalService } from '../../src/services/session-goals' import { SettingsService } from '../../src/services/settings' import { sseAggregator, type SSEEvent } from '../../src/services/sse-aggregator' import type { PushNotificationPayload } from '@opencode-manager/shared/types' +import type { SessionGoal } from '@opencode-manager/shared/schemas' +import { createFakeSessionGoalClient } from '../helpers/fake-session-goal-client' const DIRECTORY = '/abs/repo' const USER_ID = 'user-1' @@ -181,3 +184,154 @@ describe('NotificationService.handleSSEEvent session routing', () => { expect(send).toHaveBeenCalledTimes(1) }) }) + +describe('NotificationService goal outcomes', () => { + beforeEach(() => { + sseAggregator.shutdown() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + function sessionIdleEvent(sessionID: string): SSEEvent { + return { + id: `evt_idle_${sessionID}`, + created: 1700000000000, + type: 'session.idle', + location: { directory: DIRECTORY }, + data: { sessionID }, + } as SSEEvent + } + + const completedGoal: SessionGoal = { + id: 1, + sessionId: 'ses_goal', + directory: DIRECTORY, + objective: 'Ship the feature', + status: 'completed', + stopReason: null, + turnState: 'running', + continuationCount: 1, + maxContinuations: 5, + tokenBudget: null, + tokensUsed: 0, + consecutiveBlocked: 0, + lastVerdict: 'done', + lastReason: 'Objective achieved', + createdAt: 1, + updatedAt: 2, + finishedAt: 3, + } + + it('notifies a goal outcome when the preference is unset', async () => { + const service = createService() + const send = vi.spyOn(service, 'sendToUser').mockResolvedValue(sendResult) + + await service.notifyGoalOutcome(completedGoal) + + expect(send).toHaveBeenCalledTimes(1) + const payload = send.mock.calls[0]?.[1] as PushNotificationPayload + expect(payload.title).toBe('Goal completed') + expect(payload.body).toContain('Ship the feature') + expect(payload.body).toContain('Objective achieved') + expect(payload.tag).toBe('session-goal-1') + expect(payload.data?.eventType).toBe('session.goal.outcome') + expect(payload.data?.sessionId).toBe('ses_goal') + expect(payload.data?.url).toBe('/repos/1/sessions/ses_goal') + }) + + it('reports the stop reason instead of a stale verdict reason', async () => { + const service = createService() + const send = vi.spyOn(service, 'sendToUser').mockResolvedValue(sendResult) + + await service.notifyGoalOutcome({ + ...completedGoal, + id: 2, + status: 'stopped', + stopReason: 'token_budget', + lastVerdict: 'continue', + lastReason: 'Keep working on the tests', + }) + + const payload = send.mock.calls[0]?.[1] as PushNotificationPayload + expect(payload.title).toBe('Goal stopped') + expect(payload.body).toContain('Token budget reached') + expect(payload.body).not.toContain('Keep working on the tests') + }) + + it('states the pause cause when a paused goal has no verdict reason', async () => { + const service = createService() + const send = vi.spyOn(service, 'sendToUser').mockResolvedValue(sendResult) + + await service.notifyGoalOutcome({ + ...completedGoal, + id: 3, + status: 'paused', + stopReason: 'audit_failed', + lastVerdict: null, + lastReason: null, + }) + + const payload = send.mock.calls[0]?.[1] as PushNotificationPayload + expect(payload.title).toBe('Goal paused') + expect(payload.body).toContain('Audit failed') + }) + + it('keeps the reason visible for a long objective within the body limit', async () => { + const service = createService() + const send = vi.spyOn(service, 'sendToUser').mockResolvedValue(sendResult) + + await service.notifyGoalOutcome({ + ...completedGoal, + id: 4, + objective: 'Ship the entire feature '.repeat(20), + status: 'stopped', + stopReason: 'continuation_limit', + }) + + const payload = send.mock.calls[0]?.[1] as PushNotificationPayload + expect(payload.body.length).toBeLessThanOrEqual(140) + expect(payload.body).toContain('Continuation limit reached') + expect(payload.body).toContain('…') + }) + + it('respects a disabled goalOutcome preference', async () => { + const db = new Database(':memory:') + const service = createService(db) + new SettingsService(db).updateSettings( + { + notifications: { + enabled: true, + events: { permissionAsked: true, questionAsked: true, sessionError: true, sessionIdle: true, goalOutcome: false }, + }, + }, + USER_ID, + ) + const send = vi.spyOn(service, 'sendToUser').mockResolvedValue(sendResult) + + await service.notifyGoalOutcome(completedGoal) + + expect(send).not.toHaveBeenCalled() + }) + + it('suppresses session.idle while the session goal is active and notifies once it ends', async () => { + const db = new Database(':memory:') + const service = createService(db) + const fake = createFakeSessionGoalClient() + const goalService = new SessionGoalService(db, fake.client, new SettingsService(db), { quietMs: 0 }) + service.addEventSuppressor(async (event, sessionId) => { + if (event.type !== 'session.idle' || !sessionId) return false + return goalService.hasActiveGoal(sessionId) + }) + const started = await goalService.start({ sessionId: 'ses_goal', directory: DIRECTORY, objective: 'Ship it' }) + const send = vi.spyOn(service, 'sendToUser').mockResolvedValue(sendResult) + + await service.handleSSEEvent(DIRECTORY, sessionIdleEvent('ses_goal')) + expect(send).not.toHaveBeenCalled() + + goalService.pause(started.id) + await service.handleSSEEvent(DIRECTORY, sessionIdleEvent('ses_goal')) + expect(send).toHaveBeenCalledTimes(1) + }) +}) diff --git a/backend/test/services/session-goals.test.ts b/backend/test/services/session-goals.test.ts index 0331a2dd5..f312c5e75 100644 --- a/backend/test/services/session-goals.test.ts +++ b/backend/test/services/session-goals.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { Database } from 'bun:sqlite' import { migrate } from '../../src/db/migration-runner' import { allMigrations } from '../../src/db/migrations' -import { getSessionGoalById, listOpenSessionGoals } from '../../src/db/session-goals' +import { getSessionGoalById, listOpenSessionGoals, transitionSessionGoal } from '../../src/db/session-goals' import { SessionGoalService, type SessionGoalServiceOptions } from '../../src/services/session-goals' import { SettingsService } from '../../src/services/settings' import type { SSEEvent } from '../../src/services/sse-aggregator' @@ -895,3 +895,175 @@ describe('SessionGoalService audit loop', () => { expect(fake.promptCalls).toHaveLength(1) }) }) + +describe('SessionGoalService recovery', () => { + let db: Database + + beforeEach(() => { + vi.useFakeTimers() + db = createTestDb() + }) + + afterEach(() => { + vi.useRealTimers() + db.close() + }) + + it('resumes auditing a running goal on recovery and leaves a waiting goal alone', async () => { + const running = await createService(db).start({ sessionId: 'ses_run', directory: DIRECTORY, objective: 'Ship it' }) + const waiting = await createService(db).start({ sessionId: 'ses_wait', directory: DIRECTORY, objective: 'Later' }) + transitionSessionGoal(db, running.id, ['active'], { turnState: 'running' }) + + const fake = createFakeSessionGoalClient() + const restarted = new SessionGoalService(db, fake.client, new SettingsService(db), { quietMs: 0 }) + + await restarted.recoverOpenGoals() + await vi.runAllTimersAsync() + + expect(fake.auditorCalls).toHaveLength(1) + expect(fake.auditorCalls[0]?.prompt).toContain('Ship it') + expect(getSessionGoalById(db, waiting.id)?.turnState).toBe('waiting') + expect(fake.auditorCalls[0]?.prompt).not.toContain('Later') + }) + + it('leaves a running goal alone while the session is busy', async () => { + const running = await createService(db).start({ sessionId: 'ses_run', directory: DIRECTORY, objective: 'Ship it' }) + transitionSessionGoal(db, running.id, ['active'], { turnState: 'running' }) + + const fake = createFakeSessionGoalClient() + fake.setBusy('ses_run', true) + const restarted = new SessionGoalService(db, fake.client, new SettingsService(db), { quietMs: 0 }) + + await restarted.recoverOpenGoals() + await vi.runAllTimersAsync() + + expect(fake.auditorCalls).toHaveLength(0) + expect(getSessionGoalById(db, running.id)?.status).toBe('active') + }) + + it('stops a running goal when its session no longer exists', async () => { + const outcomes: string[] = [] + const running = await createService(db).start({ sessionId: 'ses_run', directory: DIRECTORY, objective: 'Ship it' }) + transitionSessionGoal(db, running.id, ['active'], { turnState: 'running' }) + + const fake = createFakeSessionGoalClient() + fake.setSessionMissing('ses_run', true) + const restarted = new SessionGoalService(db, fake.client, new SettingsService(db), { + quietMs: 0, + onOutcome: (goal) => outcomes.push(goal.status), + }) + + await restarted.recoverOpenGoals() + await vi.runAllTimersAsync() + + const latest = restarted.getLatest('ses_run') + expect(latest?.status).toBe('stopped') + expect(latest?.stopReason).toBe('session_deleted') + expect(fake.auditorCalls).toHaveLength(0) + expect(outcomes).toEqual(['stopped']) + }) + + it('reports whether a session has an active goal', async () => { + const service = createService(db) + const started = await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) + + expect(service.hasActiveGoal('ses_1')).toBe(true) + expect(service.hasActiveGoal('ses_missing')).toBe(false) + + service.pause(started.id) + + expect(service.hasActiveGoal('ses_1')).toBe(false) + }) + + it('retries recovery after a transient session lookup failure without session events', async () => { + const running = await createService(db).start({ sessionId: 'ses_run', directory: DIRECTORY, objective: 'Ship it' }) + transitionSessionGoal(db, running.id, ['active'], { turnState: 'running' }) + + const fake = createFakeSessionGoalClient({ failSessionGet: true }) + fake.setAuditorReplies(['{"verdict":"done","reason":"finished"}']) + const restarted = new SessionGoalService(db, fake.client, new SettingsService(db), { quietMs: 0 }) + + await restarted.recoverOpenGoals() + expect(fake.auditorCalls).toHaveLength(0) + expect(restarted.getLatest('ses_run')?.status).toBe('active') + + fake.setFailSessionGet(false) + await vi.runAllTimersAsync() + + expect(fake.auditorCalls).toHaveLength(1) + expect(restarted.getLatest('ses_run')?.status).toBe('completed') + }) + + it('retries recovery after a transient busy-check failure without session events', async () => { + const running = await createService(db).start({ sessionId: 'ses_run', directory: DIRECTORY, objective: 'Ship it' }) + transitionSessionGoal(db, running.id, ['active'], { turnState: 'running' }) + + const fake = createFakeSessionGoalClient() + fake.setAuditorReplies(['{"verdict":"done","reason":"finished"}']) + fake.setFailSessionActive(true) + const restarted = new SessionGoalService(db, fake.client, new SettingsService(db), { quietMs: 0 }) + + await restarted.recoverOpenGoals() + expect(fake.auditorCalls).toHaveLength(0) + + fake.setFailSessionActive(false) + await vi.runAllTimersAsync() + + expect(fake.auditorCalls).toHaveLength(1) + expect(restarted.getLatest('ses_run')?.status).toBe('completed') + }) + + it('stops with session_deleted when the session disappears before the recovery retry', async () => { + const running = await createService(db).start({ sessionId: 'ses_run', directory: DIRECTORY, objective: 'Ship it' }) + transitionSessionGoal(db, running.id, ['active'], { turnState: 'running' }) + + const fake = createFakeSessionGoalClient({ failSessionGet: true }) + const restarted = new SessionGoalService(db, fake.client, new SettingsService(db), { quietMs: 0 }) + + await restarted.recoverOpenGoals() + fake.setFailSessionGet(false) + fake.setSessionMissing('ses_run', true) + await vi.runAllTimersAsync() + + const latest = restarted.getLatest('ses_run') + expect(latest?.status).toBe('stopped') + expect(latest?.stopReason).toBe('session_deleted') + expect(fake.auditorCalls).toHaveLength(0) + }) + + it('does not audit a goal paused during the recovery retry delay', async () => { + const running = await createService(db).start({ sessionId: 'ses_run', directory: DIRECTORY, objective: 'Ship it' }) + transitionSessionGoal(db, running.id, ['active'], { turnState: 'running' }) + + const fake = createFakeSessionGoalClient({ failSessionGet: true }) + const restarted = new SessionGoalService(db, fake.client, new SettingsService(db), { quietMs: 0 }) + + await restarted.recoverOpenGoals() + restarted.pause(running.id) + + fake.setFailSessionGet(false) + await vi.runAllTimersAsync() + + expect(restarted.getLatest('ses_run')?.status).toBe('paused') + expect(fake.auditorCalls).toHaveLength(0) + }) + + it('does not audit a goal cancelled during the recovery retry delay', async () => { + const running = await createService(db).start({ sessionId: 'ses_run', directory: DIRECTORY, objective: 'Ship it' }) + transitionSessionGoal(db, running.id, ['active'], { turnState: 'running' }) + + const fake = createFakeSessionGoalClient({ failSessionGet: true }) + const restarted = new SessionGoalService(db, fake.client, new SettingsService(db), { quietMs: 0 }) + + await restarted.recoverOpenGoals() + restarted.cancel(running.id) + + fake.setFailSessionGet(false) + await vi.runAllTimersAsync() + + const latest = restarted.getLatest('ses_run') + expect(latest?.status).toBe('stopped') + expect(latest?.stopReason).toBe('cancelled') + expect(fake.auditorCalls).toHaveLength(0) + }) +}) diff --git a/shared/src/schemas/notifications.ts b/shared/src/schemas/notifications.ts index 2b5304cff..fcd21e710 100644 --- a/shared/src/schemas/notifications.ts +++ b/shared/src/schemas/notifications.ts @@ -17,6 +17,7 @@ export const NotificationPreferencesSchema = z.object({ questionAsked: z.boolean(), sessionError: z.boolean(), sessionIdle: z.boolean(), + goalOutcome: z.boolean().optional(), }), }); @@ -27,6 +28,7 @@ export const DEFAULT_NOTIFICATION_PREFERENCES = { questionAsked: true, sessionError: true, sessionIdle: false, + goalOutcome: true, }, }; From 8cd00e1a8ab0ab369fea3fd70c8a3056c2519baa Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:29:46 -0400 Subject: [PATCH 08/16] section 8: Phase 8: Goal mode in the composer, goal status bar and goal --- frontend/src/api/sessionGoals.ts | 39 +++ .../message/PromptInput.command.test.tsx | 5 + .../message/PromptInput.goal.test.tsx | 225 ++++++++++++++++++ .../message/PromptInput.mention.test.tsx | 5 + .../message/PromptInput.stt.test.tsx | 5 + .../src/components/message/PromptInput.tsx | 43 +++- .../session/SessionGoalBar.test.tsx | 128 ++++++++++ .../src/components/session/SessionGoalBar.tsx | 157 ++++++++++++ .../settings/NotificationSettings.test.tsx | 14 ++ .../settings/NotificationSettings.tsx | 18 ++ .../SessionAutomationSettings.test.tsx | 57 ++++- .../settings/SessionAutomationSettings.tsx | 93 +++++++- frontend/src/hooks/useSessionGoals.ts | 57 +++++ frontend/src/pages/SessionDetail.tsx | 2 + .../SessionDetail.assistant-loading.test.tsx | 8 + .../__tests__/SessionDetail.commands.test.tsx | 8 + .../SessionDetail.form-prompt.test.tsx | 8 + .../__tests__/SessionDetail.polling.test.tsx | 8 + .../SessionDetail.scroll-floating.test.tsx | 8 + 19 files changed, 877 insertions(+), 11 deletions(-) create mode 100644 frontend/src/api/sessionGoals.ts create mode 100644 frontend/src/components/message/PromptInput.goal.test.tsx create mode 100644 frontend/src/components/session/SessionGoalBar.test.tsx create mode 100644 frontend/src/components/session/SessionGoalBar.tsx create mode 100644 frontend/src/hooks/useSessionGoals.ts diff --git a/frontend/src/api/sessionGoals.ts b/frontend/src/api/sessionGoals.ts new file mode 100644 index 000000000..e0d402a52 --- /dev/null +++ b/frontend/src/api/sessionGoals.ts @@ -0,0 +1,39 @@ +import { fetchWrapper } from './fetchWrapper' +import { API_BASE_URL } from '@/config' +import type { SessionGoal, StartSessionGoalRequest } from '@opencode-manager/shared/schemas' + +export async function getLatestSessionGoal(sessionId: string): Promise { + const res = await fetchWrapper<{ goal: SessionGoal | null }>(`${API_BASE_URL}/api/session-goals`, { + params: { sessionId }, + }) + return res.goal +} + +export async function startSessionGoal(input: StartSessionGoalRequest): Promise { + const res = await fetchWrapper<{ goal: SessionGoal }>(`${API_BASE_URL}/api/session-goals`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(input), + }) + return res.goal +} + +async function runGoalAction(id: number, action: 'pause' | 'resume' | 'cancel'): Promise { + const res = await fetchWrapper<{ goal: SessionGoal }>( + `${API_BASE_URL}/api/session-goals/${encodeURIComponent(id)}/${action}`, + { method: 'POST' }, + ) + return res.goal +} + +export function pauseSessionGoal(id: number): Promise { + return runGoalAction(id, 'pause') +} + +export function resumeSessionGoal(id: number): Promise { + return runGoalAction(id, 'resume') +} + +export function cancelSessionGoal(id: number): Promise { + return runGoalAction(id, 'cancel') +} diff --git a/frontend/src/components/message/PromptInput.command.test.tsx b/frontend/src/components/message/PromptInput.command.test.tsx index ddbb1ee59..40bd405ae 100644 --- a/frontend/src/components/message/PromptInput.command.test.tsx +++ b/frontend/src/components/message/PromptInput.command.test.tsx @@ -86,6 +86,11 @@ vi.mock('@/components/session/PermissionModeToggle', () => ({ PermissionModeToggle: () => null, })) +vi.mock('@/hooks/useSessionGoals', () => ({ + useSessionGoal: () => ({ data: undefined }), + useStartSessionGoal: () => ({ mutateAsync: vi.fn(), isPending: false }), +})) + vi.mock('@/components/ui/session-status-indicator', () => ({ SessionStatusIndicator: () =>
SessionStatus
, })) diff --git a/frontend/src/components/message/PromptInput.goal.test.tsx b/frontend/src/components/message/PromptInput.goal.test.tsx new file mode 100644 index 000000000..2a4917e1b --- /dev/null +++ b/frontend/src/components/message/PromptInput.goal.test.tsx @@ -0,0 +1,225 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { PromptInput } from './PromptInput' +import { useUIState } from '@/stores/uiStateStore' +import { createCommandActionsMock, stubMatchMedia } from '@/test/test-utils' + +const mocks = vi.hoisted(() => ({ + sendPrompt: vi.fn(), + sendShell: vi.fn(), + interrupt: vi.fn(), + startGoal: vi.fn(), + useSessionGoal: vi.fn(), + agents: [] as Array<{ id: string; name: string; description?: string; mode?: string; hidden?: boolean }>, + setAgent: vi.fn(), + cycleVariant: vi.fn(), + showToast: { + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warning: vi.fn(), + loading: vi.fn(), + promise: vi.fn(), + dismiss: vi.fn(), + }, + useSTT: vi.fn(), + useMobile: vi.fn(), + useCommands: vi.fn(), + useFileSearch: vi.fn(), + useModelSelection: vi.fn(), + useVariants: vi.fn(), + useSessionAgent: vi.fn(), + useUserBash: vi.fn(), + useSessionAgentStore: vi.fn(), + useSendErrorStore: vi.fn(), +})) + +vi.mock('@/hooks/useOpenCode', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useSendPrompt: () => ({ mutate: mocks.sendPrompt, isPending: false }), + useSendShell: () => ({ mutate: mocks.sendShell, isPending: false }), + useInterruptSession: () => ({ mutate: mocks.interrupt }), + useAgents: () => ({ data: mocks.agents }), + } +}) + +vi.mock('@/hooks/useSessionGoals', () => ({ + useSessionGoal: mocks.useSessionGoal, + useStartSessionGoal: () => ({ mutateAsync: mocks.startGoal, isPending: false }), +})) + +vi.mock('@/hooks/useSTT', () => ({ useSTT: mocks.useSTT })) +vi.mock('@/hooks/useMobile', () => ({ useMobile: mocks.useMobile })) +vi.mock('@/hooks/useCommands', () => ({ useCommands: mocks.useCommands })) +vi.mock('@/hooks/useFileSearch', () => ({ useFileSearch: mocks.useFileSearch })) +vi.mock('@/hooks/useModelSelection', () => ({ useModelSelection: mocks.useModelSelection })) +vi.mock('@/hooks/useVariants', () => ({ useVariants: mocks.useVariants })) +vi.mock('@/hooks/useSessionAgent', () => ({ useSessionAgent: mocks.useSessionAgent })) +vi.mock('@/stores/userBashStore', () => ({ useUserBash: mocks.useUserBash })) +vi.mock('@/stores/sessionAgentStore', () => ({ useSessionAgentStore: mocks.useSessionAgentStore })) +vi.mock('@/stores/sendErrorStore', () => ({ useSendErrorStore: mocks.useSendErrorStore })) +vi.mock('@/lib/toast', () => ({ showToast: mocks.showToast })) + +vi.mock('@/contexts/EventContext', () => ({ + usePermissions: () => ({ + hasForSession: vi.fn().mockReturnValue(false), + setShowDialog: vi.fn(), + }), +})) + +vi.mock('@/components/agent/AgentQuickSelect', () => ({ + AgentQuickSelect: ({ children }: { children: React.ReactNode }) =>
{children}
, +})) + +vi.mock('@/components/model/ModelQuickSelect', () => ({ + ModelQuickSelect: ({ children, open }: { children?: React.ReactNode; open?: boolean }) => ( +
{children}
+ ), +})) + +vi.mock('@/components/session/PermissionModeToggle', () => ({ + PermissionModeToggle: () => null, +})) + +vi.mock('@/components/ui/session-status-indicator', () => ({ + SessionStatusIndicator: () =>
SessionStatus
, +})) + +vi.mock('@/components/command/CommandSuggestions', () => ({ + CommandSuggestions: () =>
CommandSuggestions
, +})) + +vi.mock('./MentionSuggestions', () => ({ + MentionSuggestions: () =>
MentionSuggestions
, +})) + +const createTestQueryClient = () => new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, +}) + +const GOAL_BUTTON = 'Goal mode: the next message becomes the objective' +const OPEN_GOAL_BUTTON = 'A goal is already active for this session' + +describe('PromptInput goal mode', () => { + const defaultProps = { + directory: '/test', + sessionID: 'test-session', + showScrollButton: false, + isSessionActive: false, + isStreamingResponse: false, + onScrollToBottom: vi.fn(), + commandActions: createCommandActionsMock(), + onPromptChange: vi.fn(), + } + + const renderComponent = () => { + const queryClient = createTestQueryClient() + return render( + + + + ) + } + + beforeEach(() => { + vi.clearAllMocks() + mocks.useSessionGoal.mockReturnValue({ data: null }) + mocks.startGoal.mockResolvedValue({ id: 1 }) + mocks.agents = [] + mocks.useMobile.mockReturnValue(false) + mocks.useSTT.mockReturnValue({ + isRecording: false, + isProcessing: false, + isSupported: false, + isEnabled: false, + interimTranscript: '', + transcript: '', + startRecording: vi.fn(), + stopRecording: vi.fn(), + abortRecording: vi.fn(), + clear: vi.fn(), + }) + mocks.useCommands.mockReturnValue({ filterCommands: () => [] }) + mocks.useFileSearch.mockReturnValue({ files: [] }) + mocks.useModelSelection.mockReturnValue({ + model: { providerID: 'anthropic', modelID: 'claude-sonnet-4' }, + modelString: 'anthropic/claude-sonnet-4', + setModel: vi.fn(), + setActiveModel: vi.fn(), + recentModels: [], + favoriteModels: [], + toggleFavorite: vi.fn(), + isModelStateLoading: false, + }) + mocks.useVariants.mockReturnValue({ hasVariants: false, currentVariant: null, cycleVariant: mocks.cycleVariant }) + mocks.useSessionAgent.mockReturnValue({ agent: 'build' }) + mocks.useUserBash.mockImplementation((selector: (state: unknown) => unknown) => selector({ addUserBashCommand: vi.fn() })) + mocks.useSessionAgentStore.mockImplementation((selector: (state: unknown) => unknown) => selector({ setAgent: mocks.setAgent })) + mocks.useSendErrorStore.mockImplementation((selector: (state: unknown) => unknown) => selector({ errors: {} })) + useUIState.getState().clearPendingPromptCommand() + useUIState.getState().clearPendingPromptFile() + }) + + afterEach(() => { + Reflect.deleteProperty(window, 'matchMedia') + }) + + it('starts a goal with the message objective before sending it', async () => { + stubMatchMedia(true) + renderComponent() + + const input = await screen.findByPlaceholderText('Send a message...') + fireEvent.change(input, { target: { value: 'Ship the feature' } }) + fireEvent.click(screen.getByRole('button', { name: GOAL_BUTTON })) + fireEvent.click(screen.getByTitle('Send')) + + await waitFor(() => expect(mocks.startGoal).toHaveBeenCalledWith({ + sessionId: 'test-session', + directory: '/test', + objective: 'Ship the feature', + })) + await waitFor(() => expect(mocks.sendPrompt).toHaveBeenCalled()) + expect(mocks.startGoal.mock.invocationCallOrder[0]).toBeLessThan(mocks.sendPrompt.mock.invocationCallOrder[0]) + }) + + it('does not send the message when starting the goal is rejected', async () => { + stubMatchMedia(true) + mocks.startGoal.mockRejectedValue(new Error('This session already has an open goal')) + renderComponent() + + const input = await screen.findByPlaceholderText('Send a message...') + fireEvent.change(input, { target: { value: 'Ship the feature' } }) + fireEvent.click(screen.getByRole('button', { name: GOAL_BUTTON })) + fireEvent.click(screen.getByTitle('Send')) + + await waitFor(() => expect(mocks.startGoal).toHaveBeenCalled()) + expect(mocks.sendPrompt).not.toHaveBeenCalled() + }) + + it('does not start a goal when the mode was not armed', async () => { + stubMatchMedia(true) + renderComponent() + + const input = await screen.findByPlaceholderText('Send a message...') + fireEvent.change(input, { target: { value: 'Just a message' } }) + fireEvent.click(screen.getByTitle('Send')) + + await waitFor(() => expect(mocks.sendPrompt).toHaveBeenCalled()) + expect(mocks.startGoal).not.toHaveBeenCalled() + }) + + it('disables goal mode while a goal is already active', async () => { + stubMatchMedia(true) + mocks.useSessionGoal.mockReturnValue({ data: { status: 'active' } }) + renderComponent() + + const button = await screen.findByRole('button', { name: OPEN_GOAL_BUTTON }) + expect(button).toBeDisabled() + }) +}) diff --git a/frontend/src/components/message/PromptInput.mention.test.tsx b/frontend/src/components/message/PromptInput.mention.test.tsx index 764199732..444e9b5ac 100644 --- a/frontend/src/components/message/PromptInput.mention.test.tsx +++ b/frontend/src/components/message/PromptInput.mention.test.tsx @@ -77,6 +77,11 @@ vi.mock('@/components/session/PermissionModeToggle', () => ({ PermissionModeToggle: () => null, })) +vi.mock('@/hooks/useSessionGoals', () => ({ + useSessionGoal: () => ({ data: undefined }), + useStartSessionGoal: () => ({ mutateAsync: vi.fn(), isPending: false }), +})) + vi.mock('@/components/ui/session-status-indicator', () => ({ SessionStatusIndicator: () =>
SessionStatus
, })) diff --git a/frontend/src/components/message/PromptInput.stt.test.tsx b/frontend/src/components/message/PromptInput.stt.test.tsx index 555d658ea..1badd860d 100644 --- a/frontend/src/components/message/PromptInput.stt.test.tsx +++ b/frontend/src/components/message/PromptInput.stt.test.tsx @@ -104,6 +104,11 @@ vi.mock('@/components/session/PermissionModeToggle', () => ({ PermissionModeToggle: () => null, })) +vi.mock('@/hooks/useSessionGoals', () => ({ + useSessionGoal: () => ({ data: undefined }), + useStartSessionGoal: () => ({ mutateAsync: vi.fn(), isPending: false }), +})) + vi.mock('@/components/ui/session-status-indicator', () => ({ SessionStatusIndicator: () =>
SessionStatus
, })) diff --git a/frontend/src/components/message/PromptInput.tsx b/frontend/src/components/message/PromptInput.tsx index bf734a905..5b21fd6d2 100644 --- a/frontend/src/components/message/PromptInput.tsx +++ b/frontend/src/components/message/PromptInput.tsx @@ -17,7 +17,7 @@ import { useMobile } from '@/hooks/useMobile' import { FINE_POINTER_MEDIA_QUERY, useMediaQuery } from '@/hooks/useMediaQuery' import { usePermissions } from '@/contexts/EventContext' -import { ArrowDown, Upload, X, Mic, MicOff } from 'lucide-react' +import { ArrowDown, Upload, X, Mic, MicOff, Target } from 'lucide-react' import { SquareFill } from '@/components/ui/square-fill' @@ -28,6 +28,7 @@ import { ModelQuickSelect } from '@/components/model/ModelQuickSelect' import { AgentQuickSelect } from '@/components/agent/AgentQuickSelect' import { VoiceStatusOverlay, type VoiceStatusOverlayState } from './VoiceStatusOverlay' import { PermissionModeToggle } from '@/components/session/PermissionModeToggle' +import { useSessionGoal, useStartSessionGoal } from '@/hooks/useSessionGoals' import { detectMentionTrigger, parsePromptToInput, getFilename, filterAgentsByQuery } from '@/lib/promptParser' import { getNextPrimaryAgentId } from '@/lib/primaryAgents' import { randomId } from '@/lib/utils' @@ -93,6 +94,7 @@ export const PromptInput = memo(forwardRef( }, ref) { const [prompt, setPrompt] = useState('') const [isBashMode, setIsBashMode] = useState(false) + const [isGoalArmed, setIsGoalArmed] = useState(false) const [showSuggestions, setShowSuggestions] = useState(false) const [suggestionQuery, setSuggestionQuery] = useState('') const [attachedFiles, setAttachedFiles] = useState(new Map()) @@ -229,6 +231,8 @@ export const PromptInput = memo(forwardRef( const sendShell = useSendShell(directory) const isPromptSubmitPending = sendPrompt.isPending || sendShell.isPending const interruptSession = useInterruptSession() + const { data: sessionGoal } = useSessionGoal(sessionID) + const startGoal = useStartSessionGoal() const { filterCommands } = useCommands({ directory }) const isExactCommandPrompt = (value: string) => { const commandPrompt = parseCommandPrompt(value) @@ -291,7 +295,7 @@ export const PromptInput = memo(forwardRef( const addUserBashCommand = useUserBash((s) => s.addUserBashCommand) - const handleSubmit = () => { + const handleSubmit = async () => { if (!prompt.trim() && imageAttachments.length === 0) return pendingVoiceAutoSubmitRef.current = false @@ -385,6 +389,15 @@ export const PromptInput = memo(forwardRef( const submittedAttachedFiles = attachedFiles const submittedImageAttachments = imageAttachments + if (isGoalArmed && directory && parsed.text.trim()) { + try { + await startGoal.mutateAsync({ sessionId: sessionID, directory, objective: parsed.text }) + } catch { + return + } + setIsGoalArmed(false) + } + pendingConfirmClearRef.current = { prompt: submittedPrompt, files: submittedAttachedFiles, @@ -1148,6 +1161,29 @@ if (isIOS && isSecureContext && navigator.clipboard && navigator.clipboard.read) const showStopButton = isSessionActive const hideSecondaryButtons = isMobile && isSessionActive const showMobileScrollButton = isMobile && showScrollButton + const hasOpenGoal = sessionGoal?.status === 'active' || sessionGoal?.status === 'paused' + const goalButtonLabel = hasOpenGoal + ? 'A goal is already active for this session' + : isGoalArmed + ? 'Goal mode armed: the next message becomes the objective' + : 'Goal mode: the next message becomes the objective' + const goalModeButton = directory ? ( + + ) : null const voiceFeedbackState: VoiceStatusOverlayState | null = isTogglingRecording ? 'starting' : isProcessing @@ -1261,6 +1297,7 @@ if (isIOS && isSecureContext && navigator.clipboard && navigator.clipboard.read) lastAddedTranscriptRef.current = '' setIsTogglingRecording(false) setLocalMode(null) + setIsGoalArmed(false) // eslint-disable-next-line react-hooks/exhaustive-deps -- Intentionally only run on sessionID change to avoid clearing transcript when recording state changes }, [sessionID]) @@ -1347,6 +1384,7 @@ return ( onOpenChange={setIsModelPickerOpen} /> {directory && } + {goalModeButton} ) : ( <> @@ -1357,6 +1395,7 @@ return ( isBashMode={isBashMode} /> {directory && } + {goalModeButton} {isSessionActive && (
diff --git a/frontend/src/components/session/SessionGoalBar.test.tsx b/frontend/src/components/session/SessionGoalBar.test.tsx new file mode 100644 index 000000000..a8339d283 --- /dev/null +++ b/frontend/src/components/session/SessionGoalBar.test.tsx @@ -0,0 +1,128 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { SessionGoalBar } from './SessionGoalBar' +import { + useCancelSessionGoal, + usePauseSessionGoal, + useResumeSessionGoal, + useSessionGoal, +} from '@/hooks/useSessionGoals' +import type { SessionGoal } from '@opencode-manager/shared/schemas' + +vi.mock('@/hooks/useSessionGoals') + +const baseGoal: SessionGoal = { + id: 1, + sessionId: 'ses_1', + directory: '/repo', + objective: 'Ship the feature', + status: 'active', + stopReason: null, + turnState: 'running', + continuationCount: 2, + maxContinuations: 20, + tokenBudget: null, + tokensUsed: 0, + consecutiveBlocked: 0, + lastVerdict: null, + lastReason: null, + createdAt: 1, + updatedAt: 1, + finishedAt: null, +} + +function mockGoals( + goal: SessionGoal | null, + overrides: { + pause?: ReturnType + resume?: ReturnType + cancel?: ReturnType + } = {}, +) { + vi.mocked(useSessionGoal).mockReturnValue({ data: goal } as ReturnType) + vi.mocked(usePauseSessionGoal).mockReturnValue({ + mutate: overrides.pause ?? vi.fn(), + isPending: false, + } as unknown as ReturnType) + vi.mocked(useResumeSessionGoal).mockReturnValue({ + mutate: overrides.resume ?? vi.fn(), + isPending: false, + } as unknown as ReturnType) + vi.mocked(useCancelSessionGoal).mockReturnValue({ + mutate: overrides.cancel ?? vi.fn(), + isPending: false, + } as unknown as ReturnType) +} + +describe('SessionGoalBar', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders nothing when there is no goal', () => { + mockGoals(null) + const { container } = render() + expect(container).toBeEmptyDOMElement() + }) + + it('shows the turn counter and pauses the active goal', async () => { + const user = userEvent.setup() + const pause = vi.fn() + mockGoals({ ...baseGoal }, { pause }) + render() + + expect(screen.getByText('Goal active')).toBeInTheDocument() + expect(screen.getByText('Turn 2/20')).toBeInTheDocument() + expect(screen.getByText('Ship the feature')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Pause' })) + + expect(pause).toHaveBeenCalledWith(1) + }) + + it('shows token usage when a budget is set', () => { + mockGoals({ ...baseGoal, tokenBudget: 1000, tokensUsed: 250 }) + render() + + expect(screen.getByText('250/1,000 tokens')).toBeInTheDocument() + }) + + it('resumes a paused goal', async () => { + const user = userEvent.setup() + const resume = vi.fn() + mockGoals({ ...baseGoal, status: 'paused' }, { resume }) + render() + + expect(screen.getByText('Goal paused')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Resume' })) + + expect(resume).toHaveBeenCalledWith(1) + }) + + it('shows a terminal summary and can be dismissed', async () => { + const user = userEvent.setup() + mockGoals({ ...baseGoal, status: 'completed', stopReason: 'continuation_limit' }) + render() + + expect(screen.getByText('Goal completed')).toBeInTheDocument() + expect(screen.getByText('continuation limit reached')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Pause' })).not.toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Dismiss' })) + + expect(screen.queryByText('Goal completed')).not.toBeInTheDocument() + }) + + it('cancels the goal through the cancel API', async () => { + const user = userEvent.setup() + const cancel = vi.fn() + mockGoals({ ...baseGoal }, { cancel }) + render() + + await user.click(screen.getByRole('button', { name: 'Cancel' })) + + expect(cancel).toHaveBeenCalledWith(1) + }) +}) diff --git a/frontend/src/components/session/SessionGoalBar.tsx b/frontend/src/components/session/SessionGoalBar.tsx new file mode 100644 index 000000000..a9dd91949 --- /dev/null +++ b/frontend/src/components/session/SessionGoalBar.tsx @@ -0,0 +1,157 @@ +import { useState } from 'react' +import { ChevronDown, ChevronUp, Target } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { + useCancelSessionGoal, + usePauseSessionGoal, + useResumeSessionGoal, + useSessionGoal, +} from '@/hooks/useSessionGoals' +import type { SessionGoalStatus, SessionGoalStopReason } from '@opencode-manager/shared/schemas' + +const STATUS_LABELS: Record = { + active: 'Goal active', + paused: 'Goal paused', + completed: 'Goal completed', + blocked: 'Goal blocked', + stopped: 'Goal stopped', +} + +const STOP_REASON_LABELS: Record = { + cancelled: 'cancelled', + user_paused: 'paused by you', + continuation_limit: 'continuation limit reached', + token_budget: 'token budget reached', + turn_error: 'turn failed', + interrupted: 'interrupted', + audit_failed: 'auditor failed', + session_deleted: 'session deleted', +} + +const STATUS_CHIP_CLASSES: Record = { + active: 'bg-highlight text-highlight-foreground border-highlight', + paused: 'bg-warning/15 text-warning border-warning/40', + completed: 'bg-success/15 text-success border-success/40', + blocked: 'bg-destructive/15 text-destructive border-destructive/40', + stopped: 'bg-muted text-muted-foreground border-border', +} + +const TERMINAL_STATUSES: SessionGoalStatus[] = ['completed', 'blocked', 'stopped'] + +function isTerminal(status: SessionGoalStatus): boolean { + return TERMINAL_STATUSES.includes(status) +} + +export function SessionGoalBar({ sessionID }: { sessionID: string }) { + const { data: goal } = useSessionGoal(sessionID) + const pauseGoal = usePauseSessionGoal() + const resumeGoal = useResumeSessionGoal() + const cancelGoal = useCancelSessionGoal() + const [expanded, setExpanded] = useState(false) + const [dismissedGoalId, setDismissedGoalId] = useState(null) + + if (!goal) return null + if (isTerminal(goal.status) && dismissedGoalId === goal.id) return null + + const terminal = isTerminal(goal.status) + const reason = goal.stopReason ? STOP_REASON_LABELS[goal.stopReason] : goal.lastReason + + return ( +
+
+ + + {STATUS_LABELS[goal.status]} + + + {!terminal && ( + + Turn {goal.continuationCount}/{goal.maxContinuations} + + )} + + {!terminal && goal.tokenBudget !== null && ( + + {goal.tokensUsed.toLocaleString()}/{goal.tokenBudget.toLocaleString()} tokens + + )} + + {terminal ? ( + <> + {reason && ( + + {reason} + + )} + + + ) : ( +
+ {goal.status === 'paused' ? ( + + ) : ( + + )} + +
+ )} +
+ + {!terminal && ( +
+

+ {goal.objective} +

+ +
+ )} + + {!terminal && goal.lastReason && ( +

+ {goal.lastReason} +

+ )} +
+ ) +} diff --git a/frontend/src/components/settings/NotificationSettings.test.tsx b/frontend/src/components/settings/NotificationSettings.test.tsx index 26b42314a..420330bbe 100644 --- a/frontend/src/components/settings/NotificationSettings.test.tsx +++ b/frontend/src/components/settings/NotificationSettings.test.tsx @@ -170,4 +170,18 @@ describe('NotificationSettings', () => { render() expect(screen.getByText(/not supported in this browser/i)).toBeInTheDocument() }) + + it('writes the goal outcome preference and defaults it on', async () => { + const user = userEvent.setup() + const updateEventPreference = vi.fn() + mockNotifications({ updateEventPreference }) + render() + + const toggle = screen.getByRole('switch', { name: 'Goal outcomes' }) + expect(toggle).toBeChecked() + + await user.click(toggle) + + expect(updateEventPreference).toHaveBeenCalledWith('goalOutcome', false) + }) }) diff --git a/frontend/src/components/settings/NotificationSettings.tsx b/frontend/src/components/settings/NotificationSettings.tsx index 479140d91..ab876109d 100644 --- a/frontend/src/components/settings/NotificationSettings.tsx +++ b/frontend/src/components/settings/NotificationSettings.tsx @@ -213,6 +213,24 @@ export function NotificationSettings() { } />
+ +
+
+ +

+ When a session goal completes, blocks or stops +

+
+ + updateEventPreference("goalOutcome", checked) + } + /> +
)} diff --git a/frontend/src/components/settings/SessionAutomationSettings.test.tsx b/frontend/src/components/settings/SessionAutomationSettings.test.tsx index 36296dd91..12f2be9a6 100644 --- a/frontend/src/components/settings/SessionAutomationSettings.test.tsx +++ b/frontend/src/components/settings/SessionAutomationSettings.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest' -import { render, screen } from '@testing-library/react' +import { render, screen, fireEvent } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { SessionAutomationSettings } from './SessionAutomationSettings' import { useSettings } from '@/hooks/useSettings' @@ -57,4 +57,59 @@ describe('SessionAutomationSettings', () => { expect(updateSettings).toHaveBeenCalledWith({ sessionDefaults: { permissionMode: 'auto' } }) }) + + it('persists the goal auditor model', () => { + const updateSettings = vi.fn() + mockUseSettings({ + preferences: { ...basePreferences, sessionDefaults: { permissionMode: 'ask', goalMaxContinuations: 20 } }, + updateSettings, + }) + render() + + fireEvent.change(screen.getByLabelText('Goal auditor model'), { + target: { value: 'anthropic/claude-sonnet-4' }, + }) + + expect(updateSettings).toHaveBeenCalledWith({ + sessionDefaults: { + permissionMode: 'ask', + goalMaxContinuations: 20, + goalAuditorModel: 'anthropic/claude-sonnet-4', + }, + }) + }) + + it('persists the max automatic continuations', () => { + const updateSettings = vi.fn() + mockUseSettings({ + preferences: { ...basePreferences, sessionDefaults: { permissionMode: 'ask', goalMaxContinuations: 20 } }, + updateSettings, + }) + render() + + fireEvent.change(screen.getByLabelText('Max automatic continuations'), { + target: { value: '50' }, + }) + + expect(updateSettings).toHaveBeenCalledWith({ + sessionDefaults: { permissionMode: 'ask', goalMaxContinuations: 50 }, + }) + }) + + it('persists the token budget per goal', () => { + const updateSettings = vi.fn() + mockUseSettings({ + preferences: { ...basePreferences, sessionDefaults: { permissionMode: 'ask', goalMaxContinuations: 20 } }, + updateSettings, + }) + render() + + fireEvent.change(screen.getByLabelText('Token budget per goal'), { + target: { value: '5000' }, + }) + + expect(updateSettings).toHaveBeenCalledWith({ + sessionDefaults: { permissionMode: 'ask', goalMaxContinuations: 20, goalTokenBudget: 5000 }, + }) + }) }) diff --git a/frontend/src/components/settings/SessionAutomationSettings.tsx b/frontend/src/components/settings/SessionAutomationSettings.tsx index fae9986df..c00f016f1 100644 --- a/frontend/src/components/settings/SessionAutomationSettings.tsx +++ b/frontend/src/components/settings/SessionAutomationSettings.tsx @@ -1,11 +1,26 @@ import { useSettings } from '@/hooks/useSettings' import { Label } from '@/components/ui/label' +import { Input } from '@/components/ui/input' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' -import type { SessionPermissionMode } from '@opencode-manager/shared/schemas' +import type { SessionDefaults, SessionPermissionMode } from '@opencode-manager/shared/schemas' export function SessionAutomationSettings() { const { preferences, updateSettings } = useSettings() - const permissionMode = preferences?.sessionDefaults?.permissionMode ?? 'ask' + const sessionDefaults = preferences?.sessionDefaults + const permissionMode = sessionDefaults?.permissionMode ?? 'ask' + const goalMaxContinuations = sessionDefaults?.goalMaxContinuations ?? 20 + const goalTokenBudget = sessionDefaults?.goalTokenBudget + const goalAuditorModel = sessionDefaults?.goalAuditorModel ?? '' + + const updateSessionDefaults = (patch: Partial) => { + updateSettings({ + sessionDefaults: { + ...sessionDefaults, + permissionMode, + ...patch, + }, + }) + } return (
@@ -20,12 +35,7 @@ export function SessionAutomationSettings() {
+ +
+
+ +

+ Model that decides whether a session goal is done, as provider/model. Leave empty to use the OpenCode default model. +

+
+ updateSessionDefaults({ goalAuditorModel: event.target.value.trim() || undefined })} + /> +
+ +
+
+ +

+ How many times a goal may keep the agent working after each audit before it stops. +

+
+ { + const value = Number(event.target.value) + if (Number.isInteger(value) && value >= 1 && value <= 200) { + updateSessionDefaults({ goalMaxContinuations: value }) + } + }} + /> +
+ +
+
+ +

+ Stop a goal once it has spent this many tokens. Leave empty for no limit. +

+
+ { + const raw = event.target.value + if (raw === '') { + updateSessionDefaults({ goalTokenBudget: undefined }) + return + } + const value = Number(raw) + if (Number.isInteger(value) && value > 0) { + updateSessionDefaults({ goalTokenBudget: value }) + } + }} + /> +
) } diff --git a/frontend/src/hooks/useSessionGoals.ts b/frontend/src/hooks/useSessionGoals.ts new file mode 100644 index 000000000..b9c32da2c --- /dev/null +++ b/frontend/src/hooks/useSessionGoals.ts @@ -0,0 +1,57 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { + cancelSessionGoal, + getLatestSessionGoal, + pauseSessionGoal, + resumeSessionGoal, + startSessionGoal, +} from '@/api/sessionGoals' +import { showToast } from '@/lib/toast' +import type { SessionGoal, StartSessionGoalRequest } from '@opencode-manager/shared/schemas' + +export function sessionGoalQueryKey(sessionId: string) { + return ['session-goal', sessionId] as const +} + +export function useSessionGoal(sessionId: string) { + return useQuery({ + queryKey: sessionGoalQueryKey(sessionId), + queryFn: () => getLatestSessionGoal(sessionId), + refetchInterval: (query) => { + const status = query.state.data?.status + return status === 'active' || status === 'paused' ? 3000 : false + }, + }) +} + +function useGoalMutation( + mutationFn: (input: TInput) => Promise, + errorMessage: string, +) { + const queryClient = useQueryClient() + return useMutation({ + mutationFn, + onSuccess: (goal: SessionGoal) => { + queryClient.setQueryData(sessionGoalQueryKey(goal.sessionId), goal) + }, + onError: (error: unknown) => { + showToast.error(error instanceof Error ? error.message : errorMessage) + }, + }) +} + +export function useStartSessionGoal() { + return useGoalMutation((input: StartSessionGoalRequest) => startSessionGoal(input), 'Failed to start goal') +} + +export function usePauseSessionGoal() { + return useGoalMutation(pauseSessionGoal, 'Failed to pause goal') +} + +export function useResumeSessionGoal() { + return useGoalMutation(resumeSessionGoal, 'Failed to resume goal') +} + +export function useCancelSessionGoal() { + return useGoalMutation(cancelSessionGoal, 'Failed to cancel goal') +} diff --git a/frontend/src/pages/SessionDetail.tsx b/frontend/src/pages/SessionDetail.tsx index e130c60fc..a1ed0eb05 100644 --- a/frontend/src/pages/SessionDetail.tsx +++ b/frontend/src/pages/SessionDetail.tsx @@ -52,6 +52,7 @@ import { PendingActionsGroup } from "@/components/notifications/PendingActionsGr import { SourceControlPanel } from "@/components/source-control"; import { SessionSendErrorBanner } from "@/components/session/SessionSendErrorBanner"; import { BackgroundWorkBar } from "@/components/session/BackgroundWorkBar"; +import { SessionGoalBar } from "@/components/session/SessionGoalBar"; import { useDialogParam } from "@/hooks/useDialogParam"; import { SessionMoreButton } from "@/components/navigation/SessionMoreButton"; import { SideQuestionDialog } from "@/components/session/SideQuestionDialog"; @@ -759,6 +760,7 @@ export function SessionDetail() { /> )} + ({ })) vi.mock('@/components/session/BackgroundWorkBar', () => ({ BackgroundWorkBar: vi.fn(() => null) })) +vi.mock('@/components/session/SessionGoalBar', () => ({ SessionGoalBar: vi.fn(() => null) })) +vi.mock('@/hooks/useSessionGoals', () => ({ + useSessionGoal: vi.fn(() => ({ data: undefined })), + useStartSessionGoal: vi.fn(() => ({ mutateAsync: vi.fn(), isPending: false })), + usePauseSessionGoal: vi.fn(() => ({ mutate: vi.fn(), isPending: false })), + useResumeSessionGoal: vi.fn(() => ({ mutate: vi.fn(), isPending: false })), + useCancelSessionGoal: vi.fn(() => ({ mutate: vi.fn(), isPending: false })), +})) vi.mock('@/components/session/SessionList', () => ({ SessionList: vi.fn(() => null), })) diff --git a/frontend/src/pages/__tests__/SessionDetail.commands.test.tsx b/frontend/src/pages/__tests__/SessionDetail.commands.test.tsx index fba20fc52..fc4b37eaa 100644 --- a/frontend/src/pages/__tests__/SessionDetail.commands.test.tsx +++ b/frontend/src/pages/__tests__/SessionDetail.commands.test.tsx @@ -193,6 +193,14 @@ vi.mock('@/api/repos', () => ({ })) vi.mock('@/components/session/BackgroundWorkBar', () => ({ BackgroundWorkBar: vi.fn(() => null) })) +vi.mock('@/components/session/SessionGoalBar', () => ({ SessionGoalBar: vi.fn(() => null) })) +vi.mock('@/hooks/useSessionGoals', () => ({ + useSessionGoal: vi.fn(() => ({ data: undefined })), + useStartSessionGoal: vi.fn(() => ({ mutateAsync: vi.fn(), isPending: false })), + usePauseSessionGoal: vi.fn(() => ({ mutate: vi.fn(), isPending: false })), + useResumeSessionGoal: vi.fn(() => ({ mutate: vi.fn(), isPending: false })), + useCancelSessionGoal: vi.fn(() => ({ mutate: vi.fn(), isPending: false })), +})) vi.mock('@/components/session/SideQuestionDialog', () => ({ SideQuestionDialog: vi.fn(() => null) })) vi.mock('@/components/session/SessionList', () => ({ SessionList: vi.fn(() => null) })) vi.mock('@/components/file-browser/FileBrowserSheet', () => ({ FileBrowserSheet: vi.fn(() => null) })) diff --git a/frontend/src/pages/__tests__/SessionDetail.form-prompt.test.tsx b/frontend/src/pages/__tests__/SessionDetail.form-prompt.test.tsx index 762afecd3..0353e649d 100644 --- a/frontend/src/pages/__tests__/SessionDetail.form-prompt.test.tsx +++ b/frontend/src/pages/__tests__/SessionDetail.form-prompt.test.tsx @@ -151,6 +151,14 @@ vi.mock('@/api/repos', () => ({ })) vi.mock('@/components/session/BackgroundWorkBar', () => ({ BackgroundWorkBar: vi.fn(() => null) })) +vi.mock('@/components/session/SessionGoalBar', () => ({ SessionGoalBar: vi.fn(() => null) })) +vi.mock('@/hooks/useSessionGoals', () => ({ + useSessionGoal: vi.fn(() => ({ data: undefined })), + useStartSessionGoal: vi.fn(() => ({ mutateAsync: vi.fn(), isPending: false })), + usePauseSessionGoal: vi.fn(() => ({ mutate: vi.fn(), isPending: false })), + useResumeSessionGoal: vi.fn(() => ({ mutate: vi.fn(), isPending: false })), + useCancelSessionGoal: vi.fn(() => ({ mutate: vi.fn(), isPending: false })), +})) vi.mock('@/components/session/SessionList', () => ({ SessionList: vi.fn(() => null), })) diff --git a/frontend/src/pages/__tests__/SessionDetail.polling.test.tsx b/frontend/src/pages/__tests__/SessionDetail.polling.test.tsx index 5cf9ad1b1..c98a23d33 100644 --- a/frontend/src/pages/__tests__/SessionDetail.polling.test.tsx +++ b/frontend/src/pages/__tests__/SessionDetail.polling.test.tsx @@ -126,6 +126,14 @@ vi.mock('@/api/repos', () => ({ })) vi.mock('@/components/session/BackgroundWorkBar', () => ({ BackgroundWorkBar: vi.fn(() => null) })) +vi.mock('@/components/session/SessionGoalBar', () => ({ SessionGoalBar: vi.fn(() => null) })) +vi.mock('@/hooks/useSessionGoals', () => ({ + useSessionGoal: vi.fn(() => ({ data: undefined })), + useStartSessionGoal: vi.fn(() => ({ mutateAsync: vi.fn(), isPending: false })), + usePauseSessionGoal: vi.fn(() => ({ mutate: vi.fn(), isPending: false })), + useResumeSessionGoal: vi.fn(() => ({ mutate: vi.fn(), isPending: false })), + useCancelSessionGoal: vi.fn(() => ({ mutate: vi.fn(), isPending: false })), +})) vi.mock('@/components/session/SessionList', () => ({ SessionList: vi.fn(() => null) })) vi.mock('@/components/session/PermissionModeToggle', () => ({ PermissionModeToggle: vi.fn(() => null) })) vi.mock('@/components/file-browser/FileBrowserSheet', () => ({ FileBrowserSheet: vi.fn(() => null) })) diff --git a/frontend/src/pages/__tests__/SessionDetail.scroll-floating.test.tsx b/frontend/src/pages/__tests__/SessionDetail.scroll-floating.test.tsx index 0772e4691..c5786f8ad 100644 --- a/frontend/src/pages/__tests__/SessionDetail.scroll-floating.test.tsx +++ b/frontend/src/pages/__tests__/SessionDetail.scroll-floating.test.tsx @@ -145,6 +145,14 @@ vi.mock('@/api/repos', () => ({ })) vi.mock('@/components/session/BackgroundWorkBar', () => ({ BackgroundWorkBar: vi.fn(() => null) })) +vi.mock('@/components/session/SessionGoalBar', () => ({ SessionGoalBar: vi.fn(() => null) })) +vi.mock('@/hooks/useSessionGoals', () => ({ + useSessionGoal: vi.fn(() => ({ data: undefined })), + useStartSessionGoal: vi.fn(() => ({ mutateAsync: vi.fn(), isPending: false })), + usePauseSessionGoal: vi.fn(() => ({ mutate: vi.fn(), isPending: false })), + useResumeSessionGoal: vi.fn(() => ({ mutate: vi.fn(), isPending: false })), + useCancelSessionGoal: vi.fn(() => ({ mutate: vi.fn(), isPending: false })), +})) vi.mock('@/components/session/SessionList', () => ({ SessionList: vi.fn(() => null), })) From f402e8479adfdff3f0cf1a9069f0218c96d07de6 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:33:25 -0400 Subject: [PATCH 09/16] section 9: Phase 9: One owner for creating and removing OpenCode worksp --- backend/src/routes/repos.ts | 20 +-- backend/src/services/repo.ts | 43 ++++- backend/test/services/repo-workspaces.test.ts | 147 ++++++++++++++++++ 3 files changed, 195 insertions(+), 15 deletions(-) create mode 100644 backend/test/services/repo-workspaces.test.ts diff --git a/backend/src/routes/repos.ts b/backend/src/routes/repos.ts index 065af5479..6048ec9c7 100644 --- a/backend/src/routes/repos.ts +++ b/backend/src/routes/repos.ts @@ -12,14 +12,13 @@ import type { OpenCodeClient } from '../services/opencode/client' import { logger } from '../utils/logger' import { getErrorMessage, getStatusCode } from '../utils/error-utils' import { handleOpenCodeError } from '../utils/route-helpers' -import { ASSISTANT_REPO_ID, isWorktreeSibling } from '@opencode-manager/shared/utils' +import { ASSISTANT_REPO_ID } from '@opencode-manager/shared/utils' import { isWorktreeError, openCodeLocation } from '@opencode-manager/shared/opencode' import { createRepoGitRoutes } from './repo-git' import { createScheduleRoutes } from './schedules' import type { GitAuthService } from '../services/git-auth' import { ScheduleService } from '../services/schedules' import { ensureAssistantMode, getAssistantModeStatus, buildAssistantRepo } from '../services/assistant-mode' -import { canonicalPathSync } from '../utils/fs-safe' import path from 'path' function resolveRepo(database: Database, id: number): Repo | null { @@ -267,19 +266,13 @@ app.get('/', async (c) => { const body = await c.req.json().catch(() => null) const parsed = DeleteWorkspaceRequestSchema.safeParse(body) if (!parsed.success) return c.json({ error: 'directory is required' }, 400) - const directory = parsed.data.directory - - const siblings = await repoService.getSiblingRepos(database, id, gitAuthService.getGitEnvironment(), openCodeClient) - const requestedDirectory = canonicalPathSync(path.resolve(directory)) - const worktree = siblings.find( - (sibling) => isWorktreeSibling(sibling) && canonicalPathSync(path.resolve(sibling.fullPath)) === requestedDirectory, - ) - if (!worktree) return c.json({ error: 'Not a deletable worktree of this repo' }, 400) try { - const projectID = await repoService.resolveRepoProjectId(openCodeClient, repo.fullPath) - await openCodeClient.api.worktree.remove({ projectID, directory: worktree.fullPath, force: true }) + await repoService.removeRepoWorkspace(database, openCodeClient, gitAuthService.getGitEnvironment(), repo, parsed.data.directory) } catch (error: unknown) { + if (error instanceof repoService.RepoWorkspaceError) { + return c.json({ error: error.message }, error.status) + } if (isWorktreeError(error)) { return c.json({ error: error.data.message }, 409) } @@ -302,8 +295,7 @@ app.get('/', async (c) => { if (!repo || repo.cloneStatus !== 'ready') return c.json({ error: 'Repo not found' }, 404) try { - const projectID = await repoService.resolveRepoProjectId(openCodeClient, repo.fullPath) - const worktree = await openCodeClient.api.worktree.create({ projectID }) + const worktree = await repoService.createRepoWorkspace(openCodeClient, repo) return c.json(worktree) } catch (error: unknown) { if (isWorktreeError(error)) { diff --git a/backend/src/services/repo.ts b/backend/src/services/repo.ts index 6a9268686..40e6e98a7 100644 --- a/backend/src/services/repo.ts +++ b/backend/src/services/repo.ts @@ -7,7 +7,7 @@ import type { Database } from 'bun:sqlite' import type { Repo, CreateRepoInput } from '../types/repo' import { logger } from '../utils/logger' import { getReposPath, getScheduleWorktreesPath } from '@opencode-manager/shared/config/env' -import { normalizeRepoDirectoryName, sanitizeRepoDirectoryName, sanitizeBranchForDirectory, getRepoBaseDirectoryName, normalizeRepoUrlForCompare, isSSHUrl, normalizeSSHUrl, SCP_STYLE_URL_PATTERN } from '@opencode-manager/shared/utils' +import { normalizeRepoDirectoryName, sanitizeRepoDirectoryName, sanitizeBranchForDirectory, getRepoBaseDirectoryName, normalizeRepoUrlForCompare, isSSHUrl, normalizeSSHUrl, isWorktreeSibling, SCP_STYLE_URL_PATTERN } from '@opencode-manager/shared/utils' import type { GitAuthService } from './git-auth' import { isGitHubHttpsUrl } from '../utils/git-auth' import path from 'path' @@ -1263,3 +1263,44 @@ export async function getSiblingRepos( return repoSiblings } } + +export class RepoWorkspaceError extends Error { + readonly status: 400 | 404 + + constructor(message: string, status: 400 | 404) { + super(message) + this.name = 'RepoWorkspaceError' + this.status = status + } +} + +export async function createRepoWorkspace( + openCodeClient: OpenCodeClient, + repo: Repo, + options: { name?: string; ref?: string } = {}, +): Promise<{ directory: string }> { + const projectID = await resolveRepoProjectId(openCodeClient, repo.fullPath) + return openCodeClient.api.worktree.create({ + projectID, + ...(options.name ? { name: options.name } : {}), + ...(options.ref ? { branch: options.ref } : {}), + }) +} + +export async function removeRepoWorkspace( + database: Database, + openCodeClient: OpenCodeClient, + gitEnv: Record, + repo: Repo, + directory: string, +): Promise { + const siblings = await getSiblingRepos(database, repo.id, gitEnv, openCodeClient) + const requestedDirectory = canonicalPathSync(path.resolve(directory)) + const worktree = siblings.find( + (sibling) => isWorktreeSibling(sibling) && canonicalPathSync(path.resolve(sibling.fullPath)) === requestedDirectory, + ) + if (!worktree) throw new RepoWorkspaceError('Not a deletable worktree of this repo', 400) + + const projectID = await resolveRepoProjectId(openCodeClient, repo.fullPath) + await openCodeClient.api.worktree.remove({ projectID, directory: worktree.fullPath, force: true }) +} diff --git a/backend/test/services/repo-workspaces.test.ts b/backend/test/services/repo-workspaces.test.ts new file mode 100644 index 000000000..f8b8534b0 --- /dev/null +++ b/backend/test/services/repo-workspaces.test.ts @@ -0,0 +1,147 @@ +import path from 'path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { Database } from 'bun:sqlite' +import { getReposPath } from '@opencode-manager/shared/config/env' +import type { OpenCodeClient } from '../../src/services/opencode/client' +import type { Repo } from '../../src/types/repo' +import { migrate } from '../../src/db/migration-runner' +import { allMigrations } from '../../src/db/migrations' + +const executeCommand = vi.fn() +const listRepos = vi.fn() +const resolveProjectId = vi.fn() +const isGitMainCheckout = vi.fn() +const getSettings = vi.fn() +const mkdirSafe = vi.fn() + +vi.mock('../../src/utils/fs-safe', () => ({ + mkdirSafe, + mkdirSyncSafe: vi.fn(), + canonicalPathSync: (target: string) => target, +})) + +vi.mock('../../src/services/settings', () => ({ + SettingsService: vi.fn().mockImplementation(() => ({ getSettings })), +})) + +vi.mock('../../src/services/project-id-resolver', () => ({ + resolveProjectId, + isGitMainCheckout, +})) + +vi.mock('../../src/utils/process', () => ({ + executeCommand, +})) + +vi.mock('../../src/db/queries', () => ({ + listRepos, +})) + +describe('repo workspace service', () => { + type WorktreeEntry = { directory: string; strategy?: string } + + let db: Database + + function createRepoRow(id: number, localPath: string, overrides: Partial = {}): Repo { + return { + id, + repoUrl: 'https://github.com/test/repo', + localPath, + fullPath: path.join(getReposPath(), localPath), + sourcePath: path.join(getReposPath(), localPath), + branch: 'main', + defaultBranch: 'main', + cloneStatus: 'ready', + clonedAt: Date.now(), + ...overrides, + } + } + + function createClient(worktrees: WorktreeEntry[], overrides: { + create?: OpenCodeClient['api']['worktree']['create'] + remove?: OpenCodeClient['api']['worktree']['remove'] + } = {}): OpenCodeClient { + return { + api: { + location: { + get: async () => ({ + directory: path.join(getReposPath(), 'repo-a'), + project: { id: 'commit-A', directory: path.join(getReposPath(), 'repo-a'), canonical: path.join(getReposPath(), 'repo-a') }, + }), + }, + worktree: { + list: async () => worktrees, + create: overrides.create ?? (async () => ({ directory: '/worktrees/feature-x' })), + remove: overrides.remove ?? (async () => undefined), + }, + }, + } as unknown as OpenCodeClient + } + + beforeEach(() => { + vi.clearAllMocks() + mkdirSafe.mockResolvedValue(undefined) + db = new Database(':memory:') + migrate(db, allMigrations) + getSettings.mockReturnValue({ preferences: { repoOrder: [] }, updatedAt: Date.now() }) + resolveProjectId.mockResolvedValue('commit-A') + isGitMainCheckout.mockResolvedValue(false) + listRepos.mockReturnValue([createRepoRow(1, 'repo-a')]) + executeCommand.mockImplementation(async (args: string[]) => { + if (args.includes('HEAD') && !args.includes('--abbrev-ref')) { + return 'abc123' + } + + if (args.includes('--abbrev-ref')) { + return 'main' + } + + return '' + }) + }) + + afterEach(() => { + db.close() + }) + + it('passes name and ref through to the worktree create call', async () => { + const { createRepoWorkspace } = await import('../../src/services/repo') + const create = vi.fn(async () => ({ directory: '/worktrees/feature-x' })) + const client = createClient([], { create }) + + const result = await createRepoWorkspace(client, createRepoRow(1, 'repo-a'), { name: 'feature-x', ref: 'feature/x' }) + + expect(create).toHaveBeenCalledWith({ projectID: 'commit-A', name: 'feature-x', branch: 'feature/x' }) + expect(result).toEqual({ directory: '/worktrees/feature-x' }) + }) + + it('creates a worktree without optional options', async () => { + const { createRepoWorkspace } = await import('../../src/services/repo') + const create = vi.fn(async () => ({ directory: '/worktrees/feature-x' })) + const client = createClient([], { create }) + + await createRepoWorkspace(client, createRepoRow(1, 'repo-a')) + + expect(create).toHaveBeenCalledWith({ projectID: 'commit-A' }) + }) + + it('rejects a directory that is not a worktree sibling of the repo', async () => { + const { removeRepoWorkspace, RepoWorkspaceError } = await import('../../src/services/repo') + const client = createClient([{ directory: '/worktrees/feature-x', strategy: 'git' }]) + + const error = await removeRepoWorkspace(db, client, {}, createRepoRow(1, 'repo-a'), '/worktrees/unknown').catch((caught: unknown) => caught) + + expect(error).toBeInstanceOf(RepoWorkspaceError) + expect(error).toMatchObject({ status: 400 }) + }) + + it('removes a matching worktree sibling with force', async () => { + const { removeRepoWorkspace } = await import('../../src/services/repo') + const remove = vi.fn(async () => undefined) + const client = createClient([{ directory: '/worktrees/feature-x', strategy: 'git' }], { remove }) + + await removeRepoWorkspace(db, client, {}, createRepoRow(1, 'repo-a'), '/worktrees/feature-x') + + expect(remove).toHaveBeenCalledWith({ projectID: 'commit-A', directory: '/worktrees/feature-x', force: true }) + }) +}) From 206113438cf9f246971e69747149940bbde9213c Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:37:29 -0400 Subject: [PATCH 10/16] section 10: Phase 10: Session launcher owner --- backend/src/services/session-launcher.ts | 128 ++++++++++++++ .../test/services/session-launcher.test.ts | 166 ++++++++++++++++++ 2 files changed, 294 insertions(+) create mode 100644 backend/src/services/session-launcher.ts create mode 100644 backend/test/services/session-launcher.test.ts diff --git a/backend/src/services/session-launcher.ts b/backend/src/services/session-launcher.ts new file mode 100644 index 000000000..46763c07c --- /dev/null +++ b/backend/src/services/session-launcher.ts @@ -0,0 +1,128 @@ +import type { Database } from 'bun:sqlite' +import { openCodeLocation, parseOpenCodeModelRef } from '@opencode-manager/shared/opencode' +import { getRepoById } from '../db/queries' +import { getErrorMessage } from '../utils/error-utils' +import type { OpenCodeClient } from './opencode/client' +import { resolveOpenCodeModel } from './opencode-models' +import { createRepoWorkspace } from './repo' + +export interface LaunchSessionInput { + repoId: number + prompt: string + title?: string + model?: string + agent?: string + workspace?: { name?: string; ref?: string } +} + +export interface LaunchedSession { + sessionId: string + repoId: number + directory: string + workspaceDirectory: string | null + model: string + title: string | null +} + +export class SessionLaunchError extends Error { + readonly status: 400 | 404 | 502 + + constructor(message: string, status: 400 | 404 | 502) { + super(message) + this.name = 'SessionLaunchError' + this.status = status + } +} + +function withWorkspace(message: string, workspaceDirectory: string | null): string { + return workspaceDirectory ? `${message} (workspace: ${workspaceDirectory})` : message +} + +export class SessionLauncher { + constructor( + private readonly db: Database, + private readonly openCodeClient: OpenCodeClient, + ) {} + + async launch(input: LaunchSessionInput): Promise { + const repo = getRepoById(this.db, input.repoId) + if (!repo || repo.cloneStatus !== 'ready') { + throw new SessionLaunchError('Repository not found or not ready', 404) + } + + let directory = repo.fullPath + let workspaceDirectory: string | null = null + + if (input.workspace) { + try { + const workspace = await createRepoWorkspace(this.openCodeClient, repo, input.workspace) + directory = workspace.directory + workspaceDirectory = workspace.directory + } catch (error) { + throw new SessionLaunchError(withWorkspace(getErrorMessage(error) || 'Failed to create workspace', null), 502) + } + } + + const model = await this.resolveModel(input.model, directory, workspaceDirectory) + + let session: { id: string; title?: string | null } + try { + session = await this.openCodeClient.api.session.create({ + ...(input.title ? { title: input.title } : {}), + ...(input.agent ? { agent: input.agent } : {}), + model: { + providerID: model.providerID, + id: model.id, + ...(model.variant ? { variant: model.variant } : {}), + }, + ...openCodeLocation(directory), + }) + + await this.openCodeClient.api.session.prompt({ sessionID: session.id, text: input.prompt }) + } catch (error) { + throw new SessionLaunchError( + withWorkspace(getErrorMessage(error) || 'Failed to create OpenCode session', workspaceDirectory), + 502, + ) + } + + return { + sessionId: session.id, + repoId: repo.id, + directory, + workspaceDirectory, + model: model.model, + title: session.title ?? input.title ?? null, + } + } + + private async resolveModel( + requestedModel: string | undefined, + directory: string, + workspaceDirectory: string | null, + ): Promise>> { + let resolved: Awaited> + try { + resolved = await resolveOpenCodeModel(this.openCodeClient, directory, { + preferredModel: requestedModel, + }) + } catch (error) { + throw new SessionLaunchError( + withWorkspace(getErrorMessage(error) || 'Failed to resolve OpenCode model', workspaceDirectory), + 502, + ) + } + + if (requestedModel) { + const requestedRef = parseOpenCodeModelRef(requestedModel) + if (!requestedRef || resolved.providerID !== requestedRef.providerID || resolved.id !== requestedRef.id) { + throw new SessionLaunchError( + withWorkspace(`Model ${requestedModel} is not available`, workspaceDirectory), + 400, + ) + } + } + + return resolved + } +} diff --git a/backend/test/services/session-launcher.test.ts b/backend/test/services/session-launcher.test.ts new file mode 100644 index 000000000..c97ce7db9 --- /dev/null +++ b/backend/test/services/session-launcher.test.ts @@ -0,0 +1,166 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { Database } from 'bun:sqlite' +import type { OpenCodeClient } from '../../src/services/opencode/client' +import { migrate } from '../../src/db/migration-runner' +import { allMigrations } from '../../src/db/migrations' +import { createRepo } from '../../src/db/queries' +import { SessionLauncher, SessionLaunchError } from '../../src/services/session-launcher' + +const mocks = vi.hoisted(() => ({ + resolveOpenCodeModel: vi.fn(), +})) + +vi.mock('../../src/services/opencode-models', () => ({ + resolveOpenCodeModel: mocks.resolveOpenCodeModel, +})) + +const REPO_DIR = '/repos/repo-a' + +interface FakeLaunchClient { + client: OpenCodeClient + create: ReturnType + prompt: ReturnType + worktreeCreate: ReturnType +} + +function createClient(overrides: { workspaceDirectory?: string; createError?: Error } = {}): FakeLaunchClient { + const create = vi.fn(async (input: { title?: string }) => { + if (overrides.createError) throw overrides.createError + return { id: 'ses_new', title: input?.title } + }) + const prompt = vi.fn(async () => ({})) + const worktreeCreate = vi.fn(async () => ({ directory: overrides.workspaceDirectory ?? '/worktrees/feature-x' })) + + const client = { + api: { + location: { + get: vi.fn(async () => ({ project: { id: 'commit-A', directory: REPO_DIR, canonical: REPO_DIR } })), + }, + worktree: { create: worktreeCreate }, + session: { create, prompt }, + }, + } as unknown as OpenCodeClient + + return { client, create, prompt, worktreeCreate } +} + +describe('SessionLauncher', () => { + let db: Database + + beforeEach(() => { + vi.clearAllMocks() + db = new Database(':memory:') + migrate(db, allMigrations) + mocks.resolveOpenCodeModel.mockResolvedValue({ providerID: 'openai', id: 'gpt-5', model: 'openai/gpt-5' }) + }) + + afterEach(() => { + db.close() + }) + + function readyRepo(overrides: { cloneStatus?: 'cloning' | 'ready' | 'error' } = {}): number { + const repo = createRepo(db, { + localPath: 'repo-a', + sourcePath: REPO_DIR, + defaultBranch: 'main', + cloneStatus: overrides.cloneStatus ?? 'ready', + clonedAt: Date.now(), + isLocal: true, + }) + return repo.id + } + + it('launches in the repo directory with the resolved model and sends the prompt', async () => { + const repoId = readyRepo() + const { client, create, prompt } = createClient() + const launcher = new SessionLauncher(db, client) + + const result = await launcher.launch({ repoId, prompt: 'hello', title: 'T', agent: 'build', model: 'openai/gpt-5' }) + + expect(mocks.resolveOpenCodeModel).toHaveBeenCalledWith(client, REPO_DIR, { preferredModel: 'openai/gpt-5' }) + expect(create).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'T', + agent: 'build', + model: { providerID: 'openai', id: 'gpt-5' }, + location: { directory: REPO_DIR }, + }), + ) + expect(prompt).toHaveBeenCalledWith({ sessionID: 'ses_new', text: 'hello' }) + expect(result).toEqual({ + sessionId: 'ses_new', + repoId, + directory: REPO_DIR, + workspaceDirectory: null, + model: 'openai/gpt-5', + title: 'T', + }) + }) + + it('launches in a new workspace and prompts there', async () => { + const repoId = readyRepo() + const { client, create, worktreeCreate } = createClient() + const launcher = new SessionLauncher(db, client) + + const result = await launcher.launch({ + repoId, + prompt: 'hello', + workspace: { name: 'feature-x', ref: 'feature/x' }, + }) + + expect(worktreeCreate).toHaveBeenCalledWith({ projectID: 'commit-A', name: 'feature-x', branch: 'feature/x' }) + expect(mocks.resolveOpenCodeModel).toHaveBeenCalledWith(client, '/worktrees/feature-x', { preferredModel: undefined }) + expect(create).toHaveBeenCalledWith(expect.objectContaining({ location: { directory: '/worktrees/feature-x' } })) + expect(result).toMatchObject({ + sessionId: 'ses_new', + repoId, + directory: '/worktrees/feature-x', + workspaceDirectory: '/worktrees/feature-x', + }) + }) + + it('rejects an unavailable requested model without creating a session', async () => { + const repoId = readyRepo() + const { client, create } = createClient() + const launcher = new SessionLauncher(db, client) + + const error = await launcher.launch({ repoId, prompt: 'hello', model: 'openai/retired' }).catch((caught: unknown) => caught) + + expect(error).toBeInstanceOf(SessionLaunchError) + expect(error).toMatchObject({ status: 400, message: 'Model openai/retired is not available' }) + expect(create).not.toHaveBeenCalled() + }) + + it('rejects a repo that is not ready', async () => { + const repoId = readyRepo({ cloneStatus: 'cloning' }) + const { client, create } = createClient() + const launcher = new SessionLauncher(db, client) + + const error = await launcher.launch({ repoId, prompt: 'hello' }).catch((caught: unknown) => caught) + + expect(error).toBeInstanceOf(SessionLaunchError) + expect(error).toMatchObject({ status: 404 }) + expect(create).not.toHaveBeenCalled() + expect(mocks.resolveOpenCodeModel).not.toHaveBeenCalled() + }) + + it('keeps a created workspace and names it when session creation fails', async () => { + const repoId = readyRepo() + const { client, worktreeCreate } = createClient({ + workspaceDirectory: '/worktrees/feature-x', + createError: new Error('boom'), + }) + const launcher = new SessionLauncher(db, client) + + const error = await launcher.launch({ repoId, prompt: 'hello', workspace: { name: 'feature-x' } }).catch( + (caught: unknown) => caught, + ) + + expect(error).toBeInstanceOf(SessionLaunchError) + expect(error).toMatchObject({ + status: 502, + message: 'boom (workspace: /worktrees/feature-x)', + }) + expect(worktreeCreate).toHaveBeenCalled() + }) +}) From fc623fde96db3e3b73a837818ad5d3e6ce9370a4 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:43:49 -0400 Subject: [PATCH 11/16] section 11: Phase 11: Internal session routes, `ocm` tool allow-list and --- backend/src/routes/internal/index.ts | 2 + backend/src/routes/internal/sessions.ts | 222 +++++++++++ backend/src/services/assistant-mode.ts | 189 ++++++++++ .../services/opencode-manager-tool-plugin.ts | 7 +- backend/test/routes/internal-sessions.test.ts | 347 ++++++++++++++++++ backend/test/services/assistant-mode.test.ts | 42 ++- .../opencode-manager-tool-plugin.test.ts | 25 ++ shared/src/schemas/index.ts | 1 + shared/src/schemas/internal-sessions.ts | 31 ++ shared/src/schemas/repo.ts | 4 + 10 files changed, 868 insertions(+), 2 deletions(-) create mode 100644 backend/src/routes/internal/sessions.ts create mode 100644 backend/test/routes/internal-sessions.test.ts create mode 100644 shared/src/schemas/internal-sessions.ts diff --git a/backend/src/routes/internal/index.ts b/backend/src/routes/internal/index.ts index 727cceb06..f96732f5c 100644 --- a/backend/src/routes/internal/index.ts +++ b/backend/src/routes/internal/index.ts @@ -13,6 +13,7 @@ import { createInternalRepoRoutes } from './repos' import { createInternalRepoSyncRoutes } from './repo-sync' import { createInternalRepoMirrorRoutes as mirrorRoutes } from './repo-mirror' import { createInternalOpenCodeWorkspacesRoutes } from './opencode-workspaces' +import { createInternalSessionRoutes } from './sessions' import { createInternalAssistantRoutes } from './assistant' import { createInternalGitCredentialsRoutes } from './git-credentials' import { createInternalSandboxRoutes } from './sandbox' @@ -37,6 +38,7 @@ export function createInternalRoutes( repos.route('/', mirrorRoutes(db)) app.route('/repos', repos) app.route('/opencode-workspaces', createInternalOpenCodeWorkspacesRoutes(db)) + app.route('/sessions', createInternalSessionRoutes(db, openCodeClient)) app.route('/assistant', createInternalAssistantRoutes(openCodeClient)) app.route('/git-credentials', createInternalGitCredentialsRoutes(db)) app.route('/sandbox', createInternalSandboxRoutes(db)) diff --git a/backend/src/routes/internal/sessions.ts b/backend/src/routes/internal/sessions.ts new file mode 100644 index 000000000..b4de90de1 --- /dev/null +++ b/backend/src/routes/internal/sessions.ts @@ -0,0 +1,222 @@ +import { Hono, type Context } from 'hono' +import { z } from 'zod' +import type { Database } from 'bun:sqlite' +import { + InternalCreateSessionRequestSchema, + InternalForkSessionRequestSchema, + InternalSessionPromptRequestSchema, +} from '@opencode-manager/shared/schemas' +import { isSessionNotFoundError } from '@opencode-manager/shared/opencode' +import { getRepoById, listRepos } from '../../db/queries' +import { getErrorMessage } from '../../utils/error-utils' +import { logger } from '../../utils/logger' +import type { OpenCodeClient } from '../../services/opencode/client' +import { SessionLaunchError, SessionLauncher } from '../../services/session-launcher' +import { isSessionBusy, readLatestAssistantReply } from '../../services/session-reply' + +const INTERNAL_SESSION_LIST_LIMIT_MIN = 1 +const INTERNAL_SESSION_LIST_LIMIT_MAX = 50 +const INTERNAL_SESSION_LIST_LIMIT_DEFAULT = 10 +const INTERNAL_SESSION_WORKSPACE_NAME_FALLBACK = 'ocm-session' + +const ListSessionsQuerySchema = z.object({ + repoId: z.coerce.number().int().optional(), + limit: z.coerce + .number() + .int() + .min(INTERNAL_SESSION_LIST_LIMIT_MIN) + .max(INTERNAL_SESSION_LIST_LIMIT_MAX) + .default(INTERNAL_SESSION_LIST_LIMIT_DEFAULT), +}) + +type JsonBodyResult = { ok: true; value: unknown } | { ok: false } + +async function readJsonBody(c: Context): Promise { + const text = await c.req.text() + if (!text.trim()) { + return { ok: true, value: {} } + } + try { + return { ok: true, value: JSON.parse(text) } + } catch { + return { ok: false } + } +} + +function openCodeErrorResponse(error: unknown): { status: 400 | 404 | 502; body: { error: string } } { + if (error instanceof SessionLaunchError) { + return { status: error.status, body: { error: error.message } } + } + if (isSessionNotFoundError(error)) { + return { status: 404, body: { error: 'Session not found' } } + } + logger.error('Internal session request failed:', error) + return { status: 502, body: { error: getErrorMessage(error) } } +} + +function workspaceNameFromTitle(title: string | undefined): string { + const slug = (title ?? '') + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, '-') + .replace(/^-+/, '') + .replace(/-+$/, '') + return slug || INTERNAL_SESSION_WORKSPACE_NAME_FALLBACK +} + +export function createInternalSessionRoutes(db: Database, openCodeClient: OpenCodeClient) { + const app = new Hono() + const sessionLauncher = new SessionLauncher(db, openCodeClient) + + app.get('/', async (c) => { + const parsedQuery = ListSessionsQuerySchema.safeParse(c.req.query()) + if (!parsedQuery.success) { + return c.json({ error: 'Invalid query', details: parsedQuery.error.issues }, 400) + } + + const { repoId, limit } = parsedQuery.data + const repo = repoId === undefined ? undefined : getRepoById(db, repoId) + if (repoId !== undefined && !repo) { + return c.json({ error: 'Repository not found' }, 404) + } + + try { + const response = await openCodeClient.api.session.list({ + limit, + order: 'desc', + parentID: null, + ...(repo ? { directory: repo.fullPath } : {}), + }) + const active = await openCodeClient.api.session.active() + const repos = listRepos(db) + + const sessions = response.data.map((session) => { + const directory = session.location.directory + const matchedRepo = repos.find((candidate) => candidate.fullPath === directory) + return { + id: session.id, + title: session.title ?? null, + directory, + repoId: matchedRepo?.id ?? null, + busy: session.id in active, + outcome: session.outcome ?? null, + updated: session.time.updated, + } + }) + + return c.json({ sessions }) + } catch (error) { + const mapped = openCodeErrorResponse(error) + return c.json(mapped.body, mapped.status) + } + }) + + app.post('/', async (c) => { + const body = await readJsonBody(c) + if (!body.ok) { + return c.json({ error: 'Invalid JSON' }, 400) + } + + const parsed = InternalCreateSessionRequestSchema.safeParse(body.value) + if (!parsed.success) { + return c.json({ error: 'Invalid request body', details: parsed.error.issues }, 400) + } + + const input = parsed.data + try { + const launched = await sessionLauncher.launch({ + repoId: input.repoId, + prompt: input.prompt, + ...(input.title ? { title: input.title } : {}), + ...(input.model ? { model: input.model } : {}), + ...(input.agent ? { agent: input.agent } : {}), + ...(input.worktree + ? { + workspace: { + name: workspaceNameFromTitle(input.title), + ...(input.ref ? { ref: input.ref } : {}), + }, + } + : {}), + }) + + const url = `/repos/${launched.repoId}/sessions/${launched.sessionId}${launched.workspaceDirectory ? '?repoTab=workspaces' : ''}` + return c.json({ ...launched, url }, 201) + } catch (error) { + const mapped = openCodeErrorResponse(error) + return c.json(mapped.body, mapped.status) + } + }) + + app.post('/:sessionId/prompt', async (c) => { + const sessionId = c.req.param('sessionId') + const body = await readJsonBody(c) + if (!body.ok) { + return c.json({ error: 'Invalid JSON' }, 400) + } + + const parsed = InternalSessionPromptRequestSchema.safeParse(body.value) + if (!parsed.success) { + return c.json({ error: 'Invalid request body', details: parsed.error.issues }, 400) + } + + try { + await openCodeClient.api.session.prompt({ + sessionID: sessionId, + text: parsed.data.text, + delivery: 'queue', + }) + return c.json({ queued: true }, 202) + } catch (error) { + const mapped = openCodeErrorResponse(error) + return c.json(mapped.body, mapped.status) + } + }) + + app.get('/:sessionId/reply', async (c) => { + const sessionId = c.req.param('sessionId') + + try { + const [busy, reply] = await Promise.all([ + isSessionBusy(openCodeClient, sessionId), + readLatestAssistantReply(openCodeClient, sessionId), + ]) + + return c.json({ + busy, + responseText: reply?.responseText ?? null, + errorText: reply?.errorText ?? null, + completed: reply?.completed ?? false, + }) + } catch (error) { + const mapped = openCodeErrorResponse(error) + return c.json(mapped.body, mapped.status) + } + }) + + app.post('/:sessionId/fork', async (c) => { + const sessionId = c.req.param('sessionId') + const body = await readJsonBody(c) + if (!body.ok) { + return c.json({ error: 'Invalid JSON' }, 400) + } + + const parsed = InternalForkSessionRequestSchema.safeParse(body.value) + if (!parsed.success) { + return c.json({ error: 'Invalid request body', details: parsed.error.issues }, 400) + } + + const beforeMessageId = parsed.data.beforeMessageId + try { + const forked = await openCodeClient.api.session.fork({ + sessionID: sessionId, + ...(beforeMessageId ? { before: beforeMessageId } : {}), + }) + return c.json({ sessionId: forked.id, directory: forked.location.directory }) + } catch (error) { + const mapped = openCodeErrorResponse(error) + return c.json(mapped.body, mapped.status) + } + }) + + return app +} diff --git a/backend/src/services/assistant-mode.ts b/backend/src/services/assistant-mode.ts index dc9dfab9a..8ce6dda9e 100644 --- a/backend/src/services/assistant-mode.ts +++ b/backend/src/services/assistant-mode.ts @@ -31,6 +31,7 @@ const ASSISTANT_SCHEDULES_SKILL_DIR = 'schedule-management' const ASSISTANT_NOTIFICATIONS_SKILL_DIR = 'notifications' const ASSISTANT_SETTINGS_SKILL_DIR = 'manager-settings' const ASSISTANT_REPOS_SKILL_DIR = 'repo-management' +const ASSISTANT_SESSIONS_SKILL_DIR = 'session-management' const ASSISTANT_SKILL_FILENAME = 'SKILL.md' const ASSISTANT_AGENTS_DIR = 'agents' const ASSISTANT_DEFAULT_AGENT_NAME = 'assistant' @@ -77,6 +78,10 @@ function getReposSkillPath(assistantDir: string): string { return path.join(assistantDir, ASSISTANT_OPENCODE_DIR, ASSISTANT_SKILLS_DIR, ASSISTANT_REPOS_SKILL_DIR, ASSISTANT_SKILL_FILENAME) } +function getSessionsSkillPath(assistantDir: string): string { + return path.join(assistantDir, ASSISTANT_OPENCODE_DIR, ASSISTANT_SKILLS_DIR, ASSISTANT_SESSIONS_SKILL_DIR, ASSISTANT_SKILL_FILENAME) +} + function getAssistantDefaultAgentPath(assistantDir: string): string { return path.join( assistantDir, @@ -897,6 +902,172 @@ List all repos available to OpenCode Manager. The repos are returned in the orde ` } +export function buildSessionsSkill(): string { + return `--- +name: session-management +description: Create and follow up on OpenCode sessions with the ${MANAGER_TOOL_NAME} tool +--- + +## When to Load + +Load this skill when the user asks you to start work in a repo, hand a task to a new session, follow up on a running session, read what a session replied, or fork a session. + +## Tool + +Use the \`${MANAGER_TOOL_NAME}\` tool with the \`request\` action. The tool runs inside OpenCode Manager, so it needs no token, no base URL, and no network access from the shell. It works the same in a normal session, a sandboxed session, and a scheduled run. Paths are relative to the internal API (for example \`/sessions?repoId=1\` or \`/sessions/ses_abc/prompt\`) and query strings are allowed. + +**Arguments:** +\`\`\`ts +{ + action: 'request', + params: { + method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE', + path: string // relative internal API path; query strings allowed + body?: object // JSON body for POST and PATCH routes + } +} +\`\`\` + +## Endpoints + +### GET /sessions + +List sessions, newest first. Pass \`repoId\` to restrict the list to one repo and \`limit\` (1-50, default 10) to bound it. + +Query params: \`repoId\`, \`limit\` + +\`\`\`json +{ + "action": "request", + "params": { + "method": "GET", + "path": "/sessions?repoId=1&limit=10" + } +} +\`\`\` + +**Response:** +\`\`\`ts +{ + sessions: Array<{ + id: string + title: string | null + directory: string + repoId: number | null // null when the directory matches no known repo + busy: boolean // true while the session is running + outcome: 'succeeded' | 'failed' | 'interrupted' | null + updated: number + }> +} +\`\`\` + +### POST /sessions + +Create a session in a repo and send the first prompt. The response returns as soon as the prompt is queued, not when the run finishes. Use \`GET /sessions/:sessionId/reply\` to poll for the result. + +Pass \`worktree: true\` to run the session in a new isolated workspace instead of the repo directory. \`ref\` selects the base ref for that workspace. \`model\` must be an available model reference; an unavailable model is rejected with \`400\` rather than silently substituted. + +\`\`\`json +{ + "action": "request", + "params": { + "method": "POST", + "path": "/sessions", + "body": { + "repoId": 1, + "prompt": "Fix the failing login test", + "title": "Fix login test", + "worktree": true + } + } +} +\`\`\` + +**Response (\`201\`):** +\`\`\`ts +{ + sessionId: string + repoId: number + directory: string + workspaceDirectory: string | null + model: string + title: string | null + url: string // deep link to open the session in the UI +} +\`\`\` + +### POST /sessions/:sessionId/prompt + +Queue a follow-up prompt for an existing session. + +\`\`\`json +{ + "action": "request", + "params": { + "method": "POST", + "path": "/sessions/ses_abc/prompt", + "body": { "text": "Also add a regression test" } + } +} +\`\`\` + +**Response (\`202\`):** \`{ "queued": true }\` + +### GET /sessions/:sessionId/reply + +Read the latest assistant reply for a session and whether it is still running. Poll this after creating a session or queueing a prompt until \`busy\` is \`false\`. + +\`\`\`json +{ + "action": "request", + "params": { + "method": "GET", + "path": "/sessions/ses_abc/reply" + } +} +\`\`\` + +**Response:** +\`\`\`ts +{ + busy: boolean + responseText: string | null // latest assistant text, null when there is none yet + errorText: string | null // assistant error message, null when the turn succeeded + completed: boolean // true once the latest assistant turn finished +} +\`\`\` + +### POST /sessions/:sessionId/fork + +Fork a session, optionally before a specific message. Omit \`beforeMessageId\` to fork from the current point. + +\`\`\`json +{ + "action": "request", + "params": { + "method": "POST", + "path": "/sessions/ses_abc/fork", + "body": { "beforeMessageId": "msg_123" } + } +} +\`\`\` + +**Response:** +\`\`\`ts +{ + sessionId: string // the new forked session + directory: string +} +\`\`\` + +## Notes + +- Session creation always goes through OpenCode Manager, which validates the repo and the requested model before creating anything +- There is no delete route. Deleting sessions and workspaces stays out of this tool +- An unknown session returns \`404\`; other failures return \`502\` +` +} + export function buildAssistantOpenCodeConfig(): OpenCodeConfigInput { const config: OpenCodeConfigInput = { default_agent: ASSISTANT_DEFAULT_AGENT_NAME, @@ -1004,6 +1175,7 @@ export async function ensureAssistantMode( await ensureDirectoryExists(path.join(assistantDir, ASSISTANT_OPENCODE_DIR, ASSISTANT_SKILLS_DIR, ASSISTANT_NOTIFICATIONS_SKILL_DIR)) await ensureDirectoryExists(path.join(assistantDir, ASSISTANT_OPENCODE_DIR, ASSISTANT_SKILLS_DIR, ASSISTANT_SETTINGS_SKILL_DIR)) await ensureDirectoryExists(path.join(assistantDir, ASSISTANT_OPENCODE_DIR, ASSISTANT_SKILLS_DIR, ASSISTANT_REPOS_SKILL_DIR)) + await ensureDirectoryExists(path.join(assistantDir, ASSISTANT_OPENCODE_DIR, ASSISTANT_SKILLS_DIR, ASSISTANT_SESSIONS_SKILL_DIR)) const schedulesSkillContent = buildSchedulesSkill() const existingSchedulesSkillContent = await fileExists(skillPath) ? await readFileContent(skillPath) : undefined @@ -1036,6 +1208,14 @@ export async function ensureAssistantMode( await writeFileContent(reposSkillPath, reposSkillContent) } + const sessionsSkillPath = getSessionsSkillPath(assistantDir) + const sessionsSkillContent = buildSessionsSkill() + const existingSessionsSkillContent = await fileExists(sessionsSkillPath) ? await readFileContent(sessionsSkillPath) : undefined + const sessionsSkillCreated = !hasSameContentHash(existingSessionsSkillContent, sessionsSkillContent) + if (sessionsSkillCreated) { + await writeFileContent(sessionsSkillPath, sessionsSkillContent) + } + const assistantAgentExists = await fileExists(assistantAgentPath) const assistantAgentContent = buildAssistantDefaultAgentMd() const existingAssistantAgentContent = assistantAgentExists @@ -1098,6 +1278,10 @@ export async function ensureAssistantMode( path: reposSkillPath, created: reposSkillCreated, }, + sessionManagementSkill: { + path: sessionsSkillPath, + created: sessionsSkillCreated, + }, defaultAgent: { name: ASSISTANT_DEFAULT_AGENT_NAME, path: assistantAgentPath, @@ -1201,6 +1385,7 @@ export async function getAssistantModeStatus(repo: Repo): Promise `- ${route}`)) diff --git a/backend/test/routes/internal-sessions.test.ts b/backend/test/routes/internal-sessions.test.ts new file mode 100644 index 000000000..f259372dc --- /dev/null +++ b/backend/test/routes/internal-sessions.test.ts @@ -0,0 +1,347 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { Hono } from 'hono' +import type { Database } from 'bun:sqlite' +import { createInternalRoutes } from '../../src/routes/internal' +import type { ScheduleService } from '../../src/services/schedules' +import type { NotificationService } from '../../src/services/notification' +import type { SettingsService } from '../../src/services/settings' +import type { OpenCodeClient } from '../../src/services/opencode/client' +import type { Repo } from '../../src/types/repo' + +const mockDb = { + prepare: vi.fn().mockReturnValue({ + run: vi.fn(), + get: vi.fn(), + all: vi.fn(), + }), + exec: vi.fn(), + close: vi.fn(), + transaction: vi.fn((fn: () => void) => fn()), +} as unknown as Database + +vi.mock('bun:sqlite', () => ({ + Database: vi.fn(() => mockDb), +})) + +const mockListRepos = vi.fn() +const mockGetRepoById = vi.fn() +vi.mock('../../src/db/queries', async () => { + const { getRepoDisplayName } = await vi.importActual('@opencode-manager/shared/utils') + return { + listRepos: (...args: unknown[]) => mockListRepos(...args), + getRepoById: (...args: unknown[]) => mockGetRepoById(...args), + getRepoName: (repo: Parameters[0]) => getRepoDisplayName(repo), + } +}) + +vi.mock('../../src/db/migration-runner', () => ({ + migrate: vi.fn(), +})) + +vi.mock('../../src/services/internal-token', () => ({ + getOrCreateInternalToken: vi.fn().mockReturnValue('test-internal-token'), +})) + +vi.mock('../../src/services/schedules', () => ({ + ScheduleService: vi.fn(), +})) + +vi.mock('../../src/services/notification', () => ({ + NotificationService: vi.fn(), +})) + +vi.mock('../../src/services/settings', () => ({ + SettingsService: vi.fn(), +})) + +vi.mock('../../src/services/opencode/client', () => ({ + createOpenCodeClient: vi.fn(), +})) + +const mockLaunch = vi.fn() +vi.mock('../../src/services/session-launcher', () => { + class SessionLaunchError extends Error { + readonly status: number + + constructor(message: string, status: number) { + super(message) + this.name = 'SessionLaunchError' + this.status = status + } + } + + class SessionLauncher { + launch = mockLaunch + } + + return { SessionLauncher, SessionLaunchError } +}) + +function makeRepo(overrides: Partial): Repo { + return { + id: 1, + localPath: 'test-repo', + fullPath: '/tmp/test-repo', + defaultBranch: 'main', + cloneStatus: 'ready', + clonedAt: Date.now(), + ...overrides, + } +} + +function sessionNotFoundError(): Error { + return Object.assign(new Error('Session not found'), { _tag: 'SessionNotFoundError' }) +} + +const assistantMessage = { + id: 'msg_1', + type: 'assistant', + agent: 'build', + model: { id: 'model', providerID: 'provider' }, + time: { created: 1, completed: 2 }, + content: [{ type: 'text', text: 'All done' }], +} + +describe('internal-sessions routes', () => { + let app: Hono + let token: string + let openCodeClient: OpenCodeClient + let sessionList: ReturnType + let sessionActive: ReturnType + let sessionPrompt: ReturnType + let sessionFork: ReturnType + let messageList: ReturnType + + beforeEach(() => { + vi.clearAllMocks() + mockLaunch.mockReset() + mockGetRepoById.mockReturnValue(null) + mockListRepos.mockReturnValue([]) + + sessionList = vi.fn().mockResolvedValue({ data: [], cursor: {} }) + sessionActive = vi.fn().mockResolvedValue({}) + sessionPrompt = vi.fn().mockResolvedValue({}) + sessionFork = vi.fn() + messageList = vi.fn().mockResolvedValue({ data: [] }) + + openCodeClient = { + api: { + session: { + list: sessionList, + active: sessionActive, + prompt: sessionPrompt, + fork: sessionFork, + }, + message: { list: messageList }, + }, + } as unknown as OpenCodeClient + + const scheduleService = {} as ScheduleService + const notificationService = {} as NotificationService + const settingsService = {} as SettingsService + app = new Hono() + app.route('/api/internal', createInternalRoutes(mockDb, scheduleService, notificationService, settingsService, openCodeClient)) + token = 'test-internal-token' + }) + + it('GET /api/internal/sessions returns 401 without bearer token', async () => { + const res = await app.request('/api/internal/sessions') + expect(res.status).toBe(401) + }) + + it('GET /api/internal/sessions filters by repo and marks busy sessions', async () => { + const repo = makeRepo({ id: 1, fullPath: '/tmp/repo-one' }) + mockGetRepoById.mockReturnValue(repo) + mockListRepos.mockReturnValue([repo]) + sessionList.mockResolvedValue({ + data: [ + { id: 'ses_a', title: 'Alpha', location: { directory: '/tmp/repo-one' }, time: { created: 1, updated: 42 }, outcome: 'succeeded' }, + { id: 'ses_b', location: { directory: '/tmp/elsewhere' }, time: { created: 2, updated: 43 } }, + ], + cursor: {}, + }) + sessionActive.mockResolvedValue({ ses_a: { sessionID: 'ses_a' } }) + + const res = await app.request('/api/internal/sessions?repoId=1&limit=5', { + headers: { authorization: `Bearer ${token}` }, + }) + + expect(res.status).toBe(200) + expect(sessionList).toHaveBeenCalledWith({ limit: 5, order: 'desc', parentID: null, directory: '/tmp/repo-one' }) + const body = await res.json() as { sessions: Array> } + expect(body.sessions).toHaveLength(2) + expect(body.sessions[0]).toMatchObject({ id: 'ses_a', title: 'Alpha', repoId: 1, busy: true, outcome: 'succeeded', updated: 42 }) + expect(body.sessions[1]).toMatchObject({ id: 'ses_b', title: null, repoId: null, busy: false, outcome: null, updated: 43 }) + }) + + it('GET /api/internal/sessions returns 404 for an unknown repoId', async () => { + const res = await app.request('/api/internal/sessions?repoId=99', { + headers: { authorization: `Bearer ${token}` }, + }) + + expect(res.status).toBe(404) + expect(sessionList).not.toHaveBeenCalled() + }) + + it('POST /api/internal/sessions creates a session through the launcher and returns a url', async () => { + mockLaunch.mockResolvedValue({ + sessionId: 'ses_new', + repoId: 1, + directory: '/tmp/repo-one', + workspaceDirectory: null, + model: 'provider/model', + title: 'Do the thing', + }) + + const res = await app.request('/api/internal/sessions', { + method: 'POST', + headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, + body: JSON.stringify({ repoId: 1, prompt: 'Do the thing', title: 'Do the thing' }), + }) + + expect(res.status).toBe(201) + expect(mockLaunch).toHaveBeenCalledWith({ repoId: 1, prompt: 'Do the thing', title: 'Do the thing' }) + const body = await res.json() as { sessionId: string; url: string } + expect(body.sessionId).toBe('ses_new') + expect(body.url).toBe('/repos/1/sessions/ses_new') + }) + + it('POST /api/internal/sessions creates an isolated workspace with a slugified name', async () => { + mockLaunch.mockResolvedValue({ + sessionId: 'ses_ws', + repoId: 1, + directory: '/tmp/repo-one-ws', + workspaceDirectory: '/tmp/repo-one-ws', + model: 'provider/model', + title: 'My Feature', + }) + + const res = await app.request('/api/internal/sessions', { + method: 'POST', + headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, + body: JSON.stringify({ repoId: 1, prompt: 'Build it', title: 'My Feature!', worktree: true, ref: 'main' }), + }) + + expect(res.status).toBe(201) + expect(mockLaunch).toHaveBeenCalledWith({ + repoId: 1, + prompt: 'Build it', + title: 'My Feature!', + workspace: { name: 'my-feature', ref: 'main' }, + }) + const body = await res.json() as { url: string } + expect(body.url).toBe('/repos/1/sessions/ses_ws?repoTab=workspaces') + }) + + it('POST /api/internal/sessions uses the fallback workspace name when the title has no slug', async () => { + mockLaunch.mockResolvedValue({ + sessionId: 'ses_ws', + repoId: 1, + directory: '/tmp/repo-one-ws', + workspaceDirectory: '/tmp/repo-one-ws', + model: 'provider/model', + title: null, + }) + + const res = await app.request('/api/internal/sessions', { + method: 'POST', + headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, + body: JSON.stringify({ repoId: 1, prompt: 'Build it', worktree: true }), + }) + + expect(res.status).toBe(201) + expect(mockLaunch).toHaveBeenCalledWith({ repoId: 1, prompt: 'Build it', workspace: { name: 'ocm-session' } }) + }) + + it('POST /api/internal/sessions rejects an invalid body', async () => { + const res = await app.request('/api/internal/sessions', { + method: 'POST', + headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, + body: JSON.stringify({ repoId: 1 }), + }) + + expect(res.status).toBe(400) + expect(mockLaunch).not.toHaveBeenCalled() + }) + + it('POST /api/internal/sessions/:sessionId/prompt queues the prompt', async () => { + const res = await app.request('/api/internal/sessions/ses_a/prompt', { + method: 'POST', + headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, + body: JSON.stringify({ text: 'Keep going' }), + }) + + expect(res.status).toBe(202) + expect(await res.json()).toEqual({ queued: true }) + expect(sessionPrompt).toHaveBeenCalledWith({ sessionID: 'ses_a', text: 'Keep going', delivery: 'queue' }) + }) + + it('GET /api/internal/sessions/:sessionId/reply returns the latest assistant reply', async () => { + sessionActive.mockResolvedValue({ ses_a: { sessionID: 'ses_a' } }) + messageList.mockResolvedValue({ data: [assistantMessage] }) + + const res = await app.request('/api/internal/sessions/ses_a/reply', { + headers: { authorization: `Bearer ${token}` }, + }) + + expect(res.status).toBe(200) + expect(messageList).toHaveBeenCalledWith({ sessionID: 'ses_a', order: 'desc', limit: 20 }) + expect(await res.json()).toEqual({ busy: true, responseText: 'All done', errorText: null, completed: true }) + }) + + it('GET /api/internal/sessions/:sessionId/reply returns empty state when there is no assistant message', async () => { + const res = await app.request('/api/internal/sessions/ses_a/reply', { + headers: { authorization: `Bearer ${token}` }, + }) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ busy: false, responseText: null, errorText: null, completed: false }) + }) + + it('POST /api/internal/sessions/:sessionId/fork passes before and returns the new session', async () => { + sessionFork.mockResolvedValue({ id: 'ses_fork', location: { directory: '/tmp/repo-one' } }) + + const res = await app.request('/api/internal/sessions/ses_a/fork', { + method: 'POST', + headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, + body: JSON.stringify({ beforeMessageId: 'msg_1' }), + }) + + expect(res.status).toBe(200) + expect(sessionFork).toHaveBeenCalledWith({ sessionID: 'ses_a', before: 'msg_1' }) + expect(await res.json()).toEqual({ sessionId: 'ses_fork', directory: '/tmp/repo-one' }) + }) + + it('POST /api/internal/sessions/:sessionId/fork accepts an empty body', async () => { + sessionFork.mockResolvedValue({ id: 'ses_fork', location: { directory: '/tmp/repo-one' } }) + + const res = await app.request('/api/internal/sessions/ses_a/fork', { + method: 'POST', + headers: { authorization: `Bearer ${token}` }, + }) + + expect(res.status).toBe(200) + expect(sessionFork).toHaveBeenCalledWith({ sessionID: 'ses_a' }) + }) + + it('returns 404 when OpenCode reports an unknown session', async () => { + sessionPrompt.mockRejectedValue(sessionNotFoundError()) + + const res = await app.request('/api/internal/sessions/ses_missing/prompt', { + method: 'POST', + headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, + body: JSON.stringify({ text: 'Hello' }), + }) + + expect(res.status).toBe(404) + }) + + it('returns 404 for DELETE /api/internal/sessions/:sessionId because no route exists', async () => { + const res = await app.request('/api/internal/sessions/ses_a', { + method: 'DELETE', + headers: { authorization: `Bearer ${token}` }, + }) + + expect(res.status).toBe(404) + }) +}) diff --git a/backend/test/services/assistant-mode.test.ts b/backend/test/services/assistant-mode.test.ts index c2ca9d75b..d52921d81 100644 --- a/backend/test/services/assistant-mode.test.ts +++ b/backend/test/services/assistant-mode.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, beforeEach, afterEach } from 'bun:test' import path from 'path' import { access, readFile, writeFile } from 'fs/promises' import { Hono } from 'hono' -import { ensureAssistantMode, getAssistantModeStatus, buildSchedulesSkill, buildReposSkill, buildSettingsSkill, buildAssistantDefaultAgentMd, buildAssistantOpenCodeConfig, buildAssistantRepo, installAssistantWorkspace } from '../../src/services/assistant-mode' +import { ensureAssistantMode, getAssistantModeStatus, buildSchedulesSkill, buildReposSkill, buildSettingsSkill, buildSessionsSkill, buildAssistantDefaultAgentMd, buildAssistantOpenCodeConfig, buildAssistantRepo, installAssistantWorkspace } from '../../src/services/assistant-mode' import { createTempAssistantWorkspace, createTestDb, mockRepo } from '../helpers/assistant-workspace' import { createInternalRoutes } from '../../src/routes/internal' import { ScheduleService } from '../../src/services/schedules' @@ -60,6 +60,33 @@ describe('buildReposSkill', () => { }) }) +describe('buildSessionsSkill', () => { + it('instructs the agent to use the ocm tool request action', () => { + const skill = buildSessionsSkill() + expect(skill).toContain('name: session-management') + expect(skill).toContain('"action": "request"') + expect(skill).toContain('"path": "/sessions?repoId=1&limit=10"') + expect(skill).not.toContain('curl') + }) + + it('documents every session route and that creation returns immediately', () => { + const skill = buildSessionsSkill() + expect(skill).toContain('GET /sessions') + expect(skill).toContain('POST /sessions') + expect(skill).toContain('POST /sessions/:sessionId/prompt') + expect(skill).toContain('GET /sessions/:sessionId/reply') + expect(skill).toContain('POST /sessions/:sessionId/fork') + expect(skill).toContain('returns as soon as the prompt is queued') + expect(skill).toContain('poll') + }) + + it('documents that there is no delete route', () => { + const skill = buildSessionsSkill() + expect(skill).toContain('There is no delete route') + expect(skill).not.toContain('DELETE /sessions') + }) +}) + describe('buildSettingsSkill', () => { it('instructs the agent to use the ocm tool request action', () => { const skill = buildSettingsSkill() @@ -175,6 +202,7 @@ describe('ensureAssistantMode', () => { const opencodeJson = await readFile(path.join(ws.assistantDir, 'opencode.json'), 'utf8') const skill = await readFile(path.join(ws.assistantDir, '.opencode/skills/schedule-management/SKILL.md'), 'utf8') const repoSkill = await readFile(path.join(ws.assistantDir, '.opencode/skills/repo-management/SKILL.md'), 'utf8') + const sessionSkill = await readFile(path.join(ws.assistantDir, '.opencode/skills/session-management/SKILL.md'), 'utf8') const assistantAgent = await readFile(path.join(ws.assistantDir, '.opencode/agents/assistant.md'), 'utf8') expect(agentsMd).toContain('.opencode/agents/assistant.md') @@ -192,6 +220,9 @@ describe('ensureAssistantMode', () => { expect(repoSkill).toContain('GET /repos') expect(repoSkill).not.toContain('Authorization: Bearer') expect(repoSkill).not.toContain('.opencode/internal-token') + expect(sessionSkill).toContain('name: session-management') + expect(sessionSkill).toContain('GET /sessions') + expect(sessionSkill).not.toContain('Authorization: Bearer') expect(assistantAgent).toContain('mode: primary') expect(assistantAgent).toContain('Default OpenCode Manager assistant workspace agent') expect(assistantAgent).not.toContain('v file') @@ -212,6 +243,7 @@ describe('ensureAssistantMode', () => { const notificationsSkillPath = path.join(ws.assistantDir, '.opencode/skills/notifications/SKILL.md') const settingsSkillPath = path.join(ws.assistantDir, '.opencode/skills/manager-settings/SKILL.md') const reposSkillPath = path.join(ws.assistantDir, '.opencode/skills/repo-management/SKILL.md') + const sessionsSkillPath = path.join(ws.assistantDir, '.opencode/skills/session-management/SKILL.md') const assistantAgentPath = path.join(ws.assistantDir, '.opencode/agents/assistant.md') const opencodeJsonContent = await readFile(opencodeJsonPath, 'utf8') @@ -262,6 +294,10 @@ describe('ensureAssistantMode', () => { expect(reposSkillContent).toContain('name: repo-management') expect(reposSkillContent).toContain('List repos available') + const sessionsSkillContent = await readFile(sessionsSkillPath, 'utf8') + expect(sessionsSkillContent).toContain('name: session-management') + expect(sessionsSkillContent).toContain('Create and follow up') + const assistantAgentContent = await readFile(assistantAgentPath, 'utf8') expect(assistantAgentContent).toContain('mode: primary') expect(assistantAgentContent).toContain('Self-Editing') @@ -274,6 +310,8 @@ describe('ensureAssistantMode', () => { expect(result.files.agentsMd?.exists).toBe(true) expect(result.repoManagementSkill?.path).toBe(reposSkillPath) expect(result.repoManagementSkill?.created).toBe(true) + expect(result.sessionManagementSkill?.path).toBe(sessionsSkillPath) + expect(result.sessionManagementSkill?.created).toBe(true) expect(result.defaultAgent?.name).toBe('assistant') expect(result.defaultAgent?.path).toBe(assistantAgentPath) expect(result.defaultAgent?.exists).toBe(true) @@ -287,6 +325,8 @@ describe('ensureAssistantMode', () => { expect(status.repoManagementSkill?.path).toBe(path.join(ws.assistantDir, '.opencode/skills/repo-management/SKILL.md')) expect(status.repoManagementSkill?.created).toBe(false) + expect(status.sessionManagementSkill?.path).toBe(path.join(ws.assistantDir, '.opencode/skills/session-management/SKILL.md')) + expect(status.sessionManagementSkill?.created).toBe(false) }) it('preserves custom assistant agent content on subsequent ensureAssistantMode calls', async () => { diff --git a/backend/test/services/opencode-manager-tool-plugin.test.ts b/backend/test/services/opencode-manager-tool-plugin.test.ts index 79fb88140..c2bd023b6 100644 --- a/backend/test/services/opencode-manager-tool-plugin.test.ts +++ b/backend/test/services/opencode-manager-tool-plugin.test.ts @@ -255,6 +255,30 @@ describe('ocm-manager plugin', () => { } }) + it('allows session routes and rejects session deletion', async () => { + const tool = await loadTool(configHome) + const allowedRoutes = [ + ['POST', '/sessions'], + ['GET', '/sessions/abc/reply'], + ] as const + + for (const [method, path] of allowedRoutes) { + const fetchMock = jsonResponse({}) + vi.stubGlobal('fetch', fetchMock) + + await runTool(tool, { action: 'request', params: { method, path } }) + + expect(fetchMock).toHaveBeenCalledTimes(1) + } + + const fetchMock = jsonResponse({}) + vi.stubGlobal('fetch', fetchMock) + + await expect(runTool(tool, { action: 'request', params: { method: 'DELETE', path: '/sessions/abc' } })) + .rejects.toThrow(/is not an allowed OpenCode Manager route/) + expect(fetchMock).not.toHaveBeenCalled() + }) + it('rejects non-allow-listed routes without calling the API', async () => { const tool = await loadTool(configHome) const deniedRoutes = [ @@ -264,6 +288,7 @@ describe('ocm-manager plugin', () => { ['POST', '/notifications/send'], ['DELETE', '/settings'], ['PUT', '/opencode-config'], + ['DELETE', '/sessions/abc'], ] as const for (const [method, path] of deniedRoutes) { diff --git a/shared/src/schemas/index.ts b/shared/src/schemas/index.ts index c106483d6..bc6f2e5fc 100644 --- a/shared/src/schemas/index.ts +++ b/shared/src/schemas/index.ts @@ -13,4 +13,5 @@ export * from './internal-assistant' export * from './session-pins' export * from './session-permissions' export * from './session-goals' +export * from './internal-sessions' export * from './logs' diff --git a/shared/src/schemas/internal-sessions.ts b/shared/src/schemas/internal-sessions.ts new file mode 100644 index 000000000..04499c6d0 --- /dev/null +++ b/shared/src/schemas/internal-sessions.ts @@ -0,0 +1,31 @@ +import { z } from "zod"; + +export const InternalCreateSessionRequestSchema = z + .object({ + repoId: z.number().int(), + prompt: z.string().trim().min(1).max(20000), + title: z.string().max(200).optional(), + model: z.string().optional(), + agent: z.string().optional(), + worktree: z.boolean().optional(), + ref: z.string().optional(), + }) + .strict(); + +export type InternalCreateSessionRequest = z.infer; + +export const InternalSessionPromptRequestSchema = z + .object({ + text: z.string().trim().min(1).max(20000), + }) + .strict(); + +export type InternalSessionPromptRequest = z.infer; + +export const InternalForkSessionRequestSchema = z + .object({ + beforeMessageId: z.string().optional(), + }) + .strict(); + +export type InternalForkSessionRequest = z.infer; diff --git a/shared/src/schemas/repo.ts b/shared/src/schemas/repo.ts index 3e2923ef7..0c1cd6591 100644 --- a/shared/src/schemas/repo.ts +++ b/shared/src/schemas/repo.ts @@ -96,6 +96,10 @@ export const AssistantModeStatusSchema = z.object({ path: z.string(), created: z.boolean(), }).optional(), + sessionManagementSkill: z.object({ + path: z.string(), + created: z.boolean(), + }).optional(), defaultAgent: z.object({ name: z.literal('assistant'), path: z.string(), From 891f5427b4e75ffca8ce0324865a353175a57220 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:55:25 -0400 Subject: [PATCH 12/16] section 12: Phase 12: Multi-run backend: run groups, parallel launch and --- backend/src/db/migrations/027-multi-runs.ts | 16 + backend/src/db/migrations/index.ts | 2 + backend/src/db/multi-runs.ts | 244 ++++++++++ backend/src/index.ts | 5 + backend/src/routes/multi-runs.ts | 51 ++ backend/src/services/multi-runs.ts | 199 ++++++++ backend/src/services/session-launcher.ts | 7 +- backend/test/routes/multi-runs.test.ts | 136 ++++++ backend/test/services/multi-runs.test.ts | 453 ++++++++++++++++++ .../test/services/session-launcher.test.ts | 40 +- shared/src/schemas/index.ts | 1 + shared/src/schemas/multi-runs.ts | 50 ++ 12 files changed, 1201 insertions(+), 3 deletions(-) create mode 100644 backend/src/db/migrations/027-multi-runs.ts create mode 100644 backend/src/db/multi-runs.ts create mode 100644 backend/src/routes/multi-runs.ts create mode 100644 backend/src/services/multi-runs.ts create mode 100644 backend/test/routes/multi-runs.test.ts create mode 100644 backend/test/services/multi-runs.test.ts create mode 100644 shared/src/schemas/multi-runs.ts diff --git a/backend/src/db/migrations/027-multi-runs.ts b/backend/src/db/migrations/027-multi-runs.ts new file mode 100644 index 000000000..c97f69583 --- /dev/null +++ b/backend/src/db/migrations/027-multi-runs.ts @@ -0,0 +1,16 @@ +import type { Migration } from '../migration-runner' +import { ensureMultiRunTables } from '../multi-runs' + +const migration: Migration = { + version: 27, + name: 'multi-runs', + up(db) { + ensureMultiRunTables(db) + }, + down(db) { + db.run('DROP TABLE IF EXISTS multi_run_entries') + db.run('DROP TABLE IF EXISTS multi_runs') + }, +} + +export default migration diff --git a/backend/src/db/migrations/index.ts b/backend/src/db/migrations/index.ts index db2c76c74..3fbc66f03 100644 --- a/backend/src/db/migrations/index.ts +++ b/backend/src/db/migrations/index.ts @@ -25,6 +25,7 @@ import migration023 from './023-schedule-mcp-servers' import migration024 from './024-schedule-runs-viewed-at' import migration025 from './025-session-permission-modes' import migration026 from './026-session-goals' +import migration027 from './027-multi-runs' export const allMigrations: Migration[] = [ migration001, @@ -53,4 +54,5 @@ export const allMigrations: Migration[] = [ migration024, migration025, migration026, + migration027, ] diff --git a/backend/src/db/multi-runs.ts b/backend/src/db/multi-runs.ts new file mode 100644 index 000000000..209d4e99c --- /dev/null +++ b/backend/src/db/multi-runs.ts @@ -0,0 +1,244 @@ +import type { Database } from 'bun:sqlite' +import type { MultiRunEntryStatus } from '@opencode-manager/shared/schemas' + +export interface MultiRunEntryRecord { + id: number + multiRunId: number + model: string + status: MultiRunEntryStatus + sessionId: string | null + directory: string | null + isolated: boolean + error: string | null + createdAt: number + updatedAt: number +} + +export interface MultiRunRecord { + id: number + repoId: number + name: string + prompt: string + isolated: boolean + baseRef: string | null + createdAt: number + entries: MultiRunEntryRecord[] +} + +export interface CreateMultiRunGroup { + repoId: number + name: string + prompt: string + isolated: boolean + baseRef: string | null +} + +export interface MultiRunEntryPatch { + status?: MultiRunEntryStatus + sessionId?: string | null + directory?: string | null + error?: string | null +} + +interface MultiRunRow { + id: number + repo_id: number + name: string + prompt: string + isolated: number + base_ref: string | null + created_at: number +} + +interface MultiRunEntryRow { + id: number + multi_run_id: number + model: string + status: MultiRunEntryStatus + session_id: string | null + directory: string | null + error: string | null + created_at: number + updated_at: number +} + +const MULTI_RUN_COLUMNS = 'id, repo_id, name, prompt, isolated, base_ref, created_at' + +const MULTI_RUN_ENTRY_COLUMNS = 'id, multi_run_id, model, status, session_id, directory, error, created_at, updated_at' + +export function ensureMultiRunTables(db: Database): void { + db.run(` + CREATE TABLE IF NOT EXISTS multi_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + repo_id INTEGER NOT NULL, + name TEXT NOT NULL, + prompt TEXT NOT NULL, + isolated INTEGER NOT NULL, + base_ref TEXT, + created_at INTEGER NOT NULL + ) + `) + db.run(` + CREATE TABLE IF NOT EXISTS multi_run_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + multi_run_id INTEGER NOT NULL, + model TEXT NOT NULL, + status TEXT NOT NULL CHECK(status IN ('starting','started','failed','discarded')), + session_id TEXT, + directory TEXT, + error TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) + `) + db.run(` + CREATE INDEX IF NOT EXISTS idx_multi_run_entries_group + ON multi_run_entries(multi_run_id) + `) + db.run(` + CREATE INDEX IF NOT EXISTS idx_multi_runs_repo + ON multi_runs(repo_id, created_at DESC) + `) +} + +export function createMultiRunWithEntries( + db: Database, + group: CreateMultiRunGroup, + models: string[], +): MultiRunRecord { + const now = Date.now() + const create = db.transaction(() => { + const result = db + .prepare(`INSERT INTO multi_runs(repo_id, name, prompt, isolated, base_ref, created_at) VALUES(?,?,?,?,?,?)`) + .run(group.repoId, group.name, group.prompt, group.isolated ? 1 : 0, group.baseRef, now) + const multiRunId = Number(result.lastInsertRowid) + + const insertEntry = db.prepare(` + INSERT INTO multi_run_entries(multi_run_id, model, status, session_id, directory, error, created_at, updated_at) + VALUES(?,?,?,?,?,?,?,?) + `) + for (const model of models) { + insertEntry.run(multiRunId, model, 'starting', null, null, null, now, now) + } + + return multiRunId + }) + + const record = getMultiRun(db, create()) + if (!record) { + throw new Error('Failed to create multi-run') + } + return record +} + +export function getMultiRun(db: Database, id: number): MultiRunRecord | null { + const row = db + .prepare(`SELECT ${MULTI_RUN_COLUMNS} FROM multi_runs WHERE id = ?`) + .get(id) as MultiRunRow | undefined + if (!row) { + return null + } + return mapMultiRunRow(db, row) +} + +export function listMultiRuns(db: Database, repoId: number, limit: number): MultiRunRecord[] { + const rows = db + .prepare(` + SELECT ${MULTI_RUN_COLUMNS} FROM multi_runs + WHERE repo_id = ? + ORDER BY created_at DESC, id DESC + LIMIT ? + `) + .all(repoId, limit) as MultiRunRow[] + return rows.map((row) => mapMultiRunRow(db, row)) +} + +export function getMultiRunEntry(db: Database, multiRunId: number, entryId: number): MultiRunEntryRecord | null { + const row = db + .prepare(`SELECT ${MULTI_RUN_ENTRY_COLUMNS} FROM multi_run_entries WHERE id = ? AND multi_run_id = ?`) + .get(entryId, multiRunId) as MultiRunEntryRow | undefined + if (!row) { + return null + } + + const group = db.prepare('SELECT isolated FROM multi_runs WHERE id = ?').get(multiRunId) as + | { isolated: number } + | undefined + return mapEntryRow(row, group?.isolated === 1) +} + +export function updateMultiRunEntry( + db: Database, + entryId: number, + fromStatuses: MultiRunEntryStatus[], + patch: MultiRunEntryPatch, +): MultiRunEntryRecord | null { + const assignments: string[] = ['updated_at = ?'] + const values: (string | number | null)[] = [Date.now()] + + if (patch.status !== undefined) { + assignments.push('status = ?') + values.push(patch.status) + } + if (patch.sessionId !== undefined) { + assignments.push('session_id = ?') + values.push(patch.sessionId) + } + if (patch.directory !== undefined) { + assignments.push('directory = ?') + values.push(patch.directory) + } + if (patch.error !== undefined) { + assignments.push('error = ?') + values.push(patch.error) + } + + const placeholders = fromStatuses.map(() => '?').join(', ') + const result = db + .prepare(`UPDATE multi_run_entries SET ${assignments.join(', ')} WHERE id = ? AND status IN (${placeholders})`) + .run(...values, entryId, ...fromStatuses) + + if (result.changes === 0) { + return null + } + + const row = db.prepare('SELECT multi_run_id FROM multi_run_entries WHERE id = ?').get(entryId) as + | { multi_run_id: number } + | undefined + if (!row) { + return null + } + return getMultiRunEntry(db, row.multi_run_id, entryId) +} + +function mapMultiRunRow(db: Database, row: MultiRunRow): MultiRunRecord { + const entries = db + .prepare(`SELECT ${MULTI_RUN_ENTRY_COLUMNS} FROM multi_run_entries WHERE multi_run_id = ? ORDER BY id ASC`) + .all(row.id) as MultiRunEntryRow[] + + return { + id: row.id, + repoId: row.repo_id, + name: row.name, + prompt: row.prompt, + isolated: row.isolated === 1, + baseRef: row.base_ref, + createdAt: row.created_at, + entries: entries.map((entry) => mapEntryRow(entry, row.isolated === 1)), + } +} + +function mapEntryRow(row: MultiRunEntryRow, isolated: boolean): MultiRunEntryRecord { + return { + id: row.id, + multiRunId: row.multi_run_id, + model: row.model, + status: row.status, + sessionId: row.session_id, + directory: row.directory, + isolated, + error: row.error, + createdAt: row.created_at, + updatedAt: row.updated_at, + } +} diff --git a/backend/src/index.ts b/backend/src/index.ts index f33e8a8a4..86fd2f1b7 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -38,6 +38,7 @@ import { createPromptTemplateRoutes } from './routes/prompt-templates' import { createSessionPinRoutes } from './routes/session-pins' import { createSessionPermissionModeRoutes } from './routes/session-permission-modes' import { createSessionGoalRoutes } from './routes/session-goals' +import { createMultiRunRoutes } from './routes/multi-runs' import { createLogRoutes } from './routes/logs' import { createInternalRoutes } from './routes/internal' import { sweepStaleUploadSessions } from './routes/internal/repo-mirror-helpers' @@ -48,6 +49,7 @@ import { ensureDirectoryExists, writeFileContent, fileExists } from './services/ import { SettingsService } from './services/settings' import { SessionPermissionModeService } from './services/session-permission-modes' import { SessionGoalService } from './services/session-goals' +import { MultiRunService } from './services/multi-runs' import { opencodeServerManager } from './services/opencode-single-server' import { createOpenCodeClient } from './services/opencode/client' import { NotificationService } from './services/notification' @@ -241,6 +243,8 @@ const sessionGoalService = new SessionGoalService(db, openCodeClient, settingsSe }) sessionGoalService.loadOpenGoals() +const multiRunService = new MultiRunService(db, openCodeClient, gitAuthService) + sseAggregator.onEvent((directory, event) => { sessionPermissionModeService.handleEvent(directory, event).catch((err) => { logger.error('Session permission mode event handling error:', err) @@ -321,6 +325,7 @@ protectedApi.route('/prompt-templates', createPromptTemplateRoutes(db)) protectedApi.route('/session-pins', createSessionPinRoutes(db)) protectedApi.route('/session-permission-modes', createSessionPermissionModeRoutes(sessionPermissionModeService)) protectedApi.route('/session-goals', createSessionGoalRoutes(sessionGoalService)) +protectedApi.route('/multi-runs', createMultiRunRoutes(multiRunService)) protectedApi.route('/schedules', createScheduleRoutes(scheduleService)) protectedApi.route('/logs', createLogRoutes()) diff --git a/backend/src/routes/multi-runs.ts b/backend/src/routes/multi-runs.ts new file mode 100644 index 000000000..315453b43 --- /dev/null +++ b/backend/src/routes/multi-runs.ts @@ -0,0 +1,51 @@ +import { Hono } from 'hono' +import { LaunchMultiRunRequestSchema } from '@opencode-manager/shared/schemas' +import { MultiRunError, type MultiRunService } from '../services/multi-runs' +import { handleServiceError, parseId } from '../utils/route-helpers' + +export function createMultiRunRoutes(service: MultiRunService) { + const app = new Hono() + + app.get('/', (c) => { + try { + const repoId = parseId(c.req.query('repoId'), 'repoId', MultiRunError) + return c.json({ runs: service.list(repoId) }) + } catch (error) { + return handleServiceError(c, error, 'Failed to list multi-runs', MultiRunError) + } + }) + + app.post('/', async (c) => { + let body: unknown + try { + body = await c.req.json() + } catch { + return c.json({ error: 'Invalid request' }, 400) + } + + const parsed = LaunchMultiRunRequestSchema.safeParse(body) + if (!parsed.success) { + return c.json({ error: 'Invalid request' }, 400) + } + + try { + const run = await service.launch(parsed.data) + return c.json({ run }, 201) + } catch (error) { + return handleServiceError(c, error, 'Failed to launch multi-run', MultiRunError) + } + }) + + app.post('/:id/entries/:entryId/discard', async (c) => { + try { + const multiRunId = parseId(c.req.param('id'), 'multi-run id', MultiRunError) + const entryId = parseId(c.req.param('entryId'), 'entry id', MultiRunError) + const run = await service.discard(multiRunId, entryId) + return c.json({ run }) + } catch (error) { + return handleServiceError(c, error, 'Failed to discard multi-run entry', MultiRunError) + } + }) + + return app +} diff --git a/backend/src/services/multi-runs.ts b/backend/src/services/multi-runs.ts new file mode 100644 index 000000000..27da501cd --- /dev/null +++ b/backend/src/services/multi-runs.ts @@ -0,0 +1,199 @@ +import type { Database } from 'bun:sqlite' +import type { LaunchMultiRunRequest, MultiRun } from '@opencode-manager/shared/schemas' +import { sanitizeRepoDirectoryName } from '@opencode-manager/shared/utils' +import { + createMultiRunWithEntries, + getMultiRun, + getMultiRunEntry, + listMultiRuns, + updateMultiRunEntry, + type MultiRunEntryRecord, + type MultiRunRecord, +} from '../db/multi-runs' +import { getRepoById } from '../db/queries' +import { getErrorMessage } from '../utils/error-utils' +import type { GitAuthService } from './git-auth' +import type { OpenCodeClient } from './opencode/client' +import { removeRepoWorkspace } from './repo' +import { SessionLauncher, SessionLaunchError, type LaunchedSession } from './session-launcher' + +const MULTI_RUN_LIST_LIMIT = 20 + +export class MultiRunError extends Error { + status: number + + constructor(message: string, status: number) { + super(message) + this.name = 'MultiRunError' + this.status = status + } +} + +export function toMultiRun(record: MultiRunRecord): MultiRun { + return { + id: record.id, + repoId: record.repoId, + name: record.name, + prompt: record.prompt, + isolated: record.isolated, + baseRef: record.baseRef, + createdAt: record.createdAt, + entries: record.entries.map((entry) => ({ + id: entry.id, + model: entry.model, + status: entry.status, + sessionId: entry.sessionId, + directory: entry.directory, + isolated: entry.isolated, + error: entry.error, + createdAt: entry.createdAt, + updatedAt: entry.updatedAt, + })), + } +} + +export class MultiRunService { + private readonly sessionLauncher: SessionLauncher + private readonly discardingEntries = new Set() + + constructor( + private readonly db: Database, + private readonly openCodeClient: OpenCodeClient, + private readonly gitAuthService: GitAuthService, + ) { + this.sessionLauncher = new SessionLauncher(db, openCodeClient) + } + + async launch(request: LaunchMultiRunRequest): Promise { + const repo = getRepoById(this.db, request.repoId) + if (!repo || repo.cloneStatus !== 'ready') { + throw new MultiRunError('Repository not found or not ready', 404) + } + + const record = createMultiRunWithEntries( + this.db, + { + repoId: request.repoId, + name: request.name, + prompt: request.prompt, + isolated: request.isolate, + baseRef: request.baseRef ?? null, + }, + request.models, + ) + + const slug = sanitizeRepoDirectoryName(request.name) + const results = await Promise.allSettled( + record.entries.map((entry, index) => this.launchEntry(entry, index, request, slug)), + ) + + results.forEach((result, index) => { + const entry = record.entries[index] + if (!entry) { + return + } + if (result.status === 'fulfilled') { + updateMultiRunEntry(this.db, entry.id, ['starting'], { + status: 'started', + sessionId: result.value.sessionId, + directory: result.value.directory, + }) + return + } + + const workspaceDirectory = + result.reason instanceof SessionLaunchError ? result.reason.workspaceDirectory : null + updateMultiRunEntry(this.db, entry.id, ['starting'], { + status: 'failed', + error: getErrorMessage(result.reason) || 'Failed to launch session', + ...(workspaceDirectory ? { directory: workspaceDirectory } : {}), + }) + }) + + return this.reload(record.id) + } + + list(repoId: number): MultiRun[] { + return listMultiRuns(this.db, repoId, MULTI_RUN_LIST_LIMIT).map(toMultiRun) + } + + async discard(multiRunId: number, entryId: number): Promise { + const record = getMultiRun(this.db, multiRunId) + if (!record) { + throw new MultiRunError('Multi-run not found', 404) + } + + const entry = getMultiRunEntry(this.db, multiRunId, entryId) + if (!entry) { + throw new MultiRunError('Multi-run entry not found', 404) + } + + if (entry.status !== 'started' && entry.status !== 'failed') { + throw new MultiRunError('Multi-run entry cannot be discarded from its current state', 409) + } + + if (this.discardingEntries.has(entryId)) { + throw new MultiRunError('Multi-run entry discard already in progress', 409) + } + this.discardingEntries.add(entryId) + + try { + if (entry.isolated && entry.directory) { + const repo = getRepoById(this.db, record.repoId) + if (!repo) { + throw new MultiRunError('Repository not found', 404) + } + + try { + await removeRepoWorkspace( + this.db, + this.openCodeClient, + this.gitAuthService.getGitEnvironment(), + repo, + entry.directory, + ) + } catch (error) { + const status = typeof (error as { status?: unknown }).status === 'number' + ? (error as { status: number }).status + : 502 + throw new MultiRunError(getErrorMessage(error) || 'Failed to remove workspace', status) + } + } + + const updated = updateMultiRunEntry(this.db, entryId, ['started', 'failed'], { status: 'discarded' }) + if (!updated) { + throw new MultiRunError('Multi-run entry cannot be discarded from its current state', 409) + } + + return this.reload(multiRunId) + } finally { + this.discardingEntries.delete(entryId) + } + } + + private async launchEntry( + entry: MultiRunEntryRecord, + index: number, + request: LaunchMultiRunRequest, + slug: string, + ): Promise { + return this.sessionLauncher.launch({ + repoId: request.repoId, + prompt: request.prompt, + model: entry.model, + title: `${request.name} · ${entry.model}`, + ...(request.agent ? { agent: request.agent } : {}), + ...(request.isolate + ? { workspace: { name: `${slug}-${index + 1}`, ...(request.baseRef ? { ref: request.baseRef } : {}) } } + : {}), + }) + } + + private reload(multiRunId: number): MultiRun { + const record = getMultiRun(this.db, multiRunId) + if (!record) { + throw new MultiRunError('Multi-run not found', 404) + } + return toMultiRun(record) + } +} diff --git a/backend/src/services/session-launcher.ts b/backend/src/services/session-launcher.ts index 46763c07c..90b3f4336 100644 --- a/backend/src/services/session-launcher.ts +++ b/backend/src/services/session-launcher.ts @@ -26,11 +26,13 @@ export interface LaunchedSession { export class SessionLaunchError extends Error { readonly status: 400 | 404 | 502 + readonly workspaceDirectory: string | null - constructor(message: string, status: 400 | 404 | 502) { + constructor(message: string, status: 400 | 404 | 502, workspaceDirectory: string | null = null) { super(message) this.name = 'SessionLaunchError' this.status = status + this.workspaceDirectory = workspaceDirectory } } @@ -83,6 +85,7 @@ export class SessionLauncher { throw new SessionLaunchError( withWorkspace(getErrorMessage(error) || 'Failed to create OpenCode session', workspaceDirectory), 502, + workspaceDirectory, ) } @@ -110,6 +113,7 @@ export class SessionLauncher { throw new SessionLaunchError( withWorkspace(getErrorMessage(error) || 'Failed to resolve OpenCode model', workspaceDirectory), 502, + workspaceDirectory, ) } @@ -119,6 +123,7 @@ export class SessionLauncher { throw new SessionLaunchError( withWorkspace(`Model ${requestedModel} is not available`, workspaceDirectory), 400, + workspaceDirectory, ) } } diff --git a/backend/test/routes/multi-runs.test.ts b/backend/test/routes/multi-runs.test.ts new file mode 100644 index 000000000..41c7607c6 --- /dev/null +++ b/backend/test/routes/multi-runs.test.ts @@ -0,0 +1,136 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { Hono } from 'hono' +import type { MultiRun } from '@opencode-manager/shared/schemas' +import { createMultiRunRoutes } from '../../src/routes/multi-runs' +import { MultiRunError, type MultiRunService } from '../../src/services/multi-runs' + +function createRun(overrides: Partial = {}): MultiRun { + return { + id: 1, + repoId: 1, + name: 'Sweep', + prompt: 'go', + isolated: true, + baseRef: null, + createdAt: 1, + entries: [], + ...overrides, + } +} + +function launchBody(overrides: Record = {}): string { + return JSON.stringify({ + repoId: 1, + name: 'Sweep', + prompt: 'go', + models: ['openai/a'], + isolate: true, + ...overrides, + }) +} + +describe('multi-run routes', () => { + let service: { + list: ReturnType + launch: ReturnType + discard: ReturnType + } + let app: Hono + + beforeEach(() => { + service = { list: vi.fn(), launch: vi.fn(), discard: vi.fn() } + app = new Hono() + app.route('/multi-runs', createMultiRunRoutes(service as unknown as MultiRunService)) + }) + + it('GET returns the repository runs', async () => { + service.list.mockReturnValue([createRun()]) + + const res = await app.request('/multi-runs?repoId=1') + + expect(res.status).toBe(200) + await expect(res.json()).resolves.toEqual({ runs: [createRun()] }) + expect(service.list).toHaveBeenCalledWith(1) + }) + + it('GET rejects a missing repoId with 400', async () => { + const res = await app.request('/multi-runs') + + expect(res.status).toBe(400) + expect(service.list).not.toHaveBeenCalled() + }) + + it('POST launches a run and returns 201', async () => { + service.launch.mockResolvedValue(createRun()) + + const res = await app.request('/multi-runs', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: launchBody(), + }) + + expect(res.status).toBe(201) + await expect(res.json()).resolves.toEqual({ run: createRun() }) + expect(service.launch).toHaveBeenCalledWith({ + repoId: 1, + name: 'Sweep', + prompt: 'go', + models: ['openai/a'], + isolate: true, + }) + }) + + it('POST rejects more than five models with 400', async () => { + const res = await app.request('/multi-runs', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: launchBody({ + models: ['openai/a', 'openai/b', 'openai/c', 'openai/d', 'openai/e', 'openai/f'], + }), + }) + + expect(res.status).toBe(400) + expect(service.launch).not.toHaveBeenCalled() + }) + + it('POST rejects duplicate models with 400', async () => { + const res = await app.request('/multi-runs', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: launchBody({ models: ['openai/a', 'openai/a'] }), + }) + + expect(res.status).toBe(400) + expect(service.launch).not.toHaveBeenCalled() + }) + + it('POST maps a service error to its status', async () => { + service.launch.mockRejectedValue(new MultiRunError('Repository not found or not ready', 404)) + + const res = await app.request('/multi-runs', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: launchBody(), + }) + + expect(res.status).toBe(404) + }) + + it('POST discard calls the service with numeric ids', async () => { + service.discard.mockResolvedValue(createRun()) + + const res = await app.request('/multi-runs/3/entries/7/discard', { method: 'POST' }) + + expect(res.status).toBe(200) + await expect(res.json()).resolves.toEqual({ run: createRun() }) + expect(service.discard).toHaveBeenCalledWith(3, 7) + }) + + it('POST discard maps a missing entry to 404', async () => { + service.discard.mockRejectedValue(new MultiRunError('Multi-run entry not found', 404)) + + const res = await app.request('/multi-runs/3/entries/7/discard', { method: 'POST' }) + + expect(res.status).toBe(404) + }) +}) diff --git a/backend/test/services/multi-runs.test.ts b/backend/test/services/multi-runs.test.ts new file mode 100644 index 000000000..8a95616cc --- /dev/null +++ b/backend/test/services/multi-runs.test.ts @@ -0,0 +1,453 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { Database } from 'bun:sqlite' +import { createRepo } from '../../src/db/queries' +import { migrate } from '../../src/db/migration-runner' +import { allMigrations } from '../../src/db/migrations' +import { createMultiRunWithEntries } from '../../src/db/multi-runs' +import { MultiRunError, MultiRunService } from '../../src/services/multi-runs' +import type { GitAuthService } from '../../src/services/git-auth' +import type { OpenCodeClient } from '../../src/services/opencode/client' + +const REPO_DIR = '/repos/repo-a' + +const mocks = vi.hoisted(() => ({ + resolveOpenCodeModel: vi.fn(), + resolveProjectId: vi.fn(), + isGitMainCheckout: vi.fn(), + executeCommand: vi.fn(), +})) + +vi.mock('../../src/services/opencode-models', () => ({ + resolveOpenCodeModel: mocks.resolveOpenCodeModel, +})) + +vi.mock('../../src/services/project-id-resolver', () => ({ + resolveProjectId: mocks.resolveProjectId, + isGitMainCheckout: mocks.isGitMainCheckout, +})) + +vi.mock('../../src/utils/process', () => ({ + executeCommand: mocks.executeCommand, +})) + +interface FakeMultiRunClient { + client: OpenCodeClient + sessionCreate: ReturnType + sessionPrompt: ReturnType + worktreeCreate: ReturnType + worktreeList: ReturnType + worktreeRemove: ReturnType + workspaces: Array<{ directory: string; strategy: string }> +} + +function createClient(): FakeMultiRunClient { + let sessionCounter = 0 + const workspaces: Array<{ directory: string; strategy: string }> = [] + + const sessionCreate = vi.fn(async (input: { title?: string }) => { + sessionCounter += 1 + return { id: `ses_${sessionCounter}`, title: input?.title } + }) + const sessionPrompt = vi.fn(async () => ({})) + const worktreeCreate = vi.fn(async (input: { name?: string }) => { + const directory = `/worktrees/${input.name ?? 'workspace'}` + workspaces.push({ directory, strategy: 'git' }) + return { directory } + }) + const worktreeList = vi.fn(async () => workspaces) + const worktreeRemove = vi.fn(async () => undefined) + + const client = { + api: { + location: { + get: vi.fn(async () => ({ project: { id: 'commit-A', directory: REPO_DIR, canonical: REPO_DIR } })), + }, + worktree: { create: worktreeCreate, list: worktreeList, remove: worktreeRemove }, + session: { create: sessionCreate, prompt: sessionPrompt }, + }, + } as unknown as OpenCodeClient + + return { client, sessionCreate, sessionPrompt, worktreeCreate, worktreeList, worktreeRemove, workspaces } +} + +describe('MultiRunService', () => { + let db: Database + + beforeEach(() => { + vi.clearAllMocks() + db = new Database(':memory:') + migrate(db, allMigrations) + + mocks.resolveOpenCodeModel.mockImplementation( + async (_client: unknown, _directory: string, options: { preferredModel?: string }) => { + const preferred = options?.preferredModel + if (!preferred) { + return { providerID: 'openai', id: 'gpt-5', model: 'openai/gpt-5' } + } + if (preferred === 'openai/retired') { + return { providerID: 'openai', id: 'retired-x', model: 'openai/retired-x' } + } + const [providerID, ...rest] = preferred.split('/') + return { providerID, id: rest.join('/'), model: preferred } + }, + ) + mocks.resolveProjectId.mockResolvedValue('commit-A') + mocks.isGitMainCheckout.mockResolvedValue(false) + mocks.executeCommand.mockResolvedValue('') + }) + + afterEach(() => { + db.close() + }) + + function readyRepo(): number { + const repo = createRepo(db, { + localPath: 'repo-a', + sourcePath: REPO_DIR, + defaultBranch: 'main', + cloneStatus: 'ready', + clonedAt: Date.now(), + isLocal: true, + }) + return repo.id + } + + function createService(client: OpenCodeClient): MultiRunService { + const gitAuthService = { getGitEnvironment: () => ({}) } as unknown as GitAuthService + return new MultiRunService(db, client, gitAuthService) + } + + it('launches one session per model, each in its own workspace named from the group', async () => { + const repoId = readyRepo() + const { client, sessionCreate, sessionPrompt, worktreeCreate } = createClient() + const service = createService(client) + + const run = await service.launch({ + repoId, + name: 'Feature Sweep', + prompt: 'do the thing', + models: ['openai/a', 'openai/b', 'openai/c'], + isolate: true, + baseRef: 'develop', + }) + + expect(run).toMatchObject({ + repoId, + name: 'Feature Sweep', + prompt: 'do the thing', + isolated: true, + baseRef: 'develop', + }) + expect(run.entries.map((entry) => entry.model)).toEqual(['openai/a', 'openai/b', 'openai/c']) + expect(run.entries.map((entry) => entry.status)).toEqual(['started', 'started', 'started']) + expect(run.entries.map((entry) => entry.isolated)).toEqual([true, true, true]) + expect(run.entries.map((entry) => entry.directory)).toEqual([ + '/worktrees/Feature-Sweep-1', + '/worktrees/Feature-Sweep-2', + '/worktrees/Feature-Sweep-3', + ]) + expect(new Set(run.entries.map((entry) => entry.sessionId)).size).toBe(3) + + expect(sessionCreate).toHaveBeenCalledTimes(3) + expect(sessionPrompt).toHaveBeenCalledTimes(3) + expect(sessionPrompt.mock.calls.every((call) => call[0].text === 'do the thing')).toBe(true) + expect( + sessionCreate.mock.calls.map((call) => `${call[0].model.providerID}/${call[0].model.id}`).sort(), + ).toEqual(['openai/a', 'openai/b', 'openai/c']) + + const workspaceCalls = worktreeCreate.mock.calls.map((call) => call[0]) + expect(workspaceCalls.map((call) => call.name).sort()).toEqual([ + 'Feature-Sweep-1', + 'Feature-Sweep-2', + 'Feature-Sweep-3', + ]) + expect(workspaceCalls.every((call) => call.branch === 'develop')).toBe(true) + }) + + it('keeps launching the other models when one model fails', async () => { + const repoId = readyRepo() + const { client, worktreeCreate } = createClient() + const service = createService(client) + + const run = await service.launch({ + repoId, + name: 'Sweep', + prompt: 'go', + models: ['openai/a', 'openai/retired', 'openai/c'], + isolate: false, + }) + + expect(run.entries.map((entry) => entry.status)).toEqual(['started', 'failed', 'started']) + expect(run.entries[1]!.error).toBe('Model openai/retired is not available') + expect(run.entries[1]!.sessionId).toBeNull() + expect(run.entries[0]!.sessionId).toBeTruthy() + expect(run.entries[2]!.sessionId).toBeTruthy() + expect(worktreeCreate).not.toHaveBeenCalled() + }) + + it('does not create workspaces when isolation is off', async () => { + const repoId = readyRepo() + const { client, worktreeCreate } = createClient() + const service = createService(client) + + const run = await service.launch({ + repoId, + name: 'Sweep', + prompt: 'go', + models: ['openai/a', 'openai/b'], + isolate: false, + }) + + expect(run.entries.map((entry) => entry.status)).toEqual(['started', 'started']) + expect(run.entries.every((entry) => entry.directory === REPO_DIR)).toBe(true) + expect(run.entries.every((entry) => entry.isolated === false)).toBe(true) + expect(worktreeCreate).not.toHaveBeenCalled() + }) + + it('rejects a repository that is not ready', async () => { + const { client } = createClient() + const service = createService(client) + + const error = await service + .launch({ repoId: 999, name: 'Sweep', prompt: 'go', models: ['openai/a'], isolate: false }) + .catch((caught: unknown) => caught) + + expect(error).toBeInstanceOf(MultiRunError) + expect(error).toMatchObject({ status: 404 }) + }) + + it('removes the workspace when discarding an isolated entry', async () => { + const repoId = readyRepo() + const { client, worktreeRemove } = createClient() + const service = createService(client) + + const run = await service.launch({ + repoId, + name: 'Sweep', + prompt: 'go', + models: ['openai/a'], + isolate: true, + }) + const entry = run.entries[0]! + expect(entry.directory).toBe('/worktrees/Sweep-1') + + const discarded = await service.discard(run.id, entry.id) + + expect(discarded.entries[0]!.status).toBe('discarded') + expect(worktreeRemove).toHaveBeenCalledWith({ + projectID: 'commit-A', + directory: '/worktrees/Sweep-1', + force: true, + }) + }) + + it('rejects discarding an entry twice', async () => { + const repoId = readyRepo() + const { client, worktreeRemove } = createClient() + const service = createService(client) + + const run = await service.launch({ + repoId, + name: 'Sweep', + prompt: 'go', + models: ['openai/a'], + isolate: true, + }) + const entry = run.entries[0]! + await service.discard(run.id, entry.id) + + const error = await service.discard(run.id, entry.id).catch((caught: unknown) => caught) + + expect(error).toBeInstanceOf(MultiRunError) + expect(error).toMatchObject({ status: 409 }) + expect(worktreeRemove).toHaveBeenCalledTimes(1) + }) + + it('does not remove a workspace when discarding a non-isolated entry', async () => { + const repoId = readyRepo() + const { client, worktreeRemove } = createClient() + const service = createService(client) + + const run = await service.launch({ + repoId, + name: 'Sweep', + prompt: 'go', + models: ['openai/a'], + isolate: false, + }) + + const discarded = await service.discard(run.id, run.entries[0]!.id) + + expect(discarded.entries[0]!.status).toBe('discarded') + expect(worktreeRemove).not.toHaveBeenCalled() + }) + + it('records the workspace of a failed isolated launch and removes it on discard', async () => { + const repoId = readyRepo() + const { client, worktreeRemove } = createClient() + const service = createService(client) + + const run = await service.launch({ + repoId, + name: 'Sweep', + prompt: 'go', + models: ['openai/retired'], + isolate: true, + }) + const entry = run.entries[0]! + + expect(entry.status).toBe('failed') + expect(entry.directory).toBe('/worktrees/Sweep-1') + expect(entry.error).toContain('/worktrees/Sweep-1') + + const discarded = await service.discard(run.id, entry.id) + + expect(discarded.entries[0]!.status).toBe('discarded') + expect(worktreeRemove).toHaveBeenCalledTimes(1) + expect(worktreeRemove).toHaveBeenCalledWith({ + projectID: 'commit-A', + directory: '/worktrees/Sweep-1', + force: true, + }) + }) + + it('records the workspace of a failed isolated launch when prompting fails and removes it on discard', async () => { + const repoId = readyRepo() + const { client, sessionPrompt, worktreeRemove } = createClient() + sessionPrompt.mockRejectedValueOnce(new Error('prompt boom')) + const service = createService(client) + + const run = await service.launch({ + repoId, + name: 'Sweep', + prompt: 'go', + models: ['openai/a'], + isolate: true, + }) + const entry = run.entries[0]! + + expect(entry.status).toBe('failed') + expect(entry.directory).toBe('/worktrees/Sweep-1') + expect(entry.error).toContain('prompt boom') + + const discarded = await service.discard(run.id, entry.id) + + expect(discarded.entries[0]!.status).toBe('discarded') + expect(worktreeRemove).toHaveBeenCalledTimes(1) + }) + + it('does not record or remove a workspace when isolated workspace creation fails', async () => { + const repoId = readyRepo() + const { client, worktreeCreate, worktreeRemove } = createClient() + worktreeCreate.mockRejectedValueOnce(new Error('no workspace')) + const service = createService(client) + + const run = await service.launch({ + repoId, + name: 'Sweep', + prompt: 'go', + models: ['openai/a'], + isolate: true, + }) + const entry = run.entries[0]! + + expect(entry.status).toBe('failed') + expect(entry.directory).toBeNull() + + const discarded = await service.discard(run.id, entry.id) + + expect(discarded.entries[0]!.status).toBe('discarded') + expect(worktreeRemove).not.toHaveBeenCalled() + }) + + it('removes the workspace once when discards race', async () => { + const repoId = readyRepo() + const { client, worktreeRemove } = createClient() + const service = createService(client) + + const run = await service.launch({ + repoId, + name: 'Sweep', + prompt: 'go', + models: ['openai/a'], + isolate: true, + }) + const entry = run.entries[0]! + + let releaseRemoval: () => void = () => {} + worktreeRemove.mockReturnValueOnce( + new Promise((resolve) => { + releaseRemoval = resolve + }), + ) + + const first = service.discard(run.id, entry.id) + const second = service.discard(run.id, entry.id).catch((caught: unknown) => caught) + + const error = await second + expect(error).toBeInstanceOf(MultiRunError) + expect(error).toMatchObject({ status: 409 }) + + releaseRemoval() + const discarded = await first + + expect(discarded.entries[0]!.status).toBe('discarded') + expect(worktreeRemove).toHaveBeenCalledTimes(1) + }) + + it('keeps the entry retryable when workspace removal fails', async () => { + const repoId = readyRepo() + const { client, worktreeRemove } = createClient() + const service = createService(client) + + const run = await service.launch({ + repoId, + name: 'Sweep', + prompt: 'go', + models: ['openai/a'], + isolate: true, + }) + const entry = run.entries[0]! + + worktreeRemove.mockRejectedValueOnce(new Error('removal failed')) + + const error = await service.discard(run.id, entry.id).catch((caught: unknown) => caught) + expect(error).toBeInstanceOf(MultiRunError) + expect(error).toMatchObject({ status: 502 }) + + expect(service.list(repoId)[0]!.entries[0]!.status).toBe('started') + + const discarded = await service.discard(run.id, entry.id) + + expect(discarded.entries[0]!.status).toBe('discarded') + expect(worktreeRemove).toHaveBeenCalledTimes(2) + }) + + it('lists the most recent groups for a repository', async () => { + const repoId = readyRepo() + const { client } = createClient() + const service = createService(client) + + await service.launch({ repoId, name: 'First', prompt: 'go', models: ['openai/a'], isolate: false }) + await service.launch({ repoId, name: 'Second', prompt: 'go', models: ['openai/b'], isolate: false }) + + const runs = service.list(repoId) + + expect(runs.map((run) => run.name)).toEqual(['Second', 'First']) + }) + + it('persists the group and its entries in one transaction', () => { + const repoId = readyRepo() + db.run(`CREATE TRIGGER fail_entry BEFORE INSERT ON multi_run_entries BEGIN SELECT RAISE(ABORT, 'nope'); END`) + + expect(() => + createMultiRunWithEntries(db, { repoId, name: 'Sweep', prompt: 'go', isolated: false, baseRef: null }, [ + 'openai/a', + 'openai/b', + ]), + ).toThrow() + + const count = db.prepare('SELECT COUNT(*) AS count FROM multi_runs').get() as { count: number } + expect(count.count).toBe(0) + }) +}) diff --git a/backend/test/services/session-launcher.test.ts b/backend/test/services/session-launcher.test.ts index c97ce7db9..d81b6365d 100644 --- a/backend/test/services/session-launcher.test.ts +++ b/backend/test/services/session-launcher.test.ts @@ -23,13 +23,18 @@ interface FakeLaunchClient { worktreeCreate: ReturnType } -function createClient(overrides: { workspaceDirectory?: string; createError?: Error } = {}): FakeLaunchClient { +function createClient( + overrides: { workspaceDirectory?: string; createError?: Error; worktreeCreateError?: Error } = {}, +): FakeLaunchClient { const create = vi.fn(async (input: { title?: string }) => { if (overrides.createError) throw overrides.createError return { id: 'ses_new', title: input?.title } }) const prompt = vi.fn(async () => ({})) - const worktreeCreate = vi.fn(async () => ({ directory: overrides.workspaceDirectory ?? '/worktrees/feature-x' })) + const worktreeCreate = vi.fn(async () => { + if (overrides.worktreeCreateError) throw overrides.worktreeCreateError + return { directory: overrides.workspaceDirectory ?? '/worktrees/feature-x' } + }) const client = { api: { @@ -160,7 +165,38 @@ describe('SessionLauncher', () => { expect(error).toMatchObject({ status: 502, message: 'boom (workspace: /worktrees/feature-x)', + workspaceDirectory: '/worktrees/feature-x', }) expect(worktreeCreate).toHaveBeenCalled() }) + + it('exposes the created workspace when the requested model is unavailable', async () => { + const repoId = readyRepo() + const { client, create } = createClient({ workspaceDirectory: '/worktrees/feature-x' }) + const launcher = new SessionLauncher(db, client) + + const error = await launcher + .launch({ repoId, prompt: 'hello', model: 'openai/retired', workspace: { name: 'feature-x' } }) + .catch((caught: unknown) => caught) + + expect(error).toBeInstanceOf(SessionLaunchError) + expect(error).toMatchObject({ + status: 400, + workspaceDirectory: '/worktrees/feature-x', + }) + expect(create).not.toHaveBeenCalled() + }) + + it('exposes no workspace when workspace creation fails', async () => { + const repoId = readyRepo() + const { client } = createClient({ worktreeCreateError: new Error('no workspace') }) + const launcher = new SessionLauncher(db, client) + + const error = await launcher + .launch({ repoId, prompt: 'hello', workspace: { name: 'feature-x' } }) + .catch((caught: unknown) => caught) + + expect(error).toBeInstanceOf(SessionLaunchError) + expect(error).toMatchObject({ status: 502, workspaceDirectory: null }) + }) }) diff --git a/shared/src/schemas/index.ts b/shared/src/schemas/index.ts index bc6f2e5fc..34362d615 100644 --- a/shared/src/schemas/index.ts +++ b/shared/src/schemas/index.ts @@ -14,4 +14,5 @@ export * from './session-pins' export * from './session-permissions' export * from './session-goals' export * from './internal-sessions' +export * from './multi-runs' export * from './logs' diff --git a/shared/src/schemas/multi-runs.ts b/shared/src/schemas/multi-runs.ts new file mode 100644 index 000000000..d65c2366d --- /dev/null +++ b/shared/src/schemas/multi-runs.ts @@ -0,0 +1,50 @@ +import { z } from "zod"; + +export const MULTI_RUN_MAX_MODELS = 5; + +export const MultiRunEntryStatusSchema = z.enum(["starting", "started", "failed", "discarded"]); + +export type MultiRunEntryStatus = z.infer; + +export const MultiRunEntrySchema = z.object({ + id: z.number().int(), + model: z.string(), + status: MultiRunEntryStatusSchema, + sessionId: z.string().nullable(), + directory: z.string().nullable(), + isolated: z.boolean(), + error: z.string().nullable(), + createdAt: z.number(), + updatedAt: z.number(), +}); + +export type MultiRunEntry = z.infer; + +export const MultiRunSchema = z.object({ + id: z.number().int(), + repoId: z.number().int(), + name: z.string(), + prompt: z.string(), + isolated: z.boolean(), + baseRef: z.string().nullable(), + createdAt: z.number(), + entries: z.array(MultiRunEntrySchema), +}); + +export type MultiRun = z.infer; + +export const LaunchMultiRunRequestSchema = z.object({ + repoId: z.number().int(), + name: z.string().trim().min(1).max(80), + prompt: z.string().trim().min(1).max(20000), + models: z + .array(z.string().min(3)) + .min(1) + .max(MULTI_RUN_MAX_MODELS) + .refine((models) => new Set(models).size === models.length, { message: "Models must be unique" }), + isolate: z.boolean(), + baseRef: z.string().trim().min(1).optional(), + agent: z.string().optional(), +}); + +export type LaunchMultiRunRequest = z.infer; From 444a43aa1dd5314d3b43b3c9b91bdab31cec71d5 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Sat, 3 Oct 2026 11:03:06 -0400 Subject: [PATCH 13/16] section 13: Phase 13: Multi-run launcher and run groups in the repo page --- frontend/src/api/multiRuns.ts | 27 ++ .../components/repo/MultiRunDialog.test.tsx | 217 +++++++++++ .../src/components/repo/MultiRunDialog.tsx | 336 ++++++++++++++++++ frontend/src/hooks/useMultiRuns.ts | 66 ++++ frontend/src/hooks/useRepoSiblings.ts | 10 +- frontend/src/pages/RepoDetail.tsx | 22 +- 6 files changed, 674 insertions(+), 4 deletions(-) create mode 100644 frontend/src/api/multiRuns.ts create mode 100644 frontend/src/components/repo/MultiRunDialog.test.tsx create mode 100644 frontend/src/components/repo/MultiRunDialog.tsx create mode 100644 frontend/src/hooks/useMultiRuns.ts diff --git a/frontend/src/api/multiRuns.ts b/frontend/src/api/multiRuns.ts new file mode 100644 index 000000000..fb2f6784f --- /dev/null +++ b/frontend/src/api/multiRuns.ts @@ -0,0 +1,27 @@ +import { fetchWrapper } from './fetchWrapper' +import { API_BASE_URL } from '@/config' +import type { LaunchMultiRunRequest, MultiRun } from '@opencode-manager/shared/schemas' + +export async function listMultiRuns(repoId: number): Promise { + const res = await fetchWrapper<{ runs: MultiRun[] }>(`${API_BASE_URL}/api/multi-runs`, { + params: { repoId }, + }) + return res.runs +} + +export async function launchMultiRun(request: LaunchMultiRunRequest): Promise { + const res = await fetchWrapper<{ run: MultiRun }>(`${API_BASE_URL}/api/multi-runs`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + }) + return res.run +} + +export async function discardMultiRunEntry(runId: number, entryId: number): Promise { + const res = await fetchWrapper<{ run: MultiRun }>( + `${API_BASE_URL}/api/multi-runs/${encodeURIComponent(runId)}/entries/${encodeURIComponent(entryId)}/discard`, + { method: 'POST' }, + ) + return res.run +} diff --git a/frontend/src/components/repo/MultiRunDialog.test.tsx b/frontend/src/components/repo/MultiRunDialog.test.tsx new file mode 100644 index 000000000..356e16286 --- /dev/null +++ b/frontend/src/components/repo/MultiRunDialog.test.tsx @@ -0,0 +1,217 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import type { ReactNode } from 'react' +import { MultiRunDialog } from './MultiRunDialog' +import type { MultiRun } from '@opencode-manager/shared/schemas' + +const mocks = vi.hoisted(() => ({ + listMultiRuns: vi.fn(), + launchMultiRun: vi.fn(), + discardMultiRunEntry: vi.fn(), + useProvidersWithModels: vi.fn(), +})) + +const mockNavigate = vi.fn() + +vi.mock('react-router-dom', async (importOriginal) => ({ + ...(await importOriginal('react-router-dom')), + useNavigate: () => mockNavigate, +})) + +vi.mock('@/api/multiRuns', () => ({ + listMultiRuns: mocks.listMultiRuns, + launchMultiRun: mocks.launchMultiRun, + discardMultiRunEntry: mocks.discardMultiRunEntry, +})) + +vi.mock('@/hooks/useProvidersWithModels', () => ({ + useProvidersWithModels: mocks.useProvidersWithModels, +})) + +vi.mock('@/lib/toast', () => ({ + showToast: { + success: vi.fn(), + error: vi.fn(), + warning: vi.fn(), + info: vi.fn(), + }, +})) + +const providers = [ + { + id: 'openai', + name: 'OpenAI', + source: 'configured', + isConnected: true, + models: [ + { id: 'gpt-4o', name: 'GPT-4o' }, + { id: 'gpt-4o-mini', name: 'GPT-4o mini' }, + { id: 'gpt-4.1', name: 'GPT-4.1' }, + ], + }, + { + id: 'anthropic', + name: 'Anthropic', + source: 'configured', + isConnected: true, + models: [ + { id: 'claude-opus', name: 'Claude Opus' }, + { id: 'claude-sonnet', name: 'Claude Sonnet' }, + { id: 'claude-haiku', name: 'Claude Haiku' }, + ], + }, +] + +const startedRun: MultiRun = { + id: 3, + repoId: 7, + name: 'Sweep', + prompt: 'go', + isolated: true, + baseRef: 'main', + createdAt: 1, + entries: [ + { + id: 11, + model: 'openai/gpt-4o', + status: 'started', + sessionId: 'ses_1', + directory: '/workspaces/sweep-1', + isolated: true, + error: null, + createdAt: 1, + updatedAt: 1, + }, + { + id: 12, + model: 'anthropic/claude-opus', + status: 'failed', + sessionId: null, + directory: null, + isolated: true, + error: 'launch failed', + createdAt: 1, + updatedAt: 1, + }, + ], +} + +function createWrapper() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + return ({ children }: { children: ReactNode }) => ( + {children} + ) +} + +function renderDialog(overrides: Partial> = {}) { + return render( + , + { wrapper: createWrapper() }, + ) +} + +describe('MultiRunDialog', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.useProvidersWithModels.mockReturnValue({ data: providers, isLoading: false }) + mocks.listMultiRuns.mockResolvedValue([]) + }) + + it('caps model selection at five', async () => { + const user = userEvent.setup() + renderDialog() + + const names = ['GPT-4o', 'GPT-4o mini', 'GPT-4.1', 'Claude Opus', 'Claude Sonnet'] + for (const name of names) { + await user.click(screen.getByRole('checkbox', { name })) + } + + expect(screen.getByText('5/5 selected')).toBeInTheDocument() + expect(screen.getByRole('checkbox', { name: 'Claude Haiku' })).toBeDisabled() + + await user.click(screen.getByRole('checkbox', { name: 'Claude Haiku' })) + expect(screen.getByText('5/5 selected')).toBeInTheDocument() + }) + + it('submits the launch request', async () => { + const user = userEvent.setup() + mocks.launchMultiRun.mockResolvedValue(startedRun) + renderDialog() + + await user.type(screen.getByLabelText('Group name'), 'Sweep') + await user.type(screen.getByLabelText('Prompt'), 'go') + await user.click(screen.getByRole('checkbox', { name: 'GPT-4o' })) + await user.click(screen.getByRole('button', { name: 'Launch' })) + + await waitFor(() => { + expect(mocks.launchMultiRun).toHaveBeenCalledWith({ + repoId: 7, + name: 'Sweep', + prompt: 'go', + models: ['openai/gpt-4o'], + isolate: true, + baseRef: 'main', + }) + }) + }) + + it('lists run entries with Open and Discard actions', async () => { + const user = userEvent.setup() + mocks.listMultiRuns.mockResolvedValue([startedRun]) + renderDialog() + + await user.click(screen.getByRole('tab', { name: 'Runs' })) + + await waitFor(() => { + expect(screen.getByText('openai/gpt-4o')).toBeInTheDocument() + }) + expect(screen.getByText('launch failed')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /open/i })).toBeInTheDocument() + expect(screen.getAllByRole('button', { name: /discard/i })).toHaveLength(2) + }) + + it('opens a started run in the workspaces tab', async () => { + const user = userEvent.setup() + mocks.listMultiRuns.mockResolvedValue([startedRun]) + renderDialog() + + await user.click(screen.getByRole('tab', { name: 'Runs' })) + await waitFor(() => { + expect(screen.getByRole('button', { name: /open/i })).toBeInTheDocument() + }) + + await user.click(screen.getByRole('button', { name: /open/i })) + + expect(mockNavigate).toHaveBeenCalledWith('/repos/7/sessions/ses_1?repoTab=workspaces') + }) + + it('discards an entry after confirmation', async () => { + const user = userEvent.setup() + mocks.listMultiRuns.mockResolvedValue([startedRun]) + mocks.discardMultiRunEntry.mockResolvedValue(startedRun) + renderDialog() + + await user.click(screen.getByRole('tab', { name: 'Runs' })) + await waitFor(() => { + expect(screen.getAllByRole('button', { name: /discard/i })).toHaveLength(2) + }) + + await user.click(screen.getAllByRole('button', { name: /discard/i })[0]) + + const confirmDialog = await screen.findByRole('dialog', { name: 'Discard run' }) + await user.click(within(confirmDialog).getByRole('button', { name: 'Discard' })) + + await waitFor(() => { + expect(mocks.discardMultiRunEntry).toHaveBeenCalledWith(3, 11) + }) + }) +}) diff --git a/frontend/src/components/repo/MultiRunDialog.tsx b/frontend/src/components/repo/MultiRunDialog.tsx new file mode 100644 index 000000000..ca30f837c --- /dev/null +++ b/frontend/src/components/repo/MultiRunDialog.tsx @@ -0,0 +1,336 @@ +import { useEffect, useMemo, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { Loader2, Trash2, ArrowUpRight } from 'lucide-react' +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Textarea } from '@/components/ui/textarea' +import { Switch } from '@/components/ui/switch' +import { Checkbox } from '@/components/ui/checkbox' +import { Badge } from '@/components/ui/badge' +import { Label } from '@/components/ui/label' +import { ConfirmDestructiveDialog } from '@/components/ui/confirm-destructive-dialog' +import { SessionStatusIndicator } from '@/components/ui/session-status-indicator' +import { useProvidersWithModels } from '@/hooks/useProvidersWithModels' +import { useDiscardMultiRunEntry, useLaunchMultiRun, useMultiRuns } from '@/hooks/useMultiRuns' +import { + MULTI_RUN_MAX_MODELS, + type LaunchMultiRunRequest, + type MultiRunEntry, + type MultiRunEntryStatus, +} from '@opencode-manager/shared/schemas' + +interface MultiRunDialogProps { + repoId: number + directory?: string + defaultBaseRef?: string + open: boolean + onOpenChange: (open: boolean) => void +} + +interface PendingDiscard { + runId: number + entryId: number + model: string + isolated: boolean +} + +const STATUS_LABELS: Record = { + starting: 'Starting', + started: 'Started', + failed: 'Failed', + discarded: 'Discarded', +} + +export function MultiRunDialog({ + repoId, + directory, + defaultBaseRef, + open, + onOpenChange, +}: MultiRunDialogProps) { + const navigate = useNavigate() + const [activeTab, setActiveTab] = useState('launch') + const [name, setName] = useState('') + const [prompt, setPrompt] = useState('') + const [selectedModels, setSelectedModels] = useState([]) + const [isolate, setIsolate] = useState(true) + const [baseRef, setBaseRef] = useState('') + const [pendingDiscard, setPendingDiscard] = useState(null) + + const { data: providers } = useProvidersWithModels({ enabled: open, directory }) + const runsQuery = useMultiRuns(repoId, open) + const launch = useLaunchMultiRun(repoId) + const discard = useDiscardMultiRunEntry(repoId) + + useEffect(() => { + if (!open) return + setActiveTab('launch') + setName('') + setPrompt('') + setSelectedModels([]) + setIsolate(true) + setBaseRef(defaultBaseRef ?? '') + }, [open, defaultBaseRef]) + + const modelGroups = useMemo( + () => providers.filter((provider) => provider.models.length > 0), + [providers], + ) + + const toggleModel = (value: string, checked: boolean) => { + setSelectedModels((current) => { + if (checked) { + if (current.includes(value) || current.length >= MULTI_RUN_MAX_MODELS) return current + return [...current, value] + } + return current.filter((model) => model !== value) + }) + } + + const canSubmit = + name.trim().length > 0 && prompt.trim().length > 0 && selectedModels.length > 0 && !launch.isPending + + const handleLaunch = async () => { + const request: LaunchMultiRunRequest = { + repoId, + name: name.trim(), + prompt: prompt.trim(), + models: selectedModels, + isolate, + ...(baseRef.trim() ? { baseRef: baseRef.trim() } : {}), + } + await launch.mutateAsync(request) + setActiveTab('runs') + } + + const openEntry = (entry: MultiRunEntry) => { + if (!entry.sessionId) return + onOpenChange(false) + const suffix = entry.isolated ? '?repoTab=workspaces' : '' + navigate(`/repos/${repoId}/sessions/${entry.sessionId}${suffix}`) + } + + const confirmDiscard = () => { + if (!pendingDiscard) return + discard.mutate( + { runId: pendingDiscard.runId, entryId: pendingDiscard.entryId }, + { onSuccess: () => setPendingDiscard(null) }, + ) + } + + const runs = runsQuery.data ?? [] + + return ( + <> + + + + Multi-run + + Run one prompt on up to {MULTI_RUN_MAX_MODELS} models at once, each in its own session. + + + + +
+ + Launch + Runs + +
+ + +
+ + setName(event.target.value)} + placeholder="Sweep" + /> +
+ +
+ +