From cbe92c92adf1461e93666dce829eaacc9a8bb9fa Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Thu, 29 Oct 2026 12:38:39 -0400 Subject: [PATCH 1/3] refactor(goals): replace the Manager session-goal API with OpenCode built-in /goal --- .github/workflows/ci.yml | 2 +- Dockerfile | 2 +- .../src/db/migrations/026-session-goals.ts | 34 +- .../202610291200-drop-session-goals.ts | 53 + backend/src/db/migrations/index.ts | 2 + backend/src/db/session-goals.ts | 268 ---- backend/src/index.ts | 30 +- backend/src/routes/internal/index.ts | 4 - backend/src/routes/session-goals.ts | 72 - backend/src/services/goal-outcomes.ts | 76 + backend/src/services/notification.ts | 48 +- .../services/opencode-server-registry.test.ts | 12 +- backend/src/services/session-goal-audit.ts | 79 -- backend/src/services/session-goals.ts | 730 ---------- backend/src/utils/json-extract.ts | 64 - .../db/drop-session-goals-migration.test.ts | 80 ++ .../db/session-server-scope-migration.test.ts | 78 +- .../test/helpers/fake-session-goal-client.ts | 231 --- backend/test/helpers/internal-test-app.ts | 16 +- backend/test/helpers/opencode-binary.test.ts | 2 +- .../routes/internal-session-goals.test.ts | 79 -- backend/test/routes/session-goals.test.ts | 212 --- backend/test/routes/settings.test.ts | 106 +- backend/test/services/goal-outcomes.test.ts | 183 +++ .../test/services/notification-format.test.ts | 46 +- .../services/notification-service.test.ts | 159 +-- .../test/services/opencode-installer.test.ts | 74 +- .../opencode-manager-tool-plugin.test.ts | 4 - .../services/opencode-single-server.test.ts | 62 +- .../test/services/session-goal-audit.test.ts | 86 -- backend/test/services/session-goals.test.ts | 1256 ----------------- backend/test/services/settings.test.ts | 22 + backend/test/shared/opencode-contract.test.ts | 39 +- backend/test/shared/opencode-goal.test.ts | 209 +++ backend/test/utils/json-extract.test.ts | 61 - frontend/src/api/opencode.ts | 19 +- frontend/src/api/sessionGoals.ts | 46 - .../message/PromptInput.command.test.tsx | 2 +- .../message/PromptInput.composer.test.tsx | 2 +- .../message/PromptInput.draft.test.tsx | 2 +- .../message/PromptInput.goal.test.tsx | 228 +-- .../message/PromptInput.mention.test.tsx | 2 +- .../message/PromptInput.stt.test.tsx | 2 +- .../src/components/message/PromptInput.tsx | 66 +- .../session/SessionGoalBar.test.tsx | 151 +- .../src/components/session/SessionGoalBar.tsx | 122 +- .../settings/NotificationSettings.tsx | 2 +- .../SessionAutomationSettings.test.tsx | 206 +-- .../settings/SessionAutomationSettings.tsx | 129 -- frontend/src/contexts/EventContext.test.tsx | 40 +- frontend/src/contexts/EventContext.tsx | 6 + frontend/src/hooks/useSessionGoals.test.tsx | 260 +++- frontend/src/hooks/useSessionGoals.ts | 116 +- frontend/src/pages/SessionDetail.tsx | 2 +- .../SessionDetail.assistant-loading.test.tsx | 5 +- .../__tests__/SessionDetail.commands.test.tsx | 5 +- .../SessionDetail.form-prompt.test.tsx | 5 +- .../__tests__/SessionDetail.polling.test.tsx | 5 +- .../__tests__/SessionDetail.remote.test.tsx | 2 +- .../SessionDetail.scroll-floating.test.tsx | 5 +- ocm-cli/README.md | 46 +- ocm-cli/package.json | 15 +- ocm-cli/src/goal-store.ts | 171 --- ocm-cli/src/manager-api.ts | 52 +- ocm-cli/src/tui-goal-dialog.tsx | 188 --- ocm-cli/src/tui-goal.ts | 202 --- ocm-cli/src/tui-plugin.ts | 15 - ocm-cli/src/tui.tsx | 55 +- ocm-cli/test/goal-store.test.ts | 419 ------ ocm-cli/test/helpers/goal-fixture.ts | 24 - ocm-cli/test/manager-api.test.ts | 112 +- ocm-cli/test/package-compat.test.ts | 58 + ocm-cli/test/tui-goal.test.ts | 415 ------ ocm-cli/test/tui-plugin.test.ts | 9 +- pnpm-lock.yaml | 444 +++--- shared/package.json | 3 +- shared/src/notifications/format.ts | 50 +- shared/src/opencode/goal.ts | 89 ++ shared/src/opencode/index.ts | 13 + shared/src/opencode/release.ts | 2 +- shared/src/schemas/index.ts | 1 - shared/src/schemas/limits.ts | 2 - shared/src/schemas/session-goals.ts | 77 - shared/src/schemas/settings.ts | 5 - 84 files changed, 2078 insertions(+), 6300 deletions(-) create mode 100644 backend/src/db/migrations/202610291200-drop-session-goals.ts delete mode 100644 backend/src/db/session-goals.ts delete mode 100644 backend/src/routes/session-goals.ts create mode 100644 backend/src/services/goal-outcomes.ts delete mode 100644 backend/src/services/session-goal-audit.ts delete mode 100644 backend/src/services/session-goals.ts delete mode 100644 backend/src/utils/json-extract.ts create mode 100644 backend/test/db/drop-session-goals-migration.test.ts delete mode 100644 backend/test/helpers/fake-session-goal-client.ts delete mode 100644 backend/test/routes/internal-session-goals.test.ts delete mode 100644 backend/test/routes/session-goals.test.ts create mode 100644 backend/test/services/goal-outcomes.test.ts delete mode 100644 backend/test/services/session-goal-audit.test.ts delete mode 100644 backend/test/services/session-goals.test.ts create mode 100644 backend/test/shared/opencode-goal.test.ts delete mode 100644 backend/test/utils/json-extract.test.ts delete mode 100644 frontend/src/api/sessionGoals.ts delete mode 100644 ocm-cli/src/goal-store.ts delete mode 100644 ocm-cli/src/tui-goal-dialog.tsx delete mode 100644 ocm-cli/src/tui-goal.ts delete mode 100644 ocm-cli/test/goal-store.test.ts delete mode 100644 ocm-cli/test/helpers/goal-fixture.ts create mode 100644 ocm-cli/test/package-compat.test.ts delete mode 100644 ocm-cli/test/tui-goal.test.ts create mode 100644 shared/src/opencode/goal.ts delete mode 100644 shared/src/schemas/session-goals.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index adeac74ec..d5e7c00c7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -125,7 +125,7 @@ jobs: - name: Install OpenCode CLI run: | source scripts/lib/opencode-release.sh - download_opencode_to 2.1.2 "$RUNNER_TEMP/opencode/bin/opencode" + download_opencode_to 2.2.1 "$RUNNER_TEMP/opencode/bin/opencode" echo "$RUNNER_TEMP/opencode/bin" >> "$GITHUB_PATH" - name: Verify OpenCode CLI diff --git a/Dockerfile b/Dockerfile index 617080b77..f326643ff 100644 --- a/Dockerfile +++ b/Dockerfile @@ -63,7 +63,7 @@ FROM base AS runner # an arm64 host builds the amd64 platform; 0.12.7 is the newest verified-good # release, so re-verify a bump there before moving it. ARG UV_VERSION=0.12.7 -ARG OPENCODE_VERSION=2.1.2 +ARG OPENCODE_VERSION=2.2.1 ARG MICROSANDBOX_VERSION=0.7.2 ARG PLAYWRIGHT_VERSION=1.63.0 # Bump TOOLS_CACHEBUST (e.g. via --build-arg) to force a fresh uv/opencode diff --git a/backend/src/db/migrations/026-session-goals.ts b/backend/src/db/migrations/026-session-goals.ts index a78f1d2d4..917eec8e7 100644 --- a/backend/src/db/migrations/026-session-goals.ts +++ b/backend/src/db/migrations/026-session-goals.ts @@ -1,11 +1,41 @@ import type { Migration } from '../migration-runner' -import { ensureSessionGoalsTable } from '../session-goals' const migration: Migration = { id: '026-session-goals', legacy: { version: 26, name: 'session-goals' }, up(db) { - ensureSessionGoalsTable(db) + db.run(` + CREATE TABLE IF NOT EXISTS session_goals ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + server_id TEXT NOT NULL DEFAULT 'local', + directory TEXT NOT NULL, + objective TEXT NOT NULL, + status TEXT NOT NULL CHECK(status IN ('active','paused','completed','blocked','stopped')), + stop_reason TEXT, + turn_state TEXT NOT NULL DEFAULT 'waiting', + continuation_count INTEGER NOT NULL DEFAULT 0, + max_continuations INTEGER NOT NULL, + token_budget INTEGER, + tokens_at_start INTEGER NOT NULL DEFAULT 0, + tokens_used INTEGER NOT NULL DEFAULT 0, + consecutive_blocked INTEGER NOT NULL DEFAULT 0, + last_verdict TEXT, + last_reason TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + finished_at INTEGER + ) + `) + db.run(` + CREATE UNIQUE INDEX IF NOT EXISTS idx_session_goals_open + ON session_goals(server_id, session_id) + WHERE status IN ('active','paused') + `) + db.run(` + CREATE INDEX IF NOT EXISTS idx_session_goals_session + ON session_goals(session_id, created_at DESC) + `) }, down(db) { db.run('DROP TABLE IF EXISTS session_goals') diff --git a/backend/src/db/migrations/202610291200-drop-session-goals.ts b/backend/src/db/migrations/202610291200-drop-session-goals.ts new file mode 100644 index 000000000..e67345501 --- /dev/null +++ b/backend/src/db/migrations/202610291200-drop-session-goals.ts @@ -0,0 +1,53 @@ +import type { Database } from 'bun:sqlite' +import type { Migration } from '../migration-runner' + +function createSessionGoalsTable(db: Database): void { + db.run(` + CREATE TABLE IF NOT EXISTS session_goals ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + server_id TEXT NOT NULL DEFAULT 'local', + directory TEXT NOT NULL, + objective TEXT NOT NULL, + status TEXT NOT NULL CHECK(status IN ('active','paused','completed','blocked','stopped')), + stop_reason TEXT, + turn_state TEXT NOT NULL DEFAULT 'waiting', + continuation_count INTEGER NOT NULL DEFAULT 0, + max_continuations INTEGER NOT NULL, + token_budget INTEGER, + tokens_at_start INTEGER NOT NULL DEFAULT 0, + tokens_used INTEGER NOT NULL DEFAULT 0, + consecutive_blocked INTEGER NOT NULL DEFAULT 0, + last_verdict TEXT, + last_reason TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + finished_at INTEGER + ) + `) + db.run(` + CREATE UNIQUE INDEX IF NOT EXISTS idx_session_goals_open + ON session_goals(server_id, session_id) + WHERE status IN ('active','paused') + `) + db.run(` + CREATE INDEX IF NOT EXISTS idx_session_goals_session + ON session_goals(session_id, created_at DESC) + `) +} + +const migration: Migration = { + id: '202610291200-drop-session-goals', + + up(db) { + db.run('DROP INDEX IF EXISTS idx_session_goals_open') + db.run('DROP INDEX IF EXISTS idx_session_goals_session') + db.run('DROP TABLE IF EXISTS session_goals') + }, + + down(db) { + createSessionGoalsTable(db) + }, +} + +export default migration diff --git a/backend/src/db/migrations/index.ts b/backend/src/db/migrations/index.ts index 5a72487ca..433dc5899 100644 --- a/backend/src/db/migrations/index.ts +++ b/backend/src/db/migrations/index.ts @@ -42,6 +42,7 @@ import { secretsGlobalScopeIndexMigration } from './202610181410-secrets-global- import migration202610221800 from './202610221800-session-device-name' import migration202610231200 from './202610231200-push-subscription-session-id' import migration202610251900 from './202610251900-schedule-sandbox-config' +import migration202610291200 from './202610291200-drop-session-goals' export const allMigrations: Migration[] = [ migration001, @@ -87,4 +88,5 @@ export const allMigrations: Migration[] = [ migration202610221800, migration202610231200, migration202610251900, + migration202610291200, ] diff --git a/backend/src/db/session-goals.ts b/backend/src/db/session-goals.ts deleted file mode 100644 index 5a2a4620e..000000000 --- a/backend/src/db/session-goals.ts +++ /dev/null @@ -1,268 +0,0 @@ -import type { Database } from 'bun:sqlite' -import { - LOCAL_OPENCODE_SERVER_ID, - type SessionGoalStatus, - type SessionGoalStopReason, - type SessionGoalTurnState, - type SessionGoalVerdict, -} from '@opencode-manager/shared/schemas' - -export interface SessionGoalRecord { - id: number - sessionId: string - serverId: string - directory: string - objective: string - status: SessionGoalStatus - stopReason: SessionGoalStopReason | null - turnState: SessionGoalTurnState - continuationCount: number - maxContinuations: number - tokenBudget: number | null - tokensAtStart: number - tokensUsed: number - consecutiveBlocked: number - lastVerdict: SessionGoalVerdict | null - lastReason: string | null - createdAt: number - updatedAt: number - finishedAt: number | null -} - -export interface InsertSessionGoalInput { - sessionId: string - serverId?: string - directory: string - objective: string - maxContinuations: number - tokenBudget: number | null - tokensAtStart: number -} - -export interface SessionGoalPatch { - status?: SessionGoalStatus - stopReason?: SessionGoalStopReason | null - turnState?: SessionGoalTurnState - continuationCount?: number - tokensUsed?: number - consecutiveBlocked?: number - lastVerdict?: SessionGoalVerdict | null - lastReason?: string | null - finishedAt?: number | null -} - -interface SessionGoalRow { - id: number - session_id: string - server_id: string - directory: string - objective: string - status: SessionGoalStatus - stop_reason: SessionGoalStopReason | null - turn_state: SessionGoalTurnState - continuation_count: number - max_continuations: number - token_budget: number | null - tokens_at_start: number - tokens_used: number - consecutive_blocked: number - last_verdict: SessionGoalVerdict | null - last_reason: string | null - created_at: number - updated_at: number - finished_at: number | null -} - -const SESSION_GOAL_COLUMNS = ` - id, session_id, server_id, directory, objective, status, stop_reason, turn_state, - continuation_count, max_continuations, token_budget, tokens_at_start, tokens_used, - consecutive_blocked, last_verdict, last_reason, created_at, updated_at, finished_at -` - -export function ensureSessionGoalsTable(db: Database): void { - db.run(` - CREATE TABLE IF NOT EXISTS session_goals ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - session_id TEXT NOT NULL, - server_id TEXT NOT NULL DEFAULT 'local', - directory TEXT NOT NULL, - objective TEXT NOT NULL, - status TEXT NOT NULL CHECK(status IN ('active','paused','completed','blocked','stopped')), - stop_reason TEXT, - turn_state TEXT NOT NULL DEFAULT 'waiting', - continuation_count INTEGER NOT NULL DEFAULT 0, - max_continuations INTEGER NOT NULL, - token_budget INTEGER, - tokens_at_start INTEGER NOT NULL DEFAULT 0, - tokens_used INTEGER NOT NULL DEFAULT 0, - consecutive_blocked INTEGER NOT NULL DEFAULT 0, - last_verdict TEXT, - last_reason TEXT, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - finished_at INTEGER - ) - `) - db.run(` - CREATE UNIQUE INDEX IF NOT EXISTS idx_session_goals_open - ON session_goals(server_id, session_id) - WHERE status IN ('active','paused') - `) - db.run(` - CREATE INDEX IF NOT EXISTS idx_session_goals_session - ON session_goals(session_id, created_at DESC) - `) -} - -export function insertSessionGoal(db: Database, input: InsertSessionGoalInput): SessionGoalRecord | null { - const now = Date.now() - try { - const result = db.prepare(` - INSERT INTO session_goals( - session_id, server_id, directory, objective, status, stop_reason, turn_state, - continuation_count, max_continuations, token_budget, tokens_at_start, tokens_used, - consecutive_blocked, last_verdict, last_reason, created_at, updated_at, finished_at - ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) - `).run( - input.sessionId, - input.serverId ?? LOCAL_OPENCODE_SERVER_ID, - input.directory, - input.objective, - 'active', - null, - 'waiting', - 0, - input.maxContinuations, - input.tokenBudget, - input.tokensAtStart, - 0, - 0, - null, - null, - now, - now, - null, - ) - return getSessionGoalById(db, Number(result.lastInsertRowid)) - } catch (error) { - if (isUniqueConstraintError(error)) { - return null - } - throw error - } -} - -export function getSessionGoalById(db: Database, id: number): SessionGoalRecord | null { - const row = db - .prepare(`SELECT ${SESSION_GOAL_COLUMNS} FROM session_goals WHERE id = ?`) - .get(id) as SessionGoalRow | undefined - return row ? mapSessionGoalRow(row) : null -} - -export function getLatestSessionGoal( - db: Database, - sessionId: string, - serverId: string = LOCAL_OPENCODE_SERVER_ID, -): SessionGoalRecord | null { - const row = db - .prepare(` - SELECT ${SESSION_GOAL_COLUMNS} FROM session_goals - WHERE server_id = ? AND session_id = ? - ORDER BY created_at DESC, id DESC - LIMIT 1 - `) - .get(serverId, sessionId) as SessionGoalRow | undefined - return row ? mapSessionGoalRow(row) : null -} - -export function listOpenSessionGoals(db: Database): SessionGoalRecord[] { - const rows = db - .prepare(`SELECT ${SESSION_GOAL_COLUMNS} FROM session_goals WHERE status IN ('active','paused') ORDER BY created_at ASC`) - .all() as SessionGoalRow[] - return rows.map(mapSessionGoalRow) -} - -export function transitionSessionGoal( - db: Database, - id: number, - fromStatuses: SessionGoalStatus[], - patch: SessionGoalPatch, -): SessionGoalRecord | null { - const assignments: string[] = ['updated_at = ?'] - const values: (string | number | null)[] = [Date.now()] - - if (patch.status !== undefined) { - assignments.push('status = ?') - values.push(patch.status) - } - if (patch.stopReason !== undefined) { - assignments.push('stop_reason = ?') - values.push(patch.stopReason) - } - if (patch.turnState !== undefined) { - assignments.push('turn_state = ?') - values.push(patch.turnState) - } - if (patch.continuationCount !== undefined) { - assignments.push('continuation_count = ?') - values.push(patch.continuationCount) - } - if (patch.tokensUsed !== undefined) { - assignments.push('tokens_used = ?') - values.push(patch.tokensUsed) - } - if (patch.consecutiveBlocked !== undefined) { - assignments.push('consecutive_blocked = ?') - values.push(patch.consecutiveBlocked) - } - if (patch.lastVerdict !== undefined) { - assignments.push('last_verdict = ?') - values.push(patch.lastVerdict) - } - if (patch.lastReason !== undefined) { - assignments.push('last_reason = ?') - values.push(patch.lastReason) - } - if (patch.finishedAt !== undefined) { - assignments.push('finished_at = ?') - values.push(patch.finishedAt) - } - - const placeholders = fromStatuses.map(() => '?').join(', ') - const result = db - .prepare(`UPDATE session_goals SET ${assignments.join(', ')} WHERE id = ? AND status IN (${placeholders})`) - .run(...values, id, ...fromStatuses) - - if (result.changes === 0) { - return null - } - return getSessionGoalById(db, id) -} - -function mapSessionGoalRow(row: SessionGoalRow): SessionGoalRecord { - return { - id: row.id, - sessionId: row.session_id, - serverId: row.server_id, - directory: row.directory, - objective: row.objective, - status: row.status, - stopReason: row.stop_reason, - turnState: row.turn_state, - continuationCount: row.continuation_count, - maxContinuations: row.max_continuations, - tokenBudget: row.token_budget, - tokensAtStart: row.tokens_at_start, - tokensUsed: row.tokens_used, - consecutiveBlocked: row.consecutive_blocked, - lastVerdict: row.last_verdict, - lastReason: row.last_reason, - createdAt: row.created_at, - updatedAt: row.updated_at, - finishedAt: row.finished_at, - } -} - -function isUniqueConstraintError(error: unknown): boolean { - return error instanceof Error && /UNIQUE constraint failed/i.test(error.message) -} diff --git a/backend/src/index.ts b/backend/src/index.ts index b2b4da1bd..019a1f3c5 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -41,7 +41,6 @@ import { createPromptTemplateRoutes } from './routes/prompt-templates' import { createSessionPinRoutes } from './routes/session-pins' import { createSessionPermissionModeRoutes } from './routes/session-permission-modes' import { createSessionSandboxRoutes } from './routes/session-sandbox' -import { createSessionGoalRoutes } from './routes/session-goals' import { createMultiRunRoutes } from './routes/multi-runs' import { createChangeWalkthroughRoutes } from './routes/change-walkthroughs' import { getChangeWalkthroughByWalkthroughSession } from './db/change-walkthroughs' @@ -67,7 +66,7 @@ import { RemoteAccessHeadersService } from './services/remote-access-headers' import { SessionPermissionModeService } from './services/session-permission-modes' import { SessionSandboxService } from './services/session-sandbox' import { SessionAncestry } from './services/session-ancestry' -import { SessionGoalService } from './services/session-goals' +import { GoalOutcomeTracker } from './services/goal-outcomes' import { MultiRunService } from './services/multi-runs' import { ChangeWalkthroughService } from './services/change-walkthroughs' import { opencodeServerManager } from './services/opencode-single-server' @@ -302,16 +301,7 @@ const scheduleRunnerInstance = new ScheduleRunner(scheduleService) const notificationService = new NotificationService(db, opencodeServerRegistry) const sessionPermissionModeService = new SessionPermissionModeService(db, opencodeServerRegistry, sessionSettingsService, sessionAncestry) -const sessionGoalService = new SessionGoalService(db, opencodeServerRegistry, sessionSettingsService, { - onOutcome: (goal) => { - void notificationService.notifyGoalOutcome(goal).catch((error) => { - logger.error('Goal outcome notification error:', error) - }) - }, - resolveSessionLock: async (sessionId, serverId) => - (await sessionPermissionModeService.getEffectiveMode(sessionId, serverId)).lockedReason, -}) -sessionGoalService.loadOpenGoals() +const goalOutcomes = new GoalOutcomeTracker() const multiRunService = new MultiRunService(db, openCodeClient, repoWorkspaces, sessionPermissionModeService, (serverId) => opencodeServerRegistry.getClient(serverId)) const changeWalkthroughService = new ChangeWalkthroughService(db, openCodeClient, sessionSettingsService, gitAuthService, gitService) @@ -331,9 +321,12 @@ sseAggregator.onServerEvent((serverId, directory, event) => { sessionPermissionModeService.handleEvent(directory, event, serverId).catch((err) => { logger.error('Session permission mode event handling error:', err) }) - sessionGoalService.handleEvent(directory, event, serverId).catch((err) => { - logger.error('Session goal event handling error:', err) - }) + const outcome = goalOutcomes.observe(serverId, directory, event) + if (outcome) { + void notificationService.notifyGoalOutcome(outcome).catch((error) => { + logger.error('Goal outcome notification error:', error) + }) + } }) notificationService.addEventSuppressor((event, sessionId, serverId) => @@ -378,10 +371,6 @@ sseAggregator.start() const remoteServerEvents = new RemoteServerEventHub(opencodeServerRegistry, sseAggregator) remoteServerEvents.start() -void sessionGoalService.recoverOpenGoals().catch((error) => { - logger.error('Session goal recovery error:', error) -}) - sseAggregator.setScheduledSessionsResolver( () => scheduleService.getActiveRunSessions(), ) @@ -397,7 +386,7 @@ app.route('/api/auth-info', createAuthInfoRoutes(auth)) app.route('/api/health', createHealthRoutes(db, openCodeSupervisor)) app.route('/api/mcp-oauth-proxy', createMcpOauthProxyRoutes(openCodeClient, requireAuth)) -app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, sessionPermissionModeService, repoWorkspaces, gitAuthService, sessionGoalService, multiRunService, changeWalkthroughService, sessionSandboxService, repoEnvService)) +app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, sessionPermissionModeService, repoWorkspaces, gitAuthService, multiRunService, changeWalkthroughService, sessionSandboxService, repoEnvService)) app.route('/api/opencode-proxy', createOpenCodeProxyRoutes(db, settingsService, upgradeWebSocket)) const protectedApi = new Hono() @@ -431,7 +420,6 @@ protectedApi.route('/opencode-servers', createOpenCodeServerTerminalRoutes(resol protectedApi.route('/opencode-servers', createOpenCodeServerRoutes(opencodeServerRegistry, () => remoteServerEvents.getStatuses())) protectedApi.route('/session-permission-modes', createSessionPermissionModeRoutes(sessionPermissionModeService)) protectedApi.route('/session-sandbox', createSessionSandboxRoutes(sessionSandboxService)) -protectedApi.route('/session-goals', createSessionGoalRoutes(sessionGoalService)) protectedApi.route('/multi-runs', createMultiRunRoutes(multiRunService)) protectedApi.route('/change-walkthroughs', createChangeWalkthroughRoutes(changeWalkthroughService)) protectedApi.route('/schedules', createScheduleRoutes(scheduleService)) diff --git a/backend/src/routes/internal/index.ts b/backend/src/routes/internal/index.ts index 2dfeb2a2e..4003062fe 100644 --- a/backend/src/routes/internal/index.ts +++ b/backend/src/routes/internal/index.ts @@ -19,7 +19,6 @@ import { createInternalShellEnvRoutes } from './shell-env' import { createInternalGitCredentialsRoutes } from './git-credentials' import { createInternalSandboxRoutes } from './sandbox' import { createInternalSessionArtifactRoutes } from './session-artifacts' -import { createSessionGoalRoutes } from '../session-goals' import { createMultiRunRoutes } from '../multi-runs' import { createChangeWalkthroughRoutes } from '../change-walkthroughs' import { createInternalWalkthroughSessionRoutes } from './walkthrough-sessions' @@ -27,7 +26,6 @@ import type { SessionPermissionModeService } from '../../services/session-permis import type { SessionSandboxService } from '../../services/session-sandbox' import type { RepoWorkspaceService } from '../../services/repo-workspace' import type { GitAuthService } from '../../services/git-auth' -import type { SessionGoalService } from '../../services/session-goals' import type { MultiRunService } from '../../services/multi-runs' import type { ChangeWalkthroughService } from '../../services/change-walkthroughs' import type { RepoEnvService } from '../../services/repo-env' @@ -41,7 +39,6 @@ export function createInternalRoutes( permissionModes: SessionPermissionModeService, repoWorkspaces: RepoWorkspaceService, gitAuthService: GitAuthService, - sessionGoals: SessionGoalService, multiRuns: MultiRunService, changeWalkthroughService: ChangeWalkthroughService, sessionSandbox: SessionSandboxService, @@ -50,7 +47,6 @@ export function createInternalRoutes( const app = new Hono() app.use('/*', createInternalTokenMiddleware(db)) app.route('/schedules', createScheduleRoutes(scheduleService)) - app.route('/session-goals', createSessionGoalRoutes(sessionGoals, { serverScoped: false })) app.route('/multi-runs', createMultiRunRoutes(multiRuns)) app.route('/notifications', createInternalNotificationRoutes(notificationService)) app.route('/settings', createInternalSettingsRoutes(settingsService)) diff --git a/backend/src/routes/session-goals.ts b/backend/src/routes/session-goals.ts deleted file mode 100644 index 8f4ca4321..000000000 --- a/backend/src/routes/session-goals.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { Hono } from 'hono' -import { StartSessionGoalRequestSchema } from '@opencode-manager/shared/schemas' -import { SessionGoalError, type SessionGoalService } from '../services/session-goals' -import { handleServiceError, parseId, parseJsonBody } from '../utils/route-helpers' - -export interface SessionGoalRouteOptions { - serverScoped?: boolean -} - -export function createSessionGoalRoutes( - service: SessionGoalService, - options: SessionGoalRouteOptions = {}, -) { - const serverScoped = options.serverScoped ?? true - const app = new Hono() - - app.get('/', (c) => { - try { - const sessionId = c.req.query('sessionId') - if (!sessionId) { - return c.json({ error: 'Invalid request' }, 400) - } - const serverId = serverScoped ? c.req.query('serverId') : undefined - return c.json({ goal: serverId ? service.getLatest(sessionId, serverId) : service.getLatest(sessionId) }) - } catch (error) { - return handleServiceError(c, error, 'Failed to read session goal', SessionGoalError) - } - }) - - app.post('/', async (c) => { - const parsed = await parseJsonBody(c, StartSessionGoalRequestSchema) - if (!parsed.ok) { - return parsed.response - } - - try { - const goal = await service.start(serverScoped ? parsed.data : { ...parsed.data, serverId: undefined }) - return c.json({ goal }, 201) - } catch (error) { - return handleServiceError(c, error, 'Failed to start session goal', SessionGoalError) - } - }) - - app.post('/:id/pause', (c) => { - try { - const goal = service.pause(parseId(c.req.param('id'), 'goal id', SessionGoalError)) - return c.json({ goal }) - } catch (error) { - return handleServiceError(c, error, 'Failed to pause session goal', SessionGoalError) - } - }) - - app.post('/:id/resume', (c) => { - try { - const goal = service.resume(parseId(c.req.param('id'), 'goal id', SessionGoalError)) - return c.json({ goal }) - } catch (error) { - return handleServiceError(c, error, 'Failed to resume session goal', SessionGoalError) - } - }) - - app.post('/:id/cancel', (c) => { - try { - const goal = service.cancel(parseId(c.req.param('id'), 'goal id', SessionGoalError)) - return c.json({ goal }) - } catch (error) { - return handleServiceError(c, error, 'Failed to cancel session goal', SessionGoalError) - } - }) - - return app -} diff --git a/backend/src/services/goal-outcomes.ts b/backend/src/services/goal-outcomes.ts new file mode 100644 index 000000000..a0ec1a5a3 --- /dev/null +++ b/backend/src/services/goal-outcomes.ts @@ -0,0 +1,76 @@ +import { + goalChangeFromEvent, + isGoalOutcome, + sessionIDFromEvent, + type GoalState, + type GoalStatus, +} from '@opencode-manager/shared/opencode' +import type { SSEEvent } from './sse-aggregator' + +/** + * A goal outcome observed for a session, ready to be turned into a notification. + * + * `directory` is the working directory to attribute the outcome to, preferring the + * observer's directory over the event location. + */ +export interface SessionGoalOutcome { + serverId: string + sessionId: string + directory: string | null + goal: GoalState +} + +/** + * Tracks the last goal state seen per session so each goal outcome is reported once. + * + * State is in-memory only and keyed by server and session separated by a NUL + * character, which cannot appear in either id. Nothing is persisted or written to + * the database, so a fresh tracker reports the first outcome it sees. + */ +export class GoalOutcomeTracker { + private readonly observed = new Map() + + /** + * Records a goal change and returns the outcome it produces, if any. + * + * Returns an outcome only when the goal reached a terminal state and that exact + * goal id and status were not already seen for the session. A deleted session, a + * cleared goal, or a missing goal reports no outcome and forgets the stored state. + */ + observe(serverId: string, directory: string | null, event: SSEEvent): SessionGoalOutcome | null { + if (event.type === 'session.deleted') { + const sessionId = sessionIDFromEvent(event) + if (sessionId) { + this.observed.delete(this.key(serverId, sessionId)) + } + return null + } + + const change = goalChangeFromEvent(event) + if (!change) return null + + const key = this.key(serverId, change.sessionID) + if (change.goal === null) { + this.observed.delete(key) + return null + } + + const goal = change.goal + const previous = this.observed.get(key) + this.observed.set(key, { id: goal.id, status: goal.status }) + + if (!isGoalOutcome(goal)) return null + if (previous?.id === goal.id && previous.status === goal.status) return null + + return { + serverId, + sessionId: change.sessionID, + directory: directory ?? event.location?.directory ?? null, + goal, + } + } + + private key(serverId: string, sessionID: string): string { + return `${serverId}\u0000${sessionID}` + } +} diff --git a/backend/src/services/notification.ts b/backend/src/services/notification.ts index 526942d6a..c438889ac 100644 --- a/backend/src/services/notification.ts +++ b/backend/src/services/notification.ts @@ -11,14 +11,12 @@ import { DEFAULT_NOTIFICATION_PREFERENCES, LOCAL_OPENCODE_SERVER_ID, type OpenCodeServer, - type SessionGoal, } from "@opencode-manager/shared/schemas"; import { getPermissionLabel, getPermissionDetail, getFormText, - getGoalOutcomeTitle, - getGoalOutcomeReason, + getGoalStatusTitle, } from "@opencode-manager/shared/notifications"; import { SettingsService } from "./settings"; import { sseAggregator, type SSEEvent } from "./sse-aggregator"; @@ -30,6 +28,7 @@ import type { Repo } from "../types/repo"; import { buildServerSessionPath, buildSessionPath } from "@opencode-manager/shared/utils"; import { sessionIDFromEvent } from "@opencode-manager/shared/opencode"; import { resolveRepoForDirectory } from "./repo"; +import type { SessionGoalOutcome } from "./goal-outcomes"; export interface NotificationServerRegistry { get(serverId: string): Pick | null; @@ -91,19 +90,23 @@ function truncateWithEllipsis(text: string, maxLength: number): string { return `${text.slice(0, maxLength - 1)}…`; } -function buildGoalOutcomeBody(goal: SessionGoal, repoName: string | undefined): string { - const reason = getGoalOutcomeReason(goal)?.trim() || undefined; +function buildGoalOutcomeBody( + objective: string, + reason: string | undefined, + repoName: string | undefined +): string { + const trimmedReason = reason?.trim() || undefined; const prefix = repoName ? `${repoName} · ` : ""; const separator = " — "; const reasonBudget = Math.max(0, MAX_BODY_LENGTH - prefix.length - separator.length); - const boundedReason = reason ? truncateWithEllipsis(reason, reasonBudget) : ""; + const boundedReason = trimmedReason ? truncateWithEllipsis(trimmedReason, reasonBudget) : ""; const suffix = boundedReason ? `${separator}${boundedReason}` : ""; const objectiveBudget = Math.max(0, MAX_BODY_LENGTH - prefix.length - suffix.length); - const objective = truncateWithEllipsis(goal.objective, objectiveBudget); + const boundedObjective = truncateWithEllipsis(objective, objectiveBudget); - return truncateWithEllipsis(`${prefix}${objective}${suffix}`, MAX_BODY_LENGTH); + return truncateWithEllipsis(`${prefix}${boundedObjective}${suffix}`, MAX_BODY_LENGTH); } function resolveEventSessionId(event: SSEEvent): string | undefined { @@ -435,36 +438,35 @@ export class NotificationService { ); } - async notifyGoalOutcome(goal: SessionGoal): Promise { - if (goal.stopReason === "turn_error") return; - if (goal.stopReason === "cancelled" || goal.stopReason === "user_paused") return; - if (sseAggregator.isSessionBeingViewed(goal.sessionId, goal.serverId)) return; + async notifyGoalOutcome(outcome: SessionGoalOutcome): Promise { + if (sseAggregator.isSessionBeingViewed(outcome.sessionId, outcome.serverId)) return; - const isRemote = goal.serverId !== LOCAL_OPENCODE_SERVER_ID; - const repo = !isRemote && goal.directory - ? await resolveRepoForDirectory(this.db, goal.directory) + const { goal } = outcome; + const isRemote = outcome.serverId !== LOCAL_OPENCODE_SERVER_ID; + const repo = !isRemote && outcome.directory + ? await resolveRepoForDirectory(this.db, outcome.directory) : null; - const server = isRemote ? this.serverRegistry?.get(goal.serverId) ?? null : null; + const server = isRemote ? this.serverRegistry?.get(outcome.serverId) ?? null : null; const repoName = repo ? getRepoName(repo) : undefined; const displayName = repoName ?? server?.name; const payload: PushNotificationPayload = { - title: getGoalOutcomeTitle(goal.status), - body: buildGoalOutcomeBody(goal, displayName), + title: getGoalStatusTitle(goal.status), + body: buildGoalOutcomeBody(goal.objective, goal.reason, displayName), tag: `session-goal-${goal.id}`, timestamp: Date.now(), renotify: true, data: { eventType: "session.goal.outcome", - sessionId: goal.sessionId, - directory: goal.directory, + sessionId: outcome.sessionId, + directory: outcome.directory ?? undefined, repoId: repo?.id, repoName, - serverId: goal.serverId, + serverId: outcome.serverId, serverName: server?.name, url: isRemote - ? buildServerSessionPath(goal.serverId, goal.sessionId) - : buildNotificationUrl(repo, goal.sessionId), + ? buildServerSessionPath(outcome.serverId, outcome.sessionId) + : buildNotificationUrl(repo, outcome.sessionId), }, }; diff --git a/backend/src/services/opencode-server-registry.test.ts b/backend/src/services/opencode-server-registry.test.ts index 6fbe3d580..219939b1c 100644 --- a/backend/src/services/opencode-server-registry.test.ts +++ b/backend/src/services/opencode-server-registry.test.ts @@ -449,7 +449,7 @@ describe('OpenCodeServerRegistry', () => { describe('test', () => { it('reports a supported version', async () => { - fetchFn.mockResolvedValueOnce(jsonResponse({ version: '2.1.2' })) + fetchFn.mockResolvedValueOnce(jsonResponse({ version: '2.2.1' })) const result = await registry.test({ baseUrl: 'https://studio.example.com/', @@ -457,7 +457,7 @@ describe('OpenCodeServerRegistry', () => { password: 'super-secret', }) - expect(result).toEqual({ ok: true, version: '2.1.2', versionSupported: true, error: null }) + expect(result).toEqual({ ok: true, version: '2.2.1', versionSupported: true, error: null }) expect(fetchFn.mock.calls[0]?.[0]).toBe('https://studio.example.com/api/info') const headers = new Headers(fetchFn.mock.calls[0]?.[1]?.headers) expect(headers.get('authorization')).toBe(`Basic ${btoa('alice:super-secret')}`) @@ -478,7 +478,7 @@ describe('OpenCodeServerRegistry', () => { username: 'alice', password: 'super-secret', }) - fetchFn.mockResolvedValueOnce(jsonResponse({ version: '2.1.2' })) + fetchFn.mockResolvedValueOnce(jsonResponse({ version: '2.2.1' })) const result = await registry.test({ serverId: server.id }) @@ -493,7 +493,7 @@ describe('OpenCodeServerRegistry', () => { baseUrl: 'https://studio.example.com', password: 'super-secret', }) - fetchFn.mockResolvedValueOnce(jsonResponse({ version: '2.1.2' })) + fetchFn.mockResolvedValueOnce(jsonResponse({ version: '2.2.1' })) const result = await registry.test({ baseUrl: 'https://studio.example.com/opencode', @@ -513,7 +513,7 @@ describe('OpenCodeServerRegistry', () => { baseUrl: 'https://studio.example.com', password: 'super-secret', }) - fetchFn.mockResolvedValueOnce(jsonResponse({ version: '2.1.2' })) + fetchFn.mockResolvedValueOnce(jsonResponse({ version: '2.2.1' })) const result = await registry.test({ baseUrl: 'https://relocated.example.com', @@ -533,7 +533,7 @@ describe('OpenCodeServerRegistry', () => { baseUrl: 'https://studio.example.com', password: 'super-secret', }) - fetchFn.mockResolvedValueOnce(jsonResponse({ version: '2.1.2' })) + fetchFn.mockResolvedValueOnce(jsonResponse({ version: '2.2.1' })) await registry.test({ baseUrl: 'https://studio.example.com', diff --git a/backend/src/services/session-goal-audit.ts b/backend/src/services/session-goal-audit.ts deleted file mode 100644 index 824b03008..000000000 --- a/backend/src/services/session-goal-audit.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { z } from 'zod' -import { SessionGoalVerdictSchema, type SessionGoalVerdict } from '@opencode-manager/shared/schemas' -import { parseFirstJsonObject } from '../utils/json-extract' -import { truncateSessionReply } from './session-reply' - -export const GOAL_AUDIT_REASON_MAX_CHARS = 500 -export const GOAL_AUDIT_UNPARSED_REASON = 'Auditor response was not understood' - -export interface GoalAuditPromptInput { - objective: string - reply: string | null -} - -export interface GoalContinuationPromptInput { - objective: string - reason: string -} - -export interface GoalVerdict { - verdict: SessionGoalVerdict - reason: string -} - -export function buildGoalAuditPrompt({ objective, reply }: GoalAuditPromptInput): string { - return [ - 'You audit whether an autonomous coding goal has been achieved.', - 'You can only see the goal objective and the agent\'s latest reply. You cannot run tools or read files.', - '', - 'Goal objective:', - objective, - '', - 'Agent\'s latest reply:', - truncateReply(reply), - '', - 'Decide the goal state:', - '- "done": the objective is verifiably achieved based on the reply.', - '- "blocked": the agent needs a user decision or access it cannot obtain.', - '- "continue": anything else, including when the reply is not enough to confirm completion.', - '', - 'Respond with only this JSON and nothing else:', - '{"verdict":"continue"|"done"|"blocked","reason":""}', - ].join('\n') -} - -export function buildGoalContinuationPrompt({ objective, reason }: GoalContinuationPromptInput): string { - return [ - `Continue working toward this goal: ${objective}`, - '', - `Progress check: ${reason}`, - '', - 'Keep working until the goal is achieved. If you need a user decision or access you cannot obtain, stop and state exactly what you need.', - ].join('\n') -} - -const goalVerdictSchema = z.object({ - verdict: SessionGoalVerdictSchema, - reason: z.string(), -}) - -export function parseGoalVerdict(text: string): GoalVerdict | null { - const parsed = parseFirstJsonObject(text, goalVerdictSchema) - if (!parsed) { - return null - } - - const reason = parsed.reason.trim() - if (!reason) { - return null - } - - return { verdict: parsed.verdict, reason: reason.slice(0, GOAL_AUDIT_REASON_MAX_CHARS) } -} - -function truncateReply(reply: string | null): string { - if (!reply) { - return '(no reply yet)' - } - return truncateSessionReply(reply) -} diff --git a/backend/src/services/session-goals.ts b/backend/src/services/session-goals.ts deleted file mode 100644 index c17e0e98b..000000000 --- a/backend/src/services/session-goals.ts +++ /dev/null @@ -1,730 +0,0 @@ -import type { Database } from 'bun:sqlite' -import { - DEFAULT_SESSION_DEFAULTS, - LOCAL_OPENCODE_SERVER_ID, - type SessionGoal, - type SessionGoalStatus, - type SessionGoalStopReason, - type SessionGoalTurnState, - type SessionLockReason, - type StartSessionGoalRequest, -} from '@opencode-manager/shared/schemas' -import { - isSessionNotFoundError, - parseOpenCodeModelRef, - sessionIDFromEvent, - type ModelRef, -} from '@opencode-manager/shared/opencode' -import { - getLatestSessionGoal, - getSessionGoalById, - insertSessionGoal, - listOpenSessionGoals, - transitionSessionGoal, - type SessionGoalPatch, - type SessionGoalRecord, -} from '../db/session-goals' -import { logger } from '../utils/logger' -import { - buildGoalAuditPrompt, - buildGoalContinuationPrompt, - GOAL_AUDIT_UNPARSED_REASON, - parseGoalVerdict, - type GoalVerdict, -} from './session-goal-audit' -import { isSessionBusy, readLatestAssistantReply, type AssistantReplyState } from './session-reply' -import type { OpenCodeClient } from './opencode/client' -import type { SettingsService } from './settings' -import type { SSEEvent } from './sse-aggregator' - -const DEFAULT_QUIET_MS = 3000 -const BLOCKED_LIMIT = 3 -const AUDITOR_ATTEMPTS = 2 -const RECOVERY_RETRY_MS = 5000 - -type PrerequisiteResult = { ok: true; value: T } | { ok: false } - -export interface SessionServerRegistry { - getClient(serverId: string): OpenCodeClient - get(serverId: string): { enabled: boolean } | null -} - -export type SessionServerSource = OpenCodeClient | SessionServerRegistry - -export interface SessionGoalServiceOptions { - quietMs?: number - onOutcome?: (goal: SessionGoal) => void - resolveSessionLock?: (sessionId: string, serverId: string) => Promise -} - -export class SessionGoalError extends Error { - status: number - - constructor(message: string, status: number) { - super(message) - this.status = status - } -} - -export function sessionTokenTotal(tokens: { input: number; output: number; reasoning: number }): number { - return tokens.input + tokens.output + tokens.reasoning -} - -function goalKey(serverId: string, sessionId: string): string { - return `${serverId}:${sessionId}` -} - -function isServerRegistry(source: SessionServerSource): source is SessionServerRegistry { - return typeof (source as SessionServerRegistry).getClient === 'function' -} - -function toServerRegistry(source: SessionServerSource): SessionServerRegistry { - if (isServerRegistry(source)) { - return source - } - return { - getClient: () => source, - get: (serverId) => (serverId === LOCAL_OPENCODE_SERVER_ID ? { enabled: true } : null), - } -} - -function toSessionGoal(record: SessionGoalRecord): SessionGoal { - return { - id: record.id, - sessionId: record.sessionId, - serverId: record.serverId, - directory: record.directory, - objective: record.objective, - status: record.status, - stopReason: record.stopReason, - turnState: record.turnState, - continuationCount: record.continuationCount, - maxContinuations: record.maxContinuations, - tokenBudget: record.tokenBudget, - tokensUsed: record.tokensUsed, - consecutiveBlocked: record.consecutiveBlocked, - lastVerdict: record.lastVerdict, - lastReason: record.lastReason, - createdAt: record.createdAt, - updatedAt: record.updatedAt, - finishedAt: record.finishedAt, - } -} - -export class SessionGoalService { - private readonly goalBySession = new Map() - private readonly auditTimers = new Map>() - private readonly recoveryTimers = new Map>() - private readonly auditing = new Set() - private readonly auditRequested = new Set() - private readonly auditGeneration = new Map() - private readonly servers: SessionServerRegistry - private readonly quietMs: number - private readonly onOutcome: (goal: SessionGoal) => void - private readonly resolveSessionLock: (sessionId: string, serverId: string) => Promise - - constructor( - private readonly db: Database, - servers: SessionServerSource, - private readonly settingsService: SettingsService, - options: SessionGoalServiceOptions = {}, - ) { - this.servers = toServerRegistry(servers) - this.quietMs = options.quietMs ?? DEFAULT_QUIET_MS - this.onOutcome = options.onOutcome ?? (() => {}) - this.resolveSessionLock = options.resolveSessionLock ?? (async () => null) - } - - loadOpenGoals(): void { - this.goalBySession.clear() - for (const record of listOpenSessionGoals(this.db)) { - this.goalBySession.set(goalKey(record.serverId, record.sessionId), record.id) - } - } - - async recoverOpenGoals(): Promise { - for (const record of listOpenSessionGoals(this.db)) { - if (record.status !== 'active' || record.turnState !== 'running') { - continue - } - if (!this.isServerAvailable(record.serverId)) { - continue - } - try { - await this.recoverRunningGoal(record) - } catch (error) { - logger.error(`Session goal recovery failed for goal ${record.id}:`, error) - } - } - } - - private async recoverRunningGoal(record: SessionGoalRecord): Promise { - const key = goalKey(record.serverId, record.sessionId) - const generation = this.currentAuditGeneration(key) - const client = this.servers.getClient(record.serverId) - try { - await client.api.session.get({ sessionID: record.sessionId }) - } catch (error) { - if (isSessionNotFoundError(error)) { - this.finishGoal(record.id, 'stopped', 'session_deleted') - return - } - logger.error(`Session goal recovery could not read session ${record.sessionId}:`, error) - this.scheduleRecoveryRetry(key, record.id, generation) - return - } - - const busy = await this.readPrerequisite(record.id, 'busy check', () => - isSessionBusy(client, record.sessionId), - ) - if (!busy.ok) { - this.scheduleRecoveryRetry(key, record.id, generation) - return - } - if (busy.value) { - return - } - this.scheduleAudit(key, record.id, 0) - } - - private isServerAvailable(serverId: string): boolean { - if (serverId === LOCAL_OPENCODE_SERVER_ID) { - return true - } - try { - const server = this.servers.get(serverId) - return Boolean(server?.enabled) - } catch (error) { - logger.error(`Failed to resolve OpenCode server ${serverId}:`, error) - return false - } - } - - private scheduleRecoveryRetry(key: string, goalId: number, generation: number): void { - this.clearRecoveryTimer(key) - const timer = setTimeout(() => { - this.recoveryTimers.delete(key) - void this.retryRecovery(key, goalId, generation).catch((error) => { - logger.error(`Session goal recovery retry failed for goal ${goalId}:`, error) - }) - }, RECOVERY_RETRY_MS) - this.recoveryTimers.set(key, timer) - } - - private async retryRecovery(key: string, goalId: number, generation: number): Promise { - const record = getSessionGoalById(this.db, goalId) - if (!record || record.status !== 'active' || record.turnState !== 'running') { - return - } - if (!this.isServerAvailable(record.serverId)) { - return - } - if (this.currentAuditGeneration(key) !== generation) { - return - } - await this.recoverRunningGoal(record) - } - - private clearRecoveryTimer(key: string): void { - const timer = this.recoveryTimers.get(key) - if (timer !== undefined) { - clearTimeout(timer) - this.recoveryTimers.delete(key) - } - } - - async start(input: StartSessionGoalRequest): Promise { - const serverId = input.serverId ?? LOCAL_OPENCODE_SERVER_ID - await this.assertSessionCanStartGoal(input.sessionId, serverId) - - const defaults = this.settingsService.getSettings().preferences.sessionDefaults - const maxContinuations = - input.maxContinuations ?? defaults?.goalMaxContinuations ?? DEFAULT_SESSION_DEFAULTS.goalMaxContinuations - const tokenBudget = input.tokenBudget ?? defaults?.goalTokenBudget ?? null - const tokensAtStart = await this.readSessionTokenTotalOrDefault(input.sessionId, serverId) - - const record = insertSessionGoal(this.db, { - sessionId: input.sessionId, - serverId, - directory: input.directory, - objective: input.objective, - maxContinuations, - tokenBudget, - tokensAtStart, - }) - - if (!record) { - throw new SessionGoalError('This session already has an open goal', 409) - } - - this.goalBySession.set(goalKey(record.serverId, record.sessionId), record.id) - return toSessionGoal(record) - } - - getLatest(sessionId: string, serverId: string = LOCAL_OPENCODE_SERVER_ID): SessionGoal | null { - const record = getLatestSessionGoal(this.db, sessionId, serverId) - return record ? toSessionGoal(record) : null - } - - private async assertSessionCanStartGoal(sessionId: string, serverId: string): Promise { - let lock: SessionLockReason | null - try { - lock = await this.resolveSessionLock(sessionId, serverId) - } catch (error) { - logger.error(`Failed to resolve session lock for ${sessionId}:`, error) - return - } - - if (lock === 'schedule') { - throw new SessionGoalError('Scheduled runs cannot run goals', 409) - } - if (lock === 'child') { - throw new SessionGoalError('Goals can only be started on top-level sessions', 400) - } - } - - pause(id: number): SessionGoal { - return this.transitionWithOutcome(id, ['active'], { status: 'paused', stopReason: 'user_paused' }) - } - - resume(id: number): SessionGoal { - const record = this.transition(id, ['paused'], { - status: 'active', - stopReason: null, - turnState: 'running', - finishedAt: null, - }) - - const goal = toSessionGoal(record) - void this.scheduleAuditWhenIdle(goal) - return goal - } - - cancel(id: number): SessionGoal { - return this.transitionWithOutcome(id, ['active', 'paused'], { - status: 'stopped', - stopReason: 'cancelled', - finishedAt: Date.now(), - }) - } - - async handleEvent( - directory: string | null, - event: SSEEvent, - serverId: string = LOCAL_OPENCODE_SERVER_ID, - ): Promise { - const sessionId = sessionIDFromEvent(event) - if (!sessionId) { - return - } - - const key = goalKey(serverId, sessionId) - const goalId = this.goalBySession.get(key) - if (goalId === undefined) { - return - } - - switch (event.type) { - case 'session.execution.started': - this.clearAuditTimer(key) - this.applyTransition(goalId, ['active'], { turnState: 'running' }) - return - case 'session.idle': - case 'session.execution.succeeded': - this.scheduleAuditFromEvent(key, goalId) - return - case 'session.status': - if (event.data.status.type === 'idle') { - this.scheduleAuditFromEvent(key, goalId) - } - return - case 'session.execution.failed': - this.clearAuditTimer(key) - this.finishGoal(goalId, 'stopped', 'turn_error') - return - case 'session.execution.interrupted': - this.clearAuditTimer(key) - this.finishGoal(goalId, 'paused', 'interrupted') - return - case 'session.deleted': - this.clearAuditTimer(key) - this.finishGoal(goalId, 'stopped', 'session_deleted', ['active', 'paused']) - return - default: - return - } - } - - private scheduleAuditFromEvent(key: string, goalId: number): void { - const record = getSessionGoalById(this.db, goalId) - if (!record || record.status !== 'active' || record.turnState !== 'running') { - return - } - this.scheduleAudit(key, goalId, this.quietMs) - } - - private async scheduleAuditWhenIdle(goal: SessionGoal): Promise { - const key = goalKey(goal.serverId, goal.sessionId) - const generation = this.currentAuditGeneration(key) - const busy = await this.readPrerequisite(goal.id, 'busy check', () => - isSessionBusy(this.servers.getClient(goal.serverId), goal.sessionId), - ) - - if (!busy.ok) { - this.failAudit(goal.id, key, generation) - return - } - if (!this.isAuditCurrent(goal.id, key, generation)) { - return - } - if (busy.value) { - return - } - this.scheduleAudit(key, goal.id, 0) - } - - private scheduleAudit(key: string, goalId: number, delay: number): void { - this.clearRecoveryTimer(key) - this.clearAuditTimer(key) - const timer = setTimeout(() => { - this.auditTimers.delete(key) - void this.runAudit(goalId).catch((error) => { - logger.error(`Session goal audit failed for goal ${goalId}:`, error) - }) - }, delay) - this.auditTimers.set(key, timer) - } - - private clearAuditTimer(key: string): void { - const timer = this.auditTimers.get(key) - if (timer !== undefined) { - clearTimeout(timer) - this.auditTimers.delete(key) - } - } - - private async runAudit(goalId: number): Promise { - if (this.auditing.has(goalId)) { - this.auditRequested.add(goalId) - return - } - - this.auditing.add(goalId) - try { - let pending = true - while (pending) { - this.auditRequested.delete(goalId) - await this.performAudit(goalId) - pending = this.auditRequested.has(goalId) - } - } finally { - this.auditing.delete(goalId) - } - } - - private async performAudit(goalId: number): Promise { - const goal = getSessionGoalById(this.db, goalId) - if (!goal || goal.status !== 'active' || goal.turnState !== 'running') { - return - } - - const sessionId = goal.sessionId - const key = goalKey(goal.serverId, sessionId) - const generation = this.currentAuditGeneration(key) - const client = this.servers.getClient(goal.serverId) - - const busy = await this.readPrerequisite(goalId, 'busy check', () => - isSessionBusy(client, sessionId), - ) - if (!busy.ok) { - this.failAudit(goalId, key, generation) - return - } - if (!this.isAuditCurrent(goalId, key, generation) || busy.value) { - return - } - - const total = await this.readPrerequisite(goalId, 'token read', () => - this.readSessionTokenTotal(sessionId, goal.serverId), - ) - if (!total.ok) { - this.failAudit(goalId, key, generation) - return - } - if (!this.isAuditCurrent(goalId, key, generation)) { - return - } - - const tokensUsed = Math.max(0, total.value - goal.tokensAtStart) - const updated = this.applyTransition(goalId, ['active'], { tokensUsed }) - if (!updated) { - return - } - - if (updated.tokenBudget !== null && tokensUsed >= updated.tokenBudget) { - this.finishGoal(goalId, 'stopped', 'token_budget') - return - } - - const reply = await this.readPrerequisite(goalId, 'reply read', () => - readLatestAssistantReply(client, sessionId), - ) - if (!reply.ok) { - this.failAudit(goalId, key, generation) - return - } - if (!this.isAuditCurrent(goalId, key, generation)) { - return - } - - if (reply.value?.errorText) { - this.finishGoal(goalId, 'stopped', 'turn_error') - return - } - - const verdict = await this.evaluateAudit(updated, reply.value) - if (!this.isAuditCurrent(goalId, key, generation)) { - return - } - if (!verdict) { - this.finishGoal(goalId, 'paused', 'audit_failed') - return - } - - await this.applyVerdict(goalId, key, generation, updated, verdict) - } - - private async evaluateAudit(goal: SessionGoalRecord, reply: AssistantReplyState | null): Promise { - const prompt = buildGoalAuditPrompt({ objective: goal.objective, reply: reply?.responseText ?? null }) - const model = this.resolveAuditorModel() - const localClient = this.servers.getClient(LOCAL_OPENCODE_SERVER_ID) - - for (let attempt = 0; attempt < AUDITOR_ATTEMPTS; attempt += 1) { - try { - const result = await localClient.api.generate.text(model ? { prompt, model } : { prompt }) - return parseGoalVerdict(result.text) ?? { verdict: 'continue', reason: GOAL_AUDIT_UNPARSED_REASON } - } catch (error) { - logger.error(`Goal auditor call failed for goal ${goal.id} (attempt ${attempt + 1}):`, error) - } - } - - return null - } - - private resolveAuditorModel(): ModelRef | undefined { - const configured = this.settingsService.getSettings().preferences.sessionDefaults?.goalAuditorModel - return configured ? parseOpenCodeModelRef(configured) : undefined - } - - private async applyVerdict( - goalId: number, - key: string, - generation: number, - goal: SessionGoalRecord, - verdict: GoalVerdict, - ): Promise { - if (!this.isAuditCurrent(goalId, key, generation)) { - return - } - - if (verdict.verdict === 'done') { - this.finishGoal(goalId, 'completed', null, ['active'], { - lastVerdict: 'done', - lastReason: verdict.reason, - }) - return - } - - if (verdict.verdict === 'blocked') { - const consecutiveBlocked = goal.consecutiveBlocked + 1 - const patch: SessionGoalPatch = { - consecutiveBlocked, - lastVerdict: 'blocked', - lastReason: verdict.reason, - } - - if (consecutiveBlocked >= BLOCKED_LIMIT) { - this.finishGoal(goalId, 'blocked', null, ['active'], patch) - return - } - - await this.continueGoal(goalId, key, generation, goal, verdict.reason, patch) - return - } - - await this.continueGoal(goalId, key, generation, goal, verdict.reason, { - consecutiveBlocked: 0, - lastVerdict: 'continue', - lastReason: verdict.reason, - }) - } - - private async continueGoal( - goalId: number, - key: string, - generation: number, - goal: SessionGoalRecord, - reason: string, - patch: SessionGoalPatch, - ): Promise { - if (!this.isAuditCurrent(goalId, key, generation)) { - return - } - - if (goal.continuationCount >= goal.maxContinuations) { - this.finishGoal(goalId, 'stopped', 'continuation_limit', ['active'], patch) - return - } - - const record = this.applyTransition(goalId, ['active'], { - ...patch, - continuationCount: goal.continuationCount + 1, - turnState: 'waiting', - }) - if (!record) { - return - } - - try { - await this.servers.getClient(record.serverId).api.session.prompt({ - sessionID: record.sessionId, - text: buildGoalContinuationPrompt({ objective: record.objective, reason }), - }) - } catch (error) { - logger.error(`Failed to send continuation for goal ${goalId}:`, error) - if (this.isAuditCurrent(goalId, key, generation, 'waiting')) { - this.finishGoal(goalId, 'stopped', 'turn_error') - } - } - } - - private finishGoal( - goalId: number, - status: SessionGoalStatus, - stopReason: SessionGoalStopReason | null, - fromStatuses: SessionGoalStatus[] = ['active'], - patch: SessionGoalPatch = {}, - ): void { - const record = this.applyTransition(goalId, fromStatuses, { - ...patch, - status, - stopReason, - finishedAt: status === 'paused' ? null : Date.now(), - }) - if (record) { - this.onOutcome(toSessionGoal(record)) - } - } - - private failAudit(goalId: number, key: string, generation: number): void { - if (!this.isAuditCurrent(goalId, key, generation)) { - return - } - this.finishGoal(goalId, 'paused', 'audit_failed') - } - - private async readPrerequisite( - goalId: number, - label: string, - read: () => Promise, - ): Promise> { - for (let attempt = 0; attempt < AUDITOR_ATTEMPTS; attempt += 1) { - try { - return { ok: true, value: await read() } - } catch (error) { - logger.error(`Session goal ${goalId} ${label} failed (attempt ${attempt + 1}):`, error) - } - } - return { ok: false } - } - - private isAuditCurrent( - goalId: number, - key: string, - generation: number, - turnState: SessionGoalTurnState = 'running', - ): boolean { - if (this.currentAuditGeneration(key) !== generation) { - return false - } - const record = getSessionGoalById(this.db, goalId) - return Boolean(record && record.status === 'active' && record.turnState === turnState) - } - - private currentAuditGeneration(key: string): number { - return this.auditGeneration.get(key) ?? 0 - } - - private applyTransition( - goalId: number, - fromStatuses: SessionGoalStatus[], - patch: SessionGoalPatch, - ): SessionGoalRecord | null { - const record = transitionSessionGoal(this.db, goalId, fromStatuses, patch) - if (!record) { - return null - } - - this.syncGoalIndex(record) - this.maybeInvalidateAudits(record, patch) - return record - } - - private transitionWithOutcome( - id: number, - fromStatuses: SessionGoalStatus[], - patch: SessionGoalPatch, - ): SessionGoal { - const goal = toSessionGoal(this.transition(id, fromStatuses, patch)) - this.onOutcome(goal) - return goal - } - - private transition(id: number, fromStatuses: SessionGoalStatus[], patch: SessionGoalPatch): SessionGoalRecord { - const record = this.applyTransition(id, fromStatuses, patch) - if (!record) { - if (!getSessionGoalById(this.db, id)) { - throw new SessionGoalError('Session goal not found', 404) - } - throw new SessionGoalError('Session goal cannot transition from its current state', 409) - } - - return record - } - - private syncGoalIndex(record: SessionGoalRecord): void { - const key = goalKey(record.serverId, record.sessionId) - if (record.status === 'active' || record.status === 'paused') { - this.goalBySession.set(key, record.id) - } else { - this.goalBySession.delete(key) - this.clearAuditTimer(key) - } - if (record.status !== 'active') { - this.clearRecoveryTimer(key) - } - } - - private maybeInvalidateAudits(record: SessionGoalRecord, patch: SessionGoalPatch): void { - if (patch.status === undefined && patch.turnState !== 'running') { - return - } - const key = goalKey(record.serverId, record.sessionId) - this.auditGeneration.set(key, this.currentAuditGeneration(key) + 1) - this.auditRequested.delete(record.id) - } - - private async readSessionTokenTotal(sessionId: string, serverId: string): Promise { - const session = await this.servers.getClient(serverId).api.session.get({ sessionID: sessionId }) - return sessionTokenTotal(session.tokens) - } - - private async readSessionTokenTotalOrDefault(sessionId: string, serverId: string): Promise { - try { - return await this.readSessionTokenTotal(sessionId, serverId) - } catch (error) { - logger.error(`Failed to read token usage for session ${sessionId}:`, error) - return 0 - } - } -} diff --git a/backend/src/utils/json-extract.ts b/backend/src/utils/json-extract.ts deleted file mode 100644 index 18772e0ba..000000000 --- a/backend/src/utils/json-extract.ts +++ /dev/null @@ -1,64 +0,0 @@ -import type { ZodType } from 'zod' - -export function extractFirstJsonObject(text: string): string | null { - const start = text.indexOf('{') - if (start === -1) { - return null - } - - let depth = 0 - let inString = false - let escaped = false - - for (let index = start; index < text.length; index += 1) { - const char = text[index] - - if (inString) { - if (escaped) { - escaped = false - } else if (char === '\\') { - escaped = true - } else if (char === '"') { - inString = false - } - continue - } - - if (char === '"') { - inString = true - continue - } - - if (char === '{') { - depth += 1 - } else if (char === '}') { - depth -= 1 - if (depth === 0) { - return text.slice(start, index + 1) - } - } - } - - return null -} - -export function parseFirstJsonObject(text: string, schema: ZodType): T | null { - const extracted = extractFirstJsonObject(text) - if (!extracted) { - return null - } - - let raw: unknown - try { - raw = JSON.parse(extracted) - } catch { - return null - } - - const parsed = schema.safeParse(raw) - if (!parsed.success) { - return null - } - - return parsed.data -} diff --git a/backend/test/db/drop-session-goals-migration.test.ts b/backend/test/db/drop-session-goals-migration.test.ts new file mode 100644 index 000000000..633c22f64 --- /dev/null +++ b/backend/test/db/drop-session-goals-migration.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, afterEach } from 'vitest' +import { Database } from 'bun:sqlite' +import { migrate } from '../../src/db/migration-runner' +import { allMigrations } from '../../src/db/migrations' +import migration from '../../src/db/migrations/202610291200-drop-session-goals' + +function migrateUntil(db: Database, predicate: (id: string) => boolean): void { + const upToInclusiveIndex = allMigrations.findIndex((entry) => predicate(entry.id)) + if (upToInclusiveIndex < 0) { + throw new Error('Migration not found') + } + migrate(db, allMigrations.slice(0, upToInclusiveIndex + 1)) +} + +function tableExists(db: Database, name: string): boolean { + return db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(name) !== undefined +} + +function indexNames(db: Database): string[] { + return (db.prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'session_goals'").all() as Array<{ + name: string + }>).map((row) => row.name) +} + +function insertOpenGoalRow(db: Database, sessionId: string): void { + const now = Date.now() + db.prepare(` + INSERT INTO session_goals(session_id, directory, objective, status, turn_state, max_continuations, created_at, updated_at) + VALUES(?,?,?,?,?,?,?,?) + `).run(sessionId, '/repo', 'Ship it', 'active', 'waiting', 1, now, now) +} + +describe('202610291200 drop session goals migration', () => { + let db: Database + + afterEach(() => { + db?.close() + }) + + it('drops the table and its indexes from a database that still has session goal rows', () => { + db = new Database(':memory:') + migrateUntil(db, (id) => id === '202610181300-session-server-scope') + insertOpenGoalRow(db, 'ses_existing') + + expect((db.prepare('SELECT COUNT(*) AS count FROM session_goals').get() as { count: number }).count).toBe(1) + expect(tableExists(db, 'session_goals')).toBe(true) + + migrate(db, allMigrations) + + expect(tableExists(db, 'session_goals')).toBe(false) + expect(indexNames(db).filter((name) => name.startsWith('idx_session_goals_'))).toEqual([]) + }) + + it('leaves a fresh database without the session goals table', () => { + db = new Database(':memory:') + migrate(db, allMigrations) + + expect(tableExists(db, 'session_goals')).toBe(false) + expect(indexNames(db).filter((name) => name.startsWith('idx_session_goals_'))).toEqual([]) + }) + + it('recreates the table and indexes when rolled back', () => { + db = new Database(':memory:') + migrate(db, allMigrations) + migration.down(db) + + expect(tableExists(db, 'session_goals')).toBe(true) + expect(indexNames(db)).toEqual(expect.arrayContaining(['idx_session_goals_open', 'idx_session_goals_session'])) + }) + + it('recreates a table that accepts an open goal row and enforces the open index', () => { + db = new Database(':memory:') + migrate(db, allMigrations) + migration.down(db) + + insertOpenGoalRow(db, 'ses_shared') + + expect(() => insertOpenGoalRow(db, 'ses_shared')).toThrow(/UNIQUE constraint failed/i) + }) +}) diff --git a/backend/test/db/session-server-scope-migration.test.ts b/backend/test/db/session-server-scope-migration.test.ts index 2025aed30..c620f6e30 100644 --- a/backend/test/db/session-server-scope-migration.test.ts +++ b/backend/test/db/session-server-scope-migration.test.ts @@ -3,12 +3,74 @@ import { Database } from 'bun:sqlite' import { migrate } from '../../src/db/migration-runner' import { allMigrations } from '../../src/db/migrations' import migration from '../../src/db/migrations/202610181300-session-server-scope' -import { getSessionGoalById, insertSessionGoal } from '../../src/db/session-goals' import { getSessionPermissionMode, setSessionPermissionMode } from '../../src/db/session-permission-modes' +interface InsertSessionGoalRowInput { + sessionId: string + serverId?: string + directory: string + objective: string + maxContinuations: number + tokenBudget: number | null + tokensAtStart: number +} + +const INSERT_SESSION_GOAL_ROW_SQL = ` + INSERT INTO session_goals( + session_id, server_id, directory, objective, status, stop_reason, turn_state, + continuation_count, max_continuations, token_budget, tokens_at_start, tokens_used, + consecutive_blocked, last_verdict, last_reason, created_at, updated_at, finished_at + ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) +` + +function insertSessionGoalRow(db: Database, input: InsertSessionGoalRowInput): number { + const now = Date.now() + const result = db.prepare(INSERT_SESSION_GOAL_ROW_SQL).run( + input.sessionId, + input.serverId ?? 'local', + input.directory, + input.objective, + 'active', + null, + 'waiting', + 0, + input.maxContinuations, + input.tokenBudget, + input.tokensAtStart, + 0, + 0, + null, + null, + now, + now, + null, + ) + return Number(result.lastInsertRowid) +} + +function tryInsertSessionGoalRow(db: Database, input: InsertSessionGoalRowInput): number | null { + try { + return insertSessionGoalRow(db, input) + } catch (error) { + if (/UNIQUE constraint failed/i.test((error as Error).message)) { + return null + } + throw error + } +} + +function sessionGoalServerIdRow(db: Database, id: number): { serverId: string } | undefined { + return db.prepare('SELECT server_id AS serverId FROM session_goals WHERE id = ?').get(id) as + | { serverId: string } + | undefined +} + function createTestDb(): Database { const db = new Database(':memory:') - migrate(db, allMigrations) + migrate( + db, + allMigrations.filter((entry) => entry.id !== '202610291200-drop-session-goals'), + ) return db } @@ -97,9 +159,9 @@ describe('202610181300 session server scope migration', () => { }) it('scopes the open goal index by server', () => { - const local = insertSessionGoal(db, GOAL_INPUT) - const remote = insertSessionGoal(db, { ...GOAL_INPUT, serverId: 'srv_remote' }) - const duplicateRemote = insertSessionGoal(db, { ...GOAL_INPUT, serverId: 'srv_remote' }) + const local = insertSessionGoalRow(db, GOAL_INPUT) + const remote = insertSessionGoalRow(db, { ...GOAL_INPUT, serverId: 'srv_remote' }) + const duplicateRemote = tryInsertSessionGoalRow(db, { ...GOAL_INPUT, serverId: 'srv_remote' }) expect(local).not.toBeNull() expect(remote).not.toBeNull() @@ -144,8 +206,8 @@ describe('202610181300 session server scope migration', () => { }) it('removes remote rows and restores session-scoped keys when rolled back', () => { - insertSessionGoal(db, GOAL_INPUT) - insertSessionGoal(db, { ...GOAL_INPUT, serverId: 'srv_x' }) + insertSessionGoalRow(db, GOAL_INPUT) + insertSessionGoalRow(db, { ...GOAL_INPUT, serverId: 'srv_x' }) setSessionPermissionMode(db, 'ses_shared', 'auto') setSessionPermissionMode(db, 'ses_shared', 'ask', 'srv_x') @@ -186,7 +248,7 @@ describe('202610181300 session server scope migration', () => { expect(getSessionPermissionMode(legacyDb, 'ses_root')).toBe('auto') expect(getSessionPermissionMode(legacyDb, 'ses_root', 'srv_x')).toBe('ask') expect(countRows(legacyDb, 'session_permission_modes')).toBe(2) - expect(getSessionGoalById(legacyDb, legacyGoalId)).toMatchObject({ serverId: 'local' }) + expect(sessionGoalServerIdRow(legacyDb, legacyGoalId)).toMatchObject({ serverId: 'local' }) legacyDb.close() }) diff --git a/backend/test/helpers/fake-session-goal-client.ts b/backend/test/helpers/fake-session-goal-client.ts deleted file mode 100644 index 7acbd4835..000000000 --- a/backend/test/helpers/fake-session-goal-client.ts +++ /dev/null @@ -1,231 +0,0 @@ -import type { ModelRef, SessionMessageInfo } from '@opencode-manager/shared/opencode' -import type { OpenCodeClient } from '../../src/services/opencode/client' - -export interface FakeSessionGoalTokens { - input: number - output: number - reasoning: number -} - -export interface FakeSessionGoalClientOptions { - tokens?: Record - failSessionGet?: boolean - messages?: Record - busySessions?: string[] - auditorReplies?: string[] - failAuditorCalls?: number - failSessionPrompt?: boolean -} - -export interface FakeSessionGoalPromptCall { - sessionID: string - text: string -} - -export interface FakeSessionGoalAuditorCall { - prompt: string - model: ModelRef | null -} - -export interface FakeSessionGoalAuditorDeferral { - resolve(text: string): void - reject(error?: unknown): void -} - -export interface FakeSessionGoalPromptDeferral { - resolve(): void - reject(error?: unknown): void -} - -export interface FakeSessionGoalClient { - client: OpenCodeClient - promptCalls: FakeSessionGoalPromptCall[] - auditorCalls: FakeSessionGoalAuditorCall[] - pendingAuditorCalls: FakeSessionGoalAuditorDeferral[] - pendingSessionPromptCalls: FakeSessionGoalPromptDeferral[] - readonly maxConcurrentAuditorCalls: number - setTokens(sessionId: string, tokens: FakeSessionGoalTokens): void - setMessages(sessionId: string, messages: SessionMessageInfo[]): void - setBusy(sessionId: string, busy: boolean): void - setAuditorReplies(replies: string[]): void - failNextAuditorCalls(count: number): void - deferNextAuditorCalls(count: number): void - deferNextSessionPrompts(count: number): void - setFailSessionGet(fail: boolean): void - setFailSessionActive(fail: boolean): void - setFailMessageList(fail: boolean): void - setSessionMissing(sessionId: string, missing: boolean): void -} - -export function createFakeSessionGoalClient(options: FakeSessionGoalClientOptions = {}): FakeSessionGoalClient { - const tokens = new Map(Object.entries(options.tokens ?? {})) - const messages = new Map(Object.entries(options.messages ?? {})) - const busySessions = new Set(options.busySessions ?? []) - const missingSessions = new Set() - const promptCalls: FakeSessionGoalPromptCall[] = [] - const auditorCalls: FakeSessionGoalAuditorCall[] = [] - const pendingAuditorCalls: FakeSessionGoalAuditorDeferral[] = [] - const pendingSessionPromptCalls: FakeSessionGoalPromptDeferral[] = [] - let auditorReplies = [...(options.auditorReplies ?? [])] - let auditorFailures = options.failAuditorCalls ?? 0 - let deferAuditorCount = 0 - let deferPromptCount = 0 - let sessionGetFails = options.failSessionGet ?? false - let sessionActiveFails = false - let messageListFails = false - let activeAuditorCalls = 0 - let maxConcurrentAuditorCalls = 0 - - const client = { - api: { - session: { - get: async ({ sessionID }: { sessionID: string }) => { - if (missingSessions.has(sessionID)) { - throw Object.assign(new Error('Session not found'), { _tag: 'SessionNotFoundError' }) - } - if (sessionGetFails) { - throw new Error('upstream unavailable') - } - const usage = tokens.get(sessionID) ?? { input: 0, output: 0, reasoning: 0 } - return { - tokens: { - input: usage.input, - output: usage.output, - reasoning: usage.reasoning, - cache: { read: 0, write: 0 }, - }, - } - }, - active: async () => { - if (sessionActiveFails) { - throw new Error('active lookup failed') - } - const active: Record = {} - for (const sessionId of busySessions) { - active[sessionId] = { type: 'running' } - } - return active - }, - prompt: async (input: { sessionID: string; text: string }) => { - if (options.failSessionPrompt) { - throw new Error('prompt rejected') - } - promptCalls.push({ sessionID: input.sessionID, text: input.text }) - if (deferPromptCount > 0) { - deferPromptCount -= 1 - await new Promise((resolve, reject) => { - pendingSessionPromptCalls.push({ resolve, reject }) - }) - } - return { id: `inbox_${promptCalls.length}` } - }, - }, - message: { - list: async ({ sessionID }: { sessionID: string }) => { - if (messageListFails) { - throw new Error('message list failed') - } - return { - data: messages.get(sessionID) ?? [], - cursor: {}, - } - }, - }, - generate: { - text: async (input: { prompt: string; model?: ModelRef | null }) => { - auditorCalls.push({ prompt: input.prompt, model: input.model ?? null }) - activeAuditorCalls += 1 - maxConcurrentAuditorCalls = Math.max(maxConcurrentAuditorCalls, activeAuditorCalls) - try { - if (deferAuditorCount > 0) { - deferAuditorCount -= 1 - const text = await new Promise((resolve, reject) => { - pendingAuditorCalls.push({ resolve, reject }) - }) - return { text } - } - if (auditorFailures > 0) { - auditorFailures -= 1 - throw new Error('auditor unavailable') - } - const text = auditorReplies.shift() ?? '{"verdict":"continue","reason":"still working"}' - return { text } - } finally { - activeAuditorCalls -= 1 - } - }, - }, - }, - forwardRaw: async () => new Response(), - } as unknown as OpenCodeClient - - return { - client, - promptCalls, - auditorCalls, - pendingAuditorCalls, - pendingSessionPromptCalls, - get maxConcurrentAuditorCalls() { - return maxConcurrentAuditorCalls - }, - setTokens: (sessionId, next) => { - tokens.set(sessionId, next) - }, - setMessages: (sessionId, next) => { - messages.set(sessionId, next) - }, - setBusy: (sessionId, next) => { - if (next) { - busySessions.add(sessionId) - } else { - busySessions.delete(sessionId) - } - }, - setAuditorReplies: (replies) => { - auditorReplies = [...replies] - }, - failNextAuditorCalls: (count) => { - auditorFailures = count - }, - deferNextAuditorCalls: (count) => { - deferAuditorCount = count - }, - deferNextSessionPrompts: (count) => { - deferPromptCount = count - }, - setFailSessionGet: (fail) => { - sessionGetFails = fail - }, - setFailSessionActive: (fail) => { - sessionActiveFails = fail - }, - setFailMessageList: (fail) => { - messageListFails = fail - }, - setSessionMissing: (sessionId, missing) => { - if (missing) { - missingSessions.add(sessionId) - } else { - missingSessions.delete(sessionId) - } - }, - } -} - -export function fakeAssistantMessage( - text: string, - options: { error?: string; completed?: boolean } = {}, -): SessionMessageInfo { - return { - id: 'msg_assistant', - type: 'assistant', - time: { - created: 0, - ...(options.completed === false ? {} : { completed: 1 }), - }, - agent: 'build', - model: { providerID: 'test', id: 'model' }, - content: [{ type: 'text', text }], - ...(options.error ? { error: { type: 'unknown', message: options.error } } : {}), - } as unknown as SessionMessageInfo -} diff --git a/backend/test/helpers/internal-test-app.ts b/backend/test/helpers/internal-test-app.ts index 34d7e20f1..1889ecfaa 100644 --- a/backend/test/helpers/internal-test-app.ts +++ b/backend/test/helpers/internal-test-app.ts @@ -12,11 +12,10 @@ type InternalTestAppOverrides = Partial<{ permissionModes: CreateInternalRoutesParams[5] repoWorkspaces: CreateInternalRoutesParams[6] gitAuthService: CreateInternalRoutesParams[7] - sessionGoals: CreateInternalRoutesParams[8] - multiRuns: CreateInternalRoutesParams[9] - changeWalkthroughService: CreateInternalRoutesParams[10] - sessionSandbox: CreateInternalRoutesParams[11] - repoEnvService: CreateInternalRoutesParams[12] + multiRuns: CreateInternalRoutesParams[8] + changeWalkthroughService: CreateInternalRoutesParams[9] + sessionSandbox: CreateInternalRoutesParams[10] + repoEnvService: CreateInternalRoutesParams[11] }> export function createInternalTestApp( @@ -32,10 +31,9 @@ export function createInternalTestApp( 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]), - overrides.changeWalkthroughService ?? ({} as CreateInternalRoutesParams[10]), - overrides.sessionSandbox ?? ({} as CreateInternalRoutesParams[11]), + overrides.multiRuns ?? ({} as CreateInternalRoutesParams[8]), + overrides.changeWalkthroughService ?? ({} as CreateInternalRoutesParams[9]), + overrides.sessionSandbox ?? ({} as CreateInternalRoutesParams[10]), overrides.repoEnvService ?? createStubRepoEnvService(), ) } diff --git a/backend/test/helpers/opencode-binary.test.ts b/backend/test/helpers/opencode-binary.test.ts index 0479f49db..8b5c50ed6 100644 --- a/backend/test/helpers/opencode-binary.test.ts +++ b/backend/test/helpers/opencode-binary.test.ts @@ -16,7 +16,7 @@ vi.mock('child_process', () => ({ spawn: childProcessMock.spawn, })) -const SUPPORTED_VERSION = '2.1.2' +const SUPPORTED_VERSION = '2.2.1' const OPENCODE_BIN_FIXTURE = '/fixture/opencode-2' const PATH_CANDIDATE = 'opencode' const ABSOLUTE_CANDIDATES = ['/usr/local/bin/opencode', '/opt/opencode/bin/opencode'] diff --git a/backend/test/routes/internal-session-goals.test.ts b/backend/test/routes/internal-session-goals.test.ts deleted file mode 100644 index 74e720981..000000000 --- a/backend/test/routes/internal-session-goals.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest' -import type { SessionGoalService } from '../../src/services/session-goals' -import { Hono } from 'hono' -import { Database } from 'bun:sqlite' -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 { - getLatest: vi.fn(), - start: vi.fn(), - pause: vi.fn(), - resume: vi.fn(), - cancel: vi.fn(), - } -} - -describe('internal-session-goals routes', () => { - let db: Database - let sessionGoals: ReturnType - let app: Hono - let token: string - - beforeEach(() => { - db = new Database(':memory:') - migrate(db, allMigrations) - sessionGoals = createSessionGoalStub() - app = new Hono() - app.route('/api/internal', createInternalTestApp(db, { sessionGoals: sessionGoals as unknown as SessionGoalService })) - token = getOrCreateInternalToken(db) - }) - - it('GET /api/internal/session-goals returns 401 without bearer token', async () => { - const res = await app.request('/api/internal/session-goals?sessionId=ses_1') - expect(res.status).toBe(401) - }) - - it('GET /api/internal/session-goals returns the latest goal with a bearer token', async () => { - sessionGoals.getLatest.mockReturnValue(null) - - const res = await app.request('/api/internal/session-goals?sessionId=ses_1', { - headers: { authorization: `Bearer ${token}` }, - }) - - expect(res.status).toBe(200) - await expect(res.json()).resolves.toEqual({ goal: null }) - expect(sessionGoals.getLatest).toHaveBeenCalledWith('ses_1') - }) - - it('POST /api/internal/session-goals starts a goal', async () => { - sessionGoals.start.mockResolvedValue({ id: 1, status: 'active' }) - const body = { sessionId: 'ses_1', directory: '/abs/repo', objective: 'Ship it' } - - const res = await app.request('/api/internal/session-goals', { - method: 'POST', - headers: { authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }) - - expect(res.status).toBe(201) - await expect(res.json()).resolves.toEqual({ goal: { id: 1, status: 'active' } }) - expect(sessionGoals.start).toHaveBeenCalledWith(body) - }) - - it('POST /api/internal/session-goals/7/pause pauses the goal', async () => { - sessionGoals.pause.mockReturnValue({ id: 7, status: 'paused' }) - - const res = await app.request('/api/internal/session-goals/7/pause', { - method: 'POST', - headers: { authorization: `Bearer ${token}` }, - }) - - expect(res.status).toBe(200) - await expect(res.json()).resolves.toEqual({ goal: { id: 7, status: 'paused' } }) - expect(sessionGoals.pause).toHaveBeenCalledWith(7) - }) -}) diff --git a/backend/test/routes/session-goals.test.ts b/backend/test/routes/session-goals.test.ts deleted file mode 100644 index dbe672219..000000000 --- a/backend/test/routes/session-goals.test.ts +++ /dev/null @@ -1,212 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest' -import { Hono } from 'hono' -import { Database } from 'bun:sqlite' -import { migrate } from '../../src/db/migration-runner' -import { allMigrations } from '../../src/db/migrations' -import { SessionGoalService, type SessionGoalServiceOptions } from '../../src/services/session-goals' -import { SettingsService } from '../../src/services/settings' -import { createSessionGoalRoutes } from '../../src/routes/session-goals' -import { createFakeSessionGoalClient } from '../helpers/fake-session-goal-client' - -const DIRECTORY = '/abs/repo' - -function createTestApp(db: Database, serviceOptions: SessionGoalServiceOptions = {}): Hono { - const app = new Hono() - const service = new SessionGoalService( - db, - createFakeSessionGoalClient({ busySessions: ['ses_1'] }).client, - new SettingsService(db), - serviceOptions, - ) - app.route('/session-goals', createSessionGoalRoutes(service)) - return app -} - -function createTestDb(): Database { - const db = new Database(':memory:') - migrate(db, allMigrations) - return db -} - -function startBody(overrides: Record = {}): string { - return JSON.stringify({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it', ...overrides }) -} - -describe('session goal routes', () => { - let db: Database - - beforeEach(() => { - db = createTestDb() - }) - - afterEach(() => { - db.close() - }) - - it('GET returns a null goal for a session with no goal', async () => { - const app = createTestApp(db) - - const res = await app.request('/session-goals?sessionId=ses_1') - - expect(res.status).toBe(200) - await expect(res.json()).resolves.toEqual({ goal: null }) - }) - - it('GET rejects a missing sessionId with 400', async () => { - const app = createTestApp(db) - - const res = await app.request('/session-goals') - - expect(res.status).toBe(400) - }) - - it('POST creates a goal and GET reads it back', async () => { - const app = createTestApp(db) - - const postRes = await app.request('/session-goals', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: startBody(), - }) - expect(postRes.status).toBe(201) - const created = await postRes.json() as { goal: { id: number; status: string } } - expect(created.goal.status).toBe('active') - - const getRes = await app.request('/session-goals?sessionId=ses_1') - const body = await getRes.json() as { goal: { id: number } } - expect(body.goal.id).toBe(created.goal.id) - }) - - it('POST rejects an invalid body with 400 and details', async () => { - const app = createTestApp(db) - - const res = await app.request('/session-goals', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ sessionId: 'ses_1', directory: DIRECTORY, objective: '' }), - }) - - expect(res.status).toBe(400) - const body = await res.json() as { error: string; details: unknown[] } - expect(body.error).toEqual(expect.any(String)) - expect(Array.isArray(body.details)).toBe(true) - }) - - it('POST rejects malformed JSON with 400', async () => { - const app = createTestApp(db) - - const res = await app.request('/session-goals', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: '{not json', - }) - - expect(res.status).toBe(400) - await expect(res.json()).resolves.toEqual({ error: 'Invalid JSON' }) - }) - - it('POST rejects a scheduled run session with 409', async () => { - const app = createTestApp(db, { resolveSessionLock: async () => 'schedule' }) - - const res = await app.request('/session-goals', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: startBody(), - }) - - expect(res.status).toBe(409) - await expect(res.json()).resolves.toEqual({ error: 'Scheduled runs cannot run goals' }) - }) - - it('POST rejects a child session with 400', async () => { - const app = createTestApp(db, { resolveSessionLock: async () => 'child' }) - - const res = await app.request('/session-goals', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: startBody(), - }) - - expect(res.status).toBe(400) - await expect(res.json()).resolves.toEqual({ error: 'Goals can only be started on top-level sessions' }) - }) - - it('POST rejects a second open goal with 409', async () => { - const app = createTestApp(db) - await app.request('/session-goals', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: startBody(), - }) - - const res = await app.request('/session-goals', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: startBody(), - }) - - expect(res.status).toBe(409) - }) - - it('POST pause and cancel change the goal state', async () => { - const app = createTestApp(db) - const created = await (await app.request('/session-goals', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: startBody(), - })).json() as { goal: { id: number } } - - const pauseRes = await app.request(`/session-goals/${created.goal.id}/pause`, { method: 'POST' }) - expect(pauseRes.status).toBe(200) - const paused = await pauseRes.json() as { goal: { status: string; stopReason: string } } - expect(paused.goal).toMatchObject({ status: 'paused', stopReason: 'user_paused' }) - - const cancelRes = await app.request(`/session-goals/${created.goal.id}/cancel`, { method: 'POST' }) - expect(cancelRes.status).toBe(200) - const cancelled = await cancelRes.json() as { goal: { status: string; stopReason: string } } - expect(cancelled.goal).toMatchObject({ status: 'stopped', stopReason: 'cancelled' }) - }) - - it('POST resume reopens a paused goal', async () => { - const app = createTestApp(db) - const created = await (await app.request('/session-goals', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: startBody(), - })).json() as { goal: { id: number } } - await app.request(`/session-goals/${created.goal.id}/pause`, { method: 'POST' }) - - const resumeRes = await app.request(`/session-goals/${created.goal.id}/resume`, { method: 'POST' }) - - expect(resumeRes.status).toBe(200) - const resumed = await resumeRes.json() as { goal: { status: string } } - expect(resumed.goal.status).toBe('active') - }) - - it('POST pause of a missing goal returns 404', async () => { - const app = createTestApp(db) - - const res = await app.request('/session-goals/999/pause', { method: 'POST' }) - - expect(res.status).toBe(404) - }) - - it('scopes goals by serverId', async () => { - const app = createTestApp(db) - - const postRes = await app.request('/session-goals', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: startBody({ serverId: 'srv_remote' }), - }) - expect(postRes.status).toBe(201) - const created = await postRes.json() as { goal: { serverId: string } } - expect(created.goal.serverId).toBe('srv_remote') - - const remoteRes = await app.request('/session-goals?sessionId=ses_1&serverId=srv_remote') - await expect(remoteRes.json()).resolves.toMatchObject({ goal: { serverId: 'srv_remote' } }) - - const localRes = await app.request('/session-goals?sessionId=ses_1') - await expect(localRes.json()).resolves.toEqual({ goal: null }) - }) -}) diff --git a/backend/test/routes/settings.test.ts b/backend/test/routes/settings.test.ts index 26db680c7..649c19295 100644 --- a/backend/test/routes/settings.test.ts +++ b/backend/test/routes/settings.test.ts @@ -377,7 +377,7 @@ describe('Settings Routes - OpenCode Upgrade', () => { mockInstallOpenCodeVersion.mockResolvedValue('/home/node/.opencode/bin/opencode') clearOpenCodeVersionCache() vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({ - versions: { '2.1.2': {} }, + versions: { '2.2.1': {} }, time: {}, }), { status: 200 }))) forceProcessAttestation(true) @@ -757,7 +757,7 @@ describe('Settings Routes - OpenCode Upgrade', () => { describe('successful upgrade scenarios', () => { it('should install the latest version and respond with success', async () => { mockGetVersion.mockReturnValueOnce('2.1.1') - mockFetchVersion.mockResolvedValueOnce('2.1.2') + mockFetchVersion.mockResolvedValueOnce('2.2.1') mockInstallOpenCodeVersion.mockResolvedValueOnce('/home/node/.opencode/bin/opencode') const req = new Request('http://localhost/opencode-upgrade', { @@ -770,13 +770,13 @@ describe('Settings Routes - OpenCode Upgrade', () => { expect(json.success).toBe(true) expect(json.upgraded).toBe(true) expect(json.oldVersion).toBe('2.1.1') - expect(json.newVersion).toBe('2.1.2') - expect(json.message).toBe('OpenCode upgraded from v2.1.1 to v2.1.2 and restarted') - expect(mockInstallOpenCodeVersion).toHaveBeenCalledWith('2.1.2') + expect(json.newVersion).toBe('2.2.1') + expect(json.message).toBe('OpenCode upgraded from v2.1.1 to v2.2.1 and restarted') + expect(mockInstallOpenCodeVersion).toHaveBeenCalledWith('2.2.1') }) it('should not install when the latest version is not newer', async () => { - mockGetVersion.mockReturnValueOnce('2.1.2') + mockGetVersion.mockReturnValueOnce('2.2.1') const req = new Request('http://localhost/opencode-upgrade', { method: 'POST' @@ -794,7 +794,7 @@ describe('Settings Routes - OpenCode Upgrade', () => { it('should replace an unsupported newer major with the latest supported release', async () => { mockGetVersion.mockReturnValueOnce('3.0.0') - mockFetchVersion.mockResolvedValueOnce('2.1.2') + mockFetchVersion.mockResolvedValueOnce('2.2.1') mockInstallOpenCodeVersion.mockResolvedValueOnce('/home/node/.opencode/bin/opencode') const res = await settingsApp.fetch(new Request('http://localhost/opencode-upgrade', { method: 'POST' })) @@ -802,12 +802,12 @@ describe('Settings Routes - OpenCode Upgrade', () => { expect(res.status).toBe(200) expect(json.upgraded).toBe(true) - expect(mockInstallOpenCodeVersion).toHaveBeenCalledWith('2.1.2') + expect(mockInstallOpenCodeVersion).toHaveBeenCalledWith('2.2.1') }) it('should restart directly after a successful upgrade', async () => { mockGetVersion.mockReturnValueOnce('2.1.1') - mockFetchVersion.mockResolvedValueOnce('2.1.2') + mockFetchVersion.mockResolvedValueOnce('2.2.1') mockInstallOpenCodeVersion.mockResolvedValueOnce('/home/node/.opencode/bin/opencode') const req = new Request('http://localhost/opencode-upgrade', { @@ -821,13 +821,13 @@ describe('Settings Routes - OpenCode Upgrade', () => { it('allows upgrading while sandbox enforcement is active', async () => { mockIsSandboxEnforced.mockReturnValue(true) mockGetVersion.mockReturnValueOnce('2.1.1') - mockFetchVersion.mockResolvedValueOnce('2.1.2') + mockFetchVersion.mockResolvedValueOnce('2.2.1') mockInstallOpenCodeVersion.mockResolvedValueOnce('/home/node/.opencode/bin/opencode') const res = await settingsApp.fetch(new Request('http://localhost/opencode-upgrade', { method: 'POST' })) expect(res.status).toBe(200) - expect(mockInstallOpenCodeVersion).toHaveBeenCalledWith('2.1.2') + expect(mockInstallOpenCodeVersion).toHaveBeenCalledWith('2.2.1') expect(mockRestart).toHaveBeenCalled() }) }) @@ -896,7 +896,7 @@ describe('Settings Routes - OpenCode Upgrade', () => { describe('version handling', () => { it('should install and restart when getVersion returns null', async () => { mockGetVersion.mockReturnValueOnce(null) - mockFetchVersion.mockResolvedValueOnce('2.1.2') + mockFetchVersion.mockResolvedValueOnce('2.2.1') mockInstallOpenCodeVersion.mockResolvedValueOnce('/home/node/.opencode/bin/opencode') const req = new Request('http://localhost/opencode-upgrade', { @@ -909,8 +909,8 @@ describe('Settings Routes - OpenCode Upgrade', () => { expect(json.success).toBe(true) expect(json.upgraded).toBe(true) expect(json.oldVersion).toBe(null) - expect(json.newVersion).toBe('2.1.2') - expect(json.message).toBe('OpenCode v2.1.2 installed and restarted') + expect(json.newVersion).toBe('2.2.1') + expect(json.message).toBe('OpenCode v2.2.1 installed and restarted') expect(mockFetchVersion).toHaveBeenCalled() expect(mockClearStartupError).toHaveBeenCalled() expect(mockRestart).toHaveBeenCalledTimes(1) @@ -932,7 +932,7 @@ describe('Settings Routes - OpenCode Upgrade', () => { expect(json.upgraded).toBe(false) expect(json.recovered).toBe(true) expect(json.error).toBe('Upgrade failed but server recovered') - expect(json.details).toContain('did not result in version 2.1.2') + expect(json.details).toContain('did not result in version 2.2.1') expect(mockRestart).toHaveBeenCalledTimes(1) }) @@ -949,7 +949,7 @@ describe('Settings Routes - OpenCode Upgrade', () => { expect(json.upgraded).toBe(false) expect(json.recovered).toBe(true) expect(json.error).toBe('Upgrade failed but server recovered') - expect(json.details).toContain('did not result in version 2.1.2') + expect(json.details).toContain('did not result in version 2.2.1') expect(mockRestart).toHaveBeenCalledTimes(1) }) }) @@ -959,12 +959,12 @@ describe('Settings Routes - OpenCode Upgrade', () => { describe('successful installation', () => { it('should install specific version successfully', async () => { mockGetVersion.mockReturnValueOnce('2.1.1') - mockFetchVersion.mockResolvedValueOnce('2.1.2') + mockFetchVersion.mockResolvedValueOnce('2.2.1') mockInstallOpenCodeVersion.mockResolvedValueOnce('/home/node/.opencode/bin/opencode') const req = new Request('http://localhost/opencode-install-version', { method: 'POST', - body: JSON.stringify({ version: '2.1.2' }), + body: JSON.stringify({ version: '2.2.1' }), headers: { 'Content-Type': 'application/json' } }) const res = await settingsApp.fetch(req) @@ -972,8 +972,8 @@ describe('Settings Routes - OpenCode Upgrade', () => { expect(res.status).toBe(200) expect(json.success).toBe(true) - expect(json.newVersion).toBe('2.1.2') - expect(mockInstallOpenCodeVersion).toHaveBeenCalledWith('2.1.2') + expect(json.newVersion).toBe('2.2.1') + expect(mockInstallOpenCodeVersion).toHaveBeenCalledWith('2.2.1') }) it('does not report success when the requested version was not installed', async () => { @@ -983,46 +983,46 @@ describe('Settings Routes - OpenCode Upgrade', () => { const res = await settingsApp.fetch(new Request('http://localhost/opencode-install-version', { method: 'POST', - body: JSON.stringify({ version: '2.1.2' }), + body: JSON.stringify({ version: '2.2.1' }), headers: { 'Content-Type': 'application/json' } })) const json = await res.json() as Record expect(res.status).toBe(400) expect(json.success).toBe(false) - expect(json.details).toContain('did not result in version 2.1.2') + expect(json.details).toContain('did not result in version 2.2.1') expect(json.newVersion).toBe('2.1.1') }) it('allows installing any version while sandbox enforcement is active', async () => { mockIsSandboxEnforced.mockReturnValue(true) mockGetVersion.mockReturnValueOnce('2.1.1') - mockFetchVersion.mockResolvedValueOnce('2.1.2') + mockFetchVersion.mockResolvedValueOnce('2.2.1') mockInstallOpenCodeVersion.mockResolvedValueOnce('/home/node/.opencode/bin/opencode') const res = await settingsApp.fetch(new Request('http://localhost/opencode-install-version', { method: 'POST', - body: JSON.stringify({ version: '2.1.2' }), + body: JSON.stringify({ version: '2.2.1' }), headers: { 'Content-Type': 'application/json' } })) expect(res.status).toBe(200) - expect(mockInstallOpenCodeVersion).toHaveBeenCalledWith('2.1.2') + expect(mockInstallOpenCodeVersion).toHaveBeenCalledWith('2.2.1') }) it('should install the bare version when the request carries a v prefix', async () => { mockGetVersion.mockReturnValueOnce('2.1.1') - mockFetchVersion.mockResolvedValueOnce('2.1.2') + mockFetchVersion.mockResolvedValueOnce('2.2.1') mockInstallOpenCodeVersion.mockResolvedValueOnce('/home/node/.opencode/bin/opencode') const req = new Request('http://localhost/opencode-install-version', { method: 'POST', - body: JSON.stringify({ version: 'v2.1.2' }), + body: JSON.stringify({ version: 'v2.2.1' }), headers: { 'Content-Type': 'application/json' } }) await settingsApp.fetch(req) - expect(mockInstallOpenCodeVersion).toHaveBeenCalledWith('2.1.2') + expect(mockInstallOpenCodeVersion).toHaveBeenCalledWith('2.2.1') }) }) @@ -1035,7 +1035,7 @@ describe('Settings Routes - OpenCode Upgrade', () => { const req = new Request('http://localhost/opencode-install-version', { method: 'POST', - body: JSON.stringify({ version: '2.1.2' }), + body: JSON.stringify({ version: '2.2.1' }), headers: { 'Content-Type': 'application/json' } }) const res = await settingsApp.fetch(req) @@ -1052,7 +1052,7 @@ describe('Settings Routes - OpenCode Upgrade', () => { const req = new Request('http://localhost/opencode-install-version', { method: 'POST', - body: JSON.stringify({ version: '2.1.2' }), + body: JSON.stringify({ version: '2.2.1' }), headers: { 'Content-Type': 'application/json' } }) const res = await settingsApp.fetch(req) @@ -1119,11 +1119,11 @@ describe('Settings Routes - OpenCode Upgrade', () => { const json = await res.json() as Record expect(res.status).toBe(400) - expect(json.error).toBe('OpenCode 1.18.32 is not supported; OpenCode Manager requires OpenCode >=2.1.2 <3.0.0') + expect(json.error).toBe('OpenCode 1.18.32 is not supported; OpenCode Manager requires OpenCode >=2.2.1 <3.0.0') expect(mockInstallOpenCodeVersion).not.toHaveBeenCalled() }) - it.each(['2.1.1', '3.0.0'])('should reject %s outside the supported range before installing anything', async (version) => { + it.each(['2.2.0', '3.0.0'])('should reject %s outside the supported range before installing anything', async (version) => { const res = await settingsApp.fetch(new Request('http://localhost/opencode-install-version', { method: 'POST', body: JSON.stringify({ version }), @@ -1132,14 +1132,14 @@ describe('Settings Routes - OpenCode Upgrade', () => { const json = await res.json() as Record expect(res.status).toBe(400) - expect(json.error).toBe(`OpenCode ${version} is not supported; OpenCode Manager requires OpenCode >=2.1.2 <3.0.0`) + expect(json.error).toBe(`OpenCode ${version} is not supported; OpenCode Manager requires OpenCode >=2.2.1 <3.0.0`) expect(mockInstallOpenCodeVersion).not.toHaveBeenCalled() }) it('should reject a prerelease version before installing anything', async () => { const res = await settingsApp.fetch(new Request('http://localhost/opencode-install-version', { method: 'POST', - body: JSON.stringify({ version: '2.1.2-beta.1' }), + body: JSON.stringify({ version: '2.2.1-beta.1' }), headers: { 'Content-Type': 'application/json' } })) @@ -1176,7 +1176,7 @@ describe('Settings Routes - OpenCode Upgrade', () => { const res = await settingsApp.fetch(new Request('http://localhost/opencode-install-version', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ version: '2.1.2' }), + body: JSON.stringify({ version: '2.2.1' }), })) const json = await res.json() as Record @@ -1953,8 +1953,8 @@ describe('Settings Routes - versions, directory files, skills, MCP and maintenan fetchMock = vi.fn() vi.stubGlobal('fetch', fetchMock) clearOpenCodeVersionCache() - mockGetVersion.mockReturnValue('2.1.2') - mockFetchVersion.mockResolvedValue('2.1.2') + mockGetVersion.mockReturnValue('2.2.1') + mockFetchVersion.mockResolvedValue('2.2.1') mockInstallOpenCodeVersion.mockReset() mockInstallOpenCodeVersion.mockResolvedValue('/home/node/.opencode/bin/opencode') mockClearStartupError.mockReturnValue(undefined) @@ -1977,14 +1977,14 @@ describe('Settings Routes - versions, directory files, skills, MCP and maintenan versions: { '1.18.32': {}, '2.1.1': {}, - '2.1.2': {}, - '2.1.3': {}, - '2.1.2-beta.1': {}, + '2.2.1': {}, + '2.2.2': {}, + '2.2.1-beta.1': {}, '3.0.0': {}, }, time: { - '2.1.2': '2026-01-02T00:00:00Z', - '2.1.3': '2026-01-03T00:00:00Z', + '2.2.1': '2026-01-02T00:00:00Z', + '2.2.2': '2026-01-03T00:00:00Z', }, }), { status: 200 })) @@ -1992,16 +1992,16 @@ describe('Settings Routes - versions, directory files, skills, MCP and maintenan const json = await res.json() as { versions: Array<{ version: string; tag: string; name: string; publishedAt: string | null }>; currentVersion: string } expect(res.status).toBe(200) - expect(json.currentVersion).toBe('2.1.2') + expect(json.currentVersion).toBe('2.2.1') expect(json.versions).toEqual([ - { version: '2.1.3', tag: 'v2.1.3', name: 'v2.1.3', publishedAt: '2026-01-03T00:00:00Z' }, - { version: '2.1.2', tag: 'v2.1.2', name: 'v2.1.2', publishedAt: '2026-01-02T00:00:00Z' }, + { version: '2.2.2', tag: 'v2.2.2', name: 'v2.2.2', publishedAt: '2026-01-03T00:00:00Z' }, + { version: '2.2.1', tag: 'v2.2.1', name: 'v2.2.1', publishedAt: '2026-01-02T00:00:00Z' }, ]) expect(fetchMock).toHaveBeenCalledWith('https://registry.npmjs.org/@opencode/cli') }) it('serves repeated requests from the installer cache', async () => { - fetchMock.mockImplementation(async () => new Response(JSON.stringify({ versions: { '2.1.2': {} }, time: {} }), { status: 200 })) + fetchMock.mockImplementation(async () => new Response(JSON.stringify({ versions: { '2.2.1': {} }, time: {} }), { status: 200 })) await app.request(new Request('http://localhost/opencode-versions')) const res = await app.request(new Request('http://localhost/opencode-versions')) @@ -2039,35 +2039,35 @@ describe('Settings Routes - versions, directory files, skills, MCP and maintenan describe('POST /opencode-install-version', () => { it('installs the requested version and restarts the server', async () => { mockGetVersion.mockReturnValue('2.1.1') - mockFetchVersion.mockResolvedValue('2.1.2') + mockFetchVersion.mockResolvedValue('2.2.1') mockInstallOpenCodeVersion.mockResolvedValueOnce('/home/node/.opencode/bin/opencode') const res = await app.request(new Request('http://localhost/opencode-install-version', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ version: '2.1.2' }), + body: JSON.stringify({ version: '2.2.1' }), })) const json = await res.json() as { success: boolean; message: string; oldVersion: string; newVersion: string } expect(res.status).toBe(200) expect(json.success).toBe(true) expect(json.oldVersion).toBe('2.1.1') - expect(json.newVersion).toBe('2.1.2') - expect(json.message).toContain('changed from v2.1.1 to v2.1.2') + expect(json.newVersion).toBe('2.2.1') + expect(json.message).toContain('changed from v2.1.1 to v2.2.1') expect(supervisor.restart).toHaveBeenCalledTimes(1) - expect(mockInstallOpenCodeVersion).toHaveBeenCalledWith('2.1.2') + expect(mockInstallOpenCodeVersion).toHaveBeenCalledWith('2.2.1') }) it('returns 500 without restarting when the version install fails before the binary swap', async () => { mockGetVersion.mockReturnValue('2.1.1') - mockFetchVersion.mockResolvedValue('2.1.2') + mockFetchVersion.mockResolvedValue('2.2.1') mockInstallOpenCodeVersion.mockRejectedValueOnce(new Error('download failed')) supervisor.restart.mockResolvedValue({ healthy: false }) const res = await app.request(new Request('http://localhost/opencode-install-version', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ version: '2.1.2' }), + body: JSON.stringify({ version: '2.2.1' }), })) const json = await res.json() as { recovered: boolean; newVersion: string } diff --git a/backend/test/services/goal-outcomes.test.ts b/backend/test/services/goal-outcomes.test.ts new file mode 100644 index 000000000..1d7ead0be --- /dev/null +++ b/backend/test/services/goal-outcomes.test.ts @@ -0,0 +1,183 @@ +import { describe, it, expect } from 'vitest' +import { + GOAL_CHANGED_EVENT_TYPE, + type GoalState, +} from '@opencode-manager/shared/opencode' +import { LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas' +import { GoalOutcomeTracker, type SessionGoalOutcome } from '../../src/services/goal-outcomes' +import type { SSEEvent } from '../../src/services/sse-aggregator' + +const SESSION_ID = 'ses_goal' +const EVENT_DIRECTORY = '/abs/repo' +const SERVER_ID = LOCAL_OPENCODE_SERVER_ID + +function goal(overrides: Partial = {}): GoalState { + return { + id: 'goal_1', + objective: 'Ship it', + status: 'active', + rounds: 0, + maxRounds: 5, + boot: 'boot_1', + updatedAt: 1, + ...overrides, + } +} + +function goalEvent(sessionID: string, goalState: GoalState | null): SSEEvent { + return { + id: 'evt_goal', + created: 1700000000000, + type: GOAL_CHANGED_EVENT_TYPE, + location: { directory: EVENT_DIRECTORY }, + data: { sessionID, goal: goalState }, + } as SSEEvent +} + +function sessionDeletedEvent(sessionID: string): SSEEvent { + return { + id: 'evt_deleted', + created: 1700000000000, + type: 'session.deleted', + data: { sessionID }, + } as SSEEvent +} + +describe('GoalOutcomeTracker', () => { + it('reports one outcome when an active goal completes', () => { + const tracker = new GoalOutcomeTracker() + + expect(tracker.observe(SERVER_ID, '/work/repo', goalEvent(SESSION_ID, goal()))).toBeNull() + + const outcome: SessionGoalOutcome | null = tracker.observe( + SERVER_ID, + '/work/repo', + goalEvent(SESSION_ID, goal({ status: 'complete' })), + ) + + expect(outcome).toEqual({ + serverId: SERVER_ID, + sessionId: SESSION_ID, + directory: '/work/repo', + goal: goal({ status: 'complete' }), + }) + }) + + it('does not report the same completed goal twice', () => { + const tracker = new GoalOutcomeTracker() + const completed = goalEvent(SESSION_ID, goal({ status: 'complete' })) + tracker.observe(SERVER_ID, null, goalEvent(SESSION_ID, goal())) + + expect(tracker.observe(SERVER_ID, null, completed)).not.toBeNull() + + expect(tracker.observe(SERVER_ID, null, completed)).toBeNull() + }) + + it('reports no outcome for a goal the user paused or stopped', () => { + const tracker = new GoalOutcomeTracker() + + expect( + tracker.observe(SERVER_ID, null, goalEvent(SESSION_ID, goal({ status: 'paused', reason: 'Paused by the user' }))), + ).toBeNull() + + expect( + tracker.observe(SERVER_ID, null, goalEvent(SESSION_ID, goal({ status: 'paused', reason: 'Stopped by the user' }))), + ).toBeNull() + }) + + it('reports an outcome for a goal the server paused', () => { + const tracker = new GoalOutcomeTracker() + tracker.observe(SERVER_ID, null, goalEvent(SESSION_ID, goal())) + + const outcome = tracker.observe( + SERVER_ID, + null, + goalEvent(SESSION_ID, goal({ status: 'paused', reason: 'The server restarted while the goal was active' })), + ) + + expect(outcome?.goal.status).toBe('paused') + expect(outcome?.goal.reason).toBe('The server restarted while the goal was active') + }) + + it('reports an outcome for a completed goal seen for the first time', () => { + const tracker = new GoalOutcomeTracker() + + const outcome = tracker.observe(SERVER_ID, null, goalEvent(SESSION_ID, goal({ status: 'complete' }))) + + expect(outcome?.goal.id).toBe('goal_1') + expect(outcome?.goal.status).toBe('complete') + }) + + it('reports a different goal that completes after the goal is cleared', () => { + const tracker = new GoalOutcomeTracker() + tracker.observe(SERVER_ID, null, goalEvent(SESSION_ID, goal({ status: 'complete' }))) + + expect(tracker.observe(SERVER_ID, null, goalEvent(SESSION_ID, null))).toBeNull() + + const outcome = tracker.observe(SERVER_ID, null, goalEvent(SESSION_ID, goal({ id: 'goal_2', status: 'complete' }))) + + expect(outcome?.goal.id).toBe('goal_2') + }) + + it('reports the same goal again after the goal is cleared', () => { + const tracker = new GoalOutcomeTracker() + tracker.observe(SERVER_ID, null, goalEvent(SESSION_ID, goal({ status: 'complete' }))) + tracker.observe(SERVER_ID, null, goalEvent(SESSION_ID, null)) + + const outcome = tracker.observe(SERVER_ID, null, goalEvent(SESSION_ID, goal({ status: 'complete' }))) + + expect(outcome?.goal.id).toBe('goal_1') + }) + + it('reports the same goal again after the session is deleted', () => { + const tracker = new GoalOutcomeTracker() + tracker.observe(SERVER_ID, null, goalEvent(SESSION_ID, goal({ status: 'complete' }))) + + expect(tracker.observe(SERVER_ID, null, sessionDeletedEvent(SESSION_ID))).toBeNull() + + const outcome = tracker.observe(SERVER_ID, null, goalEvent(SESSION_ID, goal({ status: 'complete' }))) + + expect(outcome?.goal.id).toBe('goal_1') + }) + + it('tracks the same session separately on each server', () => { + const tracker = new GoalOutcomeTracker() + + tracker.observe('server_a', null, goalEvent(SESSION_ID, goal())) + tracker.observe('server_b', null, goalEvent(SESSION_ID, goal())) + + const outcomeA = tracker.observe('server_a', null, goalEvent(SESSION_ID, goal({ status: 'complete' }))) + const outcomeB = tracker.observe('server_b', null, goalEvent(SESSION_ID, goal({ status: 'complete' }))) + + expect(outcomeA?.serverId).toBe('server_a') + expect(outcomeB?.serverId).toBe('server_b') + }) + + it('ignores non-goal events and goal changes without a session id', () => { + const tracker = new GoalOutcomeTracker() + const idleEvent = { + id: 'evt_idle', + created: 1700000000000, + type: 'session.idle', + data: { sessionID: SESSION_ID }, + } as SSEEvent + const missingSessionId = { + id: 'evt_goal', + created: 1700000000000, + type: GOAL_CHANGED_EVENT_TYPE, + location: { directory: EVENT_DIRECTORY }, + data: { goal: goal({ status: 'complete' }) }, + } as SSEEvent + + expect(tracker.observe(SERVER_ID, null, idleEvent)).toBeNull() + expect(tracker.observe(SERVER_ID, null, missingSessionId)).toBeNull() + }) + + it('falls back to the event location when no directory is passed', () => { + const tracker = new GoalOutcomeTracker() + + const outcome = tracker.observe(SERVER_ID, null, goalEvent(SESSION_ID, goal({ status: 'complete' }))) + + expect(outcome?.directory).toBe(EVENT_DIRECTORY) + }) +}) diff --git a/backend/test/services/notification-format.test.ts b/backend/test/services/notification-format.test.ts index 17cfd95d0..c6f4dcf6f 100644 --- a/backend/test/services/notification-format.test.ts +++ b/backend/test/services/notification-format.test.ts @@ -5,51 +5,23 @@ import { getPermissionDetail, getPermissionAlwaysScope, getFormText, - getGoalOutcomeTitle, - getGoalStopReasonLabel, - getGoalTurnLabel, - getGoalTokenLabel, + getGoalStatusTitle, + getGoalRoundsLabel, } from '@opencode-manager/shared/notifications' import { buildEventNotificationPayload, buildNotificationUrl } from '../../src/services/notification' import { ASSISTANT_REPO_ID } from '@opencode-manager/shared/utils' const ctx = { repoName: 'oc-manager', repoId: 1, sessionId: 'ses_1', directory: '/abs/repo', url: '/repos/1/sessions/ses_1' } -describe('goal notification labels', () => { - it('maps goal stop reasons to human labels', () => { - expect(getGoalStopReasonLabel('cancelled')).toBe('Cancelled') - expect(getGoalStopReasonLabel('user_paused')).toBe('Paused by user') - expect(getGoalStopReasonLabel('continuation_limit')).toBe('Continuation limit reached') - expect(getGoalStopReasonLabel('turn_error')).toBe('Turn failed') - expect(getGoalStopReasonLabel('interrupted')).toBe('Interrupted') - expect(getGoalStopReasonLabel('audit_failed')).toBe('Audit failed') - expect(getGoalStopReasonLabel('session_deleted')).toBe('Session deleted') +describe('OpenCode goal labels', () => { + it('titles each OpenCode goal status', () => { + expect(getGoalStatusTitle('active')).toBe('Goal active') + expect(getGoalStatusTitle('paused')).toBe('Goal paused') + expect(getGoalStatusTitle('complete')).toBe('Goal completed') }) - it('derives the notification title from the status', () => { - expect(getGoalOutcomeTitle('active')).toBe('Goal active') - expect(getGoalOutcomeTitle('paused')).toBe('Goal paused') - expect(getGoalOutcomeTitle('completed')).toBe('Goal completed') - expect(getGoalOutcomeTitle('blocked')).toBe('Goal blocked') - expect(getGoalOutcomeTitle('stopped')).toBe('Goal stopped') - }) - - it('resolves a stop reason label', () => { - expect(getGoalStopReasonLabel('token_budget')).toBe('Token budget reached') - }) -}) - -describe('goal progress labels', () => { - it('formats the turn counter', () => { - expect(getGoalTurnLabel({ continuationCount: 2, maxContinuations: 20 })).toBe('Turn 2/20') - }) - - it('formats the token usage with locale separators', () => { - expect(getGoalTokenLabel({ tokensUsed: 250, tokenBudget: 1000 })).toBe('250/1,000 tokens') - }) - - it('returns null when there is no token budget', () => { - expect(getGoalTokenLabel({ tokensUsed: 250, tokenBudget: null })).toBeNull() + it('formats the round counter', () => { + expect(getGoalRoundsLabel({ rounds: 3, maxRounds: 50 })).toBe('Round 3/50') }) }) diff --git a/backend/test/services/notification-service.test.ts b/backend/test/services/notification-service.test.ts index ff522fb3b..5350851c2 100644 --- a/backend/test/services/notification-service.test.ts +++ b/backend/test/services/notification-service.test.ts @@ -8,7 +8,8 @@ import { NotificationService, type NotificationServerRegistry } from '../../src/ import { SettingsService } from '../../src/services/settings' import { sseAggregator, type SSEEvent } from '../../src/services/sse-aggregator' import type { PushNotificationPayload } from '@opencode-manager/shared/types' -import type { SessionGoal } from '@opencode-manager/shared/schemas' +import type { GoalState } from '@opencode-manager/shared/opencode' +import type { SessionGoalOutcome } from '../../src/services/goal-outcomes' const DIRECTORY = '/abs/repo' const USER_ID = 'user-1' @@ -195,39 +196,36 @@ describe('NotificationService goal outcomes', () => { vi.restoreAllMocks() }) - const completedGoal: SessionGoal = { - id: 1, - sessionId: 'ses_goal', + const completedGoal: GoalState = { + id: 'goal_1', + objective: 'Ship the feature', + status: 'complete', + reason: 'Objective achieved', + rounds: 1, + maxRounds: 5, + boot: 'boot-1', + updatedAt: 3, + } + + const goalOutcome: SessionGoalOutcome = { serverId: 'local', + sessionId: 'ses_goal', directory: DIRECTORY, - objective: 'Ship the feature', - status: 'completed', - stopReason: null, - turnState: 'running', - continuationCount: 1, - maxContinuations: 5, - tokenBudget: null, - tokensUsed: 0, - consecutiveBlocked: 0, - lastVerdict: 'done', - lastReason: 'Objective achieved', - createdAt: 1, - updatedAt: 2, - finishedAt: 3, + goal: completedGoal, } it('notifies a goal outcome when the preference is unset', async () => { const service = createService() const send = vi.spyOn(service, 'sendToUser').mockResolvedValue(sendResult) - await service.notifyGoalOutcome(completedGoal) + await service.notifyGoalOutcome(goalOutcome) expect(send).toHaveBeenCalledTimes(1) const payload = send.mock.calls[0]?.[1] as PushNotificationPayload expect(payload.title).toBe('Goal completed') expect(payload.body).toContain('Ship the feature') expect(payload.body).toContain('Objective achieved') - expect(payload.tag).toBe('session-goal-1') + expect(payload.tag).toBe('session-goal-goal_1') expect(payload.data?.eventType).toBe('session.goal.outcome') expect(payload.data?.sessionId).toBe('ses_goal') expect(payload.data?.url).toBe('/repos/1/sessions/ses_goal') @@ -240,10 +238,10 @@ describe('NotificationService goal outcomes', () => { const send = vi.spyOn(service, 'sendToUser').mockResolvedValue(sendResult) await service.notifyGoalOutcome({ - ...completedGoal, - sessionId: 'ses_remote', serverId: 'srv_kitchen', + sessionId: 'ses_remote', directory: '/home/me/app', + goal: completedGoal, }) expect(send).toHaveBeenCalledTimes(1) @@ -254,59 +252,55 @@ describe('NotificationService goal outcomes', () => { expect(payload.data?.url).toBe('/servers/srv_kitchen/sessions/ses_remote') }) - it('reports the stop reason instead of a stale verdict reason', async () => { + it('titles a paused goal from its reason', async () => { const service = createService() const send = vi.spyOn(service, 'sendToUser').mockResolvedValue(sendResult) await service.notifyGoalOutcome({ - ...completedGoal, - id: 2, - status: 'stopped', - stopReason: 'token_budget', - lastVerdict: 'continue', - lastReason: 'Keep working on the tests', + ...goalOutcome, + goal: { + ...completedGoal, + status: 'paused', + reason: 'The server restarted while the goal was active', + }, }) const payload = send.mock.calls[0]?.[1] as PushNotificationPayload - expect(payload.title).toBe('Goal stopped') - expect(payload.body).toContain('Token budget reached') - expect(payload.body).not.toContain('Keep working on the tests') + expect(payload.title).toBe('Goal paused') + expect(payload.body).toContain('The server restarted while the goal was active') }) - it('states the pause cause when a paused goal has no verdict reason', async () => { + it('keeps the reason visible for a long objective within the body limit', async () => { const service = createService() const send = vi.spyOn(service, 'sendToUser').mockResolvedValue(sendResult) await service.notifyGoalOutcome({ - ...completedGoal, - id: 3, - status: 'paused', - stopReason: 'audit_failed', - lastVerdict: null, - lastReason: null, + ...goalOutcome, + goal: { + ...completedGoal, + objective: 'Ship the entire feature '.repeat(20), + reason: 'Objective achieved', + }, }) const payload = send.mock.calls[0]?.[1] as PushNotificationPayload - expect(payload.title).toBe('Goal paused') - expect(payload.body).toContain('Audit failed') + expect(payload.body.length).toBeLessThanOrEqual(140) + expect(payload.body).toContain('Objective achieved') + expect(payload.body).toContain('…') }) - it('keeps the reason visible for a long objective within the body limit', async () => { + it('omits the reason when the goal has none', async () => { const service = createService() const send = vi.spyOn(service, 'sendToUser').mockResolvedValue(sendResult) await service.notifyGoalOutcome({ - ...completedGoal, - id: 4, - objective: 'Ship the entire feature '.repeat(20), - status: 'stopped', - stopReason: 'continuation_limit', + ...goalOutcome, + goal: { ...completedGoal, reason: undefined }, }) const payload = send.mock.calls[0]?.[1] as PushNotificationPayload - expect(payload.body.length).toBeLessThanOrEqual(140) - expect(payload.body).toContain('Continuation limit reached') - expect(payload.body).toContain('…') + expect(payload.body).toContain('Ship the feature') + expect(payload.body).not.toContain(' — ') }) it('respects a disabled goalOutcome preference', async () => { @@ -323,79 +317,30 @@ describe('NotificationService goal outcomes', () => { ) const send = vi.spyOn(service, 'sendToUser').mockResolvedValue(sendResult) - await service.notifyGoalOutcome(completedGoal) - - expect(send).not.toHaveBeenCalled() - }) - - it('does not notify when the goal stopped because its turn failed', async () => { - const service = createService() - const send = vi.spyOn(service, 'sendToUser').mockResolvedValue(sendResult) - - await service.notifyGoalOutcome({ - ...completedGoal, - id: 11, - status: 'stopped', - stopReason: 'turn_error', - }) + await service.notifyGoalOutcome(goalOutcome) expect(send).not.toHaveBeenCalled() }) - it('does not notify when the user cancelled the goal', async () => { + it('does not notify while the goal session is being viewed', async () => { const service = createService() const send = vi.spyOn(service, 'sendToUser').mockResolvedValue(sendResult) + vi.spyOn(sseAggregator, 'isSessionBeingViewed').mockReturnValue(true) - await service.notifyGoalOutcome({ - ...completedGoal, - id: 12, - status: 'stopped', - stopReason: 'cancelled', - }) + await service.notifyGoalOutcome(goalOutcome) expect(send).not.toHaveBeenCalled() }) - it('does not notify when the user paused the goal', async () => { + it('notifies a complete goal whose rounds reached the maximum', async () => { const service = createService() const send = vi.spyOn(service, 'sendToUser').mockResolvedValue(sendResult) await service.notifyGoalOutcome({ - ...completedGoal, - id: 13, - status: 'paused', - stopReason: 'user_paused', + ...goalOutcome, + goal: { ...completedGoal, rounds: 5, maxRounds: 5 }, }) - expect(send).not.toHaveBeenCalled() - }) - - it('does not notify while the goal session is being viewed', async () => { - const service = createService() - const send = vi.spyOn(service, 'sendToUser').mockResolvedValue(sendResult) - vi.spyOn(sseAggregator, 'isSessionBeingViewed').mockReturnValue(true) - - await service.notifyGoalOutcome(completedGoal) - - expect(send).not.toHaveBeenCalled() - }) - - it('notifies the remaining goal outcomes', async () => { - const service = createService() - const send = vi.spyOn(service, 'sendToUser').mockResolvedValue(sendResult) - const outcomes: Array> = [ - { status: 'blocked', stopReason: null }, - { status: 'stopped', stopReason: 'continuation_limit' }, - { status: 'stopped', stopReason: 'token_budget' }, - { status: 'stopped', stopReason: 'interrupted' }, - { status: 'paused', stopReason: 'audit_failed' }, - { status: 'stopped', stopReason: 'session_deleted' }, - ] - - for (const [index, outcome] of outcomes.entries()) { - await service.notifyGoalOutcome({ ...completedGoal, id: 100 + index, ...outcome }) - } - - expect(send).toHaveBeenCalledTimes(outcomes.length) + expect(send).toHaveBeenCalledTimes(1) }) }) diff --git a/backend/test/services/opencode-installer.test.ts b/backend/test/services/opencode-installer.test.ts index e589eae07..a358f22e7 100644 --- a/backend/test/services/opencode-installer.test.ts +++ b/backend/test/services/opencode-installer.test.ts @@ -131,46 +131,46 @@ describe('resolveOpenCodeInstallTarget', () => { describe('installOpenCodeVersion', () => { it('downloads, verifies and installs the requested version', async () => { const homeDirectory = join(workDirectory, 'home') - const archivePath = createArchive(workDirectory, '2.1.2') + const archivePath = createArchive(workDirectory, '2.2.1') const { calls, fetchFn } = createFetch(archivePath) - const installedPath = await installOpenCodeVersion('2.1.2', { fetch: fetchFn, homeDirectory, target: linuxTarget }) + const installedPath = await installOpenCodeVersion('2.2.1', { fetch: fetchFn, homeDirectory, target: linuxTarget }) expect(installedPath).toBe(resolveOpenCodeBinaryPath(homeDirectory)) - expect(calls).toEqual(['https://opencode.ai/files/bin/2.1.2/opencode-linux-x64.tar.gz']) + expect(calls).toEqual(['https://opencode.ai/files/bin/2.2.1/opencode-linux-x64.tar.gz']) expect(statSync(installedPath).mode & 0o777).toBe(0o755) - expect(readFileSync(installedPath, 'utf-8')).toContain('2.1.2') + expect(readFileSync(installedPath, 'utf-8')).toContain('2.2.1') }) it('requests the musl asset for a musl linux target', async () => { const homeDirectory = join(workDirectory, 'home') - const archivePath = createArchive(workDirectory, '2.1.2') + const archivePath = createArchive(workDirectory, '2.2.1') const { calls, fetchFn } = createFetch(archivePath) - await installOpenCodeVersion('2.1.2', { + await installOpenCodeVersion('2.2.1', { fetch: fetchFn, homeDirectory, target: { platform: 'linux', arch: 'arm64', musl: true }, }) - expect(calls).toEqual(['https://opencode.ai/files/bin/2.1.2/opencode-linux-arm64-musl.tar.gz']) + expect(calls).toEqual(['https://opencode.ai/files/bin/2.2.1/opencode-linux-arm64-musl.tar.gz']) }) it('extracts the darwin zip asset and installs it', async () => { const homeDirectory = join(workDirectory, 'home') - const archivePath = createDarwinArchive(workDirectory, '2.1.2') + const archivePath = createDarwinArchive(workDirectory, '2.2.1') const { calls, fetchFn } = createFetch(archivePath) - const installedPath = await installOpenCodeVersion('2.1.2', { + const installedPath = await installOpenCodeVersion('2.2.1', { fetch: fetchFn, homeDirectory, target: { platform: 'darwin', arch: 'arm64', musl: false }, }) - expect(calls).toEqual(['https://opencode.ai/files/bin/2.1.2/opencode-darwin-arm64.zip']) + expect(calls).toEqual(['https://opencode.ai/files/bin/2.2.1/opencode-darwin-arm64.zip']) expect(installedPath).toBe(resolveOpenCodeBinaryPath(homeDirectory)) expect(statSync(installedPath).mode & 0o777).toBe(0o755) - expect(readFileSync(installedPath, 'utf-8')).toContain('2.1.2') + expect(readFileSync(installedPath, 'utf-8')).toContain('2.2.1') }) it('rejects an OpenCode 1.x version before downloading anything', async () => { @@ -178,18 +178,18 @@ describe('installOpenCodeVersion', () => { const { calls, fetchFn } = createFetch(null) await expect(installOpenCodeVersion('1.18.32', { fetch: fetchFn, homeDirectory, target: linuxTarget })) - .rejects.toThrow('OpenCode 1.18.32 is not supported; OpenCode Manager requires OpenCode >=2.1.2 <3.0.0') + .rejects.toThrow('OpenCode 1.18.32 is not supported; OpenCode Manager requires OpenCode >=2.2.1 <3.0.0') expect(calls).toEqual([]) }) - it.each(['2.1.1', '3.0.0', '2.1.2-beta.1', '2.1.2; rm -rf /'])( + it.each(['2.2.0', '3.0.0', '2.2.1-beta.1', '2.2.1; rm -rf /'])( 'rejects %s outside the supported stable range before downloading anything', async (version) => { const homeDirectory = join(workDirectory, 'home') const { calls, fetchFn } = createFetch(null) await expect(installOpenCodeVersion(version, { fetch: fetchFn, homeDirectory, target: linuxTarget })) - .rejects.toThrow(/requires OpenCode >=2\.1\.2 <3\.0\.0/) + .rejects.toThrow(/requires OpenCode >=2\.2\.1 <3\.0\.0/) expect(calls).toEqual([]) }, ) @@ -198,18 +198,18 @@ describe('installOpenCodeVersion', () => { const homeDirectory = join(workDirectory, 'home') const { fetchFn } = createFetch(null, 404) - await expect(installOpenCodeVersion('2.1.2', { fetch: fetchFn, homeDirectory, target: linuxTarget })) + await expect(installOpenCodeVersion('2.2.1', { fetch: fetchFn, homeDirectory, target: linuxTarget })) .rejects.toThrow(/404/) expect(() => statSync(resolveOpenCodeBinaryPath(homeDirectory))).toThrow() }) it('fails and leaves no binary behind when the archive reports a different version', async () => { const homeDirectory = join(workDirectory, 'home') - const archivePath = createArchive(workDirectory, '2.1.3') + const archivePath = createArchive(workDirectory, '2.2.2') const { fetchFn } = createFetch(archivePath) - await expect(installOpenCodeVersion('2.1.2', { fetch: fetchFn, homeDirectory, target: linuxTarget })) - .rejects.toThrow(/2\.1\.2/) + await expect(installOpenCodeVersion('2.2.1', { fetch: fetchFn, homeDirectory, target: linuxTarget })) + .rejects.toThrow(/2\.2\.1/) expect(() => statSync(resolveOpenCodeBinaryPath(homeDirectory))).toThrow() }) @@ -218,21 +218,21 @@ describe('installOpenCodeVersion', () => { mkdirSync(join(homeDirectory, '.opencode', 'bin'), { recursive: true }) writeFileSync(resolveOpenCodeBinaryPath(homeDirectory), '#!/bin/sh\necho "opencode v2.1.1"\n') - const archivePath = createArchive(workDirectory, '2.1.2') + const archivePath = createArchive(workDirectory, '2.2.1') const { fetchFn } = createFetch(archivePath) - await installOpenCodeVersion('2.1.2', { fetch: fetchFn, homeDirectory, target: linuxTarget }) + await installOpenCodeVersion('2.2.1', { fetch: fetchFn, homeDirectory, target: linuxTarget }) - expect(readFileSync(resolveOpenCodeBinaryPath(homeDirectory), 'utf-8')).toContain('2.1.2') + expect(readFileSync(resolveOpenCodeBinaryPath(homeDirectory), 'utf-8')).toContain('2.2.1') }) it('cleans up its staging directory after a successful install', async () => { const homeDirectory = join(workDirectory, 'home') - const archivePath = createArchive(workDirectory, '2.1.2') + const archivePath = createArchive(workDirectory, '2.2.1') const { fetchFn } = createFetch(archivePath) const before = countStagingDirectories() - await installOpenCodeVersion('2.1.2', { fetch: fetchFn, homeDirectory, target: linuxTarget }) + await installOpenCodeVersion('2.2.1', { fetch: fetchFn, homeDirectory, target: linuxTarget }) expect(countStagingDirectories()).toBe(before) }) @@ -244,15 +244,15 @@ describe('listOpenCodeVersions', () => { '1.18.32': {}, '2.0.14': {}, '2.1.1': {}, - '2.1.2': {}, - '2.1.3': {}, - '2.1.2-beta.1': {}, + '2.2.1': {}, + '2.2.2': {}, + '2.2.1-beta.1': {}, '3.0.0': {}, 'not-a-version': {}, }, time: { - '2.1.2': '2026-02-01T00:00:00.000Z', - '2.1.3': '2026-03-01T00:00:00.000Z', + '2.2.1': '2026-02-01T00:00:00.000Z', + '2.2.2': '2026-03-01T00:00:00.000Z', }, } @@ -262,20 +262,20 @@ describe('listOpenCodeVersions', () => { const versions = await listOpenCodeVersions({ fetch: fetchFn }) expect(versions).toEqual([ - { version: '2.1.3', publishedAt: '2026-03-01T00:00:00.000Z' }, - { version: '2.1.2', publishedAt: '2026-02-01T00:00:00.000Z' }, + { version: '2.2.2', publishedAt: '2026-03-01T00:00:00.000Z' }, + { version: '2.2.1', publishedAt: '2026-02-01T00:00:00.000Z' }, ]) expect(calls).toEqual(['https://registry.npmjs.org/@opencode/cli']) }) it('reports a null publishedAt when the registry has no timestamp for a version', async () => { const { fetchFn } = createRegistryFetch({ - versions: { '2.1.5': {} }, + versions: { '2.2.5': {} }, time: {}, }) await expect(listOpenCodeVersions({ fetch: fetchFn })).resolves.toEqual([ - { version: '2.1.5', publishedAt: null }, + { version: '2.2.5', publishedAt: null }, ]) }) @@ -293,14 +293,14 @@ describe('listOpenCodeVersions', () => { it('refreshes the list once the five minute cache expires', async () => { const stale = createRegistryFetch(registryPayload) - const fresh = createRegistryFetch({ versions: { '2.1.7': {} }, time: {} }) + const fresh = createRegistryFetch({ versions: { '2.2.7': {} }, time: {} }) let now = 1_000_000 await listOpenCodeVersions({ fetch: stale.fetchFn, now: () => now }) now += 5 * 60 * 1000 const refreshed = await listOpenCodeVersions({ fetch: fresh.fetchFn, now: () => now }) - expect(refreshed).toEqual([{ version: '2.1.7', publishedAt: null }]) + expect(refreshed).toEqual([{ version: '2.2.7', publishedAt: null }]) expect(stale.calls).toHaveLength(1) expect(fresh.calls).toHaveLength(1) }) @@ -331,7 +331,7 @@ describe('OpenCodeInstallError swap signalling', () => { const homeDirectory = join(workDirectory, 'home') const { fetchFn } = createFetch(null, 404) - const promise = installOpenCodeVersion('2.1.2', { fetch: fetchFn, homeDirectory, target: linuxTarget }) + const promise = installOpenCodeVersion('2.2.1', { fetch: fetchFn, homeDirectory, target: linuxTarget }) await expect(promise).rejects.toBeInstanceOf(OpenCodeInstallError) await expect(promise).rejects.toMatchObject({ swapStarted: false }) @@ -341,10 +341,10 @@ describe('OpenCodeInstallError swap signalling', () => { it('marks a failure while replacing the binary as having started the binary swap', async () => { const homeDirectory = join(workDirectory, 'home') mkdirSync(resolveOpenCodeBinaryPath(homeDirectory), { recursive: true }) - const archivePath = createArchive(workDirectory, '2.1.2') + const archivePath = createArchive(workDirectory, '2.2.1') const { fetchFn } = createFetch(archivePath) - const promise = installOpenCodeVersion('2.1.2', { fetch: fetchFn, homeDirectory, target: linuxTarget }) + const promise = installOpenCodeVersion('2.2.1', { fetch: fetchFn, homeDirectory, target: linuxTarget }) await expect(promise).rejects.toBeInstanceOf(OpenCodeInstallError) await expect(promise).rejects.toMatchObject({ swapStarted: true }) diff --git a/backend/test/services/opencode-manager-tool-plugin.test.ts b/backend/test/services/opencode-manager-tool-plugin.test.ts index c211573e5..6e52a9e96 100644 --- a/backend/test/services/opencode-manager-tool-plugin.test.ts +++ b/backend/test/services/opencode-manager-tool-plugin.test.ts @@ -662,10 +662,6 @@ describe('ocm-manager plugin', () => { ['DELETE', '/settings'], ['PUT', '/opencode-config'], ['DELETE', '/sessions/abc'], - ['GET', '/session-goals'], - ['POST', '/session-goals'], - ['POST', '/session-goals/1/pause'], - ['POST', '/session-goals/1/cancel'], ['GET', '/multi-runs'], ['POST', '/multi-runs'], ['POST', '/multi-runs/1/fusions'], diff --git a/backend/test/services/opencode-single-server.test.ts b/backend/test/services/opencode-single-server.test.ts index d09288fc2..39808c733 100644 --- a/backend/test/services/opencode-single-server.test.ts +++ b/backend/test/services/opencode-single-server.test.ts @@ -3,7 +3,7 @@ import { describe, it, expect, vi, beforeEach, afterEach, beforeAll } from 'vite const createOpenCodeClientMock = vi.hoisted(() => vi.fn(() => ({ api: { server: { - info: vi.fn().mockResolvedValue({ version: '2.1.2', pid: 1234, urls: ['http://127.0.0.1:5551'], paths: { tmp: '/tmp' } }), + info: vi.fn().mockResolvedValue({ version: '2.2.1', pid: 1234, urls: ['http://127.0.0.1:5551'], paths: { tmp: '/tmp' } }), }, }, forwardRaw: vi.fn(), @@ -354,7 +354,7 @@ describe('OpenCodeServerManager - server auth', () => { })) execSyncMock.mockImplementation((cmd: string) => { if (cmd.includes('lsof')) return spawnMock.mock.calls.length > 0 ? '1234\n' : '' - if (cmd.includes('opencode --version')) return '2.1.2\n' + if (cmd.includes('opencode --version')) return '2.2.1\n' throw new Error('not found') }) setOpenCodeEnv({ host: '0.0.0.0', password: 'envpassword123' }) @@ -382,7 +382,7 @@ describe('OpenCodeServerManager - server auth', () => { })) execSyncMock.mockImplementation((cmd: string) => { if (cmd.includes('lsof')) return spawnMock.mock.calls.length > 0 ? '1234\n' : '' - if (cmd.includes('opencode --version')) return '2.1.2\n' + if (cmd.includes('opencode --version')) return '2.2.1\n' throw new Error('not found') }) setOpenCodeEnv({ host: '0.0.0.0', password: '' }) @@ -420,7 +420,7 @@ describe('OpenCodeServerManager - server auth', () => { })) execSyncMock.mockImplementation((cmd: string) => { if (cmd.includes('lsof')) return spawnMock.mock.calls.length > 0 ? '1234\n' : '' - if (cmd.includes('opencode --version')) return '2.1.2\n' + if (cmd.includes('opencode --version')) return '2.2.1\n' throw new Error('not found') }) const { OpenCodeServerManager } = await import('../../src/services/opencode-single-server') @@ -539,7 +539,7 @@ describe('OpenCodeServerManager - server auth', () => { })) execSyncMock.mockImplementation((cmd: string) => { if (cmd.includes('lsof')) return spawnMock.mock.calls.length > 0 ? '1234\n' : '' - if (cmd.includes('opencode --version')) return '2.1.2\n' + if (cmd.includes('opencode --version')) return '2.2.1\n' throw new Error('not found') }) const savedEnv = snapshotMicrosandboxEnv() @@ -782,7 +782,7 @@ describe('OpenCodeServerManager - server auth', () => { let capturedSignal: AbortSignal | undefined const info = vi.fn(async (options: { signal?: AbortSignal }) => { capturedSignal = options.signal - return { version: '2.1.2', pid: 1234, urls: ['http://127.0.0.1:5551'], paths: { tmp: '/tmp' } } + return { version: '2.2.1', pid: 1234, urls: ['http://127.0.0.1:5551'], paths: { tmp: '/tmp' } } }) createOpenCodeClientMock.mockImplementationOnce(() => ({ api: { server: { info } }, @@ -812,7 +812,7 @@ describe('OpenCodeServerManager - server auth', () => { capturedSignal = options.signal queueMicrotask(() => emitExit(1, null)) return new Promise((resolve) => { - setTimeout(() => resolve({ version: '2.1.2', pid: 1234, urls: [], paths: { tmp: '/tmp' } }), 0) + setTimeout(() => resolve({ version: '2.2.1', pid: 1234, urls: [], paths: { tmp: '/tmp' } }), 0) }) }) createOpenCodeClientMock.mockImplementationOnce(() => ({ @@ -861,7 +861,7 @@ describe('OpenCodeServerManager - server auth', () => { })) execSyncMock.mockImplementation((cmd: string) => { if (cmd.includes('lsof')) return spawnMock.mock.calls.length > 0 ? '1234\n' : '' - if (cmd.includes('opencode --version')) return '2.1.2\n' + if (cmd.includes('opencode --version')) return '2.2.1\n' throw new Error('not found') }) const { OpenCodeServerManager } = await import('../../src/services/opencode-single-server') @@ -949,7 +949,7 @@ describe('OpenCodeServerManager - server auth', () => { })) execSyncMock.mockImplementation((cmd: string) => { if (cmd.includes('lsof')) return spawnMock.mock.calls.length > 0 ? '1234\n' : '' - if (cmd.includes('opencode --version')) return '2.1.2\n' + if (cmd.includes('opencode --version')) return '2.2.1\n' throw new Error('not found') }) const { OpenCodeServerManager } = await import('../../src/services/opencode-single-server') @@ -1035,7 +1035,7 @@ describe('OpenCodeServerManager - server auth', () => { })) execSyncMock.mockImplementation((cmd: string) => { if (cmd.includes('lsof')) return spawnMock.mock.calls.length > 0 ? '1234\n' : '' - if (cmd.includes('opencode --version')) return '2.1.2\n' + if (cmd.includes('opencode --version')) return '2.2.1\n' throw new Error('not found') }) manager.setDatabase(createPasswordDb(null)) @@ -1148,7 +1148,7 @@ describe('OpenCodeServerManager - server auth', () => { })) execSyncMock.mockImplementation((cmd: string) => { if (cmd.includes('lsof')) return spawnMock.mock.calls.length > 0 ? '1234\n' : '' - if (cmd.includes('opencode --version')) return '2.1.2\n' + if (cmd.includes('opencode --version')) return '2.2.1\n' throw new Error('not found') }) const { OpenCodeServerManager } = await import('../../src/services/opencode-single-server') @@ -1198,7 +1198,7 @@ describe('OpenCodeServerManager - server auth', () => { if (cmd.includes('lsof')) { return spawnMock.mock.calls.length > 0 ? '1234\n' : '9999\n' } - if (cmd.includes('opencode --version')) return '2.1.2\n' + if (cmd.includes('opencode --version')) return '2.2.1\n' throw new Error('not found') }) const { OpenCodeServerManager } = await import('../../src/services/opencode-single-server') @@ -1945,7 +1945,7 @@ describe('OpenCodeServerManager - server auth', () => { if (cmd.includes('lsof')) { return spawnMock.mock.calls.length > 0 ? '1234\n' : '9999\n' } - if (cmd.includes('opencode --version')) return '2.1.2\n' + if (cmd.includes('opencode --version')) return '2.2.1\n' throw new Error('not found') }) readFileSyncMock.mockReturnValue(procStatStringWithGroup(9999, '42')) @@ -2002,7 +2002,7 @@ describe('OpenCodeServerManager - server auth', () => { })) execSyncMock.mockImplementation((cmd: string) => { if (cmd.includes('lsof')) return spawnMock.mock.calls.length > 0 ? '1234\n' : '' - if (cmd.includes('opencode --version')) return '2.1.2\n' + if (cmd.includes('opencode --version')) return '2.2.1\n' throw new Error('not found') }) readdirSyncMock.mockReturnValue(['10001']) @@ -2081,7 +2081,7 @@ describe('OpenCodeServerManager - server auth', () => { })) execSyncMock.mockImplementation((cmd: string) => { if (cmd.includes('lsof')) return '' - if (cmd.includes('opencode --version')) return '2.1.2\n' + if (cmd.includes('opencode --version')) return '2.2.1\n' throw new Error('not found') }) readdirSyncMock.mockReturnValue(['10001']) @@ -2144,7 +2144,7 @@ describe('OpenCodeServerManager - server auth', () => { })) execSyncMock.mockImplementation((cmd: string) => { if (cmd.includes('lsof')) return spawnMock.mock.calls.length > 0 ? '1234\n' : '' - if (cmd.includes('opencode --version')) return '2.1.2\n' + if (cmd.includes('opencode --version')) return '2.2.1\n' throw new Error('not found') }) readFileSyncMock.mockImplementation((filePath: string) => { @@ -2200,7 +2200,7 @@ describe('OpenCodeServerManager - server auth', () => { })) execSyncMock.mockImplementation((cmd: string) => { if (cmd.includes('lsof')) return '9999\n' - if (cmd.includes('opencode --version')) return '2.1.2\n' + if (cmd.includes('opencode --version')) return '2.2.1\n' throw new Error('not found') }) readFileSyncMock.mockReturnValue(procStatStringWithGroup(9999, '42')) @@ -2242,7 +2242,7 @@ describe('OpenCodeServerManager - server auth', () => { })) execSyncMock.mockImplementation((cmd: string) => { if (cmd.includes('lsof')) return '9998\n' - if (cmd.includes('opencode --version')) return '2.1.2\n' + if (cmd.includes('opencode --version')) return '2.2.1\n' throw new Error('not found') }) killSpy.mockImplementation(((pid: number, signal?: number | string) => { @@ -2279,7 +2279,7 @@ describe('OpenCodeServerManager - server auth', () => { })) execSyncMock.mockImplementation((cmd: string) => { if (cmd.includes('lsof')) return '9997\n' - if (cmd.includes('opencode --version')) return '2.1.2\n' + if (cmd.includes('opencode --version')) return '2.2.1\n' throw new Error('not found') }) killSpy.mockImplementation(((pid: number, signal?: number | string) => { @@ -2311,7 +2311,7 @@ describe('OpenCodeServerManager - server auth', () => { })) execSyncMock.mockImplementation((cmd: string) => { if (cmd.includes('lsof')) return spawnMock.mock.calls.length > 0 ? '8888\n' : '' - if (cmd.includes('opencode --version')) return '2.1.2\n' + if (cmd.includes('opencode --version')) return '2.2.1\n' throw new Error('not found') }) killSpy.mockImplementation(((pid: number, signal?: number | string) => { @@ -2986,7 +2986,7 @@ describe('OpenCodeServerManager - server auth', () => { })) execSyncMock.mockImplementation((cmd: string) => { if (cmd.includes('lsof')) return '' - if (cmd.includes('opencode --version')) return '2.1.2\n' + if (cmd.includes('opencode --version')) return '2.2.1\n' throw new Error('not found') }) installManagedPluginsMock.mockRejectedValueOnce(new Error('readonly filesystem')) @@ -3018,7 +3018,7 @@ describe('OpenCodeServerManager - server auth', () => { })) execSyncMock.mockImplementation((cmd: string) => { if (cmd.includes('lsof')) return spawnMock.mock.calls.length > 0 ? '1234\n' : '' - if (cmd.includes('opencode --version')) return '2.1.2\n' + if (cmd.includes('opencode --version')) return '2.2.1\n' throw new Error('not found') }) const { OpenCodeServerManager } = await import('../../src/services/opencode-single-server') @@ -3049,7 +3049,7 @@ describe('OpenCodeServerManager - server auth', () => { })) execSyncMock.mockImplementation((cmd: string) => { if (cmd.includes('lsof')) return spawnMock.mock.calls.length > 0 ? '1234\n' : '' - if (cmd.includes('opencode --version')) return '2.1.2\n' + if (cmd.includes('opencode --version')) return '2.2.1\n' throw new Error('not found') }) const { OpenCodeServerManager } = await import('../../src/services/opencode-single-server') @@ -3077,7 +3077,7 @@ describe('OpenCodeServerManager - server auth', () => { })) execSyncMock.mockImplementation((cmd: string) => { if (cmd.includes('lsof')) return spawnMock.mock.calls.length > 0 ? '1234\n' : '' - if (cmd.includes('opencode --version')) return '2.1.2\n' + if (cmd.includes('opencode --version')) return '2.2.1\n' throw new Error('not found') }) restoreQuarantinedOpenCodePluginsMock.mockRejectedValueOnce(new Error('readonly filesystem')) @@ -3108,7 +3108,7 @@ describe('OpenCodeServerManager - server auth', () => { })) execSyncMock.mockImplementation((cmd: string) => { if (cmd.includes('lsof')) return '' - if (cmd.includes('opencode --version')) return '2.1.2\n' + if (cmd.includes('opencode --version')) return '2.2.1\n' throw new Error('not found') }) installManagedPluginsMock.mockRejectedValueOnce(new Error('readonly filesystem')) @@ -3140,7 +3140,7 @@ describe('OpenCodeServerManager - server auth', () => { })) execSyncMock.mockImplementation((cmd: string) => { if (cmd.includes('lsof')) return spawnMock.mock.calls.length > 0 ? '1234\n' : '' - if (cmd.includes('opencode --version')) return '2.1.2\n' + if (cmd.includes('opencode --version')) return '2.2.1\n' throw new Error('not found') }) const { OpenCodeServerManager } = await import('../../src/services/opencode-single-server') @@ -3176,7 +3176,7 @@ describe('OpenCodeServerManager - server auth', () => { await expect(manager.start()).rejects.toThrow('OpenCode 1.18.32 is not supported') expect(spawnMock).not.toHaveBeenCalled() - expect(manager.getLastStartupError()).toContain('requires OpenCode >=2.1.2 <3.0.0') + expect(manager.getLastStartupError()).toContain('requires OpenCode >=2.2.1 <3.0.0') expect(manager.isLastStartupErrorNonRecoverable()).toBe(true) }) @@ -3185,7 +3185,7 @@ describe('OpenCodeServerManager - server auth', () => { isEnabled: () => false, })) execSyncMock.mockImplementation((cmd: string) => { - if (cmd.includes('opencode --version')) return 'opencode v2.1.2\n' + if (cmd.includes('opencode --version')) return 'opencode v2.2.1\n' throw new Error('not found') }) const { OpenCodeServerManager } = await import('../../src/services/opencode-single-server') @@ -3195,7 +3195,7 @@ describe('OpenCodeServerManager - server auth', () => { await manager.start() expect(spawnMock).toHaveBeenCalled() - expect(manager.getVersion()).toBe('2.1.2') + expect(manager.getVersion()).toBe('2.2.1') expect(manager.isVersionSupported()).toBe(true) }) @@ -3216,7 +3216,7 @@ describe('OpenCodeServerManager - server auth', () => { marked = true manager.markRestartPending() } - return { version: '2.1.2', pid: 1234, urls: [], paths: { tmp: '/tmp' } } + return { version: '2.2.1', pid: 1234, urls: [], paths: { tmp: '/tmp' } } }), }, }, @@ -3420,7 +3420,7 @@ describe('ConfigReloadError', () => { }) describe('OpenCodeServerManager - checkHealth', () => { - const serverInfo = { version: '2.1.2', pid: 1234, urls: ['http://127.0.0.1:5551'], paths: { tmp: '/tmp' } } + const serverInfo = { version: '2.2.1', pid: 1234, urls: ['http://127.0.0.1:5551'], paths: { tmp: '/tmp' } } it('probes GET /api/info through the API client and returns true for a ServerInfo response', async () => { const { opencodeServerManager } = await import('../../src/services/opencode-single-server') diff --git a/backend/test/services/session-goal-audit.test.ts b/backend/test/services/session-goal-audit.test.ts deleted file mode 100644 index a8d537297..000000000 --- a/backend/test/services/session-goal-audit.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { - buildGoalAuditPrompt, - buildGoalContinuationPrompt, - GOAL_AUDIT_REASON_MAX_CHARS, - parseGoalVerdict, -} from '../../src/services/session-goal-audit' -import { SESSION_REPLY_MAX_LENGTH } from '../../src/services/session-reply' - -describe('parseGoalVerdict', () => { - it('parses a plain JSON verdict', () => { - expect(parseGoalVerdict('{"verdict":"done","reason":"The feature shipped."}')).toEqual({ - verdict: 'done', - reason: 'The feature shipped.', - }) - }) - - it('parses a fenced JSON verdict with surrounding prose', () => { - const text = [ - 'Here is my decision:', - '```json', - '{"verdict":"blocked","reason":"Needs a database decision."}', - '```', - 'Let me know.', - ].join('\n') - - expect(parseGoalVerdict(text)).toEqual({ - verdict: 'blocked', - reason: 'Needs a database decision.', - }) - }) - - it('returns null for invalid JSON', () => { - expect(parseGoalVerdict('not json at all')).toBeNull() - expect(parseGoalVerdict('{"verdict":')).toBeNull() - }) - - it('returns null for an unknown verdict', () => { - expect(parseGoalVerdict('{"verdict":"maybe","reason":"unsure"}')).toBeNull() - }) - - it('returns null for a missing or empty reason', () => { - expect(parseGoalVerdict('{"verdict":"done"}')).toBeNull() - expect(parseGoalVerdict('{"verdict":"done","reason":" "}')).toBeNull() - }) - - it('caps the reason length', () => { - const longReason = 'a'.repeat(GOAL_AUDIT_REASON_MAX_CHARS + 50) - const parsed = parseGoalVerdict(`{"verdict":"continue","reason":"${longReason}"}`) - - expect(parsed?.verdict).toBe('continue') - expect(parsed?.reason).toHaveLength(GOAL_AUDIT_REASON_MAX_CHARS) - }) -}) - -describe('buildGoalAuditPrompt', () => { - it('includes the objective and reply and asks for JSON only', () => { - const prompt = buildGoalAuditPrompt({ objective: 'Ship the feature', reply: 'I updated the route.' }) - - expect(prompt).toContain('Ship the feature') - expect(prompt).toContain('I updated the route.') - expect(prompt).toContain('{"verdict":"continue"|"done"|"blocked","reason":""}') - }) - - it('marks a missing reply', () => { - expect(buildGoalAuditPrompt({ objective: 'Ship it', reply: null })).toContain('(no reply yet)') - }) - - it('truncates a long reply with a marker', () => { - const reply = 'x'.repeat(SESSION_REPLY_MAX_LENGTH + 100) - const prompt = buildGoalAuditPrompt({ objective: 'Ship it', reply }) - - expect(prompt).toContain('[reply truncated]') - expect(prompt).not.toContain('x'.repeat(SESSION_REPLY_MAX_LENGTH + 1)) - }) -}) - -describe('buildGoalContinuationPrompt', () => { - it('includes the objective and the progress reason', () => { - const prompt = buildGoalContinuationPrompt({ objective: 'Ship the feature', reason: 'Tests still fail.' }) - - expect(prompt).toContain('Ship the feature') - expect(prompt).toContain('Tests still fail.') - expect(prompt).toContain('state exactly what you need') - }) -}) diff --git a/backend/test/services/session-goals.test.ts b/backend/test/services/session-goals.test.ts deleted file mode 100644 index 22b12262d..000000000 --- a/backend/test/services/session-goals.test.ts +++ /dev/null @@ -1,1256 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' -import { Database } from 'bun:sqlite' -import { migrate } from '../../src/db/migration-runner' -import { allMigrations } from '../../src/db/migrations' -import { getSessionGoalById, listOpenSessionGoals, transitionSessionGoal } from '../../src/db/session-goals' -import { SessionGoalService, type SessionGoalServiceOptions } from '../../src/services/session-goals' -import { SettingsService } from '../../src/services/settings' -import type { SSEEvent } from '../../src/services/sse-aggregator' -import { - createFakeSessionGoalClient, - fakeAssistantMessage, - type FakeSessionGoalClient, - type FakeSessionGoalClientOptions, -} from '../helpers/fake-session-goal-client' - -const DIRECTORY = '/abs/repo' - -function createTestDb(): Database { - const db = new Database(':memory:') - migrate(db, allMigrations) - return db -} - -function createService( - db: Database, - options: FakeSessionGoalClientOptions = {}, - settingsService: SettingsService = new SettingsService(db), - serviceOptions: SessionGoalServiceOptions = {}, -): SessionGoalService { - return new SessionGoalService(db, createFakeSessionGoalClient(options).client, settingsService, serviceOptions) -} - -function executionStarted(sessionID: string): SSEEvent { - return { - id: `evt_started_${sessionID}`, - created: 0, - type: 'session.execution.started', - location: { directory: DIRECTORY }, - data: { sessionID }, - } as unknown as SSEEvent -} - -function executionSucceeded(sessionID: string): SSEEvent { - return { - id: `evt_succeeded_${sessionID}`, - created: 0, - type: 'session.execution.succeeded', - location: { directory: DIRECTORY }, - data: { sessionID }, - } as unknown as SSEEvent -} - -function sessionIdle(sessionID: string): SSEEvent { - return { - id: `evt_idle_${sessionID}`, - created: 0, - type: 'session.idle', - location: { directory: DIRECTORY }, - data: { sessionID }, - } as unknown as SSEEvent -} - -function sessionStatusIdle(sessionID: string): SSEEvent { - return { - id: `evt_status_${sessionID}`, - created: 0, - type: 'session.status', - location: { directory: DIRECTORY }, - data: { sessionID, status: { type: 'idle' } }, - } as unknown as SSEEvent -} - -function executionFailed(sessionID: string): SSEEvent { - return { - id: `evt_failed_${sessionID}`, - created: 0, - type: 'session.execution.failed', - location: { directory: DIRECTORY }, - data: { sessionID, error: { type: 'unknown', message: 'boom' } }, - } as unknown as SSEEvent -} - -function executionInterrupted(sessionID: string): SSEEvent { - return { - id: `evt_interrupted_${sessionID}`, - created: 0, - type: 'session.execution.interrupted', - location: { directory: DIRECTORY }, - data: { sessionID, reason: 'user' }, - } as unknown as SSEEvent -} - -function sessionDeleted(sessionID: string): SSEEvent { - return { - id: `evt_deleted_${sessionID}`, - created: 0, - type: 'session.deleted', - location: { directory: DIRECTORY }, - data: { sessionID }, - } as unknown as SSEEvent -} - -describe('SessionGoalService', () => { - let db: Database - - beforeEach(() => { - vi.useFakeTimers() - db = createTestDb() - }) - - afterEach(() => { - vi.useRealTimers() - db.close() - }) - - it('starts an active goal with the settings defaults and the current token count', async () => { - const settingsService = new SettingsService(db) - settingsService.updateSettings({ - sessionDefaults: { - permissionMode: 'ask', - goalMaxContinuations: 7, - goalTokenBudget: 1234, - goalAuditorModel: 'provider/model', - }, - }) - const service = createService(db, { tokens: { ses_1: { input: 100, output: 50, reasoning: 10 } } }, settingsService) - - const goal = await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship the feature' }) - - expect(goal).toMatchObject({ - sessionId: 'ses_1', - directory: DIRECTORY, - objective: 'Ship the feature', - status: 'active', - stopReason: null, - turnState: 'waiting', - continuationCount: 0, - maxContinuations: 7, - tokenBudget: 1234, - tokensUsed: 0, - consecutiveBlocked: 0, - lastVerdict: null, - lastReason: null, - finishedAt: null, - }) - expect(getSessionGoalById(db, goal.id)?.tokensAtStart).toBe(160) - }) - - it('lets explicit input override the settings defaults', async () => { - const settingsService = new SettingsService(db) - settingsService.updateSettings({ - sessionDefaults: { permissionMode: 'ask', goalMaxContinuations: 7, goalTokenBudget: 1234 }, - }) - const service = createService(db, {}, settingsService) - - const goal = await service.start({ - sessionId: 'ses_1', - directory: DIRECTORY, - objective: 'Ship the feature', - maxContinuations: 3, - tokenBudget: 99, - }) - - expect(goal.maxContinuations).toBe(3) - expect(goal.tokenBudget).toBe(99) - }) - - it('falls back to the default max continuations when settings do not set it', async () => { - const settingsService = new SettingsService(db) - settingsService.updateSettings({ sessionDefaults: { permissionMode: 'ask' } }) - const service = createService(db, {}, settingsService) - - const goal = await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) - - expect(goal.maxContinuations).toBe(20) - expect(goal.tokenBudget).toBeNull() - }) - - it('uses zero tokens when the session lookup fails', async () => { - const service = createService(db, { failSessionGet: true }) - - const goal = await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) - - expect(getSessionGoalById(db, goal.id)?.tokensAtStart).toBe(0) - }) - - it('rejects a second open goal for the same session with 409', async () => { - const service = createService(db) - - await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'First' }) - - await expect( - service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Second' }), - ).rejects.toMatchObject({ status: 409 }) - expect(listOpenSessionGoals(db)).toHaveLength(1) - }) - - it('rejects a goal on a scheduled run session with 409 and creates no goal', async () => { - const service = createService(db, {}, new SettingsService(db), { - resolveSessionLock: async () => 'schedule', - }) - - await expect( - service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }), - ).rejects.toMatchObject({ status: 409, message: 'Scheduled runs cannot run goals' }) - expect(listOpenSessionGoals(db)).toHaveLength(0) - }) - - it('rejects a goal on a child session with 400 and creates no goal', async () => { - const service = createService(db, {}, new SettingsService(db), { - resolveSessionLock: async () => 'child', - }) - - await expect( - service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }), - ).rejects.toMatchObject({ status: 400, message: 'Goals can only be started on top-level sessions' }) - expect(listOpenSessionGoals(db)).toHaveLength(0) - }) - - it('starts a goal when the session is not locked', async () => { - const service = createService(db, {}, new SettingsService(db), { - resolveSessionLock: async () => null, - }) - - const goal = await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) - - expect(goal.status).toBe('active') - expect(listOpenSessionGoals(db)).toHaveLength(1) - }) - - it('starts a goal when the session lock lookup fails', async () => { - const service = createService(db, {}, new SettingsService(db), { - resolveSessionLock: async () => { - throw new Error('lock lookup failed') - }, - }) - - const goal = await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) - - expect(goal.status).toBe('active') - }) - - it('allows a new goal once the previous one is cancelled', async () => { - const service = createService(db) - const first = await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'First' }) - - service.cancel(first.id) - const second = await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Second' }) - - expect(second.status).toBe('active') - expect(listOpenSessionGoals(db)).toHaveLength(1) - }) - - it('pauses and resumes a goal', async () => { - const service = createService(db) - const started = await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) - - const paused = service.pause(started.id) - expect(paused.status).toBe('paused') - expect(paused.stopReason).toBe('user_paused') - - const resumed = service.resume(started.id) - expect(resumed.status).toBe('active') - expect(resumed.stopReason).toBeNull() - expect(resumed.turnState).toBe('running') - }) - - it('cancels a paused goal and rejects cancelling a finished goal', async () => { - const service = createService(db) - const started = await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) - service.pause(started.id) - - const cancelled = service.cancel(started.id) - expect(cancelled.status).toBe('stopped') - expect(cancelled.stopReason).toBe('cancelled') - expect(cancelled.finishedAt).not.toBeNull() - - expect(() => service.cancel(started.id)).toThrowError( - expect.objectContaining({ status: 409 }) as Error, - ) - }) - - it('rejects pausing a missing goal with 404', async () => { - const service = createService(db) - - expect(() => service.pause(999)).toThrowError(expect.objectContaining({ status: 404 }) as Error) - }) - - it('emits exactly one outcome when pausing an active goal', async () => { - const outcomes: string[] = [] - const service = createService(db, {}, new SettingsService(db), { - onOutcome: (goal) => outcomes.push(goal.status), - }) - const started = await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) - - const paused = service.pause(started.id) - - expect(paused.status).toBe('paused') - expect(outcomes).toEqual(['paused']) - }) - - it('emits exactly one outcome when cancelling an active goal', async () => { - const outcomes: string[] = [] - const service = createService(db, {}, new SettingsService(db), { - onOutcome: (goal) => outcomes.push(goal.status), - }) - const started = await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) - - const cancelled = service.cancel(started.id) - - expect(cancelled.status).toBe('stopped') - expect(outcomes).toEqual(['stopped']) - }) - - it('emits exactly one outcome when cancelling a paused goal', async () => { - const outcomes: string[] = [] - const service = createService(db, {}, new SettingsService(db), { - onOutcome: (goal) => outcomes.push(goal.status), - }) - const started = await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) - service.pause(started.id) - outcomes.length = 0 - - service.cancel(started.id) - - expect(outcomes).toEqual(['stopped']) - }) - - it('does not emit an outcome when a pause transition is rejected', async () => { - const outcomes: string[] = [] - const service = createService(db, {}, new SettingsService(db), { - onOutcome: (goal) => outcomes.push(goal.status), - }) - const started = await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) - service.pause(started.id) - outcomes.length = 0 - - expect(() => service.pause(started.id)).toThrowError(expect.objectContaining({ status: 409 }) as Error) - - expect(outcomes).toEqual([]) - }) - - it('does not emit an outcome when a cancel transition is rejected', async () => { - const outcomes: string[] = [] - const service = createService(db, {}, new SettingsService(db), { - onOutcome: (goal) => outcomes.push(goal.status), - }) - const started = await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) - service.cancel(started.id) - outcomes.length = 0 - - expect(() => service.cancel(started.id)).toThrowError(expect.objectContaining({ status: 409 }) as Error) - - expect(outcomes).toEqual([]) - }) - - it('does not emit an outcome when resuming a paused goal', async () => { - const outcomes: string[] = [] - const service = createService(db, {}, new SettingsService(db), { - onOutcome: (goal) => outcomes.push(goal.status), - }) - const started = await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) - service.pause(started.id) - outcomes.length = 0 - - service.resume(started.id) - await vi.runAllTimersAsync() - - expect(outcomes).toEqual([]) - }) - - it('returns the latest goal for a session', async () => { - const service = createService(db) - - expect(service.getLatest('ses_1')).toBeNull() - - const started = await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) - - expect(service.getLatest('ses_1')?.id).toBe(started.id) - }) - - it('loads open goals so events are handled after a restart', async () => { - const started = await createService(db).start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) - const fake = createFakeSessionGoalClient() - const restarted = new SessionGoalService(db, fake.client, new SettingsService(db), { quietMs: 0 }) - - restarted.loadOpenGoals() - await restarted.handleEvent(DIRECTORY, executionStarted('ses_1')) - await restarted.handleEvent(DIRECTORY, sessionIdle('ses_1')) - await vi.runAllTimersAsync() - - expect(fake.auditorCalls).toHaveLength(1) - expect(getSessionGoalById(db, started.id)?.turnState).toBe('waiting') - }) -}) - -describe('SessionGoalService audit loop', () => { - let db: Database - let fake: FakeSessionGoalClient - let service: SessionGoalService - - beforeEach(() => { - vi.useFakeTimers() - db = createTestDb() - fake = createFakeSessionGoalClient() - service = new SessionGoalService(db, fake.client, new SettingsService(db), { quietMs: 0 }) - }) - - afterEach(() => { - vi.useRealTimers() - db.close() - }) - - async function runTurn(sessionId: string): Promise { - await service.handleEvent(DIRECTORY, executionStarted(sessionId)) - await service.handleEvent(DIRECTORY, sessionIdle(sessionId)) - await vi.runAllTimersAsync() - } - - it('completes the goal when the auditor says done', async () => { - fake.setMessages('ses_1', [fakeAssistantMessage('The feature is shipped and tested.')]) - fake.setAuditorReplies(['{"verdict":"done","reason":"Objective achieved."}']) - const started = await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) - - await runTurn('ses_1') - - const latest = service.getLatest('ses_1') - expect(latest?.status).toBe('completed') - expect(latest?.stopReason).toBeNull() - expect(latest?.lastVerdict).toBe('done') - expect(latest?.lastReason).toBe('Objective achieved.') - expect(latest?.finishedAt).not.toBeNull() - expect(fake.promptCalls).toHaveLength(0) - expect(fake.auditorCalls[0]?.prompt).toContain('Ship it') - expect(fake.auditorCalls[0]?.prompt).toContain('The feature is shipped and tested.') - expect(getSessionGoalById(db, started.id)?.turnState).toBe('running') - }) - - it('sends one continuation prompt and increments the count on continue', async () => { - fake.setMessages('ses_1', [fakeAssistantMessage('Working on the route.')]) - fake.setAuditorReplies(['{"verdict":"continue","reason":"Tests still fail."}']) - await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) - - await runTurn('ses_1') - - const latest = service.getLatest('ses_1') - expect(latest?.status).toBe('active') - expect(latest?.turnState).toBe('waiting') - expect(latest?.continuationCount).toBe(1) - expect(fake.promptCalls).toHaveLength(1) - expect(fake.promptCalls[0]?.sessionID).toBe('ses_1') - expect(fake.promptCalls[0]?.text).toContain('Ship it') - expect(fake.promptCalls[0]?.text).toContain('Tests still fail.') - }) - - it('stops the goal at the continuation cap', async () => { - fake.setMessages('ses_1', [fakeAssistantMessage('Still working.')]) - await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it', maxContinuations: 1 }) - - await runTurn('ses_1') - await runTurn('ses_1') - - const latest = service.getLatest('ses_1') - expect(latest?.status).toBe('stopped') - expect(latest?.stopReason).toBe('continuation_limit') - expect(latest?.continuationCount).toBe(1) - expect(fake.promptCalls).toHaveLength(1) - }) - - it('continues twice and blocks on the third blocked verdict', async () => { - fake.setMessages('ses_1', [fakeAssistantMessage('I need a decision.')]) - fake.setAuditorReplies([ - '{"verdict":"blocked","reason":"Needs a decision."}', - '{"verdict":"blocked","reason":"Needs a decision."}', - '{"verdict":"blocked","reason":"Needs a decision."}', - ]) - await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) - - await runTurn('ses_1') - expect(service.getLatest('ses_1')?.status).toBe('active') - expect(service.getLatest('ses_1')?.consecutiveBlocked).toBe(1) - - await runTurn('ses_1') - expect(service.getLatest('ses_1')?.status).toBe('active') - expect(service.getLatest('ses_1')?.consecutiveBlocked).toBe(2) - - await runTurn('ses_1') - const latest = service.getLatest('ses_1') - expect(latest?.status).toBe('blocked') - expect(latest?.consecutiveBlocked).toBe(3) - expect(fake.promptCalls).toHaveLength(2) - }) - - it('resets the blocked counter on a continue verdict', async () => { - fake.setMessages('ses_1', [fakeAssistantMessage('Back to work.')]) - fake.setAuditorReplies([ - '{"verdict":"blocked","reason":"Needs a decision."}', - '{"verdict":"continue","reason":"Unblocked."}', - '{"verdict":"blocked","reason":"Needs a decision."}', - ]) - await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) - - await runTurn('ses_1') - await runTurn('ses_1') - expect(service.getLatest('ses_1')?.consecutiveBlocked).toBe(0) - - await runTurn('ses_1') - expect(service.getLatest('ses_1')?.consecutiveBlocked).toBe(1) - }) - - it('stops on a token budget overrun before calling the auditor', async () => { - fake.setMessages('ses_1', [fakeAssistantMessage('Still working.')]) - await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it', tokenBudget: 100 }) - fake.setTokens('ses_1', { input: 250, output: 0, reasoning: 0 }) - - await runTurn('ses_1') - - const latest = service.getLatest('ses_1') - expect(latest?.status).toBe('stopped') - expect(latest?.stopReason).toBe('token_budget') - expect(latest?.tokensUsed).toBe(250) - expect(fake.auditorCalls).toHaveLength(0) - }) - - it('stops with turn_error when the execution fails', async () => { - await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) - - await service.handleEvent(DIRECTORY, executionFailed('ses_1')) - - const latest = service.getLatest('ses_1') - expect(latest?.status).toBe('stopped') - expect(latest?.stopReason).toBe('turn_error') - expect(fake.auditorCalls).toHaveLength(0) - }) - - it('stops with turn_error when the latest reply carries an error', async () => { - fake.setMessages('ses_1', [fakeAssistantMessage('Partial output', { error: 'provider crashed' })]) - await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) - - await runTurn('ses_1') - - const latest = service.getLatest('ses_1') - expect(latest?.status).toBe('stopped') - expect(latest?.stopReason).toBe('turn_error') - expect(fake.auditorCalls).toHaveLength(0) - }) - - it('pauses with audit_failed after two auditor failures', async () => { - fake.setMessages('ses_1', [fakeAssistantMessage('Still working.')]) - fake.failNextAuditorCalls(2) - await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) - - await runTurn('ses_1') - - const latest = service.getLatest('ses_1') - expect(latest?.status).toBe('paused') - expect(latest?.stopReason).toBe('audit_failed') - expect(latest?.finishedAt).toBeNull() - expect(fake.auditorCalls).toHaveLength(2) - }) - - it('treats an unparseable verdict as continue', async () => { - fake.setMessages('ses_1', [fakeAssistantMessage('Still working.')]) - fake.setAuditorReplies(['I am not sure.']) - await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) - - await runTurn('ses_1') - - const latest = service.getLatest('ses_1') - expect(latest?.status).toBe('active') - expect(latest?.lastVerdict).toBe('continue') - expect(latest?.lastReason).toBe('Auditor response was not understood') - expect(fake.promptCalls).toHaveLength(1) - }) - - it('does not audit an idle event while the turn is waiting', async () => { - await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) - - await service.handleEvent(DIRECTORY, sessionIdle('ses_1')) - await vi.runAllTimersAsync() - - expect(fake.auditorCalls).toHaveLength(0) - }) - - it('audits on execution.succeeded and on a session.status idle event', async () => { - fake.setMessages('ses_1', [fakeAssistantMessage('Done.')]) - fake.setAuditorReplies(['{"verdict":"continue","reason":"keep going"}', '{"verdict":"done","reason":"finished"}']) - await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) - - await service.handleEvent(DIRECTORY, executionStarted('ses_1')) - await service.handleEvent(DIRECTORY, executionSucceeded('ses_1')) - await vi.runAllTimersAsync() - expect(fake.auditorCalls).toHaveLength(1) - - await service.handleEvent(DIRECTORY, executionStarted('ses_1')) - await service.handleEvent(DIRECTORY, sessionStatusIdle('ses_1')) - await vi.runAllTimersAsync() - - expect(fake.auditorCalls).toHaveLength(2) - expect(service.getLatest('ses_1')?.status).toBe('completed') - }) - - it('ignores events for a session without an open goal', async () => { - await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) - - await service.handleEvent(DIRECTORY, executionStarted('ses_other')) - await service.handleEvent(DIRECTORY, executionFailed('ses_other')) - await vi.runAllTimersAsync() - - expect(service.getLatest('ses_1')?.status).toBe('active') - expect(fake.auditorCalls).toHaveLength(0) - }) - - it('schedules an audit immediately when a paused goal resumes', async () => { - fake.setMessages('ses_1', [fakeAssistantMessage('Back.')]) - fake.setAuditorReplies(['{"verdict":"done","reason":"finished"}']) - const started = await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) - service.pause(started.id) - - service.resume(started.id) - await vi.runAllTimersAsync() - - expect(fake.auditorCalls).toHaveLength(1) - expect(service.getLatest('ses_1')?.status).toBe('completed') - }) - - it('does not schedule an audit on resume while the session is busy', async () => { - const started = await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) - service.pause(started.id) - fake.setBusy('ses_1', true) - - service.resume(started.id) - await vi.runAllTimersAsync() - - expect(fake.auditorCalls).toHaveLength(0) - expect(service.getLatest('ses_1')?.status).toBe('active') - }) - - it('pauses with interrupted when the execution is interrupted', async () => { - await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) - - await service.handleEvent(DIRECTORY, executionInterrupted('ses_1')) - - const latest = service.getLatest('ses_1') - expect(latest?.status).toBe('paused') - expect(latest?.stopReason).toBe('interrupted') - expect(latest?.finishedAt).toBeNull() - }) - - it('stops with session_deleted when the session is deleted', async () => { - const started = await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) - service.pause(started.id) - - await service.handleEvent(DIRECTORY, sessionDeleted('ses_1')) - - const latest = service.getLatest('ses_1') - expect(latest?.status).toBe('stopped') - expect(latest?.stopReason).toBe('session_deleted') - }) - - it('stops with turn_error when the continuation prompt fails', async () => { - const failing = createFakeSessionGoalClient({ failSessionPrompt: true }) - const failingService = new SessionGoalService(db, failing.client, new SettingsService(db), { quietMs: 0 }) - failing.setMessages('ses_1', [fakeAssistantMessage('Working.')]) - failing.setAuditorReplies(['{"verdict":"continue","reason":"keep going"}']) - await failingService.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) - - await failingService.handleEvent(DIRECTORY, executionStarted('ses_1')) - await failingService.handleEvent(DIRECTORY, sessionIdle('ses_1')) - await vi.runAllTimersAsync() - - const latest = failingService.getLatest('ses_1') - expect(latest?.status).toBe('stopped') - expect(latest?.stopReason).toBe('turn_error') - }) - - it('discards a late continuation failure after a new execution starts', async () => { - const outcomes: string[] = [] - const tracked = new SessionGoalService(db, fake.client, new SettingsService(db), { - quietMs: 0, - onOutcome: (goal) => outcomes.push(goal.status), - }) - fake.setMessages('ses_1', [fakeAssistantMessage('Working.')]) - fake.setAuditorReplies(['{"verdict":"continue","reason":"keep going"}']) - fake.deferNextSessionPrompts(1) - await tracked.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) - - await tracked.handleEvent(DIRECTORY, executionStarted('ses_1')) - await tracked.handleEvent(DIRECTORY, sessionIdle('ses_1')) - await vi.runAllTimersAsync() - expect(fake.pendingSessionPromptCalls).toHaveLength(1) - expect(tracked.getLatest('ses_1')?.turnState).toBe('waiting') - - await tracked.handleEvent(DIRECTORY, executionStarted('ses_1')) - fake.pendingSessionPromptCalls[0]?.reject(new Error('transport dropped')) - await vi.runAllTimersAsync() - - const latest = tracked.getLatest('ses_1') - expect(latest?.status).toBe('active') - expect(latest?.turnState).toBe('running') - expect(latest?.stopReason).toBeNull() - expect(outcomes).toEqual([]) - }) - - it('discards a late continuation failure across pause and resume', async () => { - const outcomes: string[] = [] - const tracked = new SessionGoalService(db, fake.client, new SettingsService(db), { - quietMs: 0, - onOutcome: (goal) => outcomes.push(goal.status), - }) - fake.setMessages('ses_1', [fakeAssistantMessage('Working.')]) - fake.setAuditorReplies(['{"verdict":"continue","reason":"keep going"}']) - fake.deferNextSessionPrompts(1) - const started = await tracked.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) - - await tracked.handleEvent(DIRECTORY, executionStarted('ses_1')) - await tracked.handleEvent(DIRECTORY, sessionIdle('ses_1')) - await vi.runAllTimersAsync() - expect(fake.pendingSessionPromptCalls).toHaveLength(1) - - tracked.pause(started.id) - tracked.resume(started.id) - await vi.runAllTimersAsync() - expect(outcomes).toEqual(['paused']) - outcomes.length = 0 - - fake.pendingSessionPromptCalls[0]?.reject(new Error('transport dropped')) - await vi.runAllTimersAsync() - - const latest = tracked.getLatest('ses_1') - expect(latest?.status).toBe('active') - expect(latest?.stopReason).toBeNull() - expect(outcomes).toEqual([]) - }) - - it('passes the configured auditor model to the auditor call', async () => { - const settingsService = new SettingsService(db) - settingsService.updateSettings({ sessionDefaults: { permissionMode: 'ask', goalAuditorModel: 'anthropic/claude-x' } }) - const configured = new SessionGoalService(db, fake.client, settingsService, { quietMs: 0 }) - fake.setMessages('ses_1', [fakeAssistantMessage('Done.')]) - fake.setAuditorReplies(['{"verdict":"done","reason":"finished"}']) - await configured.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) - - await configured.handleEvent(DIRECTORY, executionStarted('ses_1')) - await configured.handleEvent(DIRECTORY, sessionIdle('ses_1')) - await vi.runAllTimersAsync() - - expect(fake.auditorCalls[0]?.model).toEqual({ providerID: 'anthropic', id: 'claude-x' }) - }) - - it('calls onOutcome for terminal transitions', async () => { - const outcomes: string[] = [] - const tracked = new SessionGoalService(db, fake.client, new SettingsService(db), { - quietMs: 0, - onOutcome: (goal) => outcomes.push(goal.status), - }) - fake.setMessages('ses_1', [fakeAssistantMessage('Done.')]) - fake.setAuditorReplies(['{"verdict":"done","reason":"finished"}']) - await tracked.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) - - await tracked.handleEvent(DIRECTORY, executionStarted('ses_1')) - await tracked.handleEvent(DIRECTORY, sessionIdle('ses_1')) - await vi.runAllTimersAsync() - - expect(outcomes).toEqual(['completed']) - }) - - it('pauses with audit_failed when the busy check fails', async () => { - const outcomes: string[] = [] - const tracked = new SessionGoalService(db, fake.client, new SettingsService(db), { - quietMs: 0, - onOutcome: (goal) => outcomes.push(goal.status), - }) - fake.setMessages('ses_1', [fakeAssistantMessage('Working.')]) - await tracked.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) - fake.setFailSessionActive(true) - - await tracked.handleEvent(DIRECTORY, executionStarted('ses_1')) - await tracked.handleEvent(DIRECTORY, sessionIdle('ses_1')) - await vi.runAllTimersAsync() - - const latest = tracked.getLatest('ses_1') - expect(latest?.status).toBe('paused') - expect(latest?.stopReason).toBe('audit_failed') - expect(fake.auditorCalls).toHaveLength(0) - expect(fake.promptCalls).toHaveLength(0) - expect(outcomes).toEqual(['paused']) - }) - - it('pauses with audit_failed when the token lookup fails without resetting usage', async () => { - fake.setMessages('ses_1', [fakeAssistantMessage('Working.')]) - fake.setTokens('ses_1', { input: 50, output: 0, reasoning: 0 }) - fake.setAuditorReplies(['{"verdict":"continue","reason":"keep going"}']) - await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it', tokenBudget: 100 }) - - fake.setTokens('ses_1', { input: 100, output: 0, reasoning: 0 }) - await runTurn('ses_1') - expect(service.getLatest('ses_1')?.tokensUsed).toBe(50) - - fake.setTokens('ses_1', { input: 250, output: 0, reasoning: 0 }) - fake.setFailSessionGet(true) - await runTurn('ses_1') - - const latest = service.getLatest('ses_1') - expect(latest?.status).toBe('paused') - expect(latest?.stopReason).toBe('audit_failed') - expect(latest?.tokensUsed).toBe(50) - expect(fake.promptCalls).toHaveLength(1) - expect(fake.auditorCalls).toHaveLength(1) - }) - - it('pauses with audit_failed when the reply lookup fails', async () => { - fake.setMessages('ses_1', [fakeAssistantMessage('Working.')]) - await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) - fake.setFailMessageList(true) - - await runTurn('ses_1') - - const latest = service.getLatest('ses_1') - expect(latest?.status).toBe('paused') - expect(latest?.stopReason).toBe('audit_failed') - expect(fake.auditorCalls).toHaveLength(0) - expect(fake.promptCalls).toHaveLength(0) - }) - - it('pauses with audit_failed when the resume busy check fails', async () => { - const outcomes: string[] = [] - const tracked = new SessionGoalService(db, fake.client, new SettingsService(db), { - quietMs: 0, - onOutcome: (goal) => outcomes.push(goal.status), - }) - const started = await tracked.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) - tracked.pause(started.id) - outcomes.length = 0 - fake.setFailSessionActive(true) - - tracked.resume(started.id) - await vi.runAllTimersAsync() - - const latest = tracked.getLatest('ses_1') - expect(latest?.status).toBe('paused') - expect(latest?.stopReason).toBe('audit_failed') - expect(fake.auditorCalls).toHaveLength(0) - expect(outcomes).toEqual(['paused']) - }) - - it('discards a stale continue verdict when a new execution starts mid-audit', async () => { - fake.setMessages('ses_1', [fakeAssistantMessage('Working.')]) - fake.deferNextAuditorCalls(1) - await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) - - await service.handleEvent(DIRECTORY, executionStarted('ses_1')) - await service.handleEvent(DIRECTORY, sessionIdle('ses_1')) - await vi.runAllTimersAsync() - expect(fake.pendingAuditorCalls).toHaveLength(1) - - await service.handleEvent(DIRECTORY, executionStarted('ses_1')) - fake.setBusy('ses_1', true) - fake.pendingAuditorCalls[0]?.resolve('{"verdict":"continue","reason":"keep going"}') - await vi.runAllTimersAsync() - - const latest = service.getLatest('ses_1') - expect(latest?.status).toBe('active') - expect(latest?.continuationCount).toBe(0) - expect(latest?.turnState).toBe('running') - expect(fake.promptCalls).toHaveLength(0) - }) - - it('discards a stale done verdict when the goal is paused mid-audit', async () => { - fake.setMessages('ses_1', [fakeAssistantMessage('Done.')]) - fake.deferNextAuditorCalls(1) - const started = await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) - - await service.handleEvent(DIRECTORY, executionStarted('ses_1')) - await service.handleEvent(DIRECTORY, sessionIdle('ses_1')) - await vi.runAllTimersAsync() - expect(fake.pendingAuditorCalls).toHaveLength(1) - - service.pause(started.id) - fake.pendingAuditorCalls[0]?.resolve('{"verdict":"done","reason":"finished"}') - await vi.runAllTimersAsync() - - const latest = service.getLatest('ses_1') - expect(latest?.status).toBe('paused') - expect(latest?.stopReason).toBe('user_paused') - expect(latest?.lastVerdict).toBeNull() - }) - - it('retains a newer idle audit while an older audit is in flight', async () => { - fake.setMessages('ses_1', [fakeAssistantMessage('Turn 1.')]) - fake.setAuditorReplies(['{"verdict":"continue","reason":"second"}']) - fake.deferNextAuditorCalls(1) - await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) - - await service.handleEvent(DIRECTORY, executionStarted('ses_1')) - await service.handleEvent(DIRECTORY, sessionIdle('ses_1')) - await vi.runAllTimersAsync() - expect(fake.pendingAuditorCalls).toHaveLength(1) - - await service.handleEvent(DIRECTORY, executionStarted('ses_1')) - fake.setMessages('ses_1', [fakeAssistantMessage('Turn 2.')]) - await service.handleEvent(DIRECTORY, sessionIdle('ses_1')) - await vi.runAllTimersAsync() - expect(fake.pendingAuditorCalls).toHaveLength(1) - - fake.pendingAuditorCalls[0]?.resolve('{"verdict":"continue","reason":"stale"}') - await vi.runAllTimersAsync() - - expect(fake.auditorCalls).toHaveLength(2) - expect(fake.maxConcurrentAuditorCalls).toBe(1) - expect(fake.promptCalls).toHaveLength(1) - expect(fake.promptCalls[0]?.text).toContain('second') - const latest = service.getLatest('ses_1') - expect(latest?.continuationCount).toBe(1) - expect(latest?.turnState).toBe('waiting') - }) - - it('discards an in-flight audit result across pause and resume', async () => { - fake.setMessages('ses_1', [fakeAssistantMessage('Working.')]) - fake.setAuditorReplies(['{"verdict":"continue","reason":"fresh"}']) - fake.deferNextAuditorCalls(1) - const started = await service.start({ sessionId: 'ses_1', directory: DIRECTORY, objective: 'Ship it' }) - - await service.handleEvent(DIRECTORY, executionStarted('ses_1')) - await service.handleEvent(DIRECTORY, sessionIdle('ses_1')) - await vi.runAllTimersAsync() - expect(fake.pendingAuditorCalls).toHaveLength(1) - - service.pause(started.id) - service.resume(started.id) - await vi.runAllTimersAsync() - - fake.pendingAuditorCalls[0]?.resolve('{"verdict":"done","reason":"stale"}') - await vi.runAllTimersAsync() - - const latest = service.getLatest('ses_1') - expect(latest?.status).toBe('active') - expect(latest?.lastVerdict).toBe('continue') - expect(fake.auditorCalls).toHaveLength(2) - expect(fake.promptCalls).toHaveLength(1) - }) -}) - -describe('SessionGoalService recovery', () => { - let db: Database - - beforeEach(() => { - vi.useFakeTimers() - db = createTestDb() - }) - - afterEach(() => { - vi.useRealTimers() - db.close() - }) - - it('resumes auditing a running goal on recovery and leaves a waiting goal alone', async () => { - const running = await createService(db).start({ sessionId: 'ses_run', directory: DIRECTORY, objective: 'Ship it' }) - const waiting = await createService(db).start({ sessionId: 'ses_wait', directory: DIRECTORY, objective: 'Later' }) - transitionSessionGoal(db, running.id, ['active'], { turnState: 'running' }) - - const fake = createFakeSessionGoalClient() - const restarted = new SessionGoalService(db, fake.client, new SettingsService(db), { quietMs: 0 }) - - await restarted.recoverOpenGoals() - await vi.runAllTimersAsync() - - expect(fake.auditorCalls).toHaveLength(1) - expect(fake.auditorCalls[0]?.prompt).toContain('Ship it') - expect(getSessionGoalById(db, waiting.id)?.turnState).toBe('waiting') - expect(fake.auditorCalls[0]?.prompt).not.toContain('Later') - }) - - it('leaves a running goal alone while the session is busy', async () => { - const running = await createService(db).start({ sessionId: 'ses_run', directory: DIRECTORY, objective: 'Ship it' }) - transitionSessionGoal(db, running.id, ['active'], { turnState: 'running' }) - - const fake = createFakeSessionGoalClient() - fake.setBusy('ses_run', true) - const restarted = new SessionGoalService(db, fake.client, new SettingsService(db), { quietMs: 0 }) - - await restarted.recoverOpenGoals() - await vi.runAllTimersAsync() - - expect(fake.auditorCalls).toHaveLength(0) - expect(getSessionGoalById(db, running.id)?.status).toBe('active') - }) - - it('stops a running goal when its session no longer exists', async () => { - const outcomes: string[] = [] - const running = await createService(db).start({ sessionId: 'ses_run', directory: DIRECTORY, objective: 'Ship it' }) - transitionSessionGoal(db, running.id, ['active'], { turnState: 'running' }) - - const fake = createFakeSessionGoalClient() - fake.setSessionMissing('ses_run', true) - const restarted = new SessionGoalService(db, fake.client, new SettingsService(db), { - quietMs: 0, - onOutcome: (goal) => outcomes.push(goal.status), - }) - - await restarted.recoverOpenGoals() - await vi.runAllTimersAsync() - - const latest = restarted.getLatest('ses_run') - expect(latest?.status).toBe('stopped') - expect(latest?.stopReason).toBe('session_deleted') - expect(fake.auditorCalls).toHaveLength(0) - expect(outcomes).toEqual(['stopped']) - }) - - it('retries recovery after a transient session lookup failure without session events', async () => { - const running = await createService(db).start({ sessionId: 'ses_run', directory: DIRECTORY, objective: 'Ship it' }) - transitionSessionGoal(db, running.id, ['active'], { turnState: 'running' }) - - const fake = createFakeSessionGoalClient({ failSessionGet: true }) - fake.setAuditorReplies(['{"verdict":"done","reason":"finished"}']) - const restarted = new SessionGoalService(db, fake.client, new SettingsService(db), { quietMs: 0 }) - - await restarted.recoverOpenGoals() - expect(fake.auditorCalls).toHaveLength(0) - expect(restarted.getLatest('ses_run')?.status).toBe('active') - - fake.setFailSessionGet(false) - await vi.runAllTimersAsync() - - expect(fake.auditorCalls).toHaveLength(1) - expect(restarted.getLatest('ses_run')?.status).toBe('completed') - }) - - it('retries recovery after a transient busy-check failure without session events', async () => { - const running = await createService(db).start({ sessionId: 'ses_run', directory: DIRECTORY, objective: 'Ship it' }) - transitionSessionGoal(db, running.id, ['active'], { turnState: 'running' }) - - const fake = createFakeSessionGoalClient() - fake.setAuditorReplies(['{"verdict":"done","reason":"finished"}']) - fake.setFailSessionActive(true) - const restarted = new SessionGoalService(db, fake.client, new SettingsService(db), { quietMs: 0 }) - - await restarted.recoverOpenGoals() - expect(fake.auditorCalls).toHaveLength(0) - - fake.setFailSessionActive(false) - await vi.runAllTimersAsync() - - expect(fake.auditorCalls).toHaveLength(1) - expect(restarted.getLatest('ses_run')?.status).toBe('completed') - }) - - it('stops with session_deleted when the session disappears before the recovery retry', async () => { - const running = await createService(db).start({ sessionId: 'ses_run', directory: DIRECTORY, objective: 'Ship it' }) - transitionSessionGoal(db, running.id, ['active'], { turnState: 'running' }) - - const fake = createFakeSessionGoalClient({ failSessionGet: true }) - const restarted = new SessionGoalService(db, fake.client, new SettingsService(db), { quietMs: 0 }) - - await restarted.recoverOpenGoals() - fake.setFailSessionGet(false) - fake.setSessionMissing('ses_run', true) - await vi.runAllTimersAsync() - - const latest = restarted.getLatest('ses_run') - expect(latest?.status).toBe('stopped') - expect(latest?.stopReason).toBe('session_deleted') - expect(fake.auditorCalls).toHaveLength(0) - }) - - it('does not audit a goal paused during the recovery retry delay', async () => { - const running = await createService(db).start({ sessionId: 'ses_run', directory: DIRECTORY, objective: 'Ship it' }) - transitionSessionGoal(db, running.id, ['active'], { turnState: 'running' }) - - const fake = createFakeSessionGoalClient({ failSessionGet: true }) - const restarted = new SessionGoalService(db, fake.client, new SettingsService(db), { quietMs: 0 }) - - await restarted.recoverOpenGoals() - restarted.pause(running.id) - - fake.setFailSessionGet(false) - await vi.runAllTimersAsync() - - expect(restarted.getLatest('ses_run')?.status).toBe('paused') - expect(fake.auditorCalls).toHaveLength(0) - }) - - it('does not audit a goal cancelled during the recovery retry delay', async () => { - const running = await createService(db).start({ sessionId: 'ses_run', directory: DIRECTORY, objective: 'Ship it' }) - transitionSessionGoal(db, running.id, ['active'], { turnState: 'running' }) - - const fake = createFakeSessionGoalClient({ failSessionGet: true }) - const restarted = new SessionGoalService(db, fake.client, new SettingsService(db), { quietMs: 0 }) - - await restarted.recoverOpenGoals() - restarted.cancel(running.id) - - fake.setFailSessionGet(false) - await vi.runAllTimersAsync() - - const latest = restarted.getLatest('ses_run') - expect(latest?.status).toBe('stopped') - expect(latest?.stopReason).toBe('cancelled') - expect(fake.auditorCalls).toHaveLength(0) - }) -}) - -describe('SessionGoalService remote servers', () => { - let db: Database - - const REMOTE_SERVER_ID = 'srv_remote' - - function createRemoteRegistry( - local: FakeSessionGoalClient, - remote: FakeSessionGoalClient, - options: { missing?: boolean; disabled?: boolean } = {}, - ) { - return { - getClient: (serverId: string) => - serverId === 'local' ? local.client : remote.client, - get: (serverId: string) => { - if (serverId === 'local') return { enabled: true } - if (options.missing) return null - return { enabled: !options.disabled } - }, - } - } - - beforeEach(() => { - vi.useFakeTimers() - db = createTestDb() - }) - - afterEach(() => { - vi.useRealTimers() - db.close() - }) - - it('continues a remote goal through the remote client while the auditor stays local', async () => { - const local = createFakeSessionGoalClient({ auditorReplies: ['{"verdict":"continue","reason":"keep going"}'] }) - const remote = createFakeSessionGoalClient({ - tokens: { ses_remote: { input: 10, output: 5, reasoning: 0 } }, - }) - const service = new SessionGoalService(db, createRemoteRegistry(local, remote), new SettingsService(db), { - quietMs: 0, - }) - - const goal = await service.start({ - sessionId: 'ses_remote', - serverId: REMOTE_SERVER_ID, - directory: DIRECTORY, - objective: 'Remote work', - }) - - expect(goal.serverId).toBe(REMOTE_SERVER_ID) - - await service.handleEvent(DIRECTORY, executionStarted('ses_remote'), REMOTE_SERVER_ID) - await service.handleEvent(DIRECTORY, sessionIdle('ses_remote'), REMOTE_SERVER_ID) - await vi.runAllTimersAsync() - - expect(remote.promptCalls).toHaveLength(1) - expect(remote.promptCalls[0]?.sessionID).toBe('ses_remote') - expect(local.promptCalls).toHaveLength(0) - expect(local.auditorCalls).toHaveLength(1) - expect(remote.auditorCalls).toHaveLength(0) - }) - - it('keeps goal state scoped to the server that owns the session', async () => { - const local = createFakeSessionGoalClient() - const remote = createFakeSessionGoalClient({ - tokens: { ses_shared: { input: 0, output: 0, reasoning: 0 } }, - }) - const service = new SessionGoalService(db, createRemoteRegistry(local, remote), new SettingsService(db)) - - await service.start({ - sessionId: 'ses_shared', - serverId: REMOTE_SERVER_ID, - directory: DIRECTORY, - objective: 'Remote only', - }) - - expect(service.getLatest('ses_shared')).toBeNull() - expect(service.getLatest('ses_shared', REMOTE_SERVER_ID)?.objective).toBe('Remote only') - }) - - it('recovers a remote goal using the remote client', async () => { - const local = createFakeSessionGoalClient({ auditorReplies: ['{"verdict":"done","reason":"finished"}'] }) - const remote = createFakeSessionGoalClient() - const starter = new SessionGoalService(db, createRemoteRegistry(local, remote), new SettingsService(db)) - const goal = await starter.start({ - sessionId: 'ses_remote', - serverId: REMOTE_SERVER_ID, - directory: DIRECTORY, - objective: 'Remote recovery', - }) - transitionSessionGoal(db, goal.id, ['active'], { turnState: 'running' }) - - const restarted = new SessionGoalService(db, createRemoteRegistry(local, remote), new SettingsService(db), { - quietMs: 0, - }) - await restarted.recoverOpenGoals() - await vi.runAllTimersAsync() - - expect(local.auditorCalls).toHaveLength(1) - expect(remote.auditorCalls).toHaveLength(0) - expect(restarted.getLatest('ses_remote', REMOTE_SERVER_ID)?.status).toBe('completed') - }) - - it('leaves remote goals open when their server is missing', async () => { - const local = createFakeSessionGoalClient() - const remote = createFakeSessionGoalClient() - const starter = new SessionGoalService(db, createRemoteRegistry(local, remote), new SettingsService(db)) - const goal = await starter.start({ - sessionId: 'ses_remote', - serverId: REMOTE_SERVER_ID, - directory: DIRECTORY, - objective: 'Orphaned', - }) - transitionSessionGoal(db, goal.id, ['active'], { turnState: 'running' }) - - const restarted = new SessionGoalService( - db, - createRemoteRegistry(local, remote, { missing: true }), - new SettingsService(db), - { quietMs: 0 }, - ) - await restarted.recoverOpenGoals() - await vi.runAllTimersAsync() - - expect(restarted.getLatest('ses_remote', REMOTE_SERVER_ID)).toMatchObject({ status: 'active' }) - expect(local.auditorCalls).toHaveLength(0) - expect(remote.auditorCalls).toHaveLength(0) - }) - - it('leaves remote goals open when their server is disabled', async () => { - const local = createFakeSessionGoalClient() - const remote = createFakeSessionGoalClient() - const starter = new SessionGoalService(db, createRemoteRegistry(local, remote), new SettingsService(db)) - const goal = await starter.start({ - sessionId: 'ses_remote', - serverId: REMOTE_SERVER_ID, - directory: DIRECTORY, - objective: 'Disabled', - }) - transitionSessionGoal(db, goal.id, ['active'], { turnState: 'running' }) - - const restarted = new SessionGoalService( - db, - createRemoteRegistry(local, remote, { disabled: true }), - new SettingsService(db), - { quietMs: 0 }, - ) - await restarted.recoverOpenGoals() - await vi.runAllTimersAsync() - - expect(restarted.getLatest('ses_remote', REMOTE_SERVER_ID)).toMatchObject({ status: 'active' }) - expect(local.auditorCalls).toHaveLength(0) - expect(remote.auditorCalls).toHaveLength(0) - }) -}) diff --git a/backend/test/services/settings.test.ts b/backend/test/services/settings.test.ts index a3810d51d..45b1c889e 100644 --- a/backend/test/services/settings.test.ts +++ b/backend/test/services/settings.test.ts @@ -79,4 +79,26 @@ describe('SettingsService', () => { expect(updated.preferences.mode).toBe('plan') expect(updated.preferences.gitIdentity).toEqual({ name: 'Root', email: 'not an email' }) }) + + it('drops stored goal settings that are no longer part of the schema', () => { + db.prepare( + `INSERT INTO user_preferences (user_id, preferences, updated_at) + VALUES (?, ?, ?)` + ).run( + 'default', + JSON.stringify({ + sessionDefaults: { + permissionMode: 'ask', + goalMaxContinuations: 20, + goalTokenBudget: 1000, + goalAuditorModel: 'x/y', + }, + }), + Date.now() + ) + + const settings = settingsService.getSettings() + + expect(settings.preferences.sessionDefaults).toEqual({ permissionMode: 'ask' }) + }) }) diff --git a/backend/test/shared/opencode-contract.test.ts b/backend/test/shared/opencode-contract.test.ts index 6604d52bf..471d225e7 100644 --- a/backend/test/shared/opencode-contract.test.ts +++ b/backend/test/shared/opencode-contract.test.ts @@ -50,8 +50,8 @@ function createFetchSpy() { describe('OpenCode v2 contract', () => { describe('version policy', () => { it('pins the client version, derives the floor from it and fixes the V2 server username', () => { - expect(OPENCODE_PINNED_VERSION).toBe('2.1.2') - expect(OPENCODE_SUPPORTED_VERSION_RANGE).toBe('>=2.1.2 <3.0.0') + expect(OPENCODE_PINNED_VERSION).toBe('2.2.1') + expect(OPENCODE_SUPPORTED_VERSION_RANGE).toBe('>=2.2.1 <3.0.0') expect(OPENCODE_SERVER_USERNAME).toBe('opencode') }) @@ -61,47 +61,48 @@ describe('OpenCode v2 contract', () => { expect(isSupportedOpenCodeVersion('2.0.15')).toBe(false) expect(isSupportedOpenCodeVersion('2.1.0')).toBe(false) expect(isSupportedOpenCodeVersion('2.1.1')).toBe(false) - expect(isSupportedOpenCodeVersion('2.1.2')).toBe(true) - expect(isSupportedOpenCodeVersion('2.1.3')).toBe(true) - expect(isSupportedOpenCodeVersion('2.2.0')).toBe(true) - expect(isSupportedOpenCodeVersion('v2.1.2')).toBe(true) - expect(isSupportedOpenCodeVersion(' v2.1.2 ')).toBe(true) + expect(isSupportedOpenCodeVersion('2.2.0')).toBe(false) + expect(isSupportedOpenCodeVersion('2.2.1')).toBe(true) + expect(isSupportedOpenCodeVersion('2.2.2')).toBe(true) + expect(isSupportedOpenCodeVersion('2.3.0')).toBe(true) + expect(isSupportedOpenCodeVersion('v2.2.1')).toBe(true) + expect(isSupportedOpenCodeVersion(' v2.2.1 ')).toBe(true) expect(isSupportedOpenCodeVersion('3.0.0')).toBe(false) expect(isSupportedOpenCodeVersion('3.1.0')).toBe(false) expect(isSupportedOpenCodeVersion('not-a-version')).toBe(false) }) it('rejects prereleases of the pinned version', () => { - expect(isSupportedOpenCodeVersion('2.1.2-alpha.1')).toBe(false) - expect(isSupportedOpenCodeVersion('2.1.2-alpha')).toBe(false) - expect(isSupportedOpenCodeVersion('2.1.2-rc.1+build.5')).toBe(false) - expect(isSupportedOpenCodeVersion('2.1.2-0')).toBe(false) + expect(isSupportedOpenCodeVersion('2.2.1-alpha.1')).toBe(false) + expect(isSupportedOpenCodeVersion('2.2.1-alpha')).toBe(false) + expect(isSupportedOpenCodeVersion('2.2.1-rc.1+build.5')).toBe(false) + expect(isSupportedOpenCodeVersion('2.2.1-0')).toBe(false) }) it('accepts prereleases of higher numeric cores within the pinned major', () => { - expect(isSupportedOpenCodeVersion('2.2.0-beta.1')).toBe(true) - expect(isSupportedOpenCodeVersion('2.1.3-rc.2')).toBe(true) + expect(isSupportedOpenCodeVersion('2.3.0-beta.1')).toBe(true) + expect(isSupportedOpenCodeVersion('2.2.2-rc.2')).toBe(true) expect(isSupportedOpenCodeVersion('3.0.0-beta.1')).toBe(false) }) it('ignores build metadata for precedence', () => { - expect(isSupportedOpenCodeVersion('2.1.2+build')).toBe(true) - expect(isSupportedOpenCodeVersion('2.1.2+2.0.0')).toBe(true) + expect(isSupportedOpenCodeVersion('2.2.1+build')).toBe(true) + expect(isSupportedOpenCodeVersion('2.2.1+2.0.0')).toBe(true) }) it('rejects malformed and incomplete versions', () => { expect(isSupportedOpenCodeVersion('2garbage')).toBe(false) expect(isSupportedOpenCodeVersion('2')).toBe(false) expect(isSupportedOpenCodeVersion('2.0')).toBe(false) - expect(isSupportedOpenCodeVersion('2.1.2.1')).toBe(false) - expect(isSupportedOpenCodeVersion('02.1.2')).toBe(false) - expect(isSupportedOpenCodeVersion('2.1.2-alpha..1')).toBe(false) + expect(isSupportedOpenCodeVersion('2.2.1.1')).toBe(false) + expect(isSupportedOpenCodeVersion('02.2.1')).toBe(false) + expect(isSupportedOpenCodeVersion('2.2.1-alpha..1')).toBe(false) expect(isSupportedOpenCodeVersion('')).toBe(false) }) it('describes the supported range for unsupported versions', () => { expect(describeUnsupportedOpenCodeVersion('3.0.0')).toBe( - 'OpenCode 3.0.0 is not supported; OpenCode Manager requires OpenCode >=2.1.2 <3.0.0', + 'OpenCode 3.0.0 is not supported; OpenCode Manager requires OpenCode >=2.2.1 <3.0.0', ) }) }) diff --git a/backend/test/shared/opencode-goal.test.ts b/backend/test/shared/opencode-goal.test.ts new file mode 100644 index 000000000..c20fc0cbb --- /dev/null +++ b/backend/test/shared/opencode-goal.test.ts @@ -0,0 +1,209 @@ +import { describe, expect, it, vi } from 'vitest' +import { + GOAL_CHANGED_EVENT_TYPE, + GOAL_COMMAND_NAME, + GOAL_RPC_ID, + GOAL_USER_PAUSE_REASONS, + goalChangeFromEvent, + isGoalOpen, + isGoalOutcome, + readSessionGoal, +} from '@opencode-manager/shared/opencode' +import type { GoalState, OpenCodeApi } from '@opencode-manager/shared/opencode' + +const SESSION_ID = 'ses_goal_1' + +const baseGoal: GoalState = { + id: 'goal_1', + objective: 'Ship the shared goal module', + status: 'active', + rounds: 0, + maxRounds: 50, + boot: 'boot_1', + updatedAt: 1_700_000_000_000, +} + +function goalWith(overrides: Partial): GoalState { + return { ...baseGoal, ...overrides } +} + +function goalWithout(key: keyof GoalState): Record { + const goal: Record = { ...baseGoal } + delete goal[key] + return goal +} + +function createApi(call: ReturnType): OpenCodeApi { + return { rpc: { call } } as unknown as OpenCodeApi +} + +function rpcError(type: string, message: string): { _tag: 'RpcError'; type: string; message: string } { + return { _tag: 'RpcError', type, message } +} + +describe('goal contract constants', () => { + it('pins the OpenCode goal rpc, event, command and user pause reasons', () => { + expect(GOAL_RPC_ID).toBe('opencode.goal') + expect(GOAL_CHANGED_EVENT_TYPE).toBe('rpc.opencode.goal.changed') + expect(GOAL_COMMAND_NAME).toBe('goal') + expect(GOAL_USER_PAUSE_REASONS).toEqual(['Paused by the user', 'Stopped by the user']) + }) +}) + +describe('readSessionGoal', () => { + it('sends the exact rpc call with the session location', async () => { + const call = vi.fn().mockResolvedValue({ output: baseGoal }) + await readSessionGoal(createApi(call), { sessionID: SESSION_ID, directory: '/repo' }) + + expect(call).toHaveBeenCalledWith({ + rpcID: 'opencode.goal', + method: 'get', + input: { sessionID: SESSION_ID }, + location: { directory: '/repo' }, + }) + }) + + it('omits the location when no directory is provided', async () => { + const call = vi.fn().mockResolvedValue({ output: baseGoal }) + await readSessionGoal(createApi(call), { sessionID: SESSION_ID }) + + expect(call).toHaveBeenCalledWith({ + rpcID: 'opencode.goal', + method: 'get', + input: { sessionID: SESSION_ID }, + }) + expect(call.mock.calls[0]?.[0]).not.toHaveProperty('location') + }) + + it('returns the goal reported by the server', async () => { + const call = vi.fn().mockResolvedValue({ output: baseGoal }) + await expect(readSessionGoal(createApi(call), { sessionID: SESSION_ID })).resolves.toEqual({ + supported: true, + goal: baseGoal, + }) + }) + + it('returns a null goal when the session has none', async () => { + const call = vi.fn().mockResolvedValue({}) + await expect(readSessionGoal(createApi(call), { sessionID: SESSION_ID })).resolves.toEqual({ + supported: true, + goal: null, + }) + }) + + it('reports an unavailable rpc as unsupported', async () => { + const call = vi.fn().mockRejectedValue(rpcError('rpc.unavailable', 'RPC is unavailable: opencode.goal')) + await expect(readSessionGoal(createApi(call), { sessionID: SESSION_ID })).resolves.toEqual({ supported: false }) + }) + + it('rethrows every other error unchanged', async () => { + const error = rpcError('rpc.failed', 'boom') + const call = vi.fn().mockRejectedValue(error) + await expect(readSessionGoal(createApi(call), { sessionID: SESSION_ID })).rejects.toBe(error) + }) +}) + +describe('goalChangeFromEvent', () => { + it('accepts the goal changed event with a goal', () => { + expect(goalChangeFromEvent({ type: 'rpc.opencode.goal.changed', data: { sessionID: SESSION_ID, goal: baseGoal } })).toEqual({ + sessionID: SESSION_ID, + goal: baseGoal, + }) + }) + + it('accepts the goal changed event with a cleared goal', () => { + expect(goalChangeFromEvent({ type: 'rpc.opencode.goal.changed', data: { sessionID: SESSION_ID, goal: null } })).toEqual({ + sessionID: SESSION_ID, + goal: null, + }) + }) + + it('ignores other event types', () => { + expect(goalChangeFromEvent({ type: 'session.idle', data: { sessionID: SESSION_ID, goal: baseGoal } })).toBeNull() + }) + + it('ignores malformed data', () => { + const eventType = 'rpc.opencode.goal.changed' + expect(goalChangeFromEvent({ type: eventType, data: null })).toBeNull() + expect(goalChangeFromEvent({ type: eventType, data: { goal: baseGoal } })).toBeNull() + expect(goalChangeFromEvent({ type: eventType, data: { sessionID: 42, goal: baseGoal } })).toBeNull() + expect(goalChangeFromEvent({ type: eventType, data: { sessionID: SESSION_ID } })).toBeNull() + expect(goalChangeFromEvent({ type: eventType, data: { sessionID: SESSION_ID, goal: 'active' } })).toBeNull() + expect(goalChangeFromEvent({ type: eventType, data: { sessionID: SESSION_ID, goal: { objective: 'no status' } } })).toBeNull() + }) + + it('ignores a goal with a status outside the contract', () => { + expect( + goalChangeFromEvent({ + type: 'rpc.opencode.goal.changed', + data: { sessionID: SESSION_ID, goal: goalWith({ status: 'stopped' as GoalState['status'] }) }, + }), + ).toBeNull() + }) + + it('ignores a goal without an objective', () => { + expect( + goalChangeFromEvent({ + type: 'rpc.opencode.goal.changed', + data: { sessionID: SESSION_ID, goal: goalWithout('objective') }, + }), + ).toBeNull() + }) + + it('ignores a goal without an id', () => { + expect( + goalChangeFromEvent({ + type: 'rpc.opencode.goal.changed', + data: { sessionID: SESSION_ID, goal: goalWithout('id') }, + }), + ).toBeNull() + }) + + it('ignores a goal whose rounds or maxRounds are not numbers', () => { + const eventType = 'rpc.opencode.goal.changed' + expect( + goalChangeFromEvent({ + type: eventType, + data: { sessionID: SESSION_ID, goal: { ...baseGoal, rounds: '0' } }, + }), + ).toBeNull() + expect( + goalChangeFromEvent({ + type: eventType, + data: { sessionID: SESSION_ID, goal: { ...baseGoal, maxRounds: null } }, + }), + ).toBeNull() + }) +}) + +describe('isGoalOpen', () => { + it('is true for active and paused goals', () => { + expect(isGoalOpen(goalWith({ status: 'active' }))).toBe(true) + expect(isGoalOpen(goalWith({ status: 'paused' }))).toBe(true) + }) + + it('is false for complete, missing and undefined goals', () => { + expect(isGoalOpen(goalWith({ status: 'complete' }))).toBe(false) + expect(isGoalOpen(null)).toBe(false) + expect(isGoalOpen(undefined)).toBe(false) + }) +}) + +describe('isGoalOutcome', () => { + it('is true for a complete goal', () => { + expect(isGoalOutcome(goalWith({ status: 'complete' }))).toBe(true) + }) + + it('is true for a paused goal that is not paused by the user', () => { + expect(isGoalOutcome(goalWith({ status: 'paused', reason: 'Reached the limit of 50 rounds' }))).toBe(true) + }) + + it('is false for a goal paused by the user', () => { + expect(isGoalOutcome(goalWith({ status: 'paused', reason: 'Paused by the user' }))).toBe(false) + expect(isGoalOutcome(goalWith({ status: 'paused', reason: 'Stopped by the user' }))).toBe(false) + }) + + it('is false for an active goal', () => { + expect(isGoalOutcome(goalWith({ status: 'active' }))).toBe(false) + }) +}) diff --git a/backend/test/utils/json-extract.test.ts b/backend/test/utils/json-extract.test.ts deleted file mode 100644 index a99d67771..000000000 --- a/backend/test/utils/json-extract.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { z } from 'zod' -import { extractFirstJsonObject, parseFirstJsonObject } from '../../src/utils/json-extract' - -describe('extractFirstJsonObject', () => { - it('returns a plain JSON object', () => { - expect(extractFirstJsonObject('{"a":1}')).toBe('{"a":1}') - }) - - it('returns the first object when several are present', () => { - expect(extractFirstJsonObject('prefix {"a":1} middle {"b":2} suffix')).toBe('{"a":1}') - }) - - it('ignores prose before and after the object', () => { - expect(extractFirstJsonObject('Here you go:\n{"a":1}\nThanks')).toBe('{"a":1}') - }) - - it('matches nested braces', () => { - expect(extractFirstJsonObject('{"a":{"b":{"c":1}},"d":2}')).toBe('{"a":{"b":{"c":1}},"d":2}') - }) - - it('ignores braces inside strings', () => { - expect(extractFirstJsonObject('{"a":"}{","b":1}')).toBe('{"a":"}{","b":1}') - }) - - it('handles escaped quotes inside strings', () => { - expect(extractFirstJsonObject('{"a":"a \\" b","c":2}')).toBe('{"a":"a \\" b","c":2}') - }) - - it('returns null when there is no object', () => { - expect(extractFirstJsonObject('no object here')).toBeNull() - }) - - it('returns null for an unterminated object', () => { - expect(extractFirstJsonObject('{"a":1')).toBeNull() - }) - - it('returns null when a string is left open', () => { - expect(extractFirstJsonObject('{"a":"unterminated}')).toBeNull() - }) -}) - -describe('parseFirstJsonObject', () => { - const schema = z.object({ a: z.number() }) - - it('parses and validates the first object', () => { - expect(parseFirstJsonObject('prefix {"a":1} suffix', schema)).toEqual({ a: 1 }) - }) - - it('returns null when there is no object', () => { - expect(parseFirstJsonObject('no object', schema)).toBeNull() - }) - - it('returns null for invalid JSON', () => { - expect(parseFirstJsonObject('{not json', schema)).toBeNull() - }) - - it('returns null when the object fails the schema', () => { - expect(parseFirstJsonObject('{"a":"nope"}', schema)).toBeNull() - }) -}) diff --git a/frontend/src/api/opencode.ts b/frontend/src/api/opencode.ts index 40bbabf9f..f3f8f7835 100644 --- a/frontend/src/api/opencode.ts +++ b/frontend/src/api/opencode.ts @@ -1,4 +1,4 @@ -import { openCodeLocation, parseOpenCodeModelRef } from '@opencode-manager/shared/opencode' +import { openCodeLocation, parseOpenCodeModelRef, readSessionGoal } from '@opencode-manager/shared/opencode' import { MANAGER_PROMPT_CLIENT_METADATA_KEY } from '@opencode-manager/shared/schemas' import { isMobileViewport } from '@/hooks/useMobile' import { getDirectoryName } from '@/lib/utils' @@ -11,6 +11,7 @@ import type { ModelRef, OpenCodeApi, PermissionRequest, + SessionGoalRead, SessionInboxCompaction, SessionInboxUser, SessionInfo, @@ -231,6 +232,22 @@ export async function getSession(sessionID: string, serverId?: string): Promise< return callOpenCode((api) => api.session.get({ sessionID }), serverId) } +/** + * Reads a session goal through the OpenCode goal RPC. + * + * @param sessionID The session whose goal is read. + * @param directory Directory of the session's project, once it is known. + * @param serverId Server that owns the session, when it is not the local one. + * @returns The goal, or an unsupported result when the server has no goal RPC. + */ +export async function getSessionGoal( + sessionID: string, + directory: string | undefined, + serverId?: string, +): Promise { + return callOpenCode((api) => readSessionGoal(api, { sessionID, directory }), serverId) +} + export async function createSession(input: CreateSessionInput, serverId?: string): Promise { const model = input.model ? parseOpenCodeModelRef(input.model) : undefined return callOpenCode( diff --git a/frontend/src/api/sessionGoals.ts b/frontend/src/api/sessionGoals.ts deleted file mode 100644 index a42dd9802..000000000 --- a/frontend/src/api/sessionGoals.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { fetchWrapper } from './fetchWrapper' -import { API_BASE_URL } from '@/config' -import { - LOCAL_OPENCODE_SERVER_ID, - type SessionGoal, - type StartSessionGoalRequest, -} from '@opencode-manager/shared/schemas' - -export async function getLatestSessionGoal( - sessionId: string, - serverId: string = LOCAL_OPENCODE_SERVER_ID, -): Promise { - const res = await fetchWrapper<{ goal: SessionGoal | null }>(`${API_BASE_URL}/api/session-goals`, { - params: { sessionId, serverId }, - }) - return res.goal -} - -export async function startSessionGoal(input: StartSessionGoalRequest): Promise { - const res = await fetchWrapper<{ goal: SessionGoal }>(`${API_BASE_URL}/api/session-goals`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(input), - }) - return res.goal -} - -async function runGoalAction(id: number, action: 'pause' | 'resume' | 'cancel'): Promise { - const res = await fetchWrapper<{ goal: SessionGoal }>( - `${API_BASE_URL}/api/session-goals/${encodeURIComponent(id)}/${action}`, - { method: 'POST' }, - ) - return res.goal -} - -export function pauseSessionGoal(id: number): Promise { - return runGoalAction(id, 'pause') -} - -export function resumeSessionGoal(id: number): Promise { - return runGoalAction(id, 'resume') -} - -export function cancelSessionGoal(id: number): Promise { - return runGoalAction(id, 'cancel') -} diff --git a/frontend/src/components/message/PromptInput.command.test.tsx b/frontend/src/components/message/PromptInput.command.test.tsx index 226567b53..5cd875cb1 100644 --- a/frontend/src/components/message/PromptInput.command.test.tsx +++ b/frontend/src/components/message/PromptInput.command.test.tsx @@ -103,7 +103,7 @@ vi.mock('@/components/session/PermissionModeToggle', () => ({ vi.mock('@/hooks/useSessionGoals', () => ({ useSessionGoal: () => ({ data: undefined }), - useStartSessionGoal: () => ({ mutateAsync: vi.fn(), isPending: false }), + useSessionGoalCommand: () => ({ mutate: vi.fn(), mutateAsync: vi.fn(), isPending: false }), })) vi.mock('@/hooks/useSessionPermissionMode', () => ({ diff --git a/frontend/src/components/message/PromptInput.composer.test.tsx b/frontend/src/components/message/PromptInput.composer.test.tsx index e7080deed..0cff285de 100644 --- a/frontend/src/components/message/PromptInput.composer.test.tsx +++ b/frontend/src/components/message/PromptInput.composer.test.tsx @@ -95,7 +95,7 @@ vi.mock('./ComposerToolsMenu', () => ({ vi.mock('./PromptSuggestions', () => ({ PromptSuggestions: () => null })) vi.mock('@/hooks/useSessionGoals', () => ({ useSessionGoal: () => ({ data: undefined }), - useStartSessionGoal: () => ({ mutateAsync: vi.fn(), isPending: false }), + useSessionGoalCommand: () => ({ mutate: vi.fn(), mutateAsync: vi.fn(), isPending: false }), })) vi.mock('@/hooks/useSessionPermissionMode', () => ({ useSessionPermissionMode: () => ({ data: undefined }) })) diff --git a/frontend/src/components/message/PromptInput.draft.test.tsx b/frontend/src/components/message/PromptInput.draft.test.tsx index 087bf0d39..3b9193d74 100644 --- a/frontend/src/components/message/PromptInput.draft.test.tsx +++ b/frontend/src/components/message/PromptInput.draft.test.tsx @@ -82,7 +82,7 @@ vi.mock('./ComposerToolsMenu', () => ({ ComposerToolsMenu: () => null })) vi.mock('./PromptSuggestions', () => ({ PromptSuggestions: () => null })) vi.mock('@/hooks/useSessionGoals', () => ({ useSessionGoal: () => ({ data: undefined }), - useStartSessionGoal: () => ({ mutateAsync: vi.fn(), isPending: false }), + useSessionGoalCommand: () => ({ mutate: vi.fn(), mutateAsync: vi.fn(), isPending: false }), })) vi.mock('@/hooks/useSessionPermissionMode', () => ({ useSessionPermissionMode: () => ({ data: undefined }) })) diff --git a/frontend/src/components/message/PromptInput.goal.test.tsx b/frontend/src/components/message/PromptInput.goal.test.tsx index 011673e91..51b296db8 100644 --- a/frontend/src/components/message/PromptInput.goal.test.tsx +++ b/frontend/src/components/message/PromptInput.goal.test.tsx @@ -9,7 +9,9 @@ const mocks = vi.hoisted(() => ({ sendPrompt: vi.fn(), sendShell: vi.fn(), interrupt: vi.fn(), - startGoal: vi.fn(), + runCommand: vi.fn(), + syncSelection: vi.fn(), + getSessionGoal: vi.fn(), useSessionGoal: vi.fn(), useSessionPermissionMode: vi.fn(), agents: [] as Array<{ id: string; name: string; description?: string; mode?: string; hidden?: boolean }>, @@ -48,27 +50,24 @@ vi.mock('@/hooks/useOpenCode', async (importOriginal) => { useAgents: () => ({ data: mocks.agents }), useSkills: () => ({ data: mocks.skills }), useSession: () => ({ data: undefined }), + useSyncSessionSelection: () => mocks.syncSelection, } }) -vi.mock('@/hooks/useSessionGoals', async () => { - const { useState } = await import('react') +vi.mock('@/api/opencode', async (importOriginal) => { + const actual = await importOriginal() return { + ...actual, + runCommand: mocks.runCommand, + getSessionGoal: mocks.getSessionGoal, + } +}) + +vi.mock('@/hooks/useSessionGoals', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, useSessionGoal: mocks.useSessionGoal, - useStartSessionGoal: () => { - const [isPending, setIsPending] = useState(false) - return { - mutateAsync: async (variables: unknown) => { - setIsPending(true) - try { - return await mocks.startGoal(variables) - } finally { - setIsPending(false) - } - }, - isPending, - } - }, } }) @@ -142,6 +141,17 @@ const createTestQueryClient = () => new QueryClient({ const GOAL_BUTTON = 'Goal mode: the next message becomes the objective' const OPEN_GOAL_BUTTON = 'A goal is already active for this session' +const UNSUPPORTED_GOAL_BUTTON = "This server's OpenCode has no goals" + +const goalState = { + id: 'goal_1', + objective: 'Ship the feature', + status: 'active', + rounds: 1, + maxRounds: 50, + boot: 'boot_1', + updatedAt: 1, +} describe('PromptInput goal mode', () => { const defaultProps = { @@ -167,9 +177,11 @@ describe('PromptInput goal mode', () => { vi.clearAllMocks() useComposerDraftStore.setState({ drafts: {} }) mocks.useVoiceConversation.mockReturnValue(createVoiceConversationControllerMock()) - mocks.useSessionGoal.mockReturnValue({ data: null }) + mocks.useSessionGoal.mockReturnValue({ data: { supported: true, goal: null } }) mocks.useSessionPermissionMode.mockReturnValue({ data: undefined }) - mocks.startGoal.mockResolvedValue({ id: 1 }) + mocks.runCommand.mockResolvedValue(undefined) + mocks.syncSelection.mockResolvedValue(undefined) + mocks.getSessionGoal.mockResolvedValue({ supported: true, goal: null }) mocks.sendPrompt.mockResolvedValue({ id: 'inbox_1' }) mocks.sendShell.mockResolvedValue({ id: 'shell_1' }) mocks.agents = [] @@ -190,6 +202,7 @@ describe('PromptInput goal mode', () => { mocks.useFileSearch.mockReturnValue({ files: [] }) mocks.useModelSelection.mockReturnValue({ model: { providerID: 'anthropic', modelID: 'claude-sonnet-4' }, + modelRef: { providerID: 'anthropic', modelID: 'claude-sonnet-4' }, modelString: 'anthropic/claude-sonnet-4', setModel: vi.fn(), setActiveAgent: vi.fn(), @@ -209,7 +222,7 @@ describe('PromptInput goal mode', () => { Reflect.deleteProperty(window, 'matchMedia') }) - it('starts a goal with the message objective before sending it', async () => { + it('runs the goal command with the message objective instead of sending the prompt', async () => { stubMatchMedia(true) renderComponent() @@ -218,67 +231,120 @@ describe('PromptInput goal mode', () => { fireEvent.click(screen.getByRole('button', { name: GOAL_BUTTON })) fireEvent.click(screen.getByTitle('Send')) - await waitFor(() => expect(mocks.startGoal).toHaveBeenCalledWith({ - sessionId: 'test-session', - directory: '/test', - objective: 'Ship the feature', - })) - await waitFor(() => expect(mocks.sendPrompt).toHaveBeenCalled()) - expect(mocks.startGoal.mock.invocationCallOrder[0]).toBeLessThan(mocks.sendPrompt.mock.invocationCallOrder[0]) + await waitFor(() => expect(mocks.runCommand).toHaveBeenCalledTimes(1)) + expect(mocks.runCommand).toHaveBeenCalledWith({ + sessionID: 'test-session', + name: 'goal', + text: 'Ship the feature', + files: [], + agents: [], + skills: [], + }, 'local') + expect(mocks.sendPrompt).not.toHaveBeenCalled() }) - it('does not send the message when starting the goal is rejected', async () => { + it('runs the goal command instead of queueing when the session is busy', async () => { stubMatchMedia(true) - mocks.startGoal.mockRejectedValue(new Error('This session already has an open goal')) - renderComponent() + renderComponent({ isStreamingResponse: true }) const input = await screen.findByPlaceholderText('Send a message...') fireEvent.change(input, { target: { value: 'Ship the feature' } }) fireEvent.click(screen.getByRole('button', { name: GOAL_BUTTON })) - fireEvent.click(screen.getByTitle('Send')) + fireEvent.click(screen.getByTitle('Queue message')) - await waitFor(() => expect(mocks.startGoal).toHaveBeenCalled()) + await waitFor(() => expect(mocks.runCommand).toHaveBeenCalledTimes(1)) + expect(mocks.runCommand).toHaveBeenCalledWith({ + sessionID: 'test-session', + name: 'goal', + text: 'Ship the feature', + files: [], + agents: [], + skills: [], + }, 'local') expect(mocks.sendPrompt).not.toHaveBeenCalled() }) - it('does not start a goal when the mode was not armed', async () => { + it('does not send the prompt when the goal command is rejected', async () => { stubMatchMedia(true) + mocks.runCommand.mockRejectedValue(new Error('goal command failed')) renderComponent() const input = await screen.findByPlaceholderText('Send a message...') - fireEvent.change(input, { target: { value: 'Just a message' } }) + fireEvent.change(input, { target: { value: 'Ship the feature' } }) + fireEvent.click(screen.getByRole('button', { name: GOAL_BUTTON })) fireEvent.click(screen.getByTitle('Send')) - await waitFor(() => expect(mocks.sendPrompt).toHaveBeenCalled()) - expect(mocks.startGoal).not.toHaveBeenCalled() + await waitFor(() => expect(mocks.runCommand).toHaveBeenCalledTimes(1)) + expect(mocks.sendPrompt).not.toHaveBeenCalled() }) - it('starts an armed goal before queueing when the session is busy', async () => { + it('passes the selected model and agent to the goal selection sync', async () => { stubMatchMedia(true) - renderComponent({ isStreamingResponse: true }) + renderComponent() const input = await screen.findByPlaceholderText('Send a message...') fireEvent.change(input, { target: { value: 'Ship the feature' } }) fireEvent.click(screen.getByRole('button', { name: GOAL_BUTTON })) - fireEvent.click(screen.getByTitle('Queue message')) + fireEvent.click(screen.getByTitle('Send')) - await waitFor(() => expect(mocks.startGoal).toHaveBeenCalledWith({ - sessionId: 'test-session', - directory: '/test', - objective: 'Ship the feature', + await waitFor(() => expect(mocks.syncSelection).toHaveBeenCalledWith({ + sessionID: 'test-session', + model: { providerID: 'anthropic', modelID: 'claude-sonnet-4' }, + agent: 'build', })) - await waitFor(() => expect(mocks.sendPrompt).toHaveBeenCalledWith( - expect.objectContaining({ delivery: 'queue' }), - )) - expect(mocks.startGoal.mock.invocationCallOrder[0]).toBeLessThan(mocks.sendPrompt.mock.invocationCallOrder[0]) + expect(mocks.runCommand).toHaveBeenCalled() }) - it('does not start the goal again while it is still starting', async () => { + it('keeps the image draft and warns when an armed goal has no objective', async () => { stubMatchMedia(true) - let resolveGoal: (value: unknown) => void = () => {} - mocks.startGoal.mockImplementation( + const { container } = renderComponent() + + const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement + fireEvent.change(fileInput, { target: { files: [new File(['img'], 'pic.png', { type: 'image/png' })] } }) + await screen.findByText('pic.png') + + fireEvent.click(screen.getByRole('button', { name: GOAL_BUTTON })) + fireEvent.click(screen.getByTitle('Send')) + + await waitFor(() => expect(mocks.showToast.error).toHaveBeenCalledWith('Enter an objective for the goal')) + expect(mocks.runCommand).not.toHaveBeenCalled() + expect(mocks.sendPrompt).not.toHaveBeenCalled() + expect(screen.getByText('pic.png')).toBeInTheDocument() + }) + + it('keeps a whitespace-only draft and warns when an armed goal has no objective', async () => { + stubMatchMedia(true) + renderComponent() + + const input = await screen.findByPlaceholderText('Send a message...') + fireEvent.change(input, { target: { value: ' ' } }) + fireEvent.click(screen.getByRole('button', { name: GOAL_BUTTON })) + fireEvent.keyDown(input, { key: 'Enter', ctrlKey: true }) + + await waitFor(() => expect(mocks.showToast.error).toHaveBeenCalledWith('Enter an objective for the goal')) + expect(mocks.runCommand).not.toHaveBeenCalled() + expect(mocks.sendPrompt).not.toHaveBeenCalled() + expect(input).toHaveValue(' ') + }) + + it('sends the prompt without the goal command when the goal mode was not armed', async () => { + stubMatchMedia(true) + renderComponent() + + const input = await screen.findByPlaceholderText('Send a message...') + fireEvent.change(input, { target: { value: 'Just a message' } }) + fireEvent.click(screen.getByTitle('Send')) + + await waitFor(() => expect(mocks.sendPrompt).toHaveBeenCalled()) + expect(mocks.runCommand).not.toHaveBeenCalled() + }) + + it('does not run the goal command again while it is still pending', async () => { + stubMatchMedia(true) + let resolveCommand: (value: unknown) => void = () => {} + mocks.runCommand.mockImplementation( () => new Promise((resolve) => { - resolveGoal = resolve + resolveCommand = resolve }), ) renderComponent({ isStreamingResponse: true }) @@ -288,19 +354,19 @@ describe('PromptInput goal mode', () => { fireEvent.click(screen.getByRole('button', { name: GOAL_BUTTON })) fireEvent.click(screen.getByTitle('Queue message')) - await waitFor(() => expect(mocks.startGoal).toHaveBeenCalledTimes(1)) + await waitFor(() => expect(mocks.runCommand).toHaveBeenCalledTimes(1)) fireEvent.click(screen.getByTitle('Queue message')) - expect(mocks.startGoal).toHaveBeenCalledTimes(1) + expect(mocks.runCommand).toHaveBeenCalledTimes(1) expect(mocks.sendPrompt).not.toHaveBeenCalled() await act(async () => { - resolveGoal({ id: 1 }) + resolveCommand(undefined) }) }) - it('does not start an armed goal for a slash command', async () => { + it('sends an exact slash command without running the goal command', async () => { stubMatchMedia(true) mocks.useCommands.mockReturnValue({ searchCommands: () => [], @@ -315,27 +381,36 @@ describe('PromptInput goal mode', () => { fireEvent.click(screen.getByTitle('Queue message')) await waitFor(() => expect(mocks.sendPrompt).toHaveBeenCalled()) - expect(mocks.startGoal).not.toHaveBeenCalled() + expect(mocks.runCommand).not.toHaveBeenCalled() + }) + + it('disables goal mode when the server has no goal support', async () => { + stubMatchMedia(true) + mocks.useSessionGoal.mockReturnValue({ data: { supported: false } }) + renderComponent() + + const button = await screen.findByRole('button', { name: UNSUPPORTED_GOAL_BUTTON }) + expect(button).toBeDisabled() }) it('disables goal mode while a goal is already active', async () => { stubMatchMedia(true) - mocks.useSessionGoal.mockReturnValue({ data: { status: 'active' } }) + mocks.useSessionGoal.mockReturnValue({ data: { supported: true, goal: goalState } }) renderComponent() const button = await screen.findByRole('button', { name: OPEN_GOAL_BUTTON }) expect(button).toBeDisabled() }) - it('disables goal mode with a reason for scheduled-run sessions', async () => { + it('keeps goal mode enabled on child sessions', async () => { stubMatchMedia(true) mocks.useSessionPermissionMode.mockReturnValue({ - data: { sessionId: 'test-session', rootSessionId: 'test-session', mode: 'ask', lockedReason: 'schedule' }, + data: { sessionId: 'test-session', rootSessionId: 'test-session', mode: 'ask', lockedReason: 'child' }, }) renderComponent() - const button = await screen.findByRole('button', { name: 'Scheduled runs cannot run goals' }) - expect(button).toBeDisabled() + const button = await screen.findByRole('button', { name: GOAL_BUTTON }) + expect(button).toBeEnabled() }) it('keeps permission and goal toggles inline on desktop', async () => { @@ -358,7 +433,7 @@ describe('PromptInput goal mode', () => { expect(screen.queryByTestId('permission-mode-toggle')).not.toBeInTheDocument() }) - it('starts a goal armed from the mobile composer options menu', async () => { + it('runs the goal command when armed from the mobile composer options menu', async () => { stubMatchMedia(true) mocks.useMobile.mockReturnValue(true) renderComponent() @@ -368,32 +443,25 @@ describe('PromptInput goal mode', () => { fireEvent.click(screen.getByTestId('composer-tools-menu')) fireEvent.click(screen.getByTitle('Send')) - await waitFor(() => expect(mocks.startGoal).toHaveBeenCalledWith({ - sessionId: 'test-session', - directory: '/test', - objective: 'Ship the feature', - })) + await waitFor(() => expect(mocks.runCommand).toHaveBeenCalledTimes(1)) + expect(mocks.runCommand).toHaveBeenCalledWith({ + sessionID: 'test-session', + name: 'goal', + text: 'Ship the feature', + files: [], + agents: [], + skills: [], + }, 'local') }) - it('passes the goal lock reason to the mobile composer options menu', async () => { + it('passes the open goal state to the mobile composer options menu', async () => { stubMatchMedia(true) mocks.useMobile.mockReturnValue(true) - mocks.useSessionGoal.mockReturnValue({ data: { status: 'active' } }) + mocks.useSessionGoal.mockReturnValue({ data: { supported: true, goal: goalState } }) renderComponent() const menu = await screen.findByTestId('composer-tools-menu') expect(menu).toHaveAttribute('title', OPEN_GOAL_BUTTON) expect(menu).toBeDisabled() }) - - it('disables goal mode with a reason for child sessions', async () => { - stubMatchMedia(true) - mocks.useSessionPermissionMode.mockReturnValue({ - data: { sessionId: 'test-session', rootSessionId: 'test-session', mode: 'ask', lockedReason: 'child' }, - }) - renderComponent() - - const button = await screen.findByRole('button', { name: 'Goals can only be started on top-level sessions' }) - expect(button).toBeDisabled() - }) }) diff --git a/frontend/src/components/message/PromptInput.mention.test.tsx b/frontend/src/components/message/PromptInput.mention.test.tsx index aaf38ed15..386848199 100644 --- a/frontend/src/components/message/PromptInput.mention.test.tsx +++ b/frontend/src/components/message/PromptInput.mention.test.tsx @@ -89,7 +89,7 @@ vi.mock('@/components/session/PermissionModeToggle', () => ({ vi.mock('@/hooks/useSessionGoals', () => ({ useSessionGoal: () => ({ data: undefined }), - useStartSessionGoal: () => ({ mutateAsync: vi.fn(), isPending: false }), + useSessionGoalCommand: () => ({ mutate: vi.fn(), mutateAsync: vi.fn(), isPending: false }), })) vi.mock('@/hooks/useSessionPermissionMode', () => ({ diff --git a/frontend/src/components/message/PromptInput.stt.test.tsx b/frontend/src/components/message/PromptInput.stt.test.tsx index c09a9656f..5d5349b73 100644 --- a/frontend/src/components/message/PromptInput.stt.test.tsx +++ b/frontend/src/components/message/PromptInput.stt.test.tsx @@ -136,7 +136,7 @@ vi.mock('@/components/session/SessionSandboxMenu', () => ({ vi.mock('@/hooks/useSessionGoals', () => ({ useSessionGoal: () => ({ data: undefined }), - useStartSessionGoal: () => ({ mutateAsync: vi.fn(), isPending: false }), + useSessionGoalCommand: () => ({ mutate: vi.fn(), mutateAsync: vi.fn(), isPending: false }), })) vi.mock('@/hooks/useSessionPermissionMode', () => ({ diff --git a/frontend/src/components/message/PromptInput.tsx b/frontend/src/components/message/PromptInput.tsx index 2be610038..f0a8d4598 100644 --- a/frontend/src/components/message/PromptInput.tsx +++ b/frontend/src/components/message/PromptInput.tsx @@ -33,16 +33,14 @@ import { PermissionModeToggle } from '@/components/session/PermissionModeToggle' import { SessionSandboxMenu } from '@/components/session/SessionSandboxMenu' import { ComposerModelPill } from './ComposerModelPill' import { ComposerToolsMenu } from './ComposerToolsMenu' -import { useSessionGoal, useStartSessionGoal } from '@/hooks/useSessionGoals' -import { useSessionPermissionMode } from '@/hooks/useSessionPermissionMode' -import { detectMentionTrigger, parsePromptToInput, getFilename, getDirectory, type MentionItem } from '@/lib/promptParser' +import { useSessionGoal, useSessionGoalCommand } from '@/hooks/useSessionGoals' +import { detectMentionTrigger, parsePromptToInput, getFilename, getDirectory, type MentionItem, type ParsedPromptInput } from '@/lib/promptParser' import { matchText, rankByMatch, type MatchRange } from '@/lib/fuzzyMatch' import { getNextPrimaryAgentId } from '@/lib/primaryAgents' import { getNextFavoriteModel } from '@/lib/favoriteModels' import { randomId } from '@/lib/utils' import { showToast } from '@/lib/toast' -import { findModelInfo, isSameModelSelection } from '@opencode-manager/shared/opencode' -import { isOpenSessionGoal } from '@opencode-manager/shared/schemas' +import { findModelInfo, isGoalOpen, isSameModelSelection } from '@opencode-manager/shared/opencode' import { useProviders } from '@/hooks/useProviders' @@ -255,11 +253,10 @@ export const PromptInput = memo(forwardRef( const setStoredAgent = useSessionAgentStore((s) => s.setAgent) const sendPrompt = useSendPrompt(directory) const sendShell = useSendShell(directory) - const startGoal = useStartSessionGoal() - const isPromptSubmitPending = sendPrompt.isPending || sendShell.isPending || startGoal.isPending + const goalCommand = useSessionGoalCommand(directory) + const isPromptSubmitPending = sendPrompt.isPending || sendShell.isPending || goalCommand.isPending const interruptSession = useInterruptSession() - const { data: sessionGoal } = useSessionGoal(sessionID) - const { data: permissionMode } = useSessionPermissionMode(sessionID) + const { data: goalRead } = useSessionGoal(sessionID, directory) const { searchCommands, findCommand, recentNames } = useCommands({ directory }) const recordCommand = useRecentCommandsStore((state) => state.recordCommand) const isExactCommandPrompt = (value: string) => { @@ -371,10 +368,17 @@ export const PromptInput = memo(forwardRef( const addUserBashCommand = useUserBash((s) => s.addUserBashCommand) - const startArmedGoal = async (objective: string): Promise => { - if (!directory || !objective.trim()) return true + const startArmedGoal = async (parsed: ParsedPromptInput): Promise => { try { - await startGoal.mutateAsync({ sessionId: sessionID, directory, objective }) + await goalCommand.mutateAsync({ + sessionID, + text: parsed.text, + files: parsed.files, + agents: parsed.agents, + skills: parsed.skills, + model: modelRef ?? undefined, + agent: currentMode, + }) } catch { return false } @@ -384,16 +388,23 @@ export const PromptInput = memo(forwardRef( const handleSubmit = async () => { if (!isModelReady) return + if (isGoalArmed && prompt.trim().length === 0) { + showToast.error('Enter an objective for the goal') + return + } if (!prompt.trim() && imageAttachments.length === 0) return - if (startGoal.isPending) return + if (goalCommand.isPending) return if (isStreamingResponse) { onScrollToBottom() const parsed = parsePromptToInput(prompt, attachedFiles, agentNames, skillIds, imageAttachments) const submitted: ComposerDraft = { prompt, isBashMode, attachedFiles, imageAttachments } if (isGoalArmed && !isBashMode && !isExactCommandPrompt(prompt)) { - const goalStarted = await startArmedGoal(parsed.text) + const goalStarted = await startArmedGoal(parsed) if (!goalStarted) return + setStoredAgent(sessionID, currentMode) + completeSubmittedDraft(submitted) + return } void runAfterSend( sendPrompt.mutateAsync({ @@ -465,8 +476,11 @@ export const PromptInput = memo(forwardRef( const submitted: ComposerDraft = { prompt, isBashMode, attachedFiles, imageAttachments } if (isGoalArmed) { - const goalStarted = await startArmedGoal(parsed.text) + const goalStarted = await startArmedGoal(parsed) if (!goalStarted) return + setStoredAgent(sessionID, currentMode) + completeSubmittedDraft(submitted) + return } pendingConfirmClearRef.current = { @@ -1102,18 +1116,16 @@ if (isIOS && isSecureContext && navigator.clipboard && navigator.clipboard.read) const composerModeStyle = composerMode ? COMPOSER_MODE_STYLES[composerMode] : null const isTakeoverOpen = isMobile && isSuggestionListOpen const showStopButton = isSessionActive - const hasOpenGoal = isOpenSessionGoal(sessionGoal) - const lockedReason = permissionMode?.lockedReason ?? null - const goalButtonLabel = lockedReason === 'schedule' - ? 'Scheduled runs cannot run goals' - : lockedReason === 'child' - ? 'Goals can only be started on top-level sessions' - : hasOpenGoal - ? 'A goal is already active for this session' - : isGoalArmed - ? 'Goal mode armed: the next message becomes the objective' - : 'Goal mode: the next message becomes the objective' - const isGoalModeDisabled = lockedReason !== null || hasOpenGoal || isBashMode || startGoal.isPending + const goalUnsupported = goalRead?.supported === false + const hasOpenGoal = goalRead?.supported === true && isGoalOpen(goalRead.goal) + const goalButtonLabel = goalUnsupported + ? "This server's OpenCode has no goals" + : hasOpenGoal + ? 'A goal is already active for this session' + : isGoalArmed + ? 'Goal mode armed: the next message becomes the objective' + : 'Goal mode: the next message becomes the objective' + const isGoalModeDisabled = goalUnsupported || hasOpenGoal || isBashMode || goalCommand.isPending const toggleGoalArmed = () => setIsGoalArmed((value) => !value) const sendVoiceTurn = useCallback( diff --git a/frontend/src/components/session/SessionGoalBar.test.tsx b/frontend/src/components/session/SessionGoalBar.test.tsx index 5b0b0384e..ed0d34e2a 100644 --- a/frontend/src/components/session/SessionGoalBar.test.tsx +++ b/frontend/src/components/session/SessionGoalBar.test.tsx @@ -2,58 +2,34 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { SessionGoalBar } from './SessionGoalBar' -import { - useCancelSessionGoal, - usePauseSessionGoal, - useResumeSessionGoal, - useSessionGoal, -} from '@/hooks/useSessionGoals' -import type { SessionGoal } from '@opencode-manager/shared/schemas' +import { useSessionGoal, useSessionGoalCommand } from '@/hooks/useSessionGoals' +import type { GoalState, SessionGoalRead } from '@opencode-manager/shared/opencode' vi.mock('@/hooks/useSessionGoals') -const baseGoal: SessionGoal = { - id: 1, - sessionId: 'ses_1', - serverId: 'local', - directory: '/repo', +const baseGoal: GoalState = { + id: 'goal_1', objective: 'Ship the feature', status: 'active', - stopReason: null, - turnState: 'running', - continuationCount: 2, - maxContinuations: 20, - tokenBudget: null, - tokensUsed: 0, - consecutiveBlocked: 0, - lastVerdict: null, - lastReason: null, - createdAt: 1, + rounds: 3, + maxRounds: 50, + boot: 'boot_1', updatedAt: 1, - finishedAt: null, } -function mockGoals( - goal: SessionGoal | null, - overrides: { - pause?: ReturnType - resume?: ReturnType - cancel?: ReturnType - } = {}, +function mockGoal( + read: SessionGoalRead, + overrides: { mutate?: ReturnType; isPending?: boolean } = {}, ) { - vi.mocked(useSessionGoal).mockReturnValue({ data: goal } as ReturnType) - vi.mocked(usePauseSessionGoal).mockReturnValue({ - mutate: overrides.pause ?? vi.fn(), - isPending: false, - } as unknown as ReturnType) - vi.mocked(useResumeSessionGoal).mockReturnValue({ - mutate: overrides.resume ?? vi.fn(), - isPending: false, - } as unknown as ReturnType) - vi.mocked(useCancelSessionGoal).mockReturnValue({ - mutate: overrides.cancel ?? vi.fn(), - isPending: false, - } as unknown as ReturnType) + vi.mocked(useSessionGoal).mockReturnValue({ data: read } as ReturnType) + vi.mocked(useSessionGoalCommand).mockReturnValue({ + mutate: overrides.mutate ?? vi.fn(), + isPending: overrides.isPending ?? false, + } as unknown as ReturnType) +} + +function renderBar() { + return render() } describe('SessionGoalBar', () => { @@ -61,85 +37,68 @@ describe('SessionGoalBar', () => { vi.clearAllMocks() }) - it('renders nothing when there is no goal', () => { - mockGoals(null) - const { container } = render() + it('renders nothing when the active goal is empty', () => { + mockGoal({ supported: true, goal: null }) + const { container } = renderBar() + expect(container).toBeEmptyDOMElement() + }) + + it('renders nothing when the server does not support goals', () => { + mockGoal({ supported: false }) + const { container } = renderBar() expect(container).toBeEmptyDOMElement() }) - it('shows the turn counter and pauses the active goal', async () => { + it('shows the active goal and pauses it', async () => { const user = userEvent.setup() - const pause = vi.fn() - mockGoals({ ...baseGoal }, { pause }) - render() + const mutate = vi.fn() + mockGoal({ supported: true, goal: { ...baseGoal } }, { mutate }) + renderBar() expect(screen.getByText('Goal active')).toBeInTheDocument() - expect(screen.getByText('Turn 2/20')).toBeInTheDocument() + expect(screen.getByText('Round 3/50')).toBeInTheDocument() expect(screen.getByText('Ship the feature')).toBeInTheDocument() await user.click(screen.getByRole('button', { name: 'Pause' })) + expect(mutate).toHaveBeenCalledWith({ sessionID: 'ses_1', text: 'pause' }) - expect(pause).toHaveBeenCalledWith(1) + await user.click(screen.getByRole('button', { name: 'Clear' })) + expect(mutate).toHaveBeenCalledWith({ sessionID: 'ses_1', text: 'clear' }) }) - it('shows token usage when a budget is set', () => { - mockGoals({ ...baseGoal, tokenBudget: 1000, tokensUsed: 250 }) - render() - - expect(screen.getByText('250/1,000 tokens')).toBeInTheDocument() - }) - - it('resumes a paused goal', async () => { + it('resumes a paused goal and shows the pause reason', async () => { const user = userEvent.setup() - const resume = vi.fn() - mockGoals({ ...baseGoal, status: 'paused' }, { resume }) - render() + const mutate = vi.fn() + mockGoal({ supported: true, goal: { ...baseGoal, status: 'paused', reason: 'Paused by the user' } }, { mutate }) + renderBar() expect(screen.getByText('Goal paused')).toBeInTheDocument() + expect(screen.getByText('Paused by the user')).toBeInTheDocument() await user.click(screen.getByRole('button', { name: 'Resume' })) - - expect(resume).toHaveBeenCalledWith(1) + expect(mutate).toHaveBeenCalledWith({ sessionID: 'ses_1', text: 'resume' }) }) - it('shows a terminal summary and can be dismissed', async () => { + it('shows a completed goal with its reason and clears it', async () => { const user = userEvent.setup() - mockGoals({ ...baseGoal, status: 'completed', stopReason: 'continuation_limit' }) - render() + const mutate = vi.fn() + mockGoal({ supported: true, goal: { ...baseGoal, status: 'complete', reason: 'Objective met' } }, { mutate }) + renderBar() expect(screen.getByText('Goal completed')).toBeInTheDocument() - expect(screen.getByText('Continuation limit reached')).toBeInTheDocument() + expect(screen.getByText('Objective met')).toBeInTheDocument() expect(screen.queryByRole('button', { name: 'Pause' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Resume' })).not.toBeInTheDocument() - await user.click(screen.getByRole('button', { name: 'Dismiss' })) - - expect(screen.queryByText('Goal completed')).not.toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Clear' })) + expect(mutate).toHaveBeenCalledWith({ sessionID: 'ses_1', text: 'clear' }) }) - it('shows the shared stop-reason wording for a user-paused goal', () => { - mockGoals({ ...baseGoal, status: 'stopped', stopReason: 'user_paused' }) - render() - - expect(screen.getByText('Goal stopped')).toBeInTheDocument() - expect(screen.getByText('Paused by user')).toBeInTheDocument() - }) - - it('shows the shared stop-reason wording for an audit failure', () => { - mockGoals({ ...baseGoal, status: 'blocked', stopReason: 'audit_failed' }) - render() - - expect(screen.getByText('Goal blocked')).toBeInTheDocument() - expect(screen.getByText('Audit failed')).toBeInTheDocument() - }) - - it('cancels the goal through the cancel API', async () => { - const user = userEvent.setup() - const cancel = vi.fn() - mockGoals({ ...baseGoal }, { cancel }) - render() - - await user.click(screen.getByRole('button', { name: 'Cancel' })) + it('disables the goal buttons while the command is pending', () => { + mockGoal({ supported: true, goal: { ...baseGoal } }, { isPending: true }) + renderBar() - expect(cancel).toHaveBeenCalledWith(1) + expect(screen.getByRole('button', { name: 'Pause' })).toBeDisabled() + expect(screen.getByRole('button', { name: 'Clear' })).toBeDisabled() }) }) diff --git a/frontend/src/components/session/SessionGoalBar.tsx b/frontend/src/components/session/SessionGoalBar.tsx index 8d64add1c..1734464ec 100644 --- a/frontend/src/components/session/SessionGoalBar.tsx +++ b/frontend/src/components/session/SessionGoalBar.tsx @@ -2,37 +2,28 @@ import { useState } from 'react' import { ChevronDown, ChevronUp, Target } from 'lucide-react' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' -import { - useCancelSessionGoal, - usePauseSessionGoal, - useResumeSessionGoal, - useSessionGoal, -} from '@/hooks/useSessionGoals' -import { getGoalOutcomeTitle, getGoalTokenLabel, getGoalTurnLabel, getGoalOutcomeReason } from '@opencode-manager/shared/notifications' -import { isTerminalSessionGoal, type SessionGoalStatus } from '@opencode-manager/shared/schemas' +import { useSessionGoal, useSessionGoalCommand } from '@/hooks/useSessionGoals' +import { getGoalRoundsLabel, getGoalStatusTitle } from '@opencode-manager/shared/notifications' +import type { GoalStatus } from '@opencode-manager/shared/opencode' -const STATUS_CHIP_CLASSES: Record = { +const STATUS_CHIP_CLASSES: Record = { active: 'bg-highlight text-highlight-foreground border-highlight', paused: 'bg-warning/15 text-warning border-warning/40', - completed: 'bg-success/15 text-success border-success/40', - blocked: 'bg-destructive/15 text-destructive border-destructive/40', - stopped: 'bg-muted text-muted-foreground border-border', + complete: 'bg-success/15 text-success border-success/40', } -export function SessionGoalBar({ sessionID }: { sessionID: string }) { - const { data: goal } = useSessionGoal(sessionID) - const pauseGoal = usePauseSessionGoal() - const resumeGoal = useResumeSessionGoal() - const cancelGoal = useCancelSessionGoal() +const GOAL_BUTTON_CLASSES = 'h-6 px-2 text-xs' + +/** Renders the session's OpenCode goal with pause, resume and clear controls. */ +export function SessionGoalBar({ sessionID, directory }: { sessionID: string; directory: string | undefined }) { + const { data } = useSessionGoal(sessionID, directory) + const goalCommand = useSessionGoalCommand(directory) const [expanded, setExpanded] = useState(false) - const [dismissedGoalId, setDismissedGoalId] = useState(null) - if (!goal) return null - const terminal = isTerminalSessionGoal(goal) - if (terminal && dismissedGoalId === goal.id) return null + if (!data || !data.supported || data.goal === null) return null - const reason = getGoalOutcomeReason(goal) - const tokenLabel = getGoalTokenLabel(goal) + const goal = data.goal + const open = goal.status !== 'complete' return (
@@ -42,74 +33,49 @@ export function SessionGoalBar({ sessionID }: { sessionID: string }) { className={`gap-1 px-2 py-0.5 font-medium ${STATUS_CHIP_CLASSES[goal.status]}`} > - {getGoalOutcomeTitle(goal.status)} + {getGoalStatusTitle(goal.status)} - {!terminal && ( - - {getGoalTurnLabel(goal)} - - )} - - {!terminal && tokenLabel !== null && ( - - {tokenLabel} - + {goal.status === 'active' && ( + {getGoalRoundsLabel(goal)} )} - {terminal ? ( - <> - {reason && ( - - {reason} - - )} +
+ {goal.status === 'active' && ( - - ) : ( -
- {goal.status === 'paused' ? ( - - ) : ( - - )} + )} + {goal.status === 'paused' && ( -
- )} + )} + +
- {!terminal && ( + {open && (

{goal.objective} @@ -126,9 +92,9 @@ export function SessionGoalBar({ sessionID }: { sessionID: string }) {

)} - {!terminal && goal.lastReason && ( -

- {goal.lastReason} + {goal.reason && ( +

+ {goal.reason}

)} diff --git a/frontend/src/components/settings/NotificationSettings.tsx b/frontend/src/components/settings/NotificationSettings.tsx index ab876109d..80edd87cf 100644 --- a/frontend/src/components/settings/NotificationSettings.tsx +++ b/frontend/src/components/settings/NotificationSettings.tsx @@ -220,7 +220,7 @@ export function NotificationSettings() { Goal outcomes

- When a session goal completes, blocks or stops + When a session goal completes or pauses on its own

({ - useProviders: vi.fn(), - useOpenCodeModelState: vi.fn(), - useOpenCodeDefaultModel: vi.fn(), -})) - -vi.mock('@/hooks/useProviders', () => ({ useProviders: mocks.useProviders })) -vi.mock('@/hooks/useModelSelection', () => ({ - useOpenCodeModelState: mocks.useOpenCodeModelState, - useOpenCodeDefaultModel: mocks.useOpenCodeDefaultModel, -})) - -const providers: Provider[] = [ - { - id: 'anthropic', - name: 'Anthropic', - models: [ - { id: 'claude-sonnet-4', key: 'claude-sonnet-4', name: 'Claude Sonnet 4', released: 0, free: false }, - ], - }, -] - const basePreferences: UserPreferences = { theme: 'dark', mode: 'build', @@ -56,9 +33,6 @@ describe('SessionAutomationSettings', () => { beforeEach(() => { vi.clearAllMocks() - mocks.useProviders.mockReturnValue({ data: { providers, models: [] } }) - mocks.useOpenCodeModelState.mockReturnValue({ data: { favorite: [], recent: [], variant: {} } }) - mocks.useOpenCodeDefaultModel.mockReturnValue({ data: null }) }) it('defaults the select to ask every time', () => { @@ -84,179 +58,13 @@ describe('SessionAutomationSettings', () => { expect(updateSettings).toHaveBeenCalledWith({ sessionDefaults: { permissionMode: 'auto' } }) }) - it('saves the goal auditor model chosen from the combobox after the debounce', () => { - vi.useFakeTimers() - try { - const updateSettings = vi.fn() - mockUseSettings({ - preferences: { ...basePreferences, sessionDefaults: { permissionMode: 'ask', goalMaxContinuations: 20 } }, - updateSettings, - }) - render() - - fireEvent.focus(screen.getByLabelText('Goal auditor model')) - fireEvent.click(screen.getByRole('option', { name: /Claude Sonnet 4/ })) - act(() => { - vi.advanceTimersByTime(800) - }) - - expect(updateSettings).toHaveBeenCalledTimes(1) - expect(updateSettings).toHaveBeenCalledWith({ - sessionDefaults: { - permissionMode: 'ask', - goalMaxContinuations: 20, - goalAuditorModel: 'anthropic/claude-sonnet-4', - }, - }) - } finally { - vi.useRealTimers() - } - }) - - it('clears the goal auditor model', () => { - vi.useFakeTimers() - try { - const updateSettings = vi.fn() - mockUseSettings({ - preferences: { - ...basePreferences, - sessionDefaults: { - permissionMode: 'ask', - goalMaxContinuations: 20, - goalAuditorModel: 'anthropic/claude-sonnet-4', - }, - }, - updateSettings, - }) - render() - - fireEvent.click(screen.getByRole('button', { name: 'Clear' })) - act(() => { - vi.advanceTimersByTime(800) - }) - - expect(updateSettings).toHaveBeenCalledTimes(1) - expect(updateSettings).toHaveBeenCalledWith({ - sessionDefaults: { - permissionMode: 'ask', - goalMaxContinuations: 20, - goalAuditorModel: undefined, - }, - }) - } finally { - vi.useRealTimers() - } - }) - - it('saves the goal auditor model after the debounce without blurring', () => { - vi.useFakeTimers() - try { - const updateSettings = vi.fn() - mockUseSettings({ - preferences: { ...basePreferences, sessionDefaults: { permissionMode: 'ask', goalMaxContinuations: 20 } }, - updateSettings, - }) - render() - - fireEvent.change(screen.getByLabelText('Goal auditor model'), { - target: { value: 'anthropic/claude-sonnet-4' }, - }) - act(() => { - vi.advanceTimersByTime(800) - }) - - expect(updateSettings).toHaveBeenCalledTimes(1) - expect(updateSettings).toHaveBeenCalledWith({ - sessionDefaults: { - permissionMode: 'ask', - goalMaxContinuations: 20, - goalAuditorModel: 'anthropic/claude-sonnet-4', - }, - }) - } finally { - vi.useRealTimers() - } - }) - - it('saves the auditor model and max continuations together after the debounce', () => { - vi.useFakeTimers() - try { - const updateSettings = vi.fn() - mockUseSettings({ - preferences: { ...basePreferences, sessionDefaults: { permissionMode: 'ask', goalMaxContinuations: 20 } }, - updateSettings, - }) - render() - - fireEvent.change(screen.getByLabelText('Goal auditor model'), { - target: { value: 'anthropic/claude-sonnet-4' }, - }) - fireEvent.change(screen.getByLabelText('Max automatic continuations'), { - target: { value: '50' }, - }) - act(() => { - vi.advanceTimersByTime(800) - }) - - expect(updateSettings).toHaveBeenCalledTimes(1) - expect(updateSettings).toHaveBeenCalledWith({ - sessionDefaults: { - permissionMode: 'ask', - goalAuditorModel: 'anthropic/claude-sonnet-4', - goalMaxContinuations: 50, - }, - }) - } finally { - vi.useRealTimers() - } - }) - - it('saves an in-range max automatic continuations on blur', () => { - const updateSettings = vi.fn() - mockUseSettings({ - preferences: { ...basePreferences, sessionDefaults: { permissionMode: 'ask', goalMaxContinuations: 20 } }, - updateSettings, - }) - render() - - const input = screen.getByLabelText('Max automatic continuations') - fireEvent.change(input, { target: { value: '50' } }) - fireEvent.blur(input) - - expect(updateSettings).toHaveBeenCalledWith({ - sessionDefaults: { permissionMode: 'ask', goalMaxContinuations: 50 }, - }) - }) - - it('does not save out-of-range max automatic continuations', () => { - const updateSettings = vi.fn() - mockUseSettings({ - preferences: { ...basePreferences, sessionDefaults: { permissionMode: 'ask', goalMaxContinuations: 20 } }, - updateSettings, - }) - render() - - const input = screen.getByLabelText('Max automatic continuations') - fireEvent.change(input, { target: { value: '500' } }) - fireEvent.blur(input) - - expect(updateSettings).not.toHaveBeenCalled() - }) - - it('saves the token budget per goal on blur', () => { - const updateSettings = vi.fn() - mockUseSettings({ - preferences: { ...basePreferences, sessionDefaults: { permissionMode: 'ask', goalMaxContinuations: 20 } }, - updateSettings, - }) + it('renders no goal controls', () => { + mockUseSettings({ preferences: { ...basePreferences, sessionDefaults: { permissionMode: 'ask' } } }) render() - const input = screen.getByLabelText('Token budget per goal') - fireEvent.change(input, { target: { value: '5000' } }) - fireEvent.blur(input) - - expect(updateSettings).toHaveBeenCalledWith({ - sessionDefaults: { permissionMode: 'ask', goalMaxContinuations: 20, goalTokenBudget: 5000 }, - }) + expect(screen.getByRole('combobox', { name: 'Default permission mode for new sessions' })).toBeInTheDocument() + expect(screen.queryByLabelText('Goal auditor model')).toBeNull() + expect(screen.queryByLabelText('Max automatic continuations')).toBeNull() + expect(screen.queryByLabelText('Token budget per goal')).toBeNull() }) }) diff --git a/frontend/src/components/settings/SessionAutomationSettings.tsx b/frontend/src/components/settings/SessionAutomationSettings.tsx index d4912d9f7..d62101bdb 100644 --- a/frontend/src/components/settings/SessionAutomationSettings.tsx +++ b/frontend/src/components/settings/SessionAutomationSettings.tsx @@ -1,44 +1,13 @@ import { useCallback } from 'react' import { useSettings } from '@/hooks/useSettings' -import { useAutosavedSetting } from '@/hooks/useAutosavedSetting' import { Label } from '@/components/ui/label' -import { Input } from '@/components/ui/input' -import { ModelCombobox } from '@/components/model/ModelCombobox' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { DEFAULT_SESSION_DEFAULTS, - GOAL_MAX_CONTINUATIONS_MAX, - GOAL_MAX_CONTINUATIONS_MIN, type SessionDefaults, type SessionPermissionMode, } from '@opencode-manager/shared/schemas' -interface GoalDraft { - goalAuditorModel: string - goalMaxContinuations: string - goalTokenBudget: string -} - -interface GoalCommitted { - goalAuditorModel: string | undefined - goalMaxContinuations: number - goalTokenBudget: number | undefined -} - -function maxContinuationsFromInput(value: string): number | null { - const parsed = Number(value) - if (!Number.isInteger(parsed)) return null - if (parsed < GOAL_MAX_CONTINUATIONS_MIN || parsed > GOAL_MAX_CONTINUATIONS_MAX) return null - return parsed -} - -function tokenBudgetFromInput(value: string): { valid: boolean; value: number | undefined } { - const trimmed = value.trim() - if (trimmed === '') return { valid: true, value: undefined } - const parsed = Number(trimmed) - return Number.isInteger(parsed) && parsed > 0 ? { valid: true, value: parsed } : { valid: false, value: undefined } -} - export function SessionAutomationSettings() { const { preferences, updateSettings } = useSettings() const sessionDefaults = preferences?.sessionDefaults @@ -57,47 +26,6 @@ export function SessionAutomationSettings() { [sessionDefaults, permissionMode, updateSettings], ) - const { draft, setDraft, commit: commitGoalFields } = useAutosavedSetting({ - stored: sessionDefaults, - toDraft: (stored) => ({ - goalAuditorModel: stored?.goalAuditorModel ?? '', - goalMaxContinuations: String(stored?.goalMaxContinuations ?? DEFAULT_SESSION_DEFAULTS.goalMaxContinuations), - goalTokenBudget: stored?.goalTokenBudget !== undefined ? String(stored.goalTokenBudget) : '', - }), - toCommitted: (current, previous) => { - const budget = tokenBudgetFromInput(current.goalTokenBudget) - return { - goalAuditorModel: current.goalAuditorModel.trim() || undefined, - goalMaxContinuations: - maxContinuationsFromInput(current.goalMaxContinuations) ?? - previous?.goalMaxContinuations ?? - DEFAULT_SESSION_DEFAULTS.goalMaxContinuations, - goalTokenBudget: budget.valid ? budget.value : previous?.goalTokenBudget, - } - }, - isEqual: (a, b) => - a.goalAuditorModel === b.goalAuditorModel && - a.goalMaxContinuations === b.goalMaxContinuations && - a.goalTokenBudget === b.goalTokenBudget, - save: (next, previous) => { - const patch: Partial = {} - if (next.goalAuditorModel !== previous?.goalAuditorModel) patch.goalAuditorModel = next.goalAuditorModel - if (next.goalMaxContinuations !== previous?.goalMaxContinuations) { - patch.goalMaxContinuations = next.goalMaxContinuations - } - if (next.goalTokenBudget !== previous?.goalTokenBudget) patch.goalTokenBudget = next.goalTokenBudget - if (Object.keys(patch).length === 0) return - updateSessionDefaults(patch) - }, - }) - - const updateGoalDraft = useCallback( - (patch: Partial) => { - setDraft((current) => ({ ...current, ...patch })) - }, - [setDraft], - ) - return (

Sessions

@@ -122,63 +50,6 @@ export function SessionAutomationSettings() {
- -
-
- -

- Model that decides whether a session goal is done. Leave empty to use the OpenCode default model. -

-
- updateGoalDraft({ goalAuditorModel: value })} - placeholder="OpenCode default" - allowCustomValue - showClear - className="w-full shrink-0 sm:w-64" - /> -
- -
-
- -

- How many times a goal may keep the agent working after each audit before it stops. -

-
- updateGoalDraft({ goalMaxContinuations: event.target.value })} - onBlur={commitGoalFields} - /> -
- -
-
- -

- Stop a goal once it has spent this many tokens. Leave empty for no limit. -

-
- updateGoalDraft({ goalTokenBudget: event.target.value })} - onBlur={commitGoalFields} - /> -
) } diff --git a/frontend/src/contexts/EventContext.test.tsx b/frontend/src/contexts/EventContext.test.tsx index 8d1294b17..5206ab2f6 100644 --- a/frontend/src/contexts/EventContext.test.tsx +++ b/frontend/src/contexts/EventContext.test.tsx @@ -4,7 +4,7 @@ import userEvent from '@testing-library/user-event' import { useState, type ReactNode } from 'react' import { MemoryRouter, useLocation } from 'react-router-dom' import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { FormInfo, PermissionRequest } from '@opencode-manager/shared/opencode' +import type { FormInfo, PermissionRequest, GoalState } from '@opencode-manager/shared/opencode' import { FetchError } from '@opencode-manager/shared' import { showToast } from '@/lib/toast' import { useSessionStatus, hasBackgroundActivity } from '@/stores/sessionStatusStore' @@ -1248,6 +1248,44 @@ describe('EventProvider permissions and forms', () => { }) }) + it('applies a goal change event to the session goal cache', async () => { + const initialGoal: GoalState = { + id: 'goal-1', + objective: 'Ship the goal bar', + status: 'active', + rounds: 0, + maxRounds: 10, + boot: 'boot-1', + updatedAt: 1000, + } + const updatedGoal: GoalState = { ...initialGoal, rounds: 1, updatedAt: 2000 } + const queryClient = createTestQueryClient() + queryClient.setQueryData(['session-goal', 'session-1'], { supported: true, goal: initialGoal }) + + render(, { wrapper: createWrapper(queryClient) }) + + await waitFor(() => expect(mocks.subscribeGlobalMonitor).toHaveBeenCalled()) + + const lastSubscribeCall = mocks.subscribeGlobalMonitor.mock.calls[mocks.subscribeGlobalMonitor.mock.calls.length - 1] + const onEvent = lastSubscribeCall[0].onEvent as (data: unknown) => void + + act(() => { + onEvent({ + type: 'rpc.opencode.goal.changed', + data: { sessionID: 'session-1', goal: updatedGoal }, + directory: '/repo', + }) + }) + + expect(queryClient.getQueryData(['session-goal', 'session-1'])).toEqual({ supported: true, goal: updatedGoal }) + + act(() => { + onEvent({ type: 'session.idle', data: { sessionID: 'other' }, directory: '/repo' }) + }) + + expect(queryClient.getQueryData(['session-goal', 'session-1'])).toEqual({ supported: true, goal: updatedGoal }) + }) + it('invalidates the sibling caches when a branch changes outside a repo row', async () => { mocks.listRepos.mockResolvedValue([]) diff --git a/frontend/src/contexts/EventContext.tsx b/frontend/src/contexts/EventContext.tsx index 9e38d4b08..5a3dda5ca 100644 --- a/frontend/src/contexts/EventContext.tsx +++ b/frontend/src/contexts/EventContext.tsx @@ -28,6 +28,7 @@ import { getServerSessionPath, getSessionPath, parseRepoRoute, parseServerProjec import { serverScopedKey } from '@/lib/serverScope' import { repoMatchesDirectory } from '@/components/navigation/sidebar-session-tree' import { changeWalkthroughQueryKey } from '@/hooks/useChangeWalkthrough' +import { applySessionGoalEvent, SESSION_GOAL_QUERY_ROOT } from '@/hooks/useSessionGoals' import { busyStatusesFromActiveSessions, useSessionStatus } from '@/stores/sessionStatusStore' import { invalidateChildSessionCaches, @@ -1051,6 +1052,9 @@ export function EventProvider({ children }: { children: React.ReactNode }) { const event = data as StreamEvent const serverId = (event as { serverId?: string }).serverId ?? LOCAL_OPENCODE_SERVER_ID + const goalEvent = { type: event.type, data: 'data' in event ? event.data : null } + if (applySessionGoalEvent(queryClient, goalEvent, serverId)) return + switch (event.type) { case 'permission.asked': { const request = event.data @@ -1233,6 +1237,7 @@ export function EventProvider({ children }: { children: React.ReactNode }) { void syncRunningShells(collectTrackedDirectories()) invalidateChildSessionCaches(queryClient) invalidateQueryKeysDebounced(queryClient, LOCATION_CATALOG_QUERY_KEYS) + queryClient.invalidateQueries({ queryKey: [SESSION_GOAL_QUERY_ROOT] }) startStatusPoll() } else { streamConnectedRef.current = false @@ -1244,6 +1249,7 @@ export function EventProvider({ children }: { children: React.ReactNode }) { } const handleResync = (subscribedDirectories: string[], serverId?: string) => { + queryClient.invalidateQueries({ queryKey: [SESSION_GOAL_QUERY_ROOT] }) if (serverId && !isLocalOpenCodeServer(serverId)) { void reconcileRemoteDirectoryActivity(serverId, subscribedDirectories) invalidateSessionListCachesDebounced(queryClient, undefined, serverId) diff --git a/frontend/src/hooks/useSessionGoals.test.tsx b/frontend/src/hooks/useSessionGoals.test.tsx index d20f4f418..742727ca6 100644 --- a/frontend/src/hooks/useSessionGoals.test.tsx +++ b/frontend/src/hooks/useSessionGoals.test.tsx @@ -1,43 +1,47 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { renderHook, waitFor } from '@testing-library/react' +import { act, renderHook, waitFor } from '@testing-library/react' import type { ReactNode } from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { useSessionGoal } from './useSessionGoals' -import { getLatestSessionGoal } from '@/api/sessionGoals' +import type { GoalState, SessionGoalRead } from '@opencode-manager/shared/opencode' import { OpenCodeServerProvider } from '@/contexts/OpenCodeServerContext' -import type { SessionGoal, SessionGoalStatus } from '@opencode-manager/shared/schemas' - -vi.mock('@/api/sessionGoals', () => ({ - getLatestSessionGoal: vi.fn(), - startSessionGoal: vi.fn(), - pauseSessionGoal: vi.fn(), - resumeSessionGoal: vi.fn(), - cancelSessionGoal: vi.fn(), +import { showToast } from '@/lib/toast' +import { applySessionGoalEvent, useSessionGoal, useSessionGoalCommand } from './useSessionGoals' + +const mocks = vi.hoisted(() => ({ + getSessionGoal: vi.fn(), + runCommand: vi.fn(), + syncSelection: vi.fn(), +})) + +vi.mock('@/api/opencode', () => ({ + getSessionGoal: mocks.getSessionGoal, + runCommand: mocks.runCommand, +})) + +vi.mock('@/hooks/useOpenCode', () => ({ + useSyncSessionSelection: () => mocks.syncSelection, })) vi.mock('@/lib/toast', () => ({ showToast: { error: vi.fn() }, })) -const baseGoal: SessionGoal = { - id: 1, - sessionId: 'ses_1', - serverId: 'local', - directory: '/repo', +const goal: GoalState = { + id: 'goal_1', objective: 'Ship the feature', status: 'active', - stopReason: null, - turnState: 'running', - continuationCount: 0, - maxContinuations: 20, - tokenBudget: null, - tokensUsed: 0, - consecutiveBlocked: 0, - lastVerdict: null, - lastReason: null, - createdAt: 1, + rounds: 2, + maxRounds: 50, + boot: 'boot_1', updatedAt: 1, - finishedAt: null, +} + +const updatedGoal: GoalState = { ...goal, rounds: 3 } + +const supportedGoal: SessionGoalRead = { supported: true, goal } + +function createQueryClient() { + return new QueryClient({ defaultOptions: { queries: { retry: false } } }) } function createWrapper(queryClient: QueryClient, serverId?: string) { @@ -51,36 +55,190 @@ function createWrapper(queryClient: QueryClient, serverId?: string) { describe('useSessionGoal', () => { beforeEach(() => { vi.clearAllMocks() + mocks.getSessionGoal.mockResolvedValue(supportedGoal) }) - it('polls only while the goal is active', async () => { - vi.mocked(getLatestSessionGoal).mockResolvedValue(baseGoal) - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) - renderHook(() => useSessionGoal('ses_1'), { wrapper: createWrapper(queryClient) }) - - await waitFor(() => expect(getLatestSessionGoal).toHaveBeenCalled()) - - const query = queryClient.getQueryCache().find({ queryKey: ['session-goal', 'ses_1'] }) - const refetchInterval = query?.options.refetchInterval as - | ((query: { state: { data?: { status: SessionGoalStatus } } }) => number | false) - | undefined - - expect(refetchInterval).toBeTypeOf('function') - expect(refetchInterval!({ state: { data: { status: 'active' } } })).toBe(3000) - expect(refetchInterval!({ state: { data: { status: 'paused' } } })).toBe(false) - expect(refetchInterval!({ state: { data: { status: 'completed' } } })).toBe(false) - expect(refetchInterval!({ state: { data: { status: 'blocked' } } })).toBe(false) - expect(refetchInterval!({ state: { data: { status: 'stopped' } } })).toBe(false) - expect(refetchInterval!({ state: {} })).toBe(false) + it('reads the goal from the local server', async () => { + const queryClient = createQueryClient() + const { result } = renderHook(() => useSessionGoal('ses_1', '/repo'), { wrapper: createWrapper(queryClient) }) + + await waitFor(() => expect(result.current.data).toEqual(supportedGoal)) + + expect(mocks.getSessionGoal).toHaveBeenCalledWith('ses_1', '/repo', 'local') }) - it('reads the goal for the current server and scopes its cache key', async () => { - vi.mocked(getLatestSessionGoal).mockResolvedValue({ ...baseGoal, serverId: 'srv_remote' }) - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) - renderHook(() => useSessionGoal('ses_1'), { wrapper: createWrapper(queryClient, 'srv_remote') }) + it('reads the goal from the current remote server and scopes its cache key', async () => { + const queryClient = createQueryClient() + const { result } = renderHook(() => useSessionGoal('ses_1', '/repo'), { + wrapper: createWrapper(queryClient, 'srv_remote'), + }) - await waitFor(() => expect(getLatestSessionGoal).toHaveBeenCalledWith('ses_1', 'srv_remote')) + await waitFor(() => expect(result.current.data).toEqual(supportedGoal)) + expect(mocks.getSessionGoal).toHaveBeenCalledWith('ses_1', '/repo', 'srv_remote') expect(queryClient.getQueryCache().find({ queryKey: ['session-goal', 'ses_1', 'srv_remote'] })).toBeDefined() }) + + it('does not poll for goal updates', async () => { + vi.useFakeTimers() + try { + const queryClient = createQueryClient() + renderHook(() => useSessionGoal('ses_1', '/repo'), { wrapper: createWrapper(queryClient) }) + + await act(async () => { + await vi.advanceTimersByTimeAsync(10_000) + }) + + expect(mocks.getSessionGoal).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) +}) + +describe('applySessionGoalEvent', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getSessionGoal.mockResolvedValue(supportedGoal) + }) + + it('updates the cached goal without refetching', async () => { + const queryClient = createQueryClient() + const { result } = renderHook(() => useSessionGoal('ses_1', '/repo'), { wrapper: createWrapper(queryClient) }) + + await waitFor(() => expect(result.current.data).toEqual(supportedGoal)) + + let applied = false + await act(async () => { + applied = applySessionGoalEvent( + queryClient, + { type: 'rpc.opencode.goal.changed', data: { sessionID: 'ses_1', goal: updatedGoal } }, + 'local', + ) + }) + + expect(applied).toBe(true) + await waitFor(() => expect(result.current.data).toEqual({ supported: true, goal: updatedGoal })) + expect(mocks.getSessionGoal).toHaveBeenCalledTimes(1) + }) + + it('ignores unrelated events and leaves the cached goal unchanged', async () => { + const queryClient = createQueryClient() + const { result } = renderHook(() => useSessionGoal('ses_1', '/repo'), { wrapper: createWrapper(queryClient) }) + + await waitFor(() => expect(result.current.data).toEqual(supportedGoal)) + + let applied = true + await act(async () => { + applied = applySessionGoalEvent(queryClient, { type: 'session.idle', data: { sessionID: 'ses_1' } }, 'local') + }) + + expect(applied).toBe(false) + expect(result.current.data).toEqual(supportedGoal) + expect(mocks.getSessionGoal).toHaveBeenCalledTimes(1) + }) + + it('writes the change under the event server scoped key', async () => { + const queryClient = createQueryClient() + + await act(async () => { + applySessionGoalEvent( + queryClient, + { type: 'rpc.opencode.goal.changed', data: { sessionID: 'ses_1', goal: updatedGoal } }, + 'srv_remote', + ) + }) + + expect(queryClient.getQueryData(['session-goal', 'ses_1', 'srv_remote'])).toEqual({ + supported: true, + goal: updatedGoal, + }) + expect(queryClient.getQueryData(['session-goal', 'ses_1'])).toBeUndefined() + }) + + it('keeps the event goal when an in-flight read resolves afterwards', async () => { + const queryClient = createQueryClient() + let resolveRead: (value: SessionGoalRead) => void = () => {} + mocks.getSessionGoal.mockImplementation( + () => new Promise((resolve) => { + resolveRead = resolve + }), + ) + + const { result } = renderHook(() => useSessionGoal('ses_1', '/repo'), { wrapper: createWrapper(queryClient) }) + + await waitFor(() => expect(mocks.getSessionGoal).toHaveBeenCalledTimes(1)) + + act(() => { + applySessionGoalEvent( + queryClient, + { type: 'rpc.opencode.goal.changed', data: { sessionID: 'ses_1', goal: updatedGoal } }, + 'local', + ) + }) + + await waitFor(() => expect(result.current.data).toEqual({ supported: true, goal: updatedGoal })) + + await act(async () => { + resolveRead(supportedGoal) + await Promise.resolve() + }) + + expect(result.current.data).toEqual({ supported: true, goal: updatedGoal }) + }) +}) + +describe('useSessionGoalCommand', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.runCommand.mockResolvedValue(undefined) + mocks.syncSelection.mockResolvedValue(undefined) + }) + + it('runs the goal command on the session', async () => { + const { result } = renderHook(() => useSessionGoalCommand('/repo'), { + wrapper: createWrapper(createQueryClient()), + }) + + await act(async () => { + await result.current.mutateAsync({ sessionID: 'ses_1', text: 'Ship it' }) + }) + + expect(mocks.runCommand).toHaveBeenCalledWith({ sessionID: 'ses_1', name: 'goal', text: 'Ship it' }, 'local') + }) + + it('syncs the session selection before running the goal command', async () => { + const calls: string[] = [] + mocks.syncSelection.mockImplementation(async () => { + calls.push('syncSelection') + }) + mocks.runCommand.mockImplementation(async () => { + calls.push('runCommand') + }) + const model = { providerID: 'anthropic', modelID: 'claude-sonnet-4' } + const { result } = renderHook(() => useSessionGoalCommand('/repo'), { + wrapper: createWrapper(createQueryClient()), + }) + + await act(async () => { + await result.current.mutateAsync({ sessionID: 'ses_1', text: 'Ship it', model, agent: 'build' }) + }) + + expect(mocks.syncSelection).toHaveBeenCalledWith({ sessionID: 'ses_1', model, agent: 'build' }) + expect(calls).toEqual(['syncSelection', 'runCommand']) + expect(mocks.runCommand).toHaveBeenCalledWith({ sessionID: 'ses_1', name: 'goal', text: 'Ship it' }, 'local') + }) + + it('reports the command failure message', async () => { + mocks.runCommand.mockRejectedValueOnce(new Error('goal command failed')) + const { result } = renderHook(() => useSessionGoalCommand('/repo'), { + wrapper: createWrapper(createQueryClient()), + }) + + await act(async () => { + await result.current.mutateAsync({ sessionID: 'ses_1', text: 'Ship it' }).catch(() => undefined) + }) + + await waitFor(() => expect(showToast.error).toHaveBeenCalledWith('goal command failed')) + }) }) diff --git a/frontend/src/hooks/useSessionGoals.ts b/frontend/src/hooks/useSessionGoals.ts index 56b3acf0e..25e8fe97c 100644 --- a/frontend/src/hooks/useSessionGoals.ts +++ b/frontend/src/hooks/useSessionGoals.ts @@ -1,62 +1,86 @@ -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { - cancelSessionGoal, - getLatestSessionGoal, - pauseSessionGoal, - resumeSessionGoal, - startSessionGoal, -} from '@/api/sessionGoals' +import { useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query' +import { getSessionGoal, runCommand, type RunCommandInput } from '@/api/opencode' import { useOpenCodeServerId } from '@/contexts/OpenCodeServerContext' +import { useSyncSessionSelection } from '@/hooks/useOpenCode' import { serverScopedKey } from '@/lib/serverScope' import { showToast } from '@/lib/toast' -import { SESSION_GOAL_POLL_INTERVAL_MS, type SessionGoal, type StartSessionGoalRequest } from '@opencode-manager/shared/schemas' +import { + GOAL_COMMAND_NAME, + goalChangeFromEvent, + type ModelRef, + type SessionGoalRead, +} from '@opencode-manager/shared/opencode' + +/** Root segment shared by every session goal query key. */ +export const SESSION_GOAL_QUERY_ROOT = 'session-goal' -function sessionGoalQueryKey(sessionId: string, serverId: string) { - return serverScopedKey(['session-goal', sessionId], serverId) +function sessionGoalQueryKey(sessionID: string, serverId: string) { + return serverScopedKey([SESSION_GOAL_QUERY_ROOT, sessionID], serverId) } -export function useSessionGoal(sessionId: string, options: { enabled?: boolean } = {}) { +/** + * Reads the goal of a session through the OpenCode goal RPC. + * + * @param sessionID The session whose goal is read. + * @param directory Directory of the session's project, once it is known. + * @returns The query holding the goal, or an unsupported result when the server has no goal RPC. + */ +export function useSessionGoal(sessionID: string, directory: string | undefined) { const serverId = useOpenCodeServerId() - return useQuery({ - queryKey: sessionGoalQueryKey(sessionId, serverId), - queryFn: () => getLatestSessionGoal(sessionId, serverId), - enabled: options.enabled ?? true, - refetchInterval: (query) => (query.state.data?.status === 'active' ? SESSION_GOAL_POLL_INTERVAL_MS : false), + return useQuery({ + queryKey: sessionGoalQueryKey(sessionID, serverId), + queryFn: () => getSessionGoal(sessionID, directory, serverId), + enabled: Boolean(directory), }) } -function useGoalMutation( - mutationFn: (input: TInput) => Promise, - errorMessage: string, -) { +/** + * Applies a goal change event to the session's cached goal. + * + * @param queryClient Cache that holds the session goal. + * @param event Event received from the OpenCode server. + * @param serverId Server that owns the session. + * @returns Whether the event was a goal change and updated the cache. + */ +export function applySessionGoalEvent( + queryClient: QueryClient, + event: { type: string; data: unknown }, + serverId: string, +): boolean { + const change = goalChangeFromEvent(event) + if (!change) return false + const nextGoal: SessionGoalRead = { supported: true, goal: change.goal } + const queryKey = sessionGoalQueryKey(change.sessionID, serverId) + void queryClient.cancelQueries({ queryKey }) + queryClient.setQueryData(queryKey, nextGoal) + return true +} + +type GoalCommandInput = Omit & { + model?: ModelRef + agent?: string +} + +/** + * Runs the `/goal` command on a session and refreshes the session's goal afterwards. + * + * @param directory Directory of the session's project, used to sync the session model and agent. + * @returns The mutation that sends the goal command. + */ +export function useSessionGoalCommand(directory: string | undefined) { + const serverId = useOpenCodeServerId() const queryClient = useQueryClient() + const syncSelection = useSyncSessionSelection(directory) return useMutation({ - mutationFn, - onSuccess: (goal: SessionGoal) => { - queryClient.setQueryData(sessionGoalQueryKey(goal.sessionId, goal.serverId), goal) + mutationFn: async ({ model, agent, ...command }: GoalCommandInput) => { + await syncSelection({ sessionID: command.sessionID, model, agent }) + await runCommand({ ...command, name: GOAL_COMMAND_NAME }, serverId) + }, + onSuccess: (_result, input) => { + void queryClient.invalidateQueries({ queryKey: sessionGoalQueryKey(input.sessionID, serverId) }) }, - onError: (error: unknown) => { - showToast.error(error instanceof Error ? error.message : errorMessage) + onError: (error: Error) => { + showToast.error(error.message ?? 'Goal command failed') }, }) } - -export function useStartSessionGoal() { - const serverId = useOpenCodeServerId() - return useGoalMutation( - (input: StartSessionGoalRequest) => startSessionGoal({ ...input, serverId }), - 'Failed to start goal', - ) -} - -export function usePauseSessionGoal() { - return useGoalMutation(pauseSessionGoal, 'Failed to pause goal') -} - -export function useResumeSessionGoal() { - return useGoalMutation(resumeSessionGoal, 'Failed to resume goal') -} - -export function useCancelSessionGoal() { - return useGoalMutation(cancelSessionGoal, 'Failed to cancel goal') -} diff --git a/frontend/src/pages/SessionDetail.tsx b/frontend/src/pages/SessionDetail.tsx index 01a0be91f..203f0f84d 100644 --- a/frontend/src/pages/SessionDetail.tsx +++ b/frontend/src/pages/SessionDetail.tsx @@ -981,7 +981,7 @@ export function SessionDetail() {
- + ({ BackgroundWorkBar: vi vi.mock('@/components/session/SessionGoalBar', () => ({ SessionGoalBar: vi.fn(() => null) })) vi.mock('@/hooks/useSessionGoals', () => ({ useSessionGoal: vi.fn(() => ({ data: undefined })), - useStartSessionGoal: vi.fn(() => ({ mutateAsync: vi.fn(), isPending: false })), - usePauseSessionGoal: vi.fn(() => ({ mutate: vi.fn(), isPending: false })), - useResumeSessionGoal: vi.fn(() => ({ mutate: vi.fn(), isPending: false })), - useCancelSessionGoal: vi.fn(() => ({ mutate: vi.fn(), isPending: false })), + useSessionGoalCommand: vi.fn(() => ({ mutate: vi.fn(), mutateAsync: vi.fn(), isPending: false })), })) vi.mock('@/hooks/useSessionPermissionMode', () => ({ useSessionPermissionMode: vi.fn(() => ({ data: undefined })), diff --git a/frontend/src/pages/__tests__/SessionDetail.commands.test.tsx b/frontend/src/pages/__tests__/SessionDetail.commands.test.tsx index ee2b5310d..83ab5937a 100644 --- a/frontend/src/pages/__tests__/SessionDetail.commands.test.tsx +++ b/frontend/src/pages/__tests__/SessionDetail.commands.test.tsx @@ -201,10 +201,7 @@ vi.mock('@/components/session/BackgroundWorkBar', () => ({ BackgroundWorkBar: vi vi.mock('@/components/session/SessionGoalBar', () => ({ SessionGoalBar: vi.fn(() => null) })) vi.mock('@/hooks/useSessionGoals', () => ({ useSessionGoal: vi.fn(() => ({ data: undefined })), - useStartSessionGoal: vi.fn(() => ({ mutateAsync: vi.fn(), isPending: false })), - usePauseSessionGoal: vi.fn(() => ({ mutate: vi.fn(), isPending: false })), - useResumeSessionGoal: vi.fn(() => ({ mutate: vi.fn(), isPending: false })), - useCancelSessionGoal: vi.fn(() => ({ mutate: vi.fn(), isPending: false })), + useSessionGoalCommand: vi.fn(() => ({ mutate: vi.fn(), mutateAsync: vi.fn(), isPending: false })), })) vi.mock('@/components/session/SideQuestionDialog', () => ({ SideQuestionDialog: vi.fn(() => null) })) vi.mock('@/components/session/ChangesWalkthroughSheet', () => ({ ChangesWalkthroughSheet: vi.fn(() => null) })) diff --git a/frontend/src/pages/__tests__/SessionDetail.form-prompt.test.tsx b/frontend/src/pages/__tests__/SessionDetail.form-prompt.test.tsx index 29bf71eb4..1dfb24af0 100644 --- a/frontend/src/pages/__tests__/SessionDetail.form-prompt.test.tsx +++ b/frontend/src/pages/__tests__/SessionDetail.form-prompt.test.tsx @@ -171,10 +171,7 @@ vi.mock('@/components/session/BackgroundWorkBar', () => ({ BackgroundWorkBar: vi vi.mock('@/components/session/SessionGoalBar', () => ({ SessionGoalBar: vi.fn(() => null) })) vi.mock('@/hooks/useSessionGoals', () => ({ useSessionGoal: vi.fn(() => ({ data: undefined })), - useStartSessionGoal: vi.fn(() => ({ mutateAsync: vi.fn(), isPending: false })), - usePauseSessionGoal: vi.fn(() => ({ mutate: vi.fn(), isPending: false })), - useResumeSessionGoal: vi.fn(() => ({ mutate: vi.fn(), isPending: false })), - useCancelSessionGoal: vi.fn(() => ({ mutate: vi.fn(), isPending: false })), + useSessionGoalCommand: vi.fn(() => ({ mutate: vi.fn(), mutateAsync: vi.fn(), isPending: false })), })) vi.mock('@/components/file-browser/FileBrowserSheet', () => ({ FileBrowserSheet: vi.fn(() => null), diff --git a/frontend/src/pages/__tests__/SessionDetail.polling.test.tsx b/frontend/src/pages/__tests__/SessionDetail.polling.test.tsx index 924df47d0..0ffe8fd21 100644 --- a/frontend/src/pages/__tests__/SessionDetail.polling.test.tsx +++ b/frontend/src/pages/__tests__/SessionDetail.polling.test.tsx @@ -133,10 +133,7 @@ vi.mock('@/components/session/BackgroundWorkBar', () => ({ BackgroundWorkBar: vi vi.mock('@/components/session/SessionGoalBar', () => ({ SessionGoalBar: vi.fn(() => null) })) vi.mock('@/hooks/useSessionGoals', () => ({ useSessionGoal: vi.fn(() => ({ data: undefined })), - useStartSessionGoal: vi.fn(() => ({ mutateAsync: vi.fn(), isPending: false })), - usePauseSessionGoal: vi.fn(() => ({ mutate: vi.fn(), isPending: false })), - useResumeSessionGoal: vi.fn(() => ({ mutate: vi.fn(), isPending: false })), - useCancelSessionGoal: vi.fn(() => ({ mutate: vi.fn(), isPending: false })), + useSessionGoalCommand: vi.fn(() => ({ mutate: vi.fn(), mutateAsync: vi.fn(), isPending: false })), })) vi.mock('@/hooks/useSessionPermissionMode', () => ({ useSessionPermissionMode: vi.fn(() => ({ data: undefined })), diff --git a/frontend/src/pages/__tests__/SessionDetail.remote.test.tsx b/frontend/src/pages/__tests__/SessionDetail.remote.test.tsx index dfd004732..9c8a4861e 100644 --- a/frontend/src/pages/__tests__/SessionDetail.remote.test.tsx +++ b/frontend/src/pages/__tests__/SessionDetail.remote.test.tsx @@ -374,7 +374,7 @@ describe('SessionDetail remote server mode', () => { expect(mocks.SessionGoalBar).toHaveBeenCalled() expect(mocks.SessionGoalBar.mock.calls.at(-1)?.[0]).toEqual( - expect.objectContaining({ sessionID: 'ses_remote' }), + expect.objectContaining({ sessionID: 'ses_remote', directory: '/home/me/app' }), ) }) diff --git a/frontend/src/pages/__tests__/SessionDetail.scroll-floating.test.tsx b/frontend/src/pages/__tests__/SessionDetail.scroll-floating.test.tsx index a86441008..f9c0f8192 100644 --- a/frontend/src/pages/__tests__/SessionDetail.scroll-floating.test.tsx +++ b/frontend/src/pages/__tests__/SessionDetail.scroll-floating.test.tsx @@ -164,10 +164,7 @@ vi.mock('@/components/session/BackgroundWorkBar', () => ({ BackgroundWorkBar: vi vi.mock('@/components/session/SessionGoalBar', () => ({ SessionGoalBar: vi.fn(() => null) })) vi.mock('@/hooks/useSessionGoals', () => ({ useSessionGoal: vi.fn(() => ({ data: undefined })), - useStartSessionGoal: vi.fn(() => ({ mutateAsync: vi.fn(), isPending: false })), - usePauseSessionGoal: vi.fn(() => ({ mutate: vi.fn(), isPending: false })), - useResumeSessionGoal: vi.fn(() => ({ mutate: vi.fn(), isPending: false })), - useCancelSessionGoal: vi.fn(() => ({ mutate: vi.fn(), isPending: false })), + useSessionGoalCommand: vi.fn(() => ({ mutate: vi.fn(), mutateAsync: vi.fn(), isPending: false })), })) vi.mock('@/components/file-browser/FileBrowserSheet', () => ({ FileBrowserSheet: vi.fn(() => null), diff --git a/ocm-cli/README.md b/ocm-cli/README.md index 04ebbd9c6..28967a0cc 100644 --- a/ocm-cli/README.md +++ b/ocm-cli/README.md @@ -23,6 +23,11 @@ Use ocm 0.2.x with OpenCode Manager < 0.19.0 and OpenCode 1.x, installed pinned `@opencode-manager/ocm-cli@0.2`. ocm is published together with each OpenCode Manager release. +From the release after 0.4.1, the TUI plugin is built against `@opencode/plugin` +2.2.x and OpenTUI 0.7, so it requires a local OpenCode >= 2.2.0: OpenCode 2.2.0 +refuses plugins built for OpenTUI 0.6, and older OpenCode builds may refuse the +OpenTUI 0.7 peers. + Attachment works with OpenCode Manager >= 0.19.0. The sync worktree targeting of `ocm push` / `ocm pull` and `/ocm-move` worktree moves need ocm 0.4.0+ and OpenCode Manager 1.0.0+; an older CLI is told to upgrade, and an older Manager is @@ -259,25 +264,9 @@ reattaches; switching to a repo also remembers it as the last repo. The current session stays where it is (use `/ocm-move` to push your local state and bring a local session along). -The plugin also registers two Manager-backed commands, both of which need an -attached Manager repo through `ocm`: - -- `/ocm-goal [objective]` starts a Manager-driven goal on the current top-level - session and sends the objective as the next message (queued if the session - is busy). With no objective, a dialog asks for it, plus optional max turns - (1-200) and token budget (a positive whole number); blank uses the Manager - defaults. `tab` moves between fields and `ctrl+s` starts the goal. 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. If the - objective cannot be sent, the goal is cancelled. Scheduled runs and subagent - sessions are rejected by the Manager. While a goal is open, a one-line status - above the composer shows ` · Turn n/max`, token usage when the goal - has a token budget, and the first line of the objective. 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. +The plugin also registers one Manager-backed command, which needs an attached +Manager repo through `ocm`: + - `/ocm-multirun [prompt]` opens a launch dialog: prompt, name (defaults to the prompt's first line), a filterable model checklist (up to 5, `enter` toggles), isolated workspaces or the shared repo directory, and, for isolated @@ -294,17 +283,18 @@ attached Manager repo through `ocm`: optional instructions; `ctrl+s` fuses and `ctrl+b` goes back. 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 -rejects these routes with `401 Unauthorized` even for a valid token, or with a -non-JSON `404`; on a `401` 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. +Session goals use OpenCode's built-in `/goal` command instead. + +`/ocm-multirun` needs an OpenCode Manager release that exposes +`/api/internal/multi-runs`. An older Manager rejects this route with +`401 Unauthorized` even for a valid token, or with a non-JSON `404`; on a `401` +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. The attached `opencode` process carries `OCM_REMOTE_MANAGER_URL`, `OCM_REMOTE_REPO_NAME`, and `OCM_REMOTE_REPO_ID`. They drive the indicator and -tell `/ocm`, `/ocm-goal`, and `/ocm-multirun` which Manager and repo they act -on; `/ocm-multirun` requires `OCM_REMOTE_REPO_ID`. These commands authenticate +tell `/ocm` and `/ocm-multirun` which Manager and repo they act on; +`/ocm-multirun` requires `OCM_REMOTE_REPO_ID`. These commands authenticate with the stored token (or `OCM_TOKEN`) for that Manager URL, not with `OPENCODE_PASSWORD`. @@ -323,7 +313,7 @@ global installs); the plugin surface is TUI-only. ## Requirements - macOS or Linux (Windows is unsupported) -- OpenCode >= 2.0.15 (same major, 2.x) available on `PATH` +- OpenCode >= 2.2.0 (same major, 2.x) available on `PATH` for the TUI plugin - Node available on `PATH` (runs the `ocm` binary) - OpenCode Manager >= 0.19.0 - `git` and `tar` (with gzip support, i.e. the `-z` flag) available on `PATH` diff --git a/ocm-cli/package.json b/ocm-cli/package.json index 25b0aab5d..e1ab8e562 100644 --- a/ocm-cli/package.json +++ b/ocm-cli/package.json @@ -18,11 +18,15 @@ } }, "peerDependencies": { - "@opentui/core": ">=0.5.10", - "@opentui/solid": ">=0.5.10", + "@opencode/theme": ">=2.2.1 <3.0.0", + "@opentui/core": ">=0.7.0 <0.8.0", + "@opentui/solid": ">=0.7.0 <0.8.0", "solid-js": ">=1.9.0" }, "peerDependenciesMeta": { + "@opencode/theme": { + "optional": true + }, "@opentui/core": { "optional": true }, @@ -54,10 +58,11 @@ }, "devDependencies": { "@eslint/js": "^9.36.0", - "@opencode/plugin": "2.1.2", + "@opencode/plugin": "2.2.1", + "@opencode/theme": "2.2.1", "@opencode-manager/shared": "workspace:*", - "@opentui/core": "^0.5.12", - "@opentui/solid": "^0.5.12", + "@opentui/core": "0.7.0", + "@opentui/solid": "0.7.0", "@types/bun": "^1.3.14", "@types/node": "^22.0.0", "eslint": "^9.39.1", diff --git a/ocm-cli/src/goal-store.ts b/ocm-cli/src/goal-store.ts deleted file mode 100644 index 60f225e3f..000000000 --- a/ocm-cli/src/goal-store.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { - SESSION_GOAL_POLL_INTERVAL_MS, - isOpenSessionGoal, - isTerminalSessionGoal, - type SessionGoal, -} from '@opencode-manager/shared/schemas' - -const BACKGROUND_POLL_INTERVAL_MS = 15000 -const MAX_ERROR_BACKOFF_MS = 30000 -const REQUEST_TIMEOUT_MS = 10000 - -export type GoalStoreDeps = { - load: (sessionID: string, signal: AbortSignal) => Promise - onOutcome: (goal: SessionGoal) => void - pollIntervalMs?: number -} - -export type GoalStore = { - watch(sessionID: string, listener: (goal: SessionGoal | null) => void): () => void - set(goal: SessionGoal): void -} - -interface GoalEntry { - readonly sessionID: string - goal: SessionGoal | null - listeners: Set<(goal: SessionGoal | null) => void> - 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 ?? SESSION_GOAL_POLL_INTERVAL_MS - const entries = new Map() - - function entryFor(sessionID: string): GoalEntry { - 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 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 wantsFollowing(entry: GoalEntry): boolean { - return isCurrent(entry) && (hasListeners(entry) || isOpenSessionGoal(entry.goal)) - } - - 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) - }, 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 (goal !== null && entry.outcomeGoalId !== goal.id && isOpenSessionGoal(previous) && isTerminalSessionGoal(goal)) { - entry.outcomeGoalId = goal.id - deps.onOutcome(goal) - } - 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, signal) - } catch { - entry.loading = false - entry.abort = null - if (!isCurrent(entry)) return - fail(entry) - return - } - - entry.loading = false - entry.abort = null - if (!isCurrent(entry)) return - if (version !== entry.version) { - schedule(entry) - return - } - entry.errorBackoffMs = 0 - applyGoal(entry, goal) - } - - return { - watch(sessionID, listener) { - const entry = entryFor(sessionID) - entry.listeners.add(listener) - if (entry.goal) listener(entry.goal) - void loadInto(entry) - return () => { - 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) - }, - } -} diff --git a/ocm-cli/src/manager-api.ts b/ocm-cli/src/manager-api.ts index 043f072aa..c4dbf21ce 100644 --- a/ocm-cli/src/manager-api.ts +++ b/ocm-cli/src/manager-api.ts @@ -4,14 +4,11 @@ import { MirrorCheckoutsResponseSchema, MirrorWorktreeCreateResponseSchema, MultiRunSchema, - SessionGoalSchema, type FuseMultiRunRequest, type LaunchMultiRunRequest, type MirrorCheckoutsResponse, type MirrorWorktreeCreateResponse, type MultiRun, - type SessionGoal, - type StartSessionGoalRequest, } from '@opencode-manager/shared/schemas' export interface MirrorBeginOpts { @@ -78,7 +75,7 @@ function createByteCounter(onProgress: (bytesSent: number) => void): TransformSt export const MANAGER_FEATURE_MISSING = 'MANAGER_FEATURE_MISSING' -const DEFAULT_FEATURE_NAME = 'ocm goals and multi-runs' +const DEFAULT_FEATURE_NAME = 'ocm multi-runs' const MOVE_FEATURE_NAME = '/ocm-move worktrees' function directoryQuery(directory: string | undefined): string { @@ -160,7 +157,7 @@ export class ManagerApi { }) if (!res.ok) { const featureLabel = feature === true ? (featureName ?? DEFAULT_FEATURE_NAME) : null - throw await this.handleErrorResponse(res, operation, featureLabel, requestInit.signal ?? undefined) + throw await this.handleErrorResponse(res, operation, featureLabel) } return parse(await res.json()) } @@ -169,11 +166,10 @@ export class ManagerApi { res: Response, operation: string, featureName: string | null, - signal?: AbortSignal, ): Promise { const error = await formatErrorResponse(res, operation) if (featureName !== null && error.status === 401) { - return this.probeFeatureSupport(operation, featureName, error, signal) + return this.probeFeatureSupport(operation, featureName, error) } return error } @@ -182,10 +178,9 @@ export class ManagerApi { operation: string, featureName: string, fallback: ManagerApiError, - signal?: AbortSignal, ): Promise { try { - const res = await fetch(`${this.baseUrl}/api/internal/opencode-workspaces`, { headers: this.headers(), signal }) + 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 ${featureName}; upgrade the Manager.`, @@ -382,45 +377,6 @@ export class ManagerApi { return (await res.json()) as MirrorPatchSnapshot } - 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 }, - ) - } - - 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 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 { - return this.runSessionGoalAction(id, 'pause') - } - - resumeSessionGoal(id: number): Promise { - return this.runSessionGoalAction(id, 'resume') - } - - cancelSessionGoal(id: number): Promise { - return this.runSessionGoalAction(id, 'cancel') - } - listMultiRuns(repoId: number): Promise { return this.requestJson( `/api/internal/multi-runs?repoId=${encodeURIComponent(repoId)}`, diff --git a/ocm-cli/src/tui-goal-dialog.tsx b/ocm-cli/src/tui-goal-dialog.tsx deleted file mode 100644 index 101659885..000000000 --- a/ocm-cli/src/tui-goal-dialog.tsx +++ /dev/null @@ -1,188 +0,0 @@ -/** @jsxImportSource @opentui/solid */ -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 { 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' - -const FORM_FIELDS = 3 - -export function showGoalDialog(context: Context, props: GoalDialogProps): void { - context.ui.dialog.show(() => ) - context.ui.dialog.set({ size: 'large' }) -} - -function GoalDialog(props: GoalDialogProps & { context: Context }) { - const theme: DialogTheme = props.context.theme.surface('dialog') - const [goal, setGoal] = createSignal(props.initialGoal) - const [busy, setBusy] = createSignal(null) - const [error, setError] = createSignal(null) - const [confirmCancel, setConfirmCancel] = createSignal(false) - const [objective, setObjective] = createSignal(props.initialObjective) - const [maxTurns, setMaxTurns] = createSignal('') - const [tokenBudget, setTokenBudget] = createSignal('') - - onCleanup(props.store.watch(props.sessionID, (next) => setGoal(next))) - createEffect(on([objective, maxTurns, tokenBudget], () => setError(null), { defer: true })) - - const openGoal = () => { - const current = goal() - return isOpenSessionGoal(current) ? current : null - } - const [field, setField] = useFieldFocus(() => FORM_FIELDS, () => openGoal() === null) - - const run = async (label: string, operation: () => Promise): Promise => { - if (busy()) return undefined - setBusy(label) - setError(null) - const failure = await operation() - setBusy(null) - setError(failure) - return failure - } - - const start = async () => { - const failure = await run('Starting goal…', () => - props.actions.start({ objective: objective(), maxTurns: maxTurns(), tokenBudget: tokenBudget() }), - ) - if (failure === null) props.context.ui.dialog.clear() - } - - useKeyboard((event) => { - const current = openGoal() - if (!current) { - if (!isSubmitKey(event)) return - event.preventDefault() - void start() - return - } - if (event.name === 'p') { - event.preventDefault() - setConfirmCancel(false) - void run(current.status === 'paused' ? 'Resuming goal…' : 'Pausing goal…', () => - current.status === 'paused' ? props.actions.resume(current) : props.actions.pause(current), - ) - return - } - if (event.name === 'x') { - event.preventDefault() - if (!confirmCancel()) { - setConfirmCancel(true) - return - } - setConfirmCancel(false) - void run('Cancelling goal…', () => props.actions.cancel(current)) - } - }) - - return ( - - Sends with this session's current agent and model; send a message first to switch. - {(last) => } - - - - setField(2)} - /> - - - void start()} - /> - - - - } - > - {(current) => ( - - - - Press x again to cancel the goal. This cannot be undone. - - - )} - - ) -} - -function GoalStatus(props: { theme: DialogTheme; goal: SessionGoal }) { - const tone = () => (props.goal.status === 'active' ? props.theme.text.feedback.info.base : props.theme.text.feedback.warning.base) - return ( - - - - {getGoalOutcomeTitle(props.goal.status)} - - {getGoalTurnLabel(props.goal)} - {(tokens) => {tokens()}} - {props.goal.turnState === 'running' ? 'working' : 'waiting'} - - {props.goal.objective} - - {(reason) => ( - - {props.goal.lastVerdict ? `Last verdict: ${props.goal.lastVerdict} · ` : ''} - {reason()} - - )} - - - ) -} - -function LastGoal(props: { theme: DialogTheme; goal: SessionGoal }) { - return ( - - Last goal: {getGoalOutcomeTitle(props.goal.status)} - {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 deleted file mode 100644 index 82b470193..000000000 --- a/ocm-cli/src/tui-goal.ts +++ /dev/null @@ -1,202 +0,0 @@ -import type { Context, ToastOptions } from '@opencode/plugin/tui/context' -import { - GOAL_MAX_CONTINUATIONS_MAX, - GOAL_MAX_CONTINUATIONS_MIN, - StartSessionGoalRequestSchema, - isOpenSessionGoal, - type SessionGoal, - type StartSessionGoalRequest, -} from '@opencode-manager/shared/schemas' -import { - getGoalOutcomeReason, - getGoalOutcomeTitle, - getGoalTokenLabel, - getGoalTurnLabel, -} from '@opencode-manager/shared/notifications' -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.' - -export type GoalFormInput = { - objective: string - maxTurns: string - tokenBudget: string -} - -export type GoalFormResult = { ok: true; request: StartSessionGoalRequest } | { ok: false; error: string } - -/** Goal operations the goal dialog calls. Each resolves to an error message, or null on success. */ -export type GoalActions = { - start(form: GoalFormInput): Promise - pause(goal: SessionGoal): Promise - resume(goal: SessionGoal): Promise - cancel(goal: SessionGoal): Promise -} - -export type GoalDialogProps = { - sessionID: string - store: GoalStore - initialGoal: SessionGoal | null - initialObjective: string - actions: GoalActions -} - -export type GoalCommandDeps = { - remote: RemoteContext | undefined - store: GoalStore | undefined - showDialog: (props: GoalDialogProps) => void -} - -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), goalObjectiveSummary(goal.objective)] - .filter((part): part is string => part !== null) - .join(' · ') -} - -export function goalOutcomeToast(goal: SessionGoal): ToastOptions { - const variant = goal.status === 'completed' ? 'success' : goal.status === 'blocked' ? 'error' : 'warning' - return { - variant, - title: getGoalOutcomeTitle(goal.status), - message: getGoalOutcomeReason(goal) ?? goalObjectiveSummary(goal.objective), - } -} - -/** 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() - if (!objective) return { ok: false, error: 'Describe what this session should accomplish.' } - - const maxContinuations = parseOptionalInteger(form.maxTurns) - if ( - maxContinuations === null || - (maxContinuations !== undefined && - (maxContinuations < GOAL_MAX_CONTINUATIONS_MIN || maxContinuations > GOAL_MAX_CONTINUATIONS_MAX)) - ) { - return { ok: false, error: `Max turns must be a whole number from ${GOAL_MAX_CONTINUATIONS_MIN} to ${GOAL_MAX_CONTINUATIONS_MAX}.` } - } - - const tokenBudget = parseOptionalInteger(form.tokenBudget) - if (tokenBudget === null || (tokenBudget !== undefined && tokenBudget <= 0)) { - return { ok: false, error: 'Token budget must be a positive whole number.' } - } - - const parsed = StartSessionGoalRequestSchema.safeParse({ - sessionId, - directory, - objective, - ...(maxContinuations !== undefined ? { maxContinuations } : {}), - ...(tokenBudget !== undefined ? { tokenBudget } : {}), - }) - if (!parsed.success) return { ok: false, error: parsed.error.issues[0]?.message ?? 'Invalid goal.' } - return { ok: true, request: parsed.data } -} - -export function describeGoalError(error: unknown): string { - if (isManagerRouteMissing(error)) return GOALS_ROUTE_MISSING - return describeCause(error) -} - -export async function runGoalCommand(context: Context, deps: GoalCommandDeps, input?: string): Promise { - try { - if (!deps.remote || !deps.store) { - context.ui.toast.show({ variant: 'error', message: GOALS_ATTACH_REQUIRED }) - return - } - - const target = requireSessionTarget(context) - if (!target) return - const { sessionID, directory } = target - - const resolved = await resolveManagerApi(deps.remote.managerUrl) - if (!resolved.ok) { - context.ui.toast.show({ variant: 'error', message: resolved.message }) - return - } - const api = resolved.api - const store = deps.store - const actions = createGoalActions(context, api, store, sessionID, directory) - - const objective = slashArgument(input, GOAL_COMMAND) - const existing = await api.getLatestSessionGoal(sessionID) - if (objective && !isOpenSessionGoal(existing)) { - const error = await actions.start({ objective, maxTurns: '', tokenBudget: '' }) - if (error) context.ui.toast.show({ variant: 'error', message: error }) - return - } - - deps.showDialog({ sessionID, store, initialGoal: existing, initialObjective: objective, actions }) - } catch (error) { - context.ui.toast.show({ variant: 'error', message: describeGoalError(error) }) - } -} - -function createGoalActions(context: Context, api: ManagerApi, store: GoalStore, sessionID: string, directory: string): GoalActions { - const run = async (operation: () => Promise): Promise => { - try { - store.set(await operation()) - return null - } catch (error) { - return describeGoalError(error) - } - } - - return { - async start(form) { - const parsed = parseGoalForm(form, sessionID, directory) - if (!parsed.ok) return parsed.error - return startGoal(context, api, store, parsed.request) - }, - pause: (goal) => run(() => api.pauseSessionGoal(goal.id)), - resume: (goal) => run(() => api.resumeSessionGoal(goal.id)), - cancel: (goal) => run(() => api.cancelSessionGoal(goal.id)), - } -} - -async function startGoal(context: Context, api: ManagerApi, store: GoalStore, request: StartSessionGoalRequest): Promise { - let goal: SessionGoal - try { - goal = await api.startSessionGoal(request) - } catch (error) { - return describeGoalError(error) - } - store.set(goal) - - try { - await context.client.session.prompt({ - sessionID: request.sessionId, - text: request.objective, - delivery: context.data.session.status(request.sessionId) === 'running' ? 'queue' : undefined, - }) - return null - } 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: ${describeCause(error)}` - } -} - -function parseOptionalInteger(value: string): number | undefined | null { - const trimmed = value.trim() - if (!trimmed) return undefined - return /^\d+$/.test(trimmed) ? Number(trimmed) : null -} diff --git a/ocm-cli/src/tui-plugin.ts b/ocm-cli/src/tui-plugin.ts index 9cf1a09b0..09098f515 100644 --- a/ocm-cli/src/tui-plugin.ts +++ b/ocm-cli/src/tui-plugin.ts @@ -14,25 +14,20 @@ 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 { GOAL_COMMAND, runGoalCommand } from './tui-goal.js' -import type { GoalDialogProps } from './tui-goal.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' import type { RemoteContext } from './remote-context.js' export type MoveProgressSetter = (progress: MoveProgress | null) => void export type OcmDialogs = { - goal: (props: GoalDialogProps) => void multiRunLaunch: (props: MultiRunLaunchDialogProps) => void multiRuns: (props: MultiRunsDialogProps) => void } export type OcmFeatures = { remote: RemoteContext | undefined - goals: GoalStore | undefined dialogs: OcmDialogs } @@ -62,16 +57,6 @@ export async function setupOcm(context: Context, setMoveProgress: MoveProgressSe slash: { name: 'ocm' }, run: () => runOcmSwitch(context, { remote: features.remote }), }, - { - id: 'ocm.goal', - title: 'Goal', - description: 'Start, pause, resume, or cancel a Manager goal for this session', - group: 'OpenCode Manager', - palette: true, - slash: { name: GOAL_COMMAND, arguments: true }, - run: (input) => - runGoalCommand(context, { remote: features.remote, store: features.goals, showDialog: features.dialogs.goal }, input), - }, { id: 'ocm.multirun', title: 'Multi-run', diff --git a/ocm-cli/src/tui.tsx b/ocm-cli/src/tui.tsx index a6567176f..e61243b53 100644 --- a/ocm-cli/src/tui.tsx +++ b/ocm-cli/src/tui.tsx @@ -1,18 +1,13 @@ /** @jsxImportSource @opentui/solid */ import { createSignal, createEffect, onCleanup, Show } from 'solid-js' import { Plugin } from '@opencode/plugin/tui' +import type { HueScale } from '@opencode/theme/tui' import { setupOcm } from './tui-plugin.js' -import { showGoalDialog } from './tui-goal-dialog.js' import { showMultiRunLaunchDialog, showMultiRunsDialog } from './tui-multi-run-dialogs.js' import { formatMoveProgress } from './move-progress.js' import type { MoveProgress } from './move-progress.js' import { readRemoteContext, remoteDisplayName } from './remote-context.js' import { readState } from './state.js' -import { createGoalStore } from './goal-store.js' -import type { GoalStore } from './goal-store.js' -import { formatGoalStatus, goalOutcomeToast } from './tui-goal.js' -import { resolveManagerApi } from './manager-auth.js' -import { isOpenSessionGoal, type SessionGoal } from '@opencode-manager/shared/schemas' const SPINNER_INTERVAL_MS = 80 @@ -42,9 +37,10 @@ export default Plugin.define({ const remote = readRemoteContext(process.env) if (remote) { const managerLabel = remoteDisplayName(remote, readState()) + const namedHues: Readonly> = context.theme.hue const indicator = () => ( - {managerLabel} + {managerLabel} {remote.repoName && · {remote.repoName}} ) @@ -53,54 +49,9 @@ export default Plugin.define({ context.ui.slot({ append: 'home.footer.status', render: indicator }) } - const goals = remote - ? createGoalStore({ - 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 }) - }, - }) - : undefined - - const GoalLine = (props: { sessionID: string; store: GoalStore }) => { - const [goal, setGoal] = createSignal(null) - createEffect(() => { - const unsubscribe = props.store.watch(props.sessionID, (next) => setGoal(next)) - onCleanup(unsubscribe) - }) - const openGoal = () => { - const current = goal() - return isOpenSessionGoal(current) ? current : null - } - return ( - - {(current) => ( - - - {formatGoalStatus(current())} - - - )} - - ) - } - - if (goals) { - context.ui.slot({ - append: 'session.composer.top', - render: (input) => , - }) - } - return setupOcm(context, setMoveProgress, { remote, - goals, dialogs: { - goal: (props) => showGoalDialog(context, props), multiRunLaunch: (props) => showMultiRunLaunchDialog(context, props), multiRuns: (props) => showMultiRunsDialog(context, props), }, diff --git a/ocm-cli/test/goal-store.test.ts b/ocm-cli/test/goal-store.test.ts deleted file mode 100644 index 24f73a94e..000000000 --- a/ocm-cli/test/goal-store.test.ts +++ /dev/null @@ -1,419 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -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() -}) - -afterEach(() => { - vi.useRealTimers() -}) - -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() }) - - const unwatch = store.watch('ses_a', vi.fn()) - await vi.advanceTimersByTimeAsync(0) - expect(load).toHaveBeenCalledTimes(1) - - 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 () => { - const active = goal() - const load = vi.fn().mockResolvedValue(active) - const store = createGoalStore({ load, onOutcome: vi.fn(), pollIntervalMs: 100 }) - const listener = vi.fn() - - const unwatch = store.watch('ses_a', listener) - await vi.advanceTimersByTimeAsync(0) - - expect(load).toHaveBeenCalledTimes(1) - expect(load).toHaveBeenCalledWith('ses_a', expect.any(AbortSignal)) - expect(listener).toHaveBeenCalledWith(active) - unwatch() - }) - - it('notifies the listener with null when no goal exists', async () => { - const load = vi.fn().mockResolvedValue(null) - const store = createGoalStore({ load, onOutcome: vi.fn(), pollIntervalMs: 100 }) - const listener = vi.fn() - - const unwatch = store.watch('ses_a', listener) - await vi.advanceTimersByTimeAsync(0) - - expect(listener).toHaveBeenCalledWith(null) - unwatch() - }) - - it('polls while the goal is active', async () => { - const load = vi.fn().mockResolvedValue(goal({ status: 'active' })) - const store = createGoalStore({ load, onOutcome: vi.fn(), pollIntervalMs: 100 }) - - const unwatch = store.watch('ses_a', vi.fn()) - await vi.advanceTimersByTimeAsync(0) - expect(load).toHaveBeenCalledTimes(1) - - await vi.advanceTimersByTimeAsync(100) - expect(load).toHaveBeenCalledTimes(2) - - await vi.advanceTimersByTimeAsync(100) - expect(load).toHaveBeenCalledTimes(3) - unwatch() - }) - - it('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(100) - - expect(load).toHaveBeenCalledTimes(2) - unwatch() - }) - - it('stops polling when the goal is terminal', async () => { - const load = vi.fn().mockResolvedValue(goal({ status: 'completed' })) - const store = createGoalStore({ load, onOutcome: vi.fn(), pollIntervalMs: 100 }) - - const unwatch = store.watch('ses_a', vi.fn()) - await vi.advanceTimersByTimeAsync(0) - await vi.advanceTimersByTimeAsync(1000) - - expect(load).toHaveBeenCalledTimes(1) - unwatch() - }) - - it('updates watchers immediately on set', async () => { - const load = vi.fn().mockResolvedValue(null) - const store = createGoalStore({ load, onOutcome: vi.fn(), pollIntervalMs: 100 }) - const listener = vi.fn() - - const unwatch = store.watch('ses_a', listener) - await vi.advanceTimersByTimeAsync(0) - listener.mockClear() - - const next = goal({ status: 'active' }) - store.set(next) - - expect(listener).toHaveBeenCalledWith(next) - unwatch() - }) - - it('starts polling when set installs an active goal', async () => { - const load = vi.fn().mockResolvedValue(null) - const store = createGoalStore({ load, onOutcome: vi.fn(), pollIntervalMs: 100 }) - - const unwatch = store.watch('ses_a', vi.fn()) - await vi.advanceTimersByTimeAsync(0) - expect(load).toHaveBeenCalledTimes(1) - - store.set(goal({ status: 'active' })) - await vi.advanceTimersByTimeAsync(100) - - expect(load).toHaveBeenCalledTimes(2) - unwatch() - }) - - it('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) - - 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) - - 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 () => { - const active = goal({ status: 'active' }) - const completed = goal({ status: 'completed' }) - const load = vi.fn().mockResolvedValue(active) - const onOutcome = vi.fn() - const store = createGoalStore({ load, onOutcome, pollIntervalMs: 100 }) - - const unwatch = store.watch('ses_a', vi.fn()) - await vi.advanceTimersByTimeAsync(0) - - load.mockResolvedValue(completed) - await vi.advanceTimersByTimeAsync(100) - - expect(onOutcome).toHaveBeenCalledTimes(1) - expect(onOutcome).toHaveBeenCalledWith(completed) - - await vi.advanceTimersByTimeAsync(1000) - expect(onOutcome).toHaveBeenCalledTimes(1) - unwatch() - }) - - it('fires onOutcome when a paused goal is cancelled through set', async () => { - const paused = goal({ status: 'paused' }) - const stopped = goal({ status: 'stopped', stopReason: 'cancelled' }) - const load = vi.fn().mockResolvedValue(paused) - const onOutcome = vi.fn() - const store = createGoalStore({ load, onOutcome, pollIntervalMs: 100 }) - - const unwatch = store.watch('ses_a', vi.fn()) - await vi.advanceTimersByTimeAsync(0) - - store.set(stopped) - - expect(onOutcome).toHaveBeenCalledTimes(1) - expect(onOutcome).toHaveBeenCalledWith(stopped) - unwatch() - }) - - it('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() - const store = createGoalStore({ load, onOutcome, pollIntervalMs: 100 }) - - const unwatch = store.watch('ses_a', vi.fn()) - await vi.advanceTimersByTimeAsync(0) - - expect(onOutcome).not.toHaveBeenCalled() - unwatch() - }) - - it('keeps following an open goal in the background and fires onOutcome once after the last listener leaves', async () => { - const active = goal({ status: '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 unwatch = store.watch('ses_a', vi.fn()) - await vi.advanceTimersByTimeAsync(0) - expect(load).toHaveBeenCalledTimes(1) - - 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) - - await vi.advanceTimersByTimeAsync(100) - expect(load).toHaveBeenCalledTimes(3) - unwatchAgain() - }) - - it('keeps the last goal and polling when a reload fails on an active goal', async () => { - const active = goal({ status: 'active' }) - const load = vi.fn().mockResolvedValue(active) - const store = createGoalStore({ load, onOutcome: vi.fn(), pollIntervalMs: 100 }) - const listener = vi.fn() - - const unwatch = store.watch('ses_a', listener) - await vi.advanceTimersByTimeAsync(0) - - load.mockRejectedValue(new Error('offline')) - await vi.advanceTimersByTimeAsync(100) - - expect(load).toHaveBeenCalledTimes(2) - expect(listener).toHaveBeenLastCalledWith(active) - - await vi.advanceTimersByTimeAsync(200) - expect(load).toHaveBeenCalledTimes(3) - unwatch() - }) - - it('stops polling when the last listener unsubscribes', async () => { - const load = vi.fn().mockResolvedValue(goal({ status: 'active' })) - const store = createGoalStore({ load, onOutcome: vi.fn(), pollIntervalMs: 100 }) - - const unwatch = store.watch('ses_a', vi.fn()) - await vi.advanceTimersByTimeAsync(0) - expect(load).toHaveBeenCalledTimes(1) - - unwatch() - await vi.advanceTimersByTimeAsync(1000) - - expect(load).toHaveBeenCalledTimes(1) - }) - - it('does not resume polling when an in-flight load resolves after the last listener unsubscribes', async () => { - let resolveLoad: (goal: SessionGoal | null) => void = () => {} - const load = vi.fn( - () => - new Promise((resolve) => { - resolveLoad = resolve - }), - ) - const store = createGoalStore({ load, onOutcome: vi.fn(), pollIntervalMs: 20 }) - - const unwatch = store.watch('ses_a', vi.fn()) - expect(load).toHaveBeenCalledTimes(1) - - unwatch() - resolveLoad(goal({ status: 'active' })) - await vi.advanceTimersByTimeAsync(0) - - await vi.advanceTimersByTimeAsync(200) - expect(load).toHaveBeenCalledTimes(1) - }) - - it('does not start polling when set targets an unwatched session', async () => { - const load = vi.fn().mockResolvedValue(goal({ status: 'active' })) - const store = createGoalStore({ load, onOutcome: vi.fn(), pollIntervalMs: 20 }) - - store.set(goal({ status: 'active' })) - await vi.advanceTimersByTimeAsync(200) - - expect(load).not.toHaveBeenCalled() - }) - - it('keeps polling while another listener remains', async () => { - const load = vi.fn().mockResolvedValue(goal({ status: 'active' })) - const store = createGoalStore({ load, onOutcome: vi.fn(), pollIntervalMs: 100 }) - const second = vi.fn() - - const unwatchFirst = store.watch('ses_a', vi.fn()) - const unwatchSecond = store.watch('ses_a', second) - await vi.advanceTimersByTimeAsync(0) - - unwatchFirst() - load.mockClear() - await vi.advanceTimersByTimeAsync(100) - - expect(load).toHaveBeenCalledTimes(1) - expect(second).toHaveBeenCalled() - unwatchSecond() - }) -}) diff --git a/ocm-cli/test/helpers/goal-fixture.ts b/ocm-cli/test/helpers/goal-fixture.ts deleted file mode 100644 index b70043ae5..000000000 --- a/ocm-cli/test/helpers/goal-fixture.ts +++ /dev/null @@ -1,24 +0,0 @@ -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 a47643608..ef8053075 100644 --- a/ocm-cli/test/manager-api.test.ts +++ b/ocm-cli/test/manager-api.test.ts @@ -1,30 +1,9 @@ import { describe, it, expect, vi, afterEach } from 'vitest' import { ManagerApi, ManagerApiError, isManagerRouteMissing, MANAGER_FEATURE_MISSING } from '../src/manager-api.js' -import type { MultiRun, SessionGoal } from '@opencode-manager/shared/schemas' +import type { MultiRun } from '@opencode-manager/shared/schemas' const BASE_URL = 'http://localhost:5003' -const goal: SessionGoal = { - id: 7, - sessionId: 'ses_1', - serverId: 'local', - directory: '/repo', - objective: 'fix the flaky test', - status: 'active', - stopReason: null, - turnState: 'waiting', - continuationCount: 0, - maxContinuations: 10, - tokenBudget: null, - tokensUsed: 0, - consecutiveBlocked: 0, - lastVerdict: null, - lastReason: null, - createdAt: 1, - updatedAt: 2, - finishedAt: null, -} - const run: MultiRun = { id: 3, repoId: 1, @@ -57,76 +36,6 @@ afterEach(() => { vi.unstubAllGlobals() }) -describe('ManagerApi session goals', () => { - const api = new ManagerApi(BASE_URL, 'tok') - - it('reads the latest goal for a session', async () => { - const fetchMock = stubFetch(okResponse({ goal })) - - await expect(api.getLatestSessionGoal('ses_1')).resolves.toEqual(goal) - expect(fetchMock).toHaveBeenCalledWith(`${BASE_URL}/api/internal/session-goals?sessionId=ses_1`, { - headers: { Authorization: 'Bearer tok' }, - }) - }) - - it('returns null when the session has no goal', async () => { - stubFetch(okResponse({ goal: null })) - - await expect(api.getLatestSessionGoal('ses_1')).resolves.toBeNull() - }) - - it('starts a goal', async () => { - const fetchMock = stubFetch(okResponse({ goal })) - const input = { sessionId: 'ses_1', directory: '/repo', objective: 'fix it' } - - await expect(api.startSessionGoal(input)).resolves.toEqual(goal) - expect(fetchMock).toHaveBeenCalledWith(`${BASE_URL}/api/internal/session-goals`, { - method: 'POST', - headers: { Authorization: 'Bearer tok', 'Content-Type': 'application/json' }, - body: JSON.stringify(input), - }) - }) - - it('pauses, resumes, and cancels a goal through the action route', async () => { - const fetchMock = stubFetch(okResponse({ goal })) - - await expect(api.pauseSessionGoal(7)).resolves.toEqual(goal) - await expect(api.resumeSessionGoal(7)).resolves.toEqual(goal) - await expect(api.cancelSessionGoal(7)).resolves.toEqual(goal) - - expect(fetchMock).toHaveBeenNthCalledWith(1, `${BASE_URL}/api/internal/session-goals/7/pause`, { - method: 'POST', - headers: { Authorization: 'Bearer tok' }, - }) - expect(fetchMock).toHaveBeenNthCalledWith(2, `${BASE_URL}/api/internal/session-goals/7/resume`, { - method: 'POST', - headers: { Authorization: 'Bearer tok' }, - }) - expect(fetchMock).toHaveBeenNthCalledWith(3, `${BASE_URL}/api/internal/session-goals/7/cancel`, { - method: 'POST', - headers: { Authorization: 'Bearer tok' }, - }) - }) - - it('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' } })) - - await expect(api.getLatestSessionGoal('ses_1')).rejects.toThrow() - }) -}) - describe('ManagerApi multi-runs', () => { const api = new ManagerApi(BASE_URL, 'tok') @@ -264,21 +173,6 @@ 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('ManagerApi mirror routes', () => { @@ -301,7 +195,7 @@ describe('ManagerApi mirror routes', () => { }) }) - it('keeps the goal and multi-run feature message unchanged', async () => { + it('keeps the multi-run feature message unchanged', async () => { const fetchMock = vi.fn() .mockResolvedValueOnce(errorResponse(401, JSON.stringify({ error: 'Unauthorized' }))) .mockResolvedValueOnce(okResponse({ workspaces: [] })) @@ -310,7 +204,7 @@ describe('ManagerApi mirror routes', () => { const error = await api.listMultiRuns(1).catch((err) => err) expect((error as ManagerApiError).code).toBe(MANAGER_FEATURE_MISSING) - expect((error as ManagerApiError).message).toContain('too old for ocm goals and multi-runs') + expect((error as ManagerApiError).message).toContain('too old for ocm multi-runs') }) it('sends the directory query on mirrorHead and mirrorContains', async () => { diff --git a/ocm-cli/test/package-compat.test.ts b/ocm-cli/test/package-compat.test.ts new file mode 100644 index 000000000..ba91278f9 --- /dev/null +++ b/ocm-cli/test/package-compat.test.ts @@ -0,0 +1,58 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { parseSemver } from '@opencode-manager/shared/utils' +import { describe, expect, it } from 'vitest' + +interface PackageManifest { + version: string + devDependencies: Record + peerDependencies: Record + peerDependenciesMeta: Record +} + +const PINNED_PACKAGES = ['@opentui/core', '@opentui/solid', '@opencode/theme'] as const + +/** + * Reads and parses a package manifest from an absolute path. + */ +function readManifest(path: string): PackageManifest { + return JSON.parse(readFileSync(path, 'utf8')) as PackageManifest +} + +/** + * Derives the minor-line peer range for a plugin's exact dependency pin. + * + * A `0.x.y` pin is constrained to its minor line; a pin at major `1` or above + * is constrained to its major line. + */ +function deriveMinorLineRange(pin: string): string { + const parsed = parseSemver(pin) + if (!parsed || parsed.prerelease.length > 0) { + throw new Error(`Cannot derive a peer range from pin "${pin}"`) + } + const { major, minor } = parsed + if (major === 0) { + return `>=0.${minor}.0 <0.${minor + 1}.0` + } + return `>=${pin} <${major + 1}.0.0` +} + +const ocmManifest = readManifest(join(__dirname, '..', 'package.json')) +const pluginManifest = readManifest( + join(__dirname, '..', 'node_modules', '@opencode', 'plugin', 'package.json'), +) + +describe('ocm-cli package compatibility with the installed @opencode/plugin', () => { + for (const name of PINNED_PACKAGES) { + it(`${name} matches the plugin peer pin`, () => { + const pluginPin = pluginManifest.peerDependencies[name] + expect(ocmManifest.devDependencies[name]).toBe(pluginPin) + expect(ocmManifest.peerDependencies[name]).toBe(deriveMinorLineRange(pluginPin)) + expect(ocmManifest.peerDependenciesMeta[name]?.optional).toBe(true) + }) + } + + it('@opencode/plugin devDependency matches the installed plugin version', () => { + expect(ocmManifest.devDependencies['@opencode/plugin']).toBe(pluginManifest.version) + }) +}) diff --git a/ocm-cli/test/tui-goal.test.ts b/ocm-cli/test/tui-goal.test.ts deleted file mode 100644 index f519ef993..000000000 --- a/ocm-cli/test/tui-goal.test.ts +++ /dev/null @@ -1,415 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import type { Context } from '@opencode/plugin/tui/context' -import type { SessionGoal } from '@opencode-manager/shared/schemas' -import { - runGoalCommand, - formatGoalStatus, - goalObjectiveSummary, - goalOutcomeToast, - parseGoalForm, - GOALS_ATTACH_REQUIRED, - GOALS_ROUTE_MISSING, -} from '../src/tui-goal.js' -import type { GoalActions } from '../src/tui-goal.js' -import { ManagerApiError } from '../src/manager-api.js' -import type { ManagerApi } from '../src/manager-api.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', () => ({ - resolveManagerApi: vi.fn(), -})) - -const remote: RemoteContext = { - managerUrl: 'https://manager.example', - managerHost: 'manager.example', - repoName: 'repo', - repoId: 1, -} - -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() - const sessionPrompt = vi.fn() - const status = vi.fn(() => 'idle') - const context = { - ui: { - router: { current: () => route }, - toast: { show: toast }, - dialog: { prompt: dialogPrompt, select }, - }, - data: { - session: { - get: () => session, - status, - }, - }, - client: { session: { prompt: sessionPrompt } }, - } as unknown as Context - return { context, toast, select, dialogPrompt, sessionPrompt, status } -} - -function makeApi() { - return { - getLatestSessionGoal: vi.fn(async () => null as SessionGoal | null), - startSessionGoal: vi.fn(), - pauseSessionGoal: vi.fn(), - resumeSessionGoal: vi.fn(), - cancelSessionGoal: vi.fn(), - } -} - -function makeStore() { - const set = vi.fn() - const store: GoalStore = { - watch: vi.fn(() => () => undefined), - set, - } - return { store, set } -} - -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, '/ocm-goal') - return deps.showDialog.mock.calls[0]![0].actions -} - -const blankLimits = { maxTurns: '', tokenBudget: '' } - -beforeEach(() => { - 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', () => { - it('renders the outcome, shared turn and token labels, and objective', () => { - expect( - formatGoalStatus(goal({ status: 'active', continuationCount: 2, maxContinuations: 5, tokenBudget: 1000, tokensUsed: 250 })), - ).toBe(`Goal active · Turn 2/5 · 250/${(1000).toLocaleString()} tokens · fix it`) - }) - - it('omits the token budget when there is none', () => { - expect(formatGoalStatus(goal({ status: 'paused', tokenBudget: null }))).toBe('Goal paused · Turn 0/5 · fix it') - }) - - 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', () => { - it('maps completed to success with the last reason', () => { - expect(goalOutcomeToast(goal({ status: 'completed', lastReason: 'all green' }))).toEqual({ - variant: 'success', - title: 'Goal completed', - message: 'all green', - }) - }) - - it('maps blocked to error and falls back to the objective', () => { - expect(goalOutcomeToast(goal({ status: 'blocked', lastReason: null, objective: 'fix it' }))).toEqual({ - variant: 'error', - title: 'Goal blocked', - message: 'fix it', - }) - }) - - it('maps stopped to warning and prefers the stop reason label', () => { - expect(goalOutcomeToast(goal({ status: 'stopped', stopReason: 'cancelled' }))).toEqual({ - variant: 'warning', - title: 'Goal stopped', - message: 'Cancelled', - }) - }) - - it('summarizes a long objective fallback', () => { - expect(goalOutcomeToast(goal({ status: 'blocked', lastReason: null, objective: 'x'.repeat(70) })).message).toBe(`${'x'.repeat(60)}…`) - }) -}) - -describe('parseGoalForm', () => { - it('trims the objective and leaves blank limits to the Manager defaults', () => { - expect(parseGoalForm({ objective: ' fix it ', ...blankLimits }, 'ses_a', '/repo')).toEqual({ - ok: true, - request: { sessionId: 'ses_a', directory: '/repo', objective: 'fix it' }, - }) - }) - - it('passes explicit limits through', () => { - expect(parseGoalForm({ objective: 'fix it', maxTurns: ' 12 ', tokenBudget: '50000' }, 'ses_a', '/repo')).toEqual({ - ok: true, - request: { sessionId: 'ses_a', directory: '/repo', objective: 'fix it', maxContinuations: 12, tokenBudget: 50000 }, - }) - }) - - it('rejects a blank objective and out-of-range or non-numeric limits', () => { - expect(parseGoalForm({ objective: ' ', ...blankLimits }, 'ses_a', '/repo')).toEqual({ - ok: false, - error: 'Describe what this session should accomplish.', - }) - for (const maxTurns of ['0', '201', 'ten', '1.5']) { - expect(parseGoalForm({ objective: 'x', maxTurns, tokenBudget: '' }, 'ses_a', '/repo')).toEqual({ - ok: false, - error: 'Max turns must be a whole number from 1 to 200.', - }) - } - for (const tokenBudget of ['0', '-5', 'lots']) { - expect(parseGoalForm({ objective: 'x', maxTurns: '', tokenBudget }, 'ses_a', '/repo')).toEqual({ - ok: false, - error: 'Token budget must be a positive whole number.', - }) - } - }) -}) - -describe('runGoalCommand', () => { - it('refuses when not attached to a Manager or without a goal store', async () => { - const fake = createFakeContext() - const api = makeApi() - const { store } = makeStore() - const detached = { ...depsFor(api, store), remote: undefined } - const storeless = { ...depsFor(api, store), store: undefined } - - 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(vi.mocked(resolveManagerApi)).not.toHaveBeenCalled() - }) - - it('refuses outside a session', async () => { - const fake = createFakeContext({ type: 'home' }) - const { store } = makeStore() - const deps = depsFor(makeApi(), store) - - await runGoalCommand(fake.context, deps, '/ocm-goal fix it') - - expect(fake.toast).toHaveBeenCalledWith({ variant: 'error', message: 'Not in a session' }) - 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() - 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, deps, '/ocm-goal fix it') - - expect(fake.toast).toHaveBeenCalledWith({ - variant: 'error', - message: 'No token stored. Run `ocm login https://manager.example`.', - }) - expect(api.getLatestSessionGoal).not.toHaveBeenCalled() - }) - - it('starts a goal directly from the slash argument and sends the objective as the next message', async () => { - const fake = createFakeContext() - const api = makeApi() - const started = goal({ status: 'active' }) - api.startSessionGoal.mockResolvedValue(started) - const { store, set } = makeStore() - const deps = depsFor(api, store) - fake.sessionPrompt.mockResolvedValue(undefined) - - 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 }) - expect(api.startSessionGoal.mock.invocationCallOrder[0]).toBeLessThan(fake.sessionPrompt.mock.invocationCallOrder[0]) - expect(deps.showDialog).not.toHaveBeenCalled() - }) - - it('queues the objective while the session is running', async () => { - const fake = createFakeContext() - const api = makeApi() - api.startSessionGoal.mockResolvedValue(goal()) - const { store } = makeStore() - fake.status.mockReturnValue('running') - fake.sessionPrompt.mockResolvedValue(undefined) - - await runGoalCommand(fake.context, depsFor(api, store), '/ocm-goal Fix it') - - expect(fake.sessionPrompt).toHaveBeenCalledWith(expect.objectContaining({ delivery: 'queue' })) - }) - - it('opens the dialog with the latest goal when the command has no objective', async () => { - const fake = createFakeContext() - const api = makeApi() - const last = goal({ status: 'completed' }) - api.getLatestSessionGoal.mockResolvedValue(last) - const { store } = makeStore() - const deps = depsFor(api, store) - - await runGoalCommand(fake.context, deps, '/ocm-goal') - - expect(deps.showDialog).toHaveBeenCalledWith({ - sessionID: 'ses_a', - store, - initialGoal: last, - initialObjective: '', - actions: expect.any(Object), - }) - expect(api.startSessionGoal).not.toHaveBeenCalled() - }) - - it('opens the dialog instead of starting when a goal is already open', async () => { - const fake = createFakeContext() - const api = makeApi() - const active = goal({ status: 'active' }) - api.getLatestSessionGoal.mockResolvedValue(active) - const { store } = makeStore() - const deps = depsFor(api, store) - - 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() - }) - - it('reports Manager errors, including an outdated Manager', async () => { - const fake = createFakeContext() - const api = makeApi() - const { store } = makeStore() - 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), '/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 }) - }) -}) - -describe('goal dialog actions', () => { - it('starts a goal with explicit limits', async () => { - const fake = createFakeContext() - const api = makeApi() - api.startSessionGoal.mockResolvedValue(goal()) - fake.sessionPrompt.mockResolvedValue(undefined) - const { store } = makeStore() - const actions = await dialogActions(fake, api, store) - - await expect(actions.start({ objective: 'fix it', maxTurns: '8', tokenBudget: '' })).resolves.toBeNull() - - expect(api.startSessionGoal).toHaveBeenCalledWith({ sessionId: 'ses_a', directory: '/repo', objective: 'fix it', maxContinuations: 8 }) - }) - - it('returns a validation error without calling the API', async () => { - const fake = createFakeContext() - const api = makeApi() - const { store } = makeStore() - const actions = await dialogActions(fake, api, store) - - await expect(actions.start({ objective: 'fix it', maxTurns: '0', tokenBudget: '' })).resolves.toBe( - 'Max turns must be a whole number from 1 to 200.', - ) - expect(api.startSessionGoal).not.toHaveBeenCalled() - }) - - it('cancels the goal and explains why when sending the objective fails', async () => { - const fake = createFakeContext() - const api = makeApi() - const started = goal({ status: 'active' }) - const cancelled = goal({ status: 'stopped', stopReason: 'cancelled' }) - api.startSessionGoal.mockResolvedValue(started) - api.cancelSessionGoal.mockResolvedValue(cancelled) - fake.sessionPrompt.mockRejectedValue(new Error('send failed')) - const { store, set } = makeStore() - const actions = await dialogActions(fake, api, store) - - await expect(actions.start({ objective: 'fix it', ...blankLimits })).resolves.toBe( - 'The goal was cancelled because the objective could not be sent: send failed', - ) - expect(api.cancelSessionGoal).toHaveBeenCalledWith(started.id) - expect(set).toHaveBeenLastCalledWith(cancelled) - }) - - it('still reports the send failure when cancelling fails', async () => { - const fake = createFakeContext() - const api = makeApi() - api.startSessionGoal.mockResolvedValue(goal()) - api.cancelSessionGoal.mockRejectedValue(new Error('cancel failed')) - fake.sessionPrompt.mockRejectedValue(new Error('send failed')) - const { store, set } = makeStore() - const actions = await dialogActions(fake, api, store) - - await expect(actions.start({ objective: 'fix it', ...blankLimits })).resolves.toMatch(/send failed$/) - expect(set).toHaveBeenCalledTimes(1) - }) - - it('pauses, resumes, and cancels through the Manager and updates the store', async () => { - const fake = createFakeContext() - const api = makeApi() - const paused = goal({ status: 'paused' }) - const resumed = goal({ status: 'active' }) - const stopped = goal({ status: 'stopped', stopReason: 'cancelled' }) - api.pauseSessionGoal.mockResolvedValue(paused) - api.resumeSessionGoal.mockResolvedValue(resumed) - api.cancelSessionGoal.mockResolvedValue(stopped) - const { store, set } = makeStore() - const actions = await dialogActions(fake, api, store) - - await expect(actions.pause(goal())).resolves.toBeNull() - await expect(actions.resume(paused)).resolves.toBeNull() - await expect(actions.cancel(resumed)).resolves.toBeNull() - - expect(api.pauseSessionGoal).toHaveBeenCalledWith(7) - expect(set.mock.calls.map((call) => call[0])).toEqual([paused, resumed, stopped]) - }) - - it('returns a goal action failure as a message', async () => { - const fake = createFakeContext() - const api = makeApi() - api.pauseSessionGoal.mockRejectedValue(new ManagerApiError('pause session goal failed (409): not active', 409, 'x', 'pause session goal')) - const { store, set } = makeStore() - const actions = await dialogActions(fake, api, store) - - await expect(actions.pause(goal())).resolves.toBe('pause session goal failed (409): not active') - expect(set).not.toHaveBeenCalled() - }) -}) diff --git a/ocm-cli/test/tui-plugin.test.ts b/ocm-cli/test/tui-plugin.test.ts index 42f9ea172..f4efc6a81 100644 --- a/ocm-cli/test/tui-plugin.test.ts +++ b/ocm-cli/test/tui-plugin.test.ts @@ -74,7 +74,7 @@ vi.mock('../src/manager-api.js', () => ({ isManagerRouteMissing: vi.fn(() => false), })) -const noFeatures = { remote: undefined, goals: undefined, dialogs: { goal: vi.fn(), multiRunLaunch: vi.fn(), multiRuns: vi.fn() } } +const noFeatures = { remote: undefined, dialogs: { multiRunLaunch: vi.fn(), multiRuns: vi.fn() } } const matched = { repoId: 1, name: 'repo', projectId: 'proj_1', branch: 'main' } const repos = [ @@ -212,17 +212,16 @@ describe('setupOcm', () => { expect(factory().mode).toBe('global') }) - it('exposes the goal slash command', async () => { + it('registers no goal command', async () => { const fake = createFakeContext() await setupOcm(fake.context, vi.fn(), noFeatures) renderAppSlot(fake) const factory = fake.layer.mock.calls[0]![0] as () => { - commands: { id: string; slash?: { name: string; arguments?: true } }[] + commands: { id: string }[] } - const goal = factory().commands.find((entry) => entry.id === 'ocm.goal') - expect(goal?.slash).toEqual({ name: 'ocm-goal', arguments: true }) + expect(factory().commands.map((entry) => entry.id)).toEqual(['ocm.session.move', 'ocm.switch', 'ocm.multirun']) }) it('exposes the multi-run slash command', async () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4937d0039..89f9f9d3e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -311,14 +311,17 @@ importers: specifier: workspace:* version: link:../shared '@opencode/plugin': - specifier: 2.1.2 - version: 2.1.2(@opentelemetry/semantic-conventions@1.43.0)(@opentui/core@0.5.12(typescript@5.9.3)(web-tree-sitter@0.25.10))(@opentui/solid@0.5.12(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10))(redis@6.2.1(@opentelemetry/api@1.9.0))(solid-js@1.9.12) + specifier: 2.2.1 + version: 2.2.1(@opencode/theme@2.2.1(typescript@5.9.3)(web-tree-sitter@0.25.10))(@opentelemetry/semantic-conventions@1.43.0)(@opentui/core@0.7.0(typescript@5.9.3)(web-tree-sitter@0.25.10))(@opentui/solid@0.7.0(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10))(redis@6.2.1(@opentelemetry/api@1.9.0))(solid-js@1.9.12)(vite@7.3.0(@types/node@22.19.19)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.9.0)) + '@opencode/theme': + specifier: 2.2.1 + version: 2.2.1(typescript@5.9.3)(web-tree-sitter@0.25.10) '@opentui/core': - specifier: ^0.5.12 - version: 0.5.12(typescript@5.9.3)(web-tree-sitter@0.25.10) + specifier: 0.7.0 + version: 0.7.0(typescript@5.9.3)(web-tree-sitter@0.25.10) '@opentui/solid': - specifier: ^0.5.12 - version: 0.5.12(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10) + specifier: 0.7.0 + version: 0.7.0(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10) '@types/bun': specifier: ^1.3.14 version: 1.3.14 @@ -350,8 +353,11 @@ importers: shared: dependencies: '@opencode/client': - specifier: 2.1.2 - version: 2.1.2(effect@4.0.1)(solid-js@1.9.15) + specifier: 2.2.1 + version: 2.2.1(effect@4.0.1)(solid-js@1.9.15) + '@opencode/schema': + specifier: 2.2.1 + version: 2.2.1 jsonc-parser: specifier: ^3.3.1 version: 3.3.1 @@ -500,14 +506,18 @@ packages: resolution: {integrity: sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==} engines: {node: '>=6.9.0'} - '@babel/core@7.28.0': - resolution: {integrity: sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==} + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} engines: {node: '>=6.9.0'} '@babel/core@7.28.5': resolution: {integrity: sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==} engines: {node: '>=6.9.0'} + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + '@babel/generator@7.28.5': resolution: {integrity: sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==} engines: {node: '>=6.9.0'} @@ -524,16 +534,16 @@ packages: resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==} engines: {node: '>=6.9.0'} + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + '@babel/helper-create-class-features-plugin@7.29.7': resolution: {integrity: sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0 - '@babel/helper-globals@7.28.0': - resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} - engines: {node: '>=6.9.0'} - '@babel/helper-globals@7.29.7': resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} engines: {node: '>=6.9.0'} @@ -546,10 +556,6 @@ packages: resolution: {integrity: sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==} engines: {node: '>=6.9.0'} - '@babel/helper-module-imports@7.27.1': - resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==} - engines: {node: '>=6.9.0'} - '@babel/helper-module-imports@7.29.7': resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} engines: {node: '>=6.9.0'} @@ -592,6 +598,10 @@ packages: resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.29.11': + resolution: {integrity: sha512-snZbO7MEDOof+hvNa97EjEY7qD7h2kwvQr5AJC3ZhQudKW5dnej6k3kP9NQV88hJwaS8vDqRLFo9rivLEhle2g==} + engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.29.7': resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} engines: {node: '>=6.9.0'} @@ -608,10 +618,18 @@ packages: resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + '@babel/helpers@7.28.4': resolution: {integrity: sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==} engines: {node: '>=6.9.0'} + '@babel/helpers@7.29.10': + resolution: {integrity: sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg==} + engines: {node: '>=6.9.0'} + '@babel/parser@7.28.5': resolution: {integrity: sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==} engines: {node: '>=6.0.0'} @@ -692,6 +710,10 @@ packages: resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} engines: {node: '>=6.9.0'} + '@babel/types@7.29.11': + resolution: {integrity: sha512-WCsemW40Cpc6gouC1SrY6PBaZ+oLRlEVY9XBvZBI7il/HyUrb2nJ505wE1MHEFyDZNnrWDEuy4jL50WVHfTTSQ==} + engines: {node: '>=6.9.0'} + '@babel/types@7.29.7': resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} @@ -1282,11 +1304,11 @@ packages: resolution: {integrity: sha512-mGUWr1uMnf0le2TwfOZY4SFxZGXGfm4Jtay/nwAa2FLNAKXUoUwaGwBMNH36UHPtinWfTSJ3nqFQr0091CxVGg==} engines: {node: ^20.17.0 || >=22.9.0} - '@opencode/ai@2.1.2': - resolution: {integrity: sha512-I2LeTbMRDmVtSWaIF2B0mwQitEHHg89fL28LdWQVec6z9SeFrx7WpEry5rxvYGKPvut8Mz7Kz+XCnVX/xhhcKg==} + '@opencode/ai@2.2.1': + resolution: {integrity: sha512-CjCIrqnh+GCWFJejWoRPu8M+yghX3eIVFeDBdMjvQrLkoJ3Hv4RjxD2InolHnRFK8lAEq+IJ6D8gwiTZMgRSzg==} - '@opencode/client@2.1.2': - resolution: {integrity: sha512-c7FqI/zMy6HrdVGHynzouiz3y1smokGuimh2+Is2iVWz8I8VzJEty9eTrvQkQzXG1Hj5K2FL1K5fytQ7yJEd9w==} + '@opencode/client@2.2.1': + resolution: {integrity: sha512-7QWtocDRR9eUsCJpyaj6k+aAEVDWO2CIzn9lwDNBJhiyt2hMf+yOyV/BKeK/9R7qKcXEOZ0tlGDvQC8HEtyjQg==} peerDependencies: effect: 4.0.1 solid-js: '>=1.9.0' @@ -1296,13 +1318,16 @@ packages: solid-js: optional: true - '@opencode/plugin@2.1.2': - resolution: {integrity: sha512-vgGoLF/Kb7uyOsRmAHViN7BRPrAl0c3UVqvKQ/ZDl42IKzqsxedpAyoVpwxQHt+3LF6non2sOvi6Y+kKzDHheg==} + '@opencode/plugin@2.2.1': + resolution: {integrity: sha512-m5va0KJpG6CpyhKRQL/lKIP7tP0LgUvcuezpmob0e8spDOMtXh/+1Idb2zKxuIq14mKjwbQd0nz+BJbsqw1fqA==} peerDependencies: - '@opencode/theme': 2.1.2 - '@opentui/core': '>=0.6.6' - '@opentui/solid': '>=0.6.6' + '@opencode/theme': 2.2.1 + '@opentui/core': 0.7.0 + '@opentui/solid': 0.7.0 + electron: '*' solid-js: '>=1.9.0' + vite: '>=6' + vite-plugin-solid: '>=2.11' peerDependenciesMeta: '@opencode/theme': optional: true @@ -1310,17 +1335,26 @@ packages: optional: true '@opentui/solid': optional: true + electron: + optional: true solid-js: optional: true + vite: + optional: true + vite-plugin-solid: + optional: true - '@opencode/protocol@2.1.2': - resolution: {integrity: sha512-dNJuZrJlcUnc3mP7GFoawXECpUoUA+1TEbXvXkFKteq9tpZbO5Qsi1AWqocRJH2cIfG8v5rddyBTyX4+0ax9Yw==} + '@opencode/protocol@2.2.1': + resolution: {integrity: sha512-lqkkRhrckEKmiSJi249rvfX/fXX8JeiMS7adtXonO1Ol3IqnEqY4uSZgvQiQ11SBR5RyDQaRW9KGoOjaTY4PAQ==} - '@opencode/schema@2.1.2': - resolution: {integrity: sha512-gYbneOINd0Tt41krlC79OWjcXMPlW9EjIR+tmG3EYD8SEA4xCdZh0WhZLrFDlLMKDcb6d9xeHTFwhqGACt0IRg==} + '@opencode/schema@2.2.1': + resolution: {integrity: sha512-TicxPQC1UKqq954X1yUKmBlXfJYl5CRr52FKwLZbYOfTxJd406Hoqt2arM4YI3c+abs17b9sumoq5uqFLhiGFA==} - '@opencode/util@2.1.2': - resolution: {integrity: sha512-9uyLAnOSgVRN6NLWEMcwXDmLl+n3FKbXxmAaZVC1l5zBPKEGvc9PKa8KG9Kar7n3iGSPRk6R/JhW/j9s0ciNEQ==} + '@opencode/theme@2.2.1': + resolution: {integrity: sha512-YAKT/TacMHM04rLi9o1zyVpgEhy3hpuHZKGs/VC6+wigRrieLvyTvS7/FmcOVEeophNmwL0FYI1sD/NCTLlxlA==} + + '@opencode/util@2.2.1': + resolution: {integrity: sha512-MgJ8oJ/S+L5gRBmYk8bEHRVR99eEvUUDGg8fZLG/UD1+RPisJ6Ce1ZO++pBjTvkdS+Okk6lPxhbjYemIQtSciQ==} '@opentelemetry/api-logs@0.219.0': resolution: {integrity: sha512-FFx7YnaYJlIjqWW/AG/yAZ0L/NEY724PipXXXQLdtZPbLwBGbUMTGL1i/esI56TWfTUXxhLfpgrnWJCG8aUJyg==} @@ -1396,56 +1430,54 @@ packages: resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==} engines: {node: '>=14'} - '@opentui/core-darwin-arm64@0.5.12': - resolution: {integrity: sha512-YdVnP0tAyerBNl0mIcmQEOotPeZzW1VnSXKBl5cyZ5e6nDd2Y+ui/8eRPpn1oqcamf1NCnzS4ohMgejOvna8Zg==} + '@opentui/core-darwin-arm64@0.7.0': + resolution: {integrity: sha512-meEyZVUeCIauqHLWo/IsSlKzZPTnGIEjRLigefSZbrSAcOiMryd3OcUWvSByIXpYb9UPOpW0YxKKfVwepb9u2A==} cpu: [arm64] os: [darwin] - '@opentui/core-darwin-x64@0.5.12': - resolution: {integrity: sha512-uRrQJdHmLUSj3PV23QPi3WSimYTTxcXnVouxF6U4xMXlOv4N3SxnHfVwMRQkPqbGOfvVWHeLE6FdK4C+ubU0sQ==} + '@opentui/core-darwin-x64@0.7.0': + resolution: {integrity: sha512-hZQ4OfAua5CtUv3hpZ81zhNTTLULbg0leQ/3eupjefityGP6163eS56IzymTWuiM/mVD/7Ad5bicH0I2JXhr1g==} cpu: [x64] os: [darwin] - '@opentui/core-linux-arm64-musl@0.5.12': - resolution: {integrity: sha512-VZ2sNMw1d/r1SLPjUbOP9LKscKz1CQjID8adTL6gG8Lrrq+mYcIUxutyB+P/eG0J/7oRZLPR6OMt7dUOap6RTg==} + '@opentui/core-linux-arm64-musl@0.7.0': + resolution: {integrity: sha512-BkTn+WLf9OPR4KhBnuOki+R/cgk2qwbL49eQn5fEYPeiJD4HPalGbEZDea59UjwNEJ7rqKq2yuPAXQjkDZ6y3w==} cpu: [arm64] os: [linux] - libc: [musl] - '@opentui/core-linux-arm64@0.5.12': - resolution: {integrity: sha512-XeKhuIaEtgipvuPHbl4qPOBj+Ut+2zObmsxMVM1jDcjz/FatG9PGeGQPx1G1SnvH2AgpT4K+eCu7DUF0+yIqoQ==} + '@opentui/core-linux-arm64@0.7.0': + resolution: {integrity: sha512-/u+Kalzkz8NntUW5zjypgt9fnzeKohLG9YMEas7n98KvyaYScrtO4Mdj5iv/STNB3IfocJOChzYAbO6C8TmlPQ==} cpu: [arm64] os: [linux] - '@opentui/core-linux-x64-musl@0.5.12': - resolution: {integrity: sha512-WWW0hVBoSYZ3D6AgZ4u2Y5/u/IyIq2pDb+4yI3WgJ70Wyt6ofHy+6kRGRgbXFn1p+rPInAHjCXD2v6C7iEKSrA==} + '@opentui/core-linux-x64-musl@0.7.0': + resolution: {integrity: sha512-HrJbcAIuayzQmWVzv+cjez37fNHL9FDoHmjwxAb4iK5GTjl89FZCxeKopyua14RA6WpP8/zUJQi8U16mm6XptA==} cpu: [x64] os: [linux] - libc: [musl] - '@opentui/core-linux-x64@0.5.12': - resolution: {integrity: sha512-eZiCjEzwbb6qClPPfk32Nha9xmr9obt69Xj0+9SKsXxWLBKkjQEGOMRoh/R9ObaQF4aq8If1xV3VEY0sD9W9vg==} + '@opentui/core-linux-x64@0.7.0': + resolution: {integrity: sha512-OD2tGl1r27zcgCsuc+TYU5e+qhrbsRSI0YUBEb0ebQo8P6Ps2FL9LFenH9om4fddCh06G9Zd45ekJMhlDrGD3Q==} cpu: [x64] os: [linux] - '@opentui/core-win32-arm64@0.5.12': - resolution: {integrity: sha512-aLbm6870Ybls6CYL4zMOCImTBPLZHZMUXJFGqMI44lIWxitkAtT6zg5lYA4oRqFRzzryDclxr29+hDgT3p3Blw==} + '@opentui/core-win32-arm64@0.7.0': + resolution: {integrity: sha512-8yqBoRsDbvKl2kGX0O8t5kpnVEnbzs+2qUZlJ8sbeA3Z+73WFj36G+clUIMVxpspOu78iKXlD84NIwTxjZUPKw==} cpu: [arm64] os: [win32] - '@opentui/core-win32-x64@0.5.12': - resolution: {integrity: sha512-KTwtwpfd2zF9opVh3SyRJYDd1o3Xv4XL8OZb8Zi+CqWUel6Y2IDCiVivCv8fGJt3J7wOIXXtuZI9ZUkLyKJCiQ==} + '@opentui/core-win32-x64@0.7.0': + resolution: {integrity: sha512-OfNAX7fNFHhOiiojUkIjhVOZf+IDjpbpT5G7HrxGqhlZ+n4jITrrJzACHctteWbMUYcibieJzIIoKuLdmD5I+w==} cpu: [x64] os: [win32] - '@opentui/core@0.5.12': - resolution: {integrity: sha512-ZXBE5gmvdovmV8zJQrOQf6E44v1tJRDEgrM2MYhEglzgXZ+smIUp95O8zeRYGsuIzQIiMPMgQqKtTJuzvAb7BQ==} - engines: {bun: '>=1.3.0', node: '>=26.4.0'} + '@opentui/core@0.7.0': + resolution: {integrity: sha512-2iWBw9oo+ZJfPbOWeTLXfDV63yTDRfclxltI4IgTa6n7OB90x5xplLMH3y9DXhnWibzTui00GbK3ZCwKfUU1SQ==} + engines: {bun: '>=1.3.14', node: '>=26.4.0'} peerDependencies: web-tree-sitter: 0.25.10 - '@opentui/solid@0.5.12': - resolution: {integrity: sha512-hAiVlVMtT7AkHGblKwcW1YAuXtxkSy1XSf/RRc4j3IlG3mTNX0bhJdnGOo3Xw14EqeZMp41Mcp5WzHAzMm/DzA==} + '@opentui/solid@0.7.0': + resolution: {integrity: sha512-S6SNZGRykV02UjNbM+f/6lSeAPN3dfEbjcK1jAAdFCfa+EVhJqcJVyaJgtwHLU9fI6c/O1VKXPzbbFYj2RhNVQ==} peerDependencies: solid-js: 1.9.12 @@ -1995,67 +2027,56 @@ packages: resolution: {integrity: sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.54.0': resolution: {integrity: sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.54.0': resolution: {integrity: sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.54.0': resolution: {integrity: sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.54.0': resolution: {integrity: sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-gnu@4.54.0': resolution: {integrity: sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.54.0': resolution: {integrity: sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.54.0': resolution: {integrity: sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.54.0': resolution: {integrity: sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.54.0': resolution: {integrity: sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.54.0': resolution: {integrity: sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-openharmony-arm64@4.54.0': resolution: {integrity: sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==} @@ -2209,28 +2230,24 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.1.18': resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.1.18': resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.1.18': resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.1.18': resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==} @@ -2873,10 +2890,6 @@ packages: resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==} engines: {node: 20 || >=22} - brace-expansion@5.0.4: - resolution: {integrity: sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==} - engines: {node: 18 || 20 || >=22} - browserslist@4.28.1: resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -3618,6 +3631,7 @@ packages: glob@10.5.0: resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@13.0.5: @@ -4035,28 +4049,24 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.30.2: resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.30.2: resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.30.2: resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.30.2: resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} @@ -4303,10 +4313,6 @@ packages: minimalistic-assert@1.0.1: resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} - minimatch@10.2.4: - resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} - engines: {node: 18 || 20 || >=22} - minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} @@ -5792,7 +5798,7 @@ snapshots: '@babel/code-frame@7.27.1': dependencies: - '@babel/helper-validator-identifier': 7.28.5 + '@babel/helper-validator-identifier': 7.29.7 js-tokens: 4.0.0 picocolors: 1.1.1 @@ -5804,25 +5810,7 @@ snapshots: '@babel/compat-data@7.28.5': {} - '@babel/core@7.28.0': - dependencies: - '@ampproject/remapping': 2.3.0 - '@babel/code-frame': 7.29.7 - '@babel/generator': 7.29.7 - '@babel/helper-compilation-targets': 7.27.2 - '@babel/helper-module-transforms': 7.29.7(@babel/core@7.28.0) - '@babel/helpers': 7.28.4 - '@babel/parser': 7.29.7 - '@babel/template': 7.29.7 - '@babel/traverse': 7.29.7 - '@babel/types': 7.29.7 - convert-source-map: 2.0.0 - debug: 4.4.3 - gensync: 1.0.0-beta.2 - json5: 2.2.3 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color + '@babel/compat-data@7.29.7': {} '@babel/core@7.28.5': dependencies: @@ -5844,10 +5832,30 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.10 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + '@babel/generator@7.28.5': dependencies: - '@babel/parser': 7.28.5 - '@babel/types': 7.28.5 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 @@ -5872,21 +5880,27 @@ snapshots: lru-cache: 5.1.1 semver: 6.3.1 - '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.28.0)': + '@babel/helper-compilation-targets@7.29.7': dependencies: - '@babel/core': 7.28.0 + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.1 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 '@babel/helper-annotate-as-pure': 7.29.7 '@babel/helper-member-expression-to-functions': 7.29.7 '@babel/helper-optimise-call-expression': 7.29.7 - '@babel/helper-replace-supers': 7.29.7(@babel/core@7.28.0) + '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7) '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 '@babel/traverse': 7.29.7 semver: 6.3.1 transitivePeerDependencies: - supports-color - '@babel/helper-globals@7.28.0': {} - '@babel/helper-globals@7.29.7': {} '@babel/helper-member-expression-to-functions@7.29.7': @@ -5900,13 +5914,6 @@ snapshots: dependencies: '@babel/types': 7.29.7 - '@babel/helper-module-imports@7.27.1': - dependencies: - '@babel/traverse': 7.28.5 - '@babel/types': 7.28.5 - transitivePeerDependencies: - - supports-color - '@babel/helper-module-imports@7.29.7': dependencies: '@babel/traverse': 7.29.7 @@ -5917,15 +5924,15 @@ snapshots: '@babel/helper-module-transforms@7.28.3(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 - '@babel/helper-module-imports': 7.27.1 - '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.28.5 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.29.7(@babel/core@7.28.0)': + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.28.0 + '@babel/core': 7.29.7 '@babel/helper-module-imports': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 '@babel/traverse': 7.29.7 @@ -5940,9 +5947,9 @@ snapshots: '@babel/helper-plugin-utils@7.29.7': {} - '@babel/helper-replace-supers@7.29.7(@babel/core@7.28.0)': + '@babel/helper-replace-supers@7.29.7(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.28.0 + '@babel/core': 7.29.7 '@babel/helper-member-expression-to-functions': 7.29.7 '@babel/helper-optimise-call-expression': 7.29.7 '@babel/traverse': 7.29.7 @@ -5958,6 +5965,8 @@ snapshots: '@babel/helper-string-parser@7.27.1': {} + '@babel/helper-string-parser@7.29.11': {} + '@babel/helper-string-parser@7.29.7': {} '@babel/helper-validator-identifier@7.28.5': {} @@ -5966,10 +5975,17 @@ snapshots: '@babel/helper-validator-option@7.27.1': {} + '@babel/helper-validator-option@7.29.7': {} + '@babel/helpers@7.28.4': dependencies: - '@babel/template': 7.27.2 - '@babel/types': 7.28.5 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/helpers@7.29.10': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.11 '@babel/parser@7.28.5': dependencies: @@ -5979,20 +5995,20 @@ snapshots: dependencies: '@babel/types': 7.29.7 - '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.28.0)': + '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.28.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.28.0)': + '@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.28.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-modules-commonjs@7.29.7(@babel/core@7.28.0)': + '@babel/plugin-transform-modules-commonjs@7.29.7(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.28.0 - '@babel/helper-module-transforms': 7.29.7(@babel/core@7.28.0) + '@babel/core': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) '@babel/helper-plugin-utils': 7.29.7 transitivePeerDependencies: - supports-color @@ -6007,25 +6023,25 @@ snapshots: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-transform-typescript@7.29.7(@babel/core@7.28.0)': + '@babel/plugin-transform-typescript@7.29.7(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.28.0 + '@babel/core': 7.29.7 '@babel/helper-annotate-as-pure': 7.29.7 - '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.28.0) + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) '@babel/helper-plugin-utils': 7.29.7 '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 - '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.28.0) + '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7) transitivePeerDependencies: - supports-color - '@babel/preset-typescript@7.27.1(@babel/core@7.28.0)': + '@babel/preset-typescript@7.27.1(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.28.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 '@babel/helper-validator-option': 7.27.1 - '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.28.0) - '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.28.0) - '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.28.0) + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7) transitivePeerDependencies: - supports-color @@ -6035,9 +6051,9 @@ snapshots: '@babel/template@7.27.2': dependencies: - '@babel/code-frame': 7.27.1 - '@babel/parser': 7.28.5 - '@babel/types': 7.28.5 + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 '@babel/template@7.29.7': dependencies: @@ -6047,12 +6063,12 @@ snapshots: '@babel/traverse@7.28.5': dependencies: - '@babel/code-frame': 7.27.1 - '@babel/generator': 7.28.5 - '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.28.5 - '@babel/template': 7.27.2 - '@babel/types': 7.28.5 + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -6074,6 +6090,11 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@babel/types@7.29.11': + dependencies: + '@babel/helper-string-parser': 7.29.11 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/types@7.29.7': dependencies: '@babel/helper-string-parser': 7.29.7 @@ -6609,10 +6630,10 @@ snapshots: node-gyp: 12.4.0 proc-log: 6.1.0 - '@opencode/ai@2.1.2': + '@opencode/ai@2.2.1': dependencies: '@aws-sdk/credential-providers': 3.1057.0 - '@opencode/schema': 2.1.2 + '@opencode/schema': 2.2.1 '@smithy/eventstream-codec': 4.2.14 '@smithy/util-utf8': 4.2.2 aws4fetch: 1.0.20 @@ -6621,35 +6642,37 @@ snapshots: transitivePeerDependencies: - supports-color - '@opencode/client@2.1.2(effect@4.0.1)(solid-js@1.9.12)': + '@opencode/client@2.2.1(effect@4.0.1)(solid-js@1.9.12)': dependencies: - '@opencode/protocol': 2.1.2 - '@opencode/schema': 2.1.2 + '@opencode/protocol': 2.2.1 + '@opencode/schema': 2.2.1 optionalDependencies: effect: 4.0.1 solid-js: 1.9.12 - '@opencode/client@2.1.2(effect@4.0.1)(solid-js@1.9.15)': + '@opencode/client@2.2.1(effect@4.0.1)(solid-js@1.9.15)': dependencies: - '@opencode/protocol': 2.1.2 - '@opencode/schema': 2.1.2 + '@opencode/protocol': 2.2.1 + '@opencode/schema': 2.2.1 optionalDependencies: effect: 4.0.1 solid-js: 1.9.15 - '@opencode/plugin@2.1.2(@opentelemetry/semantic-conventions@1.43.0)(@opentui/core@0.5.12(typescript@5.9.3)(web-tree-sitter@0.25.10))(@opentui/solid@0.5.12(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10))(redis@6.2.1(@opentelemetry/api@1.9.0))(solid-js@1.9.12)': + '@opencode/plugin@2.2.1(@opencode/theme@2.2.1(typescript@5.9.3)(web-tree-sitter@0.25.10))(@opentelemetry/semantic-conventions@1.43.0)(@opentui/core@0.7.0(typescript@5.9.3)(web-tree-sitter@0.25.10))(@opentui/solid@0.7.0(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10))(redis@6.2.1(@opentelemetry/api@1.9.0))(solid-js@1.9.12)(vite@7.3.0(@types/node@22.19.19)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.9.0))': dependencies: '@ai-sdk/provider': 3.0.8 - '@opencode/ai': 2.1.2 - '@opencode/client': 2.1.2(effect@4.0.1)(solid-js@1.9.12) - '@opencode/protocol': 2.1.2 - '@opencode/schema': 2.1.2 - '@opencode/util': 2.1.2(@opentelemetry/semantic-conventions@1.43.0)(redis@6.2.1(@opentelemetry/api@1.9.0)) + '@opencode/ai': 2.2.1 + '@opencode/client': 2.2.1(effect@4.0.1)(solid-js@1.9.12) + '@opencode/protocol': 2.2.1 + '@opencode/schema': 2.2.1 + '@opencode/util': 2.2.1(@opentelemetry/semantic-conventions@1.43.0)(redis@6.2.1(@opentelemetry/api@1.9.0)) effect: 4.0.1 optionalDependencies: - '@opentui/core': 0.5.12(typescript@5.9.3)(web-tree-sitter@0.25.10) - '@opentui/solid': 0.5.12(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10) + '@opencode/theme': 2.2.1(typescript@5.9.3)(web-tree-sitter@0.25.10) + '@opentui/core': 0.7.0(typescript@5.9.3)(web-tree-sitter@0.25.10) + '@opentui/solid': 0.7.0(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10) solid-js: 1.9.12 + vite: 7.3.0(@types/node@22.19.19)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.9.0) transitivePeerDependencies: - '@opentelemetry/sdk-trace-web' - '@opentelemetry/semantic-conventions' @@ -6658,17 +6681,25 @@ snapshots: - supports-color - utf-8-validate - '@opencode/protocol@2.1.2': + '@opencode/protocol@2.2.1': dependencies: - '@opencode/schema': 2.1.2 + '@opencode/schema': 2.2.1 effect: 4.0.1 - '@opencode/schema@2.1.2': + '@opencode/schema@2.2.1': dependencies: '@standard-schema/spec': 1.1.0 effect: 4.0.1 - '@opencode/util@2.1.2(@opentelemetry/semantic-conventions@1.43.0)(redis@6.2.1(@opentelemetry/api@1.9.0))': + '@opencode/theme@2.2.1(typescript@5.9.3)(web-tree-sitter@0.25.10)': + dependencies: + '@opentui/core': 0.7.0(typescript@5.9.3)(web-tree-sitter@0.25.10) + effect: 4.0.1 + transitivePeerDependencies: + - typescript + - web-tree-sitter + + '@opencode/util@2.2.1(@opentelemetry/semantic-conventions@1.43.0)(redis@6.2.1(@opentelemetry/api@1.9.0))': dependencies: '@effect/opentelemetry': 4.0.1(@opentelemetry/api-logs@0.219.0)(@opentelemetry/api@1.9.0)(@opentelemetry/resources@2.8.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-logs@0.219.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-metrics@2.8.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-node@2.8.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.43.0)(effect@4.0.1) '@effect/platform-node': 4.0.1(effect@4.0.1)(redis@6.2.1(@opentelemetry/api@1.9.0)) @@ -6776,57 +6807,56 @@ snapshots: '@opentelemetry/semantic-conventions@1.43.0': {} - '@opentui/core-darwin-arm64@0.5.12': + '@opentui/core-darwin-arm64@0.7.0': optional: true - '@opentui/core-darwin-x64@0.5.12': + '@opentui/core-darwin-x64@0.7.0': optional: true - '@opentui/core-linux-arm64-musl@0.5.12': + '@opentui/core-linux-arm64-musl@0.7.0': optional: true - '@opentui/core-linux-arm64@0.5.12': + '@opentui/core-linux-arm64@0.7.0': optional: true - '@opentui/core-linux-x64-musl@0.5.12': + '@opentui/core-linux-x64-musl@0.7.0': optional: true - '@opentui/core-linux-x64@0.5.12': + '@opentui/core-linux-x64@0.7.0': optional: true - '@opentui/core-win32-arm64@0.5.12': + '@opentui/core-win32-arm64@0.7.0': optional: true - '@opentui/core-win32-x64@0.5.12': + '@opentui/core-win32-x64@0.7.0': optional: true - '@opentui/core@0.5.12(typescript@5.9.3)(web-tree-sitter@0.25.10)': + '@opentui/core@0.7.0(typescript@5.9.3)(web-tree-sitter@0.25.10)': dependencies: bun-ffi-structs: 0.3.1(typescript@5.9.3) diff: 9.0.0 marked: 17.0.1 string-width: 7.2.0 - strip-ansi: 7.1.2 web-tree-sitter: 0.25.10 optionalDependencies: - '@opentui/core-darwin-arm64': 0.5.12 - '@opentui/core-darwin-x64': 0.5.12 - '@opentui/core-linux-arm64': 0.5.12 - '@opentui/core-linux-arm64-musl': 0.5.12 - '@opentui/core-linux-x64': 0.5.12 - '@opentui/core-linux-x64-musl': 0.5.12 - '@opentui/core-win32-arm64': 0.5.12 - '@opentui/core-win32-x64': 0.5.12 + '@opentui/core-darwin-arm64': 0.7.0 + '@opentui/core-darwin-x64': 0.7.0 + '@opentui/core-linux-arm64': 0.7.0 + '@opentui/core-linux-arm64-musl': 0.7.0 + '@opentui/core-linux-x64': 0.7.0 + '@opentui/core-linux-x64-musl': 0.7.0 + '@opentui/core-win32-arm64': 0.7.0 + '@opentui/core-win32-x64': 0.7.0 transitivePeerDependencies: - typescript - '@opentui/solid@0.5.12(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10)': + '@opentui/solid@0.7.0(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10)': dependencies: - '@babel/core': 7.28.0 - '@babel/preset-typescript': 7.27.1(@babel/core@7.28.0) - '@opentui/core': 0.5.12(typescript@5.9.3)(web-tree-sitter@0.25.10) + '@babel/core': 7.29.7 + '@babel/preset-typescript': 7.27.1(@babel/core@7.29.7) + '@opentui/core': 0.7.0(typescript@5.9.3)(web-tree-sitter@0.25.10) babel-plugin-module-resolver: 5.0.2 - babel-preset-solid: 1.9.12(@babel/core@7.28.0)(solid-js@1.9.12) + babel-preset-solid: 1.9.12(@babel/core@7.29.7)(solid-js@1.9.12) entities: 7.0.1 s-js: 0.4.9 solid-js: 1.9.12 @@ -8217,11 +8247,11 @@ snapshots: b4a@1.7.3: {} - babel-plugin-jsx-dom-expressions@0.40.7(@babel/core@7.28.0): + babel-plugin-jsx-dom-expressions@0.40.7(@babel/core@7.29.7): dependencies: - '@babel/core': 7.28.0 + '@babel/core': 7.29.7 '@babel/helper-module-imports': 7.18.6 - '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.28.0) + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) '@babel/types': 7.29.7 html-entities: 2.3.3 parse5: 7.3.0 @@ -8234,10 +8264,10 @@ snapshots: reselect: 4.1.8 resolve: 1.22.12 - babel-preset-solid@1.9.12(@babel/core@7.28.0)(solid-js@1.9.12): + babel-preset-solid@1.9.12(@babel/core@7.29.7)(solid-js@1.9.12): dependencies: - '@babel/core': 7.28.0 - babel-plugin-jsx-dom-expressions: 0.40.7(@babel/core@7.28.0) + '@babel/core': 7.29.7 + babel-plugin-jsx-dom-expressions: 0.40.7(@babel/core@7.29.7) optionalDependencies: solid-js: 1.9.12 @@ -8344,10 +8374,6 @@ snapshots: dependencies: balanced-match: 4.0.4 - brace-expansion@5.0.4: - dependencies: - balanced-match: 4.0.4 - browserslist@4.28.1: dependencies: baseline-browser-mapping: 2.9.11 @@ -9612,8 +9638,8 @@ snapshots: magicast@0.3.5: dependencies: - '@babel/parser': 7.28.5 - '@babel/types': 7.28.5 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 source-map-js: 1.2.1 make-dir@4.0.0: @@ -10024,10 +10050,6 @@ snapshots: minimalistic-assert@1.0.1: {} - minimatch@10.2.4: - dependencies: - brace-expansion: 5.0.4 - minimatch@10.2.5: dependencies: brace-expansion: 5.0.12 @@ -10912,7 +10934,7 @@ snapshots: dependencies: '@istanbuljs/schema': 0.1.3 glob: 10.5.0 - minimatch: 10.2.4 + minimatch: 10.2.5 text-decoder@1.2.3: dependencies: diff --git a/shared/package.json b/shared/package.json index db525f010..aaa65f8e8 100644 --- a/shared/package.json +++ b/shared/package.json @@ -21,7 +21,8 @@ "./themes": "./src/themes/index.ts" }, "dependencies": { - "@opencode/client": "2.1.2", + "@opencode/client": "2.2.1", + "@opencode/schema": "2.2.1", "jsonc-parser": "^3.3.1", "zod": "^4.1.12" }, diff --git a/shared/src/notifications/format.ts b/shared/src/notifications/format.ts index 243b0156b..d16558bd7 100644 --- a/shared/src/notifications/format.ts +++ b/shared/src/notifications/format.ts @@ -1,37 +1,6 @@ -import type { SessionGoal, SessionGoalStatus, SessionGoalStopReason } from '../schemas/session-goals' +import type { GoalState, GoalStatus } from '../opencode/goal' import type { MultiRunEntryStatus } from '../schemas/multi-runs' -const GOAL_OUTCOME_TITLES: Record = { - active: 'Goal active', - paused: 'Goal paused', - completed: 'Goal completed', - blocked: 'Goal blocked', - stopped: 'Goal stopped', -} - -const GOAL_STOP_REASON_LABELS: Record = { - cancelled: 'Cancelled', - user_paused: 'Paused by user', - continuation_limit: 'Continuation limit reached', - token_budget: 'Token budget reached', - turn_error: 'Turn failed', - interrupted: 'Interrupted', - audit_failed: 'Audit failed', - session_deleted: 'Session deleted', -} - -export function getGoalOutcomeTitle(status: SessionGoalStatus): string { - return GOAL_OUTCOME_TITLES[status] -} - -export function getGoalStopReasonLabel(stopReason: SessionGoalStopReason): string { - 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', @@ -43,13 +12,20 @@ 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}` +const GOAL_STATUS_TITLES: Record = { + active: 'Goal active', + paused: 'Goal paused', + complete: 'Goal completed', +} + +/** Titles a notification for the current state of an OpenCode goal. */ +export function getGoalStatusTitle(status: GoalStatus): string { + return GOAL_STATUS_TITLES[status] } -export function getGoalTokenLabel(goal: Pick): string | null { - if (goal.tokenBudget === null) return null - return `${goal.tokensUsed.toLocaleString()}/${goal.tokenBudget.toLocaleString()} tokens` +/** Labels the continuation progress of an OpenCode goal. */ +export function getGoalRoundsLabel(goal: Pick): string { + return `Round ${goal.rounds}/${goal.maxRounds}` } const PERMISSION_LABELS: Record = { diff --git a/shared/src/opencode/goal.ts b/shared/src/opencode/goal.ts new file mode 100644 index 000000000..41c9a9535 --- /dev/null +++ b/shared/src/opencode/goal.ts @@ -0,0 +1,89 @@ +import { isRpcError } from '@opencode/client' +import type { Goal } from '@opencode/schema/goal' +import type { OpenCodeApi } from './index' + +/** The OpenCode goal as the server reports it. */ +export type GoalState = Goal.State + +/** The lifecycle status of an OpenCode goal. */ +export type GoalStatus = Goal.State['status'] + +/** The RPC the `/goal` plugin exposes on the OpenCode server. */ +export const GOAL_RPC_ID = 'opencode.goal' + +/** The event the `/goal` plugin publishes in a session's location on every goal write. */ +export const GOAL_CHANGED_EVENT_TYPE = 'rpc.opencode.goal.changed' + +/** The slash command that controls a session goal. */ +export const GOAL_COMMAND_NAME = 'goal' + +/** Pause reasons that mean the user stopped the goal rather than the goal finishing its work. */ +export const GOAL_USER_PAUSE_REASONS = ['Paused by the user', 'Stopped by the user'] as const + +const GOAL_STATUSES = ['active', 'paused', 'complete'] as const satisfies readonly GoalStatus[] + +/** Reports whether a value from a remote server has the shape of a reported goal. */ +function isGoalState(value: unknown): value is GoalState { + if (typeof value !== 'object' || value === null) return false + const goal = value as { id?: unknown; objective?: unknown; status?: unknown; rounds?: unknown; maxRounds?: unknown } + return ( + typeof goal.id === 'string' && + typeof goal.objective === 'string' && + (GOAL_STATUSES as readonly unknown[]).includes(goal.status) && + typeof goal.rounds === 'number' && + typeof goal.maxRounds === 'number' + ) +} + +/** The result of reading a session goal, distinguishing an older server without the goal RPC. */ +export type SessionGoalRead = { supported: true; goal: GoalState | null } | { supported: false } + +/** Reads a session goal, reporting an unsupported server instead of throwing. */ +export async function readSessionGoal( + api: OpenCodeApi, + input: { sessionID: string; directory?: string }, +): Promise { + try { + const result = await api.rpc.call({ + rpcID: GOAL_RPC_ID, + method: 'get', + input: { sessionID: input.sessionID }, + ...(input.directory ? { location: { directory: input.directory } } : {}), + }) + return { supported: true, goal: (result.output ?? null) as GoalState | null } + } catch (error) { + if (isRpcError(error) && error.type === 'rpc.unavailable') return { supported: false } + throw error + } +} + +/** Extracts a session goal change from an OpenCode event, ignoring unrelated and malformed events. */ +export function goalChangeFromEvent( + event: { type: string; data: unknown }, +): { sessionID: string; goal: GoalState | null } | null { + if (event.type !== GOAL_CHANGED_EVENT_TYPE) return null + + const data = event.data + if (typeof data !== 'object' || data === null) return null + + const sessionID = (data as { sessionID?: unknown }).sessionID + if (typeof sessionID !== 'string') return null + + const goal = (data as { goal?: unknown }).goal + if (goal === null) return { sessionID, goal: null } + if (!isGoalState(goal)) return null + + return { sessionID, goal } +} + +/** Reports whether the goal is still being worked on, whether running or paused. */ +export function isGoalOpen(goal: GoalState | null | undefined): boolean { + return goal?.status === 'active' || goal?.status === 'paused' +} + +/** Reports whether the goal reached an outcome, meaning it completed or the reviewer paused it. */ +export function isGoalOutcome(goal: GoalState): boolean { + if (goal.status === 'complete') return true + if (goal.status !== 'paused') return false + return !(GOAL_USER_PAUSE_REASONS as readonly string[]).includes(goal.reason ?? '') +} diff --git a/shared/src/opencode/index.ts b/shared/src/opencode/index.ts index 493bf05a0..00fef098c 100644 --- a/shared/src/opencode/index.ts +++ b/shared/src/opencode/index.ts @@ -51,6 +51,19 @@ export { ClientError, isIntegrationNotFoundError, isMcpServerNotFoundError, isSe export { assistantText, sessionIDFromEvent, toolContentText } from './content' +export { + GOAL_CHANGED_EVENT_TYPE, + GOAL_COMMAND_NAME, + GOAL_RPC_ID, + GOAL_USER_PAUSE_REASONS, + goalChangeFromEvent, + isGoalOpen, + isGoalOutcome, + readSessionGoal, +} from './goal' + +export type { GoalState, GoalStatus, SessionGoalRead } from './goal' + export { configModelRef, formatOpenCodeModelRef, parseOpenCodeModelRef, selectConfiguredModelRef } from './modelRef' export { diff --git a/shared/src/opencode/release.ts b/shared/src/opencode/release.ts index b482ffddc..97aefb6fc 100644 --- a/shared/src/opencode/release.ts +++ b/shared/src/opencode/release.ts @@ -1,6 +1,6 @@ import { compareParsedSemver, compareSemver, normalizeSemver, parseSemver, type Semver } from '../utils/semver' -export const OPENCODE_PINNED_VERSION = '2.1.2' +export const OPENCODE_PINNED_VERSION = '2.2.1' const OPENCODE_RELEASE_BASE_URL = 'https://opencode.ai/files/bin' diff --git a/shared/src/schemas/index.ts b/shared/src/schemas/index.ts index b295a360b..2935c7834 100644 --- a/shared/src/schemas/index.ts +++ b/shared/src/schemas/index.ts @@ -15,7 +15,6 @@ export * from './internal-assistant' export * from './session-pins' export * from './session-permissions' export * from './session-sandbox' -export * from './session-goals' export * from './internal-sessions' export * from './multi-runs' export * from './logs' diff --git a/shared/src/schemas/limits.ts b/shared/src/schemas/limits.ts index 6b48252e8..7e8da583e 100644 --- a/shared/src/schemas/limits.ts +++ b/shared/src/schemas/limits.ts @@ -1,5 +1,3 @@ export const SESSION_PROMPT_MAX_LENGTH = 20000; export const FUSION_PROMPT_MAX_LENGTH = 60000; export const FUSION_INSTRUCTIONS_MAX_LENGTH = 4000; -export const GOAL_MAX_CONTINUATIONS_MIN = 1; -export const GOAL_MAX_CONTINUATIONS_MAX = 200; diff --git a/shared/src/schemas/session-goals.ts b/shared/src/schemas/session-goals.ts deleted file mode 100644 index 51f96cb4a..000000000 --- a/shared/src/schemas/session-goals.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { z } from "zod"; -import { SESSION_PROMPT_MAX_LENGTH, GOAL_MAX_CONTINUATIONS_MIN, GOAL_MAX_CONTINUATIONS_MAX } from "./limits"; - -export const SessionGoalStatusSchema = z.enum(["active", "paused", "completed", "blocked", "stopped"]); - -export type SessionGoalStatus = z.infer; - -export const SessionGoalStopReasonSchema = z.enum([ - "cancelled", - "user_paused", - "continuation_limit", - "token_budget", - "turn_error", - "interrupted", - "audit_failed", - "session_deleted", -]); - -export type SessionGoalStopReason = z.infer; - -export const SessionGoalVerdictSchema = z.enum(["continue", "done", "blocked"]); - -export type SessionGoalVerdict = z.infer; - -export const SessionGoalTurnStateSchema = z.enum(["waiting", "running"]); - -export type SessionGoalTurnState = z.infer; - -export const SessionGoalSchema = z.object({ - id: z.number().int(), - sessionId: z.string(), - serverId: z.string(), - directory: z.string(), - objective: z.string(), - status: SessionGoalStatusSchema, - stopReason: SessionGoalStopReasonSchema.nullable(), - turnState: SessionGoalTurnStateSchema, - continuationCount: z.number().int(), - maxContinuations: z.number().int(), - tokenBudget: z.number().int().nullable(), - tokensUsed: z.number().int(), - consecutiveBlocked: z.number().int(), - lastVerdict: SessionGoalVerdictSchema.nullable(), - lastReason: z.string().nullable(), - createdAt: z.number(), - updatedAt: z.number(), - finishedAt: z.number().nullable(), -}); - -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), - serverId: z.string().min(1).optional(), - directory: z.string().min(1), - objective: z.string().trim().min(1).max(SESSION_PROMPT_MAX_LENGTH), - maxContinuations: z.number().int().min(GOAL_MAX_CONTINUATIONS_MIN).max(GOAL_MAX_CONTINUATIONS_MAX).optional(), - tokenBudget: z.number().int().positive().optional(), -}); - -export type StartSessionGoalRequest = z.infer; diff --git a/shared/src/schemas/settings.ts b/shared/src/schemas/settings.ts index f135b3768..9803275f0 100644 --- a/shared/src/schemas/settings.ts +++ b/shared/src/schemas/settings.ts @@ -3,7 +3,6 @@ import { NotificationPreferencesSchema, DEFAULT_NOTIFICATION_PREFERENCES } from import { OPENCODE_CONFIG_SOURCE_NAMES } from "../config/defaults"; import { ColorThemeIdSchema, MANAGER_COLOR_THEME_ID } from "../themes"; import { SessionPermissionModeSchema } from "./session-permissions"; -import { GOAL_MAX_CONTINUATIONS_MIN, GOAL_MAX_CONTINUATIONS_MAX } from "./limits"; import { SessionSandboxResourcesSchema } from "./session-sandbox"; export const CustomCommandSchema = z.object({ @@ -193,16 +192,12 @@ export const DEFAULT_SANDBOX_PREFERENCES: SandboxPreferences = { export const SessionDefaultsSchema = z.object({ permissionMode: SessionPermissionModeSchema, - goalMaxContinuations: z.number().int().min(GOAL_MAX_CONTINUATIONS_MIN).max(GOAL_MAX_CONTINUATIONS_MAX).optional(), - goalTokenBudget: z.number().int().positive().optional(), - goalAuditorModel: z.string().optional(), }); export type SessionDefaults = z.infer; export const DEFAULT_SESSION_DEFAULTS = { permissionMode: 'ask', - goalMaxContinuations: 20, } satisfies SessionDefaults; export const DevicePairingPreferencesSchema = z.object({ From 5db74e3fa7ce5128f7d5ebc1e22dfacbb2dd915a Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Fri, 30 Oct 2026 01:03:14 -0400 Subject: [PATCH 2/3] fix(goals): validate the goal RPC output before returning it --- backend/test/shared/opencode-goal.test.ts | 9 +++++++++ shared/src/opencode/goal.ts | 5 ++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/backend/test/shared/opencode-goal.test.ts b/backend/test/shared/opencode-goal.test.ts index c20fc0cbb..e7056a5a8 100644 --- a/backend/test/shared/opencode-goal.test.ts +++ b/backend/test/shared/opencode-goal.test.ts @@ -91,6 +91,15 @@ describe('readSessionGoal', () => { }) }) + it.each([ + ['an unknown status', goalWith({ status: 'bogus' as GoalState['status'] })], + ['a missing objective', goalWithout('objective')], + ['a non-object output', 'not a goal'], + ])('reports a goal with %s as unsupported', async (_label, output) => { + const call = vi.fn().mockResolvedValue({ output }) + await expect(readSessionGoal(createApi(call), { sessionID: SESSION_ID })).resolves.toEqual({ supported: false }) + }) + it('reports an unavailable rpc as unsupported', async () => { const call = vi.fn().mockRejectedValue(rpcError('rpc.unavailable', 'RPC is unavailable: opencode.goal')) await expect(readSessionGoal(createApi(call), { sessionID: SESSION_ID })).resolves.toEqual({ supported: false }) diff --git a/shared/src/opencode/goal.ts b/shared/src/opencode/goal.ts index 41c9a9535..2336de363 100644 --- a/shared/src/opencode/goal.ts +++ b/shared/src/opencode/goal.ts @@ -50,7 +50,10 @@ export async function readSessionGoal( input: { sessionID: input.sessionID }, ...(input.directory ? { location: { directory: input.directory } } : {}), }) - return { supported: true, goal: (result.output ?? null) as GoalState | null } + const output: unknown = result.output ?? null + if (output === null) return { supported: true, goal: null } + if (!isGoalState(output)) return { supported: false } + return { supported: true, goal: output } } catch (error) { if (isRpcError(error) && error.type === 'rpc.unavailable') return { supported: false } throw error From 46ca6239890ba8541e3de1a522b1e018e74640b2 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Fri, 30 Oct 2026 01:08:26 -0400 Subject: [PATCH 3/3] chore(ocm-cli): bump to 0.5.0 and document the /ocm-goal removal --- ocm-cli/README.md | 17 ++++++++++++----- ocm-cli/package.json | 2 +- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/ocm-cli/README.md b/ocm-cli/README.md index 28967a0cc..182a605c2 100644 --- a/ocm-cli/README.md +++ b/ocm-cli/README.md @@ -10,10 +10,11 @@ to the local working tree. | ocm | OpenCode Manager | Local OpenCode | |---|---|---| +| 0.5.x | >= 0.19.0 | >= 2.2.0, < 3 | | 0.4.x | >= 0.19.0 | >= 2.0.15, < 3 | | 0.3.x | >= 0.19.0 | >= 2.0.15, < 3 | -ocm 0.4.x and 0.3.x attach through the repo-scoped Manager proxy +ocm 0.5.x, 0.4.x and 0.3.x attach through the repo-scoped Manager proxy (`/api/opencode-proxy/repos/:repoId`), load the plugin from the OpenCode 2 `cli.json` `plugins` list, and move sessions with OpenCode 2 session export/import. Older Managers lack that route; `ocm` then fails with @@ -23,10 +24,16 @@ Use ocm 0.2.x with OpenCode Manager < 0.19.0 and OpenCode 1.x, installed pinned `@opencode-manager/ocm-cli@0.2`. ocm is published together with each OpenCode Manager release. -From the release after 0.4.1, the TUI plugin is built against `@opencode/plugin` -2.2.x and OpenTUI 0.7, so it requires a local OpenCode >= 2.2.0: OpenCode 2.2.0 -refuses plugins built for OpenTUI 0.6, and older OpenCode builds may refuse the -OpenTUI 0.7 peers. +From ocm 0.5.0, the TUI plugin is built against `@opencode/plugin` 2.2.x and +OpenTUI 0.7, so it requires a local OpenCode >= 2.2.0: OpenCode 2.2.0 refuses +plugins built for OpenTUI 0.6, and older OpenCode builds may refuse the OpenTUI +0.7 peers. Stay on ocm 0.4.x with a local OpenCode older than 2.2.0. + +ocm 0.5.0 removes `/ocm-goal`; session goals use OpenCode's built-in `/goal` +command, and OpenCode Manager no longer serves `/api/internal/session-goals`. +Running `/ocm-goal` from ocm 0.4.x against such a Manager reports that the +Manager is too old for ocm goals; the Manager is not too old. Upgrade ocm to +0.5.0 and use `/goal` instead. Attachment works with OpenCode Manager >= 0.19.0. The sync worktree targeting of `ocm push` / `ocm pull` and `/ocm-move` worktree moves need ocm 0.4.0+ and diff --git a/ocm-cli/package.json b/ocm-cli/package.json index e1ab8e562..28cce15ff 100644 --- a/ocm-cli/package.json +++ b/ocm-cli/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-manager/ocm-cli", - "version": "0.4.1", + "version": "0.5.0", "description": "OpenCode Manager CLI: attach a local OpenCode TUI to a Manager-hosted repo.", "license": "MIT", "repository": {