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/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/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 b0b66cd14..3fbc66f03 100644 --- a/backend/src/db/migrations/index.ts +++ b/backend/src/db/migrations/index.ts @@ -23,6 +23,9 @@ 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' +import migration026 from './026-session-goals' +import migration027 from './027-multi-runs' export const allMigrations: Migration[] = [ migration001, @@ -49,4 +52,7 @@ export const allMigrations: Migration[] = [ migration022, migration023, 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..cda5da46a --- /dev/null +++ b/backend/src/db/multi-runs.ts @@ -0,0 +1,265 @@ +import type { Database } from 'bun:sqlite' +import type { MultiRunEntryStatus } from '@opencode-manager/shared/schemas' + +export interface MultiRunEntryRecord { + id: 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 toMultiRunRecord(row, loadEntries(db, row.id)) +} + +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[] + if (rows.length === 0) { + return [] + } + + const placeholders = rows.map(() => '?').join(', ') + const entryRows = db + .prepare(` + SELECT ${MULTI_RUN_ENTRY_COLUMNS} FROM multi_run_entries + WHERE multi_run_id IN (${placeholders}) + ORDER BY multi_run_id ASC, id ASC + `) + .all(...rows.map((row) => row.id)) as MultiRunEntryRow[] + + const entriesByRun = new Map() + for (const row of rows) { + entriesByRun.set(row.id, []) + } + for (const entryRow of entryRows) { + entriesByRun.get(entryRow.multi_run_id)?.push(entryRow) + } + + return rows.map((row) => toMultiRunRecord(row, entriesByRun.get(row.id) ?? [])) +} + +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 loadEntries(db: Database, multiRunId: number): MultiRunEntryRow[] { + return db + .prepare(`SELECT ${MULTI_RUN_ENTRY_COLUMNS} FROM multi_run_entries WHERE multi_run_id = ? ORDER BY id ASC`) + .all(multiRunId) as MultiRunEntryRow[] +} + +function toMultiRunRecord(row: MultiRunRow, entries: MultiRunEntryRow[]): MultiRunRecord { + 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, + 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/db/queries.ts b/backend/src/db/queries.ts index c02e57ac8..12567f3a2 100644 --- a/backend/src/db/queries.ts +++ b/backend/src/db/queries.ts @@ -346,9 +346,13 @@ export function deleteRepo(db: Database, id: number): void { return } - for (const table of TABLES_WITH_REPO_ID) { - db.prepare(`DELETE FROM ${table} WHERE repo_id = ?`).run(id) - } - const stmt = db.prepare('DELETE FROM repos WHERE id = ?') - stmt.run(id) + const remove = db.transaction(() => { + db.prepare('DELETE FROM multi_run_entries WHERE multi_run_id IN (SELECT id FROM multi_runs WHERE repo_id = ?)').run(id) + db.prepare('DELETE FROM multi_runs WHERE repo_id = ?').run(id) + for (const table of TABLES_WITH_REPO_ID) { + db.prepare(`DELETE FROM ${table} WHERE repo_id = ?`).run(id) + } + db.prepare('DELETE FROM repos WHERE id = ?').run(id) + }) + remove() } 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/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..ee3996b38 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -36,6 +36,9 @@ 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 { 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' @@ -44,6 +47,9 @@ 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 { 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' @@ -227,6 +233,34 @@ const scheduleService = new ScheduleService(db, openCodeClient, scheduleWorktree const scheduleRunnerInstance = new ScheduleRunner(scheduleService) const notificationService = new NotificationService(db) +const sessionSettingsService = new SettingsService(db) +const sessionPermissionModeService = new SessionPermissionModeService(db, openCodeClient, sessionSettingsService) +const sessionGoalService = new SessionGoalService(db, openCodeClient, sessionSettingsService, { + onOutcome: (goal) => { + void notificationService.notifyGoalOutcome(goal).catch((error) => { + logger.error('Goal outcome notification error:', error) + }) + }, + resolveSessionLock: async (sessionId) => + (await sessionPermissionModeService.getEffectiveMode(sessionId)).lockedReason, +}) +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) + }) + sessionGoalService.handleEvent(directory, event).catch((err) => { + logger.error('Session goal 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) { @@ -249,8 +283,17 @@ if (ENV.VAPID.PUBLIC_KEY && ENV.VAPID.PRIVATE_KEY) { sseAggregator.setPendingActionsFetcher(openCodeClient) sseAggregator.setPasswordResolver(() => new SettingsService(db).getOpenCodeServerPassword()) +sseAggregator.onUpstreamConnected(() => { + void sessionPermissionModeService.acceptPendingRequestsForActiveSessions().catch((err) => { + logger.error('Failed to accept pending permission requests for active sessions:', err) + }) +}) sseAggregator.start() +void sessionGoalService.recoverOpenGoals().catch((error) => { + logger.error('Session goal recovery error:', error) +}) + sseAggregator.setScheduledSessionsResolver( () => scheduleService.getActiveRunSessions(), ) @@ -264,7 +307,7 @@ app.route('/api/auth-info', createAuthInfoRoutes(auth, db)) app.route('/api/health', createHealthRoutes(db, openCodeSupervisor)) app.route('/api/mcp-oauth-proxy', createMcpOauthProxyRoutes(openCodeClient, requireAuth)) -app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient)) +app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, sessionPermissionModeService)) app.route('/api/opencode-proxy', createOpenCodeProxyRoutes(db, settingsService)) const protectedApi = new Hono() @@ -283,6 +326,9 @@ 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('/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/internal/index.ts b/backend/src/routes/internal/index.ts index 727cceb06..0f12a097d 100644 --- a/backend/src/routes/internal/index.ts +++ b/backend/src/routes/internal/index.ts @@ -13,9 +13,11 @@ 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' +import type { SessionPermissionModeService } from '../../services/session-permission-modes' export function createInternalRoutes( db: Database, @@ -23,6 +25,7 @@ export function createInternalRoutes( notificationService: NotificationService, settingsService: SettingsService, openCodeClient: OpenCodeClient, + permissionModes: SessionPermissionModeService, ) { const app = new Hono() app.use('/*', createInternalTokenMiddleware(db)) @@ -37,6 +40,7 @@ export function createInternalRoutes( repos.route('/', mirrorRoutes(db)) app.route('/repos', repos) app.route('/opencode-workspaces', createInternalOpenCodeWorkspacesRoutes(db)) + app.route('/sessions', createInternalSessionRoutes(db, openCodeClient, permissionModes)) 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..877fc86d8 --- /dev/null +++ b/backend/src/routes/internal/sessions.ts @@ -0,0 +1,211 @@ +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 { buildSessionPath } from '@opencode-manager/shared/utils' +import { getRepoById } from '../../db/queries' +import { handleOpenCodeError, parseJsonBody } from '../../utils/route-helpers' +import type { OpenCodeClient } from '../../services/opencode/client' +import type { SessionPermissionModeService } from '../../services/session-permission-modes' +import { SessionLaunchError, SessionLauncher } from '../../services/session-launcher' +import { + isSessionBusy, + readLatestAssistantReply, + truncateSessionReply, + waitForSessionSettled, +} from '../../services/session-reply' +import { resolveRepoForDirectory, resolveRepoProjectId } from '../../services/repo' + +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 INTERNAL_SESSION_REPLY_WAIT_MAX_MS = 45000 + +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), +}) + +const ReplyQuerySchema = z.object({ + waitMs: z.coerce.number().int().min(0).max(INTERNAL_SESSION_REPLY_WAIT_MAX_MS).optional(), +}) + +function handleSessionRouteError(c: Context, error: unknown) { + if (error instanceof SessionLaunchError) { + return c.json({ error: error.message }, error.status) + } + return handleOpenCodeError(c, error, 'Internal session request failed', { unknownStatus: 502 }) +} + +export function createInternalSessionRoutes( + db: Database, + openCodeClient: OpenCodeClient, + permissionModes: SessionPermissionModeService, +) { + 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 project = repo ? await resolveRepoProjectId(openCodeClient, repo.fullPath) : undefined + const response = await openCodeClient.api.session.list({ + limit, + order: 'desc', + parentID: null, + ...(project ? { project } : {}), + }) + const active = await openCodeClient.api.session.active() + + const repoIdByDirectory = new Map() + for (const directory of new Set(response.data.map((session) => session.location.directory))) { + const matchedRepo = await resolveRepoForDirectory(db, directory) + repoIdByDirectory.set(directory, matchedRepo?.id ?? null) + } + + const sessions = response.data.map((session) => { + const directory = session.location.directory + return { + id: session.id, + title: session.title ?? null, + directory, + repoId: repoIdByDirectory.get(directory) ?? null, + busy: session.id in active, + outcome: session.outcome ?? null, + updated: session.time.updated, + } + }) + + return c.json({ sessions }) + } catch (error) { + return handleSessionRouteError(c, error) + } + }) + + app.post('/', async (c) => { + const body = await parseJsonBody(c, InternalCreateSessionRequestSchema) + if (!body.ok) { + return body.response + } + + const input = body.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: input.title ?? INTERNAL_SESSION_WORKSPACE_NAME_FALLBACK, + ...(input.ref ? { ref: input.ref } : {}), + }, + } + : {}), + }) + + permissionModes.pinAsk(launched.sessionId) + + const url = buildSessionPath( + launched.repoId, + launched.sessionId, + launched.workspaceDirectory ? { repoTab: 'workspaces' } : undefined, + ) + return c.json({ ...launched, url }, 201) + } catch (error) { + return handleSessionRouteError(c, error) + } + }) + + app.post('/:sessionId/prompt', async (c) => { + const sessionId = c.req.param('sessionId') + const body = await parseJsonBody(c, InternalSessionPromptRequestSchema) + if (!body.ok) { + return body.response + } + + try { + await openCodeClient.api.session.prompt({ + sessionID: sessionId, + text: body.data.text, + delivery: 'queue', + }) + return c.json({ queued: true }, 202) + } catch (error) { + return handleSessionRouteError(c, error) + } + }) + + app.get('/:sessionId/reply', async (c) => { + const sessionId = c.req.param('sessionId') + const parsedQuery = ReplyQuerySchema.safeParse(c.req.query()) + if (!parsedQuery.success) { + return c.json({ error: 'Invalid query', details: parsedQuery.error.issues }, 400) + } + + try { + const waitMs = parsedQuery.data.waitMs ?? 0 + if (waitMs > 0) { + await waitForSessionSettled(openCodeClient, sessionId, waitMs) + } + + const [busy, reply] = await Promise.all([ + isSessionBusy(openCodeClient, sessionId), + readLatestAssistantReply(openCodeClient, sessionId), + ]) + + return c.json({ + busy, + responseText: reply?.responseText ? truncateSessionReply(reply.responseText) : null, + errorText: reply?.errorText ?? null, + completed: reply?.completed ?? false, + }) + } catch (error) { + return handleSessionRouteError(c, error) + } + }) + + app.post('/:sessionId/fork', async (c) => { + const sessionId = c.req.param('sessionId') + const body = await parseJsonBody(c, InternalForkSessionRequestSchema, { allowEmpty: true }) + if (!body.ok) { + return body.response + } + + const beforeMessageId = body.data.beforeMessageId + try { + const forked = await openCodeClient.api.session.fork({ + sessionID: sessionId, + ...(beforeMessageId ? { before: beforeMessageId } : {}), + }) + permissionModes.pinAsk(forked.id) + return c.json({ sessionId: forked.id, directory: forked.location.directory }) + } catch (error) { + return handleSessionRouteError(c, error) + } + }) + + return app +} diff --git a/backend/src/routes/multi-runs.ts b/backend/src/routes/multi-runs.ts new file mode 100644 index 000000000..50cf54a28 --- /dev/null +++ b/backend/src/routes/multi-runs.ts @@ -0,0 +1,44 @@ +import { Hono } from 'hono' +import { LaunchMultiRunRequestSchema } from '@opencode-manager/shared/schemas' +import { MultiRunError, type MultiRunService } from '../services/multi-runs' +import { handleServiceError, parseId, parseJsonBody } 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) => { + const parsed = await parseJsonBody(c, LaunchMultiRunRequestSchema) + if (!parsed.ok) { + return parsed.response + } + + 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/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/routes/session-goals.ts b/backend/src/routes/session-goals.ts new file mode 100644 index 000000000..9300ad49a --- /dev/null +++ b/backend/src/routes/session-goals.ts @@ -0,0 +1,63 @@ +import { Hono } from 'hono' +import { StartSessionGoalRequestSchema } from '@opencode-manager/shared/schemas' +import { SessionGoalError, type SessionGoalService } from '../services/session-goals' +import { handleServiceError, parseId, parseJsonBody } 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) => { + const parsed = await parseJsonBody(c, StartSessionGoalRequestSchema) + if (!parsed.ok) { + return parsed.response + } + + 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/routes/session-permission-modes.ts b/backend/src/routes/session-permission-modes.ts new file mode 100644 index 000000000..19e17ded4 --- /dev/null +++ b/backend/src/routes/session-permission-modes.ts @@ -0,0 +1,33 @@ +import { Hono } from 'hono' +import { SetSessionPermissionModeRequestSchema } from '@opencode-manager/shared/schemas' +import { SessionPermissionModeError, type SessionPermissionModeService } from '../services/session-permission-modes' +import { handleServiceError, parseJsonBody } 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) => { + const parsed = await parseJsonBody(c, SetSessionPermissionModeRequestSchema) + if (!parsed.ok) { + return parsed.response + } + + 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/assistant-mode.ts b/backend/src/services/assistant-mode.ts index dc9dfab9a..af0a7d40c 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,174 @@ 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; this covers every OpenCode workspace of that repo, not only the repo directory. Pass \`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 belongs to 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 read the result. + +Sessions created through this tool always start in \`ask\` permission mode; existing sessions keep the mode the user chose. + +Pass \`worktree: true\` to run the session in a new isolated workspace instead of the repo directory. Creating a workspace can take tens of seconds; if the request times out it may still have succeeded, so call \`GET /sessions\` before retrying. \`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. Prefer \`?waitMs=30000\` (integer, 0-45000) to wait for the session to settle instead of polling in a loop; the response returns once it settles or the timeout elapses. \`responseText\` is capped. + +\`\`\`json +{ + "action": "request", + "params": { + "method": "GET", + "path": "/sessions/ses_abc/reply?waitMs=30000" + } +} +\`\`\` + +**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. The forked session starts in \`ask\` permission mode. + +\`\`\`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 +1177,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 +1210,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 +1280,10 @@ export async function ensureAssistantMode( path: reposSkillPath, created: reposSkillCreated, }, + sessionManagementSkill: { + path: sessionsSkillPath, + created: sessionsSkillCreated, + }, defaultAgent: { name: ASSISTANT_DEFAULT_AGENT_NAME, path: assistantAgentPath, @@ -1201,6 +1387,7 @@ export async function getAssistantModeStatus(repo: Repo): Promise ({ + 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 { + let repo: Repo + try { + repo = requireReadyRepo(this.db, request.repoId) + } catch (error) { + throw new MultiRunError(getErrorMessage(error) || 'Repository unavailable', 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 validations = await Promise.all( + record.entries.map(async (entry) => { + try { + await this.sessionLauncher.resolveModel(repo, entry.model) + return { entry, ok: true as const } + } catch (error) { + return { entry, ok: false as const, error } + } + }), + ) + + const launchable: Array<{ entry: MultiRunEntryRecord; index: number }> = [] + validations.forEach((validation, index) => { + if (validation.ok) { + launchable.push({ entry: validation.entry, index }) + return + } + + updateMultiRunEntry(this.db, validation.entry.id, ['starting'], { + status: 'failed', + error: getErrorMessage(validation.error) || 'Failed to launch session', + }) + }) + + const results = await Promise.allSettled( + launchable.map(({ entry, index }) => this.launchEntry(entry, index, request)), + ) + + results.forEach((result, resultIndex) => { + const launch = launchable[resultIndex] + if (!launch) { + return + } + + if (result.status === 'fulfilled') { + updateMultiRunEntry(this.db, launch.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, launch.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 && existsSync(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 = error instanceof RepoWorkspaceError ? error.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, + ): 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: `${request.name}-${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/notification.ts b/backend/src/services/notification.ts index d64e2ab2c..400227d9f 100644 --- a/backend/src/services/notification.ts +++ b/backend/src/services/notification.ts @@ -2,31 +2,30 @@ import { Database } from "bun:sqlite"; import webpush from "web-push"; import { logger } from "../utils/logger"; import type { PushSubscriptionRecord } from "../types/settings"; -import type { PushNotificationPayload } from "@opencode-manager/shared/types"; +import type { + NotificationPreferences, + PushNotificationPayload, +} from "@opencode-manager/shared/types"; import { NotificationEventType, DEFAULT_NOTIFICATION_PREFERENCES, + type SessionGoal, } from "@opencode-manager/shared/schemas"; import { getPermissionLabel, getPermissionDetail, getFormText, + getGoalOutcomeTitle, + getGoalStopReasonLabel, } from "@opencode-manager/shared/notifications"; import { SettingsService } from "./settings"; import { sseAggregator, type SSEEvent } from "./sse-aggregator"; -import { - getRepoByLocalPath, - getRepoBySourcePath, - getRepoName, - listRepos, -} from "../db/queries"; +import { getRepoName } from "../db/queries"; import { getScheduleRunBySessionId } from "../db/schedules"; import type { Repo } from "../types/repo"; -import { getReposPath } from "@opencode-manager/shared/config/env"; -import { ASSISTANT_REPO_ID } from "@opencode-manager/shared/utils"; +import { buildSessionPath } from "@opencode-manager/shared/utils"; import { sessionIDFromEvent } from "@opencode-manager/shared/opencode"; -import { resolveProjectId } from "./project-id-resolver"; -import path from "path"; +import { resolveRepoForDirectory } from "./repo"; interface VapidConfig { publicKey: string; @@ -77,6 +76,30 @@ const RUN_OUTCOME_EVENTS = new Set([ NotificationEventType.SESSION_FAILED, ]); +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 + ? getGoalStopReasonLabel(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 truncateWithEllipsis(`${prefix}${objective}${suffix}`, MAX_BODY_LENGTH); +} + function resolveEventSessionId(event: SSEEvent): string | undefined { if (event.type === NotificationEventType.FORM_CREATED) { return event.data.form.sessionID; @@ -94,8 +117,7 @@ export function buildNotificationUrl( ): string { if (!repo) return "/"; if (!sessionId) return `/repos/${repo.id}`; - const suffix = repo.id === ASSISTANT_REPO_ID ? "?assistant=1" : ""; - return `/repos/${repo.id}/sessions/${sessionId}${suffix}`; + return buildSessionPath(repo.id, sessionId); } export function buildEventNotificationPayload( @@ -117,10 +139,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 = truncateWithEllipsis(rawBody, MAX_BODY_LENGTH); return { title, @@ -139,9 +158,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 +200,10 @@ export class NotificationService { return this.vapidConfig?.publicKey ?? null; } + addEventSuppressor(suppressor: EventSuppressor): void { + this.eventSuppressors.push(suppressor); + } + isConfigured(): boolean { return this.vapidConfig !== null; } @@ -289,25 +315,24 @@ export class NotificationService { return run ? buildScheduleRunReportUrl(run.id) : null; } - private async resolveRepoForDirectory(directory: string): Promise { - const repo = - getRepoBySourcePath(this.db, path.resolve(directory)) ?? - getRepoByLocalPath(this.db, path.relative(getReposPath(), directory)); - if (repo) return repo; + private async deliverToSubscribers( + payload: PushNotificationPayload, + shouldNotify: (preferences: NotificationPreferences) => boolean + ): Promise { + if (!this.isConfigured()) return; - const projectId = await resolveProjectId(directory); - if (!projectId) return null; + const userIds = this.getAllUserIds(); + if (userIds.length === 0) return; - const readyRepos = listRepos(this.db).filter( - (candidate) => candidate.cloneStatus === "ready" - ); - for (const candidate of readyRepos) { - const candidateProjectId = await resolveProjectId(candidate.fullPath).catch( - () => null - ); - if (candidateProjectId === projectId) return candidate; + for (const userId of userIds) { + const settings = this.settingsService.getSettings(userId); + const notifPrefs = + settings.preferences.notifications ?? DEFAULT_NOTIFICATION_PREFERENCES; + + if (!shouldNotify(notifPrefs)) continue; + + await this.sendToUser(userId, payload); } - return null; } async handleSSEEvent( @@ -321,12 +346,11 @@ export class NotificationService { if (sessionId && sseAggregator.isSessionBeingViewed(sessionId)) return; if (sessionId && sseAggregator.isSubagentSession(sessionId)) return; - if (!this.isConfigured()) return; - - const userIds = this.getAllUserIds(); - if (userIds.length === 0) return; + for (const suppressor of this.eventSuppressors) { + if (await suppressor(event, sessionId)) return; + } - const repo = directory ? await this.resolveRepoForDirectory(directory) : null; + const repo = directory ? await resolveRepoForDirectory(this.db, directory) : null; const repoId = repo?.id; const repoName = repo ? getRepoName(repo) : undefined; const reportUrl = RUN_OUTCOME_EVENTS.has(event.type) ? this.getScheduleRunReportUrl(sessionId) : null; @@ -341,16 +365,45 @@ export class NotificationService { }); if (!payload) return; - for (const userId of userIds) { - const settings = this.settingsService.getSettings(userId); - const notifPrefs = - settings.preferences.notifications ?? DEFAULT_NOTIFICATION_PREFERENCES; + await this.deliverToSubscribers( + payload, + (preferences) => + preferences.enabled && + preferences.events[config.preferencesKey] === true + ); + } - if (!notifPrefs.enabled) continue; - if (!notifPrefs.events[config.preferencesKey]) continue; + async notifyGoalOutcome(goal: SessionGoal): Promise { + if (goal.stopReason === "turn_error") return; + if (goal.stopReason === "cancelled" || goal.stopReason === "user_paused") return; + if (sseAggregator.isSessionBeingViewed(goal.sessionId)) return; - await this.sendToUser(userId, payload); - } + const repo = goal.directory + ? await resolveRepoForDirectory(this.db, goal.directory) + : null; + const repoName = repo ? getRepoName(repo) : undefined; + + const payload: PushNotificationPayload = { + title: getGoalOutcomeTitle(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), + }, + }; + + await this.deliverToSubscribers( + payload, + (preferences) => + preferences.enabled && preferences.events.goalOutcome !== false + ); } async sendTestNotification(userId: string): Promise { diff --git a/backend/src/services/opencode-manager-tool-plugin.ts b/backend/src/services/opencode-manager-tool-plugin.ts index c07acdca5..1f32c01b9 100644 --- a/backend/src/services/opencode-manager-tool-plugin.ts +++ b/backend/src/services/opencode-manager-tool-plugin.ts @@ -3,7 +3,7 @@ import { ASSISTANT_NOTIFICATION_LIMITS } from '@opencode-manager/shared/schemas' export const MANAGER_TOOL_NAME = 'ocm' -const MANAGER_TOOL_REQUEST_TIMEOUT_MS = 15000 +const MANAGER_TOOL_REQUEST_TIMEOUT_MS = 60000 export const MANAGER_TOOL_ALLOWED_ROUTES = [ 'GET /settings', @@ -16,6 +16,11 @@ export const MANAGER_TOOL_ALLOWED_ROUTES = [ 'GET /repos', 'GET /repos/*/git-info', 'GET /opencode-workspaces', + 'GET /sessions', + 'POST /sessions', + 'POST /sessions/*/prompt', + 'GET /sessions/*/reply', + 'POST /sessions/*/fork', 'GET /schedules/all', 'GET /schedules/all/runs', 'GET /repos/*/schedules', @@ -106,7 +111,7 @@ function buildManagerToolDescription(): string { 'The action runs inside OpenCode Manager itself, so it needs no token and no network access from the agent shell, and it works in sandboxed sessions and scheduled runs.', 'Actions:', '- send_notification: send a push notification to every device the user has registered.', - '- request: call an allow-listed internal API route to read and manage settings, the OpenCode configuration file, repos, OpenCode workspaces, and schedules.', + '- request: call an allow-listed internal API route to read and manage settings, the OpenCode configuration file, repos, OpenCode workspaces, sessions (list, create, follow up, read the latest reply, fork), and schedules.', 'Allowed request routes:', ] .concat(MANAGER_TOOL_ALLOWED_ROUTES.map((route) => `- ${route}`)) diff --git a/backend/src/services/repo.ts b/backend/src/services/repo.ts index 6a9268686..f76d2d437 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' @@ -121,7 +121,7 @@ function buildWorkspaceAliasCandidates(sourcePath: string, rootPath?: string): s if (relativePath && !relativePath.startsWith('..')) { const relativeAlias = relativePath .split(path.sep) - .map(sanitizeRepoDirectoryName) + .map((segment) => sanitizeRepoDirectoryName(segment)) .filter(Boolean) .join('--') @@ -1168,6 +1168,30 @@ export async function resolveRepoProjectId(openCodeClient: OpenCodeClient, direc return project.id } +export async function resolveRepoForDirectory( + database: Database, + directory: string, +): Promise { + const repo = + getRepoBySourcePath(database, path.resolve(directory)) ?? + getRepoByLocalPath(database, path.relative(getReposPath(), directory)) + if (repo) return repo + + const projectId = await resolveProjectId(directory) + if (!projectId) return null + + const readyRepos = listRepos(database).filter( + (candidate) => candidate.cloneStatus === 'ready', + ) + for (const candidate of readyRepos) { + const candidateProjectId = await resolveProjectId(candidate.fullPath).catch( + () => null, + ) + if (candidateProjectId === projectId) return candidate + } + return null +} + export async function getSiblingRepos( database: Database, repoId: number, @@ -1263,3 +1287,44 @@ export async function getSiblingRepos( return repoSiblings } } + +export class RepoWorkspaceError extends Error { + readonly status: 400 + + constructor(message: string, status: 400) { + 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/src/services/schedules.ts b/backend/src/services/schedules.ts index e97294ff3..12ab0952f 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 } from '@opencode-manager/shared/opencode' import { buildSchedulePermissionRuleset } from '@opencode-manager/shared/schemas' import { getRepoById } from '../db/queries' import type { ScheduleJobWithRepo } from '../db/schedules' @@ -46,10 +46,11 @@ import { computeNextRunAtForJob, } from './schedule-config' import { resolveOpenCodeModel } from './opencode-models' +import { isSessionBusy, readLatestAssistantReply, sessionSettleSignal, type AssistantReplyState } from './session-reply' import type { OpenCodeClient } from './opencode/client' import type { ScheduleWorktreeManager } from './schedule-worktree' import type { Repo } from '../types/repo' -import { sseAggregator, type SSEEvent, type ScheduledSessionRef } from './sse-aggregator' +import { sseAggregator, type ScheduledSessionRef } from './sse-aggregator' import { getErrorMessage } from '../utils/error-utils' import { logger } from '../utils/logger' import { buildAssistantRepo } from './assistant-mode' @@ -165,38 +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 - } - - return event.data.error.message || null -} - -function getSessionStatusType(event: SSEEvent): string | null { - return event.type === 'session.status' ? event.data.status.type : null -} - function createSessionMonitor(directory: string, sessionId: string): SessionMonitor { const queued: SessionSignal[] = [] let waiting: ((signal: SessionSignal) => void) | null = null @@ -217,26 +186,9 @@ function createSessionMonitor(directory: string, sessionId: string): SessionMoni return } - if (sessionIDFromEvent(event) !== sessionId) { - return - } - - if (event.type === 'session.execution.failed') { - push({ errorText: getSessionErrorText(event) ?? 'The session reported an unknown error.', disposed: false }) - return - } - - if (event.type === 'session.execution.interrupted') { - push({ errorText: 'The session execution was interrupted.', disposed: false }) - return - } - - if ( - event.type === 'session.idle' - || event.type === 'session.execution.succeeded' - || (event.type === 'session.status' && getSessionStatusType(event) === 'idle') - ) { - push({ errorText: null, disposed: false }) + const signal = sessionSettleSignal(event, sessionId) + if (signal) { + push({ errorText: signal.errorText, disposed: false }) } }) @@ -1184,7 +1136,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 +1161,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 +1182,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 +1192,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-goal-audit.ts b/backend/src/services/session-goal-audit.ts new file mode 100644 index 000000000..f0be2bcd1 --- /dev/null +++ b/backend/src/services/session-goal-audit.ts @@ -0,0 +1,135 @@ +import { SessionGoalVerdictSchema, type SessionGoalVerdict } from '@opencode-manager/shared/schemas' +import { truncateSessionReply } from './session-reply' + +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)' + } + return truncateSessionReply(reply) +} + +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 new file mode 100644 index 000000000..db5115fa5 --- /dev/null +++ b/backend/src/services/session-goals.ts @@ -0,0 +1,667 @@ +import type { Database } from 'bun:sqlite' +import { + DEFAULT_SESSION_DEFAULTS, + type SessionGoal, + type SessionGoalStatus, + type SessionGoalStopReason, + type SessionGoalTurnState, + type SessionLockReason, + type StartSessionGoalRequest, +} from '@opencode-manager/shared/schemas' +import { + isSessionNotFoundError, + 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 +const RECOVERY_RETRY_MS = 5000 + +type PrerequisiteResult = { ok: true; value: T } | { ok: false } + +export interface SessionGoalServiceOptions { + quietMs?: number + onOutcome?: (goal: SessionGoal) => void + resolveSessionLock?: (sessionId: string) => Promise +} + +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 +} + +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 { + 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() + private readonly quietMs: number + private readonly onOutcome: (goal: SessionGoal) => void + private readonly resolveSessionLock: (sessionId: string) => Promise + + 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 ?? (() => {}) + this.resolveSessionLock = options.resolveSessionLock ?? (async () => null) + } + + loadOpenGoals(): void { + this.goalBySession.clear() + for (const record of listOpenSessionGoals(this.db)) { + this.goalBySession.set(record.sessionId, record.id) + } + } + + 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 { + await this.assertSessionCanStartGoal(input.sessionId) + + 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.readSessionTokenTotalOrDefault(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) + } + + this.goalBySession.set(record.sessionId, record.id) + return toSessionGoal(record) + } + + getLatest(sessionId: string): SessionGoal | null { + const record = getLatestSessionGoal(this.db, sessionId) + return record ? toSessionGoal(record) : null + } + + private async assertSessionCanStartGoal(sessionId: string): Promise { + let lock: SessionLockReason | null + try { + lock = await this.resolveSessionLock(sessionId) + } catch (error) { + logger.error(`Failed to resolve session lock for ${sessionId}:`, error) + return + } + + if (lock === 'schedule') { + throw new SessionGoalError('Scheduled runs cannot run goals', 409) + } + if (lock === 'child') { + throw new SessionGoalError('Goals can only be started on top-level sessions', 400) + } + } + + pause(id: number): SessionGoal { + return this.transitionWithOutcome(id, ['active'], { status: 'paused', stopReason: 'user_paused' }) + } + + resume(id: number): SessionGoal { + 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 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.clearRecoveryTimer(sessionId) + 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 = this.applyTransition(id, fromStatuses, patch) + 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) + } + + 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) + } + if (record.status !== 'active') { + this.clearRecoveryTimer(record.sessionId) + } + } + + 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 { + return await this.readSessionTokenTotal(sessionId) + } catch (error) { + logger.error(`Failed to read token usage for session ${sessionId}:`, error) + return 0 + } + } +} diff --git a/backend/src/services/session-launcher.ts b/backend/src/services/session-launcher.ts new file mode 100644 index 000000000..d5a62bfb1 --- /dev/null +++ b/backend/src/services/session-launcher.ts @@ -0,0 +1,130 @@ +import type { Database } from 'bun:sqlite' +import { openCodeLocation, parseOpenCodeModelRef } from '@opencode-manager/shared/opencode' +import { sanitizeRepoDirectoryName } from '@opencode-manager/shared/utils' +import { getRepoById } from '../db/queries' +import type { Repo } from '../types/repo' +import { getErrorMessage } from '../utils/error-utils' +import type { OpenCodeClient } from './opencode/client' +import { resolveOpenCodeModel, type ResolvedOpenCodeModel } from './opencode-models' +import { createRepoWorkspace } from './repo' + +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 + readonly workspaceDirectory: string | null + + constructor(message: string, status: 400 | 404 | 502, workspaceDirectory: string | null = null) { + super(message) + this.name = 'SessionLaunchError' + this.status = status + this.workspaceDirectory = workspaceDirectory + } +} + +function withWorkspace(message: string, workspaceDirectory: string | null): string { + return workspaceDirectory ? `${message} (workspace: ${workspaceDirectory})` : message +} + +export function requireReadyRepo(db: Database, repoId: number): Repo { + const repo = getRepoById(db, repoId) + if (!repo || repo.cloneStatus !== 'ready') { + throw new SessionLaunchError('Repository not found or not ready', 404) + } + return repo +} + +export class SessionLauncher { + constructor( + private readonly db: Database, + private readonly openCodeClient: OpenCodeClient, + ) {} + + async resolveModel(repo: Repo, requestedModel?: string): Promise { + let resolved: ResolvedOpenCodeModel + try { + resolved = await resolveOpenCodeModel(this.openCodeClient, repo.fullPath, { + preferredModel: requestedModel, + }) + } catch (error) { + throw new SessionLaunchError(getErrorMessage(error) || 'Failed to resolve OpenCode model', 502) + } + + if (requestedModel) { + const requestedRef = parseOpenCodeModelRef(requestedModel) + if (!requestedRef || resolved.providerID !== requestedRef.providerID || resolved.id !== requestedRef.id) { + throw new SessionLaunchError(`Model ${requestedModel} is not available`, 400) + } + } + + return resolved + } + + async launch(input: LaunchSessionInput): Promise { + const repo = requireReadyRepo(this.db, input.repoId) + const model = await this.resolveModel(repo, input.model) + + let directory = repo.fullPath + let workspaceDirectory: string | null = null + + if (input.workspace) { + try { + const workspace = await createRepoWorkspace(this.openCodeClient, repo, { + name: sanitizeRepoDirectoryName(input.workspace.name ?? '', 'session'), + ...(input.workspace.ref ? { ref: input.workspace.ref } : {}), + }) + directory = workspace.directory + workspaceDirectory = workspace.directory + } catch (error) { + throw new SessionLaunchError(getErrorMessage(error) || 'Failed to create workspace', 502) + } + } + + 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, + workspaceDirectory, + ) + } + + return { + sessionId: session.id, + repoId: repo.id, + directory, + workspaceDirectory, + model: model.model, + title: session.title ?? input.title ?? null, + } + } +} diff --git a/backend/src/services/session-permission-modes.ts b/backend/src/services/session-permission-modes.ts new file mode 100644 index 000000000..0d908fab8 --- /dev/null +++ b/backend/src/services/session-permission-modes.ts @@ -0,0 +1,249 @@ +import type { Database } from 'bun:sqlite' +import type { + SessionLockReason, + SessionPermissionMode, + SessionPermissionModeState, +} from '@opencode-manager/shared/schemas' +import { openCodeLocation, type PermissionRequest } from '@opencode-manager/shared/opencode' +import { getScheduleRunBySessionId } from '../db/schedules' +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 + +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, + ) {} + + private rememberParent(sessionId: string, parentId: string | null | undefined): void { + this.parentBySession.set(sessionId, parentId ?? null) + } + + private 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 handleEvent(directory: string, event: SSEEvent): Promise { + switch (event.type) { + case 'session.created': { + const { sessionID, parentID, permissions } = event.data + this.rememberParent(sessionID, parentID) + const hasExplicitPermissions = Array.isArray(permissions) && permissions.length > 0 + if (!parentID && !hasExplicitPermissions && this.defaultMode() === 'auto') { + insertSessionPermissionModeIfAbsent(this.db, sessionID, 'auto') + } + return + } + case 'session.forked': { + const { sessionID, parentID } = event.data + this.rememberParent(sessionID, null) + const sourceMode = await this.getEffectiveMode(parentID) + if (sourceMode.mode === '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 + } + } + } + + 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 { + const rootSessionId = await this.resolveRootSessionId(sessionId) + const lockedReason = this.lockReasonForRoot(sessionId, rootSessionId) + if (lockedReason === 'schedule') { + throw new SessionPermissionModeError( + 'Scheduled runs use their own permission configuration', + 409, + ) + } + + if (lockedReason === 'child') { + throw new SessionPermissionModeError( + 'Child sessions inherit the permission mode of their parent session', + 400, + ) + } + + setSessionPermissionMode(this.db, sessionId, mode) + if (mode === 'auto') { + await this.acceptPendingRequestsInDirectory(directory, sessionId) + } + return { sessionId, rootSessionId, mode, lockedReason: null } + } + + pinAsk(sessionId: string): void { + setSessionPermissionMode(this.db, sessionId, 'ask') + } + + defaultMode(): SessionPermissionMode { + return this.settingsService.getSettings().preferences.sessionDefaults?.permissionMode ?? 'ask' + } + + async acceptPendingRequestsForActiveSessions(): Promise { + try { + const active = await this.openCodeClient.api.session.active() + const directories = new Set() + + for (const sessionId of Object.keys(active ?? {})) { + const rootSessionId = await this.resolveRootSessionIdOrNull(sessionId) + if (!rootSessionId) continue + if (this.effectiveModeForRoot(sessionId, rootSessionId).mode !== 'auto') continue + const directory = await this.resolveSessionDirectory(sessionId) + if (directory) directories.add(directory) + } + + for (const directory of directories) { + await this.acceptPendingRequestsInDirectory(directory) + } + } catch (error) { + logger.error('Failed to accept pending permission requests for active sessions:', error) + } + } + + private effectiveModeForRoot( + sessionId: string, + rootSessionId: string | null, + ): SessionPermissionModeState { + if (!rootSessionId) { + return { sessionId, rootSessionId: sessionId, mode: 'ask', lockedReason: null } + } + + const lockedReason = this.lockReasonForRoot(sessionId, rootSessionId) + + if (lockedReason === 'schedule') { + return { sessionId, rootSessionId, mode: 'ask', lockedReason } + } + + const stored = getSessionPermissionMode(this.db, rootSessionId) + return { sessionId, rootSessionId, mode: stored ?? 'ask', lockedReason } + } + + private lockReasonForRoot(sessionId: string, rootSessionId: string): SessionLockReason | null { + if (getScheduleRunBySessionId(this.db, rootSessionId)) { + return 'schedule' + } + + return rootSessionId !== sessionId ? 'child' : null + } + + private async acceptPendingRequestsInDirectory(directory: string, expectedRootSessionId?: 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 directory ${directory}:`, error) + return + } + + for (const request of requests) { + await this.autoAcceptRequest(request.sessionID, request.id, expectedRootSessionId) + } + } + + private async resolveSessionDirectory(sessionId: string): Promise { + try { + const session = await this.openCodeClient.api.session.get({ sessionID: sessionId }) + return session.location.directory + } catch (error) { + logger.error(`Failed to resolve directory for session ${sessionId}:`, error) + return null + } + } + + 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 + } + + 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/src/services/session-reply.ts b/backend/src/services/session-reply.ts new file mode 100644 index 000000000..bc8d7dd15 --- /dev/null +++ b/backend/src/services/session-reply.ts @@ -0,0 +1,116 @@ +import { assistantText, sessionIDFromEvent, type SessionMessageAssistant, type SessionMessageInfo } from '@opencode-manager/shared/opencode' +import type { OpenCodeClient } from './opencode/client' +import { sseAggregator, type SSEEvent } from './sse-aggregator' + +export const SESSION_REPLY_MAX_LENGTH = 20000 + +const SESSION_REPLY_TRUNCATION_MARKER = '\n\n[reply truncated]' + +export interface AssistantReplyState { + responseText: string | null + errorText: string | null + completed: boolean +} + +export interface SessionSettleSignal { + errorText: string | null +} + +export function truncateSessionReply(text: string): string { + if (text.length <= SESSION_REPLY_MAX_LENGTH) { + return text + } + return `${text.slice(0, SESSION_REPLY_MAX_LENGTH)}${SESSION_REPLY_TRUNCATION_MARKER}` +} + +export function sessionSettleSignal(event: SSEEvent, sessionId: string): SessionSettleSignal | null { + if (sessionIDFromEvent(event) !== sessionId) { + return null + } + + switch (event.type) { + case 'session.execution.failed': + return { errorText: event.data.error.message || 'The session reported an unknown error.' } + case 'session.execution.interrupted': + return { errorText: 'The session execution was interrupted.' } + case 'session.idle': + case 'session.execution.succeeded': + return { errorText: null } + case 'session.status': + return event.data.status.type === 'idle' ? { errorText: null } : null + default: + return null + } +} + +export function waitForSessionSettled(client: OpenCodeClient, sessionId: string, timeoutMs: number): Promise { + if (timeoutMs <= 0) { + return Promise.resolve() + } + + return new Promise((resolve) => { + let settled = false + let timer: ReturnType | null = null + let unsubscribe: () => void = () => {} + + const finish = () => { + if (settled) { + return + } + settled = true + if (timer) { + clearTimeout(timer) + } + unsubscribe() + resolve() + } + + unsubscribe = sseAggregator.onEvent((_directory, event) => { + if (sessionSettleSignal(event, sessionId)) { + finish() + } + }) + + timer = setTimeout(finish, timeoutMs) + + void isSessionBusy(client, sessionId) + .then((busy) => { + if (!busy) { + finish() + } + }) + .catch(() => { + finish() + }) + }) +} + +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/src/services/sse-aggregator.ts b/backend/src/services/sse-aggregator.ts index 2636192c2..1692fbebd 100644 --- a/backend/src/services/sse-aggregator.ts +++ b/backend/src/services/sse-aggregator.ts @@ -46,6 +46,7 @@ class SSEAggregator { private activeSessions: Map> = new Map() private sessionDirectories: Map = new Map() private eventListeners: Set = new Set() + private upstreamConnectedListeners: Set<() => void> = new Set() private subagentSessions: Map> = new Map() private upstream: EventSource | null = null private reconnectTimeout: ReturnType | null = null @@ -370,12 +371,19 @@ class SSEAggregator { this.upstreamConnected = true this.reconnectDelay = RECONNECT_DELAY_MS this.everConnected = true + this.notifyUpstreamConnected() this.broadcastResync() if (wasConnectedBefore) { void this.replaySessionStatusesForTrackedDirectories() } } + private notifyUpstreamConnected(): void { + this.upstreamConnectedListeners.forEach(listener => { + try { listener() } catch { /* ignore listener errors */ } + }) + } + private broadcastResync(): void { this.broadcastToAll('resync', JSON.stringify({ timestamp: Date.now() })) } @@ -394,6 +402,11 @@ class SSEAggregator { return () => { this.eventListeners.delete(listener) } } + onUpstreamConnected(listener: () => void): () => void { + this.upstreamConnectedListeners.add(listener) + return () => { this.upstreamConnectedListeners.delete(listener) } + } + private handleUpstreamMessage(data: string): void { let event: V2Event try { @@ -407,14 +420,17 @@ class SSEAggregator { const payloadJson = MULTILINE_PATTERN.test(data) ? JSON.stringify(event) : data const directory = event.location?.directory const sessionID = sessionIDFromEvent(event) + const forkParentId = event.type === 'session.forked' ? event.data.parentID : undefined + const inheritedDirectory = forkParentId ? this.sessionDirectories.get(forkParentId) : undefined try { - const resolvedDirectory = directory ?? (sessionID ? this.sessionDirectories.get(sessionID) : undefined) + const rememberedDirectory = directory ?? inheritedDirectory + const resolvedDirectory = rememberedDirectory ?? (sessionID ? this.sessionDirectories.get(sessionID) : undefined) if (sessionID && event.type === 'session.deleted') { this.sessionDirectories.delete(sessionID) - } else if (directory && sessionID) { - this.sessionDirectories.set(sessionID, directory) + } else if (sessionID && rememberedDirectory) { + this.sessionDirectories.set(sessionID, rememberedDirectory) } if (directory) { @@ -626,6 +642,7 @@ class SSEAggregator { this.directoryClients.clear() this.clients.clear() this.eventListeners.clear() + this.upstreamConnectedListeners.clear() } broadcastToAll(event: string, data: string): void { diff --git a/backend/src/utils/route-helpers.ts b/backend/src/utils/route-helpers.ts index d9eb81992..56545a408 100644 --- a/backend/src/utils/route-helpers.ts +++ b/backend/src/utils/route-helpers.ts @@ -1,5 +1,6 @@ import type { Context } from 'hono' import type { ContentfulStatusCode } from 'hono/utils/http-status' +import type { ZodType } from 'zod' import { ClientError, openCodeErrorStatus } from '@opencode-manager/shared/opencode' import { isOAuthErrorCode } from '@opencode-manager/shared/schemas' import { getErrorMessage } from './error-utils' @@ -43,7 +44,12 @@ export function handleServiceError( return c.json({ error: getErrorMessage(error) }, 500) } -export function handleOpenCodeError(c: Context, error: unknown, fallback: string) { +export function handleOpenCodeError( + c: Context, + error: unknown, + fallback: string, + options?: { unknownStatus?: 500 | 502 }, +) { if (error instanceof Error) { const tag = (error as { _tag?: unknown })._tag if (typeof tag === 'string') { @@ -59,5 +65,40 @@ export function handleOpenCodeError(c: Context, error: unknown, fallback: string } logger.error(fallback, error) + const status = options?.unknownStatus ?? 500 + if (status === 502) { + return c.json({ error: getErrorMessage(error) || fallback }, 502) + } return c.json({ error: fallback }, 500) } + +export type JsonBodyResult = { ok: true; data: T } | { ok: false; response: Response } + +export async function parseJsonBody( + c: Context, + schema: ZodType, + options?: { allowEmpty?: boolean }, +): Promise> { + const text = await c.req.text() + + let value: unknown + if (!text.trim()) { + if (!options?.allowEmpty) { + return { ok: false, response: c.json({ error: 'Invalid JSON' }, 400) } + } + value = {} + } else { + try { + value = JSON.parse(text) + } catch { + return { ok: false, response: c.json({ error: 'Invalid JSON' }, 400) } + } + } + + const parsed = schema.safeParse(value) + if (!parsed.success) { + return { ok: false, response: c.json({ error: 'Invalid request body', details: parsed.error.issues }, 400) } + } + + return { ok: true, data: parsed.data } +} diff --git a/backend/test/db/queries.test.ts b/backend/test/db/queries.test.ts index 9e4546f0d..fcb0f07bf 100644 --- a/backend/test/db/queries.test.ts +++ b/backend/test/db/queries.test.ts @@ -239,26 +239,39 @@ describe('Database Queries', () => { }) describe('deleteRepo', () => { - it('should delete repo schedules before deleting repo by ID', () => { + it('should delete repo-scoped rows, including multi-runs, in one transaction', () => { + mockDb.transaction.mockImplementation((fn: () => void) => fn) + + const deleteEntriesStmt = { run: vi.fn().mockReturnValue({ changes: 2 }) } + const deleteMultiRunsStmt = { run: vi.fn().mockReturnValue({ changes: 1 }) } const deleteRunsStmt = { run: vi.fn().mockReturnValue({ changes: 2 }) } const deleteJobsStmt = { run: vi.fn().mockReturnValue({ changes: 1 }) } const deleteSettingsStmt = { run: vi.fn().mockReturnValue({ changes: 0 }) } const deleteRepoStmt = { run: vi.fn().mockReturnValue({ changes: 1 }) } mockDb.prepare - .mockReturnValueOnce(deleteRunsStmt) + .mockReturnValueOnce(deleteEntriesStmt) + .mockReturnValueOnce(deleteMultiRunsStmt) .mockReturnValueOnce(deleteJobsStmt) + .mockReturnValueOnce(deleteRunsStmt) .mockReturnValueOnce(deleteSettingsStmt) .mockReturnValueOnce(deleteRepoStmt) db.deleteRepo(mockDb, 1) - expect(mockDb.prepare).toHaveBeenNthCalledWith(1, 'DELETE FROM schedule_jobs WHERE repo_id = ?') + expect(mockDb.transaction).toHaveBeenCalledTimes(1) + expect(mockDb.prepare).toHaveBeenNthCalledWith(1, + 'DELETE FROM multi_run_entries WHERE multi_run_id IN (SELECT id FROM multi_runs WHERE repo_id = ?)' + ) + expect(deleteEntriesStmt.run).toHaveBeenCalledWith(1) + expect(mockDb.prepare).toHaveBeenNthCalledWith(2, 'DELETE FROM multi_runs WHERE repo_id = ?') + expect(deleteMultiRunsStmt.run).toHaveBeenCalledWith(1) + expect(mockDb.prepare).toHaveBeenNthCalledWith(3, 'DELETE FROM schedule_jobs WHERE repo_id = ?') expect(deleteJobsStmt.run).toHaveBeenCalledWith(1) - expect(mockDb.prepare).toHaveBeenNthCalledWith(2, 'DELETE FROM schedule_runs WHERE repo_id = ?') + expect(mockDb.prepare).toHaveBeenNthCalledWith(4, 'DELETE FROM schedule_runs WHERE repo_id = ?') expect(deleteRunsStmt.run).toHaveBeenCalledWith(1) - expect(mockDb.prepare).toHaveBeenNthCalledWith(3, 'DELETE FROM repo_settings WHERE repo_id = ?') + expect(mockDb.prepare).toHaveBeenNthCalledWith(5, 'DELETE FROM repo_settings WHERE repo_id = ?') expect(deleteSettingsStmt.run).toHaveBeenCalledWith(1) - expect(mockDb.prepare).toHaveBeenNthCalledWith(4, + expect(mockDb.prepare).toHaveBeenNthCalledWith(6, 'DELETE FROM repos WHERE id = ?' ) expect(deleteRepoStmt.run).toHaveBeenCalledWith(1) 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..7acbd4835 --- /dev/null +++ b/backend/test/helpers/fake-session-goal-client.ts @@ -0,0 +1,231 @@ +import type { ModelRef, SessionMessageInfo } from '@opencode-manager/shared/opencode' +import type { OpenCodeClient } from '../../src/services/opencode/client' + +export interface FakeSessionGoalTokens { + input: number + output: number + reasoning: number +} + +export interface FakeSessionGoalClientOptions { + tokens?: Record + failSessionGet?: boolean + messages?: Record + busySessions?: string[] + auditorReplies?: string[] + failAuditorCalls?: number + failSessionPrompt?: boolean +} + +export interface FakeSessionGoalPromptCall { + sessionID: string + text: string +} + +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 + 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[] = [] + 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 (missingSessions.has(sessionID)) { + throw Object.assign(new Error('Session not found'), { _tag: 'SessionNotFoundError' }) + } + if (sessionGetFails) { + throw new Error('upstream unavailable') + } + const usage = tokens.get(sessionID) ?? { input: 0, output: 0, reasoning: 0 } + return { + tokens: { + input: usage.input, + output: usage.output, + reasoning: usage.reasoning, + cache: { read: 0, write: 0 }, + }, + } + }, + 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 + }, + setSessionMissing: (sessionId, missing) => { + if (missing) { + missingSessions.add(sessionId) + } else { + missingSessions.delete(sessionId) + } + }, + } +} + +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/helpers/fake-session-permission-client.ts b/backend/test/helpers/fake-session-permission-client.ts new file mode 100644 index 000000000..330291f35 --- /dev/null +++ b/backend/test/helpers/fake-session-permission-client.ts @@ -0,0 +1,76 @@ +import { vi } from 'vitest' +import type { OpenCodeClient } from '../../src/services/opencode/client' + +export interface FakePendingPermissionRequest { + id: string + sessionID: string +} + +export interface FakeSessionInfo { + parentID?: string | null + location?: { directory: string } +} + +export interface FakeSessionPermissionClientOptions { + parents?: Record + directories?: Record + activeSessions?: string[] + failSessionGet?: boolean + pendingRequests?: Record + getSession?: (sessionID: string) => Promise + listRequests?: (directory: string) => Promise +} + +export interface FakeSessionPermissionClient extends OpenCodeClient { + replyPermission: ReturnType +} + +export function createFakeSessionPermissionClient( + options: FakeSessionPermissionClientOptions = {}, +): FakeSessionPermissionClient { + const parents = options.parents ?? {} + const directories = options.directories ?? {} + 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, + location: { directory: directories[sessionID] ?? '' }, + } + }) + + const listRequests = + options.listRequests ?? + (async (directory: string) => pendingRequests[directory] ?? []) + + return { + replyPermission, + api: { + session: { + get: async ({ sessionID }: { sessionID: string }) => getSession(sessionID), + active: async () => Object.fromEntries( + (options.activeSessions ?? []).map(sessionID => [sessionID, { type: 'running' as const }]), + ), + }, + 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 FakeSessionPermissionClient +} diff --git a/backend/test/index.test.ts b/backend/test/index.test.ts index aedaadfa5..367e9401c 100644 --- a/backend/test/index.test.ts +++ b/backend/test/index.test.ts @@ -86,6 +86,7 @@ vi.mock('../src/services/skills', () => ({ const sseAggregatorMock = vi.hoisted(() => ({ onEvent: vi.fn(), + onUpstreamConnected: vi.fn(), setPendingActionsFetcher: vi.fn(), setPasswordResolver: vi.fn(), setScheduledSessionsResolver: vi.fn(), diff --git a/backend/test/routes/internal-assistant.test.ts b/backend/test/routes/internal-assistant.test.ts index e28172ac5..b4d7f711f 100644 --- a/backend/test/routes/internal-assistant.test.ts +++ b/backend/test/routes/internal-assistant.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' +import type { SessionPermissionModeService } from '../../src/services/session-permission-modes' import { Hono } from 'hono' import { Database } from 'bun:sqlite' import { createInternalRoutes } from '../../src/routes/internal' @@ -43,7 +44,7 @@ describe('internal/assistant routes', () => { notificationService = new NotificationService(db) settingsService = new SettingsService(db) app = new Hono() - app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient)) + app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, {} as SessionPermissionModeService)) token = getOrCreateInternalToken(db) }) diff --git a/backend/test/routes/internal-notifications.test.ts b/backend/test/routes/internal-notifications.test.ts index 0acf4d0a8..2f022521e 100644 --- a/backend/test/routes/internal-notifications.test.ts +++ b/backend/test/routes/internal-notifications.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, beforeEach, vi } from 'bun:test' +import type { SessionPermissionModeService } from '../../src/services/session-permission-modes' import { Hono } from 'hono' import { Database } from 'bun:sqlite' import { createInternalRoutes } from '../../src/routes/internal' @@ -29,7 +30,7 @@ describe('internal/notifications routes', () => { notificationService = new NotificationService(db) settingsService = new SettingsService(db) app = new Hono() - app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient)) + app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, {} as SessionPermissionModeService)) token = getOrCreateInternalToken(db) }) diff --git a/backend/test/routes/internal-opencode-config.test.ts b/backend/test/routes/internal-opencode-config.test.ts index 21b7796e7..185502eee 100644 --- a/backend/test/routes/internal-opencode-config.test.ts +++ b/backend/test/routes/internal-opencode-config.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'bun:test' +import type { SessionPermissionModeService } from '../../src/services/session-permission-modes' import { Hono } from 'hono' import { Database } from 'bun:sqlite' import { readFile, writeFile } from 'fs/promises' @@ -59,7 +60,7 @@ describe('internal/opencode-config routes', () => { const notificationService = new NotificationService(db) const settingsService = new SettingsService(db) app = new Hono() - app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient)) + app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, {} as SessionPermissionModeService)) token = getOrCreateInternalToken(db) }) diff --git a/backend/test/routes/internal-opencode-workspaces.test.ts b/backend/test/routes/internal-opencode-workspaces.test.ts index 73be9428b..7d1e740a9 100644 --- a/backend/test/routes/internal-opencode-workspaces.test.ts +++ b/backend/test/routes/internal-opencode-workspaces.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' +import type { SessionPermissionModeService } from '../../src/services/session-permission-modes' import { Hono } from 'hono' import type { Database } from 'bun:sqlite' import { createInternalRoutes } from '../../src/routes/internal' @@ -82,7 +83,7 @@ describe('internal-opencode-workspaces routes', () => { forwardRaw: vi.fn(), } as unknown as OpenCodeClient app = new Hono() - app.route('/api/internal', createInternalRoutes(mockDb, scheduleService, notificationService, settingsService, openCodeClient)) + app.route('/api/internal', createInternalRoutes(mockDb, scheduleService, notificationService, settingsService, openCodeClient, {} as SessionPermissionModeService)) token = 'test-internal-token' }) diff --git a/backend/test/routes/internal-repos.test.ts b/backend/test/routes/internal-repos.test.ts index 3d38b8240..2f06927f3 100644 --- a/backend/test/routes/internal-repos.test.ts +++ b/backend/test/routes/internal-repos.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, beforeEach } from 'vitest' +import type { SessionPermissionModeService } from '../../src/services/session-permission-modes' import { Hono } from 'hono' import { Database } from 'bun:sqlite' import { createInternalRoutes } from '../../src/routes/internal' @@ -30,7 +31,7 @@ describe('internal-repos routes', () => { notificationService = new NotificationService(db) settingsService = new SettingsService(db) app = new Hono() - app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient)) + app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, {} as SessionPermissionModeService)) token = getOrCreateInternalToken(db) }) diff --git a/backend/test/routes/internal-sandbox.test.ts b/backend/test/routes/internal-sandbox.test.ts index 76cd5f368..97ac967fa 100644 --- a/backend/test/routes/internal-sandbox.test.ts +++ b/backend/test/routes/internal-sandbox.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, mock, vi } from 'bun:test' +import type { SessionPermissionModeService } from '../../src/services/session-permission-modes' import { Hono } from 'hono' import { Database } from 'bun:sqlite' import { mkdirSync, rmSync } from 'node:fs' @@ -93,7 +94,7 @@ describe('internal sandbox routes', () => { const notificationService = new NotificationService(db) settingsService = new SettingsService(db) app = new Hono() - app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient)) + app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, {} as SessionPermissionModeService)) token = getOrCreateInternalToken(db) repoDir = path.join(getReposPath(), 'sandbox-route-test') mkdirSync(repoDir, { recursive: true }) diff --git a/backend/test/routes/internal-schedules.test.ts b/backend/test/routes/internal-schedules.test.ts index 417428cc4..2d5a37801 100644 --- a/backend/test/routes/internal-schedules.test.ts +++ b/backend/test/routes/internal-schedules.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, beforeEach } from 'vitest' +import type { SessionPermissionModeService } from '../../src/services/session-permission-modes' import { Hono } from 'hono' import { Database } from 'bun:sqlite' import { createInternalRoutes } from '../../src/routes/internal' @@ -28,7 +29,7 @@ describe('internal-schedules routes', () => { notificationService = new NotificationService(db) settingsService = new SettingsService(db) app = new Hono() - app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient)) + app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, {} as SessionPermissionModeService)) token = getOrCreateInternalToken(db) }) diff --git a/backend/test/routes/internal-sessions.test.ts b/backend/test/routes/internal-sessions.test.ts new file mode 100644 index 000000000..e19fc21ab --- /dev/null +++ b/backend/test/routes/internal-sessions.test.ts @@ -0,0 +1,410 @@ +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 { SessionPermissionModeService } from '../../src/services/session-permission-modes' +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() +const mockPinAsk = 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 } +}) + +const mockResolveRepoForDirectory = vi.fn() +const mockResolveRepoProjectId = vi.fn() +vi.mock('../../src/services/repo', () => ({ + resolveRepoForDirectory: (...args: unknown[]) => mockResolveRepoForDirectory(...args), + resolveRepoProjectId: (...args: unknown[]) => mockResolveRepoProjectId(...args), +})) + +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([]) + mockResolveRepoForDirectory.mockReset() + mockResolveRepoForDirectory.mockResolvedValue(null) + mockResolveRepoProjectId.mockReset() + mockResolveRepoProjectId.mockResolvedValue('project-1') + + 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 + const permissionModes = { pinAsk: mockPinAsk } as unknown as SessionPermissionModeService + app = new Hono() + app.route( + '/api/internal', + createInternalRoutes(mockDb, scheduleService, notificationService, settingsService, openCodeClient, permissionModes), + ) + 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 the repo OpenCode project and resolves workspace directories', async () => { + const repo = makeRepo({ id: 1, fullPath: '/tmp/repo-one' }) + mockGetRepoById.mockReturnValue(repo) + mockResolveRepoProjectId.mockResolvedValue('proj-1') + mockResolveRepoForDirectory.mockImplementation(async (_db: unknown, directory: string) => + directory === '/tmp/elsewhere' ? null : repo, + ) + sessionList.mockResolvedValue({ + data: [ + { id: 'ses_a', title: 'Alpha', location: { directory: '/tmp/repo-one' }, time: { created: 1, updated: 42 }, outcome: 'succeeded' }, + { id: 'ses_ws', title: 'Workspace', location: { directory: '/tmp/repo-one-workspaces/feature' }, time: { created: 2, updated: 43 } }, + { id: 'ses_b', location: { directory: '/tmp/elsewhere' }, time: { created: 3, updated: 44 } }, + { id: 'ses_a2', location: { directory: '/tmp/repo-one' }, time: { created: 4, updated: 45 } }, + ], + 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(mockResolveRepoProjectId).toHaveBeenCalledWith(openCodeClient, '/tmp/repo-one') + expect(sessionList).toHaveBeenCalledWith({ limit: 5, order: 'desc', parentID: null, project: 'proj-1' }) + expect(mockResolveRepoForDirectory).toHaveBeenCalledTimes(3) + const body = await res.json() as { sessions: Array> } + expect(body.sessions).toHaveLength(4) + 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_ws', title: 'Workspace', repoId: 1, busy: false, outcome: null, updated: 43 }) + expect(body.sessions[2]).toMatchObject({ id: 'ses_b', title: null, repoId: null, busy: false, outcome: null, updated: 44 }) + expect(body.sessions[3]).toMatchObject({ id: 'ses_a2', repoId: 1, busy: false, updated: 45 }) + }) + + it('GET /api/internal/sessions lists without a project filter when repoId is omitted', async () => { + sessionList.mockResolvedValue({ data: [], cursor: {} }) + + const res = await app.request('/api/internal/sessions', { + headers: { authorization: `Bearer ${token}` }, + }) + + expect(res.status).toBe(200) + expect(sessionList).toHaveBeenCalledWith({ limit: 10, order: 'desc', parentID: null }) + expect(mockResolveRepoProjectId).not.toHaveBeenCalled() + }) + + 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' }) + expect(mockPinAsk).toHaveBeenCalledWith('ses_new') + 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 passes the raw title as the workspace 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' }, + }) + expect(mockPinAsk).toHaveBeenCalledWith('ses_ws') + 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 is missing', 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' } }) + expect(mockPinAsk).toHaveBeenCalledWith('ses_ws') + }) + + 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('GET /api/internal/sessions/:sessionId/reply rejects an invalid waitMs', async () => { + const res = await app.request('/api/internal/sessions/ses_a/reply?waitMs=60000', { + headers: { authorization: `Bearer ${token}` }, + }) + + expect(res.status).toBe(400) + expect(messageList).not.toHaveBeenCalled() + }) + + it('GET /api/internal/sessions/:sessionId/reply returns without waiting when the session is not busy', async () => { + messageList.mockResolvedValue({ data: [assistantMessage] }) + + const res = await app.request('/api/internal/sessions/ses_a/reply?waitMs=5000', { + headers: { authorization: `Bearer ${token}` }, + }) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ busy: false, responseText: 'All done', errorText: null, completed: true }) + }) + + 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(mockPinAsk).toHaveBeenCalledWith('ses_fork') + 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' }) + expect(mockPinAsk).toHaveBeenCalledWith('ses_fork') + }) + + 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/routes/internal-settings.test.ts b/backend/test/routes/internal-settings.test.ts index 276970d91..93025c6fc 100644 --- a/backend/test/routes/internal-settings.test.ts +++ b/backend/test/routes/internal-settings.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, beforeEach } from 'bun:test' +import type { SessionPermissionModeService } from '../../src/services/session-permission-modes' import { Hono } from 'hono' import { Database } from 'bun:sqlite' import { createInternalRoutes } from '../../src/routes/internal' @@ -29,7 +30,7 @@ describe('internal/settings routes', () => { notificationService = new NotificationService(db) settingsService = new SettingsService(db) app = new Hono() - app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient)) + app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, {} as SessionPermissionModeService)) token = getOrCreateInternalToken(db) }) @@ -153,6 +154,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/multi-runs.test.ts b/backend/test/routes/multi-runs.test.ts new file mode 100644 index 000000000..cf3aa4027 --- /dev/null +++ b/backend/test/routes/multi-runs.test.ts @@ -0,0 +1,150 @@ +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) + await expect(res.json()).resolves.toEqual({ error: 'Invalid request body', details: expect.any(Array) }) + 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) + await expect(res.json()).resolves.toEqual({ error: 'Invalid request body', details: expect.any(Array) }) + expect(service.launch).not.toHaveBeenCalled() + }) + + it('POST rejects malformed JSON with 400', async () => { + const res = await app.request('/multi-runs', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{not json', + }) + + expect(res.status).toBe(400) + await expect(res.json()).resolves.toEqual({ error: 'Invalid JSON' }) + 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/routes/session-goals.test.ts b/backend/test/routes/session-goals.test.ts new file mode 100644 index 000000000..52f5bb9c2 --- /dev/null +++ b/backend/test/routes/session-goals.test.ts @@ -0,0 +1,193 @@ +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, type SessionGoalServiceOptions } 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, serviceOptions: SessionGoalServiceOptions = {}): Hono { + const app = new Hono() + const service = new SessionGoalService( + db, + createFakeSessionGoalClient({ busySessions: ['ses_1'] }).client, + new SettingsService(db), + serviceOptions, + ) + 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 and details', 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) + const body = await res.json() as { error: string; details: unknown[] } + expect(body.error).toBe('Invalid request body') + expect(Array.isArray(body.details)).toBe(true) + }) + + it('POST rejects malformed JSON with 400', async () => { + const app = createTestApp(db) + + const res = await app.request('/session-goals', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{not json', + }) + + expect(res.status).toBe(400) + await expect(res.json()).resolves.toEqual({ error: 'Invalid JSON' }) + }) + + it('POST rejects a scheduled run session with 409', async () => { + const app = createTestApp(db, { resolveSessionLock: async () => 'schedule' }) + + const res = await app.request('/session-goals', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: startBody(), + }) + + expect(res.status).toBe(409) + await expect(res.json()).resolves.toEqual({ error: 'Scheduled runs cannot run goals' }) + }) + + it('POST rejects a child session with 400', async () => { + const app = createTestApp(db, { resolveSessionLock: async () => 'child' }) + + const res = await app.request('/session-goals', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: startBody(), + }) + + expect(res.status).toBe(400) + await expect(res.json()).resolves.toEqual({ error: 'Goals can only be started on top-level sessions' }) + }) + + 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/routes/session-permission-modes.test.ts b/backend/test/routes/session-permission-modes.test.ts new file mode 100644 index 000000000..da66b2d45 --- /dev/null +++ b/backend/test/routes/session-permission-modes.test.ts @@ -0,0 +1,116 @@ +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', + lockedReason: null, + }) + }) + + it('GET reports the child lock reason for a child session', async () => { + const app = createTestApp(db, { ses_child: 'ses_root', ses_root: null }) + + const res = await app.request('/session-permission-modes/ses_child') + expect(res.status).toBe(200) + await expect(res.json()).resolves.toEqual({ + sessionId: 'ses_child', + rootSessionId: 'ses_root', + mode: 'ask', + lockedReason: 'child', + }) + }) + + 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', + lockedReason: null, + }) + + 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) + await expect(res.json()).resolves.toEqual({ error: 'Invalid request body', details: expect.any(Array) }) + }) + + 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) + await expect(res.json()).resolves.toEqual({ error: 'Invalid JSON' }) + }) + + 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/assistant-mode.test.ts b/backend/test/services/assistant-mode.test.ts index c2ca9d75b..0b10972c8 100644 --- a/backend/test/services/assistant-mode.test.ts +++ b/backend/test/services/assistant-mode.test.ts @@ -1,8 +1,9 @@ import { describe, expect, it, beforeEach, afterEach } from 'bun:test' +import type { SessionPermissionModeService } from '../../src/services/session-permission-modes' 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 +61,49 @@ 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') + }) + + it('documents the ask permission mode and workspace timeout behavior', () => { + const skill = buildSessionsSkill() + expect(skill).toContain('`ask` permission mode') + expect(skill).toContain('keep the mode the user chose') + expect(skill).toContain('may still have succeeded') + expect(skill).toContain('before retrying') + }) + + it('documents the reply wait parameter and workspace repoId semantics', () => { + const skill = buildSessionsSkill() + expect(skill).toContain('waitMs=30000') + expect(skill).toContain('OpenCode workspace') + expect(skill).toContain('belongs to no known repo') + expect(skill).toContain('capped') + }) +}) + describe('buildSettingsSkill', () => { it('instructs the agent to use the ocm tool request action', () => { const skill = buildSettingsSkill() @@ -175,6 +219,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 +237,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 +260,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 +311,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 +327,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 +342,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 () => { @@ -629,7 +686,7 @@ describe('assistant-mode end-to-end', () => { const notificationService = new NotificationService(db) const settingsService = new SettingsService(db) const app = new Hono() - app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, createOpenCodeClient())) + app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, createOpenCodeClient(), {} as SessionPermissionModeService)) const unauth = await app.request('/api/internal/schedules/all') expect(unauth.status).toBe(401) diff --git a/backend/test/services/multi-runs.test.ts b/backend/test/services/multi-runs.test.ts new file mode 100644 index 000000000..81e930a58 --- /dev/null +++ b/backend/test/services/multi-runs.test.ts @@ -0,0 +1,515 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { Database } from 'bun:sqlite' +import { createRepo, deleteRepo } 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(), + existsSync: vi.fn(), +})) + +vi.mock('../../src/services/opencode-models', () => ({ + resolveOpenCodeModel: mocks.resolveOpenCodeModel, +})) + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, existsSync: mocks.existsSync } +}) + +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.existsSync.mockReturnValue(true) + + 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('fails only the entry whose model is unavailable and creates no workspace for it', async () => { + const repoId = readyRepo() + const { client, worktreeCreate, worktreeRemove } = createClient() + const service = createService(client) + + const run = await service.launch({ + repoId, + name: 'Sweep', + prompt: 'go', + models: ['openai/a', 'openai/retired', 'openai/c'], + isolate: true, + }) + + expect(run.entries.map((entry) => entry.status)).toEqual(['started', 'failed', 'started']) + expect(run.entries[1]!.directory).toBeNull() + expect(run.entries[1]!.sessionId).toBeNull() + expect(run.entries[1]!.error).toBe('Model openai/retired is not available') + expect(run.entries[0]!.directory).toBe('/worktrees/Sweep-1') + expect(run.entries[2]!.directory).toBe('/worktrees/Sweep-3') + + expect(worktreeCreate).toHaveBeenCalledTimes(2) + expect(worktreeCreate.mock.calls.map((call) => call[0].name).sort()).toEqual(['Sweep-1', 'Sweep-3']) + expect(worktreeRemove).not.toHaveBeenCalled() + }) + + it('marks an isolated entry discarded when its workspace directory is gone', 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') + + mocks.existsSync.mockReturnValue(false) + const discarded = await service.discard(run.id, entry.id) + + expect(discarded.entries[0]!.status).toBe('discarded') + expect(worktreeRemove).not.toHaveBeenCalled() + }) + + it('maps a workspace that is not a deletable sibling to 400 and keeps the entry retryable', async () => { + const repoId = readyRepo() + const { client, workspaces, 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]! + + workspaces.length = 0 + + const error = await service.discard(run.id, entry.id).catch((caught: unknown) => caught) + + expect(error).toBeInstanceOf(MultiRunError) + expect(error).toMatchObject({ status: 400 }) + expect(worktreeRemove).not.toHaveBeenCalled() + expect(service.list(repoId)[0]!.entries[0]!.status).toBe('started') + }) + + 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) + }) + + it('deletes the repository multi-run rows when the repository is deleted', async () => { + const repoId = readyRepo() + const { client } = createClient() + const service = createService(client) + + await service.launch({ repoId, name: 'Sweep', prompt: 'go', models: ['openai/a'], isolate: false }) + + deleteRepo(db, repoId) + + expect(db.prepare('SELECT COUNT(*) AS count FROM multi_runs').get()).toEqual({ count: 0 }) + expect(db.prepare('SELECT COUNT(*) AS count FROM multi_run_entries').get()).toEqual({ count: 0 }) + }) +}) diff --git a/backend/test/services/notification-format.test.ts b/backend/test/services/notification-format.test.ts index d5111e99d..c587fb61d 100644 --- a/backend/test/services/notification-format.test.ts +++ b/backend/test/services/notification-format.test.ts @@ -1,10 +1,40 @@ import { describe, it, expect } from 'vitest' -import { getPermissionLabel, getPermissionDetail, getFormText } from '@opencode-manager/shared/notifications' +import { + getPermissionLabel, + getPermissionDetail, + getFormText, + getGoalOutcomeTitle, + getGoalStopReasonLabel, +} from '@opencode-manager/shared/notifications' import { buildEventNotificationPayload, buildNotificationUrl } from '../../src/services/notification' import { ASSISTANT_REPO_ID } from '@opencode-manager/shared/utils' const ctx = { repoName: 'oc-manager', repoId: 1, sessionId: 'ses_1', directory: '/abs/repo', url: '/repos/1/sessions/ses_1' } +describe('goal notification labels', () => { + it('maps goal stop reasons to human labels', () => { + expect(getGoalStopReasonLabel('cancelled')).toBe('Cancelled') + expect(getGoalStopReasonLabel('user_paused')).toBe('Paused by user') + expect(getGoalStopReasonLabel('continuation_limit')).toBe('Continuation limit reached') + expect(getGoalStopReasonLabel('turn_error')).toBe('Turn failed') + expect(getGoalStopReasonLabel('interrupted')).toBe('Interrupted') + expect(getGoalStopReasonLabel('audit_failed')).toBe('Audit failed') + expect(getGoalStopReasonLabel('session_deleted')).toBe('Session deleted') + }) + + it('derives the notification title from the status', () => { + expect(getGoalOutcomeTitle('active')).toBe('Goal active') + expect(getGoalOutcomeTitle('paused')).toBe('Goal paused') + expect(getGoalOutcomeTitle('completed')).toBe('Goal completed') + expect(getGoalOutcomeTitle('blocked')).toBe('Goal blocked') + expect(getGoalOutcomeTitle('stopped')).toBe('Goal stopped') + }) + + it('resolves a stop reason label', () => { + expect(getGoalStopReasonLabel('token_budget')).toBe('Token budget reached') + }) +}) + describe('getPermissionLabel', () => { it('maps known V2 permission actions to friendly labels', () => { expect(getPermissionLabel('shell')).toBe('Run Command') diff --git a/backend/test/services/notification-service.test.ts b/backend/test/services/notification-service.test.ts index eb0cace1f..9a61c7532 100644 --- a/backend/test/services/notification-service.test.ts +++ b/backend/test/services/notification-service.test.ts @@ -8,6 +8,7 @@ import { NotificationService } from '../../src/services/notification' 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' const DIRECTORY = '/abs/repo' const USER_ID = 'user-1' @@ -158,4 +159,218 @@ 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) + }) +}) + +describe('NotificationService goal outcomes', () => { + beforeEach(() => { + sseAggregator.shutdown() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + 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('does not notify when the goal stopped because its turn failed', async () => { + const service = createService() + const send = vi.spyOn(service, 'sendToUser').mockResolvedValue(sendResult) + + await service.notifyGoalOutcome({ + ...completedGoal, + id: 11, + status: 'stopped', + stopReason: 'turn_error', + }) + + expect(send).not.toHaveBeenCalled() + }) + + it('does not notify when the user cancelled the goal', async () => { + const service = createService() + const send = vi.spyOn(service, 'sendToUser').mockResolvedValue(sendResult) + + await service.notifyGoalOutcome({ + ...completedGoal, + id: 12, + status: 'stopped', + stopReason: 'cancelled', + }) + + expect(send).not.toHaveBeenCalled() + }) + + it('does not notify when the user paused the goal', async () => { + const service = createService() + const send = vi.spyOn(service, 'sendToUser').mockResolvedValue(sendResult) + + await service.notifyGoalOutcome({ + ...completedGoal, + id: 13, + status: 'paused', + stopReason: 'user_paused', + }) + + expect(send).not.toHaveBeenCalled() + }) + + it('does not notify while the goal session is being viewed', async () => { + const service = createService() + const send = vi.spyOn(service, 'sendToUser').mockResolvedValue(sendResult) + vi.spyOn(sseAggregator, 'isSessionBeingViewed').mockReturnValue(true) + + await service.notifyGoalOutcome(completedGoal) + + expect(send).not.toHaveBeenCalled() + }) + + it('notifies the remaining goal outcomes', async () => { + const service = createService() + const send = vi.spyOn(service, 'sendToUser').mockResolvedValue(sendResult) + const outcomes: Array> = [ + { status: 'blocked', stopReason: null }, + { status: 'stopped', stopReason: 'continuation_limit' }, + { status: 'stopped', stopReason: 'token_budget' }, + { status: 'stopped', stopReason: 'interrupted' }, + { status: 'paused', stopReason: 'audit_failed' }, + { status: 'stopped', stopReason: 'session_deleted' }, + ] + + for (const [index, outcome] of outcomes.entries()) { + await service.notifyGoalOutcome({ ...completedGoal, id: 100 + index, ...outcome }) + } + + expect(send).toHaveBeenCalledTimes(outcomes.length) + }) }) 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/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 }) + }) +}) diff --git a/backend/test/services/repo.test.ts b/backend/test/services/repo.test.ts index e3f13ba71..77a2a977e 100644 --- a/backend/test/services/repo.test.ts +++ b/backend/test/services/repo.test.ts @@ -580,3 +580,51 @@ describe('getSiblingRepos worktree API', () => { expect(siblings[0]?.id).toBe(1) }) }) + +describe('resolveRepoForDirectory', () => { + function createReadyRepo(id: number, localPath: string): 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(), + } + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('resolves a workspace directory that shares a repo OpenCode project id', async () => { + const { resolveRepoForDirectory } = await import('../../src/services/repo') + const repo = createReadyRepo(1, 'repo-a') + const workspaceDirectory = '/worktrees/feature-x' + getRepoBySourcePath.mockReturnValue(null) + getRepoByLocalPath.mockReturnValue(null) + listRepos.mockReturnValue([repo]) + resolveProjectId.mockImplementation(async (directory: string) => + directory === workspaceDirectory || directory === repo.fullPath ? 'commit-A' : null, + ) + + const result = await resolveRepoForDirectory({} as never, workspaceDirectory) + + expect(result).toBe(repo) + }) + + it('returns null for a directory that matches no repo', async () => { + const { resolveRepoForDirectory } = await import('../../src/services/repo') + getRepoBySourcePath.mockReturnValue(null) + getRepoByLocalPath.mockReturnValue(null) + listRepos.mockReturnValue([createReadyRepo(1, 'repo-a')]) + resolveProjectId.mockResolvedValue(null) + + const result = await resolveRepoForDirectory({} as never, '/unknown/directory') + + expect(result).toBeNull() + }) +}) 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..a8d537297 --- /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, + parseGoalVerdict, +} from '../../src/services/session-goal-audit' +import { SESSION_REPLY_MAX_LENGTH } from '../../src/services/session-reply' + +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(SESSION_REPLY_MAX_LENGTH + 100) + const prompt = buildGoalAuditPrompt({ objective: 'Ship it', reply }) + + expect(prompt).toContain('[reply truncated]') + expect(prompt).not.toContain('x'.repeat(SESSION_REPLY_MAX_LENGTH + 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 new file mode 100644 index 000000000..d9b317875 --- /dev/null +++ b/backend/test/services/session-goals.test.ts @@ -0,0 +1,1102 @@ +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, 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' +import { + createFakeSessionGoalClient, + fakeAssistantMessage, + type FakeSessionGoalClient, + type FakeSessionGoalClientOptions, +} 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: FakeSessionGoalClientOptions = {}, + settingsService: SettingsService = new SettingsService(db), + serviceOptions: SessionGoalServiceOptions = {}, +): SessionGoalService { + 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() + }) + + 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('rejects a goal on a scheduled run session with 409 and creates no goal', async () => { + const service = createService(db, {}, new SettingsService(db), { + resolveSessionLock: async () => 'schedule', + }) + + await expect( + service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }), + ).rejects.toMatchObject({ status: 409, message: 'Scheduled runs cannot run goals' }) + expect(listOpenSessionGoals(db)).toHaveLength(0) + }) + + it('rejects a goal on a child session with 400 and creates no goal', async () => { + const service = createService(db, {}, new SettingsService(db), { + resolveSessionLock: async () => 'child', + }) + + await expect( + service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }), + ).rejects.toMatchObject({ status: 400, message: 'Goals can only be started on top-level sessions' }) + expect(listOpenSessionGoals(db)).toHaveLength(0) + }) + + it('starts a goal when the session is not locked', async () => { + const service = createService(db, {}, new SettingsService(db), { + resolveSessionLock: async () => null, + }) + + const goal = await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) + + expect(goal.status).toBe('active') + expect(listOpenSessionGoals(db)).toHaveLength(1) + }) + + it('starts a goal when the session lock lookup fails', async () => { + const service = createService(db, {}, new SettingsService(db), { + resolveSessionLock: async () => { + throw new Error('lock lookup failed') + }, + }) + + const goal = await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) + + expect(goal.status).toBe('active') + }) + + 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('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) + + 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) + }) + + 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) + }) +}) + +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('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/backend/test/services/session-launcher.test.ts b/backend/test/services/session-launcher.test.ts new file mode 100644 index 000000000..e52fa29ce --- /dev/null +++ b/backend/test/services/session-launcher.test.ts @@ -0,0 +1,202 @@ +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; 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 () => { + if (overrides.worktreeCreateError) throw overrides.worktreeCreateError + return { 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, REPO_DIR, { 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 before creating a workspace or session', async () => { + const repoId = readyRepo() + const { client, create, worktreeCreate } = createClient() + 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, + message: 'Model openai/retired is not available', + workspaceDirectory: null, + }) + expect(worktreeCreate).not.toHaveBeenCalled() + expect(create).not.toHaveBeenCalled() + }) + + it('slugs a raw workspace name before creating the workspace', async () => { + const repoId = readyRepo() + const { client, worktreeCreate } = createClient({ workspaceDirectory: '/worktrees/Feature-Sweep' }) + const launcher = new SessionLauncher(db, client) + + await launcher.launch({ repoId, prompt: 'hello', workspace: { name: 'Feature Sweep!' } }) + + expect(worktreeCreate).toHaveBeenCalledWith({ projectID: 'commit-A', name: 'Feature-Sweep' }) + }) + + 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)', + workspaceDirectory: '/worktrees/feature-x', + }) + expect(worktreeCreate).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/backend/test/services/session-permission-modes.test.ts b/backend/test/services/session-permission-modes.test.ts new file mode 100644 index 000000000..c4d6f4c67 --- /dev/null +++ b/backend/test/services/session-permission-modes.test.ts @@ -0,0 +1,467 @@ +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 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:') + migrate(db, allMigrations) + 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 +} + +function sessionForkedEvent(sessionID: string, parentID: string): SSEEvent { + return { + id: `evt_forked_${sessionID}`, + created: Date.now(), + type: 'session.forked', + location: { directory: DIRECTORY }, + data: { sessionID, parentID, boundary: { messageID: 'msg_1', partID: null } }, + } as unknown as SSEEvent +} + +function sessionCreatedWithPermissionsEvent(sessionID: string, permissions: unknown[]): SSEEvent { + return { + id: `evt_created_${sessionID}`, + created: Date.now(), + type: 'session.created', + location: { directory: DIRECTORY }, + data: { sessionID, permissions }, + } as unknown as SSEEvent +} + +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', + lockedReason: null, + }) + }) + + it('applies a stored auto root mode to its child and marks it locked to the child', 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', + lockedReason: 'child', + }) + }) + + 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', + lockedReason: 'schedule', + }) + }) + + 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', + lockedReason: null, + }) + }) + + 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') + }) + + 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('does not stamp the default auto mode when the created event carries an explicit permissions ruleset', async () => { + const settingsService = new SettingsService(db) + settingsService.updateSettings({ sessionDefaults: { permissionMode: 'auto' } }) + const service = new SessionPermissionModeService(db, createFakeSessionPermissionClient(), settingsService) + + await service.handleEvent( + DIRECTORY, + sessionCreatedWithPermissionsEvent('ses_root', [{ permission: 'bash', pattern: '*', action: 'allow' }]), + ) + + expect(getSessionPermissionMode(db, 'ses_root')).toBeNull() + }) + + it('copies an auto source mode onto a forked root session', async () => { + setSessionPermissionMode(db, 'ses_source', 'auto') + const service = new SessionPermissionModeService( + db, + createFakeSessionPermissionClient({ parents: { ses_source: null } }), + new SettingsService(db), + ) + + await service.handleEvent(DIRECTORY, sessionForkedEvent('ses_fork', 'ses_source')) + + expect(getSessionPermissionMode(db, 'ses_fork')).toBe('auto') + }) + + it('leaves a forked session unstamped when the source is ask', async () => { + const service = new SessionPermissionModeService( + db, + createFakeSessionPermissionClient({ parents: { ses_source: null } }), + new SettingsService(db), + ) + + await service.handleEvent(DIRECTORY, sessionForkedEvent('ses_fork', 'ses_source')) + + expect(getSessionPermissionMode(db, 'ses_fork')).toBeNull() + }) + + it('does not overwrite an explicit pin when copying the source mode onto a fork', async () => { + setSessionPermissionMode(db, 'ses_source', 'auto') + setSessionPermissionMode(db, 'ses_fork', 'ask') + const service = new SessionPermissionModeService( + db, + createFakeSessionPermissionClient({ parents: { ses_source: null } }), + new SettingsService(db), + ) + + await service.handleEvent(DIRECTORY, sessionForkedEvent('ses_fork', 'ses_source')) + + expect(getSessionPermissionMode(db, 'ses_fork')).toBe('ask') + }) + + it('pinAsk overrides a default auto stamp and survives later insert-if-absent events', async () => { + const settingsService = new SettingsService(db) + settingsService.updateSettings({ sessionDefaults: { permissionMode: 'auto' } }) + const service = new SessionPermissionModeService( + db, + createFakeSessionPermissionClient({ parents: { ses_root: null } }), + settingsService, + ) + + await service.handleEvent(DIRECTORY, sessionCreatedEvent('ses_root')) + expect(getSessionPermissionMode(db, 'ses_root')).toBe('auto') + + service.pinAsk('ses_root') + expect(getSessionPermissionMode(db, 'ses_root')).toBe('ask') + + await service.handleEvent(DIRECTORY, sessionCreatedEvent('ses_root')) + expect(getSessionPermissionMode(db, 'ses_root')).toBe('ask') + }) + + it('rejects setMode for a schedule-run session with 409', 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' }) + + const service = new SessionPermissionModeService(db, createFakeSessionPermissionClient(), new SettingsService(db)) + + await expect(service.setMode('ses_scheduled', 'auto', DIRECTORY)).rejects.toMatchObject({ status: 409 }) + expect(getSessionPermissionMode(db, 'ses_scheduled')).toBeNull() + }) + + it('accepts pending requests for active auto sessions and skips ask sessions', async () => { + setSessionPermissionMode(db, 'ses_auto', 'auto') + const client = createFakeSessionPermissionClient({ + parents: { ses_auto: null, ses_ask: null }, + directories: { ses_auto: DIRECTORY, ses_ask: DIRECTORY }, + activeSessions: ['ses_auto', 'ses_ask'], + pendingRequests: { + [DIRECTORY]: [ + { id: 'perm-auto', sessionID: 'ses_auto' }, + { id: 'perm-ask', sessionID: 'ses_ask' }, + ], + }, + }) + const service = new SessionPermissionModeService(db, client, new SettingsService(db)) + + await service.acceptPendingRequestsForActiveSessions() + + expect(client.replyPermission).toHaveBeenCalledTimes(1) + expect(client.replyPermission).toHaveBeenCalledWith({ + sessionID: 'ses_auto', + requestID: 'perm-auto', + decision: 'once', + }) + }) + + it('accepts a pending request for a child of an active auto root', async () => { + setSessionPermissionMode(db, 'ses_root', 'auto') + const client = createFakeSessionPermissionClient({ + parents: { ses_root: null, ses_child: 'ses_root' }, + directories: { ses_child: DIRECTORY }, + activeSessions: ['ses_child'], + pendingRequests: { [DIRECTORY]: [{ id: 'perm-child', sessionID: 'ses_child' }] }, + }) + const service = new SessionPermissionModeService(db, client, new SettingsService(db)) + + await service.acceptPendingRequestsForActiveSessions() + + expect(client.replyPermission).toHaveBeenCalledTimes(1) + expect(client.replyPermission).toHaveBeenCalledWith({ + sessionID: 'ses_child', + requestID: 'perm-child', + decision: 'once', + }) + }) + + 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() + }) +}) diff --git a/backend/test/services/session-reply.test.ts b/backend/test/services/session-reply.test.ts new file mode 100644 index 000000000..7b972a10d --- /dev/null +++ b/backend/test/services/session-reply.test.ts @@ -0,0 +1,215 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest' +import type { SessionMessageInfo } from '@opencode-manager/shared/opencode' +import type { OpenCodeClient } from '../../src/services/opencode/client' +import { + SESSION_REPLY_MAX_LENGTH, + getLatestAssistantReplyState, + isSessionBusy, + readLatestAssistantReply, + sessionSettleSignal, + truncateSessionReply, + waitForSessionSettled, +} from '../../src/services/session-reply' +import { assistantMessage } from '../helpers/stub-schedule-api' + +const mockOnEvent = vi.fn() +vi.mock('../../src/services/sse-aggregator', () => ({ + sseAggregator: { onEvent: (...args: unknown[]) => mockOnEvent(...args) }, +})) + +beforeEach(() => { + mockOnEvent.mockReset() +}) + +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) + }) +}) + +describe('truncateSessionReply', () => { + it('returns short replies unchanged', () => { + expect(truncateSessionReply('short reply')).toBe('short reply') + }) + + it('caps long replies and appends a truncation marker', () => { + const reply = 'x'.repeat(SESSION_REPLY_MAX_LENGTH + 25) + const truncated = truncateSessionReply(reply) + + expect(truncated.startsWith('x'.repeat(SESSION_REPLY_MAX_LENGTH))).toBe(true) + expect(truncated.endsWith('[reply truncated]')).toBe(true) + expect(truncated).not.toContain('x'.repeat(SESSION_REPLY_MAX_LENGTH + 1)) + }) +}) + +describe('sessionSettleSignal', () => { + it('signals a clean settle for idle, succeeded, and status idle events', () => { + expect(sessionSettleSignal({ type: 'session.idle', data: { sessionID: 's1' } } as never, 's1')).toEqual({ errorText: null }) + expect(sessionSettleSignal({ type: 'session.execution.succeeded', data: { sessionID: 's1' } } as never, 's1')).toEqual({ errorText: null }) + expect(sessionSettleSignal({ type: 'session.status', data: { sessionID: 's1', status: { type: 'idle' } } } as never, 's1')).toEqual({ errorText: null }) + }) + + it('signals a failure with the reported error message', () => { + expect(sessionSettleSignal({ type: 'session.execution.failed', data: { sessionID: 's1', error: { message: 'boom' } } } as never, 's1')).toEqual({ errorText: 'boom' }) + expect(sessionSettleSignal({ type: 'session.execution.failed', data: { sessionID: 's1', error: { message: '' } } } as never, 's1')).toEqual({ errorText: 'The session reported an unknown error.' }) + }) + + it('signals an interruption', () => { + expect(sessionSettleSignal({ type: 'session.execution.interrupted', data: { sessionID: 's1' } } as never, 's1')).toEqual({ errorText: 'The session execution was interrupted.' }) + }) + + it('ignores events for other sessions and non-settling events', () => { + expect(sessionSettleSignal({ type: 'session.idle', data: { sessionID: 's2' } } as never, 's1')).toBeNull() + expect(sessionSettleSignal({ type: 'session.status', data: { sessionID: 's1', status: { type: 'busy' } } } as never, 's1')).toBeNull() + expect(sessionSettleSignal({ type: 'session.execution.started', data: { sessionID: 's1' } } as never, 's1')).toBeNull() + }) +}) + +describe('waitForSessionSettled', () => { + function captureListener() { + let listener: ((directory: string, event: unknown) => void) | undefined + const unsubscribe = vi.fn() + mockOnEvent.mockImplementation((callback: (directory: string, event: unknown) => void) => { + listener = callback + return unsubscribe + }) + return { + emit: (event: unknown) => listener?.('', event), + unsubscribe, + } + } + + it('does not subscribe when the timeout is zero', async () => { + const client = createFakeClient([], { 'ses-1': {} }) + + await expect(waitForSessionSettled(client, 'ses-1', 0)).resolves.toBeUndefined() + expect(mockOnEvent).not.toHaveBeenCalled() + }) + + it('resolves once the session settles and unsubscribes', async () => { + const client = createFakeClient([], { 'ses-1': {} }) + const { emit, unsubscribe } = captureListener() + + const settled = waitForSessionSettled(client, 'ses-1', 1000) + emit({ type: 'session.idle', data: { sessionID: 'ses-1' } }) + + await expect(settled).resolves.toBeUndefined() + expect(unsubscribe).toHaveBeenCalledTimes(1) + }) + + it('ignores settle events for other sessions', async () => { + const client = createFakeClient([], { 'ses-1': {} }) + const { emit, unsubscribe } = captureListener() + + const settled = waitForSessionSettled(client, 'ses-1', 1000) + emit({ type: 'session.idle', data: { sessionID: 'ses-2' } }) + expect(unsubscribe).not.toHaveBeenCalled() + + emit({ type: 'session.idle', data: { sessionID: 'ses-1' } }) + await expect(settled).resolves.toBeUndefined() + expect(unsubscribe).toHaveBeenCalledTimes(1) + }) + + it('resolves immediately when the session is no longer busy after subscribing', async () => { + const client = createFakeClient([], {}) + const { unsubscribe } = captureListener() + + await expect(waitForSessionSettled(client, 'ses-1', 1000)).resolves.toBeUndefined() + expect(unsubscribe).toHaveBeenCalledTimes(1) + }) + + it('resolves on timeout and unsubscribes', async () => { + const client = createFakeClient([], { 'ses-1': {} }) + const { unsubscribe } = captureListener() + + await expect(waitForSessionSettled(client, 'ses-1', 20)).resolves.toBeUndefined() + expect(unsubscribe).toHaveBeenCalledTimes(1) + }) +}) diff --git a/backend/test/services/sse-aggregator.test.ts b/backend/test/services/sse-aggregator.test.ts index b8c621c90..0fe3d2b61 100644 --- a/backend/test/services/sse-aggregator.test.ts +++ b/backend/test/services/sse-aggregator.test.ts @@ -583,6 +583,34 @@ describe('SSEAggregator upstream resynchronization signal', () => { expect(client.events.filter(event => event.event === 'resync')).toHaveLength(1) }) + + it('invokes onUpstreamConnected listeners on the first and every later upstream connect', () => { + const listener = vi.fn() + const unsubscribe = sseAggregator.onUpstreamConnected(listener) + + const openUpstream = (sseAggregator as unknown as { handleUpstreamOpen(wasConnectedBefore: boolean): void }).handleUpstreamOpen.bind(sseAggregator) + openUpstream(false) + openUpstream(true) + + expect(listener).toHaveBeenCalledTimes(2) + + unsubscribe() + openUpstream(true) + + expect(listener).toHaveBeenCalledTimes(2) + }) + + it('isolates a throwing onUpstreamConnected listener from the others', () => { + const throwing = vi.fn(() => { throw new Error('boom') }) + const healthy = vi.fn() + sseAggregator.onUpstreamConnected(throwing) + sseAggregator.onUpstreamConnected(healthy) + + ;(sseAggregator as unknown as { handleUpstreamOpen(wasConnectedBefore: boolean): void }).handleUpstreamOpen(false) + + expect(throwing).toHaveBeenCalledTimes(1) + expect(healthy).toHaveBeenCalledTimes(1) + }) }) describe('SSEAggregator directory-indexed broadcast', () => { @@ -735,4 +763,37 @@ describe('SSEAggregator directory-less session event routing', () => { payload: { id: 'evt_1', created: 1, type: 'session.execution.succeeded', data: { sessionID: 'ses_del' } }, }) }) + + it('resolves a directory-less session.forked from the remembered parent directory and notifies listeners', () => { + const subscribed = createCapturingClient() + const global = createCapturingClient() + sseAggregator.addClient('fork-a', subscribed.callback, subscribed.writeFrame, ['/r']) + sseAggregator.addClient('fork-b', global.callback, global.writeFrame, []) + + const seen: Array<{ directory: string; type: string; sessionID: string }> = [] + sseAggregator.onEvent((directory, event) => { + seen.push({ directory, type: event.type, sessionID: sessionIDFromEvent(event) ?? '' }) + }) + + emitRawEvent({ type: 'session.created', location: { directory: '/r' }, data: { sessionID: 'ses_source' } }) + seen.length = 0 + + emitRawEvent({ type: 'session.forked', data: { sessionID: 'ses_fork', parentID: 'ses_source', boundary: { messageID: 'msg_1' } } }) + + expect(seen).toEqual([{ directory: '/r', type: 'session.forked', sessionID: 'ses_fork' }]) + + const forkPayload = { + id: 'evt_1', + created: 1, + type: 'session.forked', + data: { sessionID: 'ses_fork', parentID: 'ses_source', boundary: { messageID: 'msg_1' } }, + } + expect(subscribed.frames.map(parseFrame).at(-1)).toEqual({ directory: null, payload: forkPayload }) + expect(global.frames.map(parseFrame).at(-1)).toEqual({ directory: null, payload: forkPayload }) + + seen.length = 0 + emitRawEvent({ type: 'session.execution.succeeded', data: { sessionID: 'ses_fork' } }) + + expect(seen).toEqual([{ directory: '/r', type: 'session.execution.succeeded', sessionID: 'ses_fork' }]) + }) }) diff --git a/backend/test/utils/route-helpers.test.ts b/backend/test/utils/route-helpers.test.ts new file mode 100644 index 000000000..383c0f091 --- /dev/null +++ b/backend/test/utils/route-helpers.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'vitest' +import { Hono } from 'hono' +import { z, type ZodType } from 'zod' +import { ClientError } from '@opencode-manager/shared/opencode' +import { handleOpenCodeError, parseJsonBody } from '../../src/utils/route-helpers' + +function createParseApp(schema: ZodType, options?: { allowEmpty?: boolean }) { + const app = new Hono() + app.post('/', async (c) => { + const result = await parseJsonBody(c, schema, options) + if (!result.ok) { + return result.response + } + return c.json({ data: result.data }) + }) + return app +} + +function createErrorApp(error: unknown, fallback: string, options?: { unknownStatus?: 500 | 502 }) { + const app = new Hono() + app.get('/', (c) => handleOpenCodeError(c, error, fallback, options)) + return app +} + +describe('parseJsonBody', () => { + const NameSchema = z.object({ name: z.string() }) + + it('parses a valid JSON body', async () => { + const response = await createParseApp(NameSchema).request('/', { + method: 'POST', + body: JSON.stringify({ name: 'ada' }), + }) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ data: { name: 'ada' } }) + }) + + it('rejects malformed JSON with an Invalid JSON body', async () => { + const response = await createParseApp(NameSchema).request('/', { + method: 'POST', + body: '{not json', + }) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toEqual({ error: 'Invalid JSON' }) + }) + + it('treats an empty body as invalid JSON by default', async () => { + const response = await createParseApp(NameSchema).request('/', { method: 'POST', body: ' ' }) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toEqual({ error: 'Invalid JSON' }) + }) + + it('parses an empty body as {} when allowEmpty is set', async () => { + const OptionalSchema = z.object({ name: z.string().optional() }) + const response = await createParseApp(OptionalSchema, { allowEmpty: true }).request('/', { + method: 'POST', + body: '', + }) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ data: {} }) + }) + + it('returns schema issues when the body fails validation', async () => { + const response = await createParseApp(NameSchema).request('/', { + method: 'POST', + body: JSON.stringify({ name: 42 }), + }) + + expect(response.status).toBe(400) + const body = await response.json() as { error: string; details: unknown[] } + expect(body.error).toBe('Invalid request body') + expect(Array.isArray(body.details)).toBe(true) + expect(body.details.length).toBeGreaterThan(0) + }) +}) + +describe('handleOpenCodeError', () => { + it('falls back to a 500 with the fallback message for unknown errors', async () => { + const response = await createErrorApp(new Error('kaboom'), 'Request failed').request('/') + + expect(response.status).toBe(500) + await expect(response.json()).resolves.toEqual({ error: 'Request failed' }) + }) + + it('uses unknownStatus 502 and surfaces the error message', async () => { + const response = await createErrorApp(new Error('kaboom'), 'Request failed', { unknownStatus: 502 }).request('/') + + expect(response.status).toBe(502) + await expect(response.json()).resolves.toEqual({ error: 'kaboom' }) + }) + + it('falls back to the fallback message when a 502 error has no message', async () => { + const response = await createErrorApp(new Error(''), 'Request failed', { unknownStatus: 502 }).request('/') + + expect(response.status).toBe(502) + await expect(response.json()).resolves.toEqual({ error: 'Request failed' }) + }) + + it('maps a tagged OpenCode not-found error to 404', async () => { + const error = Object.assign(new Error('session gone'), { _tag: 'SessionNotFoundError' }) + const response = await createErrorApp(error, 'Request failed').request('/') + + expect(response.status).toBe(404) + await expect(response.json()).resolves.toEqual({ error: 'session gone' }) + }) + + it('maps a ClientError to a 502 with the ClientError code', async () => { + const response = await createErrorApp(new ClientError('Transport'), 'Request failed').request('/') + + expect(response.status).toBe(502) + await expect(response.json()).resolves.toEqual({ error: 'Request failed', code: 'ClientError' }) + }) +}) diff --git a/docs/features/assistant-internal-api.md b/docs/features/assistant-internal-api.md index 3b1a8f194..ba06c5b23 100644 --- a/docs/features/assistant-internal-api.md +++ b/docs/features/assistant-internal-api.md @@ -83,6 +83,11 @@ DELETE /repos/*/schedules/*/runs GET /repos/*/schedules/*/runs/* DELETE /repos/*/schedules/*/runs/* POST /repos/*/schedules/*/runs/*/cancel +GET /sessions +POST /sessions +POST /sessions/*/prompt +GET /sessions/*/reply +POST /sessions/*/fork ``` **Deliberately not allow-listed:** @@ -371,13 +376,157 @@ Retrieve a list of all managed repositories, ordered by the user's repo preferen - `401`: Missing or invalid bearer token - `500`: Server error (database failure) +### Sessions + +These routes back the `session-management` skill, so an agent can start work in a repo, hand a task to a new session, follow it up, read its reply, and fork it. Session creation always goes through the Manager's session launcher, which validates the repository and the requested model before creating anything. Malformed JSON is rejected with `{ "error": "Invalid JSON" }`; a body that fails validation is rejected with `{ "error": "Invalid request body", "details": [...] }`; unknown upstream failures return `502`. + +**GET `/api/internal/sessions`** + +List sessions, newest first. Pass `repoId` to restrict the list to one repo — this covers every OpenCode workspace of that repo, not only the repo directory — and `limit` (1-50, default 10) to bound it. + +**Query Parameters:** +- `repoId` (optional): Restrict to a repository. An unknown id returns `404`. +- `limit` (optional): 1-50, default 10. + +**Response:** +```ts +{ + sessions: Array<{ + id: string + title: string | null + directory: string + repoId: number | null // null only when the directory belongs to no known repo + busy: boolean // true while the session is running + outcome: 'succeeded' | 'failed' | 'interrupted' | null + updated: number + }> +} +``` + +**Status Codes:** +- `200`: Session list returned +- `400`: Invalid query +- `401`: Missing or invalid bearer token +- `404`: Repository not found +- `502`: OpenCode error + +**POST `/api/internal/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; poll `GET /api/internal/sessions/:sessionId/reply` for the result. Pass `worktree: true` to run in a new isolated workspace, with `ref` selecting its base ref. A requested `model` must be available; an unavailable model is rejected with `400` rather than silently substituted. Sessions created here are pinned to **ask** permission mode. With `worktree: true`, creation can take tens of seconds; a request that times out may still have succeeded, so check `GET /api/internal/sessions` before retrying. + +**Request Body:** +```ts +{ + repoId: number + prompt: string // 1-20000 characters + title?: string // max 200 characters + model?: string + agent?: string + worktree?: boolean + ref?: string +} +``` + +**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 +} +``` + +**Status Codes:** +- `201`: Session created and prompt queued +- `400`: Invalid request body, or the requested model is unavailable +- `401`: Missing or invalid bearer token +- `404`: Repository not found +- `502`: OpenCode error + +**POST `/api/internal/sessions/:sessionId/prompt`** + +Queue a follow-up prompt for an existing session. + +**Request Body:** +```ts +{ + text: string // 1-20000 characters +} +``` + +**Response (`202`):** +```ts +{ queued: true } +``` + +**Status Codes:** +- `202`: Prompt queued +- `400`: Invalid request body +- `401`: Missing or invalid bearer token +- `404`: Session not found +- `502`: OpenCode error + +**GET `/api/internal/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`. Pass `waitMs` (0-45000) to wait until the session settles instead of polling in a loop; the response returns when it settles or the timeout elapses. `responseText` is capped at 20,000 characters, with a truncation marker appended when it is longer. + +**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 +} +``` + +**Status Codes:** +- `200`: Reply state returned +- `400`: Invalid `waitMs` +- `401`: Missing or invalid bearer token +- `404`: Session not found +- `502`: OpenCode error + +**POST `/api/internal/sessions/:sessionId/fork`** + +Fork a session, optionally before a specific message. Omit `beforeMessageId` to fork from the current point. The forked session is pinned to **ask** permission mode. + +**Request Body:** +```ts +{ + beforeMessageId?: string +} +``` + +**Response:** +```ts +{ + sessionId: string // the new forked session + directory: string +} +``` + +**Status Codes:** +- `200`: Session forked +- `400`: Invalid request body +- `401`: Missing or invalid bearer token +- `404`: Session not found +- `502`: OpenCode error + +There is no delete route for sessions. Deleting sessions and workspaces stays out of this tool, as does changing permission modes and starting goals. + ## Skills -The assistant workspace includes four skills that document these capabilities: +The assistant workspace includes five skills that document these capabilities: 1. **Schedule Management** (`.opencode/skills/schedule-management/SKILL.md`) — manage schedule jobs and runs through the `ocm` `request` action. 2. **Notifications** (`.opencode/skills/notifications/SKILL.md`) — send push notifications through the `ocm` `send_notification` action. 3. **Manager Settings** (`.opencode/skills/manager-settings/SKILL.md`) — read and patch user preferences, read and update the OpenCode configuration file, and reload the assistant workspace through the `ocm` `request` action. 4. **Repo Management** (`.opencode/skills/repo-management/SKILL.md`) — list managed repositories through the `ocm` `request` action. +5. **Session Management** (`.opencode/skills/session-management/SKILL.md`) — list, create, follow up, read the reply of, and fork sessions through the `ocm` `request` action. These skills are automatically provisioned when assistant mode is initialized and contain detailed examples and usage patterns. diff --git a/docs/features/assistant-mode.md b/docs/features/assistant-mode.md index 8bab36f48..5dc93b9a0 100644 --- a/docs/features/assistant-mode.md +++ b/docs/features/assistant-mode.md @@ -15,7 +15,7 @@ The assistant workspace is a special repository-like directory managed and maint ## Skills Provided -Four skills are provisioned automatically when assistant mode is initialized: +Five skills are provisioned automatically when assistant mode is initialized: | Skill | What it teaches | |-------|----------------| @@ -23,6 +23,7 @@ Four skills are provisioned automatically when assistant mode is initialized: | `notifications` | Send push notifications to registered user devices with the `ocm` `send_notification` action | | `manager-settings` | Read and patch user preferences, read and update the OpenCode configuration file, and reload the assistant workspace, through the `ocm` `request` action | | `repo-management` | List all managed repositories through the `ocm` `request` action | +| `session-management` | List, create, follow up, read the reply of, and fork sessions through the `ocm` `request` action | The assistant manages the Manager's global OpenCode configuration file through the `ocm` tool (`/opencode-config`), and the file on disk is the source of truth. diff --git a/docs/features/chat.md b/docs/features/chat.md index 919adc6bf..5a7e44e2b 100644 --- a/docs/features/chat.md +++ b/docs/features/chat.md @@ -94,6 +94,47 @@ Toggle between two operational modes: Toggle modes using the mode selector in the chat header. +## Permission modes + +Control who answers OpenCode permission requests for a session. The shield button in the composer toggles between two modes: + +- **Ask every time** (default) - each permission request waits for you in the permission dialog. +- **Accept everything** - every "ask" request is answered automatically. + +The mode is owned by the backend, so a session in Accept everything keeps working while the browser is closed. Accept everything answers each request once and never saves a permanent rule, and it never overrides a `deny` rule. When you switch a session to Accept everything, requests already waiting are answered immediately. + +Child sessions inherit the mode of their root session and cannot change it, and the composer toggle is disabled for them with an explanation. A session forked from another starts with the source session's mode, except that forks of scheduled runs stay **Ask every time**. + +Sessions started by a scheduled run always use their schedule's own permission configuration; the composer toggle is disabled there with an explanation. Sessions created or forked by an agent through the `ocm` tool always start in **Ask every time**, regardless of the default, though an agent can still send follow-ups to a session you switched to **Accept everything**. + +If Manager restarts or loses its connection to OpenCode, permission requests already waiting in **Accept everything** sessions are answered when it reconnects. + +To change the default for new sessions, go to **Settings → General → Sessions** and pick a **Default permission mode for new sessions**. The default is stamped onto a session when it is created, so changing it only affects sessions created afterwards, never existing ones. + +Auto-accepted requests send no push notification, so you are not alerted for a request the session answered itself. + +## Session goals + +A goal keeps a session working on an objective until an auditor model decides it is done or blocked, without you sending follow-up messages. + +Arm goal mode with the target button in the composer, then send your message. That message becomes the goal objective, and it is sent to the session as usual. Goals cannot be started on scheduled-run sessions or subagent (child) sessions, and the goal button is disabled there. If you arm a goal and send while the agent is still responding, the message is queued and the goal starts; auditing begins after that queued turn. While a goal is active, each time the session goes idle the auditor decides the next step: + +- **done** - the objective is verifiably achieved, and the goal completes. +- **blocked** - the agent needs a decision or access it cannot obtain. Three consecutive blocked verdicts stop the goal as blocked. +- **continue** - the agent keeps working toward the objective. + +The auditor only sees the objective and the agent's latest reply; it cannot run tools or read files. Automatic continuations are capped by **Max automatic continuations**, and you can also set a **Token budget per goal** to stop a goal once it has spent that many tokens. A failed turn stops the goal, an interrupted turn pauses it, and deleting the session stops it. + +The goal is persisted and driven by the backend, so it survives closing the browser and a Manager restart: an active goal resumes auditing once the session is idle again. + +A status bar above the message list shows the goal state, the turn count, token usage, and the latest reason, with **Pause** (or **Resume**), **Cancel**, and **Dismiss** actions. + +Goal settings live in **Settings → General → Sessions**: + +- **Goal auditor model** - the model that decides whether the goal is done, as `provider/model`. Leave empty to use the OpenCode default model. +- **Max automatic continuations** - how many times a goal may continue before it stops (1-200, default 20). +- **Token budget per goal** - stop once the goal has spent this many tokens. Empty means no limit. + ## Mermaid Diagrams AI responses can include Mermaid diagrams that render automatically: diff --git a/docs/features/multi-run.md b/docs/features/multi-run.md new file mode 100644 index 000000000..7adb0eecc --- /dev/null +++ b/docs/features/multi-run.md @@ -0,0 +1,28 @@ +# Multi-run + +Run one prompt across several models at once to compare their results. + +## Launching a Multi-run + +Open a repository and click **Multi-run** in the header, next to **New Session**. In the **Launch** tab: + +1. Give the run group a **Group name**. +2. Enter the **Prompt** to send to every model. +3. Select up to **five models** from the checkbox list, grouped by provider. +4. Leave **Isolate runs** on to give each model its own workspace. +5. Optionally set **Start from** to a branch or ref. Empty starts from the current HEAD. + +Click **Launch**. Each selected model gets its own session, and the same prompt is sent to all of them. Models are checked before any workspace is created, so a model that is not available fails its entry without creating a workspace while the other models still launch. Launching is parallel, and one model failing does not stop the others: the failed entry records its error while the rest keep running. + +## Isolation + +With **Isolate runs** on, each run is created in its own OpenCode workspace: a detached checkout of the repository at the chosen ref, with no branch created. These workspaces appear in the repository's **Workspaces** tab, and the session for an isolated run opens with that tab selected. With isolation off, every run shares the repository directory. + +## Runs + +The **Runs** tab lists each group and its entries. Every entry shows the model, its status, and for a started run the live session status indicator. From here you can: + +- **Open** a run to view its session and compare the result. +- **Discard** a run after confirmation. Discarding an isolated run also removes its workspace directory from the Workspaces tab; if that workspace was already removed, for example from the Workspaces tab, discarding still succeeds. A non-isolated run keeps its session in the repository. + +Discarding is irreversible and cannot be repeated on an already discarded run. Deleting a repository removes its multi-run history. diff --git a/docs/features/notifications.md b/docs/features/notifications.md index 8e54acfc7..d021a9155 100644 --- a/docs/features/notifications.md +++ b/docs/features/notifications.md @@ -11,7 +11,7 @@ Push notifications allow you to receive alerts on your mobile device or desktop - A **session encounters an error** during execution - A **session completes successfully** -A notification is suppressed when a visible tab is already viewing the session that raised it, preventing duplicate alerts while you're actively monitoring that session. Subagent sessions never notify. +A notification is suppressed when a visible tab is already viewing the session that raised it, preventing duplicate alerts while you're actively monitoring that session. Subagent sessions never notify. A permission request that a session answers automatically (Accept everything mode) sends no notification. Goal outcome pushes are not sent when you paused or cancelled the goal yourself, when the turn failed (the regular error notification covers it), or while you are viewing the session. ## Supported Events @@ -21,6 +21,7 @@ A notification is suppressed when a visible tab is already viewing the session t | `questionAsked` | Agent asks a clarifying question | Enabled | | `sessionError` | Session encounters an error | Enabled | | `sessionIdle` | Session completes successfully | Disabled | +| `goalOutcome` | A session goal completes, is blocked, stops, or pauses | Enabled | ## Content and Click Behaviour @@ -121,3 +122,4 @@ Control which events trigger notifications: - **Question Asked** - Get notified when agent has a question (default: on) - **Session Error** - Get notified on session errors (default: on) - **Session Complete** - Get notified when session finishes (default: off) +- **Goal outcomes** - Get notified when a session goal completes, is blocked, stops, or pauses (default: on) diff --git a/docs/features/overview.md b/docs/features/overview.md index b62c3f669..dc6a73032 100644 --- a/docs/features/overview.md +++ b/docs/features/overview.md @@ -29,6 +29,9 @@ OpenCode Manager provides a comprehensive web interface for managing OpenCode AI - **Slash Commands** - Built-in (`/help`, `/new`, `/compact`) and custom commands - **File Mentions** - Reference files with `@filename` autocomplete - **Plan/Build Modes** - Toggle between read-only and file-change modes +- **Permission Modes** — Ask every time or Accept everything, per session, with a default for new sessions +- **Session Goals** — Keep a session working toward an objective until an auditor model says it is done or blocked +- **Multi-run** — Run one prompt across up to five models at once, optionally each in its own workspace - **Per-Agent Model Selection** — Each agent retains its own model selection independently - **Session Pinning** — Pin important sessions to a dedicated section at the top of the session list - **Mermaid Diagrams** - Visual diagram rendering in chat @@ -108,7 +111,8 @@ Both pickers live under **Settings → General**. ### Assistant Mode - **Dedicated AI Workspace** — Isolated assistant directory with managed configuration and a built-in bearer-token API -- **Auto-Provisioned Skills** — Schedule management, notifications, settings, and repo listing skills available out of the box +- **Auto-Provisioned Skills** — Schedule management, notifications, settings, repo listing, and session management skills available out of the box +- **Session Management** — List, create, follow up, read the reply of, and fork sessions through the `ocm` tool - **Session Continuity** — Navigating to the assistant always resumes the most recent session or creates one automatically [Learn more →](assistant-mode.md) diff --git a/docs/index.md b/docs/index.md index 7d6c81a4e..bf3442640 100644 --- a/docs/index.md +++ b/docs/index.md @@ -57,7 +57,7 @@ OpenCode Manager runs as a pnpm workspace: - **Files** — Directory browser with tree view, syntax highlighting, create/rename/delete, ZIP download — [Learn more](features/files.md) - **Schedules** — Recurring repo jobs with reusable prompts, run history, linked sessions — [Learn more](features/schedules.md) - **Session Pinning** — Pin important sessions to the top of the session list — [Learn more](features/session-pins.md) -- **Assistant Mode** — Dedicated AI workspace with auto-provisioned skills for schedule management, notifications, settings, and repo listing — [Learn more](features/assistant-mode.md) +- **Assistant Mode** — Dedicated AI workspace with auto-provisioned skills for schedule management, notifications, settings, repo listing, and session management — [Learn more](features/assistant-mode.md) - **AI Configuration** — Model/provider setup, OAuth for Anthropic/GitHub Copilot, custom agents — [Learn more](features/ai-config.md) - **Server Health & Restart** — Monitor server status, graceful restarts with session resume, auto-recovery — [Learn more](features/server-health.md) - **Manager Logs** — Manager log lines plus captured OpenCode server stdout/stderr in Settings, with level/source filters, search, and pause/copy controls — [Learn more](features/logs.md) 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/api/providers.test.ts b/frontend/src/api/providers.test.ts index f484ba1d6..2b9f69643 100644 --- a/frontend/src/api/providers.test.ts +++ b/frontend/src/api/providers.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { getOpenCodeConfigModel, getProviders, getProvidersWithModels, providerCredentialsApi } from './providers' +import { getOpenCodeConfigModel, getProviders, getProvidersWithModels, providerCredentialsApi, providerModelRef } from './providers' import { API_BASE_URL } from '@/config' import { makeOpenCodeConfigFile } from '@/test/fixtures/opencode-config' @@ -323,6 +323,18 @@ describe('getProvidersWithModels', () => { }) }) +describe('providerModelRef', () => { + it('prefers the catalog key over the backing model id', () => { + expect(providerModelRef({ id: 'anthropic' }, { id: 'claude-sonnet-4-20250514', key: 'claude-sonnet-4' })).toBe( + 'anthropic/claude-sonnet-4' + ) + }) + + it('falls back to the model id when no key is present', () => { + expect(providerModelRef({ id: 'openai' }, { id: 'gpt-4o' })).toBe('openai/gpt-4o') + }) +}) + describe('providerCredentialsApi', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/frontend/src/api/providers.ts b/frontend/src/api/providers.ts index 6c7a87d26..e64c5a784 100644 --- a/frontend/src/api/providers.ts +++ b/frontend/src/api/providers.ts @@ -284,6 +284,10 @@ export function formatModelName(model: Model): string { return model.name || model.id; } +export function providerModelRef(provider: { id: string }, model: { id: string; key?: string }): string { + return `${provider.id}/${model.key ?? model.id}`; +} + export function formatProviderName( provider: Provider | ProviderWithModels, ): string { 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/api/sessionPermissionModes.test.ts b/frontend/src/api/sessionPermissionModes.test.ts new file mode 100644 index 000000000..a1d9e4e17 --- /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', lockedReason: null } + 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, lockedReason: null } + 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..750aa1d0d 100644 --- a/frontend/src/components/message/PromptInput.command.test.tsx +++ b/frontend/src/components/message/PromptInput.command.test.tsx @@ -82,6 +82,19 @@ vi.mock('@/components/model/ModelQuickSelect', () => ({ ), })) +vi.mock('@/components/session/PermissionModeToggle', () => ({ + PermissionModeToggle: () => null, +})) + +vi.mock('@/hooks/useSessionGoals', () => ({ + useSessionGoal: () => ({ data: undefined }), + useStartSessionGoal: () => ({ mutateAsync: vi.fn(), isPending: false }), +})) + +vi.mock('@/hooks/useSessionPermissionMode', () => ({ + useSessionPermissionMode: () => ({ data: undefined }), +})) + 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..58e255e86 --- /dev/null +++ b/frontend/src/components/message/PromptInput.goal.test.tsx @@ -0,0 +1,288 @@ +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(), + useSessionPermissionMode: 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/useSessionPermissionMode', () => ({ + useSessionPermissionMode: mocks.useSessionPermissionMode, +})) + +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 = (overrides: Partial = {}) => { + const queryClient = createTestQueryClient() + return render( + + + + ) + } + + beforeEach(() => { + vi.clearAllMocks() + mocks.useSessionGoal.mockReturnValue({ data: null }) + mocks.useSessionPermissionMode.mockReturnValue({ data: undefined }) + 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('starts an armed goal before queueing when the session is busy', async () => { + stubMatchMedia(true) + renderComponent({ isStreamingResponse: true }) + + 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('Queue message')) + + await waitFor(() => expect(mocks.startGoal).toHaveBeenCalledWith({ + sessionId: 'test-session', + directory: '/test', + objective: 'Ship the feature', + })) + await waitFor(() => expect(mocks.sendPrompt).toHaveBeenCalledWith( + expect.objectContaining({ delivery: 'queue' }), + expect.anything(), + )) + expect(mocks.startGoal.mock.invocationCallOrder[0]).toBeLessThan(mocks.sendPrompt.mock.invocationCallOrder[0]) + }) + + it('does not start an armed goal for a slash command', async () => { + stubMatchMedia(true) + mocks.useCommands.mockReturnValue({ filterCommands: () => [{ name: 'review' }] }) + renderComponent({ isStreamingResponse: true }) + + const input = await screen.findByPlaceholderText('Send a message...') + fireEvent.change(input, { target: { value: '/review the diff' } }) + fireEvent.click(screen.getByRole('button', { name: GOAL_BUTTON })) + fireEvent.click(screen.getByTitle('Queue message')) + + 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() + }) + + it('disables goal mode with a reason for scheduled-run sessions', async () => { + stubMatchMedia(true) + mocks.useSessionPermissionMode.mockReturnValue({ + data: { sessionId: 'test-session', rootSessionId: 'test-session', mode: 'ask', lockedReason: 'schedule' }, + }) + renderComponent() + + const button = await screen.findByRole('button', { name: 'Scheduled runs cannot run goals' }) + expect(button).toBeDisabled() + }) + + it('disables goal mode with a reason for child sessions', async () => { + stubMatchMedia(true) + mocks.useSessionPermissionMode.mockReturnValue({ + data: { sessionId: 'test-session', rootSessionId: 'test-session', mode: 'ask', lockedReason: 'child' }, + }) + renderComponent() + + const button = await screen.findByRole('button', { name: 'Goals can only be started on top-level sessions' }) + expect(button).toBeDisabled() + }) +}) diff --git a/frontend/src/components/message/PromptInput.mention.test.tsx b/frontend/src/components/message/PromptInput.mention.test.tsx index b4394cf1d..56eb36fd6 100644 --- a/frontend/src/components/message/PromptInput.mention.test.tsx +++ b/frontend/src/components/message/PromptInput.mention.test.tsx @@ -73,6 +73,19 @@ vi.mock('@/components/model/ModelQuickSelect', () => ({ ModelQuickSelect: ({ children }: { children: React.ReactNode }) =>
{children}
, })) +vi.mock('@/components/session/PermissionModeToggle', () => ({ + PermissionModeToggle: () => null, +})) + +vi.mock('@/hooks/useSessionGoals', () => ({ + useSessionGoal: () => ({ data: undefined }), + useStartSessionGoal: () => ({ mutateAsync: vi.fn(), isPending: false }), +})) + +vi.mock('@/hooks/useSessionPermissionMode', () => ({ + useSessionPermissionMode: () => ({ data: undefined }), +})) + 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..afe225561 100644 --- a/frontend/src/components/message/PromptInput.stt.test.tsx +++ b/frontend/src/components/message/PromptInput.stt.test.tsx @@ -100,6 +100,19 @@ vi.mock('@/components/model/ModelQuickSelect', () => ({ ModelQuickSelect: ({ children }: { children: React.ReactNode }) =>
{children}
, })) +vi.mock('@/components/session/PermissionModeToggle', () => ({ + PermissionModeToggle: () => null, +})) + +vi.mock('@/hooks/useSessionGoals', () => ({ + useSessionGoal: () => ({ data: undefined }), + useStartSessionGoal: () => ({ mutateAsync: vi.fn(), isPending: false }), +})) + +vi.mock('@/hooks/useSessionPermissionMode', () => ({ + useSessionPermissionMode: () => ({ data: undefined }), +})) + 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..49895260e 100644 --- a/frontend/src/components/message/PromptInput.tsx +++ b/frontend/src/components/message/PromptInput.tsx @@ -17,9 +17,10 @@ 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' +import { IconToggleButton } from '@/components/ui/icon-toggle-button' import { CommandSuggestions } from '@/components/command/CommandSuggestions' import { MentionSuggestions, type MentionItem } from './MentionSuggestions' @@ -27,6 +28,9 @@ 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 { useSessionGoal, useStartSessionGoal } from '@/hooks/useSessionGoals' +import { useSessionPermissionMode } from '@/hooks/useSessionPermissionMode' import { detectMentionTrigger, parsePromptToInput, getFilename, filterAgentsByQuery } from '@/lib/promptParser' import { getNextPrimaryAgentId } from '@/lib/primaryAgents' import { randomId } from '@/lib/utils' @@ -92,6 +96,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()) @@ -228,6 +233,9 @@ 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 { data: permissionMode } = useSessionPermissionMode(sessionID) const { filterCommands } = useCommands({ directory }) const isExactCommandPrompt = (value: string) => { const commandPrompt = parseCommandPrompt(value) @@ -290,7 +298,18 @@ export const PromptInput = memo(forwardRef( const addUserBashCommand = useUserBash((s) => s.addUserBashCommand) - const handleSubmit = () => { + const startArmedGoal = async (objective: string): Promise => { + if (!directory || !objective.trim()) return true + try { + await startGoal.mutateAsync({ sessionId: sessionID, directory, objective }) + } catch { + return false + } + setIsGoalArmed(false) + return true + } + + const handleSubmit = async () => { if (!prompt.trim() && imageAttachments.length === 0) return pendingVoiceAutoSubmitRef.current = false @@ -302,6 +321,10 @@ export const PromptInput = memo(forwardRef( const submittedPrompt = prompt const submittedAttachedFiles = attachedFiles const submittedImageAttachments = imageAttachments + if (isGoalArmed && !isBashMode && !isExactCommandPrompt(prompt)) { + const goalStarted = await startArmedGoal(parsed.text) + if (!goalStarted) return + } sendPrompt.mutate( { sessionID, @@ -384,6 +407,11 @@ export const PromptInput = memo(forwardRef( const submittedAttachedFiles = attachedFiles const submittedImageAttachments = imageAttachments + if (isGoalArmed) { + const goalStarted = await startArmedGoal(parsed.text) + if (!goalStarted) return + } + pendingConfirmClearRef.current = { prompt: submittedPrompt, files: submittedAttachedFiles, @@ -1147,6 +1175,27 @@ 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 lockedReason = permissionMode?.lockedReason ?? null + const goalButtonLabel = lockedReason === 'schedule' + ? 'Scheduled runs cannot run goals' + : lockedReason === 'child' + ? 'Goals can only be started on top-level sessions' + : 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 ? ( + setIsGoalArmed((value) => !value)} + > + + + ) : null const voiceFeedbackState: VoiceStatusOverlayState | null = isTogglingRecording ? 'starting' : isProcessing @@ -1260,6 +1309,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]) @@ -1345,6 +1395,8 @@ return ( open={isModelPickerOpen} onOpenChange={setIsModelPickerOpen} /> + {directory && } + {goalModeButton} ) : ( <> @@ -1354,6 +1406,8 @@ return ( onAgentChange={handleAgentChange} isBashMode={isBashMode} /> + {directory && } + {goalModeButton} {isSessionActive && (
diff --git a/frontend/src/components/repo/MultiRunDialog.test.tsx b/frontend/src/components/repo/MultiRunDialog.test.tsx new file mode 100644 index 000000000..4efd7b8a4 --- /dev/null +++ b/frontend/src/components/repo/MultiRunDialog.test.tsx @@ -0,0 +1,240 @@ +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' }, + { id: 'claude-sonnet-4-5', key: 'claude-sonnet-4.5', name: 'Claude Sonnet 4.5' }, + ], + }, +] + +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('submits the catalog id for configured model aliases', 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: 'Claude Sonnet 4.5' })) + await user.click(screen.getByRole('button', { name: 'Launch' })) + + await waitFor(() => { + expect(mocks.launchMultiRun).toHaveBeenCalledWith({ + repoId: 7, + name: 'Sweep', + prompt: 'go', + models: ['anthropic/claude-sonnet-4.5'], + 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..09764208e --- /dev/null +++ b/frontend/src/components/repo/MultiRunDialog.tsx @@ -0,0 +1,357 @@ +import { memo, useCallback, 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 { formatModelName, formatProviderName, providerModelRef, type ProviderWithModels } from '@/api/providers' +import { buildSessionPath } from '@opencode-manager/shared/utils' +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 = useCallback((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) + navigate(buildSessionPath(repoId, entry.sessionId, entry.isolated ? { repoTab: 'workspaces' } : undefined)) + } + + 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" + /> +
+ +
+ +