diff --git a/.env.example b/.env.example index 5bc3d226e..49cc7b850 100644 --- a/.env.example +++ b/.env.example @@ -81,8 +81,9 @@ DEBUG=false # AUTH_SECRET= # Trusted origins for authentication (comma-separated) -# Add your server's external URL when accessing remotely -# AUTH_TRUSTED_ORIGINS=http://localhost:5173,http://localhost:5003 +# Add your server's external URL when accessing remotely. localhost and +# 127.0.0.1 are distinct browser origins, so both are listed. +# AUTH_TRUSTED_ORIGINS=http://localhost:5173,http://localhost:5003,http://127.0.0.1:5173,http://127.0.0.1:5003 # Admin Account (optional - for pre-configured single-user mode) # When set, admin user is created on first startup and self-registration is disabled diff --git a/backend/src/db/change-walkthroughs.ts b/backend/src/db/change-walkthroughs.ts new file mode 100644 index 000000000..63baea3d8 --- /dev/null +++ b/backend/src/db/change-walkthroughs.ts @@ -0,0 +1,49 @@ +import type { Database } from 'bun:sqlite' +import { ChangeWalkthroughSchema, type ChangeWalkthrough } from '@opencode-manager/shared/schemas' + +interface ChangeWalkthroughRow { + session_id: string + diff_hash: string + payload: string + created_at: number +} + +export function ensureChangeWalkthroughTable(db: Database): void { + db.run(` + CREATE TABLE IF NOT EXISTS change_walkthroughs ( + session_id TEXT PRIMARY KEY, + diff_hash TEXT NOT NULL, + payload TEXT NOT NULL, + created_at INTEGER NOT NULL + ) + `) +} + +export function getChangeWalkthrough(db: Database, sessionId: string): ChangeWalkthrough | null { + const row = db + .prepare('SELECT session_id, diff_hash, payload, created_at FROM change_walkthroughs WHERE session_id = ?') + .get(sessionId) as ChangeWalkthroughRow | undefined + if (!row) { + return null + } + try { + return ChangeWalkthroughSchema.parse(JSON.parse(row.payload)) + } catch { + return null + } +} + +export function deleteChangeWalkthrough(db: Database, sessionId: string): void { + db.prepare('DELETE FROM change_walkthroughs WHERE session_id = ?').run(sessionId) +} + +export function saveChangeWalkthrough(db: Database, walkthrough: ChangeWalkthrough): void { + db.prepare(` + INSERT INTO change_walkthroughs(session_id, diff_hash, payload, created_at) + VALUES(?,?,?,?) + ON CONFLICT(session_id) DO UPDATE SET + diff_hash = excluded.diff_hash, + payload = excluded.payload, + created_at = excluded.created_at + `).run(walkthrough.sessionId, walkthrough.diffHash, JSON.stringify(walkthrough), walkthrough.createdAt) +} diff --git a/backend/src/db/migrations/202610061700-multi-run-fusions.ts b/backend/src/db/migrations/202610061700-multi-run-fusions.ts new file mode 100644 index 000000000..3edcfd920 --- /dev/null +++ b/backend/src/db/migrations/202610061700-multi-run-fusions.ts @@ -0,0 +1,14 @@ +import type { Migration } from '../migration-runner' +import { ensureMultiRunFusionTable } from '../multi-runs' + +const migration: Migration = { + id: '202610061700-multi-run-fusions', + up(db) { + ensureMultiRunFusionTable(db) + }, + down(db) { + db.run('DROP TABLE IF EXISTS multi_run_fusions') + }, +} + +export default migration diff --git a/backend/src/db/migrations/202610061701-change-walkthroughs.ts b/backend/src/db/migrations/202610061701-change-walkthroughs.ts new file mode 100644 index 000000000..2f5b0d1b2 --- /dev/null +++ b/backend/src/db/migrations/202610061701-change-walkthroughs.ts @@ -0,0 +1,14 @@ +import type { Migration } from '../migration-runner' +import { ensureChangeWalkthroughTable } from '../change-walkthroughs' + +const migration: Migration = { + id: '202610061701-change-walkthroughs', + up(db) { + ensureChangeWalkthroughTable(db) + }, + down(db) { + db.run('DROP TABLE IF EXISTS change_walkthroughs') + }, +} + +export default migration diff --git a/backend/src/db/migrations/index.ts b/backend/src/db/migrations/index.ts index b2c66a0f8..e0f7ae43c 100644 --- a/backend/src/db/migrations/index.ts +++ b/backend/src/db/migrations/index.ts @@ -27,6 +27,8 @@ import migration025 from './025-session-permission-modes' import migration026 from './026-session-goals' import migration027 from './027-multi-runs' import migration202610061345 from './202610061345-schedule-workspace-mode' +import migration202610061700 from './202610061700-multi-run-fusions' +import migration202610061701 from './202610061701-change-walkthroughs' export const allMigrations: Migration[] = [ migration001, @@ -57,4 +59,6 @@ export const allMigrations: Migration[] = [ migration026, migration027, migration202610061345, + migration202610061700, + migration202610061701, ] diff --git a/backend/src/db/multi-runs.ts b/backend/src/db/multi-runs.ts index cda5da46a..8a039233a 100644 --- a/backend/src/db/multi-runs.ts +++ b/backend/src/db/multi-runs.ts @@ -1,5 +1,10 @@ import type { Database } from 'bun:sqlite' -import type { MultiRunEntryStatus } from '@opencode-manager/shared/schemas' +import { + MultiRunFusionSourceSchema, + type MultiRunEntryStatus, + type MultiRunFusionSource, + type MultiRunFusionStatus, +} from '@opencode-manager/shared/schemas' export interface MultiRunEntryRecord { id: number @@ -13,6 +18,23 @@ export interface MultiRunEntryRecord { updatedAt: number } +export interface MultiRunFusionRecord { + id: number + multiRunId: number + requestId: string + model: string + instructions: string | null + isolated: boolean + baseRef: string | null + sources: MultiRunFusionSource[] + status: MultiRunFusionStatus + sessionId: string | null + directory: string | null + error: string | null + createdAt: number + updatedAt: number +} + export interface MultiRunRecord { id: number repoId: number @@ -22,6 +44,7 @@ export interface MultiRunRecord { baseRef: string | null createdAt: number entries: MultiRunEntryRecord[] + fusions: MultiRunFusionRecord[] } export interface CreateMultiRunGroup { @@ -39,6 +62,23 @@ export interface MultiRunEntryPatch { error?: string | null } +export interface CreateMultiRunFusionInput { + multiRunId: number + requestId: string + model: string + instructions: string | null + isolated: boolean + baseRef: string | null + sources: MultiRunFusionSource[] +} + +export interface MultiRunFusionPatch { + status?: MultiRunFusionStatus + sessionId?: string | null + directory?: string | null + error?: string | null +} + interface MultiRunRow { id: number repo_id: number @@ -61,10 +101,30 @@ interface MultiRunEntryRow { updated_at: number } +interface MultiRunFusionRow { + id: number + multi_run_id: number + request_id: string + model: string + instructions: string | null + isolated: number + base_ref: string | null + sources: string + status: MultiRunFusionStatus + 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' +const MULTI_RUN_FUSION_COLUMNS = + 'id, multi_run_id, request_id, model, instructions, isolated, base_ref, sources, status, session_id, directory, error, created_at, updated_at' + export function ensureMultiRunTables(db: Database): void { db.run(` CREATE TABLE IF NOT EXISTS multi_runs ( @@ -100,6 +160,32 @@ export function ensureMultiRunTables(db: Database): void { `) } +export function ensureMultiRunFusionTable(db: Database): void { + db.run(` + CREATE TABLE IF NOT EXISTS multi_run_fusions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + multi_run_id INTEGER NOT NULL, + request_id TEXT NOT NULL, + model TEXT NOT NULL, + instructions TEXT, + isolated INTEGER NOT NULL, + base_ref TEXT, + sources TEXT NOT NULL, + status TEXT NOT NULL CHECK(status IN ('starting','started','failed')), + session_id TEXT, + directory TEXT, + error TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + UNIQUE(multi_run_id, request_id) + ) + `) + db.run(` + CREATE INDEX IF NOT EXISTS idx_multi_run_fusions_group + ON multi_run_fusions(multi_run_id) + `) +} + export function createMultiRunWithEntries( db: Database, group: CreateMultiRunGroup, @@ -137,7 +223,7 @@ export function getMultiRun(db: Database, id: number): MultiRunRecord | null { if (!row) { return null } - return toMultiRunRecord(row, loadEntries(db, row.id)) + return toMultiRunRecord(row, loadEntries(db, row.id), loadFusions(db, row.id)) } export function listMultiRuns(db: Database, repoId: number, limit: number): MultiRunRecord[] { @@ -170,7 +256,25 @@ export function listMultiRuns(db: Database, repoId: number, limit: number): Mult entriesByRun.get(entryRow.multi_run_id)?.push(entryRow) } - return rows.map((row) => toMultiRunRecord(row, entriesByRun.get(row.id) ?? [])) + const fusionRows = db + .prepare(` + SELECT ${MULTI_RUN_FUSION_COLUMNS} FROM multi_run_fusions + WHERE multi_run_id IN (${placeholders}) + ORDER BY multi_run_id ASC, id ASC + `) + .all(...rows.map((row) => row.id)) as MultiRunFusionRow[] + + const fusionsByRun = new Map() + for (const row of rows) { + fusionsByRun.set(row.id, []) + } + for (const fusionRow of fusionRows) { + fusionsByRun.get(fusionRow.multi_run_id)?.push(fusionRow) + } + + return rows.map((row) => + toMultiRunRecord(row, entriesByRun.get(row.id) ?? [], fusionsByRun.get(row.id) ?? []), + ) } export function getMultiRunEntry(db: Database, multiRunId: number, entryId: number): MultiRunEntryRecord | null { @@ -193,6 +297,106 @@ export function updateMultiRunEntry( fromStatuses: MultiRunEntryStatus[], patch: MultiRunEntryPatch, ): MultiRunEntryRecord | null { + if (!runGuardedStatusUpdate(db, 'multi_run_entries', entryId, fromStatuses, patch)) { + 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) +} + +export function getMultiRunFusionByRequest( + db: Database, + multiRunId: number, + requestId: string, +): MultiRunFusionRecord | null { + const row = db + .prepare(`SELECT ${MULTI_RUN_FUSION_COLUMNS} FROM multi_run_fusions WHERE multi_run_id = ? AND request_id = ?`) + .get(multiRunId, requestId) as MultiRunFusionRow | undefined + if (!row) { + return null + } + return mapFusionRow(row) +} + +export function insertMultiRunFusion( + db: Database, + input: CreateMultiRunFusionInput, +): { fusion: MultiRunFusionRecord; created: boolean } { + const now = Date.now() + return db.transaction(() => { + const result = db + .prepare(` + INSERT INTO multi_run_fusions( + multi_run_id, request_id, model, instructions, isolated, base_ref, sources, + status, session_id, directory, error, created_at, updated_at + ) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?) + ON CONFLICT(multi_run_id, request_id) DO NOTHING + `) + .run( + input.multiRunId, + input.requestId, + input.model, + input.instructions, + input.isolated ? 1 : 0, + input.baseRef, + JSON.stringify(input.sources), + 'starting', + null, + null, + null, + now, + now, + ) + const fusion = getMultiRunFusionByRequest(db, input.multiRunId, input.requestId) + if (!fusion) { + throw new Error('Failed to create multi-run fusion') + } + return { fusion, created: result.changes > 0 } + })() +} + +export function updateMultiRunFusion( + db: Database, + fusionId: number, + fromStatuses: MultiRunFusionStatus[], + patch: MultiRunFusionPatch, +): MultiRunFusionRecord | null { + if (!runGuardedStatusUpdate(db, 'multi_run_fusions', fusionId, fromStatuses, patch)) { + return null + } + + const row = db.prepare('SELECT multi_run_id, request_id FROM multi_run_fusions WHERE id = ?').get(fusionId) as + | { multi_run_id: number; request_id: string } + | undefined + if (!row) { + return null + } + return getMultiRunFusionByRequest(db, row.multi_run_id, row.request_id) +} + +type GuardedUpdateTable = 'multi_run_entries' | 'multi_run_fusions' + +interface GuardedUpdatePatch { + status?: string + sessionId?: string | null + directory?: string | null + error?: string | null +} + +function runGuardedStatusUpdate( + db: Database, + table: GuardedUpdateTable, + id: number, + fromStatuses: readonly string[], + patch: GuardedUpdatePatch, +): boolean { const assignments: string[] = ['updated_at = ?'] const values: (string | number | null)[] = [Date.now()] @@ -215,20 +419,10 @@ export function updateMultiRunEntry( 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) + .prepare(`UPDATE ${table} SET ${assignments.join(', ')} WHERE id = ? AND status IN (${placeholders})`) + .run(...values, id, ...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) + return result.changes > 0 } function loadEntries(db: Database, multiRunId: number): MultiRunEntryRow[] { @@ -237,7 +431,17 @@ function loadEntries(db: Database, multiRunId: number): MultiRunEntryRow[] { .all(multiRunId) as MultiRunEntryRow[] } -function toMultiRunRecord(row: MultiRunRow, entries: MultiRunEntryRow[]): MultiRunRecord { +function loadFusions(db: Database, multiRunId: number): MultiRunFusionRow[] { + return db + .prepare(`SELECT ${MULTI_RUN_FUSION_COLUMNS} FROM multi_run_fusions WHERE multi_run_id = ? ORDER BY id ASC`) + .all(multiRunId) as MultiRunFusionRow[] +} + +function toMultiRunRecord( + row: MultiRunRow, + entries: MultiRunEntryRow[], + fusions: MultiRunFusionRow[], +): MultiRunRecord { return { id: row.id, repoId: row.repo_id, @@ -247,6 +451,26 @@ function toMultiRunRecord(row: MultiRunRow, entries: MultiRunEntryRow[]): MultiR baseRef: row.base_ref, createdAt: row.created_at, entries: entries.map((entry) => mapEntryRow(entry, row.isolated === 1)), + fusions: fusions.map(mapFusionRow), + } +} + +function mapFusionRow(row: MultiRunFusionRow): MultiRunFusionRecord { + return { + id: row.id, + multiRunId: row.multi_run_id, + requestId: row.request_id, + model: row.model, + instructions: row.instructions, + isolated: row.isolated === 1, + baseRef: row.base_ref, + sources: MultiRunFusionSourceSchema.array().parse(JSON.parse(row.sources)), + status: row.status, + sessionId: row.session_id, + directory: row.directory, + 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 8cdacd320..9b045c215 100644 --- a/backend/src/db/queries.ts +++ b/backend/src/db/queries.ts @@ -348,6 +348,7 @@ export function deleteRepo(db: Database, id: number): void { } const remove = db.transaction(() => { + db.prepare('DELETE FROM multi_run_fusions WHERE multi_run_id IN (SELECT id FROM multi_runs WHERE repo_id = ?)').run(id) 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) { diff --git a/backend/src/index.ts b/backend/src/index.ts index 4f0efb137..4dcedd4ee 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -41,6 +41,7 @@ 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 { createChangeWalkthroughRoutes } from './routes/change-walkthroughs' import { createLogRoutes } from './routes/logs' import { createPreviewRoutes, createPreviewAvailability } from './routes/preview' import { createPreviewGatewayApp, PreviewSessionStore } from './services/preview/gateway' @@ -54,6 +55,7 @@ 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 { ChangeWalkthroughService } from './services/change-walkthroughs' import { opencodeServerManager } from './services/opencode-single-server' import { createOpenCodeClient } from './services/opencode/client' import { getOpenCodeUpstreamBaseUrl } from './services/opencode/upstream' @@ -270,6 +272,7 @@ const sessionGoalService = new SessionGoalService(db, openCodeClient, sessionSet sessionGoalService.loadOpenGoals() const multiRunService = new MultiRunService(db, openCodeClient, repoWorkspaces) +const changeWalkthroughService = new ChangeWalkthroughService(db, openCodeClient) sseAggregator.onEvent((directory, event) => { sessionPermissionModeService.handleEvent(directory, event).catch((err) => { @@ -278,6 +281,11 @@ sseAggregator.onEvent((directory, event) => { sessionGoalService.handleEvent(directory, event).catch((err) => { logger.error('Session goal event handling error:', err) }) + try { + changeWalkthroughService.handleEvent(event) + } catch (err) { + logger.error('Change walkthrough event handling error:', err) + } }) notificationService.addEventSuppressor(async (event, sessionId) => { @@ -355,6 +363,7 @@ 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('/change-walkthroughs', createChangeWalkthroughRoutes(changeWalkthroughService)) protectedApi.route('/schedules', createScheduleRoutes(scheduleService)) protectedApi.route('/logs', createLogRoutes()) protectedApi.route('/preview', createPreviewRoutes({ store: previewSessionStore, isEnabled: previewAvailability.isEnabled })) diff --git a/backend/src/routes/change-walkthroughs.ts b/backend/src/routes/change-walkthroughs.ts new file mode 100644 index 000000000..03b547db5 --- /dev/null +++ b/backend/src/routes/change-walkthroughs.ts @@ -0,0 +1,34 @@ +import { Hono } from 'hono' +import { GenerateChangeWalkthroughRequestSchema } from '@opencode-manager/shared/schemas' +import type { ChangeWalkthroughService } from '../services/change-walkthroughs' +import { handleServiceError, parseJsonBody } from '../utils/route-helpers' +import { ServiceError } from '../utils/service-error' + +export function createChangeWalkthroughRoutes(service: ChangeWalkthroughService) { + const app = new Hono() + + app.get('/:sessionId', async (c) => { + try { + const state = await service.getState(c.req.param('sessionId')) + return c.json(state) + } catch (error) { + return handleServiceError(c, error, 'Failed to read change walkthrough', ServiceError) + } + }) + + app.post('/:sessionId', async (c) => { + const parsed = await parseJsonBody(c, GenerateChangeWalkthroughRequestSchema, { allowEmpty: true }) + if (!parsed.ok) { + return parsed.response + } + + try { + const { walkthrough, created } = await service.generate(c.req.param('sessionId'), parsed.data) + return c.json({ walkthrough }, created ? 201 : 200) + } catch (error) { + return handleServiceError(c, error, 'Failed to generate change walkthrough', ServiceError) + } + }) + + return app +} diff --git a/backend/src/routes/multi-runs.ts b/backend/src/routes/multi-runs.ts index 50cf54a28..8aa2105eb 100644 --- a/backend/src/routes/multi-runs.ts +++ b/backend/src/routes/multi-runs.ts @@ -1,7 +1,8 @@ import { Hono } from 'hono' -import { LaunchMultiRunRequestSchema } from '@opencode-manager/shared/schemas' +import { FuseMultiRunRequestSchema, LaunchMultiRunRequestSchema } from '@opencode-manager/shared/schemas' import { MultiRunError, type MultiRunService } from '../services/multi-runs' import { handleServiceError, parseId, parseJsonBody } from '../utils/route-helpers' +import { ServiceError } from '../utils/service-error' export function createMultiRunRoutes(service: MultiRunService) { const app = new Hono() @@ -11,7 +12,7 @@ export function createMultiRunRoutes(service: MultiRunService) { 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) + return handleServiceError(c, error, 'Failed to list multi-runs', ServiceError) } }) @@ -25,7 +26,7 @@ export function createMultiRunRoutes(service: MultiRunService) { const run = await service.launch(parsed.data) return c.json({ run }, 201) } catch (error) { - return handleServiceError(c, error, 'Failed to launch multi-run', MultiRunError) + return handleServiceError(c, error, 'Failed to launch multi-run', ServiceError) } }) @@ -36,7 +37,22 @@ export function createMultiRunRoutes(service: MultiRunService) { 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 handleServiceError(c, error, 'Failed to discard multi-run entry', ServiceError) + } + }) + + app.post('/:id/fusions', async (c) => { + try { + const multiRunId = parseId(c.req.param('id'), 'multi-run id', MultiRunError) + const parsed = await parseJsonBody(c, FuseMultiRunRequestSchema) + if (!parsed.ok) { + return parsed.response + } + + const { run, created } = await service.fuse(multiRunId, parsed.data) + return c.json({ run }, created ? 201 : 200) + } catch (error) { + return handleServiceError(c, error, 'Failed to fuse multi-run', ServiceError) } }) diff --git a/backend/src/routes/repo-git.ts b/backend/src/routes/repo-git.ts index 8cb37d3b5..381110464 100644 --- a/backend/src/routes/repo-git.ts +++ b/backend/src/routes/repo-git.ts @@ -9,6 +9,7 @@ import type { CommitMessageContext } from '../services/git/commit-message-prompt import { RenameBranchRequestSchema, DeleteBranchRequestSchema, StashPushRequestSchema, StashApplyRequestSchema, StashDropRequestSchema, IntegrateBranchRequestSchema } from '@opencode-manager/shared' import type { GitService } from '../services/git/GitService' import type { OpenCodeClient } from '../services/opencode/client' +import { GenerateTextTimeoutError, generateTextWithTimeout } from '../services/opencode/generate-text' import type { GitStatusResponse } from '../types/git' import type { Repo } from '../types/repo' import { getErrorMessage } from '../utils/error-utils' @@ -19,13 +20,6 @@ export interface RepoGitRouteOptions { commitMessageTimeoutMs?: number } -class CommitMessageTimeoutError extends Error { - constructor() { - super('Commit message generation timed out') - this.name = 'CommitMessageTimeoutError' - } -} - function parseStashIndex(raw: string | undefined): number | null { if (raw === undefined || !/^\d+$/.test(raw)) { return null @@ -174,25 +168,12 @@ export function createRepoGitRoutes( return c.json({ error: getErrorMessage(error) }, 400) } - const controller = new AbortController() - let timedOut = false - let timer: ReturnType | undefined - const timeout = new Promise((_resolve, reject) => { - timer = setTimeout(() => { - timedOut = true - controller.abort() - reject(new CommitMessageTimeoutError()) - }, commitMessageTimeoutMs) - }) - try { - const { text } = await Promise.race([ - openCodeClient.api.generate.text( - { prompt: buildCommitMessagePrompt(context) }, - { signal: controller.signal }, - ), - timeout, - ]) + const text = await generateTextWithTimeout( + openCodeClient, + { prompt: buildCommitMessagePrompt(context) }, + commitMessageTimeoutMs, + ) const message = normalizeGeneratedCommitMessage(text) if (!message) { @@ -201,13 +182,11 @@ export function createRepoGitRoutes( return c.json({ message }) } catch (error: unknown) { - if (timedOut) { + if (error instanceof GenerateTextTimeoutError) { return c.json({ error: 'Commit message generation timed out' }, 502) } logger.error('Failed to generate commit message:', error) return c.json({ error: getErrorMessage(error) }, 502) - } finally { - if (timer) clearTimeout(timer) } })) diff --git a/backend/src/services/change-walkthroughs.ts b/backend/src/services/change-walkthroughs.ts new file mode 100644 index 000000000..9c9d70e42 --- /dev/null +++ b/backend/src/services/change-walkthroughs.ts @@ -0,0 +1,364 @@ +import type { Database } from 'bun:sqlite' +import { createHash } from 'node:crypto' +import { z } from 'zod' +import { + isSessionNotFoundError, + type FileDiffInfo, + type SessionInfo, +} from '@opencode-manager/shared/opencode' +import { + WALKTHROUGH_DIFF_MAX_CHARS, + WALKTHROUGH_HUNK_MAX_CHARS, + WALKTHROUGH_MAX_STOPS, + WALKTHROUGH_TEXT_MAX_CHARS, + type ChangeWalkthrough, + type ChangeWalkthroughState, + type GenerateChangeWalkthroughRequest, + type WalkthroughHunk, + type WalkthroughOmittedFile, + type WalkthroughStop, +} from '@opencode-manager/shared/schemas' +import { splitDiffHunks } from '@opencode-manager/shared/utils' +import { deleteChangeWalkthrough, getChangeWalkthrough, saveChangeWalkthrough } from '../db/change-walkthroughs' +import { getErrorMessage } from '../utils/error-utils' +import { ServiceError } from '../utils/service-error' +import { truncateText } from '../utils/text-truncate' +import { extractFirstJsonObject } from '../utils/json-extract' +import { GenerateTextTimeoutError, generateTextWithTimeout } from './opencode/generate-text' +import type { OpenCodeClient } from './opencode/client' +import { readSessionChanges } from './session-changes' +import type { SSEEvent } from './sse-aggregator' + +const DEFAULT_TIMEOUT_MS = 120_000 +const HUNK_TRUNCATION_MARKER = '\n[hunk truncated]' +const TEXT_TRUNCATION_MARKER = '' +const REMAINING_STOP_TITLE = 'Remaining changes' +const REMAINING_STOP_EXPLANATION = 'These changes were not covered by the generated walkthrough.' + +const modelStopSchema = z.object({ + title: z.string().catch(''), + explanation: z.string().catch(''), + hunkIds: z.array(z.string()).catch([]), +}) + +const modelResponseSchema = z.object({ + summary: z.string().catch(''), + stops: z.array(modelStopSchema), +}) + +export interface WalkthroughInput { + hunks: WalkthroughHunk[] + omittedFiles: WalkthroughOmittedFile[] +} + +export interface ParsedWalkthrough { + summary: string + stops: WalkthroughStop[] +} + +export interface ChangeWalkthroughServiceOptions { + timeoutMs?: number +} + +export class ChangeWalkthroughError extends ServiceError { + constructor(message: string, status: number, options?: { code?: string; details?: unknown }) { + super(message, status, options) + this.name = 'ChangeWalkthroughError' + } +} + +export function computeChangesHash(changes: FileDiffInfo[]): string { + const hash = createHash('sha256') + for (const change of changes) { + hash.update(`${change.file}\0${change.status}\0${change.patch}\n`) + } + return hash.digest('hex') +} + +export function buildWalkthroughInstructions(title: string): string { + return [ + `You are writing a change walkthrough for a reviewer who reads it top to bottom to understand the change to "${title}".`, + '## How to write the walkthrough', + [ + '- Order the stops by the reading order that best explains the change: contracts and data model first, then core logic, then callers and UI, then tests and config.', + '- Group related hunks, possibly across files.', + '- Reference only the hunk ids given below, each at most once.', + `- Use at most ${WALKTHROUGH_MAX_STOPS} stops.`, + '- Explain intent and impact rather than restating the code.', + '- Respond with only the JSON {"summary": string, "stops": [{"title": string, "explanation": string, "hunkIds": string[]}]}.', + ].join('\n'), + '## Hunks', + ].join('\n') +} + +export function formatHunkBlock(hunk: WalkthroughHunk): string { + return `### ${hunk.id} ${hunk.file} (${hunk.status})\n\n\`\`\`diff\n${hunk.text}\n\`\`\`` +} + +export function buildWalkthroughInput(changes: FileDiffInfo[], title: string): WalkthroughInput { + const hunks: WalkthroughHunk[] = [] + const omittedFiles: WalkthroughOmittedFile[] = [] + let total = buildWalkthroughInstructions(title).length + let budgetExhausted = false + + changes.forEach((change, fileIndex) => { + if (budgetExhausted) { + omittedFiles.push({ file: change.file, reason: 'budget' }) + return + } + + const fileHunks = splitDiffHunks(change.patch) + if (fileHunks.length === 0) { + omittedFiles.push({ file: change.file, reason: 'binary' }) + return + } + + const prepared = fileHunks.map((hunk, hunkIndex) => { + const truncated = truncateText(hunk.text, WALKTHROUGH_HUNK_MAX_CHARS, HUNK_TRUNCATION_MARKER) + return { + id: `f${fileIndex}h${hunkIndex}`, + file: change.file, + status: change.status, + header: hunk.header, + text: truncated.text, + truncated: truncated.truncated, + } satisfies WalkthroughHunk + }) + + const fileTotal = prepared.reduce((sum, hunk) => sum + formatHunkBlock(hunk).length + 2, 0) + if (total + fileTotal > WALKTHROUGH_DIFF_MAX_CHARS) { + budgetExhausted = true + omittedFiles.push({ file: change.file, reason: 'budget' }) + return + } + + total += fileTotal + hunks.push(...prepared) + }) + + return { hunks, omittedFiles } +} + +export function buildWalkthroughPrompt({ title, hunks }: { title: string; hunks: WalkthroughHunk[] }): string { + const instructions = buildWalkthroughInstructions(title) + const blocks = hunks.map((hunk) => formatHunkBlock(hunk)) + + return [instructions, ...blocks].join('\n\n') +} + +export function parseWalkthroughResponse(text: string, hunks: WalkthroughHunk[]): ParsedWalkthrough | null { + const extracted = extractFirstJsonObject(text) + if (!extracted) { + return null + } + + let raw: unknown + try { + raw = JSON.parse(extracted) + } catch { + return null + } + + const parsed = modelResponseSchema.safeParse(raw) + if (!parsed.success) { + return null + } + + const validIds = new Set(hunks.map((hunk) => hunk.id)) + const referenced = new Set() + const stops: WalkthroughStop[] = [] + + for (const modelStop of parsed.data.stops) { + if (stops.length >= WALKTHROUGH_MAX_STOPS) { + break + } + + const hunkIds: string[] = [] + for (const id of modelStop.hunkIds) { + if (!validIds.has(id) || referenced.has(id)) { + continue + } + referenced.add(id) + hunkIds.push(id) + } + + if (hunkIds.length === 0) { + continue + } + + stops.push({ + title: truncateText(modelStop.title, WALKTHROUGH_TEXT_MAX_CHARS, TEXT_TRUNCATION_MARKER).text, + explanation: truncateText(modelStop.explanation, WALKTHROUGH_TEXT_MAX_CHARS, TEXT_TRUNCATION_MARKER).text, + hunkIds, + }) + } + + if (stops.length === 0) { + return null + } + + const unreferenced = hunks.filter((hunk) => !referenced.has(hunk.id)).map((hunk) => hunk.id) + if (unreferenced.length > 0) { + stops.push({ + title: REMAINING_STOP_TITLE, + explanation: REMAINING_STOP_EXPLANATION, + hunkIds: unreferenced, + }) + } + + return { + summary: truncateText(parsed.data.summary, WALKTHROUGH_TEXT_MAX_CHARS, TEXT_TRUNCATION_MARKER).text, + stops, + } +} + +export class ChangeWalkthroughService { + private readonly inFlight = new Map>() + private readonly deletedDuringGeneration = new Set() + private readonly timeoutMs: number + + constructor( + private readonly db: Database, + private readonly openCodeClient: OpenCodeClient, + options: ChangeWalkthroughServiceOptions = {}, + ) { + this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS + } + + async getState(sessionId: string): Promise { + await this.readSession(sessionId) + + let currentDiffHash: string | null = null + try { + const changes = await readSessionChanges(this.openCodeClient, sessionId) + currentDiffHash = computeChangesHash(changes) + } catch { + currentDiffHash = null + } + + const walkthrough = getChangeWalkthrough(this.db, sessionId) + return { + walkthrough, + currentDiffHash, + stale: walkthrough !== null && currentDiffHash !== null && walkthrough.diffHash !== currentDiffHash, + } + } + + generate(sessionId: string, request: GenerateChangeWalkthroughRequest): Promise<{ walkthrough: ChangeWalkthrough; created: boolean }> { + const existing = this.inFlight.get(sessionId) + if (existing) { + return existing + } + + const pending = this.runGenerate(sessionId, request).finally(() => { + this.inFlight.delete(sessionId) + this.deletedDuringGeneration.delete(sessionId) + }) + this.inFlight.set(sessionId, pending) + return pending + } + + /** Removes the stored walkthrough of a deleted session, including one still being generated. */ + handleEvent(event: SSEEvent): void { + if (event.type !== 'session.deleted') { + return + } + + const { sessionID } = event.data + if (this.inFlight.has(sessionID)) { + this.deletedDuringGeneration.add(sessionID) + } + deleteChangeWalkthrough(this.db, sessionID) + } + + private async runGenerate( + sessionId: string, + request: GenerateChangeWalkthroughRequest, + ): Promise<{ walkthrough: ChangeWalkthrough; created: boolean }> { + const session = await this.readSession(sessionId) + + let changes: FileDiffInfo[] + try { + changes = await readSessionChanges(this.openCodeClient, sessionId) + } catch (error) { + throw new ChangeWalkthroughError(getErrorMessage(error) || 'Failed to read session changes', 502, { + code: 'WALKTHROUGH_CHANGES_UNAVAILABLE', + }) + } + + if (changes.length === 0) { + throw new ChangeWalkthroughError('This session has no changes to walk through', 409, { + code: 'WALKTHROUGH_NO_CHANGES', + }) + } + + const diffHash = computeChangesHash(changes) + const stored = getChangeWalkthrough(this.db, sessionId) + if (stored && stored.diffHash === diffHash && !request.regenerate) { + return { walkthrough: stored, created: false } + } + + const title = session.title ?? sessionId + const { hunks, omittedFiles } = buildWalkthroughInput(changes, title) + if (hunks.length === 0) { + if (omittedFiles.some((file) => file.reason === 'budget')) { + throw new ChangeWalkthroughError('These changes are too large to walk through', 413, { + code: 'WALKTHROUGH_CONTEXT_LIMIT', + details: { omittedFiles }, + }) + } + throw new ChangeWalkthroughError('This session has no text changes to walk through', 409, { + code: 'WALKTHROUGH_NO_TEXT_CHANGES', + }) + } + + const prompt = buildWalkthroughPrompt({ title, hunks }) + + let responseText: string + try { + responseText = await generateTextWithTimeout(this.openCodeClient, { prompt }, this.timeoutMs) + } catch (error) { + if (error instanceof GenerateTextTimeoutError) { + throw new ChangeWalkthroughError('Generating the change walkthrough timed out', 504, { + code: 'WALKTHROUGH_TIMEOUT', + }) + } + throw new ChangeWalkthroughError(getErrorMessage(error) || 'Failed to generate the change walkthrough', 502) + } + + const parsed = parseWalkthroughResponse(responseText, hunks) + if (!parsed) { + throw new ChangeWalkthroughError('The model did not return a usable change walkthrough', 502, { + code: 'WALKTHROUGH_UNPARSEABLE', + }) + } + + const walkthrough: ChangeWalkthrough = { + sessionId, + diffHash, + summary: parsed.summary, + stops: parsed.stops, + hunks, + omittedFiles, + createdAt: Date.now(), + } + + if (this.deletedDuringGeneration.has(sessionId)) { + throw new ChangeWalkthroughError('Session not found', 404) + } + + saveChangeWalkthrough(this.db, walkthrough) + return { walkthrough, created: true } + } + + private async readSession(sessionId: string): Promise { + try { + return await this.openCodeClient.api.session.get({ sessionID: sessionId }) + } catch (error) { + if (isSessionNotFoundError(error)) { + throw new ChangeWalkthroughError('Session not found', 404) + } + throw new ChangeWalkthroughError(getErrorMessage(error) || 'Failed to read session', 502) + } + } +} diff --git a/backend/src/services/git/commit-message-prompt.ts b/backend/src/services/git/commit-message-prompt.ts index 15ab5bc37..c2cbe40e6 100644 --- a/backend/src/services/git/commit-message-prompt.ts +++ b/backend/src/services/git/commit-message-prompt.ts @@ -1,18 +1,15 @@ +import { truncateText } from '../../utils/text-truncate' + export const MAX_COMMIT_PROMPT_DIFF_CHARS = 60_000 +const COMMIT_PROMPT_DIFF_TRUNCATION_MARKER = '\n[diff truncated]' + export interface CommitMessageContext { stagedStat: string stagedDiff: string recentSubjects: string[] } -function truncateDiff(diff: string): string { - if (diff.length <= MAX_COMMIT_PROMPT_DIFF_CHARS) { - return diff - } - return `${diff.slice(0, MAX_COMMIT_PROMPT_DIFF_CHARS)}\n[diff truncated]` -} - export function buildCommitMessagePrompt({ stagedStat, stagedDiff, recentSubjects }: CommitMessageContext): string { const subjects = recentSubjects.length > 0 ? recentSubjects.map(subject => `- ${subject}`).join('\n') @@ -31,7 +28,7 @@ export function buildCommitMessagePrompt({ stagedStat, stagedDiff, recentSubject stagedStat.trim(), '', 'Staged diff:', - truncateDiff(stagedDiff), + truncateText(stagedDiff, MAX_COMMIT_PROMPT_DIFF_CHARS, COMMIT_PROMPT_DIFF_TRUNCATION_MARKER).text, ].join('\n') } diff --git a/backend/src/services/multi-run-fusion.ts b/backend/src/services/multi-run-fusion.ts new file mode 100644 index 000000000..aae081bc5 --- /dev/null +++ b/backend/src/services/multi-run-fusion.ts @@ -0,0 +1,267 @@ +import { + isSessionNotFoundError, + type FileDiffInfo, + type SessionInfo, +} from '@opencode-manager/shared/opencode' +import { + FUSION_PROMPT_MAX_LENGTH, + type FusionUnavailableSource, + type MultiRunFusionSource, +} from '@opencode-manager/shared/schemas' +import type { MultiRunEntryRecord } from '../db/multi-runs' +import { getErrorMessage } from '../utils/error-utils' +import { ServiceError } from '../utils/service-error' +import { truncateText } from '../utils/text-truncate' +import type { OpenCodeClient } from './opencode/client' +import { readSessionChanges } from './session-changes' +import { + SESSION_REPLY_TRUNCATION_MARKER, + isSessionBusyIn, + readLatestAssistantReply, + type ActiveSessions, +} from './session-reply' + +export interface CollectedFusionSource { + entryId: number + model: string + sessionId: string + directory: string | null + outcome: 'succeeded' | 'failed' | 'interrupted' | null + replyText: string + changes: FileDiffInfo[] | null +} + +export interface FusionSourceCollection { + ready: CollectedFusionSource[] + unavailable: FusionUnavailableSource[] +} + +export const FUSION_MIN_SOURCE_CONTEXT = 1500 + +const FUSION_REPLY_BUDGET_RATIO = 0.4 + +const FUSION_BLOCK_SEPARATOR = '\n\n' + +export class FusionContextLimitError extends ServiceError { + constructor(requiredPerSource: number, availablePerSource: number) { + super( + 'The selected results are too large to fuse. Select fewer sources or shorten the instructions.', + 413, + { code: 'FUSION_CONTEXT_LIMIT', details: { requiredPerSource, availablePerSource } }, + ) + this.name = 'FusionContextLimitError' + } +} + +export interface FusionPromptInput { + runName: string + objective: string + instructions?: string + sources: CollectedFusionSource[] +} + +export interface FusionPrompt { + prompt: string + sources: MultiRunFusionSource[] +} + +export function buildFusionPrompt({ runName, objective, instructions, sources }: FusionPromptInput): FusionPrompt { + const preamble = buildFusionPreamble({ runName, objective, instructions }) + + if (sources.length === 0) { + return { prompt: preamble, sources: [] } + } + + const scaffolds = sources.map((source, index) => buildFusionSourceBlock(source, index, '', source.changes === null ? null : '')) + const fixed = preamble.length + + scaffolds.reduce((total, scaffold) => total + scaffold.length, 0) + + FUSION_BLOCK_SEPARATOR.length * sources.length + + const perSource = Math.floor((FUSION_PROMPT_MAX_LENGTH - fixed) / sources.length) + if (perSource < FUSION_MIN_SOURCE_CONTEXT) { + throw new FusionContextLimitError(FUSION_MIN_SOURCE_CONTEXT, perSource) + } + + const markerLength = SESSION_REPLY_TRUNCATION_MARKER.length + const replySlot = Math.floor(perSource * FUSION_REPLY_BUDGET_RATIO) + const replyBudget = Math.max(replySlot - markerLength, 0) + const patchBudget = Math.max(perSource - replySlot - markerLength, 0) + + const attributed: MultiRunFusionSource[] = [] + const blocks = sources.map((source, index) => { + const reply = truncateText(source.replyText, replyBudget, SESSION_REPLY_TRUNCATION_MARKER) + const changes = source.changes === null + ? null + : truncateText(renderFusionPatchText(source.changes), patchBudget, SESSION_REPLY_TRUNCATION_MARKER) + + attributed.push({ + entryId: source.entryId, + sessionId: source.sessionId, + model: source.model, + truncated: reply.truncated || Boolean(changes?.truncated), + }) + + return buildFusionSourceBlock(source, index, reply.text, changes ? changes.text : null) + }) + + return { prompt: [preamble, ...blocks].join(FUSION_BLOCK_SEPARATOR), sources: attributed } +} + +function buildFusionPreamble({ runName, objective, instructions }: { runName: string; objective: string; instructions?: string }): string { + const trimmedInstructions = instructions?.trim() + + return [ + `You are synthesizing the results of the multi-run "${runName}" into a single implementation in the current workspace.`, + '## Original objective', + objective, + ...(trimmedInstructions ? ['## Additional instructions', trimmedInstructions] : []), + '## Rules', + [ + '- The source sessions and workspaces below are read-only references. Do not modify, merge or push them.', + '- Apply any adopted changes only in the current workspace.', + '- Attribute each adopted part of the synthesis to the source it came from.', + ].join('\n'), + ].join(FUSION_BLOCK_SEPARATOR) +} + +function buildFusionSourceBlock( + source: CollectedFusionSource, + index: number, + replyBody: string, + patchBody: string | null, +): string { + return [ + `## Source ${index + 1} — ${source.model}`, + '', + `- Session: ${source.sessionId}`, + `- Outcome: ${source.outcome ?? 'unknown'}`, + `- Workspace: ${source.directory ?? 'not recorded'}`, + '', + 'Changed files:', + ...renderFusionChangedFiles(source.changes), + '', + '### Final reply', + '', + replyBody, + '', + '### Changes', + '', + ...(patchBody === null ? ['Changes could not be read.'] : ['```diff', patchBody, '```']), + ].join('\n') +} + +function renderFusionChangedFiles(changes: FileDiffInfo[] | null): string[] { + if (changes === null) { + return ['- (changes could not be read)'] + } + if (changes.length === 0) { + return ['- (no files changed)'] + } + return changes.map((change) => `- ${change.file} (${change.status}, +${change.additions}/-${change.deletions})`) +} + +function renderFusionPatchText(changes: FileDiffInfo[]): string { + return changes.map((change) => change.patch).join('\n') +} + +type FusionSourceResult = + | { kind: 'ready'; source: CollectedFusionSource } + | { kind: 'unavailable'; source: FusionUnavailableSource } + +export async function collectFusionSources( + client: OpenCodeClient, + entries: MultiRunEntryRecord[], +): Promise { + let activeSessions: Promise | null = null + const getActiveSessions = (): Promise => { + activeSessions ??= client.api.session.active() + return activeSessions + } + + const results = await Promise.all(entries.map((entry) => collectFusionSource(client, entry, getActiveSessions))) + + const ready: CollectedFusionSource[] = [] + const unavailable: FusionUnavailableSource[] = [] + + for (const result of results) { + if (result.kind === 'ready') { + ready.push(result.source) + } else { + unavailable.push(result.source) + } + } + + return { ready, unavailable } +} + +async function collectFusionSource( + client: OpenCodeClient, + entry: MultiRunEntryRecord, + getActiveSessions: () => Promise, +): Promise { + if (entry.status !== 'started' || !entry.sessionId) { + return unavailable(entry, 'not-started', 'The result has not started a session.') + } + + const sessionId = entry.sessionId + + let session: SessionInfo + try { + session = await client.api.session.get({ sessionID: sessionId }) + } catch (error) { + if (isSessionNotFoundError(error)) { + return unavailable(entry, 'missing', 'The session no longer exists.') + } + return unavailable(entry, 'unavailable', getErrorMessage(error)) + } + + try { + if (isSessionBusyIn(await getActiveSessions(), sessionId)) { + return unavailable(entry, 'running', 'The session is still running.') + } + + const reply = await readLatestAssistantReply(client, sessionId) + if (!reply || !reply.completed) { + return unavailable(entry, 'incomplete', 'The session has not produced a final reply.') + } + + if (session.outcome === 'failed' || session.outcome === 'interrupted' || reply.errorText) { + return unavailable(entry, 'failed', failureMessage(session, reply.errorText)) + } + + const changes = await readSessionChanges(client, sessionId).catch(() => null) + + return { + kind: 'ready', + source: { + entryId: entry.id, + model: entry.model, + sessionId, + directory: entry.directory, + outcome: session.outcome ?? null, + replyText: reply.responseText ?? '', + changes, + }, + } + } catch (error) { + return unavailable(entry, 'unavailable', getErrorMessage(error)) + } +} + +function failureMessage(session: SessionInfo, errorText: string | null): string { + if (errorText) { + return `The session failed: ${errorText}` + } + + return session.outcome === 'interrupted' + ? 'The session was interrupted.' + : 'The session did not complete successfully.' +} + +function unavailable( + entry: MultiRunEntryRecord, + reason: FusionUnavailableSource['reason'], + message: string, +): FusionSourceResult { + return { kind: 'unavailable', source: { entryId: entry.id, model: entry.model, reason, message } } +} diff --git a/backend/src/services/multi-runs.ts b/backend/src/services/multi-runs.ts index 9df882a84..99345af25 100644 --- a/backend/src/services/multi-runs.ts +++ b/backend/src/services/multi-runs.ts @@ -1,18 +1,25 @@ import type { Database } from 'bun:sqlite' import { existsSync } from 'node:fs' -import type { LaunchMultiRunRequest, MultiRun } from '@opencode-manager/shared/schemas' +import { resolve } from 'node:path' +import type { FuseMultiRunRequest, LaunchMultiRunRequest, MultiRun } from '@opencode-manager/shared/schemas' import { createMultiRunWithEntries, getMultiRun, getMultiRunEntry, + getMultiRunFusionByRequest, + insertMultiRunFusion, listMultiRuns, updateMultiRunEntry, + updateMultiRunFusion, type MultiRunEntryRecord, + type MultiRunFusionRecord, type MultiRunRecord, } from '../db/multi-runs' import { getRepoById } from '../db/queries' import type { Repo } from '../types/repo' import { getErrorMessage } from '../utils/error-utils' +import { ServiceError } from '../utils/service-error' +import { buildFusionPrompt, collectFusionSources } from './multi-run-fusion' import type { OpenCodeClient } from './opencode/client' import { RepoWorkspaceError } from './repo' import type { RepoWorkspaceService } from './repo-workspace' @@ -20,15 +27,7 @@ import { requireReadyRepo, SessionLauncher, SessionLaunchError, type LaunchedSes const MULTI_RUN_LIST_LIMIT = 20 -export class MultiRunError extends Error { - status: number - - constructor(message: string, status: number) { - super(message) - this.name = 'MultiRunError' - this.status = status - } -} +export class MultiRunError extends ServiceError {} function toMultiRun(record: MultiRunRecord): MultiRun { return { @@ -50,6 +49,21 @@ function toMultiRun(record: MultiRunRecord): MultiRun { createdAt: entry.createdAt, updatedAt: entry.updatedAt, })), + fusions: record.fusions.map((fusion) => ({ + id: fusion.id, + requestId: fusion.requestId, + model: fusion.model, + instructions: fusion.instructions, + isolated: fusion.isolated, + baseRef: fusion.baseRef, + status: fusion.status, + sessionId: fusion.sessionId, + directory: fusion.directory, + error: fusion.error, + sources: fusion.sources, + createdAt: fusion.createdAt, + updatedAt: fusion.updatedAt, + })), } } @@ -59,7 +73,7 @@ export class MultiRunService { constructor( private readonly db: Database, - openCodeClient: OpenCodeClient, + private readonly openCodeClient: OpenCodeClient, private readonly repoWorkspaces: RepoWorkspaceService, ) { this.sessionLauncher = new SessionLauncher(db, openCodeClient, repoWorkspaces) @@ -128,18 +142,134 @@ export class MultiRunService { return } - const workspaceDirectory = - result.reason instanceof SessionLaunchError ? result.reason.workspaceDirectory : null + const launchError = result.reason instanceof SessionLaunchError ? result.reason : null + const workspaceDirectory = launchError?.workspaceDirectory ?? null updateMultiRunEntry(this.db, launch.entry.id, ['starting'], { status: 'failed', error: getErrorMessage(result.reason) || 'Failed to launch session', ...(workspaceDirectory ? { directory: workspaceDirectory } : {}), + ...(launchError?.sessionId ? { sessionId: launchError.sessionId } : {}), }) }) return this.reload(record.id) } + async fuse(multiRunId: number, request: FuseMultiRunRequest): Promise<{ run: MultiRun; created: boolean }> { + const record = getMultiRun(this.db, multiRunId) + if (!record) { + throw new MultiRunError('Multi-run not found', 404) + } + + if (getMultiRunFusionByRequest(this.db, multiRunId, request.requestId)) { + return { run: this.reload(multiRunId), created: false } + } + + let repo: Repo + try { + repo = requireReadyRepo(this.db, record.repoId) + } catch (error) { + throw new MultiRunError(getErrorMessage(error) || 'Repository unavailable', 404) + } + + const entriesById = new Map(record.entries.map((entry) => [entry.id, entry])) + const selectedEntries = request.entryIds.map((entryId) => { + const entry = entriesById.get(entryId) + if (!entry) { + throw new MultiRunError(`Unknown multi-run entry ${entryId}`, 400) + } + return entry + }) + + if (!request.isolate) { + const overlappingEntryIds = selectedEntries + .filter((entry) => entry.directory !== null && resolve(entry.directory) === resolve(repo.fullPath)) + .map((entry) => entry.id) + if (overlappingEntryIds.length > 0) { + throw new MultiRunError( + 'The synthesis cannot run in the repository checkout because a selected result ran there. Enable an isolated workspace.', + 409, + { code: 'FUSION_DESTINATION_OVERLAPS_SOURCE', details: { entryIds: overlappingEntryIds } }, + ) + } + } + + try { + await this.sessionLauncher.resolveModel(repo, request.model) + } catch (error) { + if (error instanceof SessionLaunchError) { + throw new MultiRunError(error.message, error.status) + } + throw new MultiRunError(getErrorMessage(error) || 'Failed to resolve model', 502) + } + + await this.reconcileUncertainFusions(record) + + const collection = await collectFusionSources(this.openCodeClient, selectedEntries) + if (collection.unavailable.length > 0) { + throw new MultiRunError('Some selected results are not ready to fuse', 409, { + code: 'FUSION_SOURCES_UNAVAILABLE', + details: { unavailableSources: collection.unavailable }, + }) + } + + const built = buildFusionPrompt({ + runName: record.name, + objective: record.prompt, + instructions: request.instructions, + sources: collection.ready, + }) + + const { fusion, created } = insertMultiRunFusion(this.db, { + multiRunId, + requestId: request.requestId, + model: request.model, + instructions: request.instructions ?? null, + isolated: request.isolate, + baseRef: request.baseRef ?? null, + sources: built.sources, + }) + + if (!created) { + return { run: this.reload(multiRunId), created: false } + } + + try { + const launched = await this.sessionLauncher.launch({ + repoId: record.repoId, + prompt: built.prompt, + model: request.model, + title: `${record.name} · fusion`, + ...(request.agent ? { agent: request.agent } : {}), + ...(request.isolate + ? { + workspace: { + name: `${record.name}-fusion-${fusion.id}`, + ...(request.baseRef ? { ref: request.baseRef } : {}), + }, + } + : {}), + }) + + updateMultiRunFusion(this.db, fusion.id, ['starting'], { + status: 'started', + sessionId: launched.sessionId, + directory: launched.directory, + }) + } catch (error) { + const launchError = error instanceof SessionLaunchError ? error : null + const workspaceDirectory = launchError?.workspaceDirectory ?? null + updateMultiRunFusion(this.db, fusion.id, ['starting'], { + status: 'failed', + error: getErrorMessage(error) || 'Failed to launch session', + ...(workspaceDirectory ? { directory: workspaceDirectory } : {}), + ...(launchError?.sessionId ? { sessionId: launchError.sessionId } : {}), + }) + } + + return { run: this.reload(multiRunId), created: true } + } + list(repoId: number): MultiRun[] { return listMultiRuns(this.db, repoId, MULTI_RUN_LIST_LIMIT).map(toMultiRun) } @@ -207,6 +337,46 @@ export class MultiRunService { }) } + private async reconcileUncertainFusions(record: MultiRunRecord): Promise { + const uncertain = record.fusions.filter( + (fusion): fusion is MultiRunFusionRecord & { sessionId: string } => + fusion.status === 'failed' && fusion.sessionId !== null, + ) + if (uncertain.length === 0) { + return + } + + const recovered = ( + await Promise.all( + uncertain.map(async (fusion) => { + try { + const response = await this.openCodeClient.api.message.list({ + sessionID: fusion.sessionId, + type: 'user', + order: 'asc', + limit: 1, + }) + if (response.data.length === 0) { + return null + } + } catch { + return null + } + + updateMultiRunFusion(this.db, fusion.id, ['failed'], { status: 'started', error: null }) + return { fusionId: fusion.id, sessionId: fusion.sessionId } + }), + ) + ).filter((entry): entry is { fusionId: number; sessionId: string } => entry !== null) + + if (recovered.length > 0) { + throw new MultiRunError('An earlier fusion attempt is already running', 409, { + code: 'FUSION_ATTEMPT_RECOVERED', + details: { fusions: recovered }, + }) + } + } + private reload(multiRunId: number): MultiRun { const record = getMultiRun(this.db, multiRunId) if (!record) { diff --git a/backend/src/services/opencode/generate-text.ts b/backend/src/services/opencode/generate-text.ts new file mode 100644 index 000000000..7308b89b3 --- /dev/null +++ b/backend/src/services/opencode/generate-text.ts @@ -0,0 +1,34 @@ +import type { ModelRef } from '@opencode-manager/shared/opencode' +import type { OpenCodeClient } from './client' + +export class GenerateTextTimeoutError extends Error { + constructor() { + super('Text generation timed out') + this.name = 'GenerateTextTimeoutError' + } +} + +export async function generateTextWithTimeout( + client: OpenCodeClient, + input: { prompt: string; model?: ModelRef }, + timeoutMs: number, +): Promise { + const controller = new AbortController() + let timer: ReturnType | undefined + const timeout = new Promise((_resolve, reject) => { + timer = setTimeout(() => { + controller.abort() + reject(new GenerateTextTimeoutError()) + }, timeoutMs) + }) + + try { + const { text } = await Promise.race([ + client.api.generate.text(input, { signal: controller.signal }), + timeout, + ]) + return text + } finally { + if (timer) clearTimeout(timer) + } +} diff --git a/backend/src/services/session-changes.ts b/backend/src/services/session-changes.ts new file mode 100644 index 000000000..44b7a0d85 --- /dev/null +++ b/backend/src/services/session-changes.ts @@ -0,0 +1,24 @@ +import type { FileDiffInfo } from '@opencode-manager/shared/opencode' +import type { OpenCodeClient } from './opencode/client' + +export const SESSION_CHANGES_CONTEXT_LINES = 3 + +export async function readSessionChanges(client: OpenCodeClient, sessionId: string): Promise { + const [first, last] = await Promise.all([ + client.api.message.list({ sessionID: sessionId, type: 'user', order: 'asc', limit: 1 }), + client.api.message.list({ sessionID: sessionId, type: 'user', order: 'desc', limit: 1 }), + ]) + + const from = first.data[0] + const to = last.data[0] + if (!from || !to) { + return [] + } + + return client.api.session.diff({ + sessionID: sessionId, + from: from.id, + to: to.id, + context: SESSION_CHANGES_CONTEXT_LINES, + }) +} diff --git a/backend/src/services/session-goal-audit.ts b/backend/src/services/session-goal-audit.ts index f0be2bcd1..92f31a56b 100644 --- a/backend/src/services/session-goal-audit.ts +++ b/backend/src/services/session-goal-audit.ts @@ -1,4 +1,5 @@ import { SessionGoalVerdictSchema, type SessionGoalVerdict } from '@opencode-manager/shared/schemas' +import { extractFirstJsonObject } from '../utils/json-extract' import { truncateSessionReply } from './session-reply' export const GOAL_AUDIT_REASON_MAX_CHARS = 500 @@ -91,45 +92,3 @@ function truncateReply(reply: string | null): string { } 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-launcher.ts b/backend/src/services/session-launcher.ts index b2b909da3..76c078591 100644 --- a/backend/src/services/session-launcher.ts +++ b/backend/src/services/session-launcher.ts @@ -29,12 +29,19 @@ export interface LaunchedSession { export class SessionLaunchError extends Error { readonly status: 400 | 404 | 502 readonly workspaceDirectory: string | null + readonly sessionId: string | null - constructor(message: string, status: 400 | 404 | 502, workspaceDirectory: string | null = null) { + constructor( + message: string, + status: 400 | 404 | 502, + workspaceDirectory: string | null = null, + sessionId: string | null = null, + ) { super(message) this.name = 'SessionLaunchError' this.status = status this.workspaceDirectory = workspaceDirectory + this.sessionId = sessionId } } @@ -109,13 +116,22 @@ export class SessionLauncher { }, ...openCodeLocation(directory), }) + } catch (error) { + throw new SessionLaunchError( + withWorkspace(getErrorMessage(error) || 'Failed to create OpenCode session', workspaceDirectory), + 502, + workspaceDirectory, + ) + } + try { 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, + session.id, ) } diff --git a/backend/src/services/session-reply.ts b/backend/src/services/session-reply.ts index bc8d7dd15..1b6920162 100644 --- a/backend/src/services/session-reply.ts +++ b/backend/src/services/session-reply.ts @@ -1,10 +1,17 @@ -import { assistantText, sessionIDFromEvent, type SessionMessageAssistant, type SessionMessageInfo } from '@opencode-manager/shared/opencode' +import { + assistantText, + sessionIDFromEvent, + type OpenCodeApi, + type SessionMessageAssistant, + type SessionMessageInfo, +} from '@opencode-manager/shared/opencode' +import { truncateText } from '../utils/text-truncate' 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 const SESSION_REPLY_TRUNCATION_MARKER = '\n\n[reply truncated]' export interface AssistantReplyState { responseText: string | null @@ -17,10 +24,7 @@ export interface SessionSettleSignal { } 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}` + return truncateText(text, SESSION_REPLY_MAX_LENGTH, SESSION_REPLY_TRUNCATION_MARKER).text } export function sessionSettleSignal(event: SSEEvent, sessionId: string): SessionSettleSignal | null { @@ -110,7 +114,12 @@ export async function readLatestAssistantReply(client: OpenCodeClient, sessionId return getLatestAssistantReplyState(response.data) } -export async function isSessionBusy(client: OpenCodeClient, sessionId: string): Promise { - const active = await client.api.session.active() +export type ActiveSessions = Awaited> + +export function isSessionBusyIn(active: ActiveSessions, sessionId: string): boolean { return sessionId in active } + +export async function isSessionBusy(client: OpenCodeClient, sessionId: string): Promise { + return isSessionBusyIn(await client.api.session.active(), sessionId) +} diff --git a/backend/src/utils/json-extract.ts b/backend/src/utils/json-extract.ts new file mode 100644 index 000000000..dec69113e --- /dev/null +++ b/backend/src/utils/json-extract.ts @@ -0,0 +1,41 @@ +export 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/utils/service-error.ts b/backend/src/utils/service-error.ts new file mode 100644 index 000000000..0e811e52b --- /dev/null +++ b/backend/src/utils/service-error.ts @@ -0,0 +1,13 @@ +export class ServiceError extends Error { + status: number + code?: string + details?: unknown + + constructor(message: string, status: number, options?: { code?: string; details?: unknown }) { + super(message) + this.name = 'ServiceError' + this.status = status + this.code = options?.code + this.details = options?.details + } +} diff --git a/backend/src/utils/text-truncate.ts b/backend/src/utils/text-truncate.ts new file mode 100644 index 000000000..f83153552 --- /dev/null +++ b/backend/src/utils/text-truncate.ts @@ -0,0 +1,11 @@ +export interface TruncateTextResult { + text: string + truncated: boolean +} + +export function truncateText(text: string, maxLength: number, marker: string): TruncateTextResult { + if (text.length <= maxLength) { + return { text, truncated: false } + } + return { text: `${text.slice(0, maxLength)}${marker}`, truncated: true } +} diff --git a/backend/test/auth/middleware-same-site.test.ts b/backend/test/auth/middleware-same-site.test.ts index 336ec451c..5fa8c67c9 100644 --- a/backend/test/auth/middleware-same-site.test.ts +++ b/backend/test/auth/middleware-same-site.test.ts @@ -181,6 +181,11 @@ describe('getTrustedOrigins', () => { }) it('falls back to the local development origins', async () => { - await expect(loadTrustedOrigins(undefined)).resolves.toEqual(['http://localhost:5173', 'http://localhost:5003']) + await expect(loadTrustedOrigins(undefined)).resolves.toEqual([ + 'http://localhost:5173', + 'http://localhost:5003', + 'http://127.0.0.1:5173', + 'http://127.0.0.1:5003', + ]) }) }) diff --git a/backend/test/db/change-walkthroughs.test.ts b/backend/test/db/change-walkthroughs.test.ts new file mode 100644 index 000000000..25db50a9b --- /dev/null +++ b/backend/test/db/change-walkthroughs.test.ts @@ -0,0 +1,123 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { Database } from 'bun:sqlite' +import type { ChangeWalkthrough } from '@opencode-manager/shared/schemas' +import { + deleteChangeWalkthrough, + ensureChangeWalkthroughTable, + getChangeWalkthrough, + saveChangeWalkthrough, +} from '../../src/db/change-walkthroughs' +import { migrate } from '../../src/db/migration-runner' +import { allMigrations } from '../../src/db/migrations' + +const SESSION_ID = 'ses_walkthrough' + +function walkthrough(overrides: Partial = {}): ChangeWalkthrough { + return { + sessionId: SESSION_ID, + diffHash: 'hash-1', + summary: 'A short summary', + stops: [{ title: 'First stop', explanation: 'Why it matters', hunkIds: ['f0h0'] }], + hunks: [ + { + id: 'f0h0', + file: 'src/app.ts', + status: 'modified', + header: '@@ -1,2 +1,2 @@', + text: '@@ -1,2 +1,2 @@\n-const a = 1;\n+const a = 2;', + truncated: false, + }, + ], + omittedFiles: [{ file: 'assets/logo.png', reason: 'binary' }], + createdAt: 1_000, + ...overrides, + } +} + +function walkthroughTableExists(db: Database): boolean { + return Boolean( + db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'change_walkthroughs'").get(), + ) +} + +describe('change walkthroughs', () => { + let db: Database + + beforeEach(() => { + db = new Database(':memory:') + migrate(db, allMigrations) + }) + + afterEach(() => { + db.close() + }) + + it('creates the change_walkthroughs table in migration 29', () => { + expect(walkthroughTableExists(db)).toBe(true) + }) + + it('creates the table idempotently', () => { + expect(() => ensureChangeWalkthroughTable(db)).not.toThrow() + expect(walkthroughTableExists(db)).toBe(true) + }) + + it('returns null for a session without a stored walkthrough', () => { + expect(getChangeWalkthrough(db, 'ses_missing')).toBeNull() + }) + + it('round-trips a stored walkthrough', () => { + const stored = walkthrough() + saveChangeWalkthrough(db, stored) + + expect(getChangeWalkthrough(db, SESSION_ID)).toEqual(stored) + }) + + it('keeps a single row holding the latest payload on repeated saves', () => { + saveChangeWalkthrough(db, walkthrough()) + saveChangeWalkthrough(db, walkthrough({ diffHash: 'hash-2', summary: 'Updated', createdAt: 2_000 })) + + const count = db + .prepare('SELECT COUNT(*) AS count FROM change_walkthroughs WHERE session_id = ?') + .get(SESSION_ID) as { count: number } + expect(count.count).toBe(1) + + const stored = getChangeWalkthrough(db, SESSION_ID) + expect(stored).toMatchObject({ diffHash: 'hash-2', summary: 'Updated', createdAt: 2_000 }) + }) + + it('deletes only the walkthrough of the given session', () => { + saveChangeWalkthrough(db, walkthrough()) + saveChangeWalkthrough(db, walkthrough({ sessionId: 'ses_other' })) + + deleteChangeWalkthrough(db, SESSION_ID) + + expect(getChangeWalkthrough(db, SESSION_ID)).toBeNull() + expect(getChangeWalkthrough(db, 'ses_other')).not.toBeNull() + }) + + it('ignores deleting a session without a stored walkthrough', () => { + expect(() => deleteChangeWalkthrough(db, 'ses_missing')).not.toThrow() + }) + + it('treats a corrupt payload as missing', () => { + db.prepare('INSERT INTO change_walkthroughs(session_id, diff_hash, payload, created_at) VALUES(?,?,?,?)').run( + SESSION_ID, + 'hash-1', + '{not json', + 1_000, + ) + + expect(getChangeWalkthrough(db, SESSION_ID)).toBeNull() + }) + + it('treats a payload that fails schema validation as missing', () => { + db.prepare('INSERT INTO change_walkthroughs(session_id, diff_hash, payload, created_at) VALUES(?,?,?,?)').run( + SESSION_ID, + 'hash-1', + JSON.stringify({ sessionId: SESSION_ID }), + 1_000, + ) + + expect(getChangeWalkthrough(db, SESSION_ID)).toBeNull() + }) +}) diff --git a/backend/test/db/multi-run-fusions.test.ts b/backend/test/db/multi-run-fusions.test.ts new file mode 100644 index 000000000..064461854 --- /dev/null +++ b/backend/test/db/multi-run-fusions.test.ts @@ -0,0 +1,229 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { Database } from 'bun:sqlite' +import { FuseMultiRunRequestSchema } from '@opencode-manager/shared/schemas' +import { + createMultiRunWithEntries, + getMultiRun, + getMultiRunFusionByRequest, + insertMultiRunFusion, + listMultiRuns, + updateMultiRunFusion, + type CreateMultiRunFusionInput, +} from '../../src/db/multi-runs' +import { migrate } from '../../src/db/migration-runner' +import { allMigrations } from '../../src/db/migrations' +import { deleteRepo } from '../../src/db/queries' + +const REPO_ID = 7 + +function fusionInput(overrides: Partial = {}): CreateMultiRunFusionInput { + return { + multiRunId: 0, + requestId: 'req-1', + model: 'openai/gpt-5', + instructions: 'combine them', + isolated: true, + baseRef: 'main', + sources: [ + { entryId: 1, sessionId: 'ses_a', model: 'openai/a', truncated: false }, + { entryId: 2, sessionId: 'ses_b', model: 'openai/b', truncated: true }, + ], + ...overrides, + } +} + +function fusionTableExists(db: Database): boolean { + return Boolean( + db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'multi_run_fusions'").get(), + ) +} + +describe('multi-run fusions', () => { + let db: Database + + beforeEach(() => { + db = new Database(':memory:') + migrate(db, allMigrations) + }) + + afterEach(() => { + db.close() + }) + + function createRun(models = ['openai/a', 'openai/b']): number { + const run = createMultiRunWithEntries( + db, + { repoId: REPO_ID, name: 'Sweep', prompt: 'go', isolated: true, baseRef: null }, + models, + ) + return run.id + } + + it('creates the multi_run_fusions table', () => { + expect(fusionTableExists(db)).toBe(true) + }) + + it('keeps existing multi-run data when the fusions migration is applied', () => { + const existing = new Database(':memory:') + migrate( + existing, + allMigrations.filter((migration) => migration.id !== '202610061700-multi-run-fusions'), + ) + existing + .prepare('INSERT INTO multi_runs(repo_id, name, prompt, isolated, base_ref, created_at) VALUES(?,?,?,?,?,?)') + .run(REPO_ID, 'Sweep', 'go', 1, null, 1) + const multiRunId = Number( + (existing.prepare('SELECT last_insert_rowid() AS id').get() as { id: number }).id, + ) + const insertEntry = existing.prepare( + 'INSERT INTO multi_run_entries(multi_run_id, model, status, session_id, directory, error, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?)', + ) + insertEntry.run(multiRunId, 'openai/a', 'started', 'ses_a', null, null, 1, 1) + insertEntry.run(multiRunId, 'openai/b', 'started', 'ses_b', null, null, 1, 1) + + migrate(existing, allMigrations) + + const reloaded = getMultiRun(existing, multiRunId) + expect(reloaded?.entries.map((entry) => entry.model)).toEqual(['openai/a', 'openai/b']) + expect(reloaded?.fusions).toEqual([]) + expect(fusionTableExists(existing)).toBe(true) + existing.close() + }) + + it('inserts a fusion once per request id and replays the stored record', () => { + const multiRunId = createRun() + const first = insertMultiRunFusion(db, fusionInput({ multiRunId })) + const second = insertMultiRunFusion(db, fusionInput({ multiRunId, model: 'openai/other' })) + + expect(first.created).toBe(true) + expect(second.created).toBe(false) + expect(second.fusion.id).toBe(first.fusion.id) + expect(second.fusion.model).toBe('openai/gpt-5') + + const count = db + .prepare('SELECT COUNT(*) AS count FROM multi_run_fusions WHERE multi_run_id = ?') + .get(multiRunId) as { count: number } + expect(count.count).toBe(1) + }) + + it('rolls back the insert when the stored sources fail readback validation', () => { + const multiRunId = createRun() + + expect(() => + insertMultiRunFusion( + db, + fusionInput({ + multiRunId, + sources: [{ entryId: 1.5, sessionId: 'ses_a', model: 'openai/a', truncated: false }], + }), + ), + ).toThrow() + + const count = db + .prepare('SELECT COUNT(*) AS count FROM multi_run_fusions WHERE multi_run_id = ?') + .get(multiRunId) as { count: number } + expect(count.count).toBe(0) + expect(getMultiRun(db, multiRunId)?.fusions).toEqual([]) + }) + + it('stores parsed sources and the starting status of a new fusion', () => { + const multiRunId = createRun() + const { fusion } = insertMultiRunFusion(db, fusionInput({ multiRunId })) + + expect(fusion).toMatchObject({ multiRunId, requestId: 'req-1', status: 'starting', isolated: true }) + expect(fusion.sources).toEqual([ + { entryId: 1, sessionId: 'ses_a', model: 'openai/a', truncated: false }, + { entryId: 2, sessionId: 'ses_b', model: 'openai/b', truncated: true }, + ]) + }) + + it('guards updates by the current status', () => { + const multiRunId = createRun() + const { fusion } = insertMultiRunFusion(db, fusionInput({ multiRunId })) + + const started = updateMultiRunFusion(db, fusion.id, ['starting'], { + status: 'started', + sessionId: 'ses_new', + directory: '/worktrees/fusion', + }) + expect(started).toMatchObject({ status: 'started', sessionId: 'ses_new', directory: '/worktrees/fusion' }) + + expect(updateMultiRunFusion(db, fusion.id, ['starting'], { status: 'failed', error: 'late' })).toBeNull() + + const failed = updateMultiRunFusion(db, fusion.id, ['started'], { status: 'failed', error: 'boom' }) + expect(failed).toMatchObject({ status: 'failed', error: 'boom' }) + }) + + it('includes fusions with parsed sources on getMultiRun and listMultiRuns', () => { + const multiRunId = createRun() + insertMultiRunFusion(db, fusionInput({ multiRunId })) + + const fetched = getMultiRun(db, multiRunId) + expect(fetched?.fusions).toHaveLength(1) + expect(fetched?.fusions[0]?.sources[0]).toEqual({ + entryId: 1, + sessionId: 'ses_a', + model: 'openai/a', + truncated: false, + }) + + const listed = listMultiRuns(db, REPO_ID, 20) + expect(listed[0]?.fusions).toHaveLength(1) + expect(listed[0]?.fusions[0]?.sources).toHaveLength(2) + }) + + it('returns an empty fusion list for runs without fusions', () => { + const multiRunId = createRun() + expect(getMultiRun(db, multiRunId)?.fusions).toEqual([]) + expect(listMultiRuns(db, REPO_ID, 20)[0]?.fusions).toEqual([]) + }) + + it('reads a fusion by its request id', () => { + const multiRunId = createRun() + const { fusion } = insertMultiRunFusion(db, fusionInput({ multiRunId })) + + expect(getMultiRunFusionByRequest(db, multiRunId, 'req-1')?.id).toBe(fusion.id) + expect(getMultiRunFusionByRequest(db, multiRunId, 'missing')).toBeNull() + expect(getMultiRunFusionByRequest(db, multiRunId + 1, 'req-1')).toBeNull() + }) + + it('removes fusions when the repository is deleted', () => { + const multiRunId = createRun() + insertMultiRunFusion(db, fusionInput({ multiRunId })) + + deleteRepo(db, REPO_ID) + + expect(db.prepare('SELECT COUNT(*) AS count FROM multi_run_fusions').get()).toEqual({ count: 0 }) + expect(db.prepare('SELECT COUNT(*) AS count FROM multi_run_entries').get()).toEqual({ count: 0 }) + expect(db.prepare('SELECT COUNT(*) AS count FROM multi_runs').get()).toEqual({ count: 0 }) + }) +}) + +describe('FuseMultiRunRequestSchema', () => { + const valid = { + requestId: '00000000-0000-4000-8000-000000000000', + entryIds: [1, 2], + model: 'openai/gpt-5', + isolate: true, + } + + it('accepts at least two unique entry ids', () => { + expect(FuseMultiRunRequestSchema.safeParse(valid).success).toBe(true) + }) + + it('rejects fewer than two entry ids', () => { + expect(FuseMultiRunRequestSchema.safeParse({ ...valid, entryIds: [1] }).success).toBe(false) + }) + + it('rejects duplicate entry ids', () => { + expect(FuseMultiRunRequestSchema.safeParse({ ...valid, entryIds: [1, 1] }).success).toBe(false) + }) + + it('rejects a non-uuid request id', () => { + expect(FuseMultiRunRequestSchema.safeParse({ ...valid, requestId: 'req-1' }).success).toBe(false) + }) + + it('rejects instructions over the limit', () => { + expect(FuseMultiRunRequestSchema.safeParse({ ...valid, instructions: 'x'.repeat(4001) }).success).toBe(false) + }) +}) diff --git a/backend/test/db/queries.test.ts b/backend/test/db/queries.test.ts index fcb0f07bf..b3fff30c7 100644 --- a/backend/test/db/queries.test.ts +++ b/backend/test/db/queries.test.ts @@ -242,6 +242,7 @@ describe('Database Queries', () => { it('should delete repo-scoped rows, including multi-runs, in one transaction', () => { mockDb.transaction.mockImplementation((fn: () => void) => fn) + const deleteFusionsStmt = { run: vi.fn().mockReturnValue({ changes: 3 }) } const deleteEntriesStmt = { run: vi.fn().mockReturnValue({ changes: 2 }) } const deleteMultiRunsStmt = { run: vi.fn().mockReturnValue({ changes: 1 }) } const deleteRunsStmt = { run: vi.fn().mockReturnValue({ changes: 2 }) } @@ -249,6 +250,7 @@ describe('Database Queries', () => { const deleteSettingsStmt = { run: vi.fn().mockReturnValue({ changes: 0 }) } const deleteRepoStmt = { run: vi.fn().mockReturnValue({ changes: 1 }) } mockDb.prepare + .mockReturnValueOnce(deleteFusionsStmt) .mockReturnValueOnce(deleteEntriesStmt) .mockReturnValueOnce(deleteMultiRunsStmt) .mockReturnValueOnce(deleteJobsStmt) @@ -260,18 +262,22 @@ describe('Database Queries', () => { expect(mockDb.transaction).toHaveBeenCalledTimes(1) expect(mockDb.prepare).toHaveBeenNthCalledWith(1, + 'DELETE FROM multi_run_fusions WHERE multi_run_id IN (SELECT id FROM multi_runs WHERE repo_id = ?)' + ) + expect(deleteFusionsStmt.run).toHaveBeenCalledWith(1) + expect(mockDb.prepare).toHaveBeenNthCalledWith(2, '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(mockDb.prepare).toHaveBeenNthCalledWith(3, '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(mockDb.prepare).toHaveBeenNthCalledWith(4, 'DELETE FROM schedule_jobs WHERE repo_id = ?') expect(deleteJobsStmt.run).toHaveBeenCalledWith(1) - expect(mockDb.prepare).toHaveBeenNthCalledWith(4, 'DELETE FROM schedule_runs WHERE repo_id = ?') + expect(mockDb.prepare).toHaveBeenNthCalledWith(5, 'DELETE FROM schedule_runs WHERE repo_id = ?') expect(deleteRunsStmt.run).toHaveBeenCalledWith(1) - expect(mockDb.prepare).toHaveBeenNthCalledWith(5, 'DELETE FROM repo_settings WHERE repo_id = ?') + expect(mockDb.prepare).toHaveBeenNthCalledWith(6, 'DELETE FROM repo_settings WHERE repo_id = ?') expect(deleteSettingsStmt.run).toHaveBeenCalledWith(1) - expect(mockDb.prepare).toHaveBeenNthCalledWith(6, + expect(mockDb.prepare).toHaveBeenNthCalledWith(7, 'DELETE FROM repos WHERE id = ?' ) expect(deleteRepoStmt.run).toHaveBeenCalledWith(1) diff --git a/backend/test/routes/change-walkthroughs.test.ts b/backend/test/routes/change-walkthroughs.test.ts new file mode 100644 index 000000000..44a5e07fd --- /dev/null +++ b/backend/test/routes/change-walkthroughs.test.ts @@ -0,0 +1,209 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { Hono } from 'hono' +import { Database } from 'bun:sqlite' +import type { FileDiffInfo, SessionInfo } from '@opencode-manager/shared/opencode' +import { migrate } from '../../src/db/migration-runner' +import { allMigrations } from '../../src/db/migrations' +import { ChangeWalkthroughService } from '../../src/services/change-walkthroughs' +import { createChangeWalkthroughRoutes } from '../../src/routes/change-walkthroughs' +import type { OpenCodeClient } from '../../src/services/opencode/client' + +const SESSION_ID = 'ses_walkthrough' + +function change(file: string, patch: string, status: FileDiffInfo['status'] = 'modified'): FileDiffInfo { + return { file, patch, additions: 1, deletions: 1, status } +} + +const CHANGES: FileDiffInfo[] = [change('src/a.ts', '@@ -1,2 +1,2 @@\n-const a = 1;\n+const a = 2;')] + +const MODEL_REPLY = JSON.stringify({ + summary: 'A summary', + stops: [{ title: 'First', explanation: 'Why', hunkIds: ['f0h0'] }], +}) + +interface FakeSession { + info?: SessionInfo | Error + changes?: FileDiffInfo[] | Error +} + +function createFakeClient(sessions: Record) { + const generateCalls: string[] = [] + let reply = MODEL_REPLY + + const client = { + api: { + session: { + get: vi.fn(async ({ sessionID }: { sessionID: string }) => { + const config = sessions[sessionID] + if (!config) { + throw Object.assign(new Error('Session not found'), { _tag: 'SessionNotFoundError' }) + } + if (config.info instanceof Error) { + throw config.info + } + return config.info ?? ({ id: sessionID, title: `Title ${sessionID}` } as SessionInfo) + }), + diff: vi.fn(async ({ sessionID }: { sessionID: string }) => { + const config = sessions[sessionID] + if (config?.changes instanceof Error) { + throw config.changes + } + return config?.changes ?? [] + }), + }, + message: { + list: vi.fn(async ({ sessionID }: { sessionID: string }) => { + if (!sessions[sessionID]) { + return { data: [], cursor: {} } + } + return { data: [{ id: 'msg-1', type: 'user', time: { created: 0 }, text: 'x' }], cursor: {} } + }), + }, + generate: { + text: vi.fn(async (input: { prompt: string }) => { + generateCalls.push(input.prompt) + return { text: reply } + }), + }, + }, + forwardRaw: vi.fn(), + } as unknown as OpenCodeClient + + return { + client, + generateCalls, + setReply: (next: string) => { + reply = next + }, + } +} + +describe('change walkthrough routes', () => { + let db: Database + let sessions: Record + let fake: ReturnType + let app: Hono + + beforeEach(() => { + db = new Database(':memory:') + migrate(db, allMigrations) + sessions = { [SESSION_ID]: { changes: CHANGES } } + fake = createFakeClient(sessions) + const service = new ChangeWalkthroughService(db, fake.client) + app = new Hono() + app.route('/change-walkthroughs', createChangeWalkthroughRoutes(service)) + }) + + afterEach(() => { + db.close() + }) + + it('GET returns the state for a session', async () => { + const res = await app.request(`/change-walkthroughs/${SESSION_ID}`) + + expect(res.status).toBe(200) + const body = (await res.json()) as { walkthrough: unknown; currentDiffHash: string | null; stale: boolean } + expect(body.walkthrough).toBeNull() + expect(body.stale).toBe(false) + expect(body.currentDiffHash).toEqual(expect.any(String)) + }) + + it('GET returns 404 for a missing session', async () => { + const res = await app.request('/change-walkthroughs/ses_missing') + + expect(res.status).toBe(404) + await expect(res.json()).resolves.toMatchObject({ error: 'Session not found' }) + }) + + it('POST creates a walkthrough and GET reads it back', async () => { + const postRes = await app.request(`/change-walkthroughs/${SESSION_ID}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }) + + expect(postRes.status).toBe(201) + const created = (await postRes.json()) as { walkthrough: { summary: string; sessionId: string } } + expect(created.walkthrough.summary).toBe('A summary') + + const getRes = await app.request(`/change-walkthroughs/${SESSION_ID}`) + const state = (await getRes.json()) as { walkthrough: { summary: string } } + expect(state.walkthrough.summary).toBe('A summary') + }) + + it('POST accepts an empty body', async () => { + const res = await app.request(`/change-walkthroughs/${SESSION_ID}`, { method: 'POST' }) + + expect(res.status).toBe(201) + }) + + it('POST returns 200 without a second model call when changes are unchanged', async () => { + await app.request(`/change-walkthroughs/${SESSION_ID}`, { method: 'POST' }) + const second = await app.request(`/change-walkthroughs/${SESSION_ID}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }) + + expect(second.status).toBe(200) + expect(fake.generateCalls).toHaveLength(1) + }) + + it('POST regenerates when asked', async () => { + await app.request(`/change-walkthroughs/${SESSION_ID}`, { method: 'POST' }) + const res = await app.request(`/change-walkthroughs/${SESSION_ID}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ regenerate: true }), + }) + + expect(res.status).toBe(201) + expect(fake.generateCalls).toHaveLength(2) + }) + + it('POST rejects an invalid body with 400', async () => { + const res = await app.request(`/change-walkthroughs/${SESSION_ID}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ regenerate: 'yes' }), + }) + + expect(res.status).toBe(400) + }) + + it('POST rejects malformed JSON with 400', async () => { + const res = await app.request(`/change-walkthroughs/${SESSION_ID}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{not json', + }) + + expect(res.status).toBe(400) + }) + + it('POST returns 409 with a code when there are no text changes', async () => { + sessions[SESSION_ID]!.changes = [ + change('assets/logo.png', 'diff --git a/assets/logo.png b/assets/logo.png\nBinary files differ'), + ] + + const res = await app.request(`/change-walkthroughs/${SESSION_ID}`, { method: 'POST' }) + + expect(res.status).toBe(409) + await expect(res.json()).resolves.toMatchObject({ code: 'WALKTHROUGH_NO_TEXT_CHANGES' }) + }) + + it('POST returns 502 with a code when the model response is unparseable', async () => { + fake.setReply('not json') + + const res = await app.request(`/change-walkthroughs/${SESSION_ID}`, { method: 'POST' }) + + expect(res.status).toBe(502) + await expect(res.json()).resolves.toMatchObject({ code: 'WALKTHROUGH_UNPARSEABLE' }) + }) + + it('POST returns 404 for a missing session', async () => { + const res = await app.request('/change-walkthroughs/ses_missing', { method: 'POST' }) + + expect(res.status).toBe(404) + }) +}) diff --git a/backend/test/routes/mcp-oauth-proxy.test.ts b/backend/test/routes/mcp-oauth-proxy.test.ts index df7c2dc1d..d53fcf92c 100644 --- a/backend/test/routes/mcp-oauth-proxy.test.ts +++ b/backend/test/routes/mcp-oauth-proxy.test.ts @@ -1079,7 +1079,7 @@ describe.skipIf(!openCodeBinary)('mcp oauth proxy routes against a real OpenCode const callbackRes = await app.request( `/callback?code=auth-code&state=${state}&iss=${encodeURIComponent('https://other.example.com')}`, ) - expect(callbackRes.status).toBe(200) + expect([200, 400]).toContain(callbackRes.status) const statusDeadline = Date.now() + 15000 let status = 'unknown' diff --git a/backend/test/routes/multi-runs.test.ts b/backend/test/routes/multi-runs.test.ts index edb10ca01..dcbb3c98c 100644 --- a/backend/test/routes/multi-runs.test.ts +++ b/backend/test/routes/multi-runs.test.ts @@ -14,6 +14,7 @@ function createRun(overrides: Partial = {}): MultiRun { baseRef: null, createdAt: 1, entries: [], + fusions: [], ...overrides, } } @@ -29,16 +30,27 @@ function launchBody(overrides: Record = {}): string { }) } +function fuseBody(overrides: Record = {}): string { + return JSON.stringify({ + requestId: '11111111-1111-4111-8111-111111111111', + entryIds: [1, 2], + model: 'openai/a', + isolate: true, + ...overrides, + }) +} + describe('multi-run routes', () => { let service: { list: ReturnType launch: ReturnType discard: ReturnType + fuse: ReturnType } let app: Hono beforeEach(() => { - service = { list: vi.fn(), launch: vi.fn(), discard: vi.fn() } + service = { list: vi.fn(), launch: vi.fn(), discard: vi.fn(), fuse: vi.fn() } app = new Hono() app.route('/multi-runs', createMultiRunRoutes(service as unknown as MultiRunService)) }) @@ -147,4 +159,89 @@ describe('multi-run routes', () => { expect(res.status).toBe(404) }) + + it('POST fusions fuses a run and returns 201 when created', async () => { + service.fuse.mockResolvedValue({ run: createRun(), created: true }) + + const res = await app.request('/multi-runs/3/fusions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: fuseBody(), + }) + + expect(res.status).toBe(201) + await expect(res.json()).resolves.toEqual({ run: createRun() }) + expect(service.fuse).toHaveBeenCalledWith(3, { + requestId: '11111111-1111-4111-8111-111111111111', + entryIds: [1, 2], + model: 'openai/a', + isolate: true, + }) + }) + + it('POST fusions returns 200 for an idempotent replay', async () => { + service.fuse.mockResolvedValue({ run: createRun(), created: false }) + + const res = await app.request('/multi-runs/3/fusions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: fuseBody(), + }) + + expect(res.status).toBe(200) + await expect(res.json()).resolves.toEqual({ run: createRun() }) + }) + + it('POST fusions serialises an unavailable-sources error with code and details', async () => { + service.fuse.mockRejectedValue( + new MultiRunError('Some selected results are not ready to fuse', 409, { + code: 'FUSION_SOURCES_UNAVAILABLE', + details: { + unavailableSources: [ + { entryId: 2, model: 'openai/b', reason: 'running', message: 'The session is still running.' }, + ], + }, + }), + ) + + const res = await app.request('/multi-runs/3/fusions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: fuseBody(), + }) + + expect(res.status).toBe(409) + await expect(res.json()).resolves.toEqual({ + error: 'Some selected results are not ready to fuse', + code: 'FUSION_SOURCES_UNAVAILABLE', + details: { + unavailableSources: [ + { entryId: 2, model: 'openai/b', reason: 'running', message: 'The session is still running.' }, + ], + }, + }) + }) + + it('POST fusions maps a missing run to 404', async () => { + service.fuse.mockRejectedValue(new MultiRunError('Multi-run not found', 404)) + + const res = await app.request('/multi-runs/3/fusions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: fuseBody(), + }) + + expect(res.status).toBe(404) + }) + + it('POST fusions rejects a body with fewer than two entries with 400', async () => { + const res = await app.request('/multi-runs/3/fusions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: fuseBody({ entryIds: [1] }), + }) + + expect(res.status).toBe(400) + expect(service.fuse).not.toHaveBeenCalled() + }) }) diff --git a/backend/test/scripts/install.test.ts b/backend/test/scripts/install.test.ts index a89c0e2e2..6c7739e38 100644 --- a/backend/test/scripts/install.test.ts +++ b/backend/test/scripts/install.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest' import { spawn, spawnSync } from 'child_process' -import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs' +import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs' import { join } from 'path' import { tmpdir } from 'os' import { repoRoot } from '../helpers/repo-root' @@ -54,13 +54,16 @@ afterEach(() => { rmSync(workDir, { recursive: true, force: true }) }) -const runInstaller = (env: Record = {}) => +const runInstaller = ( + env: Record = {}, + path = `${stubDir}:/usr/bin:/bin`, +) => new Promise<{ status: number | null; stdout: string; stderr: string }>((resolve, reject) => { - const child = spawn('sh', [installerPath], { + const child = spawn('/bin/sh', [installerPath], { detached: true, stdio: ['ignore', 'pipe', 'pipe'], env: { - PATH: `${stubDir}:/usr/bin:/bin`, + PATH: path, HOME: workDir, OCM_DIR: installDir, OCM_STUB_LOG: logPath, @@ -157,9 +160,11 @@ describe('install.sh', () => { }) it('stops before downloading anything when Docker is missing', async () => { - rmSync(join(stubDir, 'docker')) + const isolatedBin = join(workDir, 'isolated-bin') + mkdirSync(isolatedBin) + copyFileSync(join(stubDir, 'curl'), join(isolatedBin, 'curl')) - const res = await runInstaller() + const res = await runInstaller({}, isolatedBin) expect(res.status).toBe(1) expect(res.stderr).toContain('Docker is required') diff --git a/backend/test/services/change-walkthroughs.test.ts b/backend/test/services/change-walkthroughs.test.ts new file mode 100644 index 000000000..5f394da76 --- /dev/null +++ b/backend/test/services/change-walkthroughs.test.ts @@ -0,0 +1,575 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { Database } from 'bun:sqlite' +import type { FileDiffInfo, SessionInfo, SessionMessageInfo } from '@opencode-manager/shared/opencode' +import { + WALKTHROUGH_DIFF_MAX_CHARS, + WALKTHROUGH_HUNK_MAX_CHARS, + WALKTHROUGH_MAX_STOPS, + WALKTHROUGH_TEXT_MAX_CHARS, + type WalkthroughHunk, +} from '@opencode-manager/shared/schemas' +import { getChangeWalkthrough } from '../../src/db/change-walkthroughs' +import { migrate } from '../../src/db/migration-runner' +import { allMigrations } from '../../src/db/migrations' +import type { OpenCodeClient } from '../../src/services/opencode/client' +import type { SSEEvent } from '../../src/services/sse-aggregator' +import { + ChangeWalkthroughError, + ChangeWalkthroughService, + buildWalkthroughInput, + buildWalkthroughPrompt, + computeChangesHash, + parseWalkthroughResponse, +} from '../../src/services/change-walkthroughs' + +const SESSION_ID = 'ses_walkthrough' + +function userMessage(id: string): SessionMessageInfo { + return { id, type: 'user', time: { created: 0 }, text: id } as SessionMessageInfo +} + +function change(file: string, patch: string, status: FileDiffInfo['status'] = 'modified'): FileDiffInfo { + return { file, patch, additions: 1, deletions: 1, status } +} + +function hunkPatch(line: number): string { + return `@@ -${line},2 +${line},2 @@\n-const a${line} = 1;\n+const a${line} = 2;` +} + +function bigHunk(index: number, size = WALKTHROUGH_HUNK_MAX_CHARS): string { + return `@@ -${index},1 +${index},1 @@\n+${'x'.repeat(size)}` +} + +function modelReply( + stops: Array<{ title: string; explanation: string; hunkIds: string[] }>, + summary = 'Summary', +): string { + return JSON.stringify({ summary, stops }) +} + +interface FakeSession { + info?: SessionInfo | Error + changes?: FileDiffInfo[] | Error +} + +function createFakeClient(sessions: Record) { + const generateCalls: string[] = [] + let generateImpl: () => Promise = async () => modelReply([]) + + const client = { + api: { + session: { + get: vi.fn(async ({ sessionID }: { sessionID: string }) => { + const config = sessions[sessionID] + if (!config) { + throw Object.assign(new Error('Session not found'), { _tag: 'SessionNotFoundError' }) + } + if (config.info instanceof Error) { + throw config.info + } + return config.info ?? ({ id: sessionID, title: `Title ${sessionID}` } as SessionInfo) + }), + diff: vi.fn(async ({ sessionID }: { sessionID: string }) => { + const config = sessions[sessionID] + if (config?.changes instanceof Error) { + throw config.changes + } + return config?.changes ?? [] + }), + }, + message: { + list: vi.fn(async ({ sessionID }: { sessionID: string }) => { + if (!sessions[sessionID]) { + return { data: [], cursor: {} } + } + return { data: [userMessage('msg-1')], cursor: {} } + }), + }, + generate: { + text: vi.fn(async (input: { prompt: string }) => { + generateCalls.push(input.prompt) + return { text: await generateImpl() } + }), + }, + }, + forwardRaw: vi.fn(), + } as unknown as OpenCodeClient + + return { + client, + generateCalls, + setGenerateImpl: (impl: () => Promise) => { + generateImpl = impl + }, + } +} + +function createTestDb(): Database { + const db = new Database(':memory:') + migrate(db, allMigrations) + return db +} + +function sessionEvent(type: string, sessionID: string): SSEEvent { + return { + id: `evt_${type}_${sessionID}`, + created: Date.now(), + type, + location: { directory: '/abs/repo' }, + data: { sessionID }, + } as unknown as SSEEvent +} + +describe('computeChangesHash', () => { + it('is stable for identical changes', () => { + const changes = [change('src/a.ts', hunkPatch(1))] + expect(computeChangesHash(changes)).toBe(computeChangesHash(changes)) + }) + + it('changes when any field changes', () => { + const base = change('src/a.ts', hunkPatch(1)) + expect(computeChangesHash([base])).not.toBe(computeChangesHash([change('src/a.ts', hunkPatch(2))])) + expect(computeChangesHash([base])).not.toBe(computeChangesHash([change('src/b.ts', hunkPatch(1))])) + expect(computeChangesHash([base])).not.toBe( + computeChangesHash([change('src/a.ts', hunkPatch(1), 'added')]), + ) + }) + + it('is order sensitive', () => { + const a = change('src/a.ts', hunkPatch(1)) + const b = change('src/b.ts', hunkPatch(1)) + expect(computeChangesHash([a, b])).not.toBe(computeChangesHash([b, a])) + }) +}) + +describe('buildWalkthroughInput', () => { + it('assigns file and hunk ordered ids', () => { + const { hunks, omittedFiles } = buildWalkthroughInput( + [ + change('src/a.ts', `${hunkPatch(1)}\n${hunkPatch(10)}`), + change('src/b.ts', hunkPatch(1)), + ], + 'My session', + ) + + expect(omittedFiles).toEqual([]) + expect(hunks.map((hunk) => hunk.id)).toEqual(['f0h0', 'f0h1', 'f1h0']) + expect(hunks.map((hunk) => hunk.file)).toEqual(['src/a.ts', 'src/a.ts', 'src/b.ts']) + }) + + it('omits files with no hunks as binary', () => { + const { hunks, omittedFiles } = buildWalkthroughInput( + [ + change('assets/logo.png', 'diff --git a/assets/logo.png b/assets/logo.png\nBinary files differ'), + change('src/a.ts', hunkPatch(1)), + ], + 'My session', + ) + + expect(hunks).toHaveLength(1) + expect(omittedFiles).toEqual([{ file: 'assets/logo.png', reason: 'binary' }]) + }) + + it('truncates an oversized hunk and marks it', () => { + const longLine = `+${'x'.repeat(WALKTHROUGH_HUNK_MAX_CHARS + 100)}` + const { hunks } = buildWalkthroughInput([change('src/a.ts', `@@ -1 +1 @@\n${longLine}`)], 'My session') + + expect(hunks[0]!.truncated).toBe(true) + expect(hunks[0]!.text).toContain('[hunk truncated]') + }) + + it('bounds the included hunks and omits the remaining files as budget', () => { + const changes = Array.from({ length: 10 }, (_, index) => change(`src/f${index}.ts`, bigHunk(index))) + const { hunks, omittedFiles } = buildWalkthroughInput(changes, 'My session') + + const included = hunks.reduce((sum, hunk) => sum + hunk.text.length, 0) + expect(included).toBeLessThanOrEqual(WALKTHROUGH_DIFF_MAX_CHARS) + expect(omittedFiles.filter((file) => file.reason === 'budget').length).toBeGreaterThan(0) + expect(omittedFiles.every((file) => file.reason === 'budget')).toBe(true) + }) + + it('never half-includes a file', () => { + const patch = Array.from({ length: 9 }, (_, index) => bigHunk(index)).join('\n') + const { hunks, omittedFiles } = buildWalkthroughInput([change('src/a.ts', patch)], 'My session') + + expect(hunks).toEqual([]) + expect(omittedFiles).toEqual([{ file: 'src/a.ts', reason: 'budget' }]) + }) + + it('keeps the prompt within budget for many small hunks behind a very long path', () => { + const longPath = `src/${'p'.repeat(996)}.ts` + const manyHunks = Array.from( + { length: 300 }, + (_, index) => `@@ -${index + 1} +${index + 1} @@\n+line${index}`, + ).join('\n') + const changes = [change('src/small.ts', hunkPatch(1)), change(longPath, manyHunks)] + + const { hunks, omittedFiles } = buildWalkthroughInput(changes, 'My session') + const prompt = buildWalkthroughPrompt({ title: 'My session', hunks }) + + expect(hunks.length).toBeGreaterThan(0) + expect(prompt.length).toBeLessThanOrEqual(WALKTHROUGH_DIFF_MAX_CHARS) + expect(omittedFiles).toEqual([{ file: longPath, reason: 'budget' }]) + }) + + it('reserves the title in the budget so a long title admits less content', () => { + const changes = Array.from({ length: 20 }, (_, index) => change(`src/f${index}.ts`, bigHunk(index))) + const longTitle = 'T'.repeat(20_000) + + const withLongTitle = buildWalkthroughInput(changes, longTitle) + const withShortTitle = buildWalkthroughInput(changes, 'short') + + expect(withLongTitle.hunks.length).toBeLessThan(withShortTitle.hunks.length) + expect( + buildWalkthroughPrompt({ title: longTitle, hunks: withLongTitle.hunks }).length, + ).toBeLessThanOrEqual(WALKTHROUGH_DIFF_MAX_CHARS) + }) +}) + +describe('buildWalkthroughPrompt', () => { + const hunks: WalkthroughHunk[] = [ + { + id: 'f0h0', + file: 'src/a.ts', + status: 'modified', + header: '@@ -1,2 +1,2 @@', + text: hunkPatch(1), + truncated: false, + }, + ] + + it('lists every hunk with its id, file and status in a diff block', () => { + const prompt = buildWalkthroughPrompt({ title: 'My session', hunks }) + + expect(prompt).toContain('### f0h0 src/a.ts (modified)') + expect(prompt).toContain('```diff') + expect(prompt).toContain(hunkPatch(1)) + expect(prompt).toContain('My session') + }) + + it('states the stop limit and the response shape', () => { + const prompt = buildWalkthroughPrompt({ title: 'My session', hunks }) + + expect(prompt).toContain(`at most ${WALKTHROUGH_MAX_STOPS} stops`) + expect(prompt).toContain('"stops"') + }) +}) + +describe('parseWalkthroughResponse', () => { + const hunks: WalkthroughHunk[] = ['f0h0', 'f0h1', 'f1h0'].map((id, index) => ({ + id, + file: index === 2 ? 'src/b.ts' : 'src/a.ts', + status: 'modified', + header: '@@ -1,2 +1,2 @@', + text: hunkPatch(index + 1), + truncated: false, + })) + + it('drops unknown ids and repeat references, first occurrence wins', () => { + const parsed = parseWalkthroughResponse( + modelReply([ + { title: 'A', explanation: 'first', hunkIds: ['f0h0', 'nope'] }, + { title: 'B', explanation: 'second', hunkIds: ['f0h0', 'f0h1'] }, + ]), + hunks, + ) + + expect(parsed?.stops.map((stop) => stop.hunkIds)).toEqual([['f0h0'], ['f0h1'], ['f1h0']]) + }) + + it('drops stops left empty', () => { + const parsed = parseWalkthroughResponse( + modelReply([ + { title: 'A', explanation: 'first', hunkIds: ['nope'] }, + { title: 'B', explanation: 'second', hunkIds: ['f0h0', 'f0h1', 'f1h0'] }, + ]), + hunks, + ) + + expect(parsed?.stops.map((stop) => stop.title)).toEqual(['B']) + }) + + it('appends unreferenced hunks as a final remaining stop', () => { + const parsed = parseWalkthroughResponse( + modelReply([{ title: 'A', explanation: 'first', hunkIds: ['f0h0'] }]), + hunks, + ) + + expect(parsed?.stops.at(-1)).toEqual({ + title: 'Remaining changes', + explanation: 'These changes were not covered by the generated walkthrough.', + hunkIds: ['f0h1', 'f1h0'], + }) + }) + + it('does not append a remaining stop when every hunk is covered', () => { + const parsed = parseWalkthroughResponse( + modelReply([{ title: 'A', explanation: 'first', hunkIds: ['f0h0', 'f0h1', 'f1h0'] }]), + hunks, + ) + + expect(parsed?.stops).toHaveLength(1) + expect(parsed?.stops[0]!.title).toBe('A') + }) + + it('caps model stops and text lengths', () => { + const manyHunks: WalkthroughHunk[] = Array.from( + { length: WALKTHROUGH_MAX_STOPS + 5 }, + (_, index) => ({ + id: `f0h${index}`, + file: 'src/a.ts', + status: 'modified', + header: '@@ -1 +1 @@', + text: hunkPatch(index), + truncated: false, + }), + ) + const stops = manyHunks.map((hunk, index) => ({ + title: `Stop ${index}`, + explanation: 'e'.repeat(WALKTHROUGH_TEXT_MAX_CHARS + 50), + hunkIds: [hunk.id], + })) + const parsed = parseWalkthroughResponse( + modelReply(stops, 's'.repeat(WALKTHROUGH_TEXT_MAX_CHARS + 50)), + manyHunks, + ) + + const modelStops = parsed!.stops.filter((stop) => stop.title !== 'Remaining changes') + expect(modelStops).toHaveLength(WALKTHROUGH_MAX_STOPS) + expect(parsed!.summary.length).toBeLessThanOrEqual(WALKTHROUGH_TEXT_MAX_CHARS) + expect(modelStops[0]!.explanation.length).toBeLessThanOrEqual(WALKTHROUGH_TEXT_MAX_CHARS) + }) + + it('returns null when the response is unparseable', () => { + expect(parseWalkthroughResponse('not json at all', hunks)).toBeNull() + expect(parseWalkthroughResponse('{not json', hunks)).toBeNull() + expect(parseWalkthroughResponse('{"summary":"x"}', hunks)).toBeNull() + }) + + it('returns null when no model stop survives', () => { + expect(parseWalkthroughResponse(modelReply([{ title: 'A', explanation: 'x', hunkIds: ['nope'] }]), hunks)).toBeNull() + }) +}) + +describe('ChangeWalkthroughService', () => { + let db: Database + let sessions: Record + let fake: ReturnType + let service: ChangeWalkthroughService + + const threeHunks: FileDiffInfo[] = [ + change('src/a.ts', `${hunkPatch(1)}\n${hunkPatch(10)}`), + change('src/b.ts', hunkPatch(1)), + ] + + beforeEach(() => { + db = createTestDb() + sessions = { [SESSION_ID]: { changes: threeHunks } } + fake = createFakeClient(sessions) + service = new ChangeWalkthroughService(db, fake.client) + }) + + afterEach(() => { + db.close() + }) + + it('throws 404 for a missing session on GET', async () => { + await expect(service.getState('ses_missing')).rejects.toMatchObject({ status: 404 }) + }) + + it('reports no walkthrough and a current hash on GET', async () => { + const state = await service.getState(SESSION_ID) + + expect(state.walkthrough).toBeNull() + expect(state.stale).toBe(false) + expect(state.currentDiffHash).toBe(computeChangesHash(threeHunks)) + }) + + it('reports stale when the changes differ from the stored hash', async () => { + fake.setGenerateImpl(async () => modelReply([{ title: 'A', explanation: 'x', hunkIds: ['f0h0', 'f0h1', 'f1h0'] }])) + await service.generate(SESSION_ID, {}) + + sessions[SESSION_ID]!.changes = [change('src/a.ts', hunkPatch(99))] + + const state = await service.getState(SESSION_ID) + expect(state.stale).toBe(true) + expect(state.currentDiffHash).toBe(computeChangesHash([change('src/a.ts', hunkPatch(99))])) + }) + + it('reports a null current hash when changes cannot be read', async () => { + sessions[SESSION_ID]!.changes = new Error('diff failed') + + const state = await service.getState(SESSION_ID) + expect(state.currentDiffHash).toBeNull() + expect(state.stale).toBe(false) + }) + + it('stores stops covering every hunk exactly once', async () => { + fake.setGenerateImpl(async () => modelReply([{ title: 'A', explanation: 'x', hunkIds: ['f0h0', 'f1h0'] }])) + + const { walkthrough, created } = await service.generate(SESSION_ID, {}) + + expect(created).toBe(true) + const ids = walkthrough.stops.flatMap((stop) => stop.hunkIds) + expect(new Set(ids).size).toBe(ids.length) + expect(new Set(ids)).toEqual(new Set(['f0h0', 'f0h1', 'f1h0'])) + expect(walkthrough.stops.at(-1)!.title).toBe('Remaining changes') + }) + + it('returns the stored walkthrough without a model call when changes are unchanged', async () => { + fake.setGenerateImpl(async () => modelReply([{ title: 'A', explanation: 'x', hunkIds: ['f0h0', 'f0h1', 'f1h0'] }])) + const first = await service.generate(SESSION_ID, {}) + + const second = await service.generate(SESSION_ID, {}) + + expect(second.created).toBe(false) + expect(second.walkthrough).toEqual(first.walkthrough) + expect(fake.generateCalls).toHaveLength(1) + }) + + it('calls the model again when regenerate is set', async () => { + fake.setGenerateImpl(async () => modelReply([{ title: 'A', explanation: 'x', hunkIds: ['f0h0', 'f0h1', 'f1h0'] }])) + await service.generate(SESSION_ID, {}) + + await service.generate(SESSION_ID, { regenerate: true }) + + expect(fake.generateCalls).toHaveLength(2) + }) + + it('coalesces concurrent generation into one model call', async () => { + let resolveGenerate: (text: string) => void = () => {} + const gate = new Promise((resolve) => { + resolveGenerate = resolve + }) + fake.setGenerateImpl(() => gate) + + const first = service.generate(SESSION_ID, {}) + const second = service.generate(SESSION_ID, {}) + resolveGenerate(modelReply([{ title: 'A', explanation: 'x', hunkIds: ['f0h0', 'f0h1', 'f1h0'] }])) + + const [firstResult, secondResult] = await Promise.all([first, second]) + expect(firstResult).toBe(secondResult) + expect(fake.generateCalls).toHaveLength(1) + }) + + it('rejects with 409 when the session has no changes', async () => { + sessions[SESSION_ID]!.changes = [] + + await expect(service.generate(SESSION_ID, {})).rejects.toMatchObject({ + status: 409, + code: 'WALKTHROUGH_NO_CHANGES', + }) + }) + + it('rejects with 409 when every change is binary', async () => { + sessions[SESSION_ID]!.changes = [ + change('assets/logo.png', 'diff --git a/assets/logo.png b/assets/logo.png\nBinary files differ'), + ] + + await expect(service.generate(SESSION_ID, {})).rejects.toMatchObject({ + status: 409, + code: 'WALKTHROUGH_NO_TEXT_CHANGES', + }) + }) + + it('rejects with 413 and the omitted files when a text file exceeds the context budget', async () => { + const file = 'src/huge.ts' + const patch = Array.from({ length: 10 }, (_, index) => bigHunk(index)).join('\n') + sessions[SESSION_ID]!.changes = [change(file, patch)] + + await expect(service.generate(SESSION_ID, {})).rejects.toMatchObject({ + status: 413, + code: 'WALKTHROUGH_CONTEXT_LIMIT', + details: { omittedFiles: [{ file, reason: 'budget' }] }, + }) + expect(fake.generateCalls).toHaveLength(0) + }) + + it('rejects with 502 when the model response is unparseable', async () => { + fake.setGenerateImpl(async () => 'not json') + + await expect(service.generate(SESSION_ID, {})).rejects.toMatchObject({ + status: 502, + code: 'WALKTHROUGH_UNPARSEABLE', + }) + }) + + it('rejects with 504 when generation times out', async () => { + const timingOut = new ChangeWalkthroughService(db, fake.client, { timeoutMs: 5 }) + fake.setGenerateImpl(() => new Promise(() => {})) + + await expect(timingOut.generate(SESSION_ID, {})).rejects.toMatchObject({ + status: 504, + code: 'WALKTHROUGH_TIMEOUT', + }) + }) + + it('rejects with 502 when the model call fails', async () => { + fake.setGenerateImpl(async () => { + throw new Error('model unavailable') + }) + + await expect(service.generate(SESSION_ID, {})).rejects.toMatchObject({ status: 502 }) + }) + + it('rejects with 502 when reading changes fails', async () => { + sessions[SESSION_ID]!.changes = new Error('diff failed') + + await expect(service.generate(SESSION_ID, {})).rejects.toMatchObject({ + status: 502, + code: 'WALKTHROUGH_CHANGES_UNAVAILABLE', + }) + }) + + it('rejects with 404 when generating for a missing session', async () => { + await expect(service.generate('ses_missing', {})).rejects.toBeInstanceOf(ChangeWalkthroughError) + await expect(service.generate('ses_missing', {})).rejects.toMatchObject({ status: 404 }) + }) + + describe('handleEvent', () => { + const coveringReply = modelReply([{ title: 'A', explanation: 'x', hunkIds: ['f0h0', 'f0h1', 'f1h0'] }]) + + it('deletes the stored walkthrough when its session is deleted', async () => { + fake.setGenerateImpl(async () => coveringReply) + await service.generate(SESSION_ID, {}) + + service.handleEvent(sessionEvent('session.deleted', SESSION_ID)) + + expect(getChangeWalkthrough(db, SESSION_ID)).toBeNull() + }) + + it('keeps the stored walkthrough for other session events', async () => { + fake.setGenerateImpl(async () => coveringReply) + await service.generate(SESSION_ID, {}) + + service.handleEvent(sessionEvent('session.execution.succeeded', SESSION_ID)) + service.handleEvent(sessionEvent('session.deleted', 'ses_other')) + + expect(getChangeWalkthrough(db, SESSION_ID)).not.toBeNull() + }) + + it('does not store a walkthrough whose session was deleted during generation', async () => { + let resolveGenerate: (text: string) => void = () => {} + fake.setGenerateImpl(() => new Promise((resolve) => { + resolveGenerate = resolve + })) + + const pending = service.generate(SESSION_ID, {}) + await vi.waitFor(() => expect(fake.generateCalls).toHaveLength(1)) + service.handleEvent(sessionEvent('session.deleted', SESSION_ID)) + resolveGenerate(coveringReply) + + await expect(pending).rejects.toMatchObject({ status: 404 }) + expect(getChangeWalkthrough(db, SESSION_ID)).toBeNull() + }) + + it('stores walkthroughs again after an earlier deletion of the same session id', async () => { + service.handleEvent(sessionEvent('session.deleted', SESSION_ID)) + fake.setGenerateImpl(async () => coveringReply) + + await service.generate(SESSION_ID, {}) + + expect(getChangeWalkthrough(db, SESSION_ID)).not.toBeNull() + }) + }) +}) diff --git a/backend/test/services/multi-run-fusion.test.ts b/backend/test/services/multi-run-fusion.test.ts new file mode 100644 index 000000000..d65eb49b2 --- /dev/null +++ b/backend/test/services/multi-run-fusion.test.ts @@ -0,0 +1,555 @@ +import { describe, expect, it, vi } from 'vitest' +import type { + FileDiffInfo, + SessionInfo, + SessionMessageInfo, +} from '@opencode-manager/shared/opencode' +import { FUSION_PROMPT_MAX_LENGTH } from '@opencode-manager/shared/schemas' +import type { MultiRunEntryRecord } from '../../src/db/multi-runs' +import type { OpenCodeClient } from '../../src/services/opencode/client' +import { + FUSION_MIN_SOURCE_CONTEXT, + FusionContextLimitError, + buildFusionPrompt, + collectFusionSources, + type CollectedFusionSource, +} from '../../src/services/multi-run-fusion' +import { SESSION_REPLY_TRUNCATION_MARKER } from '../../src/services/session-reply' +import { assistantMessage } from '../helpers/stub-schedule-api' + +function entry(overrides: Partial = {}): MultiRunEntryRecord { + return { + id: 1, + model: 'openai/gpt-5', + status: 'started', + sessionId: 'ses-1', + directory: '/worktrees/run-1', + isolated: true, + error: null, + createdAt: 0, + updatedAt: 0, + ...overrides, + } +} + +function sessionInfo(overrides: Partial = {}): SessionInfo { + return { id: 'ses-1', outcome: 'succeeded', ...overrides } as SessionInfo +} + +function userMessage(id: string): SessionMessageInfo { + return { id, type: 'user', time: { created: 0 }, text: id } as SessionMessageInfo +} + +interface FakeSession { + info?: SessionInfo | Error + busy?: boolean + activeError?: Error + messages?: SessionMessageInfo[] | Error + firstUser?: SessionMessageInfo + lastUser?: SessionMessageInfo + diff?: FileDiffInfo[] | Error +} + +function createFakeClient(sessions: Record) { + const sessionGet = vi.fn(async ({ sessionID }: { sessionID: string }) => { + const config = sessions[sessionID] + if (config?.info instanceof Error) { + throw config.info + } + return config?.info ?? sessionInfo({ id: sessionID }) + }) + + const sessionActive = vi.fn(async () => { + const active: Record = {} + for (const [id, config] of Object.entries(sessions)) { + if (config.activeError) { + throw config.activeError + } + if (config.busy) { + active[id] = { type: 'running' } + } + } + return active + }) + + const messageList = vi.fn(async (input: { sessionID: string; type?: string; order?: 'asc' | 'desc' }) => { + const config = sessions[input.sessionID] + if (input.type === 'user') { + const message = input.order === 'asc' ? config?.firstUser : config?.lastUser + return { data: message ? [message] : [], cursor: {} } + } + if (config?.messages instanceof Error) { + throw config.messages + } + return { data: config?.messages ?? [], cursor: {} } + }) + + const sessionDiff = vi.fn(async ({ sessionID }: { sessionID: string }) => { + const config = sessions[sessionID] + if (config?.diff instanceof Error) { + throw config.diff + } + return config?.diff ?? [] + }) + + const sessionPrompt = vi.fn(async () => ({})) + const sessionCreate = vi.fn(async () => ({})) + const sessionInterrupt = vi.fn(async () => ({})) + + const client = { + api: { + session: { + get: sessionGet, + active: sessionActive, + diff: sessionDiff, + prompt: sessionPrompt, + create: sessionCreate, + interrupt: sessionInterrupt, + }, + message: { list: messageList }, + }, + forwardRaw: vi.fn(), + } as unknown as OpenCodeClient + + return { client, sessionGet, sessionActive, messageList, sessionDiff, sessionPrompt, sessionCreate, sessionInterrupt } +} + +function notFoundError(): Error { + return Object.assign(new Error('Session not found'), { _tag: 'SessionNotFoundError' }) +} + +function fusionSource(overrides: Partial = {}): CollectedFusionSource { + return { + entryId: 1, + model: 'openai/gpt-5', + sessionId: 'ses-1', + directory: '/worktrees/run-1', + outcome: 'succeeded', + replyText: 'Final reply', + changes: [], + ...overrides, + } +} + +function fileDiff(file: string, patch: string, overrides: Partial = {}): FileDiffInfo { + return { file, patch, additions: 1, deletions: 1, status: 'modified', ...overrides } +} + +describe('collectFusionSources', () => { + it('returns a completed idle session in ready with its reply, outcome and changes', async () => { + const changes: FileDiffInfo[] = [ + { file: 'src/a.ts', patch: '@@ -1 +1 @@', additions: 1, deletions: 1, status: 'modified' }, + ] + const { client, sessionDiff } = createFakeClient({ + 'ses-1': { + info: sessionInfo({ id: 'ses-1', outcome: 'succeeded' }), + messages: [assistantMessage('Final answer', { completed: true })], + firstUser: userMessage('msg-first'), + lastUser: userMessage('msg-last'), + diff: changes, + }, + }) + + const result = await collectFusionSources(client, [entry()]) + + expect(result.unavailable).toEqual([]) + expect(result.ready).toEqual([ + { + entryId: 1, + model: 'openai/gpt-5', + sessionId: 'ses-1', + directory: '/worktrees/run-1', + outcome: 'succeeded', + replyText: 'Final answer', + changes, + }, + ]) + expect(sessionDiff).toHaveBeenCalledWith({ + sessionID: 'ses-1', + from: 'msg-first', + to: 'msg-last', + context: 3, + }) + }) + + it('reports a null outcome for a session that never settled', async () => { + const { client } = createFakeClient({ + 'ses-1': { + info: sessionInfo({ id: 'ses-1', outcome: undefined }), + messages: [assistantMessage('Done', { completed: true })], + diff: [], + }, + }) + + const result = await collectFusionSources(client, [entry()]) + + expect(result.ready[0]?.outcome).toBeNull() + }) + + it('classifies entries that have not started as not-started without reading them', async () => { + const { client, sessionGet } = createFakeClient({}) + + const result = await collectFusionSources(client, [ + entry({ id: 1, status: 'failed', sessionId: null }), + entry({ id: 2, status: 'discarded', sessionId: 'ses-2' }), + ]) + + expect(result.ready).toEqual([]) + expect(result.unavailable.map((source) => source.reason)).toEqual(['not-started', 'not-started']) + expect(result.unavailable.map((source) => source.entryId)).toEqual([1, 2]) + expect(sessionGet).not.toHaveBeenCalled() + }) + + it('classifies a deleted session as missing', async () => { + const { client } = createFakeClient({ 'ses-1': { info: notFoundError() } }) + + const result = await collectFusionSources(client, [entry()]) + + expect(result.ready).toEqual([]) + expect(result.unavailable).toEqual([ + { entryId: 1, model: 'openai/gpt-5', reason: 'missing', message: expect.any(String) }, + ]) + }) + + it('classifies a busy session as running without reading its reply', async () => { + const { client, messageList } = createFakeClient({ + 'ses-1': { + info: sessionInfo({ id: 'ses-1' }), + busy: true, + messages: [assistantMessage('Still going', { completed: true })], + }, + }) + + const result = await collectFusionSources(client, [entry()]) + + expect(result.unavailable).toEqual([ + { entryId: 1, model: 'openai/gpt-5', reason: 'running', message: expect.any(String) }, + ]) + expect(messageList).not.toHaveBeenCalled() + }) + + it('classifies a session without an assistant reply as incomplete', async () => { + const { client } = createFakeClient({ 'ses-1': { info: sessionInfo({ id: 'ses-1' }), messages: [] } }) + + const result = await collectFusionSources(client, [entry()]) + + expect(result.unavailable).toEqual([ + { entryId: 1, model: 'openai/gpt-5', reason: 'incomplete', message: expect.any(String) }, + ]) + }) + + it('classifies an unfinished assistant reply as incomplete', async () => { + const { client } = createFakeClient({ + 'ses-1': { + info: sessionInfo({ id: 'ses-1' }), + messages: [assistantMessage('Partial output', { completed: false })], + }, + }) + + const result = await collectFusionSources(client, [entry()]) + + expect(result.unavailable).toEqual([ + { entryId: 1, model: 'openai/gpt-5', reason: 'incomplete', message: expect.any(String) }, + ]) + }) + + it('classifies a failed session as failed and includes the error text', async () => { + const { client } = createFakeClient({ + 'ses-1': { + info: sessionInfo({ id: 'ses-1', outcome: 'failed' }), + messages: [assistantMessage('', { completed: true, error: 'Provider exploded' })], + }, + }) + + const result = await collectFusionSources(client, [entry()]) + + expect(result.unavailable).toEqual([ + { + entryId: 1, + model: 'openai/gpt-5', + reason: 'failed', + message: expect.stringContaining('Provider exploded'), + }, + ]) + }) + + it('classifies an interrupted session as failed', async () => { + const { client } = createFakeClient({ + 'ses-1': { + info: sessionInfo({ id: 'ses-1', outcome: 'interrupted' }), + messages: [assistantMessage('Stopped early', { completed: true })], + }, + }) + + const result = await collectFusionSources(client, [entry()]) + + expect(result.unavailable).toEqual([ + { + entryId: 1, + model: 'openai/gpt-5', + reason: 'failed', + message: expect.stringContaining('interrupted'), + }, + ]) + }) + + it('classifies a session read error as unavailable with the error message', async () => { + const { client } = createFakeClient({ 'ses-1': { info: new Error('connection reset') } }) + + const result = await collectFusionSources(client, [entry()]) + + expect(result.unavailable).toEqual([ + { entryId: 1, model: 'openai/gpt-5', reason: 'unavailable', message: 'connection reset' }, + ]) + }) + + it('classifies a busy-check error as unavailable', async () => { + const { client } = createFakeClient({ + 'ses-1': { info: sessionInfo({ id: 'ses-1' }), activeError: new Error('active boom') }, + }) + + const result = await collectFusionSources(client, [entry()]) + + expect(result.unavailable).toEqual([ + { entryId: 1, model: 'openai/gpt-5', reason: 'unavailable', message: 'active boom' }, + ]) + }) + + it('keeps a source ready with changes null when the diff read fails', async () => { + const { client } = createFakeClient({ + 'ses-1': { + info: sessionInfo({ id: 'ses-1' }), + messages: [assistantMessage('Done', { completed: true })], + firstUser: userMessage('msg-first'), + lastUser: userMessage('msg-last'), + diff: new Error('turn range invalid'), + }, + }) + + const result = await collectFusionSources(client, [entry()]) + + expect(result.unavailable).toEqual([]) + expect(result.ready).toEqual([ + expect.objectContaining({ entryId: 1, replyText: 'Done', changes: null }), + ]) + }) + + it('collects every entry concurrently and splits ready from unavailable', async () => { + const { client } = createFakeClient({ + 'ses-ready': { + info: sessionInfo({ id: 'ses-ready' }), + messages: [assistantMessage('Ready reply', { completed: true })], + diff: [], + }, + 'ses-busy': { info: sessionInfo({ id: 'ses-busy' }), busy: true }, + }) + + const result = await collectFusionSources(client, [ + entry({ id: 1, model: 'openai/a', sessionId: 'ses-ready' }), + entry({ id: 2, model: 'openai/b', sessionId: 'ses-busy' }), + entry({ id: 3, model: 'openai/c', sessionId: null, status: 'starting' }), + ]) + + expect(result.ready.map((source) => source.entryId)).toEqual([1]) + expect(result.unavailable.map((source) => source.entryId)).toEqual([2, 3]) + expect(result.unavailable.map((source) => source.reason)).toEqual(['running', 'not-started']) + }) + + it('reads the active-session map once for multiple started sources', async () => { + const { client, sessionActive } = createFakeClient({ + 'ses-a': { + info: sessionInfo({ id: 'ses-a' }), + messages: [assistantMessage('Reply A', { completed: true })], + diff: [], + }, + 'ses-b': { + info: sessionInfo({ id: 'ses-b' }), + messages: [assistantMessage('Reply B', { completed: true })], + diff: [], + }, + }) + + const result = await collectFusionSources(client, [ + entry({ id: 1, model: 'openai/a', sessionId: 'ses-a' }), + entry({ id: 2, model: 'openai/b', sessionId: 'ses-b' }), + ]) + + expect(result.ready.map((source) => source.entryId)).toEqual([1, 2]) + expect(sessionActive).toHaveBeenCalledTimes(1) + }) + + it('classifies every started source as unavailable when the active-session read fails', async () => { + const { client, sessionActive } = createFakeClient({ + 'ses-a': { info: sessionInfo({ id: 'ses-a' }), activeError: new Error('active boom') }, + 'ses-b': { info: sessionInfo({ id: 'ses-b' }), activeError: new Error('active boom') }, + }) + + const result = await collectFusionSources(client, [ + entry({ id: 1, model: 'openai/a', sessionId: 'ses-a' }), + entry({ id: 2, model: 'openai/b', sessionId: 'ses-b' }), + ]) + + expect(result.ready).toEqual([]) + expect(result.unavailable).toEqual([ + { entryId: 1, model: 'openai/a', reason: 'unavailable', message: 'active boom' }, + { entryId: 2, model: 'openai/b', reason: 'unavailable', message: 'active boom' }, + ]) + expect(sessionActive).toHaveBeenCalledTimes(1) + }) + + it('only calls read endpoints and never writes to source sessions', async () => { + const { client, sessionPrompt, sessionCreate, sessionInterrupt } = createFakeClient({ + 'ses-1': { + info: sessionInfo({ id: 'ses-1' }), + messages: [assistantMessage('Done', { completed: true })], + firstUser: userMessage('msg-first'), + lastUser: userMessage('msg-last'), + diff: [], + }, + }) + + await collectFusionSources(client, [entry()]) + + expect(sessionPrompt).not.toHaveBeenCalled() + expect(sessionCreate).not.toHaveBeenCalled() + expect(sessionInterrupt).not.toHaveBeenCalled() + }) +}) + +describe('buildFusionPrompt', () => { + it('includes the objective, instructions and every source with untruncated content', () => { + const result = buildFusionPrompt({ + runName: 'Refactor auth', + objective: 'Refactor the authentication flow.', + instructions: 'Prefer the smallest possible change.', + sources: [ + fusionSource({ + entryId: 1, + model: 'openai/a', + sessionId: 'ses-a', + replyText: 'Reply from A', + changes: [fileDiff('src/a.ts', '@@ -1 +1 @@\n-old\n+new')], + }), + fusionSource({ + entryId: 2, + model: 'anthropic/b', + sessionId: 'ses-b', + replyText: 'Reply from B', + changes: [fileDiff('src/b.ts', '@@ -2 +2 @@\n-x\n+y')], + }), + ], + }) + + expect(result.prompt).toContain('Refactor the authentication flow.') + expect(result.prompt).toContain('Prefer the smallest possible change.') + expect(result.prompt).toContain('## Source 1 — openai/a') + expect(result.prompt).toContain('## Source 2 — anthropic/b') + expect(result.prompt).toContain('ses-a') + expect(result.prompt).toContain('ses-b') + expect(result.prompt).toContain('Reply from A') + expect(result.prompt).toContain('Reply from B') + expect(result.prompt).toContain('@@ -1 +1 @@') + expect(result.prompt).toContain('@@ -2 +2 @@') + expect(result.prompt).toContain('src/a.ts (modified, +1/-1)') + expect(result.prompt).not.toContain(SESSION_REPLY_TRUNCATION_MARKER) + expect(result.prompt.length).toBeLessThanOrEqual(FUSION_PROMPT_MAX_LENGTH) + expect(result.sources).toEqual([ + { entryId: 1, sessionId: 'ses-a', model: 'openai/a', truncated: false }, + { entryId: 2, sessionId: 'ses-b', model: 'anthropic/b', truncated: false }, + ]) + }) + + it('omits the additional instructions section when none are given', () => { + const result = buildFusionPrompt({ + runName: 'Run', + objective: 'Objective.', + sources: [ + fusionSource({ entryId: 1 }), + fusionSource({ entryId: 2, model: 'openai/b', sessionId: 'ses-b' }), + ], + }) + + expect(result.prompt).not.toContain('## Additional instructions') + expect(result.prompt).toContain('## Rules') + }) + + it('truncates oversized replies and diffs, flags the source and stays within the limit', () => { + const result = buildFusionPrompt({ + runName: 'Large', + objective: 'Synthesize the results.', + sources: [ + fusionSource({ + entryId: 1, + model: 'openai/a', + sessionId: 'ses-a', + replyText: 'r'.repeat(100_000), + changes: [], + }), + fusionSource({ + entryId: 2, + model: 'openai/b', + sessionId: 'ses-b', + replyText: 'short reply', + changes: [fileDiff('src/b.ts', `@@ -1 +1 @@\n${'p'.repeat(200_000)}`)], + }), + fusionSource({ entryId: 3, model: 'openai/c', sessionId: 'ses-c', replyText: 'small reply', changes: [] }), + ], + }) + + expect(result.sources).toEqual([ + { entryId: 1, sessionId: 'ses-a', model: 'openai/a', truncated: true }, + { entryId: 2, sessionId: 'ses-b', model: 'openai/b', truncated: true }, + { entryId: 3, sessionId: 'ses-c', model: 'openai/c', truncated: false }, + ]) + expect(result.prompt).toContain(SESSION_REPLY_TRUNCATION_MARKER) + expect(result.prompt.length).toBeLessThanOrEqual(FUSION_PROMPT_MAX_LENGTH) + }) + + it('renders a changes-could-not-be-read line when the diff is unavailable', () => { + const result = buildFusionPrompt({ + runName: 'Run', + objective: 'Objective.', + sources: [ + fusionSource({ entryId: 1, changes: null }), + fusionSource({ entryId: 2, model: 'openai/b', sessionId: 'ses-b' }), + ], + }) + + expect(result.prompt).toContain('Changes could not be read.') + expect(result.sources[0]?.truncated).toBe(false) + }) + + it('returns only the preamble when there are no sources', () => { + const result = buildFusionPrompt({ runName: 'Run', objective: 'Objective.', sources: [] }) + + expect(result.sources).toEqual([]) + expect(result.prompt).toContain('## Original objective') + expect(result.prompt).not.toContain('## Source') + }) + + it('throws FusionContextLimitError when the fixed sections leave too little room per source', () => { + let error: unknown + try { + buildFusionPrompt({ + runName: 'Oversized', + objective: 'o'.repeat(55_000), + instructions: 'i'.repeat(4_000), + sources: [ + fusionSource({ entryId: 1 }), + fusionSource({ entryId: 2, model: 'openai/b', sessionId: 'ses-b' }), + ], + }) + } catch (caught) { + error = caught + } + + expect(error).toBeInstanceOf(FusionContextLimitError) + const limitError = error as FusionContextLimitError + expect(limitError.status).toBe(413) + expect(limitError.code).toBe('FUSION_CONTEXT_LIMIT') + expect(limitError.details).toEqual({ + requiredPerSource: FUSION_MIN_SOURCE_CONTEXT, + availablePerSource: expect.any(Number), + }) + }) +}) diff --git a/backend/test/services/multi-runs.test.ts b/backend/test/services/multi-runs.test.ts index eee0f9cc8..0dd6dddb0 100644 --- a/backend/test/services/multi-runs.test.ts +++ b/backend/test/services/multi-runs.test.ts @@ -4,11 +4,15 @@ 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 { FusionContextLimitError } from '../../src/services/multi-run-fusion' import { MultiRunError, MultiRunService } from '../../src/services/multi-runs' import { RepoWorkspaceError } from '../../src/services/repo' import type { RepoWorkspaceService } from '../../src/services/repo-workspace' import type { Repo } from '../../src/types/repo' import type { OpenCodeClient } from '../../src/services/opencode/client' +import { assistantMessage } from '../helpers/stub-schedule-api' + +const FUSE_REQUEST_ID = '11111111-1111-4111-8111-111111111111' const REPO_DIR = '/repos/repo-a' @@ -38,13 +42,29 @@ vi.mock('../../src/utils/process', () => ({ executeCommand: mocks.executeCommand, })) +interface FakeSessionConfig { + busy?: boolean + missing?: boolean + reply?: string + outcome?: 'succeeded' | 'failed' | 'interrupted' + hasUserMessage?: boolean +} + interface FakeMultiRunClient { client: OpenCodeClient sessionCreate: ReturnType sessionPrompt: ReturnType + sessionGet: ReturnType + sessionActive: ReturnType + messageList: ReturnType + sessionDiff: ReturnType +} + +function notFoundError(): Error { + return Object.assign(new Error('Session not found'), { _tag: 'SessionNotFoundError' }) } -function createClient(): FakeMultiRunClient { +function createClient(sessions: Record = {}): FakeMultiRunClient { let sessionCounter = 0 const sessionCreate = vi.fn(async (input: { title?: string }) => { @@ -53,13 +73,56 @@ function createClient(): FakeMultiRunClient { }) const sessionPrompt = vi.fn(async () => ({})) + const sessionGet = vi.fn(async ({ sessionID }: { sessionID: string }) => { + const config = sessions[sessionID] + if (config?.missing) { + throw notFoundError() + } + return { id: sessionID, outcome: config?.outcome ?? 'succeeded' } + }) + + const sessionActive = vi.fn(async () => { + const active: Record = {} + for (const [id, config] of Object.entries(sessions)) { + if (config.busy) { + active[id] = { type: 'running' } + } + } + return active + }) + + const messageList = vi.fn(async (input: { sessionID: string; type?: string; order?: 'asc' | 'desc' }) => { + if (input.type === 'user') { + if (sessions[input.sessionID]?.hasUserMessage === false) { + return { data: [], cursor: {} } + } + return { + data: [{ id: `${input.sessionID}-user-${input.order}`, type: 'user', time: { created: 0 }, text: 'hi' }], + cursor: {}, + } + } + return { + data: [assistantMessage(sessions[input.sessionID]?.reply ?? 'Fusion source reply', { completed: true })], + cursor: {}, + } + }) + + const sessionDiff = vi.fn(async () => []) + const client = { api: { - session: { create: sessionCreate, prompt: sessionPrompt }, + session: { + create: sessionCreate, + prompt: sessionPrompt, + get: sessionGet, + active: sessionActive, + diff: sessionDiff, + }, + message: { list: messageList }, }, } as unknown as OpenCodeClient - return { client, sessionCreate, sessionPrompt } + return { client, sessionCreate, sessionPrompt, sessionGet, sessionActive, messageList, sessionDiff } } interface FakeRepoWorkspaces { @@ -405,6 +468,7 @@ describe('MultiRunService', () => { expect(entry.status).toBe('failed') expect(entry.directory).toBe('/worktrees/Sweep-1') expect(entry.error).toContain('prompt boom') + expect(entry.sessionId).toBe('ses_1') const discarded = await service.discard(run.id, entry.id) @@ -516,6 +580,420 @@ describe('MultiRunService', () => { expect(runs.map((run) => run.name)).toEqual(['Second', 'First']) }) + it('fuses completed entries into one new session and records the sources', async () => { + const repoId = readyRepo() + const { client, sessionCreate, sessionPrompt } = createClient() + const repoWorkspaces = createRepoWorkspaces() + const service = createService(client, repoWorkspaces.service) + + const run = await service.launch({ + repoId, + name: 'Sweep', + prompt: 'do the thing', + models: ['openai/a', 'openai/b'], + isolate: true, + }) + const entryIds = run.entries.map((entry) => entry.id) + const sessionIds = run.entries.map((entry) => entry.sessionId) + const before = run.entries.map((entry) => ({ + status: entry.status, + sessionId: entry.sessionId, + directory: entry.directory, + })) + sessionCreate.mockClear() + sessionPrompt.mockClear() + + const { run: fused, created } = await service.fuse(run.id, { + requestId: FUSE_REQUEST_ID, + entryIds, + model: 'openai/c', + isolate: true, + baseRef: 'develop', + }) + + expect(created).toBe(true) + const fusion = fused.fusions[0]! + expect(fusion.status).toBe('started') + expect(fusion.model).toBe('openai/c') + expect(fusion.sessionId).toBeTruthy() + expect(fusion.directory).toBe('/worktrees/Sweep-fusion-1') + expect(fusion.sources.map((source) => source.entryId)).toEqual(entryIds) + expect(fusion.sources.map((source) => source.sessionId)).toEqual(sessionIds) + expect(fusion.baseRef).toBe('develop') + + const fusionWorkspaceCall = repoWorkspaces.create.mock.calls.find( + (call) => call[1].name === 'Sweep-fusion-1', + ) + expect(fusionWorkspaceCall?.[1].ref).toBe('develop') + + const fusionPrompt = sessionPrompt.mock.calls.at(-1)![0].text + expect(fusionPrompt).toContain('do the thing') + expect(fusionPrompt).toContain('openai/a') + expect(fusionPrompt).toContain('openai/b') + expect(sessionCreate).toHaveBeenCalledTimes(1) + + expect( + fused.entries.map((entry) => ({ + status: entry.status, + sessionId: entry.sessionId, + directory: entry.directory, + })), + ).toEqual(before) + }) + + it('returns the same fusion for a repeated requestId and launches once', async () => { + const repoId = readyRepo() + const { client, sessionCreate } = createClient() + const repoWorkspaces = createRepoWorkspaces() + const service = createService(client, repoWorkspaces.service) + + const run = await service.launch({ + repoId, + name: 'Sweep', + prompt: 'go', + models: ['openai/a', 'openai/b'], + isolate: true, + }) + const entryIds = run.entries.map((entry) => entry.id) + sessionCreate.mockClear() + + const request = { requestId: FUSE_REQUEST_ID, entryIds, model: 'openai/c', isolate: false } + const first = await service.fuse(run.id, request) + const second = await service.fuse(run.id, request) + + expect(first.created).toBe(true) + expect(second.created).toBe(false) + expect(second.run.fusions).toHaveLength(1) + expect(second.run.fusions[0]!.id).toBe(first.run.fusions[0]!.id) + expect(second.run.fusions[0]!.sessionId).toBe(first.run.fusions[0]!.sessionId) + expect(sessionCreate).toHaveBeenCalledTimes(1) + }) + + it('rejects fusion when a selected result is still running', async () => { + const repoId = readyRepo() + const { client, sessionCreate } = createClient({ ses_2: { busy: true } }) + const repoWorkspaces = createRepoWorkspaces() + const service = createService(client, repoWorkspaces.service) + + const run = await service.launch({ + repoId, + name: 'Sweep', + prompt: 'go', + models: ['openai/a', 'openai/b'], + isolate: true, + }) + const entryIds = run.entries.map((entry) => entry.id) + sessionCreate.mockClear() + + const error = await service + .fuse(run.id, { requestId: FUSE_REQUEST_ID, entryIds, model: 'openai/c', isolate: false }) + .catch((caught: unknown) => caught) + + expect(error).toBeInstanceOf(MultiRunError) + expect(error).toMatchObject({ status: 409, code: 'FUSION_SOURCES_UNAVAILABLE' }) + expect((error as MultiRunError).details).toEqual({ + unavailableSources: [expect.objectContaining({ reason: 'running' })], + }) + expect(sessionCreate).not.toHaveBeenCalled() + expect(service.list(repoId)[0]!.fusions).toEqual([]) + }) + + it('rejects fusion when the prompt exceeds the context limit', async () => { + const repoId = readyRepo() + const { client, sessionCreate } = createClient() + const repoWorkspaces = createRepoWorkspaces() + const service = createService(client, repoWorkspaces.service) + + const run = await service.launch({ + repoId, + name: 'Huge', + prompt: 'o'.repeat(60_000), + models: ['openai/a', 'openai/b'], + isolate: true, + }) + const entryIds = run.entries.map((entry) => entry.id) + sessionCreate.mockClear() + + const error = await service + .fuse(run.id, { requestId: FUSE_REQUEST_ID, entryIds, model: 'openai/c', isolate: false }) + .catch((caught: unknown) => caught) + + expect(error).toBeInstanceOf(FusionContextLimitError) + expect(error).toMatchObject({ status: 413, code: 'FUSION_CONTEXT_LIMIT' }) + expect(sessionCreate).not.toHaveBeenCalled() + expect(service.list(repoId)[0]!.fusions).toEqual([]) + }) + + it('rejects fusion with an unavailable synthesis model before creating a fusion', async () => { + const repoId = readyRepo() + const { client, sessionCreate } = createClient() + const repoWorkspaces = createRepoWorkspaces() + const service = createService(client, repoWorkspaces.service) + + const run = await service.launch({ + repoId, + name: 'Sweep', + prompt: 'go', + models: ['openai/a', 'openai/b'], + isolate: true, + }) + const entryIds = run.entries.map((entry) => entry.id) + sessionCreate.mockClear() + + const error = await service + .fuse(run.id, { requestId: FUSE_REQUEST_ID, entryIds, model: 'openai/retired', isolate: false }) + .catch((caught: unknown) => caught) + + expect(error).toBeInstanceOf(MultiRunError) + expect(error).toMatchObject({ status: 400 }) + expect((error as Error).message).toContain('not available') + expect(sessionCreate).not.toHaveBeenCalled() + expect(service.list(repoId)[0]!.fusions).toEqual([]) + }) + + it('records a failed fusion with the workspace directory when the launch fails', async () => { + const repoId = readyRepo() + const { client, sessionCreate } = createClient() + const repoWorkspaces = createRepoWorkspaces() + const service = createService(client, repoWorkspaces.service) + + const run = await service.launch({ + repoId, + name: 'Sweep', + prompt: 'go', + models: ['openai/a', 'openai/b'], + isolate: false, + }) + const entryIds = run.entries.map((entry) => entry.id) + sessionCreate.mockClear() + sessionCreate.mockRejectedValueOnce(new Error('fusion boom')) + + const { run: fused, created } = await service.fuse(run.id, { + requestId: FUSE_REQUEST_ID, + entryIds, + model: 'openai/c', + isolate: true, + }) + + expect(created).toBe(true) + const fusion = fused.fusions[0]! + expect(fusion.status).toBe('failed') + expect(fusion.error).toContain('fusion boom') + expect(fusion.directory).toBe('/worktrees/Sweep-fusion-1') + }) + + it('records the created session id on a failed fusion when the prompt is rejected', async () => { + const repoId = readyRepo() + const { client, sessionCreate, sessionPrompt } = createClient() + const repoWorkspaces = createRepoWorkspaces() + const service = createService(client, repoWorkspaces.service) + + const run = await service.launch({ + repoId, + name: 'Sweep', + prompt: 'go', + models: ['openai/a', 'openai/b'], + isolate: true, + }) + const entryIds = run.entries.map((entry) => entry.id) + sessionCreate.mockClear() + sessionPrompt.mockRejectedValueOnce(new Error('prompt boom')) + + const { run: fused, created } = await service.fuse(run.id, { + requestId: FUSE_REQUEST_ID, + entryIds, + model: 'openai/c', + isolate: false, + }) + + expect(created).toBe(true) + const fusion = fused.fusions[0]! + expect(fusion.status).toBe('failed') + expect(fusion.error).toContain('prompt boom') + expect(fusion.sessionId).toBe('ses_3') + expect(sessionCreate).toHaveBeenCalledTimes(1) + }) + + it('recovers an uncertain fusion when the session already admitted the prompt', async () => { + const repoId = readyRepo() + const { client, sessionCreate, sessionPrompt } = createClient() + const repoWorkspaces = createRepoWorkspaces() + const service = createService(client, repoWorkspaces.service) + + const run = await service.launch({ + repoId, + name: 'Sweep', + prompt: 'go', + models: ['openai/a', 'openai/b'], + isolate: true, + }) + const entryIds = run.entries.map((entry) => entry.id) + sessionPrompt.mockRejectedValueOnce(new Error('prompt boom')) + + const { run: firstFused } = await service.fuse(run.id, { + requestId: FUSE_REQUEST_ID, + entryIds, + model: 'openai/c', + isolate: false, + }) + const failedFusion = firstFused.fusions[0]! + expect(failedFusion.status).toBe('failed') + expect(failedFusion.sessionId).toBe('ses_3') + + sessionCreate.mockClear() + + const error = await service + .fuse(run.id, { + requestId: '22222222-2222-4222-8222-222222222222', + entryIds, + model: 'openai/c', + isolate: false, + }) + .catch((caught: unknown) => caught) + + expect(error).toBeInstanceOf(MultiRunError) + expect(error).toMatchObject({ status: 409, code: 'FUSION_ATTEMPT_RECOVERED' }) + expect((error as MultiRunError).details).toEqual({ + fusions: [{ fusionId: failedFusion.id, sessionId: 'ses_3' }], + }) + expect(sessionCreate).not.toHaveBeenCalled() + + const recovered = service.list(repoId)[0]!.fusions[0]! + expect(recovered.status).toBe('started') + expect(recovered.error).toBeNull() + }) + + it('proceeds with a new fusion when the failed attempt never admitted the prompt', async () => { + const repoId = readyRepo() + const { client, sessionCreate, sessionPrompt } = createClient({ ses_3: { hasUserMessage: false } }) + const repoWorkspaces = createRepoWorkspaces() + const service = createService(client, repoWorkspaces.service) + + const run = await service.launch({ + repoId, + name: 'Sweep', + prompt: 'go', + models: ['openai/a', 'openai/b'], + isolate: true, + }) + const entryIds = run.entries.map((entry) => entry.id) + sessionPrompt.mockRejectedValueOnce(new Error('prompt boom')) + + const { run: firstFused } = await service.fuse(run.id, { + requestId: FUSE_REQUEST_ID, + entryIds, + model: 'openai/c', + isolate: false, + }) + expect(firstFused.fusions[0]!.status).toBe('failed') + + sessionCreate.mockClear() + + const { run: fused, created } = await service.fuse(run.id, { + requestId: '33333333-3333-4333-8333-333333333333', + entryIds, + model: 'openai/c', + isolate: false, + }) + + expect(created).toBe(true) + expect(sessionCreate).toHaveBeenCalledTimes(1) + expect(fused.fusions.map((fusion) => fusion.status)).toEqual(['failed', 'started']) + }) + + it('rejects a non-isolated fusion when a selected result ran in the repository checkout', async () => { + const repoId = readyRepo() + const { client, sessionCreate } = createClient() + const repoWorkspaces = createRepoWorkspaces() + const service = createService(client, repoWorkspaces.service) + + const run = await service.launch({ + repoId, + name: 'Sweep', + prompt: 'go', + models: ['openai/a', 'openai/b'], + isolate: false, + }) + const entryIds = run.entries.map((entry) => entry.id) + sessionCreate.mockClear() + + const error = await service + .fuse(run.id, { + requestId: FUSE_REQUEST_ID, + entryIds, + model: 'openai/c', + isolate: false, + }) + .catch((caught: unknown) => caught) + + expect(error).toBeInstanceOf(MultiRunError) + expect(error).toMatchObject({ status: 409, code: 'FUSION_DESTINATION_OVERLAPS_SOURCE' }) + expect((error as MultiRunError).details).toEqual({ entryIds }) + expect(sessionCreate).not.toHaveBeenCalled() + expect(service.list(repoId)[0]!.fusions).toEqual([]) + }) + + it('allows an isolated fusion when the selected results ran in the repository checkout', async () => { + const repoId = readyRepo() + const { client, sessionCreate } = createClient() + const repoWorkspaces = createRepoWorkspaces() + const service = createService(client, repoWorkspaces.service) + + const run = await service.launch({ + repoId, + name: 'Sweep', + prompt: 'go', + models: ['openai/a', 'openai/b'], + isolate: false, + }) + const entryIds = run.entries.map((entry) => entry.id) + sessionCreate.mockClear() + + const { run: fused, created } = await service.fuse(run.id, { + requestId: FUSE_REQUEST_ID, + entryIds, + model: 'openai/c', + isolate: true, + }) + + expect(created).toBe(true) + expect(fused.fusions[0]!.status).toBe('started') + expect(fused.fusions[0]!.directory).toBe('/worktrees/Sweep-fusion-1') + expect(sessionCreate).toHaveBeenCalledTimes(1) + }) + + it('rejects fusion with an entry id that does not belong to the run', async () => { + const repoId = readyRepo() + const { client, sessionCreate } = createClient() + const repoWorkspaces = createRepoWorkspaces() + const service = createService(client, repoWorkspaces.service) + + const run = await service.launch({ + repoId, + name: 'Sweep', + prompt: 'go', + models: ['openai/a', 'openai/b'], + isolate: false, + }) + const entryIds = run.entries.map((entry) => entry.id) + sessionCreate.mockClear() + + const error = await service + .fuse(run.id, { + requestId: FUSE_REQUEST_ID, + entryIds: [entryIds[0]!, 9999], + model: 'openai/c', + isolate: false, + }) + .catch((caught: unknown) => caught) + + expect(error).toBeInstanceOf(MultiRunError) + expect(error).toMatchObject({ status: 400 }) + expect((error as Error).message).toBe('Unknown multi-run entry 9999') + expect(sessionCreate).not.toHaveBeenCalled() + expect(service.list(repoId)[0]!.fusions).toEqual([]) + }) + 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`) diff --git a/backend/test/services/opencode/generate-text.test.ts b/backend/test/services/opencode/generate-text.test.ts new file mode 100644 index 000000000..7fdafdf22 --- /dev/null +++ b/backend/test/services/opencode/generate-text.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, vi, afterEach } from 'vitest' +import type { ModelRef } from '@opencode-manager/shared/opencode' +import type { OpenCodeClient } from '../../../src/services/opencode/client' +import { GenerateTextTimeoutError, generateTextWithTimeout } from '../../../src/services/opencode/generate-text' + +interface GenerateCall { + input: { prompt: string; model?: ModelRef } + signal: AbortSignal | undefined +} + +function makeClient(impl: (call: GenerateCall) => Promise<{ text: string }>): { + client: OpenCodeClient + calls: GenerateCall[] +} { + const calls: GenerateCall[] = [] + const client = { + api: { + generate: { + text: async (input: GenerateCall['input'], options?: { signal?: AbortSignal }) => { + const call: GenerateCall = { input, signal: options?.signal } + calls.push(call) + return impl(call) + }, + }, + }, + } as unknown as OpenCodeClient + + return { client, calls } +} + +describe('generateTextWithTimeout', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('returns the generated text and passes the prompt with an abort signal', async () => { + const { client, calls } = makeClient(async () => ({ text: 'hello' })) + + await expect(generateTextWithTimeout(client, { prompt: 'write' }, 1000)).resolves.toBe('hello') + + expect(calls).toHaveLength(1) + expect(calls[0]?.input).toEqual({ prompt: 'write' }) + expect(calls[0]?.signal).toBeInstanceOf(AbortSignal) + }) + + it('forwards the model when provided', async () => { + const { client, calls } = makeClient(async () => ({ text: 'hello' })) + const model: ModelRef = { providerID: 'anthropic', id: 'claude-sonnet-4' } + + await generateTextWithTimeout(client, { prompt: 'write', model }, 1000) + + expect(calls[0]?.input).toEqual({ prompt: 'write', model }) + }) + + it('rejects with GenerateTextTimeoutError and aborts the request after the timeout', async () => { + vi.useFakeTimers() + const { client, calls } = makeClient(() => new Promise(() => {})) + + const promise = generateTextWithTimeout(client, { prompt: 'slow' }, 50) + const assertion = expect(promise).rejects.toBeInstanceOf(GenerateTextTimeoutError) + + await vi.advanceTimersByTimeAsync(50) + await assertion + + expect(calls[0]?.signal?.aborted).toBe(true) + }) + + it('propagates a client error without waiting for the timeout', async () => { + const { client } = makeClient(async () => { + throw new Error('model unavailable') + }) + + await expect(generateTextWithTimeout(client, { prompt: 'write' }, 1000)).rejects.toThrow('model unavailable') + }) + + it('clears the timeout once the client resolves', async () => { + vi.useFakeTimers() + const { client, calls } = makeClient(async () => ({ text: 'done' })) + + await expect(generateTextWithTimeout(client, { prompt: 'write' }, 50)).resolves.toBe('done') + + await vi.advanceTimersByTimeAsync(100) + + expect(calls[0]?.signal?.aborted).toBe(false) + }) +}) diff --git a/backend/test/services/session-changes.test.ts b/backend/test/services/session-changes.test.ts new file mode 100644 index 000000000..f027a8c01 --- /dev/null +++ b/backend/test/services/session-changes.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it, vi } from 'vitest' +import type { FileDiffInfo, SessionMessageInfo } from '@opencode-manager/shared/opencode' +import type { OpenCodeClient } from '../../src/services/opencode/client' +import { readSessionChanges } from '../../src/services/session-changes' + +function userMessage(id: string): SessionMessageInfo { + return { id, type: 'user', time: { created: 0 }, text: id } as SessionMessageInfo +} + +interface FakeClientOptions { + asc?: SessionMessageInfo[] + desc?: SessionMessageInfo[] + diff?: FileDiffInfo[] | Error +} + +function createFakeClient(options: FakeClientOptions = {}) { + const list = vi.fn(async (input: { order?: 'asc' | 'desc' }) => ({ + data: input.order === 'asc' ? (options.asc ?? []) : (options.desc ?? []), + cursor: {}, + })) + const diff = vi.fn(async () => { + if (options.diff instanceof Error) { + throw options.diff + } + return options.diff ?? [] + }) + + const client = { + api: { message: { list }, session: { diff } }, + forwardRaw: vi.fn(), + } as unknown as OpenCodeClient + + return { client, list, diff } +} + +describe('readSessionChanges', () => { + it('diffs from the first to the last user message with the context window', async () => { + const changes: FileDiffInfo[] = [ + { file: 'src/a.ts', patch: '@@ -1 +1 @@', additions: 1, deletions: 1, status: 'modified' }, + ] + const { client, diff } = createFakeClient({ + asc: [userMessage('msg-first')], + desc: [userMessage('msg-last')], + diff: changes, + }) + + await expect(readSessionChanges(client, 'ses-1')).resolves.toEqual(changes) + expect(diff).toHaveBeenCalledWith({ + sessionID: 'ses-1', + from: 'msg-first', + to: 'msg-last', + context: 3, + }) + }) + + it('looks up the first and last user messages with limit one in both orders', async () => { + const { client, list } = createFakeClient({ + asc: [userMessage('msg-first')], + desc: [userMessage('msg-last')], + }) + + await readSessionChanges(client, 'ses-1') + + expect(list).toHaveBeenCalledWith({ sessionID: 'ses-1', type: 'user', order: 'asc', limit: 1 }) + expect(list).toHaveBeenCalledWith({ sessionID: 'ses-1', type: 'user', order: 'desc', limit: 1 }) + }) + + it('returns an empty list without diffing when the session has no user messages', async () => { + const { client, diff } = createFakeClient({ asc: [], desc: [] }) + + await expect(readSessionChanges(client, 'ses-1')).resolves.toEqual([]) + expect(diff).not.toHaveBeenCalled() + }) + + it('returns an empty list without diffing when only the last lookup is empty', async () => { + const { client, diff } = createFakeClient({ asc: [userMessage('msg-first')], desc: [] }) + + await expect(readSessionChanges(client, 'ses-1')).resolves.toEqual([]) + expect(diff).not.toHaveBeenCalled() + }) + + it('propagates OpenCode diff errors unchanged', async () => { + const error = Object.assign(new Error('turn range invalid'), { _tag: 'TurnRangeError' }) + const { client } = createFakeClient({ + asc: [userMessage('msg-first')], + desc: [userMessage('msg-last')], + diff: error, + }) + + await expect(readSessionChanges(client, 'ses-1')).rejects.toBe(error) + }) +}) diff --git a/backend/test/services/session-launcher.test.ts b/backend/test/services/session-launcher.test.ts index bc7401c7b..40546fd61 100644 --- a/backend/test/services/session-launcher.test.ts +++ b/backend/test/services/session-launcher.test.ts @@ -23,12 +23,15 @@ interface FakeLaunchClient { prompt: ReturnType } -function createClient(overrides: { createError?: Error } = {}): FakeLaunchClient { +function createClient(overrides: { createError?: Error; promptError?: 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 prompt = vi.fn(async () => { + if (overrides.promptError) throw overrides.promptError + return {} + }) const client = { api: { @@ -206,10 +209,31 @@ describe('SessionLauncher', () => { status: 502, message: 'boom (workspace: /worktrees/feature-x)', workspaceDirectory: '/worktrees/feature-x', + sessionId: null, }) expect(createWorkspace).toHaveBeenCalled() }) + it('keeps the created session id when prompting the session fails', async () => { + const repoId = readyRepo() + const { client, create } = createClient({ promptError: new Error('prompt boom') }) + const { service } = createRepoWorkspaces({ directory: '/worktrees/feature-x' }) + const launcher = new SessionLauncher(db, client, service) + + const error = await launcher.launch({ repoId, prompt: 'hello', workspace: { name: 'feature-x' } }).catch( + (caught: unknown) => caught, + ) + + expect(create).toHaveBeenCalled() + expect(error).toBeInstanceOf(SessionLaunchError) + expect(error).toMatchObject({ + status: 502, + message: 'prompt boom (workspace: /worktrees/feature-x)', + workspaceDirectory: '/worktrees/feature-x', + sessionId: 'ses_new', + }) + }) + it('exposes no workspace when workspace creation fails', async () => { const repoId = readyRepo() const { client } = createClient() diff --git a/backend/test/services/session-reply.test.ts b/backend/test/services/session-reply.test.ts index 7b972a10d..66fd0f1c5 100644 --- a/backend/test/services/session-reply.test.ts +++ b/backend/test/services/session-reply.test.ts @@ -5,10 +5,12 @@ import { SESSION_REPLY_MAX_LENGTH, getLatestAssistantReplyState, isSessionBusy, + isSessionBusyIn, readLatestAssistantReply, sessionSettleSignal, truncateSessionReply, waitForSessionSettled, + type ActiveSessions, } from '../../src/services/session-reply' import { assistantMessage } from '../helpers/stub-schedule-api' @@ -114,6 +116,16 @@ describe('isSessionBusy', () => { }) }) +describe('isSessionBusyIn', () => { + it('is true when the session appears in the fetched active set', () => { + expect(isSessionBusyIn({ 'ses-1': { type: 'running' } } as unknown as ActiveSessions, 'ses-1')).toBe(true) + }) + + it('is false when the session is absent from the fetched active set', () => { + expect(isSessionBusyIn({} as unknown as ActiveSessions, 'ses-1')).toBe(false) + }) +}) + describe('truncateSessionReply', () => { it('returns short replies unchanged', () => { expect(truncateSessionReply('short reply')).toBe('short reply') diff --git a/backend/test/utils/json-extract.test.ts b/backend/test/utils/json-extract.test.ts new file mode 100644 index 000000000..fc9be96bb --- /dev/null +++ b/backend/test/utils/json-extract.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from 'vitest' +import { extractFirstJsonObject } from '../../src/utils/json-extract' + +describe('extractFirstJsonObject', () => { + it('returns a plain JSON object', () => { + expect(extractFirstJsonObject('{"a":1}')).toBe('{"a":1}') + }) + + it('returns the first object when several are present', () => { + expect(extractFirstJsonObject('prefix {"a":1} middle {"b":2} suffix')).toBe('{"a":1}') + }) + + it('ignores prose before and after the object', () => { + expect(extractFirstJsonObject('Here you go:\n{"a":1}\nThanks')).toBe('{"a":1}') + }) + + it('matches nested braces', () => { + expect(extractFirstJsonObject('{"a":{"b":{"c":1}},"d":2}')).toBe('{"a":{"b":{"c":1}},"d":2}') + }) + + it('ignores braces inside strings', () => { + expect(extractFirstJsonObject('{"a":"}{","b":1}')).toBe('{"a":"}{","b":1}') + }) + + it('handles escaped quotes inside strings', () => { + expect(extractFirstJsonObject('{"a":"a \\" b","c":2}')).toBe('{"a":"a \\" b","c":2}') + }) + + it('returns null when there is no object', () => { + expect(extractFirstJsonObject('no object here')).toBeNull() + }) + + it('returns null for an unterminated object', () => { + expect(extractFirstJsonObject('{"a":1')).toBeNull() + }) + + it('returns null when a string is left open', () => { + expect(extractFirstJsonObject('{"a":"unterminated}')).toBeNull() + }) +}) diff --git a/backend/test/utils/text-truncate.test.ts b/backend/test/utils/text-truncate.test.ts new file mode 100644 index 000000000..cbc590fa3 --- /dev/null +++ b/backend/test/utils/text-truncate.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +import { truncateText } from '../../src/utils/text-truncate' + +describe('truncateText', () => { + it('returns text unchanged when it is within the limit', () => { + expect(truncateText('hello', 10, '[cut]')).toEqual({ text: 'hello', truncated: false }) + }) + + it('returns text unchanged when it is exactly at the limit', () => { + expect(truncateText('hello', 5, '[cut]')).toEqual({ text: 'hello', truncated: false }) + }) + + it('slices to the limit, appends the marker and reports truncation', () => { + expect(truncateText('hello world', 5, '…')).toEqual({ text: 'hello…', truncated: true }) + }) + + it('supports a zero limit', () => { + expect(truncateText('hello', 0, '[cut]')).toEqual({ text: '[cut]', truncated: true }) + }) + + it('leaves empty text untouched', () => { + expect(truncateText('', 5, '[cut]')).toEqual({ text: '', truncated: false }) + }) +}) diff --git a/frontend/package.json b/frontend/package.json index ed03830fb..82e3223eb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -47,6 +47,7 @@ "react-router-dom": "^7.13.0", "rehype-highlight": "^7.0.2", "rehype-raw": "^7.0.0", + "rehype-sanitize": "^6.0.0", "remark-gfm": "^4.0.1", "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", diff --git a/frontend/src/api/changeWalkthroughs.ts b/frontend/src/api/changeWalkthroughs.ts new file mode 100644 index 000000000..d49e57dc6 --- /dev/null +++ b/frontend/src/api/changeWalkthroughs.ts @@ -0,0 +1,29 @@ +import { fetchWrapper } from './fetchWrapper' +import { API_BASE_URL } from '@/config' +import type { + ChangeWalkthrough, + ChangeWalkthroughState, + GenerateChangeWalkthroughRequest, +} from '@opencode-manager/shared/schemas' + +export async function getChangeWalkthrough(sessionId: string): Promise { + return fetchWrapper( + `${API_BASE_URL}/api/change-walkthroughs/${encodeURIComponent(sessionId)}`, + ) +} + +export async function generateChangeWalkthrough( + sessionId: string, + request: GenerateChangeWalkthroughRequest, +): Promise { + const res = await fetchWrapper<{ walkthrough: ChangeWalkthrough }>( + `${API_BASE_URL}/api/change-walkthroughs/${encodeURIComponent(sessionId)}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + timeout: 150_000, + }, + ) + return res.walkthrough +} diff --git a/frontend/src/api/multiRuns.ts b/frontend/src/api/multiRuns.ts index fb2f6784f..4c2aff15f 100644 --- a/frontend/src/api/multiRuns.ts +++ b/frontend/src/api/multiRuns.ts @@ -1,6 +1,6 @@ import { fetchWrapper } from './fetchWrapper' import { API_BASE_URL } from '@/config' -import type { LaunchMultiRunRequest, MultiRun } from '@opencode-manager/shared/schemas' +import type { FuseMultiRunRequest, 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`, { @@ -18,6 +18,18 @@ export async function launchMultiRun(request: LaunchMultiRunRequest): Promise { + const res = await fetchWrapper<{ run: MultiRun }>( + `${API_BASE_URL}/api/multi-runs/${encodeURIComponent(runId)}/fusions`, + { + 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`, diff --git a/frontend/src/components/file-browser/DiffLines.tsx b/frontend/src/components/file-browser/DiffLines.tsx new file mode 100644 index 000000000..1efe45928 --- /dev/null +++ b/frontend/src/components/file-browser/DiffLines.tsx @@ -0,0 +1,112 @@ +import { Plus, Minus } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { parseDiffLines, type DiffLine } from "@opencode-manager/shared/utils"; + +export function DiffLineRow({ + line, + showLineNumbers, + onLineClick, +}: { + line: DiffLine; + showLineNumbers: boolean; + onLineClick?: (lineNumber: number) => void; +}) { + if (line.type === "header") { + return ( +
+ {line.content} +
+ ); + } + + if (line.type === "hunk") { + return ( +
+ {line.content} +
+ ); + } + + const bgClass = + line.type === "add" + ? "bg-diff-add/10" + : line.type === "remove" + ? "bg-diff-delete/10" + : ""; + + const textClass = + line.type === "add" + ? "text-diff-add" + : line.type === "remove" + ? "text-diff-delete" + : "text-foreground"; + + const lineNumber = line.newLineNumber ?? line.oldLineNumber; + const isClickable = onLineClick && lineNumber !== undefined; + + return ( +
+ isClickable && lineNumber !== undefined && onLineClick(lineNumber) + } + > + {showLineNumbers && ( +
+ + {line.oldLineNumber || ""} + + + {line.newLineNumber || ""} + +
+ )} +
+ {line.type === "add" && ( + + )} + {line.type === "remove" && ( + + )} +
+
+        {line.content || " "}
+      
+
+ ); +} + +export function DiffLines({ + diff, + showLineNumbers = true, + onLineClick, +}: { + diff: string; + showLineNumbers?: boolean; + onLineClick?: (line: number) => void; +}) { + return ( + <> + {parseDiffLines(diff).map((line, index) => ( + + ))} + + ); +} diff --git a/frontend/src/components/file-browser/FileDiffView.tsx b/frontend/src/components/file-browser/FileDiffView.tsx index c4c0ac70a..c4193ba23 100644 --- a/frontend/src/components/file-browser/FileDiffView.tsx +++ b/frontend/src/components/file-browser/FileDiffView.tsx @@ -6,8 +6,6 @@ import { FileX, FileEdit, File, - Plus, - Minus, ArrowLeft, ExternalLink, X, @@ -17,6 +15,7 @@ import { CopyButton } from "@/components/ui/copy-button"; import { cn } from "@/lib/utils"; import type { GitFileStatusType } from "@/types/git"; import { GIT_STATUS_COLORS, GIT_STATUS_LABELS } from "@/lib/git-status-styles"; +import { DiffLines } from "./DiffLines"; interface FileDiffViewProps { repoId: number; @@ -29,63 +28,6 @@ interface FileDiffViewProps { isMobile?: boolean; } -interface DiffLine { - type: "add" | "remove" | "context" | "header" | "hunk"; - content: string; - oldLineNumber?: number; - newLineNumber?: number; -} - -function parseDiff(diff: string): DiffLine[] { - const lines = diff.split("\n"); - const result: DiffLine[] = []; - let oldLine = 0; - let newLine = 0; - - for (const line of lines) { - if ( - line.startsWith("diff --git") || - line.startsWith("index ") || - line.startsWith("---") || - line.startsWith("+++") - ) { - result.push({ type: "header", content: line }); - } else if (line.startsWith("@@")) { - const match = line.match(/@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/); - if (match) { - oldLine = parseInt(match[1], 10); - newLine = parseInt(match[2], 10); - } - result.push({ type: "hunk", content: line }); - } else if (line.startsWith("+")) { - result.push({ - type: "add", - content: line.substring(1), - newLineNumber: newLine, - }); - newLine++; - } else if (line.startsWith("-")) { - result.push({ - type: "remove", - content: line.substring(1), - oldLineNumber: oldLine, - }); - oldLine++; - } else if (line.startsWith(" ") || line === "") { - result.push({ - type: "context", - content: line.substring(1) || "", - oldLineNumber: oldLine, - newLineNumber: newLine, - }); - oldLine++; - newLine++; - } - } - - return result; -} - const statusConfig: Record< GitFileStatusType, { icon: typeof FileText; color: string; bgColor: string; label: string } @@ -128,92 +70,6 @@ const statusConfig: Record< }, }; -function DiffLineComponent({ - line, - showLineNumbers, - onLineClick, -}: { - line: DiffLine; - showLineNumbers: boolean; - onLineClick?: (lineNumber: number) => void; -}) { - if (line.type === "header") { - return ( -
- {line.content} -
- ); - } - - if (line.type === "hunk") { - return ( -
- {line.content} -
- ); - } - - const bgClass = - line.type === "add" - ? "bg-diff-add/10" - : line.type === "remove" - ? "bg-diff-delete/10" - : ""; - - const textClass = - line.type === "add" - ? "text-diff-add" - : line.type === "remove" - ? "text-diff-delete" - : "text-foreground"; - - const lineNumber = line.newLineNumber ?? line.oldLineNumber; - const isClickable = onLineClick && lineNumber !== undefined; - - return ( -
- isClickable && lineNumber !== undefined && onLineClick(lineNumber) - } - > - {showLineNumbers && ( -
- - {line.oldLineNumber || ""} - - - {line.newLineNumber || ""} - -
- )} -
- {line.type === "add" && ( - - )} - {line.type === "remove" && ( - - )} -
-
-        {line.content || " "}
-      
-
- ); -} - export function FileDiffView({ repoId, filePath, @@ -263,7 +119,6 @@ export function FileDiffView({ const config = statusConfig[diffData.status]; const Icon = config.icon; - const diffLines = diffData.diff ? parseDiff(diffData.diff) : []; return (
@@ -362,18 +217,15 @@ export function FileDiffView({
) : (
- {diffLines.map((line, index) => ( - onOpenFile(filePath, lineNum) - : undefined - } - /> - ))} + onOpenFile(filePath, lineNum) + : undefined + } + />
)} diff --git a/frontend/src/components/file-browser/MarkdownRenderer.test.tsx b/frontend/src/components/file-browser/MarkdownRenderer.test.tsx new file mode 100644 index 000000000..6f2443692 --- /dev/null +++ b/frontend/src/components/file-browser/MarkdownRenderer.test.tsx @@ -0,0 +1,30 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { MarkdownRenderer } from './MarkdownRenderer' + +describe('MarkdownRenderer', () => { + it('toggles a task list item in the source content', () => { + const onContentChange = vi.fn() + render() + + const [first, second] = screen.getAllByRole('checkbox') + expect(first).not.toBeChecked() + expect(first).toBeEnabled() + expect(second).toBeChecked() + + fireEvent.click(first!) + expect(onContentChange).toHaveBeenCalledWith('- [x] first\n- [x] second') + }) + + it('strips unsafe raw HTML from a repository file and keeps safe HTML', () => { + const { container } = render( + document.documentElement.dataset.fileProbe = 1\n\n

Logo

'} + />, + ) + + expect(container.querySelector('script')).toBeNull() + expect(document.documentElement.dataset.fileProbe).toBeUndefined() + expect(screen.getByAltText('Logo')).toHaveAttribute('src', 'docs/logo.png') + }) +}) diff --git a/frontend/src/components/file-browser/MarkdownRenderer.tsx b/frontend/src/components/file-browser/MarkdownRenderer.tsx index 35e589c7a..ccfc798b3 100644 --- a/frontend/src/components/file-browser/MarkdownRenderer.tsx +++ b/frontend/src/components/file-browser/MarkdownRenderer.tsx @@ -1,9 +1,8 @@ import { memo, useMemo, useCallback } from 'react' import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' -import rehypeHighlight from 'rehype-highlight' -import rehypeRaw from 'rehype-raw' import { markdownComponents } from './MarkdownComponents' +import { markdownRehypePlugins } from '@/lib/markdownRehypePlugins' import { MarkdownLink } from '@/components/ui/markdown-link' import { resolvePathFromFile } from '@/lib/markdownLinks' import type { Components } from 'react-markdown' @@ -95,7 +94,7 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({ content, classN
{content} diff --git a/frontend/src/components/message/MessagePart.test.tsx b/frontend/src/components/message/MessagePart.test.tsx index bbef9209d..f704cbc49 100644 --- a/frontend/src/components/message/MessagePart.test.tsx +++ b/frontend/src/components/message/MessagePart.test.tsx @@ -108,6 +108,21 @@ describe('MessagePart', () => { expect(external).toHaveAttribute('rel', 'noopener noreferrer') }) + it('strips unsafe raw HTML from a text part and keeps safe HTML', () => { + const { container } = renderWithProviders( + \n\n
NotesBody
', + )} + />, + ) + + expect(container.querySelector('iframe')).toBeNull() + expect(container.querySelector('script')).toBeNull() + expect(document.documentElement.dataset.chatProbe).toBeUndefined() + expect(container.querySelector('details summary')?.textContent).toBe('Notes') + }) + it('renders null for an empty text part', () => { const { container } = renderWithProviders() diff --git a/frontend/src/components/message/TextPart.tsx b/frontend/src/components/message/TextPart.tsx index 38d684bae..40d2e4364 100644 --- a/frontend/src/components/message/TextPart.tsx +++ b/frontend/src/components/message/TextPart.tsx @@ -1,13 +1,12 @@ import React, { useEffect, useState, useId, useCallback } from 'react' import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' -import rehypeHighlight from 'rehype-highlight' -import rehypeRaw from 'rehype-raw' import mermaid from 'mermaid' import { Maximize2, X, AlertCircle } from 'lucide-react' import { CopyButton } from '@/components/ui/copy-button' import { MarkdownLink } from '@/components/ui/markdown-link' import { useTheme } from '@/hooks/useTheme' +import { markdownRehypePlugins } from '@/lib/markdownRehypePlugins' interface TextPartProps { text: string @@ -195,7 +194,7 @@ export function TextPart({ text, onFileClick }: TextPartProps) {
({ + fuseMultiRun: vi.fn(), + listMultiRuns: vi.fn(), + launchMultiRun: vi.fn(), + discardMultiRunEntry: vi.fn(), + useProvidersWithModels: vi.fn(), + useOpenCodeModelState: vi.fn(), + listBranches: vi.fn(), +})) + +vi.mock('@/api/multiRuns', () => ({ + fuseMultiRun: mocks.fuseMultiRun, + listMultiRuns: mocks.listMultiRuns, + launchMultiRun: mocks.launchMultiRun, + discardMultiRunEntry: mocks.discardMultiRunEntry, +})) + +vi.mock('@/hooks/useProvidersWithModels', () => ({ + useProvidersWithModels: mocks.useProvidersWithModels, +})) + +vi.mock('@/hooks/useModelSelection', () => ({ + useOpenCodeModelState: mocks.useOpenCodeModelState, +})) + +vi.mock('@/api/repos', () => ({ + listBranches: mocks.listBranches, +})) + +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-4.1', name: 'GPT-4.1' }, + ], + }, + { + id: 'anthropic', + name: 'Anthropic', + source: 'configured', + isConnected: true, + models: [{ id: 'claude-opus', name: 'Claude Opus' }], + }, +] + +const run: 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: 'started', + sessionId: 'ses_2', + directory: '/workspaces/sweep-2', + isolated: true, + error: null, + createdAt: 1, + updatedAt: 1, + }, + { + id: 13, + model: 'openai/gpt-4.1', + status: 'failed', + sessionId: null, + directory: null, + isolated: true, + error: 'launch failed', + createdAt: 1, + updatedAt: 1, + }, + ], + fusions: [], +} + +const nonIsolatedRun: MultiRun = { + ...run, + entries: run.entries.map((entry) => (entry.id === 11 ? { ...entry, isolated: false } : entry)), +} + +function createWrapper() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + return ({ children }: { children: ReactNode }) => ( + {children} + ) +} + +function renderFuseDialog(overrides: Partial> = {}) { + const onOpenChange = vi.fn() + const onOpenSession = vi.fn() + const view = render( + , + { wrapper: createWrapper() }, + ) + return { ...view, onOpenChange, onOpenSession } +} + +async function selectSourcesAndModel(user: ReturnType) { + await user.click(screen.getByRole('checkbox', { name: 'openai/gpt-4o' })) + await user.click(screen.getByRole('checkbox', { name: 'anthropic/claude-opus' })) + await user.click(screen.getByRole('checkbox', { name: 'GPT-4o' })) +} + +describe('FuseRunDialog', () => { + beforeAll(() => { + Element.prototype.hasPointerCapture ??= () => false + Element.prototype.setPointerCapture ??= () => {} + Element.prototype.releasePointerCapture ??= () => {} + Element.prototype.scrollIntoView ??= () => {} + }) + + beforeEach(() => { + vi.clearAllMocks() + mocks.useProvidersWithModels.mockReturnValue({ data: providers, isLoading: false }) + mocks.useOpenCodeModelState.mockReturnValue({ data: { recent: [], favorite: [], variant: {} } }) + mocks.listBranches.mockResolvedValue({ + branches: [ + { name: 'main', type: 'local', current: true }, + { name: 'feature', type: 'local', current: false }, + ], + status: { ahead: 0, behind: 0 }, + }) + }) + + it('only allows started sources and blocks submit until two sources and a model are selected', async () => { + const user = userEvent.setup() + renderFuseDialog() + + const failed = screen.getByRole('checkbox', { name: 'openai/gpt-4.1' }) + expect(failed).toBeDisabled() + + const submit = screen.getByRole('button', { name: 'Fuse' }) + expect(submit).toBeDisabled() + + await user.click(screen.getByRole('checkbox', { name: 'openai/gpt-4o' })) + expect(submit).toBeDisabled() + + await user.click(screen.getByRole('checkbox', { name: 'anthropic/claude-opus' })) + expect(submit).toBeDisabled() + + await user.click(screen.getByRole('checkbox', { name: 'GPT-4o' })) + expect(submit).toBeEnabled() + }) + + it('replaces the synthesis model when a different model is toggled', async () => { + const user = userEvent.setup() + renderFuseDialog() + + await user.click(screen.getByRole('checkbox', { name: 'GPT-4o' })) + expect(screen.getByRole('checkbox', { name: 'GPT-4o' })).toBeChecked() + + await user.click(screen.getByRole('checkbox', { name: 'Claude Opus' })) + expect(screen.getByRole('checkbox', { name: 'GPT-4o' })).not.toBeChecked() + expect(screen.getByRole('checkbox', { name: 'Claude Opus' })).toBeChecked() + }) + + it('submits the selected sources, model, instructions and workspace', async () => { + const user = userEvent.setup() + mocks.fuseMultiRun.mockResolvedValue({ ...run, fusions: [] }) + renderFuseDialog() + + await selectSourcesAndModel(user) + await user.type(screen.getByLabelText('Instructions'), 'Merge the best ideas') + await user.click(screen.getByRole('button', { name: 'Fuse' })) + + await waitFor(() => { + expect(mocks.fuseMultiRun).toHaveBeenCalledTimes(1) + }) + const [runId, request] = mocks.fuseMultiRun.mock.calls[0] as [number, FuseMultiRunRequest] + expect(runId).toBe(3) + expect(request).toEqual({ + requestId: expect.stringMatching(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/), + entryIds: [11, 12], + model: 'openai/gpt-4o', + instructions: 'Merge the best ideas', + isolate: true, + baseRef: 'main', + }) + }) + + it('does not submit again while the request is pending', async () => { + const user = userEvent.setup() + mocks.fuseMultiRun.mockReturnValue(new Promise(() => {})) + renderFuseDialog() + + await selectSourcesAndModel(user) + const submit = screen.getByRole('button', { name: 'Fuse' }) + await user.click(submit) + await user.click(submit) + + expect(mocks.fuseMultiRun).toHaveBeenCalledTimes(1) + }) + + it('lists unavailable sources and reasons from a 409 response', async () => { + const user = userEvent.setup() + mocks.fuseMultiRun.mockRejectedValue( + new FetchError('Some selected results are not ready to fuse', 409, 'FUSION_SOURCES_UNAVAILABLE', undefined, { + details: { + unavailableSources: [ + { entryId: 12, model: 'anthropic/claude-opus', reason: 'running', message: 'The session is still running.' }, + ], + }, + }), + ) + renderFuseDialog() + + await selectSourcesAndModel(user) + await user.click(screen.getByRole('button', { name: 'Fuse' })) + + expect(await screen.findByText('anthropic/claude-opus: The session is still running.')).toBeInTheDocument() + }) + + it('shows the context limit message for a 413 response', async () => { + const user = userEvent.setup() + mocks.fuseMultiRun.mockRejectedValue( + new FetchError('The selected results are too large to fuse.', 413, 'FUSION_CONTEXT_LIMIT'), + ) + renderFuseDialog() + + await selectSourcesAndModel(user) + await user.click(screen.getByRole('button', { name: 'Fuse' })) + + expect( + await screen.findByText('Too much context: select fewer sources or shorten instructions'), + ).toBeInTheDocument() + }) + + it('closes and opens the new session when the fusion starts', async () => { + const user = userEvent.setup() + mocks.fuseMultiRun.mockImplementation((_runId: number, request: FuseMultiRunRequest) => + Promise.resolve({ + ...run, + fusions: [ + { + id: 1, + requestId: request.requestId, + model: request.model, + instructions: null, + isolated: true, + baseRef: 'main', + status: 'started', + sessionId: 'ses_fusion', + directory: '/workspaces/fusion', + error: null, + sources: [], + createdAt: 1, + updatedAt: 1, + }, + ], + }), + ) + const { onOpenChange, onOpenSession } = renderFuseDialog() + + await selectSourcesAndModel(user) + await user.click(screen.getByRole('button', { name: 'Fuse' })) + + await waitFor(() => { + expect(onOpenSession).toHaveBeenCalledWith('ses_fusion', true) + }) + expect(onOpenChange).toHaveBeenCalledWith(false) + }) + + it('regenerates the request id after a failed fusion so retries are idempotent per failure', async () => { + const user = userEvent.setup() + mocks.fuseMultiRun.mockImplementation((_runId: number, request: FuseMultiRunRequest) => + Promise.resolve({ + ...run, + fusions: [ + { + id: 1, + requestId: request.requestId, + model: request.model, + instructions: null, + isolated: true, + baseRef: 'main', + status: 'failed', + sessionId: null, + directory: null, + error: 'launch failed', + sources: [], + createdAt: 1, + updatedAt: 1, + }, + ], + }), + ) + renderFuseDialog() + + await selectSourcesAndModel(user) + const submit = screen.getByRole('button', { name: 'Fuse' }) + await user.click(submit) + await waitFor(() => expect(mocks.fuseMultiRun).toHaveBeenCalledTimes(1)) + + await user.click(submit) + await waitFor(() => expect(mocks.fuseMultiRun).toHaveBeenCalledTimes(2)) + + const first = mocks.fuseMultiRun.mock.calls[0][1] as FuseMultiRunRequest + const second = mocks.fuseMultiRun.mock.calls[1][1] as FuseMultiRunRequest + expect(second.requestId).not.toBe(first.requestId) + }) + + it('runs in the repository checkout and omits the base ref when isolation is off', async () => { + const user = userEvent.setup() + mocks.fuseMultiRun.mockResolvedValue({ ...run, fusions: [] }) + renderFuseDialog() + + await user.click(screen.getByRole('switch', { name: 'Isolated workspace' })) + expect(screen.getByText('Runs in the repository checkout.')).toBeInTheDocument() + expect(screen.queryByRole('combobox', { name: 'Start from' })).not.toBeInTheDocument() + + await selectSourcesAndModel(user) + await user.click(screen.getByRole('button', { name: 'Fuse' })) + + await waitFor(() => expect(mocks.fuseMultiRun).toHaveBeenCalledTimes(1)) + const [, request] = mocks.fuseMultiRun.mock.calls[0] as [number, FuseMultiRunRequest] + expect(request).toMatchObject({ isolate: false }) + expect(request).not.toHaveProperty('baseRef') + }) + + it('forces isolation when a selected source ran in the repository checkout', async () => { + const user = userEvent.setup() + mocks.fuseMultiRun.mockResolvedValue({ ...nonIsolatedRun, fusions: [] }) + renderFuseDialog({ run: nonIsolatedRun }) + + expect(screen.getByRole('switch', { name: 'Isolated workspace' })).toBeEnabled() + + await selectSourcesAndModel(user) + + const toggle = screen.getByRole('switch', { name: 'Isolated workspace' }) + expect(toggle).toBeChecked() + expect(toggle).toBeDisabled() + expect( + screen.getByText('Isolation is required because a selected result ran in the repository checkout'), + ).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Fuse' })) + + await waitFor(() => expect(mocks.fuseMultiRun).toHaveBeenCalledTimes(1)) + const [, request] = mocks.fuseMultiRun.mock.calls[0] as [number, FuseMultiRunRequest] + expect(request).toMatchObject({ isolate: true, baseRef: 'main' }) + }) + + it('shows the recovered-attempt message when an earlier fusion is already running', async () => { + const user = userEvent.setup() + mocks.fuseMultiRun.mockRejectedValue( + new FetchError('An earlier fusion attempt is already running', 409, 'FUSION_ATTEMPT_RECOVERED', undefined, { + details: { fusions: [{ fusionId: 1, sessionId: 'ses_1' }] }, + }), + ) + renderFuseDialog() + + await selectSourcesAndModel(user) + await user.click(screen.getByRole('button', { name: 'Fuse' })) + + expect( + await screen.findByText('An earlier attempt is already running. Open it from the Fusions list.'), + ).toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/repo/FuseRunDialog.tsx b/frontend/src/components/repo/FuseRunDialog.tsx new file mode 100644 index 000000000..9a3f45430 --- /dev/null +++ b/frontend/src/components/repo/FuseRunDialog.tsx @@ -0,0 +1,288 @@ +import { useCallback, useDeferredValue, useEffect, useMemo, useState } from 'react' +import { Loader2, Search } from 'lucide-react' +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' +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 { Label } from '@/components/ui/label' +import { SessionStatusIndicator } from '@/components/ui/session-status-indicator' +import { BranchCombobox } from '@/components/repo/BranchCombobox' +import { ModelCheckboxList } from '@/components/repo/ModelCheckboxList' +import { useProvidersWithModels } from '@/hooks/useProvidersWithModels' +import { useOpenCodeModelState } from '@/hooks/useModelSelection' +import { useFuseMultiRun } from '@/hooks/useMultiRuns' +import { buildModelSections, filterModelSections } from '@/lib/modelSections' +import { randomId } from '@/lib/utils' +import { + FUSION_INSTRUCTIONS_MAX_LENGTH, + FusionUnavailableDetailsSchema, + MULTI_RUN_FUSION_MIN_SOURCES, + type FuseMultiRunRequest, + type MultiRun, + type MultiRunEntryStatus, +} from '@opencode-manager/shared/schemas' + +interface FuseRunDialogProps { + repoId: number + directory?: string + run: MultiRun + open: boolean + onOpenChange: (open: boolean) => void + onOpenSession: (sessionId: string, isolated: boolean) => void +} + +const ENTRY_STATUS_LABELS: Record = { + starting: 'Starting', + started: 'Started', + failed: 'Failed', + discarded: 'Discarded', +} + +function unavailableDetails(error: unknown) { + const parsed = FusionUnavailableDetailsSchema.safeParse((error as { details?: unknown } | null)?.details) + return parsed.success ? parsed.data : null +} + +export function FuseRunDialog({ + repoId, + directory, + run, + open, + onOpenChange, + onOpenSession, +}: FuseRunDialogProps) { + const [selectedEntryIds, setSelectedEntryIds] = useState([]) + const [selectedModel, setSelectedModel] = useState(null) + const [instructions, setInstructions] = useState('') + const [isolate, setIsolate] = useState(true) + const [baseRef, setBaseRef] = useState('') + const [modelSearch, setModelSearch] = useState('') + const [requestId, setRequestId] = useState(() => randomId()) + const deferredModelSearch = useDeferredValue(modelSearch) + + const fuse = useFuseMultiRun(repoId) + const { data: providers } = useProvidersWithModels({ enabled: open, directory }) + const { data: modelState } = useOpenCodeModelState(directory, open) + + useEffect(() => { + if (!open) return + setSelectedEntryIds([]) + setSelectedModel(null) + setInstructions('') + setIsolate(true) + setBaseRef(run.baseRef ?? '') + setModelSearch('') + setRequestId(randomId()) + }, [open, run.id, run.baseRef]) + + const modelSections = useMemo(() => buildModelSections(providers, modelState), [providers, modelState]) + const visibleModelSections = useMemo( + () => filterModelSections(modelSections, deferredModelSearch), + [modelSections, deferredModelSearch], + ) + const selectedModels = useMemo(() => (selectedModel ? [selectedModel] : []), [selectedModel]) + + const requiresIsolation = useMemo( + () => run.entries.some((entry) => selectedEntryIds.includes(entry.id) && !entry.isolated), + [run.entries, selectedEntryIds], + ) + const effectiveIsolate = requiresIsolation || isolate + + const toggleEntry = useCallback((entryId: number, checked: boolean) => { + setSelectedEntryIds((current) => { + if (checked) { + if (current.includes(entryId)) return current + return [...current, entryId] + } + return current.filter((id) => id !== entryId) + }) + }, []) + + const toggleModel = useCallback((value: string, checked: boolean) => { + setSelectedModel(checked ? value : null) + }, []) + + const canSubmit = + selectedEntryIds.length >= MULTI_RUN_FUSION_MIN_SOURCES && selectedModel !== null && !fuse.isPending + + const handleSubmit = () => { + if (!selectedModel) return + const trimmedInstructions = instructions.trim() + const request: FuseMultiRunRequest = { + requestId, + entryIds: selectedEntryIds, + model: selectedModel, + ...(trimmedInstructions ? { instructions: trimmedInstructions } : {}), + isolate: effectiveIsolate, + ...(effectiveIsolate && baseRef ? { baseRef } : {}), + } + fuse.mutate( + { runId: run.id, request }, + { + onSuccess: (result) => { + const fusion = result.fusions.find((candidate) => candidate.requestId === requestId) + if (fusion?.status === 'started' && fusion.sessionId) { + onOpenChange(false) + onOpenSession(fusion.sessionId, fusion.isolated) + } else if (fusion?.status === 'failed') { + setRequestId(randomId()) + } + }, + }, + ) + } + + const details = unavailableDetails(fuse.error) + const errorCode = (fuse.error as { code?: string } | null)?.code + + return ( + + + + Fuse results + + Ask one model to synthesise the selected results from “{run.name}”. + + + +
+
+ +
+ {run.entries.map((entry) => { + const selectable = entry.status === 'started' && !!entry.sessionId + return ( + + ) + })} +
+

+ Select at least {MULTI_RUN_FUSION_MIN_SOURCES} completed results. +

+
+ +
+ +
+ + setModelSearch(event.target.value)} + placeholder="Search models..." + aria-label="Search models" + autoComplete="off" + className="pl-9" + /> +
+
+ +
+
+ +
+ +