From c41149dc391ad5ddef75aaab1287a872a874d65a Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Wed, 7 Oct 2026 20:26:49 -0400 Subject: [PATCH 1/2] fix(tui): address review follow-ups for session goals and multi-runs --- backend/src/services/notification.ts | 6 +- backend/test/helpers/internal-test-app.ts | 34 +++ .../test/routes/internal-assistant.test.ts | 20 +- .../test/routes/internal-multi-runs.test.ts | 26 +- .../routes/internal-notifications.test.ts | 19 +- .../routes/internal-opencode-config.test.ts | 15 +- .../internal-opencode-workspaces.test.ts | 19 +- backend/test/routes/internal-repos.test.ts | 12 +- backend/test/routes/internal-sandbox.test.ts | 17 +- .../test/routes/internal-schedules.test.ts | 15 +- .../routes/internal-session-goals.test.ts | 26 +- backend/test/routes/internal-sessions.test.ts | 17 +- backend/test/routes/internal-settings.test.ts | 19 +- backend/test/services/assistant-mode.test.ts | 13 +- frontend/src/api/providers.ts | 6 +- .../src/components/message/PromptInput.tsx | 3 +- frontend/src/components/repo/MultiRunCard.tsx | 15 +- .../src/components/session/SessionGoalBar.tsx | 16 +- frontend/src/hooks/useSessionGoals.ts | 4 +- ocm-cli/README.md | 39 +-- ocm-cli/bin/ocm.ts | 65 ++--- ocm-cli/src/goal-store.ts | 148 +++++++---- ocm-cli/src/manager-api.ts | 183 ++++++++----- ocm-cli/src/manager-auth.ts | 42 ++- ocm-cli/src/tui-dialogs.ts | 20 ++ ocm-cli/src/tui-goal-dialog.tsx | 13 +- ocm-cli/src/tui-goal.ts | 64 +++-- ocm-cli/src/tui-multi-run-dialogs.tsx | 10 +- ocm-cli/src/tui-multi-run.ts | 90 ++++--- ocm-cli/src/tui-plugin.ts | 27 +- ocm-cli/src/tui.tsx | 19 +- ocm-cli/test/goal-store.test.ts | 233 +++++++++++----- ocm-cli/test/helpers/goal-fixture.ts | 24 ++ ocm-cli/test/manager-api.test.ts | 88 ++++++- ocm-cli/test/manager-auth.test.ts | 53 +++- ocm-cli/test/tui-goal.test.ts | 112 ++++---- ocm-cli/test/tui-multi-run.test.ts | 248 +++++++++++++++--- ocm-cli/test/tui-plugin.test.ts | 4 +- shared/src/notifications/format.ts | 16 ++ shared/src/opencode/index.ts | 2 + shared/src/opencode/modelPreference.ts | 8 + shared/src/schemas/multi-runs.ts | 11 +- shared/src/schemas/session-goals.ts | 16 ++ 43 files changed, 1163 insertions(+), 674 deletions(-) create mode 100644 backend/test/helpers/internal-test-app.ts create mode 100644 ocm-cli/test/helpers/goal-fixture.ts diff --git a/backend/src/services/notification.ts b/backend/src/services/notification.ts index 400227d9f..949b7fa28 100644 --- a/backend/src/services/notification.ts +++ b/backend/src/services/notification.ts @@ -16,7 +16,7 @@ import { getPermissionDetail, getFormText, getGoalOutcomeTitle, - getGoalStopReasonLabel, + getGoalOutcomeReason, } from "@opencode-manager/shared/notifications"; import { SettingsService } from "./settings"; import { sseAggregator, type SSEEvent } from "./sse-aggregator"; @@ -84,9 +84,7 @@ function truncateWithEllipsis(text: string, maxLength: number): string { } function buildGoalOutcomeBody(goal: SessionGoal, repoName: string | undefined): string { - const reason = goal.stopReason - ? getGoalStopReasonLabel(goal.stopReason) - : goal.lastReason?.trim() || undefined; + const reason = getGoalOutcomeReason(goal)?.trim() || undefined; const prefix = repoName ? `${repoName} · ` : ""; const separator = " — "; diff --git a/backend/test/helpers/internal-test-app.ts b/backend/test/helpers/internal-test-app.ts new file mode 100644 index 000000000..8802456ff --- /dev/null +++ b/backend/test/helpers/internal-test-app.ts @@ -0,0 +1,34 @@ +import { createInternalRoutes } from '../../src/routes/internal' +import { createStubOpenCodeClient } from './stub-opencode-client' + +type CreateInternalRoutesParams = Parameters + +type InternalTestAppOverrides = Partial<{ + scheduleService: CreateInternalRoutesParams[1] + notificationService: CreateInternalRoutesParams[2] + settingsService: CreateInternalRoutesParams[3] + openCodeClient: CreateInternalRoutesParams[4] + permissionModes: CreateInternalRoutesParams[5] + repoWorkspaces: CreateInternalRoutesParams[6] + gitAuthService: CreateInternalRoutesParams[7] + sessionGoals: CreateInternalRoutesParams[8] + multiRuns: CreateInternalRoutesParams[9] +}> + +export function createInternalTestApp( + db: CreateInternalRoutesParams[0], + overrides: InternalTestAppOverrides = {}, +) { + return createInternalRoutes( + db, + overrides.scheduleService ?? ({} as CreateInternalRoutesParams[1]), + overrides.notificationService ?? ({} as CreateInternalRoutesParams[2]), + overrides.settingsService ?? ({} as CreateInternalRoutesParams[3]), + overrides.openCodeClient ?? createStubOpenCodeClient(), + overrides.permissionModes ?? ({} as CreateInternalRoutesParams[5]), + overrides.repoWorkspaces ?? ({} as CreateInternalRoutesParams[6]), + overrides.gitAuthService ?? ({} as CreateInternalRoutesParams[7]), + overrides.sessionGoals ?? ({} as CreateInternalRoutesParams[8]), + overrides.multiRuns ?? ({} as CreateInternalRoutesParams[9]), + ) +} diff --git a/backend/test/routes/internal-assistant.test.ts b/backend/test/routes/internal-assistant.test.ts index 4c91a790e..b4f668069 100644 --- a/backend/test/routes/internal-assistant.test.ts +++ b/backend/test/routes/internal-assistant.test.ts @@ -1,20 +1,11 @@ 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 { ScheduleService } from '../../src/services/schedules' -import { NotificationService } from '../../src/services/notification' -import { SettingsService } from '../../src/services/settings' import { allMigrations } from '../../src/db/migrations' import { getOrCreateInternalToken } from '../../src/services/internal-token' import { migrate } from '../../src/db/migration-runner' import type { OpenCodeClient } from '../../src/services/opencode/client' -import type { ScheduleWorktreeManager } from '../../src/services/schedule-worktree' +import { createInternalTestApp } from '../helpers/internal-test-app' const readOpenCodeConfigFileMock = vi.hoisted(() => vi.fn()) @@ -25,9 +16,6 @@ vi.mock('../../src/services/opencode-config-file', async (importOriginal) => ({ describe('internal/assistant routes', () => { let db: Database - let scheduleService: ScheduleService - let notificationService: NotificationService - let settingsService: SettingsService let app: Hono let token: string let reloadMock: ReturnType @@ -43,12 +31,8 @@ describe('internal/assistant routes', () => { forwardRaw: vi.fn(), } as unknown as OpenCodeClient - const stubWorktreeManager = { prepare: () => Promise.resolve(null), finalize: () => Promise.resolve({ commitHash: null }) } as unknown as ScheduleWorktreeManager - scheduleService = new ScheduleService(db, openCodeClient, stubWorktreeManager) - 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, {} as unknown as SessionGoalService, {} as unknown as MultiRunService)) + app.route('/api/internal', createInternalTestApp(db, { openCodeClient })) token = getOrCreateInternalToken(db) }) diff --git a/backend/test/routes/internal-multi-runs.test.ts b/backend/test/routes/internal-multi-runs.test.ts index 06a830365..abe5ac39c 100644 --- a/backend/test/routes/internal-multi-runs.test.ts +++ b/backend/test/routes/internal-multi-runs.test.ts @@ -1,17 +1,9 @@ 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 { createInternalTestApp } from '../helpers/internal-test-app' import { getOrCreateInternalToken } from '../../src/services/internal-token' import { migrate } from '../../src/db/migration-runner' @@ -59,21 +51,7 @@ describe('internal-multi-runs routes', () => { 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, - ), - ) + app.route('/api/internal', createInternalTestApp(db, { multiRuns: multiRuns as unknown as MultiRunService })) token = getOrCreateInternalToken(db) }) diff --git a/backend/test/routes/internal-notifications.test.ts b/backend/test/routes/internal-notifications.test.ts index 701f806b7..482fa0172 100644 --- a/backend/test/routes/internal-notifications.test.ts +++ b/backend/test/routes/internal-notifications.test.ts @@ -1,40 +1,25 @@ 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' -import { ScheduleService } from '../../src/services/schedules' import { NotificationService } from '../../src/services/notification' -import { SettingsService } from '../../src/services/settings' -import { createOpenCodeClient } 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' import { createScheduleRun, updateScheduleRunMetadata } from '../../src/db/schedules' -import type { ScheduleWorktreeManager } from '../../src/services/schedule-worktree' +import { createInternalTestApp } from '../helpers/internal-test-app' describe('internal/notifications routes', () => { let db: Database - let scheduleService: ScheduleService let notificationService: NotificationService - let settingsService: SettingsService let app: Hono let token: string beforeEach(() => { db = new Database(':memory:') migrate(db, allMigrations) - const openCodeClient = createOpenCodeClient() - const stubWorktreeManager = { prepare: () => Promise.resolve(null), finalize: () => Promise.resolve({ commitHash: null }) } as unknown as ScheduleWorktreeManager - scheduleService = new ScheduleService(db, openCodeClient, stubWorktreeManager) 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, {} as unknown as SessionGoalService, {} as unknown as MultiRunService)) + app.route('/api/internal', createInternalTestApp(db, { notificationService })) token = getOrCreateInternalToken(db) }) diff --git a/backend/test/routes/internal-opencode-config.test.ts b/backend/test/routes/internal-opencode-config.test.ts index dfcbec4d2..5cd6307c4 100644 --- a/backend/test/routes/internal-opencode-config.test.ts +++ b/backend/test/routes/internal-opencode-config.test.ts @@ -1,16 +1,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' import path from 'path' -import { createInternalRoutes } from '../../src/routes/internal' -import { ScheduleService } from '../../src/services/schedules' -import { NotificationService } from '../../src/services/notification' import { SettingsService } from '../../src/services/settings' import { ClientError } from '@opencode-manager/shared/opencode' import type { OpenCodeClient } from '../../src/services/opencode/client' @@ -19,7 +11,7 @@ import { getOrCreateInternalToken } from '../../src/services/internal-token' import { migrate } from '../../src/db/migration-runner' import { OPENCODE_CONFIG_SEED, readOpenCodeConfigFile, writeOpenCodeConfigFile } from '../../src/services/opencode-config-file' import { createTempAssistantWorkspace } from '../helpers/assistant-workspace' -import type { ScheduleWorktreeManager } from '../../src/services/schedule-worktree' +import { createInternalTestApp } from '../helpers/internal-test-app' describe('internal/opencode-config routes', () => { let db: Database @@ -59,12 +51,9 @@ describe('internal/opencode-config routes', () => { }, forwardRaw: forwardRawMock, } as unknown as OpenCodeClient - const stubWorktreeManager = { prepare: () => Promise.resolve(null), finalize: () => Promise.resolve({ commitHash: null }) } as unknown as ScheduleWorktreeManager - const scheduleService = new ScheduleService(db, openCodeClient, stubWorktreeManager) - 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, {} as unknown as SessionGoalService, {} as unknown as MultiRunService)) + app.route('/api/internal', createInternalTestApp(db, { settingsService, openCodeClient })) token = getOrCreateInternalToken(db) }) diff --git a/backend/test/routes/internal-opencode-workspaces.test.ts b/backend/test/routes/internal-opencode-workspaces.test.ts index 738f76e14..048944e22 100644 --- a/backend/test/routes/internal-opencode-workspaces.test.ts +++ b/backend/test/routes/internal-opencode-workspaces.test.ts @@ -1,17 +1,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' -import type { ScheduleService } from '../../src/services/schedules' -import type { NotificationService } from '../../src/services/notification' -import type { SettingsService } from '../../src/services/settings' -import type { OpenCodeClient } from '../../src/services/opencode/client' import type { Repo } from '../../src/types/repo' +import { createInternalTestApp } from '../helpers/internal-test-app' const mockDb = { prepare: vi.fn().mockReturnValue({ @@ -80,14 +71,8 @@ describe('internal-opencode-workspaces routes', () => { beforeEach(() => { vi.clearAllMocks() mockListRepos.mockReturnValue([]) - const scheduleService = {} as ScheduleService - const notificationService = {} as NotificationService - const settingsService = {} as SettingsService - const openCodeClient = { - 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, {} as unknown as SessionGoalService, {} as unknown as MultiRunService)) + app.route('/api/internal', createInternalTestApp(mockDb)) token = 'test-internal-token' }) diff --git a/backend/test/routes/internal-repos.test.ts b/backend/test/routes/internal-repos.test.ts index 6b518a8a2..453779517 100644 --- a/backend/test/routes/internal-repos.test.ts +++ b/backend/test/routes/internal-repos.test.ts @@ -1,14 +1,7 @@ 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' import { ScheduleService } from '../../src/services/schedules' -import { NotificationService } from '../../src/services/notification' import { SettingsService } from '../../src/services/settings' import { createOpenCodeClient } from '../../src/services/opencode/client' import { allMigrations } from '../../src/db/migrations' @@ -17,11 +10,11 @@ import { migrate } from '../../src/db/migration-runner' import { createRepo } from '../../src/db/queries' import type { CreateRepoInput } from '../../src/types/repo' import type { ScheduleWorktreeManager } from '../../src/services/schedule-worktree' +import { createInternalTestApp } from '../helpers/internal-test-app' describe('internal-repos routes', () => { let db: Database let scheduleService: ScheduleService - let notificationService: NotificationService let settingsService: SettingsService let app: Hono let token: string @@ -32,10 +25,9 @@ describe('internal-repos routes', () => { const openCodeClient = createOpenCodeClient() const stubWorktreeManager = { prepare: () => Promise.resolve(null), finalize: () => Promise.resolve({ commitHash: null }) } as unknown as ScheduleWorktreeManager scheduleService = new ScheduleService(db, openCodeClient, stubWorktreeManager) - 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, {} as unknown as SessionGoalService, {} as unknown as MultiRunService)) + app.route('/api/internal', createInternalTestApp(db, { scheduleService, settingsService })) token = getOrCreateInternalToken(db) }) diff --git a/backend/test/routes/internal-sandbox.test.ts b/backend/test/routes/internal-sandbox.test.ts index 4a1d62b30..d2212de1c 100644 --- a/backend/test/routes/internal-sandbox.test.ts +++ b/backend/test/routes/internal-sandbox.test.ts @@ -1,18 +1,9 @@ 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' import path from 'node:path' -import { createInternalRoutes } from '../../src/routes/internal' -import { ScheduleService } from '../../src/services/schedules' -import { NotificationService } from '../../src/services/notification' import { SettingsService } from '../../src/services/settings' -import { createOpenCodeClient } 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' @@ -26,7 +17,7 @@ import { executeCommand } from '../../src/utils/process' import { detectSandboxCapability } from '../../src/services/sandbox/capability' import { forceProcessAttestation } from '../../src/services/opencode/process-identity' import { getReposPath, ENV } from '@opencode-manager/shared/config/env' -import type { ScheduleWorktreeManager } from '../../src/services/schedule-worktree' +import { createInternalTestApp } from '../helpers/internal-test-app' function trustedRunningInspect(): { exitCode: number; stdout: string; stderr: string } { const canonical = buildCanonicalSandboxSpec() @@ -92,13 +83,9 @@ describe('internal sandbox routes', () => { forceProcessAttestation(true) db = new Database(':memory:') migrate(db, allMigrations) - const openCodeClient = createOpenCodeClient() - const stubWorktreeManager = { prepare: () => Promise.resolve(null), finalize: () => Promise.resolve({ commitHash: null }) } as unknown as ScheduleWorktreeManager - const scheduleService = new ScheduleService(db, openCodeClient, stubWorktreeManager) - 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, {} as unknown as SessionGoalService, {} as unknown as MultiRunService)) + app.route('/api/internal', createInternalTestApp(db)) 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 d0f41635e..f94827b55 100644 --- a/backend/test/routes/internal-schedules.test.ts +++ b/backend/test/routes/internal-schedules.test.ts @@ -1,26 +1,17 @@ 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' import { ScheduleService } from '../../src/services/schedules' -import { NotificationService } from '../../src/services/notification' -import { SettingsService } from '../../src/services/settings' import { createOpenCodeClient } 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' import type { ScheduleWorktreeManager } from '../../src/services/schedule-worktree' +import { createInternalTestApp } from '../helpers/internal-test-app' describe('internal-schedules routes', () => { let db: Database let scheduleService: ScheduleService - let notificationService: NotificationService - let settingsService: SettingsService let app: Hono let token: string @@ -30,10 +21,8 @@ describe('internal-schedules routes', () => { const openCodeClient = createOpenCodeClient() const stubWorktreeManager = { prepare: () => Promise.resolve(null), finalize: () => Promise.resolve({ commitHash: null }), listWorktrees: () => [] } as unknown as ScheduleWorktreeManager scheduleService = new ScheduleService(db, openCodeClient, stubWorktreeManager) - 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, {} as unknown as SessionGoalService, {} as unknown as MultiRunService)) + app.route('/api/internal', createInternalTestApp(db, { scheduleService })) token = getOrCreateInternalToken(db) }) diff --git a/backend/test/routes/internal-session-goals.test.ts b/backend/test/routes/internal-session-goals.test.ts index e7ddc6eed..74e720981 100644 --- a/backend/test/routes/internal-session-goals.test.ts +++ b/backend/test/routes/internal-session-goals.test.ts @@ -1,19 +1,11 @@ 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' +import { createInternalTestApp } from '../helpers/internal-test-app' function createSessionGoalStub() { return { @@ -36,21 +28,7 @@ describe('internal-session-goals routes', () => { 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, - ), - ) + app.route('/api/internal', createInternalTestApp(db, { sessionGoals: sessionGoals as unknown as SessionGoalService })) token = getOrCreateInternalToken(db) }) diff --git a/backend/test/routes/internal-sessions.test.ts b/backend/test/routes/internal-sessions.test.ts index f4e412151..33cc5635a 100644 --- a/backend/test/routes/internal-sessions.test.ts +++ b/backend/test/routes/internal-sessions.test.ts @@ -1,17 +1,10 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' import { Hono } from 'hono' import type { Database } from 'bun:sqlite' -import { createInternalRoutes } from '../../src/routes/internal' -import type { ScheduleService } from '../../src/services/schedules' -import type { NotificationService } from '../../src/services/notification' -import type { SettingsService } from '../../src/services/settings' import type { OpenCodeClient } from '../../src/services/opencode/client' import type { SessionPermissionModeService } from '../../src/services/session-permission-modes' -import type { 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' +import { createInternalTestApp } from '../helpers/internal-test-app' const mockDb = { prepare: vi.fn().mockReturnValue({ @@ -153,15 +146,9 @@ describe('internal-sessions routes', () => { }, } as unknown as OpenCodeClient - const scheduleService = {} as ScheduleService - const notificationService = {} as NotificationService - const settingsService = {} as SettingsService const permissionModes = { pinAsk: mockPinAsk } as unknown as SessionPermissionModeService app = new Hono() - app.route( - '/api/internal', - createInternalRoutes(mockDb, scheduleService, notificationService, settingsService, openCodeClient, permissionModes, {} as unknown as RepoWorkspaceService, {} as unknown as GitAuthService, {} as unknown as SessionGoalService, {} as unknown as MultiRunService), - ) + app.route('/api/internal', createInternalTestApp(mockDb, { openCodeClient, permissionModes })) token = 'test-internal-token' }) diff --git a/backend/test/routes/internal-settings.test.ts b/backend/test/routes/internal-settings.test.ts index 4110bbfc0..341240da2 100644 --- a/backend/test/routes/internal-settings.test.ts +++ b/backend/test/routes/internal-settings.test.ts @@ -1,26 +1,15 @@ 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' -import { ScheduleService } from '../../src/services/schedules' -import { NotificationService } from '../../src/services/notification' import { SettingsService } from '../../src/services/settings' -import { createOpenCodeClient } 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' import type { UserPreferences } from '@opencode-manager/shared/types' -import type { ScheduleWorktreeManager } from '../../src/services/schedule-worktree' +import { createInternalTestApp } from '../helpers/internal-test-app' describe('internal/settings routes', () => { let db: Database - let scheduleService: ScheduleService - let notificationService: NotificationService let settingsService: SettingsService let app: Hono let token: string @@ -28,13 +17,9 @@ describe('internal/settings routes', () => { beforeEach(() => { db = new Database(':memory:') migrate(db, allMigrations) - const openCodeClient = createOpenCodeClient() - const stubWorktreeManager = { prepare: () => Promise.resolve(null), finalize: () => Promise.resolve({ commitHash: null }) } as unknown as ScheduleWorktreeManager - scheduleService = new ScheduleService(db, openCodeClient, stubWorktreeManager) - 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, {} as unknown as SessionGoalService, {} as unknown as MultiRunService)) + app.route('/api/internal', createInternalTestApp(db, { settingsService })) token = getOrCreateInternalToken(db) }) diff --git a/backend/test/services/assistant-mode.test.ts b/backend/test/services/assistant-mode.test.ts index eae66156c..0bee4910c 100644 --- a/backend/test/services/assistant-mode.test.ts +++ b/backend/test/services/assistant-mode.test.ts @@ -1,22 +1,15 @@ 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' import { ensureAssistantMode, getAssistantModeStatus, buildSchedulesSkill, buildReposSkill, buildSettingsSkill, buildSessionsSkill, buildAssistantDefaultAgentMd, buildAssistantOpenCodeConfig, buildAssistantRepo, installAssistantWorkspace } from '../../src/services/assistant-mode' import { createTempAssistantWorkspace, createTestDb, mockRepo } from '../helpers/assistant-workspace' -import { createInternalRoutes } from '../../src/routes/internal' import { ScheduleService } from '../../src/services/schedules' -import { NotificationService } from '../../src/services/notification' -import { SettingsService } from '../../src/services/settings' import { getOrCreateInternalToken } from '../../src/services/internal-token' import { createOpenCodeClient } from '../../src/services/opencode/client' import { getRepoById } from '../../src/db/queries' import type { ScheduleWorktreeManager } from '../../src/services/schedule-worktree' +import { createInternalTestApp } from '../helpers/internal-test-app' describe('buildSchedulesSkill', () => { it('instructs the agent to use the ocm tool request action', () => { @@ -687,10 +680,8 @@ describe('assistant-mode end-to-end', () => { const stubWorktreeManager = { prepare: () => Promise.resolve(null), finalize: () => Promise.resolve({ commitHash: null }), listWorktrees: () => [] } as unknown as ScheduleWorktreeManager const scheduleService = new ScheduleService(db, createOpenCodeClient(), stubWorktreeManager) - 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, {} as unknown as SessionGoalService, {} as unknown as MultiRunService)) + app.route('/api/internal', createInternalTestApp(db, { scheduleService })) const unauth = await app.request('/api/internal/schedules/all') expect(unauth.status).toBe(401) diff --git a/frontend/src/api/providers.ts b/frontend/src/api/providers.ts index ac2eac7c5..919fa4e4b 100644 --- a/frontend/src/api/providers.ts +++ b/frontend/src/api/providers.ts @@ -4,7 +4,9 @@ import { callOpenCode } from "./opencodeApi"; import { compareCatalogModels, formatOpenCodeModelRef, + isActiveCatalogProvider, isModelFree, + isSelectableCatalogModel, modelPreferenceKey, openCodeLocation, selectConfiguredModelRef, @@ -64,11 +66,11 @@ export async function getProviders(directory?: string): Promise Promise.all([api.provider.list(location), api.model.list(location)]), ); - const activeProviders = providerResult.data.filter((provider) => provider.activation !== "disabled"); + const activeProviders = providerResult.data.filter(isActiveCatalogProvider); const providers = activeProviders.map((provider): Provider => { const models = modelResult.data - .filter((model) => model.providerID === provider.id && model.status !== "deprecated" && model.enabled) + .filter((model) => model.providerID === provider.id && isSelectableCatalogModel(model)) .map(mapModelInfo); models.sort((a, b) => diff --git a/frontend/src/components/message/PromptInput.tsx b/frontend/src/components/message/PromptInput.tsx index 9fde6ba14..885dfc5a0 100644 --- a/frontend/src/components/message/PromptInput.tsx +++ b/frontend/src/components/message/PromptInput.tsx @@ -38,6 +38,7 @@ import { getNextPrimaryAgentId } from '@/lib/primaryAgents' import { randomId } from '@/lib/utils' import { showToast } from '@/lib/toast' import { findModelInfo } from '@opencode-manager/shared/opencode' +import { isOpenSessionGoal } from '@opencode-manager/shared/schemas' import { useProviders } from '@/hooks/useProviders' @@ -1336,7 +1337,7 @@ if (isIOS && isSecureContext && navigator.clipboard && navigator.clipboard.read) const showStopButton = isSessionActive const hideSecondaryButtons = isMobile && isSessionActive const showMobileScrollButton = isMobile && showScrollButton - const hasOpenGoal = sessionGoal?.status === 'active' || sessionGoal?.status === 'paused' + const hasOpenGoal = isOpenSessionGoal(sessionGoal) const lockedReason = permissionMode?.lockedReason ?? null const goalButtonLabel = lockedReason === 'schedule' ? 'Scheduled runs cannot run goals' diff --git a/frontend/src/components/repo/MultiRunCard.tsx b/frontend/src/components/repo/MultiRunCard.tsx index 719c73c46..718f7df5f 100644 --- a/frontend/src/components/repo/MultiRunCard.tsx +++ b/frontend/src/components/repo/MultiRunCard.tsx @@ -14,11 +14,14 @@ import { useSessionStatusForSession, type SessionStatusType } from '@/stores/ses import { MULTI_RUN_FUSION_MIN_SOURCES, MULTI_RUN_MAX_MODELS, + canDiscardMultiRunEntry, + isFusionSourceEntry, type MultiRun, type MultiRunEntry, type MultiRunFusion, type MultiRunFusionStatus, } from '@opencode-manager/shared/schemas' +import { getMultiRunEntryStatusLabel } from '@opencode-manager/shared/notifications' type StatusTone = 'success' | 'warning' | 'destructive' | 'info' | 'muted' @@ -70,14 +73,14 @@ const HEADER_BUTTON_CLASS = 'h-7 px-2 text-xs' function describeEntryReadiness(entry: MultiRunEntry, live: SessionStatusType): EntryReadiness { switch (entry.status) { case 'discarded': - return { ready: false, label: 'Discarded', tone: 'muted', reason: 'discarded' } + return { ready: false, label: getMultiRunEntryStatusLabel('discarded'), tone: 'muted', reason: 'discarded' } case 'failed': - return { ready: false, label: 'Failed', tone: 'destructive', reason: entry.error ?? 'no final reply' } + return { ready: false, label: getMultiRunEntryStatusLabel('failed'), tone: 'destructive', reason: entry.error ?? 'no final reply' } case 'starting': - return { ready: false, label: 'Starting', tone: 'info', reason: 'still starting' } + return { ready: false, label: getMultiRunEntryStatusLabel('starting'), tone: 'info', reason: 'still starting' } case 'started': if (!entry.sessionId) { - return { ready: false, label: 'Started', tone: 'muted', reason: 'no session recorded' } + return { ready: false, label: getMultiRunEntryStatusLabel('started'), tone: 'muted', reason: 'no session recorded' } } if (live.type !== 'idle') { return { ready: false, label: 'Running', tone: 'warning', reason: 'still running — wait for a final reply' } @@ -148,7 +151,7 @@ export function MultiRunCard({ const [promptExpanded, setPromptExpanded] = useState(false) const selecting = selectedEntryIds !== null const isOpen = expanded || selecting - const launchedEntries = run.entries.filter((entry) => entry.status === 'started' && entry.sessionId).length + const launchedEntries = run.entries.filter(isFusionSourceEntry).length const selectionFull = (selectedEntryIds?.length ?? 0) >= MULTI_RUN_MAX_MODELS const promptIsLong = run.prompt.length > PROMPT_PREVIEW_LENGTH @@ -295,7 +298,7 @@ function EntryRow({ const reason = unavailableReason ?? readiness.reason const unavailable = unavailableReason !== null || !readiness.ready const sessionId = entry.sessionId - const canDiscard = entry.status === 'started' || entry.status === 'failed' + const canDiscard = canDiscardMultiRunEntry(entry) const isolationLabel = entry.isolated ? 'Isolated' : 'Repository checkout' const detail = selecting && unavailable diff --git a/frontend/src/components/session/SessionGoalBar.tsx b/frontend/src/components/session/SessionGoalBar.tsx index 769c6012d..8d64add1c 100644 --- a/frontend/src/components/session/SessionGoalBar.tsx +++ b/frontend/src/components/session/SessionGoalBar.tsx @@ -8,8 +8,8 @@ import { useResumeSessionGoal, useSessionGoal, } from '@/hooks/useSessionGoals' -import { getGoalOutcomeTitle, getGoalStopReasonLabel, getGoalTokenLabel, getGoalTurnLabel } from '@opencode-manager/shared/notifications' -import type { SessionGoalStatus } from '@opencode-manager/shared/schemas' +import { getGoalOutcomeTitle, getGoalTokenLabel, getGoalTurnLabel, getGoalOutcomeReason } from '@opencode-manager/shared/notifications' +import { isTerminalSessionGoal, type SessionGoalStatus } from '@opencode-manager/shared/schemas' const STATUS_CHIP_CLASSES: Record = { active: 'bg-highlight text-highlight-foreground border-highlight', @@ -19,12 +19,6 @@ const STATUS_CHIP_CLASSES: Record = { stopped: 'bg-muted text-muted-foreground border-border', } -const TERMINAL_STATUSES: SessionGoalStatus[] = ['completed', 'blocked', 'stopped'] - -function isTerminal(status: SessionGoalStatus): boolean { - return TERMINAL_STATUSES.includes(status) -} - export function SessionGoalBar({ sessionID }: { sessionID: string }) { const { data: goal } = useSessionGoal(sessionID) const pauseGoal = usePauseSessionGoal() @@ -34,10 +28,10 @@ export function SessionGoalBar({ sessionID }: { sessionID: string }) { const [dismissedGoalId, setDismissedGoalId] = useState(null) if (!goal) return null - if (isTerminal(goal.status) && dismissedGoalId === goal.id) return null + const terminal = isTerminalSessionGoal(goal) + if (terminal && dismissedGoalId === goal.id) return null - const terminal = isTerminal(goal.status) - const reason = goal.stopReason ? getGoalStopReasonLabel(goal.stopReason) : goal.lastReason + const reason = getGoalOutcomeReason(goal) const tokenLabel = getGoalTokenLabel(goal) return ( diff --git a/frontend/src/hooks/useSessionGoals.ts b/frontend/src/hooks/useSessionGoals.ts index bd686d533..3122e6bf5 100644 --- a/frontend/src/hooks/useSessionGoals.ts +++ b/frontend/src/hooks/useSessionGoals.ts @@ -7,7 +7,7 @@ import { startSessionGoal, } from '@/api/sessionGoals' import { showToast } from '@/lib/toast' -import type { SessionGoal, StartSessionGoalRequest } from '@opencode-manager/shared/schemas' +import { SESSION_GOAL_POLL_INTERVAL_MS, type SessionGoal, type StartSessionGoalRequest } from '@opencode-manager/shared/schemas' function sessionGoalQueryKey(sessionId: string) { return ['session-goal', sessionId] as const @@ -17,7 +17,7 @@ export function useSessionGoal(sessionId: string) { return useQuery({ queryKey: sessionGoalQueryKey(sessionId), queryFn: () => getLatestSessionGoal(sessionId), - refetchInterval: (query) => (query.state.data?.status === 'active' ? 3000 : false), + refetchInterval: (query) => (query.state.data?.status === 'active' ? SESSION_GOAL_POLL_INTERVAL_MS : false), }) } diff --git a/ocm-cli/README.md b/ocm-cli/README.md index ab8c567fa..1e4db24ff 100644 --- a/ocm-cli/README.md +++ b/ocm-cli/README.md @@ -155,27 +155,34 @@ 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 +- `/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. On - the run list, `f` fuses every started result of the highlighted run. A fusion - always runs in a new worktree. + Manager defaults). The objective is sent as plain text with the session's + current agent and model: the plugin cannot read the composer's selection, so + send a message first if you switched agent or model, and `@file` mentions are + not attached. Scheduled runs and subagent sessions are rejected by the + Manager. While a goal is open, a one-line status above the composer shows its + turn (and token usage when the goal has a token budget). The TUI keeps + following the goal after you leave the session, and a toast reports the + outcome with an option to open the session. Running `/ocm-goal` while a goal + is open shows its live status with `p` to pause or resume and `x` (twice) to + cancel. +- `/ocm-multirun [prompt]` opens a launch dialog: prompt, name, a filterable + model checklist (up to 5), isolated workspaces or the shared repo directory, + and an optional base ref. `ctrl+s` launches; the started sessions open in + tabs, or the first one opens when tabs are off. With no prompt, + `/ocm-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. On the run list, + `f` fuses every started result of the highlighted run. A fusion always runs in + a new isolated workspace. 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 `404 Not Found`, and the TUI reports that the -Manager must be upgraded. +rejects these routes with `401 Unauthorized` even for a valid token; the TUI +checks the token against another Manager route and then reports that the +Manager must be upgraded instead of asking you to log in again. Enable it in `~/.config/opencode/cli.json`: diff --git a/ocm-cli/bin/ocm.ts b/ocm-cli/bin/ocm.ts index ca384e5f6..c33151ef8 100644 --- a/ocm-cli/bin/ocm.ts +++ b/ocm-cli/bin/ocm.ts @@ -3,7 +3,7 @@ import { basename, dirname, resolve } from 'path' import { fileURLToPath } from 'url' import { readState, writeState, clearState, getStatePath, writeInstallNotice, type OcmState } from '../src/state.js' import { installVendoredOcm, resolveOpenCodeConfigDir, InstallError, OCM_PLUGIN_SPEC } from '../src/vendor-install.js' -import { getToken, setToken, deleteToken, hasStoredToken, describeTokenStore, describeTokenWriteTarget, envToken, TOKEN_ENV, TokenStoreError } from '../src/internal-token-store.js' +import { setToken, deleteToken, hasStoredToken, describeTokenStore, describeTokenWriteTarget, envToken, TOKEN_ENV, TokenStoreError } from '../src/internal-token-store.js' import { ManagerApi, ManagerApiError } from '../src/manager-api.js' import { mirrorUp, mirrorDown, mirrorUpFast, mirrorDownFast, prepareMirror, MirrorAbort, checkPushDivergence, checkPullDivergence, describePushDivergence } from '../src/mirror.js' import type { RemoteRepoSummary, MirrorProgress, PushDivergence, PullDivergence } from '../src/mirror.js' @@ -14,7 +14,8 @@ 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' +import { normalizeManagerUrl, resolveManagerAuth } from '../src/manager-auth.js' +import type { ManagerAuthOk } from '../src/manager-auth.js' const USAGE = `ocm v${VERSION} - OpenCode Manager workspace launcher @@ -97,27 +98,10 @@ function guardDivergentPull(repoName: string, div: PullDivergence): boolean { ) } -function requireState(): OcmState { - const state = readState() - if (!state || !state.managerUrl) { - die(`no manager configured. Run \`ocm login \` first.`) - } - return state -} - -async function requireToken(state: OcmState): Promise { - const store = describeTokenStore() - let token: string | null - try { - token = await getToken(state.managerUrl) - } catch (err) { - if (!(err instanceof TokenStoreError)) throw err - die(`token store error (${store.kind}: ${store.location}): ${err.message}. Run \`ocm login ${state.managerUrl}\` after fixing the store.`) - } - if (!token) { - die(`no token stored for ${state.managerUrl} (${store.kind}: ${store.location}). Run \`ocm login ${state.managerUrl}\`.`) - } - return token +async function requireAuth(): Promise<{ auth: ManagerAuthOk; state: OcmState }> { + const auth = await resolveManagerAuth() + if (!auth.ok) die(auth.message) + return { auth, state: readState()! } } async function attach(managerUrl: string, token: string, repo: ManagerRepo, cwd: string): Promise { @@ -256,9 +240,8 @@ export async function cmdStatus(): Promise { } async function cmdList(): Promise { - const state = requireState() - const token = await requireToken(state) - const repos = await fetchRepos(state.managerUrl, token) + const { auth } = await requireAuth() + const repos = await fetchRepos(auth.managerUrl, auth.token) if (repos.length === 0) { info('No ready repos.') return @@ -276,9 +259,8 @@ async function cmdList(): Promise { async function cmdUse(args: string[]): Promise { const needle = args[0] if (!needle) die('usage: ocm use ') - const state = requireState() - const token = await requireToken(state) - const repos = await fetchRepos(state.managerUrl, token) + const { auth, state } = await requireAuth() + const repos = await fetchRepos(auth.managerUrl, auth.token) const repo = findRepo(repos, needle) if (!repo) die(`repo not found: ${needle}`) @@ -290,13 +272,12 @@ async function cmdUse(args: string[]): Promise { lastRepoBranch: repo.branch, }) - await attach(state.managerUrl, token, repo, process.cwd()) + await attach(auth.managerUrl, auth.token, repo, process.cwd()) } async function cmdDefault(): Promise { info(`ocm v${VERSION}`) - const state = requireState() - const token = await requireToken(state) + const { auth, state } = await requireAuth() const last = state.lastRepoId !== undefined && state.lastRepoDir ? { @@ -308,7 +289,7 @@ async function cmdDefault(): Promise { : undefined info('connecting...') - const repos = await fetchRepos(state.managerUrl, token) + const repos = await fetchRepos(auth.managerUrl, auth.token) const localProjectId = await resolveOpenCodeProjectId(process.cwd()) const result = resolveTarget({ cwd: process.cwd(), repos, localProjectId, last }) @@ -323,13 +304,13 @@ async function cmdDefault(): Promise { lastRepoDir: repo.directory, lastRepoBranch: repo.branch, }) - await attach(state.managerUrl, token, toManagerRepo(repo), result.repoRoot) + await attach(auth.managerUrl, auth.token, toManagerRepo(repo), result.repoRoot) return } case 'last': { const repo = result.repo info(`attaching to ${repo.name} (last used)`) - await attach(state.managerUrl, token, toManagerRepo(repo), process.cwd()) + await attach(auth.managerUrl, auth.token, toManagerRepo(repo), process.cwd()) return } case 'cwd-ambiguous': { @@ -387,10 +368,9 @@ export async function cmdPush(args: string[]): Promise { else if (arg === '--full') full = true } - const state = requireState() - const token = await requireToken(state) - const api = new ManagerApi(state.managerUrl, token) - const repos = await fetchRepos(state.managerUrl, token) + const { auth } = await requireAuth() + const api = new ManagerApi(auth.managerUrl, auth.token) + const repos = await fetchRepos(auth.managerUrl, auth.token) const remotes: RemoteRepoSummary[] = toRemoteRepoSummaries(repos) @@ -469,10 +449,9 @@ async function cmdPull(args: string[]): Promise { else if (arg === '--full') full = true } - const state = requireState() - const token = await requireToken(state) - const api = new ManagerApi(state.managerUrl, token) - const repos = await fetchRepos(state.managerUrl, token) + const { auth } = await requireAuth() + const api = new ManagerApi(auth.managerUrl, auth.token) + const repos = await fetchRepos(auth.managerUrl, auth.token) const remotes: RemoteRepoSummary[] = toRemoteRepoSummaries(repos) diff --git a/ocm-cli/src/goal-store.ts b/ocm-cli/src/goal-store.ts index 9e4e131d5..60f225e3f 100644 --- a/ocm-cli/src/goal-store.ts +++ b/ocm-cli/src/goal-store.ts @@ -1,9 +1,16 @@ -import type { SessionGoal } from '@opencode-manager/shared/schemas' +import { + SESSION_GOAL_POLL_INTERVAL_MS, + isOpenSessionGoal, + isTerminalSessionGoal, + type SessionGoal, +} from '@opencode-manager/shared/schemas' -export const GOAL_POLL_INTERVAL_MS = 3000 +const BACKGROUND_POLL_INTERVAL_MS = 15000 +const MAX_ERROR_BACKOFF_MS = 30000 +const REQUEST_TIMEOUT_MS = 10000 export type GoalStoreDeps = { - load: (sessionID: string) => Promise + load: (sessionID: string, signal: AbortSignal) => Promise onOutcome: (goal: SessionGoal) => void pollIntervalMs?: number } @@ -11,78 +18,130 @@ export type GoalStoreDeps = { 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' + timer: ReturnType | null + abort: AbortController | null + loading: boolean + version: number + errorBackoffMs: number + outcomeGoalId: number | null } export function createGoalStore(deps: GoalStoreDeps): GoalStore { - const pollIntervalMs = deps.pollIntervalMs ?? GOAL_POLL_INTERVAL_MS + const pollIntervalMs = deps.pollIntervalMs ?? SESSION_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) + const existing = entries.get(sessionID) + if (existing) return existing + const entry: GoalEntry = { + sessionID, + goal: null, + listeners: new Set(), + timer: null, + abort: null, + loading: false, + version: 0, + errorBackoffMs: 0, + outcomeGoalId: null, } + entries.set(sessionID, entry) return entry } - function stopPolling(entry: GoalEntry): void { - if (entry.timer !== null) { - clearInterval(entry.timer) - entry.timer = null - } + function clearTimer(entry: GoalEntry): void { + if (entry.timer === null) return + clearTimeout(entry.timer) + entry.timer = null + } + + function isCurrent(entry: GoalEntry): boolean { + return entries.get(entry.sessionID) === entry + } + + function hasListeners(entry: GoalEntry): boolean { + return entry.listeners.size > 0 + } + + function baseDelay(entry: GoalEntry): number { + return hasListeners(entry) ? pollIntervalMs : BACKGROUND_POLL_INTERVAL_MS } - function isLive(entry: GoalEntry): boolean { - return entry.listeners.size > 0 && entries.get(entry.sessionID) === entry + function wantsFollowing(entry: GoalEntry): boolean { + return isCurrent(entry) && (hasListeners(entry) || isOpenSessionGoal(entry.goal)) } - function startPolling(entry: GoalEntry): void { - stopPolling(entry) - entry.timer = setInterval(() => { + function schedule(entry: GoalEntry): void { + clearTimer(entry) + if (!wantsFollowing(entry)) return + const delay = entry.errorBackoffMs > 0 ? Math.min(entry.errorBackoffMs, MAX_ERROR_BACKOFF_MS) : baseDelay(entry) + entry.timer = setTimeout(() => { + entry.timer = null void loadInto(entry) - }, pollIntervalMs) + }, delay) + } + + function drop(entry: GoalEntry): void { + clearTimer(entry) + entry.abort?.abort() + entry.abort = null + entries.delete(entry.sessionID) } 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)) { + if (goal !== null && entry.outcomeGoalId !== goal.id && isOpenSessionGoal(previous) && isTerminalSessionGoal(goal)) { + entry.outcomeGoalId = goal.id deps.onOutcome(goal) } - if (goal?.status === 'active' && isLive(entry)) startPolling(entry) - else stopPolling(entry) + if (isOpenSessionGoal(goal)) { + schedule(entry) + return + } + clearTimer(entry) + if (!hasListeners(entry)) drop(entry) + } + + function fail(entry: GoalEntry): void { + const current = entry.errorBackoffMs === 0 ? baseDelay(entry) : entry.errorBackoffMs + entry.errorBackoffMs = Math.min(current * 2, MAX_ERROR_BACKOFF_MS) + schedule(entry) } async function loadInto(entry: GoalEntry): Promise { + if (entry.loading || !wantsFollowing(entry)) return + entry.loading = true + const version = ++entry.version + const controller = new AbortController() + entry.abort = controller + const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]) + let goal: SessionGoal | null try { - goal = await deps.load(entry.sessionID) + goal = await deps.load(entry.sessionID, signal) } catch { - if (entry.goal?.status !== 'active' || !isLive(entry)) stopPolling(entry) + entry.loading = false + entry.abort = null + if (!isCurrent(entry)) return + fail(entry) return } - if (entries.get(entry.sessionID) !== entry) { - stopPolling(entry) + + entry.loading = false + entry.abort = null + if (!isCurrent(entry)) return + if (version !== entry.version) { + schedule(entry) return } + entry.errorBackoffMs = 0 applyGoal(entry, goal) } @@ -90,24 +149,23 @@ export function createGoalStore(deps: GoalStoreDeps): GoalStore { watch(sessionID, listener) { const entry = entryFor(sessionID) entry.listeners.add(listener) + if (entry.goal) listener(entry.goal) void loadInto(entry) return () => { - entry.listeners.delete(listener) - if (entry.listeners.size === 0) { - stopPolling(entry) - entries.delete(sessionID) + if (!entry.listeners.delete(listener)) return + if (hasListeners(entry)) return + if (isOpenSessionGoal(entry.goal)) { + schedule(entry) + return } + drop(entry) } }, set(goal) { const entry = entries.get(goal.sessionId) if (!entry) return + entry.version++ applyGoal(entry, goal) }, - refresh(sessionID) { - const entry = entries.get(sessionID) - if (!entry) return Promise.resolve() - return loadInto(entry) - }, } } diff --git a/ocm-cli/src/manager-api.ts b/ocm-cli/src/manager-api.ts index 5a54b9e99..fe4edaac6 100644 --- a/ocm-cli/src/manager-api.ts +++ b/ocm-cli/src/manager-api.ts @@ -76,6 +76,8 @@ function createByteCounter(onProgress: (bytesSent: number) => void): TransformSt }) } +export const MANAGER_FEATURE_MISSING = 'MANAGER_FEATURE_MISSING' + export class ManagerApiError extends Error { constructor( message: string, @@ -83,6 +85,7 @@ export class ManagerApiError extends Error { public readonly code: string | null, public readonly operation: string, public readonly details: unknown = null, + public readonly jsonBody: boolean = false, ) { super(message) this.name = 'ManagerApiError' @@ -90,7 +93,9 @@ export class ManagerApiError extends Error { } export function isManagerRouteMissing(error: unknown): boolean { - return error instanceof ManagerApiError && error.status === 404 && error.code === null + if (!(error instanceof ManagerApiError)) return false + if (error.code === MANAGER_FEATURE_MISSING) return true + return error.status === 404 && !error.jsonBody } async function formatErrorResponse(res: Response, operation: string): Promise { @@ -98,14 +103,20 @@ async function formatErrorResponse(res: Response, operation: string): Promise( + path: string, + operation: string, + parse: (body: unknown) => T, + init?: RequestInit & { feature?: boolean }, + ): Promise { + const { feature, ...requestInit } = init ?? {} + const baseHeaders = this.headers( + typeof requestInit.body === 'string' ? { 'Content-Type': 'application/json' } : {}, + ) + const res = await fetch(`${this.baseUrl}${path}`, { + ...requestInit, + headers: { ...baseHeaders, ...(requestInit.headers as Record | undefined) }, + }) + if (!res.ok) throw await this.handleErrorResponse(res, operation, feature === true) + return parse(await res.json()) + } + + private async handleErrorResponse(res: Response, operation: string, feature: boolean): Promise { + const error = await formatErrorResponse(res, operation) + if (feature && error.status === 401) { + return this.probeFeatureSupport(operation, error) + } + return error + } + + private async probeFeatureSupport(operation: string, fallback: ManagerApiError): Promise { + try { + const res = await fetch(`${this.baseUrl}/api/internal/opencode-workspaces`, { headers: this.headers() }) + if (res.ok) { + return new ManagerApiError( + `${operation} failed: this OpenCode Manager is too old for ocm goals and multi-runs; upgrade the Manager.`, + 401, + MANAGER_FEATURE_MISSING, + operation, + ) + } + } catch { + return fallback + } + return fallback + } + async mirrorBegin(repoId: number, opts: MirrorBeginOpts): Promise { const url = `${this.baseUrl}/api/internal/repos/${repoId}/mirror/begin` const body: Record = { force: opts.force === true } @@ -270,37 +324,31 @@ export class ManagerApi { return (await res.json()) as MirrorPatchSnapshot } - async getLatestSessionGoal(sessionId: 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) + getLatestSessionGoal(sessionId: string, signal?: AbortSignal): Promise { + return this.requestJson( + `/api/internal/session-goals?sessionId=${encodeURIComponent(sessionId)}`, + 'read session goal', + (body) => SessionGoalSchema.nullable().parse((body as { goal: unknown }).goal), + { signal, feature: true }, + ) } - 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) + startSessionGoal(input: StartSessionGoalRequest): Promise { + return this.requestJson( + '/api/internal/session-goals', + 'start session goal', + (body) => SessionGoalSchema.parse((body as { goal: unknown }).goal), + { method: 'POST', body: JSON.stringify(input), feature: true }, + ) } - 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) + private runSessionGoalAction(id: number, action: 'pause' | 'resume' | 'cancel'): Promise { + return this.requestJson( + `/api/internal/session-goals/${encodeURIComponent(id)}/${action}`, + `${action} session goal`, + (body) => SessionGoalSchema.parse((body as { goal: unknown }).goal), + { method: 'POST', feature: true }, + ) } pauseSessionGoal(id: number): Promise { @@ -315,48 +363,39 @@ export class ManagerApi { 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) + listMultiRuns(repoId: number): Promise { + return this.requestJson( + `/api/internal/multi-runs?repoId=${encodeURIComponent(repoId)}`, + 'list multi-runs', + (body) => MultiRunSchema.array().parse((body as { runs: unknown }).runs), + { feature: true }, + ) } - 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) + launchMultiRun(request: LaunchMultiRunRequest): Promise { + return this.requestJson( + '/api/internal/multi-runs', + 'launch multi-run', + (body) => MultiRunSchema.parse((body as { run: unknown }).run), + { method: 'POST', body: JSON.stringify(request), feature: true }, + ) } - 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) + fuseMultiRun(runId: number, request: FuseMultiRunRequest): Promise { + return this.requestJson( + `/api/internal/multi-runs/${encodeURIComponent(runId)}/fusions`, + 'fuse multi-run', + (body) => MultiRunSchema.parse((body as { run: unknown }).run), + { method: 'POST', body: JSON.stringify(request), feature: true }, + ) } - 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) + discardMultiRunEntry(runId: number, entryId: number): Promise { + return this.requestJson( + `/api/internal/multi-runs/${encodeURIComponent(runId)}/entries/${encodeURIComponent(entryId)}/discard`, + 'discard multi-run entry', + (body) => MultiRunSchema.parse((body as { run: unknown }).run), + { method: 'POST', feature: true }, + ) } } diff --git a/ocm-cli/src/manager-auth.ts b/ocm-cli/src/manager-auth.ts index 687d77982..5ec88dfb7 100644 --- a/ocm-cli/src/manager-auth.ts +++ b/ocm-cli/src/manager-auth.ts @@ -1,13 +1,20 @@ import { readState } from './state.js' -import { getToken } from './internal-token-store.js' +import { getToken, describeTokenStore } from './internal-token-store.js' import { TokenStoreError } from './token-store.js' +import { ManagerApi } from './manager-api.js' + +export type ManagerAuthFailureReason = 'no-manager' | 'token-store' | 'no-token' export type ManagerAuth = | { ok: true; managerUrl: string; token: string } - | { ok: false; message: string } + | { ok: false; reason: ManagerAuthFailureReason; message: string } export type ManagerAuthOk = Extract +export type ResolveManagerApiResult = + | { ok: true; auth: ManagerAuthOk; api: ManagerApi } + | { ok: false; message: string } + /** Canonical Manager URL form used as the token-store account: trimmed, without trailing slashes. */ export function normalizeManagerUrl(url: string): string { return url.trim().replace(/\/+$/, '') @@ -18,20 +25,43 @@ export async function resolveManagerAuth( ): Promise { const managerUrl = rawManagerUrl ? normalizeManagerUrl(rawManagerUrl) : '' if (!managerUrl) { - return { ok: false, message: 'No manager configured. Run `ocm login ` first.' } + return { + ok: false, + reason: 'no-manager', + 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 (!(err instanceof TokenStoreError)) throw err + return { + ok: false, + reason: 'token-store', + message: `token store error (${tokenStoreLabel()}): ${err.message}. Run \`ocm login ${managerUrl}\` after fixing the store.`, + } } if (!token) { - return { ok: false, message: `No token stored. Run \`ocm login ${managerUrl}\`.` } + return { + ok: false, + reason: 'no-token', + message: `no token stored for ${managerUrl} (${tokenStoreLabel()}). Run \`ocm login ${managerUrl}\`.`, + } } return { ok: true, managerUrl, token } } + +function tokenStoreLabel(): string { + const store = describeTokenStore() + return `${store.kind}: ${store.location}` +} + +export async function resolveManagerApi(managerUrl?: string): Promise { + const auth = await resolveManagerAuth(managerUrl) + if (!auth.ok) return { ok: false, message: auth.message } + return { ok: true, auth, api: new ManagerApi(auth.managerUrl, auth.token) } +} diff --git a/ocm-cli/src/tui-dialogs.ts b/ocm-cli/src/tui-dialogs.ts index 6ed62584e..01205df5c 100644 --- a/ocm-cli/src/tui-dialogs.ts +++ b/ocm-cli/src/tui-dialogs.ts @@ -12,6 +12,26 @@ export function selectDialog( return context.ui.dialog.select({ title, options }) } +type SessionInfo = NonNullable> + +export type SessionTarget = { sessionID: string; directory: string; session: SessionInfo } + +/** Resolves the routed session and its directory, or shows an error toast and returns undefined. */ +export function requireSessionTarget(context: Context): SessionTarget | undefined { + const route = context.ui.router.current() + if (route.type !== 'session') { + context.ui.toast.show({ variant: 'error', message: 'Not in a session' }) + return undefined + } + const session = context.data.session.get(route.sessionID) + const directory = session?.location.directory + if (!session || !directory) { + context.ui.toast.show({ variant: 'error', message: 'Session has no directory' }) + return undefined + } + return { sessionID: route.sessionID, directory, session } +} + export function slashArgument(input: string | undefined, name: string): string { const trimmed = (input ?? '').trim() const prefix = `/${name}` diff --git a/ocm-cli/src/tui-goal-dialog.tsx b/ocm-cli/src/tui-goal-dialog.tsx index 7fefbe50e..101659885 100644 --- a/ocm-cli/src/tui-goal-dialog.tsx +++ b/ocm-cli/src/tui-goal-dialog.tsx @@ -3,10 +3,8 @@ import { TextAttributes } from '@opentui/core' import { useKeyboard } from '@opentui/solid' import { createEffect, createSignal, on, onCleanup, Show } from 'solid-js' import type { Context } from '@opencode/plugin/tui/context' -import type { SessionGoal } from '@opencode-manager/shared/schemas' -import { getGoalOutcomeTitle, getGoalTokenLabel, getGoalTurnLabel } from '@opencode-manager/shared/notifications' -import { isOpenGoal } from './goal-store.js' -import { goalReason } from './tui-goal.js' +import { isOpenSessionGoal, type SessionGoal } from '@opencode-manager/shared/schemas' +import { getGoalOutcomeReason, getGoalOutcomeTitle, getGoalTokenLabel, getGoalTurnLabel } from '@opencode-manager/shared/notifications' import type { GoalDialogProps } from './tui-goal.js' import { DialogShell, TextAreaField, TextField, isSubmitKey, useFieldFocus } from './tui-form.js' import type { DialogTheme } from './tui-form.js' @@ -33,7 +31,7 @@ function GoalDialog(props: GoalDialogProps & { context: Context }) { const openGoal = () => { const current = goal() - return isOpenGoal(current) ? current : null + return isOpenSessionGoal(current) ? current : null } const [field, setField] = useFieldFocus(() => FORM_FIELDS, () => openGoal() === null) @@ -96,6 +94,7 @@ function GoalDialog(props: GoalDialogProps & { context: Context }) { ['ctrl+s', 'start goal'], ]} > + Sends with this session's current agent and model; send a message first to switch. {(last) => } {props.goal.turnState === 'running' ? 'working' : 'waiting'} {props.goal.objective} - + {(reason) => ( {props.goal.lastVerdict ? `Last verdict: ${props.goal.lastVerdict} · ` : ''} @@ -183,7 +182,7 @@ function LastGoal(props: { theme: DialogTheme; goal: SessionGoal }) { return ( Last goal: {getGoalOutcomeTitle(props.goal.status)} - {goalReason(props.goal) ? ` · ${goalReason(props.goal)}` : ''} · {props.goal.objective} + {getGoalOutcomeReason(props.goal) ? ` · ${getGoalOutcomeReason(props.goal)}` : ''} · {props.goal.objective} ) } diff --git a/ocm-cli/src/tui-goal.ts b/ocm-cli/src/tui-goal.ts index b1cd20d96..82b470193 100644 --- a/ocm-cli/src/tui-goal.ts +++ b/ocm-cli/src/tui-goal.ts @@ -3,23 +3,26 @@ import { GOAL_MAX_CONTINUATIONS_MAX, GOAL_MAX_CONTINUATIONS_MIN, StartSessionGoalRequestSchema, + isOpenSessionGoal, type SessionGoal, type StartSessionGoalRequest, } from '@opencode-manager/shared/schemas' import { + getGoalOutcomeReason, getGoalOutcomeTitle, - getGoalStopReasonLabel, getGoalTokenLabel, getGoalTurnLabel, } from '@opencode-manager/shared/notifications' -import { ManagerApi, isManagerRouteMissing } from './manager-api.js' -import { resolveManagerAuth } from './manager-auth.js' -import type { ManagerAuthOk } from './manager-auth.js' -import { slashArgument } from './tui-dialogs.js' -import { isOpenGoal } from './goal-store.js' +import { isManagerRouteMissing } from './manager-api.js' +import type { ManagerApi } from './manager-api.js' +import { resolveManagerApi } from './manager-auth.js' +import { describeCause } from './token-store.js' +import { requireSessionTarget, slashArgument } from './tui-dialogs.js' import type { GoalStore } from './goal-store.js' import type { RemoteContext } from './remote-context.js' +export const GOAL_COMMAND = 'ocm-goal' + 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.' @@ -52,11 +55,19 @@ export type GoalCommandDeps = { remote: RemoteContext | undefined store: GoalStore | undefined showDialog: (props: GoalDialogProps) => void - createApi?: (auth: ManagerAuthOk) => ManagerApi +} + +const GOAL_OBJECTIVE_MAX_LENGTH = 60 + +/** The objective's first line, cut to a single-line summary with a trailing ellipsis when long. */ +export function goalObjectiveSummary(objective: string): string { + const newline = objective.indexOf('\n') + const firstLine = (newline === -1 ? objective : objective.slice(0, newline)).trim() + return firstLine.length > GOAL_OBJECTIVE_MAX_LENGTH ? `${firstLine.slice(0, GOAL_OBJECTIVE_MAX_LENGTH)}…` : firstLine } export function formatGoalStatus(goal: SessionGoal): string { - return [getGoalOutcomeTitle(goal.status), getGoalTurnLabel(goal), getGoalTokenLabel(goal), goal.objective] + return [getGoalOutcomeTitle(goal.status), getGoalTurnLabel(goal), getGoalTokenLabel(goal), goalObjectiveSummary(goal.objective)] .filter((part): part is string => part !== null) .join(' · ') } @@ -66,15 +77,10 @@ export function goalOutcomeToast(goal: SessionGoal): ToastOptions { return { variant, title: getGoalOutcomeTitle(goal.status), - message: goalReason(goal) ?? goal.objective, + message: getGoalOutcomeReason(goal) ?? goalObjectiveSummary(goal.objective), } } -/** The stop-reason label, else the auditor's last reason, else null. */ -export function goalReason(goal: SessionGoal): string | null { - return goal.stopReason ? getGoalStopReasonLabel(goal.stopReason) : goal.lastReason -} - /** Validates the goal dialog fields into a start request; blank limits fall back to the Manager defaults. */ export function parseGoalForm(form: GoalFormInput, sessionId: string, directory: string): GoalFormResult { const objective = form.objective.trim() @@ -107,7 +113,7 @@ export function parseGoalForm(form: GoalFormInput, sessionId: string, directory: export function describeGoalError(error: unknown): string { if (isManagerRouteMissing(error)) return GOALS_ROUTE_MISSING - return error instanceof Error ? error.message : String(error) + return describeCause(error) } export async function runGoalCommand(context: Context, deps: GoalCommandDeps, input?: string): Promise { @@ -117,30 +123,22 @@ export async function runGoalCommand(context: Context, deps: GoalCommandDeps, in return } - const route = context.ui.router.current() - if (route.type !== 'session') { - context.ui.toast.show({ variant: 'error', message: 'Not in a session' }) - return - } - const sessionID = route.sessionID - const directory = context.data.session.get(sessionID)?.location.directory - if (!directory) { - context.ui.toast.show({ variant: 'error', message: 'Session has no directory' }) - return - } + const target = requireSessionTarget(context) + if (!target) return + const { sessionID, directory } = target - const auth = await resolveManagerAuth(deps.remote.managerUrl) - if (!auth.ok) { - context.ui.toast.show({ variant: 'error', message: auth.message }) + const resolved = await resolveManagerApi(deps.remote.managerUrl) + if (!resolved.ok) { + context.ui.toast.show({ variant: 'error', message: resolved.message }) return } - const api = deps.createApi ? deps.createApi(auth) : new ManagerApi(auth.managerUrl, auth.token) + const api = resolved.api const store = deps.store const actions = createGoalActions(context, api, store, sessionID, directory) - const objective = slashArgument(input, 'goal') + const objective = slashArgument(input, GOAL_COMMAND) const existing = await api.getLatestSessionGoal(sessionID) - if (objective && !isOpenGoal(existing)) { + if (objective && !isOpenSessionGoal(existing)) { const error = await actions.start({ objective, maxTurns: '', tokenBudget: '' }) if (error) context.ui.toast.show({ variant: 'error', message: error }) return @@ -193,7 +191,7 @@ async function startGoal(context: Context, api: ManagerApi, store: GoalStore, re } catch (error) { const cancelled = await api.cancelSessionGoal(goal.id).catch(() => undefined) if (cancelled) store.set(cancelled) - return `The goal was cancelled because the objective could not be sent: ${error instanceof Error ? error.message : String(error)}` + return `The goal was cancelled because the objective could not be sent: ${describeCause(error)}` } } diff --git a/ocm-cli/src/tui-multi-run-dialogs.tsx b/ocm-cli/src/tui-multi-run-dialogs.tsx index a55ecceb4..2e8e7f156 100644 --- a/ocm-cli/src/tui-multi-run-dialogs.tsx +++ b/ocm-cli/src/tui-multi-run-dialogs.tsx @@ -6,12 +6,12 @@ import type { Context } from '@opencode/plugin/tui/context' import { MULTI_RUN_FUSION_MIN_SOURCES, MULTI_RUN_MAX_MODELS, + canDiscardMultiRunEntry, type MultiRun, type MultiRunEntry, type MultiRunFusion, } from '@opencode-manager/shared/schemas' import { - canDiscard, defaultMultiRunName, formatEntryStatus, runSummary, @@ -32,7 +32,7 @@ import { import type { DialogTheme } from './tui-form.js' const ISOLATION_OPTIONS = [ - { title: 'Isolated worktrees (recommended)', value: true }, + { title: 'Isolated workspaces (recommended)', value: true }, { title: 'Shared repo directory', value: false }, ] as const @@ -323,7 +323,7 @@ function MultiRunsDialog(props: MultiRunsDialogProps & { context: Context }) { setFusionIds((ids) => toggleFusionSource(current, ids, row.entry.id)) return } - if (name === 'd' && canDiscard(row.entry)) { + if (name === 'd' && canDiscardMultiRunEntry(row.entry)) { event.preventDefault() void discard(row.entry) } @@ -441,7 +441,7 @@ function RunDetails(props: { Press d again to discard this run - {props.run.entries.find((entry) => entry.id === props.pendingDiscard)?.isolated ? ' and remove its worktree' : ''}. This cannot be + {props.run.entries.find((entry) => entry.id === props.pendingDiscard)?.isolated ? ' and remove its workspace' : ''}. This cannot be undone. @@ -539,7 +539,7 @@ function FusionForm(props: { Fuse {props.entryIds.length} results: {sources().join(', ')} - The synthesis always runs in a new worktree. + The synthesis always runs in a new isolated workspace. void showRunsDialog: (props: MultiRunsDialogProps) => void - createApi?: (auth: ManagerAuthOk) => ManagerApi } export async function listModelOptions(context: Context): Promise { - await context.data.location.model.sync() + await Promise.all([context.data.location.model.sync(), context.data.location.provider.sync()]) + const providerNames = new Map( + (context.data.location.provider.list() ?? []) + .filter(isActiveCatalogProvider) + .map((provider) => [provider.id, provider.name]), + ) const models = context.data.location.model.list() ?? [] return models - .filter((model) => model.enabled && model.status !== 'deprecated') + .filter((model) => isSelectableCatalogModel(model) && providerNames.has(model.providerID)) + .sort((a, b) => + compareCatalogModels( + { ...a, providerName: providerNames.get(a.providerID) }, + { ...b, providerName: providerNames.get(b.providerID) }, + ), + ) .map((model) => { const ref = formatOpenCodeModelRef({ providerID: model.providerID, id: model.id }) return { title: model.name, description: ref, value: ref } @@ -109,18 +128,11 @@ export function openManagerSessions(context: Context, sessionIDs: string[]): voi } export function defaultMultiRunName(prompt: string): string { - return prompt.trim().split('\n')[0]!.slice(0, 80).trim() -} - -const ENTRY_STATUS_LABELS: Record = { - starting: 'Starting', - started: 'Started', - failed: 'Failed', - discarded: 'Discarded', + return prompt.trim().split('\n')[0]!.slice(0, MULTI_RUN_NAME_MAX_LENGTH).trim() } export function formatEntryStatus(entry: MultiRunEntry): string { - const parts = [ENTRY_STATUS_LABELS[entry.status], entry.isolated ? 'isolated' : 'shared directory'] + const parts = [getMultiRunEntryStatusLabel(entry.status), entry.isolated ? 'isolated' : 'shared directory'] if (entry.error) parts.push(entry.error) return parts.join(' · ') } @@ -132,13 +144,7 @@ export function runSummary(run: MultiRun): string { /** Started entries backed by a session: the sessions to open and the valid fusion sources. */ export function startedEntries(run: MultiRun): (MultiRunEntry & { sessionId: string })[] { - return run.entries.filter( - (entry): entry is MultiRunEntry & { sessionId: string } => entry.status === 'started' && entry.sessionId !== null, - ) -} - -export function canDiscard(entry: MultiRunEntry): boolean { - return entry.status === 'started' || entry.status === 'failed' + return run.entries.filter(isFusionSourceEntry) } /** Toggles an entry in the fusion selection, keeping run order and ignoring non-sources and selections past the model limit. */ @@ -191,15 +197,16 @@ export function parseFusionForm(form: FusionFormInput, requestId: string): Actio /** Maps a Manager error to a message, plus the session of an earlier fusion attempt the Manager recovered instead. */ export function describeMultiRunError(error: unknown): { message: string; recoveredSessionId?: string } { if (isManagerRouteMissing(error)) return { message: MULTI_RUN_ROUTE_MISSING } - if (!(error instanceof ManagerApiError)) return { message: error instanceof Error ? error.message : String(error) } + if (!(error instanceof ManagerApiError)) return { message: describeCause(error) } const unavailable = FusionUnavailableDetailsSchema.safeParse(error.details) if (unavailable.success && unavailable.data.unavailableSources.length > 0) { return { message: unavailable.data.unavailableSources.map((source) => `${source.model}: ${source.message}`).join('\n') } } - const recovered = FusionRecoveredDetailsSchema.safeParse(error.details) - const recoveredSessionId = recovered.success ? recovered.data.fusions[0]?.sessionId : undefined + const recovered = + error.code === null || error.code === 'FUSION_ATTEMPT_RECOVERED' ? FusionRecoveredDetailsSchema.safeParse(error.details) : undefined + const recoveredSessionId = recovered?.success ? recovered.data.fusions[0]?.sessionId : undefined if (recoveredSessionId) return { message: 'An earlier fusion attempt is already running.', recoveredSessionId } return { message: error.message } @@ -210,7 +217,8 @@ export function multiRunLaunchToast(run: MultiRun): ToastOptions { 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') } + const variant = started === 0 ? 'error' : failed.length > 0 ? 'warning' : 'success' + return { variant, message: lines.join('\n') } } export async function runMultiRunCommand(context: Context, deps: MultiRunCommandDeps, input?: string): Promise { @@ -221,17 +229,21 @@ export async function runMultiRunCommand(context: Context, deps: MultiRunCommand return } - const auth = await resolveManagerAuth(deps.remote.managerUrl) - if (!auth.ok) { - context.ui.toast.show({ variant: 'error', message: auth.message }) + const resolved = await resolveManagerApi(deps.remote.managerUrl) + if (!resolved.ok) { + context.ui.toast.show({ variant: 'error', message: resolved.message }) return } - const api = deps.createApi ? deps.createApi(auth) : new ManagerApi(auth.managerUrl, auth.token) - const actions = createMultiRunActions(context, api, repoId) - const prompt = slashArgument(input, 'multirun') + const actions = createMultiRunActions(context, resolved.api, repoId) + const prompt = slashArgument(input, MULTI_RUN_COMMAND) if (prompt) { + const probe = await actions.list() + if (!probe.ok) { + context.ui.toast.show({ variant: 'error', message: probe.error }) + return + } deps.showLaunchDialog({ actions, initialPrompt: prompt }) return } @@ -242,7 +254,13 @@ export async function runMultiRunCommand(context: Context, deps: MultiRunCommand } } +function fusionRequestKey(runId: number, form: FusionFormInput): string { + return JSON.stringify([runId, [...form.entryIds].sort((a, b) => a - b), form.model, form.baseRef.trim(), form.instructions.trim()]) +} + function createMultiRunActions(context: Context, api: ManagerApi, repoId: number): MultiRunActions { + const pendingFusions = new Map() + const attempt = async (operation: () => Promise): Promise> => { try { return { ok: true, value: await operation() } @@ -269,17 +287,21 @@ function createMultiRunActions(context: Context, api: ManagerApi, repoId: number return result }, async fuse(run, form) { - const requestId = randomUUID() + const key = fusionRequestKey(run.id, form) + const requestId = pendingFusions.get(key) ?? randomUUID() const parsed = parseFusionForm(form, requestId) if (!parsed.ok) return parsed.error + pendingFusions.set(key, requestId) try { const updated = await api.fuseMultiRun(run.id, parsed.value) + pendingFusions.delete(key) const fusion = updated.fusions.find((candidate) => candidate.requestId === requestId) if (fusion?.status === 'failed') return fusion.error ?? `Fusion with ${fusion.model} failed` context.ui.toast.show({ variant: 'success', message: `Fusing with ${form.model}` }) if (fusion?.sessionId) openManagerSessions(context, [fusion.sessionId]) return null } catch (error) { + if (error instanceof ManagerApiError) pendingFusions.delete(key) const described = describeMultiRunError(error) if (!described.recoveredSessionId) return described.message context.ui.toast.show({ variant: 'info', message: `${described.message} Opened it instead.` }) diff --git a/ocm-cli/src/tui-plugin.ts b/ocm-cli/src/tui-plugin.ts index e251eb577..27b86b9a9 100644 --- a/ocm-cli/src/tui-plugin.ts +++ b/ocm-cli/src/tui-plugin.ts @@ -10,14 +10,14 @@ import { getBranchName } from './local-repo.js' import { transferSession, moveReminderText } from './session-move.js' import { createManagerSessionTransfer } from './remote-session.js' import type { ManagerSessionTransfer } from './remote-session.js' -import { confirmDialog, selectDialog } from './tui-dialogs.js' +import { confirmDialog, requireSessionTarget, selectDialog } from './tui-dialogs.js' 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 { GOAL_COMMAND, runGoalCommand } from './tui-goal.js' import type { GoalDialogProps } from './tui-goal.js' -import { runMultiRunCommand } from './tui-multi-run.js' +import { MULTI_RUN_COMMAND, runMultiRunCommand } from './tui-multi-run.js' import type { MultiRunLaunchDialogProps, MultiRunsDialogProps } from './tui-multi-run.js' import { runOcmSwitch } from './tui-ocm.js' import type { GoalStore } from './goal-store.js' @@ -69,7 +69,7 @@ export async function setupOcm(context: Context, setMoveProgress: MoveProgressSe description: 'Start, pause, resume, or cancel a Manager goal for this session', group: 'OpenCode Manager', palette: true, - slash: { name: 'goal', arguments: true }, + slash: { name: GOAL_COMMAND, arguments: true }, run: (input) => runGoalCommand(context, { remote: features.remote, store: features.goals, showDialog: features.dialogs.goal }, input), }, @@ -79,7 +79,7 @@ export async function setupOcm(context: Context, setMoveProgress: MoveProgressSe description: 'Run one prompt across several models on OpenCode Manager', group: 'OpenCode Manager', palette: true, - slash: { name: 'multirun', arguments: true }, + slash: { name: MULTI_RUN_COMMAND, arguments: true }, run: (input) => runMultiRunCommand( context, @@ -158,18 +158,9 @@ async function resolveMoveTarget(managerApi: ManagerApi, matched: RemoteRepoSumm async function runSessionMove(context: Context, setMoveProgress: MoveProgressSetter): Promise { try { - 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 sessionTarget = requireSessionTarget(context) + if (!sessionTarget) return + const { sessionID, directory, session } = sessionTarget const auth = await resolveManagerAuth() if (!auth.ok) { @@ -178,7 +169,7 @@ async function runSessionMove(context: Context, setMoveProgress: MoveProgressSet } const repos = await fetchRepos(auth.managerUrl, auth.token) - const plan = await prepareMirror(session.location.directory, toRemoteRepoSummaries(repos)) + const plan = await prepareMirror(directory, toRemoteRepoSummaries(repos)) if (plan.matched.length === 0) { context.ui.toast.show({ variant: 'error', message: 'No matching Manager repo; run `ocm push --create` first' }) diff --git a/ocm-cli/src/tui.tsx b/ocm-cli/src/tui.tsx index 931b5840f..420566e49 100644 --- a/ocm-cli/src/tui.tsx +++ b/ocm-cli/src/tui.tsx @@ -7,12 +7,11 @@ import { showMultiRunLaunchDialog, showMultiRunsDialog } from './tui-multi-run-d 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 { createGoalStore } 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' +import { resolveManagerApi } from './manager-auth.js' +import { isOpenSessionGoal, type SessionGoal } from '@opencode-manager/shared/schemas' const SPINNER_INTERVAL_MS = 80 @@ -54,10 +53,10 @@ export default Plugin.define({ 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) + load: async (sessionID, signal) => { + const resolved = await resolveManagerApi(remote.managerUrl) + if (!resolved.ok) throw new Error(resolved.message) + return resolved.api.getLatestSessionGoal(sessionID, signal) }, onOutcome: (goal) => { context.ui.toast.show({ ...goalOutcomeToast(goal), sessionID: goal.sessionId }) @@ -73,13 +72,13 @@ export default Plugin.define({ }) const openGoal = () => { const current = goal() - return current && isOpenGoal(current) ? current : null + return isOpenSessionGoal(current) ? current : null } return ( {(current) => ( - + {formatGoalStatus(current())} diff --git a/ocm-cli/test/goal-store.test.ts b/ocm-cli/test/goal-store.test.ts index fefe5b06d..24f73a94e 100644 --- a/ocm-cli/test/goal-store.test.ts +++ b/ocm-cli/test/goal-store.test.ts @@ -1,29 +1,7 @@ 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, - } -} +import { SESSION_GOAL_POLL_INTERVAL_MS, type SessionGoal } from '@opencode-manager/shared/schemas' +import { createGoalStore } from '../src/goal-store.js' +import { goal } from './helpers/goal-fixture.js' beforeEach(() => { vi.useFakeTimers() @@ -33,24 +11,19 @@ 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) - }) +describe('createGoalStore', () => { + it('defaults the poll interval to the shared session goal interval', async () => { + expect(SESSION_GOAL_POLL_INTERVAL_MS).toBe(3000) + const load = vi.fn().mockResolvedValue(goal({ status: 'active' })) + const store = createGoalStore({ load, onOutcome: vi.fn() }) - 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) - }) -}) + const unwatch = store.watch('ses_a', vi.fn()) + await vi.advanceTimersByTimeAsync(0) + expect(load).toHaveBeenCalledTimes(1) -describe('createGoalStore', () => { - it('defaults the poll interval to the frontend refetch interval', () => { - expect(GOAL_POLL_INTERVAL_MS).toBe(3000) + await vi.advanceTimersByTimeAsync(SESSION_GOAL_POLL_INTERVAL_MS) + expect(load).toHaveBeenCalledTimes(2) + unwatch() }) it('loads once on watch and notifies the listener with the goal', async () => { @@ -63,7 +36,7 @@ describe('createGoalStore', () => { await vi.advanceTimersByTimeAsync(0) expect(load).toHaveBeenCalledTimes(1) - expect(load).toHaveBeenCalledWith('ses_a') + expect(load).toHaveBeenCalledWith('ses_a', expect.any(AbortSignal)) expect(listener).toHaveBeenCalledWith(active) unwatch() }) @@ -96,15 +69,15 @@ describe('createGoalStore', () => { unwatch() }) - it('stops polling when the goal is paused', async () => { + it('keeps polling while 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) + await vi.advanceTimersByTimeAsync(100) - expect(load).toHaveBeenCalledTimes(1) + expect(load).toHaveBeenCalledTimes(2) unwatch() }) @@ -151,22 +124,95 @@ describe('createGoalStore', () => { unwatch() }) - it('reloads on demand through refresh', async () => { - const load = vi.fn().mockResolvedValue(null) + it('does not start a second load while one is in flight', async () => { + let resolveLoad: (goal: SessionGoal | null) => void = () => {} + const load = vi.fn( + () => + new Promise((resolve) => { + resolveLoad = resolve + }), + ) + const store = createGoalStore({ load, onOutcome: vi.fn(), pollIntervalMs: 100 }) + + const unwatch = store.watch('ses_a', vi.fn()) + expect(load).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(1000) + expect(load).toHaveBeenCalledTimes(1) + + resolveLoad(goal({ status: 'active' })) + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(100) + expect(load).toHaveBeenCalledTimes(2) + unwatch() + }) + + it('drops a stale load that resolves after set installs newer state', async () => { + let resolveLoad: (goal: SessionGoal | null) => void = () => {} + const load = vi.fn( + () => + new Promise((resolve) => { + resolveLoad = resolve + }), + ) const store = createGoalStore({ load, onOutcome: vi.fn(), pollIntervalMs: 100 }) const listener = vi.fn() const unwatch = store.watch('ses_a', listener) + const installed = goal({ status: 'paused' }) + store.set(installed) + expect(listener).toHaveBeenLastCalledWith(installed) + + resolveLoad(goal({ status: 'active' })) await vi.advanceTimersByTimeAsync(0) - listener.mockClear() - const next = goal() - load.mockResolvedValue(next) - await store.refresh('ses_a') + expect(listener).toHaveBeenCalledTimes(1) + expect(listener).toHaveBeenLastCalledWith(installed) + unwatch() + }) + it('backs off exponentially on load errors and resets on success', async () => { + const load = vi + .fn() + .mockRejectedValueOnce(new Error('offline')) + .mockRejectedValueOnce(new Error('offline')) + .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(1) + await vi.advanceTimersByTimeAsync(100) expect(load).toHaveBeenCalledTimes(2) - expect(listener).toHaveBeenCalledWith(next) + + await vi.advanceTimersByTimeAsync(300) + expect(load).toHaveBeenCalledTimes(2) + await vi.advanceTimersByTimeAsync(100) + expect(load).toHaveBeenCalledTimes(3) + + await vi.advanceTimersByTimeAsync(100) + expect(load).toHaveBeenCalledTimes(4) + unwatch() + }) + + it('aborts the in-flight load when the last listener leaves without an open goal', async () => { + let captured: AbortSignal | undefined + const load = vi.fn((_sessionID: string, signal: AbortSignal) => { + captured = signal + return new Promise(() => {}) + }) + const store = createGoalStore({ load, onOutcome: vi.fn(), pollIntervalMs: 100 }) + + const unwatch = store.watch('ses_a', vi.fn()) + expect(captured?.aborted).toBe(false) + unwatch() + + expect(captured?.aborted).toBe(true) + expect(load).toHaveBeenCalledTimes(1) }) it('fires onOutcome once when a watched goal reaches a terminal status', async () => { @@ -185,7 +231,7 @@ describe('createGoalStore', () => { expect(onOutcome).toHaveBeenCalledTimes(1) expect(onOutcome).toHaveBeenCalledWith(completed) - await store.refresh('ses_a') + await vi.advanceTimersByTimeAsync(1000) expect(onOutcome).toHaveBeenCalledTimes(1) unwatch() }) @@ -207,6 +253,29 @@ describe('createGoalStore', () => { unwatch() }) + it('fires onOutcome for each new goal id followed in the same entry', 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) + + const first = goal({ id: 7, status: 'active' }) + const firstDone = goal({ id: 7, status: 'completed' }) + store.set(first) + store.set(firstDone) + expect(onOutcome).toHaveBeenCalledTimes(1) + + const second = goal({ id: 8, status: 'active' }) + const secondDone = goal({ id: 8, status: 'completed' }) + store.set(second) + store.set(secondDone) + expect(onOutcome).toHaveBeenCalledTimes(2) + expect(onOutcome).toHaveBeenLastCalledWith(secondDone) + 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() @@ -219,37 +288,69 @@ describe('createGoalStore', () => { unwatch() }) - it('keeps the last goal and polling when a reload fails on an active goal', async () => { + it('keeps following an open goal in the background and fires onOutcome once after the last listener leaves', async () => { const active = goal({ status: 'active' }) - const load = vi.fn().mockResolvedValue(active) + const completed = goal({ status: 'completed' }) + const load = vi.fn().mockResolvedValueOnce(active).mockResolvedValue(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(load).toHaveBeenCalledTimes(1) + + unwatch() + await vi.advanceTimersByTimeAsync(14999) + expect(load).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(1) + expect(load).toHaveBeenCalledTimes(2) + expect(onOutcome).toHaveBeenCalledTimes(1) + expect(onOutcome).toHaveBeenCalledWith(completed) + + await vi.advanceTimersByTimeAsync(60000) + expect(load).toHaveBeenCalledTimes(2) + expect(onOutcome).toHaveBeenCalledTimes(1) + }) + + it('returns to foreground polling when a listener re-subscribes', async () => { + const load = vi.fn().mockResolvedValue(goal({ status: 'active' })) const store = createGoalStore({ load, onOutcome: vi.fn(), pollIntervalMs: 100 }) - const listener = vi.fn() - const unwatch = store.watch('ses_a', listener) + const unwatch = store.watch('ses_a', vi.fn()) await vi.advanceTimersByTimeAsync(0) + expect(load).toHaveBeenCalledTimes(1) - load.mockRejectedValue(new Error('offline')) - await vi.advanceTimersByTimeAsync(100) + unwatch() + await vi.advanceTimersByTimeAsync(5000) + expect(load).toHaveBeenCalledTimes(1) + const unwatchAgain = store.watch('ses_a', vi.fn()) + await vi.advanceTimersByTimeAsync(0) expect(load).toHaveBeenCalledTimes(2) - expect(listener).toHaveBeenLastCalledWith(active) await vi.advanceTimersByTimeAsync(100) expect(load).toHaveBeenCalledTimes(3) - unwatch() + unwatchAgain() }) - it('does not poll after an initial load failure', async () => { - const load = vi.fn().mockRejectedValue(new Error('offline')) + 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) - await vi.advanceTimersByTimeAsync(1000) - expect(load).toHaveBeenCalledTimes(1) - expect(listener).not.toHaveBeenCalled() + load.mockRejectedValue(new Error('offline')) + await vi.advanceTimersByTimeAsync(100) + + expect(load).toHaveBeenCalledTimes(2) + expect(listener).toHaveBeenLastCalledWith(active) + + await vi.advanceTimersByTimeAsync(200) + expect(load).toHaveBeenCalledTimes(3) unwatch() }) diff --git a/ocm-cli/test/helpers/goal-fixture.ts b/ocm-cli/test/helpers/goal-fixture.ts new file mode 100644 index 000000000..b70043ae5 --- /dev/null +++ b/ocm-cli/test/helpers/goal-fixture.ts @@ -0,0 +1,24 @@ +import type { SessionGoal } from '@opencode-manager/shared/schemas' + +export 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, + } +} diff --git a/ocm-cli/test/manager-api.test.ts b/ocm-cli/test/manager-api.test.ts index dec686da7..e89d90ae1 100644 --- a/ocm-cli/test/manager-api.test.ts +++ b/ocm-cli/test/manager-api.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, afterEach } from 'vitest' -import { ManagerApi, ManagerApiError, isManagerRouteMissing } from '../src/manager-api.js' +import { ManagerApi, ManagerApiError, isManagerRouteMissing, MANAGER_FEATURE_MISSING } from '../src/manager-api.js' import type { MultiRun, SessionGoal } from '@opencode-manager/shared/schemas' const BASE_URL = 'http://localhost:5003' @@ -105,6 +105,18 @@ describe('ManagerApi session goals', () => { }) }) + it('forwards the abort signal to fetch', async () => { + const fetchMock = stubFetch(okResponse({ goal })) + const controller = new AbortController() + + await api.getLatestSessionGoal('ses_1', controller.signal) + + expect(fetchMock).toHaveBeenCalledWith(`${BASE_URL}/api/internal/session-goals?sessionId=ses_1`, { + headers: { Authorization: 'Bearer tok' }, + signal: controller.signal, + }) + }) + it('rejects a goal response that fails schema validation', async () => { stubFetch(okResponse({ goal: { ...goal, status: 'bogus' } })) @@ -162,16 +174,26 @@ describe('ManagerApi multi-runs', () => { describe('ManagerApiError', () => { const api = new ManagerApi(BASE_URL, 'tok') - it('carries a JSON error code and details', async () => { + it('reads the machine code from the JSON code field, not the human error text', async () => { const details = { unavailableSources: [{ entryId: 1, model: 'a/b', reason: 'running', message: 'still running' }] } - stubFetch(errorResponse(409, JSON.stringify({ error: 'Fusion unavailable', details }))) + stubFetch(errorResponse(409, JSON.stringify({ error: 'Fusion unavailable', code: 'FUSION_UNAVAILABLE', details }))) const error = await api.fuseMultiRun(3, { requestId: '11111111-1111-4111-8111-111111111111', entryIds: [1, 2], model: 'a/b' }).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).code).toBe('FUSION_UNAVAILABLE') + expect((error as ManagerApiError).message).toContain('Fusion unavailable') expect((error as ManagerApiError).details).toEqual(details) + expect((error as ManagerApiError).jsonBody).toBe(true) + }) + + it('leaves the code null when the body only carries human error text', async () => { + stubFetch(errorResponse(409, JSON.stringify({ error: 'Fusion unavailable' }))) + + const error = await api.fuseMultiRun(3, { requestId: '11111111-1111-4111-8111-111111111111', entryIds: [1, 2], model: 'a/b' }).catch((err) => err) + + expect((error as ManagerApiError).code).toBeNull() }) it('leaves details null when the error body has none', async () => { @@ -181,6 +203,64 @@ describe('ManagerApiError', () => { expect((error as ManagerApiError).details).toBeNull() }) + + it('marks a plain-text error body as non-JSON', async () => { + stubFetch(errorResponse(500, 'boom')) + + const error = await api.listMultiRuns(1).catch((err) => err) + + expect((error as ManagerApiError).jsonBody).toBe(false) + expect((error as ManagerApiError).code).toBeNull() + }) +}) + +describe('ManagerApi feature support', () => { + const api = new ManagerApi(BASE_URL, 'tok') + + it('maps a 401 to MANAGER_FEATURE_MISSING when the token probe succeeds', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(errorResponse(401, JSON.stringify({ error: 'Unauthorized' }))) + .mockResolvedValueOnce(okResponse({ workspaces: [] })) + vi.stubGlobal('fetch', fetchMock) + + const error = await api.listMultiRuns(1).catch((err) => err) + + expect(error).toBeInstanceOf(ManagerApiError) + expect((error as ManagerApiError).status).toBe(401) + expect((error as ManagerApiError).code).toBe(MANAGER_FEATURE_MISSING) + expect(isManagerRouteMissing(error)).toBe(true) + expect(fetchMock).toHaveBeenNthCalledWith(2, `${BASE_URL}/api/internal/opencode-workspaces`, { + headers: { Authorization: 'Bearer tok' }, + }) + }) + + it('keeps the plain 401 when the token probe is also rejected', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(errorResponse(401, JSON.stringify({ error: 'Unauthorized' }))) + .mockResolvedValueOnce(errorResponse(401, JSON.stringify({ error: 'Unauthorized' }))) + vi.stubGlobal('fetch', fetchMock) + + const error = await api.listMultiRuns(1).catch((err) => err) + + expect(error).toBeInstanceOf(ManagerApiError) + expect((error as ManagerApiError).status).toBe(401) + expect((error as ManagerApiError).code).toBeNull() + expect(isManagerRouteMissing(error)).toBe(false) + }) + + it('keeps the plain 401 when the token probe throws', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(errorResponse(401, JSON.stringify({ error: 'Unauthorized' }))) + .mockRejectedValueOnce(new Error('network down')) + vi.stubGlobal('fetch', fetchMock) + + const error = await api.listMultiRuns(1).catch((err) => err) + + expect(error).toBeInstanceOf(ManagerApiError) + expect((error as ManagerApiError).status).toBe(401) + expect((error as ManagerApiError).code).toBeNull() + expect(isManagerRouteMissing(error)).toBe(false) + }) }) describe('isManagerRouteMissing', () => { diff --git a/ocm-cli/test/manager-auth.test.ts b/ocm-cli/test/manager-auth.test.ts index b92b2d0e1..6940ea78a 100644 --- a/ocm-cli/test/manager-auth.test.ts +++ b/ocm-cli/test/manager-auth.test.ts @@ -1,17 +1,25 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { resolveManagerAuth } from '../src/manager-auth.js' +import { resolveManagerAuth, resolveManagerApi } from '../src/manager-auth.js' +import { ManagerApi } from '../src/manager-api.js' import { TokenStoreError } from '../src/token-store.js' const mocks = vi.hoisted(() => ({ readState: vi.fn(), getToken: vi.fn(), + describeTokenStore: vi.fn(), })) vi.mock('../src/state.js', () => ({ readState: mocks.readState })) -vi.mock('../src/internal-token-store.js', () => ({ getToken: mocks.getToken })) +vi.mock('../src/internal-token-store.js', () => ({ + getToken: mocks.getToken, + describeTokenStore: mocks.describeTokenStore, +})) + +const STORE = { kind: 'file', location: '/tmp/credentials.json' } beforeEach(() => { vi.resetAllMocks() + mocks.describeTokenStore.mockReturnValue(STORE) }) describe('resolveManagerAuth', () => { @@ -20,6 +28,7 @@ describe('resolveManagerAuth', () => { await expect(resolveManagerAuth()).resolves.toEqual({ ok: false, + reason: 'no-manager', message: 'No manager configured. Run `ocm login ` first.', }) expect(mocks.getToken).not.toHaveBeenCalled() @@ -49,23 +58,32 @@ describe('resolveManagerAuth', () => { expect(mocks.getToken).toHaveBeenCalledWith('https://mgr.example') }) - it('reports a token store failure', async () => { + it('reports a token store failure with the store kind and location', 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', + reason: 'token-store', + message: 'token store error (file: /tmp/credentials.json): keychain locked. Run `ocm login https://mgr.example` after fixing the store.', }) }) - it('reports a missing token', async () => { + it('rethrows a non token-store error', async () => { + mocks.readState.mockReturnValue({ managerUrl: 'https://mgr.example' }) + mocks.getToken.mockRejectedValue(new Error('boom')) + + await expect(resolveManagerAuth()).rejects.toThrow('boom') + }) + + it('reports a missing token with the store kind and location', 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`.', + reason: 'no-token', + message: 'no token stored for https://mgr.example (file: /tmp/credentials.json). Run `ocm login https://mgr.example`.', }) }) @@ -80,3 +98,26 @@ describe('resolveManagerAuth', () => { }) }) }) + +describe('resolveManagerApi', () => { + it('builds an api from the resolved auth', async () => { + mocks.readState.mockReturnValue({ managerUrl: 'https://mgr.example' }) + mocks.getToken.mockResolvedValue('tok') + + const result = await resolveManagerApi() + + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.auth).toEqual({ ok: true, managerUrl: 'https://mgr.example', token: 'tok' }) + expect(result.api).toBeInstanceOf(ManagerApi) + }) + + it('reports the auth failure message without building an api', async () => { + mocks.readState.mockReturnValue(null) + + await expect(resolveManagerApi()).resolves.toEqual({ + ok: false, + message: 'No manager configured. Run `ocm login ` first.', + }) + }) +}) diff --git a/ocm-cli/test/tui-goal.test.ts b/ocm-cli/test/tui-goal.test.ts index 3b209911e..f519ef993 100644 --- a/ocm-cli/test/tui-goal.test.ts +++ b/ocm-cli/test/tui-goal.test.ts @@ -4,6 +4,7 @@ import type { SessionGoal } from '@opencode-manager/shared/schemas' import { runGoalCommand, formatGoalStatus, + goalObjectiveSummary, goalOutcomeToast, parseGoalForm, GOALS_ATTACH_REQUIRED, @@ -12,12 +13,13 @@ import { import type { GoalActions } 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 { resolveManagerApi } from '../src/manager-auth.js' import type { GoalStore } from '../src/goal-store.js' import type { RemoteContext } from '../src/remote-context.js' +import { goal } from './helpers/goal-fixture.js' vi.mock('../src/manager-auth.js', () => ({ - resolveManagerAuth: vi.fn(), + resolveManagerApi: vi.fn(), })) const remote: RemoteContext = { @@ -27,30 +29,10 @@ const remote: RemoteContext = { 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' }) { +function createFakeContext( + route: { type: string; sessionID?: string } = { type: 'session', sessionID: 'ses_a' }, + session: unknown = { location: { directory: '/repo' } }, +) { const toast = vi.fn() const select = vi.fn() const dialogPrompt = vi.fn() @@ -64,7 +46,7 @@ function createFakeContext(route: { type: string; sessionID?: string } = { type: }, data: { session: { - get: () => ({ location: { directory: '/repo' } }), + get: () => session, status, }, }, @@ -88,26 +70,43 @@ function makeStore() { 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, showDialog: vi.fn() } +function depsFor(api: ReturnType, store: GoalStore) { + vi.mocked(resolveManagerApi).mockResolvedValue({ + ok: true, + auth: { ok: true, managerUrl: remote.managerUrl, token: 'tok' }, + api: api as unknown as ManagerApi, + }) + return { remote, store, showDialog: vi.fn() } } async function dialogActions(fake: ReturnType, api: ReturnType, store: GoalStore): Promise { const deps = depsFor(api, store) - await runGoalCommand(fake.context, deps, '/goal') + await runGoalCommand(fake.context, deps, '/ocm-goal') return deps.showDialog.mock.calls[0]![0].actions } const blankLimits = { maxTurns: '', tokenBudget: '' } beforeEach(() => { - vi.mocked(resolveManagerAuth).mockReset() - vi.mocked(resolveManagerAuth).mockResolvedValue({ ok: true, managerUrl: remote.managerUrl, token: 'tok' }) + vi.mocked(resolveManagerApi).mockReset() +}) + +describe('goalObjectiveSummary', () => { + it('keeps the first line of a multi-line objective', () => { + expect(goalObjectiveSummary('fix it\nand more')).toBe('fix it') + }) + + it('leaves a short single line unchanged', () => { + expect(goalObjectiveSummary('fix it')).toBe('fix it') + }) + + it('cuts a long first line and appends an ellipsis', () => { + expect(goalObjectiveSummary('x'.repeat(70))).toBe(`${'x'.repeat(60)}…`) + }) }) describe('formatGoalStatus', () => { @@ -120,6 +119,10 @@ describe('formatGoalStatus', () => { it('omits the token budget when there is none', () => { expect(formatGoalStatus(goal({ status: 'paused', tokenBudget: null }))).toBe('Goal paused · Turn 0/5 · fix it') }) + + it('renders one line using only the objective first line', () => { + expect(formatGoalStatus(goal({ status: 'active', objective: 'fix it\nsecond line' }))).toBe('Goal active · Turn 0/5 · fix it') + }) }) describe('goalOutcomeToast', () => { @@ -146,6 +149,10 @@ describe('goalOutcomeToast', () => { message: 'Cancelled', }) }) + + it('summarizes a long objective fallback', () => { + expect(goalOutcomeToast(goal({ status: 'blocked', lastReason: null, objective: 'x'.repeat(70) })).message).toBe(`${'x'.repeat(60)}…`) + }) }) describe('parseGoalForm', () => { @@ -191,12 +198,12 @@ describe('runGoalCommand', () => { const detached = { ...depsFor(api, store), remote: undefined } const storeless = { ...depsFor(api, store), store: undefined } - await runGoalCommand(fake.context, detached, '/goal fix it') - await runGoalCommand(fake.context, storeless, '/goal fix it') + await runGoalCommand(fake.context, detached, '/ocm-goal fix it') + await runGoalCommand(fake.context, storeless, '/ocm-goal fix it') expect(fake.toast).toHaveBeenCalledTimes(2) expect(fake.toast).toHaveBeenCalledWith({ variant: 'error', message: GOALS_ATTACH_REQUIRED }) - expect(detached.createApi).not.toHaveBeenCalled() + expect(vi.mocked(resolveManagerApi)).not.toHaveBeenCalled() }) it('refuses outside a session', async () => { @@ -204,19 +211,31 @@ describe('runGoalCommand', () => { const { store } = makeStore() const deps = depsFor(makeApi(), store) - await runGoalCommand(fake.context, deps, '/goal fix it') + await runGoalCommand(fake.context, deps, '/ocm-goal fix it') expect(fake.toast).toHaveBeenCalledWith({ variant: 'error', message: 'Not in a session' }) - expect(deps.createApi).not.toHaveBeenCalled() + expect(vi.mocked(resolveManagerApi)).not.toHaveBeenCalled() + }) + + it('refuses when the session has no directory', async () => { + const fake = createFakeContext({ type: 'session', sessionID: 'ses_a' }, { location: {} }) + const { store } = makeStore() + const deps = depsFor(makeApi(), store) + + await runGoalCommand(fake.context, deps, '/ocm-goal fix it') + + expect(fake.toast).toHaveBeenCalledWith({ variant: 'error', message: 'Session has no directory' }) + expect(vi.mocked(resolveManagerApi)).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`.' }) + const deps = depsFor(api, store) + vi.mocked(resolveManagerApi).mockResolvedValue({ ok: false, message: 'No token stored. Run `ocm login https://manager.example`.' }) - await runGoalCommand(fake.context, depsFor(api, store), '/goal fix it') + await runGoalCommand(fake.context, deps, '/ocm-goal fix it') expect(fake.toast).toHaveBeenCalledWith({ variant: 'error', @@ -234,8 +253,9 @@ describe('runGoalCommand', () => { const deps = depsFor(api, store) fake.sessionPrompt.mockResolvedValue(undefined) - await runGoalCommand(fake.context, deps, '/goal Fix the flaky test') + await runGoalCommand(fake.context, deps, '/ocm-goal Fix the flaky test') + expect(vi.mocked(resolveManagerApi)).toHaveBeenCalledWith(remote.managerUrl) 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 }) @@ -251,7 +271,7 @@ describe('runGoalCommand', () => { fake.status.mockReturnValue('running') fake.sessionPrompt.mockResolvedValue(undefined) - await runGoalCommand(fake.context, depsFor(api, store), '/goal Fix it') + await runGoalCommand(fake.context, depsFor(api, store), '/ocm-goal Fix it') expect(fake.sessionPrompt).toHaveBeenCalledWith(expect.objectContaining({ delivery: 'queue' })) }) @@ -264,7 +284,7 @@ describe('runGoalCommand', () => { const { store } = makeStore() const deps = depsFor(api, store) - await runGoalCommand(fake.context, deps, '/goal') + await runGoalCommand(fake.context, deps, '/ocm-goal') expect(deps.showDialog).toHaveBeenCalledWith({ sessionID: 'ses_a', @@ -284,7 +304,7 @@ describe('runGoalCommand', () => { const { store } = makeStore() const deps = depsFor(api, store) - await runGoalCommand(fake.context, deps, '/goal something else') + await runGoalCommand(fake.context, deps, '/ocm-goal something else') expect(deps.showDialog).toHaveBeenCalledWith(expect.objectContaining({ initialGoal: active, initialObjective: 'something else' })) expect(api.startSessionGoal).not.toHaveBeenCalled() @@ -297,8 +317,8 @@ describe('runGoalCommand', () => { api.getLatestSessionGoal.mockRejectedValueOnce(new ManagerApiError('read failed', 500, 'boom', 'read session goal')) api.getLatestSessionGoal.mockRejectedValueOnce(new ManagerApiError('not found', 404, null, 'read session goal')) - await runGoalCommand(fake.context, depsFor(api, store), '/goal fix it') - await runGoalCommand(fake.context, depsFor(api, store), '/goal fix it') + await runGoalCommand(fake.context, depsFor(api, store), '/ocm-goal fix it') + await runGoalCommand(fake.context, depsFor(api, store), '/ocm-goal fix it') expect(fake.toast).toHaveBeenNthCalledWith(1, { variant: 'error', message: 'read failed' }) expect(fake.toast).toHaveBeenNthCalledWith(2, { variant: 'error', message: GOALS_ROUTE_MISSING }) diff --git a/ocm-cli/test/tui-multi-run.test.ts b/ocm-cli/test/tui-multi-run.test.ts index 803503c69..7ba873209 100644 --- a/ocm-cli/test/tui-multi-run.test.ts +++ b/ocm-cli/test/tui-multi-run.test.ts @@ -1,7 +1,7 @@ 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 type { ModelInfo } from '@opencode-manager/shared/opencode' +import type { MultiRun, MultiRunEntry, MultiRunFusion } from '@opencode-manager/shared/schemas' import { describeMultiRunError, filterModelOptions, @@ -19,12 +19,11 @@ import { import type { LaunchFormInput, MultiRunActions } 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 { resolveManagerApi } 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(), + resolveManagerApi: vi.fn(), })) const remote: RemoteContext = { @@ -34,6 +33,12 @@ const remote: RemoteContext = { repoId: 1, } +type FakeProvider = { id: string; name: string; activation: 'auto' | 'enabled' | 'disabled' } + +function provider(overrides: Partial = {}): FakeProvider { + return { id: 'openai', name: 'OpenAI', activation: 'enabled', ...overrides } +} + function model(overrides: Partial = {}): ModelInfo { return { id: 'gpt-5', @@ -111,6 +116,8 @@ function createFakeContext() { const navigate = vi.fn() const sync = vi.fn(async () => undefined) const list = vi.fn(() => [] as ModelInfo[]) + const providerSync = vi.fn(async () => undefined) + const providerList = vi.fn(() => [] as FakeProvider[]) const context = { ui: { toast: { show: toast }, @@ -118,9 +125,9 @@ function createFakeContext() { tabs: { enabled, open, focus }, router: { navigate, current: () => ({ type: 'session', sessionID: 'ses_a' }) }, }, - data: { location: { model: { sync, list } } }, + data: { location: { model: { sync, list }, provider: { sync: providerSync, list: providerList } } }, } as unknown as Context - return { context, toast, prompt, select, confirm, open, focus, enabled, navigate, sync, list } + return { context, toast, prompt, select, confirm, open, focus, enabled, navigate, sync, list, providerSync, providerList } } function makeApi() { @@ -132,13 +139,22 @@ function makeApi() { } } -function depsFor(api: ReturnType, createApi = vi.fn(() => api as unknown as ManagerApi)) { - return { remote, createApi, showLaunchDialog: vi.fn(), showRunsDialog: vi.fn() } +function mockResolvedApi(api: ReturnType): void { + vi.mocked(resolveManagerApi).mockResolvedValue({ + ok: true, + auth: { ok: true, managerUrl: remote.managerUrl, token: 'tok' }, + api: api as unknown as ManagerApi, + }) +} + +function depsFor() { + return { remote, showLaunchDialog: vi.fn(), showRunsDialog: vi.fn() } } async function launchActions(fake: ReturnType, api: ReturnType): Promise { - const deps = depsFor(api) - await runMultiRunCommand(fake.context, deps, '/multirun hello') + mockResolvedApi(api) + const deps = depsFor() + await runMultiRunCommand(fake.context, deps, '/ocm-multirun hello') return deps.showLaunchDialog.mock.calls[0]![0].actions } @@ -148,14 +164,19 @@ function launchForm(overrides: Partial = {}): LaunchFormInput { return { prompt: 'fix the flaky test', name: '', models: ['openai/gpt-5'], isolate: true, baseRef: '', ...overrides } } +function fusionForm(overrides: Partial<{ entryIds: number[]; model: string; baseRef: string; instructions: string }> = {}) { + return { entryIds: [1, 2], model: 'anthropic/claude', baseRef: '', instructions: '', ...overrides } +} + beforeEach(() => { - vi.mocked(resolveManagerAuth).mockReset() - vi.mocked(resolveManagerAuth).mockResolvedValue({ ok: true, managerUrl: remote.managerUrl, token: 'tok' }) + vi.mocked(resolveManagerApi).mockReset() + mockResolvedApi(makeApi()) }) describe('listModelOptions', () => { - it('syncs then maps enabled, non-deprecated models to refs', async () => { + it('syncs then maps selectable models of active providers to refs', async () => { const fake = createFakeContext() + fake.providerList.mockReturnValue([provider({ id: 'openai', name: 'OpenAI' }), provider({ id: 'anthropic', name: 'Anthropic' })]) fake.list.mockReturnValue([ model({ providerID: 'openai', id: 'gpt-5', name: 'GPT-5' }), model({ providerID: 'anthropic', id: 'claude', name: 'Claude' }), @@ -164,19 +185,80 @@ describe('listModelOptions', () => { ]) 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' }, + { title: 'GPT-5', description: 'openai/gpt-5', value: 'openai/gpt-5' }, ]) expect(fake.sync).toHaveBeenCalledTimes(1) + expect(fake.providerSync).toHaveBeenCalledTimes(1) expect(fake.sync.mock.invocationCallOrder[0]).toBeLessThan(fake.list.mock.invocationCallOrder[0]) }) + it('drops models whose provider is disabled or absent from the catalog', async () => { + const fake = createFakeContext() + fake.providerList.mockReturnValue([provider({ id: 'openai', name: 'OpenAI' }), provider({ id: 'hidden', name: 'Hidden', activation: 'disabled' })]) + fake.list.mockReturnValue([ + model({ providerID: 'openai', id: 'gpt-5', name: 'GPT-5' }), + model({ providerID: 'hidden', id: 'h', name: 'Hidden' }), + model({ providerID: 'ghost', id: 'g', name: 'Ghost' }), + ]) + + await expect(listModelOptions(fake.context)).resolves.toEqual([ + { title: 'GPT-5', description: 'openai/gpt-5', value: 'openai/gpt-5' }, + ]) + }) + + it('orders by provider rank then provider name', async () => { + const fake = createFakeContext() + fake.providerList.mockReturnValue([ + provider({ id: 'zeta', name: 'Zeta' }), + provider({ id: 'opencode-go', name: 'OpenCode Go' }), + provider({ id: 'alpha', name: 'Alpha' }), + ]) + fake.list.mockReturnValue([ + model({ providerID: 'zeta', id: 'z', name: 'Z' }), + model({ providerID: 'alpha', id: 'a', name: 'A' }), + model({ providerID: 'opencode-go', id: 'o', name: 'O' }), + ]) + + await expect(listModelOptions(fake.context)).resolves.toEqual([ + { title: 'O', description: 'opencode-go/o', value: 'opencode-go/o' }, + { title: 'A', description: 'alpha/a', value: 'alpha/a' }, + { title: 'Z', description: 'zeta/z', value: 'zeta/z' }, + ]) + }) + + it('prefers free then newest models within a provider', async () => { + const fake = createFakeContext() + fake.providerList.mockReturnValue([provider({ id: 'openai', name: 'OpenAI' })]) + fake.list.mockReturnValue([ + model({ providerID: 'openai', id: 'paid', name: 'Paid', cost: [{ input: 3 }], time: { released: 9 } }), + model({ providerID: 'openai', id: 'free-old', name: 'Free Old', cost: [{ input: 0 }], time: { released: 1 } }), + model({ providerID: 'openai', id: 'free-new', name: 'Free New', cost: [{ input: 0 }], time: { released: 5 } }), + model({ providerID: 'openai', id: 'paid-new', name: 'Paid New', cost: [{ input: 3 }], time: { released: 10 } }), + ]) + + await expect(listModelOptions(fake.context)).resolves.toEqual([ + { title: 'Free New', description: 'openai/free-new', value: 'openai/free-new' }, + { title: 'Free Old', description: 'openai/free-old', value: 'openai/free-old' }, + { title: 'Paid New', description: 'openai/paid-new', value: 'openai/paid-new' }, + { title: 'Paid', description: 'openai/paid', value: 'openai/paid' }, + ]) + }) + 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([]) }) + + it('tolerates a provider list that is not loaded yet', async () => { + const fake = createFakeContext() + fake.providerList.mockReturnValue(undefined as unknown as FakeProvider[]) + fake.list.mockReturnValue([model()]) + + await expect(listModelOptions(fake.context)).resolves.toEqual([]) + }) }) describe('openManagerSessions', () => { @@ -343,7 +425,7 @@ describe('describeMultiRunError', () => { }) it('lists each unavailable fusion source', () => { - const error = new ManagerApiError('conflict', 409, 'Some selected results are not ready to fuse', 'fuse multi-run', { + const error = new ManagerApiError('conflict', 409, null, 'fuse multi-run', { unavailableSources: [ { entryId: 1, model: 'openai/gpt-5', reason: 'running', message: 'still running' }, { entryId: 2, model: 'anthropic/claude', reason: 'failed', message: 'failed' }, @@ -352,8 +434,8 @@ describe('describeMultiRunError', () => { expect(describeMultiRunError(error)).toEqual({ message: 'openai/gpt-5: still running\nanthropic/claude: failed' }) }) - it('returns the session of a recovered fusion attempt', () => { - const error = new ManagerApiError('conflict', 409, 'An earlier fusion attempt is already running', 'fuse multi-run', { + it('returns the session of a recovered fusion attempt when the Manager sent no machine code', () => { + const error = new ManagerApiError('conflict', 409, null, 'fuse multi-run', { fusions: [{ fusionId: 4, sessionId: 'ses_recovered' }], }) expect(describeMultiRunError(error)).toEqual({ @@ -362,8 +444,19 @@ describe('describeMultiRunError', () => { }) }) + it('recovers only when the machine code marks the attempt recovered', () => { + const details = { fusions: [{ fusionId: 4, sessionId: 'ses_recovered' }] } + expect(describeMultiRunError(new ManagerApiError('conflict', 409, 'FUSION_ATTEMPT_RECOVERED', 'fuse multi-run', details))).toEqual({ + message: 'An earlier fusion attempt is already running.', + recoveredSessionId: 'ses_recovered', + }) + expect(describeMultiRunError(new ManagerApiError('context limit', 409, 'FUSION_CONTEXT_LIMIT', 'fuse multi-run', details))).toEqual({ + message: 'context limit', + }) + }) + it('falls back to the error message', () => { - expect(describeMultiRunError(new ManagerApiError('launch failed (500)', 500, 'boom', 'launch multi-run'))).toEqual({ + expect(describeMultiRunError(new ManagerApiError('launch failed (500)', 500, null, 'launch multi-run'))).toEqual({ message: 'launch failed (500)', }) expect(describeMultiRunError(new Error('offline'))).toEqual({ message: 'offline' }) @@ -373,23 +466,23 @@ describe('describeMultiRunError', () => { describe('runMultiRunCommand', () => { it('refuses when not attached to a Manager repo', async () => { const fake = createFakeContext() - const api = makeApi() - const withoutRemote = { ...depsFor(api), remote: undefined } - const withoutRepo = { ...depsFor(api), remote: { ...remote, repoId: undefined } } + const withoutRemote = { ...depsFor(), remote: undefined } + const withoutRepo = { ...depsFor(), remote: { ...remote, repoId: undefined } } - await runMultiRunCommand(fake.context, withoutRemote, '/multirun fix it') + await runMultiRunCommand(fake.context, withoutRemote, '/ocm-multirun fix it') await runMultiRunCommand(fake.context, withoutRepo) expect(fake.toast).toHaveBeenCalledTimes(2) expect(fake.toast).toHaveBeenCalledWith({ variant: 'error', message: MULTI_RUN_ATTACH_REQUIRED }) expect(withoutRemote.showLaunchDialog).not.toHaveBeenCalled() expect(withoutRepo.showRunsDialog).not.toHaveBeenCalled() + expect(resolveManagerApi).not.toHaveBeenCalled() }) it('reports an auth failure without opening a dialog', async () => { - vi.mocked(resolveManagerAuth).mockResolvedValue({ ok: false, message: 'No token stored.' }) + vi.mocked(resolveManagerApi).mockResolvedValue({ ok: false, message: 'No token stored.' }) const fake = createFakeContext() - const deps = depsFor(makeApi()) + const deps = depsFor() await runMultiRunCommand(fake.context, deps) @@ -397,21 +490,52 @@ describe('runMultiRunCommand', () => { expect(deps.showRunsDialog).not.toHaveBeenCalled() }) - it('opens the launch dialog prefilled with the slash argument', async () => { + it('resolves the Manager api through resolveManagerApi and uses the returned api', async () => { + const fake = createFakeContext() + const api = makeApi() + mockResolvedApi(api) + const deps = depsFor() + + await runMultiRunCommand(fake.context, deps) + const actions = deps.showRunsDialog.mock.calls[0]![0].actions + await actions.list() + + expect(resolveManagerApi).toHaveBeenCalledWith(remote.managerUrl) + expect(api.listMultiRuns).toHaveBeenCalledWith(1) + }) + + it('opens the launch dialog prefilled with the slash argument after listing runs', async () => { const fake = createFakeContext() - const deps = depsFor(makeApi()) + const api = makeApi() + mockResolvedApi(api) + const deps = depsFor() - await runMultiRunCommand(fake.context, deps, '/multirun fix the flaky test ') + await runMultiRunCommand(fake.context, deps, '/ocm-multirun fix the flaky test ') + expect(api.listMultiRuns).toHaveBeenCalledWith(1) expect(deps.showLaunchDialog).toHaveBeenCalledWith({ actions: expect.any(Object), initialPrompt: 'fix the flaky test' }) expect(deps.showRunsDialog).not.toHaveBeenCalled() }) + it('does not open the launch dialog when the Manager cannot list multi-runs', async () => { + const fake = createFakeContext() + const api = makeApi() + api.listMultiRuns.mockRejectedValue(new ManagerApiError('not found', 404, null, 'list multi-runs')) + mockResolvedApi(api) + const deps = depsFor() + + await runMultiRunCommand(fake.context, deps, '/ocm-multirun hello') + + expect(api.listMultiRuns).toHaveBeenCalledWith(1) + expect(deps.showLaunchDialog).not.toHaveBeenCalled() + expect(fake.toast).toHaveBeenCalledWith({ variant: 'error', message: MULTI_RUN_ROUTE_MISSING }) + }) + it('opens the runs dialog without a prompt, whose new action opens an empty launch dialog', async () => { const fake = createFakeContext() - const deps = depsFor(makeApi()) + const deps = depsFor() - await runMultiRunCommand(fake.context, deps, '/multirun') + await runMultiRunCommand(fake.context, deps, '/ocm-multirun') deps.showRunsDialog.mock.calls[0]![0].newRun() expect(deps.showLaunchDialog).toHaveBeenCalledWith({ actions: expect.any(Object), initialPrompt: '' }) @@ -454,6 +578,27 @@ describe('multi-run dialog actions', () => { expect(fake.toast).toHaveBeenCalledWith({ variant: 'warning', message: 'Launched sweep: 1/2 started\nanthropic/claude: quota' }) }) + it('errors when no entry started', async () => { + const fake = createFakeContext() + const api = makeApi() + api.launchMultiRun.mockResolvedValue( + multiRun({ + entries: [ + entry({ id: 1, status: 'failed', sessionId: null, error: 'quota' }), + entry({ id: 2, model: 'anthropic/claude', status: 'failed', sessionId: null, error: 'quota' }), + ], + }), + ) + const actions = await launchActions(fake, api) + + await actions.launch(launchForm()) + + expect(fake.toast).toHaveBeenCalledWith({ + variant: 'error', + message: 'Launched sweep: 0/2 started\nopenai/gpt-5: quota\nanthropic/claude: quota', + }) + }) + it('returns validation and API errors from launch without toasting', async () => { const fake = createFakeContext() const api = makeApi() @@ -495,9 +640,7 @@ describe('multi-run dialog actions', () => { ) const actions = await launchActions(fake, api) - await expect( - actions.fuse(multiRun(), { entryIds: [1, 2], model: 'anthropic/claude', baseRef: '', instructions: '' }), - ).resolves.toBeNull() + await expect(actions.fuse(multiRun(), fusionForm())).resolves.toBeNull() const request = api.fuseMultiRun.mock.calls[0]![1] as Record expect(request).toEqual({ requestId: expect.stringMatching(/^[0-9a-f-]{36}$/), entryIds: [1, 2], model: 'anthropic/claude' }) @@ -513,25 +656,50 @@ describe('multi-run dialog actions', () => { ) const actions = await launchActions(fake, api) - await expect( - actions.fuse(multiRun(), { entryIds: [1, 2], model: 'anthropic/claude', baseRef: '', instructions: '' }), - ).resolves.toBe('context limit') + await expect(actions.fuse(multiRun(), fusionForm())).resolves.toBe('context limit') expect(fake.open).not.toHaveBeenCalled() }) + it('reuses the fusion request id while a network error leaves the outcome unknown', async () => { + const fake = createFakeContext() + const api = makeApi() + api.fuseMultiRun.mockRejectedValue(new Error('offline')) + const actions = await launchActions(fake, api) + + await expect(actions.fuse(multiRun(), fusionForm())).resolves.toBe('offline') + await expect(actions.fuse(multiRun(), fusionForm())).resolves.toBe('offline') + + const [first, second] = api.fuseMultiRun.mock.calls.map((call) => (call[1] as { requestId: string }).requestId) + expect(second).toBe(first) + }) + + it('mints a fresh fusion request id once the Manager has answered', async () => { + const fake = createFakeContext() + const api = makeApi() + api.fuseMultiRun.mockRejectedValueOnce(new ManagerApiError('boom', 500, null, 'fuse multi-run')) + api.fuseMultiRun.mockImplementationOnce(async (_runId: number, request: { requestId: string }) => + multiRun({ fusions: [fusion({ requestId: request.requestId, sessionId: 'ses_f' })] }), + ) + const actions = await launchActions(fake, api) + + await expect(actions.fuse(multiRun(), fusionForm())).resolves.toBe('boom') + await expect(actions.fuse(multiRun(), fusionForm())).resolves.toBeNull() + + const [first, second] = api.fuseMultiRun.mock.calls.map((call) => (call[1] as { requestId: string }).requestId) + expect(second).not.toBe(first) + }) + it('opens the recovered fusion session instead of failing', async () => { const fake = createFakeContext() const api = makeApi() api.fuseMultiRun.mockRejectedValue( - new ManagerApiError('conflict', 409, 'An earlier fusion attempt is already running', 'fuse multi-run', { + new ManagerApiError('conflict', 409, 'FUSION_ATTEMPT_RECOVERED', 'fuse multi-run', { fusions: [{ fusionId: 4, sessionId: 'ses_recovered' }], }), ) const actions = await launchActions(fake, api) - await expect( - actions.fuse(multiRun(), { entryIds: [1, 2], model: 'anthropic/claude', baseRef: '', instructions: '' }), - ).resolves.toBeNull() + await expect(actions.fuse(multiRun(), fusionForm())).resolves.toBeNull() expect(fake.open).toHaveBeenCalledWith('ses_recovered') expect(fake.toast).toHaveBeenCalledWith({ variant: 'info', diff --git a/ocm-cli/test/tui-plugin.test.ts b/ocm-cli/test/tui-plugin.test.ts index ea5c40330..9e14bea0f 100644 --- a/ocm-cli/test/tui-plugin.test.ts +++ b/ocm-cli/test/tui-plugin.test.ts @@ -228,7 +228,7 @@ describe('setupOcm', () => { 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 }) + expect(goal?.slash).toEqual({ name: 'ocm-goal', arguments: true }) }) it('exposes the multi-run slash command', async () => { @@ -241,7 +241,7 @@ describe('setupOcm', () => { 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 }) + expect(multiRun?.slash).toEqual({ name: 'ocm-multirun', arguments: true }) }) it('exposes the server switch slash command', async () => { diff --git a/shared/src/notifications/format.ts b/shared/src/notifications/format.ts index b097172f7..379bb5a1c 100644 --- a/shared/src/notifications/format.ts +++ b/shared/src/notifications/format.ts @@ -1,4 +1,5 @@ import type { SessionGoal, SessionGoalStatus, SessionGoalStopReason } from '../schemas/session-goals' +import type { MultiRunEntryStatus } from '../schemas/multi-runs' const GOAL_OUTCOME_TITLES: Record = { active: 'Goal active', @@ -27,6 +28,21 @@ export function getGoalStopReasonLabel(stopReason: SessionGoalStopReason): strin return GOAL_STOP_REASON_LABELS[stopReason] } +export function getGoalOutcomeReason(goal: Pick): string | null { + return goal.stopReason ? getGoalStopReasonLabel(goal.stopReason) : goal.lastReason +} + +const MULTI_RUN_ENTRY_STATUS_LABELS: Record = { + starting: 'Starting', + started: 'Started', + failed: 'Failed', + discarded: 'Discarded', +} + +export function getMultiRunEntryStatusLabel(status: MultiRunEntryStatus): string { + return MULTI_RUN_ENTRY_STATUS_LABELS[status] +} + export function getGoalTurnLabel(goal: Pick): string { return `Turn ${goal.continuationCount}/${goal.maxContinuations}` } diff --git a/shared/src/opencode/index.ts b/shared/src/opencode/index.ts index aaa6312f9..3333b8768 100644 --- a/shared/src/opencode/index.ts +++ b/shared/src/opencode/index.ts @@ -57,9 +57,11 @@ export { decodeModelPreference, favoriteModels, findModelInfo, + isActiveCatalogProvider, isModelFree, isSameModelRef, isSameModelSelection, + isSelectableCatalogModel, modelPreferenceKey, normalizeModelVariant, recentModels, diff --git a/shared/src/opencode/modelPreference.ts b/shared/src/opencode/modelPreference.ts index b9b8b0c7a..5b63848c6 100644 --- a/shared/src/opencode/modelPreference.ts +++ b/shared/src/opencode/modelPreference.ts @@ -225,6 +225,14 @@ export function compareCatalogModels(a: CatalogSortEntry, b: CatalogSortEntry): return (a.name ?? '').localeCompare(b.name ?? '') } +export function isActiveCatalogProvider(provider: { activation?: string }): boolean { + return provider.activation !== 'disabled' +} + +export function isSelectableCatalogModel(model: { enabled: boolean; status: string }): boolean { + return model.enabled && model.status !== 'deprecated' +} + interface SelectEffectiveModelRefOptions { models: ModelInfo[] defaultModel?: ModelRef | null diff --git a/shared/src/schemas/multi-runs.ts b/shared/src/schemas/multi-runs.ts index ea794034b..ef7d50dc1 100644 --- a/shared/src/schemas/multi-runs.ts +++ b/shared/src/schemas/multi-runs.ts @@ -3,6 +3,7 @@ import { FUSION_INSTRUCTIONS_MAX_LENGTH, SESSION_PROMPT_MAX_LENGTH } from "./lim export const MULTI_RUN_MAX_MODELS = 5; export const MULTI_RUN_FUSION_MIN_SOURCES = 2; +export const MULTI_RUN_NAME_MAX_LENGTH = 80; export const MultiRunEntryStatusSchema = z.enum(["starting", "started", "failed", "discarded"]); @@ -22,6 +23,14 @@ export const MultiRunEntrySchema = z.object({ export type MultiRunEntry = z.infer; +export function isFusionSourceEntry(entry: MultiRunEntry): entry is MultiRunEntry & { sessionId: string } { + return entry.status === "started" && entry.sessionId !== null; +} + +export function canDiscardMultiRunEntry(entry: Pick): boolean { + return entry.status === "started" || entry.status === "failed"; +} + export const MultiRunFusionStatusSchema = z.enum(["starting", "started", "failed"]); export type MultiRunFusionStatus = z.infer; @@ -69,7 +78,7 @@ export type MultiRun = z.infer; export const LaunchMultiRunRequestSchema = z.object({ repoId: z.number().int(), - name: z.string().trim().min(1).max(80), + name: z.string().trim().min(1).max(MULTI_RUN_NAME_MAX_LENGTH), prompt: z.string().trim().min(1).max(SESSION_PROMPT_MAX_LENGTH), models: z .array(z.string().min(3)) diff --git a/shared/src/schemas/session-goals.ts b/shared/src/schemas/session-goals.ts index e552f4b94..2a6295864 100644 --- a/shared/src/schemas/session-goals.ts +++ b/shared/src/schemas/session-goals.ts @@ -48,6 +48,22 @@ export const SessionGoalSchema = z.object({ export type SessionGoal = z.infer; +export const SESSION_GOAL_POLL_INTERVAL_MS = 3000; + +export type OpenSessionGoal = SessionGoal & { status: "active" | "paused" }; + +export type TerminalSessionGoal = SessionGoal & { status: "completed" | "blocked" | "stopped" }; + +/** True while the goal still drives its session: active or paused. */ +export function isOpenSessionGoal(goal: SessionGoal | null | undefined): goal is OpenSessionGoal { + return goal?.status === "active" || goal?.status === "paused"; +} + +/** True once the goal has finished: completed, blocked or stopped. */ +export function isTerminalSessionGoal(goal: SessionGoal | null | undefined): goal is TerminalSessionGoal { + return goal?.status === "completed" || goal?.status === "blocked" || goal?.status === "stopped"; +} + export const StartSessionGoalRequestSchema = z.object({ sessionId: z.string().min(1), directory: z.string().min(1), From 390f95710cc9e803c76307c9196f3fd71d640ccf Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Wed, 7 Oct 2026 20:47:12 -0400 Subject: [PATCH 2/2] fix(ocm): abort the feature probe with the caller signal and keep fusion request ids across reopened multi-run commands --- ocm-cli/src/manager-api.ts | 19 ++++++++++++++----- ocm-cli/src/tui-multi-run.ts | 4 ++-- ocm-cli/test/manager-api.test.ts | 15 +++++++++++++++ ocm-cli/test/tui-multi-run.test.ts | 15 +++++++++++---- 4 files changed, 42 insertions(+), 11 deletions(-) diff --git a/ocm-cli/src/manager-api.ts b/ocm-cli/src/manager-api.ts index fe4edaac6..366afe733 100644 --- a/ocm-cli/src/manager-api.ts +++ b/ocm-cli/src/manager-api.ts @@ -151,21 +151,30 @@ export class ManagerApi { ...requestInit, headers: { ...baseHeaders, ...(requestInit.headers as Record | undefined) }, }) - if (!res.ok) throw await this.handleErrorResponse(res, operation, feature === true) + if (!res.ok) throw await this.handleErrorResponse(res, operation, feature === true, requestInit.signal ?? undefined) return parse(await res.json()) } - private async handleErrorResponse(res: Response, operation: string, feature: boolean): Promise { + private async handleErrorResponse( + res: Response, + operation: string, + feature: boolean, + signal?: AbortSignal, + ): Promise { const error = await formatErrorResponse(res, operation) if (feature && error.status === 401) { - return this.probeFeatureSupport(operation, error) + return this.probeFeatureSupport(operation, error, signal) } return error } - private async probeFeatureSupport(operation: string, fallback: ManagerApiError): Promise { + private async probeFeatureSupport( + operation: string, + fallback: ManagerApiError, + signal?: AbortSignal, + ): Promise { try { - const res = await fetch(`${this.baseUrl}/api/internal/opencode-workspaces`, { headers: this.headers() }) + const res = await fetch(`${this.baseUrl}/api/internal/opencode-workspaces`, { headers: this.headers(), signal }) if (res.ok) { return new ManagerApiError( `${operation} failed: this OpenCode Manager is too old for ocm goals and multi-runs; upgrade the Manager.`, diff --git a/ocm-cli/src/tui-multi-run.ts b/ocm-cli/src/tui-multi-run.ts index 41497a7cd..46daaa19f 100644 --- a/ocm-cli/src/tui-multi-run.ts +++ b/ocm-cli/src/tui-multi-run.ts @@ -258,9 +258,9 @@ function fusionRequestKey(runId: number, form: FusionFormInput): string { return JSON.stringify([runId, [...form.entryIds].sort((a, b) => a - b), form.model, form.baseRef.trim(), form.instructions.trim()]) } -function createMultiRunActions(context: Context, api: ManagerApi, repoId: number): MultiRunActions { - const pendingFusions = new Map() +const pendingFusions = new Map() +function createMultiRunActions(context: Context, api: ManagerApi, repoId: number): MultiRunActions { const attempt = async (operation: () => Promise): Promise> => { try { return { ok: true, value: await operation() } diff --git a/ocm-cli/test/manager-api.test.ts b/ocm-cli/test/manager-api.test.ts index e89d90ae1..5c75a0d0e 100644 --- a/ocm-cli/test/manager-api.test.ts +++ b/ocm-cli/test/manager-api.test.ts @@ -261,6 +261,21 @@ describe('ManagerApi feature support', () => { expect((error as ManagerApiError).code).toBeNull() expect(isManagerRouteMissing(error)).toBe(false) }) + + it('forwards the caller abort signal to the token probe', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(errorResponse(401, JSON.stringify({ error: 'Unauthorized' }))) + .mockResolvedValueOnce(okResponse({ workspaces: [] })) + vi.stubGlobal('fetch', fetchMock) + const controller = new AbortController() + + await api.getLatestSessionGoal('ses_1', controller.signal).catch(() => undefined) + + expect(fetchMock).toHaveBeenNthCalledWith(2, `${BASE_URL}/api/internal/opencode-workspaces`, { + headers: { Authorization: 'Bearer tok' }, + signal: controller.signal, + }) + }) }) describe('isManagerRouteMissing', () => { diff --git a/ocm-cli/test/tui-multi-run.test.ts b/ocm-cli/test/tui-multi-run.test.ts index 7ba873209..5cf46c8d1 100644 --- a/ocm-cli/test/tui-multi-run.test.ts +++ b/ocm-cli/test/tui-multi-run.test.ts @@ -660,17 +660,24 @@ describe('multi-run dialog actions', () => { expect(fake.open).not.toHaveBeenCalled() }) - it('reuses the fusion request id while a network error leaves the outcome unknown', async () => { + it('reuses the fusion request id across reopened commands while a network error leaves the outcome unknown', async () => { const fake = createFakeContext() const api = makeApi() - api.fuseMultiRun.mockRejectedValue(new Error('offline')) - const actions = await launchActions(fake, api) + api.fuseMultiRun.mockRejectedValueOnce(new Error('offline')) + api.fuseMultiRun.mockRejectedValueOnce(new Error('offline')) + api.fuseMultiRun.mockImplementationOnce(async (_runId: number, request: { requestId: string }) => + multiRun({ fusions: [fusion({ requestId: request.requestId, sessionId: 'ses_f' })] }), + ) + const actions = await launchActions(fake, api) await expect(actions.fuse(multiRun(), fusionForm())).resolves.toBe('offline') await expect(actions.fuse(multiRun(), fusionForm())).resolves.toBe('offline') + const reopened = await launchActions(createFakeContext(), api) + await expect(reopened.fuse(multiRun(), fusionForm())).resolves.toBeNull() - const [first, second] = api.fuseMultiRun.mock.calls.map((call) => (call[1] as { requestId: string }).requestId) + const [first, second, third] = api.fuseMultiRun.mock.calls.map((call) => (call[1] as { requestId: string }).requestId) expect(second).toBe(first) + expect(third).toBe(first) }) it('mints a fresh fusion request id once the Manager has answered', async () => {