From 2749260e3973e7f5ef7fcb1651c8f201f782d56f Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Sun, 14 Jun 2026 09:47:02 -0400 Subject: [PATCH 1/4] chore: bump version to 0.5.0 --- package.json | 2 +- src/version.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 1afc083cd..0efc36af3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-forge", - "version": "0.4.21", + "version": "0.5.0", "type": "module", "oc-plugin": [ "server", diff --git a/src/version.ts b/src/version.ts index 49935ce50..9fa2621ae 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const VERSION = '0.4.21' +export const VERSION = '0.5.0' From 1f0cd02baf0fffc2739469b56bd835ef72da6495 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Sun, 14 Jun 2026 10:03:57 -0400 Subject: [PATCH 2/4] refactor: deduplicate loop bringup by extracting send-loop-prompt and section-bootstrap --- src/loop/runtime.ts | 98 +++++-------- src/loop/send-loop-prompt.ts | 57 ++++++++ src/services/execution.ts | 169 +++++++++------------- src/services/section-bootstrap.ts | 30 ++++ test/loop/send-loop-prompt.test.ts | 159 +++++++++++++++++++++ test/services/section-bootstrap.test.ts | 179 ++++++++++++++++++++++++ vitest.config.ts | 2 + 7 files changed, 534 insertions(+), 160 deletions(-) create mode 100644 src/loop/send-loop-prompt.ts create mode 100644 src/services/section-bootstrap.ts create mode 100644 test/loop/send-loop-prompt.test.ts create mode 100644 test/services/section-bootstrap.test.ts diff --git a/src/loop/runtime.ts b/src/loop/runtime.ts index 338f471d2..2c299e50d 100644 --- a/src/loop/runtime.ts +++ b/src/loop/runtime.ts @@ -10,7 +10,7 @@ import type { ReviewFindingsRepo, ReviewFindingRow } from '../storage/repos/revi import type { SectionPlansRepo, SectionPlanRow } from '../storage/repos/section-plans-repo' import type { LoopSessionUsageRepo } from '../storage/repos/loop-session-usage-repo' import { createLoopWatchdog, type LoopWatchdogStallInfo, type LoopWatchdogRecoveryContext } from '../hooks/watchdog' -import { retryWithModelFallback } from '../utils/model-fallback' +import { sendLoopPrompt } from './send-loop-prompt' import { resolveLoopModel, resolveLoopAuditorModel } from '../utils/loop-helpers' import type { createSandboxManager } from '../sandbox/manager' // worktree-completion imports moved to hooks/loop.ts (termination side-effects) @@ -195,73 +195,49 @@ export function createLoop(deps: LoopRuntimeDeps): Loop { if (agent === 'auditor-loop') { const auditorModel = input.model != null ? input.model : undefined + const { result, usedModel } = await sendLoopPrompt({ + loopName, sessionId, agent: 'auditor-loop', logger, + primaryModel: auditorModel, + performPrompt: async (model) => { + const freshState = loopService.getActiveState(loopName) + if (!freshState?.active) throw new Error('loop_cancelled') + markPromptSent(loopName, sessionId, logger) + const r = await promptAuditSession(client, { + sessionId, + worktreeDir: freshState.worktreeDir, + workspaceId: freshState.workspaceId, + prompt: promptText, + ...(model ? { auditorModel: model, ...(input.variant ? { auditorVariant: input.variant } : {}) } : {}), + }) + return r.ok ? {} : { error: r.error } + }, + }) + return { error: result.error, usedModel } + } - const sendFn = async (model?: { providerID: string; modelID: string }) => { + const effectiveModel = input.model != null ? input.model : resolveLoopModel(getConfig(), loopService, loopName) + const { result, usedModel } = await sendLoopPrompt({ + loopName, sessionId, agent: 'code', logger, + primaryModel: effectiveModel, + performPrompt: async (model) => { const freshState = loopService.getActiveState(loopName) if (!freshState?.active) throw new Error('loop_cancelled') + markPromptSent(loopName, sessionId, logger) try { - return await withInFlightGuard(loopName, sessionId, 'auditor-loop', logger, async () => { - markPromptSent(loopName, sessionId, logger) - const result = await promptAuditSession(client, { - sessionId, - worktreeDir: freshState.worktreeDir, - workspaceId: freshState.workspaceId, - prompt: promptText, - ...(model ? { auditorModel: model, ...(input.variant ? { auditorVariant: input.variant } : {}) } : {}), - }) - return result.ok ? { data: true } : { error: result.error } + await client.session.promptAsync({ + sessionID: sessionId, + directory: freshState.worktreeDir, + ...(freshState.workspaceId ? { workspace: freshState.workspaceId } : {}), + agent: 'code', + parts: [{ type: 'text' as const, text: promptText }], + ...(model ? { model, ...(input.variant ? { variant: input.variant } : {}) } : {}), }) + return {} } catch (err) { - if (err instanceof ConcurrentPromptError) return { error: err } - throw err - } - } - - const { result, usedModel } = await retryWithModelFallback(() => sendFn(auditorModel), () => sendFn(undefined), auditorModel, logger) - if (result.error) { - if (result.error instanceof ConcurrentPromptError) { - return { error: result.error, usedModel } + return { error: err } } - clearPromptPending(loopName, logger) - } - return { error: result.error, usedModel } - } - - const effectiveModel = input.model != null ? input.model : resolveLoopModel(getConfig(), loopService, loopName) - - const sendFn = async (model?: { providerID: string; modelID: string }) => { - const freshState = loopService.getActiveState(loopName) - if (!freshState?.active) throw new Error('loop_cancelled') - try { - return await withInFlightGuard(loopName, sessionId, 'code', logger, async () => { - markPromptSent(loopName, sessionId, logger) - try { - await client.session.promptAsync({ - sessionID: sessionId, - directory: freshState.worktreeDir, - ...(freshState.workspaceId ? { workspace: freshState.workspaceId } : {}), - agent: 'code', - parts: [{ type: 'text' as const, text: promptText }], - ...(model ? { model, ...(input.variant ? { variant: input.variant } : {}) } : {}), - }) - return { data: true } - } catch (err) { - return { error: err } - } - }) - } catch (err) { - if (err instanceof ConcurrentPromptError) return { error: err } - throw err - } - } - - const { result, usedModel } = await retryWithModelFallback(() => sendFn(effectiveModel), () => sendFn(undefined), effectiveModel, logger) - if (result.error) { - if (result.error instanceof ConcurrentPromptError) { - return { error: result.error, usedModel } - } - clearPromptPending(loopName, logger) - } + }, + }) return { error: result.error, usedModel } } diff --git a/src/loop/send-loop-prompt.ts b/src/loop/send-loop-prompt.ts new file mode 100644 index 000000000..1d876023c --- /dev/null +++ b/src/loop/send-loop-prompt.ts @@ -0,0 +1,57 @@ +import type { Logger } from '../types' +import { retryWithModelFallback } from '../utils/model-fallback' +import { clearPromptPending } from './idle-gate' +import { withInFlightGuard, ConcurrentPromptError, type PromptAgent } from './in-flight-guard' + +export interface SendLoopPromptOptions { + loopName: string + sessionId: string + agent: PromptAgent + logger: Logger + primaryModel?: { providerID: string; modelID: string } | null + /** Performs ONE provider call for the given model (caller owns markPromptSent + + * the promptAsync/promptAuditSession call). Returns {} on success, {error} on failure. */ + performPrompt: (model: { providerID: string; modelID: string } | undefined) => Promise<{ error?: unknown }> + /** Wrap each attempt in withInFlightGuard. Default true. (attach path passes false.) */ + useInFlightGuard?: boolean + /** Call clearPromptPending on non-concurrent error. Default true. (restart passes false; it clears once after its transient-retry loop.) */ + clearPendingOnError?: boolean +} + +export interface SendLoopPromptResult { + result: { error?: unknown } + usedModel?: { providerID: string; modelID: string } | undefined +} + +/** Single source of truth for "send a loop prompt with model fallback + in-flight guard". */ +export async function sendLoopPrompt(opts: SendLoopPromptOptions): Promise { + const { loopName, sessionId, agent, logger, performPrompt } = opts + const useGuard = opts.useInFlightGuard !== false + const clearOnError = opts.clearPendingOnError !== false + const primary = opts.primaryModel ?? undefined + + const attempt = async ( + model: { providerID: string; modelID: string } | undefined, + ): Promise<{ error?: unknown }> => { + if (!useGuard) return performPrompt(model) + try { + return await withInFlightGuard(loopName, sessionId, agent, logger, () => performPrompt(model)) + } catch (err) { + if (err instanceof ConcurrentPromptError) return { error: err } + throw err + } + } + + const { result, usedModel } = await retryWithModelFallback( + () => attempt(primary), + () => attempt(undefined), + primary, + logger, + ) + + if (result.error && !(result.error instanceof ConcurrentPromptError) && clearOnError) { + clearPromptPending(loopName, logger) + } + + return { result, usedModel } +} diff --git a/src/services/execution.ts b/src/services/execution.ts index 11c2e60c4..e9e2b1821 100644 --- a/src/services/execution.ts +++ b/src/services/execution.ts @@ -14,7 +14,7 @@ import type { LoopsRepo } from '../storage/repos/loops-repo' import type { createLoopEventHandler } from '../hooks' import type { SandboxManager } from '../sandbox/manager' import { extractPlanExecutionMetadata } from '../utils/plan-execution' -import { parseModelString, retryWithModelFallback } from '../utils/model-fallback' +import { parseModelString } from '../utils/model-fallback' import { formatLoopSessionTitle, formatPlanSessionTitle } from '../utils/session-titles' import { buildLoopPermissionRuleset, buildAuditSessionPermissionRuleset } from '../constants/loop' @@ -24,13 +24,10 @@ import { createLoopSessionWithWorkspace, publishWorkspaceDetachedToast } from '. import { aggregateToUsageSummary } from '../utils/loop-format' import { join } from 'path' import { existsSync } from 'fs' -import { decomposeDeterministically } from './deterministic-decomposer' +import { applyPlanDecomposition } from './section-bootstrap' +import { sendLoopPrompt } from '../loop/send-loop-prompt' import { markPromptSent, clearPromptPending, terminationStatusFor, parseTerminationReasonString } from '../loop' -import { - withInFlightGuard, - ConcurrentPromptError, - type PromptAgent, -} from '../loop/in-flight-guard' +import { ConcurrentPromptError } from '../loop/in-flight-guard' import { getRestartability, type RestartBlockedReason } from '../loop/restartability' import { loopBranchExists } from '../workspace/forge-naming' import { resolveHostSessionDirectory } from '../utils/resolve-project-root' @@ -715,20 +712,18 @@ export async function attachLoopToSession( }) // === Section extraction === - - const maxSections = 12 - const sections = decomposeDeterministically(planText, { maxSections }) + const { totalSections } = applyPlanDecomposition({ + projectId: ctx.projectId, + loopName, + planText, + loopsRepo: deps.loopsRepo, + sectionPlansRepo: deps.sectionPlansRepo, + }) let promptText: string - if (sections.length > 0 && deps.sectionPlansRepo) { - deps.sectionPlansRepo.bulkInsert({ projectId: ctx.projectId, loopName, sections }) - deps.loopsRepo.setTotalSections(ctx.projectId, loopName, sections.length) - deps.loopsRepo.setCurrentSectionIndex(ctx.projectId, loopName, 0) - deps.sectionPlansRepo.setStatus(ctx.projectId, loopName, 0, 'in_progress') - deps.sectionPlansRepo.setStartedAt(ctx.projectId, loopName, 0, Date.now()) - const updatedState = { ...state, phase: 'coding' as const, currentSectionIndex: 0, totalSections: sections.length } + if (totalSections > 0) { + const updatedState = { ...state, phase: 'coding' as const, currentSectionIndex: 0, totalSections } promptText = deps.loop.buildSectionInitialPrompt(updatedState as import('../loop/state').LoopState) } else { - deps.loopsRepo.setTotalSections(ctx.projectId, loopName, 0) promptText = planText } @@ -789,38 +784,32 @@ export async function attachLoopToSession( const promptParts = [{ type: 'text' as const, text: promptText }] const workspaceParam = workspaceId ? { workspace: workspaceId } : {} - async function sendPromptCall(model?: { providerID: string; modelID: string }): Promise<{ error?: unknown }> { - markPromptSent(loopName, sessionId, deps.logger) - try { - await deps.client.session.promptAsync({ - sessionID: sessionId, - directory: sessionDir, - parts: promptParts, - agent: 'code', - ...workspaceParam, - ...(model ? { model } : {}), - }) - return {} - } catch (err) { - return { error: err } - } - } - - let promptResult: { result: { error?: unknown }; usedModel?: { providerID: string; modelID: string } | undefined } - - if (loopModel) { - promptResult = await retryWithModelFallback( - () => sendPromptCall(loopModel), - () => sendPromptCall(undefined), - loopModel, - deps.logger as unknown as Console, - ) - } else { - promptResult = { result: await sendPromptCall(undefined), usedModel: undefined } - } + const promptResult = await sendLoopPrompt({ + loopName, + sessionId, + agent: 'code', + logger: deps.logger, + primaryModel: loopModel, + useInFlightGuard: false, + performPrompt: async (model) => { + markPromptSent(loopName, sessionId, deps.logger) + try { + await deps.client.session.promptAsync({ + sessionID: sessionId, + directory: sessionDir, + parts: promptParts, + agent: 'code', + ...workspaceParam, + ...(model ? { model } : {}), + }) + return {} + } catch (err) { + return { error: err } + } + }, + }) if (promptResult.result.error) { - clearPromptPending(loopName, deps.logger) deps.logger.error('attachLoopToSession: failed to send prompt', promptResult.result.error) deps.loop.deleteState(loopName) return { ok: false, code: 'prompt_failed', message: 'Loop session created but failed to send prompt' } @@ -1613,28 +1602,19 @@ export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): Fo }) // Unified section extraction on restart — preserve existing progress if sections exist - const maxSections = 12 - const planText = stoppedState.prompt ?? '' - const sections = decomposeDeterministically(planText, { maxSections }) - if (sections.length > 0 && deps.sectionPlansRepo && !stoppedState.totalSections) { - // New sections being extracted (first-time or fresh) - deps.sectionPlansRepo.bulkInsert({ + if (!stoppedState.totalSections) { + const planText = stoppedState.prompt ?? '' + const { totalSections } = applyPlanDecomposition({ projectId: ctx.projectId, loopName: stoppedState.loopName, - sections, + planText, + loopsRepo: deps.loopsRepo, + sectionPlansRepo: deps.sectionPlansRepo, }) - - deps.loopsRepo.setTotalSections(ctx.projectId, stoppedState.loopName, sections.length) - deps.loopsRepo.setCurrentSectionIndex(ctx.projectId, stoppedState.loopName, 0) - - deps.sectionPlansRepo.setStatus(ctx.projectId, stoppedState.loopName, 0, 'in_progress') - deps.sectionPlansRepo.setStartedAt(ctx.projectId, stoppedState.loopName, 0, Date.now()) - - stoppedState.currentSectionIndex = 0 - stoppedState.totalSections = sections.length - } else if (!stoppedState.totalSections) { - deps.loopsRepo.setTotalSections(ctx.projectId, stoppedState.loopName, 0) - stoppedState.totalSections = 0 + stoppedState.totalSections = totalSections + if (totalSections > 0) { + stoppedState.currentSectionIndex = 0 + } } // else: existing totalSections preserved as-is @@ -1711,33 +1691,20 @@ export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): Fo ? stoppedState.auditorVariant : stoppedState.executionVariant - const sendRestartPrompt = async (model?: { providerID: string; modelID: string }) => { + const performRestartPrompt = async (model?: { providerID: string; modelID: string }): Promise<{ error?: unknown }> => { + markPromptSent(stoppedState.loopName, effectiveSessionId, deps.logger) try { - return await withInFlightGuard( - stoppedState.loopName, - effectiveSessionId, - promptAgent as PromptAgent, - deps.logger, - async () => { - markPromptSent(stoppedState.loopName, effectiveSessionId, deps.logger) - try { - await deps.client.session.promptAsync({ - sessionID: effectiveSessionId, - directory: stoppedState.worktreeDir, - parts: [{ type: 'text' as const, text: promptText }], - agent: promptAgent, - ...(model ? { model, ...(restartVariant ? { variant: restartVariant } : {}) } : {}), - ...workspaceParam, - }) - return {} - } catch (err) { - return { error: err } - } - }, - ) + await deps.client.session.promptAsync({ + sessionID: effectiveSessionId, + directory: stoppedState.worktreeDir, + parts: [{ type: 'text' as const, text: promptText }], + agent: promptAgent, + ...(model ? { model, ...(restartVariant ? { variant: restartVariant } : {}) } : {}), + ...workspaceParam, + }) + return {} } catch (err) { - if (err instanceof ConcurrentPromptError) return { error: err } - throw err + return { error: err } } } @@ -1746,14 +1713,18 @@ export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): Fo // a transient race tore the restart down and reverted the loop to terminal. // (Workspace connection was already awaited via selectInitialWorktreeSession.) const RESTART_PROMPT_MAX_ATTEMPTS = 4 - let promptResult: { data?: unknown; error?: unknown } = { error: new Error('restart prompt not attempted') } + let promptResult: { error?: unknown } = { error: new Error('restart prompt not attempted') } for (let attempt = 1; attempt <= RESTART_PROMPT_MAX_ATTEMPTS; attempt++) { - const { result } = await retryWithModelFallback( - () => sendRestartPrompt(loopModel!), - () => sendRestartPrompt(), - loopModel, - deps.logger, - ) + const { result } = await sendLoopPrompt({ + loopName: stoppedState.loopName, + sessionId: effectiveSessionId, + agent: promptAgent, + logger: deps.logger, + primaryModel: loopModel, + useInFlightGuard: true, + clearPendingOnError: false, + performPrompt: performRestartPrompt, + }) promptResult = result if (!result.error || !isTransientSessionError(result.error) || attempt === RESTART_PROMPT_MAX_ATTEMPTS) { break diff --git a/src/services/section-bootstrap.ts b/src/services/section-bootstrap.ts new file mode 100644 index 000000000..3fda31505 --- /dev/null +++ b/src/services/section-bootstrap.ts @@ -0,0 +1,30 @@ +import { decomposeDeterministically } from './deterministic-decomposer' +import type { LoopsRepo } from '../storage/repos/loops-repo' +import type { SectionPlansRepo } from '../storage/repos/section-plans-repo' + +export interface ApplyPlanDecompositionArgs { + projectId: string + loopName: string + planText: string + loopsRepo: LoopsRepo + sectionPlansRepo?: SectionPlansRepo + maxSections?: number +} + +/** Single source of truth for the one-shot decompose-and-persist step shared by + * loop start (attachLoopToSession) and loop restart (handleLoopRestart). */ +export function applyPlanDecomposition(args: ApplyPlanDecompositionArgs): { totalSections: number } { + const { projectId, loopName, planText, loopsRepo, sectionPlansRepo } = args + const maxSections = args.maxSections ?? 12 + const sections = decomposeDeterministically(planText, { maxSections }) + if (sections.length > 0 && sectionPlansRepo) { + sectionPlansRepo.bulkInsert({ projectId, loopName, sections }) + loopsRepo.setTotalSections(projectId, loopName, sections.length) + loopsRepo.setCurrentSectionIndex(projectId, loopName, 0) + sectionPlansRepo.setStatus(projectId, loopName, 0, 'in_progress') + sectionPlansRepo.setStartedAt(projectId, loopName, 0, Date.now()) + return { totalSections: sections.length } + } + loopsRepo.setTotalSections(projectId, loopName, 0) + return { totalSections: 0 } +} diff --git a/test/loop/send-loop-prompt.test.ts b/test/loop/send-loop-prompt.test.ts new file mode 100644 index 000000000..7a43c9fcd --- /dev/null +++ b/test/loop/send-loop-prompt.test.ts @@ -0,0 +1,159 @@ +import { describe, test, expect, beforeEach, vi } from 'vitest' +import { + markPromptInFlight, + __resetInFlightGuard, + ConcurrentPromptError, + type PromptAgent, +} from '../../src/loop/in-flight-guard' +import type { Logger } from '../../src/types' + +const { clearPromptPendingMock, markPromptSentMock } = vi.hoisted(() => ({ + clearPromptPendingMock: vi.fn(), + markPromptSentMock: vi.fn(), +})) + +vi.mock('../../src/loop/idle-gate', () => ({ + clearPromptPending: clearPromptPendingMock, + markPromptSent: markPromptSentMock, + sessionsAwaitingBusy: new Map(), + AWAITING_BUSY_TIMEOUT_MS: 10000, + isAwaitingBusy: vi.fn(), + isAwaitingBusyExpired: vi.fn(), +})) + +import { sendLoopPrompt } from '../../src/loop/send-loop-prompt' + +function createMockLogger(): Logger { + return { + log: () => {}, + error: () => {}, + debug: () => {}, + } +} + +const testModel = { providerID: 'test-provider', modelID: 'test-model' } +const loopName = 'test-loop' +const sessionId = 'test-session' +const agent: PromptAgent = 'code' + +describe('sendLoopPrompt', () => { + beforeEach(() => { + __resetInFlightGuard() + clearPromptPendingMock.mockClear() + markPromptSentMock.mockClear() + }) + + test('success with model', async () => { + const performPrompt = vi.fn().mockResolvedValue({}) + + const result = await sendLoopPrompt({ + loopName, + sessionId, + agent, + logger: createMockLogger(), + primaryModel: testModel, + performPrompt, + }) + + expect(result.result.error).toBeUndefined() + expect(result.usedModel).toEqual(testModel) + expect(performPrompt).toHaveBeenCalledTimes(1) + expect(performPrompt).toHaveBeenCalledWith(testModel) + expect(clearPromptPendingMock).not.toHaveBeenCalled() + }) + + test('model fallback', async () => { + const performPrompt = vi.fn((model: typeof testModel | undefined) => { + if (model) return { error: new Error('model failed') } + return {} + }) + + const result = await sendLoopPrompt({ + loopName, + sessionId, + agent, + logger: createMockLogger(), + primaryModel: testModel, + performPrompt, + }) + + expect(result.result.error).toBeUndefined() + expect(result.usedModel).toBeUndefined() + expect(performPrompt).toHaveBeenCalledTimes(3) + expect(performPrompt).toHaveBeenNthCalledWith(1, testModel) + expect(performPrompt).toHaveBeenNthCalledWith(2, testModel) + expect(performPrompt).toHaveBeenNthCalledWith(3, undefined) + }) + + test('error clears pending (default)', async () => { + const testError = new Error('provider failure') + const performPrompt = vi.fn().mockResolvedValue({ error: testError }) + + const result = await sendLoopPrompt({ + loopName, + sessionId, + agent, + logger: createMockLogger(), + primaryModel: null, + performPrompt, + }) + + expect(result.result.error).toBe(testError) + expect(clearPromptPendingMock).toHaveBeenCalledTimes(1) + expect(clearPromptPendingMock).toHaveBeenCalledWith(loopName, expect.anything()) + }) + + test('clearPendingOnError: false', async () => { + const testError = new Error('provider failure') + const performPrompt = vi.fn().mockResolvedValue({ error: testError }) + + const result = await sendLoopPrompt({ + loopName, + sessionId, + agent, + logger: createMockLogger(), + primaryModel: null, + performPrompt, + clearPendingOnError: false, + }) + + expect(result.result.error).toBe(testError) + expect(clearPromptPendingMock).not.toHaveBeenCalled() + }) + + test('in-flight guard rejects concurrent', async () => { + const performPrompt = vi.fn() + markPromptInFlight(loopName, 'other-session', agent) + + const result = await sendLoopPrompt({ + loopName, + sessionId, + agent, + logger: createMockLogger(), + primaryModel: testModel, + performPrompt, + }) + + expect(result.result.error).toBeInstanceOf(ConcurrentPromptError) + expect(clearPromptPendingMock).not.toHaveBeenCalled() + expect(performPrompt).not.toHaveBeenCalled() + }) + + test('useInFlightGuard: false allows concurrent', async () => { + const performPrompt = vi.fn().mockResolvedValue({}) + markPromptInFlight(loopName, 'other-session', agent) + + const result = await sendLoopPrompt({ + loopName, + sessionId, + agent, + logger: createMockLogger(), + primaryModel: testModel, + performPrompt, + useInFlightGuard: false, + }) + + expect(result.result.error).toBeUndefined() + expect(performPrompt).toHaveBeenCalledTimes(1) + }) +}) diff --git a/test/services/section-bootstrap.test.ts b/test/services/section-bootstrap.test.ts new file mode 100644 index 000000000..28d2dda81 --- /dev/null +++ b/test/services/section-bootstrap.test.ts @@ -0,0 +1,179 @@ +import { describe, test, expect, vi } from 'vitest' +import { applyPlanDecomposition } from '../../src/services/section-bootstrap' +import type { LoopsRepo } from '../../src/storage/repos/loops-repo' +import type { SectionPlansRepo } from '../../src/storage/repos/section-plans-repo' + +const PROJECT_ID = 'test-project' +const LOOP_NAME = 'test-loop' + +function buildSpyLoopsRepo(): LoopsRepo { + return { + insert: vi.fn(), + get: vi.fn(), + getLarge: vi.fn(), + getBySessionId: vi.fn(), + listByStatus: vi.fn(), + listAll: vi.fn(), + updatePhase: vi.fn(), + updateIteration: vi.fn(), + incrementError: vi.fn(), + resetError: vi.fn(), + setCurrentSessionId: vi.fn(), + setWorkspaceId: vi.fn(), + clearWorkspaceId: vi.fn(), + setModelFailed: vi.fn(), + setLastAuditResult: vi.fn(), + clearLastAuditResult: vi.fn(), + setSandboxContainer: vi.fn(), + setPhaseAndResetError: vi.fn(), + setStatus: vi.fn(), + replaceSession: vi.fn(), + restart: vi.fn(), + terminate: vi.fn(), + delete: vi.fn(), + findPartial: vi.fn(), + setCurrentSectionIndex: vi.fn(), + setTotalSections: vi.fn(), + setFinalAuditDone: vi.fn(), + } +} + +function buildSpySectionPlansRepo(): SectionPlansRepo { + return { + bulkInsert: vi.fn(), + list: vi.fn(), + listCompleted: vi.fn(), + get: vi.fn(), + getNextIncomplete: vi.fn(), + setStatus: vi.fn(), + incrementAttempts: vi.fn(), + setSummary: vi.fn(), + resetForRewind: vi.fn(), + setStartedAt: vi.fn(), + setCompletedAt: vi.fn(), + updateContent: vi.fn(), + count: vi.fn(), + deleteAll: vi.fn(), + restoreAll: vi.fn(), + } +} + +describe('applyPlanDecomposition', () => { + test('sectioned plan + repo present: bulk inserts, sets totals, marks first section in_progress', () => { + const loopsRepo = buildSpyLoopsRepo() + const sectionPlansRepo = buildSpySectionPlansRepo() + + const planText = [ + '', + '# Objective', + '', + 'Do the thing.', + '', + '', + '## Phase 1: Setup', + '', + 'Install dependencies and configure the environment.', + '', + '', + '## Phase 2: Build', + '', + 'Compile and run the tests.', + '', + '## Verification', + 'Check that everything works.', + '', + ].join('\n') + + const result = applyPlanDecomposition({ + projectId: PROJECT_ID, + loopName: LOOP_NAME, + planText, + loopsRepo, + sectionPlansRepo, + }) + + expect(result).toEqual({ totalSections: 2 }) + + // bulkInsert called with sections matching the two phases + expect(sectionPlansRepo.bulkInsert).toHaveBeenCalledTimes(1) + const bulkInsertArgs = vi.mocked(sectionPlansRepo.bulkInsert).mock.calls[0][0] + expect(bulkInsertArgs.projectId).toBe(PROJECT_ID) + expect(bulkInsertArgs.loopName).toBe(LOOP_NAME) + expect(bulkInsertArgs.sections).toHaveLength(2) + expect(bulkInsertArgs.sections[0].index).toBe(0) + expect(bulkInsertArgs.sections[0].title).toContain('Phase 1') + expect(bulkInsertArgs.sections[1].index).toBe(1) + expect(bulkInsertArgs.sections[1].title).toContain('Phase 2') + + // loopsRepo.setTotalSections called + expect(loopsRepo.setTotalSections).toHaveBeenCalledTimes(1) + expect(loopsRepo.setTotalSections).toHaveBeenCalledWith(PROJECT_ID, LOOP_NAME, 2) + + // loopsRepo.setCurrentSectionIndex called + expect(loopsRepo.setCurrentSectionIndex).toHaveBeenCalledTimes(1) + expect(loopsRepo.setCurrentSectionIndex).toHaveBeenCalledWith(PROJECT_ID, LOOP_NAME, 0) + + // First section set to in_progress + expect(sectionPlansRepo.setStatus).toHaveBeenCalledTimes(1) + expect(sectionPlansRepo.setStatus).toHaveBeenCalledWith(PROJECT_ID, LOOP_NAME, 0, 'in_progress') + + // First section startedAt set + expect(sectionPlansRepo.setStartedAt).toHaveBeenCalledTimes(1) + expect(sectionPlansRepo.setStartedAt).toHaveBeenCalledWith(PROJECT_ID, LOOP_NAME, 0, expect.any(Number)) + }) + + test('no-marker plan: returns 0, does not persist sections', () => { + const loopsRepo = buildSpyLoopsRepo() + const sectionPlansRepo = buildSpySectionPlansRepo() + + const planText = '# Simple Plan\n\nJust a regular plan with no section markers.' + + const result = applyPlanDecomposition({ + projectId: PROJECT_ID, + loopName: LOOP_NAME, + planText, + loopsRepo, + sectionPlansRepo, + }) + + expect(result).toEqual({ totalSections: 0 }) + + // loopsRepo.setTotalSections(projectId, loopName, 0) + expect(loopsRepo.setTotalSections).toHaveBeenCalledTimes(1) + expect(loopsRepo.setTotalSections).toHaveBeenCalledWith(PROJECT_ID, LOOP_NAME, 0) + + // No section persistence calls + expect(sectionPlansRepo.bulkInsert).not.toHaveBeenCalled() + expect(sectionPlansRepo.setStatus).not.toHaveBeenCalled() + expect(sectionPlansRepo.setStartedAt).not.toHaveBeenCalled() + expect(loopsRepo.setCurrentSectionIndex).not.toHaveBeenCalled() + }) + + test('sectionPlansRepo undefined + sectioned plan: returns 0, no persistence', () => { + const loopsRepo = buildSpyLoopsRepo() + + const planText = [ + '', + '## Phase 1: Something', + '', + 'Body text.', + ].join('\n') + + const result = applyPlanDecomposition({ + projectId: PROJECT_ID, + loopName: LOOP_NAME, + planText, + loopsRepo, + sectionPlansRepo: undefined, + }) + + expect(result).toEqual({ totalSections: 0 }) + + // loopsRepo.setTotalSections(projectId, loopName, 0) + expect(loopsRepo.setTotalSections).toHaveBeenCalledTimes(1) + expect(loopsRepo.setTotalSections).toHaveBeenCalledWith(PROJECT_ID, LOOP_NAME, 0) + + // No section persistence calls (no sectionPlansRepo) + expect(loopsRepo.setCurrentSectionIndex).not.toHaveBeenCalled() + }) +}) diff --git a/vitest.config.ts b/vitest.config.ts index 885152285..6e21d3b55 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -33,6 +33,7 @@ export default defineConfig({ 'test/loop/transitions.test.ts', 'test/loop/restartability.test.ts', 'test/loop/in-flight-guard.test.ts', + 'test/loop/send-loop-prompt.test.ts', 'test/loop/runtime.test.ts', 'test/loop/start.test.ts', 'test/loop/cancel.test.ts', @@ -55,6 +56,7 @@ export default defineConfig({ 'test/loop-service.test.ts', 'test/loop-service-notify.test.ts', 'test/boot-sandbox-preserve.test.ts', + 'test/services/section-bootstrap.test.ts', 'test/services/attach-loop.test.ts', 'test/hooks/forge-session-attach.test.ts', 'test/hooks/host-side-effects-unwarp.test.ts', From 286c0255e83879219c30637013529996790c88c1 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Sun, 14 Jun 2026 10:26:36 -0400 Subject: [PATCH 3/4] fix: clean up retry timeouts in loop event gate tests --- test/hooks/loop-event-gate.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/hooks/loop-event-gate.test.ts b/test/hooks/loop-event-gate.test.ts index 20f4aa7cc..a1e4e146b 100644 --- a/test/hooks/loop-event-gate.test.ts +++ b/test/hooks/loop-event-gate.test.ts @@ -34,6 +34,7 @@ describe('Loop Event Idle Gate', () => { let plansRepo: ReturnType let reviewFindingsRepo: ReturnType let sectionPlansRepo: ReturnType + let handler: ReturnType | null beforeEach(() => { tempDir = mkdtempSync(join(tmpdir(), 'loop-event-gate-test-')) @@ -61,9 +62,11 @@ describe('Loop Event Idle Gate', () => { }) afterEach(() => { + handler?.clearAllRetryTimeouts() db.close() rmSync(tempDir, { recursive: true, force: true }) sessionsAwaitingBusy.clear() + handler = null }) function makeState(overrides: Partial = {}): LoopState { @@ -108,7 +111,7 @@ describe('Loop Event Idle Gate', () => { const { logger } = createCapturingLogger() const { client: forgeClient } = createFakeForgeClient() - return createLoopEventHandler( + handler = createLoopEventHandler( loopsRepo, plansRepo, reviewFindingsRepo, @@ -119,6 +122,7 @@ describe('Loop Event Idle Gate', () => { undefined, tempDir, ) + return handler } describe('busy event clears pending gate', () => { From 4a0d4dcae978818ca0826e8b5fdfe79294dd745d Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Sun, 14 Jun 2026 20:51:10 -0400 Subject: [PATCH 4/4] refactor: route tui-client through ForgeClient port --- src/client/port.ts | 21 +++ src/client/sdk-adapter.ts | 25 ++- src/tui.tsx | 4 +- src/tui/execute-plan-panel.tsx | 4 +- src/utils/tui-client.ts | 151 ++++++++++-------- src/utils/tui-models.ts | 19 +-- src/utils/workspace-listing.ts | 43 +++-- test/client/sdk-adapter.test.ts | 46 ++++++ test/helpers/fake-client.test.ts | 18 +++ test/helpers/fake-client.ts | 19 +++ test/tui-models.test.ts | 21 +-- ...i-client-await-workspace-connected.test.ts | 33 ++-- test/utils/tui-client-select-session.test.ts | 42 ++--- test/utils/tui-client-workspaces.test.ts | 105 ++++++------ 14 files changed, 333 insertions(+), 218 deletions(-) diff --git a/src/client/port.ts b/src/client/port.ts index 53f972c7c..abc7ea21c 100644 --- a/src/client/port.ts +++ b/src/client/port.ts @@ -30,6 +30,20 @@ export type WorkspaceCreateResult = NonNullable>['data']> export type WorkspaceStatus = NonNullable>['data']> +// ── Session list types (experimental) ─────────────────────────────────────── +// `list` lives under `experimental.session` in the SDK; the port hides that +// placement so callers use a single `session` namespace. +export type SessionListParams = NonNullable[0]> +export type SessionList = NonNullable>['data']> + +// ── Project param/result types ─────────────────────────────────────────────── +export type ProjectListParams = NonNullable[0]> +export type ProjectList = NonNullable>['data']> + +// ── Provider param/result types ────────────────────────────────────────────── +export type ProviderListParams = NonNullable[0]> +export type ProviderList = NonNullable>['data']> + // ── TUI param types ────────────────────────────────────────────────────────── export type TuiPublishParams = NonNullable[0]> export type TuiSelectSessionParams = NonNullable[0]> @@ -71,6 +85,7 @@ export interface ForgeClient { update(params: SessionUpdateParams): Promise messages(params: SessionMessagesParams): Promise status(params?: SessionStatusParams): Promise + list(params?: SessionListParams): Promise promptAsync(params: SessionPromptAsyncParams): Promise abort(params: SessionAbortParams): Promise delete(params: SessionDeleteParams): Promise @@ -83,6 +98,12 @@ export interface ForgeClient { remove(params: WorkspaceRemoveParams): Promise warp(params: WorkspaceWarpParams): Promise } + project: { + list(params?: ProjectListParams): Promise + } + provider: { + list(params?: ProviderListParams): Promise + } tui: { publish(params: TuiPublishParams): Promise selectSession(params: TuiSelectSessionParams): Promise diff --git a/src/client/sdk-adapter.ts b/src/client/sdk-adapter.ts index ae75bb92e..79b04005e 100644 --- a/src/client/sdk-adapter.ts +++ b/src/client/sdk-adapter.ts @@ -88,6 +88,19 @@ export function createForgeClient(v2: OpencodeClient): ForgeClient { update: (params) => withVoid('session.update', v2.session.update(params)), messages: (params) => withData('session.messages', v2.session.messages(params)), status: (params) => withData('session.status', v2.session.status(params)), + // `list` is exposed by the SDK under `experimental.session`; guard for + // hosts that do not provide it so callers get a classified error. + list: (params) => { + const expSession = v2.experimental?.session + if (!expSession || typeof expSession.list !== 'function') { + return Promise.reject(new ForgeClientError({ + kind: 'unavailable', + method: 'session.list', + message: 'experimental.session.list not available on this host', + })) + } + return withData('session.list', expSession.list(params)) + }, promptAsync: (params) => withVoid('session.promptAsync', v2.session.promptAsync(params)), abort: (params) => withVoid('session.abort', v2.session.abort(params)), delete: (params) => withVoid('session.delete', v2.session.delete(params)), @@ -126,6 +139,16 @@ export function createForgeClient(v2: OpencodeClient): ForgeClient { warp: (params) => guardWs('warp', () => withVoid('workspace.warp', wsApi!.warp(params))), } + // ── project namespace ───────────────────────────────────────────────────── + const project: ForgeClient['project'] = { + list: (params) => withData('project.list', v2.project.list(params)), + } + + // ── provider namespace ──────────────────────────────────────────────────── + const provider: ForgeClient['provider'] = { + list: (params) => withData('provider.list', v2.provider.list(params)), + } + // ── tui namespace ──────────────────────────────────────────────────────── const tui: ForgeClient['tui'] = { publish: async (params) => { @@ -152,7 +175,7 @@ export function createForgeClient(v2: OpencodeClient): ForgeClient { }, } - return { session, workspace, tui, sync } + return { session, workspace, project, provider, tui, sync } } // ── Combined factory ───────────────────────────────────────────────────────── diff --git a/src/tui.tsx b/src/tui.tsx index d1f88ea2d..3e13c6fb1 100644 --- a/src/tui.tsx +++ b/src/tui.tsx @@ -10,8 +10,6 @@ import { connectForgeProject, type ForgeProjectClient } from './utils/tui-client import { ExecutePlanPanel } from './tui/execute-plan-panel' import { attachLoopSessionFollower, getCurrentRouteSessionId } from './tui/session-follow' import { openInBrowser, startDashboardServer, type DashboardServerHandle } from './dashboard/launch' -import { createForgeClient } from './client/sdk-adapter' -import { fetchLatestPlanForSession } from './utils/plan-from-messages' import { normalizePastedPlanText } from './utils/marked-plan-parser' type TuiKeybinds = { @@ -370,7 +368,7 @@ const tui: TuiPlugin = async (api) => { const currentClient = await ensureClient() if (!currentClient) return - const planText = await fetchLatestPlanForSession(createForgeClient(api.client), sessionID, directory) + const planText = await currentClient.loadLatestPlan(sessionID) if (!planText) { api.ui.toast({ message: 'No plan in current session — paste one to execute', diff --git a/src/tui/execute-plan-panel.tsx b/src/tui/execute-plan-panel.tsx index cf399676c..21201db03 100644 --- a/src/tui/execute-plan-panel.tsx +++ b/src/tui/execute-plan-panel.tsx @@ -5,7 +5,7 @@ import { PLAN_EXECUTION_LABELS } from '../utils/plan-execution' import { extractPlanExecutionMetadata } from '../utils/plan-execution' import { buildDialogSelectOptions, getModelDisplayLabel, getAvailableModelVariants, getVariantDisplayLabel, normalizeVariantForModel, type ModelInfo } from '../utils/tui-models' import { resolveExecutionDialogDefaults } from '../utils/tui-execution-preferences' -import { selectTuiSession, type ForgeProjectClient } from '../utils/tui-client' +import { type ForgeProjectClient } from '../utils/tui-client' import { buildExecutionContextSnapshot, type ExecutionContextCache, type ExecutionContextSnapshot } from '../utils/tui-execution-context-cache' import { withBusyGuard } from '../utils/busy-guard' import type { PluginConfig } from '../types' @@ -303,7 +303,7 @@ export function ExecutePlanPanel(props: { await props.onExecuted?.() props.client.workspaces.list().catch(() => {}) if (result.sessionId && (apiMode === 'new-session' || apiMode === 'loop')) { - await selectTuiSession(props.api, result.sessionId, result.workspaceId) + await props.client.selectSession(result.sessionId, result.workspaceId) } } diff --git a/src/utils/tui-client.ts b/src/utils/tui-client.ts index 032219358..d2de0877d 100644 --- a/src/utils/tui-client.ts +++ b/src/utils/tui-client.ts @@ -11,7 +11,7 @@ import { } from './tui-models' import { deriveExecutionPreferencesFromWorkspaces } from './tui-execution-preferences' import { parseModelString } from './model-fallback' -import { listConnectedWorkspaces, type WorkspaceListApi } from './workspace-listing' +import { listConnectedWorkspaces } from './workspace-listing' import { type ForgeLoopExtra } from '../services/execution' import { buildLoopPermissionRuleset } from '../constants/loop' import { getForgeWorkspaceLoopName, removeExistingForgeLoopWorkspaces } from '../workspace/forge-worktree' @@ -20,6 +20,8 @@ import { decomposeDeterministically } from '../services/deterministic-decomposer import { buildSectionInitialPromptText } from '../loop/prompts' import { extractPlanExecutionMetadata, sanitizeLoopName } from './plan-execution' import { createForgeClient } from '../client/sdk-adapter' +import type { ForgeClient } from '../client/port' +import { fetchLatestPlanForSession } from './plan-from-messages' export type ApiExecutionMode = 'new-session' | 'execute-here' | 'loop' @@ -94,18 +96,15 @@ function nextAvailableLoopName(baseName: string, names: string[]): string { return candidate } -async function reserveTuiLoopName(api: TuiPluginApi, projectId: string | null, baseName: string): Promise { - const workspaceApi = api.client.experimental.workspace +async function reserveTuiLoopName(client: ForgeClient, projectId: string | null, baseName: string): Promise { const names = new Set() if (projectId) { for (const loop of fetchLoopsList(projectId)) { names.add(loop.name) } } - if (typeof workspaceApi.list !== 'function') return nextAvailableLoopName(baseName, [...names]) try { - const result = await workspaceApi.list() - const entries = ((result as { data?: unknown[] } | undefined)?.data ?? []) as Array<{ name?: string; extra?: Record | null }> + const entries = (await client.workspace.list()) as Array<{ name?: string; extra?: Record | null }> for (const entry of entries) { if (entry.name) names.add(entry.name) const loopName = getForgeWorkspaceLoopName(entry) @@ -140,6 +139,18 @@ export interface ForgeProjectClient { status(): Promise> } + /** + * Navigate the TUI to a session (route-first, SDK fallback). Routes through + * the same {@link ForgeClient} port as every other call. + */ + selectSession(sessionId: string, workspaceId?: string): Promise + + /** + * Read the latest marked plan from a session's chat history, or `null` when + * none is found. Routes through the same {@link ForgeClient} port. + */ + loadLatestPlan(sessionId: string): Promise + /** Single round-trip pair: read preferences and list models. */ loadExecutionContext(): Promise } @@ -168,7 +179,7 @@ export interface AwaitWorkspaceConnectedResult { * workspace (which causes the call to silently no-op). */ export async function awaitWorkspaceConnected( - api: TuiPluginApi, + client: ForgeClient, workspaceId: string, timeoutMs = 5000, pollIntervalMs = 100, @@ -178,8 +189,7 @@ export async function awaitWorkspaceConnected( try { while (Date.now() - start < timeoutMs) { try { - const result = await api.client.experimental.workspace.status() - const entries = ((result as { data?: unknown } | undefined)?.data ?? []) as Array<{ workspaceID: string; status: string }> + const entries = (await client.workspace.status()) as Array<{ workspaceID: string; status: string }> const entry = entries.find((e) => e.workspaceID === workspaceId) if (entry) { lastStatus = entry.status @@ -232,7 +242,7 @@ function buildTuiLoopInitialPrompt(planText: string): string { }) } -export async function selectTuiSession(api: TuiPluginApi, sessionId: string, workspaceId?: string): Promise { +export async function selectTuiSession(api: TuiPluginApi, client: ForgeClient, sessionId: string, workspaceId?: string): Promise { try { api.route.navigate('session', { sessionID: sessionId }) tuiDebug(`selectTuiSession: route.navigate session=${sessionId} workspace=${workspaceId ?? 'none'}`) @@ -242,7 +252,7 @@ export async function selectTuiSession(api: TuiPluginApi, sessionId: string, wor } try { - await api.client.tui.selectSession({ + await client.tui.selectSession({ sessionID: sessionId, ...(workspaceId ? { workspace: workspaceId } : {}), }) @@ -258,11 +268,14 @@ export async function connectForgeProject( ): Promise { tuiDebug(`connect start directory=${directory ?? 'none'}`) + // Single client path: every SDK call in this project client goes through the + // typed ForgeClient port wrapping the TUI's v2 client. + const client = createForgeClient(api.client) + let projectId: string | null = null try { - const projectsRes = await api.client.project.list() - const projects = (projectsRes?.data ?? []) as Array<{ id: string; worktree: string }> + const projects = (await client.project.list()) as Array<{ id: string; worktree: string }> const matched = directory ? projects.find((p) => p.worktree === directory) : projects[0] projectId = matched?.id ?? null } catch { @@ -283,43 +296,43 @@ export async function connectForgeProject( const prompt = `The architect agent has created an implementation plan in this conversation above. You are now the code agent taking over this session. Your job is to execute the plan — edit files, run commands, create tests, and implement every phase. Do NOT just describe or summarize the changes. Actually make them.\n\nPlan reference: ${req.plan}` const modelVariant = buildPromptModelSelection(parsedModel, req.executionVariant) - const result = await api.client.session.promptAsync({ - sessionID: req.targetSessionId ?? sessionId, - directory, - agent: 'code', - ...modelVariant, - parts: [{ type: 'text' as const, text: prompt }], - }) - - if (result.error) return null + try { + await client.session.promptAsync({ + sessionID: req.targetSessionId ?? sessionId, + directory, + agent: 'code', + ...modelVariant, + parts: [{ type: 'text' as const, text: prompt }], + }) + } catch { + return null + } return { sessionId: req.targetSessionId ?? sessionId } } if (req.mode === 'new-session') { - const createResult = await api.client.session.create({ - title: req.title.length > 60 ? `${req.title.substring(0, 57)}...` : req.title, - directory, - }) - - if (createResult.error || !createResult.data) return null - - const newSessionId = createResult.data.id - const modelVariant = buildPromptModelSelection(parsedModel, req.executionVariant) - const result = await api.client.session.promptAsync({ - sessionID: newSessionId, - directory, - agent: 'code', - ...modelVariant, - parts: [{ type: 'text' as const, text: req.plan }], - }) - - if (result.error) return null - return { sessionId: newSessionId } + try { + const session = await client.session.create({ + title: req.title.length > 60 ? `${req.title.substring(0, 57)}...` : req.title, + directory, + }) + const modelVariant = buildPromptModelSelection(parsedModel, req.executionVariant) + await client.session.promptAsync({ + sessionID: session.id, + directory, + agent: 'code', + ...modelVariant, + parts: [{ type: 'text' as const, text: req.plan }], + }) + return { sessionId: session.id } + } catch { + return null + } } if (req.mode === 'loop') { const requestedLoopName = req.loopName ?? (req.title ? sanitizeLoopName(req.title) : extractPlanExecutionMetadata(req.plan).executionName) - const loopName = await reserveTuiLoopName(api, projectId, requestedLoopName) + const loopName = await reserveTuiLoopName(client, projectId, requestedLoopName) tuiDebug(`plan.execute(loop): inline plan (planText.length=${req.plan.length}) hostSession=${sessionId ?? 'none'} loop=${loopName}`) const createdAt = Date.now() const forgeLoop: ForgeLoopExtra = { @@ -335,35 +348,31 @@ export async function connectForgeProject( pendingAttachStartedAt: createdAt, } try { - await removeExistingForgeLoopWorkspaces(createForgeClient(api.client), loopName, { + await removeExistingForgeLoopWorkspaces(client, loopName, { log: (message) => tuiDebug(`plan.execute(loop): ${message}`), error: (message, err) => tuiDebug(`plan.execute(loop): ${message} ${err instanceof Error ? err.message : String(err)}`), }) - const wsRes = await api.client.experimental.workspace.create({ + const workspace = await client.workspace.create({ type: 'forge', branch: null, extra: { loopName, projectDirectory: directory, workspaceCreatedAt: createdAt, forgeLoop }, }) - if (wsRes.error || !wsRes.data) return null - const workspace = wsRes.data - await api.client.experimental.workspace.syncList().catch(() => undefined) + await client.workspace.syncList().catch(() => undefined) - const connected = await awaitWorkspaceConnected(api, workspace.id, 5000, 100) + const connected = await awaitWorkspaceConnected(client, workspace.id, 5000, 100) tuiDebug(`plan.execute(loop): workspace ${workspace.id} connected=${connected.connected} source=${connected.source} elapsedMs=${connected.elapsedMs} lastStatus=${connected.lastStatus ?? 'unknown'}`) if (connected.connected) { await waitForWorkspacePluginSettle(workspace.id) } const permission = buildLoopPermissionRuleset() - const sesRes = await api.client.session.create({ + const session = await client.session.create({ workspaceID: workspace.id, title: loopName, directory: workspace.directory ?? undefined, permission, }) - if (sesRes.error || !sesRes.data) return null - const session = sesRes.data const promptText = buildTuiLoopInitialPrompt(req.plan) const promptInput = { @@ -374,17 +383,18 @@ export async function connectForgeProject( parts: [{ type: 'text' as const, text: promptText }], ...buildPromptModelSelection(parsedModel, req.executionVariant), } - const promptResult = await api.client.session.promptAsync(promptInput) - if (promptResult.error) { - tuiDebug(`plan.execute(loop): promptAsync failed session=${session.id} workspace=${workspace.id} error=${String(promptResult.error)}`) - await api.client.experimental.workspace.remove({ id: workspace.id }).catch(() => undefined) + try { + await client.session.promptAsync(promptInput) + } catch (err) { + tuiDebug(`plan.execute(loop): promptAsync failed session=${session.id} workspace=${workspace.id} error=${err instanceof Error ? err.message : String(err)}`) + await client.workspace.remove({ id: workspace.id }).catch(() => undefined) return null } tuiDebug(`plan.execute(loop): promptAsync ok session=${session.id} workspace=${workspace.id}`) - await selectTuiSession(api, session.id, workspace.id) + await selectTuiSession(api, client, session.id, workspace.id) - await api.client.experimental.workspace.syncList().catch(() => undefined) + await client.workspace.syncList().catch(() => undefined) return { sessionId: session.id, @@ -404,15 +414,14 @@ export async function connectForgeProject( const workspaces: ForgeProjectClient['workspaces'] = { async list() { try { - return await listConnectedWorkspaces(api.client.experimental?.workspace as WorkspaceListApi | undefined) + return await listConnectedWorkspaces(client.workspace) } catch { return [] } }, async status() { try { - const data = await api.client.experimental.workspace.status() - const entries = (data.data ?? []) as Array<{ workspaceID: string; status: string }> + const entries = (await client.workspace.status()) as Array<{ workspaceID: string; status: string }> return Object.fromEntries(entries.map((s) => [s.workspaceID, s.status])) } catch { return {} @@ -424,20 +433,20 @@ export async function connectForgeProject( projectId: projectId ?? '', plan, workspaces, + selectSession(sessionId, workspaceId) { + return selectTuiSession(api, client, sessionId, workspaceId) + }, + loadLatestPlan(sessionId) { + return fetchLatestPlanForSession(client, sessionId, directory) + }, async loadExecutionContext() { - const workspaceApi = api.client.experimental?.workspace - const sessionApi = api.client.experimental?.session const [sessionsResult, workspacesResult, modelsResult] = await Promise.all([ - sessionApi && typeof sessionApi.list === 'function' - ? sessionApi.list({ directory }).catch(() => null) - : Promise.resolve(null), - workspaceApi && typeof workspaceApi.list === 'function' - ? workspaceApi.list({ directory }).catch(() => null) - : Promise.resolve(null), - fetchAvailableModels(api), + client.session.list({ directory }).catch(() => null), + client.workspace.list({ directory }).catch(() => null), + fetchAvailableModels(api, client), ]) - const sessions = ((sessionsResult as { data?: unknown[] } | null)?.data ?? []) as SessionForRecents[] - const workspaceList = ((workspacesResult as { data?: unknown[] } | null)?.data ?? []) as WorkspaceForRecents[] + const sessions = (sessionsResult ?? []) as unknown as SessionForRecents[] + const workspaceList = (workspacesResult ?? []) as unknown as WorkspaceForRecents[] const preferences = projectId ? deriveExecutionPreferencesFromWorkspaces(projectId, workspaceList) : null diff --git a/src/utils/tui-models.ts b/src/utils/tui-models.ts index c2348149e..efdac46a3 100644 --- a/src/utils/tui-models.ts +++ b/src/utils/tui-models.ts @@ -9,6 +9,7 @@ */ import type { TuiPluginApi } from '@opencode-ai/plugin/tui' +import type { ForgeClient } from '../client/port' interface ModelKey { providerID: string @@ -151,25 +152,15 @@ export function readOpenCodeFavoriteModels(api: TuiPluginApi): string[] { * - Successful fetch with providers (may be empty if no providers have models) * - Failed fetch with an error message */ -export async function fetchAvailableModels(api: TuiPluginApi): Promise { +export async function fetchAvailableModels(api: TuiPluginApi, client: ForgeClient): Promise { const directory = api.state.path.directory const configuredProviderIds = Object.keys(api.state.config?.provider ?? {}) const favoriteModels = readOpenCodeFavoriteModels(api) try { - const result = await api.client.provider.list({ directory }) - if (result.error) { - const errorMsg = - (result.error as { data?: { message?: string }; message?: string })?.data?.message - ?? (result.error as { message?: string })?.message - ?? 'Failed to fetch providers' - return { providers: [], connectedProviderIds: [], configuredProviderIds, favoriteModels, error: errorMsg } - } - if (!result.data) { - return { providers: [], connectedProviderIds: [], configuredProviderIds, favoriteModels, error: 'No provider data returned' } - } + const data = await client.provider.list({ directory }) const providers: ProviderInfo[] = [] - const allModels = result.data.all || [] - const connected = result.data.connected || [] + const allModels = data.all || [] + const connected = data.connected || [] for (const provider of allModels) { if (!connected.includes(provider.id)) continue const models: ModelInfo[] = [] diff --git a/src/utils/workspace-listing.ts b/src/utils/workspace-listing.ts index a2c50eb3d..8ba1d4b42 100644 --- a/src/utils/workspace-listing.ts +++ b/src/utils/workspace-listing.ts @@ -1,3 +1,5 @@ +import type { ForgeClient } from '../client/port' + export type TuiWorkspaceEntry = { id: string name: string @@ -12,34 +14,27 @@ type WorkspaceStatusEntry = { status: string } -export type WorkspaceListApi = { - list?: () => Promise<{ data?: unknown[] }> - status?: () => Promise<{ data?: unknown[] }> - syncList?: () => Promise -} - -export async function listConnectedWorkspaces(workspaceApi: WorkspaceListApi | undefined): Promise { - if (!workspaceApi || typeof workspaceApi.list !== 'function') return [] - - if (typeof workspaceApi.syncList === 'function') { - try { - await workspaceApi.syncList() - } catch { - // swallow — syncList is a best-effort trigger only - } +export async function listConnectedWorkspaces(workspace: ForgeClient['workspace']): Promise { + // syncList is a best-effort sync trigger only. + try { + await workspace.syncList() + } catch { + // swallow } - const rawEntries: TuiWorkspaceEntry[] = ((await workspaceApi.list()).data ?? []) as TuiWorkspaceEntry[] + let rawEntries: TuiWorkspaceEntry[] + try { + rawEntries = (await workspace.list()) as unknown as TuiWorkspaceEntry[] + } catch { + return [] + } let statusMap: Record = {} - if (typeof workspaceApi.status === 'function') { - try { - const statusResult = await workspaceApi.status() - const entries = (statusResult.data ?? []) as WorkspaceStatusEntry[] - statusMap = Object.fromEntries(entries.map((s) => [s.workspaceID, s.status])) - } catch { - // ignore status errors - } + try { + const entries = (await workspace.status()) as unknown as WorkspaceStatusEntry[] + statusMap = Object.fromEntries(entries.map((s) => [s.workspaceID, s.status])) + } catch { + // ignore status errors } const filtered = rawEntries.filter((w) => { diff --git a/test/client/sdk-adapter.test.ts b/test/client/sdk-adapter.test.ts index d810affde..70e53e87f 100644 --- a/test/client/sdk-adapter.test.ts +++ b/test/client/sdk-adapter.test.ts @@ -221,6 +221,52 @@ describe('createForgeClient', () => { expect((err as ForgeClientError).kind).toBe('request') expect((err as ForgeClientError).message).toContain('concurrent prompt in progress') }) + + // ── project.list ────────────────────────────────────────────────────── + it('project.list resolves to data on success', async () => { + const list = vi.fn().mockResolvedValue({ data: [{ id: 'p1', worktree: '/wt' }], error: undefined }) + const client = createForgeClient({ project: { list } } as unknown as OpencodeClient) + + const result = await client.project.list({ directory: '/wt' }) + + expect(result).toEqual([{ id: 'p1', worktree: '/wt' }]) + expect(list).toHaveBeenCalledWith({ directory: '/wt' }) + }) + + // ── provider.list ───────────────────────────────────────────────────── + it('provider.list resolves to data on success', async () => { + const list = vi.fn().mockResolvedValue({ data: { all: [], connected: ['anthropic'] }, error: undefined }) + const client = createForgeClient({ provider: { list } } as unknown as OpencodeClient) + + const result = await client.provider.list({ directory: '/wt' }) + + expect(result).toEqual({ all: [], connected: ['anthropic'] }) + }) + + // ── session.list (experimental) ────────────────────────────────────── + it('session.list resolves to data from experimental.session', async () => { + const list = vi.fn().mockResolvedValue({ data: [{ id: 'ses1' }], error: undefined }) + const client = createForgeClient({ experimental: { session: { list } } } as unknown as OpencodeClient) + + const result = await client.session.list({ directory: '/wt' }) + + expect(result).toEqual([{ id: 'ses1' }]) + expect(list).toHaveBeenCalledWith({ directory: '/wt' }) + }) + + it('session.list throws ForgeClientError with kind="unavailable" when experimental.session is missing', async () => { + const v2 = { + session: stubV2().session, + experimental: { workspace: stubV2().experimental!.workspace }, + } as unknown as OpencodeClient + const client = createForgeClient(v2) + + const err = await client.session.list({}).catch((e: unknown) => e) + + expect(err).toBeInstanceOf(ForgeClientError) + expect((err as ForgeClientError).kind).toBe('unavailable') + expect((err as ForgeClientError).method).toBe('session.list') + }) }) describe('createV2ClientFromPluginInput', () => { diff --git a/test/helpers/fake-client.test.ts b/test/helpers/fake-client.test.ts index eadbdc8f4..076f4fb7a 100644 --- a/test/helpers/fake-client.test.ts +++ b/test/helpers/fake-client.test.ts @@ -122,6 +122,24 @@ describe('createFakeForgeClient', () => { await expect(client.sync.start({ directory: '/tmp' })).resolves.toBeUndefined() }) + test('session.list returns empty array', async () => { + const { client } = createFakeForgeClient() + const result = await client.session.list({ directory: '/tmp' }) + expect(result).toEqual([]) + }) + + test('project.list returns empty array', async () => { + const { client } = createFakeForgeClient() + const result = await client.project.list({ directory: '/tmp' }) + expect(result).toEqual([]) + }) + + test('provider.list returns empty all/connected', async () => { + const { client } = createFakeForgeClient() + const result = await client.provider.list({ directory: '/tmp' }) + expect(result).toEqual({ all: [], connected: [] }) + }) + // ── Overrides ───────────────────────────────────────────────────────── test('overrides take effect for session.create', async () => { diff --git a/test/helpers/fake-client.ts b/test/helpers/fake-client.ts index cfa452ed0..b75da9c3f 100644 --- a/test/helpers/fake-client.ts +++ b/test/helpers/fake-client.ts @@ -116,6 +116,11 @@ export function createFakeForgeClient( async (_p: Record) => ({}), overrides?.session?.status, ), + list: makeMethod( + 'session.list', + async (_p: Record) => [], + overrides?.session?.list, + ), promptAsync: makeMethod( 'session.promptAsync', async (_p: Record) => {}, @@ -168,6 +173,20 @@ export function createFakeForgeClient( overrides?.workspace?.warp, ), }, + project: { + list: makeMethod( + 'project.list', + async (_p: Record) => [], + overrides?.project?.list, + ), + }, + provider: { + list: makeMethod( + 'provider.list', + async (_p: Record) => ({ all: [], connected: [] }), + overrides?.provider?.list, + ), + }, tui: { publish: makeMethod( 'tui.publish', diff --git a/test/tui-models.test.ts b/test/tui-models.test.ts index 1f7a4e0c9..6082d8641 100644 --- a/test/tui-models.test.ts +++ b/test/tui-models.test.ts @@ -1,5 +1,6 @@ import { describe, test, expect, mock } from 'bun:test' import type { TuiPluginApi } from '@opencode-ai/plugin/tui' +import { createForgeClient } from '../src/client/sdk-adapter' import { fetchAvailableModels, flattenProviders, @@ -79,7 +80,7 @@ describe('fetchAvailableModels', () => { const providerListMock = mock(() => Promise.resolve({ data: { all: mockProviders, connected: ['anthropic'] } })) const mockApi = createMockApi(['anthropic'], providerListMock) - const result = await fetchAvailableModels(mockApi) + const result = await fetchAvailableModels(mockApi, createForgeClient(mockApi.client as never)) expect(result.error).toBeUndefined() expect(result.providers).toHaveLength(1) @@ -96,7 +97,7 @@ describe('fetchAvailableModels', () => { const providerListMock = mock(() => Promise.resolve({ data: { all: [], connected: [] } })) const mockApi = createMockApi([], providerListMock) - const result = await fetchAvailableModels(mockApi) + const result = await fetchAvailableModels(mockApi, createForgeClient(mockApi.client as never)) expect(result.error).toBeUndefined() expect(result.providers).toHaveLength(0) @@ -112,7 +113,7 @@ describe('fetchAvailableModels', () => { })) const mockApi = createMockApi(['anthropic'], providerListMock) - const result = await fetchAvailableModels(mockApi) + const result = await fetchAvailableModels(mockApi, createForgeClient(mockApi.client as never)) expect(result.providers).toHaveLength(0) expect(result.error).toBe('Authentication failed') @@ -123,7 +124,7 @@ describe('fetchAvailableModels', () => { const providerListMock = mock(() => Promise.reject(new Error('Network error'))) const mockApi = createMockApi(['openai'], providerListMock) - const result = await fetchAvailableModels(mockApi) + const result = await fetchAvailableModels(mockApi, createForgeClient(mockApi.client as never)) expect(result.providers).toHaveLength(0) expect(result.error).toBe('Network error') @@ -134,10 +135,10 @@ describe('fetchAvailableModels', () => { const providerListMock = mock(() => Promise.resolve({ data: null })) const mockApi = createMockApi(['google'], providerListMock) - const result = await fetchAvailableModels(mockApi) + const result = await fetchAvailableModels(mockApi, createForgeClient(mockApi.client as never)) expect(result.providers).toHaveLength(0) - expect(result.error).toBe('No provider data returned') + expect(result.error).toBe('no data returned') expect(result.configuredProviderIds).toEqual(['google']) }) @@ -153,7 +154,7 @@ describe('fetchAvailableModels', () => { const providerListMock = mock(() => Promise.resolve({ data: { all: mockProviders, connected: ['empty-provider'] } })) const mockApi = createMockApi([], providerListMock) - const result = await fetchAvailableModels(mockApi) + const result = await fetchAvailableModels(mockApi, createForgeClient(mockApi.client as never)) expect(result.error).toBeUndefined() expect(result.providers).toHaveLength(1) @@ -187,7 +188,7 @@ describe('fetchAvailableModels', () => { const providerListMock = mock(() => Promise.resolve({ data: { all: mockProviders, connected: ['anthropic'] } })) const mockApi = createMockApi([], providerListMock) - const result = await fetchAvailableModels(mockApi) + const result = await fetchAvailableModels(mockApi, createForgeClient(mockApi.client as never)) expect(result.providers).toHaveLength(1) expect(result.providers[0].id).toBe('anthropic') @@ -219,7 +220,7 @@ describe('fetchAvailableModels', () => { const providerListMock = mock(() => Promise.resolve({ data: { all: mockProviders, connected: ['anthropic'] } })) const mockApi = createMockApi([], providerListMock) - const result = await fetchAvailableModels(mockApi) + const result = await fetchAvailableModels(mockApi, createForgeClient(mockApi.client as never)) expect(result.providers).toHaveLength(1) expect(result.providers[0].models).toHaveLength(1) @@ -598,7 +599,7 @@ describe('fetchAvailableModels with variants', () => { const providerListMock = mock(() => Promise.resolve({ data: { all: mockProviders, connected: ['anthropic'] } })) const mockApi = createMockApi(['anthropic'], providerListMock) - const result = await fetchAvailableModels(mockApi) + const result = await fetchAvailableModels(mockApi, createForgeClient(mockApi.client as never)) expect(result.providers[0].models[0].variants).toEqual({ default: { name: 'Default' }, diff --git a/test/utils/tui-client-await-workspace-connected.test.ts b/test/utils/tui-client-await-workspace-connected.test.ts index 720dc13d4..fbae6e8b8 100644 --- a/test/utils/tui-client-await-workspace-connected.test.ts +++ b/test/utils/tui-client-await-workspace-connected.test.ts @@ -5,19 +5,16 @@ vi.mock('../../src/storage', () => ({ })) import { awaitWorkspaceConnected } from '../../src/utils/tui-client' -import type { TuiPluginApi } from '@opencode-ai/plugin/tui' +import { createForgeClient } from '../../src/client/sdk-adapter' -function makeApi(statusResults: Array | Error>) { +function makeClient(statusResults: Array | Error>) { const status = vi.fn() statusResults.forEach((r) => { if (r instanceof Error) status.mockRejectedValueOnce(r) else status.mockResolvedValueOnce({ data: r }) }) - return { - client: { - experimental: { workspace: { status } }, - }, - } as unknown as TuiPluginApi + const client = createForgeClient({ experimental: { workspace: { status } } } as never) + return { client, status } } describe('awaitWorkspaceConnected', () => { @@ -30,10 +27,10 @@ describe('awaitWorkspaceConnected', () => { }) it('cached path: connected on first poll returns source cached', async () => { - const api = makeApi([ + const { client } = makeClient([ [{ workspaceID: 'ws_1', status: 'connected' }], ]) - const promise = awaitWorkspaceConnected(api, 'ws_1', 5000, 100) + const promise = awaitWorkspaceConnected(client, 'ws_1', 5000, 100) await vi.advanceTimersByTimeAsync(200) const result = await promise @@ -43,27 +40,27 @@ describe('awaitWorkspaceConnected', () => { }) it('polled path: connecting then connected after 1 poll', async () => { - const api = makeApi([ + const { client, status } = makeClient([ [{ workspaceID: 'ws_2', status: 'connecting' }], [{ workspaceID: 'ws_2', status: 'connected' }], ]) - const promise = awaitWorkspaceConnected(api, 'ws_2', 5000, 10) + const promise = awaitWorkspaceConnected(client, 'ws_2', 5000, 10) await vi.advanceTimersByTimeAsync(50) const result = await promise expect(result.connected).toBe(true) expect(result.lastStatus).toBe('connected') - expect(api.client.experimental.workspace.status).toHaveBeenCalledTimes(2) + expect(status).toHaveBeenCalledTimes(2) }) it('timeout path: workspace stays connecting returns source timeout', async () => { - const api = makeApi([ + const { client } = makeClient([ [{ workspaceID: 'ws_3', status: 'connecting' }], [{ workspaceID: 'ws_3', status: 'connecting' }], [{ workspaceID: 'ws_3', status: 'connecting' }], ]) - const promise = awaitWorkspaceConnected(api, 'ws_3', 300, 100) + const promise = awaitWorkspaceConnected(client, 'ws_3', 300, 100) await vi.advanceTimersByTimeAsync(400) const result = await promise @@ -74,11 +71,11 @@ describe('awaitWorkspaceConnected', () => { }) it('missing workspace: not in status list returns source timeout', async () => { - const api = makeApi([ + const { client } = makeClient([ [{ workspaceID: 'ws_other', status: 'connected' }], [{ workspaceID: 'ws_other', status: 'connected' }], ]) - const promise = awaitWorkspaceConnected(api, 'ws_missing', 250, 100) + const promise = awaitWorkspaceConnected(client, 'ws_missing', 250, 100) await vi.advanceTimersByTimeAsync(300) const result = await promise @@ -89,12 +86,12 @@ describe('awaitWorkspaceConnected', () => { }) it('status() throws on every poll keeps polling until timeout', async () => { - const api = makeApi([ + const { client } = makeClient([ new Error('network fail'), new Error('network fail'), new Error('network fail'), ]) - const promise = awaitWorkspaceConnected(api, 'ws_err', 250, 100) + const promise = awaitWorkspaceConnected(client, 'ws_err', 250, 100) await vi.advanceTimersByTimeAsync(300) const result = await promise diff --git a/test/utils/tui-client-select-session.test.ts b/test/utils/tui-client-select-session.test.ts index e7ca88cd3..065f0e4d7 100644 --- a/test/utils/tui-client-select-session.test.ts +++ b/test/utils/tui-client-select-session.test.ts @@ -5,50 +5,52 @@ vi.mock('../../src/storage', () => ({ })) import { selectTuiSession } from '../../src/utils/tui-client' +import { createForgeClient } from '../../src/client/sdk-adapter' import type { TuiPluginApi } from '@opencode-ai/plugin/tui' -function makeApi() { - return { - route: { navigate: vi.fn() }, - client: { tui: { selectSession: vi.fn().mockResolvedValue(undefined) } }, - } as unknown as TuiPluginApi +function makeFixture() { + const navigate = vi.fn() + const selectSession = vi.fn().mockResolvedValue({ data: true }) + const api = { route: { navigate } } as unknown as TuiPluginApi + const client = createForgeClient({ tui: { selectSession } } as never) + return { api, client, navigate, selectSession } } describe('selectTuiSession', () => { test('route-first success: calls api.route.navigate and does not call SDK', async () => { - const api = makeApi() - await selectTuiSession(api, 'ses_123', 'ws_123') + const { api, client, navigate, selectSession } = makeFixture() + await selectTuiSession(api, client, 'ses_123', 'ws_123') - expect((api.route.navigate as any)).toHaveBeenCalledTimes(1) - expect((api.route.navigate as any)).toHaveBeenCalledWith('session', { sessionID: 'ses_123' }) - expect((api.client.tui.selectSession as any)).not.toHaveBeenCalled() + expect(navigate).toHaveBeenCalledTimes(1) + expect(navigate).toHaveBeenCalledWith('session', { sessionID: 'ses_123' }) + expect(selectSession).not.toHaveBeenCalled() }) test('SDK fallback when route navigate throws', async () => { - const api = makeApi() - ;(api.route.navigate as any).mockImplementation(() => { + const { api, client, navigate, selectSession } = makeFixture() + navigate.mockImplementation(() => { throw new Error('route unavailable') }) - await selectTuiSession(api, 'ses_456', 'ws_456') + await selectTuiSession(api, client, 'ses_456', 'ws_456') - expect((api.client.tui.selectSession as any)).toHaveBeenCalledTimes(1) - expect((api.client.tui.selectSession as any)).toHaveBeenCalledWith({ + expect(selectSession).toHaveBeenCalledTimes(1) + expect(selectSession).toHaveBeenCalledWith({ sessionID: 'ses_456', workspace: 'ws_456', }) }) test('no workspace fallback payload', async () => { - const api = makeApi() - ;(api.route.navigate as any).mockImplementation(() => { + const { api, client, navigate, selectSession } = makeFixture() + navigate.mockImplementation(() => { throw new Error('route unavailable') }) - await selectTuiSession(api, 'ses_789') + await selectTuiSession(api, client, 'ses_789') - expect((api.client.tui.selectSession as any)).toHaveBeenCalledTimes(1) - expect((api.client.tui.selectSession as any)).toHaveBeenCalledWith({ + expect(selectSession).toHaveBeenCalledTimes(1) + expect(selectSession).toHaveBeenCalledWith({ sessionID: 'ses_789', }) }) diff --git a/test/utils/tui-client-workspaces.test.ts b/test/utils/tui-client-workspaces.test.ts index 290b76648..9d0bfa73a 100644 --- a/test/utils/tui-client-workspaces.test.ts +++ b/test/utils/tui-client-workspaces.test.ts @@ -5,40 +5,43 @@ vi.mock('bun:sqlite', () => ({ })) import { listConnectedWorkspaces } from '../../src/utils/workspace-listing' +import { createForgeClient } from '../../src/client/sdk-adapter' -function createWorkspaceApi(overrides?: { +function createWorkspace(overrides?: { syncList?: () => Promise - listOverride?: () => Promise<{ data: unknown[] }> - statusOverride?: () => Promise<{ data: unknown[] }> + listOverride?: () => Promise<{ data?: unknown[] }> + statusOverride?: () => Promise<{ data?: unknown[] }> + omitList?: boolean }) { - return { - list: overrides?.listOverride ?? vi.fn().mockResolvedValue({ - data: [ - { id: 'ws-1', name: 'loop-a', type: 'worktree', directory: '/wt/a', timeUsed: 100 }, - { id: 'ws-2', name: 'loop-b', type: 'worktree', directory: '/wt/b', timeUsed: 200 }, - { id: 'ws-3', name: 'loop-c', type: 'worktree', directory: '/wt/c', timeUsed: 50 }, - ], - }), - status: overrides?.statusOverride ?? vi.fn().mockResolvedValue({ - data: [ - { workspaceID: 'ws-1', status: 'connected' }, - { workspaceID: 'ws-2', status: 'disconnected' }, - { workspaceID: 'ws-3', status: 'connected' }, - ], - }), - ...(overrides?.syncList !== undefined ? { syncList: overrides.syncList } : {}), - } + const list = overrides?.listOverride ?? vi.fn().mockResolvedValue({ + data: [ + { id: 'ws-1', name: 'loop-a', type: 'worktree', directory: '/wt/a', timeUsed: 100 }, + { id: 'ws-2', name: 'loop-b', type: 'worktree', directory: '/wt/b', timeUsed: 200 }, + { id: 'ws-3', name: 'loop-c', type: 'worktree', directory: '/wt/c', timeUsed: 50 }, + ], + }) + const status = overrides?.statusOverride ?? vi.fn().mockResolvedValue({ + data: [ + { workspaceID: 'ws-1', status: 'connected' }, + { workspaceID: 'ws-2', status: 'disconnected' }, + { workspaceID: 'ws-3', status: 'connected' }, + ], + }) + const syncList = overrides?.syncList ?? vi.fn().mockResolvedValue({ data: undefined }) + const workspaceApi: Record = { status, syncList } + if (!overrides?.omitList) workspaceApi.list = list + const client = createForgeClient({ experimental: { workspace: workspaceApi } } as never) + return { workspace: client.workspace, list, status, syncList } } describe('listConnectedWorkspaces', () => { it('calls syncList as a sync trigger only and returns entries from list()', async () => { - const syncList = vi.fn().mockResolvedValue(undefined) - const api = createWorkspaceApi({ syncList }) + const { workspace, list, syncList } = createWorkspace() - const result = await listConnectedWorkspaces(api) + const result = await listConnectedWorkspaces(workspace) expect(syncList).toHaveBeenCalledOnce() - expect(api.list).toHaveBeenCalledOnce() + expect(list).toHaveBeenCalledOnce() expect(result).toHaveLength(2) const ids = result.map((w) => w.id) expect(ids).toContain('ws-1') @@ -47,68 +50,61 @@ describe('listConnectedWorkspaces', () => { }) it('calls list() after syncList even when syncList resolves with no data', async () => { - const syncList = vi.fn().mockResolvedValue(undefined) - const api = createWorkspaceApi({ syncList }) + const { workspace, list, syncList } = createWorkspace() - const result = await listConnectedWorkspaces(api) + const result = await listConnectedWorkspaces(workspace) expect(syncList).toHaveBeenCalledOnce() - expect(api.list).toHaveBeenCalledOnce() - expect(result).toHaveLength(2) - }) - - it('falls back to list() when syncList is not available', async () => { - const api = createWorkspaceApi() - - const result = await listConnectedWorkspaces(api) - - expect(api.list).toHaveBeenCalledOnce() + expect(list).toHaveBeenCalledOnce() expect(result).toHaveLength(2) }) it('filters to connected (or unknown) workspaces', async () => { - const api = createWorkspaceApi() + const { workspace } = createWorkspace() - const result = await listConnectedWorkspaces(api) + const result = await listConnectedWorkspaces(workspace) expect(result).toHaveLength(2) expect(result.map((w) => w.id)).toEqual(['ws-1', 'ws-3']) }) it('sorts by timeUsed desc', async () => { - const api = createWorkspaceApi() + const { workspace } = createWorkspace() - const result = await listConnectedWorkspaces(api) + const result = await listConnectedWorkspaces(workspace) expect(result[0].id).toBe('ws-1') expect(result[1].id).toBe('ws-3') }) - it('returns empty array when syncList fails but list() still works', async () => { + it('returns entries when syncList fails but list() still works', async () => { const syncList = vi.fn().mockRejectedValue(new Error('host unavailable')) - const api = createWorkspaceApi({ syncList }) + const { workspace, list } = createWorkspace({ syncList }) - const result = await listConnectedWorkspaces(api) + const result = await listConnectedWorkspaces(workspace) expect(syncList).toHaveBeenCalledOnce() - expect(api.list).toHaveBeenCalledOnce() + expect(list).toHaveBeenCalledOnce() expect(result).toHaveLength(2) expect(result.map((w) => w.id)).toContain('ws-1') }) - it('returns empty array when workspaceApi is undefined', async () => { - const result = await listConnectedWorkspaces(undefined) + it('returns empty array when experimental.workspace.list is unavailable', async () => { + const { workspace } = createWorkspace({ omitList: true }) + const result = await listConnectedWorkspaces(workspace) expect(result).toEqual([]) }) - it('returns empty array when list is not a function', async () => { - const api = { status: vi.fn(), syncList: vi.fn() } - const result = await listConnectedWorkspaces(api) + it('returns empty array when list() rejects', async () => { + const { workspace } = createWorkspace({ + listOverride: vi.fn().mockRejectedValue(new Error('list failed')), + }) + const result = await listConnectedWorkspaces(workspace) expect(result).toEqual([]) }) it('includes entries with unknown status', async () => { - const api = createWorkspaceApi({ + const { workspace } = createWorkspace({ statusOverride: vi.fn().mockResolvedValue({ data: [ { workspaceID: 'ws-1', status: 'connected' }, @@ -116,17 +112,16 @@ describe('listConnectedWorkspaces', () => { }), }) - const result = await listConnectedWorkspaces(api) + const result = await listConnectedWorkspaces(workspace) expect(result).toHaveLength(3) expect(result.map((w) => w.id)).toEqual(['ws-2', 'ws-1', 'ws-3']) }) it('sorts entries from list() by timeUsed desc, not syncList result', async () => { - const syncList = vi.fn().mockResolvedValue(undefined) - const api = createWorkspaceApi({ syncList }) + const { workspace } = createWorkspace() - const result = await listConnectedWorkspaces(api) + const result = await listConnectedWorkspaces(workspace) expect(result[0].id).toBe('ws-1') expect(result[1].id).toBe('ws-3')