diff --git a/backend/src/index.ts b/backend/src/index.ts index 4dcedd4ee..b7b9409cd 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -271,7 +271,7 @@ const sessionGoalService = new SessionGoalService(db, openCodeClient, sessionSet }) sessionGoalService.loadOpenGoals() -const multiRunService = new MultiRunService(db, openCodeClient, repoWorkspaces) +const multiRunService = new MultiRunService(db, openCodeClient, repoWorkspaces, sessionPermissionModeService) const changeWalkthroughService = new ChangeWalkthroughService(db, openCodeClient) sseAggregator.onEvent((directory, event) => { diff --git a/backend/src/services/multi-run-fusion.ts b/backend/src/services/multi-run-fusion.ts index aae081bc5..8e27ed07b 100644 --- a/backend/src/services/multi-run-fusion.ts +++ b/backend/src/services/multi-run-fusion.ts @@ -1,3 +1,4 @@ +import { resolve } from 'node:path' import { isSessionNotFoundError, type FileDiffInfo, @@ -5,8 +6,10 @@ import { } from '@opencode-manager/shared/opencode' import { FUSION_PROMPT_MAX_LENGTH, + buildSchedulePermissionRuleset, type FusionUnavailableSource, type MultiRunFusionSource, + type SchedulePermissionRuleset, } from '@opencode-manager/shared/schemas' import type { MultiRunEntryRecord } from '../db/multi-runs' import { getErrorMessage } from '../utils/error-utils' @@ -42,6 +45,38 @@ const FUSION_REPLY_BUDGET_RATIO = 0.4 const FUSION_BLOCK_SEPARATOR = '\n\n' +/** + * Builds the OpenCode session permission ruleset that lets a fusion session read + * its selected source workspaces while denying any edit to them. + * + * The ruleset starts from the default unattended baseline + * (`buildSchedulePermissionRuleset(null)`), which denies external directories, + * questions, and destructive shell patterns. Because rules are last-match-wins, + * the per-source rules appended afterwards take precedence for those paths. + * + * Each unique directory is normalized with `resolve` before a pair of rules is + * emitted: an `external_directory` allow so the session may read outside its own + * workspace, followed by an `edit` deny so the read-only reference cannot be + * mutated. Deduplication happens after normalization. + */ +export function buildFusionSourcePermissionRuleset(directories: string[]): SchedulePermissionRuleset { + const seen = new Set() + const ruleset = buildSchedulePermissionRuleset(null) + + for (const directory of directories) { + const normalized = resolve(directory).replaceAll('\\', '/') + if (seen.has(normalized)) { + continue + } + + seen.add(normalized) + ruleset.push({ action: 'external_directory', resource: `${normalized}/*`, effect: 'allow' }) + ruleset.push({ action: 'edit', resource: `${normalized}/*`, effect: 'deny' }) + } + + return ruleset +} + export class FusionContextLimitError extends ServiceError { constructor(requiredPerSource: number, availablePerSource: number) { super( diff --git a/backend/src/services/multi-runs.ts b/backend/src/services/multi-runs.ts index 99345af25..9c7d5cc88 100644 --- a/backend/src/services/multi-runs.ts +++ b/backend/src/services/multi-runs.ts @@ -1,7 +1,7 @@ import type { Database } from 'bun:sqlite' import { existsSync } from 'node:fs' -import { resolve } from 'node:path' import type { FuseMultiRunRequest, LaunchMultiRunRequest, MultiRun } from '@opencode-manager/shared/schemas' +import { ASSISTANT_REPO_ID } from '@opencode-manager/shared/utils' import { createMultiRunWithEntries, getMultiRun, @@ -18,12 +18,14 @@ import { import { getRepoById } from '../db/queries' import type { Repo } from '../types/repo' import { getErrorMessage } from '../utils/error-utils' +import { logger } from '../utils/logger' import { ServiceError } from '../utils/service-error' -import { buildFusionPrompt, collectFusionSources } from './multi-run-fusion' +import { buildFusionPrompt, buildFusionSourcePermissionRuleset, collectFusionSources } from './multi-run-fusion' import type { OpenCodeClient } from './opencode/client' import { RepoWorkspaceError } from './repo' import type { RepoWorkspaceService } from './repo-workspace' import { requireReadyRepo, SessionLauncher, SessionLaunchError, type LaunchedSession } from './session-launcher' +import type { SessionPermissionModeService } from './session-permission-modes' const MULTI_RUN_LIST_LIMIT = 20 @@ -75,6 +77,7 @@ export class MultiRunService { private readonly db: Database, private readonly openCodeClient: OpenCodeClient, private readonly repoWorkspaces: RepoWorkspaceService, + private readonly permissionModes: SessionPermissionModeService, ) { this.sessionLauncher = new SessionLauncher(db, openCodeClient, repoWorkspaces) } @@ -165,6 +168,12 @@ export class MultiRunService { return { run: this.reload(multiRunId), created: false } } + if (record.repoId === ASSISTANT_REPO_ID) { + throw new MultiRunError('Fusion needs a Git repository because it always runs in a new worktree.', 409, { + code: 'FUSION_REQUIRES_GIT_REPOSITORY', + }) + } + let repo: Repo try { repo = requireReadyRepo(this.db, record.repoId) @@ -181,18 +190,11 @@ export class MultiRunService { 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 } }, - ) - } - } + const sourcePermissions = buildFusionSourcePermissionRuleset( + selectedEntries + .map((entry) => entry.directory) + .filter((directory): directory is string => directory !== null), + ) try { await this.sessionLauncher.resolveModel(repo, request.model) @@ -225,7 +227,7 @@ export class MultiRunService { requestId: request.requestId, model: request.model, instructions: request.instructions ?? null, - isolated: request.isolate, + isolated: true, baseRef: request.baseRef ?? null, sources: built.sources, }) @@ -234,21 +236,19 @@ export class MultiRunService { return { run: this.reload(multiRunId), created: false } } + let launched: LaunchedSession | null = null try { - const launched = await this.sessionLauncher.launch({ + 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 } : {}), - }, - } - : {}), + permissions: sourcePermissions, + workspace: { + name: `${record.name}-fusion-${fusion.id}`, + ...(request.baseRef ? { ref: request.baseRef } : {}), + }, }) updateMultiRunFusion(this.db, fusion.id, ['starting'], { @@ -267,6 +267,14 @@ export class MultiRunService { }) } + if (launched) { + try { + await this.permissionModes.applyDefaultMode(launched.sessionId, launched.directory) + } catch (error) { + logger.error(`Failed to apply the default permission mode to fusion session ${launched.sessionId}:`, error) + } + } + return { run: this.reload(multiRunId), created: true } } diff --git a/backend/src/services/session-launcher.ts b/backend/src/services/session-launcher.ts index 76c078591..21202f359 100644 --- a/backend/src/services/session-launcher.ts +++ b/backend/src/services/session-launcher.ts @@ -8,6 +8,8 @@ import type { OpenCodeClient } from './opencode/client' import { resolveOpenCodeModel, type ResolvedOpenCodeModel } from './opencode-models' import type { RepoWorkspaceService } from './repo-workspace' +type SessionCreateInput = NonNullable[0]> + interface LaunchSessionInput { repoId: number prompt: string @@ -15,6 +17,7 @@ interface LaunchSessionInput { model?: string agent?: string workspace?: { name?: string; ref?: string } + permissions?: SessionCreateInput['permissions'] } export interface LaunchedSession { @@ -109,6 +112,7 @@ export class SessionLauncher { session = await this.openCodeClient.api.session.create({ ...(input.title ? { title: input.title } : {}), ...(input.agent ? { agent: input.agent } : {}), + ...(input.permissions ? { permissions: input.permissions } : {}), model: { providerID: model.providerID, id: model.id, diff --git a/backend/src/services/session-permission-modes.ts b/backend/src/services/session-permission-modes.ts index 0d908fab8..ba41f859c 100644 --- a/backend/src/services/session-permission-modes.ts +++ b/backend/src/services/session-permission-modes.ts @@ -126,6 +126,21 @@ export class SessionPermissionModeService { return this.settingsService.getSettings().preferences.sessionDefaults?.permissionMode ?? 'ask' } + /** + * Records the default permission mode for a session after it has been launched + * and accepts any permission requests it raised before the mode was stored. + * + * Does nothing when the default mode is `ask`, since that needs no stored row. + */ + async applyDefaultMode(sessionId: string, directory: string): Promise { + if (this.defaultMode() !== 'auto') { + return + } + + insertSessionPermissionModeIfAbsent(this.db, sessionId, 'auto') + await this.acceptPendingRequestsInDirectory(directory, sessionId) + } + async acceptPendingRequestsForActiveSessions(): Promise { try { const active = await this.openCodeClient.api.session.active() diff --git a/backend/test/db/multi-run-fusions.test.ts b/backend/test/db/multi-run-fusions.test.ts index 064461854..24aa281f5 100644 --- a/backend/test/db/multi-run-fusions.test.ts +++ b/backend/test/db/multi-run-fusions.test.ts @@ -204,7 +204,6 @@ describe('FuseMultiRunRequestSchema', () => { requestId: '00000000-0000-4000-8000-000000000000', entryIds: [1, 2], model: 'openai/gpt-5', - isolate: true, } it('accepts at least two unique entry ids', () => { diff --git a/backend/test/routes/multi-runs.test.ts b/backend/test/routes/multi-runs.test.ts index dcbb3c98c..2287153f8 100644 --- a/backend/test/routes/multi-runs.test.ts +++ b/backend/test/routes/multi-runs.test.ts @@ -35,7 +35,6 @@ function fuseBody(overrides: Record = {}): string { requestId: '11111111-1111-4111-8111-111111111111', entryIds: [1, 2], model: 'openai/a', - isolate: true, ...overrides, }) } @@ -175,7 +174,6 @@ describe('multi-run routes', () => { requestId: '11111111-1111-4111-8111-111111111111', entryIds: [1, 2], model: 'openai/a', - isolate: true, }) }) diff --git a/backend/test/services/multi-runs.test.ts b/backend/test/services/multi-runs.test.ts index 0dd6dddb0..d92b948c8 100644 --- a/backend/test/services/multi-runs.test.ts +++ b/backend/test/services/multi-runs.test.ts @@ -1,13 +1,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Database } from 'bun:sqlite' +import { buildSchedulePermissionRuleset } from '@opencode-manager/shared/schemas' +import { ASSISTANT_REPO_ID } from '@opencode-manager/shared/utils' 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 { createMultiRunWithEntries, getMultiRun } from '../../src/db/multi-runs' +import { getSessionPermissionMode } from '../../src/db/session-permission-modes' 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 { SessionPermissionModeService } from '../../src/services/session-permission-modes' +import { SettingsService } from '../../src/services/settings' import type { Repo } from '../../src/types/repo' import type { OpenCodeClient } from '../../src/services/opencode/client' import { assistantMessage } from '../helpers/stub-schedule-api' @@ -22,6 +27,11 @@ const mocks = vi.hoisted(() => ({ isGitMainCheckout: vi.fn(), executeCommand: vi.fn(), existsSync: vi.fn(), + loggerError: vi.fn(), +})) + +vi.mock('../../src/utils/logger', () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: mocks.loggerError, debug: vi.fn() }, })) vi.mock('../../src/services/opencode-models', () => ({ @@ -109,6 +119,9 @@ function createClient(sessions: Record = {}): FakeMul const sessionDiff = vi.fn(async () => []) + const permissionRequestList = vi.fn(async () => ({ data: [] })) + const permissionReply = vi.fn(async () => ({})) + const client = { api: { session: { @@ -119,6 +132,10 @@ function createClient(sessions: Record = {}): FakeMul diff: sessionDiff, }, message: { list: messageList }, + permission: { + request: { list: permissionRequestList }, + reply: permissionReply, + }, }, } as unknown as OpenCodeClient @@ -198,8 +215,16 @@ describe('MultiRunService', () => { return repo.id } - function createService(client: OpenCodeClient, repoWorkspaces: RepoWorkspaceService): MultiRunService { - return new MultiRunService(db, client, repoWorkspaces) + function createService( + client: OpenCodeClient, + repoWorkspaces: RepoWorkspaceService, + permissionModes: SessionPermissionModeService = new SessionPermissionModeService( + db, + client, + new SettingsService(db), + ), + ): MultiRunService { + return new MultiRunService(db, client, repoWorkspaces, permissionModes) } it('launches one session per model, each in its own workspace named from the group', async () => { @@ -607,7 +632,6 @@ describe('MultiRunService', () => { requestId: FUSE_REQUEST_ID, entryIds, model: 'openai/c', - isolate: true, baseRef: 'develop', }) @@ -641,6 +665,131 @@ describe('MultiRunService', () => { ).toEqual(before) }) + it('grants read access to the selected source workspaces and denies edits when fusing', 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', 'openai/c'], + isolate: true, + }) + const entryIds = run.entries.map((entry) => entry.id) + sessionCreate.mockClear() + + await service.fuse(run.id, { + requestId: FUSE_REQUEST_ID, + entryIds: [entryIds[0]!, entryIds[2]!], + model: 'openai/d', + }) + + expect(sessionCreate).toHaveBeenCalledTimes(1) + expect(sessionCreate.mock.calls[0]![0].permissions).toEqual([ + ...buildSchedulePermissionRuleset(null), + { action: 'external_directory', resource: '/worktrees/Sweep-1/*', effect: 'allow' }, + { action: 'edit', resource: '/worktrees/Sweep-1/*', effect: 'deny' }, + { action: 'external_directory', resource: '/worktrees/Sweep-3/*', effect: 'allow' }, + { action: 'edit', resource: '/worktrees/Sweep-3/*', effect: 'deny' }, + ]) + const serializedPermissions = JSON.stringify(sessionCreate.mock.calls[0]![0].permissions) + expect(serializedPermissions).not.toContain('Sweep-2') + expect(serializedPermissions).not.toContain('Sweep-fusion-1') + }) + + it('stores the default auto mode on the fusion session', async () => { + const repoId = readyRepo() + const { client, sessionCreate } = createClient() + const repoWorkspaces = createRepoWorkspaces() + const settingsService = new SettingsService(db) + settingsService.updateSettings({ sessionDefaults: { permissionMode: 'auto' } }) + const permissionModes = new SessionPermissionModeService(db, client, settingsService) + const service = createService(client, repoWorkspaces.service, permissionModes) + + 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 { run: fused } = await service.fuse(run.id, { + requestId: FUSE_REQUEST_ID, + entryIds, + model: 'openai/c', + }) + + const fusionSessionId = fused.fusions[0]!.sessionId! + expect(getSessionPermissionMode(db, fusionSessionId)).toBe('auto') + }) + + it('stores no mode on the fusion session when the default is ask', 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 { run: fused } = await service.fuse(run.id, { + requestId: FUSE_REQUEST_ID, + entryIds, + model: 'openai/c', + }) + + const fusionSessionId = fused.fusions[0]!.sessionId! + expect(getSessionPermissionMode(db, fusionSessionId)).toBeNull() + }) + + it('keeps the fusion started and logs when applying the default permission mode fails', async () => { + const repoId = readyRepo() + const { client } = createClient() + const repoWorkspaces = createRepoWorkspaces() + const permissionModes = { + applyDefaultMode: vi.fn(async () => { + throw new Error('permission store unavailable') + }), + } as unknown as SessionPermissionModeService + const service = createService(client, repoWorkspaces.service, permissionModes) + + 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) + + const { run: fused } = await service.fuse(run.id, { + requestId: FUSE_REQUEST_ID, + entryIds, + model: 'openai/c', + }) + + const fusion = fused.fusions[0]! + expect(fusion.status).toBe('started') + expect(fusion.sessionId).not.toBeNull() + expect(mocks.loggerError).toHaveBeenCalledWith( + expect.stringContaining(fusion.sessionId!), + expect.any(Error), + ) + }) + it('returns the same fusion for a repeated requestId and launches once', async () => { const repoId = readyRepo() const { client, sessionCreate } = createClient() @@ -657,7 +806,7 @@ describe('MultiRunService', () => { const entryIds = run.entries.map((entry) => entry.id) sessionCreate.mockClear() - const request = { requestId: FUSE_REQUEST_ID, entryIds, model: 'openai/c', isolate: false } + const request = { requestId: FUSE_REQUEST_ID, entryIds, model: 'openai/c' } const first = await service.fuse(run.id, request) const second = await service.fuse(run.id, request) @@ -686,7 +835,7 @@ describe('MultiRunService', () => { sessionCreate.mockClear() const error = await service - .fuse(run.id, { requestId: FUSE_REQUEST_ID, entryIds, model: 'openai/c', isolate: false }) + .fuse(run.id, { requestId: FUSE_REQUEST_ID, entryIds, model: 'openai/c' }) .catch((caught: unknown) => caught) expect(error).toBeInstanceOf(MultiRunError) @@ -715,7 +864,7 @@ describe('MultiRunService', () => { sessionCreate.mockClear() const error = await service - .fuse(run.id, { requestId: FUSE_REQUEST_ID, entryIds, model: 'openai/c', isolate: false }) + .fuse(run.id, { requestId: FUSE_REQUEST_ID, entryIds, model: 'openai/c' }) .catch((caught: unknown) => caught) expect(error).toBeInstanceOf(FusionContextLimitError) @@ -741,7 +890,7 @@ describe('MultiRunService', () => { sessionCreate.mockClear() const error = await service - .fuse(run.id, { requestId: FUSE_REQUEST_ID, entryIds, model: 'openai/retired', isolate: false }) + .fuse(run.id, { requestId: FUSE_REQUEST_ID, entryIds, model: 'openai/retired' }) .catch((caught: unknown) => caught) expect(error).toBeInstanceOf(MultiRunError) @@ -772,7 +921,6 @@ describe('MultiRunService', () => { requestId: FUSE_REQUEST_ID, entryIds, model: 'openai/c', - isolate: true, }) expect(created).toBe(true) @@ -780,6 +928,7 @@ describe('MultiRunService', () => { expect(fusion.status).toBe('failed') expect(fusion.error).toContain('fusion boom') expect(fusion.directory).toBe('/worktrees/Sweep-fusion-1') + expect(fusion.isolated).toBe(true) }) it('records the created session id on a failed fusion when the prompt is rejected', async () => { @@ -803,7 +952,6 @@ describe('MultiRunService', () => { requestId: FUSE_REQUEST_ID, entryIds, model: 'openai/c', - isolate: false, }) expect(created).toBe(true) @@ -834,7 +982,6 @@ describe('MultiRunService', () => { requestId: FUSE_REQUEST_ID, entryIds, model: 'openai/c', - isolate: false, }) const failedFusion = firstFused.fusions[0]! expect(failedFusion.status).toBe('failed') @@ -847,7 +994,6 @@ describe('MultiRunService', () => { requestId: '22222222-2222-4222-8222-222222222222', entryIds, model: 'openai/c', - isolate: false, }) .catch((caught: unknown) => caught) @@ -883,7 +1029,6 @@ describe('MultiRunService', () => { requestId: FUSE_REQUEST_ID, entryIds, model: 'openai/c', - isolate: false, }) expect(firstFused.fusions[0]!.status).toBe('failed') @@ -893,7 +1038,6 @@ describe('MultiRunService', () => { requestId: '33333333-3333-4333-8333-333333333333', entryIds, model: 'openai/c', - isolate: false, }) expect(created).toBe(true) @@ -901,65 +1045,25 @@ describe('MultiRunService', () => { 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() + it('rejects fusion for the Assistant before creating a fusion', async () => { 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 record = createMultiRunWithEntries( + db, + { repoId: ASSISTANT_REPO_ID, name: 'Sweep', prompt: 'go', isolated: false, baseRef: null }, + ['openai/a', 'openai/b'], + ) const error = await service - .fuse(run.id, { - requestId: FUSE_REQUEST_ID, - entryIds, - model: 'openai/c', - isolate: false, - }) + .fuse(record.id, { requestId: FUSE_REQUEST_ID, entryIds: record.entries.map((entry) => entry.id), model: 'openai/c' }) .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(error).toMatchObject({ status: 409, code: 'FUSION_REQUIRES_GIT_REPOSITORY' }) + expect(repoWorkspaces.create).not.toHaveBeenCalled() 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) + expect(getMultiRun(db, record.id)!.fusions).toEqual([]) }) it('rejects fusion with an entry id that does not belong to the run', async () => { @@ -983,7 +1087,6 @@ describe('MultiRunService', () => { requestId: FUSE_REQUEST_ID, entryIds: [entryIds[0]!, 9999], model: 'openai/c', - isolate: false, }) .catch((caught: unknown) => caught) diff --git a/backend/test/services/session-launcher.test.ts b/backend/test/services/session-launcher.test.ts index 40546fd61..9362414a5 100644 --- a/backend/test/services/session-launcher.test.ts +++ b/backend/test/services/session-launcher.test.ts @@ -122,6 +122,32 @@ describe('SessionLauncher', () => { }) }) + it('forwards a permission ruleset to session creation when provided', async () => { + const repoId = readyRepo() + const { client, create } = createClient() + const { service } = createRepoWorkspaces() + const launcher = new SessionLauncher(db, client, service) + const permissions = [ + { action: 'external_directory', resource: '/repos/source/*', effect: 'allow' as const }, + { action: 'edit', resource: '/repos/source/*', effect: 'deny' as const }, + ] + + await launcher.launch({ repoId, prompt: 'hello', permissions }) + + expect(create).toHaveBeenCalledWith(expect.objectContaining({ permissions })) + }) + + it('omits the permissions key from session creation when none are given', async () => { + const repoId = readyRepo() + const { client, create } = createClient() + const { service } = createRepoWorkspaces() + const launcher = new SessionLauncher(db, client, service) + + await launcher.launch({ repoId, prompt: 'hello' }) + + expect(create.mock.calls[0]![0]).not.toHaveProperty('permissions') + }) + it('launches in a new workspace and prompts there', async () => { const repoId = readyRepo() const { client, create } = createClient() diff --git a/backend/test/services/session-permission-modes.test.ts b/backend/test/services/session-permission-modes.test.ts index c4d6f4c67..0f1263738 100644 --- a/backend/test/services/session-permission-modes.test.ts +++ b/backend/test/services/session-permission-modes.test.ts @@ -177,6 +177,54 @@ describe('SessionPermissionModeService', () => { expect(service.defaultMode()).toBe('auto') }) + it('stores the default auto mode for a launched session', async () => { + const settingsService = new SettingsService(db) + settingsService.updateSettings({ sessionDefaults: { permissionMode: 'auto' } }) + const service = new SessionPermissionModeService(db, createFakeSessionPermissionClient(), settingsService) + + await service.applyDefaultMode('ses_launched', DIRECTORY) + + expect(getSessionPermissionMode(db, 'ses_launched')).toBe('auto') + }) + + it('stores no mode for a launched session when the default is ask', async () => { + const service = new SessionPermissionModeService(db, createFakeSessionPermissionClient(), new SettingsService(db)) + + await service.applyDefaultMode('ses_launched', DIRECTORY) + + expect(getSessionPermissionMode(db, 'ses_launched')).toBeNull() + }) + + it('does not override an existing stored ask when applying the default auto mode', async () => { + const settingsService = new SettingsService(db) + settingsService.updateSettings({ sessionDefaults: { permissionMode: 'auto' } }) + setSessionPermissionMode(db, 'ses_launched', 'ask') + const service = new SessionPermissionModeService(db, createFakeSessionPermissionClient(), settingsService) + + await service.applyDefaultMode('ses_launched', DIRECTORY) + + expect(getSessionPermissionMode(db, 'ses_launched')).toBe('ask') + }) + + it('accepts a pending request raised before the launched session mode was recorded', async () => { + const settingsService = new SettingsService(db) + settingsService.updateSettings({ sessionDefaults: { permissionMode: 'auto' } }) + const client = createFakeSessionPermissionClient({ + parents: { ses_launched: null }, + pendingRequests: { [DIRECTORY]: [{ id: 'perm-launched', sessionID: 'ses_launched' }] }, + }) + const service = new SessionPermissionModeService(db, client, settingsService) + + await service.applyDefaultMode('ses_launched', DIRECTORY) + + expect(client.replyPermission).toHaveBeenCalledTimes(1) + expect(client.replyPermission).toHaveBeenCalledWith({ + sessionID: 'ses_launched', + requestID: 'perm-launched', + decision: 'once', + }) + }) + it('auto-accepts a permission request for an auto root session', async () => { setSessionPermissionMode(db, 'ses_root', 'auto') const client = createFakeSessionPermissionClient({ parents: { ses_root: null } }) diff --git a/frontend/src/components/model/ModelCombobox.tsx b/frontend/src/components/model/ModelCombobox.tsx index d0a7bb084..dfdcf10ad 100644 --- a/frontend/src/components/model/ModelCombobox.tsx +++ b/frontend/src/components/model/ModelCombobox.tsx @@ -2,6 +2,7 @@ import { useCallback, useMemo } from 'react' import { Combobox, type ComboboxOption } from '@/components/ui/combobox' import { useModelSections } from '@/hooks/useModelSections' import { filterModelSections, toModelComboboxOptions } from '@/lib/modelSections' +import { cn } from '@/lib/utils' interface ModelComboboxProps { value: string @@ -15,6 +16,8 @@ interface ModelComboboxProps { id?: string disabled?: boolean className?: string + listClassName?: string + emptyMeansDefault?: boolean } export function ModelCombobox({ @@ -29,8 +32,11 @@ export function ModelCombobox({ id, disabled, className, + listClassName, + emptyMeansDefault = true, }: ModelComboboxProps) { - const { sections, defaultModel } = useModelSections(directory, { enabled }) + const { sections, defaultModel: openCodeDefaultModel } = useModelSections(directory, { enabled }) + const defaultModel = emptyMeansDefault ? openCodeDefaultModel : null const options = useMemo(() => toModelComboboxOptions(sections, defaultModel), [sections, defaultModel]) @@ -53,7 +59,7 @@ export function ModelCombobox({ ariaLabel={ariaLabel} disabled={disabled} className={className} - listClassName="max-h-[min(24rem,60vh)]" + listClassName={cn('max-h-[min(24rem,60vh)]', listClassName)} /> ) } diff --git a/frontend/src/components/repo/BranchCombobox.tsx b/frontend/src/components/repo/BranchCombobox.tsx index ea74d13fa..06353d172 100644 --- a/frontend/src/components/repo/BranchCombobox.tsx +++ b/frontend/src/components/repo/BranchCombobox.tsx @@ -17,6 +17,7 @@ interface BranchComboboxProps { clearable?: boolean id?: string ariaLabel?: string + listClassName?: string } /** @@ -34,6 +35,7 @@ export function BranchCombobox({ clearable = false, id, ariaLabel, + listClassName, }: BranchComboboxProps) { const { data: branchesData, isLoading } = useRepoBranches(repoId, enabled) @@ -69,6 +71,7 @@ export function BranchCombobox({ allowCustomValue={false} showClear={clearable} ariaLabel={ariaLabel} + listClassName={listClassName} /> ) } diff --git a/frontend/src/components/repo/FuseRunDialog.test.tsx b/frontend/src/components/repo/FuseRunDialog.test.tsx deleted file mode 100644 index aa502aa37..000000000 --- a/frontend/src/components/repo/FuseRunDialog.test.tsx +++ /dev/null @@ -1,419 +0,0 @@ -import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest' -import { render, screen, waitFor } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import type { ReactNode } from 'react' -import { FuseRunDialog } from './FuseRunDialog' -import { FetchError } from '@/api/fetchWrapper' -import type { FuseMultiRunRequest, MultiRun } from '@opencode-manager/shared/schemas' - -const mocks = vi.hoisted(() => ({ - fuseMultiRun: vi.fn(), - listMultiRuns: vi.fn(), - launchMultiRun: vi.fn(), - discardMultiRunEntry: vi.fn(), - useProviders: vi.fn(), - useOpenCodeModelState: vi.fn(), - useOpenCodeDefaultModel: vi.fn(), - listBranches: vi.fn(), -})) - -vi.mock('@/api/multiRuns', () => ({ - fuseMultiRun: mocks.fuseMultiRun, - listMultiRuns: mocks.listMultiRuns, - launchMultiRun: mocks.launchMultiRun, - discardMultiRunEntry: mocks.discardMultiRunEntry, -})) - -vi.mock('@/hooks/useProviders', () => ({ - useProviders: mocks.useProviders, -})) - -vi.mock('@/hooks/useModelSelection', () => ({ - useOpenCodeModelState: mocks.useOpenCodeModelState, - useOpenCodeDefaultModel: mocks.useOpenCodeDefaultModel, -})) - -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', - models: [ - { id: 'gpt-4o', name: 'GPT-4o', released: 0, free: false }, - { id: 'gpt-4.1', name: 'GPT-4.1', released: 0, free: false }, - ], - }, - { - id: 'anthropic', - name: 'Anthropic', - models: [{ id: 'claude-opus', name: 'Claude Opus', released: 0, free: false }], - }, -] - -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' })) -} - -function synthesisCheckboxLabels() { - return screen - .getAllByRole('checkbox') - .map((checkbox) => checkbox.getAttribute('aria-label') ?? '') - .filter((label) => !label.includes('/')) -} - -describe('FuseRunDialog', () => { - beforeAll(() => { - Element.prototype.hasPointerCapture ??= () => false - Element.prototype.setPointerCapture ??= () => {} - Element.prototype.releasePointerCapture ??= () => {} - Element.prototype.scrollIntoView ??= () => {} - }) - - beforeEach(() => { - vi.clearAllMocks() - mocks.useProviders.mockReturnValue({ data: { providers, models: [] }, isLoading: false }) - mocks.useOpenCodeModelState.mockReturnValue({ data: { recent: [], favorite: [], variant: {} } }) - mocks.useOpenCodeDefaultModel.mockReturnValue({ data: null }) - 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() - }) - - it('lists the OpenCode default model first without repeating it', () => { - mocks.useOpenCodeDefaultModel.mockReturnValue({ data: 'anthropic/claude-opus' }) - renderFuseDialog() - - expect(screen.getByText('Default')).toBeInTheDocument() - expect(synthesisCheckboxLabels()).toEqual(['Claude Opus', 'GPT-4o', 'GPT-4.1']) - }) -}) diff --git a/frontend/src/components/repo/FuseRunDialog.tsx b/frontend/src/components/repo/FuseRunDialog.tsx deleted file mode 100644 index a6e7ce6d9..000000000 --- a/frontend/src/components/repo/FuseRunDialog.tsx +++ /dev/null @@ -1,285 +0,0 @@ -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 { useModelSections } from '@/hooks/useModelSections' -import { useFuseMultiRun } from '@/hooks/useMultiRuns' -import { 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 { sections: modelSections } = useModelSections(directory, { enabled: open }) - - useEffect(() => { - if (!open) return - setSelectedEntryIds([]) - setSelectedModel(null) - setInstructions('') - setIsolate(true) - setBaseRef(run.baseRef ?? '') - setModelSearch('') - setRequestId(randomId()) - }, [open, run.id, run.baseRef]) - - 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" - /> -
-
- -
-
- -
- -