From e8548b1e079578fbe6c2c9f43d97a70d1e4afbde Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Tue, 6 Oct 2026 22:24:06 -0400 Subject: [PATCH 01/11] section 1: Phase 1: Expose session goals and multi-runs on the internal --- backend/src/index.ts | 2 +- backend/src/routes/internal/index.ts | 8 ++ .../test/routes/internal-assistant.test.ts | 4 +- .../test/routes/internal-multi-runs.test.ts | 124 ++++++++++++++++++ .../routes/internal-notifications.test.ts | 4 +- .../routes/internal-opencode-config.test.ts | 4 +- .../internal-opencode-workspaces.test.ts | 4 +- backend/test/routes/internal-repos.test.ts | 4 +- backend/test/routes/internal-sandbox.test.ts | 4 +- .../test/routes/internal-schedules.test.ts | 4 +- .../routes/internal-session-goals.test.ts | 101 ++++++++++++++ backend/test/routes/internal-sessions.test.ts | 4 +- backend/test/routes/internal-settings.test.ts | 4 +- backend/test/services/assistant-mode.test.ts | 4 +- 14 files changed, 264 insertions(+), 11 deletions(-) create mode 100644 backend/test/routes/internal-multi-runs.test.ts create mode 100644 backend/test/routes/internal-session-goals.test.ts diff --git a/backend/src/index.ts b/backend/src/index.ts index b7b9409cd..67a0b450c 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -340,7 +340,7 @@ app.route('/api/auth-info', createAuthInfoRoutes(auth, db)) app.route('/api/health', createHealthRoutes(db, openCodeSupervisor)) app.route('/api/mcp-oauth-proxy', createMcpOauthProxyRoutes(openCodeClient, requireAuth)) -app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, sessionPermissionModeService, repoWorkspaces, gitAuthService)) +app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, sessionPermissionModeService, repoWorkspaces, gitAuthService, sessionGoalService, multiRunService)) app.route('/api/opencode-proxy', createOpenCodeProxyRoutes(db, settingsService)) const protectedApi = new Hono() diff --git a/backend/src/routes/internal/index.ts b/backend/src/routes/internal/index.ts index 346b9414e..4ca67c54e 100644 --- a/backend/src/routes/internal/index.ts +++ b/backend/src/routes/internal/index.ts @@ -17,9 +17,13 @@ import { createInternalSessionRoutes } from './sessions' import { createInternalAssistantRoutes } from './assistant' import { createInternalGitCredentialsRoutes } from './git-credentials' import { createInternalSandboxRoutes } from './sandbox' +import { createSessionGoalRoutes } from '../session-goals' +import { createMultiRunRoutes } from '../multi-runs' import type { SessionPermissionModeService } from '../../services/session-permission-modes' import type { RepoWorkspaceService } from '../../services/repo-workspace' import type { GitAuthService } from '../../services/git-auth' +import type { SessionGoalService } from '../../services/session-goals' +import type { MultiRunService } from '../../services/multi-runs' export function createInternalRoutes( db: Database, @@ -30,10 +34,14 @@ export function createInternalRoutes( permissionModes: SessionPermissionModeService, repoWorkspaces: RepoWorkspaceService, gitAuthService: GitAuthService, + sessionGoals: SessionGoalService, + multiRuns: MultiRunService, ) { const app = new Hono() app.use('/*', createInternalTokenMiddleware(db)) app.route('/schedules', createScheduleRoutes(scheduleService)) + app.route('/session-goals', createSessionGoalRoutes(sessionGoals)) + app.route('/multi-runs', createMultiRunRoutes(multiRuns)) app.route('/notifications', createInternalNotificationRoutes(notificationService)) app.route('/settings', createInternalSettingsRoutes(settingsService)) app.route('/opencode-config', createOpenCodeConfigRoutes(settingsService, openCodeClient, { redactSecrets: true })) diff --git a/backend/test/routes/internal-assistant.test.ts b/backend/test/routes/internal-assistant.test.ts index ea4bc953a..4c91a790e 100644 --- a/backend/test/routes/internal-assistant.test.ts +++ b/backend/test/routes/internal-assistant.test.ts @@ -2,6 +2,8 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' import type { SessionPermissionModeService } from '../../src/services/session-permission-modes' import type { RepoWorkspaceService } from '../../src/services/repo-workspace' import type { GitAuthService } from '../../src/services/git-auth' +import type { SessionGoalService } from '../../src/services/session-goals' +import type { MultiRunService } from '../../src/services/multi-runs' import { Hono } from 'hono' import { Database } from 'bun:sqlite' import { createInternalRoutes } from '../../src/routes/internal' @@ -46,7 +48,7 @@ describe('internal/assistant routes', () => { notificationService = new NotificationService(db) settingsService = new SettingsService(db) app = new Hono() - app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, {} as SessionPermissionModeService, {} as unknown as RepoWorkspaceService, {} as unknown as GitAuthService)) + app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, {} as SessionPermissionModeService, {} as unknown as RepoWorkspaceService, {} as unknown as GitAuthService, {} as unknown as SessionGoalService, {} as unknown as MultiRunService)) token = getOrCreateInternalToken(db) }) diff --git a/backend/test/routes/internal-multi-runs.test.ts b/backend/test/routes/internal-multi-runs.test.ts new file mode 100644 index 000000000..39bf0867a --- /dev/null +++ b/backend/test/routes/internal-multi-runs.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import type { SessionPermissionModeService } from '../../src/services/session-permission-modes' +import type { RepoWorkspaceService } from '../../src/services/repo-workspace' +import type { GitAuthService } from '../../src/services/git-auth' +import type { SessionGoalService } from '../../src/services/session-goals' +import type { MultiRunService } from '../../src/services/multi-runs' +import { Hono } from 'hono' +import { Database } from 'bun:sqlite' +import { createInternalRoutes } from '../../src/routes/internal' +import type { ScheduleService } from '../../src/services/schedules' +import type { NotificationService } from '../../src/services/notification' +import type { SettingsService } from '../../src/services/settings' +import type { OpenCodeClient } from '../../src/services/opencode/client' +import { allMigrations } from '../../src/db/migrations' +import { getOrCreateInternalToken } from '../../src/services/internal-token' +import { migrate } from '../../src/db/migration-runner' + +function createMultiRunStub() { + return { + list: vi.fn(), + launch: vi.fn(), + discard: vi.fn(), + fuse: vi.fn(), + } +} + +function createRun(overrides: Record = {}) { + return { + id: 1, + repoId: 1, + name: 'Sweep', + prompt: 'go', + isolated: true, + baseRef: null, + createdAt: 1, + entries: [], + fusions: [], + ...overrides, + } +} + +function fuseBody(overrides: Record = {}) { + return { + requestId: '11111111-1111-4111-8111-111111111111', + entryIds: [1, 2], + model: 'openai/a', + isolate: true, + ...overrides, + } +} + +describe('internal-multi-runs routes', () => { + let db: Database + let multiRuns: ReturnType + let app: Hono + let token: string + + beforeEach(() => { + db = new Database(':memory:') + migrate(db, allMigrations) + multiRuns = createMultiRunStub() + app = new Hono() + app.route( + '/api/internal', + createInternalRoutes( + db, + {} as ScheduleService, + {} as NotificationService, + {} as SettingsService, + {} as OpenCodeClient, + {} as SessionPermissionModeService, + {} as unknown as RepoWorkspaceService, + {} as unknown as GitAuthService, + {} as unknown as SessionGoalService, + multiRuns as unknown as MultiRunService, + ), + ) + token = getOrCreateInternalToken(db) + }) + + it('GET /api/internal/multi-runs returns 401 without bearer token', async () => { + const res = await app.request('/api/internal/multi-runs?repoId=1') + expect(res.status).toBe(401) + }) + + it('GET /api/internal/multi-runs returns the repository runs with a bearer token', async () => { + multiRuns.list.mockReturnValue([]) + + const res = await app.request('/api/internal/multi-runs?repoId=1', { + headers: { authorization: `Bearer ${token}` }, + }) + + expect(res.status).toBe(200) + await expect(res.json()).resolves.toEqual({ runs: [] }) + expect(multiRuns.list).toHaveBeenCalledWith(1) + }) + + it('POST /api/internal/multi-runs/3/entries/4/discard discards the entry', async () => { + multiRuns.discard.mockResolvedValue(createRun()) + + const res = await app.request('/api/internal/multi-runs/3/entries/4/discard', { + method: 'POST', + headers: { authorization: `Bearer ${token}` }, + }) + + expect(res.status).toBe(200) + expect(multiRuns.discard).toHaveBeenCalledWith(3, 4) + }) + + it('POST /api/internal/multi-runs/3/fusions fuses the run', async () => { + multiRuns.fuse.mockResolvedValue({ run: createRun(), created: true }) + const body = fuseBody() + + const res = await app.request('/api/internal/multi-runs/3/fusions', { + method: 'POST', + headers: { authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + + expect(res.status).toBe(201) + await expect(res.json()).resolves.toEqual({ run: createRun() }) + expect(multiRuns.fuse).toHaveBeenCalledWith(3, body) + }) +}) diff --git a/backend/test/routes/internal-notifications.test.ts b/backend/test/routes/internal-notifications.test.ts index 36b90f237..701f806b7 100644 --- a/backend/test/routes/internal-notifications.test.ts +++ b/backend/test/routes/internal-notifications.test.ts @@ -2,6 +2,8 @@ import { describe, it, expect, beforeEach, vi } from 'bun:test' import type { SessionPermissionModeService } from '../../src/services/session-permission-modes' import type { RepoWorkspaceService } from '../../src/services/repo-workspace' import type { GitAuthService } from '../../src/services/git-auth' +import type { SessionGoalService } from '../../src/services/session-goals' +import type { MultiRunService } from '../../src/services/multi-runs' import { Hono } from 'hono' import { Database } from 'bun:sqlite' import { createInternalRoutes } from '../../src/routes/internal' @@ -32,7 +34,7 @@ describe('internal/notifications routes', () => { notificationService = new NotificationService(db) settingsService = new SettingsService(db) app = new Hono() - app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, {} as SessionPermissionModeService, {} as unknown as RepoWorkspaceService, {} as unknown as GitAuthService)) + app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, {} as SessionPermissionModeService, {} as unknown as RepoWorkspaceService, {} as unknown as GitAuthService, {} as unknown as SessionGoalService, {} as unknown as MultiRunService)) token = getOrCreateInternalToken(db) }) diff --git a/backend/test/routes/internal-opencode-config.test.ts b/backend/test/routes/internal-opencode-config.test.ts index 01170e5b5..dfcbec4d2 100644 --- a/backend/test/routes/internal-opencode-config.test.ts +++ b/backend/test/routes/internal-opencode-config.test.ts @@ -2,6 +2,8 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'bun:test' import type { SessionPermissionModeService } from '../../src/services/session-permission-modes' import type { RepoWorkspaceService } from '../../src/services/repo-workspace' import type { GitAuthService } from '../../src/services/git-auth' +import type { SessionGoalService } from '../../src/services/session-goals' +import type { MultiRunService } from '../../src/services/multi-runs' import { Hono } from 'hono' import { Database } from 'bun:sqlite' import { readFile, writeFile } from 'fs/promises' @@ -62,7 +64,7 @@ describe('internal/opencode-config routes', () => { const notificationService = new NotificationService(db) const settingsService = new SettingsService(db) app = new Hono() - app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, {} as SessionPermissionModeService, {} as unknown as RepoWorkspaceService, {} as unknown as GitAuthService)) + app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, {} as SessionPermissionModeService, {} as unknown as RepoWorkspaceService, {} as unknown as GitAuthService, {} as unknown as SessionGoalService, {} as unknown as MultiRunService)) token = getOrCreateInternalToken(db) }) diff --git a/backend/test/routes/internal-opencode-workspaces.test.ts b/backend/test/routes/internal-opencode-workspaces.test.ts index d302cd97a..738f76e14 100644 --- a/backend/test/routes/internal-opencode-workspaces.test.ts +++ b/backend/test/routes/internal-opencode-workspaces.test.ts @@ -2,6 +2,8 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' import type { SessionPermissionModeService } from '../../src/services/session-permission-modes' import type { RepoWorkspaceService } from '../../src/services/repo-workspace' import type { GitAuthService } from '../../src/services/git-auth' +import type { SessionGoalService } from '../../src/services/session-goals' +import type { MultiRunService } from '../../src/services/multi-runs' import { Hono } from 'hono' import type { Database } from 'bun:sqlite' import { createInternalRoutes } from '../../src/routes/internal' @@ -85,7 +87,7 @@ describe('internal-opencode-workspaces routes', () => { forwardRaw: vi.fn(), } as unknown as OpenCodeClient app = new Hono() - app.route('/api/internal', createInternalRoutes(mockDb, scheduleService, notificationService, settingsService, openCodeClient, {} as SessionPermissionModeService, {} as unknown as RepoWorkspaceService, {} as unknown as GitAuthService)) + app.route('/api/internal', createInternalRoutes(mockDb, scheduleService, notificationService, settingsService, openCodeClient, {} as SessionPermissionModeService, {} as unknown as RepoWorkspaceService, {} as unknown as GitAuthService, {} as unknown as SessionGoalService, {} as unknown as MultiRunService)) token = 'test-internal-token' }) diff --git a/backend/test/routes/internal-repos.test.ts b/backend/test/routes/internal-repos.test.ts index 4dc3ec0c9..6b518a8a2 100644 --- a/backend/test/routes/internal-repos.test.ts +++ b/backend/test/routes/internal-repos.test.ts @@ -2,6 +2,8 @@ import { describe, it, expect, beforeEach } from 'vitest' import type { SessionPermissionModeService } from '../../src/services/session-permission-modes' import type { RepoWorkspaceService } from '../../src/services/repo-workspace' import type { GitAuthService } from '../../src/services/git-auth' +import type { SessionGoalService } from '../../src/services/session-goals' +import type { MultiRunService } from '../../src/services/multi-runs' import { Hono } from 'hono' import { Database } from 'bun:sqlite' import { createInternalRoutes } from '../../src/routes/internal' @@ -33,7 +35,7 @@ describe('internal-repos routes', () => { notificationService = new NotificationService(db) settingsService = new SettingsService(db) app = new Hono() - app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, {} as SessionPermissionModeService, {} as unknown as RepoWorkspaceService, {} as unknown as GitAuthService)) + app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, {} as SessionPermissionModeService, {} as unknown as RepoWorkspaceService, {} as unknown as GitAuthService, {} as unknown as SessionGoalService, {} as unknown as MultiRunService)) token = getOrCreateInternalToken(db) }) diff --git a/backend/test/routes/internal-sandbox.test.ts b/backend/test/routes/internal-sandbox.test.ts index b30f8fe83..4a1d62b30 100644 --- a/backend/test/routes/internal-sandbox.test.ts +++ b/backend/test/routes/internal-sandbox.test.ts @@ -2,6 +2,8 @@ import { afterEach, beforeEach, describe, expect, it, mock, vi } from 'bun:test' import type { SessionPermissionModeService } from '../../src/services/session-permission-modes' import type { RepoWorkspaceService } from '../../src/services/repo-workspace' import type { GitAuthService } from '../../src/services/git-auth' +import type { SessionGoalService } from '../../src/services/session-goals' +import type { MultiRunService } from '../../src/services/multi-runs' import { Hono } from 'hono' import { Database } from 'bun:sqlite' import { mkdirSync, rmSync } from 'node:fs' @@ -96,7 +98,7 @@ describe('internal sandbox routes', () => { const notificationService = new NotificationService(db) settingsService = new SettingsService(db) app = new Hono() - app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, {} as SessionPermissionModeService, {} as unknown as RepoWorkspaceService, {} as unknown as GitAuthService)) + app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, {} as SessionPermissionModeService, {} as unknown as RepoWorkspaceService, {} as unknown as GitAuthService, {} as unknown as SessionGoalService, {} as unknown as MultiRunService)) token = getOrCreateInternalToken(db) repoDir = path.join(getReposPath(), 'sandbox-route-test') mkdirSync(repoDir, { recursive: true }) diff --git a/backend/test/routes/internal-schedules.test.ts b/backend/test/routes/internal-schedules.test.ts index dc42d4994..d0f41635e 100644 --- a/backend/test/routes/internal-schedules.test.ts +++ b/backend/test/routes/internal-schedules.test.ts @@ -2,6 +2,8 @@ import { describe, it, expect, beforeEach } from 'vitest' import type { SessionPermissionModeService } from '../../src/services/session-permission-modes' import type { RepoWorkspaceService } from '../../src/services/repo-workspace' import type { GitAuthService } from '../../src/services/git-auth' +import type { SessionGoalService } from '../../src/services/session-goals' +import type { MultiRunService } from '../../src/services/multi-runs' import { Hono } from 'hono' import { Database } from 'bun:sqlite' import { createInternalRoutes } from '../../src/routes/internal' @@ -31,7 +33,7 @@ describe('internal-schedules routes', () => { notificationService = new NotificationService(db) settingsService = new SettingsService(db) app = new Hono() - app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, {} as SessionPermissionModeService, {} as unknown as RepoWorkspaceService, {} as unknown as GitAuthService)) + app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, {} as SessionPermissionModeService, {} as unknown as RepoWorkspaceService, {} as unknown as GitAuthService, {} as unknown as SessionGoalService, {} as unknown as MultiRunService)) token = getOrCreateInternalToken(db) }) diff --git a/backend/test/routes/internal-session-goals.test.ts b/backend/test/routes/internal-session-goals.test.ts new file mode 100644 index 000000000..e7ddc6eed --- /dev/null +++ b/backend/test/routes/internal-session-goals.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import type { SessionPermissionModeService } from '../../src/services/session-permission-modes' +import type { RepoWorkspaceService } from '../../src/services/repo-workspace' +import type { GitAuthService } from '../../src/services/git-auth' +import type { SessionGoalService } from '../../src/services/session-goals' +import type { MultiRunService } from '../../src/services/multi-runs' +import { Hono } from 'hono' +import { Database } from 'bun:sqlite' +import { createInternalRoutes } from '../../src/routes/internal' +import type { ScheduleService } from '../../src/services/schedules' +import type { NotificationService } from '../../src/services/notification' +import type { SettingsService } from '../../src/services/settings' +import type { OpenCodeClient } from '../../src/services/opencode/client' +import { allMigrations } from '../../src/db/migrations' +import { getOrCreateInternalToken } from '../../src/services/internal-token' +import { migrate } from '../../src/db/migration-runner' + +function createSessionGoalStub() { + return { + getLatest: vi.fn(), + start: vi.fn(), + pause: vi.fn(), + resume: vi.fn(), + cancel: vi.fn(), + } +} + +describe('internal-session-goals routes', () => { + let db: Database + let sessionGoals: ReturnType + let app: Hono + let token: string + + beforeEach(() => { + db = new Database(':memory:') + migrate(db, allMigrations) + sessionGoals = createSessionGoalStub() + app = new Hono() + app.route( + '/api/internal', + createInternalRoutes( + db, + {} as ScheduleService, + {} as NotificationService, + {} as SettingsService, + {} as OpenCodeClient, + {} as SessionPermissionModeService, + {} as unknown as RepoWorkspaceService, + {} as unknown as GitAuthService, + sessionGoals as unknown as SessionGoalService, + {} as unknown as MultiRunService, + ), + ) + token = getOrCreateInternalToken(db) + }) + + it('GET /api/internal/session-goals returns 401 without bearer token', async () => { + const res = await app.request('/api/internal/session-goals?sessionId=ses_1') + expect(res.status).toBe(401) + }) + + it('GET /api/internal/session-goals returns the latest goal with a bearer token', async () => { + sessionGoals.getLatest.mockReturnValue(null) + + const res = await app.request('/api/internal/session-goals?sessionId=ses_1', { + headers: { authorization: `Bearer ${token}` }, + }) + + expect(res.status).toBe(200) + await expect(res.json()).resolves.toEqual({ goal: null }) + expect(sessionGoals.getLatest).toHaveBeenCalledWith('ses_1') + }) + + it('POST /api/internal/session-goals starts a goal', async () => { + sessionGoals.start.mockResolvedValue({ id: 1, status: 'active' }) + const body = { sessionId: 'ses_1', directory: '/abs/repo', objective: 'Ship it' } + + const res = await app.request('/api/internal/session-goals', { + method: 'POST', + headers: { authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + + expect(res.status).toBe(201) + await expect(res.json()).resolves.toEqual({ goal: { id: 1, status: 'active' } }) + expect(sessionGoals.start).toHaveBeenCalledWith(body) + }) + + it('POST /api/internal/session-goals/7/pause pauses the goal', async () => { + sessionGoals.pause.mockReturnValue({ id: 7, status: 'paused' }) + + const res = await app.request('/api/internal/session-goals/7/pause', { + method: 'POST', + headers: { authorization: `Bearer ${token}` }, + }) + + expect(res.status).toBe(200) + await expect(res.json()).resolves.toEqual({ goal: { id: 7, status: 'paused' } }) + expect(sessionGoals.pause).toHaveBeenCalledWith(7) + }) +}) diff --git a/backend/test/routes/internal-sessions.test.ts b/backend/test/routes/internal-sessions.test.ts index 374912716..f4e412151 100644 --- a/backend/test/routes/internal-sessions.test.ts +++ b/backend/test/routes/internal-sessions.test.ts @@ -9,6 +9,8 @@ import type { OpenCodeClient } from '../../src/services/opencode/client' import type { SessionPermissionModeService } from '../../src/services/session-permission-modes' import type { RepoWorkspaceService } from '../../src/services/repo-workspace' import type { GitAuthService } from '../../src/services/git-auth' +import type { SessionGoalService } from '../../src/services/session-goals' +import type { MultiRunService } from '../../src/services/multi-runs' import type { Repo } from '../../src/types/repo' const mockDb = { @@ -158,7 +160,7 @@ describe('internal-sessions routes', () => { app = new Hono() app.route( '/api/internal', - createInternalRoutes(mockDb, scheduleService, notificationService, settingsService, openCodeClient, permissionModes, {} as unknown as RepoWorkspaceService, {} as unknown as GitAuthService), + createInternalRoutes(mockDb, scheduleService, notificationService, settingsService, openCodeClient, permissionModes, {} as unknown as RepoWorkspaceService, {} as unknown as GitAuthService, {} as unknown as SessionGoalService, {} as unknown as MultiRunService), ) token = 'test-internal-token' }) diff --git a/backend/test/routes/internal-settings.test.ts b/backend/test/routes/internal-settings.test.ts index 8316b0541..4110bbfc0 100644 --- a/backend/test/routes/internal-settings.test.ts +++ b/backend/test/routes/internal-settings.test.ts @@ -2,6 +2,8 @@ import { describe, it, expect, beforeEach } from 'bun:test' import type { SessionPermissionModeService } from '../../src/services/session-permission-modes' import type { RepoWorkspaceService } from '../../src/services/repo-workspace' import type { GitAuthService } from '../../src/services/git-auth' +import type { SessionGoalService } from '../../src/services/session-goals' +import type { MultiRunService } from '../../src/services/multi-runs' import { Hono } from 'hono' import { Database } from 'bun:sqlite' import { createInternalRoutes } from '../../src/routes/internal' @@ -32,7 +34,7 @@ describe('internal/settings routes', () => { notificationService = new NotificationService(db) settingsService = new SettingsService(db) app = new Hono() - app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, {} as SessionPermissionModeService, {} as unknown as RepoWorkspaceService, {} as unknown as GitAuthService)) + app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, {} as SessionPermissionModeService, {} as unknown as RepoWorkspaceService, {} as unknown as GitAuthService, {} as unknown as SessionGoalService, {} as unknown as MultiRunService)) token = getOrCreateInternalToken(db) }) diff --git a/backend/test/services/assistant-mode.test.ts b/backend/test/services/assistant-mode.test.ts index cd58e27f5..eae66156c 100644 --- a/backend/test/services/assistant-mode.test.ts +++ b/backend/test/services/assistant-mode.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it, beforeEach, afterEach } from 'bun:test' import type { SessionPermissionModeService } from '../../src/services/session-permission-modes' import type { RepoWorkspaceService } from '../../src/services/repo-workspace' import type { GitAuthService } from '../../src/services/git-auth' +import type { SessionGoalService } from '../../src/services/session-goals' +import type { MultiRunService } from '../../src/services/multi-runs' import path from 'path' import { access, readFile, writeFile } from 'fs/promises' import { Hono } from 'hono' @@ -688,7 +690,7 @@ describe('assistant-mode end-to-end', () => { const notificationService = new NotificationService(db) const settingsService = new SettingsService(db) const app = new Hono() - app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, createOpenCodeClient(), {} as SessionPermissionModeService, {} as unknown as RepoWorkspaceService, {} as unknown as GitAuthService)) + app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, createOpenCodeClient(), {} as SessionPermissionModeService, {} as unknown as RepoWorkspaceService, {} as unknown as GitAuthService, {} as unknown as SessionGoalService, {} as unknown as MultiRunService)) const unauth = await app.request('/api/internal/schedules/all') expect(unauth.status).toBe(401) From b316cb960cf81db14473b69dd2a5610fcf47a286 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Tue, 6 Oct 2026 22:25:21 -0400 Subject: [PATCH 02/11] section 2: Phase 2: Attach env carries the Manager URL and repo id --- ocm-cli/src/remote-context.ts | 19 +++++++++++---- ocm-cli/src/warp.ts | 2 +- ocm-cli/test/remote-context.test.ts | 38 +++++++++++++++++++++++++++-- ocm-cli/test/warp.test.ts | 3 ++- 4 files changed, 53 insertions(+), 9 deletions(-) diff --git a/ocm-cli/src/remote-context.ts b/ocm-cli/src/remote-context.ts index f483f33de..97c61fa64 100644 --- a/ocm-cli/src/remote-context.ts +++ b/ocm-cli/src/remote-context.ts @@ -1,15 +1,19 @@ export const REMOTE_MANAGER_URL_ENV = 'OCM_REMOTE_MANAGER_URL' export const REMOTE_REPO_NAME_ENV = 'OCM_REMOTE_REPO_NAME' +export const REMOTE_REPO_ID_ENV = 'OCM_REMOTE_REPO_ID' export type RemoteContext = { + managerUrl: string managerHost: string repoName?: string + repoId?: number } -export function buildRemoteAttachEnv(managerUrl: string, repoName: string): Record { +export function buildRemoteAttachEnv(managerUrl: string, repoName: string, repoId: number): Record { return { [REMOTE_MANAGER_URL_ENV]: managerUrl, [REMOTE_REPO_NAME_ENV]: repoName, + [REMOTE_REPO_ID_ENV]: String(repoId), } } @@ -17,14 +21,19 @@ export function readRemoteContext(env: NodeJS.ProcessEnv): RemoteContext | undef const urlValue = env[REMOTE_MANAGER_URL_ENV] if (!urlValue) return undefined + const managerUrl = urlValue.trim() + let managerHost: string try { - managerHost = new URL(urlValue).host + managerHost = new URL(managerUrl).host } catch { - managerHost = urlValue.trim() + managerHost = managerUrl } const repoName = env[REMOTE_REPO_NAME_ENV] || undefined - return { managerHost, repoName } -} \ No newline at end of file + const repoIdValue = Number(env[REMOTE_REPO_ID_ENV]) + const repoId = Number.isInteger(repoIdValue) && repoIdValue > 0 ? repoIdValue : undefined + + return { managerUrl, managerHost, repoName, repoId } +} diff --git a/ocm-cli/src/warp.ts b/ocm-cli/src/warp.ts index f1de71851..7c1ae7496 100644 --- a/ocm-cli/src/warp.ts +++ b/ocm-cli/src/warp.ts @@ -42,7 +42,7 @@ export function buildAttachInvocation(target: AttachTarget): AttachInvocation { if (target.sessionID) args.push('--session', target.sessionID) return { args, - env: { ...process.env, OPENCODE_PASSWORD: target.token, ...buildRemoteAttachEnv(target.managerUrl, target.repoName) }, + env: { ...process.env, OPENCODE_PASSWORD: target.token, ...buildRemoteAttachEnv(target.managerUrl, target.repoName, target.repoId) }, } } diff --git a/ocm-cli/test/remote-context.test.ts b/ocm-cli/test/remote-context.test.ts index d7a5255bb..8e259691d 100644 --- a/ocm-cli/test/remote-context.test.ts +++ b/ocm-cli/test/remote-context.test.ts @@ -1,16 +1,22 @@ import { describe, it, expect } from 'vitest' import { REMOTE_MANAGER_URL_ENV, + REMOTE_REPO_ID_ENV, buildRemoteAttachEnv, readRemoteContext, } from '../src/remote-context.js' describe('buildRemoteAttachEnv', () => { it('round-trips through readRemoteContext', () => { - const env = buildRemoteAttachEnv('https://mgr.example.com', 'oc-manager') + const env = buildRemoteAttachEnv('https://mgr.example.com', 'oc-manager', 7) const result = readRemoteContext(env) - expect(result).toEqual({ managerHost: 'mgr.example.com', repoName: 'oc-manager' }) + expect(result).toEqual({ + managerUrl: 'https://mgr.example.com', + managerHost: 'mgr.example.com', + repoName: 'oc-manager', + repoId: 7, + }) }) }) @@ -43,4 +49,32 @@ describe('readRemoteContext', () => { expect(result?.repoName).toBeUndefined() }) + + it('returns the trimmed raw manager URL', () => { + const env = { [REMOTE_MANAGER_URL_ENV]: ' https://mgr.example.com ' } + const result = readRemoteContext(env) + + expect(result?.managerUrl).toBe('https://mgr.example.com') + }) + + it('returns undefined repoId when the repo id var is missing', () => { + const env = { [REMOTE_MANAGER_URL_ENV]: 'https://mgr.example.com' } + const result = readRemoteContext(env) + + expect(result?.repoId).toBeUndefined() + }) + + it.each(['', 'abc', '0', '-1', '3.5'])('returns undefined repoId for %j', (value) => { + const env = { [REMOTE_MANAGER_URL_ENV]: 'https://mgr.example.com', [REMOTE_REPO_ID_ENV]: value } + const result = readRemoteContext(env) + + expect(result?.repoId).toBeUndefined() + }) + + it('returns the numeric repoId when the repo id var is a positive integer', () => { + const env = { [REMOTE_MANAGER_URL_ENV]: 'https://mgr.example.com', [REMOTE_REPO_ID_ENV]: '42' } + const result = readRemoteContext(env) + + expect(result?.repoId).toBe(42) + }) }) \ No newline at end of file diff --git a/ocm-cli/test/warp.test.ts b/ocm-cli/test/warp.test.ts index deb8d771d..2533f8611 100644 --- a/ocm-cli/test/warp.test.ts +++ b/ocm-cli/test/warp.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import type { WarpTarget } from '../src/warp.js' import { setPendingWarp, takePendingWarp, buildAttachInvocation, runPendingWarp } from '../src/warp.js' -import { REMOTE_MANAGER_URL_ENV, REMOTE_REPO_NAME_ENV } from '../src/remote-context.js' +import { REMOTE_MANAGER_URL_ENV, REMOTE_REPO_ID_ENV, REMOTE_REPO_NAME_ENV } from '../src/remote-context.js' const sampleTarget: WarpTarget = { managerUrl: 'https://manager.example.com', @@ -24,6 +24,7 @@ describe('buildAttachInvocation', () => { OPENCODE_PASSWORD: 'tok_abc123', [REMOTE_MANAGER_URL_ENV]: 'https://manager.example.com', [REMOTE_REPO_NAME_ENV]: 'my-repo', + [REMOTE_REPO_ID_ENV]: '42', }) }) From a99e69f653038d9eeb5a464c4935a4449693e952 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Tue, 6 Oct 2026 22:27:04 -0400 Subject: [PATCH 03/11] section 3: Phase 3: Shared Manager auth helper and Manager API client f --- ocm-cli/src/manager-api.ts | 108 ++++++++++++++- ocm-cli/src/manager-auth.ts | 29 +++++ ocm-cli/src/tui-plugin.ts | 36 ++--- ocm-cli/test/manager-api.test.ts | 210 ++++++++++++++++++++++++++++++ ocm-cli/test/manager-auth.test.ts | 71 ++++++++++ 5 files changed, 427 insertions(+), 27 deletions(-) create mode 100644 ocm-cli/src/manager-auth.ts create mode 100644 ocm-cli/test/manager-api.test.ts create mode 100644 ocm-cli/test/manager-auth.test.ts diff --git a/ocm-cli/src/manager-api.ts b/ocm-cli/src/manager-api.ts index 806b46af0..5a54b9e99 100644 --- a/ocm-cli/src/manager-api.ts +++ b/ocm-cli/src/manager-api.ts @@ -3,8 +3,15 @@ import { Readable } from 'stream' import { MirrorTargetEnsureResponseSchema, MirrorTargetPlanResponseSchema, + MultiRunSchema, + SessionGoalSchema, + type FuseMultiRunRequest, + type LaunchMultiRunRequest, type MirrorTargetEnsureResponse, type MirrorTargetPlanResponse, + type MultiRun, + type SessionGoal, + type StartSessionGoalRequest, } from '@opencode-manager/shared/schemas' export interface MirrorBeginOpts { @@ -75,23 +82,30 @@ export class ManagerApiError extends Error { public readonly status: number, public readonly code: string | null, public readonly operation: string, + public readonly details: unknown = null, ) { super(message) this.name = 'ManagerApiError' } } +export function isManagerRouteMissing(error: unknown): boolean { + return error instanceof ManagerApiError && error.status === 404 && error.code === null +} + async function formatErrorResponse(res: Response, operation: string): Promise { const text = await res.text().catch(() => '') let code: string | null = null let detail = text + let details: unknown = null if (text) { try { - const parsed = JSON.parse(text) as { error?: unknown; message?: unknown } + const parsed = JSON.parse(text) as { error?: unknown; message?: unknown; details?: unknown } const errField = typeof parsed.error === 'string' ? parsed.error : null const msgField = typeof parsed.message === 'string' ? parsed.message : null code = errField detail = msgField ?? errField ?? text + details = parsed.details ?? null } catch { /* not JSON, keep raw text */ } @@ -99,7 +113,7 @@ async function formatErrorResponse(res: Response, operation: string): Promise { + const res = await fetch(`${this.baseUrl}/api/internal/session-goals?sessionId=${encodeURIComponent(sessionId)}`, { + headers: this.headers(), + }) + + if (!res.ok) throw await formatErrorResponse(res, 'read session goal') + const body = (await res.json()) as { goal: unknown } + return SessionGoalSchema.nullable().parse(body.goal) + } + + async startSessionGoal(input: StartSessionGoalRequest): Promise { + const res = await fetch(`${this.baseUrl}/api/internal/session-goals`, { + method: 'POST', + headers: { ...this.headers(), 'Content-Type': 'application/json' }, + body: JSON.stringify(input), + }) + + if (!res.ok) throw await formatErrorResponse(res, 'start session goal') + const body = (await res.json()) as { goal: unknown } + return SessionGoalSchema.parse(body.goal) + } + + private async runSessionGoalAction(id: number, action: 'pause' | 'resume' | 'cancel'): Promise { + const res = await fetch(`${this.baseUrl}/api/internal/session-goals/${encodeURIComponent(id)}/${action}`, { + method: 'POST', + headers: this.headers(), + }) + + if (!res.ok) throw await formatErrorResponse(res, `${action} session goal`) + const body = (await res.json()) as { goal: unknown } + return SessionGoalSchema.parse(body.goal) + } + + pauseSessionGoal(id: number): Promise { + return this.runSessionGoalAction(id, 'pause') + } + + resumeSessionGoal(id: number): Promise { + return this.runSessionGoalAction(id, 'resume') + } + + cancelSessionGoal(id: number): Promise { + return this.runSessionGoalAction(id, 'cancel') + } + + async listMultiRuns(repoId: number): Promise { + const res = await fetch(`${this.baseUrl}/api/internal/multi-runs?repoId=${encodeURIComponent(repoId)}`, { + headers: this.headers(), + }) + + if (!res.ok) throw await formatErrorResponse(res, 'list multi-runs') + const body = (await res.json()) as { runs: unknown } + return MultiRunSchema.array().parse(body.runs) + } + + async launchMultiRun(request: LaunchMultiRunRequest): Promise { + const res = await fetch(`${this.baseUrl}/api/internal/multi-runs`, { + method: 'POST', + headers: { ...this.headers(), 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + }) + + if (!res.ok) throw await formatErrorResponse(res, 'launch multi-run') + const body = (await res.json()) as { run: unknown } + return MultiRunSchema.parse(body.run) + } + + async fuseMultiRun(runId: number, request: FuseMultiRunRequest): Promise { + const res = await fetch(`${this.baseUrl}/api/internal/multi-runs/${encodeURIComponent(runId)}/fusions`, { + method: 'POST', + headers: { ...this.headers(), 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + }) + + if (!res.ok) throw await formatErrorResponse(res, 'fuse multi-run') + const body = (await res.json()) as { run: unknown } + return MultiRunSchema.parse(body.run) + } + + async discardMultiRunEntry(runId: number, entryId: number): Promise { + const res = await fetch(`${this.baseUrl}/api/internal/multi-runs/${encodeURIComponent(runId)}/entries/${encodeURIComponent(entryId)}/discard`, { + method: 'POST', + headers: this.headers(), + }) + + if (!res.ok) throw await formatErrorResponse(res, 'discard multi-run entry') + const body = (await res.json()) as { run: unknown } + return MultiRunSchema.parse(body.run) + } } diff --git a/ocm-cli/src/manager-auth.ts b/ocm-cli/src/manager-auth.ts new file mode 100644 index 000000000..2afbff454 --- /dev/null +++ b/ocm-cli/src/manager-auth.ts @@ -0,0 +1,29 @@ +import { readState } from './state.js' +import { getToken } from './internal-token-store.js' +import { TokenStoreError } from './token-store.js' + +export type ManagerAuth = + | { ok: true; managerUrl: string; token: string } + | { ok: false; message: string } + +export async function resolveManagerAuth( + managerUrl: string | undefined = readState()?.managerUrl, +): Promise { + if (!managerUrl) { + return { ok: false, message: 'No manager configured. Run `ocm login ` first.' } + } + + let token: string | null + try { + token = await getToken(managerUrl) + } catch (err) { + const reason = err instanceof TokenStoreError ? err.message : String(err) + return { ok: false, message: `Token store unavailable: ${reason}` } + } + + if (!token) { + return { ok: false, message: `No token stored. Run \`ocm login ${managerUrl}\`.` } + } + + return { ok: true, managerUrl, token } +} diff --git a/ocm-cli/src/tui-plugin.ts b/ocm-cli/src/tui-plugin.ts index 633e1b119..45fde6351 100644 --- a/ocm-cli/src/tui-plugin.ts +++ b/ocm-cli/src/tui-plugin.ts @@ -1,7 +1,6 @@ import type { Context } from '@opencode/plugin/tui/context' -import { readInstallNotice, readState } from './state.js' -import { getToken } from './internal-token-store.js' -import { TokenStoreError } from './token-store.js' +import { readInstallNotice } from './state.js' +import { resolveManagerAuth } from './manager-auth.js' import { fetchRepos, toRemoteRepoSummaries } from './manager-repos.js' import { ManagerApi, ManagerApiError } from './manager-api.js' import type { MirrorTargetPlanResponse } from '@opencode-manager/shared/schemas' @@ -116,26 +115,13 @@ async function runSessionMove(context: Context, setMoveProgress: MoveProgressSet return } - const state = readState() - if (!state?.managerUrl) { - context.ui.toast.show({ variant: 'error', message: 'No manager configured. Run `ocm login ` first.' }) + const auth = await resolveManagerAuth() + if (!auth.ok) { + context.ui.toast.show({ variant: 'error', message: auth.message }) return } - let token: string | null - try { - token = await getToken(state.managerUrl) - } catch (err) { - const reason = err instanceof TokenStoreError ? err.message : String(err) - context.ui.toast.show({ variant: 'error', message: `Token store unavailable: ${reason}` }) - return - } - if (!token) { - context.ui.toast.show({ variant: 'error', message: `No token stored. Run \`ocm login ${state.managerUrl}\`.` }) - return - } - - const repos = await fetchRepos(state.managerUrl, token) + const repos = await fetchRepos(auth.managerUrl, auth.token) const plan = await prepareMirror(session.location.directory, toRemoteRepoSummaries(repos)) if (plan.matched.length === 0) { @@ -150,16 +136,16 @@ async function runSessionMove(context: Context, setMoveProgress: MoveProgressSet const matchedRepoId = matched.repoId const remoteRepo = repos.find((r) => r.repoId === matchedRepoId)! - await warmRepoProxy(state.managerUrl, token, matchedRepoId) + await warmRepoProxy(auth.managerUrl, auth.token, matchedRepoId) - const transfer = createManagerSessionTransfer(state.managerUrl, token) + const transfer = createManagerSessionTransfer(auth.managerUrl, auth.token) const blocker = await describeMoveBlocker(transfer, sessionID, session.parentID) if (blocker) { context.ui.toast.show({ variant: 'error', message: blocker }) return } - const managerApi = new ManagerApi(state.managerUrl, token) + const managerApi = new ManagerApi(auth.managerUrl, auth.token) const target = await resolveMoveTarget(managerApi, matched, remoteRepo.directory, localBranch) const discardReasons = target.repoId === null ? [] : await describeRemoteDiscard(plan.repoRoot, managerApi, target.repoId) @@ -199,8 +185,8 @@ async function runSessionMove(context: Context, setMoveProgress: MoveProgressSet setMoveProgress(null) const warp = await confirmDialog(context, { title: 'Attach to moved session?', message: 'Exit this TUI and attach to the moved session on the Manager now?' }) if (warp) { - await warmRepoProxy(state.managerUrl, token, pushed.repoId) - setPendingWarp({ managerUrl: state.managerUrl, token, repoId: pushed.repoId, sessionID: result.sessionID, repoName: matched.name }) + await warmRepoProxy(auth.managerUrl, auth.token, pushed.repoId) + setPendingWarp({ managerUrl: auth.managerUrl, token: auth.token, repoId: pushed.repoId, sessionID: result.sessionID, repoName: matched.name }) context.keymap.dispatch('app.exit') return } diff --git a/ocm-cli/test/manager-api.test.ts b/ocm-cli/test/manager-api.test.ts new file mode 100644 index 000000000..e17f7b944 --- /dev/null +++ b/ocm-cli/test/manager-api.test.ts @@ -0,0 +1,210 @@ +import { describe, it, expect, vi, afterEach } from 'vitest' +import { ManagerApi, ManagerApiError, isManagerRouteMissing } from '../src/manager-api.js' +import type { MultiRun, SessionGoal } from '@opencode-manager/shared/schemas' + +const BASE_URL = 'http://localhost:5003' + +const goal: SessionGoal = { + id: 7, + sessionId: 'ses_1', + directory: '/repo', + objective: 'fix the flaky test', + status: 'active', + stopReason: null, + turnState: 'waiting', + continuationCount: 0, + maxContinuations: 10, + tokenBudget: null, + tokensUsed: 0, + consecutiveBlocked: 0, + lastVerdict: null, + lastReason: null, + createdAt: 1, + updatedAt: 2, + finishedAt: null, +} + +const run: MultiRun = { + id: 3, + repoId: 1, + name: 'sweep', + prompt: 'hello', + isolated: true, + baseRef: null, + createdAt: 1, + entries: [], + fusions: [], +} + +function okResponse(body: unknown) { + return { ok: true, status: 200, json: () => Promise.resolve(body), text: () => Promise.resolve('') } +} + +function errorResponse(status: number, text: string) { + return { ok: false, status, json: () => Promise.reject(new Error('no json')), text: () => Promise.resolve(text) } +} + +function stubFetch(response: unknown) { + const fetchMock = vi.fn().mockResolvedValue(response) + vi.stubGlobal('fetch', fetchMock) + return fetchMock +} + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('ManagerApi session goals', () => { + const api = new ManagerApi(BASE_URL, 'tok') + + it('reads the latest goal for a session', async () => { + const fetchMock = stubFetch(okResponse({ goal })) + + await expect(api.getLatestSessionGoal('ses_1')).resolves.toEqual(goal) + expect(fetchMock).toHaveBeenCalledWith(`${BASE_URL}/api/internal/session-goals?sessionId=ses_1`, { + headers: { Authorization: 'Bearer tok' }, + }) + }) + + it('returns null when the session has no goal', async () => { + stubFetch(okResponse({ goal: null })) + + await expect(api.getLatestSessionGoal('ses_1')).resolves.toBeNull() + }) + + it('starts a goal', async () => { + const fetchMock = stubFetch(okResponse({ goal })) + const input = { sessionId: 'ses_1', directory: '/repo', objective: 'fix it' } + + await expect(api.startSessionGoal(input)).resolves.toEqual(goal) + expect(fetchMock).toHaveBeenCalledWith(`${BASE_URL}/api/internal/session-goals`, { + method: 'POST', + headers: { Authorization: 'Bearer tok', 'Content-Type': 'application/json' }, + body: JSON.stringify(input), + }) + }) + + it('pauses, resumes, and cancels a goal through the action route', async () => { + const fetchMock = stubFetch(okResponse({ goal })) + + await expect(api.pauseSessionGoal(7)).resolves.toEqual(goal) + await expect(api.resumeSessionGoal(7)).resolves.toEqual(goal) + await expect(api.cancelSessionGoal(7)).resolves.toEqual(goal) + + expect(fetchMock).toHaveBeenNthCalledWith(1, `${BASE_URL}/api/internal/session-goals/7/pause`, { + method: 'POST', + headers: { Authorization: 'Bearer tok' }, + }) + expect(fetchMock).toHaveBeenNthCalledWith(2, `${BASE_URL}/api/internal/session-goals/7/resume`, { + method: 'POST', + headers: { Authorization: 'Bearer tok' }, + }) + expect(fetchMock).toHaveBeenNthCalledWith(3, `${BASE_URL}/api/internal/session-goals/7/cancel`, { + method: 'POST', + headers: { Authorization: 'Bearer tok' }, + }) + }) + + it('rejects a goal response that fails schema validation', async () => { + stubFetch(okResponse({ goal: { ...goal, status: 'bogus' } })) + + await expect(api.getLatestSessionGoal('ses_1')).rejects.toThrow() + }) +}) + +describe('ManagerApi multi-runs', () => { + const api = new ManagerApi(BASE_URL, 'tok') + + it('lists runs for a repo', async () => { + const fetchMock = stubFetch(okResponse({ runs: [run] })) + + await expect(api.listMultiRuns(1)).resolves.toEqual([run]) + expect(fetchMock).toHaveBeenCalledWith(`${BASE_URL}/api/internal/multi-runs?repoId=1`, { + headers: { Authorization: 'Bearer tok' }, + }) + }) + + it('launches a run', async () => { + const fetchMock = stubFetch(okResponse({ run })) + const request = { repoId: 1, name: 'sweep', prompt: 'hello', models: ['a/b'], isolate: true } + + await expect(api.launchMultiRun(request)).resolves.toEqual(run) + expect(fetchMock).toHaveBeenCalledWith(`${BASE_URL}/api/internal/multi-runs`, { + method: 'POST', + headers: { Authorization: 'Bearer tok', 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + }) + }) + + it('fuses selected entries', async () => { + const fetchMock = stubFetch(okResponse({ run })) + const request = { requestId: '11111111-1111-4111-8111-111111111111', entryIds: [1, 2], model: 'a/b', isolate: true } + + await expect(api.fuseMultiRun(3, request)).resolves.toEqual(run) + expect(fetchMock).toHaveBeenCalledWith(`${BASE_URL}/api/internal/multi-runs/3/fusions`, { + method: 'POST', + headers: { Authorization: 'Bearer tok', 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + }) + }) + + it('discards an entry', async () => { + const fetchMock = stubFetch(okResponse({ run })) + + await expect(api.discardMultiRunEntry(3, 4)).resolves.toEqual(run) + expect(fetchMock).toHaveBeenCalledWith(`${BASE_URL}/api/internal/multi-runs/3/entries/4/discard`, { + method: 'POST', + headers: { Authorization: 'Bearer tok' }, + }) + }) +}) + +describe('ManagerApiError', () => { + const api = new ManagerApi(BASE_URL, 'tok') + + it('carries a JSON error code and details', async () => { + const details = { unavailableSources: [{ entryId: 1, model: 'a/b', reason: 'running', message: 'still running' }] } + stubFetch(errorResponse(409, JSON.stringify({ error: 'Fusion unavailable', details }))) + + const error = await api.fuseMultiRun(3, { requestId: '11111111-1111-4111-8111-111111111111', entryIds: [1, 2], model: 'a/b', isolate: true }).catch((err) => err) + + expect(error).toBeInstanceOf(ManagerApiError) + expect((error as ManagerApiError).status).toBe(409) + expect((error as ManagerApiError).code).toBe('Fusion unavailable') + expect((error as ManagerApiError).details).toEqual(details) + }) + + it('leaves details null when the error body has none', async () => { + stubFetch(errorResponse(400, JSON.stringify({ error: 'Invalid request' }))) + + const error = await api.listMultiRuns(1).catch((err) => err) + + expect((error as ManagerApiError).details).toBeNull() + }) +}) + +describe('isManagerRouteMissing', () => { + const api = new ManagerApi(BASE_URL, 'tok') + + it('is true for a 404 with a non-JSON body', async () => { + stubFetch(errorResponse(404, '404 Not Found')) + + const error = await api.listMultiRuns(1).catch((err) => err) + + expect(isManagerRouteMissing(error)).toBe(true) + }) + + it('is false for a 404 with a JSON error body', async () => { + stubFetch(errorResponse(404, JSON.stringify({ error: 'Multi-run not found' }))) + + const error = await api.listMultiRuns(1).catch((err) => err) + + expect(isManagerRouteMissing(error)).toBe(false) + }) + + it('is false for non-404 failures and unrelated errors', () => { + expect(isManagerRouteMissing(new ManagerApiError('x', 500, null, 'op'))).toBe(false) + expect(isManagerRouteMissing(new Error('nope'))).toBe(false) + expect(isManagerRouteMissing(null)).toBe(false) + }) +}) diff --git a/ocm-cli/test/manager-auth.test.ts b/ocm-cli/test/manager-auth.test.ts new file mode 100644 index 000000000..c6bc95e66 --- /dev/null +++ b/ocm-cli/test/manager-auth.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { resolveManagerAuth } from '../src/manager-auth.js' +import { TokenStoreError } from '../src/token-store.js' + +const mocks = vi.hoisted(() => ({ + readState: vi.fn(), + getToken: vi.fn(), +})) + +vi.mock('../src/state.js', () => ({ readState: mocks.readState })) +vi.mock('../src/internal-token-store.js', () => ({ getToken: mocks.getToken })) + +beforeEach(() => { + vi.resetAllMocks() +}) + +describe('resolveManagerAuth', () => { + it('reports no manager when no state is stored', async () => { + mocks.readState.mockReturnValue(null) + + await expect(resolveManagerAuth()).resolves.toEqual({ + ok: false, + message: 'No manager configured. Run `ocm login ` first.', + }) + expect(mocks.getToken).not.toHaveBeenCalled() + }) + + it('does not read state when given an explicit manager URL', async () => { + mocks.readState.mockReturnValue(null) + mocks.getToken.mockResolvedValue('tok') + + await expect(resolveManagerAuth('https://mgr.example')).resolves.toEqual({ + ok: true, + managerUrl: 'https://mgr.example', + token: 'tok', + }) + expect(mocks.readState).not.toHaveBeenCalled() + expect(mocks.getToken).toHaveBeenCalledWith('https://mgr.example') + }) + + it('reports a token store failure', async () => { + mocks.readState.mockReturnValue({ managerUrl: 'https://mgr.example' }) + mocks.getToken.mockRejectedValue(new TokenStoreError('keychain locked', 'keychain')) + + await expect(resolveManagerAuth()).resolves.toEqual({ + ok: false, + message: 'Token store unavailable: keychain locked', + }) + }) + + it('reports a missing token', async () => { + mocks.readState.mockReturnValue({ managerUrl: 'https://mgr.example' }) + mocks.getToken.mockResolvedValue(null) + + await expect(resolveManagerAuth()).resolves.toEqual({ + ok: false, + message: 'No token stored. Run `ocm login https://mgr.example`.', + }) + }) + + it('resolves the manager URL and token', async () => { + mocks.readState.mockReturnValue({ managerUrl: 'https://mgr.example' }) + mocks.getToken.mockResolvedValue('tok') + + await expect(resolveManagerAuth()).resolves.toEqual({ + ok: true, + managerUrl: 'https://mgr.example', + token: 'tok', + }) + }) +}) From 87e55372c023da4daf263a53c5212523b33eed28 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Tue, 6 Oct 2026 22:29:45 -0400 Subject: [PATCH 04/11] section 4: Phase 4: Prompt and multi-select dialog helpers --- ocm-cli/src/tui-dialogs.ts | 62 +++++++++++- ocm-cli/test/tui-dialogs.test.ts | 167 ++++++++++++++++++++++++++++++- 2 files changed, 225 insertions(+), 4 deletions(-) diff --git a/ocm-cli/src/tui-dialogs.ts b/ocm-cli/src/tui-dialogs.ts index 723efced6..aa03813a3 100644 --- a/ocm-cli/src/tui-dialogs.ts +++ b/ocm-cli/src/tui-dialogs.ts @@ -1,4 +1,6 @@ -import type { Context } from '@opencode/plugin/tui/context' +import type { Context, DialogPromptOptions } from '@opencode/plugin/tui/context' + +const DONE = -1 export function confirmDialog(context: Context, props: { title: string; message: string }): Promise { return context.ui.dialog.confirm(props).then((value) => value === true) @@ -11,3 +13,61 @@ export function selectDialog( ): Promise { return context.ui.dialog.select({ title, options }) } + +export function promptDialog(context: Context, props: DialogPromptOptions): Promise { + return context.ui.dialog.prompt(props).then((value) => { + const trimmed = value?.trim() + return trimmed ? trimmed : undefined + }) +} + +export async function multiSelectDialog( + context: Context, + props: { + title: string + options: { title: string; description?: string; value: Value }[] + min: number + max: number + }, +): Promise { + const selected = new Set() + let current: number | undefined + + for (;;) { + const options = [ + { + title: `Done (${selected.size} selected)`, + value: DONE, + disabled: selected.size < props.min, + }, + ...props.options.map((option, index) => ({ + title: `${selected.has(index) ? '[x]' : '[ ]'} ${option.title}`, + description: option.description, + value: index, + disabled: !selected.has(index) && selected.size >= props.max, + })), + ] + + const choice = await context.ui.dialog.select({ title: props.title, options, current }) + + if (choice === undefined) return undefined + if (choice === DONE) { + return props.options.filter((_, index) => selected.has(index)).map((option) => option.value) + } + + current = choice + if (selected.has(choice)) { + selected.delete(choice) + } else { + selected.add(choice) + } + } +} + +export function slashArgument(input: string | undefined, name: string): string { + const trimmed = (input ?? '').trim() + const prefix = `/${name}` + if (trimmed === prefix) return '' + if (trimmed.startsWith(`${prefix} `)) return trimmed.slice(prefix.length).trim() + return trimmed +} diff --git a/ocm-cli/test/tui-dialogs.test.ts b/ocm-cli/test/tui-dialogs.test.ts index 87727d2a7..2c1648a67 100644 --- a/ocm-cli/test/tui-dialogs.test.ts +++ b/ocm-cli/test/tui-dialogs.test.ts @@ -1,14 +1,15 @@ import { describe, it, expect, vi } from 'vitest' import type { Context } from '@opencode/plugin/tui/context' -import { confirmDialog, selectDialog } from '../src/tui-dialogs.js' +import { confirmDialog, multiSelectDialog, promptDialog, selectDialog, slashArgument } from '../src/tui-dialogs.js' function createFakeContext() { const confirm = vi.fn() const select = vi.fn() + const prompt = vi.fn() const context = { - ui: { dialog: { confirm, select } }, + ui: { dialog: { confirm, select, prompt } }, } as unknown as Context - return { context, confirm, select } + return { context, confirm, select, prompt } } describe('confirmDialog', () => { @@ -55,3 +56,163 @@ describe('selectDialog', () => { expect(await selectDialog(context, 'Pick', [{ title: 'X', value: 42 }])).toBeUndefined() }) }) + +describe('promptDialog', () => { + it('forwards the options and resolves the trimmed value', async () => { + const { context, prompt } = createFakeContext() + prompt.mockResolvedValue(' fix it ') + + const props = { title: 'Goal', description: 'What do you want?', placeholder: 'Describe it', value: 'seed' } + + expect(await promptDialog(context, props)).toBe('fix it') + expect(prompt).toHaveBeenCalledWith(props) + }) + + it('resolves undefined when the dialog is dismissed', async () => { + const { context, prompt } = createFakeContext() + prompt.mockResolvedValue(undefined) + + expect(await promptDialog(context, { title: 'Goal' })).toBeUndefined() + }) + + it('resolves undefined when the value is blank', async () => { + const { context, prompt } = createFakeContext() + prompt.mockResolvedValue(' ') + + expect(await promptDialog(context, { title: 'Goal' })).toBeUndefined() + }) +}) + +describe('multiSelectDialog', () => { + const options = [ + { title: 'A', value: 'a' }, + { title: 'B', value: 'b' }, + { title: 'C', value: 'c' }, + ] + + it('resolves selected values in option order', async () => { + const { context, select } = createFakeContext() + select.mockResolvedValueOnce(0).mockResolvedValueOnce(1).mockResolvedValueOnce(-1) + + expect(await multiSelectDialog(context, { title: 'Pick', options, min: 1, max: 3 })).toEqual(['a', 'b']) + expect(select).toHaveBeenCalledTimes(3) + }) + + it('disables the done entry while fewer than min are selected', async () => { + const { context, select } = createFakeContext() + select.mockResolvedValueOnce(0).mockResolvedValueOnce(-1) + + await multiSelectDialog(context, { title: 'Pick', options, min: 2, max: 3 }) + + expect(select.mock.calls[0][0].options[0]).toEqual({ title: 'Done (0 selected)', value: -1, disabled: true }) + expect(select.mock.calls[1][0].options[0]).toEqual({ title: 'Done (1 selected)', value: -1, disabled: true }) + }) + + it('enables the done entry once min are selected', async () => { + const { context, select } = createFakeContext() + select.mockResolvedValueOnce(0).mockResolvedValueOnce(-1) + + await multiSelectDialog(context, { title: 'Pick', options, min: 1, max: 3 }) + + expect(select.mock.calls[1][0].options[0]).toEqual({ title: 'Done (1 selected)', value: -1, disabled: false }) + }) + + it('disables unselected options once max are selected', async () => { + const { context, select } = createFakeContext() + select.mockResolvedValueOnce(0).mockResolvedValueOnce(-1) + + await multiSelectDialog(context, { title: 'Pick', options, min: 1, max: 1 }) + + const second = select.mock.calls[1][0].options + expect(second[1]).toMatchObject({ title: '[x] A', value: 0, disabled: false }) + expect(second[2]).toMatchObject({ title: '[ ] B', value: 1, disabled: true }) + expect(second[3]).toMatchObject({ title: '[ ] C', value: 2, disabled: true }) + }) + + it('marks selected options with an x marker', async () => { + const { context, select } = createFakeContext() + select.mockResolvedValueOnce(1).mockResolvedValueOnce(-1) + + await multiSelectDialog(context, { title: 'Pick', options, min: 1, max: 3 }) + + const first = select.mock.calls[0][0].options + expect(first[1]).toMatchObject({ title: '[ ] A' }) + expect(first[2]).toMatchObject({ title: '[ ] B' }) + const second = select.mock.calls[1][0].options + expect(second[1]).toMatchObject({ title: '[ ] A' }) + expect(second[2]).toMatchObject({ title: '[x] B' }) + }) + + it('keeps the cursor on the last toggled entry', async () => { + const { context, select } = createFakeContext() + select.mockResolvedValueOnce(2).mockResolvedValueOnce(0).mockResolvedValueOnce(-1) + + await multiSelectDialog(context, { title: 'Pick', options, min: 1, max: 3 }) + + expect(select.mock.calls[0][0].current).toBeUndefined() + expect(select.mock.calls[1][0].current).toBe(2) + expect(select.mock.calls[2][0].current).toBe(0) + }) + + it('toggles a selected option off', async () => { + const { context, select } = createFakeContext() + select.mockResolvedValueOnce(0).mockResolvedValueOnce(1).mockResolvedValueOnce(0).mockResolvedValueOnce(-1) + + expect(await multiSelectDialog(context, { title: 'Pick', options, min: 1, max: 3 })).toEqual(['b']) + }) + + it('resolves undefined when dismissed before any selection', async () => { + const { context, select } = createFakeContext() + select.mockResolvedValueOnce(undefined) + + expect(await multiSelectDialog(context, { title: 'Pick', options, min: 1, max: 3 })).toBeUndefined() + }) + + it('resolves undefined when dismissed after a selection', async () => { + const { context, select } = createFakeContext() + select.mockResolvedValueOnce(0).mockResolvedValueOnce(undefined) + + expect(await multiSelectDialog(context, { title: 'Pick', options, min: 1, max: 3 })).toBeUndefined() + }) + + it('passes option descriptions through', async () => { + const { context, select } = createFakeContext() + select.mockResolvedValueOnce(-1) + + await multiSelectDialog(context, { + title: 'Pick', + options: [{ title: 'A', description: 'first', value: 'a' }], + min: 0, + max: 1, + }) + + expect(select.mock.calls[0][0].options[1]).toEqual({ + title: '[ ] A', + description: 'first', + value: 0, + disabled: false, + }) + }) +}) + +describe('slashArgument', () => { + it('resolves an empty string for undefined input', () => { + expect(slashArgument(undefined, 'goal')).toBe('') + }) + + it('strips a leading slash token and trims the rest', () => { + expect(slashArgument('/goal fix it ', 'goal')).toBe('fix it') + }) + + it('returns the trimmed argument when no slash token is present', () => { + expect(slashArgument(' fix it ', 'goal')).toBe('fix it') + }) + + it('only strips a whole-token match', () => { + expect(slashArgument('/goalie x', 'goal')).toBe('/goalie x') + }) + + it('resolves an empty string when only the slash token is present', () => { + expect(slashArgument('/goal', 'goal')).toBe('') + }) +}) From e63f756c3252241387d453d29f54cce7100a3231 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Tue, 6 Oct 2026 22:34:57 -0400 Subject: [PATCH 05/11] section 5: Phase 5: Session goals in the TUI (`/goal`, composer status --- ocm-cli/src/goal-store.ts | 113 ++++++++++ ocm-cli/src/tui-goal.ts | 141 +++++++++++++ ocm-cli/src/tui-plugin.ts | 19 +- ocm-cli/src/tui.tsx | 51 ++++- ocm-cli/test/goal-store.test.ts | 318 ++++++++++++++++++++++++++++ ocm-cli/test/tui-goal.test.ts | 360 ++++++++++++++++++++++++++++++++ ocm-cli/test/tui-plugin.test.ts | 20 +- 7 files changed, 1018 insertions(+), 4 deletions(-) create mode 100644 ocm-cli/src/goal-store.ts create mode 100644 ocm-cli/src/tui-goal.ts create mode 100644 ocm-cli/test/goal-store.test.ts create mode 100644 ocm-cli/test/tui-goal.test.ts diff --git a/ocm-cli/src/goal-store.ts b/ocm-cli/src/goal-store.ts new file mode 100644 index 000000000..9e4e131d5 --- /dev/null +++ b/ocm-cli/src/goal-store.ts @@ -0,0 +1,113 @@ +import type { SessionGoal } from '@opencode-manager/shared/schemas' + +export const GOAL_POLL_INTERVAL_MS = 3000 + +export type GoalStoreDeps = { + load: (sessionID: string) => Promise + onOutcome: (goal: SessionGoal) => void + pollIntervalMs?: number +} + +export type GoalStore = { + watch(sessionID: string, listener: (goal: SessionGoal | null) => void): () => void + set(goal: SessionGoal): void + refresh(sessionID: string): Promise +} + +interface GoalEntry { + readonly sessionID: string + goal: SessionGoal | null + listeners: Set<(goal: SessionGoal | null) => void> + timer: ReturnType | null +} + +export function isOpenGoal(goal: SessionGoal | null | undefined): goal is SessionGoal { + return !!goal && (goal.status === 'active' || goal.status === 'paused') +} + +function isTerminalGoal(goal: SessionGoal): boolean { + return goal.status === 'completed' || goal.status === 'blocked' || goal.status === 'stopped' +} + +export function createGoalStore(deps: GoalStoreDeps): GoalStore { + const pollIntervalMs = deps.pollIntervalMs ?? GOAL_POLL_INTERVAL_MS + const entries = new Map() + + function entryFor(sessionID: string): GoalEntry { + let entry = entries.get(sessionID) + if (!entry) { + entry = { sessionID, goal: null, listeners: new Set(), timer: null } + entries.set(sessionID, entry) + } + return entry + } + + function stopPolling(entry: GoalEntry): void { + if (entry.timer !== null) { + clearInterval(entry.timer) + entry.timer = null + } + } + + function isLive(entry: GoalEntry): boolean { + return entry.listeners.size > 0 && entries.get(entry.sessionID) === entry + } + + function startPolling(entry: GoalEntry): void { + stopPolling(entry) + entry.timer = setInterval(() => { + void loadInto(entry) + }, pollIntervalMs) + } + + function applyGoal(entry: GoalEntry, goal: SessionGoal | null): void { + const previous = entry.goal + entry.goal = goal + for (const listener of entry.listeners) listener(goal) + if (entry.listeners.size > 0 && isOpenGoal(previous) && goal !== null && isTerminalGoal(goal)) { + deps.onOutcome(goal) + } + if (goal?.status === 'active' && isLive(entry)) startPolling(entry) + else stopPolling(entry) + } + + async function loadInto(entry: GoalEntry): Promise { + let goal: SessionGoal | null + try { + goal = await deps.load(entry.sessionID) + } catch { + if (entry.goal?.status !== 'active' || !isLive(entry)) stopPolling(entry) + return + } + if (entries.get(entry.sessionID) !== entry) { + stopPolling(entry) + return + } + applyGoal(entry, goal) + } + + return { + watch(sessionID, listener) { + const entry = entryFor(sessionID) + entry.listeners.add(listener) + void loadInto(entry) + return () => { + entry.listeners.delete(listener) + if (entry.listeners.size === 0) { + stopPolling(entry) + entries.delete(sessionID) + } + } + }, + set(goal) { + const entry = entries.get(goal.sessionId) + if (!entry) return + applyGoal(entry, goal) + }, + refresh(sessionID) { + const entry = entries.get(sessionID) + if (!entry) return Promise.resolve() + return loadInto(entry) + }, + } +} diff --git a/ocm-cli/src/tui-goal.ts b/ocm-cli/src/tui-goal.ts new file mode 100644 index 000000000..075a7c816 --- /dev/null +++ b/ocm-cli/src/tui-goal.ts @@ -0,0 +1,141 @@ +import type { Context, ToastOptions } from '@opencode/plugin/tui/context' +import type { SessionGoal } from '@opencode-manager/shared/schemas' +import { getGoalOutcomeTitle, getGoalStopReasonLabel } from '@opencode-manager/shared/notifications' +import { ManagerApi, ManagerApiError, isManagerRouteMissing } from './manager-api.js' +import { resolveManagerAuth } from './manager-auth.js' +import type { ManagerAuth } from './manager-auth.js' +import { promptDialog, selectDialog, slashArgument } from './tui-dialogs.js' +import { isOpenGoal } from './goal-store.js' +import type { GoalStore } from './goal-store.js' +import type { RemoteContext } from './remote-context.js' + +export const GOALS_ATTACH_REQUIRED = + 'Goals run on OpenCode Manager. Attach with `ocm` (or move this session with /ocm-move) first.' +export const GOALS_ROUTE_MISSING = 'This OpenCode Manager does not expose goals to ocm; upgrade the Manager.' + +export type ManagerAuthOk = Extract + +export type GoalCommandDeps = { + remote: RemoteContext | undefined + store: GoalStore | undefined + createApi?: (auth: ManagerAuthOk) => ManagerApi +} + +export function formatGoalStatus(goal: SessionGoal): string { + const parts = [getGoalOutcomeTitle(goal.status), `turn ${goal.continuationCount}/${goal.maxContinuations}`] + if (goal.tokenBudget !== null) { + parts.push(`${goal.tokensUsed}/${goal.tokenBudget} tokens`) + } + parts.push(goal.objective) + return parts.join(' · ') +} + +export function goalOutcomeToast(goal: SessionGoal): ToastOptions { + const variant = goal.status === 'completed' ? 'success' : goal.status === 'blocked' ? 'error' : 'warning' + return { + variant, + title: getGoalOutcomeTitle(goal.status), + message: goal.stopReason ? getGoalStopReasonLabel(goal.stopReason) : goal.lastReason ?? goal.objective, + } +} + +function showGoalError(context: Context, error: unknown): void { + if (isManagerRouteMissing(error)) { + context.ui.toast.show({ variant: 'error', message: GOALS_ROUTE_MISSING }) + return + } + if (error instanceof ManagerApiError) { + context.ui.toast.show({ variant: 'error', message: error.message }) + return + } + context.ui.toast.show({ variant: 'error', message: error instanceof Error ? error.message : String(error) }) +} + +async function runOpenGoalAction(context: Context, api: ManagerApi, goal: SessionGoal): Promise { + const action = await selectDialog(context, 'Session goal', [ + goal.status === 'paused' + ? { title: 'Resume goal', value: 'resume' as const } + : { title: 'Pause goal', value: 'pause' as const }, + { title: 'Cancel goal', value: 'cancel' as const }, + ]) + if (!action) return undefined + switch (action) { + case 'pause': + return api.pauseSessionGoal(goal.id) + case 'resume': + return api.resumeSessionGoal(goal.id) + case 'cancel': + return api.cancelSessionGoal(goal.id) + } +} + +async function startGoal( + context: Context, + store: GoalStore, + api: ManagerApi, + sessionID: string, + directory: string, + objective: string, +): Promise { + const goal = await api.startSessionGoal({ sessionId: sessionID, directory, objective }) + store.set(goal) + try { + await context.client.session.prompt({ + sessionID, + text: objective, + delivery: context.data.session.status(sessionID) === 'running' ? 'queue' : undefined, + }) + } catch (error) { + const cancelled = await api.cancelSessionGoal(goal.id).catch(() => undefined) + if (cancelled) store.set(cancelled) + context.ui.toast.show({ variant: 'error', message: error instanceof Error ? error.message : String(error) }) + } +} + +export async function runGoalCommand(context: Context, deps: GoalCommandDeps, input?: string): Promise { + try { + if (!deps.remote || !deps.store) { + context.ui.toast.show({ variant: 'error', message: GOALS_ATTACH_REQUIRED }) + return + } + + const current = context.ui.router.current() + if (current.type !== 'session') { + context.ui.toast.show({ variant: 'error', message: 'Not in a session' }) + return + } + const sessionID = current.sessionID + const session = context.data.session.get(sessionID) + if (!session?.location.directory) { + context.ui.toast.show({ variant: 'error', message: 'Session has no directory' }) + return + } + + const auth = await resolveManagerAuth(deps.remote.managerUrl) + if (!auth.ok) { + context.ui.toast.show({ variant: 'error', message: auth.message }) + return + } + const api = deps.createApi ? deps.createApi(auth) : new ManagerApi(auth.managerUrl, auth.token) + + const existing = await api.getLatestSessionGoal(sessionID) + if (isOpenGoal(existing)) { + const result = await runOpenGoalAction(context, api, existing) + if (result) deps.store.set(result) + return + } + + const objective = + slashArgument(input, 'goal') || + (await promptDialog(context, { + title: 'Start goal', + description: 'What should this session accomplish?', + placeholder: 'Describe the goal', + })) + if (!objective) return + + await startGoal(context, deps.store, api, sessionID, session.location.directory, objective) + } catch (error) { + showGoalError(context, error) + } +} diff --git a/ocm-cli/src/tui-plugin.ts b/ocm-cli/src/tui-plugin.ts index 45fde6351..9d025fbd7 100644 --- a/ocm-cli/src/tui-plugin.ts +++ b/ocm-cli/src/tui-plugin.ts @@ -15,10 +15,18 @@ import { setPendingWarp, runPendingWarp } from './warp.js' import { pushPhaseProgress, importProgress } from './move-progress.js' import { warmRepoProxy } from './repo-proxy.js' import type { MoveProgress } from './move-progress.js' +import { runGoalCommand } from './tui-goal.js' +import type { GoalStore } from './goal-store.js' +import type { RemoteContext } from './remote-context.js' export type MoveProgressSetter = (progress: MoveProgress | null) => void -export async function setupOcm(context: Context, setMoveProgress: MoveProgressSetter): Promise<() => void> { +export type OcmFeatures = { + remote: RemoteContext | undefined + goals: GoalStore | undefined +} + +export async function setupOcm(context: Context, setMoveProgress: MoveProgressSetter, features: OcmFeatures): Promise<() => void> { showInstallNotice(context) context.ui.slot({ append: 'app', @@ -35,6 +43,15 @@ export async function setupOcm(context: Context, setMoveProgress: MoveProgressSe slash: { name: 'ocm-move' }, run: () => runSessionMove(context, setMoveProgress), }, + { + id: 'ocm.goal', + title: 'Goal', + description: 'Start, pause, resume, or cancel a Manager goal for this session', + group: 'OpenCode Manager', + palette: true, + slash: { name: 'goal', arguments: true }, + run: (input) => runGoalCommand(context, { remote: features.remote, store: features.goals }, input), + }, ], })) return null diff --git a/ocm-cli/src/tui.tsx b/ocm-cli/src/tui.tsx index 2c7285101..7263cb287 100644 --- a/ocm-cli/src/tui.tsx +++ b/ocm-cli/src/tui.tsx @@ -5,6 +5,12 @@ import { setupOcm } from './tui-plugin.js' import { formatMoveProgress } from './move-progress.js' import type { MoveProgress } from './move-progress.js' import { readRemoteContext } from './remote-context.js' +import { createGoalStore, isOpenGoal } from './goal-store.js' +import type { GoalStore } from './goal-store.js' +import { formatGoalStatus, goalOutcomeToast } from './tui-goal.js' +import { ManagerApi } from './manager-api.js' +import { resolveManagerAuth } from './manager-auth.js' +import type { SessionGoal } from '@opencode-manager/shared/schemas' const SPINNER_INTERVAL_MS = 80 @@ -44,6 +50,49 @@ export default Plugin.define({ context.ui.slot({ append: 'home.footer.status', render: indicator }) } - return setupOcm(context, setMoveProgress) + const goals = remote + ? createGoalStore({ + load: async (sessionID) => { + const auth = await resolveManagerAuth(remote.managerUrl) + if (!auth.ok) throw new Error(auth.message) + return new ManagerApi(auth.managerUrl, auth.token).getLatestSessionGoal(sessionID) + }, + onOutcome: (goal) => { + context.ui.toast.show({ ...goalOutcomeToast(goal), sessionID: goal.sessionId }) + }, + }) + : undefined + + const GoalLine = (props: { sessionID: string; store: GoalStore }) => { + const [goal, setGoal] = createSignal(null) + createEffect(() => { + const unsubscribe = props.store.watch(props.sessionID, (next) => setGoal(next)) + onCleanup(unsubscribe) + }) + const openGoal = () => { + const current = goal() + return current && isOpenGoal(current) ? current : null + } + return ( + + {(current) => ( + + + {formatGoalStatus(current())} + + + )} + + ) + } + + if (goals) { + context.ui.slot({ + append: 'session.composer.top', + render: (input) => , + }) + } + + return setupOcm(context, setMoveProgress, { remote, goals }) }, }) diff --git a/ocm-cli/test/goal-store.test.ts b/ocm-cli/test/goal-store.test.ts new file mode 100644 index 000000000..fefe5b06d --- /dev/null +++ b/ocm-cli/test/goal-store.test.ts @@ -0,0 +1,318 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import type { SessionGoal } from '@opencode-manager/shared/schemas' +import { createGoalStore, isOpenGoal, GOAL_POLL_INTERVAL_MS } from '../src/goal-store.js' + +function goal(overrides: Partial = {}): SessionGoal { + return { + id: 1, + sessionId: 'ses_a', + directory: '/repo', + objective: 'fix it', + status: 'active', + stopReason: null, + turnState: 'waiting', + continuationCount: 0, + maxContinuations: 5, + tokenBudget: null, + tokensUsed: 0, + consecutiveBlocked: 0, + lastVerdict: null, + lastReason: null, + createdAt: 1, + updatedAt: 1, + finishedAt: null, + ...overrides, + } +} + +beforeEach(() => { + vi.useFakeTimers() +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('isOpenGoal', () => { + it('accepts active and paused goals', () => { + expect(isOpenGoal(goal({ status: 'active' }))).toBe(true) + expect(isOpenGoal(goal({ status: 'paused' }))).toBe(true) + }) + + it('rejects terminal and missing goals', () => { + expect(isOpenGoal(goal({ status: 'completed' }))).toBe(false) + expect(isOpenGoal(goal({ status: 'blocked' }))).toBe(false) + expect(isOpenGoal(goal({ status: 'stopped' }))).toBe(false) + expect(isOpenGoal(null)).toBe(false) + expect(isOpenGoal(undefined)).toBe(false) + }) +}) + +describe('createGoalStore', () => { + it('defaults the poll interval to the frontend refetch interval', () => { + expect(GOAL_POLL_INTERVAL_MS).toBe(3000) + }) + + it('loads once on watch and notifies the listener with the goal', async () => { + const active = goal() + const load = vi.fn().mockResolvedValue(active) + const store = createGoalStore({ load, onOutcome: vi.fn(), pollIntervalMs: 100 }) + const listener = vi.fn() + + const unwatch = store.watch('ses_a', listener) + await vi.advanceTimersByTimeAsync(0) + + expect(load).toHaveBeenCalledTimes(1) + expect(load).toHaveBeenCalledWith('ses_a') + expect(listener).toHaveBeenCalledWith(active) + unwatch() + }) + + it('notifies the listener with null when no goal exists', async () => { + const load = vi.fn().mockResolvedValue(null) + const store = createGoalStore({ load, onOutcome: vi.fn(), pollIntervalMs: 100 }) + const listener = vi.fn() + + const unwatch = store.watch('ses_a', listener) + await vi.advanceTimersByTimeAsync(0) + + expect(listener).toHaveBeenCalledWith(null) + unwatch() + }) + + it('polls while the goal is active', async () => { + const load = vi.fn().mockResolvedValue(goal({ status: 'active' })) + const store = createGoalStore({ load, onOutcome: vi.fn(), pollIntervalMs: 100 }) + + const unwatch = store.watch('ses_a', vi.fn()) + await vi.advanceTimersByTimeAsync(0) + expect(load).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(100) + expect(load).toHaveBeenCalledTimes(2) + + await vi.advanceTimersByTimeAsync(100) + expect(load).toHaveBeenCalledTimes(3) + unwatch() + }) + + it('stops polling when the goal is paused', async () => { + const load = vi.fn().mockResolvedValue(goal({ status: 'paused' })) + const store = createGoalStore({ load, onOutcome: vi.fn(), pollIntervalMs: 100 }) + + const unwatch = store.watch('ses_a', vi.fn()) + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(1000) + + expect(load).toHaveBeenCalledTimes(1) + unwatch() + }) + + it('stops polling when the goal is terminal', async () => { + const load = vi.fn().mockResolvedValue(goal({ status: 'completed' })) + const store = createGoalStore({ load, onOutcome: vi.fn(), pollIntervalMs: 100 }) + + const unwatch = store.watch('ses_a', vi.fn()) + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(1000) + + expect(load).toHaveBeenCalledTimes(1) + unwatch() + }) + + it('updates watchers immediately on set', async () => { + const load = vi.fn().mockResolvedValue(null) + const store = createGoalStore({ load, onOutcome: vi.fn(), pollIntervalMs: 100 }) + const listener = vi.fn() + + const unwatch = store.watch('ses_a', listener) + await vi.advanceTimersByTimeAsync(0) + listener.mockClear() + + const next = goal({ status: 'active' }) + store.set(next) + + expect(listener).toHaveBeenCalledWith(next) + unwatch() + }) + + it('starts polling when set installs an active goal', async () => { + const load = vi.fn().mockResolvedValue(null) + const store = createGoalStore({ load, onOutcome: vi.fn(), pollIntervalMs: 100 }) + + const unwatch = store.watch('ses_a', vi.fn()) + await vi.advanceTimersByTimeAsync(0) + expect(load).toHaveBeenCalledTimes(1) + + store.set(goal({ status: 'active' })) + await vi.advanceTimersByTimeAsync(100) + + expect(load).toHaveBeenCalledTimes(2) + unwatch() + }) + + it('reloads on demand through refresh', async () => { + const load = vi.fn().mockResolvedValue(null) + const store = createGoalStore({ load, onOutcome: vi.fn(), pollIntervalMs: 100 }) + const listener = vi.fn() + + const unwatch = store.watch('ses_a', listener) + await vi.advanceTimersByTimeAsync(0) + listener.mockClear() + + const next = goal() + load.mockResolvedValue(next) + await store.refresh('ses_a') + + expect(load).toHaveBeenCalledTimes(2) + expect(listener).toHaveBeenCalledWith(next) + unwatch() + }) + + it('fires onOutcome once when a watched goal reaches a terminal status', async () => { + const active = goal({ status: 'active' }) + const completed = goal({ status: 'completed' }) + const load = vi.fn().mockResolvedValue(active) + const onOutcome = vi.fn() + const store = createGoalStore({ load, onOutcome, pollIntervalMs: 100 }) + + const unwatch = store.watch('ses_a', vi.fn()) + await vi.advanceTimersByTimeAsync(0) + + load.mockResolvedValue(completed) + await vi.advanceTimersByTimeAsync(100) + + expect(onOutcome).toHaveBeenCalledTimes(1) + expect(onOutcome).toHaveBeenCalledWith(completed) + + await store.refresh('ses_a') + expect(onOutcome).toHaveBeenCalledTimes(1) + unwatch() + }) + + it('fires onOutcome when a paused goal is cancelled through set', async () => { + const paused = goal({ status: 'paused' }) + const stopped = goal({ status: 'stopped', stopReason: 'cancelled' }) + const load = vi.fn().mockResolvedValue(paused) + const onOutcome = vi.fn() + const store = createGoalStore({ load, onOutcome, pollIntervalMs: 100 }) + + const unwatch = store.watch('ses_a', vi.fn()) + await vi.advanceTimersByTimeAsync(0) + + store.set(stopped) + + expect(onOutcome).toHaveBeenCalledTimes(1) + expect(onOutcome).toHaveBeenCalledWith(stopped) + unwatch() + }) + + it('does not fire onOutcome for a goal that is already terminal', async () => { + const load = vi.fn().mockResolvedValue(goal({ status: 'completed' })) + const onOutcome = vi.fn() + const store = createGoalStore({ load, onOutcome, pollIntervalMs: 100 }) + + const unwatch = store.watch('ses_a', vi.fn()) + await vi.advanceTimersByTimeAsync(0) + + expect(onOutcome).not.toHaveBeenCalled() + unwatch() + }) + + it('keeps the last goal and polling when a reload fails on an active goal', async () => { + const active = goal({ status: 'active' }) + const load = vi.fn().mockResolvedValue(active) + const store = createGoalStore({ load, onOutcome: vi.fn(), pollIntervalMs: 100 }) + const listener = vi.fn() + + const unwatch = store.watch('ses_a', listener) + await vi.advanceTimersByTimeAsync(0) + + load.mockRejectedValue(new Error('offline')) + await vi.advanceTimersByTimeAsync(100) + + expect(load).toHaveBeenCalledTimes(2) + expect(listener).toHaveBeenLastCalledWith(active) + + await vi.advanceTimersByTimeAsync(100) + expect(load).toHaveBeenCalledTimes(3) + unwatch() + }) + + it('does not poll after an initial load failure', async () => { + const load = vi.fn().mockRejectedValue(new Error('offline')) + const store = createGoalStore({ load, onOutcome: vi.fn(), pollIntervalMs: 100 }) + const listener = vi.fn() + + const unwatch = store.watch('ses_a', listener) + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(1000) + + expect(load).toHaveBeenCalledTimes(1) + expect(listener).not.toHaveBeenCalled() + unwatch() + }) + + it('stops polling when the last listener unsubscribes', async () => { + const load = vi.fn().mockResolvedValue(goal({ status: 'active' })) + const store = createGoalStore({ load, onOutcome: vi.fn(), pollIntervalMs: 100 }) + + const unwatch = store.watch('ses_a', vi.fn()) + await vi.advanceTimersByTimeAsync(0) + expect(load).toHaveBeenCalledTimes(1) + + unwatch() + await vi.advanceTimersByTimeAsync(1000) + + expect(load).toHaveBeenCalledTimes(1) + }) + + it('does not resume polling when an in-flight load resolves after the last listener unsubscribes', async () => { + let resolveLoad: (goal: SessionGoal | null) => void = () => {} + const load = vi.fn( + () => + new Promise((resolve) => { + resolveLoad = resolve + }), + ) + const store = createGoalStore({ load, onOutcome: vi.fn(), pollIntervalMs: 20 }) + + const unwatch = store.watch('ses_a', vi.fn()) + expect(load).toHaveBeenCalledTimes(1) + + unwatch() + resolveLoad(goal({ status: 'active' })) + await vi.advanceTimersByTimeAsync(0) + + await vi.advanceTimersByTimeAsync(200) + expect(load).toHaveBeenCalledTimes(1) + }) + + it('does not start polling when set targets an unwatched session', async () => { + const load = vi.fn().mockResolvedValue(goal({ status: 'active' })) + const store = createGoalStore({ load, onOutcome: vi.fn(), pollIntervalMs: 20 }) + + store.set(goal({ status: 'active' })) + await vi.advanceTimersByTimeAsync(200) + + expect(load).not.toHaveBeenCalled() + }) + + it('keeps polling while another listener remains', async () => { + const load = vi.fn().mockResolvedValue(goal({ status: 'active' })) + const store = createGoalStore({ load, onOutcome: vi.fn(), pollIntervalMs: 100 }) + const second = vi.fn() + + const unwatchFirst = store.watch('ses_a', vi.fn()) + const unwatchSecond = store.watch('ses_a', second) + await vi.advanceTimersByTimeAsync(0) + + unwatchFirst() + load.mockClear() + await vi.advanceTimersByTimeAsync(100) + + expect(load).toHaveBeenCalledTimes(1) + expect(second).toHaveBeenCalled() + unwatchSecond() + }) +}) diff --git a/ocm-cli/test/tui-goal.test.ts b/ocm-cli/test/tui-goal.test.ts new file mode 100644 index 000000000..8c7494da0 --- /dev/null +++ b/ocm-cli/test/tui-goal.test.ts @@ -0,0 +1,360 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { Context } from '@opencode/plugin/tui/context' +import type { SessionGoal } from '@opencode-manager/shared/schemas' +import { + runGoalCommand, + formatGoalStatus, + goalOutcomeToast, + GOALS_ATTACH_REQUIRED, + GOALS_ROUTE_MISSING, +} from '../src/tui-goal.js' +import { ManagerApiError } from '../src/manager-api.js' +import type { ManagerApi } from '../src/manager-api.js' +import { resolveManagerAuth } from '../src/manager-auth.js' +import type { GoalStore } from '../src/goal-store.js' +import type { RemoteContext } from '../src/remote-context.js' + +vi.mock('../src/manager-auth.js', () => ({ + resolveManagerAuth: vi.fn(), +})) + +const remote: RemoteContext = { + managerUrl: 'https://manager.example', + managerHost: 'manager.example', + repoName: 'repo', + repoId: 1, +} + +function goal(overrides: Partial = {}): SessionGoal { + return { + id: 7, + sessionId: 'ses_a', + directory: '/repo', + objective: 'fix it', + status: 'active', + stopReason: null, + turnState: 'waiting', + continuationCount: 0, + maxContinuations: 5, + tokenBudget: null, + tokensUsed: 0, + consecutiveBlocked: 0, + lastVerdict: null, + lastReason: null, + createdAt: 1, + updatedAt: 1, + finishedAt: null, + ...overrides, + } +} + +function createFakeContext(route: { type: string; sessionID?: string } = { type: 'session', sessionID: 'ses_a' }) { + const toast = vi.fn() + const select = vi.fn() + const dialogPrompt = vi.fn() + const sessionPrompt = vi.fn() + const status = vi.fn(() => 'idle') + const context = { + ui: { + router: { current: () => route }, + toast: { show: toast }, + dialog: { prompt: dialogPrompt, select }, + }, + data: { + session: { + get: () => ({ location: { directory: '/repo' } }), + status, + }, + }, + client: { session: { prompt: sessionPrompt } }, + } as unknown as Context + return { context, toast, select, dialogPrompt, sessionPrompt, status } +} + +function makeApi() { + return { + getLatestSessionGoal: vi.fn(async () => null as SessionGoal | null), + startSessionGoal: vi.fn(), + pauseSessionGoal: vi.fn(), + resumeSessionGoal: vi.fn(), + cancelSessionGoal: vi.fn(), + } +} + +function makeStore() { + const set = vi.fn() + const store: GoalStore = { + watch: vi.fn(() => () => undefined), + set, + refresh: vi.fn(async () => undefined), + } + return { store, set } +} + +function depsFor(api: ReturnType, store: GoalStore, createApi = vi.fn(() => api as unknown as ManagerApi)) { + return { remote, store, createApi } +} + +beforeEach(() => { + vi.mocked(resolveManagerAuth).mockReset() + vi.mocked(resolveManagerAuth).mockResolvedValue({ ok: true, managerUrl: remote.managerUrl, token: 'tok' }) +}) + +describe('formatGoalStatus', () => { + it('renders the outcome, turn counter, token budget and objective', () => { + expect( + formatGoalStatus(goal({ status: 'active', continuationCount: 2, maxContinuations: 5, tokenBudget: 1000, tokensUsed: 250 })), + ).toBe('Goal active · turn 2/5 · 250/1000 tokens · fix it') + }) + + it('omits the token budget when there is none', () => { + expect(formatGoalStatus(goal({ status: 'paused', tokenBudget: null }))).toBe('Goal paused · turn 0/5 · fix it') + }) +}) + +describe('goalOutcomeToast', () => { + it('maps completed to success with the last reason', () => { + expect(goalOutcomeToast(goal({ status: 'completed', lastReason: 'all green' }))).toEqual({ + variant: 'success', + title: 'Goal completed', + message: 'all green', + }) + }) + + it('maps blocked to error and falls back to the objective', () => { + expect(goalOutcomeToast(goal({ status: 'blocked', lastReason: null, objective: 'fix it' }))).toEqual({ + variant: 'error', + title: 'Goal blocked', + message: 'fix it', + }) + }) + + it('maps stopped to warning and prefers the stop reason label', () => { + expect(goalOutcomeToast(goal({ status: 'stopped', stopReason: 'cancelled' }))).toEqual({ + variant: 'warning', + title: 'Goal stopped', + message: 'Cancelled', + }) + }) +}) + +describe('runGoalCommand', () => { + it('refuses when not attached to a Manager', async () => { + const fake = createFakeContext() + const { store } = makeStore() + const createApi = vi.fn() + + await runGoalCommand(fake.context, { remote: undefined, store, createApi }, '/goal fix it') + + expect(fake.toast).toHaveBeenCalledWith({ variant: 'error', message: GOALS_ATTACH_REQUIRED }) + expect(createApi).not.toHaveBeenCalled() + }) + + it('refuses when no goal store is available', async () => { + const fake = createFakeContext() + + await runGoalCommand(fake.context, { remote, store: undefined }, '/goal fix it') + + expect(fake.toast).toHaveBeenCalledWith({ variant: 'error', message: GOALS_ATTACH_REQUIRED }) + }) + + it('refuses outside a session', async () => { + const fake = createFakeContext({ type: 'home' }) + const { store } = makeStore() + const createApi = vi.fn() + + await runGoalCommand(fake.context, { remote, store, createApi }, '/goal fix it') + + expect(fake.toast).toHaveBeenCalledWith({ variant: 'error', message: 'Not in a session' }) + expect(createApi).not.toHaveBeenCalled() + }) + + it('reports an auth failure without calling the API', async () => { + const fake = createFakeContext() + const api = makeApi() + const { store } = makeStore() + vi.mocked(resolveManagerAuth).mockResolvedValue({ ok: false, message: 'No token stored. Run `ocm login https://manager.example`.' }) + + await runGoalCommand(fake.context, depsFor(api, store), '/goal fix it') + + expect(fake.toast).toHaveBeenCalledWith({ + variant: 'error', + message: 'No token stored. Run `ocm login https://manager.example`.', + }) + expect(api.getLatestSessionGoal).not.toHaveBeenCalled() + }) + + it('starts a goal and sends the objective as the next message', async () => { + const fake = createFakeContext() + const api = makeApi() + const started = goal({ status: 'active' }) + api.startSessionGoal.mockResolvedValue(started) + const { store, set } = makeStore() + fake.sessionPrompt.mockResolvedValue(undefined) + + await runGoalCommand(fake.context, depsFor(api, store), '/goal Fix the flaky test') + + expect(api.startSessionGoal).toHaveBeenCalledWith({ sessionId: 'ses_a', directory: '/repo', objective: 'Fix the flaky test' }) + expect(set).toHaveBeenCalledWith(started) + expect(fake.sessionPrompt).toHaveBeenCalledWith({ sessionID: 'ses_a', text: 'Fix the flaky test', delivery: undefined }) + expect(api.startSessionGoal.mock.invocationCallOrder[0]).toBeLessThan(fake.sessionPrompt.mock.invocationCallOrder[0]) + }) + + it('queues the prompt while the session is running', async () => { + const fake = createFakeContext() + const api = makeApi() + api.startSessionGoal.mockResolvedValue(goal()) + const { store } = makeStore() + fake.status.mockReturnValue('running') + fake.sessionPrompt.mockResolvedValue(undefined) + + await runGoalCommand(fake.context, depsFor(api, store), '/goal Fix it') + + expect(fake.sessionPrompt).toHaveBeenCalledWith(expect.objectContaining({ delivery: 'queue' })) + }) + + it('prompts for the objective when the command has none', async () => { + const fake = createFakeContext() + const api = makeApi() + api.startSessionGoal.mockResolvedValue(goal()) + const { store } = makeStore() + fake.dialogPrompt.mockResolvedValue('from dialog') + fake.sessionPrompt.mockResolvedValue(undefined) + + await runGoalCommand(fake.context, depsFor(api, store), '/goal') + + expect(fake.dialogPrompt).toHaveBeenCalledWith(expect.objectContaining({ title: 'Start goal' })) + expect(api.startSessionGoal).toHaveBeenCalledWith(expect.objectContaining({ objective: 'from dialog' })) + }) + + it('does nothing when the start prompt is dismissed', async () => { + const fake = createFakeContext() + const api = makeApi() + const { store } = makeStore() + fake.dialogPrompt.mockResolvedValue(undefined) + + await runGoalCommand(fake.context, depsFor(api, store), '/goal') + + expect(api.startSessionGoal).not.toHaveBeenCalled() + expect(fake.sessionPrompt).not.toHaveBeenCalled() + }) + + it('cancels the goal when sending the objective fails', async () => { + const fake = createFakeContext() + const api = makeApi() + const started = goal({ status: 'active' }) + const cancelled = goal({ status: 'stopped', stopReason: 'cancelled' }) + api.startSessionGoal.mockResolvedValue(started) + api.cancelSessionGoal.mockResolvedValue(cancelled) + const { store, set } = makeStore() + fake.sessionPrompt.mockRejectedValue(new Error('send failed')) + + await runGoalCommand(fake.context, depsFor(api, store), '/goal Fix it') + + expect(api.cancelSessionGoal).toHaveBeenCalledWith(started.id) + expect(set).toHaveBeenLastCalledWith(cancelled) + expect(fake.toast).toHaveBeenCalledWith({ variant: 'error', message: 'send failed' }) + }) + + it('still reports the send failure when cancelling fails', async () => { + const fake = createFakeContext() + const api = makeApi() + api.startSessionGoal.mockResolvedValue(goal()) + api.cancelSessionGoal.mockRejectedValue(new Error('cancel failed')) + const { store, set } = makeStore() + fake.sessionPrompt.mockRejectedValue(new Error('send failed')) + + await runGoalCommand(fake.context, depsFor(api, store), '/goal Fix it') + + expect(set).toHaveBeenCalledTimes(1) + expect(fake.toast).toHaveBeenCalledWith({ variant: 'error', message: 'send failed' }) + }) + + it('offers pause and cancel for an active goal', async () => { + const fake = createFakeContext() + const api = makeApi() + const active = goal({ status: 'active' }) + const paused = goal({ status: 'paused' }) + api.getLatestSessionGoal.mockResolvedValue(active) + api.pauseSessionGoal.mockResolvedValue(paused) + const { store, set } = makeStore() + fake.select.mockResolvedValue('pause') + + await runGoalCommand(fake.context, depsFor(api, store), '/goal') + + const options = fake.select.mock.calls[0][0].options as { title: string }[] + expect(options.map((option) => option.title)).toEqual(['Pause goal', 'Cancel goal']) + expect(api.pauseSessionGoal).toHaveBeenCalledWith(active.id) + expect(set).toHaveBeenCalledWith(paused) + expect(api.startSessionGoal).not.toHaveBeenCalled() + }) + + it('offers resume and cancel for a paused goal', async () => { + const fake = createFakeContext() + const api = makeApi() + const paused = goal({ status: 'paused' }) + const resumed = goal({ status: 'active' }) + api.getLatestSessionGoal.mockResolvedValue(paused) + api.resumeSessionGoal.mockResolvedValue(resumed) + const { store, set } = makeStore() + fake.select.mockResolvedValue('resume') + + await runGoalCommand(fake.context, depsFor(api, store), '/goal') + + const options = fake.select.mock.calls[0][0].options as { title: string }[] + expect(options.map((option) => option.title)).toEqual(['Resume goal', 'Cancel goal']) + expect(api.resumeSessionGoal).toHaveBeenCalledWith(paused.id) + expect(set).toHaveBeenCalledWith(resumed) + }) + + it('cancels an open goal', async () => { + const fake = createFakeContext() + const api = makeApi() + const active = goal({ status: 'active' }) + const stopped = goal({ status: 'stopped', stopReason: 'cancelled' }) + api.getLatestSessionGoal.mockResolvedValue(active) + api.cancelSessionGoal.mockResolvedValue(stopped) + const { store, set } = makeStore() + fake.select.mockResolvedValue('cancel') + + await runGoalCommand(fake.context, depsFor(api, store), '/goal') + + expect(api.cancelSessionGoal).toHaveBeenCalledWith(active.id) + expect(set).toHaveBeenCalledWith(stopped) + }) + + it('does nothing when the goal action dialog is dismissed', async () => { + const fake = createFakeContext() + const api = makeApi() + api.getLatestSessionGoal.mockResolvedValue(goal({ status: 'active' })) + const { store, set } = makeStore() + fake.select.mockResolvedValue(undefined) + + await runGoalCommand(fake.context, depsFor(api, store), '/goal') + + expect(api.pauseSessionGoal).not.toHaveBeenCalled() + expect(set).not.toHaveBeenCalled() + }) + + it('reports a Manager API error message', async () => { + const fake = createFakeContext() + const api = makeApi() + api.getLatestSessionGoal.mockRejectedValue(new ManagerApiError('read failed', 500, 'boom', 'read session goal')) + const { store } = makeStore() + + await runGoalCommand(fake.context, depsFor(api, store), '/goal fix it') + + expect(fake.toast).toHaveBeenCalledWith({ variant: 'error', message: 'read failed' }) + }) + + it('reports an outdated Manager when the goal route is missing', async () => { + const fake = createFakeContext() + const api = makeApi() + api.getLatestSessionGoal.mockRejectedValue(new ManagerApiError('not found', 404, null, 'read session goal')) + const { store } = makeStore() + + await runGoalCommand(fake.context, depsFor(api, store), '/goal fix it') + + expect(fake.toast).toHaveBeenCalledWith({ variant: 'error', message: GOALS_ROUTE_MISSING }) + }) +}) diff --git a/ocm-cli/test/tui-plugin.test.ts b/ocm-cli/test/tui-plugin.test.ts index 1af83f7a0..16a1dd20c 100644 --- a/ocm-cli/test/tui-plugin.test.ts +++ b/ocm-cli/test/tui-plugin.test.ts @@ -74,8 +74,11 @@ vi.mock('../src/manager-api.js', () => ({ super(message) } }, + isManagerRouteMissing: vi.fn(() => false), })) +const noFeatures = { remote: undefined, goals: undefined } + const matched = { repoId: 1, name: 'repo', projectId: 'proj_1', branch: 'main' } const repos = [ { @@ -187,7 +190,7 @@ function configureMove(fake: ReturnType) { } async function invokeMove(fake: ReturnType) { - await setupOcm(fake.context, vi.fn()) + await setupOcm(fake.context, vi.fn(), noFeatures) renderAppSlot(fake) const factory = fake.layer.mock.calls[0]![0] as () => { commands: { id: string; run: () => Promise }[] } const command = factory().commands.find((entry) => entry.id === 'ocm.session.move')! @@ -203,7 +206,7 @@ describe('setupOcm', () => { it('registers commands from the app slot so the host keymap provider is mounted', async () => { const fake = createFakeContext() - await setupOcm(fake.context, vi.fn()) + await setupOcm(fake.context, vi.fn(), noFeatures) expect(fake.layer).not.toHaveBeenCalled() expect(fake.slot).toHaveBeenCalledWith(expect.objectContaining({ append: 'app' })) @@ -214,6 +217,19 @@ describe('setupOcm', () => { const factory = fake.layer.mock.calls[0]![0] as () => { mode?: string } expect(factory().mode).toBe('global') }) + + it('exposes the goal slash command', async () => { + const fake = createFakeContext() + + await setupOcm(fake.context, vi.fn(), noFeatures) + renderAppSlot(fake) + + const factory = fake.layer.mock.calls[0]![0] as () => { + commands: { id: string; slash?: { name: string; arguments?: true } }[] + } + const goal = factory().commands.find((entry) => entry.id === 'ocm.goal') + expect(goal?.slash).toEqual({ name: 'goal', arguments: true }) + }) }) describe('ocm.session.move command', () => { From 0aaa2bd3d6fbd8c06d95c858925e9b15f483a453 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Tue, 6 Oct 2026 22:38:48 -0400 Subject: [PATCH 06/11] section 6: Phase 6: Launch a multi-run from the TUI (`/multirun`) --- ocm-cli/src/manager-auth.ts | 2 + ocm-cli/src/tui-goal.ts | 4 +- ocm-cli/src/tui-multi-run.ts | 161 ++++++++++++ ocm-cli/src/tui-plugin.ts | 10 + ocm-cli/test/tui-multi-run.test.ts | 403 +++++++++++++++++++++++++++++ ocm-cli/test/tui-plugin.test.ts | 13 + 6 files changed, 590 insertions(+), 3 deletions(-) create mode 100644 ocm-cli/src/tui-multi-run.ts create mode 100644 ocm-cli/test/tui-multi-run.test.ts diff --git a/ocm-cli/src/manager-auth.ts b/ocm-cli/src/manager-auth.ts index 2afbff454..00247e77e 100644 --- a/ocm-cli/src/manager-auth.ts +++ b/ocm-cli/src/manager-auth.ts @@ -6,6 +6,8 @@ export type ManagerAuth = | { ok: true; managerUrl: string; token: string } | { ok: false; message: string } +export type ManagerAuthOk = Extract + export async function resolveManagerAuth( managerUrl: string | undefined = readState()?.managerUrl, ): Promise { diff --git a/ocm-cli/src/tui-goal.ts b/ocm-cli/src/tui-goal.ts index 075a7c816..4fda065e8 100644 --- a/ocm-cli/src/tui-goal.ts +++ b/ocm-cli/src/tui-goal.ts @@ -3,7 +3,7 @@ import type { SessionGoal } from '@opencode-manager/shared/schemas' import { getGoalOutcomeTitle, getGoalStopReasonLabel } from '@opencode-manager/shared/notifications' import { ManagerApi, ManagerApiError, isManagerRouteMissing } from './manager-api.js' import { resolveManagerAuth } from './manager-auth.js' -import type { ManagerAuth } from './manager-auth.js' +import type { ManagerAuthOk } from './manager-auth.js' import { promptDialog, selectDialog, slashArgument } from './tui-dialogs.js' import { isOpenGoal } from './goal-store.js' import type { GoalStore } from './goal-store.js' @@ -13,8 +13,6 @@ export const GOALS_ATTACH_REQUIRED = 'Goals run on OpenCode Manager. Attach with `ocm` (or move this session with /ocm-move) first.' export const GOALS_ROUTE_MISSING = 'This OpenCode Manager does not expose goals to ocm; upgrade the Manager.' -export type ManagerAuthOk = Extract - export type GoalCommandDeps = { remote: RemoteContext | undefined store: GoalStore | undefined diff --git a/ocm-cli/src/tui-multi-run.ts b/ocm-cli/src/tui-multi-run.ts new file mode 100644 index 000000000..db00a3979 --- /dev/null +++ b/ocm-cli/src/tui-multi-run.ts @@ -0,0 +1,161 @@ +import type { Context, ToastOptions } from '@opencode/plugin/tui/context' +import { + MULTI_RUN_MAX_MODELS, + type LaunchMultiRunRequest, + type MultiRun, + type MultiRunEntry, +} from '@opencode-manager/shared/schemas' +import { formatOpenCodeModelRef } from '@opencode-manager/shared/opencode' +import { ManagerApi, ManagerApiError, isManagerRouteMissing } from './manager-api.js' +import { resolveManagerAuth } from './manager-auth.js' +import type { ManagerAuthOk } from './manager-auth.js' +import { multiSelectDialog, promptDialog, selectDialog, slashArgument } from './tui-dialogs.js' +import type { RemoteContext } from './remote-context.js' + +export const MULTI_RUN_ATTACH_REQUIRED = + 'Multi-runs run on OpenCode Manager. Attach to a Manager repo with `ocm` first.' +export const MULTI_RUN_ROUTE_MISSING = 'This OpenCode Manager does not expose multi-runs to ocm; upgrade the Manager.' + +export type ModelOption = { + title: string + description: string + value: string +} + +export type MultiRunCommandDeps = { + remote: RemoteContext | undefined + createApi?: (auth: ManagerAuthOk) => ManagerApi +} + +export async function listModelOptions(context: Context): Promise { + await context.data.location.model.sync() + const models = context.data.location.model.list() ?? [] + return models + .filter((model) => model.enabled && model.status !== 'deprecated') + .map((model) => { + const ref = formatOpenCodeModelRef({ providerID: model.providerID, id: model.id }) + return { title: model.name, description: ref, value: ref } + }) +} + +export function openManagerSessions(context: Context, sessionIDs: string[]): void { + const first = sessionIDs[0] + if (!first) return + + if (context.ui.tabs.enabled()) { + for (const sessionID of sessionIDs) context.ui.tabs.open(sessionID) + context.ui.tabs.focus(first) + return + } + + context.ui.router.navigate({ type: 'session', sessionID: first }) +} + +function defaultMultiRunName(prompt: string): string { + return prompt.split('\n')[0].slice(0, 80) +} + +function startedSessionIds(run: MultiRun): string[] { + return run.entries + .filter((entry): entry is MultiRunEntry & { sessionId: string } => entry.status === 'started' && entry.sessionId !== null) + .map((entry) => entry.sessionId) +} + +function multiRunLaunchToast(run: MultiRun): ToastOptions { + const started = run.entries.filter((entry) => entry.status === 'started').length + const failed = run.entries.filter((entry) => entry.status === 'failed') + const lines = [`Launched ${run.name}: ${started}/${run.entries.length} started`] + for (const entry of failed) lines.push(`${entry.model}: ${entry.error ?? 'failed'}`) + return { variant: failed.length > 0 ? 'warning' : 'success', message: lines.join('\n') } +} + +function showMultiRunError(context: Context, error: unknown): void { + if (isManagerRouteMissing(error)) { + context.ui.toast.show({ variant: 'error', message: MULTI_RUN_ROUTE_MISSING }) + return + } + if (error instanceof ManagerApiError) { + context.ui.toast.show({ variant: 'error', message: error.message }) + return + } + context.ui.toast.show({ variant: 'error', message: error instanceof Error ? error.message : String(error) }) +} + +async function launchMultiRunFlow(context: Context, api: ManagerApi, repoId: number, prompt: string): Promise { + const name = await promptDialog(context, { + title: 'Multi-run name', + description: 'Name this group of runs.', + placeholder: 'Sweep', + value: defaultMultiRunName(prompt), + }) + if (!name) return + + const models = await multiSelectDialog(context, { + title: 'Models', + options: await listModelOptions(context), + min: 1, + max: MULTI_RUN_MAX_MODELS, + }) + if (!models) return + + const isolate = await selectDialog(context, 'Isolation', [ + { title: 'Isolated worktrees (recommended)', value: true }, + { title: 'Shared repo directory', value: false }, + ]) + if (isolate === undefined) return + + const baseRef = isolate + ? await promptDialog(context, { + title: 'Start from', + description: 'Each isolated workspace starts from this branch.', + placeholder: 'Current HEAD', + }) + : undefined + + const request: LaunchMultiRunRequest = { + repoId, + name, + prompt, + models, + isolate, + ...(isolate && baseRef ? { baseRef } : {}), + } + const run = await api.launchMultiRun(request) + context.ui.toast.show(multiRunLaunchToast(run)) + openManagerSessions(context, startedSessionIds(run)) +} + +export async function runMultiRunCommand( + context: Context, + deps: MultiRunCommandDeps, + input?: string, +): Promise { + try { + const remote = deps.remote + if (!remote?.repoId) { + context.ui.toast.show({ variant: 'error', message: MULTI_RUN_ATTACH_REQUIRED }) + return + } + + const auth = await resolveManagerAuth(remote.managerUrl) + if (!auth.ok) { + context.ui.toast.show({ variant: 'error', message: auth.message }) + return + } + + const prompt = + slashArgument(input, 'multirun') || + (await promptDialog(context, { + title: 'Multi-run prompt', + description: 'The prompt to send to every model.', + placeholder: 'Describe the task', + })) + if (!prompt) return + + const api = deps.createApi ? deps.createApi(auth) : new ManagerApi(auth.managerUrl, auth.token) + + await launchMultiRunFlow(context, api, remote.repoId, prompt) + } catch (error) { + showMultiRunError(context, error) + } +} diff --git a/ocm-cli/src/tui-plugin.ts b/ocm-cli/src/tui-plugin.ts index 9d025fbd7..a4334e8cd 100644 --- a/ocm-cli/src/tui-plugin.ts +++ b/ocm-cli/src/tui-plugin.ts @@ -16,6 +16,7 @@ import { pushPhaseProgress, importProgress } from './move-progress.js' import { warmRepoProxy } from './repo-proxy.js' import type { MoveProgress } from './move-progress.js' import { runGoalCommand } from './tui-goal.js' +import { runMultiRunCommand } from './tui-multi-run.js' import type { GoalStore } from './goal-store.js' import type { RemoteContext } from './remote-context.js' @@ -52,6 +53,15 @@ export async function setupOcm(context: Context, setMoveProgress: MoveProgressSe slash: { name: 'goal', arguments: true }, run: (input) => runGoalCommand(context, { remote: features.remote, store: features.goals }, input), }, + { + id: 'ocm.multirun', + title: 'Multi-run', + description: 'Run one prompt across several models on OpenCode Manager', + group: 'OpenCode Manager', + palette: true, + slash: { name: 'multirun', arguments: true }, + run: (input) => runMultiRunCommand(context, { remote: features.remote }, input), + }, ], })) return null diff --git a/ocm-cli/test/tui-multi-run.test.ts b/ocm-cli/test/tui-multi-run.test.ts new file mode 100644 index 000000000..aff34b26d --- /dev/null +++ b/ocm-cli/test/tui-multi-run.test.ts @@ -0,0 +1,403 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { Context } from '@opencode/plugin/tui/context' +import type { ModelInfo } from '@opencode/client' +import type { MultiRun, MultiRunEntry } from '@opencode-manager/shared/schemas' +import { + listModelOptions, + openManagerSessions, + runMultiRunCommand, + MULTI_RUN_ATTACH_REQUIRED, + MULTI_RUN_ROUTE_MISSING, +} from '../src/tui-multi-run.js' +import { ManagerApiError } from '../src/manager-api.js' +import type { ManagerApi } from '../src/manager-api.js' +import { resolveManagerAuth } from '../src/manager-auth.js' +import type { RemoteContext } from '../src/remote-context.js' + +vi.mock('../src/manager-auth.js', () => ({ + resolveManagerAuth: vi.fn(), +})) + +const remote: RemoteContext = { + managerUrl: 'https://manager.example', + managerHost: 'manager.example', + repoName: 'repo', + repoId: 1, +} + +function model(overrides: Partial = {}): ModelInfo { + return { + id: 'gpt-5', + modelID: 'gpt-5', + providerID: 'openai', + name: 'GPT-5', + capabilities: {} as ModelInfo['capabilities'], + variants: [], + time: { released: 0 }, + cost: [], + status: 'active', + enabled: true, + limit: { context: 0, output: 0 }, + ...overrides, + } as ModelInfo +} + +function entry(overrides: Partial = {}): MultiRunEntry { + return { + id: 1, + model: 'openai/gpt-5', + status: 'started', + sessionId: 'ses_1', + directory: '/work/1', + isolated: true, + error: null, + createdAt: 1, + updatedAt: 1, + ...overrides, + } +} + +function multiRun(overrides: Partial = {}): MultiRun { + return { + id: 3, + repoId: 1, + name: 'sweep', + prompt: 'hello', + isolated: true, + baseRef: null, + createdAt: 1, + entries: [entry()], + fusions: [], + ...overrides, + } +} + +function createFakeContext() { + const toast = vi.fn() + const prompt = vi.fn() + const select = vi.fn() + const confirm = vi.fn() + const open = vi.fn() + const focus = vi.fn() + const enabled = vi.fn(() => true) + const navigate = vi.fn() + const sync = vi.fn(async () => undefined) + const list = vi.fn(() => [] as ModelInfo[]) + const context = { + ui: { + toast: { show: toast }, + dialog: { prompt, select, confirm }, + tabs: { enabled, open, focus }, + router: { navigate, current: () => ({ type: 'session', sessionID: 'ses_a' }) }, + }, + data: { location: { model: { sync, list } } }, + } as unknown as Context + return { context, toast, prompt, select, confirm, open, focus, enabled, navigate, sync, list } +} + +function makeApi() { + return { + launchMultiRun: vi.fn(async () => multiRun()), + listMultiRuns: vi.fn(async () => [] as MultiRun[]), + } +} + +function depsFor(api: ReturnType, createApi = vi.fn(() => api as unknown as ManagerApi)) { + return { remote, createApi } +} + +beforeEach(() => { + vi.mocked(resolveManagerAuth).mockReset() + vi.mocked(resolveManagerAuth).mockResolvedValue({ ok: true, managerUrl: remote.managerUrl, token: 'tok' }) +}) + +describe('listModelOptions', () => { + it('syncs then maps enabled, non-deprecated models to refs', async () => { + const fake = createFakeContext() + fake.list.mockReturnValue([ + model({ providerID: 'openai', id: 'gpt-5', name: 'GPT-5' }), + model({ providerID: 'anthropic', id: 'claude', name: 'Claude' }), + model({ providerID: 'openai', id: 'old', name: 'Old', status: 'deprecated' }), + model({ providerID: 'openai', id: 'off', name: 'Off', enabled: false }), + ]) + + await expect(listModelOptions(fake.context)).resolves.toEqual([ + { title: 'GPT-5', description: 'openai/gpt-5', value: 'openai/gpt-5' }, + { title: 'Claude', description: 'anthropic/claude', value: 'anthropic/claude' }, + ]) + expect(fake.sync).toHaveBeenCalledTimes(1) + expect(fake.sync.mock.invocationCallOrder[0]).toBeLessThan(fake.list.mock.invocationCallOrder[0]) + }) + + it('tolerates a model list that is not loaded yet', async () => { + const fake = createFakeContext() + fake.list.mockReturnValue(undefined as unknown as ModelInfo[]) + + await expect(listModelOptions(fake.context)).resolves.toEqual([]) + }) +}) + +describe('openManagerSessions', () => { + it('opens a tab per session then focuses the first when tabs are enabled', () => { + const fake = createFakeContext() + + openManagerSessions(fake.context, ['ses_1', 'ses_2']) + + expect(fake.open).toHaveBeenNthCalledWith(1, 'ses_1') + expect(fake.open).toHaveBeenNthCalledWith(2, 'ses_2') + expect(fake.focus).toHaveBeenCalledWith('ses_1') + expect(fake.navigate).not.toHaveBeenCalled() + }) + + it('navigates to the first session when tabs are disabled', () => { + const fake = createFakeContext() + fake.enabled.mockReturnValue(false) + + openManagerSessions(fake.context, ['ses_1', 'ses_2']) + + expect(fake.navigate).toHaveBeenCalledWith({ type: 'session', sessionID: 'ses_1' }) + expect(fake.open).not.toHaveBeenCalled() + expect(fake.focus).not.toHaveBeenCalled() + }) + + it('does nothing for an empty session list', () => { + const fake = createFakeContext() + + openManagerSessions(fake.context, []) + + expect(fake.enabled).not.toHaveBeenCalled() + expect(fake.open).not.toHaveBeenCalled() + expect(fake.focus).not.toHaveBeenCalled() + expect(fake.navigate).not.toHaveBeenCalled() + }) +}) + +describe('runMultiRunCommand', () => { + it('refuses when not attached to a Manager', async () => { + const fake = createFakeContext() + const createApi = vi.fn() + + await runMultiRunCommand(fake.context, { remote: undefined, createApi }, '/multirun fix it') + + expect(fake.toast).toHaveBeenCalledWith({ variant: 'error', message: MULTI_RUN_ATTACH_REQUIRED }) + expect(createApi).not.toHaveBeenCalled() + }) + + it('refuses when the attach env carries no repo id', async () => { + const fake = createFakeContext() + const createApi = vi.fn() + + await runMultiRunCommand(fake.context, { remote: { ...remote, repoId: undefined }, createApi }, '/multirun fix it') + + expect(fake.toast).toHaveBeenCalledWith({ variant: 'error', message: MULTI_RUN_ATTACH_REQUIRED }) + expect(createApi).not.toHaveBeenCalled() + }) + + it('launches with the chosen name, models, isolation and base ref, then opens the sessions', async () => { + const fake = createFakeContext() + const api = makeApi() + const run = multiRun({ + name: 'sweep', + entries: [entry({ id: 1, sessionId: 'ses_1' }), entry({ id: 2, sessionId: 'ses_2' })], + }) + api.launchMultiRun.mockResolvedValue(run) + fake.list.mockReturnValue([ + model({ providerID: 'openai', id: 'gpt-5', name: 'GPT-5' }), + model({ providerID: 'anthropic', id: 'claude', name: 'Claude' }), + ]) + fake.prompt.mockResolvedValueOnce('sweep').mockResolvedValueOnce('main') + fake.select.mockResolvedValueOnce(0).mockResolvedValueOnce(1).mockResolvedValueOnce(-1).mockResolvedValueOnce(true) + + await runMultiRunCommand(fake.context, depsFor(api), '/multirun fix the bug') + + expect(fake.prompt).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ title: 'Multi-run name', value: 'fix the bug' }), + ) + expect(api.launchMultiRun).toHaveBeenCalledWith({ + repoId: 1, + name: 'sweep', + prompt: 'fix the bug', + models: ['openai/gpt-5', 'anthropic/claude'], + isolate: true, + baseRef: 'main', + }) + expect(fake.toast).toHaveBeenCalledWith({ variant: 'success', message: 'Launched sweep: 2/2 started' }) + expect(fake.open).toHaveBeenNthCalledWith(1, 'ses_1') + expect(fake.open).toHaveBeenNthCalledWith(2, 'ses_2') + expect(fake.focus).toHaveBeenCalledWith('ses_1') + }) + + it('defaults the name to the first 80 characters of the prompt first line', async () => { + const fake = createFakeContext() + const api = makeApi() + fake.list.mockReturnValue([model()]) + fake.prompt.mockResolvedValueOnce('name').mockResolvedValueOnce(undefined) + fake.select.mockResolvedValueOnce(0).mockResolvedValueOnce(-1).mockResolvedValueOnce(false) + const longFirstLine = 'a'.repeat(100) + + await runMultiRunCommand(fake.context, depsFor(api), `/multirun ${longFirstLine}\nsecond line`) + + expect(fake.prompt).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ value: 'a'.repeat(80) }), + ) + }) + + it('omits the base ref when isolation is off', async () => { + const fake = createFakeContext() + const api = makeApi() + fake.list.mockReturnValue([model()]) + fake.prompt.mockResolvedValueOnce('sweep') + fake.select.mockResolvedValueOnce(0).mockResolvedValueOnce(-1).mockResolvedValueOnce(false) + + await runMultiRunCommand(fake.context, depsFor(api), '/multirun fix it') + + expect(api.launchMultiRun).toHaveBeenCalledWith({ + repoId: 1, + name: 'sweep', + prompt: 'fix it', + models: ['openai/gpt-5'], + isolate: false, + }) + expect(fake.prompt).toHaveBeenCalledTimes(1) + }) + + it('omits the base ref when the base ref prompt is left blank', async () => { + const fake = createFakeContext() + const api = makeApi() + fake.list.mockReturnValue([model()]) + fake.prompt.mockResolvedValueOnce('sweep').mockResolvedValueOnce(undefined) + fake.select.mockResolvedValueOnce(0).mockResolvedValueOnce(-1).mockResolvedValueOnce(true) + + await runMultiRunCommand(fake.context, depsFor(api), '/multirun fix it') + + expect(api.launchMultiRun).toHaveBeenCalledWith({ + repoId: 1, + name: 'sweep', + prompt: 'fix it', + models: ['openai/gpt-5'], + isolate: true, + }) + }) + + it('reports failed entries with a warning toast', async () => { + const fake = createFakeContext() + const api = makeApi() + api.launchMultiRun.mockResolvedValue( + multiRun({ + name: 'sweep', + entries: [ + entry({ id: 1, model: 'openai/gpt-5', sessionId: 'ses_1' }), + entry({ id: 2, model: 'anthropic/claude', status: 'failed', sessionId: null, error: 'model unavailable' }), + ], + }), + ) + fake.list.mockReturnValue([model()]) + fake.prompt.mockResolvedValueOnce('sweep').mockResolvedValueOnce(undefined) + fake.select.mockResolvedValueOnce(0).mockResolvedValueOnce(-1).mockResolvedValueOnce(true) + + await runMultiRunCommand(fake.context, depsFor(api), '/multirun fix it') + + expect(fake.toast).toHaveBeenCalledWith({ + variant: 'warning', + message: 'Launched sweep: 1/2 started\nanthropic/claude: model unavailable', + }) + expect(fake.open).toHaveBeenCalledTimes(1) + expect(fake.open).toHaveBeenCalledWith('ses_1') + }) + + it('does not call the API when the name dialog is dismissed', async () => { + const fake = createFakeContext() + const api = makeApi() + fake.prompt.mockResolvedValueOnce(undefined) + + await runMultiRunCommand(fake.context, depsFor(api), '/multirun fix it') + + expect(api.launchMultiRun).not.toHaveBeenCalled() + }) + + it('does not call the API when the model dialog is dismissed', async () => { + const fake = createFakeContext() + const api = makeApi() + fake.list.mockReturnValue([model()]) + fake.prompt.mockResolvedValueOnce('sweep') + fake.select.mockResolvedValueOnce(undefined) + + await runMultiRunCommand(fake.context, depsFor(api), '/multirun fix it') + + expect(api.launchMultiRun).not.toHaveBeenCalled() + }) + + it('does not call the API when the isolation dialog is dismissed', async () => { + const fake = createFakeContext() + const api = makeApi() + fake.list.mockReturnValue([model()]) + fake.prompt.mockResolvedValueOnce('sweep') + fake.select.mockResolvedValueOnce(0).mockResolvedValueOnce(-1).mockResolvedValueOnce(undefined) + + await runMultiRunCommand(fake.context, depsFor(api), '/multirun fix it') + + expect(api.launchMultiRun).not.toHaveBeenCalled() + }) + + it('asks for the prompt when the command has none', async () => { + const fake = createFakeContext() + const api = makeApi() + fake.list.mockReturnValue([model()]) + fake.prompt.mockResolvedValueOnce('from dialog').mockResolvedValueOnce('sweep').mockResolvedValueOnce(undefined) + fake.select.mockResolvedValueOnce(0).mockResolvedValueOnce(-1).mockResolvedValueOnce(true) + + await runMultiRunCommand(fake.context, depsFor(api), '/multirun') + + expect(fake.prompt).toHaveBeenNthCalledWith(1, expect.objectContaining({ title: 'Multi-run prompt' })) + expect(api.launchMultiRun).toHaveBeenCalledWith(expect.objectContaining({ prompt: 'from dialog' })) + }) + + it('does nothing when the prompt dialog is dismissed', async () => { + const fake = createFakeContext() + const api = makeApi() + fake.prompt.mockResolvedValueOnce(undefined) + + await runMultiRunCommand(fake.context, depsFor(api), '/multirun') + + expect(api.launchMultiRun).not.toHaveBeenCalled() + }) + + it('reports an auth failure without calling the API', async () => { + const fake = createFakeContext() + const api = makeApi() + vi.mocked(resolveManagerAuth).mockResolvedValue({ ok: false, message: 'No token stored.' }) + + await runMultiRunCommand(fake.context, depsFor(api), '/multirun fix it') + + expect(fake.toast).toHaveBeenCalledWith({ variant: 'error', message: 'No token stored.' }) + expect(api.launchMultiRun).not.toHaveBeenCalled() + }) + + it('reports a Manager API error message', async () => { + const fake = createFakeContext() + const api = makeApi() + api.launchMultiRun.mockRejectedValue(new ManagerApiError('launch failed', 500, 'boom', 'launch multi-run')) + fake.list.mockReturnValue([model()]) + fake.prompt.mockResolvedValueOnce('sweep').mockResolvedValueOnce(undefined) + fake.select.mockResolvedValueOnce(0).mockResolvedValueOnce(-1).mockResolvedValueOnce(true) + + await runMultiRunCommand(fake.context, depsFor(api), '/multirun fix it') + + expect(fake.toast).toHaveBeenCalledWith({ variant: 'error', message: 'launch failed' }) + }) + + it('reports an outdated Manager when the multi-run route is missing', async () => { + const fake = createFakeContext() + const api = makeApi() + api.launchMultiRun.mockRejectedValue(new ManagerApiError('not found', 404, null, 'launch multi-run')) + fake.list.mockReturnValue([model()]) + fake.prompt.mockResolvedValueOnce('sweep').mockResolvedValueOnce(undefined) + fake.select.mockResolvedValueOnce(0).mockResolvedValueOnce(-1).mockResolvedValueOnce(true) + + await runMultiRunCommand(fake.context, depsFor(api), '/multirun fix it') + + expect(fake.toast).toHaveBeenCalledWith({ variant: 'error', message: MULTI_RUN_ROUTE_MISSING }) + }) +}) diff --git a/ocm-cli/test/tui-plugin.test.ts b/ocm-cli/test/tui-plugin.test.ts index 16a1dd20c..50326969a 100644 --- a/ocm-cli/test/tui-plugin.test.ts +++ b/ocm-cli/test/tui-plugin.test.ts @@ -230,6 +230,19 @@ describe('setupOcm', () => { const goal = factory().commands.find((entry) => entry.id === 'ocm.goal') expect(goal?.slash).toEqual({ name: 'goal', arguments: true }) }) + + it('exposes the multi-run slash command', async () => { + const fake = createFakeContext() + + await setupOcm(fake.context, vi.fn(), noFeatures) + renderAppSlot(fake) + + const factory = fake.layer.mock.calls[0]![0] as () => { + commands: { id: string; slash?: { name: string; arguments?: true } }[] + } + const multiRun = factory().commands.find((entry) => entry.id === 'ocm.multirun') + expect(multiRun?.slash).toEqual({ name: 'multirun', arguments: true }) + }) }) describe('ocm.session.move command', () => { From a9a1fd8d5a4fe86af5dd1be6dddd24d5a1ddd9eb Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Tue, 6 Oct 2026 22:41:30 -0400 Subject: [PATCH 07/11] section 7: Phase 7: Browse recent multi-runs: open, discard, and fuse f --- ocm-cli/src/tui-multi-run.ts | 207 +++++++++++++-- ocm-cli/test/tui-multi-run.test.ts | 390 +++++++++++++++++++++++++++-- 2 files changed, 563 insertions(+), 34 deletions(-) diff --git a/ocm-cli/src/tui-multi-run.ts b/ocm-cli/src/tui-multi-run.ts index db00a3979..55bd0cf33 100644 --- a/ocm-cli/src/tui-multi-run.ts +++ b/ocm-cli/src/tui-multi-run.ts @@ -1,15 +1,20 @@ +import { randomUUID } from 'node:crypto' import type { Context, ToastOptions } from '@opencode/plugin/tui/context' import { + FusionUnavailableDetailsSchema, + MULTI_RUN_FUSION_MIN_SOURCES, MULTI_RUN_MAX_MODELS, + type FuseMultiRunRequest, type LaunchMultiRunRequest, type MultiRun, type MultiRunEntry, + type MultiRunEntryStatus, } from '@opencode-manager/shared/schemas' import { formatOpenCodeModelRef } from '@opencode-manager/shared/opencode' import { ManagerApi, ManagerApiError, isManagerRouteMissing } from './manager-api.js' import { resolveManagerAuth } from './manager-auth.js' import type { ManagerAuthOk } from './manager-auth.js' -import { multiSelectDialog, promptDialog, selectDialog, slashArgument } from './tui-dialogs.js' +import { confirmDialog, multiSelectDialog, promptDialog, selectDialog, slashArgument } from './tui-dialogs.js' import type { RemoteContext } from './remote-context.js' export const MULTI_RUN_ATTACH_REQUIRED = @@ -55,10 +60,32 @@ function defaultMultiRunName(prompt: string): string { return prompt.split('\n')[0].slice(0, 80) } +const ENTRY_STATUS_LABELS: Record = { + starting: 'Starting', + started: 'Started', + failed: 'Failed', + discarded: 'Discarded', +} + +export function formatEntryStatus(entry: MultiRunEntry): string { + const parts = [ENTRY_STATUS_LABELS[entry.status], entry.isolated ? 'isolated' : 'shared directory'] + if (entry.error) parts.push(entry.error) + return parts.join(' · ') +} + +function startedSources(run: MultiRun): (MultiRunEntry & { sessionId: string })[] { + return run.entries.filter( + (entry): entry is MultiRunEntry & { sessionId: string } => entry.status === 'started' && entry.sessionId !== null, + ) +} + function startedSessionIds(run: MultiRun): string[] { - return run.entries - .filter((entry): entry is MultiRunEntry & { sessionId: string } => entry.status === 'started' && entry.sessionId !== null) - .map((entry) => entry.sessionId) + return startedSources(run).map((entry) => entry.sessionId) +} + +function runSummary(run: MultiRun): string { + const started = run.entries.filter((entry) => entry.status === 'started').length + return `${run.entries.length} models · ${started} started · ${run.fusions.length} fusions` } function multiRunLaunchToast(run: MultiRun): ToastOptions { @@ -75,6 +102,14 @@ function showMultiRunError(context: Context, error: unknown): void { return } if (error instanceof ManagerApiError) { + const unavailable = FusionUnavailableDetailsSchema.safeParse(error.details) + if (unavailable.success && unavailable.data.unavailableSources.length > 0) { + context.ui.toast.show({ + variant: 'error', + message: unavailable.data.unavailableSources.map((source) => `${source.model}: ${source.message}`).join('\n'), + }) + return + } context.ui.toast.show({ variant: 'error', message: error.message }) return } @@ -125,6 +160,153 @@ async function launchMultiRunFlow(context: Context, api: ManagerApi, repoId: num openManagerSessions(context, startedSessionIds(run)) } +type RunChoice = { kind: 'new' } | { kind: 'run'; run: MultiRun } + +type RunAction = + | { kind: 'open'; sessionId: string } + | { kind: 'fuse' } + | { kind: 'discard'; entry: MultiRunEntry } + +async function discardEntryFlow(context: Context, api: ManagerApi, run: MultiRun, entry: MultiRunEntry): Promise { + const confirmed = await confirmDialog(context, { + title: 'Discard run', + message: entry.isolated + ? `Discard ${entry.model} and remove its workspace directory? This cannot be undone.` + : `Discard ${entry.model}? This cannot be undone.`, + }) + if (!confirmed) return + + await api.discardMultiRunEntry(run.id, entry.id) + context.ui.toast.show({ variant: 'success', message: `Discarded ${entry.model}` }) +} + +async function fuseMultiRunFlow(context: Context, api: ManagerApi, run: MultiRun): Promise { + const entryIds = await multiSelectDialog(context, { + title: 'Fusion sources', + options: startedSources(run).map((entry) => ({ + title: entry.model, + description: formatEntryStatus(entry), + value: entry.id, + })), + min: MULTI_RUN_FUSION_MIN_SOURCES, + max: MULTI_RUN_MAX_MODELS, + }) + if (!entryIds) return + + const model = await selectDialog(context, 'Synthesis model', await listModelOptions(context)) + if (!model) return + + const instructions = await promptDialog(context, { + title: 'Instructions (optional)', + description: 'Optional guidance for the synthesis.', + placeholder: 'Optional guidance', + }) + + const requiresIsolation = run.entries.some((entry) => entryIds.includes(entry.id) && !entry.isolated) + let isolate = true + if (!requiresIsolation) { + const chosen = await selectDialog(context, 'Isolation', [ + { title: 'Isolated worktree (recommended)', value: true }, + { title: 'Shared repo directory', value: false }, + ]) + if (chosen === undefined) return + isolate = chosen + } + + const baseRef = isolate + ? await promptDialog(context, { + title: 'Start from', + description: 'The synthesis workspace starts from this branch.', + placeholder: 'Current HEAD', + value: run.baseRef ?? '', + }) + : undefined + + const requestId = randomUUID() + const request: FuseMultiRunRequest = { + requestId, + entryIds, + model, + isolate, + ...(instructions ? { instructions } : {}), + ...(isolate && baseRef ? { baseRef } : {}), + } + + const updated = await api.fuseMultiRun(run.id, request) + const fusion = updated.fusions.find((candidate) => candidate.requestId === requestId) + if (!fusion) return + if (fusion.status === 'failed') { + context.ui.toast.show({ variant: 'error', message: fusion.error ?? `Fusion of ${fusion.model} failed` }) + return + } + + context.ui.toast.show({ variant: 'success', message: `Fused ${fusion.model}` }) + if (fusion.sessionId) openManagerSessions(context, [fusion.sessionId]) +} + +async function browseRunFlow(context: Context, api: ManagerApi, run: MultiRun): Promise { + const options: { title: string; description?: string; value: RunAction; disabled?: boolean }[] = [] + for (const entry of run.entries) { + if (entry.sessionId) { + options.push({ + title: `Open ${entry.model}`, + description: formatEntryStatus(entry), + value: { kind: 'open', sessionId: entry.sessionId }, + }) + } + } + for (const fusion of run.fusions) { + if (fusion.sessionId) { + options.push({ title: `Open fusion ${fusion.model}`, value: { kind: 'open', sessionId: fusion.sessionId } }) + } + } + options.push({ + title: 'Fuse results…', + value: { kind: 'fuse' }, + disabled: startedSources(run).length < MULTI_RUN_FUSION_MIN_SOURCES, + }) + for (const entry of run.entries) { + if (entry.status === 'started' || entry.status === 'failed') { + options.push({ title: `Discard ${entry.model}…`, value: { kind: 'discard', entry } }) + } + } + + const action = await selectDialog(context, run.name, options) + if (!action) return + + if (action.kind === 'open') { + openManagerSessions(context, [action.sessionId]) + return + } + if (action.kind === 'discard') { + await discardEntryFlow(context, api, run, action.entry) + return + } + await fuseMultiRunFlow(context, api, run) +} + +async function browseMultiRunsFlow(context: Context, api: ManagerApi, repoId: number): Promise { + const runs = await api.listMultiRuns(repoId) + const choice = await selectDialog(context, 'Multi-runs', [ + { title: 'New multi-run…', value: { kind: 'new' } }, + ...runs.map((run) => ({ title: run.name, description: runSummary(run), value: { kind: 'run' as const, run } })), + ]) + if (!choice) return + + if (choice.kind === 'new') { + const prompt = await promptDialog(context, { + title: 'Multi-run prompt', + description: 'The prompt to send to every model.', + placeholder: 'Describe the task', + }) + if (!prompt) return + await launchMultiRunFlow(context, api, repoId, prompt) + return + } + + await browseRunFlow(context, api, choice.run) +} + export async function runMultiRunCommand( context: Context, deps: MultiRunCommandDeps, @@ -143,18 +325,15 @@ export async function runMultiRunCommand( return } - const prompt = - slashArgument(input, 'multirun') || - (await promptDialog(context, { - title: 'Multi-run prompt', - description: 'The prompt to send to every model.', - placeholder: 'Describe the task', - })) - if (!prompt) return - const api = deps.createApi ? deps.createApi(auth) : new ManagerApi(auth.managerUrl, auth.token) - await launchMultiRunFlow(context, api, remote.repoId, prompt) + const prompt = slashArgument(input, 'multirun') + if (prompt) { + await launchMultiRunFlow(context, api, remote.repoId, prompt) + return + } + + await browseMultiRunsFlow(context, api, remote.repoId) } catch (error) { showMultiRunError(context, error) } diff --git a/ocm-cli/test/tui-multi-run.test.ts b/ocm-cli/test/tui-multi-run.test.ts index aff34b26d..06c44d777 100644 --- a/ocm-cli/test/tui-multi-run.test.ts +++ b/ocm-cli/test/tui-multi-run.test.ts @@ -6,6 +6,7 @@ import { listModelOptions, openManagerSessions, runMultiRunCommand, + formatEntryStatus, MULTI_RUN_ATTACH_REQUIRED, MULTI_RUN_ROUTE_MISSING, } from '../src/tui-multi-run.js' @@ -13,6 +14,7 @@ import { ManagerApiError } from '../src/manager-api.js' import type { ManagerApi } from '../src/manager-api.js' import { resolveManagerAuth } from '../src/manager-auth.js' import type { RemoteContext } from '../src/remote-context.js' +import type { MultiRunFusion } from '@opencode-manager/shared/schemas' vi.mock('../src/manager-auth.js', () => ({ resolveManagerAuth: vi.fn(), @@ -72,6 +74,25 @@ function multiRun(overrides: Partial = {}): MultiRun { } } +function fusion(overrides: Partial = {}): MultiRunFusion { + return { + id: 9, + requestId: 'req-1', + model: 'anthropic/claude', + instructions: null, + isolated: true, + baseRef: null, + status: 'started', + sessionId: 'ses_f', + directory: '/work/f', + error: null, + sources: [], + createdAt: 1, + updatedAt: 1, + ...overrides, + } +} + function createFakeContext() { const toast = vi.fn() const prompt = vi.fn() @@ -99,6 +120,8 @@ function makeApi() { return { launchMultiRun: vi.fn(async () => multiRun()), listMultiRuns: vi.fn(async () => [] as MultiRun[]), + fuseMultiRun: vi.fn(async () => multiRun()), + discardMultiRunEntry: vi.fn(async () => multiRun()), } } @@ -172,6 +195,16 @@ describe('openManagerSessions', () => { }) }) +describe('formatEntryStatus', () => { + it('describes the status, isolation and error', () => { + expect(formatEntryStatus(entry({ status: 'started', isolated: true }))).toBe('Started · isolated') + expect(formatEntryStatus(entry({ status: 'failed', isolated: false, error: 'model unavailable' }))).toBe( + 'Failed · shared directory · model unavailable', + ) + expect(formatEntryStatus(entry({ status: 'discarded', isolated: true }))).toBe('Discarded · isolated') + }) +}) + describe('runMultiRunCommand', () => { it('refuses when not attached to a Manager', async () => { const fake = createFakeContext() @@ -341,22 +374,101 @@ describe('runMultiRunCommand', () => { expect(api.launchMultiRun).not.toHaveBeenCalled() }) - it('asks for the prompt when the command has none', async () => { + it('reports an auth failure without calling the API', async () => { const fake = createFakeContext() const api = makeApi() + vi.mocked(resolveManagerAuth).mockResolvedValue({ ok: false, message: 'No token stored.' }) + + await runMultiRunCommand(fake.context, depsFor(api), '/multirun fix it') + + expect(fake.toast).toHaveBeenCalledWith({ variant: 'error', message: 'No token stored.' }) + expect(api.launchMultiRun).not.toHaveBeenCalled() + }) + + it('reports a Manager API error message', async () => { + const fake = createFakeContext() + const api = makeApi() + api.launchMultiRun.mockRejectedValue(new ManagerApiError('launch failed', 500, 'boom', 'launch multi-run')) fake.list.mockReturnValue([model()]) - fake.prompt.mockResolvedValueOnce('from dialog').mockResolvedValueOnce('sweep').mockResolvedValueOnce(undefined) + fake.prompt.mockResolvedValueOnce('sweep').mockResolvedValueOnce(undefined) fake.select.mockResolvedValueOnce(0).mockResolvedValueOnce(-1).mockResolvedValueOnce(true) + await runMultiRunCommand(fake.context, depsFor(api), '/multirun fix it') + + expect(fake.toast).toHaveBeenCalledWith({ variant: 'error', message: 'launch failed' }) + }) + + it('reports an outdated Manager when the multi-run route is missing', async () => { + const fake = createFakeContext() + const api = makeApi() + api.launchMultiRun.mockRejectedValue(new ManagerApiError('not found', 404, null, 'launch multi-run')) + fake.list.mockReturnValue([model()]) + fake.prompt.mockResolvedValueOnce('sweep').mockResolvedValueOnce(undefined) + fake.select.mockResolvedValueOnce(0).mockResolvedValueOnce(-1).mockResolvedValueOnce(true) + + await runMultiRunCommand(fake.context, depsFor(api), '/multirun fix it') + + expect(fake.toast).toHaveBeenCalledWith({ variant: 'error', message: MULTI_RUN_ROUTE_MISSING }) + }) +}) + +describe('runMultiRunCommand browse', () => { + it('lists recent runs with a new multi-run option first', async () => { + const fake = createFakeContext() + const api = makeApi() + api.listMultiRuns.mockResolvedValue([ + multiRun({ + id: 3, + name: 'sweep', + entries: [entry({ id: 1, status: 'started' }), entry({ id: 2, status: 'failed', sessionId: null })], + fusions: [fusion()], + }), + ]) + fake.select.mockResolvedValueOnce(undefined) + + await runMultiRunCommand(fake.context, depsFor(api), '/multirun') + + expect(api.listMultiRuns).toHaveBeenCalledWith(1) + expect(fake.select).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Multi-runs', + options: [ + { title: 'New multi-run…', value: { kind: 'new' } }, + expect.objectContaining({ + title: 'sweep', + description: '2 models · 1 started · 1 fusions', + value: expect.objectContaining({ kind: 'run' }), + }), + ], + }), + ) + expect(api.launchMultiRun).not.toHaveBeenCalled() + }) + + it('launches a new multi-run from the list', async () => { + const fake = createFakeContext() + const api = makeApi() + fake.list.mockReturnValue([model()]) + fake.select + .mockResolvedValueOnce({ kind: 'new' }) + .mockResolvedValueOnce(0) + .mockResolvedValueOnce(-1) + .mockResolvedValueOnce(false) + fake.prompt.mockResolvedValueOnce('from list').mockResolvedValueOnce('sweep') + await runMultiRunCommand(fake.context, depsFor(api), '/multirun') expect(fake.prompt).toHaveBeenNthCalledWith(1, expect.objectContaining({ title: 'Multi-run prompt' })) - expect(api.launchMultiRun).toHaveBeenCalledWith(expect.objectContaining({ prompt: 'from dialog' })) + expect(fake.prompt).toHaveBeenNthCalledWith(2, expect.objectContaining({ title: 'Multi-run name' })) + expect(api.launchMultiRun).toHaveBeenCalledWith( + expect.objectContaining({ repoId: 1, prompt: 'from list', name: 'sweep' }), + ) }) - it('does nothing when the prompt dialog is dismissed', async () => { + it('does nothing when the new multi-run prompt is dismissed', async () => { const fake = createFakeContext() const api = makeApi() + fake.select.mockResolvedValueOnce({ kind: 'new' }) fake.prompt.mockResolvedValueOnce(undefined) await runMultiRunCommand(fake.context, depsFor(api), '/multirun') @@ -364,39 +476,277 @@ describe('runMultiRunCommand', () => { expect(api.launchMultiRun).not.toHaveBeenCalled() }) - it('reports an auth failure without calling the API', async () => { + it('opens an entry session from the run menu', async () => { const fake = createFakeContext() const api = makeApi() - vi.mocked(resolveManagerAuth).mockResolvedValue({ ok: false, message: 'No token stored.' }) + const run = multiRun({ id: 3, name: 'sweep', entries: [entry({ id: 1, sessionId: 'ses_1' })], fusions: [] }) + api.listMultiRuns.mockResolvedValue([run]) + fake.select + .mockResolvedValueOnce({ kind: 'run', run }) + .mockResolvedValueOnce({ kind: 'open', sessionId: 'ses_1' }) - await runMultiRunCommand(fake.context, depsFor(api), '/multirun fix it') + await runMultiRunCommand(fake.context, depsFor(api), '/multirun') - expect(fake.toast).toHaveBeenCalledWith({ variant: 'error', message: 'No token stored.' }) - expect(api.launchMultiRun).not.toHaveBeenCalled() + expect(fake.select).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + title: 'sweep', + options: expect.arrayContaining([ + expect.objectContaining({ + title: 'Open openai/gpt-5', + description: 'Started · isolated', + value: { kind: 'open', sessionId: 'ses_1' }, + }), + ]), + }), + ) + expect(fake.open).toHaveBeenCalledWith('ses_1') + expect(fake.focus).toHaveBeenCalledWith('ses_1') }) - it('reports a Manager API error message', async () => { + it('opens a fusion session from the run menu', async () => { const fake = createFakeContext() const api = makeApi() - api.launchMultiRun.mockRejectedValue(new ManagerApiError('launch failed', 500, 'boom', 'launch multi-run')) + const run = multiRun({ id: 3, name: 'sweep', entries: [], fusions: [fusion({ sessionId: 'ses_f' })] }) + api.listMultiRuns.mockResolvedValue([run]) + fake.select + .mockResolvedValueOnce({ kind: 'run', run }) + .mockResolvedValueOnce({ kind: 'open', sessionId: 'ses_f' }) + + await runMultiRunCommand(fake.context, depsFor(api), '/multirun') + + expect(fake.select).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + options: expect.arrayContaining([ + expect.objectContaining({ title: 'Open fusion anthropic/claude', value: { kind: 'open', sessionId: 'ses_f' } }), + ]), + }), + ) + expect(fake.open).toHaveBeenCalledWith('ses_f') + }) + + it('disables fusion until enough results have started', async () => { + const fake = createFakeContext() + const api = makeApi() + const run = multiRun({ id: 3, name: 'sweep', entries: [entry({ id: 1, status: 'started' })], fusions: [] }) + api.listMultiRuns.mockResolvedValue([run]) + fake.select.mockResolvedValueOnce({ kind: 'run', run }).mockResolvedValueOnce(undefined) + + await runMultiRunCommand(fake.context, depsFor(api), '/multirun') + + expect(fake.select).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + options: expect.arrayContaining([ + expect.objectContaining({ title: 'Fuse results…', disabled: true, value: { kind: 'fuse' } }), + ]), + }), + ) + }) + + it('discards an entry after confirmation', async () => { + const fake = createFakeContext() + const api = makeApi() + const run = multiRun({ id: 3, name: 'sweep', entries: [entry({ id: 1, isolated: true })], fusions: [] }) + api.listMultiRuns.mockResolvedValue([run]) + fake.select + .mockResolvedValueOnce({ kind: 'run', run }) + .mockResolvedValueOnce({ kind: 'discard', entry: run.entries[0] }) + fake.confirm.mockResolvedValueOnce(true) + + await runMultiRunCommand(fake.context, depsFor(api), '/multirun') + + expect(fake.confirm).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining('workspace directory') }), + ) + expect(api.discardMultiRunEntry).toHaveBeenCalledWith(3, 1) + expect(fake.toast).toHaveBeenCalledWith({ variant: 'success', message: 'Discarded openai/gpt-5' }) + }) + + it('uses the plain discard message for a shared entry and skips the API on decline', async () => { + const fake = createFakeContext() + const api = makeApi() + const run = multiRun({ id: 3, name: 'sweep', entries: [entry({ id: 1, isolated: false })], fusions: [] }) + api.listMultiRuns.mockResolvedValue([run]) + fake.select + .mockResolvedValueOnce({ kind: 'run', run }) + .mockResolvedValueOnce({ kind: 'discard', entry: run.entries[0] }) + fake.confirm.mockResolvedValueOnce(false) + + await runMultiRunCommand(fake.context, depsFor(api), '/multirun') + + expect(fake.confirm).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.not.stringContaining('workspace directory') }), + ) + expect(api.discardMultiRunEntry).not.toHaveBeenCalled() + }) + + it('fuses selected results and opens the fusion session', async () => { + const fake = createFakeContext() + const api = makeApi() + const run = multiRun({ + id: 3, + name: 'sweep', + baseRef: 'main', + entries: [ + entry({ id: 1, sessionId: 'ses_1', isolated: true }), + entry({ id: 2, sessionId: 'ses_2', isolated: true }), + ], + fusions: [], + }) + api.listMultiRuns.mockResolvedValue([run]) + api.fuseMultiRun.mockImplementation(async (_runId, request) => + multiRun({ fusions: [fusion({ requestId: request.requestId, sessionId: 'ses_f' })] }), + ) + fake.list.mockReturnValue([model({ providerID: 'anthropic', id: 'claude', name: 'Claude' })]) + fake.select + .mockResolvedValueOnce({ kind: 'run', run }) + .mockResolvedValueOnce({ kind: 'fuse' }) + .mockResolvedValueOnce(0) + .mockResolvedValueOnce(1) + .mockResolvedValueOnce(-1) + .mockResolvedValueOnce('anthropic/claude') + .mockResolvedValueOnce(true) + fake.prompt.mockResolvedValueOnce('compare').mockResolvedValueOnce('main') + + await runMultiRunCommand(fake.context, depsFor(api), '/multirun') + + expect(api.fuseMultiRun).toHaveBeenCalledWith( + 3, + expect.objectContaining({ + entryIds: [1, 2], + model: 'anthropic/claude', + instructions: 'compare', + isolate: true, + baseRef: 'main', + requestId: expect.any(String), + }), + ) + expect(fake.prompt).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ title: 'Start from', value: 'main' }), + ) + expect(fake.open).toHaveBeenCalledWith('ses_f') + }) + + it('forces isolation when a selected source is not isolated', async () => { + const fake = createFakeContext() + const api = makeApi() + const run = multiRun({ + id: 3, + name: 'sweep', + entries: [ + entry({ id: 1, sessionId: 'ses_1', isolated: false }), + entry({ id: 2, sessionId: 'ses_2', isolated: true }), + ], + fusions: [], + }) + api.listMultiRuns.mockResolvedValue([run]) + api.fuseMultiRun.mockImplementation(async (_runId, request) => + multiRun({ fusions: [fusion({ requestId: request.requestId })] }), + ) + fake.list.mockReturnValue([model({ providerID: 'anthropic', id: 'claude', name: 'Claude' })]) + fake.select + .mockResolvedValueOnce({ kind: 'run', run }) + .mockResolvedValueOnce({ kind: 'fuse' }) + .mockResolvedValueOnce(0) + .mockResolvedValueOnce(1) + .mockResolvedValueOnce(-1) + .mockResolvedValueOnce('anthropic/claude') + fake.prompt.mockResolvedValueOnce(undefined).mockResolvedValueOnce(undefined) + + await runMultiRunCommand(fake.context, depsFor(api), '/multirun') + + expect(api.fuseMultiRun).toHaveBeenCalledWith(3, expect.objectContaining({ isolate: true })) + expect(fake.select).toHaveBeenCalledTimes(6) + }) + + it('reports a failed fusion', async () => { + const fake = createFakeContext() + const api = makeApi() + const run = multiRun({ + id: 3, + name: 'sweep', + entries: [entry({ id: 1, sessionId: 'ses_1' }), entry({ id: 2, sessionId: 'ses_2' })], + fusions: [], + }) + api.listMultiRuns.mockResolvedValue([run]) + api.fuseMultiRun.mockImplementation(async (_runId, request) => + multiRun({ + fusions: [fusion({ requestId: request.requestId, status: 'failed', sessionId: null, error: 'model overloaded' })], + }), + ) fake.list.mockReturnValue([model()]) - fake.prompt.mockResolvedValueOnce('sweep').mockResolvedValueOnce(undefined) - fake.select.mockResolvedValueOnce(0).mockResolvedValueOnce(-1).mockResolvedValueOnce(true) + fake.select + .mockResolvedValueOnce({ kind: 'run', run }) + .mockResolvedValueOnce({ kind: 'fuse' }) + .mockResolvedValueOnce(0) + .mockResolvedValueOnce(1) + .mockResolvedValueOnce(-1) + .mockResolvedValueOnce('openai/gpt-5') + .mockResolvedValueOnce(false) + fake.prompt.mockResolvedValueOnce(undefined) - await runMultiRunCommand(fake.context, depsFor(api), '/multirun fix it') + await runMultiRunCommand(fake.context, depsFor(api), '/multirun') - expect(fake.toast).toHaveBeenCalledWith({ variant: 'error', message: 'launch failed' }) + expect(fake.toast).toHaveBeenCalledWith({ variant: 'error', message: 'model overloaded' }) + expect(fake.open).not.toHaveBeenCalled() }) - it('reports an outdated Manager when the multi-run route is missing', async () => { + it('lists unavailable sources when the Manager rejects the fusion', async () => { const fake = createFakeContext() const api = makeApi() - api.launchMultiRun.mockRejectedValue(new ManagerApiError('not found', 404, null, 'launch multi-run')) + const run = multiRun({ + id: 3, + name: 'sweep', + entries: [entry({ id: 1, sessionId: 'ses_1' }), entry({ id: 2, sessionId: 'ses_2' })], + fusions: [], + }) + api.listMultiRuns.mockResolvedValue([run]) + api.fuseMultiRun.mockRejectedValue( + new ManagerApiError('conflict', 409, 'FUSION_SOURCES_UNAVAILABLE', 'fuse multi-run', { + unavailableSources: [ + { entryId: 1, model: 'openai/gpt-5', reason: 'not-started', message: 'has not started' }, + { entryId: 2, model: 'anthropic/claude', reason: 'failed', message: 'failed' }, + ], + }), + ) fake.list.mockReturnValue([model()]) - fake.prompt.mockResolvedValueOnce('sweep').mockResolvedValueOnce(undefined) - fake.select.mockResolvedValueOnce(0).mockResolvedValueOnce(-1).mockResolvedValueOnce(true) + fake.select + .mockResolvedValueOnce({ kind: 'run', run }) + .mockResolvedValueOnce({ kind: 'fuse' }) + .mockResolvedValueOnce(0) + .mockResolvedValueOnce(1) + .mockResolvedValueOnce(-1) + .mockResolvedValueOnce('openai/gpt-5') + .mockResolvedValueOnce(false) + fake.prompt.mockResolvedValueOnce(undefined) - await runMultiRunCommand(fake.context, depsFor(api), '/multirun fix it') + await runMultiRunCommand(fake.context, depsFor(api), '/multirun') + + expect(fake.toast).toHaveBeenCalledWith({ + variant: 'error', + message: 'openai/gpt-5: has not started\nanthropic/claude: failed', + }) + }) + + it('reports a list failure', async () => { + const fake = createFakeContext() + const api = makeApi() + api.listMultiRuns.mockRejectedValue(new ManagerApiError('list failed', 500, 'boom', 'list multi-runs')) + + await runMultiRunCommand(fake.context, depsFor(api), '/multirun') + + expect(fake.toast).toHaveBeenCalledWith({ variant: 'error', message: 'list failed' }) + }) + + it('reports an outdated Manager when the list route is missing', async () => { + const fake = createFakeContext() + const api = makeApi() + api.listMultiRuns.mockRejectedValue(new ManagerApiError('not found', 404, null, 'list multi-runs')) + + await runMultiRunCommand(fake.context, depsFor(api), '/multirun') expect(fake.toast).toHaveBeenCalledWith({ variant: 'error', message: MULTI_RUN_ROUTE_MISSING }) }) From 1e852c935a5b9c49720424eb1b7779387aa36850 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Tue, 6 Oct 2026 22:50:15 -0400 Subject: [PATCH 08/11] feat(tui): add session goals, multi-runs and server switching to the ocm TUI Expose session goals and multi-runs on the Manager internal API, add shared goal label formatters, and add the /goal, /multirun and /ocm commands with their dialogs, Manager auth and API helpers, and tests. --- .../test/routes/internal-multi-runs.test.ts | 1 - .../test/services/notification-format.test.ts | 16 + .../opencode-manager-tool-plugin.test.ts | 8 + .../src/components/session/SessionGoalBar.tsx | 9 +- ocm-cli/README.md | 31 + ocm-cli/bin/ocm.ts | 3 +- ocm-cli/src/manager-auth.ts | 8 +- ocm-cli/src/tui-dialogs.ts | 56 +- ocm-cli/src/tui-form.tsx | 289 ++++++++ ocm-cli/src/tui-goal-dialog.tsx | 189 +++++ ocm-cli/src/tui-goal.ts | 215 ++++-- ocm-cli/src/tui-multi-run-dialogs.tsx | 569 +++++++++++++++ ocm-cli/src/tui-multi-run.ts | 392 +++++------ ocm-cli/src/tui-ocm.ts | 115 +++ ocm-cli/src/tui-plugin.ts | 38 +- ocm-cli/src/tui.tsx | 12 +- ocm-cli/src/warp.ts | 34 +- ocm-cli/test/manager-api.test.ts | 4 +- ocm-cli/test/manager-auth.test.ts | 11 + ocm-cli/test/tui-dialogs.test.ts | 145 +--- ocm-cli/test/tui-goal.test.ts | 249 ++++--- ocm-cli/test/tui-multi-run.test.ts | 665 ++++++------------ ocm-cli/test/tui-ocm.test.ts | 236 +++++++ ocm-cli/test/tui-plugin.test.ts | 15 +- ocm-cli/test/warp.test.ts | 43 +- shared/src/notifications/format.ts | 11 +- 26 files changed, 2288 insertions(+), 1076 deletions(-) create mode 100644 ocm-cli/src/tui-form.tsx create mode 100644 ocm-cli/src/tui-goal-dialog.tsx create mode 100644 ocm-cli/src/tui-multi-run-dialogs.tsx create mode 100644 ocm-cli/src/tui-ocm.ts create mode 100644 ocm-cli/test/tui-ocm.test.ts diff --git a/backend/test/routes/internal-multi-runs.test.ts b/backend/test/routes/internal-multi-runs.test.ts index 39bf0867a..06a830365 100644 --- a/backend/test/routes/internal-multi-runs.test.ts +++ b/backend/test/routes/internal-multi-runs.test.ts @@ -44,7 +44,6 @@ function fuseBody(overrides: Record = {}) { requestId: '11111111-1111-4111-8111-111111111111', entryIds: [1, 2], model: 'openai/a', - isolate: true, ...overrides, } } diff --git a/backend/test/services/notification-format.test.ts b/backend/test/services/notification-format.test.ts index c587fb61d..c83e30f7e 100644 --- a/backend/test/services/notification-format.test.ts +++ b/backend/test/services/notification-format.test.ts @@ -5,6 +5,8 @@ import { getFormText, getGoalOutcomeTitle, getGoalStopReasonLabel, + getGoalTurnLabel, + getGoalTokenLabel, } from '@opencode-manager/shared/notifications' import { buildEventNotificationPayload, buildNotificationUrl } from '../../src/services/notification' import { ASSISTANT_REPO_ID } from '@opencode-manager/shared/utils' @@ -35,6 +37,20 @@ describe('goal notification labels', () => { }) }) +describe('goal progress labels', () => { + it('formats the turn counter', () => { + expect(getGoalTurnLabel({ continuationCount: 2, maxContinuations: 20 })).toBe('Turn 2/20') + }) + + it('formats the token usage with locale separators', () => { + expect(getGoalTokenLabel({ tokensUsed: 250, tokenBudget: 1000 })).toBe('250/1,000 tokens') + }) + + it('returns null when there is no token budget', () => { + expect(getGoalTokenLabel({ tokensUsed: 250, tokenBudget: null })).toBeNull() + }) +}) + describe('getPermissionLabel', () => { it('maps known V2 permission actions to friendly labels', () => { expect(getPermissionLabel('shell')).toBe('Run Command') diff --git a/backend/test/services/opencode-manager-tool-plugin.test.ts b/backend/test/services/opencode-manager-tool-plugin.test.ts index 028fdbb29..63e446ffe 100644 --- a/backend/test/services/opencode-manager-tool-plugin.test.ts +++ b/backend/test/services/opencode-manager-tool-plugin.test.ts @@ -289,6 +289,14 @@ describe('ocm-manager plugin', () => { ['DELETE', '/settings'], ['PUT', '/opencode-config'], ['DELETE', '/sessions/abc'], + ['GET', '/session-goals'], + ['POST', '/session-goals'], + ['POST', '/session-goals/1/pause'], + ['POST', '/session-goals/1/cancel'], + ['GET', '/multi-runs'], + ['POST', '/multi-runs'], + ['POST', '/multi-runs/1/fusions'], + ['POST', '/multi-runs/1/entries/2/discard'], ] as const for (const [method, path] of deniedRoutes) { diff --git a/frontend/src/components/session/SessionGoalBar.tsx b/frontend/src/components/session/SessionGoalBar.tsx index 44dd797be..769c6012d 100644 --- a/frontend/src/components/session/SessionGoalBar.tsx +++ b/frontend/src/components/session/SessionGoalBar.tsx @@ -8,7 +8,7 @@ import { useResumeSessionGoal, useSessionGoal, } from '@/hooks/useSessionGoals' -import { getGoalOutcomeTitle, getGoalStopReasonLabel } from '@opencode-manager/shared/notifications' +import { getGoalOutcomeTitle, getGoalStopReasonLabel, getGoalTokenLabel, getGoalTurnLabel } from '@opencode-manager/shared/notifications' import type { SessionGoalStatus } from '@opencode-manager/shared/schemas' const STATUS_CHIP_CLASSES: Record = { @@ -38,6 +38,7 @@ export function SessionGoalBar({ sessionID }: { sessionID: string }) { const terminal = isTerminal(goal.status) const reason = goal.stopReason ? getGoalStopReasonLabel(goal.stopReason) : goal.lastReason + const tokenLabel = getGoalTokenLabel(goal) return (
@@ -52,13 +53,13 @@ export function SessionGoalBar({ sessionID }: { sessionID: string }) { {!terminal && ( - Turn {goal.continuationCount}/{goal.maxContinuations} + {getGoalTurnLabel(goal)} )} - {!terminal && goal.tokenBudget !== null && ( + {!terminal && tokenLabel !== null && ( - {goal.tokensUsed.toLocaleString()}/{goal.tokenBudget.toLocaleString()} tokens + {tokenLabel} )} diff --git a/ocm-cli/README.md b/ocm-cli/README.md index 842144cfd..244a9cc16 100644 --- a/ocm-cli/README.md +++ b/ocm-cli/README.md @@ -144,6 +144,37 @@ on the Manager immediately. Use it from inside an OpenCode session after `ocm login` and after the repo already exists on the Manager (`ocm push --create` if needed). +`/ocm` switches this TUI to another server, the same way the `ocm` CLI picks +one. In a local TUI it finds the Manager repo that matches the current +directory (by git origin) and asks to attach to it; when no repo or several +repos match, it shows a picker of the Manager's ready repos. In a TUI that is +already attached, the picker offers the other Manager repos and **Local +opencode**. Switching exits the TUI and reattaches; the current session stays +where it is (use `/ocm-move` to bring a local session along). + +The plugin also registers two Manager-backed commands, both of which need an +attached Manager repo through `ocm`: + +- `/goal [objective]` starts a Manager-driven goal on the current top-level + session and sends the objective as the next message. With no objective, a + dialog asks for it, plus optional max turns and token budget (blank uses the + Manager defaults). Scheduled runs and subagent sessions are rejected by the + Manager. While a goal is open, a status line above the composer shows its + turn and token usage, and a toast reports the outcome. Running `/goal` while + a goal is open shows its live status with `p` to pause or resume and `x` + (twice) to cancel. +- `/multirun [prompt]` opens a launch dialog: prompt, name, a filterable model + checklist (up to 5), isolated worktrees or the shared repo directory, and an + optional base ref. `ctrl+s` launches, and the started sessions open in tabs. + With no prompt, `/multirun` opens the runs browser for the attached repo: + pick a run, open entry or fusion sessions, discard entries (`d` twice), select + results with `space`, and press `f` to fuse them with a synthesis model. A + fusion always runs in a new worktree. + +Both commands need an OpenCode Manager release that exposes +`/api/internal/session-goals` and `/api/internal/multi-runs`. An older Manager +answers these requests with `401 Unauthorized`. + Enable it in `~/.config/opencode/cli.json`: ```jsonc diff --git a/ocm-cli/bin/ocm.ts b/ocm-cli/bin/ocm.ts index 140af6896..ca384e5f6 100644 --- a/ocm-cli/bin/ocm.ts +++ b/ocm-cli/bin/ocm.ts @@ -14,6 +14,7 @@ import { resolveTarget, formatRepoIdentities, parseRepoIdPositional, restrictMat import { buildAttachInvocation } from '../src/warp.js' import { type ManagerRepo, fetchRepos, toRemoteRepoSummaries } from '../src/manager-repos.js' import { OCM_VERSION as VERSION, warmRepoProxy } from '../src/repo-proxy.js' +import { normalizeManagerUrl } from '../src/manager-auth.js' const USAGE = `ocm v${VERSION} - OpenCode Manager workspace launcher @@ -151,7 +152,7 @@ function findRepo(repos: ManagerRepo[], needle: string | number): ManagerRepo | export async function cmdLogin(args: string[]): Promise { const url = args[0] if (!url) die('usage: ocm login [token]') - const normalisedUrl = url.replace(/\/+$/, '') + const normalisedUrl = normalizeManagerUrl(url) let token = args[1] if (!token) { diff --git a/ocm-cli/src/manager-auth.ts b/ocm-cli/src/manager-auth.ts index 00247e77e..687d77982 100644 --- a/ocm-cli/src/manager-auth.ts +++ b/ocm-cli/src/manager-auth.ts @@ -8,9 +8,15 @@ export type ManagerAuth = export type ManagerAuthOk = Extract +/** Canonical Manager URL form used as the token-store account: trimmed, without trailing slashes. */ +export function normalizeManagerUrl(url: string): string { + return url.trim().replace(/\/+$/, '') +} + export async function resolveManagerAuth( - managerUrl: string | undefined = readState()?.managerUrl, + rawManagerUrl: string | undefined = readState()?.managerUrl, ): Promise { + const managerUrl = rawManagerUrl ? normalizeManagerUrl(rawManagerUrl) : '' if (!managerUrl) { return { ok: false, message: 'No manager configured. Run `ocm login ` first.' } } diff --git a/ocm-cli/src/tui-dialogs.ts b/ocm-cli/src/tui-dialogs.ts index aa03813a3..6ed62584e 100644 --- a/ocm-cli/src/tui-dialogs.ts +++ b/ocm-cli/src/tui-dialogs.ts @@ -1,6 +1,4 @@ -import type { Context, DialogPromptOptions } from '@opencode/plugin/tui/context' - -const DONE = -1 +import type { Context, DialogSelectOption } from '@opencode/plugin/tui/context' export function confirmDialog(context: Context, props: { title: string; message: string }): Promise { return context.ui.dialog.confirm(props).then((value) => value === true) @@ -9,61 +7,11 @@ export function confirmDialog(context: Context, props: { title: string; message: export function selectDialog( context: Context, title: string, - options: { title: string; description?: string; value: Value }[], + options: readonly DialogSelectOption[], ): Promise { return context.ui.dialog.select({ title, options }) } -export function promptDialog(context: Context, props: DialogPromptOptions): Promise { - return context.ui.dialog.prompt(props).then((value) => { - const trimmed = value?.trim() - return trimmed ? trimmed : undefined - }) -} - -export async function multiSelectDialog( - context: Context, - props: { - title: string - options: { title: string; description?: string; value: Value }[] - min: number - max: number - }, -): Promise { - const selected = new Set() - let current: number | undefined - - for (;;) { - const options = [ - { - title: `Done (${selected.size} selected)`, - value: DONE, - disabled: selected.size < props.min, - }, - ...props.options.map((option, index) => ({ - title: `${selected.has(index) ? '[x]' : '[ ]'} ${option.title}`, - description: option.description, - value: index, - disabled: !selected.has(index) && selected.size >= props.max, - })), - ] - - const choice = await context.ui.dialog.select({ title: props.title, options, current }) - - if (choice === undefined) return undefined - if (choice === DONE) { - return props.options.filter((_, index) => selected.has(index)).map((option) => option.value) - } - - current = choice - if (selected.has(choice)) { - selected.delete(choice) - } else { - selected.add(choice) - } - } -} - export function slashArgument(input: string | undefined, name: string): string { const trimmed = (input ?? '').trim() const prefix = `/${name}` diff --git a/ocm-cli/src/tui-form.tsx b/ocm-cli/src/tui-form.tsx new file mode 100644 index 000000000..7f8c57a6d --- /dev/null +++ b/ocm-cli/src/tui-form.tsx @@ -0,0 +1,289 @@ +/** @jsxImportSource @opentui/solid */ +import { TextAttributes, type KeyEvent, type TextareaRenderable } from '@opentui/core' +import { useKeyboard } from '@opentui/solid' +import { createEffect, createMemo, createSignal, For, Show, type JSX } from 'solid-js' +import type { Context } from '@opencode/plugin/tui/context' +import { filterModelOptions } from './tui-multi-run.js' +import type { ModelOption } from './tui-multi-run.js' + +export type DialogTheme = ReturnType + +export type KeyHint = readonly [key: string, label: string] + +const PICKER_ROWS = 8 + +/** True for the dialog-wide submit chord. */ +export function isSubmitKey(event: KeyEvent): boolean { + return event.ctrl && event.name === 's' +} + +/** Tab / Shift+Tab focus cycling over `count` fields; returns the focused index accessor. */ +export function useFieldFocus(count: () => number, enabled: () => boolean = () => true) { + const [index, setIndex] = createSignal(0) + createEffect(() => { + if (index() >= count()) setIndex(Math.max(0, count() - 1)) + }) + useKeyboard((event) => { + if (!enabled() || event.name !== 'tab') return + event.preventDefault() + setIndex((current) => (current + (event.shift ? count() - 1 : 1)) % count()) + }) + return [index, setIndex] as const +} + +export function DialogShell(props: { + theme: DialogTheme + title: string + subtitle?: string + hints: readonly KeyHint[] + error?: string | null + busy?: string | null + children: JSX.Element +}) { + return ( + + + + + {props.title} + + + {(subtitle) => ( + + {subtitle()} + + )} + + + + esc + + + {props.children} + {(message) => {message()}} + {(message) => {message()}} + + + {(hint) => ( + + {hint[0]} {hint[1]} + + )} + + + + ) +} + +export function FieldLabel(props: { theme: DialogTheme; label: string; focused: boolean; detail?: string }) { + return ( + + {props.label} + {(detail) => {detail()}} + + ) +} + +export function TextField(props: { + theme: DialogTheme + label: string + initialValue: string + placeholder?: string + focused: boolean + onInput: (value: string) => void + onSubmit?: () => void +}) { + const initial = props.initialValue + return ( + + + props.onSubmit?.()} + /> + + ) +} + +export function TextAreaField(props: { + theme: DialogTheme + label: string + initialValue: string + placeholder?: string + focused: boolean + height: number + onInput: (value: string) => void +}) { + const initial = props.initialValue + let textarea: TextareaRenderable | undefined + return ( + + +