From a7938c2db6fd94d3401c637007f1597ec36ff105 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Sat, 13 Jun 2026 00:34:24 -0400 Subject: [PATCH 1/2] feat: add typed client port with SDK adapter and test infrastructure --- src/client/port.ts | 93 ++ src/client/sdk-adapter.ts | 200 ++++ src/hooks/forge-session-attach.ts | 37 +- src/hooks/host-side-effects.ts | 30 +- src/hooks/loop-permission.ts | 33 +- src/hooks/loop.ts | 11 +- src/hooks/plan-approval.ts | 111 +- src/hooks/plan-capture.ts | 3 +- src/hooks/watchdog.ts | 9 +- src/index.ts | 85 +- src/loop/runtime.ts | 205 +--- src/loop/service.ts | 2 - src/loop/session-output.ts | 13 +- src/services/execution.ts | 455 +++----- src/services/plan-capture.ts | 27 +- src/tools/loop.ts | 22 +- src/tools/types.ts | 9 +- src/tui.tsx | 3 +- src/utils/audit-session.ts | 34 +- src/utils/loop-session.ts | 63 +- src/utils/plan-from-messages.ts | 11 +- src/utils/resolve-project-root.ts | 10 +- src/utils/tui-client.ts | 3 +- src/workspace/forge-adapter.ts | 2 +- src/workspace/forge-worktree.ts | 150 ++- src/workspace/pending-teardown.ts | 2 +- src/workspace/remove-with-context.ts | 21 +- src/workspace/sweep-stale.ts | 19 +- test/audit-session.test.ts | 102 +- test/client/sdk-adapter.test.ts | 256 +++++ test/helpers/fake-client.test.ts | 195 ++++ test/helpers/fake-client.ts | 296 ++++++ test/hooks/audit-rotate-ordering.test.ts | 113 +- test/hooks/forge-session-attach.test.ts | 791 +++++++------- test/hooks/host-side-effects-unwarp.test.ts | 134 ++- test/hooks/loop-event-gate.test.ts | 49 +- test/hooks/loop-final-audit-rewind.test.ts | 1 - test/hooks/loop-section-advancement.test.ts | 3 +- test/hooks/loop-section-audit-retry.test.ts | 187 +--- test/index/session-lookup.test.ts | 135 ++- test/loop-permission-ruleset.test.ts | 84 +- test/loop-runtime-audit-permissions.test.ts | 75 +- test/loop-service.test.ts | 1 - test/loop-status-tool.test.ts | 508 ++++----- test/loop/cancel.test.ts | 66 +- test/loop/in-flight-guard.test.ts | 4 +- test/loop/runtime.test.ts | 734 ++++--------- test/loop/start.test.ts | 40 +- test/parent-session-lookup.test.ts | 85 +- test/plan-approval.test.ts | 994 ++++++++++++------ test/plan-capture.test.ts | 54 +- test/resolve-project-root.test.ts | 66 +- test/section-management.test.ts | 4 +- test/services/attach-loop.test.ts | 41 +- .../services/execution-attach-cleanup.test.ts | 21 +- .../execution-in-flight-guard.test.ts | 90 +- test/services/execution-restart.test.ts | 313 +++--- test/services/execution.start-loop.test.ts | 647 +++++++----- test/tools/review-section-scope.test.ts | 2 +- test/tools/section-read.test.ts | 2 +- test/utils/loop-session.test.ts | 162 +-- test/utils/plan-from-messages.test.ts | 66 +- test/utils/tui-client-workspaces.test.ts | 4 +- test/watchdog.test.ts | 82 +- test/workspace/forge-worktree.test.ts | 258 ++--- test/workspace/sweep-stale.test.ts | 171 ++- vitest.config.ts | 2 + 67 files changed, 4292 insertions(+), 4209 deletions(-) create mode 100644 src/client/port.ts create mode 100644 src/client/sdk-adapter.ts create mode 100644 test/client/sdk-adapter.test.ts create mode 100644 test/helpers/fake-client.test.ts create mode 100644 test/helpers/fake-client.ts diff --git a/src/client/port.ts b/src/client/port.ts new file mode 100644 index 0000000000..53f972c7ce --- /dev/null +++ b/src/client/port.ts @@ -0,0 +1,93 @@ +import type { OpencodeClient } from '@opencode-ai/sdk/v2' + +type V2 = OpencodeClient + +// ── Session param types ────────────────────────────────────────────────────── +export type SessionCreateParams = NonNullable[0]> +export type SessionGetParams = NonNullable[0]> +export type SessionUpdateParams = NonNullable[0]> +export type SessionMessagesParams = NonNullable[0]> +export type SessionStatusParams = NonNullable[0]> +export type SessionPromptAsyncParams = NonNullable[0]> +export type SessionAbortParams = NonNullable[0]> +export type SessionDeleteParams = NonNullable[0]> + +// ── Session result types ───────────────────────────────────────────────────── +export type Session = NonNullable>['data']> +export type SessionMessages = NonNullable>['data']> +export type SessionStatus = NonNullable>['data']> + +// ── Workspace param types ──────────────────────────────────────────────────── +export type WorkspaceCreateParams = NonNullable[0]> +export type WorkspaceListParams = NonNullable[0]> +export type WorkspaceStatusParams = NonNullable[0]> +export type WorkspaceSyncListParams = NonNullable[0]> +export type WorkspaceRemoveParams = NonNullable[0]> +export type WorkspaceWarpParams = NonNullable[0]> + +// ── Workspace result types ─────────────────────────────────────────────────── +export type WorkspaceCreateResult = NonNullable>['data']> +export type WorkspaceList = NonNullable>['data']> +export type WorkspaceStatus = NonNullable>['data']> + +// ── TUI param types ────────────────────────────────────────────────────────── +export type TuiPublishParams = NonNullable[0]> +export type TuiSelectSessionParams = NonNullable[0]> + +// ── Sync param types ───────────────────────────────────────────────────────── +export type SyncStartParams = NonNullable[0]> + +// ── Error model ────────────────────────────────────────────────────────────── + +export type ForgeClientErrorKind = 'connection' | 'not-found' | 'unavailable' | 'request' + +export class ForgeClientError extends Error { + readonly kind: ForgeClientErrorKind + readonly method: string + override readonly cause?: unknown + /** SDK error code, propagated from `cause.code` when available (e.g. `"concurrent_prompt"`). */ + readonly code?: string + + constructor(args: { kind: ForgeClientErrorKind; method: string; message: string; cause?: unknown }) { + super(args.message) + this.name = 'ForgeClientError' + this.kind = args.kind + this.method = args.method + this.cause = args.cause + // Propagate SDK error code through so callers can detect specific error + // codes (e.g. 'concurrent_prompt') on a port-level error. + this.code = (args.cause && typeof args.cause === 'object' && 'code' in (args.cause as Record)) + ? (args.cause as { code: string }).code + : undefined + } +} + +// ── Port interface ─────────────────────────────────────────────────────────── + +export interface ForgeClient { + session: { + create(params: SessionCreateParams): Promise + get(params: SessionGetParams): Promise + update(params: SessionUpdateParams): Promise + messages(params: SessionMessagesParams): Promise + status(params?: SessionStatusParams): Promise + promptAsync(params: SessionPromptAsyncParams): Promise + abort(params: SessionAbortParams): Promise + delete(params: SessionDeleteParams): Promise + } + workspace: { + create(params: WorkspaceCreateParams): Promise + list(params?: WorkspaceListParams): Promise + status(params?: WorkspaceStatusParams): Promise + syncList(params?: WorkspaceSyncListParams): Promise + remove(params: WorkspaceRemoveParams): Promise + warp(params: WorkspaceWarpParams): Promise + } + tui: { + publish(params: TuiPublishParams): Promise + selectSession(params: TuiSelectSessionParams): Promise + } + sync: { + start(params?: SyncStartParams): Promise + } +} diff --git a/src/client/sdk-adapter.ts b/src/client/sdk-adapter.ts new file mode 100644 index 0000000000..6bbe84a34e --- /dev/null +++ b/src/client/sdk-adapter.ts @@ -0,0 +1,200 @@ +import type { OpencodeClient } from '@opencode-ai/sdk/v2' +import { createOpencodeClient as createV2Client } from '@opencode-ai/sdk/v2' +import type { PluginInput } from '@opencode-ai/plugin' +import type { Logger } from '../types' +import { ForgeClientError, type ForgeClient, type ForgeClientErrorKind } from './port' + +// ── Error classification ───────────────────────────────────────────────────── + +function extractMessage(err: unknown): string { + if (err instanceof Error) return err.message + if (typeof err === 'string') return err + if (err && typeof err === 'object') { + const obj = err as Record + if (typeof obj.message === 'string') return obj.message + if (obj.data && typeof obj.data === 'object') { + const data = obj.data as Record + if (typeof data.message === 'string') return data.message + } + } + try { + return JSON.stringify(err) + } catch { + return String(err) + } +} + +function classify(err: unknown, method: string): ForgeClientError { + const rawMessage = extractMessage(err) + let kind: ForgeClientErrorKind = 'request' + if (/Unable to connect|fetch failed|ECONNREFUSED/i.test(rawMessage)) { + kind = 'connection' + } else if (/not found/i.test(rawMessage)) { + kind = 'not-found' + } + return new ForgeClientError({ kind, method, message: rawMessage, cause: err }) +} + +// ── Result normalisation helpers ───────────────────────────────────────────── + +/** + * Call an SDK method that returns meaningful data. Normalises the + * `{ data, error }` envelope so callers always get data or a classified error. + */ +async function withData( + method: string, + promise: Promise<{ data?: T | undefined; error?: unknown }>, +): Promise { + let result: { data?: T | undefined; error?: unknown } + try { + result = await promise + } catch (err: unknown) { + throw classify(err, method) + } + if (result.error) { + throw classify(result.error, method) + } + if (result.data == null) { + throw classify(new Error('no data returned'), method) + } + return result.data +} + +/** + * Call an SDK method that returns no meaningful data (void). Only checks the + * `{ data, error }` envelope for errors. + */ +async function withVoid( + method: string, + promise: Promise<{ data?: unknown; error?: unknown }>, +): Promise { + let result: { data?: unknown; error?: unknown } + try { + result = await promise + } catch (err: unknown) { + throw classify(err, method) + } + if (result.error) { + throw classify(result.error, method) + } +} + +// ── Factory ────────────────────────────────────────────────────────────────── + +export function createForgeClient(v2: OpencodeClient, _logger?: Logger | Console): ForgeClient { + // ── session namespace ──────────────────────────────────────────────────── + const session: ForgeClient['session'] = { + create: (params) => withData('session.create', v2.session.create(params)), + get: (params) => withData('session.get', v2.session.get(params)), + 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)), + 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)), + } + + // ── workspace namespace ────────────────────────────────────────────────── + // Guard: experimental.workspace must be available at runtime. + const wsApi = v2.experimental?.workspace + + function requireWsApi(method: string): NonNullable { + if (!wsApi || typeof wsApi[method.split('.').pop() as keyof typeof wsApi] !== 'function') { + throw new ForgeClientError({ + kind: 'unavailable', + method: `workspace.${method}`, + message: `experimental.workspace.${method} not available on this host`, + }) + } + return wsApi + } + + function guardWs(method: string, fn: () => Promise): Promise { + try { + requireWsApi(method) + } catch (err: unknown) { + return Promise.reject(err) + } + return fn() + } + + const workspace: ForgeClient['workspace'] = { + create: (params) => guardWs('create', () => withData('workspace.create', wsApi!.create(params))), + list: (params) => guardWs('list', () => withData('workspace.list', wsApi!.list(params))), + status: (params) => guardWs('status', () => withData('workspace.status', wsApi!.status(params))), + syncList: (params) => guardWs('syncList', () => withVoid('workspace.syncList', wsApi!.syncList(params))), + remove: (params) => guardWs('remove', () => withVoid('workspace.remove', wsApi!.remove(params))), + warp: (params) => guardWs('warp', () => withVoid('workspace.warp', wsApi!.warp(params))), + } + + // ── tui namespace ──────────────────────────────────────────────────────── + const tui: ForgeClient['tui'] = { + publish: async (params) => { + if (!v2.tui) return // resolve as no-op when namespace unavailable + return withVoid('tui.publish', v2.tui.publish(params)) + }, + selectSession: async (params) => { + if (!v2.tui) { + throw new ForgeClientError({ + kind: 'unavailable', + method: 'tui.selectSession', + message: 'tui namespace not available on this host', + }) + } + return withVoid('tui.selectSession', v2.tui.selectSession(params)) + }, + } + + // ── sync namespace ─────────────────────────────────────────────────────── + const sync: ForgeClient['sync'] = { + start: async (params) => { + if (!v2.sync?.start) return // resolve as no-op when method unavailable + return withVoid('sync.start', v2.sync.start(params)) + }, + } + + return { session, workspace, tui, sync } +} + +// ── Combined factory ───────────────────────────────────────────────────────── + +/** + * One-stop factory: create an SDK v2 client from plugin input, then wrap it in + * a `ForgeClient`. This is the only import callers in `src/` (outside the + * adapter) need. + */ +export function createForgeClientFromPluginInput( + pluginInput: PluginInput, + _logger?: Logger | Console, +): ForgeClient { + return createForgeClient(createV2ClientFromPluginInput(pluginInput), _logger) +} + +// ── Legacy client adapter ──────────────────────────────────────────────────── + +/** + * Create an SDK v2 client from the plugin's legacy client. This is the **only** + * place in `src/` that should touch the legacy client after this port completes. + * + * Extracts the in-process fetch function and Authorization header from the + * plugin-provided legacy client so the v2 client can dispatch in-process AND + * satisfy the server's Basic auth requirement. + */ +export function createV2ClientFromPluginInput(pluginInput: PluginInput): OpencodeClient { + const legacyHttp = ( + pluginInput.client as unknown as { + _client?: { getConfig: () => { fetch?: typeof fetch; headers?: Headers } } + } + )._client + const legacyConfig = legacyHttp?.getConfig?.() + const legacyFetch = legacyConfig?.fetch + const legacyAuthHeader = + legacyConfig?.headers?.get?.('authorization') ?? legacyConfig?.headers?.get?.('Authorization') + const v2ClientConfig: Parameters[0] = { + baseUrl: pluginInput.serverUrl.toString(), + directory: pluginInput.directory, + ...(legacyFetch ? { fetch: legacyFetch } : {}), + ...(legacyAuthHeader ? { headers: { Authorization: legacyAuthHeader } } : {}), + } + return createV2Client(v2ClientConfig) +} diff --git a/src/hooks/forge-session-attach.ts b/src/hooks/forge-session-attach.ts index 27c16ab019..c9f3b86d91 100644 --- a/src/hooks/forge-session-attach.ts +++ b/src/hooks/forge-session-attach.ts @@ -1,5 +1,5 @@ -import type { createOpencodeClient as createV2Client } from '@opencode-ai/sdk/v2' import type { Logger } from '../types' +import type { ForgeClient } from '../client/port' import type { ForgeExecutionServiceDeps, PlanSource } from '../services/execution' import { attachLoopToSession } from '../services/execution' import { resolveSandboxContextForLoop } from '../sandbox/context' @@ -8,7 +8,7 @@ import { removeForgeWorkspaceWithContext } from '../workspace/remove-with-contex import { getForgeWorkspaceLoopName } from '../workspace/forge-worktree' export interface ForgeSessionAttachHookDeps { - v2: ReturnType + client: ForgeClient execDeps: ForgeExecutionServiceDeps projectId: string directory: string @@ -50,8 +50,10 @@ export function createForgeSessionMessageAttachHook(deps: ForgeSessionAttachHook const sessionId = input.sessionID if (!sessionId) return - const sessionResult = await deps.v2.session?.get?.({ sessionID: sessionId }).catch(() => null) - const sessionInfo = (sessionResult?.data ?? null) as Record | null + const sessionInfo = await deps.client.session.get({ sessionID: sessionId }).then( + (data) => data as Record | null, + () => null, + ) const workspaceId = sessionInfo?.workspaceID as string | undefined if (!workspaceId) return @@ -165,7 +167,7 @@ async function attachForgeSession( if (!cfg) { if (action.action === 'remove-fully') { await removeForgeWorkspaceWithContext( - { v2: deps.v2, pendingTeardowns: deps.execDeps.pendingTeardowns, logger: deps.logger }, + { client: deps.client, pendingTeardowns: deps.execDeps.pendingTeardowns, logger: deps.logger }, { workspaceId, loopName, action: 'remove-fully', reasonLabel: 'attach-missing-row-no-config' }, ) publishAttachFailureToast( @@ -182,7 +184,7 @@ async function attachForgeSession( } if (action.action === 'remove-fully' && cfg.initialPromptOwner === 'tui' && !isPendingAttachWorkspace(classifyEntry)) { await removeForgeWorkspaceWithContext( - { v2: deps.v2, pendingTeardowns: deps.execDeps.pendingTeardowns, logger: deps.logger }, + { client: deps.client, pendingTeardowns: deps.execDeps.pendingTeardowns, logger: deps.logger }, { workspaceId, loopName, action: 'remove-fully', reasonLabel: 'attach-expired-pending' }, ) publishAttachFailureToast( @@ -202,7 +204,7 @@ async function attachForgeSession( } else if (action.action === 'remove-fully' && action.reason === 'completed') { // Completed loop: remove workspace + toast await removeForgeWorkspaceWithContext( - { v2: deps.v2, pendingTeardowns: deps.execDeps.pendingTeardowns, logger: deps.logger }, + { client: deps.client, pendingTeardowns: deps.execDeps.pendingTeardowns, logger: deps.logger }, { workspaceId, loopName, action: 'remove-fully', reasonLabel: 'attach-safety-net-completed' }, ) publishAttachFailureToast( @@ -215,7 +217,7 @@ async function attachForgeSession( } else if (action.action === 'remove-registration-only') { // Restartable (cancelled/errored/stalled): remove registration, preserve worktree for manual restart await removeForgeWorkspaceWithContext( - { v2: deps.v2, pendingTeardowns: deps.execDeps.pendingTeardowns, logger: deps.logger }, + { client: deps.client, pendingTeardowns: deps.execDeps.pendingTeardowns, logger: deps.logger }, { workspaceId, loopName, action: 'remove-registration-only', reasonLabel: 'attach-safety-net-restartable' }, ) publishAttachFailureToast( @@ -251,7 +253,7 @@ async function attachForgeSession( deps.logger.error(`[forge-session-attach] plan not found for session=${planSource.sessionId} loop=${loopName} workspace=${workspaceId}`) publishAttachFailureToast(deps, ws.directory ?? deps.directory, `Forge loop "${loopName}"`, 'No stored plan found for this loop. Re-run "Execute → Loop" from a session that has a captured plan.') await removeForgeWorkspaceWithContext( - { v2: deps.v2, pendingTeardowns: deps.execDeps.pendingTeardowns, logger: deps.logger }, + { client: deps.client, pendingTeardowns: deps.execDeps.pendingTeardowns, logger: deps.logger }, { workspaceId, loopName, action: 'remove-fully', reasonLabel: 'attach-no-plan' }, ) return @@ -301,13 +303,13 @@ async function attachForgeSession( : `Failed to start loop: ${result.message}`, ) await removeForgeWorkspaceWithContext( - { v2: deps.v2, pendingTeardowns: deps.execDeps.pendingTeardowns, logger: deps.logger }, + { client: deps.client, pendingTeardowns: deps.execDeps.pendingTeardowns, logger: deps.logger }, { workspaceId, loopName, action: removalAction, reasonLabel: 'attach-conflict-terminal' }, ) } else if (!result.ok && result.code !== 'already_attached') { publishAttachFailureToast(deps, ws.directory ?? deps.directory, `Forge loop "${loopName}"`, `Failed to start loop: ${result.message}`) await removeForgeWorkspaceWithContext( - { v2: deps.v2, pendingTeardowns: deps.execDeps.pendingTeardowns, logger: deps.logger }, + { client: deps.client, pendingTeardowns: deps.execDeps.pendingTeardowns, logger: deps.logger }, { workspaceId, loopName, action: 'remove-fully', reasonLabel: 'attach-failed' }, ) } @@ -315,7 +317,7 @@ async function attachForgeSession( deps.logger.error('[forge-session-attach] attachLoopToSession threw', err) publishAttachFailureToast(deps, ws.directory ?? deps.directory, `Forge loop "${loopName}"`, 'Failed to start loop (unexpected error). Check forge logs.') await removeForgeWorkspaceWithContext( - { v2: deps.v2, pendingTeardowns: deps.execDeps.pendingTeardowns, logger: deps.logger }, + { client: deps.client, pendingTeardowns: deps.execDeps.pendingTeardowns, logger: deps.logger }, { workspaceId, loopName, action: 'remove-fully', reasonLabel: 'attach-error' }, ) } @@ -352,15 +354,13 @@ function publishAttachFailureToast( title: string, message: string, ): void { - const tui = deps.v2.tui - if (!tui || typeof tui.publish !== 'function') return - tui.publish({ + deps.client.tui.publish({ directory, body: { type: 'tui.toast.show', properties: { title, message, variant: 'error', duration: 6000 }, }, - }).catch((err) => { + }).catch((err: unknown) => { deps.logger.error('[forge-session-attach] failed to publish toast', err) }) } @@ -371,10 +371,9 @@ async function findWorkspaceById( directory?: string, ): Promise { try { - const result = await deps.v2.experimental.workspace.list( + const entries = (await deps.client.workspace.list( directory ? { directory } : undefined, - ) - const entries = (result.data ?? []) as WorkspaceEntry[] + ) ?? []) as WorkspaceEntry[] return entries.find((e) => e.id === workspaceId) ?? null } catch { return null diff --git a/src/hooks/host-side-effects.ts b/src/hooks/host-side-effects.ts index 4a0674ff3a..eabce597c5 100644 --- a/src/hooks/host-side-effects.ts +++ b/src/hooks/host-side-effects.ts @@ -1,4 +1,4 @@ -import type { OpencodeClient } from '@opencode-ai/sdk/v2' +import type { ForgeClient } from '../client/port' import type { LoopState, TerminationReason } from '../loop' import type { Logger, PluginConfig } from '../types' import type { createSandboxManager } from '../sandbox/manager' @@ -11,7 +11,7 @@ import { aggregateToUsageSummary } from '../utils/loop-format' import { sweepStaleForgeWorkspaces } from '../workspace/sweep-stale' export interface TerminationSideEffectsContext { - v2Client: OpencodeClient + client: ForgeClient logger: Logger getConfig: () => PluginConfig sandboxManager?: ReturnType @@ -106,12 +106,10 @@ function publishTerminationToast( reason: TerminationReason, ctx: TerminationSideEffectsContext, ): void { - if (!ctx.v2Client.tui) return - const variants = getToastVariant(reason) const message = getToastMessage(state, reason) - ctx.v2Client.tui.publish({ + ctx.client.tui.publish({ directory: state.projectDir ?? state.worktreeDir, body: { type: 'tui.toast.show', @@ -179,11 +177,10 @@ async function unwarpToHostSession( state: LoopState, ctx: TerminationSideEffectsContext, ): Promise { - if (!ctx.v2Client.tui) return if (!state.hostSessionId || !state.projectDir) return try { - await ctx.v2Client.tui.publish({ + await ctx.client.tui.publish({ directory: state.projectDir, body: { type: 'tui.session.select', @@ -218,28 +215,19 @@ async function teardownWorktree( await unwarpToHostSession(state, ctx) try { - const workspaceApi = ctx.v2Client.experimental?.workspace - if (workspaceApi?.remove) { - const result = await workspaceApi.remove({ id: state.workspaceId }) - if (result.error) { - ctx.logger.error(`Loop: workspace.remove returned error for ${state.workspaceId}`, result.error) - } else { - ctx.logger.log(`Loop: workspace ${state.workspaceId} removed for ${state.loopName}`) - } - } else { - ctx.logger.error('Loop: experimental.workspace.remove not available; cannot tear down worktree') - } + await ctx.client.workspace.remove({ id: state.workspaceId }) + ctx.logger.log(`Loop: workspace ${state.workspaceId} removed for ${state.loopName}`) } catch (err) { ctx.logger.error(`Loop: workspace.remove threw for ${state.workspaceId}`, err) } finally { ctx.pendingTeardowns?.clear(state.loopName) } - // Opportunistic sweep of stale sibling workspaces - if (ctx.loopsRepo && ctx.projectId && ctx.pendingTeardowns && state.projectDir) { + // Opportunistic sweep of stale sibling workspaces (port required) + if (ctx.client && ctx.loopsRepo && ctx.projectId && ctx.pendingTeardowns && state.projectDir) { try { const report = await sweepStaleForgeWorkspaces( - { v2: ctx.v2Client, loopsRepo: ctx.loopsRepo, pendingTeardowns: ctx.pendingTeardowns, logger: ctx.logger }, + { client: ctx.client, loopsRepo: ctx.loopsRepo, pendingTeardowns: ctx.pendingTeardowns, logger: ctx.logger }, { projectId: ctx.projectId, projectDirectory: state.projectDir, excludeLoopName: state.loopName, reasonLabel: 'orphan-sweep' }, ) if (report.swept.length > 0) { diff --git a/src/hooks/loop-permission.ts b/src/hooks/loop-permission.ts index b6adf360ad..65cc91390e 100644 --- a/src/hooks/loop-permission.ts +++ b/src/hooks/loop-permission.ts @@ -1,4 +1,4 @@ -import type { OpencodeClient } from '@opencode-ai/sdk/v2' +import type { ForgeClient } from '../client/port' import type { Logger } from '../types' import type { createSessionLoopResolver } from '../services/session-loop-resolver' import { buildLoopPermissionRuleset } from '../constants/loop' @@ -22,7 +22,7 @@ interface SessionCreatedProperties { type PermissionRule = { permission: string; pattern: string; action: 'allow' | 'deny' | 'ask' } export interface CreateLoopPermissionRejectHookDeps { - v2: OpencodeClient + client: ForgeClient sessionLoopResolver: ReturnType directory: string logger: Logger @@ -35,7 +35,7 @@ export type LoopPermissionRejectHook = ( export function createLoopPermissionRejectHook( deps: CreateLoopPermissionRejectHookDeps, ): LoopPermissionRejectHook { - const { v2, sessionLoopResolver, directory, logger } = deps + const { client, sessionLoopResolver, directory, logger } = deps return async (eventInput) => { if (eventInput.event?.type !== 'session.created') return @@ -61,8 +61,8 @@ export function createLoopPermissionRejectHook( let ruleset: PermissionRule[] | null = null let rulesetSource = 'loop-default' try { - const parent = await v2.session.get({ sessionID: parentID, directory: targetDirectory }) - const parentRules = (parent as { data?: { permission?: PermissionRule[] } })?.data?.permission + const parent = await client.session.get({ sessionID: parentID, directory: targetDirectory }) + const parentRules = (parent as { permission?: PermissionRule[] })?.permission if (Array.isArray(parentRules) && parentRules.some((r) => r.permission === '*' && r.action === 'allow')) { ruleset = parentRules rulesetSource = `inherited-from-parent=${parentID}` @@ -77,26 +77,19 @@ export function createLoopPermissionRejectHook( ) try { - const result = await v2.session.update({ + await client.session.update({ sessionID, directory: targetDirectory, permission: ruleset, }) - if ((result as { error?: unknown })?.error) { - logger.error( - `[loop-permission] session.update returned error for ${sessionID}`, - (result as { error?: unknown }).error, - ) - } else { - PATCHED_SESSIONS.add(sessionID) - if (PATCHED_SESSIONS.size > PATCHED_SESSIONS_MAX) { - const oldest = PATCHED_SESSIONS.values().next().value - if (oldest) PATCHED_SESSIONS.delete(oldest) - } - logger.log( - `[loop-permission] applied loop=${resolved.loopName} session=${sessionID} ruleCount=${ruleset.length}`, - ) + PATCHED_SESSIONS.add(sessionID) + if (PATCHED_SESSIONS.size > PATCHED_SESSIONS_MAX) { + const oldest = PATCHED_SESSIONS.values().next().value + if (oldest) PATCHED_SESSIONS.delete(oldest) } + logger.log( + `[loop-permission] applied loop=${resolved.loopName} session=${sessionID} ruleCount=${ruleset.length}`, + ) } catch (err) { logger.error(`[loop-permission] session.update threw for ${sessionID}`, err) } diff --git a/src/hooks/loop.ts b/src/hooks/loop.ts index c3548c9945..62ab19c7dc 100644 --- a/src/hooks/loop.ts +++ b/src/hooks/loop.ts @@ -1,5 +1,4 @@ -import type { PluginInput } from '@opencode-ai/plugin' -import type { OpencodeClient } from '@opencode-ai/sdk/v2' +import type { ForgeClient } from '../client/port' import type { LoopChangeNotifier, TerminationReason } from '../loop' import { createLoop, isWorkspaceNotFoundError } from '../loop' import type { Logger, PluginConfig, LoopConfig } from '../types' @@ -39,8 +38,7 @@ export function createLoopEventHandler( plansRepo: PlansRepo, reviewFindingsRepo: ReviewFindingsRepo, projectId: string, - client: PluginInput['client'], - v2Client: OpencodeClient, + forgeClient: ForgeClient, logger: Logger, getConfig: () => PluginConfig, sandboxManager?: ReturnType, @@ -56,8 +54,7 @@ export function createLoopEventHandler( plansRepo, reviewFindingsRepo, projectId, - client, - v2Client, + client: forgeClient, logger, getConfig, sandboxManager, @@ -68,7 +65,7 @@ export function createLoopEventHandler( loopSessionUsageRepo, onTerminated: async (state, reason) => { await performTerminationSideEffects(state, reason, state.sessionId, { - v2Client, + client: forgeClient, logger, getConfig, sandboxManager, diff --git a/src/hooks/plan-approval.ts b/src/hooks/plan-approval.ts index 0a718fdfca..4b65cd7eab 100644 --- a/src/hooks/plan-approval.ts +++ b/src/hooks/plan-approval.ts @@ -10,7 +10,7 @@ function publishPlanApprovalToast( variant: 'success' | 'error' | 'info', message: string, ): void { - ctx.v2.tui?.publish({ + ctx.client.tui.publish({ directory: ctx.directory, body: { type: 'tui.toast.show', @@ -28,35 +28,13 @@ function publishPlanApprovalToast( async function abortApprovalSourceSession(ctx: ToolContext, sessionID: string): Promise { const logger = ctx.logger - const legacyClient = ctx.input?.client - if (legacyClient?.session) { - try { - logger.log(`Plan approval: awaiting legacy session.abort for ${sessionID}`) - const result = await legacyClient.session.abort({ - path: { id: sessionID }, - query: { directory: ctx.directory }, - } as Parameters[0]) - if ((result as { error?: unknown })?.error) { - logger.error('Plan approval: legacy session.abort returned error', (result as { error?: unknown }).error) - } else { - logger.log(`Plan approval: legacy session.abort resolved for ${sessionID}`) - return true - } - } catch (err) { - logger.error('Plan approval: legacy session.abort threw', err) - } - } try { - logger.log(`Plan approval: awaiting v2.session.abort for ${sessionID}`) - const v2Result = await ctx.v2.session.abort({ sessionID, directory: ctx.directory }) - if ((v2Result as { error?: unknown })?.error) { - logger.error('Plan approval: v2.session.abort returned error', (v2Result as { error?: unknown }).error) - return false - } - logger.log(`Plan approval: v2.session.abort resolved for ${sessionID}`) + logger.log(`Plan approval: awaiting session.abort for ${sessionID}`) + await ctx.client.session.abort({ sessionID, directory: ctx.directory }) + logger.log(`Plan approval: session.abort resolved for ${sessionID}`) return true } catch (err) { - logger.error('Plan approval: v2.session.abort threw', err) + logger.error('Plan approval: session.abort threw', err) return false } } @@ -269,8 +247,7 @@ export function createToolExecuteAfterHook(ctx: ToolContext, deps: LoopToolBlock config, logger, dataDir: ctx.dataDir, - v2: ctx.v2, - legacyClient: ctx.input?.client, + client: ctx.client, plansRepo: ctx.plansRepo, loopsRepo: ctx.loopsRepo, loopHandler: ctx.loopHandler, @@ -365,8 +342,8 @@ export function createToolExecuteAfterHook(ctx: ToolContext, deps: LoopToolBlock } export function createPlanApprovalEventHook(ctx: ToolContext) { - const { v2, logger } = ctx - + const { logger } = ctx + return async (eventInput: { event: { type: string; properties?: Record } }) => { if (eventInput.event?.type !== 'session.status') return @@ -375,68 +352,42 @@ export function createPlanApprovalEventHook(ctx: ToolContext) { const sessionID = eventInput.event.properties?.sessionID as string if (!sessionID) return - + const pending = pendingExecutions.get(sessionID) if (!pending) return - + pendingExecutions.delete(sessionID) - + const planRef = pending.planText ? `\n\nImplementation Plan:\n${pending.planText}` : '\n\nPlan reference: Execute the implementation plan from this conversation. Review all phases above and implement each one.' - + const executeHerePrompt = `The architect agent has created an implementation plan. 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.${planRef}` - - const legacyClient = ctx.input?.client - - // Try legacy client first (in-process fetch, always reliable) - if (legacyClient) { - try { - logger.log(`createPlanApprovalEventHook: trying legacy promptAsync for ${sessionID}`) - const { result, usedModel } = await retryWithModelFallback( - () => legacyClient.session.promptAsync({ - path: { id: sessionID }, - query: { directory: pending.directory }, - body: { - agent: 'code', - parts: [{ type: 'text' as const, text: executeHerePrompt }], - ...(pending.executionModel ? { model: pending.executionModel } : {}), - }, - } as Parameters[0]) as unknown as Promise<{ data?: unknown; error?: unknown }>, - () => legacyClient.session.promptAsync({ - path: { id: sessionID }, - query: { directory: pending.directory }, - body: { - agent: 'code', - parts: [{ type: 'text' as const, text: executeHerePrompt }], - }, - } as Parameters[0]) as unknown as Promise<{ data?: unknown; error?: unknown }>, - pending.executionModel, - logger, - ) - if (!(result as { error?: unknown })?.error) { - const modelInfo = usedModel ? `${usedModel.providerID}/${usedModel.modelID}` : 'default' - logger.log(`Plan approval: switched to code agent via legacy client (model: ${modelInfo})`) - return - } - logger.error('createPlanApprovalEventHook: legacy promptAsync returned error', (result as { error?: unknown }).error) - } catch (err) { - logger.error('createPlanApprovalEventHook: legacy promptAsync threw', err) - } - } - // Fallback to v2 + // Wraps port-style call (throws on error) into envelope pattern for retryWithModelFallback + const promptAsyncEnvelope = (params: { + sessionID: string + directory: string + agent: string + parts: Array<{ type: 'text'; text: string }> + model?: { providerID: string; modelID: string } + }): Promise<{ data?: unknown; error?: unknown }> => + ctx.client.session.promptAsync(params).then( + () => ({ data: {} as unknown }), + (err) => ({ data: undefined, error: err }), + ) + try { - logger.log(`createPlanApprovalEventHook: falling back to v2 promptAsync for ${sessionID}`) + logger.log(`createPlanApprovalEventHook: prompting session ${sessionID}`) const { result, usedModel } = await retryWithModelFallback( - () => v2.session.promptAsync({ + () => promptAsyncEnvelope({ sessionID, directory: pending.directory, agent: 'code', parts: [{ type: 'text' as const, text: executeHerePrompt }], ...(pending.executionModel ? { model: pending.executionModel } : {}), }), - () => v2.session.promptAsync({ + () => promptAsyncEnvelope({ sessionID, directory: pending.directory, agent: 'code', @@ -446,13 +397,13 @@ export function createPlanApprovalEventHook(ctx: ToolContext) { logger, ) if ((result as { error?: unknown })?.error) { - logger.error('Plan approval: v2 promptAsync returned error', (result as { error?: unknown }).error) + logger.error('Plan approval: promptAsync returned error', (result as { error?: unknown }).error) return } const modelInfo = usedModel ? `${usedModel.providerID}/${usedModel.modelID}` : 'default' - logger.log(`Plan approval: switched to code agent via v2 client (model: ${modelInfo})`) + logger.log(`Plan approval: switched to code agent (model: ${modelInfo})`) } catch (err) { - logger.error('createPlanApprovalEventHook: v2 promptAsync threw', err) + logger.error('createPlanApprovalEventHook: promptAsync threw', err) } } } diff --git a/src/hooks/plan-capture.ts b/src/hooks/plan-capture.ts index 3a24ae2e16..a90544fb03 100644 --- a/src/hooks/plan-capture.ts +++ b/src/hooks/plan-capture.ts @@ -26,7 +26,7 @@ function isMessageUpdatedEvent(event: PlanCaptureEvent): event is MessageUpdated } export function createPlanCaptureEventHook(ctx: ToolContext) { - const { v2, input: { client }, logger, plansRepo, projectId, directory } = ctx + const { client, logger, plansRepo, projectId, directory } = ctx function logCaptureError(sessionID: string, error: unknown) { logger.error(`plan-capture: hook failed for session ${sessionID}`, error as Error) @@ -66,7 +66,6 @@ export function createPlanCaptureEventHook(ctx: ToolContext) { try { const result = await captureLatestPlanForSession( { - v2, client, plansRepo, projectId, diff --git a/src/hooks/watchdog.ts b/src/hooks/watchdog.ts index cf5f10a597..85f2960c70 100644 --- a/src/hooks/watchdog.ts +++ b/src/hooks/watchdog.ts @@ -1,6 +1,6 @@ -import type { OpencodeClient } from '@opencode-ai/sdk/v2' import type { LoopService, LoopState, TerminationReason } from '../loop' import type { Logger } from '../types' +import type { ForgeClient } from '../client/port' export type LoopWatchdogStallReason = | 'non_busy_status' @@ -52,13 +52,14 @@ function formatError(err: unknown): string { export function createLoopWatchdog(input: { loopService: Pick - v2Client: OpencodeClient + client: ForgeClient logger: Logger recover(loopName: string, state: LoopState, context: LoopWatchdogRecoveryContext): Promise terminate(loopName: string, state: LoopState, reason: TerminationReason): Promise statusRetryAttempts?: number statusRetryBackoffMs?: number }): LoopWatchdog { + const { client } = input const lastActivityTime = new Map() const stallWatchdogs = new Map() const consecutiveStalls = new Map() @@ -89,8 +90,8 @@ export function createLoopWatchdog(input: { let lastErr: unknown = null for (let i = 0; i < attempts; i++) { try { - const r = await input.v2Client.session.status({ directory }) - return { ok: true, data: (r.data ?? {}) as Record } + const data = await client.session.status({ directory }) + return { ok: true, data: (data ?? {}) as Record } } catch (err) { lastErr = err if (i < attempts - 1) { diff --git a/src/index.ts b/src/index.ts index 0ff9b48ffb..1106302acb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ import type { Plugin, PluginInput, Hooks } from '@opencode-ai/plugin' import { join } from 'path' -import { createOpencodeClient as createV2Client } from '@opencode-ai/sdk/v2' +import type { ForgeClient, SessionGetParams } from './client/port' import { buildAgents } from './agents' import { createConfigHandler } from './config' import { createSessionHooks, createLoopEventHandler } from './hooks' @@ -17,6 +17,7 @@ import { createTools } from './tools' import { createToolExecuteBeforeHook, createToolExecuteAfterHook, createPlanApprovalEventHook } from './hooks' import { createSandboxToolBeforeHook, createSandboxToolAfterHook } from './hooks/sandbox-tools' import type { ToolContext } from './tools' +import { createForgeClientFromPluginInput } from './client/sdk-adapter' import { LRUCache } from './utils/lru-cache' import { createSessionLoopResolver } from './services/session-loop-resolver' @@ -26,7 +27,7 @@ import { createLoopPermissionRejectHook } from './hooks/loop-permission' export interface CreateParentSessionLookupOptions { - v2: ReturnType + client: ForgeClient directory: string loop: import('./loop').Loop logger: ReturnType @@ -36,7 +37,7 @@ export interface CreateParentSessionLookupOptions { const PARENT_LOOKUP_NEGATIVE_TTL_MS = 15000 export function createParentSessionLookup({ - v2, + client, directory, loop, logger, @@ -56,9 +57,7 @@ export function createParentSessionLookup({ negativeCache.delete(sessionId) } - type SessionGetInput = Parameters[0] - - const attempts: Array<{ label: string; directory?: string; input: SessionGetInput }> = [] + const attempts: Array<{ label: string; directory?: string; input: Record }> = [] const seenDirectories = new Set() const activeLoops = loop.listActive() @@ -70,12 +69,12 @@ export function createParentSessionLookup({ attempts.push({ label: `loop:${state.loopName}`, directory: state.worktreeDir, - input: { sessionID: sessionId, directory: state.worktreeDir, ...workspaceParam } as SessionGetInput, + input: { sessionID: sessionId, directory: state.worktreeDir, ...workspaceParam }, }) if (state.workspaceId) { attempts.push({ label: `loop-ws:${state.loopName}`, - input: { sessionID: sessionId, workspace: state.workspaceId } as SessionGetInput, + input: { sessionID: sessionId, workspace: state.workspaceId }, }) } } @@ -84,7 +83,7 @@ export function createParentSessionLookup({ attempts.push({ label: 'host', directory, - input: { sessionID: sessionId, directory } as SessionGetInput, + input: { sessionID: sessionId, directory }, }) } @@ -92,9 +91,9 @@ export function createParentSessionLookup({ for (const attempt of attempts) { try { - const result = await v2.session.get(attempt.input) - if (result.data) { - const parentId = result.data.parentID ?? null + const session = await client.session.get(attempt.input as SessionGetParams) + if (session) { + const parentId = session.parentID ?? null cache.set(sessionId, parentId) return parentId } @@ -113,13 +112,13 @@ export function createParentSessionLookup({ } export interface CreateSessionDirectoryLookupOptions { - v2: ReturnType + client: ForgeClient directory: string loop: import('./loop').Loop } export function createSessionDirectoryLookup({ - v2, + client, directory, loop, }: CreateSessionDirectoryLookupOptions): (sessionId: string) => Promise { @@ -130,9 +129,7 @@ export function createSessionDirectoryLookup({ return cache.get(sessionId) ?? null } - type SessionGetInput = Parameters[0] - - const attempts: Array<{ label: string; directory?: string; input: SessionGetInput }> = [] + const attempts: Array<{ label: string; directory?: string; input: Record }> = [] const seenDirectories = new Set() const activeLoops = loop.listActive() @@ -144,12 +141,12 @@ export function createSessionDirectoryLookup({ attempts.push({ label: `loop:${state.loopName}`, directory: state.worktreeDir, - input: { sessionID: sessionId, directory: state.worktreeDir, ...workspaceParam } as SessionGetInput, + input: { sessionID: sessionId, directory: state.worktreeDir, ...workspaceParam }, }) if (state.workspaceId) { attempts.push({ label: `loop-ws:${state.loopName}`, - input: { sessionID: sessionId, workspace: state.workspaceId } as SessionGetInput, + input: { sessionID: sessionId, workspace: state.workspaceId }, }) } } @@ -158,16 +155,16 @@ export function createSessionDirectoryLookup({ attempts.push({ label: 'host', directory, - input: { sessionID: sessionId, directory } as SessionGetInput, + input: { sessionID: sessionId, directory }, }) } for (const attempt of attempts) { try { - const result = await v2.session.get(attempt.input) - if (result.data?.directory) { - cache.set(sessionId, result.data.directory) - return result.data.directory + const session = await client.session.get(attempt.input as SessionGetParams) + if (session && session.directory) { + cache.set(sessionId, session.directory) + return session.directory } } catch { // fall through to next attempt @@ -187,26 +184,9 @@ export function createSessionDirectoryLookup({ */ export function createForgePlugin(config: PluginConfig): Plugin { return async (input: PluginInput): Promise => { - const { directory, project, client } = input + const { directory, project } = input const projectId = project.id - const serverUrl = input.serverUrl - - // Extract legacy fetch and auth headers from the plugin-provided client so - // the v2 client can dispatch in-process AND satisfy the server's Basic auth - // requirement (introduced for keychain-backed auth in TUI/server). - const legacyHttp = (client as unknown as { _client?: { getConfig: () => { fetch?: typeof fetch; headers?: Headers } } })._client - const legacyConfig = legacyHttp?.getConfig?.() - const legacyFetch = legacyConfig?.fetch - const legacyAuthHeader = legacyConfig?.headers?.get?.('authorization') ?? legacyConfig?.headers?.get?.('Authorization') - const v2ClientConfig: Parameters[0] = { - baseUrl: serverUrl.toString(), - directory, - ...(legacyFetch ? { fetch: legacyFetch } : {}), - ...(legacyAuthHeader ? { headers: { Authorization: legacyAuthHeader } } : {}), - } - const v2 = createV2Client(v2ClientConfig) - const loggingConfig = config.logging const logger = createLogger({ enabled: loggingConfig?.enabled ?? false, @@ -214,7 +194,8 @@ export function createForgePlugin(config: PluginConfig): Plugin { debug: loggingConfig?.debug ?? false, }) logger.log(`Initializing plugin for directory: ${directory}, projectId: ${projectId}`) - logger.log(`v2 client fetch: ${legacyFetch ? 'in-process' : 'globalThis'}; auth: ${legacyAuthHeader ? 'inherited' : 'none'}`) + + const forgeClient = createForgeClientFromPluginInput(input, logger) const dataDir = config.dataDir || resolveDataDir() @@ -276,7 +257,7 @@ export function createForgePlugin(config: PluginConfig): Plugin { logger.debug(`[notifyLoopChange] reason=${reason} loop=${loopName} dirs=${targetDirectories.join(',')} projectId=${projectId}`) } - const loopHandler = createLoopEventHandler(loopsRepo, plansRepo, reviewFindingsRepo, projectId, client, v2, logger, () => config, sandboxManager || undefined, dataDir, config.loop, sectionPlansRepo, notifyLoopChange, pendingTeardowns, loopSessionUsageRepo) + const loopHandler = createLoopEventHandler(loopsRepo, plansRepo, reviewFindingsRepo, projectId, forgeClient, logger, () => config, sandboxManager || undefined, dataDir, config.loop, sectionPlansRepo, notifyLoopChange, pendingTeardowns, loopSessionUsageRepo) const agents = buildAgents() @@ -329,8 +310,7 @@ export function createForgePlugin(config: PluginConfig): Plugin { config, logger, dataDir, - v2, - legacyClient: client, + client: forgeClient, plansRepo, loopsRepo, loopHandler, @@ -342,22 +322,22 @@ export function createForgePlugin(config: PluginConfig): Plugin { pendingTeardowns, } const forgeSessionAttachHook = createForgeSessionAttachHook({ - v2, + client: forgeClient, execDeps: forgeAttachExecDeps, projectId, directory, logger, }) const forgeSessionMessageAttachHook = createForgeSessionMessageAttachHook({ - v2, + client: forgeClient, execDeps: forgeAttachExecDeps, projectId, directory, logger, }) - const parentSessionLookup = createParentSessionLookup({ v2, directory, loop: loopHandler.loop, logger }) - const sessionDirectoryLookup = createSessionDirectoryLookup({ v2, directory, loop: loopHandler.loop }) + const parentSessionLookup = createParentSessionLookup({ client: forgeClient, directory, loop: loopHandler.loop, logger }) + const sessionDirectoryLookup = createSessionDirectoryLookup({ client: forgeClient, directory, loop: loopHandler.loop }) const sessionLoopResolver = createSessionLoopResolver({ loop: loopHandler.loop, getParentSessionId: parentSessionLookup, @@ -365,7 +345,7 @@ export function createForgePlugin(config: PluginConfig): Plugin { logger, }) const loopPermissionRejectHook = createLoopPermissionRejectHook({ - v2, + client: forgeClient, sessionLoopResolver, directory, logger, @@ -387,9 +367,8 @@ export function createForgePlugin(config: PluginConfig): Plugin { dataDir, loopHandler, loop: loopHandler.loop, - v2, + client: forgeClient, cleanup, - input, sandboxManager, plansRepo, reviewFindingsRepo, diff --git a/src/loop/runtime.ts b/src/loop/runtime.ts index 106388ac3d..338f471d2c 100644 --- a/src/loop/runtime.ts +++ b/src/loop/runtime.ts @@ -1,5 +1,4 @@ -import type { PluginInput } from '@opencode-ai/plugin' -import type { OpencodeClient } from '@opencode-ai/sdk/v2' +import type { ForgeClient } from '../client/port' import type { LoopChangeNotifier } from './service' import { createLoopService, MAX_RETRIES } from './service' import { generateUniqueName } from './name-uniqueness' @@ -15,11 +14,11 @@ import { retryWithModelFallback } from '../utils/model-fallback' import { resolveLoopModel, resolveLoopAuditorModel } from '../utils/loop-helpers' import type { createSandboxManager } from '../sandbox/manager' // worktree-completion imports moved to hooks/loop.ts (termination side-effects) -import { buildLoopPermissionRuleset, buildAuditSessionPermissionRuleset } from '../constants/loop' +import { buildLoopPermissionRuleset } from '../constants/loop' import { createLoopSessionWithWorkspace, publishWorkspaceDetachedToast } from '../utils/loop-session' // worktree-cleanup imports moved to hooks/loop.ts (termination side-effects) import { createAuditSession, promptAuditSession } from '../utils/audit-session' -import { formatAuditSessionTitle, formatLoopSessionTitle } from '../utils/session-titles' +import { formatLoopSessionTitle } from '../utils/session-titles' import { bindSessionToWorkspace } from '../workspace/forge-worktree' import { markPromptSent, clearPromptPending, sessionsAwaitingBusy, isAwaitingBusy, isAwaitingBusyExpired } from './idle-gate' import { @@ -52,8 +51,7 @@ export interface LoopRuntimeDeps { plansRepo: PlansRepo reviewFindingsRepo: ReviewFindingsRepo projectId: string - client: PluginInput['client'] - v2Client: OpencodeClient + client: ForgeClient logger: Logger getConfig: () => PluginConfig sandboxManager?: ReturnType @@ -148,8 +146,8 @@ export function isWorkspaceNotFoundError(err: unknown): boolean { } export function createLoop(deps: LoopRuntimeDeps): Loop { - const { loopsRepo, plansRepo, reviewFindingsRepo, projectId, client, v2Client, logger, getConfig, onTerminated, notify, loopConfig, sectionPlansRepo, loopSessionUsageRepo } = deps - const loopService = createLoopService(loopsRepo, plansRepo, reviewFindingsRepo, projectId, logger, loopConfig, notify, undefined, sectionPlansRepo) + const { loopsRepo, plansRepo, reviewFindingsRepo, projectId, client, logger, getConfig, onTerminated, notify, loopConfig, sectionPlansRepo, loopSessionUsageRepo } = deps + const loopService = createLoopService(loopsRepo, plansRepo, reviewFindingsRepo, projectId, logger, loopConfig, notify, sectionPlansRepo) const retryTimeouts = new Map() const idleRetryTimeouts = new Map() @@ -204,7 +202,7 @@ export function createLoop(deps: LoopRuntimeDeps): Loop { try { return await withInFlightGuard(loopName, sessionId, 'auditor-loop', logger, async () => { markPromptSent(loopName, sessionId, logger) - const result = await promptAuditSessionWithFallback({ + const result = await promptAuditSession(client, { sessionId, worktreeDir: freshState.worktreeDir, workspaceId: freshState.workspaceId, @@ -235,17 +233,22 @@ export function createLoop(deps: LoopRuntimeDeps): Loop { 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) - return await v2Client.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 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 @@ -264,28 +267,11 @@ export function createLoop(deps: LoopRuntimeDeps): Loop { async function getLastAssistantInfo(sessionId: string, worktreeDir: string): Promise<{ text: string | null; error: string | null; lastMessageRole: string }> { try { - let messagesResult = await v2Client.session.messages({ + const messages = await client.session.messages({ sessionID: sessionId, directory: worktreeDir, limit: 4, - }) - - if (messagesResult.error || !messagesResult.data?.length) { - try { - logger.log(`Loop: falling back to plugin client for session messages (${sessionId})`) - const legacyResult = await client.session.messages({ - path: { id: sessionId }, - query: { directory: worktreeDir, limit: 4 }, - }) - if (!legacyResult.error) { - messagesResult = legacyResult as typeof messagesResult - } - } catch (fallbackErr) { - logger.error(`Loop: plugin client session messages fallback failed for ${sessionId}`, fallbackErr) - } - } - - const messages = (messagesResult.data ?? []) as Array<{ + }) as Array<{ info: { role: string; finish?: string; error?: { name?: string; data?: { message?: string } } } parts: Array<{ type: string; text?: string }> }> @@ -359,12 +345,10 @@ export function createLoop(deps: LoopRuntimeDeps): Loop { } try { - const messagesResult = await v2Client.session.messages({ + const messages = await client.session.messages({ sessionID: input.sessionId, directory: input.directory, - }) - - const messages = (messagesResult.data ?? []) as Array<{ + }) as Array<{ info: { role: string cost?: number @@ -421,7 +405,7 @@ export function createLoop(deps: LoopRuntimeDeps): Loop { loopService.clearWorkspaceId(loopName) state.workspaceId = undefined publishWorkspaceDetachedToast({ - v2: v2Client, + client: client, directory: state.projectDir ?? state.worktreeDir, loopName, logger, @@ -454,7 +438,7 @@ export function createLoop(deps: LoopRuntimeDeps): Loop { return { recovered: false } } const newWorkspace = await createBuiltinWorktreeWorkspace( - v2Client, + client, { loopName, directory: projectDirectory, @@ -468,7 +452,7 @@ export function createLoop(deps: LoopRuntimeDeps): Loop { } try { - await bindSessionToWorkspace(v2Client, newWorkspace.workspaceId, sessionId, logger, { loopName }) + await bindSessionToWorkspace(client, newWorkspace.workspaceId, sessionId, logger, { loopName }) loopService.setWorkspaceId(loopName, newWorkspace.workspaceId) state.workspaceId = newWorkspace.workspaceId if (newWorkspace.directory) state.worktreeDir = newWorkspace.directory @@ -501,7 +485,7 @@ export function createLoop(deps: LoopRuntimeDeps): Loop { return {} } const workspace = await createBuiltinWorktreeWorkspace( - v2Client, + client, { loopName, directory: projectDirectory, @@ -546,7 +530,7 @@ export function createLoop(deps: LoopRuntimeDeps): Loop { const ensured = await ensureWorkspaceForLoop(loopName, state, 'during session rotation') const createResult = await createLoopSessionWithWorkspace({ - v2: v2Client, + client: client, title: formatLoopSessionTitle(state.loopName, { iteration: titleContext?.iteration ?? state.iteration ?? 0, currentSectionIndex: titleContext?.currentSectionIndex ?? state.currentSectionIndex ?? 0, @@ -725,16 +709,16 @@ export function createLoop(deps: LoopRuntimeDeps): Loop { if (!freshState?.active) throw new Error('loop_cancelled') try { await withInFlightGuard(loopName, activeSessionId, 'code', logger, async () => { - const result = await v2Client.session.promptAsync({ - sessionID: activeSessionId, - directory: freshState.worktreeDir, - ...(freshState.workspaceId ? { workspace: freshState.workspaceId } : {}), - agent: 'code', - parts: [{ type: 'text' as const, text: continuationPrompt }], - }) - if (result.error) { - await handlePromptError(loopName, currentState, `retry failed ${errorContext}`, result.error) - return + try { + await client.session.promptAsync({ + sessionID: activeSessionId, + directory: freshState.worktreeDir, + ...(freshState.workspaceId ? { workspace: freshState.workspaceId } : {}), + agent: 'code', + parts: [{ type: 'text' as const, text: continuationPrompt }], + }) + } catch (err) { + await handlePromptError(loopName, currentState, `retry failed ${errorContext}`, err) } }) } catch (err) { @@ -835,14 +819,13 @@ export function createLoop(deps: LoopRuntimeDeps): Loop { if (!fresh?.active || fresh.phase !== 'coding' || fresh.sessionId !== codeSessionId) throw new Error('loop_cancelled') try { await withInFlightGuard(loopName, codeSessionId, 'code', logger, async () => { - const result = await v2Client.session.promptAsync({ + await client.session.promptAsync({ sessionID: codeSessionId, directory: fresh.worktreeDir, ...(fresh.workspaceId ? { workspace: fresh.workspaceId } : {}), agent: 'code', parts: [{ type: 'text' as const, text: recoveryPrompt }], }) - if (result.error) throw result.error }) } catch (err) { if (err instanceof ConcurrentPromptError) { logger.log('Loop: failed to recover code launch — retry rejected as concurrent prompt (prior guard active), skipping'); return } @@ -892,7 +875,7 @@ export function createLoop(deps: LoopRuntimeDeps): Loop { fallbackModel: oldest.fallbackModel, }) - void v2Client.session.delete({ sessionID: oldest.sessionId, directory: oldest.directory }).catch((err: unknown) => { + void client.session.delete({ sessionID: oldest.sessionId, directory: oldest.directory }).catch((err: unknown) => { logger.error(`Loop: failed to delete trimmed session ${oldest.sessionId} (loop=${loopName})`, err) }) } @@ -934,7 +917,7 @@ export function createLoop(deps: LoopRuntimeDeps): Loop { }).catch((err: unknown) => { logger.error(`Loop: failed to capture usage for retained session ${entry.sessionId} on terminate (loop=${loopName})`, err) }) - void v2Client.session.delete({ sessionID: entry.sessionId, directory: entry.directory }).catch((err: unknown) => { + void client.session.delete({ sessionID: entry.sessionId, directory: entry.directory }).catch((err: unknown) => { logger.error(`Loop: failed to delete retained session ${entry.sessionId} on terminate (loop=${loopName})`, err) }) } @@ -960,7 +943,7 @@ export function createLoop(deps: LoopRuntimeDeps): Loop { }) try { - await v2Client.session.abort({ sessionID: sessionId }) + await client.session.abort({ sessionID: sessionId }) } catch { // Session may already be idle } @@ -1015,93 +998,9 @@ export function createLoop(deps: LoopRuntimeDeps): Loop { } } - async function createAuditSessionWithFallback(input: { - loopName: string - iteration: number - currentSectionIndex: number - totalSections: number - worktreeDir: string - workspaceId?: string - isSandbox: boolean - auditorModel?: { providerID: string; modelID: string } - prompt: string - }): Promise<{ auditSessionId: string; boundWorkspaceId?: string; bindFailed: boolean; bindError?: unknown } | null> { - const created = await createAuditSession({ - v2: v2Client, - loopName: input.loopName, - iteration: input.iteration, - currentSectionIndex: input.currentSectionIndex, - totalSections: input.totalSections, - worktreeDir: input.worktreeDir, - workspaceId: input.workspaceId, - isSandbox: input.isSandbox, - auditorModel: input.auditorModel, - prompt: input.prompt, - logger, - }) - if (created) { - return { - auditSessionId: created.auditSessionId, - boundWorkspaceId: created.boundWorkspaceId, - bindFailed: created.bindFailed, - bindError: created.bindError, - } - } - try { - logger.log(`Loop: falling back to plugin client for audit session creation (${input.loopName})`) - const result = await client.session.create({ - body: { - title: formatAuditSessionTitle(input.loopName, { - iteration: input.iteration, - currentSectionIndex: input.currentSectionIndex, - totalSections: input.totalSections, - }), - permission: buildAuditSessionPermissionRuleset({ sandbox: input.isSandbox }), - ...(input.workspaceId ? { workspaceID: input.workspaceId } : {}), - }, - query: { - directory: input.worktreeDir, - ...(input.workspaceId ? { workspace: input.workspaceId } : {}), - }, - } as Parameters[0]) - const session = result.data as { id?: string } | undefined - if (!session?.id) return null - return { auditSessionId: session.id, bindFailed: false } - } catch (err) { - logger.error(`Loop: plugin client audit session creation failed`, err) - return null - } - } - async function promptAuditSessionWithFallback(input: { - sessionId: string - worktreeDir: string - workspaceId?: string - prompt: string - auditorModel?: { providerID: string; modelID: string } - auditorVariant?: string - }): Promise<{ ok: true } | { ok: false; error: unknown }> { - const result = await promptAuditSession(v2Client, input) - if (result.ok) return result - try { - logger.log(`Loop: falling back to plugin client for audit prompt (${input.sessionId})`) - const legacyResult = await client.session.promptAsync({ - path: { id: input.sessionId }, - query: { directory: input.worktreeDir, ...(input.workspaceId ? { workspace: input.workspaceId } : {}) }, - body: { - agent: 'auditor-loop', - parts: [{ type: 'text' as const, text: input.prompt }], - ...(input.auditorModel ? { model: input.auditorModel, ...(input.auditorVariant ? { variant: input.auditorVariant } : {}) } : {}), - }, - }) - if (legacyResult.error) return { ok: false, error: legacyResult.error } - return { ok: true } - } catch (err) { - return { ok: false, error: err } - } - } async function recoverWatchdogStall( loopName: string, @@ -1128,7 +1027,7 @@ export function createLoop(deps: LoopRuntimeDeps): Loop { const watchdog = createLoopWatchdog({ loopService, - v2Client, + client, logger, recover: recoverWatchdogStall, terminate: terminateLoop, @@ -1140,7 +1039,8 @@ export function createLoop(deps: LoopRuntimeDeps): Loop { const auditorModel = resolveLoopAuditorModel(getConfig(), loopService, loopName, logger) const ensured = await ensureWorkspaceForLoop(loopName, currentState, 'before final audit creation') - const created = await createAuditSessionWithFallback({ + const created = await createAuditSession({ + client, loopName, iteration: currentState.iteration ?? 0, currentSectionIndex: currentState.currentSectionIndex ?? 0, @@ -1150,6 +1050,7 @@ export function createLoop(deps: LoopRuntimeDeps): Loop { isSandbox: currentState.sandbox ?? false, auditorModel, prompt: finalAuditPrompt, + logger, }) if (!created) { logger.error(`Loop: final audit session creation failed for ${loopName}`) @@ -1281,7 +1182,7 @@ export function createLoop(deps: LoopRuntimeDeps): Loop { prompt: string }, attempts = MAX_RETRIES): Promise<{ auditSessionId: string; boundWorkspaceId?: string; bindFailed: boolean; bindError?: unknown } | null> { for (let i = 0; i < attempts; i++) { - const created = await createAuditSessionWithFallback(input) + const created = await createAuditSession({ client, ...input, logger }) if (created) return created loopService.incrementError(loopName) const state = loopService.getActiveState(loopName) @@ -1367,7 +1268,7 @@ export function createLoop(deps: LoopRuntimeDeps): Loop { currentState = loopService.getActiveState(loopName) ?? currentState if (recovered.recovered || !currentState.workspaceId) { const auditPromptText = loopService.buildAuditPrompt(currentState) - const retryResult = await promptAuditSessionWithFallback({ + const retryResult = await promptAuditSession(client, { sessionId: created.auditSessionId, worktreeDir: currentState.worktreeDir, workspaceId: currentState.workspaceId, @@ -1388,7 +1289,7 @@ export function createLoop(deps: LoopRuntimeDeps): Loop { const auditPromptText = loopService.buildAuditPrompt(fresh) try { await withInFlightGuard(loopName, created.auditSessionId, 'auditor-loop', logger, async () => { - const retryResult = await promptAuditSessionWithFallback({ + const retryResult = await promptAuditSession(client, { sessionId: created.auditSessionId, worktreeDir: fresh.worktreeDir, workspaceId: fresh.workspaceId, @@ -2001,7 +1902,7 @@ export function createLoop(deps: LoopRuntimeDeps): Loop { }).catch((err: unknown) => { logger.error(`Loop: failed to capture usage for retained session ${entry.sessionId} on clear (loop=${loopName})`, err) }) - void v2Client.session.delete({ sessionID: entry.sessionId, directory: entry.directory }).catch(() => {}) + void client.session.delete({ sessionID: entry.sessionId, directory: entry.directory }).catch(() => {}) } loopRetainedSessions.delete(loopName) } diff --git a/src/loop/service.ts b/src/loop/service.ts index 1a517e1316..f090fe4e89 100644 --- a/src/loop/service.ts +++ b/src/loop/service.ts @@ -1,5 +1,4 @@ import type { Logger, LoopConfig } from '../types' -import type { OpencodeClient } from '@opencode-ai/sdk/v2' import type { LoopsRepo, LoopRow, LoopLargeFields } from '../storage/repos/loops-repo' import type { PlansRepo } from '../storage/repos/plans-repo' import type { ReviewFindingsRepo, ReviewFindingRow } from '../storage/repos/review-findings-repo' @@ -130,7 +129,6 @@ export function createLoopService( logger: Logger, loopConfig?: LoopConfig, notify?: LoopChangeNotifier, - _v2Client?: OpencodeClient, sectionPlansRepo?: SectionPlansRepo, ): LoopService { const notifyLoopChange: LoopChangeNotifier = notify ?? (() => {}) diff --git a/src/loop/session-output.ts b/src/loop/session-output.ts index ff43364cfc..fdb86b95a4 100644 --- a/src/loop/session-output.ts +++ b/src/loop/session-output.ts @@ -1,4 +1,4 @@ -import type { OpencodeClient } from '@opencode-ai/sdk/v2' +import type { ForgeClient } from '../client/port' import type { Logger } from '../types' import { summarizeAssistantUsage, type LoopUsageSummary, type UsageAttribution } from './token-usage' @@ -18,7 +18,7 @@ export interface FetchSessionOutputOptions { } export async function fetchSessionOutput( - v2Client: OpencodeClient, + client: ForgeClient, sessionId: string, directory: string, logger?: Logger, @@ -30,12 +30,10 @@ export async function fetchSessionOutput( } try { - const messagesResult = await v2Client.session.messages({ + const messages = (await client.session.messages({ sessionID: sessionId, directory, - }) - - const messages = (messagesResult.data ?? []) as { + })) as unknown as { info: { role: string cost?: number @@ -79,8 +77,7 @@ export async function fetchSessionOutput( : undefined const usageSummary = summarizeAssistantUsage(messages, attribution) - const sessionResult = await v2Client.session.get({ sessionID: sessionId, directory }) - const session = sessionResult.data as { summary?: { additions: number; deletions: number; files: number } } | undefined + const session = await client.session.get({ sessionID: sessionId, directory }) as unknown as { summary?: { additions: number; deletions: number; files: number } } | undefined const fileChanges = session?.summary ? { additions: session.summary.additions, diff --git a/src/services/execution.ts b/src/services/execution.ts index 86c0282c05..0c1e4a471a 100644 --- a/src/services/execution.ts +++ b/src/services/execution.ts @@ -6,7 +6,9 @@ */ import type { PluginConfig, Logger } from '../types' -import type { OpencodeClient } from '@opencode-ai/sdk/v2' +import type { ForgeClient } from '../client/port' +import { ForgeClientError } from '../client/port' + import type { PlansRepo } from '../storage/repos/plans-repo' import type { LoopsRepo } from '../storage/repos/loops-repo' import type { createLoopEventHandler } from '../hooks' @@ -391,8 +393,7 @@ export interface ForgeExecutionServiceDeps { config: PluginConfig logger: Logger | Console dataDir: string - v2: OpencodeClient - legacyClient?: import('@opencode-ai/sdk').OpencodeClient + client: ForgeClient plansRepo: PlansRepo loopsRepo: LoopsRepo loopHandler?: ReturnType @@ -474,240 +475,53 @@ async function resolvePlanSource( } // ============================================================================ -// Fallback Helpers for Legacy Plugin SDK +// Port-based helpers // ============================================================================ -interface SessionCreateInput { - title: string - directory: string - permission?: ReturnType -} - -interface SessionCreateResult { - data?: { id: string } - error?: unknown -} - -interface SessionPromptInput { - sessionID: string - directory: string - parts: Array<{ type: 'text'; text: string }> - agent: string - model?: { providerID: string; modelID: string } - workspace?: string -} - -interface SessionPromptResult { - data?: unknown - error?: unknown -} - -async function createSessionWithFallback( - deps: ForgeExecutionServiceDeps, - input: SessionCreateInput, -): Promise { - // Try v2 SDK first - try { - const result = await deps.v2.session.create({ - title: input.title, - directory: input.directory, - ...(input.permission ? { permission: input.permission } : {}), - }) - - if (result.data) { - return { data: result.data } - } - - if (result.error) { - const errorMsg = result.error instanceof Error ? result.error.message : String(result.error) - if (errorMsg.includes('Unable to connect')) { - deps.logger.log('createSessionWithFallback: v2 SDK unavailable, falling back to legacy SDK') - } else { - deps.logger.error('createSessionWithFallback: v2 SDK error', result.error) - } - } - } catch (err) { - const errorMsg = err instanceof Error ? err.message : String(err) - if (errorMsg.includes('Unable to connect')) { - deps.logger.log('createSessionWithFallback: v2 SDK threw connection error, falling back to legacy SDK') - } else { - deps.logger.error('createSessionWithFallback: v2 SDK threw error', err) - } - } - - // Fallback to legacy SDK - if (!deps.legacyClient) { - deps.logger.error('createSessionWithFallback: no legacy SDK available') - return { error: new Error('No legacy SDK available') } - } - - try { - const result = await deps.legacyClient.session.create({ - body: { - title: input.title, - ...(input.permission ? { permission: input.permission } : {}), - }, - query: { - directory: input.directory, - }, - } as Parameters[0]) - - const session = result.data as { id?: string } | undefined - if (session?.id) { - return { data: { id: session.id } } - } - - return { error: new Error('Legacy SDK returned no session ID') } - } catch (err) { - deps.logger.error('createSessionWithFallback: legacy SDK failed', err) - return { error: err } - } -} - -async function promptSessionWithFallback( - deps: ForgeExecutionServiceDeps, - input: SessionPromptInput, - model?: { providerID: string; modelID: string }, -): Promise<{ result: SessionPromptResult; usedModel?: typeof model }> { - // Try v2 SDK first - try { - const result = await deps.v2.session.promptAsync({ - sessionID: input.sessionID, - directory: input.directory, - parts: input.parts, - agent: input.agent, - ...(model ? { model } : {}), - ...(input.workspace ? { workspace: input.workspace } : {}), - }) - - if (!result.error) { - return { result: { data: result.data }, usedModel: model } - } - - const errorMsg = result.error instanceof Error ? result.error.message : String(result.error) - if (errorMsg.includes('Unable to connect')) { - deps.logger.log('promptSessionWithFallback: v2 SDK unavailable, falling back to legacy SDK') - } else { - deps.logger.error('promptSessionWithFallback: v2 SDK error', result.error) - } - } catch (err) { - const errorMsg = err instanceof Error ? err.message : String(err) - if (errorMsg.includes('Unable to connect')) { - deps.logger.log('promptSessionWithFallback: v2 SDK threw connection error, falling back to legacy SDK') - } else { - deps.logger.error('promptSessionWithFallback: v2 SDK threw error', err) - } - } - - // Fallback to legacy SDK - if (!deps.legacyClient) { - deps.logger.error('promptSessionWithFallback: no legacy SDK available') - return { result: { error: new Error('No legacy SDK available') }, usedModel: model } - } - - try { - const legacyResult = await deps.legacyClient.session.promptAsync({ - path: { id: input.sessionID }, - query: { - directory: input.directory, - ...(input.workspace ? { workspace: input.workspace } : {}), - }, - body: { - agent: input.agent, - parts: input.parts, - ...(model ? { model } : {}), - }, - } as Parameters[0]) - - // Legacy SDK returns { data, request, response } - const legacyData = legacyResult as { data?: unknown } - if (!legacyData.data) { - return { result: { error: new Error('Legacy SDK returned no data') }, usedModel: model } - } - - return { result: { data: legacyData.data }, usedModel: model } - } catch (err) { - deps.logger.error('promptSessionWithFallback: legacy SDK failed', err) - return { result: { error: err }, usedModel: model } - } -} - -async function selectSessionWithFallback( - deps: ForgeExecutionServiceDeps, +/** + * Best-effort TUI navigation: try `selectSession` (with retries), fall back to + * `tui.publish` with a `tui.session.select` event. There is no legacy/fallback + * path — the port client is the only client. + */ +async function selectSessionBestEffort( + client: ForgeClient, + directory: string, + logger: Logger | Console, selection: { sessionID: string; workspace?: string }, ): Promise { const maxAttempts = 3 const backoffMs = 250 - async function attemptSelectSession(attempt: number): Promise<{ ok: boolean; retryable: boolean }> { + for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { - await deps.v2.tui!.selectSession({ + await client.tui.selectSession({ sessionID: selection.sessionID, ...(selection.workspace ? { workspace: selection.workspace } : {}), }) - deps.logger.log(`[warp] select.v2.selectSession ok attempt=${attempt}`) - return { ok: true, retryable: false } + logger.log(`[warp] select.session ok attempt=${attempt}`) + return } catch (err) { const errorMsg = err instanceof Error ? err.message : String(err) - deps.logger.log(`[warp] select.v2.selectSession failed attempt=${attempt} error="${errorMsg}"`) - const retryable = errorMsg.includes('Unable to connect') - if (retryable) { - deps.logger.log('selectSessionWithFallback: v2 TUI unavailable, will retry then fall back to publish') + logger.log(`[warp] select.session failed attempt=${attempt} error="${errorMsg}"`) + if (err instanceof ForgeClientError && err.kind === 'connection') { + logger.log(`selectSessionBestEffort: TUI connection error, will retry then fall back to publish`) + if (attempt < maxAttempts) { + await new Promise(resolve => setTimeout(resolve, backoffMs)) + } + } else if (err instanceof ForgeClientError && err.kind === 'unavailable') { + logger.log(`selectSessionBestEffort: TUI unavailable, skipping retry and falling back to publish`) + break // Exit loop immediately to reach publish fallback } else { - deps.logger.error('selectSessionWithFallback: v2 TUI error', err) + logger.error('selectSessionBestEffort: TUI error', err) + break } - return { ok: false, retryable } } } - if (deps.v2.tui) { - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - const result = await attemptSelectSession(attempt) - if (result.ok) return - if (!result.retryable) break - if (attempt < maxAttempts) { - await new Promise(resolve => setTimeout(resolve, backoffMs)) - } - } - } else { - deps.logger.log('[warp] select.v2.selectSession skipped reason=no-v2-tui') - } - + // Fall back to publish-based TUI navigation try { - if (!deps.v2.tui) { - deps.logger.log('[warp] select.v2.publish skipped reason=no-v2-tui') - } else { - await deps.v2.tui.publish({ - directory: deps.directory, - body: { - type: 'tui.session.select', - properties: { - sessionID: selection.sessionID, - ...(selection.workspace ? { workspace: selection.workspace } : {}), - }, - }, - }) - deps.logger.log('[warp] select.v2.publish ok') - return - } - } catch (err) { - const errorMsg = err instanceof Error ? err.message : String(err) - deps.logger.log(`[warp] select.v2.publish failed error="${errorMsg}"`) - if (errorMsg.includes('Unable to connect')) { - deps.logger.log('selectSessionWithFallback: v2 TUI publish unavailable, falling back to legacy SDK') - } else { - deps.logger.error('selectSessionWithFallback: v2 TUI publish error', err) - } - } - - if (!deps.legacyClient?.tui) { - deps.logger.log('[warp] select.legacy.publish skipped reason=no-legacy-tui') - deps.logger.error('selectSessionWithFallback: no legacy TUI available') - return - } - - try { - await deps.legacyClient.tui.publish({ + await client.tui.publish({ + directory, body: { type: 'tui.session.select', properties: { @@ -715,12 +529,16 @@ async function selectSessionWithFallback( ...(selection.workspace ? { workspace: selection.workspace } : {}), }, }, - } as unknown as Parameters[0]) - deps.logger.log('[warp] select.legacy.publish ok') + }) + logger.log('[warp] select.publish ok') } catch (err) { const errorMsg = err instanceof Error ? err.message : String(err) - deps.logger.log(`[warp] select.legacy.publish failed error="${errorMsg}"`) - deps.logger.error('selectSessionWithFallback: legacy TUI failed', err) + logger.log(`[warp] select.publish failed error="${errorMsg}"`) + if (err instanceof ForgeClientError && err.kind === 'unavailable') { + logger.log('selectSessionBestEffort: TUI publish unavailable') + } else { + logger.error('selectSessionBestEffort: TUI publish error', err) + } } } @@ -850,7 +668,7 @@ export async function attachLoopToSession( // real project directory from the host session that launched the loop, and // only fall back to ctx.directory when that lookup is unavailable. const resolvedProjectDir = - (await resolveHostSessionDirectory(deps.v2, input.hostSessionId, ctx.directory, deps.logger)) ?? ctx.directory + (await resolveHostSessionDirectory(deps.client, input.hostSessionId, ctx.directory, deps.logger)) ?? ctx.directory try { // Persist loop state @@ -953,7 +771,7 @@ export async function attachLoopToSession( ? { workspace: workspaceId, sessionID: sessionId } : { sessionID: sessionId } - selectSessionWithFallback(deps, selection).catch((err: unknown) => { + selectSessionBestEffort(deps.client, deps.directory, deps.logger, selection).catch((err: unknown) => { deps.logger.error('attachLoopToSession: failed to navigate TUI (early)', err as Error) }) } @@ -971,56 +789,34 @@ export async function attachLoopToSession( const promptParts = [{ type: 'text' as const, text: promptText }] const workspaceParam = workspaceId ? { workspace: workspaceId } : {} - let promptResult: { result: SessionPromptResult; usedModel?: typeof loopModel } - - if (loopModel) { - promptResult = await retryWithModelFallback( - async () => { - markPromptSent(loopName, sessionId, deps.logger) - const { result } = await promptSessionWithFallback( - deps, - { - sessionID: sessionId, - directory: sessionDir, - parts: promptParts, - agent: 'code', - ...workspaceParam, - }, - loopModel, - ) - return result - }, - async () => { - markPromptSent(loopName, sessionId, deps.logger) - const { result } = await promptSessionWithFallback( - deps, - { - sessionID: sessionId, - directory: sessionDir, - parts: promptParts, - agent: 'code', - ...workspaceParam, - }, - undefined, - ) - return result - }, - loopModel, - deps.logger as unknown as Console, - ) - } else { + async function sendPromptCall(model?: { providerID: string; modelID: string }): Promise<{ error?: unknown }> { markPromptSent(loopName, sessionId, deps.logger) - promptResult = await promptSessionWithFallback( - deps, - { + 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 } } if (promptResult.result.error) { @@ -1041,14 +837,14 @@ export async function attachLoopToSession( ? { workspace: workspaceId, sessionID: sessionId } : { sessionID: sessionId } - selectSessionWithFallback(deps, selection).catch((err: unknown) => { + selectSessionBestEffort(deps.client, deps.directory, deps.logger, selection).catch((err: unknown) => { deps.logger.error('attachLoopToSession: failed to navigate TUI', err as Error) }) } // Abort source session if requested if (abortSourceSessionOnSuccess && ctx.sourceSessionId) { - deps.v2.session.abort({ sessionID: ctx.sourceSessionId }).catch((err: unknown) => { + deps.client.session.abort({ sessionID: ctx.sourceSessionId }).catch((err: unknown) => { deps.logger.error('attachLoopToSession: failed to abort source session', err as Error) }) } @@ -1076,6 +872,7 @@ export async function attachLoopToSession( // ============================================================================ export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): ForgeExecutionService { + const _fc: ForgeClient = deps.client const inFlightLoopStarts = new Map>>() function hashPlanForDedupe(text: string): string { @@ -1098,52 +895,54 @@ export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): Fo const executionModel = command.executionModel ?? deps.config.executionModel const parsedModel = parseModelString(executionModel) - // Create new session with fallback - const createResult = await createSessionWithFallback(deps, { - title: sessionTitle, - directory: ctx.directory, - }) - - if (!createResult.data) { - deps.logger.error('handlePlanNewSession: failed to create session', createResult.error) + // Create new session + let sessionId: string + try { + const session = await deps.client.session.create({ + title: sessionTitle, + directory: ctx.directory, + }) + sessionId = session.id + } catch (err) { + deps.logger.error('handlePlanNewSession: failed to create session', err) return fail('internal_error', 500, 'Failed to create session') } - - const sessionId = createResult.data.id deps.logger.log(`handlePlanNewSession: created session=${sessionId}`) // Navigate TUI if requested with early timing if (command.lifecycle?.selectSession && command.lifecycle.selectSessionTiming === 'after-create') { - selectSessionWithFallback(deps, { sessionID: sessionId }).catch((err: unknown) => { + selectSessionBestEffort(deps.client, deps.directory, deps.logger, { sessionID: sessionId }).catch((err: unknown) => { deps.logger.error('handlePlanNewSession: failed to navigate TUI (early)', err as Error) }) } - // Prompt code agent with fallback - const { result: promptResult, usedModel: actualModel } = await promptSessionWithFallback( - deps, - { + // Prompt code agent + let promptError: unknown = null + try { + await deps.client.session.promptAsync({ sessionID: sessionId, directory: ctx.directory, parts: [{ type: 'text' as const, text: planText }], agent: 'code', - }, - parsedModel!, - ) + model: parsedModel!, + }) + } catch (err) { + promptError = err + } - if (promptResult.error) { - deps.logger.error('handlePlanNewSession: failed to prompt session', promptResult.error) + if (promptError) { + deps.logger.error('handlePlanNewSession: failed to prompt session', promptError) // Delete created session if requested if (command.lifecycle?.deleteSessionOnPromptFailure) { - await deps.v2.session.delete({ sessionID: sessionId, directory: ctx.directory }).catch((err: unknown) => { + await deps.client.session.delete({ sessionID: sessionId, directory: ctx.directory }).catch((err: unknown) => { deps.logger.error('handlePlanNewSession: failed to delete failed session', err as Error) }) } // Return to source session if requested if (command.lifecycle?.returnToSourceOnPromptFailure && ctx.sourceSessionId) { - selectSessionWithFallback(deps, { sessionID: ctx.sourceSessionId }).catch((err: unknown) => { + selectSessionBestEffort(deps.client, deps.directory, deps.logger, { sessionID: ctx.sourceSessionId }).catch((err: unknown) => { deps.logger.error('handlePlanNewSession: failed to return to source session', err as Error) }) } @@ -1153,20 +952,20 @@ export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): Fo // Navigate TUI if requested with default/post-prompt timing if (command.lifecycle?.selectSession && command.lifecycle.selectSessionTiming !== 'after-create') { - selectSessionWithFallback(deps, { sessionID: sessionId }).catch((err: unknown) => { + selectSessionBestEffort(deps.client, deps.directory, deps.logger, { sessionID: sessionId }).catch((err: unknown) => { deps.logger.error('handlePlanNewSession: failed to navigate TUI', err as Error) }) } // Abort source session if requested if (command.lifecycle?.abortSourceSession && ctx.sourceSessionId) { - deps.v2.session.abort({ sessionID: ctx.sourceSessionId }).catch((err: unknown) => { + deps.client.session.abort({ sessionID: ctx.sourceSessionId }).catch((err: unknown) => { deps.logger.error('handlePlanNewSession: failed to abort source session', err as Error) }) } - const modelUsed = actualModel - ? `${actualModel.providerID}/${actualModel.modelID}` + const modelUsed = parsedModel + ? `${parsedModel.providerID}/${parsedModel.modelID}` : null return ok({ @@ -1198,25 +997,27 @@ export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): Fo // Build execute-here prompt const executeHerePrompt = `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: ${planText}` - // Prompt code agent in target session with fallback - const { result: promptResult, usedModel: actualModel } = await promptSessionWithFallback( - deps, - { + // Prompt code agent in target session + let promptError: unknown = null + try { + await deps.client.session.promptAsync({ sessionID: command.targetSessionId, directory: ctx.directory, parts: [{ type: 'text' as const, text: executeHerePrompt }], agent: 'code', - }, - parsedModel, - ) + ...(parsedModel ? { model: parsedModel } : {}), + }) + } catch (err) { + promptError = err + } - if (promptResult.error) { - deps.logger.error('handlePlanHere: execute-here execution failed', promptResult.error) + if (promptError) { + deps.logger.error('handlePlanHere: execute-here execution failed', promptError) return fail('prompt_failed', 502, 'Failed to execute here') } - const modelUsed = actualModel - ? `${actualModel.providerID}/${actualModel.modelID}` + const modelUsed = parsedModel + ? `${parsedModel.providerID}/${parsedModel.modelID}` : null return ok({ @@ -1289,7 +1090,7 @@ export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): Fo const rollbackLoopStart = async (): Promise => { if (createdSessionId) { - await deps.v2.session.abort({ sessionID: createdSessionId }).catch(() => {}) + await deps.client.session.abort({ sessionID: createdSessionId }).catch(() => {}) } if (loopStatePersisted) { deps.loop.deleteState(uniqueLoopName) @@ -1301,10 +1102,7 @@ export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): Fo sandboxContainer = null } if (createdWorkspaceId) { - const workspaceApi = deps.v2.experimental?.workspace - if (workspaceApi?.remove) { - await workspaceApi.remove({ id: createdWorkspaceId }).catch(() => {}) - } + await _fc.workspace.remove({ id: createdWorkspaceId }).catch(() => {}) } if (hostWorktreeDir) { const { cleanupLoopWorktree } = await import('../utils/worktree-cleanup') @@ -1329,7 +1127,7 @@ export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): Fo selectSession: command.lifecycle?.selectSession, logger: deps.logger, workspaceStatusRegistry: deps.workspaceStatusRegistry, - selectSessionFn: (sel) => selectSessionWithFallback(deps, sel), + selectSessionFn: (sel) => selectSessionBestEffort(deps.client, deps.directory, deps.logger, sel), }) } @@ -1342,7 +1140,7 @@ export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): Fo // Create builtin worktree workspace (single call — no separate worktree.create) const { createBuiltinWorktreeWorkspace } = await import('../workspace/forge-worktree') - const ws = await createBuiltinWorktreeWorkspace(deps.v2, { + const ws = await createBuiltinWorktreeWorkspace(_fc, { loopName: uniqueLoopName, directory: ctx.directory, }, deps.logger, deps.workspaceStatusRegistry) @@ -1363,7 +1161,7 @@ export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): Fo // Create single code session const createResult = await createLoopSessionWithWorkspace({ - v2: deps.v2, + client: _fc, title: sessionTitle, directory: hostWorktreeDir!, permission: permissionRuleset, @@ -1730,7 +1528,7 @@ export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): Fo if (stoppedState.active) { const latestState = deps.loop.getActiveState(stoppedState.loopName) if (latestState?.active) { - try { await deps.v2.session.abort({ sessionID: latestState.sessionId }) } catch {} + try { await deps.client.session.abort({ sessionID: latestState.sessionId }) } catch {} await deps.loopHandler!.clearLoopTimers(stoppedState.loopName) // Sync stoppedState with latest persisted values Object.assign(stoppedState, { @@ -1760,7 +1558,7 @@ export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): Fo if (stoppedState.worktree) { const { createBuiltinWorktreeWorkspace } = await import('../workspace/forge-worktree') - const ws = await createBuiltinWorktreeWorkspace(deps.v2, { + const ws = await createBuiltinWorktreeWorkspace(_fc, { loopName: stoppedState.loopName, directory: stoppedState.projectDir || ctx.directory, }, deps.logger, deps.workspaceStatusRegistry) @@ -1782,7 +1580,7 @@ export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): Fo // Unified session creation for restart (always a single code session) const createResult = await createLoopSessionWithWorkspace({ - v2: deps.v2, + client: _fc, title: formatLoopSessionTitle(stoppedState.loopName, { iteration: stoppedState.iteration ?? 0, currentSectionIndex: stoppedState.currentSectionIndex ?? 0, @@ -1813,7 +1611,7 @@ export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): Fo selectSession: true, logger: deps.logger, workspaceStatusRegistry: deps.workspaceStatusRegistry, - selectSessionFn: (sel) => selectSessionWithFallback(deps, sel), + selectSessionFn: (sel) => selectSessionBestEffort(deps.client, deps.directory, deps.logger, sel), }) // Unified section extraction on restart — preserve existing progress if sections exist @@ -1924,14 +1722,19 @@ export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): Fo deps.logger, async () => { markPromptSent(stoppedState.loopName, effectiveSessionId, deps.logger) - return await deps.v2.session.promptAsync({ - sessionID: effectiveSessionId, - directory: stoppedState.worktreeDir, - parts: [{ type: 'text' as const, text: promptText }], - agent: promptAgent, - ...(model ? { model, ...(restartVariant ? { variant: restartVariant } : {}) } : {}), - ...workspaceParam, - }) + 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 } + } }, ) } catch (err) { @@ -1996,7 +1799,7 @@ export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): Fo if (outcome.bindFailed) { publishWorkspaceDetachedToast({ - v2: deps.v2, + client: deps.client, directory: stoppedState.projectDir ?? stoppedState.worktreeDir, loopName: stoppedState.loopName, logger: deps.logger, diff --git a/src/services/plan-capture.ts b/src/services/plan-capture.ts index e1ca9c1252..ab76146bea 100644 --- a/src/services/plan-capture.ts +++ b/src/services/plan-capture.ts @@ -1,13 +1,11 @@ -import type { ToolContext } from '../tools/types' +import type { ForgeClient } from '../client/port' import type { PlansRepo } from '../storage/repos/plans-repo' import type { Logger } from '../types' import type { PlanCaptureMessage } from '../utils/marked-plan-parser' -import type { PluginInput } from '@opencode-ai/plugin' import { extractMarkedPlan, inspectLatestMarkedPlan, sanitizePlanPaths } from '../utils/marked-plan-parser' export interface CaptureLatestPlanDeps { - v2: ToolContext['v2'] - client: PluginInput['client'] + client: ForgeClient plansRepo: PlansRepo projectId: string directory: string @@ -74,31 +72,18 @@ export function captureMarkedPlanTextForSession( } async function readRecentMessages( - deps: Pick, + deps: Pick, sessionID: string ): Promise { try { - const messagesResult = await deps.v2.session.messages({ + const messages = await deps.client.session.messages({ sessionID, directory: deps.directory, limit: 20, }) - if (!messagesResult.error && messagesResult.data && messagesResult.data.length > 0) { - return { status: 'found', messages: messagesResult.data as unknown as PlanCaptureMessage[] } - } - - try { - deps.logger.log(`plan-capture: v2 messages empty/error, falling back to legacy client for ${sessionID}`) - const legacyResult = await deps.client.session.messages({ - path: { id: sessionID }, - query: { directory: deps.directory, limit: 20 }, - }) - if (!legacyResult.error && legacyResult.data && legacyResult.data.length > 0) { - return { status: 'found', messages: legacyResult.data as unknown as PlanCaptureMessage[] } - } - } catch (fallbackErr) { - deps.logger.error(`plan-capture: legacy client messages fallback failed for ${sessionID}`, fallbackErr as Error) + if (messages && messages.length > 0) { + return { status: 'found', messages: messages as unknown as PlanCaptureMessage[] } } return { status: 'missing' } diff --git a/src/tools/loop.ts b/src/tools/loop.ts index eb30935e09..c822ff27d9 100644 --- a/src/tools/loop.ts +++ b/src/tools/loop.ts @@ -14,7 +14,7 @@ import { loopBranchExists } from '../workspace/forge-naming' const z = tool.schema export function createLoopTools(ctx: ToolContext): Record> { - const { v2, loopHandler, config, logger } = ctx + const { loopHandler, config, logger } = ctx function makeService(sourceSessionId?: string) { const execCtx: ForgeExecutionRequestContext = { @@ -29,8 +29,7 @@ export function createLoopTools(ctx: ToolContext): Record s.worktreeDir).filter(Boolean))] const results = await Promise.allSettled( - uniqueDirs.map((dir) => v2.session.status({ directory: dir })), + uniqueDirs.map((dir) => ctx.client.session.status({ directory: dir })), ) for (const result of results) { - if (result.status === 'fulfilled' && result.value.data) { - Object.assign(statuses, result.value.data) + if (result.status === 'fulfilled' && result.value) { + Object.assign(statuses, result.value) } } } catch { @@ -336,7 +334,7 @@ export function createLoopTools(ctx: ToolContext): Record | undefined + const statusResult = await ctx.client.session.status({ directory: state.worktreeDir }) + const statuses = statusResult as Record | undefined // Check if any session registered to this loop is busy (main + child/subagent sessions) const isBusy = Object.entries(statuses ?? {}).some(([sid, s]) => ctx.loop.resolveLoopName(sid) === state.loopName && s.type === 'busy', @@ -442,7 +440,7 @@ export function createLoopTools(ctx: ToolContext): Record /** Loop runtime interface for state management and lifecycle operations. */ loop: Loop - /** OpenCode v2 API client. */ - v2: ReturnType + /** Forge client port wrapping the SDK v2 adapter. */ + client: ForgeClient /** Cleanup function to call on plugin shutdown. */ cleanup: () => Promise - /** Original plugin input from OpenCode. */ - input: PluginInput /** Sandbox manager instance, null if sandboxing is disabled. */ sandboxManager: ReturnType | null /** Plans repo for plan storage. */ diff --git a/src/tui.tsx b/src/tui.tsx index 796b913c54..d1f88ea2d3 100644 --- a/src/tui.tsx +++ b/src/tui.tsx @@ -10,6 +10,7 @@ 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' @@ -369,7 +370,7 @@ const tui: TuiPlugin = async (api) => { const currentClient = await ensureClient() if (!currentClient) return - const planText = await fetchLatestPlanForSession(api.client, sessionID, directory) + const planText = await fetchLatestPlanForSession(createForgeClient(api.client), sessionID, directory) if (!planText) { api.ui.toast({ message: 'No plan in current session — paste one to execute', diff --git a/src/utils/audit-session.ts b/src/utils/audit-session.ts index e0a53bf8d0..d73cae8343 100644 --- a/src/utils/audit-session.ts +++ b/src/utils/audit-session.ts @@ -1,11 +1,11 @@ -import type { OpencodeClient } from '@opencode-ai/sdk/v2' +import type { ForgeClient } from '../client/port' import type { Logger } from '../types' import { createLoopSessionWithWorkspace } from './loop-session' import { buildAuditSessionPermissionRuleset } from '../constants/loop' import { formatAuditSessionTitle } from './session-titles' interface RunAuditSessionInput { - v2: OpencodeClient + client: ForgeClient loopName: string iteration: number currentSectionIndex: number @@ -28,9 +28,10 @@ interface RunAuditSessionResult { export async function createAuditSession( input: RunAuditSessionInput, ): Promise { + const { client } = input const permission = buildAuditSessionPermissionRuleset({ sandbox: input.isSandbox }) const created = await createLoopSessionWithWorkspace({ - v2: input.v2, + client, title: formatAuditSessionTitle(input.loopName, { iteration: input.iteration, currentSectionIndex: input.currentSectionIndex, @@ -53,7 +54,7 @@ export async function createAuditSession( } export async function promptAuditSession( - v2: OpencodeClient, + client: ForgeClient, input: { sessionId: string worktreeDir: string @@ -64,16 +65,19 @@ export async function promptAuditSession( }, ): Promise<{ ok: true } | { ok: false; error: unknown }> { const parts = [{ type: 'text' as const, text: input.prompt }] - const result = await v2.session.promptAsync({ - sessionID: input.sessionId, - directory: input.worktreeDir, - ...(input.workspaceId ? { workspace: input.workspaceId } : {}), - agent: 'auditor-loop', - parts, - ...(input.auditorModel ? { model: input.auditorModel } : {}), - ...(input.auditorVariant ? { variant: input.auditorVariant } : {}), - }) - if (result.error) return { ok: false, error: result.error } - return { ok: true } + try { + await client.session.promptAsync({ + sessionID: input.sessionId, + directory: input.worktreeDir, + ...(input.workspaceId ? { workspace: input.workspaceId } : {}), + agent: 'auditor-loop', + parts, + ...(input.auditorModel ? { model: input.auditorModel } : {}), + ...(input.auditorVariant ? { variant: input.auditorVariant } : {}), + }) + return { ok: true } + } catch (error) { + return { ok: false, error } + } } diff --git a/src/utils/loop-session.ts b/src/utils/loop-session.ts index 7a676b985b..31af5e6ec7 100644 --- a/src/utils/loop-session.ts +++ b/src/utils/loop-session.ts @@ -1,11 +1,11 @@ -import type { OpencodeClient } from '@opencode-ai/sdk/v2' +import type { ForgeClient } from '../client/port' import type { Logger } from '../types' import type { WorkspaceStatusRegistry } from '../utils/workspace-status-registry' import { bindSessionToWorkspace } from '../workspace/forge-worktree' import { buildLoopPermissionRuleset } from '../constants/loop' interface CreateLoopSessionInput { - v2: OpencodeClient + client: ForgeClient title: string directory: string permission?: ReturnType @@ -13,7 +13,6 @@ interface CreateLoopSessionInput { loopName?: string logPrefix: string logger: Logger | Console - legacyClient?: import('@opencode-ai/sdk').OpencodeClient workspaceStatusRegistry?: WorkspaceStatusRegistry } @@ -27,6 +26,7 @@ interface CreateLoopSessionResult { export async function createLoopSessionWithWorkspace( input: CreateLoopSessionInput, ): Promise { + const { client } = input const createParams: { title: string directory: string @@ -44,46 +44,12 @@ export async function createLoopSessionWithWorkspace( const _sessionStart = Date.now() input.logger.log(`[warp] session.create.start loopName="${input.loopName ?? 'unknown'}" logPrefix="${input.logPrefix}"${input.workspaceId ? ` workspaceId=${input.workspaceId}` : ''}`) - // Try v2 SDK first - const createResult = await input.v2.session.create(createParams) - if (createResult.error || !createResult.data) { - const errorMsg = createResult.error instanceof Error ? createResult.error.message : String(createResult.error) - if (errorMsg.includes('Unable to connect')) { - input.logger.log(`${input.logPrefix}: v2 SDK unavailable, falling back to legacy SDK`) - } else { - input.logger.error(`${input.logPrefix}: failed to create session via v2 SDK`, createResult.error) - } - - // Fallback to legacy SDK if available - if (input.legacyClient) { - try { - const legacyResult = await input.legacyClient.session.create({ - body: { - title: input.title, - ...(input.permission ? { permission: input.permission } : {}), - }, - query: { - directory: input.directory, - }, - } as Parameters[0]) - - const session = legacyResult.data as { id?: string } | undefined - if (session?.id) { - input.logger.log(`${input.logPrefix}: created session via legacy SDK fallback`) - sessionId = session.id - } else { - input.logger.error(`${input.logPrefix}: legacy SDK returned no session ID`) - return null - } - } catch (err) { - input.logger.error(`${input.logPrefix}: legacy SDK failed`, err) - return null - } - } else { - return null - } - } else { - sessionId = createResult.data.id + try { + const session = await client.session.create(createParams) + sessionId = session.id + } catch (err) { + input.logger.error(`${input.logPrefix}: failed to create session`, err) + return null } input.logger.log(`[warp] session.create.complete loopName="${input.loopName ?? 'unknown'}" logPrefix="${input.logPrefix}" sessionId=${sessionId}${input.workspaceId ? ` workspaceId=${input.workspaceId}` : ''} elapsedMs=${Date.now() - _sessionStart}`) @@ -97,7 +63,7 @@ export async function createLoopSessionWithWorkspace( const _bindStart = Date.now() try { input.logger.log(`[warp] bind.start loopName="${input.loopName ?? 'unknown'}" workspaceId=${input.workspaceId} sessionId=${result.sessionId}`) - await bindSessionToWorkspace(input.v2, input.workspaceId, result.sessionId, input.logger, { loopName: input.loopName }, input.workspaceStatusRegistry) + await bindSessionToWorkspace(client, input.workspaceId, result.sessionId, input.logger, { loopName: input.loopName }, input.workspaceStatusRegistry) result.boundWorkspaceId = input.workspaceId input.logger.log(`${input.logPrefix}: workspace ${input.workspaceId} bound to session ${result.sessionId}`) input.logger.log(`[warp] bind.complete loopName="${input.loopName ?? 'unknown'}" workspaceId=${input.workspaceId} sessionId=${result.sessionId} elapsedMs=${Date.now() - _bindStart}`) @@ -113,7 +79,7 @@ export async function createLoopSessionWithWorkspace( } interface WorkspaceDetachedToastInput { - v2: OpencodeClient + client: ForgeClient directory: string loopName: string variant?: 'warning' @@ -122,13 +88,12 @@ interface WorkspaceDetachedToastInput { } export function publishWorkspaceDetachedToast(input: WorkspaceDetachedToastInput): void { - if (!input.v2.tui) return - + const { client } = input const message = input.context ? `Workspace attachment lost ${input.context}; session continues without workspace grouping.` : 'Workspace attachment lost; session continues without workspace grouping.' - input.v2.tui.publish({ + client.tui.publish({ directory: input.directory, body: { type: 'tui.toast.show', @@ -139,7 +104,7 @@ export function publishWorkspaceDetachedToast(input: WorkspaceDetachedToastInput duration: 5000, }, }, - }).catch((err) => { + }).catch((err: unknown) => { input.logger.error('Loop: failed to publish workspace-detached toast', err) }) } diff --git a/src/utils/plan-from-messages.ts b/src/utils/plan-from-messages.ts index 30ce4e1e0e..6e62cc2a29 100644 --- a/src/utils/plan-from-messages.ts +++ b/src/utils/plan-from-messages.ts @@ -11,7 +11,7 @@ * provided `debug` callback when supplied. */ -import type { OpencodeClient } from '@opencode-ai/sdk/v2' +import type { ForgeClient } from '../client/port' import { inspectLatestMarkedPlan, type PlanCaptureMessage, @@ -31,7 +31,7 @@ export interface FetchLatestPlanForSessionDeps { const DEFAULT_LIMIT = 20 export async function fetchLatestPlanForSession( - client: OpencodeClient, + client: ForgeClient, sessionID: string, directory: string | undefined, deps: FetchLatestPlanForSessionDeps = {}, @@ -41,16 +41,11 @@ export async function fetchLatestPlanForSession( let messages: PlanCaptureMessage[] try { - const result = await client.session.messages({ + const data = await client.session.messages({ sessionID, ...(directory ? { directory } : {}), limit, }) - if ((result as { error?: unknown }).error) { - debug(`fetchLatestPlanForSession: messages returned error for session ${sessionID}: ${String((result as { error?: unknown }).error)}`) - return null - } - const data = (result as { data?: unknown[] }).data if (!data || data.length === 0) { debug(`fetchLatestPlanForSession: no messages for session ${sessionID}`) return null diff --git a/src/utils/resolve-project-root.ts b/src/utils/resolve-project-root.ts index 1a0aa7b5c5..a770cb5e2b 100644 --- a/src/utils/resolve-project-root.ts +++ b/src/utils/resolve-project-root.ts @@ -1,4 +1,4 @@ -import type { OpencodeClient } from '@opencode-ai/sdk/v2' +import type { ForgeClient } from '../client/port' import type { Logger } from '../types' /** @@ -16,14 +16,14 @@ import type { Logger } from '../types' * callers can fall back to a sensible default. */ export async function resolveHostSessionDirectory( - v2: OpencodeClient, + client: ForgeClient, hostSessionId: string | undefined, fallbackDirectory: string, logger?: Logger, ): Promise { if (!hostSessionId) return null - type SessionGetInput = Parameters[0] + type SessionGetInput = Parameters[0] const attempts: SessionGetInput[] = [ { sessionID: hostSessionId } as SessionGetInput, @@ -32,8 +32,8 @@ export async function resolveHostSessionDirectory( for (const input of attempts) { try { - const result = await v2.session.get(input) - const dir = result.data?.directory + const session = await client.session.get(input) + const dir = session?.directory if (dir) return dir } catch { // fall through to next attempt diff --git a/src/utils/tui-client.ts b/src/utils/tui-client.ts index 6e9278567a..0322193580 100644 --- a/src/utils/tui-client.ts +++ b/src/utils/tui-client.ts @@ -19,6 +19,7 @@ import { fetchLoopsList } from './tui-loop-store' import { decomposeDeterministically } from '../services/deterministic-decomposer' import { buildSectionInitialPromptText } from '../loop/prompts' import { extractPlanExecutionMetadata, sanitizeLoopName } from './plan-execution' +import { createForgeClient } from '../client/sdk-adapter' export type ApiExecutionMode = 'new-session' | 'execute-here' | 'loop' @@ -334,7 +335,7 @@ export async function connectForgeProject( pendingAttachStartedAt: createdAt, } try { - await removeExistingForgeLoopWorkspaces(api.client, loopName, { + await removeExistingForgeLoopWorkspaces(createForgeClient(api.client), loopName, { log: (message) => tuiDebug(`plan.execute(loop): ${message}`), error: (message, err) => tuiDebug(`plan.execute(loop): ${message} ${err instanceof Error ? err.message : String(err)}`), }) diff --git a/src/workspace/forge-adapter.ts b/src/workspace/forge-adapter.ts index 75ff757e5f..d1dd4a9aaf 100644 --- a/src/workspace/forge-adapter.ts +++ b/src/workspace/forge-adapter.ts @@ -11,7 +11,7 @@ import { cleanupLoopWorktree } from '../utils/worktree-cleanup' /** * Runtime context for a forge workspace teardown. Populated by the caller - * (loop termination side-effects, etc.) before `experimental.workspace.remove` + * (loop termination side-effects, etc.) before `client.workspace.remove` * is invoked so the adapter can produce informative commit messages. * * When no context is registered (orphan sweep, TUI delete without an active diff --git a/src/workspace/forge-worktree.ts b/src/workspace/forge-worktree.ts index 767baa6bb5..a2ec144b2f 100644 --- a/src/workspace/forge-worktree.ts +++ b/src/workspace/forge-worktree.ts @@ -1,15 +1,15 @@ /** - * Forge workspace helpers using opencode's experimental workspace API. + * Forge workspace helpers using the ForgeClient port. * * The recommended entry point is {@link createBuiltinWorktreeWorkspace}, which creates - * a Forge workspace with `type: 'forge'` through opencode's experimental adapter, + * a Forge workspace with `type: 'forge'` through the ForgeClient adapter, * then registers it via syncList so the TUI can show it as connected (green dot). * * Workspaces are created with `type: 'forge'` (not `type: 'worktree'`) because * Forge uses its own adapter registered in the experimental workspace API. */ -import type { OpencodeClient } from '@opencode-ai/sdk/v2' +import type { ForgeClient } from '../client/port' import type { WorkspaceStatusRegistry } from '../utils/workspace-status-registry' export interface ForgeWorkspaceEntry { @@ -30,15 +30,12 @@ export function getForgeWorkspaceLoopName(entry: Pick void; error: (msg: string, ...args: unknown[]) => void }, ): Promise { - const workspaceApi = client.experimental?.workspace - if (!workspaceApi || typeof workspaceApi.list !== 'function') return [] try { - const result = await workspaceApi.list() - const entries = ((result as { data?: unknown[] } | undefined)?.data ?? []) as ForgeWorkspaceEntry[] + const entries = (await client.workspace.list() ?? []) as ForgeWorkspaceEntry[] const matches = entries.filter((entry) => entry.id && workspaceMatchesLoop(entry, loopName)) if (matches.length > 0) { (logger ?? console).log?.(`findExistingForgeWorkspaces: found ${matches.length} existing workspace(s) for loop ${loopName}`) @@ -57,32 +54,34 @@ function workspaceMatchesLoop(entry: ForgeWorkspaceEntry, loopName: string): boo } export async function removeExistingForgeLoopWorkspaces( - client: OpencodeClient, + client: ForgeClient, loopName: string, logger?: { log: (msg: string, ...args: unknown[]) => void; error: (msg: string, ...args: unknown[]) => void }, ): Promise { - const workspaceApi = client.experimental?.workspace - if (!workspaceApi || typeof workspaceApi.remove !== 'function') return const matches = await findExistingForgeWorkspaces(client, loopName, logger) for (const match of matches) { - await workspaceApi.remove({ id: match.id }) - ;(logger ?? console).log?.(`removeExistingForgeLoopWorkspaces: removed old workspace ${match.id} for loop ${loopName}`) + try { + await client.workspace.remove({ id: match.id }) + ;(logger ?? console).log?.(`removeExistingForgeLoopWorkspaces: removed old workspace ${match.id} for loop ${loopName}`) + } catch (err) { + ;(logger ?? console).error?.(`removeExistingForgeLoopWorkspaces: failed to remove workspace ${match.id}`, err) + } } } /** - * Creates a Forge workspace via opencode's experimental workspace API with the `forge` adapter. + * Creates a Forge workspace via the ForgeClient port with the `forge` adapter. * - * Uses `experimental.workspace.create({ type: 'forge', branch: null })` so the + * Uses `client.workspace.create({ type: 'forge', branch: null })` so the * workspace appears as fully connected (green dot) in the TUI. * - * After a successful create, also issues a best-effort `experimental.workspace.syncList()` + * After a successful create, also issues a best-effort `client.workspace.syncList()` * so the new workspace is registered in the Warp picker, not just reachable from the session list. * * @returns `{ workspaceId, directory, branch }` or `null` on failure. */ export async function createBuiltinWorktreeWorkspace( - client: OpencodeClient, + client: ForgeClient, options: { loopName: string directory: string @@ -90,11 +89,6 @@ export async function createBuiltinWorktreeWorkspace( logger?: { log: (msg: string, ...args: unknown[]) => void; error: (msg: string, ...args: unknown[]) => void }, statusRegistry?: WorkspaceStatusRegistry, ): Promise<{ workspaceId: string; directory: string; branch: string } | null> { - const workspaceApi = client.experimental?.workspace - if (!workspaceApi || typeof workspaceApi.create !== 'function') { - (logger ?? console).log?.('createBuiltinWorktreeWorkspace: experimental.workspace API not available') - return null - } if (!options.directory) { (logger ?? console).error('createBuiltinWorktreeWorkspace: options.directory is required') return null @@ -107,27 +101,13 @@ export async function createBuiltinWorktreeWorkspace( branch: null, extra: { loopName: options.loopName, projectDirectory: options.directory, workspaceCreatedAt: Date.now() }, } - const result = await workspaceApi.create(createParams) - - if ('error' in result && result.error) { - (logger ?? console).error('createBuiltinWorktreeWorkspace: workspace.create returned error', result.error) - return null - } - - const rawResult = result as unknown - - const workspaceData = 'data' in result ? result.data as unknown : rawResult - - const wsId = - rawResult && typeof rawResult === 'object' && 'id' in rawResult && typeof rawResult.id === 'string' - ? rawResult.id - : null + const workspaceData = await client.workspace.create(createParams) const id = typeof workspaceData === 'string' ? workspaceData : workspaceData && typeof workspaceData === 'object' && 'id' in workspaceData && typeof workspaceData.id === 'string' ? workspaceData.id - : wsId + : null const directory = workspaceData && typeof workspaceData === 'object' && 'directory' in workspaceData ? String((workspaceData as Record).directory ?? '') @@ -153,37 +133,33 @@ export async function createBuiltinWorktreeWorkspace( (logger ?? console).log?.(`createBuiltinWorktreeWorkspace: workspace ${id} created for ${options.loopName}`) ;(logger ?? console).log?.(`[warp] workspace.create.complete loopName=${options.loopName} workspaceId=${id} elapsedMs=${Date.now() - _wsStart}`) - if (typeof workspaceApi.syncList === 'function') { - try { - await workspaceApi.syncList() - ;(logger ?? console).log?.(`createBuiltinWorktreeWorkspace: workspace ${id} registered via syncList`) - ;(logger ?? console).log?.(`[warp] syncList.complete loopName=${options.loopName} workspaceId=${id} elapsedMs=${Date.now() - _wsStart}`) - } catch (err) { - ;(logger ?? console).error('createBuiltinWorktreeWorkspace: syncList after create failed; workspace may be reachable via session list but not visible in Warp picker', err) - } - } else { - ;(logger ?? console).log?.('createBuiltinWorktreeWorkspace: syncList not available on SDK, skipping registration') + // Best-effort syncList to register in the Warp picker + try { + await client.workspace.syncList() + ;(logger ?? console).log?.(`createBuiltinWorktreeWorkspace: workspace ${id} registered via syncList`) + ;(logger ?? console).log?.(`[warp] syncList.complete loopName=${options.loopName} workspaceId=${id} elapsedMs=${Date.now() - _wsStart}`) + } catch (err) { + ;(logger ?? console).error('createBuiltinWorktreeWorkspace: syncList after create failed; workspace may be reachable via session list but not visible in Warp picker', err) } - if (typeof client.sync?.start === 'function') { - try { - await client.sync.start() - ;(logger ?? console).log?.(`createBuiltinWorktreeWorkspace: workspace sync started for ${id}`) - ;(logger ?? console).log?.(`[warp] sync.start.complete loopName=${options.loopName} workspaceId=${id} elapsedMs=${Date.now() - _wsStart}`) - } catch (err) { - ;(logger ?? console).error('createBuiltinWorktreeWorkspace: sync.start after create failed; workspace status may remain unavailable in the TUI', err) - } + // Best-effort sync.start (adapter no-ops when unavailable) + try { + await client.sync.start() + ;(logger ?? console).log?.(`createBuiltinWorktreeWorkspace: workspace sync started for ${id}`) + ;(logger ?? console).log?.(`[warp] sync.start.complete loopName=${options.loopName} workspaceId=${id} elapsedMs=${Date.now() - _wsStart}`) + } catch (err) { + ;(logger ?? console).error('createBuiltinWorktreeWorkspace: sync.start after create failed; workspace status may remain unavailable in the TUI', err) } try { - const [listResult, statusResult] = await Promise.all([ - typeof workspaceApi.list === 'function' ? workspaceApi.list() : Promise.resolve(undefined), - typeof workspaceApi.status === 'function' ? workspaceApi.status() : Promise.resolve(undefined), + const [listData, statusData] = await Promise.all([ + client.workspace.list(), + client.workspace.status(), ]) - const listed = ((listResult as { data?: Array<{ id?: string }> } | undefined)?.data ?? []).some((workspace) => workspace.id === id) - const statusData = ((statusResult as { data?: Array<{ workspaceID?: string; status?: string }> } | undefined)?.data ?? []) - const status = statusData.find((entry) => entry.workspaceID === id)?.status - statusRegistry?.primeFromSnapshot(statusData.map((entry) => ({ workspaceID: entry.workspaceID ?? '', status: entry.status ?? '' }))) + const listed = (listData ?? []).some((workspace: Record) => workspace.id === id) + const statusArr = (statusData ?? []) as Array<{ workspaceID?: string; status?: string }> + const status = statusArr.find((entry) => entry.workspaceID === id)?.status + statusRegistry?.primeFromSnapshot(statusArr.map((entry) => ({ workspaceID: entry.workspaceID ?? '', status: entry.status ?? '' }))) ;(logger ?? console).log?.(`createBuiltinWorktreeWorkspace: workspace ${id} visibility listed=${listed} status=${status ?? 'unknown'}`) } catch (err) { ;(logger ?? console).error('createBuiltinWorktreeWorkspace: post-create workspace visibility check failed', err) @@ -200,18 +176,13 @@ export async function createBuiltinWorktreeWorkspace( * Binds a session to a workspace by calling the warp API. */ export async function bindSessionToWorkspace( - client: OpencodeClient, + client: ForgeClient, workspaceId: string, sessionId: string, logger?: { log: (msg: string, ...args: unknown[]) => void; error: (msg: string, ...args: unknown[]) => void }, options?: { copyChanges?: boolean; loopName?: string }, statusRegistry?: WorkspaceStatusRegistry, ): Promise { - const workspaceApi = client.experimental?.workspace - if (!workspaceApi || typeof workspaceApi.warp !== 'function') { - (logger ?? console).log?.('bindSessionToWorkspace: experimental.workspace.warp not available') - throw new Error('experimental.workspace.warp not available on this host') - } const warpParams: { id: string; sessionID: string; copyChanges?: boolean } = { id: workspaceId, sessionID: sessionId, @@ -220,36 +191,35 @@ export async function bindSessionToWorkspace( const _warpStart = Date.now() ;(logger ?? console).log?.(`[warp] warp.start loopName=${options?.loopName ?? 'unknown'} workspaceId=${workspaceId} sessionId=${sessionId}`) - const result = await workspaceApi.warp(warpParams) - - if ('error' in result && result.error) { - const _warpError = String(result.error) + try { + await client.workspace.warp(warpParams) + } catch (err) { + const _warpError = err instanceof Error ? err.message : String(err) ;(logger ?? console).error(`[warp] warp.failed loopName=${options?.loopName ?? 'unknown'} workspaceId=${workspaceId} sessionId=${sessionId} elapsedMs=${Date.now() - _warpStart} error="${_warpError}"`) - ;(logger ?? console).error(`bindSessionToWorkspace: warp failed for workspace=${workspaceId} session=${sessionId}`, result.error) - throw new Error(`Session warp failed: ${JSON.stringify(result.error)}`) + ;(logger ?? console).error(`bindSessionToWorkspace: warp failed for workspace=${workspaceId} session=${sessionId}`, err) + throw err } ;(logger ?? console).log?.(`[warp] warp.complete loopName=${options?.loopName ?? 'unknown'} workspaceId=${workspaceId} sessionId=${sessionId} elapsedMs=${Date.now() - _warpStart}`) - if (typeof client.sync?.start === 'function') { - try { - await client.sync.start() - ;(logger ?? console).log?.(`bindSessionToWorkspace: workspace sync started for workspace=${workspaceId} session=${sessionId}`) - } catch (err) { - ;(logger ?? console).error('bindSessionToWorkspace: sync.start after warp failed; workspace status may remain unavailable in the TUI', err) - } + // Best-effort sync.start (adapter no-ops when unavailable) + try { + await client.sync.start() + ;(logger ?? console).log?.(`bindSessionToWorkspace: workspace sync started for workspace=${workspaceId} session=${sessionId}`) + } catch (err) { + ;(logger ?? console).error('bindSessionToWorkspace: sync.start after warp failed; workspace status may remain unavailable in the TUI', err) } try { - const [listResult, statusResult] = await Promise.all([ - typeof workspaceApi.list === 'function' ? workspaceApi.list() : Promise.resolve(undefined), - typeof workspaceApi.status === 'function' ? workspaceApi.status() : Promise.resolve(undefined), + const [listData, statusData] = await Promise.all([ + client.workspace.list(), + client.workspace.status(), ]) - const listed = ((listResult as { data?: Array<{ id?: string }> } | undefined)?.data ?? []) - .some((workspace) => workspace.id === workspaceId) - const statusData = ((statusResult as { data?: Array<{ workspaceID?: string; status?: string }> } | undefined)?.data ?? []) - const status = statusData.find((entry) => entry.workspaceID === workspaceId)?.status - statusRegistry?.primeFromSnapshot(statusData.map((entry) => ({ workspaceID: entry.workspaceID ?? '', status: entry.status ?? '' }))) + const listArr = (listData ?? []) as Array<{ id?: string }> + const listed = listArr.some((workspace) => workspace.id === workspaceId) + const statusArr = (statusData ?? []) as Array<{ workspaceID?: string; status?: string }> + const status = statusArr.find((entry) => entry.workspaceID === workspaceId)?.status + statusRegistry?.primeFromSnapshot(statusArr.map((entry) => ({ workspaceID: entry.workspaceID ?? '', status: entry.status ?? '' }))) ;(logger ?? console).log?.(`bindSessionToWorkspace: workspace ${workspaceId} visibility after warp listed=${listed} status=${status ?? 'unknown'}`) } catch (err) { ;(logger ?? console).error('bindSessionToWorkspace: post-warp workspace visibility check failed', err) diff --git a/src/workspace/pending-teardown.ts b/src/workspace/pending-teardown.ts index 546ca5910f..0f6ef87229 100644 --- a/src/workspace/pending-teardown.ts +++ b/src/workspace/pending-teardown.ts @@ -3,7 +3,7 @@ import type { TeardownContext } from './forge-adapter' /** * Registry of pending workspace teardowns keyed by loop name. * - * Set by callers of `experimental.workspace.remove` (loop termination side + * Set by callers of `client.workspace.remove` (loop termination side * effects, etc.) so the forge workspace adapter can produce informative commit * messages (iteration count, termination reason) while still being the single * source of truth for teardown behavior. diff --git a/src/workspace/remove-with-context.ts b/src/workspace/remove-with-context.ts index 70b7a0281b..eb808e1956 100644 --- a/src/workspace/remove-with-context.ts +++ b/src/workspace/remove-with-context.ts @@ -5,12 +5,12 @@ * can produce informative commit messages and honor doRemoveWorktree. */ -import type { OpencodeClient } from '@opencode-ai/sdk/v2' +import type { ForgeClient } from '../client/port' import type { PendingTeardownRegistry } from './pending-teardown' import type { Logger } from '../types' export interface RemoveWithDeps { - v2: OpencodeClient + client: ForgeClient pendingTeardowns: PendingTeardownRegistry logger: Logger } @@ -39,7 +39,7 @@ export async function removeForgeWorkspaceWithContext( deps: RemoveWithDeps, input: RemoveWithInput, ): Promise { - const { v2, pendingTeardowns, logger } = deps + const { client, pendingTeardowns, logger } = deps const { workspaceId, loopName, action, reasonLabel } = input const doRemoveWorktree = action === 'remove-fully' @@ -53,20 +53,7 @@ export async function removeForgeWorkspaceWithContext( }) try { - const workspaceApi = v2.experimental?.workspace - if (!workspaceApi || typeof workspaceApi.remove !== 'function') { - const error = 'experimental.workspace.remove not available' - logger.error(`[remove-with-context] ${error} for workspace ${workspaceId}`) - return { ok: false, error } - } - - const result = await workspaceApi.remove({ id: workspaceId }) - if ('error' in result && result.error) { - const error = `workspace.remove returned error: ${JSON.stringify(result.error)}` - logger.error(`[remove-with-context] ${error} for workspace ${workspaceId}`) - return { ok: false, error } - } - + await client.workspace.remove({ id: workspaceId }) logger.log(`[remove-with-context] removed workspace ${workspaceId} for loop ${loopName} (action=${action})`) return { ok: true } } catch (err) { diff --git a/src/workspace/sweep-stale.ts b/src/workspace/sweep-stale.ts index 29f0e0e500..1dba092cff 100644 --- a/src/workspace/sweep-stale.ts +++ b/src/workspace/sweep-stale.ts @@ -8,7 +8,7 @@ * The sweep is fire-and-forget: failures are logged but never block the teardown. */ -import type { OpencodeClient } from '@opencode-ai/sdk/v2' +import type { ForgeClient } from '../client/port' import type { LoopsRepo } from '../storage/repos/loops-repo' import type { PendingTeardownRegistry } from './pending-teardown' import type { Logger } from '../types' @@ -19,7 +19,7 @@ import { getForgeWorkspaceLoopName } from './forge-worktree' import { removeForgeWorkspaceWithContext } from './remove-with-context' export interface SweepStaleDeps { - v2: OpencodeClient + client: ForgeClient loopsRepo: LoopsRepo pendingTeardowns: PendingTeardownRegistry logger: Logger @@ -41,7 +41,7 @@ export interface SweepStaleReport { /** * Sweep stale forge workspaces in the same project. * - * - Lists all workspaces via v2.experimental.workspace.list() + * - Lists all workspaces via client.workspace.list() * - Filters to forge workspaces in the same projectDirectory * - Excludes the terminating loop's own workspace (excludeLoopName) * - Classifies each entry via classifyForgeWorkspace @@ -54,23 +54,16 @@ export async function sweepStaleForgeWorkspaces( deps: SweepStaleDeps, input: SweepStaleInput, ): Promise { - const { v2, loopsRepo, pendingTeardowns, logger } = deps + const { client, loopsRepo, pendingTeardowns, logger } = deps const { projectId, projectDirectory, excludeLoopName, reasonLabel = 'orphan-sweep' } = input const swept: SweepStaleReport['swept'] = [] const skipped: SweepStaleReport['skipped'] = [] const failed: SweepStaleReport['failed'] = [] - const workspaceApi = v2.experimental?.workspace - if (!workspaceApi || typeof workspaceApi.list !== 'function') { - logger.error('[sweep-stale] experimental.workspace.list not available; skipping sweep') - return { swept, skipped, failed } - } - let entries: ForgeWorkspaceEntry[] try { - const result = await workspaceApi.list() - const dataList = ((result as { data?: unknown[] } | undefined)?.data ?? []) as Array<{ id?: unknown; type?: unknown; extra?: unknown }> + const dataList = (await client.workspace.list() ?? []) as Array<{ id?: unknown; type?: unknown; extra?: unknown }> entries = dataList .filter((e) => e.id && typeof e.id === 'string') .map((e) => ({ @@ -100,7 +93,7 @@ export async function sweepStaleForgeWorkspaces( // Attempt removal const removeResult = await removeForgeWorkspaceWithContext( - { v2, pendingTeardowns, logger }, + { client, pendingTeardowns, logger }, { workspaceId: entry.id, loopName: action.loopName, diff --git a/test/audit-session.test.ts b/test/audit-session.test.ts index cc7e92863b..254d7eca0f 100644 --- a/test/audit-session.test.ts +++ b/test/audit-session.test.ts @@ -2,32 +2,30 @@ import { describe, test, expect, mock } from 'bun:test' import { createAuditSession, promptAuditSession } from '../src/utils/audit-session' import { buildAuditSessionPermissionRuleset } from '../src/constants/loop' import type { Logger } from '../src/types' +import { createFakeForgeClient } from './helpers/fake-client' +import { ForgeClientError } from '../src/client/port' -interface MockV2Client { - session: { - create: ReturnType Promise<{ data?: { id: string }; error?: unknown }>>> - promptAsync: ReturnType Promise<{ data?: unknown; error?: unknown }>>> - delete: ReturnType Promise>> - } -} - -function createMockV2Client(): MockV2Client { - return { - session: { - create: mock(() => Promise.resolve({ data: { id: 'sess_mock_123' } })), - promptAsync: mock(() => Promise.resolve({ data: {} })), - delete: mock(() => Promise.resolve()), - }, +describe('createAuditSession', () => { + function createMockClient() { + const { client } = createFakeForgeClient({ + session: { + create: async (params: any) => ({ id: 'sess_mock_123' }), + promptAsync: async () => {}, + delete: async () => {}, + }, + workspace: { + warp: async () => {}, + }, + }) + return { client, sessionCreate: client.session.create, promptAsync: client.session.promptAsync } } -} -describe('createAuditSession', () => { test('creates session with correct audit ruleset', async () => { - const mockV2 = createMockV2Client() + const { client, sessionCreate } = createMockClient() const logger = { log: mock(), error: mock() } as unknown as Logger const result = await createAuditSession({ - v2: mockV2 as any, + client, loopName: 'test-loop', iteration: 1, currentSectionIndex: 0, @@ -39,20 +37,26 @@ describe('createAuditSession', () => { }) expect(result).not.toBeNull() - expect(mockV2.session.create).toHaveBeenCalled() - const callArgs = (mockV2.session.create as any).mock.calls[0][0] + expect(sessionCreate).toHaveBeenCalled() + const callArgs = (sessionCreate as any).mock.calls[0][0] expect(callArgs.permission).toEqual(buildAuditSessionPermissionRuleset({ sandbox: true })) expect(callArgs.title).toBe('audit: test-loop #1') expect(callArgs).not.toHaveProperty('parentID') }) test('returns null on session creation error', async () => { - const mockV2 = createMockV2Client() - mockV2.session.create = mock(() => Promise.resolve({ error: new Error('create failed') })) + const { client } = createFakeForgeClient({ + session: { + create: async () => { throw new ForgeClientError({ kind: 'request', method: 'session.create', message: 'create failed', cause: new Error('create failed') }) }, + }, + workspace: { + warp: async () => {}, + }, + }) const logger = { log: mock(), error: mock() } as unknown as Logger const result = await createAuditSession({ - v2: mockV2 as any, + client, loopName: 'test-loop', iteration: 1, currentSectionIndex: 0, @@ -67,11 +71,11 @@ describe('createAuditSession', () => { }) test('uses non-sandbox ruleset when isSandbox is false', async () => { - const mockV2 = createMockV2Client() + const { client, sessionCreate } = createMockClient() const logger = { log: mock(), error: mock() } as unknown as Logger await createAuditSession({ - v2: mockV2 as any, + client, loopName: 'test-loop', iteration: 1, currentSectionIndex: 0, @@ -82,16 +86,16 @@ describe('createAuditSession', () => { logger, }) - const callArgs = (mockV2.session.create as any).mock.calls[0][0] + const callArgs = (sessionCreate as any).mock.calls[0][0] expect(callArgs.permission).toEqual(buildAuditSessionPermissionRuleset({ sandbox: false })) }) test('creates audit session as top-level session even when previous code session exists', async () => { - const mockV2 = createMockV2Client() + const { client, sessionCreate } = createMockClient() const logger = { log: mock(), error: mock() } as unknown as Logger await createAuditSession({ - v2: mockV2 as any, + client, loopName: 'test-loop', iteration: 2, currentSectionIndex: 0, @@ -103,17 +107,17 @@ describe('createAuditSession', () => { logger, }) - const callArgs = (mockV2.session.create as any).mock.calls[0][0] + const callArgs = (sessionCreate as any).mock.calls[0][0] expect(callArgs.workspaceID).toBe('workspace-1') expect(callArgs).not.toHaveProperty('parentID') }) test('formats title with section context for sectioned loops', async () => { - const mockV2 = createMockV2Client() + const { client, sessionCreate } = createMockClient() const logger = { log: mock(), error: mock() } as unknown as Logger await createAuditSession({ - v2: mockV2 as any, + client, loopName: 'test-loop', iteration: 3, currentSectionIndex: 1, @@ -124,32 +128,43 @@ describe('createAuditSession', () => { logger, }) - const callArgs = (mockV2.session.create as any).mock.calls[0][0] + const callArgs = (sessionCreate as any).mock.calls[0][0] expect(callArgs.title).toBe('audit: test-loop 2/4 #3') }) }) describe('promptAuditSession', () => { + function makeClient() { + const { client } = createFakeForgeClient({ + session: { + promptAsync: async () => {}, + }, + }) + return { client, promptAsync: client.session.promptAsync } + } + test('returns ok:true on success', async () => { - const mockV2 = createMockV2Client() - mockV2.session.promptAsync = mock(() => Promise.resolve({ data: {} })) + const { client, promptAsync } = makeClient() - const result = await promptAuditSession(mockV2 as any, { + const result = await promptAuditSession(client, { sessionId: 'sess_audit_123', worktreeDir: '/tmp/test', prompt: 'test prompt', }) expect(result).toEqual({ ok: true }) - expect(mockV2.session.promptAsync).toHaveBeenCalled() + expect(promptAsync).toHaveBeenCalled() }) test('returns ok:false on error', async () => { - const mockV2 = createMockV2Client() const testError = new Error('prompt failed') - mockV2.session.promptAsync = mock(() => Promise.resolve({ error: testError })) + const { client } = createFakeForgeClient({ + session: { + promptAsync: async () => { throw testError }, + }, + }) - const result = await promptAuditSession(mockV2 as any, { + const result = await promptAuditSession(client, { sessionId: 'sess_audit_123', worktreeDir: '/tmp/test', prompt: 'test prompt', @@ -159,17 +174,16 @@ describe('promptAuditSession', () => { }) test('passes auditorModel when provided', async () => { - const mockV2 = createMockV2Client() - mockV2.session.promptAsync = mock(() => Promise.resolve({ data: {} })) + const { client, promptAsync } = makeClient() - await promptAuditSession(mockV2 as any, { + await promptAuditSession(client, { sessionId: 'sess_audit_123', worktreeDir: '/tmp/test', prompt: 'test prompt', auditorModel: { providerID: 'openai', modelID: 'gpt-4' }, }) - const callArgs = (mockV2.session.promptAsync as any).mock.calls[0][0] + const callArgs = (promptAsync as any).mock.calls[0][0] expect(callArgs.model).toEqual({ providerID: 'openai', modelID: 'gpt-4' }) expect(callArgs.agent).toBe('auditor-loop') }) diff --git a/test/client/sdk-adapter.test.ts b/test/client/sdk-adapter.test.ts new file mode 100644 index 0000000000..d810affde8 --- /dev/null +++ b/test/client/sdk-adapter.test.ts @@ -0,0 +1,256 @@ +import { describe, it, expect, vi } from 'vitest' +import { createForgeClient, createV2ClientFromPluginInput } from '../../src/client/sdk-adapter' +import { ForgeClientError } from '../../src/client/port' +import type { OpencodeClient } from '@opencode-ai/sdk/v2' +import type { PluginInput } from '@opencode-ai/plugin' + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Create a minimal stub v2 client with the given overrides. */ +function stubV2(overrides?: { + session?: Partial>> + experimental?: { + workspace?: Partial['workspace'], ReturnType>> + } + tui?: Partial, ReturnType>> + sync?: { start?: ReturnType } +}): OpencodeClient { + const sessionApi = { + create: vi.fn().mockResolvedValue({ data: { id: 's1' }, error: undefined }), + get: vi.fn().mockResolvedValue({ data: { id: 's1' }, error: undefined }), + update: vi.fn().mockResolvedValue({ data: {}, error: undefined }), + messages: vi.fn().mockResolvedValue({ data: [], error: undefined }), + status: vi.fn().mockResolvedValue({ data: {}, error: undefined }), + promptAsync: vi.fn().mockResolvedValue({ data: undefined, error: undefined }), + abort: vi.fn().mockResolvedValue({ data: true, error: undefined }), + delete: vi.fn().mockResolvedValue({ data: true, error: undefined }), + ...overrides?.session, + } as unknown as OpencodeClient['session'] + + const workspaceApi = overrides?.experimental?.workspace ?? { + create: vi.fn().mockResolvedValue({ data: { id: 'ws-1' }, error: undefined }), + list: vi.fn().mockResolvedValue({ data: [], error: undefined }), + status: vi.fn().mockResolvedValue({ data: [], error: undefined }), + syncList: vi.fn().mockResolvedValue({ data: undefined, error: undefined }), + remove: vi.fn().mockResolvedValue({ data: {}, error: undefined }), + warp: vi.fn().mockResolvedValue({ data: undefined, error: undefined }), + } + + const tuiApi = overrides?.tui ?? { + publish: vi.fn().mockResolvedValue({ data: true, error: undefined }), + selectSession: vi.fn().mockResolvedValue({ data: true, error: undefined }), + } + + const syncApi = overrides?.sync ?? { + start: vi.fn().mockResolvedValue({ data: true, error: undefined }), + } + + return { + session: sessionApi, + experimental: { workspace: workspaceApi }, + tui: tuiApi, + sync: syncApi, + } as unknown as OpencodeClient +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('createForgeClient', () => { + // ── session.create happy path ───────────────────────────────────────── + it('session.create resolves to data on success', async () => { + const v2 = stubV2() + const client = createForgeClient(v2) + + const result = await client.session.create({ directory: '/test' }) + + expect(result).toEqual({ id: 's1' }) + expect(v2.session.create).toHaveBeenCalledWith({ directory: '/test' }) + }) + + // ── { error } envelope ─────────────────────────────────────────────── + it('session.create throws ForgeClientError with kind="not-found" when SDK returns error envelope', async () => { + const sdkError = new Error('Session not found') + const v2 = stubV2({ + session: { + create: vi.fn().mockResolvedValue({ data: null, error: sdkError }), + }, + }) + const client = createForgeClient(v2) + + const err = await client.session.create({ directory: '/test' }).catch((e: unknown) => e) + + expect(err).toBeInstanceOf(ForgeClientError) + expect((err as ForgeClientError).kind).toBe('not-found') + expect((err as ForgeClientError).method).toBe('session.create') + expect((err as ForgeClientError).message).toContain('Session not found') + }) + + // ── data: null without error ────────────────────────────────────────── + it('session.create throws ForgeClientError when SDK returns data: null without error', async () => { + const v2 = stubV2({ + session: { + create: vi.fn().mockResolvedValue({ data: null, error: undefined }), + }, + }) + const client = createForgeClient(v2) + + const err = await client.session.create({ directory: '/test' }).catch((e: unknown) => e) + + expect(err).toBeInstanceOf(ForgeClientError) + expect((err as ForgeClientError).kind).toBe('request') + expect((err as ForgeClientError).method).toBe('session.create') + expect((err as ForgeClientError).message).toContain('no data returned') + }) + + // ── thrown SDK error ───────────────────────────────────────────────── + it('session.create throws ForgeClientError with kind="connection" when SDK throws', async () => { + const v2 = stubV2({ + session: { + create: vi.fn().mockRejectedValue(new Error('Unable to connect')), + }, + }) + const client = createForgeClient(v2) + + const err = await client.session.create({ directory: '/test' }).catch((e: unknown) => e) + + expect(err).toBeInstanceOf(ForgeClientError) + expect((err as ForgeClientError).kind).toBe('connection') + expect((err as ForgeClientError).method).toBe('session.create') + }) + + // ── workspace.warp with { error } ──────────────────────────────────── + it('workspace.warp throws classified ForgeClientError when SDK returns error', async () => { + const sdkError = { name: 'WorkspaceWarpError', data: { message: 'Workspace not found' } } + const v2 = stubV2({ + experimental: { + workspace: { + warp: vi.fn().mockResolvedValue({ data: undefined, error: sdkError }), + }, + }, + }) + const client = createForgeClient(v2) + + const err = await client.workspace.warp({ id: 'ws-1', sessionID: 's1' }).catch((e: unknown) => e) + + expect(err).toBeInstanceOf(ForgeClientError) + expect((err as ForgeClientError).kind).toBe('not-found') + expect((err as ForgeClientError).method).toBe('workspace.warp') + expect((err as ForgeClientError).message).toContain('Workspace not found') + }) + + // ── missing experimental.workspace namespace ───────────────────────── + it('workspace.list throws ForgeClientError with kind="unavailable" when experimental.workspace is missing', async () => { + const v2 = { + session: stubV2().session, + tui: stubV2().tui, + sync: stubV2().sync, + } as unknown as OpencodeClient + const client = createForgeClient(v2) + + const err = await client.workspace.list({}).catch((e: unknown) => e) + + expect(err).toBeInstanceOf(ForgeClientError) + expect((err as ForgeClientError).kind).toBe('unavailable') + expect((err as ForgeClientError).method).toBe('workspace.list') + }) + + // ── missing tui namespace → publish resolves silently ──────────────── + it('tui.publish resolves without throwing when tui namespace is missing', async () => { + const v2 = { + session: stubV2().session, + experimental: stubV2().experimental, + sync: stubV2().sync, + } as unknown as OpencodeClient + const client = createForgeClient(v2) + + await expect(client.tui.publish({ directory: '/test' })).resolves.toBeUndefined() + }) + + // ── missing sync namespace → start resolves silently ───────────────── + it('sync.start resolves without throwing when sync.start is missing', async () => { + const v2 = { + session: stubV2().session, + experimental: stubV2().experimental, + tui: stubV2().tui, + } as unknown as OpencodeClient + const client = createForgeClient(v2) + + await expect(client.sync.start({ directory: '/test' })).resolves.toBeUndefined() + }) + + // ── missing tui namespace → selectSession throws unavailable ───────── + it('tui.selectSession throws ForgeClientError with kind="unavailable" when tui namespace is missing', async () => { + const v2 = { + session: stubV2().session, + experimental: stubV2().experimental, + sync: stubV2().sync, + } as unknown as OpencodeClient + const client = createForgeClient(v2) + + const err = await client.tui.selectSession({ sessionID: 's1' }).catch((e: unknown) => e) + + expect(err).toBeInstanceOf(ForgeClientError) + expect((err as ForgeClientError).kind).toBe('unavailable') + expect((err as ForgeClientError).method).toBe('tui.selectSession') + }) + + // ── error code propagation ──────────────────────────────────────────── + it('ForgeClientError propagates code from cause.code (e.g. concurrent_prompt)', async () => { + const sdkError = new Error('concurrent prompt in progress') + ;(sdkError as any).code = 'concurrent_prompt' + const v2 = stubV2({ + session: { + promptAsync: vi.fn().mockResolvedValue({ data: undefined, error: sdkError }), + }, + }) + const client = createForgeClient(v2) + + const err = await client.session.promptAsync({ + sessionID: 's1', + directory: '/test', + agent: 'code', + parts: [{ type: 'text', text: 'hello' }], + }).catch((e: unknown) => e) + + expect(err).toBeInstanceOf(ForgeClientError) + expect((err as ForgeClientError).code).toBe('concurrent_prompt') + expect((err as ForgeClientError).kind).toBe('request') + expect((err as ForgeClientError).message).toContain('concurrent prompt in progress') + }) +}) + +describe('createV2ClientFromPluginInput', () => { + it('returns a v2 client constructed from the legacy PluginInput', async () => { + const fakeFetch = vi.fn() + const fakeHeaders = new Headers({ authorization: 'Bearer test-token' }) + + const fakePluginInput = { + client: { + _client: { + getConfig: () => ({ + fetch: fakeFetch, + headers: fakeHeaders, + }), + }, + } as unknown as PluginInput['client'], + directory: '/test/dir', + serverUrl: new URL('http://localhost:9999'), + project: { id: 'test-project', name: 'Test' }, + worktree: '/test/wt', + experimental_workspace: { register: () => {} }, + $: {} as never, + } as PluginInput + + const v2Client = createV2ClientFromPluginInput(fakePluginInput) + + // Structural smoke test: returned object has .session + expect(v2Client).toBeDefined() + expect(v2Client.session).toBeDefined() + // Can call session methods + expect(typeof v2Client.session.create).toBe('function') + }) +}) diff --git a/test/helpers/fake-client.test.ts b/test/helpers/fake-client.test.ts new file mode 100644 index 0000000000..eadbdc8f49 --- /dev/null +++ b/test/helpers/fake-client.test.ts @@ -0,0 +1,195 @@ +import { describe, test, expect } from 'bun:test' +import { createFakeForgeClient, type RecordedCall, type CreateFakeForgeClientResult } from './fake-client' +import type { ForgeClient } from '../../src/client/port' + +// --------------------------------------------------------------------------- +// Compile-time assertions — consumers never need `as any` +// --------------------------------------------------------------------------- +const _typeCheck: ForgeClient = createFakeForgeClient().client +const _typeCheck2: CreateFakeForgeClientResult = createFakeForgeClient() + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('createFakeForgeClient', () => { + // ── Defaults ────────────────────────────────────────────────────────────── + + test('session.create returns incremental IDs', async () => { + const { client } = createFakeForgeClient() + + const a = await client.session.create({ directory: '/tmp', title: 'session A' }) + const b = await client.session.create({ directory: '/tmp', title: 'session B' }) + + expect(a.id).toBe('ses_fake_1') + expect(b.id).toBe('ses_fake_2') + }) + + test('session.get returns a default session', async () => { + const { client } = createFakeForgeClient() + const result = await client.session.get({ sessionID: 's1', directory: '/tmp' }) + expect(result).toEqual({ id: 'ses_fake_1' }) + }) + + test('session.update resolves to undefined', async () => { + const { client } = createFakeForgeClient() + await expect(client.session.update({ sessionID: 's1', directory: '/tmp' })).resolves.toBeUndefined() + }) + + test('session.messages returns empty array', async () => { + const { client } = createFakeForgeClient() + const result = await client.session.messages({ sessionID: 's1', directory: '/tmp', limit: 10 }) + expect(result).toEqual([]) + }) + + test('session.status returns empty object', async () => { + const { client } = createFakeForgeClient() + const result = await client.session.status() + expect(result).toEqual({}) + }) + + test('session.promptAsync resolves to undefined', async () => { + const { client } = createFakeForgeClient() + await expect( + client.session.promptAsync({ + sessionID: 's1', + directory: '/tmp', + agent: 'code', + parts: [{ type: 'text', text: 'hello' }], + }), + ).resolves.toBeUndefined() + }) + + test('session.abort resolves to undefined', async () => { + const { client } = createFakeForgeClient() + await expect(client.session.abort({ sessionID: 's1' })).resolves.toBeUndefined() + }) + + test('session.delete resolves to undefined', async () => { + const { client } = createFakeForgeClient() + await expect(client.session.delete({ sessionID: 's1', directory: '/tmp' })).resolves.toBeUndefined() + }) + + test('workspace.create returns incremental IDs', async () => { + const { client } = createFakeForgeClient() + + const a = await client.workspace.create({ directory: '/a' }) + const b = await client.workspace.create({ directory: '/b' }) + + expect(a.id).toBe('ws_fake_1') + expect(b.id).toBe('ws_fake_2') + }) + + test('workspace.list returns empty array', async () => { + const { client } = createFakeForgeClient() + const result = await client.workspace.list() + expect(result).toEqual([]) + }) + + test('workspace.status returns empty object', async () => { + const { client } = createFakeForgeClient() + const result = await client.workspace.status() + expect(result).toEqual({}) + }) + + test('workspace.syncList resolves to undefined', async () => { + const { client } = createFakeForgeClient() + await expect(client.workspace.syncList()).resolves.toBeUndefined() + }) + + test('workspace.remove resolves to undefined', async () => { + const { client } = createFakeForgeClient() + await expect(client.workspace.remove({ id: 's1', directory: '/tmp' })).resolves.toBeUndefined() + }) + + test('workspace.warp resolves to undefined', async () => { + const { client } = createFakeForgeClient() + await expect(client.workspace.warp({ id: 'ws-1', sessionID: 's1' })).resolves.toBeUndefined() + }) + + test('tui.publish resolves to undefined', async () => { + const { client } = createFakeForgeClient() + await expect(client.tui.publish({ directory: '/tmp' })).resolves.toBeUndefined() + }) + + test('tui.selectSession resolves to undefined', async () => { + const { client } = createFakeForgeClient() + await expect(client.tui.selectSession({ sessionID: 's1' })).resolves.toBeUndefined() + }) + + test('sync.start resolves to undefined', async () => { + const { client } = createFakeForgeClient() + await expect(client.sync.start({ directory: '/tmp' })).resolves.toBeUndefined() + }) + + // ── Overrides ───────────────────────────────────────────────────────── + + test('overrides take effect for session.create', async () => { + const { client } = createFakeForgeClient({ + session: { create: async () => ({ id: 'custom-session' }) }, + }) + + const result = await client.session.create({ directory: '/tmp' }) + expect(result).toEqual({ id: 'custom-session' }) + }) + + test('overrides take effect for workspace.create', async () => { + const { client } = createFakeForgeClient({ + workspace: { create: async () => ({ id: 'custom-ws' }) }, + }) + + const result = await client.workspace.create({ directory: '/tmp' }) + expect(result).toEqual({ id: 'custom-ws' }) + }) + + test('overrides take effect for void methods', async () => { + const { client } = createFakeForgeClient({ + session: { update: async () => { throw new Error('override-error') } }, + }) + + await expect(client.session.update({ sessionID: 's1', directory: '/tmp' })).rejects.toThrow('override-error') + }) + + test('un-overridden methods still use defaults', async () => { + const { client } = createFakeForgeClient({ + session: { create: async () => ({ id: 'custom' }) }, + }) + + // create is overridden + const session = await client.session.create({ directory: '/tmp' }) + expect(session.id).toBe('custom') + + // get is still the default + const getResult = await client.session.get({ sessionID: 's1', directory: '/tmp' }) + expect(getResult.id).toBe('ses_fake_1') + }) + + // ── Call recording ───────────────────────────────────────────────────── + + test('calls are recorded in invocation order across namespaces', async () => { + const { client, calls } = createFakeForgeClient() + + await client.session.create({ directory: '/a' }) + await client.workspace.create({ directory: '/b' }) + await client.session.messages({ sessionID: 'a', directory: '/tmp', limit: 5 }) + await client.tui.publish({ directory: '/tmp' }) + + expect(calls).toHaveLength(4) + expect(calls[0]).toEqual({ method: 'session.create', params: { directory: '/a' } }) + expect(calls[1]).toEqual({ method: 'workspace.create', params: { directory: '/b' } }) + expect(calls[2]).toEqual({ method: 'session.messages', params: { sessionID: 'a', directory: '/tmp', limit: 5 } }) + expect(calls[3]).toEqual({ method: 'tui.publish', params: { directory: '/tmp' } }) + }) + + test('override invocations are still recorded', async () => { + const { client, calls } = createFakeForgeClient({ + session: { create: async () => ({ id: 'custom' }) }, + }) + + await client.session.create({ directory: '/tmp', title: 'test' }) + + expect(calls).toHaveLength(1) + expect(calls[0].method).toBe('session.create') + expect(calls[0].params).toEqual({ directory: '/tmp', title: 'test' }) + }) +}) diff --git a/test/helpers/fake-client.ts b/test/helpers/fake-client.ts new file mode 100644 index 0000000000..cc40d3c29b --- /dev/null +++ b/test/helpers/fake-client.ts @@ -0,0 +1,296 @@ +/** + * Fully typed `ForgeClient` test fake with cross-namespace call recording. + * + * Use this instead of hand-rolling mock clients in every test file. Every + * method is a `vi.fn()` (via `mock` from `bun:test`) that resolves a sensible + * default. The returned `calls` array records every method invocation in order + * across all namespaces, so tests can assert sequencing. + * + * @example + * ```ts + * const { client, calls } = createFakeForgeClient() + * await client.session.create({ sessionID: 's1', directory: '/tmp' }) + * expect(calls[0].method).toBe('session.create') + * ``` + * + * @example + * ```ts + * // Override a specific method — the override replaces the default impl but + * // calls are still recorded in the `calls` array. + * const { client } = createFakeForgeClient({ + * session: { create: async () => ({ id: 'custom' }) }, + * }) + * ``` + */ + +import { mock } from 'bun:test' +import type { ForgeClient } from '../../src/client/port' + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/** + * Recursive `Partial` so callers only need to specify the methods they want to + * override at any nesting level. + */ +type DeepPartial = T extends (...args: unknown[]) => unknown + ? (...args: any[]) => any + : T extends object + ? { [P in keyof T]?: DeepPartial } + : T + +// ── Public types ────────────────────────────────────────────────────────────── + +export interface RecordedCall { + method: string + params: unknown +} + +export interface CreateFakeForgeClientResult { + client: ForgeClient + /** + * Every method invocation, in call order, across all namespaces. Useful for + * sequencing assertions that span `session`, `workspace`, `tui`, and `sync`. + */ + calls: RecordedCall[] +} + +// ── Factory ─────────────────────────────────────────────────────────────────── + +let globalSessionCounter = 0 +let globalWorkspaceCounter = 0 + +export function createFakeForgeClient( + overrides?: DeepPartial, +): CreateFakeForgeClientResult { + const calls: RecordedCall[] = [] + + // Reset per-call counters so each factory invocation starts clean. + globalSessionCounter = 0 + globalWorkspaceCounter = 0 + + /** + * Create a `mock()` with call recording. If an `overrideImpl` is provided it + * replaces the default implementation, but the resulting `mock()` still + * records invocations to the shared `calls` array. + */ + function makeMethod unknown>( + methodPath: string, + defaultImpl: T, + overrideImpl?: T, + ): T & ReturnType { + const impl = overrideImpl ?? defaultImpl + return mock(((...args: unknown[]) => { + calls.push({ method: methodPath, params: args[0] }) + return impl(...args) + }) as any) as any + } + + const client: ForgeClient = { + session: { + create: makeMethod( + 'session.create', + async (_p: Record) => ({ + id: `ses_fake_${++globalSessionCounter}`, + }), + overrides?.session?.create, + ), + get: makeMethod( + 'session.get', + async (_p: Record) => ({ + id: 'ses_fake_1', + }), + overrides?.session?.get, + ), + update: makeMethod( + 'session.update', + async (_p: Record) => {}, + overrides?.session?.update, + ), + messages: makeMethod( + 'session.messages', + async (_p: Record) => [], + overrides?.session?.messages, + ), + status: makeMethod( + 'session.status', + async (_p: Record) => ({}), + overrides?.session?.status, + ), + promptAsync: makeMethod( + 'session.promptAsync', + async (_p: Record) => {}, + overrides?.session?.promptAsync, + ), + abort: makeMethod( + 'session.abort', + async (_p: Record) => {}, + overrides?.session?.abort, + ), + delete: makeMethod( + 'session.delete', + async (_p: Record) => {}, + overrides?.session?.delete, + ), + }, + workspace: { + create: makeMethod( + 'workspace.create', + async (_p: Record) => ({ + id: `ws_fake_${++globalWorkspaceCounter}`, + directory: '/tmp/fake-workspace-dir', + branch: 'fake-workspace-branch', + }), + overrides?.workspace?.create, + ), + list: makeMethod( + 'workspace.list', + async (_p: Record) => [], + overrides?.workspace?.list, + ), + status: makeMethod( + 'workspace.status', + async (_p: Record) => ({}), + overrides?.workspace?.status, + ), + syncList: makeMethod( + 'workspace.syncList', + async (_p: Record) => {}, + overrides?.workspace?.syncList, + ), + remove: makeMethod( + 'workspace.remove', + async (_p: Record) => {}, + overrides?.workspace?.remove, + ), + warp: makeMethod( + 'workspace.warp', + async (_p: Record) => {}, + overrides?.workspace?.warp, + ), + }, + tui: { + publish: makeMethod( + 'tui.publish', + async (_p: Record) => {}, + overrides?.tui?.publish, + ), + selectSession: makeMethod( + 'tui.selectSession', + async (_p: Record) => {}, + overrides?.tui?.selectSession, + ), + }, + sync: { + start: makeMethod( + 'sync.start', + async (_p: Record) => {}, + overrides?.sync?.start, + ), + }, + } + + return { client, calls } +} + +/** + * Convenience: create a mock ForgeClient from an existing v2-style mock that + * has `session.*`, `tui.*`, `experimental.workspace.*` methods. This lets + * tests that already have a hand-rolled `mockV2Client` also satisfy the + * `client: ForgeClient` field without rewriting every assertion. + * + * The returned object delegates calls to the v2 mock's methods, stripping the + * `{ data, error }` envelope so the ForgeClient contract (data-or-throw) is + * upheld. + */ +type V2Mock = { + session?: Record + tui?: Record + experimental?: { workspace?: Record } +} + +function unwrapResult(result: any): any { + if (result?.error) throw result.error + if (result?.data === undefined || result?.data === null) throw Object.assign(new Error('not found'), { kind: 'not-found' }) + return result.data +} + +export function forgeClientFromV2Mock(v2Mock: V2Mock): ForgeClient { + const ws = v2Mock.experimental?.workspace ?? {} + const sess = v2Mock.session ?? {} + const tuiNs = v2Mock.tui ?? {} + return { + session: { + create: async (params: any) => { + if (!sess.create) throw new Error('session.create not available') + return unwrapResult(await sess.create(params)) + }, + get: async (params: any) => { + if (!sess.get) throw new Error('session.get not available') + return unwrapResult(await sess.get(params)) + }, + update: async (params: any) => { + if (!sess.update) throw new Error('session.update not available') + return unwrapResult(await sess.update(params)) + }, + messages: async (params: any) => { + if (!sess.messages) throw new Error('session.messages not available') + return unwrapResult(await sess.messages(params)) + }, + status: async (params: any) => { + if (!sess.status) throw new Error('session.status not available') + return unwrapResult(await sess.status(params)) + }, + promptAsync: async (params: any) => { + if (!sess.promptAsync) throw new Error('session.promptAsync not available') + const result = await sess.promptAsync(params) + if (result?.error) throw result.error + }, + abort: async (params: any) => { + if (!sess.abort) throw new Error('session.abort not available') + await sess.abort(params) + }, + delete: async (params: any) => { + if (!sess.delete) throw new Error('session.delete not available') + await sess.delete(params) + }, + }, + workspace: { + create: async (params: any) => { + if (!ws.create) throw new Error('workspace.create not available') + return unwrapResult(await ws.create(params)) + }, + list: async (params?: any) => { + if (!ws.list) throw new Error('workspace.list not available') + return unwrapResult(await ws.list(params)) ?? [] + }, + status: async (params?: any) => { + if (!ws.status) throw new Error('workspace.status not available') + return unwrapResult(await ws.status(params)) ?? {} + }, + syncList: async (params?: any) => { + if (!ws.syncList) throw new Error('workspace.syncList not available') + await ws.syncList(params) + }, + remove: async (params: any) => { + if (!ws.remove) throw new Error('workspace.remove not available') + await ws.remove(params) + }, + warp: async (params: any) => { + if (!ws.warp) throw new Error('workspace.warp not available') + const result = await ws.warp(params) + if (result?.error) throw result.error + }, + }, + tui: { + publish: async (params: any) => { + if (!tuiNs.publish) throw new Error('tui.publish not available') + await tuiNs.publish(params) + }, + selectSession: async (params: any) => { + if (!tuiNs.selectSession) throw new Error('tui.selectSession not available') + await tuiNs.selectSession(params) + }, + }, + sync: {} as any, + } +} diff --git a/test/hooks/audit-rotate-ordering.test.ts b/test/hooks/audit-rotate-ordering.test.ts index d86ff3b4b4..aaaae7041c 100644 --- a/test/hooks/audit-rotate-ordering.test.ts +++ b/test/hooks/audit-rotate-ordering.test.ts @@ -8,6 +8,7 @@ import { createReviewFindingsRepo } from '../../src/storage/repos/review-finding import { createLoopService } from '../../src/loop/service' import type { Logger } from '../../src/types' import type { LoopState } from '../../src/loop/state' +import type { ForgeClient } from '../../src/client/port' interface Database { run: (sql: string) => void @@ -273,86 +274,46 @@ interface CallRecord { args: unknown } -interface MockV2Client { - session: { - create: ReturnType Promise<{ data?: { id: string }; error?: unknown }>>> - promptAsync: ReturnType Promise<{ data?: unknown; error?: unknown }>>> - delete: ReturnType Promise<{ data?: boolean }>>> - get: ReturnType Promise<{ data?: { permission?: unknown }; error?: unknown }>>> - messages: ReturnType Promise<{ data?: any[]; error?: unknown }>>> - abort: ReturnType Promise>> - } - experimental: { - workspace: { - create: ReturnType Promise<{ data?: { id: string }; error?: unknown }>>> - warp: ReturnType Promise<{ data?: unknown; error?: unknown }>>> - } - } - tui: { - selectSession: ReturnType Promise>> - publish: ReturnType Promise>> - } -} - -interface MockClient { - session: { - create: ReturnType Promise<{ data?: { id: string } }>>> - messages: ReturnType Promise<{ data?: any[] }>>> - promptAsync: ReturnType Promise<{ data?: unknown; error?: unknown }>>> - } -} - -function createMockV2Client(callTracker?: CallRecord[], messagesRole?: 'assistant' | 'user'): MockV2Client { - const tracker = callTracker ?? [] - const lastRole = messagesRole ?? 'assistant' +/** + * Build a ForgeClient from the same tracker so all call records are unified. + * @param lastRole - The role of the last message in the messages response ('assistant' or 'user') + */ +function forgeFromTracker(tracker: CallRecord[], lastRole: 'assistant' | 'user' = 'assistant'): ForgeClient { return { session: { - create: vi.fn(async (params: any) => { - tracker.push({ kind: 'create', args: params }) - return { data: { id: 'new-code-1' } } - }), - promptAsync: vi.fn(async (params: any) => { - tracker.push({ kind: 'prompt', args: params }) - return { data: {} } + create: vi.fn(async (params: any) => { tracker.push({ kind: 'create', args: params }); return { id: 'new-code-1' } }), + get: vi.fn(async () => ({ id: 'sess' })), + update: vi.fn(async () => {}), + messages: vi.fn(async (params: any) => { + tracker.push({ kind: 'messages', args: params }) + return [ + { info: { role: 'user' }, parts: [{ type: 'text', text: 'test' }] }, + ...(lastRole === 'assistant' + ? [{ info: { role: 'assistant' as const, finish: 'stop' as const }, parts: [{ type: 'text' as const, text: 'response' }] }] + : []), + ] }), - delete: vi.fn(async (params: any) => { - tracker.push({ kind: 'delete', args: params }) - return { data: true } - }), - get: vi.fn(async () => ({ data: { permission: {} } })), - messages: vi.fn(async () => ({ data: [ - { info: { role: 'user' }, parts: [{ type: 'text', text: 'test' }] }, - ...(lastRole === 'assistant' ? [{ info: { role: 'assistant', finish: 'stop' }, parts: [{ type: 'text', text: 'audit findings: none' }] }] : []), - ] })), + status: vi.fn(async () => ({})), + promptAsync: vi.fn(async (params: any) => { tracker.push({ kind: 'prompt', args: params }) }), abort: vi.fn(async () => {}), + delete: vi.fn(async (params: any) => { tracker.push({ kind: 'delete', args: params }) }), }, - experimental: { - workspace: { - create: vi.fn(async (params: any) => { - tracker.push({ kind: 'workspace-create', args: params }) - return { data: { id: 'ws-1' } } - }), - warp: vi.fn(async (params: any) => { - tracker.push({ kind: 'restore', args: params }) - return { data: {} } - }), - }, + workspace: { + create: vi.fn(async (params: any) => { tracker.push({ kind: 'workspace-create', args: params }); return { id: 'ws-1' } }), + list: vi.fn(async () => []), + status: vi.fn(async () => ({})), + syncList: vi.fn(async () => {}), + remove: vi.fn(async () => {}), + warp: vi.fn(async (params: any) => { tracker.push({ kind: 'restore', args: params }) }), }, tui: { - selectSession: vi.fn(async () => {}), publish: vi.fn(async () => {}), + selectSession: vi.fn(async () => {}), }, - } -} - -function createMockClient(): MockClient { - return { - session: { - create: vi.fn(() => Promise.resolve({ data: { id: 'sess_mock_123' } })), - messages: vi.fn(() => Promise.resolve({ data: [] })), - promptAsync: vi.fn(() => Promise.resolve({ data: {} })), + sync: { + start: vi.fn(async () => {}), }, - } + } as unknown as ForgeClient } describe('audit→code rotation ordering', () => { @@ -362,8 +323,6 @@ describe('audit→code rotation ordering', () => { let reviewFindingsRepo: ReturnType let loopService: ReturnType let tempDir: string - let mockV2: MockV2Client - let mockClient: MockClient let callTracker: CallRecord[] const projectId = 'test-project' @@ -389,8 +348,6 @@ describe('audit→code rotation ordering', () => { loopService = createLoopService(loopsRepo, plansRepo, reviewFindingsRepo, projectId, mockLogger) callTracker = [] - mockV2 = createMockV2Client(callTracker) - mockClient = createMockClient() }) afterEach(() => { @@ -449,7 +406,6 @@ describe('audit→code rotation ordering', () => { // Create mock that returns successful assistant response to exercise the // successful audit→code rotation path (not the error continuation path) const successCallTracker: CallRecord[] = [] - const successMockV2 = createMockV2Client(successCallTracker, 'assistant') // Use default successful assistant response (no error) to test the // !assistantErrorDetected branch in handleAuditingPhase @@ -459,8 +415,7 @@ describe('audit→code rotation ordering', () => { plansRepo, reviewFindingsRepo, projectId, - mockClient as any, - successMockV2 as any, + forgeFromTracker(successCallTracker), mockLogger, () => ({ loop: { model: 'test/test-model' } }), undefined, @@ -530,7 +485,6 @@ describe('audit→code rotation ordering', () => { // Create a separate mock for failure path - no assistant message so it triggers rotation const failureCallTracker: CallRecord[] = [] - const failureMockV2 = createMockV2Client(failureCallTracker, 'user') const { createLoopEventHandler } = await import('../../src/hooks/loop') const handler = createLoopEventHandler( @@ -538,8 +492,7 @@ describe('audit→code rotation ordering', () => { plansRepo, reviewFindingsRepo, projectId, - mockClient as any, - failureMockV2 as any, + forgeFromTracker(failureCallTracker, 'user'), mockLogger, () => ({ loop: { model: 'test/test-model' } }), undefined, diff --git a/test/hooks/forge-session-attach.test.ts b/test/hooks/forge-session-attach.test.ts index be368f1747..6897a4ee93 100644 --- a/test/hooks/forge-session-attach.test.ts +++ b/test/hooks/forge-session-attach.test.ts @@ -3,6 +3,7 @@ import { describe, test, expect, beforeEach, vi } from 'vitest' const mockAttachLoop = vi.fn().mockResolvedValue({ ok: true, loopName: 'test-loop' }) import { createForgeSessionAttachHook, createForgeSessionMessageAttachHook } from '../../src/hooks/forge-session-attach' +import { createFakeForgeClient } from '../helpers/fake-client' describe('createForgeSessionAttachHook', () => { beforeEach(() => { @@ -11,7 +12,7 @@ describe('createForgeSessionAttachHook', () => { }) function buildHookDeps(overrides?: { - workspaceList?: () => Promise<{ data?: unknown[] }> + workspaceList?: () => Promise workspaceRemove?: ReturnType tuiPublish?: ReturnType sessionGet?: ReturnType @@ -24,21 +25,21 @@ describe('createForgeSessionAttachHook', () => { const loggerErrorSpy = overrides?.loggerErrorSpy ?? vi.fn() const loggerLogSpy = overrides?.loggerLogSpy ?? vi.fn() - return { - v2: { - experimental: { - workspace: { - list: overrides?.workspaceList ?? vi.fn().mockResolvedValue({ data: [] }), - remove: overrides?.workspaceRemove ?? vi.fn().mockResolvedValue({ data: {} }), - }, - }, - tui: { - publish: overrides?.tuiPublish ?? vi.fn().mockResolvedValue({ data: {} }), - }, - session: { - get: overrides?.sessionGet ?? vi.fn().mockResolvedValue({ data: null }), - }, + const { client } = createFakeForgeClient({ + workspace: { + list: overrides?.workspaceList ?? (async () => []), + remove: overrides?.workspaceRemove ?? (async () => {}), + }, + tui: { + publish: overrides?.tuiPublish ?? (async () => {}), }, + session: { + get: overrides?.sessionGet ?? (async () => { throw Object.assign(new Error('not found'), { kind: 'not-found' }) }), + }, + }) + + return { + client, execDeps: { plansRepo: { getForSession: overrides?.plansRepoGetForSession ?? vi.fn().mockReturnValue(null), @@ -69,28 +70,26 @@ describe('createForgeSessionAttachHook', () => { const getForSessionMock = vi.fn().mockReturnValue({ content: '# Plan\n\nDo stuff.' }) const loopsRepoGetMock = vi.fn().mockReturnValue(null) const deps = buildHookDeps({ - workspaceList: vi.fn().mockResolvedValue({ - data: [ - { - id: 'ws_forge', - type: 'forge', - directory: '/tmp/wt/forge', - extra: { - loopName: 'my-feature', - projectDirectory: '/tmp/wt/forge', - forgeLoop: { - hostSessionId: 'host_sess', - title: 'My Feature', - executionModel: 'prov/exec', - auditorModel: 'prov/aud', - planSource: 'stored', - maxIterations: 50, - sandboxEnabled: false, - }, + workspaceList: vi.fn().mockResolvedValue([ + { + id: 'ws_forge', + type: 'forge', + directory: '/tmp/wt/forge', + extra: { + loopName: 'my-feature', + projectDirectory: '/tmp/wt/forge', + forgeLoop: { + hostSessionId: 'host_sess', + title: 'My Feature', + executionModel: 'prov/exec', + auditorModel: 'prov/aud', + planSource: 'stored', + maxIterations: 50, + sandboxEnabled: false, }, }, - ], - }), + }, + ]), plansRepoGetForSession: getForSessionMock, loopsRepoGet: loopsRepoGetMock, }) @@ -126,32 +125,28 @@ describe('createForgeSessionAttachHook', () => { const loopsRepoGetMock = vi.fn().mockReturnValue(null) const deps = buildHookDeps({ sessionGet: vi.fn().mockResolvedValue({ - data: { - id: 'new_sess', - workspaceID: 'ws_inline', - directory: '/tmp/wt/inline', - projectID: 'proj_1', - }, + id: 'new_sess', + workspaceID: 'ws_inline', + directory: '/tmp/wt/inline', + projectID: 'proj_1', }), - workspaceList: vi.fn().mockResolvedValue({ - data: [ - { - id: 'ws_inline', - type: 'forge', - directory: '/tmp/wt/inline', - extra: { - loopName: 'inline-loop', - forgeLoop: { - title: 'Inline Loop', - planSource: 'inline', - planText: '# Inline Plan\n\nInline stuff.', - initialPromptOwner: 'tui', - pendingAttachStartedAt: Date.now(), - }, + workspaceList: vi.fn().mockResolvedValue([ + { + id: 'ws_inline', + type: 'forge', + directory: '/tmp/wt/inline', + extra: { + loopName: 'inline-loop', + forgeLoop: { + title: 'Inline Loop', + planSource: 'inline', + planText: '# Inline Plan\n\nInline stuff.', + initialPromptOwner: 'tui', + pendingAttachStartedAt: Date.now(), }, }, - ], - }), + }, + ]), loopsRepoGet: loopsRepoGetMock, }) @@ -183,32 +178,28 @@ describe('createForgeSessionAttachHook', () => { const deps = buildHookDeps({ sandboxManager: { restore, getActive }, sessionGet: vi.fn().mockResolvedValue({ - data: { - id: 'new_sess', - workspaceID: 'ws_inline', - directory: '/tmp/wt/inline', - projectID: 'proj_1', - }, + id: 'new_sess', + workspaceID: 'ws_inline', + directory: '/tmp/wt/inline', + projectID: 'proj_1', }), - workspaceList: vi.fn().mockResolvedValue({ - data: [ - { - id: 'ws_inline', - type: 'forge', - directory: '/tmp/wt/inline', - extra: { - loopName: 'inline-loop', - forgeLoop: { - title: 'Inline Loop', - planSource: 'inline', - planText: '# Inline Plan\n\nInline stuff.', - initialPromptOwner: 'tui', - pendingAttachStartedAt: Date.now(), - }, + workspaceList: vi.fn().mockResolvedValue([ + { + id: 'ws_inline', + type: 'forge', + directory: '/tmp/wt/inline', + extra: { + loopName: 'inline-loop', + forgeLoop: { + title: 'Inline Loop', + planSource: 'inline', + planText: '# Inline Plan\n\nInline stuff.', + initialPromptOwner: 'tui', + pendingAttachStartedAt: Date.now(), }, }, - ], - }), + }, + ]), loopsRepoGet: vi.fn().mockReturnValue(null), }) @@ -224,39 +215,35 @@ describe('createForgeSessionAttachHook', () => { }) test('chat.message fallback removes expired pending attach workspace without binding', async () => { - const workspaceRemove = vi.fn().mockResolvedValue({ data: {} }) - const tuiPublish = vi.fn().mockResolvedValue({ data: {} }) + const workspaceRemove = vi.fn().mockResolvedValue(undefined) + const tuiPublish = vi.fn().mockResolvedValue(undefined) const deps = buildHookDeps({ workspaceRemove, tuiPublish, sessionGet: vi.fn().mockResolvedValue({ - data: { - id: 'new_sess', - workspaceID: 'ws_expired', - directory: '/tmp/wt/expired', - projectID: 'proj_1', - }, + id: 'new_sess', + workspaceID: 'ws_expired', + directory: '/tmp/wt/expired', + projectID: 'proj_1', }), - workspaceList: vi.fn().mockResolvedValue({ - data: [ - { - id: 'ws_expired', - type: 'forge', - directory: '/tmp/wt/expired', - extra: { - loopName: 'expired-loop', - projectDirectory: '/tmp/wt/expired', - forgeLoop: { - title: 'Expired Loop', - planSource: 'inline', - planText: '# Inline Plan', - initialPromptOwner: 'tui', - pendingAttachStartedAt: Date.now() - (10 * 60 * 1000), - }, + workspaceList: vi.fn().mockResolvedValue([ + { + id: 'ws_expired', + type: 'forge', + directory: '/tmp/wt/expired', + extra: { + loopName: 'expired-loop', + projectDirectory: '/tmp/wt/expired', + forgeLoop: { + title: 'Expired Loop', + planSource: 'inline', + planText: '# Inline Plan', + initialPromptOwner: 'tui', + pendingAttachStartedAt: Date.now() - (10 * 60 * 1000), }, }, - ], - }), + }, + ]), }) const handler = createForgeSessionMessageAttachHook(deps as any) @@ -279,7 +266,7 @@ describe('createForgeSessionAttachHook', () => { }) test('attach conflict with restartable terminal row removes registration only', async () => { - const workspaceRemove = vi.fn().mockResolvedValue({ data: {} }) + const workspaceRemove = vi.fn().mockResolvedValue(undefined) const loopsRepoGet = vi.fn() .mockReturnValueOnce(null) .mockReturnValueOnce({ projectId: 'proj_1', loopName: 'race-loop', status: 'cancelled' }) @@ -287,24 +274,22 @@ describe('createForgeSessionAttachHook', () => { const deps = buildHookDeps({ workspaceRemove, loopsRepoGet, - workspaceList: vi.fn().mockResolvedValue({ - data: [ - { - id: 'ws_race', - type: 'forge', - directory: '/tmp/wt/race', - extra: { - loopName: 'race-loop', - projectDirectory: '/tmp/wt/race', - forgeLoop: { - title: 'Race Loop', - planSource: 'inline', - planText: '# Plan', - }, + workspaceList: vi.fn().mockResolvedValue([ + { + id: 'ws_race', + type: 'forge', + directory: '/tmp/wt/race', + extra: { + loopName: 'race-loop', + projectDirectory: '/tmp/wt/race', + forgeLoop: { + title: 'Race Loop', + planSource: 'inline', + planText: '# Plan', }, }, - ], - }), + }, + ]), }) const handler = createForgeSessionAttachHook(deps as any) @@ -332,31 +317,27 @@ describe('createForgeSessionAttachHook', () => { const deps = buildHookDeps({ loopsRepoGet, sessionGet: vi.fn().mockResolvedValue({ - data: { - id: 'new_sess', - workspaceID: 'ws_inline', - directory: '/tmp/wt/inline', - projectID: 'proj_1', - }, + id: 'new_sess', + workspaceID: 'ws_inline', + directory: '/tmp/wt/inline', + projectID: 'proj_1', }), - workspaceList: vi.fn().mockResolvedValue({ - data: [ - { - id: 'ws_inline', - type: 'forge', - directory: '/tmp/wt/inline', - extra: { - loopName: 'inline-loop', - forgeLoop: { - title: 'Inline Loop', - planSource: 'inline', - planText: '# Inline Plan\n\nInline stuff.', - initialPromptOwner: 'tui', - }, + workspaceList: vi.fn().mockResolvedValue([ + { + id: 'ws_inline', + type: 'forge', + directory: '/tmp/wt/inline', + extra: { + loopName: 'inline-loop', + forgeLoop: { + title: 'Inline Loop', + planSource: 'inline', + planText: '# Inline Plan\n\nInline stuff.', + initialPromptOwner: 'tui', }, }, - ], - }), + }, + ]), }) const handler = createForgeSessionMessageAttachHook(deps as any) @@ -368,24 +349,22 @@ describe('createForgeSessionAttachHook', () => { test('inline planSource resolves planText inline', async () => { const deps = buildHookDeps({ - workspaceList: vi.fn().mockResolvedValue({ - data: [ - { - id: 'ws_inline', - type: 'forge', - directory: '/tmp/wt/inline', - extra: { - loopName: 'inline-loop', - projectDirectory: '/tmp/wt/inline', - forgeLoop: { - title: 'Inline Loop', - planSource: 'inline', - planText: '# Inline Plan\n\nInline stuff.', - }, + workspaceList: vi.fn().mockResolvedValue([ + { + id: 'ws_inline', + type: 'forge', + directory: '/tmp/wt/inline', + extra: { + loopName: 'inline-loop', + projectDirectory: '/tmp/wt/inline', + forgeLoop: { + title: 'Inline Loop', + planSource: 'inline', + planText: '# Inline Plan\n\nInline stuff.', }, }, - ], - }), + }, + ]), }) const handler = createForgeSessionAttachHook(deps as any) @@ -406,7 +385,7 @@ describe('createForgeSessionAttachHook', () => { test('workspace not found returns silently', async () => { const deps = buildHookDeps({ - workspaceList: vi.fn().mockResolvedValue({ data: [] }), + workspaceList: vi.fn().mockResolvedValue([]), }) const handler = createForgeSessionAttachHook(deps as any) @@ -425,16 +404,14 @@ describe('createForgeSessionAttachHook', () => { test('non-forge workspace returns silently', async () => { const deps = buildHookDeps({ - workspaceList: vi.fn().mockResolvedValue({ - data: [ - { - id: 'ws_worktree', - type: 'worktree', - directory: '/tmp/wt/worktree', - extra: {}, - }, - ], - }), + workspaceList: vi.fn().mockResolvedValue([ + { + id: 'ws_worktree', + type: 'worktree', + directory: '/tmp/wt/worktree', + extra: {}, + }, + ]), }) const handler = createForgeSessionAttachHook(deps as any) @@ -453,20 +430,18 @@ describe('createForgeSessionAttachHook', () => { test('unrelated event type (session.updated) returns silently', async () => { const deps = buildHookDeps({ - workspaceList: vi.fn().mockResolvedValue({ - data: [ - { - id: 'ws_test', - type: 'forge', - directory: '/tmp/wt/test', - extra: { - loopName: 'test-loop', - projectDirectory: '/tmp/wt/test', - forgeLoop: {}, - }, + workspaceList: vi.fn().mockResolvedValue([ + { + id: 'ws_test', + type: 'forge', + directory: '/tmp/wt/test', + extra: { + loopName: 'test-loop', + projectDirectory: '/tmp/wt/test', + forgeLoop: {}, }, - ], - }), + }, + ]), }) const handler = createForgeSessionAttachHook(deps as any) @@ -485,19 +460,17 @@ describe('createForgeSessionAttachHook', () => { test('missing sessionId/workspaceId returns silently', async () => { const deps = buildHookDeps({ - workspaceList: vi.fn().mockResolvedValue({ - data: [ - { - id: 'ws_test', - type: 'forge', - directory: '/tmp/wt/test', - extra: { - loopName: 'test-loop', - forgeLoop: {}, - }, + workspaceList: vi.fn().mockResolvedValue([ + { + id: 'ws_test', + type: 'forge', + directory: '/tmp/wt/test', + extra: { + loopName: 'test-loop', + forgeLoop: {}, }, - ], - }), + }, + ]), }) const handler = createForgeSessionAttachHook(deps as any) @@ -523,16 +496,14 @@ describe('createForgeSessionAttachHook', () => { test('no forgeLoop extra returns silently', async () => { const deps = buildHookDeps({ - workspaceList: vi.fn().mockResolvedValue({ - data: [ - { - id: 'ws_no_extra', - type: 'forge', - directory: '/tmp/wt/no-extra', - extra: {}, - }, - ], - }), + workspaceList: vi.fn().mockResolvedValue([ + { + id: 'ws_no_extra', + type: 'forge', + directory: '/tmp/wt/no-extra', + extra: {}, + }, + ]), }) const handler = createForgeSessionAttachHook(deps as any) @@ -550,24 +521,22 @@ describe('createForgeSessionAttachHook', () => { }) test('missing-row workspace with loopName but no forgeLoop config is removed as stale', async () => { - const workspaceRemove = vi.fn().mockResolvedValue({ data: {} }) - const tuiPublish = vi.fn().mockResolvedValue({ data: {} }) + const workspaceRemove = vi.fn().mockResolvedValue(undefined) + const tuiPublish = vi.fn().mockResolvedValue(undefined) const deps = buildHookDeps({ workspaceRemove, tuiPublish, - workspaceList: vi.fn().mockResolvedValue({ - data: [ - { - id: 'ws_no_config', - type: 'forge', - directory: '/tmp/wt/no-config', - extra: { - loopName: 'no-config-loop', - projectDirectory: '/tmp/wt/no-config', - }, + workspaceList: vi.fn().mockResolvedValue([ + { + id: 'ws_no_config', + type: 'forge', + directory: '/tmp/wt/no-config', + extra: { + loopName: 'no-config-loop', + projectDirectory: '/tmp/wt/no-config', }, - ], - }), + }, + ]), }) const handler = createForgeSessionAttachHook(deps as any) @@ -592,26 +561,24 @@ describe('createForgeSessionAttachHook', () => { test('stored plan missing logs error, removes orphan workspace, and publishes toast', async () => { const loggerErrorSpy = vi.fn() - const workspaceRemove = vi.fn().mockResolvedValue({ data: {} }) - const tuiPublish = vi.fn().mockResolvedValue({ data: {} }) + const workspaceRemove = vi.fn().mockResolvedValue(undefined) + const tuiPublish = vi.fn().mockResolvedValue(undefined) const deps = buildHookDeps({ - workspaceList: vi.fn().mockResolvedValue({ - data: [ - { - id: 'ws_stored', - type: 'forge', - directory: '/tmp/wt/stored', - extra: { - loopName: 'stored-loop', - projectDirectory: '/tmp/wt/stored', - forgeLoop: { - planSource: 'stored', - hostSessionId: 'host_sess', - }, + workspaceList: vi.fn().mockResolvedValue([ + { + id: 'ws_stored', + type: 'forge', + directory: '/tmp/wt/stored', + extra: { + loopName: 'stored-loop', + projectDirectory: '/tmp/wt/stored', + forgeLoop: { + planSource: 'stored', + hostSessionId: 'host_sess', }, }, - ], - }), + }, + ]), plansRepoGetForSession: vi.fn().mockReturnValue(null), loggerErrorSpy, workspaceRemove, @@ -651,24 +618,22 @@ describe('createForgeSessionAttachHook', () => { test('empty-string cfg.hostSessionId falls through to event sessionId for plan lookup', async () => { const plansRepoGetForSession = vi.fn().mockReturnValue({ content: '# Plan\n\nFrom event session.' }) const deps = buildHookDeps({ - workspaceList: vi.fn().mockResolvedValue({ - data: [ - { - id: 'ws_empty_host', - type: 'forge', - directory: '/tmp/wt/empty-host', - extra: { - loopName: 'empty-host-loop', - projectDirectory: '/tmp/wt/empty-host', - forgeLoop: { - hostSessionId: '', - title: 'Empty Host', - planSource: 'stored', - }, + workspaceList: vi.fn().mockResolvedValue([ + { + id: 'ws_empty_host', + type: 'forge', + directory: '/tmp/wt/empty-host', + extra: { + loopName: 'empty-host-loop', + projectDirectory: '/tmp/wt/empty-host', + forgeLoop: { + hostSessionId: '', + title: 'Empty Host', + planSource: 'stored', }, }, - ], - }), + }, + ]), plansRepoGetForSession, }) @@ -692,28 +657,26 @@ describe('createForgeSessionAttachHook', () => { test('attachLoopToSession throw is caught, logged, and orphan workspace removed', async () => { const loggerErrorSpy = vi.fn() - const workspaceRemove = vi.fn().mockResolvedValue({ data: {} }) - const tuiPublish = vi.fn().mockResolvedValue({ data: {} }) + const workspaceRemove = vi.fn().mockResolvedValue(undefined) + const tuiPublish = vi.fn().mockResolvedValue(undefined) mockAttachLoop.mockRejectedValueOnce(new Error('boom')) const deps = buildHookDeps({ - workspaceList: vi.fn().mockResolvedValue({ - data: [ - { - id: 'ws_err', - type: 'forge', - directory: '/tmp/wt/err', - extra: { - loopName: 'err-loop', - projectDirectory: '/tmp/wt/err', - forgeLoop: { - title: 'Err Loop', - planSource: 'inline', - planText: '# Plan', - }, + workspaceList: vi.fn().mockResolvedValue([ + { + id: 'ws_err', + type: 'forge', + directory: '/tmp/wt/err', + extra: { + loopName: 'err-loop', + projectDirectory: '/tmp/wt/err', + forgeLoop: { + title: 'Err Loop', + planSource: 'inline', + planText: '# Plan', }, }, - ], - }), + }, + ]), loggerErrorSpy, workspaceRemove, tuiPublish, @@ -741,28 +704,26 @@ describe('createForgeSessionAttachHook', () => { }) test('attachLoopToSession returns ok:false (non-already_attached) triggers orphan cleanup', async () => { - const workspaceRemove = vi.fn().mockResolvedValue({ data: {} }) - const tuiPublish = vi.fn().mockResolvedValue({ data: {} }) + const workspaceRemove = vi.fn().mockResolvedValue(undefined) + const tuiPublish = vi.fn().mockResolvedValue(undefined) mockAttachLoop.mockResolvedValueOnce({ ok: false, code: 'prompt_failed', message: 'prompt blew up' }) const deps = buildHookDeps({ - workspaceList: vi.fn().mockResolvedValue({ - data: [ - { - id: 'ws_fail', - type: 'forge', - directory: '/tmp/wt/fail', - extra: { - loopName: 'fail-loop', - projectDirectory: '/tmp/wt/fail', - forgeLoop: { - title: 'Fail Loop', - planSource: 'inline', - planText: '# Plan', - }, + workspaceList: vi.fn().mockResolvedValue([ + { + id: 'ws_fail', + type: 'forge', + directory: '/tmp/wt/fail', + extra: { + loopName: 'fail-loop', + projectDirectory: '/tmp/wt/fail', + forgeLoop: { + title: 'Fail Loop', + planSource: 'inline', + planText: '# Plan', }, }, - ], - }), + }, + ]), workspaceRemove, tuiPublish, }) @@ -792,28 +753,26 @@ describe('createForgeSessionAttachHook', () => { }) test('attachLoopToSession returns already_attached does NOT remove workspace', async () => { - const workspaceRemove = vi.fn().mockResolvedValue({ data: {} }) - const tuiPublish = vi.fn().mockResolvedValue({ data: {} }) + const workspaceRemove = vi.fn().mockResolvedValue(undefined) + const tuiPublish = vi.fn().mockResolvedValue(undefined) mockAttachLoop.mockResolvedValueOnce({ ok: false, code: 'already_attached', message: 'already attached' }) const deps = buildHookDeps({ - workspaceList: vi.fn().mockResolvedValue({ - data: [ - { - id: 'ws_dup', - type: 'forge', - directory: '/tmp/wt/dup', - extra: { - loopName: 'dup-loop', - projectDirectory: '/tmp/wt/dup', - forgeLoop: { - title: 'Dup Loop', - planSource: 'inline', - planText: '# Plan', - }, + workspaceList: vi.fn().mockResolvedValue([ + { + id: 'ws_dup', + type: 'forge', + directory: '/tmp/wt/dup', + extra: { + loopName: 'dup-loop', + projectDirectory: '/tmp/wt/dup', + forgeLoop: { + title: 'Dup Loop', + planSource: 'inline', + planText: '# Plan', }, }, - ], - }), + }, + ]), workspaceRemove, tuiPublish, }) @@ -840,28 +799,26 @@ describe('createForgeSessionAttachHook', () => { .mockReturnValueOnce({ projectId: 'proj_1', loopName: 'my-feature', status: 'running' }) const deps = buildHookDeps({ - workspaceList: vi.fn().mockResolvedValue({ - data: [ - { - id: 'ws_forge', - type: 'forge', - directory: '/tmp/wt/forge', - extra: { - loopName: 'my-feature', - projectDirectory: '/tmp/wt/forge', - forgeLoop: { - hostSessionId: 'host_sess', - title: 'My Feature', - executionModel: 'prov/exec', - auditorModel: 'prov/aud', - planSource: 'stored', - maxIterations: 50, - sandboxEnabled: false, - }, + workspaceList: vi.fn().mockResolvedValue([ + { + id: 'ws_forge', + type: 'forge', + directory: '/tmp/wt/forge', + extra: { + loopName: 'my-feature', + projectDirectory: '/tmp/wt/forge', + forgeLoop: { + hostSessionId: 'host_sess', + title: 'My Feature', + executionModel: 'prov/exec', + auditorModel: 'prov/aud', + planSource: 'stored', + maxIterations: 50, + sandboxEnabled: false, }, }, - ], - }), + }, + ]), plansRepoGetForSession: vi.fn().mockReturnValue({ content: '# Plan\n\nDo stuff.' }), loopsRepoGet: loopsRepoGetMock, loggerErrorSpy, @@ -902,29 +859,27 @@ describe('createForgeSessionAttachHook', () => { loopName: 'restart-loop', status, }) - const workspaceRemove = vi.fn().mockResolvedValue({ data: {} }) - const tuiPublish = vi.fn().mockResolvedValue({ data: {} }) + const workspaceRemove = vi.fn().mockResolvedValue(undefined) + const tuiPublish = vi.fn().mockResolvedValue(undefined) const loggerLogSpy = vi.fn() const deps = buildHookDeps({ - workspaceList: vi.fn().mockResolvedValue({ - data: [ - { - id: 'ws_restart', - type: 'forge', - directory: '/tmp/wt/restart', - extra: { - loopName: 'restart-loop', - projectDirectory: '/tmp/wt/restart', - forgeLoop: { - title: 'Restart', - planSource: 'inline', - planText: '# Plan', - }, + workspaceList: vi.fn().mockResolvedValue([ + { + id: 'ws_restart', + type: 'forge', + directory: '/tmp/wt/restart', + extra: { + loopName: 'restart-loop', + projectDirectory: '/tmp/wt/restart', + forgeLoop: { + title: 'Restart', + planSource: 'inline', + planText: '# Plan', }, }, - ], - }), + }, + ]), loopsRepoGet: loopsRepoGetMock, workspaceRemove, tuiPublish, @@ -990,28 +945,26 @@ describe('createForgeSessionAttachHook', () => { const loopsRepoGetMock = vi.fn().mockReturnValue(null) const deps = buildHookDeps({ - workspaceList: vi.fn().mockResolvedValue({ - data: [ - { - id: 'ws_forge', - type: 'forge', - directory: '/tmp/wt/forge', - extra: { - loopName: 'my-feature', - projectDirectory: '/tmp/wt/forge', - forgeLoop: { - hostSessionId: 'host_sess', - title: 'My Feature', - executionModel: 'prov/exec', - auditorModel: 'prov/aud', - planSource: 'stored', - maxIterations: 50, - sandboxEnabled: false, - }, + workspaceList: vi.fn().mockResolvedValue([ + { + id: 'ws_forge', + type: 'forge', + directory: '/tmp/wt/forge', + extra: { + loopName: 'my-feature', + projectDirectory: '/tmp/wt/forge', + forgeLoop: { + hostSessionId: 'host_sess', + title: 'My Feature', + executionModel: 'prov/exec', + auditorModel: 'prov/aud', + planSource: 'stored', + maxIterations: 50, + sandboxEnabled: false, }, }, - ], - }), + }, + ]), plansRepoGetForSession: vi.fn().mockReturnValue({ content: '# Plan\n\nDo stuff.' }), loopsRepoGet: loopsRepoGetMock, loggerErrorSpy, @@ -1068,7 +1021,7 @@ describe('createForgeSessionAttachHook', () => { }, }, } - const workspaceList = vi.fn().mockResolvedValue({ data: [forgeWorkspaceWithForgeLoop] }) + const workspaceList = vi.fn().mockResolvedValue([forgeWorkspaceWithForgeLoop]) const deps = buildHookDeps({ workspaceList }) const handler = createForgeSessionAttachHook(deps as any) @@ -1102,7 +1055,7 @@ describe('createForgeSessionAttachHook', () => { }, }, } - const workspaceList = vi.fn().mockResolvedValue({ data: [forgeWorkspaceWithForgeLoop] }) + const workspaceList = vi.fn().mockResolvedValue([forgeWorkspaceWithForgeLoop]) const deps = buildHookDeps({ workspaceList }) const handler = createForgeSessionAttachHook(deps as any) @@ -1123,24 +1076,22 @@ describe('createForgeSessionAttachHook', () => { test('uses sessionInfo.projectID for loopsRepo.get and attachLoopToSession ctx', async () => { const loopsRepoGet = vi.fn().mockReturnValue(null) const plansRepoGetForSession = vi.fn().mockReturnValue({ content: 'plan text' }) - const workspaceList = vi.fn().mockResolvedValue({ - data: [ - { - id: 'ws_forge_pid', - type: 'forge', - directory: '/tmp/wt/pid', - extra: { - loopName: 'demo', - projectDirectory: '/tmp/wt/pid', - forgeLoop: { - hostSessionId: 'host_sess', - title: 'Demo Loop', - planSource: 'stored', - }, + const workspaceList = vi.fn().mockResolvedValue([ + { + id: 'ws_forge_pid', + type: 'forge', + directory: '/tmp/wt/pid', + extra: { + loopName: 'demo', + projectDirectory: '/tmp/wt/pid', + forgeLoop: { + hostSessionId: 'host_sess', + title: 'Demo Loop', + planSource: 'stored', }, }, - ], - }) + }, + ]) const deps = buildHookDeps({ loopsRepoGet, plansRepoGetForSession, @@ -1169,24 +1120,22 @@ describe('createForgeSessionAttachHook', () => { test('falls back to deps.projectId when sessionInfo.projectID is missing', async () => { const loopsRepoGet = vi.fn().mockReturnValue(null) const plansRepoGetForSession = vi.fn().mockReturnValue({ content: 'plan text' }) - const workspaceList = vi.fn().mockResolvedValue({ - data: [ - { - id: 'ws_forge_fb', - type: 'forge', - directory: '/tmp/wt/fb', - extra: { - loopName: 'fallback-loop', - projectDirectory: '/tmp/wt/fb', - forgeLoop: { - hostSessionId: 'host_sess', - title: 'Fallback Loop', - planSource: 'stored', - }, + const workspaceList = vi.fn().mockResolvedValue([ + { + id: 'ws_forge_fb', + type: 'forge', + directory: '/tmp/wt/fb', + extra: { + loopName: 'fallback-loop', + projectDirectory: '/tmp/wt/fb', + forgeLoop: { + hostSessionId: 'host_sess', + title: 'Fallback Loop', + planSource: 'stored', }, }, - ], - }) + }, + ]) const deps = buildHookDeps({ loopsRepoGet, plansRepoGetForSession, @@ -1213,8 +1162,8 @@ describe('createForgeSessionAttachHook', () => { }) test('publishes a tui.toast when workspace is unfindable after retry', async () => { - const workspaceList = vi.fn().mockResolvedValue({ data: [] }) - const tuiPublish = vi.fn().mockResolvedValue({ data: {} }) + const workspaceList = vi.fn().mockResolvedValue([]) + const tuiPublish = vi.fn().mockResolvedValue(undefined) const deps = buildHookDeps({ workspaceList, tuiPublish }) const handler = createForgeSessionAttachHook(deps as any) @@ -1244,8 +1193,8 @@ describe('createForgeSessionAttachHook', () => { }) test('does not publish a toast when sessionInfo.directory is missing', async () => { - const workspaceList = vi.fn().mockResolvedValue({ data: [] }) - const tuiPublish = vi.fn().mockResolvedValue({ data: {} }) + const workspaceList = vi.fn().mockResolvedValue([]) + const tuiPublish = vi.fn().mockResolvedValue(undefined) const deps = buildHookDeps({ workspaceList, tuiPublish }) const handler = createForgeSessionAttachHook(deps as any) @@ -1266,25 +1215,23 @@ describe('createForgeSessionAttachHook', () => { test('attach hook prefers inline planText over stored plan when both are available', async () => { const plansRepoGetForSession = vi.fn().mockReturnValue({ content: 'STALE_PRIOR_PLAN_TEXT' }) const deps = buildHookDeps({ - workspaceList: vi.fn().mockResolvedValue({ - data: [ - { - id: 'ws_inline_vs_stored', - type: 'forge', - directory: '/tmp/wt/inline-vs-stored', - extra: { - loopName: 'my-plan', - projectDirectory: '/tmp/wt/inline-vs-stored', - forgeLoop: { - hostSessionId: 'ses_host', - title: 'My Plan', - planSource: 'inline', - planText: 'FRESH_PLAN_TEXT', - }, + workspaceList: vi.fn().mockResolvedValue([ + { + id: 'ws_inline_vs_stored', + type: 'forge', + directory: '/tmp/wt/inline-vs-stored', + extra: { + loopName: 'my-plan', + projectDirectory: '/tmp/wt/inline-vs-stored', + forgeLoop: { + hostSessionId: 'ses_host', + title: 'My Plan', + planSource: 'inline', + planText: 'FRESH_PLAN_TEXT', }, }, - ], - }), + }, + ]), plansRepoGetForSession, }) diff --git a/test/hooks/host-side-effects-unwarp.test.ts b/test/hooks/host-side-effects-unwarp.test.ts index 355ab9fdd3..478e19b501 100644 --- a/test/hooks/host-side-effects-unwarp.test.ts +++ b/test/hooks/host-side-effects-unwarp.test.ts @@ -33,16 +33,30 @@ function buildCtx(overrides?: { log?: ReturnType error?: ReturnType }) { - const tuiPublish = overrides?.tuiPublish ?? vi.fn().mockResolvedValue({ data: {} }) - const workspaceRemove = overrides?.workspaceRemove ?? vi.fn().mockResolvedValue({ data: {} }) + const tuiPublish = overrides?.tuiPublish ?? vi.fn().mockResolvedValue(undefined) + const workspaceRemove = overrides?.workspaceRemove ?? vi.fn().mockResolvedValue(undefined) const log = overrides?.log ?? vi.fn() const error = overrides?.error ?? vi.fn() return { ctx: { - v2Client: { - tui: { publish: tuiPublish }, - experimental: { workspace: { remove: workspaceRemove } }, + client: { + session: {} as any, + workspace: { + create: async () => ({ id: '' }) as any, + list: async () => [], + status: async () => ({}), + syncList: async () => {}, + remove: workspaceRemove, + warp: async () => {}, + }, + tui: { + publish: tuiPublish, + selectSession: async () => {}, + }, + sync: { + start: async () => {}, + }, } as never, logger: { log, error, debug: () => {} }, getConfig: () => ({}) as PluginConfig, @@ -64,11 +78,9 @@ describe('performTerminationSideEffects unwarp', () => { const body = (arg as { body: { type: string } }).body if (body.type === 'tui.session.select') callOrder.push('select') if (body.type === 'tui.toast.show') callOrder.push('toast') - return { data: {} } }) const workspaceRemove = vi.fn().mockImplementation(async () => { callOrder.push('remove') - return { data: {} } }) const { ctx } = buildCtx({ tuiPublish, workspaceRemove }) @@ -118,7 +130,6 @@ describe('performTerminationSideEffects unwarp', () => { const tuiPublish = vi.fn().mockImplementation(async (arg: unknown) => { const body = (arg as { body: { type: string } }).body if (body.type === 'tui.session.select') throw new Error('publish failed') - return { data: {} } }) const { ctx, workspaceRemove, error } = buildCtx({ tuiPublish }) @@ -145,39 +156,37 @@ describe('performTerminationSideEffects unwarp', () => { }) test('sweep removes sibling completed forge workspace during teardown', async () => { - const tuiPublish = vi.fn().mockResolvedValue({ data: {} }) - const workspaceRemove = vi.fn().mockResolvedValue({ data: {} }) - const workspaceList = vi.fn().mockResolvedValue({ - data: [ - // The terminating loop's own workspace - { - id: 'ws_abc', - type: 'forge', - extra: { - loopName: 'feat-x', - projectDirectory: '/tmp/project', - }, + const tuiPublish = vi.fn().mockResolvedValue(undefined) + const workspaceRemove = vi.fn().mockResolvedValue(undefined) + const workspaceList = vi.fn().mockResolvedValue([ + // The terminating loop's own workspace + { + id: 'ws_abc', + type: 'forge', + extra: { + loopName: 'feat-x', + projectDirectory: '/tmp/project', }, - // A sibling completed workspace that should be swept - { - id: 'ws_sibling_completed', - type: 'forge', - extra: { - loopName: 'sibling-completed-loop', - projectDirectory: '/tmp/project', - }, + }, + // A sibling completed workspace that should be swept + { + id: 'ws_sibling_completed', + type: 'forge', + extra: { + loopName: 'sibling-completed-loop', + projectDirectory: '/tmp/project', }, - // A sibling running workspace that should be kept - { - id: 'ws_sibling_running', - type: 'forge', - extra: { - loopName: 'sibling-running-loop', - projectDirectory: '/tmp/project', - }, + }, + // A sibling running workspace that should be kept + { + id: 'ws_sibling_running', + type: 'forge', + extra: { + loopName: 'sibling-running-loop', + projectDirectory: '/tmp/project', }, - ], - }) + }, + ]) const loopsRepoGet = vi.fn().mockImplementation((projectId: string, loopName: string) => { if (loopName === 'sibling-completed-loop') return { projectId, loopName, status: 'completed' } @@ -191,11 +200,25 @@ describe('performTerminationSideEffects unwarp', () => { clear: vi.fn(), } + const client = { + session: {} as any, + workspace: { + create: async () => ({ id: '' }) as any, + list: workspaceList, + status: async () => ({}), + syncList: async () => {}, + remove: workspaceRemove, + warp: async () => {}, + }, + tui: { + publish: tuiPublish, + selectSession: async () => {}, + }, + sync: { start: async () => {} }, + } as never + const ctx = { - v2Client: { - tui: { publish: tuiPublish }, - experimental: { workspace: { remove: workspaceRemove, list: workspaceList } }, - } as never, + client, logger: { log: vi.fn(), error: vi.fn(), debug: () => {} }, getConfig: () => ({}) as PluginConfig, pendingTeardowns: pendingTeardowns as never, @@ -205,7 +228,8 @@ describe('performTerminationSideEffects unwarp', () => { await performTerminationSideEffects(buildState(), completed, 'sess_worktree', ctx) - // Verify the terminating loop's own workspace was removed + // Verify the terminating loop's own workspace was removed (via ForgeClient, + // which delegates to the workspaceRemove mock) expect(workspaceRemove).toHaveBeenCalledWith({ id: 'ws_abc' }) // Verify the sibling completed workspace was swept (excludeLoopName excludes feat-x) @@ -226,14 +250,26 @@ describe('performTerminationSideEffects unwarp', () => { }) test('sweep is skipped when loopsRepo or projectId not in ctx', async () => { - const tuiPublish = vi.fn().mockResolvedValue({ data: {} }) - const workspaceRemove = vi.fn().mockResolvedValue({ data: {} }) - const workspaceList = vi.fn().mockResolvedValue({ data: [] }) + const tuiPublish = vi.fn().mockResolvedValue(undefined) + const workspaceRemove = vi.fn().mockResolvedValue(undefined) + const workspaceList = vi.fn().mockResolvedValue([]) const ctx = { - v2Client: { - tui: { publish: tuiPublish }, - experimental: { workspace: { remove: workspaceRemove, list: workspaceList } }, + client: { + session: {} as any, + workspace: { + create: async () => ({ id: '' }) as any, + list: workspaceList, + status: async () => ({}), + syncList: async () => {}, + remove: workspaceRemove, + warp: async () => {}, + }, + tui: { + publish: tuiPublish, + selectSession: async () => {}, + }, + sync: { start: async () => {} }, } as never, logger: { log: vi.fn(), error: vi.fn(), debug: () => {} }, getConfig: () => ({}) as PluginConfig, diff --git a/test/hooks/loop-event-gate.test.ts b/test/hooks/loop-event-gate.test.ts index 9f2efb0d9b..20f4aa7cca 100644 --- a/test/hooks/loop-event-gate.test.ts +++ b/test/hooks/loop-event-gate.test.ts @@ -12,7 +12,7 @@ import type { LoopState } from '../../src/loop/state' import { createLoopEventHandler } from '../../src/hooks/loop' import { markPromptSent, clearPromptPending, sessionsAwaitingBusy, isAwaitingBusy, isAwaitingBusyExpired, AWAITING_BUSY_TIMEOUT_MS } from '../../src/loop/idle-gate' import type { Logger, PluginConfig } from '../../src/types' -import type { OpencodeClient } from '@opencode-ai/sdk/v2' +import { createFakeForgeClient } from '../helpers/fake-client' import { setupLoopsTestDb } from '../helpers/loops-test-db' const PROJECT_ID = 'test-project' @@ -22,48 +22,10 @@ const mockConfig: PluginConfig = { auditorModel: 'test/auditor', loop: { enabled: true, - model: 'test/loop', defaultMaxIterations: 5, }, } -type DeleteCall = { sessionID: string; directory: string } -type PublishCall = { directory: string; body: unknown } - -interface MockClientState { - deleteCalls: DeleteCall[] - publishCalls: PublishCall[] - deleteThrows: boolean -} - -function createMockV2Client(state: MockClientState): OpencodeClient { - return { - session: { - create: async () => ({ error: null, data: { id: 'sess' } }), - promptAsync: async () => ({ error: null, data: null }), - status: async () => ({ error: null, data: {} }), - abort: async () => {}, - delete: async (params: DeleteCall) => { - state.deleteCalls.push(params) - if (state.deleteThrows) throw new Error('delete failed') - return { error: undefined } - }, - messages: async () => ({ error: null, data: [] }), - get: async () => ({ error: null, data: {} }), - }, - tui: { - publish: async (params: PublishCall) => { - state.publishCalls.push(params) - }, - selectSession: async () => {}, - }, - worktree: { - create: async () => ({ error: null, data: { directory: '/tmp/wt', branch: 'b' } }), - remove: async () => {}, - }, - } as unknown as OpencodeClient -} - describe('Loop Event Idle Gate', () => { let db: Database let loopService: ReturnType @@ -92,7 +54,6 @@ describe('Loop Event Idle Gate', () => { { log: () => {}, error: () => {}, debug: () => {} }, undefined, undefined, - undefined, sectionPlansRepo, ) @@ -143,18 +104,16 @@ describe('Loop Event Idle Gate', () => { return { logger, logs } } - function createHandler(v2Client?: OpencodeClient) { - const clientState: MockClientState = { deleteCalls: [], publishCalls: [], deleteThrows: false } - const mockClient = v2Client ?? createMockV2Client(clientState) + function createHandler() { const { logger } = createCapturingLogger() + const { client: forgeClient } = createFakeForgeClient() return createLoopEventHandler( loopsRepo, plansRepo, reviewFindingsRepo, PROJECT_ID, - { client: {} as any }, - mockClient, + forgeClient, logger, () => mockConfig, undefined, diff --git a/test/hooks/loop-final-audit-rewind.test.ts b/test/hooks/loop-final-audit-rewind.test.ts index c72a438a0a..ff546516d7 100644 --- a/test/hooks/loop-final-audit-rewind.test.ts +++ b/test/hooks/loop-final-audit-rewind.test.ts @@ -12,7 +12,6 @@ import { createLoopService, MAX_RETRIES } from '../../src/loop/service' import { createLoopEventHandler } from '../../src/hooks/loop' import { openForgeDatabase } from '../../src/storage/database' import type { Logger, PluginConfig } from '../../src/types' -import type { OpencodeClient } from '@opencode-ai/sdk/v2' const mockLogger: Logger = { log: () => {}, diff --git a/test/hooks/loop-section-advancement.test.ts b/test/hooks/loop-section-advancement.test.ts index 2a8e7ef41e..2341096f10 100644 --- a/test/hooks/loop-section-advancement.test.ts +++ b/test/hooks/loop-section-advancement.test.ts @@ -11,7 +11,6 @@ import { createLoopService } from '../../src/loop/service' import type { LoopState } from '../../src/loop/state' import { createLoopEventHandler } from '../../src/hooks/loop' import type { Logger, PluginConfig } from '../../src/types' -import type { OpencodeClient } from '@opencode-ai/sdk/v2' const mockLogger: Logger = { log: () => {}, @@ -135,7 +134,7 @@ describe('Loop Section Advancement', () => { plansRepo = createPlansRepo(db) reviewFindingsRepo = createReviewFindingsRepo(db) sectionPlansRepo = createSectionPlansRepo(db) - loopService = createLoopService(loopsRepo, plansRepo, reviewFindingsRepo, projectId, mockLogger, undefined, undefined, undefined, sectionPlansRepo) + loopService = createLoopService(loopsRepo, plansRepo, reviewFindingsRepo, projectId, mockLogger, undefined, undefined, sectionPlansRepo) }) afterEach(() => { diff --git a/test/hooks/loop-section-audit-retry.test.ts b/test/hooks/loop-section-audit-retry.test.ts index 86f62fee40..127067f0e0 100644 --- a/test/hooks/loop-section-audit-retry.test.ts +++ b/test/hooks/loop-section-audit-retry.test.ts @@ -11,7 +11,7 @@ import { createLoopService, MAX_RETRIES } from '../../src/loop/service' import type { LoopState } from '../../src/loop/state' import { createLoopEventHandler } from '../../src/hooks/loop' import type { Logger, PluginConfig } from '../../src/types' -import type { OpencodeClient } from '@opencode-ai/sdk/v2' +import { createFakeForgeClient } from '../helpers/fake-client' import { setupLoopsTestDb } from '../helpers/loops-test-db' const mockLogger: Logger = { @@ -50,7 +50,6 @@ describe('Loop Section Audit Retry', () => { mockLogger, undefined, undefined, - undefined, sectionPlansRepo, ) }) @@ -88,57 +87,6 @@ describe('Loop Section Audit Retry', () => { } } - function createMockV2Client(options: { - messagesCalls?: Array<{ lastMessageRole: string; text?: string }> - createCalls?: Array<{ data?: { id: string }; error?: unknown }> - }): OpencodeClient { - const callIndex = { value: 0 } - const createCallIndex = { value: 0 } - - return { - session: { - messages: async () => { - const callConfig = options.messagesCalls?.[callIndex.value] || { lastMessageRole: 'assistant', text: '' } - callIndex.value++ - return { - data: [ - { - info: { role: callConfig.lastMessageRole }, - parts: [{ type: 'text' as const, text: callConfig.text ?? '' }], - }, - ], - } - }, - promptAsync: async () => ({ data: {}, error: null }), - abort: async () => {}, - status: async () => ({ - data: { 'sess-1': { type: 'idle' } }, - }), - create: async () => { - const callConfig = options.createCalls?.[createCallIndex.value] - createCallIndex.value++ - if (callConfig) { - if (callConfig.error) { - return { data: undefined, error: callConfig.error } - } - return { data: callConfig.data ?? { id: `mock-session-${Date.now()}` }, error: undefined } - } - return { data: { id: `mock-session-${Date.now()}` }, error: undefined } - }, - delete: async () => {}, - get: async () => ({ data: {} }), - }, - tui: { - publish: async () => {}, - selectSession: async () => {}, - }, - worktree: { - create: async () => ({ data: { directory: '/mock/worktree', branch: 'mock-branch' }, error: undefined }), - remove: async () => {}, - }, - } as unknown as OpencodeClient - } - function createCapturingLogger() { const logs: Array<{ level: string; message: string }> = [] return { @@ -156,7 +104,6 @@ describe('Loop Section Audit Retry', () => { auditorModel: 'test/auditor', loop: { enabled: true, - model: 'test/loop', defaultMaxIterations: 5, }, } @@ -178,33 +125,14 @@ describe('Loop Section Audit Retry', () => { const { logger } = createCapturingLogger() - let promptCalls: Array<{ agent?: string; text?: string }> = [] - const v2Client = createMockV2Client({ - messagesCalls: [ - { lastMessageRole: 'assistant', text: 'dirty audit: found issues' }, - ], - createCalls: [], - }) - - const origPromptAsync = (v2Client as any).session.promptAsync - ;(v2Client as any).session.promptAsync = async (opts: any) => { - if (opts?.parts) { - const text = opts.parts.map((p: any) => p.text ?? '').join('\n') - promptCalls.push({ agent: opts.agent, text }) - } - return origPromptAsync(opts) - } - - const pluginClient = { + const { client: forgeClient } = createFakeForgeClient({ session: { - create: async () => ({ data: { id: 'new-audit-sess' } }), - promptAsync: async () => ({ data: {}, error: null }), + messages: async () => [{ info: { role: 'assistant' }, parts: [{ type: 'text' as const, text: 'dirty audit: found issues' }] }], }, - } - + }) const getConfig = () => mockConfig as PluginConfig - const handler = createLoopEventHandler(loopsRepo, plansRepo, reviewFindingsRepo, PROJECT_ID, pluginClient as any, v2Client as any, logger, getConfig, undefined, undefined, undefined, sectionPlansRepo) + const handler = createLoopEventHandler(loopsRepo, plansRepo, reviewFindingsRepo, PROJECT_ID, forgeClient, logger, getConfig, undefined, undefined, undefined, sectionPlansRepo) await handler.onEvent({ event: { @@ -222,7 +150,9 @@ describe('Loop Section Audit Retry', () => { const after = loopService.getActiveState(state.loopName)! expect(after.currentSectionIndex).toBe(0) - const hasContinuationPrompt = promptCalls.some(p => p.text && p.text.includes('continuation')) + const hasContinuationPrompt = (forgeClient.session.promptAsync as any).mock.calls.some( + (call: any) => call[0]?.parts?.some((p: any) => typeof p.text === 'string' && p.text.includes('continuation')) + ) expect(hasContinuationPrompt).toBe(true) const afterAuditResult = loopService.getActiveState(state.loopName)! @@ -249,23 +179,14 @@ describe('Loop Section Audit Retry', () => { const { logger } = createCapturingLogger() - const v2Client = createMockV2Client({ - messagesCalls: [ - { lastMessageRole: 'assistant', text: 'second dirty audit' }, - ], - createCalls: [], - }) - - const pluginClient = { + const { client: forgeClient } = createFakeForgeClient({ session: { - create: async () => ({ data: { id: 'new-audit-sess' } }), - promptAsync: async () => ({ data: {}, error: null }), + messages: async () => [{ info: { role: 'assistant' }, parts: [{ type: 'text' as const, text: 'second dirty audit' }] }], }, - } - + }) const getConfig = () => mockConfig as PluginConfig - const handler = createLoopEventHandler(loopsRepo, plansRepo, reviewFindingsRepo, PROJECT_ID, pluginClient as any, v2Client as any, logger, getConfig, undefined, undefined, undefined, sectionPlansRepo) + const handler = createLoopEventHandler(loopsRepo, plansRepo, reviewFindingsRepo, PROJECT_ID, forgeClient, logger, getConfig, undefined, undefined, undefined, sectionPlansRepo) await handler.onEvent({ event: { @@ -305,33 +226,14 @@ describe('Loop Section Audit Retry', () => { const summaryText = 'OK\n\n### Done\n- Implemented feature X\n### Deviations\n- None\n### Follow-ups\n- Handled in section 2\n' - let promptCalls: Array<{ agent?: string; text?: string }> = [] - const v2Client = createMockV2Client({ - messagesCalls: [ - { lastMessageRole: 'assistant', text: summaryText }, - ], - createCalls: [], - }) - - const origPromptAsync = (v2Client as any).session.promptAsync - ;(v2Client as any).session.promptAsync = async (opts: any) => { - if (opts?.parts) { - const text = opts.parts.map((p: any) => p.text ?? '').join('\n') - promptCalls.push({ agent: opts.agent, text }) - } - return origPromptAsync(opts) - } - - const pluginClient = { + const { client: forgeClient } = createFakeForgeClient({ session: { - create: async () => ({ data: { id: 'new-audit-sess' } }), - promptAsync: async () => ({ data: {}, error: null }), + messages: async () => [{ info: { role: 'assistant' }, parts: [{ type: 'text' as const, text: summaryText }] }], }, - } - + }) const getConfig = () => mockConfig as PluginConfig - const handler = createLoopEventHandler(loopsRepo, plansRepo, reviewFindingsRepo, PROJECT_ID, pluginClient as any, v2Client as any, logger, getConfig, undefined, undefined, undefined, sectionPlansRepo) + const handler = createLoopEventHandler(loopsRepo, plansRepo, reviewFindingsRepo, PROJECT_ID, forgeClient, logger, getConfig, undefined, undefined, undefined, sectionPlansRepo) await handler.onEvent({ event: { @@ -350,7 +252,9 @@ describe('Loop Section Audit Retry', () => { const after = loopService.getActiveState(state.loopName)! expect(after.currentSectionIndex).toBe(1) - const hasPriorSectionSummary = promptCalls.some(p => p.text && p.text.includes('Implemented feature X')) + const hasPriorSectionSummary = (forgeClient.session.promptAsync as any).mock.calls.some( + (call: any) => call[0]?.parts?.some((p: any) => typeof p.text === 'string' && p.text.includes('Implemented feature X')) + ) expect(hasPriorSectionSummary).toBe(true) }) @@ -379,29 +283,19 @@ describe('Loop Section Audit Retry', () => { const summaryText = 'OK\n\n### Done\n- Implemented section 4\n### Deviations\n- None\n### Follow-ups\n- None\n' - const v2Client = createMockV2Client({ - messagesCalls: [ - { lastMessageRole: 'assistant', text: summaryText }, - ], - createCalls: [], - }) - const createTitleCalls: string[] = [] - ;(v2Client as any).session.create = async (opts: any) => { - if (opts?.title) createTitleCalls.push(opts.title) - return { data: { id: `rotated-sess-${createTitleCalls.length}` }, error: undefined } - } - - const pluginClient = { + const { client: forgeClient } = createFakeForgeClient({ session: { - create: async () => ({ data: { id: 'new-audit-sess' } }), - promptAsync: async () => ({ data: {}, error: null }), + messages: async () => [{ info: { role: 'assistant' }, parts: [{ type: 'text' as const, text: summaryText }] }], + create: async (opts: any) => { + if (opts?.title) createTitleCalls.push(opts.title) + return { id: `rotated-sess-${createTitleCalls.length}` } + }, }, - } - + }) const getConfig = () => mockConfig as PluginConfig - const handler = createLoopEventHandler(loopsRepo, plansRepo, reviewFindingsRepo, PROJECT_ID, pluginClient as any, v2Client as any, logger, getConfig, undefined, undefined, undefined, sectionPlansRepo) + const handler = createLoopEventHandler(loopsRepo, plansRepo, reviewFindingsRepo, PROJECT_ID, forgeClient, logger, getConfig, undefined, undefined, undefined, sectionPlansRepo) await handler.onEvent({ event: { @@ -443,33 +337,14 @@ describe('Loop Section Audit Retry', () => { const { logger } = createCapturingLogger() - let promptCalls: Array<{ agent?: string; text?: string }> = [] - const v2Client = createMockV2Client({ - messagesCalls: [ - { lastMessageRole: 'assistant', text: 'dirty audit: found issues' }, - ], - createCalls: [], - }) - - const origPromptAsync = (v2Client as any).session.promptAsync - ;(v2Client as any).session.promptAsync = async (opts: any) => { - if (opts?.parts) { - const text = opts.parts.map((p: any) => p.text ?? '').join('\n') - promptCalls.push({ agent: opts.agent, text }) - } - return origPromptAsync(opts) - } - - const pluginClient = { + const { client: forgeClient } = createFakeForgeClient({ session: { - create: async () => ({ data: { id: 'new-audit-sess' } }), - promptAsync: async () => ({ data: {}, error: null }), + messages: async () => [{ info: { role: 'assistant' }, parts: [{ type: 'text' as const, text: 'dirty audit: found issues' }] }], }, - } - + }) const getConfig = () => mockConfig as PluginConfig - const handler = createLoopEventHandler(loopsRepo, plansRepo, reviewFindingsRepo, PROJECT_ID, pluginClient as any, v2Client as any, logger, getConfig, undefined, undefined, undefined, sectionPlansRepo) + const handler = createLoopEventHandler(loopsRepo, plansRepo, reviewFindingsRepo, PROJECT_ID, forgeClient, logger, getConfig, undefined, undefined, undefined, sectionPlansRepo) await handler.onEvent({ event: { diff --git a/test/index/session-lookup.test.ts b/test/index/session-lookup.test.ts index d52cc3fe99..02b122d137 100644 --- a/test/index/session-lookup.test.ts +++ b/test/index/session-lookup.test.ts @@ -1,5 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import type { Logger } from '../../src/types' +import { createFakeForgeClient } from '../helpers/fake-client' +import { ForgeClientError } from '../../src/client/port' vi.mock('bun:sqlite', () => ({ Database: vi.fn(), @@ -26,25 +28,6 @@ function createMockLogger() { } } -function createMockV2Client(responses: Map) { - return { - session: { - get: async (input: Record) => { - const key = input.directory - ? `${input.sessionID}:${input.directory}${input.workspace ? `:${input.workspace}` : ''}` - : input.workspace - ? `${input.sessionID}:${input.workspace}` - : String(input.sessionID) - const response = responses.get(key) ?? responses.get(String(input.sessionID)) - if (!response) { - throw new Error(`No mock response for ${key}`) - } - return response - }, - }, - } -} - function createMockLoop(activeLoops: Array<{ loopName: string; worktreeDir: string; workspaceId?: string }>) { return { listActive: () => activeLoops.map((l) => ({ @@ -61,21 +44,23 @@ function createMockLoop(activeLoops: Array<{ loopName: string; worktreeDir: stri } } +const notFoundErr = () => new ForgeClientError({ kind: 'not-found', method: 'session.get', message: 'not found' }) + describe('createParentSessionLookup', () => { it('with one active loop having workspaceId, issues directory+workspace then workspace-only then host fallback', async () => { const sessionId = 'ses-1' - const v2 = { + const { client } = createFakeForgeClient({ session: { - get: vi.fn().mockResolvedValue({ data: undefined }), + get: async () => { throw notFoundErr() }, }, - } + }) const loopService = createMockLoop([ { loopName: 'test-loop', worktreeDir: '/wt', workspaceId: 'wrk_x' }, ]) const lookup = createParentSessionLookup({ - v2: v2 as any, + client, directory: '/host', loop: loopService as any, logger: createMockLogger() as any, @@ -84,7 +69,7 @@ describe('createParentSessionLookup', () => { await lookup(sessionId) - const calls = v2.session.get.mock.calls.map((c: unknown[]) => c[0] as Record) + const calls = ((client.session.get as any).mock.calls as unknown[][]).map((c: unknown[]) => c[0] as Record) expect(calls).toHaveLength(3) expect(calls[0]).toEqual({ sessionID: sessionId, @@ -103,11 +88,11 @@ describe('createParentSessionLookup', () => { it('host fallback is attempted after active loop attempts', async () => { const sessionId = 'ses-2' - const v2 = { + const { client } = createFakeForgeClient({ session: { - get: vi.fn().mockResolvedValue({ data: undefined }), + get: async () => { throw notFoundErr() }, }, - } + }) const loopService = createMockLoop([ { loopName: 'loop-1', worktreeDir: '/wt-1', workspaceId: 'wrk_1' }, @@ -115,7 +100,7 @@ describe('createParentSessionLookup', () => { ]) const lookup = createParentSessionLookup({ - v2: v2 as any, + client, directory: '/host', loop: loopService as any, logger: createMockLogger() as any, @@ -124,7 +109,7 @@ describe('createParentSessionLookup', () => { await lookup(sessionId) - const calls = v2.session.get.mock.calls.map((c: unknown[]) => c[0] as Record) + const calls = ((client.session.get as any).mock.calls as unknown[][]).map((c: unknown[]) => c[0] as Record) expect(calls).toHaveLength(5) expect(calls[0]).toEqual({ sessionID: sessionId, directory: '/wt-1', workspace: 'wrk_1' }) expect(calls[1]).toEqual({ sessionID: sessionId, workspace: 'wrk_1' }) @@ -135,11 +120,11 @@ describe('createParentSessionLookup', () => { it('failure is logged once per sessionId within negative-TTL window', async () => { const sessionId = 'ses-3' - const v2 = { + const { client } = createFakeForgeClient({ session: { - get: vi.fn().mockResolvedValue({ data: undefined }), + get: async () => { throw notFoundErr() }, }, - } + }) const loopService = createMockLoop([ { loopName: 'test-loop', worktreeDir: '/wt', workspaceId: 'wrk_x' }, @@ -147,7 +132,7 @@ describe('createParentSessionLookup', () => { const logger = createMockLogger() const lookup = createParentSessionLookup({ - v2: v2 as any, + client, directory: '/host', loop: loopService as any, logger: logger as any, @@ -166,18 +151,18 @@ describe('createParentSessionLookup', () => { it('positive cache prevents re-fetch', async () => { const sessionId = 'ses-4' - const v2 = { + const { client } = createFakeForgeClient({ session: { - get: vi.fn().mockResolvedValue({ data: { parentID: 'parent-4' } }), + get: async () => ({ parentID: 'parent-4' }), }, - } + }) const loopService = createMockLoop([ { loopName: 'test-loop', worktreeDir: '/wt', workspaceId: 'wrk_x' }, ]) const lookup = createParentSessionLookup({ - v2: v2 as any, + client, directory: '/host', loop: loopService as any, logger: createMockLogger() as any, @@ -190,21 +175,21 @@ describe('createParentSessionLookup', () => { const result2 = await lookup(sessionId) expect(result2).toBe('parent-4') - expect(v2.session.get).toHaveBeenCalledTimes(1) + expect(client.session.get).toHaveBeenCalledTimes(1) }) it('no active loops fallback to host directory', async () => { const sessionId = 'ses-host' - const v2 = { + const { client } = createFakeForgeClient({ session: { - get: vi.fn().mockResolvedValue({ data: { parentID: 'parent-host' } }), + get: async () => ({ parentID: 'parent-host' }), }, - } + }) const loopService = createMockLoop([]) const lookup = createParentSessionLookup({ - v2: v2 as any, + client, directory: '/host', loop: loopService as any, logger: createMockLogger() as any, @@ -214,7 +199,7 @@ describe('createParentSessionLookup', () => { const result = await lookup(sessionId) expect(result).toBe('parent-host') - const calls = v2.session.get.mock.calls.map((c: unknown[]) => c[0] as Record) + const calls = ((client.session.get as any).mock.calls as unknown[][]).map((c: unknown[]) => c[0] as Record) expect(calls).toHaveLength(1) expect(calls[0]).toEqual({ sessionID: sessionId, @@ -224,18 +209,18 @@ describe('createParentSessionLookup', () => { it('active loop without workspaceId issues directory attempt then host fallback', async () => { const sessionId = 'ses-nows' - const v2 = { + const { client } = createFakeForgeClient({ session: { - get: vi.fn().mockResolvedValue({ data: undefined }), + get: async () => { throw notFoundErr() }, }, - } + }) const loopService = createMockLoop([ { loopName: 'test-loop', worktreeDir: '/wt' }, ]) const lookup = createParentSessionLookup({ - v2: v2 as any, + client, directory: '/host', loop: loopService as any, logger: createMockLogger() as any, @@ -244,7 +229,7 @@ describe('createParentSessionLookup', () => { await lookup(sessionId) - const calls = v2.session.get.mock.calls.map((c: unknown[]) => c[0] as Record) + const calls = ((client.session.get as any).mock.calls as unknown[][]).map((c: unknown[]) => c[0] as Record) expect(calls).toHaveLength(2) expect(calls[0]).toEqual({ sessionID: sessionId, @@ -258,11 +243,11 @@ describe('createParentSessionLookup', () => { it('negative cache TTL is 15s by default', async () => { const sessionId = 'ses-ttl' - const v2 = { + const { client } = createFakeForgeClient({ session: { - get: vi.fn().mockResolvedValue({ data: undefined }), + get: async () => { throw notFoundErr() }, }, - } + }) const loopService = createMockLoop([ { loopName: 'test-loop', worktreeDir: '/wt', workspaceId: 'wrk_x' }, @@ -275,7 +260,7 @@ describe('createParentSessionLookup', () => { try { const lookup = createParentSessionLookup({ - v2: v2 as any, + client, directory: '/host', loop: loopService as any, logger: logger as any, @@ -284,16 +269,16 @@ describe('createParentSessionLookup', () => { // First call: sets negative cache entry at 1000 + 15000 = 16000 // 3 attempts: loop:/wt (workspace), loop-ws:test-loop, host await lookup(sessionId) - expect(v2.session.get).toHaveBeenCalledTimes(3) + expect(client.session.get).toHaveBeenCalledTimes(3) // Second call within TTL (now still 1000): returns null without re-attempting await lookup(sessionId) - expect(v2.session.get).toHaveBeenCalledTimes(3) // no additional calls + expect(client.session.get).toHaveBeenCalledTimes(3) // no additional calls // Third call after TTL expires (now = 17000 > 16000): re-attempts now = 17000 await lookup(sessionId) - expect(v2.session.get).toHaveBeenCalledTimes(6) // 3 more calls + expect(client.session.get).toHaveBeenCalledTimes(6) // 3 more calls } finally { vi.restoreAllMocks() } @@ -301,11 +286,11 @@ describe('createParentSessionLookup', () => { it('no log noise on zero-attempt empty path', async () => { const sessionId = 'ses-no-log' - const v2 = { + const { client } = createFakeForgeClient({ session: { - get: vi.fn().mockResolvedValue({ data: undefined }), + get: async () => { throw notFoundErr() }, }, - } + }) // Active loop with empty worktreeDir (so it's skipped) const loopService = createMockLoop([ @@ -314,7 +299,7 @@ describe('createParentSessionLookup', () => { const logger = createMockLogger() const lookup = createParentSessionLookup({ - v2: v2 as any, + client, directory: '/host', loop: loopService as any, logger: logger as any, @@ -324,8 +309,8 @@ describe('createParentSessionLookup', () => { const result = await lookup(sessionId) expect(result).toBeNull() // Only host directory attempt is made because active loop worktreeDir is empty - expect(v2.session.get).toHaveBeenCalledTimes(1) - expect(v2.session.get).toHaveBeenCalledWith({ + expect(client.session.get).toHaveBeenCalledTimes(1) + expect(client.session.get).toHaveBeenCalledWith({ sessionID: sessionId, directory: '/host', }) @@ -335,28 +320,30 @@ describe('createParentSessionLookup', () => { describe('createSessionDirectoryLookup', () => { it('with workspaceId, includes workspace and host fallback in attempts', async () => { const sessionId = 'ses-5' - const v2 = { + let callCount = 0 + const { client } = createFakeForgeClient({ session: { - get: vi.fn() - .mockResolvedValueOnce({ data: undefined }) - .mockResolvedValueOnce({ data: undefined }) - .mockResolvedValueOnce({ data: { directory: '/host' } }), + get: async () => { + callCount++ + if (callCount <= 2) throw notFoundErr() + return { directory: '/host' } + }, }, - } + }) const loopService = createMockLoop([ { loopName: 'test-loop', worktreeDir: '/wt', workspaceId: 'wrk_x' }, ]) const lookup = createSessionDirectoryLookup({ - v2: v2 as any, + client, directory: '/host', loop: loopService as any, }) await lookup(sessionId) - const calls = v2.session.get.mock.calls.map((c: unknown[]) => c[0] as Record) + const calls = ((client.session.get as any).mock.calls as unknown[][]).map((c: unknown[]) => c[0] as Record) expect(calls).toHaveLength(3) expect(calls[0]).toEqual({ sessionID: sessionId, @@ -375,18 +362,18 @@ describe('createSessionDirectoryLookup', () => { it('positive result is cached', async () => { const sessionId = 'ses-6' - const v2 = { + const { client } = createFakeForgeClient({ session: { - get: vi.fn().mockResolvedValue({ data: { directory: '/wt' } }), + get: async () => ({ directory: '/wt' }), }, - } + }) const loopService = createMockLoop([ { loopName: 'test-loop', worktreeDir: '/wt', workspaceId: 'wrk_x' }, ]) const lookup = createSessionDirectoryLookup({ - v2: v2 as any, + client, directory: '/host', loop: loopService as any, }) @@ -397,6 +384,6 @@ describe('createSessionDirectoryLookup', () => { const result2 = await lookup(sessionId) expect(result2).toBe('/wt') - expect(v2.session.get).toHaveBeenCalledTimes(1) + expect(client.session.get).toHaveBeenCalledTimes(1) }) }) diff --git a/test/loop-permission-ruleset.test.ts b/test/loop-permission-ruleset.test.ts index 2ba26aa442..d6eb613e40 100644 --- a/test/loop-permission-ruleset.test.ts +++ b/test/loop-permission-ruleset.test.ts @@ -136,19 +136,39 @@ describe('buildAuditSessionPermissionRuleset', () => { describe('createAuditSession passes audit permission rules into session creation', () => { test('session.create receives permission equal to buildAuditSessionPermissionRuleset()', async () => { const expectedPermission = buildAuditSessionPermissionRuleset({ sandbox: false }) - const mockCreate = mock(async (params: any) => ({ data: { id: 'audit-session' }, error: null })) - const mockGet = mock(async () => ({ data: { permission: expectedPermission }, error: null })) - const mockV2 = { + const mockCreate = mock(async (params: any) => ({ id: 'audit-session' })) + const mockClient = { session: { create: mockCreate, - get: mockGet, + get: mock(async () => ({})), + promptAsync: mock(async () => {}), + aborts: mock(async () => {}), + status: mock(async () => ({})), + messages: mock(async () => []), + update: mock(async () => {}), + delete: mock(async () => {}), + }, + workspace: { + create: mock(async () => ({ id: '', directory: '', branch: '' })), + list: mock(async () => []), + status: mock(async () => []), + syncList: mock(async () => {}), + remove: mock(async () => {}), + warp: mock(async () => {}), + }, + tui: { + publish: mock(async () => {}), + selectSession: mock(async () => {}), + }, + sync: { + start: mock(async () => {}), }, } as any const logger = { log: mock(), error: mock() } as unknown as Logger await createAuditSession({ - v2: mockV2, + client: mockClient, loopName: 'permission-loop', iteration: 1, currentSectionIndex: 0, @@ -174,19 +194,39 @@ describe('createAuditSession passes audit permission rules into session creation describe('createLoopSessionWithWorkspace passes loop permission rules into session creation', () => { test('session.create receives permission exactly equal to buildLoopPermissionRuleset()', async () => { const expectedPermission = buildLoopPermissionRuleset() - const mockCreate = mock(async (params: any) => ({ data: { id: 'loop-session' }, error: null })) - const mockGet = mock(async () => ({ data: {} })) - const mockV2 = { + const mockCreate = mock(async (params: any) => ({ id: 'loop-session' })) + const mockClient = { session: { create: mockCreate, - get: mockGet, + get: mock(async () => ({})), + promptAsync: mock(async () => {}), + abort: mock(async () => {}), + status: mock(async () => ({})), + messages: mock(async () => []), + update: mock(async () => {}), + delete: mock(async () => {}), + }, + workspace: { + create: mock(async () => ({ id: '', directory: '', branch: '' })), + list: mock(async () => []), + status: mock(async () => []), + syncList: mock(async () => {}), + remove: mock(async () => {}), + warp: mock(async () => {}), + }, + tui: { + publish: mock(async () => {}), + selectSession: mock(async () => {}), + }, + sync: { + start: mock(async () => {}), }, } as any const logger = { log: mock(), error: mock() } as unknown as Logger await createLoopSessionWithWorkspace({ - v2: mockV2, + client: mockClient, title: 'test loop session', directory: '/tmp/permission-loop', permission: expectedPermission, @@ -208,13 +248,13 @@ describe('createLoopSessionWithWorkspace passes loop permission rules into sessi describe('createLoopPermissionRejectHook', () => { test('does not update subagent session permissions when the session is outside an active loop', async () => { - const mockGet = mock(async () => ({ data: { permission: buildLoopPermissionRuleset() } })) - const mockUpdate = mock(async () => ({ data: {}, error: null })) + const mockGet = mock(async () => ({ permission: buildLoopPermissionRuleset() })) + const mockUpdate = mock(async () => {}) const mockResolve = mock(async () => null) const logger = { log: mock(), error: mock(), debug: mock() } as unknown as Logger const hook = createLoopPermissionRejectHook({ - v2: { + client: { session: { get: mockGet, update: mockUpdate, @@ -247,12 +287,12 @@ describe('createLoopPermissionRejectHook', () => { test('copies active loop parent permissions onto child subagent sessions', async () => { const parentPermission = buildLoopPermissionRuleset({ sandbox: true }) - const mockGet = mock(async () => ({ data: { permission: parentPermission } })) - const mockUpdate = mock(async () => ({ data: {}, error: null })) + const mockGet = mock(async () => ({ permission: parentPermission })) + const mockUpdate = mock(async () => {}) const logger = { log: mock(), error: mock(), debug: mock() } as unknown as Logger const hook = createLoopPermissionRejectHook({ - v2: { + client: { session: { get: mockGet, update: mockUpdate, @@ -294,12 +334,12 @@ describe('createLoopPermissionRejectHook', () => { }) test('falls back to worktree-only rules when parent permissions are unavailable for a non-sandbox loop', async () => { - const mockGet = mock(async () => ({ data: {} })) - const mockUpdate = mock(async () => ({ data: {}, error: null })) + const mockGet = mock(async () => ({})) + const mockUpdate = mock(async () => {}) const logger = { log: mock(), error: mock(), debug: mock() } as unknown as Logger const hook = createLoopPermissionRejectHook({ - v2: { session: { get: mockGet, update: mockUpdate } } as any, + client: { session: { get: mockGet, update: mockUpdate } } as any, sessionLoopResolver: { resolveActiveLoopForSession: mock(async () => ({ loopName: 'active-loop', @@ -328,12 +368,12 @@ describe('createLoopPermissionRejectHook', () => { test('is idempotent: firing twice for the same child session results in a single session.update call', async () => { const parentPermission = buildLoopPermissionRuleset({ sandbox: true }) - const mockGet = mock(async () => ({ data: { permission: parentPermission } })) - const mockUpdate = mock(async () => ({ data: {}, error: null })) + const mockGet = mock(async () => ({ permission: parentPermission })) + const mockUpdate = mock(async () => {}) const logger = { log: mock(), error: mock(), debug: mock() } as unknown as Logger const hook = createLoopPermissionRejectHook({ - v2: { session: { get: mockGet, update: mockUpdate } } as any, + client: { session: { get: mockGet, update: mockUpdate } } as any, sessionLoopResolver: { resolveActiveLoopForSession: mock(async () => ({ loopName: 'active-loop', diff --git a/test/loop-runtime-audit-permissions.test.ts b/test/loop-runtime-audit-permissions.test.ts index 9cffec48a0..643bbb8d33 100644 --- a/test/loop-runtime-audit-permissions.test.ts +++ b/test/loop-runtime-audit-permissions.test.ts @@ -12,12 +12,12 @@ import type { LoopState } from '../src/loop/state' import { createLoop } from '../src/loop/runtime' import { buildAuditSessionPermissionRuleset } from '../src/constants/loop' import type { Logger, PluginConfig } from '../src/types' -import type { OpencodeClient } from '@opencode-ai/sdk/v2' import { setupLoopsTestDb } from './helpers/loops-test-db' +import { createFakeForgeClient } from './helpers/fake-client' const PROJECT_ID = 'test-project' -describe('Legacy audit fallback permissions', () => { +describe('Audit session permissions', () => { let db: DB let tempDir: string let loopsRepo: ReturnType @@ -71,39 +71,39 @@ describe('Legacy audit fallback permissions', () => { } } - test('fallback includes buildAuditSessionPermissionRuleset()', async () => { - const legacyCreateCalls: Array> = [] + test('audit session includes buildAuditSessionPermissionRuleset()', async () => { + const createCalls: Array> = [] - const pluginClient = { + const { client } = createFakeForgeClient({ session: { - create: vi.fn(async (input: any) => { - legacyCreateCalls.push(input) - return { data: { id: 'legacy-audit' }, error: null } - }), - promptAsync: vi.fn(async () => ({ data: {}, error: null })), - messages: vi.fn(async () => ({ data: [], error: null })), + create: async (input: any) => { + createCalls.push(input) + return { id: 'audit-session' } + }, + get: async () => ({ id: 'ses_fake_1', permission: null }), + status: async () => ({}), + promptAsync: async () => {}, + messages: async () => [ + { + info: { role: 'assistant', finish: 'stop' }, + parts: [{ type: 'text', text: 'All clear.' }], + }, + ], + abort: async () => {}, + delete: async () => {}, + update: async () => {}, }, - } - - const v2Client = { - session: { - create: vi.fn(async () => ({ error: new Error('v2 down'), data: undefined })), - get: vi.fn(async () => ({ data: {}, error: null })), - promptAsync: vi.fn(async () => ({ data: {}, error: null })), - abort: vi.fn(async () => ({ data: {}, error: null })), - messages: vi.fn(async () => ({ - data: [ - { - info: { role: 'assistant', finish: 'stop' }, - parts: [{ type: 'text', text: 'All clear.' }], - }, - ], - error: null, - })), - status: vi.fn(async () => ({ data: {}, error: null })), - delete: vi.fn(async () => ({ data: {}, error: null })), + workspace: { + warp: async () => {}, + list: async () => [], + remove: async () => {}, + status: async () => ({}), }, - } as unknown as OpencodeClient + tui: { + publish: async () => {}, + selectSession: async () => {}, + }, + }) const logger: Logger = { log: () => {}, @@ -137,8 +137,7 @@ describe('Legacy audit fallback permissions', () => { reviewFindingsRepo, sectionPlansRepo, projectId: PROJECT_ID, - client: pluginClient as any, - v2Client, + client, logger, getConfig: () => config, sandboxManager: undefined, @@ -165,12 +164,12 @@ describe('Legacy audit fallback permissions', () => { }, }) - expect(legacyCreateCalls.length).toBeGreaterThan(0) + expect(createCalls.length).toBeGreaterThan(0) - const callBody = legacyCreateCalls[0] as any - expect(callBody.body).toBeDefined() - expect(callBody.body.permission).toEqual(buildAuditSessionPermissionRuleset({ sandbox: false })) - expect(callBody.body.permission).toContainEqual({ + // With the ForgeClient port, create params are passed directly (not wrapped in { body }) + const callParams = createCalls[0] as any + expect(callParams.permission).toEqual(buildAuditSessionPermissionRuleset({ sandbox: false })) + expect(callParams.permission).toContainEqual({ permission: 'external_directory', pattern: '*', action: 'deny', diff --git a/test/loop-service.test.ts b/test/loop-service.test.ts index f9038c67d0..313decfcba 100644 --- a/test/loop-service.test.ts +++ b/test/loop-service.test.ts @@ -42,7 +42,6 @@ describe('Loop', () => { projectId, logger: mockLogger, client: {} as any, - v2Client: {} as any, getConfig: () => ({} as any), }) }) diff --git a/test/loop-status-tool.test.ts b/test/loop-status-tool.test.ts index 4eb38f6614..3d4027c516 100644 --- a/test/loop-status-tool.test.ts +++ b/test/loop-status-tool.test.ts @@ -1,7 +1,5 @@ import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest' import { mkdirSync } from 'fs' -import type { TuiPluginApi } from '@opencode-ai/plugin/tui' -import type { OpencodeClient } from '@opencode-ai/sdk/v2' import { createLoopService } from '../src/loop/service' import type { LoopState } from '../src/loop/state' import { createLoopsRepo } from '../src/storage/repos/loops-repo' @@ -17,6 +15,23 @@ import { join } from 'path' import { randomUUID } from 'crypto' import Database from 'better-sqlite3' import { setupLoopsTestDb } from './helpers/loops-test-db' +import { createFakeForgeClient } from './helpers/fake-client' +import { createPendingTeardownRegistry } from '../src/workspace/pending-teardown' +import type { WorkspaceStatusRegistry } from '../src/utils/workspace-status-registry' + +/** + * Creates a workspace status registry where awaitConnected resolves immediately. + * This avoids 5s timeouts when the execution service waits for workspace + * connection events that mock clients never fire. + */ +function createNoWaitWorkspaceStatusRegistry(): WorkspaceStatusRegistry { + return { + recordEvent: () => {}, + getStatus: () => 'connected' as const, + awaitConnected: async () => ({ connected: true, elapsedMs: 0, source: 'cached' as const }), + primeFromSnapshot: () => {}, + } +} const TEST_DIR = '/tmp/opencode-loop-status-test-' + Date.now() @@ -29,87 +44,6 @@ function createTestDb(): { db: Database; path: string } { return { db, path } } -function createMockV2Client(overrides?: Partial): OpencodeClient { - return { - session: { - create: vi.fn(async (params) => ({ - data: { id: 'mock-session-' + Date.now(), title: params.title }, - error: null, - })), - promptAsync: vi.fn(async () => ({ data: {}, error: null })), - abort: vi.fn(async () => ({ data: {}, error: null })), - status: vi.fn(async () => ({ data: {}, error: null })), - delete: vi.fn(async () => ({ data: {}, error: null })), - messages: vi.fn(async () => ({ data: [], error: null })), - get: vi.fn(async () => ({ data: {}, error: null })), - }, - worktree: { - create: vi.fn(async () => ({ data: { name: 'mock', directory: '/tmp/mock', branch: 'main' }, error: null })), - remove: vi.fn(async () => ({ data: {}, error: null })), - }, - experimental: { - workspace: { - create: vi.fn(async () => ({ - data: { id: 'mock-workspace-' + Date.now(), directory: TEST_DIR + '/worktree', branch: 'opencode/loop-test-loop' }, - error: null, - })), - warp: vi.fn(async () => ({ data: {}, error: null })), - list: vi.fn(async () => ({ data: [], error: null })), - status: vi.fn(async () => ({ data: [], error: null })), - syncList: vi.fn(async () => ({ data: {}, error: null })), - remove: vi.fn(async () => ({ data: {}, error: null })), - }, - }, - tui: { - selectSession: vi.fn(async () => ({ data: {}, error: null })), - publish: vi.fn(async () => ({ data: {}, error: null })), - }, - ...overrides, - } as unknown as OpencodeClient -} - -function createMockTuiApi(overrides?: Partial): TuiPluginApi { - return { - client: createMockV2Client(), - state: { - path: { - directory: TEST_DIR, - }, - }, - ui: { - toast: vi.fn(() => {}), - dialog: { - clear: vi.fn(() => {}), - replace: vi.fn(() => {}), - setSize: vi.fn(() => {}), - }, - }, - theme: { - current: { - text: 'white', - textMuted: 'gray', - border: 'blue', - info: 'cyan', - success: 'green', - warning: 'yellow', - error: 'red', - markdownText: 'white', - }, - }, - route: { - navigate: vi.fn(() => {}), - current: { name: 'session', params: {} }, - }, - event: { - on: vi.fn(() => () => {}), - }, - app: { - version: 'local', - }, - ...overrides, - } as TuiPluginApi -} - describe('loop-status tool restart path', () => { let db: Database let dbPath: string @@ -157,8 +91,7 @@ describe('loop-status tool restart path', () => { } test('force-restart preserves workspaceId and hostSessionId', async () => { - const mockApi = createMockTuiApi() - const v2Client = mockApi.client as unknown as OpencodeClient + const { client: forgeClient } = createFakeForgeClient() const logger = createLogger({ enabled: false, file: '' }) const loopsRepo = createLoopsRepo(db) @@ -176,9 +109,11 @@ describe('loop-status tool restart path', () => { sessionId: oldSessionId, } as LoopState) - const loopHandler = createLoopEventHandler(loopsRepo, plansRepo, reviewFindingsRepo, projectId, mockApi as any, v2Client, logger, () => ({}), undefined, dbPath) + const loopHandler = createLoopEventHandler(loopsRepo, plansRepo, reviewFindingsRepo, projectId, forgeClient, logger, () => ({}), undefined, dbPath) const tools = createLoopTools({ - v2: v2Client, + client: forgeClient, + workspaceStatusRegistry: createNoWaitWorkspaceStatusRegistry(), + pendingTeardowns: createPendingTeardownRegistry(), directory: TEST_DIR, config: {}, loopService, @@ -202,22 +137,22 @@ describe('loop-status tool restart path', () => { expect(result).toContain('Restarted loop') // Verify new session was created with workspaceID - const createCalls = ((v2Client.session.create as any)).mock.calls + const createCalls = ((forgeClient.session.create as any)).mock.calls expect(createCalls.length).toBeGreaterThan(0) const lastCreateCall = createCalls[createCalls.length - 1][0] // Restart creates a FRESH workspace, so directory points to the new workspace directory - expect(lastCreateCall.workspaceID).toMatch(/^mock-workspace-/) + expect(lastCreateCall.workspaceID).toMatch(/^ws_fake_/) expect(lastCreateCall.title).toContain(loopName) expect(lastCreateCall).not.toHaveProperty('parentID') // Verify workspace binding was called with the fresh workspace id - expect((v2Client.experimental?.workspace?.warp as any)).toHaveBeenCalled() + expect((forgeClient.workspace.warp as any)).toHaveBeenCalled() // Verify persisted state has a fresh workspaceId (new on every restart) // and preserves hostSessionId const newState = loopService.getActiveState(loopName) expect(newState).toBeDefined() - expect(newState?.workspaceId).toMatch(/^mock-workspace-/) + expect(newState?.workspaceId).toMatch(/^ws_fake_/) expect(newState?.workspaceId).not.toBe(workspaceId) // fresh workspace, not old one expect(newState?.hostSessionId).toBe(hostSessionId) // Suppress unused variable warning for worktreeDir @@ -225,8 +160,7 @@ describe('loop-status tool restart path', () => { }) test('force-restart during auditing phase prevents double-rotation', async () => { - const mockApi = createMockTuiApi() - const v2Client = mockApi.client as unknown as OpencodeClient + const { client: forgeClient } = createFakeForgeClient() const logger = createLogger({ enabled: false, file: '' }) const loopsRepo = createLoopsRepo(db) @@ -250,9 +184,11 @@ describe('loop-status tool restart path', () => { worktreeBranch: 'opencode/loop-test-loop2', } as LoopState) - const loopHandler = createLoopEventHandler(loopsRepo, plansRepo, reviewFindingsRepo, projectId, mockApi as any, v2Client, logger, () => ({}), undefined, dbPath) + const loopHandler = createLoopEventHandler(loopsRepo, plansRepo, reviewFindingsRepo, projectId, forgeClient, logger, () => ({}), undefined, dbPath) const tools = createLoopTools({ - v2: v2Client, + client: forgeClient, + workspaceStatusRegistry: createNoWaitWorkspaceStatusRegistry(), + pendingTeardowns: createPendingTeardownRegistry(), directory: TEST_DIR, config: {}, loopService, @@ -276,7 +212,7 @@ describe('loop-status tool restart path', () => { await restartPromise // Verify only one new session was created (not multiple) - const createCalls = ((v2Client.session.create as any)).mock.calls + const createCalls = ((forgeClient.session.create as any)).mock.calls // Should have exactly one create call for the restart expect(createCalls.length).toBe(1) const createArgs = createCalls[0][0] @@ -288,8 +224,18 @@ describe('loop-status tool restart path', () => { }) test('force-restart clears workspaceId but preserves hostSessionId when bind fails', async () => { - const mockApi = createMockTuiApi() - const v2Client = mockApi.client as unknown as OpencodeClient + const toastCalls: Array<{ variant?: string; message?: string }> = [] + const { client: forgeClient } = createFakeForgeClient({ + workspace: { + warp: async () => { throw new Error('workspace gone') }, + }, + tui: { + publish: async (opts: any) => { + const props = opts?.body?.properties ?? {} + toastCalls.push({ variant: props.variant, message: props.message }) + }, + }, + }) const logger = createLogger({ enabled: false, file: '' }) const loopsRepo = createLoopsRepo(db) @@ -309,21 +255,11 @@ describe('loop-status tool restart path', () => { worktreeDir, } as LoopState) - // Override warp to throw - ;(v2Client.experimental!.workspace!.warp as any) = vi.fn(async () => { - throw new Error('workspace gone') - }) - - const toastCalls: Array<{ variant?: string; message?: string }> = [] - ;(v2Client.tui!.publish as any) = vi.fn(async (opts: any) => { - const props = opts?.body?.properties ?? {} - toastCalls.push({ variant: props.variant, message: props.message }) - return { data: {}, error: null } - }) - - const loopHandler = createLoopEventHandler(loopsRepo, plansRepo, reviewFindingsRepo, projectId, mockApi as any, v2Client, logger, () => ({}), undefined, dbPath) + const loopHandler = createLoopEventHandler(loopsRepo, plansRepo, reviewFindingsRepo, projectId, forgeClient, logger, () => ({}), undefined, dbPath) const tools = createLoopTools({ - v2: v2Client, + client: forgeClient, + workspaceStatusRegistry: createNoWaitWorkspaceStatusRegistry(), + pendingTeardowns: createPendingTeardownRegistry(), directory: TEST_DIR, config: {}, loopService, @@ -344,7 +280,7 @@ describe('loop-status tool restart path', () => { expect(result).toContain('Restarted loop') - const createCalls = ((v2Client.session.create as any)).mock.calls + const createCalls = ((forgeClient.session.create as any)).mock.calls expect(createCalls.length).toBeGreaterThan(0) const createArgs = createCalls[0][0] expect(createArgs).not.toHaveProperty('parentID') @@ -364,8 +300,7 @@ describe('loop-status tool restart path', () => { }) test('non-force restart (inactive loop) preserves metadata', async () => { - const mockApi = createMockTuiApi() - const v2Client = mockApi.client as unknown as OpencodeClient + const { client: forgeClient } = createFakeForgeClient() const logger = createLogger({ enabled: false, file: '' }) const loopsRepo = createLoopsRepo(db) @@ -385,9 +320,11 @@ describe('loop-status tool restart path', () => { terminationReason: 'cancelled', } as LoopState) - const loopHandler = createLoopEventHandler(loopsRepo, plansRepo, reviewFindingsRepo, projectId, mockApi as any, v2Client, logger, () => ({}), undefined, dbPath) + const loopHandler = createLoopEventHandler(loopsRepo, plansRepo, reviewFindingsRepo, projectId, forgeClient, logger, () => ({}), undefined, dbPath) const tools = createLoopTools({ - v2: v2Client, + client: forgeClient, + workspaceStatusRegistry: createNoWaitWorkspaceStatusRegistry(), + pendingTeardowns: createPendingTeardownRegistry(), directory: TEST_DIR, config: {}, loopService, @@ -412,12 +349,12 @@ describe('loop-status tool restart path', () => { // Verify new state has fresh workspace (created on every restart) // hostSessionId is preserved for post-completion TUI redirect const newState = loopService.getActiveState(loopName) - expect(newState?.workspaceId).toMatch(/^mock-workspace-/) + expect(newState?.workspaceId).toMatch(/^ws_fake_/) expect(newState?.workspaceId).not.toBe(workspaceId) expect(newState?.hostSessionId).toBe(hostSessionId) // Verify restart session was created without parentID - const createCalls = ((v2Client.session.create as any)).mock.calls + const createCalls = ((forgeClient.session.create as any)).mock.calls expect(createCalls.length).toBeGreaterThan(0) const createArgs = createCalls[0][0] expect(createArgs).not.toHaveProperty('parentID') @@ -426,8 +363,7 @@ describe('loop-status tool restart path', () => { }) test('force-restart errored loop without workspace includes permission ruleset', async () => { - const mockApi = createMockTuiApi() - const v2Client = mockApi.client as unknown as OpencodeClient + const { client: forgeClient } = createFakeForgeClient() const logger = createLogger({ enabled: false, file: '' }) const loopsRepo = createLoopsRepo(db) @@ -467,9 +403,11 @@ describe('loop-status tool restart path', () => { completedAt: new Date().toISOString(), } as LoopState) - const loopHandler = createLoopEventHandler(loopsRepo, plansRepo, reviewFindingsRepo, projectId, mockApi as any, v2Client, logger, () => ({}), undefined, dbPath) + const loopHandler = createLoopEventHandler(loopsRepo, plansRepo, reviewFindingsRepo, projectId, forgeClient, logger, () => ({}), undefined, dbPath) const tools = createLoopTools({ - v2: v2Client, + client: forgeClient, + workspaceStatusRegistry: createNoWaitWorkspaceStatusRegistry(), + pendingTeardowns: createPendingTeardownRegistry(), directory: TEST_DIR, config: {}, loopService, @@ -488,7 +426,7 @@ describe('loop-status tool restart path', () => { force: true, }, { sessionID: 'test-session' } as any) - const createCalls = ((v2Client.session.create as any)).mock.calls + const createCalls = ((forgeClient.session.create as any)).mock.calls expect(createCalls.length).toBeGreaterThan(0) // Find the session.create call that has permission property @@ -500,8 +438,7 @@ describe('loop-status tool restart path', () => { }) test('non-force restart of final_audit_retry_exhausted succeeds without force', async () => { - const mockApi = createMockTuiApi() - const v2Client = mockApi.client as unknown as OpencodeClient + const { client: forgeClient } = createFakeForgeClient() const logger = createLogger({ enabled: false, file: '' }) const loopsRepo = createLoopsRepo(db) @@ -541,9 +478,11 @@ describe('loop-status tool restart path', () => { completedAt: new Date().toISOString(), } as LoopState) - const loopHandler = createLoopEventHandler(loopsRepo, plansRepo, reviewFindingsRepo, projectId, mockApi as any, v2Client, logger, () => ({}), undefined, dbPath) + const loopHandler = createLoopEventHandler(loopsRepo, plansRepo, reviewFindingsRepo, projectId, forgeClient, logger, () => ({}), undefined, dbPath) const tools = createLoopTools({ - v2: v2Client, + client: forgeClient, + workspaceStatusRegistry: createNoWaitWorkspaceStatusRegistry(), + pendingTeardowns: createPendingTeardownRegistry(), directory: TEST_DIR, config: {}, loopService, @@ -576,14 +515,14 @@ describe('loop-status tool restart path', () => { expect(newState?.finalAuditDone).toBe(false) // Verify promptAsync was called with auditor-loop agent using auditor model - const promptCalls = ((v2Client.session.promptAsync as any)).mock.calls + const promptCalls = ((forgeClient.session.promptAsync as any)).mock.calls expect(promptCalls.length).toBeGreaterThan(0) const lastPromptCall = promptCalls[promptCalls.length - 1][0] expect(lastPromptCall.agent).toBe('auditor-loop') expect(lastPromptCall.model).toEqual({ providerID: 'provider', modelID: 'auditor-model' }) // Verify session creation uses audit permissions, not loop permissions - const createCalls = ((v2Client.session.create as any)).mock.calls + const createCalls = ((forgeClient.session.create as any)).mock.calls expect(createCalls.length).toBeGreaterThan(0) const callWithPermission = createCalls.find((call: any[]) => call[0]?.permission !== undefined @@ -593,8 +532,7 @@ describe('loop-status tool restart path', () => { }) test('forced restart of final_audit_retry_exhausted resumes at final_auditing', async () => { - const mockApi = createMockTuiApi() - const v2Client = mockApi.client as unknown as OpencodeClient + const { client: forgeClient } = createFakeForgeClient() const logger = createLogger({ enabled: false, file: '' }) const loopsRepo = createLoopsRepo(db) @@ -634,9 +572,11 @@ describe('loop-status tool restart path', () => { completedAt: new Date().toISOString(), } as LoopState) - const loopHandler = createLoopEventHandler(loopsRepo, plansRepo, reviewFindingsRepo, projectId, mockApi as any, v2Client, logger, () => ({}), undefined, dbPath) + const loopHandler = createLoopEventHandler(loopsRepo, plansRepo, reviewFindingsRepo, projectId, forgeClient, logger, () => ({}), undefined, dbPath) const tools = createLoopTools({ - v2: v2Client, + client: forgeClient, + workspaceStatusRegistry: createNoWaitWorkspaceStatusRegistry(), + pendingTeardowns: createPendingTeardownRegistry(), directory: TEST_DIR, config: {}, loopService, @@ -669,14 +609,14 @@ describe('loop-status tool restart path', () => { expect(newState?.finalAuditDone).toBe(false) // Verify promptAsync was called with auditor-loop agent using auditor model - const promptCalls = ((v2Client.session.promptAsync as any)).mock.calls + const promptCalls = ((forgeClient.session.promptAsync as any)).mock.calls expect(promptCalls.length).toBeGreaterThan(0) const lastPromptCall = promptCalls[promptCalls.length - 1][0] expect(lastPromptCall.agent).toBe('auditor-loop') expect(lastPromptCall.model).toEqual({ providerID: 'provider', modelID: 'auditor-model' }) // Verify session creation uses audit permissions, not loop permissions - const createCalls = ((v2Client.session.create as any)).mock.calls + const createCalls = ((forgeClient.session.create as any)).mock.calls expect(createCalls.length).toBeGreaterThan(0) const callWithPermission = createCalls.find((call: any[]) => call[0]?.permission !== undefined @@ -702,60 +642,10 @@ describe('loop-status cumulative usage', () => { db.close() }) - function createMockV2ClientWithMessages(messages: Array<{ role: string; cost?: number; tokens?: any; model?: string }>): OpencodeClient { - return { - session: { - create: vi.fn(async (params) => ({ - data: { id: 'mock-session-' + Date.now(), title: params.title }, - error: null, - })), - promptAsync: vi.fn(async () => ({ data: {}, error: null })), - abort: vi.fn(async () => ({ data: {}, error: null })), - status: vi.fn(async () => ({ data: {}, error: null })), - delete: vi.fn(async () => ({ data: {}, error: null })), - messages: vi.fn(async () => ({ - data: messages.map((m, i) => ({ - id: `msg-${i}`, - role: m.role, - parts: [{ type: 'text' as const, text: 'test' }], - info: { - role: m.role, - cost: m.cost ?? 0, - tokens: m.tokens ?? { input: 100, output: 50, reasoning: 20, cache: { read: 10, write: 5 } }, - model: m.model, - }, - })), - error: null, - })), - get: vi.fn(async () => ({ data: { summary: { additions: 10, deletions: 5, files: 2 } }, error: null })), - }, - worktree: { - create: vi.fn(async () => ({ data: { name: 'mock', directory: '/tmp/mock', branch: 'main' }, error: null })), - remove: vi.fn(async () => ({ data: {}, error: null })), - }, - experimental: { - workspace: { - create: vi.fn(async () => ({ - data: { id: 'mock-workspace-' + Date.now(), directory: TEST_DIR + '/worktree', branch: 'opencode/loop-test' }, - error: null, - })), - warp: vi.fn(async () => ({ data: {}, error: null })), - list: vi.fn(async () => ({ data: [], error: null })), - status: vi.fn(async () => ({ data: [], error: null })), - syncList: vi.fn(async () => ({ data: {}, error: null })), - remove: vi.fn(async () => ({ data: {}, error: null })), - }, - }, - tui: { - selectSession: vi.fn(async () => ({ data: {}, error: null })), - publish: vi.fn(async () => ({ data: {}, error: null })), - }, - } as unknown as OpencodeClient - } + // (createMockV2ClientWithMessages removed - use createFakeForgeClient with session overrides instead) test('cumulative usage appears in detailed status for inactive loop', async () => { - const mockApi = createMockTuiApi() - const v2Client = mockApi.client as unknown as OpencodeClient + const { client: forgeClient } = createFakeForgeClient() const logger = createLogger({ enabled: false, file: '' }) const loopsRepo = createLoopsRepo(db) @@ -813,9 +703,11 @@ describe('loop-status cumulative usage', () => { capturedAt: Date.now(), }) - const loopHandler = createLoopEventHandler(loopsRepo, plansRepo, reviewFindingsRepo, projectId, mockApi as any, v2Client, logger, () => ({}), undefined, dbPath, {}, undefined, undefined, undefined, loopSessionUsageRepo) + const loopHandler = createLoopEventHandler(loopsRepo, plansRepo, reviewFindingsRepo, projectId, forgeClient, logger, () => ({}), undefined, dbPath, {}, undefined, undefined, undefined, loopSessionUsageRepo) const tools = createLoopTools({ - v2: v2Client, + client: forgeClient, + workspaceStatusRegistry: createNoWaitWorkspaceStatusRegistry(), + pendingTeardowns: createPendingTeardownRegistry(), directory: TEST_DIR, config: {}, loopService, @@ -840,8 +732,7 @@ describe('loop-status cumulative usage', () => { }) test('cumulative usage appears in detailed status for active loop', async () => { - const mockApi = createMockTuiApi() - const v2Client = mockApi.client as unknown as OpencodeClient + const { client: forgeClient } = createFakeForgeClient() const logger = createLogger({ enabled: false, file: '' }) const loopsRepo = createLoopsRepo(db) @@ -897,9 +788,11 @@ describe('loop-status cumulative usage', () => { capturedAt: Date.now() - 10000, }) - const loopHandler = createLoopEventHandler(loopsRepo, plansRepo, reviewFindingsRepo, projectId, mockApi as any, v2Client, logger, () => ({}), undefined, dbPath, {}, undefined, undefined, undefined, loopSessionUsageRepo) + const loopHandler = createLoopEventHandler(loopsRepo, plansRepo, reviewFindingsRepo, projectId, forgeClient, logger, () => ({}), undefined, dbPath, {}, undefined, undefined, undefined, loopSessionUsageRepo) const tools = createLoopTools({ - v2: v2Client, + client: forgeClient, + workspaceStatusRegistry: createNoWaitWorkspaceStatusRegistry(), + pendingTeardowns: createPendingTeardownRegistry(), directory: TEST_DIR, config: {}, loopService, @@ -923,8 +816,7 @@ describe('loop-status cumulative usage', () => { }) test('per-model totals appear in cumulative usage', async () => { - const mockApi = createMockTuiApi() - const v2Client = mockApi.client as unknown as OpencodeClient + const { client: forgeClient } = createFakeForgeClient() const logger = createLogger({ enabled: false, file: '' }) const loopsRepo = createLoopsRepo(db) @@ -998,9 +890,11 @@ describe('loop-status cumulative usage', () => { }, ]) - const loopHandler = createLoopEventHandler(loopsRepo, plansRepo, reviewFindingsRepo, projectId, mockApi as any, v2Client, logger, () => ({}), undefined, dbPath, {}, undefined, undefined, undefined, loopSessionUsageRepo) + const loopHandler = createLoopEventHandler(loopsRepo, plansRepo, reviewFindingsRepo, projectId, forgeClient, logger, () => ({}), undefined, dbPath, {}, undefined, undefined, undefined, loopSessionUsageRepo) const tools = createLoopTools({ - v2: v2Client, + client: forgeClient, + workspaceStatusRegistry: createNoWaitWorkspaceStatusRegistry(), + pendingTeardowns: createPendingTeardownRegistry(), directory: TEST_DIR, config: {}, loopService, @@ -1024,10 +918,14 @@ describe('loop-status cumulative usage', () => { }) test('live current session is merged when not persisted', async () => { - const mockApi = createMockTuiApi() - const v2Client = createMockV2ClientWithMessages([ - { role: 'assistant', cost: 0.02, tokens: { input: 2000, output: 1000, reasoning: 200, cache: { read: 50, write: 25 } }, model: 'anthropic/claude-3-5-sonnet' }, - ]) + const { client: forgeClient } = createFakeForgeClient({ + session: { + messages: async () => [ + { id: 'msg-0', role: 'assistant', parts: [{ type: 'text' as const, text: 'test' }], info: { role: 'assistant', cost: 0.02, tokens: { input: 2000, output: 1000, reasoning: 200, cache: { read: 50, write: 25 } }, model: 'anthropic/claude-3-5-sonnet' } }, + ], + get: async () => ({ summary: { additions: 10, deletions: 5, files: 2 } } as any), + }, + }) const logger = createLogger({ enabled: false, file: '' }) const loopsRepo = createLoopsRepo(db) @@ -1083,9 +981,11 @@ describe('loop-status cumulative usage', () => { capturedAt: Date.now() - 10000, }) - const loopHandler = createLoopEventHandler(loopsRepo, plansRepo, reviewFindingsRepo, projectId, mockApi as any, v2Client, logger, () => ({}), undefined, dbPath, {}, undefined, undefined, undefined, loopSessionUsageRepo) + const loopHandler = createLoopEventHandler(loopsRepo, plansRepo, reviewFindingsRepo, projectId, forgeClient, logger, () => ({}), undefined, dbPath, {}, undefined, undefined, undefined, loopSessionUsageRepo) const tools = createLoopTools({ - v2: v2Client, + client: forgeClient, + workspaceStatusRegistry: createNoWaitWorkspaceStatusRegistry(), + pendingTeardowns: createPendingTeardownRegistry(), directory: TEST_DIR, config: {}, loopService, @@ -1112,10 +1012,14 @@ describe('loop-status cumulative usage', () => { }) test('already-persisted current session is not double-counted', async () => { - const mockApi = createMockTuiApi() - const v2Client = createMockV2ClientWithMessages([ - { role: 'assistant', cost: 0.03, tokens: { input: 3000, output: 1500, reasoning: 300, cache: { read: 75, write: 40 } }, model: 'anthropic/claude-3-5-sonnet' }, - ]) + const { client: forgeClient } = createFakeForgeClient({ + session: { + messages: async () => [ + { id: 'msg-0', role: 'assistant', parts: [{ type: 'text' as const, text: 'test' }], info: { role: 'assistant', cost: 0.03, tokens: { input: 3000, output: 1500, reasoning: 300, cache: { read: 75, write: 40 } }, model: 'anthropic/claude-3-5-sonnet' } }, + ], + get: async () => ({ summary: { additions: 10, deletions: 5, files: 2 } } as any), + }, + }) const logger = createLogger({ enabled: false, file: '' }) const loopsRepo = createLoopsRepo(db) @@ -1188,9 +1092,11 @@ describe('loop-status cumulative usage', () => { }, ]) - const loopHandler = createLoopEventHandler(loopsRepo, plansRepo, reviewFindingsRepo, projectId, mockApi as any, v2Client, logger, () => ({}), undefined, dbPath, {}, undefined, undefined, undefined, loopSessionUsageRepo) + const loopHandler = createLoopEventHandler(loopsRepo, plansRepo, reviewFindingsRepo, projectId, forgeClient, logger, () => ({}), undefined, dbPath, {}, undefined, undefined, undefined, loopSessionUsageRepo) const tools = createLoopTools({ - v2: v2Client, + client: forgeClient, + workspaceStatusRegistry: createNoWaitWorkspaceStatusRegistry(), + pendingTeardowns: createPendingTeardownRegistry(), directory: TEST_DIR, config: {}, loopService, @@ -1218,10 +1124,14 @@ describe('loop-status cumulative usage', () => { }) test('cumulative usage appears from live usage even when no persisted aggregate exists', async () => { - const mockApi = createMockTuiApi() - const v2Client = createMockV2ClientWithMessages([ - { role: 'assistant', cost: 0.015, tokens: { input: 1500, output: 750, reasoning: 150, cache: { read: 40, write: 20 } }, model: 'anthropic/claude-3-5-sonnet' }, - ]) + const { client: forgeClient } = createFakeForgeClient({ + session: { + messages: async () => [ + { id: 'msg-0', role: 'assistant', parts: [{ type: 'text' as const, text: 'test' }], info: { role: 'assistant', cost: 0.015, tokens: { input: 1500, output: 750, reasoning: 150, cache: { read: 40, write: 20 } }, model: 'anthropic/claude-3-5-sonnet' } }, + ], + get: async () => ({ summary: { additions: 10, deletions: 5, files: 2 } } as any), + }, + }) const logger = createLogger({ enabled: false, file: '' }) const loopsRepo = createLoopsRepo(db) @@ -1262,9 +1172,11 @@ describe('loop-status cumulative usage', () => { // NO persisted usage inserted - const loopHandler = createLoopEventHandler(loopsRepo, plansRepo, reviewFindingsRepo, projectId, mockApi as any, v2Client, logger, () => ({}), undefined, dbPath, {}, undefined, undefined, undefined, loopSessionUsageRepo) + const loopHandler = createLoopEventHandler(loopsRepo, plansRepo, reviewFindingsRepo, projectId, forgeClient, logger, () => ({}), undefined, dbPath, {}, undefined, undefined, undefined, loopSessionUsageRepo) const tools = createLoopTools({ - v2: v2Client, + client: forgeClient, + workspaceStatusRegistry: createNoWaitWorkspaceStatusRegistry(), + pendingTeardowns: createPendingTeardownRegistry(), directory: TEST_DIR, config: {}, loopService, @@ -1289,10 +1201,14 @@ describe('loop-status cumulative usage', () => { }) test('inactive loop merges live final session when not persisted', async () => { - const mockApi = createMockTuiApi() - const v2Client = createMockV2ClientWithMessages([ - { role: 'assistant', cost: 0.025, tokens: { input: 2500, output: 1250, reasoning: 250, cache: { read: 60, write: 30 } }, model: 'anthropic/claude-3-5-sonnet' }, - ]) + const { client: forgeClient } = createFakeForgeClient({ + session: { + messages: async () => [ + { id: 'msg-0', role: 'assistant', parts: [{ type: 'text' as const, text: 'test' }], info: { role: 'assistant', cost: 0.025, tokens: { input: 2500, output: 1250, reasoning: 250, cache: { read: 60, write: 30 } }, model: 'anthropic/claude-3-5-sonnet' } }, + ], + get: async () => ({ summary: { additions: 10, deletions: 5, files: 2 } } as any), + }, + }) const logger = createLogger({ enabled: false, file: '' }) const loopsRepo = createLoopsRepo(db) @@ -1350,9 +1266,11 @@ describe('loop-status cumulative usage', () => { capturedAt: Date.now() - 10000, }) - const loopHandler = createLoopEventHandler(loopsRepo, plansRepo, reviewFindingsRepo, projectId, mockApi as any, v2Client, logger, () => ({}), undefined, dbPath, {}, undefined, undefined, undefined, loopSessionUsageRepo) + const loopHandler = createLoopEventHandler(loopsRepo, plansRepo, reviewFindingsRepo, projectId, forgeClient, logger, () => ({}), undefined, dbPath, {}, undefined, undefined, undefined, loopSessionUsageRepo) const tools = createLoopTools({ - v2: v2Client, + client: forgeClient, + workspaceStatusRegistry: createNoWaitWorkspaceStatusRegistry(), + pendingTeardowns: createPendingTeardownRegistry(), directory: TEST_DIR, config: {}, loopService, @@ -1379,10 +1297,14 @@ describe('loop-status cumulative usage', () => { }) test('inactive loop uses persisted-only when final session is already persisted', async () => { - const mockApi = createMockTuiApi() - const v2Client = createMockV2ClientWithMessages([ - { role: 'assistant', cost: 0.035, tokens: { input: 3500, output: 1750, reasoning: 350, cache: { read: 90, write: 45 } }, model: 'anthropic/claude-3-5-sonnet' }, - ]) + const { client: forgeClient } = createFakeForgeClient({ + session: { + messages: async () => [ + { id: 'msg-0', role: 'assistant', parts: [{ type: 'text' as const, text: 'test' }], info: { role: 'assistant', cost: 0.035, tokens: { input: 3500, output: 1750, reasoning: 350, cache: { read: 90, write: 45 } }, model: 'anthropic/claude-3-5-sonnet' } }, + ], + get: async () => ({ summary: { additions: 10, deletions: 5, files: 2 } } as any), + }, + }) const logger = createLogger({ enabled: false, file: '' }) const loopsRepo = createLoopsRepo(db) @@ -1457,9 +1379,11 @@ describe('loop-status cumulative usage', () => { }, ]) - const loopHandler = createLoopEventHandler(loopsRepo, plansRepo, reviewFindingsRepo, projectId, mockApi as any, v2Client, logger, () => ({}), undefined, dbPath, {}, undefined, undefined, undefined, loopSessionUsageRepo) + const loopHandler = createLoopEventHandler(loopsRepo, plansRepo, reviewFindingsRepo, projectId, forgeClient, logger, () => ({}), undefined, dbPath, {}, undefined, undefined, undefined, loopSessionUsageRepo) const tools = createLoopTools({ - v2: v2Client, + client: forgeClient, + workspaceStatusRegistry: createNoWaitWorkspaceStatusRegistry(), + pendingTeardowns: createPendingTeardownRegistry(), directory: TEST_DIR, config: {}, loopService, @@ -1487,11 +1411,15 @@ describe('loop-status cumulative usage', () => { }) test('active loop attributes live usage to executionModel when messages lack model metadata', async () => { - const mockApi = createMockTuiApi() // Messages WITHOUT model field - should fall back to loop state's executionModel - const v2Client = createMockV2ClientWithMessages([ - { role: 'assistant', cost: 0.02, tokens: { input: 2000, output: 1000, reasoning: 200, cache: { read: 50, write: 25 } } }, - ]) + const { client: forgeClient } = createFakeForgeClient({ + session: { + messages: async () => [ + { id: 'msg-0', role: 'assistant', parts: [{ type: 'text' as const, text: 'test' }], info: { role: 'assistant', cost: 0.02, tokens: { input: 2000, output: 1000, reasoning: 200, cache: { read: 50, write: 25 } } } }, + ], + get: async () => ({ summary: { additions: 10, deletions: 5, files: 2 } } as any), + }, + }) const logger = createLogger({ enabled: false, file: '' }) const loopsRepo = createLoopsRepo(db) @@ -1530,9 +1458,11 @@ describe('loop-status cumulative usage', () => { finalAuditDone: false, } as any) - const loopHandler = createLoopEventHandler(loopsRepo, plansRepo, reviewFindingsRepo, projectId, mockApi as any, v2Client, logger, () => ({}), undefined, dbPath, {}, undefined, undefined, undefined, loopSessionUsageRepo) + const loopHandler = createLoopEventHandler(loopsRepo, plansRepo, reviewFindingsRepo, projectId, forgeClient, logger, () => ({}), undefined, dbPath, {}, undefined, undefined, undefined, loopSessionUsageRepo) const tools = createLoopTools({ - v2: v2Client, + client: forgeClient, + workspaceStatusRegistry: createNoWaitWorkspaceStatusRegistry(), + pendingTeardowns: createPendingTeardownRegistry(), directory: TEST_DIR, config: {}, loopService, @@ -1557,11 +1487,15 @@ describe('loop-status cumulative usage', () => { }) test('active loop in auditing phase attributes live usage to auditorModel when messages lack model metadata', async () => { - const mockApi = createMockTuiApi() // Messages WITHOUT model field - should fall back to loop state's auditorModel - const v2Client = createMockV2ClientWithMessages([ - { role: 'assistant', cost: 0.025, tokens: { input: 2500, output: 1250, reasoning: 250, cache: { read: 60, write: 30 } } }, - ]) + const { client: forgeClient } = createFakeForgeClient({ + session: { + messages: async () => [ + { id: 'msg-0', role: 'assistant', parts: [{ type: 'text' as const, text: 'test' }], info: { role: 'assistant', cost: 0.025, tokens: { input: 2500, output: 1250, reasoning: 250, cache: { read: 60, write: 30 } } } }, + ], + get: async () => ({ summary: { additions: 10, deletions: 5, files: 2 } } as any), + }, + }) const logger = createLogger({ enabled: false, file: '' }) const loopsRepo = createLoopsRepo(db) @@ -1600,9 +1534,11 @@ describe('loop-status cumulative usage', () => { finalAuditDone: false, } as any) - const loopHandler = createLoopEventHandler(loopsRepo, plansRepo, reviewFindingsRepo, projectId, mockApi as any, v2Client, logger, () => ({}), undefined, dbPath, {}, undefined, undefined, undefined, loopSessionUsageRepo) + const loopHandler = createLoopEventHandler(loopsRepo, plansRepo, reviewFindingsRepo, projectId, forgeClient, logger, () => ({}), undefined, dbPath, {}, undefined, undefined, undefined, loopSessionUsageRepo) const tools = createLoopTools({ - v2: v2Client, + client: forgeClient, + workspaceStatusRegistry: createNoWaitWorkspaceStatusRegistry(), + pendingTeardowns: createPendingTeardownRegistry(), directory: TEST_DIR, config: {}, loopService, @@ -1643,47 +1579,10 @@ describe('loop-status restartability display', () => { db.close() }) - function createMockV2ClientWithWorktreeCheck(worktreeDir: string): OpencodeClient { - return { - session: { - create: vi.fn(async (params) => ({ - data: { id: 'mock-session-' + Date.now(), title: params.title }, - error: null, - })), - promptAsync: vi.fn(async () => ({ data: {}, error: null })), - abort: vi.fn(async () => ({ data: {}, error: null })), - status: vi.fn(async () => ({ data: {}, error: null })), - delete: vi.fn(async () => ({ data: {}, error: null })), - messages: vi.fn(async () => ({ data: [], error: null })), - get: vi.fn(async () => ({ data: {}, error: null })), - }, - worktree: { - create: vi.fn(async () => ({ data: { name: 'mock', directory: '/tmp/mock', branch: 'main' }, error: null })), - remove: vi.fn(async () => ({ data: {}, error: null })), - }, - experimental: { - workspace: { - create: vi.fn(async () => ({ - data: { id: 'mock-workspace-' + Date.now(), directory: TEST_DIR + '/worktree', branch: 'opencode/loop-test' }, - error: null, - })), - warp: vi.fn(async () => ({ data: {}, error: null })), - list: vi.fn(async () => ({ data: [], error: null })), - status: vi.fn(async () => ({ data: [], error: null })), - syncList: vi.fn(async () => ({ data: {}, error: null })), - remove: vi.fn(async () => ({ data: {}, error: null })), - }, - }, - tui: { - selectSession: vi.fn(async () => ({ data: {}, error: null })), - publish: vi.fn(async () => ({ data: {}, error: null })), - }, - } as unknown as OpencodeClient - } + // (createMockV2ClientWithWorktreeCheck removed - use createFakeForgeClient instead) test('inactive cancelled loop with worktree shows Restart: available', async () => { - const mockApi = createMockTuiApi() - const v2Client = mockApi.client as unknown as OpencodeClient + const { client: forgeClient } = createFakeForgeClient() const logger = createLogger({ enabled: false, file: '' }) const loopsRepo = createLoopsRepo(db) @@ -1722,9 +1621,11 @@ describe('loop-status restartability display', () => { completedAt: new Date().toISOString(), } as any) - const loopHandler = createLoopEventHandler(loopsRepo, plansRepo, reviewFindingsRepo, projectId, mockApi as any, v2Client, logger, () => ({}), undefined, dbPath) + const loopHandler = createLoopEventHandler(loopsRepo, plansRepo, reviewFindingsRepo, projectId, forgeClient, logger, () => ({}), undefined, dbPath) const tools = createLoopTools({ - v2: v2Client, + client: forgeClient, + workspaceStatusRegistry: createNoWaitWorkspaceStatusRegistry(), + pendingTeardowns: createPendingTeardownRegistry(), directory: TEST_DIR, config: {}, loopService, @@ -1745,8 +1646,7 @@ describe('loop-status restartability display', () => { }) test('inactive errored loop with worktree shows Restart: available', async () => { - const mockApi = createMockTuiApi() - const v2Client = mockApi.client as unknown as OpencodeClient + const { client: forgeClient } = createFakeForgeClient() const logger = createLogger({ enabled: false, file: '' }) const loopsRepo = createLoopsRepo(db) @@ -1785,9 +1685,11 @@ describe('loop-status restartability display', () => { completedAt: new Date().toISOString(), } as any) - const loopHandler = createLoopEventHandler(loopsRepo, plansRepo, reviewFindingsRepo, projectId, mockApi as any, v2Client, logger, () => ({}), undefined, dbPath) + const loopHandler = createLoopEventHandler(loopsRepo, plansRepo, reviewFindingsRepo, projectId, forgeClient, logger, () => ({}), undefined, dbPath) const tools = createLoopTools({ - v2: v2Client, + client: forgeClient, + workspaceStatusRegistry: createNoWaitWorkspaceStatusRegistry(), + pendingTeardowns: createPendingTeardownRegistry(), directory: TEST_DIR, config: {}, loopService, @@ -1808,8 +1710,7 @@ describe('loop-status restartability display', () => { }) test('inactive stalled loop with worktree shows Restart: available', async () => { - const mockApi = createMockTuiApi() - const v2Client = mockApi.client as unknown as OpencodeClient + const { client: forgeClient } = createFakeForgeClient() const logger = createLogger({ enabled: false, file: '' }) const loopsRepo = createLoopsRepo(db) @@ -1848,9 +1749,11 @@ describe('loop-status restartability display', () => { completedAt: new Date().toISOString(), } as any) - const loopHandler = createLoopEventHandler(loopsRepo, plansRepo, reviewFindingsRepo, projectId, mockApi as any, v2Client, logger, () => ({}), undefined, dbPath) + const loopHandler = createLoopEventHandler(loopsRepo, plansRepo, reviewFindingsRepo, projectId, forgeClient, logger, () => ({}), undefined, dbPath) const tools = createLoopTools({ - v2: v2Client, + client: forgeClient, + workspaceStatusRegistry: createNoWaitWorkspaceStatusRegistry(), + pendingTeardowns: createPendingTeardownRegistry(), directory: TEST_DIR, config: {}, loopService, @@ -1871,8 +1774,7 @@ describe('loop-status restartability display', () => { }) test('completed loop shows Restart: not available (completed)', async () => { - const mockApi = createMockTuiApi() - const v2Client = mockApi.client as unknown as OpencodeClient + const { client: forgeClient } = createFakeForgeClient() const logger = createLogger({ enabled: false, file: '' }) const loopsRepo = createLoopsRepo(db) @@ -1911,9 +1813,11 @@ describe('loop-status restartability display', () => { completedAt: new Date().toISOString(), } as any) - const loopHandler = createLoopEventHandler(loopsRepo, plansRepo, reviewFindingsRepo, projectId, mockApi as any, v2Client, logger, () => ({}), undefined, dbPath) + const loopHandler = createLoopEventHandler(loopsRepo, plansRepo, reviewFindingsRepo, projectId, forgeClient, logger, () => ({}), undefined, dbPath) const tools = createLoopTools({ - v2: v2Client, + client: forgeClient, + workspaceStatusRegistry: createNoWaitWorkspaceStatusRegistry(), + pendingTeardowns: createPendingTeardownRegistry(), directory: TEST_DIR, config: {}, loopService, @@ -1934,8 +1838,7 @@ describe('loop-status restartability display', () => { }) test('loop with missing worktree shows Restart blocked message', async () => { - const mockApi = createMockTuiApi() - const v2Client = mockApi.client as unknown as OpencodeClient + const { client: forgeClient } = createFakeForgeClient() const logger = createLogger({ enabled: false, file: '' }) const loopsRepo = createLoopsRepo(db) @@ -1974,9 +1877,11 @@ describe('loop-status restartability display', () => { completedAt: new Date().toISOString(), } as any) - const loopHandler = createLoopEventHandler(loopsRepo, plansRepo, reviewFindingsRepo, projectId, mockApi as any, v2Client, logger, () => ({}), undefined, dbPath) + const loopHandler = createLoopEventHandler(loopsRepo, plansRepo, reviewFindingsRepo, projectId, forgeClient, logger, () => ({}), undefined, dbPath) const tools = createLoopTools({ - v2: v2Client, + client: forgeClient, + workspaceStatusRegistry: createNoWaitWorkspaceStatusRegistry(), + pendingTeardowns: createPendingTeardownRegistry(), directory: TEST_DIR, config: {}, loopService, @@ -1997,8 +1902,7 @@ describe('loop-status restartability display', () => { }) test('active loop shows Restart: available with force=true', async () => { - const mockApi = createMockTuiApi() - const v2Client = mockApi.client as unknown as OpencodeClient + const { client: forgeClient } = createFakeForgeClient() const logger = createLogger({ enabled: false, file: '' }) const loopsRepo = createLoopsRepo(db) @@ -2035,9 +1939,11 @@ describe('loop-status restartability display', () => { finalAuditDone: false, } as any) - const loopHandler = createLoopEventHandler(loopsRepo, plansRepo, reviewFindingsRepo, projectId, mockApi as any, v2Client, logger, () => ({}), undefined, dbPath) + const loopHandler = createLoopEventHandler(loopsRepo, plansRepo, reviewFindingsRepo, projectId, forgeClient, logger, () => ({}), undefined, dbPath) const tools = createLoopTools({ - v2: v2Client, + client: forgeClient, + workspaceStatusRegistry: createNoWaitWorkspaceStatusRegistry(), + pendingTeardowns: createPendingTeardownRegistry(), directory: TEST_DIR, config: {}, loopService, diff --git a/test/loop/cancel.test.ts b/test/loop/cancel.test.ts index c4758125fc..aa4e5ea9b6 100644 --- a/test/loop/cancel.test.ts +++ b/test/loop/cancel.test.ts @@ -9,10 +9,10 @@ import { createReviewFindingsRepo } from '../../src/storage/repos/review-finding import { createSectionPlansRepo } from '../../src/storage/repos/section-plans-repo' import { createLoopService } from '../../src/loop/service' import type { LoopState } from '../../src/loop/state' -import { createLoop, type Loop, type LoopRuntimeDeps } from '../../src/loop/runtime' +import { createLoop, type Loop } from '../../src/loop/runtime' import { sessionsAwaitingBusy } from '../../src/loop/idle-gate' import type { Logger, PluginConfig } from '../../src/types' -import type { OpencodeClient } from '@opencode-ai/sdk/v2' +import type { ForgeClient } from '../../src/client/port' import { setupLoopsTestDb } from '../helpers/loops-test-db' const PROJECT_ID = 'test-project' @@ -27,36 +27,34 @@ const mockConfig: PluginConfig = { }, } -interface MockClientState { - abortCalls: string[] - publishCalls: Array<{ directory: string; body: unknown }> -} - -function createMockV2Client(state: MockClientState): OpencodeClient { +function createMockForgeClient(): ForgeClient { return { session: { - create: async () => ({ error: null, data: { id: 'sess' } }), - promptAsync: async () => ({ error: null, data: null }), - status: async () => ({ error: null, data: {} }), - abort: async (params) => { - state.abortCalls.push((params as any).sessionID) - return {} - }, - delete: async () => ({ error: undefined }), - messages: async () => ({ error: null, data: [] }), - get: async () => ({ error: null, data: {} }), + create: async () => ({ id: 'sess' }) as any, + promptAsync: async () => {}, + status: async () => ({}) as any, + abort: async () => {}, + delete: async () => {}, + messages: async () => [], + get: async () => ({}) as any, + update: async () => {}, + }, + workspace: { + create: async () => ({ id: '', directory: '/tmp/wt', branch: 'b' }) as any, + list: async () => [], + status: async () => ({}) as any, + syncList: async () => {}, + remove: async () => {}, + warp: async () => {}, }, tui: { - publish: async (params) => { - state.publishCalls.push(params as { directory: string; body: unknown }) - }, + publish: async () => {}, selectSession: async () => {}, }, - worktree: { - create: async () => ({ error: null, data: { directory: '/tmp/wt', branch: 'b' } }), - remove: async () => {}, + sync: { + start: async () => {}, }, - } as unknown as OpencodeClient + } } function createCapturingLogger(): { logger: Logger; logs: Array<{ level: string; message: string }> } { @@ -191,7 +189,6 @@ describe('Loop Runtime cancel()', () => { { log: () => {}, error: () => {}, debug: () => {} }, undefined, undefined, - undefined, sectionPlansRepo, ) @@ -237,15 +234,8 @@ describe('Loop Runtime cancel()', () => { } function createRuntime(overrides: { - clientState?: MockClientState loopConfig?: Partial - } = {}): { loop: Loop; clientState: MockClientState; logger: Logger; logs: Array<{ level: string; message: string }> } { - const clientState = overrides.clientState ?? { - abortCalls: [], - publishCalls: [], - } - - const v2Client = createMockV2Client(clientState) + } = {}): { loop: Loop; logger: Logger; logs: Array<{ level: string; message: string }> } { const { logger, logs } = createCapturingLogger() const config: PluginConfig = { ...mockConfig, ...(overrides.loopConfig ?? {}) } @@ -255,13 +245,12 @@ describe('Loop Runtime cancel()', () => { reviewFindingsRepo, sectionPlansRepo, projectId: PROJECT_ID, - client: { client: {} as any } as any, - v2Client, + client: createMockForgeClient(), logger, getConfig: () => config, }) - return { loop, clientState, logger, logs } + return { loop, logger, logs } } describe('cancel terminates with cancelled reason', () => { @@ -292,8 +281,7 @@ describe('Loop Runtime cancel()', () => { reviewFindingsRepo, sectionPlansRepo, projectId: PROJECT_ID, - client: {} as any, - v2Client: createMockV2Client({ abortCalls: [], publishCalls: [] }), + client: createMockForgeClient(), logger, getConfig: () => mockConfig, onTerminated: async (state, _reason) => { diff --git a/test/loop/in-flight-guard.test.ts b/test/loop/in-flight-guard.test.ts index 087f9db08d..e0985bc23f 100644 --- a/test/loop/in-flight-guard.test.ts +++ b/test/loop/in-flight-guard.test.ts @@ -146,8 +146,8 @@ describe('withInFlightGuard', () => { test('passes through return value when no concurrent prompt is in-flight', async () => { const { logger } = createMockLogger() - const result = await withInFlightGuard('loopA', 'sess-1', 'code', logger, async () => ({ data: 'ok' })) - expect(result).toEqual({ data: 'ok' }) + const result = await withInFlightGuard('loopA', 'sess-1', 'code', logger, async () => 'ok') + expect(result).toEqual('ok') expect(getPromptInFlight('loopA')).toBeUndefined() }) diff --git a/test/loop/runtime.test.ts b/test/loop/runtime.test.ts index 630ba9fd8b..ca545c7f85 100644 --- a/test/loop/runtime.test.ts +++ b/test/loop/runtime.test.ts @@ -10,7 +10,7 @@ import { createSectionPlansRepo } from '../../src/storage/repos/section-plans-re import { createLoopSessionUsageRepo, type LoopSessionUsageRepo } from '../../src/storage/repos/loop-session-usage-repo' import { createLoopService } from '../../src/loop/service' import type { LoopState } from '../../src/loop/state' -import { createLoop, type Loop, type LoopRuntimeDeps } from '../../src/loop/runtime' +import { createLoop, type Loop } from '../../src/loop/runtime' import { sessionsAwaitingBusy } from '../../src/loop/idle-gate' import { markPromptInFlight, @@ -19,7 +19,9 @@ import { __resetInFlightGuard, } from '../../src/loop/in-flight-guard' import type { Logger, PluginConfig, LoopConfig } from '../../src/types' -import type { OpencodeClient } from '@opencode-ai/sdk/v2' +import { createFakeForgeClient, type RecordedCall } from '../helpers/fake-client' +import type { ForgeClient } from '../../src/client/port' +import { setupLoopsTestDb } from '../helpers/loops-test-db' const PROJECT_ID = 'test-project' @@ -33,86 +35,6 @@ const mockConfig: PluginConfig = { }, } -interface MockClientState { - createCalls: Array> - deleteCalls: Array<{ sessionID: string; directory: string }> - publishCalls: Array<{ directory: string; body: unknown }> - selectCalls: Array<{ sessionID: string; workspace?: string }> - deleteThrows: boolean - abortCalls: string[] - promptCalls: Array<{ sessionID: string; agent?: string; variant?: string; text?: string }> - promptAsyncFailCount?: number - messagesResult: Array<{ info: { role: string; finish?: string }; parts: Array<{ type: string; text?: string }> }> | null - messagesBySession?: Map }>> -} - -function createMockV2Client(state: MockClientState): OpencodeClient { - return { - session: { - create: async (params) => { - state.createCalls.push(params as Record) - return { error: null, data: { id: 'sess' } } - }, - promptAsync: async (params) => { - state.promptCalls.push({ - sessionID: (params as any).sessionID ?? '', - agent: (params as any).agent, - variant: (params as any).variant, - text: (params as any).parts?.[0]?.text ?? (params as any).prompt ?? '', - }) - if (state.promptAsyncFailCount && state.promptAsyncFailCount > 0) { - state.promptAsyncFailCount-- - return { error: { name: 'TestError', data: { message: 'simulated model failure' } }, data: null } - } - return { error: null, data: null } - }, - status: async () => ({ error: null, data: {} }), - abort: async (params) => { - state.abortCalls.push((params as any).sessionID) - return {} - }, - delete: async (params) => { - state.deleteCalls.push(params as { sessionID: string; directory: string }) - if (state.deleteThrows) throw new Error('delete failed') - return { error: undefined } - }, - messages: async (params) => { - const sessionID = (params as any)?.sessionID as string | undefined - if (sessionID && state.messagesBySession?.has(sessionID)) { - return { - error: null, - data: state.messagesBySession.get(sessionID) as any, - } - } - return { - error: null, - data: (state.messagesResult ?? []) as any, - } - }, - get: async () => ({ error: null, data: {} }), - }, - tui: { - publish: async (params) => { - state.publishCalls.push(params as { directory: string; body: unknown }) - }, - selectSession: async (params) => { - state.selectCalls.push(params as { sessionID: string; workspace?: string }) - }, - }, - worktree: { - create: async () => ({ error: null, data: { directory: '/tmp/wt', branch: 'b' } }), - remove: async () => {}, - }, - experimental: { - workspace: { - warp: async () => ({ error: null }), - list: async () => ({ error: null, data: [] }), - status: async () => ({ error: null, data: [] }), - }, - }, - } as unknown as OpencodeClient -} - function createCapturingLogger(): { logger: Logger; logs: Array<{ level: string; message: string }> } { const logs: Array<{ level: string; message: string }> = [] const logger: Logger = { @@ -123,121 +45,6 @@ function createCapturingLogger(): { logger: Logger; logs: Array<{ level: string; return { logger, logs } } -const DB_SCHEMA = ` -CREATE TABLE loops ( - project_id TEXT NOT NULL, - loop_name TEXT NOT NULL, - status TEXT NOT NULL, - current_session_id TEXT NOT NULL, - worktree INTEGER NOT NULL, - worktree_dir TEXT NOT NULL, - session_directory TEXT, - worktree_branch TEXT, - project_dir TEXT NOT NULL, - max_iterations INTEGER NOT NULL, - iteration INTEGER NOT NULL DEFAULT 0, - audit_count INTEGER NOT NULL DEFAULT 0, - error_count INTEGER NOT NULL DEFAULT 0, - phase TEXT NOT NULL, - execution_model TEXT, - auditor_model TEXT, - model_failed INTEGER NOT NULL DEFAULT 0, - sandbox INTEGER NOT NULL DEFAULT 0, - sandbox_container TEXT, - started_at INTEGER NOT NULL, - completed_at INTEGER, - termination_reason TEXT, - completion_summary TEXT, - workspace_id TEXT, - host_session_id TEXT, - audit_session_id TEXT, - current_section_index INTEGER NOT NULL DEFAULT 0, - total_sections INTEGER NOT NULL DEFAULT 0, - final_audit_done INTEGER NOT NULL DEFAULT 0, - final_audit_attempts INTEGER NOT NULL DEFAULT 0, - execution_variant TEXT, - auditor_variant TEXT, - PRIMARY KEY (project_id, loop_name) -) -` - -const LOOP_LARGE_FIELDS_SCHEMA = ` -CREATE TABLE loop_large_fields ( - project_id TEXT NOT NULL, - loop_name TEXT NOT NULL, - last_audit_result TEXT, - PRIMARY KEY (project_id, loop_name), - FOREIGN KEY (project_id, loop_name) REFERENCES loops(project_id, loop_name) ON DELETE CASCADE -) -` - -const PLANS_SCHEMA = ` -CREATE TABLE plans ( - project_id TEXT NOT NULL, - loop_name TEXT, - session_id TEXT, - content TEXT NOT NULL, - updated_at INTEGER NOT NULL, - CHECK (loop_name IS NOT NULL OR session_id IS NOT NULL), - CHECK (NOT (loop_name IS NOT NULL AND session_id IS NOT NULL)), - UNIQUE (project_id, loop_name), - UNIQUE (project_id, session_id) -) -` - -const REVIEW_FINDINGS_SCHEMA = ` -CREATE TABLE review_findings ( - project_id TEXT NOT NULL, - loop_name TEXT NOT NULL DEFAULT '', - file TEXT NOT NULL, - line INTEGER NOT NULL, - severity TEXT NOT NULL, - description TEXT NOT NULL, - scenario TEXT, - created_at INTEGER NOT NULL, - section_index INTEGER, - PRIMARY KEY (project_id, loop_name, file, line, section_index) -) -` - -const SECTION_PLANS_SCHEMA = ` -CREATE TABLE section_plans ( - project_id TEXT NOT NULL, - loop_name TEXT NOT NULL, - section_index INTEGER NOT NULL, - title TEXT NOT NULL, - content TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','in_progress','completed','failed')), - attempts INTEGER NOT NULL DEFAULT 0, - started_at INTEGER, - completed_at INTEGER, - summary_done TEXT, - summary_deviations TEXT, - summary_follow_ups TEXT, - created_at INTEGER NOT NULL, - PRIMARY KEY (project_id, loop_name, section_index) -) -` - -const LOOP_SESSION_USAGE_SCHEMA = ` -CREATE TABLE loop_session_usage ( - project_id TEXT NOT NULL, - loop_name TEXT NOT NULL, - session_id TEXT NOT NULL, - role TEXT NOT NULL, - model TEXT NOT NULL, - cost REAL NOT NULL DEFAULT 0, - input_tokens INTEGER NOT NULL DEFAULT 0, - output_tokens INTEGER NOT NULL DEFAULT 0, - reasoning_tokens INTEGER NOT NULL DEFAULT 0, - cache_read_tokens INTEGER NOT NULL DEFAULT 0, - cache_write_tokens INTEGER NOT NULL DEFAULT 0, - message_count INTEGER NOT NULL DEFAULT 1, - captured_at INTEGER NOT NULL, - PRIMARY KEY (project_id, loop_name, session_id, model) -) -` - describe('Loop Runtime', () => { let db: Database let loopService: ReturnType @@ -247,17 +54,12 @@ describe('Loop Runtime', () => { let reviewFindingsRepo: ReturnType let sectionPlansRepo: ReturnType let loopSessionUsageRepo: LoopSessionUsageRepo + let currentLoop: Loop | null = null beforeEach(() => { tempDir = mkdtempSync(join(tmpdir(), 'loop-runtime-test-')) db = new Database(join(tempDir, 'test.db')) - - db.exec(DB_SCHEMA) - db.exec(LOOP_LARGE_FIELDS_SCHEMA) - db.exec(PLANS_SCHEMA) - db.exec(REVIEW_FINDINGS_SCHEMA) - db.exec(SECTION_PLANS_SCHEMA) - db.exec(LOOP_SESSION_USAGE_SCHEMA) + setupLoopsTestDb(db) loopsRepo = createLoopsRepo(db) plansRepo = createPlansRepo(db) @@ -273,7 +75,6 @@ describe('Loop Runtime', () => { { log: () => {}, error: () => {}, debug: () => {} }, undefined, undefined, - undefined, sectionPlansRepo, ) @@ -282,6 +83,10 @@ describe('Loop Runtime', () => { }) afterEach(() => { + if (currentLoop) { + currentLoop.clearAllRetryTimeouts() + currentLoop = null + } db.close() try { rmSync(tempDir, { recursive: true, force: true }) @@ -322,23 +127,12 @@ describe('Loop Runtime', () => { } function createRuntime(overrides: { - v2Client?: OpencodeClient + client?: ForgeClient loopConfig?: Partial serviceLoopConfig?: LoopConfig withUsageRepo?: boolean - } = {}): { loop: Loop; clientState: MockClientState; logger: Logger; logs: Array<{ level: string; message: string }> } { - const clientState: MockClientState = { - deleteCalls: [], - createCalls: [], - publishCalls: [], - selectCalls: [], - deleteThrows: false, - abortCalls: [], - promptCalls: [], - messagesResult: null, - } - - const v2Client = overrides.v2Client ?? createMockV2Client(clientState) + } = {}): { loop: Loop; calls: RecordedCall[]; logger: Logger; logs: Array<{ level: string; message: string }> } { + const forge = overrides.client ? { client: overrides.client, calls: [] as RecordedCall[] } : createFakeForgeClient() const { logger, logs } = createCapturingLogger() const config: PluginConfig = { ...mockConfig, ...(overrides.loopConfig ?? {}) } @@ -348,8 +142,7 @@ describe('Loop Runtime', () => { reviewFindingsRepo, sectionPlansRepo, projectId: PROJECT_ID, - client: { client: {} as any } as any, - v2Client, + client: forge.client, logger, getConfig: () => config, sandboxManager: undefined, @@ -357,23 +150,24 @@ describe('Loop Runtime', () => { loopSessionUsageRepo: overrides.withUsageRepo ? loopSessionUsageRepo : undefined, }) - return { loop, clientState, logger, logs } + currentLoop = loop + return { loop, calls: forge.calls, logger, logs } } describe('idle coding session advances to auditing', () => { test('idle event on a coding phase transitions to auditing phase', async () => { - const { loop, clientState } = createRuntime() - clientState.messagesResult = [ - { - info: { role: 'assistant', finish: 'stop' }, - parts: [{ type: 'text', text: 'Audit passed.' }], + const { client, calls } = createFakeForgeClient({ + session: { + messages: async () => [ + { info: { role: 'assistant', finish: 'stop' }, parts: [{ type: 'text', text: 'Audit passed.' }] }, + ], }, - ] + }) + const { loop } = createRuntime({ client }) const state = makeState({ phase: 'coding', totalSections: 0, - auditCount: 0, }) loopService.setState(state.loopName, state) @@ -392,17 +186,15 @@ describe('Loop Runtime', () => { }) test('does not transition to auditing when latest coding message is still user prompt', async () => { - const { loop, clientState } = createRuntime() - clientState.messagesResult = [ - { - info: { role: 'assistant', finish: 'stop' }, - parts: [{ type: 'text', text: 'Older code response.' }], + const { client, calls } = createFakeForgeClient({ + session: { + messages: async () => [ + { info: { role: 'assistant', finish: 'stop' }, parts: [{ type: 'text', text: 'Older code response.' }] }, + { info: { role: 'user' }, parts: [{ type: 'text', text: 'Latest code prompt that was not answered.' }] }, + ], }, - { - info: { role: 'user' }, - parts: [{ type: 'text', text: 'Latest code prompt that was not answered.' }], - }, - ] + }) + const { loop } = createRuntime({ client }) const state = makeState({ phase: 'coding', @@ -423,35 +215,34 @@ describe('Loop Runtime', () => { expect(updatedState).not.toBeNull() expect(updatedState!.phase).toBe('coding') - expect(clientState.promptCalls.some((call) => call.agent === 'auditor-loop')).toBe(false) + const auditorCalls = calls.filter(c => c.method === 'session.promptAsync' && (c.params as any)?.agent === 'auditor-loop') + expect(auditorCalls.length).toBe(0) - const hasCodePrompt = clientState.promptCalls.some((call) => call.agent === 'code') - expect(hasCodePrompt).toBe(false) + const codeCalls = calls.filter(c => c.method === 'session.promptAsync' && (c.params as any)?.agent === 'code') + expect(codeCalls.length).toBe(0) }) }) describe('clean non-sectioned audit terminates completed', () => { test('audit session returning clean assistant message terminates with completed', async () => { - const { loop, clientState } = createRuntime() + const { client, calls } = createFakeForgeClient({ + session: { + messages: async () => [ + { info: { role: 'assistant', finish: 'stop' }, parts: [{ type: 'text', text: 'All clear. No issues found.' }] }, + ], + }, + }) + const { loop } = createRuntime({ client }) const state = makeState({ phase: 'auditing', totalSections: 0, - auditCount: 0, iteration: 1, maxIterations: 3, }) loopService.setState(state.loopName, state) - // Mock the audit session's assistant message (clean result with no findings) - clientState.messagesResult = [ - { - info: { role: 'assistant', finish: 'stop' }, - parts: [{ type: 'text', text: 'All clear. No issues found.' }], - }, - ] - await loop.tick({ type: 'session.status', properties: { @@ -470,36 +261,16 @@ describe('Loop Runtime', () => { describe('runtime re-provisioning updates state.workspaceId', () => { test('ensureWorkspaceForLoop provisions workspace and sets workspaceId', async () => { - const clientState: MockClientState = { - deleteCalls: [], - createCalls: [], - publishCalls: [], - selectCalls: [], - deleteThrows: false, - abortCalls: [], - promptCalls: [], - messagesResult: [ - { - info: { role: 'assistant', finish: 'stop' }, - parts: [{ type: 'text', text: 'Audit passed.' }], - }, - ], - } - - const wsCreateMock = mock(async () => ({ - data: { id: 'ws_new', directory: '/tmp/wt/new', branch: 'opencode/new' }, - })) - const warpMock = mock(async () => ({ error: null })) - - const v2Client = { - ...createMockV2Client(clientState), - experimental: { - workspace: { - create: wsCreateMock, - warp: warpMock, - }, + const { client, calls } = createFakeForgeClient({ + session: { + messages: async () => [ + { info: { role: 'assistant', finish: 'stop' }, parts: [{ type: 'text', text: 'Audit passed.' }] }, + ], }, - } as unknown as OpencodeClient + workspace: { + create: async () => ({ id: 'ws_new', directory: '/tmp/wt/new', branch: 'opencode/new' }), + }, + }) const { logger } = createCapturingLogger() const config: PluginConfig = { ...mockConfig } @@ -510,13 +281,13 @@ describe('runtime re-provisioning updates state.workspaceId', () => { reviewFindingsRepo, sectionPlansRepo, projectId: PROJECT_ID, - client: { client: {} as any } as any, - v2Client, + client, logger, getConfig: () => config, sandboxManager: undefined, dataDir: tempDir, }) + currentLoop = loop const state = makeState({ phase: 'coding', @@ -541,26 +312,11 @@ describe('runtime re-provisioning updates state.workspaceId', () => { const afterState = loopService.getAnyState(state.loopName) expect(afterState).not.toBeNull() expect(afterState!.workspaceId).toBe('ws_new') - - // createBuiltinWorktreeWorkspace was invoked (proves internal state mutation occurred) - expect(wsCreateMock).toHaveBeenCalledTimes(1) - expect(wsCreateMock).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'forge', - extra: expect.objectContaining({ - loopName: 'test-loop', - projectDirectory: expect.any(String), - workspaceCreatedAt: expect.any(Number), - }), - }), - ) }) }) describe('stall handling terminates with stall timeout when configured cap is reached', () => { test('repeated stall recovery attempts eventually terminate with stall_timeout', async () => { - // Create a runtime with low stall limits for testing - // Need to also configure the loopService with matching stall settings const stallConfig: LoopConfig = { stallTimeoutMs: 50, maxConsecutiveStalls: 2, @@ -574,22 +330,10 @@ describe('stall handling terminates with stall timeout when configured cap is re { log: () => {}, error: () => {}, debug: () => {} }, stallConfig, undefined, - undefined, sectionPlansRepo, ) - const clientState: MockClientState = { - deleteCalls: [], - createCalls: [], - publishCalls: [], - selectCalls: [], - deleteThrows: false, - abortCalls: [], - promptCalls: [], - messagesResult: null, - } - - const v2Client = createMockV2Client(clientState) + const { client, calls } = createFakeForgeClient() const { logger, logs } = createCapturingLogger() const config: PluginConfig = { ...mockConfig } @@ -599,19 +343,18 @@ describe('stall handling terminates with stall timeout when configured cap is re reviewFindingsRepo, sectionPlansRepo, projectId: PROJECT_ID, - client: { client: {} as any } as any, - v2Client, + client, logger, getConfig: () => config, sandboxManager: undefined, dataDir: tempDir, loopConfig: stallConfig, }) + currentLoop = loop const state = makeState({ phase: 'coding', totalSections: 0, - }) loopService.setState(state.loopName, state) @@ -638,18 +381,18 @@ describe('stall handling terminates with stall timeout when configured cap is re test('rejects audit prompt while code prompt in-flight', async () => { markPromptInFlight('test-loop', 'other-session-id', 'code') - const { loop, clientState, logger, logs } = createRuntime() - clientState.messagesResult = [ - { - info: { role: 'assistant', finish: 'stop' }, - parts: [{ type: 'text', text: 'Audit passed.' }], + const { client, calls } = createFakeForgeClient({ + session: { + messages: async () => [ + { info: { role: 'assistant', finish: 'stop' }, parts: [{ type: 'text', text: 'Audit passed.' }] }, + ], }, - ] + }) + const { loop, logger, logs } = createRuntime({ client }) const state = makeState({ phase: 'coding', totalSections: 0, - auditCount: 0, }) loopService.setState(state.loopName, state) @@ -676,18 +419,18 @@ describe('stall handling terminates with stall timeout when configured cap is re test('rejects duplicate auditor prompt for same audit session', async () => { markPromptInFlight('test-loop', 'sess', 'auditor-loop') - const { loop, clientState, logs } = createRuntime() - clientState.messagesResult = [ - { - info: { role: 'assistant', finish: 'stop' }, - parts: [{ type: 'text', text: 'Implementation complete.' }], + const { client, calls } = createFakeForgeClient({ + session: { + messages: async () => [ + { info: { role: 'assistant', finish: 'stop' }, parts: [{ type: 'text', text: 'Implementation complete.' }] }, + ], }, - ] + }) + const { loop, logs } = createRuntime({ client }) const state = makeState({ phase: 'coding', totalSections: 0, - auditCount: 0, }) loopService.setState(state.loopName, state) @@ -705,8 +448,8 @@ describe('stall handling terminates with stall timeout when configured cap is re ) expect(hasGuardError).toBe(true) - const auditorPrompts = clientState.promptCalls.filter((c) => c.agent === 'auditor-loop') - expect(auditorPrompts).toHaveLength(0) + const auditorCalls = calls.filter(c => c.method === 'session.promptAsync' && (c.params as any)?.agent === 'auditor-loop') + expect(auditorCalls).toHaveLength(0) const prior = getPromptInFlight('test-loop') expect(prior).toBeDefined() @@ -755,39 +498,23 @@ describe('stall handling terminates with stall timeout when configured cap is re }) test('clears in-flight when promptAsync throws a transient error', async () => { - const clientState: MockClientState = { - deleteCalls: [], - createCalls: [], - publishCalls: [], - selectCalls: [], - deleteThrows: false, - abortCalls: [], - promptCalls: [], - messagesResult: [ - { - info: { role: 'assistant', finish: 'stop' }, - parts: [{ type: 'text', text: 'Implementation complete.' }], + const { client, calls } = createFakeForgeClient({ + session: { + messages: async () => [ + { info: { role: 'assistant', finish: 'stop' }, parts: [{ type: 'text', text: 'Implementation complete.' }] }, + ], + promptAsync: async (params: any) => { + if (params?.agent === 'code' && params?.sessionID === 'loop-session-id') { + throw new Error('transient transport error') + } }, - ], - } - - const v2Client = createMockV2Client(clientState) - const origPromptAsync = v2Client.session.promptAsync - let promptCallCount = 0 - ;(v2Client as any).session.promptAsync = async (params: any) => { - promptCallCount++ - if (params?.agent === 'code' && params?.sessionID === 'loop-session-id') { - throw new Error('transient transport error') - } - return origPromptAsync(params) - } - - const { loop, logs } = createRuntime({ v2Client }) + }, + }) + const { loop, logs } = createRuntime({ client }) const state = makeState({ phase: 'coding', totalSections: 0, - auditCount: 0, }) loopService.setState(state.loopName, state) @@ -804,18 +531,18 @@ describe('stall handling terminates with stall timeout when configured cap is re }) test('clears in-flight on prompt completion', async () => { - const { loop, clientState } = createRuntime() - clientState.messagesResult = [ - { - info: { role: 'assistant', finish: 'stop' }, - parts: [{ type: 'text', text: 'All clear.' }], + const { client, calls } = createFakeForgeClient({ + session: { + messages: async () => [ + { info: { role: 'assistant', finish: 'stop' }, parts: [{ type: 'text', text: 'All clear.' }] }, + ], }, - ] + }) + const { loop } = createRuntime({ client }) const state = makeState({ phase: 'coding', totalSections: 0, - auditCount: 0, }) loopService.setState(state.loopName, state) @@ -834,18 +561,18 @@ describe('stall handling terminates with stall timeout when configured cap is re test('handlePromptError short-circuits on ConcurrentPromptError, preserving loop active state', async () => { markPromptInFlight('test-loop', 'other-session-id', 'code') - const { loop, clientState, logs } = createRuntime() - clientState.messagesResult = [ - { - info: { role: 'assistant', finish: 'stop' }, - parts: [{ type: 'text', text: 'Audit passed.' }], + const { client, calls } = createFakeForgeClient({ + session: { + messages: async () => [ + { info: { role: 'assistant', finish: 'stop' }, parts: [{ type: 'text', text: 'Audit passed.' }] }, + ], }, - ] + }) + const { loop, logs } = createRuntime({ client }) const state = makeState({ phase: 'coding', totalSections: 0, - auditCount: 0, }) loopService.setState(state.loopName, state) @@ -871,52 +598,49 @@ describe('stall handling terminates with stall timeout when configured cap is re describe('session retention', () => { test('queues session for retention on coding phase transition', async () => { - const { loop, clientState } = createRuntime() + const { client, calls } = createFakeForgeClient({ + session: { + messages: async () => [ + { info: { role: 'assistant', finish: 'stop' }, parts: [{ type: 'text', text: 'All clear.' }] }, + ], + }, + }) + const { loop } = createRuntime({ client }) const state = makeState({ phase: 'coding', totalSections: 0, - auditCount: 0, }) loopService.setState(state.loopName, state) - clientState.messagesResult = [ - { - info: { role: 'assistant', finish: 'stop' }, - parts: [{ type: 'text', text: 'All clear.' }], - }, - ] - - // Trigger a single rotation: coding→audit await loop.tick({ type: 'session.status', properties: { status: { type: 'idle' }, sessionID: state.sessionId }, }) - expect(clientState.deleteCalls.map((call) => call.sessionID)).toContain(state.sessionId) + const deleteCalls = calls.filter(c => c.method === 'session.delete') + expect(deleteCalls.map((c: any) => c.params.sessionID)).toContain(state.sessionId) }) test('tolerates delete failure without crashing', async () => { - const { loop, clientState, logger, logs } = createRuntime() - clientState.deleteThrows = true + const { client, calls } = createFakeForgeClient({ + session: { + messages: async () => [ + { info: { role: 'assistant', finish: 'stop' }, parts: [{ type: 'text', text: 'All clear.' }] }, + ], + delete: async () => { throw new Error('delete failed') }, + }, + }) + const { loop, logger, logs } = createRuntime({ client }) const state = makeState({ phase: 'coding', totalSections: 0, - auditCount: 0, }) loopService.setState(state.loopName, state) - clientState.messagesResult = [ - { - info: { role: 'assistant', finish: 'stop' }, - parts: [{ type: 'text', text: 'All clear.' }], - }, - ] - - // Trigger a rotation; delete error should be caught and logged await loop.tick({ type: 'session.status', properties: { status: { type: 'idle' }, sessionID: state.sessionId }, @@ -930,35 +654,33 @@ describe('stall handling terminates with stall timeout when configured cap is re }) test('terminate flushes retained sessions', async () => { - const { loop, clientState } = createRuntime() + const { client, calls } = createFakeForgeClient({ + session: { + messages: async () => [ + { info: { role: 'assistant', finish: 'stop' }, parts: [{ type: 'text', text: 'All clear.' }] }, + ], + }, + }) + const { loop } = createRuntime({ client }) const state = makeState({ phase: 'coding', totalSections: 0, - auditCount: 0, }) loopService.setState(state.loopName, state) - clientState.messagesResult = [ - { - info: { role: 'assistant', finish: 'stop' }, - parts: [{ type: 'text', text: 'All clear.' }], - }, - ] - // First rotation: coding→audit await loop.tick({ type: 'session.status', properties: { status: { type: 'idle' }, sessionID: state.sessionId }, }) - // After tick, state changed to auditing with session='sess' // Terminate the loop: terminateLoop should clean up retained sessions await loop.cancel(state.loopName) - // Check that v2Client.session.delete was called for the old coding session - const deletedSids = clientState.deleteCalls.map((c) => c.sessionID) + const deleteCalls = calls.filter(c => c.method === 'session.delete') + const deletedSids = deleteCalls.map((c: any) => c.params.sessionID) expect(deletedSids).toContain(state.sessionId) }) }) @@ -982,8 +704,12 @@ describe('stall handling terminates with stall timeout when configured cap is re } test('code session rotation captures usage with state.executionModel', async () => { - const { loop, clientState, logs } = createRuntime({ withUsageRepo: true }) - clientState.messagesResult = [mockAssistantMessage(0.001, { input: 100, output: 50, reasoning: 10 })] + const { client, calls } = createFakeForgeClient({ + session: { + messages: async () => [mockAssistantMessage(0.001, { input: 100, output: 50, reasoning: 10 })], + }, + }) + const { loop, logs } = createRuntime({ client, withUsageRepo: true }) const state = makeState({ phase: 'coding', @@ -1007,8 +733,12 @@ describe('stall handling terminates with stall timeout when configured cap is re }) test('audit termination captures usage with state.auditorModel', async () => { - const { loop, clientState } = createRuntime({ withUsageRepo: true }) - clientState.messagesResult = [mockAssistantMessage(0.002, { input: 200, output: 100, reasoning: 20 })] + const { client, calls } = createFakeForgeClient({ + session: { + messages: async () => [mockAssistantMessage(0.002, { input: 200, output: 100, reasoning: 20 })], + }, + }) + const { loop } = createRuntime({ client, withUsageRepo: true }) const state = makeState({ phase: 'auditing', @@ -1035,8 +765,12 @@ describe('stall handling terminates with stall timeout when configured cap is re }) test('state models take precedence over current config', async () => { - const { loop, clientState } = createRuntime({ withUsageRepo: true }) - clientState.messagesResult = [mockAssistantMessage(0.001, { input: 150, output: 75, reasoning: 15 })] + const { client, calls } = createFakeForgeClient({ + session: { + messages: async () => [mockAssistantMessage(0.001, { input: 150, output: 75, reasoning: 15 })], + }, + }) + const { loop } = createRuntime({ client, withUsageRepo: true }) const state = makeState({ phase: 'coding', @@ -1058,23 +792,12 @@ describe('stall handling terminates with stall timeout when configured cap is re }) test('capture failure logs error but does not block termination', async () => { - const clientState: MockClientState = { - deleteCalls: [], - createCalls: [], - publishCalls: [], - selectCalls: [], - deleteThrows: false, - abortCalls: [], - promptCalls: [], - messagesResult: null, - } - - const v2Client = createMockV2Client(clientState) - ;(v2Client.session.messages as any) = async () => { - throw new Error('messages fetch failed') - } - - const { loop, logs } = createRuntime({ v2Client, withUsageRepo: true }) + const { client, calls } = createFakeForgeClient({ + session: { + messages: async () => { throw new Error('messages fetch failed') }, + }, + }) + const { loop, logs } = createRuntime({ client, withUsageRepo: true }) const state = makeState({ phase: 'coding' }) loopService.setState(state.loopName, state) @@ -1090,13 +813,16 @@ describe('stall handling terminates with stall timeout when configured cap is re }) test('retained sessions preserve role and model: code session retained, audit session enqueued', async () => { - const { loop, clientState } = createRuntime({ withUsageRepo: true }) - - // Set up per-session messages - clientState.messagesBySession = new Map() - clientState.messagesBySession.set('coding-session-1', [ + const messagesBySession = new Map>>() + messagesBySession.set('coding-session-1', [ mockAssistantMessage(0.001, { input: 100, output: 50, reasoning: 10 }), ]) + const { client, calls } = createFakeForgeClient({ + session: { + messages: async (params: any) => messagesBySession.get(params.sessionID) ?? [], + }, + }) + const { loop } = createRuntime({ client, withUsageRepo: true }) const state = makeState({ phase: 'coding', @@ -1119,7 +845,7 @@ describe('stall handling terminates with stall timeout when configured cap is re expect(afterFirstTick.phase).toBe('auditing') // Set up messages for the audit session - clientState.messagesBySession.set(afterFirstTick.sessionId, [ + messagesBySession.set(afterFirstTick.sessionId, [ mockAssistantMessage(0.002, { input: 200, output: 100, reasoning: 20 }), ]) @@ -1131,7 +857,6 @@ describe('stall handling terminates with stall timeout when configured cap is re expect(usage!.byModel['state/exec-model'].inputTokens).toBe(100) // Now terminate the loop while in auditing phase - // This should capture the audit session with auditor role await loop.cancel(state.loopName) // Wait for async capture @@ -1146,10 +871,12 @@ describe('stall handling terminates with stall timeout when configured cap is re }) test('retained audit session cleaned up on termination with correct attribution', async () => { - const { loop, clientState } = createRuntime({ withUsageRepo: true }) - clientState.messagesResult = [ - mockAssistantMessage(0.002, { input: 200, output: 100, reasoning: 20 }), - ] + const { client, calls } = createFakeForgeClient({ + session: { + messages: async () => [mockAssistantMessage(0.002, { input: 200, output: 100, reasoning: 20 })], + }, + }) + const { loop } = createRuntime({ client, withUsageRepo: true }) // Start in auditing phase const state = makeState({ @@ -1183,10 +910,12 @@ describe('stall handling terminates with stall timeout when configured cap is re }) test('retained sessions cleaned up on clearLoopTimers with correct attribution', async () => { - const { loop, clientState } = createRuntime({ withUsageRepo: true }) - clientState.messagesResult = [ - mockAssistantMessage(0.001, { input: 150, output: 75, reasoning: 15 }), - ] + const { client, calls } = createFakeForgeClient({ + session: { + messages: async () => [mockAssistantMessage(0.001, { input: 150, output: 75, reasoning: 15 })], + }, + }) + const { loop } = createRuntime({ client, withUsageRepo: true }) const state = makeState({ phase: 'coding', @@ -1218,13 +947,14 @@ describe('stall handling terminates with stall timeout when configured cap is re describe('variant dispatch', () => { test('coding prompt sends executionVariant from loop state', async () => { - const { loop, clientState } = createRuntime() - clientState.messagesResult = [ - { - info: { role: 'assistant', finish: 'stop' }, - parts: [{ type: 'text', text: 'Audit passed.' }], + const { client, calls } = createFakeForgeClient({ + session: { + messages: async () => [ + { info: { role: 'assistant', finish: 'stop' }, parts: [{ type: 'text', text: 'Audit passed.' }] }, + ], }, - ] + }) + const { loop } = createRuntime({ client }) const state = makeState({ phase: 'auditing', @@ -1254,21 +984,22 @@ describe('stall handling terminates with stall timeout when configured cap is re }) // After auditing phase processes dirty audit, it transitions to coding and sends code prompts - const codePrompts = clientState.promptCalls.filter(c => c.agent === 'code') + const codePrompts = calls.filter(c => c.method === 'session.promptAsync' && (c.params as any)?.agent === 'code') expect(codePrompts.length).toBeGreaterThan(0) for (const call of codePrompts) { - expect(call.variant).toBe('thinking-max') + expect((call.params as any)?.variant).toBe('thinking-max') } }) test('auditor prompt sends auditorVariant from loop state', async () => { - const { loop, clientState } = createRuntime() - clientState.messagesResult = [ - { - info: { role: 'assistant', finish: 'stop' }, - parts: [{ type: 'text', text: 'Audit passed.' }], + const { client, calls } = createFakeForgeClient({ + session: { + messages: async () => [ + { info: { role: 'assistant', finish: 'stop' }, parts: [{ type: 'text', text: 'Audit passed.' }] }, + ], }, - ] + }) + const { loop } = createRuntime({ client }) const state = makeState({ phase: 'coding', @@ -1288,32 +1019,28 @@ describe('stall handling terminates with stall timeout when configured cap is re }) // The auditor prompt should have the auditorVariant - const auditorPrompts = clientState.promptCalls.filter(c => c.agent === 'auditor-loop') + const auditorPrompts = calls.filter(c => c.method === 'session.promptAsync' && (c.params as any)?.agent === 'auditor-loop') expect(auditorPrompts.length).toBeGreaterThan(0) for (const call of auditorPrompts) { - expect(call.variant).toBe('audit-high') + expect((call.params as any)?.variant).toBe('audit-high') } }) test('model fallback omits variant when model is undefined', async () => { - const clientState: MockClientState = { - deleteCalls: [], - createCalls: [], - publishCalls: [], - selectCalls: [], - deleteThrows: false, - abortCalls: [], - promptCalls: [], - promptAsyncFailCount: 2, - messagesResult: [ - { - info: { role: 'assistant', finish: 'stop' }, - parts: [{ type: 'text', text: 'Audit passed.' }], + let failCount = 2 + const { client, calls } = createFakeForgeClient({ + session: { + messages: async () => [ + { info: { role: 'assistant', finish: 'stop' }, parts: [{ type: 'text', text: 'Audit passed.' }] }, + ], + promptAsync: async (params: any) => { + if (failCount > 0) { + failCount-- + throw Object.assign(new Error('simulated model failure'), { name: 'TestError', data: { message: 'simulated model failure' } }) + } }, - ], - } - - const v2Client = createMockV2Client(clientState) + }, + }) const { logger } = createCapturingLogger() const config: PluginConfig = { ...mockConfig, executionModel: 'test/model' } @@ -1323,13 +1050,13 @@ describe('stall handling terminates with stall timeout when configured cap is re reviewFindingsRepo, sectionPlansRepo, projectId: PROJECT_ID, - client: { client: {} as any } as any, - v2Client, + client, logger, getConfig: () => config, sandboxManager: undefined, dataDir: tempDir, }) + currentLoop = loop const state = makeState({ phase: 'auditing', @@ -1359,17 +1086,24 @@ describe('stall handling terminates with stall timeout when configured cap is re }) // Model-based attempts should have been made (and failed) - const codePrompts = clientState.promptCalls.filter(c => c.agent === 'code') + const codePrompts = calls.filter(c => c.method === 'session.promptAsync' && (c.params as any)?.agent === 'code') expect(codePrompts.length).toBeGreaterThan(0) // After model fails, fallback without model should NOT send variant - const fallbackPrompts = codePrompts.filter(c => !c.variant) + const fallbackPrompts = codePrompts.filter(c => !(c.params as any)?.variant) expect(fallbackPrompts.length).toBeGreaterThan(0) }) }) describe('coder decisions in final-audit fix', () => { test('final-audit fix coding parses coder-decisions and renders into subsequent final audit prompt', async () => { - const { loop, clientState, logs } = createRuntime() + const { client, calls } = createFakeForgeClient({ + session: { + messages: async () => [ + { info: { role: 'assistant', finish: 'stop' }, parts: [{ type: 'text', text: 'Final audit found issues.' }] }, + ], + }, + }) + const { loop, logs } = createRuntime({ client }) const loopName = 'test-loop-cd-fix' // Create a loop in final_auditing phase with outstanding bugs @@ -1395,13 +1129,6 @@ describe('stall handling terminates with stall timeout when configured cap is re }) // Step 1: Set auditor response and trigger final audit phase - clientState.messagesResult = [ - { - info: { role: 'assistant', finish: 'stop' }, - parts: [{ type: 'text', text: 'Final audit found issues.' }], - }, - ] - await loop.tick({ type: 'session.status', properties: { @@ -1411,19 +1138,19 @@ describe('stall handling terminates with stall timeout when configured cap is re }) // After the first tick, the loop should have transitioned to coding with a fix prompt - const fixCodePrompts = clientState.promptCalls.filter(c => c.agent === 'code') + const fixCodePrompts = calls.filter(c => c.method === 'session.promptAsync' && (c.params as any)?.agent === 'code') expect(fixCodePrompts.length).toBeGreaterThan(0) - const fixPromptText = fixCodePrompts[fixCodePrompts.length - 1].text ?? '' + const fixPromptText = (fixCodePrompts[fixCodePrompts.length - 1].params as any)?.parts?.[0]?.text ?? '' expect(fixPromptText).toContain('[Final-audit fix') // Verify the loop state after first tick const stateAfterFirstTick = loopService.getActiveState(loopName) expect(stateAfterFirstTick).not.toBeNull() expect(stateAfterFirstTick!.phase).toBe('coding') - expect(stateAfterFirstTick!.sessionId).toBe('sess') + const codeSessionId = stateAfterFirstTick!.sessionId - // Step 2: Set coding assistant response WITH coder-decisions markers - clientState.messagesResult = [ + // Step 2: Change messages for the coding assistant response WITH coder-decisions markers + ;(client.session.messages as any).mockImplementation(async () => [ { info: { role: 'assistant', finish: 'stop' }, parts: [{ @@ -1431,16 +1158,16 @@ describe('stall handling terminates with stall timeout when configured cap is re text: `Fixed the bug.\n\n### Decisions\n- Chose approach X\n### Verification\n- FOO=bar pnpm test\n### Notes for auditor\n- none\n`, }], }, - ] + ]) - const auditorPromptsBefore = clientState.promptCalls.filter(c => c.agent === 'auditor-loop').length + const auditorPromptsBefore = calls.filter(c => c.method === 'session.promptAsync' && (c.params as any)?.agent === 'auditor-loop').length // Send a busy event to clear the idle-gate (prompt was sent during the first tick) await loop.tick({ type: 'session.status', properties: { status: { type: 'busy' }, - sessionID: 'sess', + sessionID: codeSessionId, }, }) @@ -1449,7 +1176,7 @@ describe('stall handling terminates with stall timeout when configured cap is re type: 'session.status', properties: { status: { type: 'idle' }, - sessionID: 'sess', + sessionID: codeSessionId, }, }) @@ -1459,12 +1186,13 @@ describe('stall handling terminates with stall timeout when configured cap is re expect(stateAfterSecondTick!.phase).toBe('final_auditing') // The final audit prompt should have been sent with coder decisions - const auditorPromptsAfter = clientState.promptCalls.filter(c => c.agent === 'auditor-loop') + const auditorPromptsAfter = calls.filter(c => c.method === 'session.promptAsync' && (c.params as any)?.agent === 'auditor-loop') expect(auditorPromptsAfter.length).toBeGreaterThan(auditorPromptsBefore) - const finalAuditPrompt = auditorPromptsAfter[auditorPromptsAfter.length - 1]?.text ?? '' - expect(finalAuditPrompt).toContain('Coder decisions & verification notes') - expect(finalAuditPrompt).toContain('Chose approach X') - expect(finalAuditPrompt).toContain('FOO=bar pnpm test') + const finalAuditPrompt = auditorPromptsAfter[auditorPromptsAfter.length - 1]?.params as any + const finalAuditPromptText = finalAuditPrompt?.parts?.[0]?.text ?? '' + expect(finalAuditPromptText).toContain('Coder decisions & verification notes') + expect(finalAuditPromptText).toContain('Chose approach X') + expect(finalAuditPromptText).toContain('FOO=bar pnpm test') }) }) }) diff --git a/test/loop/start.test.ts b/test/loop/start.test.ts index 56bacf60be..4b74bb072d 100644 --- a/test/loop/start.test.ts +++ b/test/loop/start.test.ts @@ -9,10 +9,10 @@ import { createReviewFindingsRepo } from '../../src/storage/repos/review-finding import { createSectionPlansRepo } from '../../src/storage/repos/section-plans-repo' import { createLoopService } from '../../src/loop/service' import type { LoopState } from '../../src/loop/state' -import { createLoop, type Loop, type LoopRuntimeDeps } from '../../src/loop/runtime' +import { createLoop, type Loop } from '../../src/loop/runtime' import { sessionsAwaitingBusy } from '../../src/loop/idle-gate' import type { Logger, PluginConfig } from '../../src/types' -import type { OpencodeClient } from '@opencode-ai/sdk/v2' +import type { ForgeClient } from '../../src/client/port' import { setupLoopsTestDb } from '../helpers/loops-test-db' const PROJECT_ID = 'test-project' @@ -27,26 +27,34 @@ const mockConfig: PluginConfig = { }, } -function createMockV2Client(): OpencodeClient { +function createMockForgeClient(): ForgeClient { return { session: { - create: async () => ({ error: null, data: { id: 'sess' } }), - promptAsync: async () => ({ error: null, data: null }), - status: async () => ({ error: null, data: {} }), - abort: async () => ({}), - delete: async () => ({ error: undefined }), - messages: async () => ({ error: null, data: [] }), - get: async () => ({ error: null, data: {} }), + create: async () => ({ id: 'sess' }) as any, + promptAsync: async () => {}, + status: async () => ({}) as any, + abort: async () => {}, + delete: async () => {}, + messages: async () => [], + get: async () => ({}) as any, + update: async () => {}, + }, + workspace: { + create: async () => ({ id: '', directory: '/tmp/wt', branch: 'b' }) as any, + list: async () => [], + status: async () => ({}) as any, + syncList: async () => {}, + remove: async () => {}, + warp: async () => {}, }, tui: { publish: async () => {}, selectSession: async () => {}, }, - worktree: { - create: async () => ({ error: null, data: { directory: '/tmp/wt', branch: 'b' } }), - remove: async () => {}, + sync: { + start: async () => {}, }, - } as unknown as OpencodeClient + } } function createCapturingLogger(): { logger: Logger; logs: Array<{ level: string; message: string }> } { @@ -181,7 +189,6 @@ describe('Loop Runtime start()', () => { { log: () => {}, error: () => {}, debug: () => {} }, undefined, undefined, - undefined, sectionPlansRepo, ) @@ -234,8 +241,7 @@ describe('Loop Runtime start()', () => { reviewFindingsRepo, sectionPlansRepo, projectId: PROJECT_ID, - client: {} as any, - v2Client: createMockV2Client(), + client: createMockForgeClient(), logger, getConfig: () => mockConfig, }) diff --git a/test/parent-session-lookup.test.ts b/test/parent-session-lookup.test.ts index c9d77cbe78..7bce201c9f 100644 --- a/test/parent-session-lookup.test.ts +++ b/test/parent-session-lookup.test.ts @@ -1,6 +1,8 @@ import { describe, test, expect, beforeEach, afterEach } from 'bun:test' -import { createParentSessionLookup, type CreateParentSessionLookupOptions } from '../src/index' +import { createParentSessionLookup } from '../src/index' import type { Logger } from '../src/types' +import { createFakeForgeClient } from './helpers/fake-client' +import { ForgeClientError } from '../src/client/port' const mockLogger: Logger = { log: () => {}, @@ -8,21 +10,6 @@ const mockLogger: Logger = { error: () => {}, } -function createMockV2Client(responses: Map) { - return { - session: { - get: async (input: { sessionID: string; directory?: string }) => { - const key = input.directory ? `${input.sessionID}:${input.directory}` : input.sessionID - const response = responses.get(key) ?? responses.get(input.sessionID) - if (!response) { - throw new Error(`No mock response for ${key}`) - } - return response - }, - }, - } -} - function createMockLoop(activeLoops: Array<{ loopName: string; worktreeDir: string }>) { return { listActive: () => activeLoops.map((l) => ({ ...l, active: true, sandbox: false, sessionId: '', startedAt: '', iteration: 0, maxIterations: 0, phase: 'coding' as const, audit: false, errorCount: 0, auditCount: 0, worktree: false })), @@ -35,13 +22,15 @@ describe('createParentSessionLookup', () => { test('positive lookup caches the parent ID across calls', async () => { const sessionId = 'session-123' const parentId = 'parent-x' - const v2 = createMockV2Client( - new Map([[sessionId, { data: { parentID: parentId } }]]), - ) + const { client } = createFakeForgeClient({ + session: { + get: async () => ({ parentID: parentId }), + }, + }) const loop = createMockLoop([]) const lookup = createParentSessionLookup({ - v2, + client, directory: '/host', loop: loop as any, logger: mockLogger, @@ -56,24 +45,15 @@ describe('createParentSessionLookup', () => { test('negative result is cached for TTL then retried', async () => { const sessionId = 'session-fail' - const parentId = 'parent-success' - let callCount = 0 - - const v2 = createMockV2Client( - new Map([ - [ - sessionId, - { - data: undefined, - error: 'not found', - }, - ], - ]), - ) + const { client } = createFakeForgeClient({ + session: { + get: async () => { throw new ForgeClientError({ kind: 'not-found', method: 'session.get', message: 'not found' }) }, + }, + }) const loop = createMockLoop([]) const lookup = createParentSessionLookup({ - v2, + client, directory: '/host', loop: loop as any, logger: mockLogger, @@ -94,24 +74,15 @@ describe('createParentSessionLookup', () => { test('first call fails, second call succeeds after TTL expiry', async () => { const sessionId = 'session-mixed' - const parentId = 'parent-y' - let failCount = 0 - - const v2 = createMockV2Client( - new Map([ - [ - sessionId, - { - data: undefined, - error: 'not found', - }, - ], - ]), - ) + const { client } = createFakeForgeClient({ + session: { + get: async () => { throw new ForgeClientError({ kind: 'not-found', method: 'session.get', message: 'not found' }) }, + }, + }) const loop = createMockLoop([]) const lookup = createParentSessionLookup({ - v2, + client, directory: '/host', loop: loop as any, logger: mockLogger, @@ -130,23 +101,23 @@ describe('createParentSessionLookup', () => { const worktreeDir = '/worktree' const callOrder: string[] = [] - const v2 = { + const { client } = createFakeForgeClient({ session: { - get: async (input: { sessionID: string; directory?: string }) => { + get: async (input: any) => { const label = input.directory ? `dir:${input.directory}` : 'no-dir' callOrder.push(label) if (input.directory === worktreeDir) { - return { data: { parentID: parentId } } + return { parentID: parentId } } - return { data: undefined } + throw new ForgeClientError({ kind: 'not-found', method: 'session.get', message: 'not found' }) }, }, - } + }) const loop = createMockLoop([{ loopName: 'test-loop', worktreeDir }]) const lookup = createParentSessionLookup({ - v2: v2 as any, + client, directory: '/host', loop: loop as any, logger: mockLogger, @@ -157,4 +128,4 @@ describe('createParentSessionLookup', () => { expect(result).toBe(parentId) expect(callOrder).toEqual([`dir:${worktreeDir}`]) }) -}) \ No newline at end of file +}) diff --git a/test/plan-approval.test.ts b/test/plan-approval.test.ts index bb6e1f5160..1c60f64918 100644 --- a/test/plan-approval.test.ts +++ b/test/plan-approval.test.ts @@ -314,42 +314,46 @@ describe('Plan Approval Tool Interception', () => { }) test('Matches metadata answer exactly', async () => { - const v2AbortSpy = vi.fn(() => Promise.resolve({ data: {} })) - const legacyAbortSpy = vi.fn(() => Promise.resolve({ data: {} } as any)) + const abortSpy = vi.fn(() => Promise.resolve()) const ctx = { loopService: { resolveLoopName: () => 'test-loop', getActiveState: () => null, }, logger: createMockLogger(), - v2: { + client: { session: { - abort: v2AbortSpy, - promptAsync: async () => ({ data: {} }), - create: async () => ({ data: { id: 'new-session-id' } }), + abort: abortSpy, + promptAsync: async () => {}, + create: async () => ({ id: 'new-session-id' }), + status: async () => ({}), + get: async () => ({}), + messages: async () => [], + update: async () => {}, + delete: async () => {}, + }, + workspace: { + create: async () => ({ id: '', directory: '', branch: '' }), + list: async () => [], + status: async () => [], + syncList: async () => {}, + remove: async () => {}, + warp: async () => {}, }, tui: { - selectSession: async () => ({ data: {} }), - publish: async () => ({ data: {} }), + publish: async () => {}, + selectSession: async () => {}, }, - } as unknown as ToolContext['v2'], + sync: { + start: async () => {}, + }, + }, plansRepo, config: {} as PluginConfig, projectId, directory: '/test', dataDir: TEST_DIR, cleanup: async () => {}, - input: { - messages: [], - systemPrompt: '', - client: { - session: { - abort: legacyAbortSpy, - create: async () => ({ data: { id: 'new-session-id' } }), - promptAsync: async () => ({ data: {} }), - }, - } as any, - }, systemPrompt: '', messages: [], loopsRepo: createLoopsRepo(db), @@ -385,35 +389,50 @@ describe('Plan Approval Tool Interception', () => { expect(output.output).toBe('Execute here') expect((output.metadata as any).forgePlanApprovalHandled).toBe(true) - expect(legacyAbortSpy).toHaveBeenCalled() + expect(abortSpy).toHaveBeenCalled() }) test('Matches metadata answer by prefix', async () => { - const abortSpy = vi.fn(() => Promise.resolve({ data: {} })) + const abortSpy = vi.fn(() => Promise.resolve()) const ctx = { loopService: { resolveLoopName: () => 'test-loop', getActiveState: () => null, }, logger: createMockLogger(), - v2: { + client: { session: { abort: abortSpy, - promptAsync: async () => ({ data: {} }), - create: async () => ({ data: { id: 'new-session-id' } }), + promptAsync: async () => {}, + create: async () => ({ id: 'new-session-id' }), + status: async () => ({}), + get: async () => ({}), + messages: async () => [], + update: async () => {}, + delete: async () => {}, + }, + workspace: { + create: async () => ({ id: '', directory: '', branch: '' }), + list: async () => [], + status: async () => [], + syncList: async () => {}, + remove: async () => {}, + warp: async () => {}, }, tui: { - selectSession: async () => ({ data: {} }), - publish: async () => ({ data: {} }), + publish: async () => {}, + selectSession: async () => {}, + }, + sync: { + start: async () => {}, }, - } as unknown as ToolContext['v2'], + }, plansRepo, config: {} as PluginConfig, projectId, directory: '/test', dataDir: TEST_DIR, cleanup: async () => {}, - input: { messages: [], systemPrompt: '' }, systemPrompt: '', messages: [], loopsRepo: createLoopsRepo(db), @@ -453,31 +472,46 @@ describe('Plan Approval Tool Interception', () => { }) test('Does not match middle-of-string text', async () => { - const abortSpy = vi.fn(() => Promise.resolve({ data: {} })) + const abortSpy = vi.fn(() => Promise.resolve()) const ctx = { loopService: { resolveLoopName: () => 'test-loop', getActiveState: () => null, }, logger: createMockLogger(), - v2: { + client: { session: { abort: abortSpy, - promptAsync: async () => ({ data: {} }), - create: async () => ({ data: { id: 'new-session-id' } }), + promptAsync: async () => {}, + create: async () => ({ id: 'new-session-id' }), + status: async () => ({}), + get: async () => ({}), + messages: async () => [], + update: async () => {}, + delete: async () => {}, + }, + workspace: { + create: async () => ({ id: '', directory: '', branch: '' }), + list: async () => [], + status: async () => [], + syncList: async () => {}, + remove: async () => {}, + warp: async () => {}, }, tui: { - selectSession: async () => ({ data: {} }), - publish: async () => ({ data: {} }), + publish: async () => {}, + selectSession: async () => {}, }, - } as unknown as ToolContext['v2'], + sync: { + start: async () => {}, + }, + }, plansRepo, config: {} as PluginConfig, projectId, directory: '/test', dataDir: TEST_DIR, cleanup: async () => {}, - input: { messages: [], systemPrompt: '' }, systemPrompt: '', messages: [], loopsRepo: createLoopsRepo(db), @@ -516,31 +550,46 @@ describe('Plan Approval Tool Interception', () => { }) test('Falls back to output when metadata answers are missing', async () => { - const abortSpy = vi.fn(() => Promise.resolve({ data: {} })) + const abortSpy = vi.fn(() => Promise.resolve()) const ctx = { loopService: { resolveLoopName: () => 'test-loop', getActiveState: () => null, }, logger: createMockLogger(), - v2: { + client: { session: { abort: abortSpy, - promptAsync: async () => ({ data: {} }), - create: async () => ({ data: { id: 'new-session-id' } }), + promptAsync: async () => {}, + create: async () => ({ id: 'new-session-id' }), + status: async () => ({}), + get: async () => ({}), + messages: async () => [], + update: async () => {}, + delete: async () => {}, + }, + workspace: { + create: async () => ({ id: '', directory: '', branch: '' }), + list: async () => [], + status: async () => [], + syncList: async () => {}, + remove: async () => {}, + warp: async () => {}, }, tui: { - selectSession: async () => ({ data: {} }), - publish: async () => ({ data: {} }), + publish: async () => {}, + selectSession: async () => {}, + }, + sync: { + start: async () => {}, }, - } as unknown as ToolContext['v2'], + }, plansRepo, config: {} as PluginConfig, projectId, directory: '/test', dataDir: TEST_DIR, cleanup: async () => {}, - input: { messages: [], systemPrompt: '' }, systemPrompt: '', messages: [], loopsRepo: createLoopsRepo(db), @@ -581,31 +630,46 @@ describe('Plan Approval Tool Interception', () => { }) test('Execute here approval schedules source abort and returns without throwing', async () => { - const abortSpy = vi.fn(() => Promise.resolve({ data: {} })) + const abortSpy = vi.fn(() => Promise.resolve()) const ctx = { loopService: { resolveLoopName: () => 'test-loop', getActiveState: () => null, }, logger: createMockLogger(), - v2: { + client: { session: { abort: abortSpy, - promptAsync: async () => ({ data: {} }), - create: async () => ({ data: { id: 'new-session-id' } }), + promptAsync: async () => {}, + create: async () => ({ id: 'new-session-id' }), + status: async () => ({}), + get: async () => ({}), + messages: async () => [], + update: async () => {}, + delete: async () => {}, + }, + workspace: { + create: async () => ({ id: '', directory: '', branch: '' }), + list: async () => [], + status: async () => [], + syncList: async () => {}, + remove: async () => {}, + warp: async () => {}, }, tui: { - selectSession: async () => ({ data: {} }), - publish: async () => ({ data: {} }), + publish: async () => {}, + selectSession: async () => {}, }, - } as unknown as ToolContext['v2'], + sync: { + start: async () => {}, + }, + }, plansRepo, config: {} as PluginConfig, projectId, directory: '/test', dataDir: TEST_DIR, cleanup: async () => {}, - input: { messages: [], systemPrompt: '' }, systemPrompt: '', messages: [], loopsRepo: createLoopsRepo(db), @@ -645,31 +709,46 @@ describe('Plan Approval Tool Interception', () => { }) test('New session approval schedules source abort and returns without throwing', async () => { - const abortSpy = vi.fn(() => Promise.resolve({ data: {} })) + const abortSpy = vi.fn(() => Promise.resolve()) const ctx = { loopService: { resolveLoopName: () => 'test-loop', getActiveState: () => null, }, logger: createMockLogger(), - v2: { + client: { session: { abort: abortSpy, - promptAsync: async () => ({ data: {} }), - create: async () => ({ data: { id: 'new-session-id' } }), + promptAsync: async () => {}, + create: async () => ({ id: 'new-session-id' }), + status: async () => ({}), + get: async () => ({}), + messages: async () => [], + update: async () => {}, + delete: async () => {}, + }, + workspace: { + create: async () => ({ id: '', directory: '', branch: '' }), + list: async () => [], + status: async () => [], + syncList: async () => {}, + remove: async () => {}, + warp: async () => {}, }, tui: { - selectSession: async () => ({ data: {} }), - publish: async () => ({ data: {} }), + publish: async () => {}, + selectSession: async () => {}, + }, + sync: { + start: async () => {}, }, - } as unknown as ToolContext['v2'], + }, plansRepo, config: {} as PluginConfig, projectId, directory: '/test', dataDir: TEST_DIR, cleanup: async () => {}, - input: { messages: [], systemPrompt: '' }, systemPrompt: '', messages: [], loopsRepo: createLoopsRepo(db), @@ -709,7 +788,7 @@ describe('Plan Approval Tool Interception', () => { }) test('Loop approval schedules source abort and returns without throwing', async () => { - const abortSpy = vi.fn(() => Promise.resolve({ data: {} })) + const abortSpy = vi.fn(() => Promise.resolve()) const loopsRepo = createLoopsRepo(db) const reviewFindingsRepo = createReviewFindingsRepo(db) const loopService = createLoopService(loopsRepo, plansRepo, reviewFindingsRepo, projectId, createMockLogger()) @@ -717,24 +796,39 @@ describe('Plan Approval Tool Interception', () => { const ctx = { loopService, logger: createMockLogger(), - v2: { + client: { session: { abort: abortSpy, - promptAsync: async () => ({ data: {} }), - create: async () => ({ data: { id: 'new-session-id' } }), + promptAsync: async () => {}, + create: async () => ({ id: 'new-session-id' }), + status: async () => ({}), + get: async () => ({}), + messages: async () => [], + update: async () => {}, + delete: async () => {}, + }, + workspace: { + create: async () => ({ id: '', directory: '', branch: '' }), + list: async () => [], + status: async () => [], + syncList: async () => {}, + remove: async () => {}, + warp: async () => {}, }, tui: { - selectSession: async () => ({ data: {} }), - publish: async () => ({ data: {} }), + publish: async () => {}, + selectSession: async () => {}, }, - } as unknown as ToolContext['v2'], + sync: { + start: async () => {}, + }, + }, plansRepo, config: {} as PluginConfig, projectId, directory: '/test', dataDir: TEST_DIR, cleanup: async () => {}, - input: { messages: [], systemPrompt: '', client: { session: { promptAsync: async () => ({ data: {} }) } } as any }, systemPrompt: '', messages: [], loopsRepo, @@ -774,7 +868,7 @@ describe('Plan Approval Tool Interception', () => { }) test('dispatches loop.start without a mode field when Loop is selected', async () => { - const abortSpy = vi.fn(() => Promise.resolve({ data: {} })) + const abortSpy = vi.fn(() => Promise.resolve()) const loopsRepo = createLoopsRepo(db) const reviewFindingsRepo = createReviewFindingsRepo(db) const loopService = createLoopService(loopsRepo, plansRepo, reviewFindingsRepo, projectId, createMockLogger()) @@ -783,24 +877,39 @@ describe('Plan Approval Tool Interception', () => { const ctx = { loopService, logger: createMockLogger(), - v2: { + client: { session: { abort: abortSpy, - promptAsync: async () => ({ data: {} }), - create: async () => ({ data: { id: 'new-session-id' } }), + promptAsync: async () => {}, + create: async () => ({ id: 'new-session-id' }), + status: async () => ({}), + get: async () => ({}), + messages: async () => [], + update: async () => {}, + delete: async () => {}, + }, + workspace: { + create: async () => ({ id: '', directory: '', branch: '' }), + list: async () => [], + status: async () => [], + syncList: async () => {}, + remove: async () => {}, + warp: async () => {}, }, tui: { - selectSession: async () => ({ data: {} }), - publish: async () => ({ data: {} }), + publish: async () => {}, + selectSession: async () => {}, + }, + sync: { + start: async () => {}, }, - } as unknown as ToolContext['v2'], + }, plansRepo, config: {} as PluginConfig, projectId, directory: '/test-dispatch', dataDir: TEST_DIR, cleanup: async () => {}, - input: { messages: [], systemPrompt: '', client: { session: { promptAsync: async () => ({ data: {} }) } } as any }, systemPrompt: '', messages: [], loopsRepo, @@ -815,7 +924,7 @@ describe('Plan Approval Tool Interception', () => { vi.spyOn(executionModule, 'createForgeExecutionService').mockImplementation((deps: any) => ({ dispatch: async (_execCtx: any, command: any) => { capturedCommand = command - return { ok: true, data: {} } + return { ok: true, data: {} as any } }, })) @@ -960,17 +1069,33 @@ describe('Execute here bypass', () => { }) function createMockContext(overrides?: Partial): ToolContext { - const mockV2 = { + const mockClient = { session: { - abort: async () => ({ data: {} }), - promptAsync: async () => ({ data: {} }), - create: async () => ({ data: { id: 'new-session-id' } }), + abort: async () => {}, + promptAsync: async () => {}, + create: async () => ({ id: 'new-session-id' }), + status: async () => ({}), + get: async () => ({}), + messages: async () => [], + update: async () => {}, + delete: async () => {}, + }, + workspace: { + create: async () => ({ id: 'mock-ws', directory: '', branch: '' }), + list: async () => [], + status: async () => [], + syncList: async () => {}, + remove: async () => {}, + warp: async () => {}, }, tui: { - selectSession: async () => ({ data: {} }), - publish: async () => ({ data: {} }), + publish: async () => {}, + selectSession: async () => {}, }, - } as unknown as ToolContext['v2'] + sync: { + start: async () => {}, + }, + } const mockConfig = { executionModel: 'test-provider/test-model', @@ -995,25 +1120,41 @@ describe('Execute here bypass', () => { plansRepo, loopsRepo, reviewFindingsRepo, - v2: mockV2, - input: { messages: [], systemPrompt: '', client: { session: { promptAsync: async () => ({ data: {} }) } } as any }, + client: mockClient, ...overrides, - } as ToolContext + } as unknown as ToolContext } test('Execute here bypasses directive injection and triggers abort', async () => { - const abortSpy = vi.fn(() => Promise.resolve({ data: {} })) + const abortSpy = vi.fn(() => Promise.resolve()) const ctx = createMockContext({ - v2: { + client: { session: { abort: abortSpy, - promptAsync: async () => ({ data: {} }), - create: async () => ({ data: { id: 'new-session-id' } }), + promptAsync: async () => {}, + create: async () => ({ id: 'new-session-id' }), + status: async () => ({}), + get: async () => ({}), + messages: async () => [], + update: async () => {}, + delete: async () => {}, + }, + workspace: { + create: async () => ({ id: '', directory: '', branch: '' }), + list: async () => [], + status: async () => [], + syncList: async () => {}, + remove: async () => {}, + warp: async () => {}, }, tui: { - selectSession: async () => ({ data: {} }), + publish: async () => {}, + selectSession: async () => {}, }, - } as unknown as ToolContext['v2'], + sync: { + start: async () => {}, + }, + }, }) ctx.plansRepo.writeForSession(projectId, sessionID, '# Test Plan\n\nThis is a test plan.') @@ -1048,29 +1189,34 @@ describe('Execute here bypass', () => { }) test('session.idle event triggers promptAsync for pending execution', async () => { - const v2PromptSpy = vi.fn(() => Promise.resolve({ data: {} })) - const legacyPromptSpy = vi.fn(() => Promise.resolve({ data: {} } as any)) + const promptSpy = vi.fn(() => Promise.resolve()) const ctx = createMockContext({ - v2: { + client: { session: { - abort: async () => ({ data: {} }), - promptAsync: v2PromptSpy, - create: async () => ({ data: { id: 'new-session-id' } }), + abort: async () => {}, + promptAsync: promptSpy, + create: async () => ({ id: 'new-session-id' }), + status: async () => ({}), + get: async () => ({}), + messages: async () => [], + update: async () => {}, + delete: async () => {}, + }, + workspace: { + create: async () => ({ id: '', directory: '', branch: '' }), + list: async () => [], + status: async () => [], + syncList: async () => {}, + remove: async () => {}, + warp: async () => {}, }, tui: { - selectSession: async () => ({ data: {} }), - }, - } as unknown as ToolContext['v2'], - input: { - messages: [], - systemPrompt: '', - client: { - session: { - abort: async () => ({ data: {} } as any), - promptAsync: legacyPromptSpy, - create: async () => ({ data: { id: 'new-session-id' } }), - }, - } as any, + publish: async () => {}, + selectSession: async () => {}, + }, + sync: { + start: async () => {}, + }, }, config: { executionModel: 'test-provider/test-model' } as PluginConfig, }) @@ -1108,11 +1254,11 @@ describe('Execute here bypass', () => { }, }) - expect(legacyPromptSpy).toHaveBeenCalled() - const callArgs = (legacyPromptSpy.mock.calls[0]?.[0] as any) - expect(callArgs?.path?.id).toBe(sessionID) - expect(callArgs?.body?.agent).toBe('code') - expect(callArgs?.body?.parts[0].text).toContain('The architect agent has created an implementation plan') + expect(promptSpy).toHaveBeenCalled() + const callArgs = (promptSpy.mock.calls[0]?.[0] as any) + expect(callArgs?.sessionID).toBe(sessionID) + expect(callArgs?.agent).toBe('code') + expect(callArgs?.parts[0].text).toContain('The architect agent has created an implementation plan') await eventHook({ event: { @@ -1121,23 +1267,39 @@ describe('Execute here bypass', () => { }, }) - expect(legacyPromptSpy).toHaveBeenCalledTimes(1) + expect(promptSpy).toHaveBeenCalledTimes(1) }) test('Other approval labels do not inject directives (programmatic dispatch)', async () => { - const abortSpy = vi.fn(() => Promise.resolve({ data: {} })) + const abortSpy = vi.fn(() => Promise.resolve()) const ctx = createMockContext({ - v2: { + client: { session: { abort: abortSpy, - promptAsync: async () => ({ data: {} }), - create: async () => ({ data: { id: 'new-session-id' } }), + promptAsync: async () => {}, + create: async () => ({ id: 'new-session-id' }), + status: async () => ({}), + get: async () => ({}), + messages: async () => [], + update: async () => {}, + delete: async () => {}, + }, + workspace: { + create: async () => ({ id: '', directory: '', branch: '' }), + list: async () => [], + status: async () => [], + syncList: async () => {}, + remove: async () => {}, + warp: async () => {}, }, tui: { - selectSession: async () => ({ data: {} }), - publish: async () => ({ data: {} }), + publish: async () => {}, + selectSession: async () => {}, + }, + sync: { + start: async () => {}, }, - } as unknown as ToolContext['v2'], + }, }) ctx.plansRepo.writeForSession(projectId, sessionID, '# Test Plan\n\nThis is a test plan.') @@ -1175,19 +1337,35 @@ describe('Execute here bypass', () => { }) test('session.idle for non-pending session is a no-op', async () => { - const promptSpy = vi.fn(() => Promise.resolve({ data: {} })) + const promptSpy = vi.fn(() => Promise.resolve()) const ctx = createMockContext({ - v2: { + client: { session: { - abort: async () => ({ data: {} }), + abort: async () => {}, promptAsync: promptSpy, - create: async () => ({ data: { id: 'new-session-id' } }), + create: async () => ({ id: 'new-session-id' }), + status: async () => ({}), + get: async () => ({}), + messages: async () => [], + update: async () => {}, + delete: async () => {}, + }, + workspace: { + create: async () => ({ id: '', directory: '', branch: '' }), + list: async () => [], + status: async () => [], + syncList: async () => {}, + remove: async () => {}, + warp: async () => {}, }, tui: { - selectSession: async () => ({ data: {} }), - publish: async () => ({ data: {} }), + publish: async () => {}, + selectSession: async () => {}, }, - } as unknown as ToolContext['v2'], + sync: { + start: async () => {}, + }, + }, }) const eventHook = createPlanApprovalEventHook(ctx) @@ -1213,9 +1391,7 @@ describe('Execute here bypass', () => { const originalPlan = '# Test Plan\n\nThis is a test plan.' plansRepo.writeForSession(projectId, testSessionID, originalPlan) - const legacyCreateSpy = vi.fn(() => Promise.resolve({ data: { id: 'new-session-123' } } as any)) - const legacyPromptSpy = vi.fn(() => Promise.resolve({ data: {} } as any)) - const legacyAbortSpy = vi.fn(() => Promise.resolve({ data: {} } as any)) + const abortSpy = vi.fn(() => Promise.resolve()) const loopsRepo = createLoopsRepo(db) const reviewFindingsRepo = createReviewFindingsRepo(db) @@ -1230,29 +1406,34 @@ describe('Execute here bypass', () => { loopsRepo, reviewFindingsRepo, loopService, - v2: { + client: { session: { - abort: async () => ({ data: {} }), - promptAsync: async () => ({ data: {} }), - create: async () => ({ data: { id: 'unused' } }), + abort: abortSpy, + promptAsync: async () => {}, + create: async () => ({ id: 'unused' }), + status: async () => ({}), + get: async () => ({}), + messages: async () => [], + update: async () => {}, + delete: async () => {}, + }, + workspace: { + create: async () => ({ id: '', directory: '', branch: '' }), + list: async () => [], + status: async () => [], + syncList: async () => {}, + remove: async () => {}, + warp: async () => {}, }, tui: { - selectSession: async () => ({ data: {} }), - publish: async () => ({ data: {} }), - }, - } as unknown as ToolContext['v2'], - input: { - messages: [] as any, - systemPrompt: '', - client: { - session: { - abort: legacyAbortSpy, - promptAsync: legacyPromptSpy, - create: legacyCreateSpy, - }, - } as any, - } as any, - } as ToolContext + publish: async () => {}, + selectSession: async () => {}, + }, + sync: { + start: async () => {}, + }, + }, + } as unknown as ToolContext const hook = createToolExecuteAfterHook(ctx) @@ -1294,8 +1475,8 @@ describe('Execute here bypass', () => { // Verify plan persists in session-scoped repo after dispatch const planAfter = plansRepo.getForSession(projectId, testSessionID) expect(planAfter?.content).toBe(originalPlan) - // Abort should have been called (legacy abort is used by the hook directly) - expect(legacyAbortSpy).toHaveBeenCalled() + // Abort should have been called (on the port client) + expect(abortSpy).toHaveBeenCalled() // Duplicate should preserve original output and add duplicate metadata expect(duplicateOutput.output).toBe('New session') expect((duplicateOutput.metadata as any).forgePlanApprovalDuplicate).toBe(true) @@ -1311,8 +1492,7 @@ describe('Execute here bypass', () => { const originalPlan = '# Execute Here Plan\n\nThis plan should persist after Execute here.' plansRepo.writeForSession(projectId, testSessionID, originalPlan) - const promptSpy = vi.fn(() => Promise.resolve({ data: {} })) - const abortSpy = vi.fn(() => Promise.resolve({ data: {} })) + const abortSpy = vi.fn(() => Promise.resolve()) const loopsRepo = createLoopsRepo(db) const reviewFindingsRepo = createReviewFindingsRepo(db) @@ -1325,16 +1505,34 @@ describe('Execute here bypass', () => { logger: createMockLogger(), plansRepo, loopService, - v2: { + client: { session: { abort: abortSpy, - promptAsync: promptSpy, + promptAsync: async () => {}, + create: async () => ({ id: '' }), + status: async () => ({}), + get: async () => ({}), + messages: async () => [], + update: async () => {}, + delete: async () => {}, + }, + workspace: { + create: async () => ({ id: '', directory: '', branch: '' }), + list: async () => [], + status: async () => [], + syncList: async () => {}, + remove: async () => {}, + warp: async () => {}, }, tui: { - selectSession: async () => ({ data: {} }), + publish: async () => {}, + selectSession: async () => {}, + }, + sync: { + start: async () => {}, }, - } as unknown as ToolContext['v2'], - } as ToolContext + }, + } as unknown as ToolContext const hook = createToolExecuteAfterHook(ctx) @@ -1380,10 +1578,7 @@ describe('Execute here bypass', () => { const originalPlan = '# Loop Plan\n\nThis plan should persist after Loop setup.' plansRepo.writeForSession(projectId, testSessionID, originalPlan) - const createSpy = vi.fn(() => Promise.resolve({ data: { id: 'loop-session-123' } })) - const promptSpy = vi.fn(() => Promise.resolve({ data: {} })) - const abortSpy = vi.fn(() => Promise.resolve({ data: {} })) - const selectSessionSpy = vi.fn(() => Promise.resolve({ data: {} })) + const abortSpy = vi.fn(() => Promise.resolve()) const ctx = { projectId, @@ -1395,25 +1590,36 @@ describe('Execute here bypass', () => { loopHandler: { startWatchdog: vi.fn(() => {}), }, - v2: { + client: { session: { abort: abortSpy, - promptAsync: promptSpy, - create: createSpy, + promptAsync: async () => {}, + create: async () => ({ id: 'loop-session-123' }), + status: async () => ({}), + get: async () => ({}), + messages: async () => [], + update: async () => {}, + delete: async () => {}, + }, + workspace: { + create: async () => ({ id: 'ws-loop-test', directory: `${TEST_DIR}/loop-workspace`, branch: 'opencode/loop' }), + list: async () => [], + status: async () => [], + syncList: async () => {}, + remove: async () => {}, + warp: async () => {}, }, tui: { - selectSession: selectSessionSpy, + publish: async () => {}, + selectSession: async () => {}, }, - experimental: { - workspace: { - create: vi.fn(async () => ({ data: { id: 'ws-loop-test', directory: `${TEST_DIR}/loop-workspace`, branch: 'opencode/loop' }, error: undefined })), - }, + sync: { + start: async () => {}, }, }, db, dataDir: TEST_DIR, cleanup: async () => {}, - input: { messages: [], systemPrompt: '' }, systemPrompt: '', messages: [], loopsRepo, @@ -1476,8 +1682,8 @@ describe('Execute here bypass', () => { const originalPlan = '# Failed Loop Plan\n\nThis plan should persist after failed Loop setup.' plansRepo.writeForSession(projectId, testSessionID, originalPlan) - const createSpy = vi.fn(() => Promise.resolve({ data: null, error: 'Failed to create session' })) - const abortSpy = vi.fn(() => Promise.resolve({ data: {} })) + const abortSpy = vi.fn(() => Promise.resolve()) + const tuiPublishSpy = vi.fn(() => Promise.resolve()) const ctx = { projectId, @@ -1489,20 +1695,36 @@ describe('Execute here bypass', () => { loopHandler: { startWatchdog: vi.fn(() => {}), }, - v2: { + client: { session: { abort: abortSpy, - promptAsync: vi.fn(() => Promise.resolve({ data: {} })), - create: createSpy, + promptAsync: async () => {}, + create: async () => { throw new Error('Failed to create session') }, + status: async () => ({}), + get: async () => ({}), + messages: async () => [], + update: async () => {}, + delete: async () => {}, + }, + workspace: { + create: async () => ({ id: '', directory: '', branch: '' }), + list: async () => [], + status: async () => [], + syncList: async () => {}, + remove: async () => {}, + warp: async () => {}, }, tui: { - selectSession: vi.fn(() => Promise.resolve({ data: {} })), + publish: tuiPublishSpy, + selectSession: async () => {}, + }, + sync: { + start: async () => {}, }, }, db, dataDir: TEST_DIR, cleanup: async () => {}, - input: { messages: [], systemPrompt: '' }, systemPrompt: '', messages: [], loopsRepo, @@ -1528,9 +1750,6 @@ describe('Execute here bypass', () => { metadata: { answers: [['Loop']] }, } - const tuiPublishSpy = vi.fn(() => Promise.resolve() as any) - ;(ctx.v2.tui as any).publish = tuiPublishSpy - await expect(hook( { tool: 'question', sessionID: testSessionID, callID: 'test-call', args }, output @@ -1605,17 +1824,33 @@ describe('Fire-and-forget dispatch behavior', () => { }) function createMockContext(overrides?: Partial): ToolContext { - const mockV2 = { + const mockClient = { session: { - abort: async () => ({ data: {} }), - promptAsync: async () => ({ data: {} }), - create: async () => ({ data: { id: 'new-session-id' } }), + abort: async () => {}, + promptAsync: async () => {}, + create: async () => ({ id: 'new-session-id' }), + status: async () => ({}), + get: async () => ({}), + messages: async () => [], + update: async () => {}, + delete: async () => {}, + }, + workspace: { + create: async () => ({ id: '', directory: '', branch: '' }), + list: async () => [], + status: async () => [], + syncList: async () => {}, + remove: async () => {}, + warp: async () => {}, }, tui: { - selectSession: async () => ({ data: {} }), - publish: async () => ({ data: {} }), + publish: async () => {}, + selectSession: async () => {}, }, - } as unknown as ToolContext['v2'] + sync: { + start: async () => {}, + }, + } const mockConfig = { executionModel: 'test-provider/test-model', @@ -1642,18 +1877,17 @@ describe('Fire-and-forget dispatch behavior', () => { plansRepo, loopsRepo, reviewFindingsRepo, - v2: mockV2, + client: mockClient, loopHandler: { startWatchdog: vi.fn(() => {}), }, sandboxManager: null, dataDir: TEST_DIR, cleanup: async () => {}, - input: { messages: [], systemPrompt: '' }, systemPrompt: '', messages: [], ...overrides, - } as ToolContext + } as unknown as ToolContext } test('New session approval returns before promptAsync resolves', async () => { @@ -1663,21 +1897,36 @@ describe('Fire-and-forget dispatch behavior', () => { resolvePrompt = resolve }) - const promptSpy = vi.fn(() => pendingPromise) - const abortSpy = vi.fn(() => Promise.resolve({ data: {} })) + const abortSpy = vi.fn(() => Promise.resolve()) const ctx = createMockContext({ - v2: { + client: { session: { abort: abortSpy, - promptAsync: promptSpy, + promptAsync: () => pendingPromise, create: () => pendingPromise, + status: async () => ({}), + get: async () => ({}), + messages: async () => [], + update: async () => {}, + delete: async () => {}, + }, + workspace: { + create: async () => ({ id: '', directory: '', branch: '' }), + list: async () => [], + status: async () => [], + syncList: async () => {}, + remove: async () => {}, + warp: async () => {}, }, tui: { - selectSession: async () => ({ data: {} }), - publish: async () => ({ data: {} }), + publish: async () => {}, + selectSession: async () => {}, + }, + sync: { + start: async () => {}, }, - } as unknown as ToolContext['v2'], + }, }) ctx.plansRepo.writeForSession(projectId, sid, '# Test Plan\n\nThis is a test plan.') @@ -1737,21 +1986,36 @@ describe('Fire-and-forget dispatch behavior', () => { resolvePrompt = resolve }) - const promptSpy = vi.fn(() => pendingPromise) - const abortSpy = vi.fn(() => Promise.resolve({ data: {} })) + const abortSpy = vi.fn(() => Promise.resolve()) const ctx = createMockContext({ - v2: { + client: { session: { abort: abortSpy, - promptAsync: promptSpy, + promptAsync: () => pendingPromise, create: () => pendingPromise, + status: async () => ({}), + get: async () => ({}), + messages: async () => [], + update: async () => {}, + delete: async () => {}, + }, + workspace: { + create: async () => ({ id: '', directory: '', branch: '' }), + list: async () => [], + status: async () => [], + syncList: async () => {}, + remove: async () => {}, + warp: async () => {}, }, tui: { - selectSession: async () => ({ data: {} }), - publish: async () => ({ data: {} }), + publish: async () => {}, + selectSession: async () => {}, + }, + sync: { + start: async () => {}, }, - } as unknown as ToolContext['v2'], + }, config: { executionModel: 'test-provider/test-model', loop: { defaultMaxIterations: 5 } } as PluginConfig, }) @@ -1807,33 +2071,36 @@ describe('Fire-and-forget dispatch behavior', () => { }) test('Duplicate New session approval schedules one dispatch', async () => { - const legacyCreateSpy = vi.fn(() => Promise.resolve({ data: { id: 'new-session-123' } } as any)) - const legacyPromptSpy = vi.fn(() => Promise.resolve({ data: {} } as any)) - const legacyAbortSpy = vi.fn(() => Promise.resolve({ data: {} } as any)) + const abortSpy = vi.fn(() => Promise.resolve()) const ctx = createMockContext({ - v2: { + client: { session: { - abort: async () => ({ data: {} }), - promptAsync: async () => ({ data: {} }), - create: async () => ({ data: { id: 'unused' } }), + abort: abortSpy, + promptAsync: async () => {}, + create: async () => ({ id: 'new-session-id' }), + status: async () => ({}), + get: async () => ({}), + messages: async () => [], + update: async () => {}, + delete: async () => {}, + }, + workspace: { + create: async () => ({ id: '', directory: '', branch: '' }), + list: async () => [], + status: async () => [], + syncList: async () => {}, + remove: async () => {}, + warp: async () => {}, }, tui: { - selectSession: async () => ({ data: {} }), - publish: async () => ({ data: {} }), - }, - } as unknown as ToolContext['v2'], - input: { - messages: [] as any, - systemPrompt: '', - client: { - session: { - abort: legacyAbortSpy, - promptAsync: legacyPromptSpy, - create: legacyCreateSpy, - }, - } as any, - } as any, + publish: async () => {}, + selectSession: async () => {}, + }, + sync: { + start: async () => {}, + }, + }, }) ctx.plansRepo.writeForSession(projectId, sessionID, '# Test Plan\n\nThis is a test plan.') @@ -1878,46 +2145,44 @@ describe('Fire-and-forget dispatch behavior', () => { await new Promise(resolve => setTimeout(resolve, 100)) // Abort is called for each approval call (both original and duplicate) - expect(legacyAbortSpy).toHaveBeenCalledTimes(2) + expect(abortSpy).toHaveBeenCalledTimes(2) // Duplicate should preserve original output and add duplicate metadata expect(duplicateOutput.output).toBe('New session') expect((duplicateOutput.metadata as any).forgePlanApprovalDuplicate).toBe(true) }) test('Duplicate New session approval with a different callID schedules one dispatch', async () => { - let resolveAbort: () => void - const abortPromise = new Promise<{ data: {} }>((resolve) => { - resolveAbort = () => resolve({ data: {} }) - }) - const legacyCreateSpy = vi.fn(() => Promise.resolve({ data: { id: 'new-session-123' } } as any)) - const legacyPromptSpy = vi.fn(() => Promise.resolve({ data: {} } as any)) - const legacyAbortSpy = vi.fn(() => abortPromise as any) - const v2CreateSpy = vi.fn(() => Promise.resolve({ data: { id: 'new-session-id' } })) - const sharedClient = { - session: { - abort: legacyAbortSpy, - promptAsync: legacyPromptSpy, - create: legacyCreateSpy, - }, - } as any + const abortSpy = vi.fn(() => Promise.resolve()) + const createSpy = vi.fn(() => Promise.resolve({ id: 'new-session-id' })) const ctx = createMockContext({ - v2: { + client: { session: { - abort: async () => ({ data: {} }), - promptAsync: async () => ({ data: {} }), - create: v2CreateSpy, + abort: abortSpy, + promptAsync: async () => {}, + create: createSpy, + status: async () => ({}), + get: async () => ({}), + messages: async () => [], + update: async () => {}, + delete: async () => {}, + }, + workspace: { + create: async () => ({ id: '', directory: '', branch: '' }), + list: async () => [], + status: async () => [], + syncList: async () => {}, + remove: async () => {}, + warp: async () => {}, }, tui: { - selectSession: async () => ({ data: {} }), - publish: async () => ({ data: {} }), - }, - } as unknown as ToolContext['v2'], - input: { - messages: [] as any, - systemPrompt: '', - client: sharedClient, - } as any, + publish: async () => {}, + selectSession: async () => {}, + }, + sync: { + start: async () => {}, + }, + }, }) ctx.plansRepo.writeForSession(projectId, sessionID, '# Test Plan\n\nThis is a test plan.') @@ -1935,18 +2200,15 @@ describe('Fire-and-forget dispatch behavior', () => { }], } + const firstOutput = { + title: 'Asked 1 question', + output: 'New session', + metadata: { answers: [['New session']] }, + } + const firstHook = hook( - { - tool: 'question', - sessionID, - callID: 'test-call-1', - args, - }, - { - title: 'Asked 1 question', - output: 'New session', - metadata: { answers: [['New session']] }, - } + { tool: 'question', sessionID, callID: 'test-call-1', args }, + firstOutput ) const duplicateOutput = { @@ -1956,26 +2218,23 @@ describe('Fire-and-forget dispatch behavior', () => { } const duplicateHook = hook( - { - tool: 'question', - sessionID, - callID: 'test-call-2', - args, - }, + { tool: 'question', sessionID, callID: 'test-call-2', args }, duplicateOutput ) - resolveAbort!() - await expect(duplicateHook).resolves.toBeUndefined() await expect(firstHook).resolves.toBeUndefined() - await new Promise(resolve => setTimeout(resolve, 500)) + await new Promise(resolve => setTimeout(resolve, 200)) - expect(v2CreateSpy).toHaveBeenCalledTimes(1) - expect(legacyAbortSpy).toHaveBeenCalledTimes(2) + // Only one dispatch session.create (claimed by first caller) + expect(createSpy).toHaveBeenCalledTimes(1) + // Abort is called for each approval call (both original and duplicate) + expect(abortSpy).toHaveBeenCalledTimes(2) + // Duplicate gets the flag expect((duplicateOutput.metadata as any).forgePlanApprovalDuplicate).toBe(true) + // A third call with yet another callID is also a duplicate const laterDuplicateOutput = { title: 'Asked 1 question', output: 'New session', @@ -1989,43 +2248,47 @@ describe('Fire-and-forget dispatch behavior', () => { await new Promise(resolve => setTimeout(resolve, 100)) - expect(v2CreateSpy).toHaveBeenCalledTimes(1) - expect(legacyAbortSpy).toHaveBeenCalledTimes(3) + // Still only one dispatch + expect(createSpy).toHaveBeenCalledTimes(1) + // Abort called a third time + expect(abortSpy).toHaveBeenCalledTimes(3) expect((laterDuplicateOutput.metadata as any).forgePlanApprovalDuplicate).toBe(true) }) test('Dispatch IIFE survives slow source-session abort', async () => { let resolveAbort: () => void - const abortPromise = new Promise<{ data: {} }>((resolve) => { - resolveAbort = () => resolve({ data: {} }) - }) - const legacyCreateSpy = vi.fn(() => Promise.resolve({ data: { id: 'new-session-123' } } as any)) - const legacyPromptSpy = vi.fn(() => Promise.resolve({ data: {} } as any)) - const legacyAbortSpy = vi.fn(() => abortPromise as any) + const abortSpy = vi.fn(() => new Promise((resolve) => { + resolveAbort = resolve + })) const ctx = createMockContext({ - v2: { + client: { session: { - abort: async () => ({ data: {} }), - promptAsync: async () => ({ data: {} }), - create: async () => ({ data: { id: 'unused' } }), + abort: abortSpy, + promptAsync: async () => {}, + create: async () => ({ id: 'new-session-id' }), + status: async () => ({}), + get: async () => ({}), + messages: async () => [], + update: async () => {}, + delete: async () => {}, + }, + workspace: { + create: async () => ({ id: '', directory: '', branch: '' }), + list: async () => [], + status: async () => [], + syncList: async () => {}, + remove: async () => {}, + warp: async () => {}, }, tui: { - selectSession: async () => ({ data: {} }), - publish: async () => ({ data: {} }), - }, - } as unknown as ToolContext['v2'], - input: { - messages: [] as any, - systemPrompt: '', - client: { - session: { - abort: legacyAbortSpy, - promptAsync: legacyPromptSpy, - create: legacyCreateSpy, - }, - } as any, - } as any, + publish: async () => {}, + selectSession: async () => {}, + }, + sync: { + start: async () => {}, + }, + }, }) ctx.plansRepo.writeForSession(projectId, sessionID, '# Test Plan\n\n') @@ -2054,6 +2317,9 @@ describe('Fire-and-forget dispatch behavior', () => { output ) + // Wait a tick for the hook to start executing and call abortSpy + await new Promise(resolve => setTimeout(resolve, 10)) + expect(resolveAbort).toBeDefined() // Now resolve the abort resolveAbort!() @@ -2061,43 +2327,60 @@ describe('Fire-and-forget dispatch behavior', () => { await expect(hookPromise).resolves.toBeUndefined() }) - test('Falls back to v2 client when legacy client is unavailable', async () => { + test('Port abort is called on session.abort', async () => { const db = createTestDb() + openDbs.push(db) const testPlansRepo = createPlansRepo(db) - const v2AbortSpy = vi.fn(() => Promise.resolve({ data: {} })) - const testSid = 'fire-v2-fallback-' + (++nextTestId) + const testLoopsRepo = createLoopsRepo(db) + const testReviewFindingsRepo = createReviewFindingsRepo(db) + const abortSpy = vi.fn(() => Promise.resolve()) const ctx = { loopService: { resolveLoopName: () => 'test-loop', getActiveState: () => null, }, logger: createMockLogger(), - v2: { + client: { session: { - abort: v2AbortSpy, - promptAsync: async () => ({ data: {} }), - create: async () => ({ data: { id: 'new-session-id' } }), + abort: abortSpy, + promptAsync: async () => {}, + create: async () => ({ id: 'new-session-id' }), + status: async () => ({}), + get: async () => ({}), + messages: async () => [], + update: async () => {}, + delete: async () => {}, + }, + workspace: { + create: async () => ({ id: '', directory: '', branch: '' }), + list: async () => [], + status: async () => [], + syncList: async () => {}, + remove: async () => {}, + warp: async () => {}, }, tui: { - selectSession: async () => ({ data: {} }), - publish: async () => ({ data: {} }), + publish: async () => {}, + selectSession: async () => {}, }, - } as unknown as ToolContext['v2'], + sync: { + start: async () => {}, + }, + }, plansRepo: testPlansRepo, config: {} as PluginConfig, projectId, directory: '/test', dataDir: TEST_DIR, cleanup: async () => {}, - input: { messages: [], systemPrompt: '', client: undefined } as any, systemPrompt: '', messages: [], - loopsRepo: createLoopsRepo(db), - reviewFindingsRepo: createReviewFindingsRepo(db), + loopsRepo: testLoopsRepo, + reviewFindingsRepo: testReviewFindingsRepo, sandboxManager: null, } as unknown as ToolContext - testPlansRepo.writeForSession(projectId, testSid, '# plan') + testPlansRepo.writeForSession(projectId, 'test-sid', '# plan') const hook = createToolExecuteAfterHook(ctx) @@ -2118,20 +2401,22 @@ describe('Fire-and-forget dispatch behavior', () => { } await expect(hook( - { tool: 'question', sessionID: testSid, callID: 'test-call', args }, + { tool: 'question', sessionID: 'test-sid', callID: 'test-call', args }, output )).resolves.toBeUndefined() - expect(v2AbortSpy).toHaveBeenCalled() - db.close() + expect(abortSpy).toHaveBeenCalled() }) - test('Treats v2 abort returning {error,...} as a failure (logs error)', async () => { + test('Treats port abort throwing as a failure (logs error)', async () => { const db = createTestDb() + openDbs.push(db) const testPlansRepo = createPlansRepo(db) - const v2AbortSpy = vi.fn(() => Promise.resolve({ data: undefined, error: 'Unable to connect' })) + const testLoopsRepo = createLoopsRepo(db) + const testReviewFindingsRepo = createReviewFindingsRepo(db) + const abortError = new Error('Unable to connect') + const abortSpy = vi.fn(() => Promise.reject(abortError)) const errors: unknown[] = [] - const testSid = 'fire-error-' + (++nextTestId) const ctx = { loopService: { resolveLoopName: () => 'test-loop', @@ -2142,32 +2427,47 @@ describe('Fire-and-forget dispatch behavior', () => { error: (...args: unknown[]) => { errors.push(args) }, debug: () => {}, } as Logger, - v2: { + client: { session: { - abort: v2AbortSpy, - promptAsync: async () => ({ data: {} }), - create: async () => ({ data: { id: 'new-session-id' } }), + abort: abortSpy, + promptAsync: async () => {}, + create: async () => ({ id: 'new-session-id' }), + status: async () => ({}), + get: async () => ({}), + messages: async () => [], + update: async () => {}, + delete: async () => {}, + }, + workspace: { + create: async () => ({ id: '', directory: '', branch: '' }), + list: async () => [], + status: async () => [], + syncList: async () => {}, + remove: async () => {}, + warp: async () => {}, }, tui: { - selectSession: async () => ({ data: {} }), - publish: async () => ({ data: {} }), + publish: async () => {}, + selectSession: async () => {}, + }, + sync: { + start: async () => {}, }, - } as unknown as ToolContext['v2'], + }, plansRepo: testPlansRepo, config: {} as PluginConfig, projectId, directory: '/test', dataDir: TEST_DIR, cleanup: async () => {}, - input: { messages: [], systemPrompt: '', client: undefined } as any, systemPrompt: '', messages: [], - loopsRepo: createLoopsRepo(db), - reviewFindingsRepo: createReviewFindingsRepo(db), + loopsRepo: testLoopsRepo, + reviewFindingsRepo: testReviewFindingsRepo, sandboxManager: null, } as unknown as ToolContext - testPlansRepo.writeForSession(projectId, testSid, '# plan') + testPlansRepo.writeForSession(projectId, 'test-sid-error', '# plan') const hook = createToolExecuteAfterHook(ctx) @@ -2188,11 +2488,13 @@ describe('Fire-and-forget dispatch behavior', () => { } await expect(hook( - { tool: 'question', sessionID: testSid, callID: 'test-call', args }, + { tool: 'question', sessionID: 'test-sid-error', callID: 'test-call', args }, output )).resolves.toBeUndefined() - expect(errors.some(e => JSON.stringify(e).includes('Unable to connect'))).toBe(true) - db.close() + expect(errors.some(e => { + const args = Array.isArray(e) ? e : [e] + return args.some(a => a instanceof Error ? a.message.includes('Unable to connect') : String(a).includes('Unable to connect')) + })).toBe(true) }) }) diff --git a/test/plan-capture.test.ts b/test/plan-capture.test.ts index e8cb1887af..eee1a48821 100644 --- a/test/plan-capture.test.ts +++ b/test/plan-capture.test.ts @@ -428,8 +428,7 @@ Outro` test('message part event auto-captures before idle or approval', async () => { const plansRepo = createFakePlansRepo() const hook = createPlanCaptureEventHook({ - v2: { session: { messages: async () => ({ data: [] }) } }, - input: { client: { session: { messages: async () => ({ data: [] }) } } }, + client: { session: { messages: async () => [] } }, plansRepo, projectId: 'test-project', directory: '/tmp/project', @@ -470,7 +469,7 @@ Outro` }) }) -describe('captureLatestPlanForSession legacy client fallback', () => { +describe('captureLatestPlanForSession with ForgeClient', () => { function createFakePlansRepo() { const plans = new Map() return { @@ -493,58 +492,55 @@ describe('captureLatestPlanForSession legacy client fallback', () => { const planMessage = { info: { role: 'assistant', id: 'msg-1' }, - parts: [{ type: 'text', text: `${PLAN_START_MARKER}\nFallback Plan\n${PLAN_END_MARKER}` }], + parts: [{ type: 'text', text: `${PLAN_START_MARKER}\nFound Plan\n${PLAN_END_MARKER}` }], } - test('falls back to legacy client when v2 returns empty data', async () => { + test('returns plan when client returns messages', async () => { const plansRepo = createFakePlansRepo() const deps = { - v2: { session: { messages: async () => ({ data: [] }) } }, - client: { session: { messages: async () => ({ data: [planMessage] }) } }, + client: { session: { messages: async () => [planMessage] } }, plansRepo, projectId: 'test-project', directory: '/tmp/project', logger, } - const result = await captureLatestPlanForSession(deps as any, 'session-fb-1') + const result = await captureLatestPlanForSession(deps as any, 'session-found') expect(result.status).toBe('captured') - expect(plansRepo.getForSession('test-project', 'session-fb-1')?.content).toBe('Fallback Plan') + expect(plansRepo.getForSession('test-project', 'session-found')?.content).toBe('Found Plan') }) - test('falls back to legacy client when v2 returns an error', async () => { + test('returns not-found when client returns empty messages', async () => { const plansRepo = createFakePlansRepo() const deps = { - v2: { session: { messages: async () => ({ error: { message: 'boom' }, data: undefined }) } }, - client: { session: { messages: async () => ({ data: [planMessage] }) } }, + client: { session: { messages: async () => [] } }, plansRepo, projectId: 'test-project', directory: '/tmp/project', logger, } - const result = await captureLatestPlanForSession(deps as any, 'session-fb-2') + const result = await captureLatestPlanForSession(deps as any, 'session-empty') - expect(result.status).toBe('captured') - expect(plansRepo.getForSession('test-project', 'session-fb-2')?.content).toBe('Fallback Plan') + expect(result.status).toBe('not-found') + expect(plansRepo.getForSession('test-project', 'session-empty')).toBeNull() }) - test('returns not-found when both v2 and legacy return empty', async () => { + test('returns read-failed when client throws', async () => { const plansRepo = createFakePlansRepo() const deps = { - v2: { session: { messages: async () => ({ data: [] }) } }, - client: { session: { messages: async () => ({ data: [] }) } }, + client: { session: { messages: async () => { throw new Error('network error') } } }, plansRepo, projectId: 'test-project', directory: '/tmp/project', logger, } - const result = await captureLatestPlanForSession(deps as any, 'session-fb-3') + const result = await captureLatestPlanForSession(deps as any, 'session-error') - expect(result.status).toBe('not-found') - expect(plansRepo.getForSession('test-project', 'session-fb-3')).toBeNull() + expect(result.status).toBe('read-failed') + expect(plansRepo.getForSession('test-project', 'session-error')).toBeNull() }) }) @@ -577,8 +573,7 @@ describe('plan capture trigger on assistant message completion', () => { parts: [{ type: 'text', text: `${PLAN_START_MARKER}\n# Completed Plan\n\n## Verification\n- bun test\n${PLAN_END_MARKER}` }], }] const hook = createPlanCaptureEventHook({ - v2: { session: { messages: async () => ({ data: messages }) } }, - input: { client: { session: { messages: async () => ({ data: messages }) } } }, + client: { session: { messages: async () => messages } }, plansRepo, projectId: 'test-project', directory: '/tmp/project', @@ -594,11 +589,10 @@ describe('plan capture trigger on assistant message completion', () => { const plansRepo = createFakePlansRepo() let messagesCalls = 0 const hook = createPlanCaptureEventHook({ - v2: { session: { messages: async () => { + client: { session: { messages: async () => { messagesCalls++ - return { data: [] } + return [] } } }, - input: { client: { session: { messages: async () => ({ data: [] }) } } }, plansRepo, projectId: 'test-project', directory: '/tmp/project', @@ -615,11 +609,10 @@ describe('plan capture trigger on assistant message completion', () => { const plansRepo = createFakePlansRepo() let messagesCalls = 0 const hook = createPlanCaptureEventHook({ - v2: { session: { messages: async () => { + client: { session: { messages: async () => { messagesCalls++ - return { data: [] } + return [] } } }, - input: { client: { session: { messages: async () => ({ data: [] }) } } }, plansRepo, projectId: 'test-project', directory: '/tmp/project', @@ -640,8 +633,7 @@ describe('plan capture trigger on assistant message completion', () => { parts: [{ type: 'text', text }], }] const hook = createPlanCaptureEventHook({ - v2: { session: { messages: async () => ({ data: messages }) } }, - input: { client: { session: { messages: async () => ({ data: messages }) } } }, + client: { session: { messages: async () => messages } }, plansRepo, projectId: 'test-project', directory: '/tmp/project', diff --git a/test/resolve-project-root.test.ts b/test/resolve-project-root.test.ts index cbd8cab076..d2a5aa7085 100644 --- a/test/resolve-project-root.test.ts +++ b/test/resolve-project-root.test.ts @@ -1,52 +1,74 @@ import { describe, test, expect } from 'bun:test' import { resolveHostSessionDirectory } from '../src/utils/resolve-project-root' -import type { OpencodeClient } from '@opencode-ai/sdk/v2' +import { createFakeForgeClient } from './helpers/fake-client' -function makeV2( - handler: (input: { sessionID: string; directory?: string }) => { data?: { directory?: string } } | Promise<{ data?: { directory?: string } }>, -): { v2: OpencodeClient; calls: Array<{ sessionID: string; directory?: string }> } { +function makeClient( + handler: (input: { sessionID: string; directory?: string }) => { directory?: string } | Promise<{ directory?: string }>, +): { calls: Array<{ sessionID: string; directory?: string }> } { const calls: Array<{ sessionID: string; directory?: string }> = [] - const v2 = { + createFakeForgeClient({ session: { - get: async (input: { sessionID: string; directory?: string }) => { - calls.push(input) - return handler(input) + get: async (input: any) => { + calls.push(input as { sessionID: string; directory?: string }) + return handler(input as { sessionID: string; directory?: string }) }, }, - } as unknown as OpencodeClient - return { v2, calls } + }) + return { calls } } describe('resolveHostSessionDirectory', () => { test('returns null when no host session id is provided', async () => { - const { v2, calls } = makeV2(() => ({ data: { directory: '/should/not/be/used' } })) - const result = await resolveHostSessionDirectory(v2, undefined, '/fallback') + const { client } = createFakeForgeClient({ + session: { + get: async () => ({ directory: '/should/not/be/used' }), + }, + }) + const result = await resolveHostSessionDirectory(client, undefined, '/fallback') expect(result).toBeNull() - expect(calls.length).toBe(0) }) test('resolves the host session directory (the real project root)', async () => { - const { v2, calls } = makeV2(() => ({ data: { directory: '/Users/chris/development/oc-manager' } })) - const result = await resolveHostSessionDirectory(v2, 'ses_host', '/worktree/path') + const calls: Array<{ sessionID: string; directory?: string }> = [] + const { client } = createFakeForgeClient({ + session: { + get: async (input: any) => { + calls.push(input as any) + return { directory: '/Users/chris/development/oc-manager' } + }, + }, + }) + const result = await resolveHostSessionDirectory(client, 'ses_host', '/worktree/path') expect(result).toBe('/Users/chris/development/oc-manager') expect(calls[0]).toEqual({ sessionID: 'ses_host' }) }) test('falls back to a directory-scoped lookup when the first attempt is empty', async () => { - const { v2, calls } = makeV2((input) => - input.directory ? { data: { directory: '/Users/chris/development/sd-mono' } } : { data: {} }, - ) - const result = await resolveHostSessionDirectory(v2, 'ses_host', '/worktree/path') + const calls: Array<{ sessionID: string; directory?: string }> = [] + const { client } = createFakeForgeClient({ + session: { + get: async (input: any) => { + calls.push(input) + if (input.directory) { + return { directory: '/Users/chris/development/sd-mono' } + } + return {} + }, + }, + }) + const result = await resolveHostSessionDirectory(client, 'ses_host', '/worktree/path') expect(result).toBe('/Users/chris/development/sd-mono') expect(calls.length).toBe(2) expect(calls[1]).toEqual({ sessionID: 'ses_host', directory: '/worktree/path' }) }) test('returns null when all lookups fail', async () => { - const { v2 } = makeV2(() => { - throw new Error('not found') + const { client } = createFakeForgeClient({ + session: { + get: async () => { throw new Error('not found') }, + }, }) - const result = await resolveHostSessionDirectory(v2, 'ses_host', '/worktree/path') + const result = await resolveHostSessionDirectory(client, 'ses_host', '/worktree/path') expect(result).toBeNull() }) }) diff --git a/test/section-management.test.ts b/test/section-management.test.ts index 9226e8c3a7..447656c225 100644 --- a/test/section-management.test.ts +++ b/test/section-management.test.ts @@ -37,7 +37,7 @@ describe('LoopService section management', () => { plansRepo = createPlansRepo(db) reviewFindingsRepo = createReviewFindingsRepo(db) sectionPlansRepo = createSectionPlansRepo(db) - loopService = createLoopService(loopsRepo, plansRepo, reviewFindingsRepo, projectId, mockLogger, undefined, undefined, undefined, sectionPlansRepo) + loopService = createLoopService(loopsRepo, plansRepo, reviewFindingsRepo, projectId, mockLogger, undefined, undefined, sectionPlansRepo) }) afterEach(() => { @@ -381,7 +381,7 @@ describe('section-read tool contract', () => { const plansRepo = createPlansRepo(db) const reviewFindingsRepo = createReviewFindingsRepo(db) const sectionPlansRepo = createSectionPlansRepo(db) - const loopService = createLoopService(loopsRepo, plansRepo, reviewFindingsRepo, 'proj', mockLogger, undefined, undefined, undefined, sectionPlansRepo) + const loopService = createLoopService(loopsRepo, plansRepo, reviewFindingsRepo, 'proj', mockLogger, undefined, undefined, sectionPlansRepo) // Insert a test loop with sections loopsRepo.insert({ diff --git a/test/services/attach-loop.test.ts b/test/services/attach-loop.test.ts index 5da82ff13d..9166398bc8 100644 --- a/test/services/attach-loop.test.ts +++ b/test/services/attach-loop.test.ts @@ -9,6 +9,7 @@ import { createReviewFindingsRepo } from '../../src/storage/repos/review-finding import { createSectionPlansRepo } from '../../src/storage/repos/section-plans-repo' import { createLoopService } from '../../src/loop/service' import type { Logger } from '../../src/types' +import { createFakeForgeClient } from '../helpers/fake-client' const noopFn = () => {} @@ -147,9 +148,19 @@ describe('attachLoopToSession', () => { sectionPlansRepo, ) - const promptAsyncMock = mock(async () => ({ error: null })) + const promptAsyncMock = mock(async () => {}) const tuiSelectSessionMock = mock(async () => undefined) + const fakeClient = createFakeForgeClient({ + session: { + create: async () => ({ id: 'new-session' }), + promptAsync: promptAsyncMock, + }, + tui: { + selectSession: tuiSelectSessionMock, + }, + }) + const deps = { projectId: PROJECT_ID, directory: '/tmp/test', @@ -160,22 +171,7 @@ describe('attachLoopToSession', () => { }, logger: { log: () => {}, error: () => {}, debug: () => {} } as Logger, dataDir: '/tmp', - v2: { - session: { - create: mock(async () => ({ data: { id: 'new-session' } })), - get: mock(async () => ({ data: {} })), - update: mock(async () => ({ data: {} })), - promptAsync: promptAsyncMock, - abort: mock(async () => ({})), - delete: mock(async () => ({})), - messages: mock(async () => ({ data: [] })), - status: mock(async () => ({ data: {} })), - }, - tui: { - publish: mock(() => {}), - selectSession: tuiSelectSessionMock, - }, - }, + client: fakeClient.client, plansRepo, loopsRepo, reviewFindingsRepo, @@ -195,7 +191,7 @@ describe('attachLoopToSession', () => { }, } - return { deps, loopsRepo, plansRepo, sectionPlansRepo, loopService, promptAsyncMock, tuiSelectSessionMock } + return { deps, loopsRepo, plansRepo, sectionPlansRepo, loopService, promptAsyncMock, tuiSelectSessionMock, fakeClient } } test('disabled mode persists state and sends code-agent prompt', async () => { @@ -287,8 +283,8 @@ describe('attachLoopToSession', () => { test('prompt failure returns ok:false and cleans up state', async () => { const { deps, loopsRepo, promptAsyncMock } = buildDeps() - // Make promptAsync return an error - promptAsyncMock.mockImplementationOnce(async () => ({ error: new Error('network timeout') })) + // Make promptAsync throw an error (port contract: throws on failure) + promptAsyncMock.mockImplementationOnce(async () => { throw new Error('network timeout') }) const { attachLoopToSession } = await import('../../src/services/execution') @@ -650,8 +646,7 @@ describe('attachLoopToSession', () => { test('attachLoopToSession does NOT call session.update for permission repair on any surface', async () => { const surfaces: Array<'tui' | 'tool' | 'approval-hook'> = ['tui', 'tool', 'approval-hook'] for (const surface of surfaces) { - const { deps } = buildDeps() - const sessionUpdateMock = deps.v2.session.update + const { deps, fakeClient } = buildDeps() const { attachLoopToSession } = await import('../../src/services/execution') await attachLoopToSession( deps as any, @@ -675,7 +670,7 @@ describe('attachLoopToSession', () => { }, ) // Assert: session.update was NEVER called (no permission repair) - expect(sessionUpdateMock).not.toHaveBeenCalled() + expect(fakeClient.client.session.update).not.toHaveBeenCalled() } }) diff --git a/test/services/execution-attach-cleanup.test.ts b/test/services/execution-attach-cleanup.test.ts index a9c0eb4d08..1f02ec6225 100644 --- a/test/services/execution-attach-cleanup.test.ts +++ b/test/services/execution-attach-cleanup.test.ts @@ -10,6 +10,7 @@ import { createSectionPlansRepo } from '../../src/storage/repos/section-plans-re import { createLoopService } from '../../src/loop/service' import type { Logger } from '../../src/types' import { setupLoopsTestDb } from '../helpers/loops-test-db' +import { createFakeForgeClient } from '../helpers/fake-client' const noopFn = () => {} @@ -48,8 +49,7 @@ describe('attachLoopToSession', () => { sectionPlansRepo, ) - const promptAsyncMock = vi.fn().mockResolvedValue({ error: null }) - const tuiSelectSessionMock = vi.fn().mockResolvedValue(undefined) + const { client } = createFakeForgeClient() const deps = { projectId: PROJECT_ID, @@ -61,22 +61,7 @@ describe('attachLoopToSession', () => { }, logger: { log: () => {}, error: () => {}, debug: () => {} } as Logger, dataDir: '/tmp', - v2: { - session: { - create: vi.fn().mockResolvedValue({ data: { id: 'new-session' } }), - get: vi.fn().mockResolvedValue({ data: {} }), - update: vi.fn().mockResolvedValue({ data: {} }), - promptAsync: promptAsyncMock, - abort: vi.fn().mockResolvedValue({}), - delete: vi.fn().mockResolvedValue({}), - messages: vi.fn().mockResolvedValue({ data: [] }), - status: vi.fn().mockResolvedValue({ data: {} }), - }, - tui: { - publish: vi.fn(), - selectSession: tuiSelectSessionMock, - }, - }, + client, plansRepo, loopsRepo, reviewFindingsRepo, diff --git a/test/services/execution-in-flight-guard.test.ts b/test/services/execution-in-flight-guard.test.ts index 557ceb7476..2ce0a343a8 100644 --- a/test/services/execution-in-flight-guard.test.ts +++ b/test/services/execution-in-flight-guard.test.ts @@ -20,6 +20,7 @@ import { } from '../../src/loop/in-flight-guard' import type { PromptAgent } from '../../src/loop/in-flight-guard' import { setupLoopsTestDb } from '../helpers/loops-test-db' +import { createFakeForgeClient } from '../helpers/fake-client' const noopFn = () => {} @@ -39,6 +40,19 @@ describe('execution in-flight guard', () => { debug: () => {}, } + const mockWorkspaceStatusRegistry = { + recordEvent: () => {}, + getStatus: () => 'connected' as const, + awaitConnected: async () => ({ connected: true, elapsedMs: 0, source: 'cached' as const }), + primeFromSnapshot: () => {}, + } + + const mockPendingTeardowns = { + set: () => {}, + get: () => undefined, + clear: () => {}, + } + beforeEach(() => { __resetInFlightGuard() tempDir = mkdtempSync(join(tmpdir(), 'exec-guard-test-')) @@ -100,23 +114,11 @@ describe('execution in-flight guard', () => { ], }) - const mockV2Client = { + const { client } = createFakeForgeClient({ session: { - create: async () => ({ data: { id: 'new-sess-999' } }), - get: async () => ({ data: {} }), - promptAsync: async () => ({ error: null, data: null }), - abort: async () => ({}), - delete: async () => ({}), - messages: async () => ({ data: [] }), - status: async () => ({ data: {} }), + create: async () => ({ id: 'new-sess-999' }), }, - experimental: { - workspace: { list: async () => ({ data: [] }), remove: async () => ({}) }, - session: { list: async () => ({ data: [] }) }, - }, - tui: { publish: async () => ({}), selectSession: async () => ({}) }, - worktree: { create: async () => ({ data: { directory: '/tmp/wt', branch: 'main' } }) }, - } + }) const loopService = createLoopService( loopsRepo, plansRepo, reviewFindingsRepo, PROJECT_ID, mockLogger, @@ -136,13 +138,15 @@ describe('execution in-flight guard', () => { config: { loop: { enabled: true }, executionModel: 'prov/exec', auditorModel: 'prov/aud' }, logger: mockLogger, dataDir: '/tmp', - v2: mockV2Client as any, plansRepo, loopsRepo, loop: loopService as any, loopHandler: mockLoopHandler as any, sectionPlansRepo, - } as any) + workspaceStatusRegistry: mockWorkspaceStatusRegistry, + pendingTeardowns: mockPendingTeardowns, + client, + }) markPromptInFlight('guard-loop', 'other-prompt-sess', 'code') @@ -217,29 +221,17 @@ describe('execution in-flight guard', () => { ], }) - const mockV2Client = { + const { client } = createFakeForgeClient({ session: { - create: async () => ({ data: { id: 'new-sess-888' } }), - get: async () => ({ data: {} }), + create: async () => ({ id: 'new-sess-888' }), promptAsync: async () => { promptCallCount++ if (promptCallCount <= 2) { - return { error: new Error('model unavailable'), data: undefined } + throw new Error('model unavailable') } - return { error: null, data: null } }, - abort: async () => ({}), - delete: async () => ({}), - messages: async () => ({ data: [] }), - status: async () => ({ data: {} }), }, - experimental: { - workspace: { list: async () => ({ data: [] }), remove: async () => ({}) }, - session: { list: async () => ({ data: [] }) }, - }, - tui: { publish: async () => ({}), selectSession: async () => ({}) }, - worktree: { create: async () => ({ data: { directory: '/tmp/wt', branch: 'main' } }) }, - } + }) const loopService = createLoopService( loopsRepo, plansRepo, reviewFindingsRepo, PROJECT_ID, mockLogger, @@ -259,13 +251,15 @@ describe('execution in-flight guard', () => { config: { loop: { enabled: true }, executionModel: 'prov/exec', auditorModel: 'prov/aud' }, logger: mockLogger, dataDir: '/tmp', - v2: mockV2Client as any, plansRepo, loopsRepo, loop: loopService as any, loopHandler: mockLoopHandler as any, sectionPlansRepo, - } as any) + workspaceStatusRegistry: mockWorkspaceStatusRegistry, + pendingTeardowns: mockPendingTeardowns, + client, + }) const result = await service.dispatch( { surface: 'api', projectId: PROJECT_ID, directory: '/tmp/test' }, @@ -324,29 +318,17 @@ describe('execution in-flight guard', () => { ], }) - const mockV2Client = { + const { client } = createFakeForgeClient({ session: { - create: async () => ({ data: { id: 'new-sess-777' } }), - get: async () => ({ data: {} }), + create: async () => ({ id: 'new-sess-777' }), promptAsync: async () => { promptCallCount++ if (promptCallCount === 1) { - return { error: new Error('model unavailable'), data: undefined } + throw new Error('model unavailable') } - return { error: null, data: null } }, - abort: async () => ({}), - delete: async () => ({}), - messages: async () => ({ data: [] }), - status: async () => ({ data: {} }), }, - experimental: { - workspace: { list: async () => ({ data: [] }), remove: async () => ({}) }, - session: { list: async () => ({ data: [] }) }, - }, - tui: { publish: async () => ({}), selectSession: async () => ({}) }, - worktree: { create: async () => ({ data: { directory: '/tmp/wt', branch: 'main' } }) }, - } + }) const loopService = createLoopService( loopsRepo, plansRepo, reviewFindingsRepo, PROJECT_ID, mockLogger, @@ -366,13 +348,15 @@ describe('execution in-flight guard', () => { config: { loop: { enabled: true }, executionModel: 'prov/exec', auditorModel: 'prov/aud' }, logger: mockLogger, dataDir: '/tmp', - v2: mockV2Client as any, plansRepo, loopsRepo, loop: loopService as any, loopHandler: mockLoopHandler as any, sectionPlansRepo, - } as any) + workspaceStatusRegistry: mockWorkspaceStatusRegistry, + pendingTeardowns: mockPendingTeardowns, + client, + }) const result = await service.dispatch( { surface: 'api', projectId: PROJECT_ID, directory: '/tmp/test' }, diff --git a/test/services/execution-restart.test.ts b/test/services/execution-restart.test.ts index 1f0e365ed6..06bce17b7a 100644 --- a/test/services/execution-restart.test.ts +++ b/test/services/execution-restart.test.ts @@ -16,6 +16,7 @@ import type { SectionPlansRepo } from '../../src/storage/repos/section-plans-rep import type { LoopService } from '../../src/loop/service' const Database = require('better-sqlite3') import { setupLoopsTestDb } from '../helpers/loops-test-db' +import { createFakeForgeClient } from '../helpers/fake-client' type Database = ReturnType const mockLogger: Logger = { @@ -173,23 +174,19 @@ describe('handleLoopRestart from stall_timeout', () => { generateUniqueLoopName: () => 'stall-loop', } - const mockV2Client = { + const { client } = createFakeForgeClient({ session: { - create: async () => ({ data: { id: 'new-session-123' } }), - get: async () => ({ data: {} }), - promptAsync: async () => ({}), - abort: async () => ({}), - delete: async () => ({}), - messages: async () => ({ data: [] }), - status: async () => ({ data: {} }), + create: async () => ({ id: 'new-session-123' }), + get: async () => ({}), + promptAsync: async () => {}, + abort: async () => {}, + delete: async () => {}, + messages: async () => [], + status: async () => ({}), }, - experimental: { - workspace: { list: async () => ({ data: [] }), remove: async () => ({}) }, - session: { list: async () => ({ data: [] }) }, - }, - tui: { publish: async () => ({}), selectSession: async () => ({}) }, - worktree: { create: async () => ({ data: { directory: '/tmp/wt', branch: 'main' } }) }, - } + workspace: { list: async () => [], remove: async () => {} }, + tui: { publish: async () => {}, selectSession: async () => {} }, + }) const mockLoopHandler = { runExclusive: async (name: string, fn: () => Promise) => fn(), @@ -209,13 +206,14 @@ describe('handleLoopRestart from stall_timeout', () => { }, logger: mockLogger, dataDir: '/tmp', - v2: mockV2Client as any, + plansRepo, loopsRepo, loop: mockLoopService as any, loopHandler: mockLoopHandler as any, sectionPlansRepo, workspaceStatusRegistry: mockWorkspaceStatusRegistry as any, + client, pendingTeardowns: mockPendingTeardowns as any, }) @@ -285,23 +283,19 @@ describe('handleLoopRestart from stall_timeout', () => { generateUniqueLoopName: () => 'iter-loop', } - const mockV2Client = { + const { client } = createFakeForgeClient({ session: { - create: async () => ({ data: { id: 'new-sess-456' } }), - get: async () => ({ data: {} }), - promptAsync: async () => ({}), - abort: async () => ({}), - delete: async () => ({}), - messages: async () => ({ data: [] }), - status: async () => ({ data: {} }), - }, - experimental: { - workspace: { list: async () => ({ data: [] }), remove: async () => ({}) }, - session: { list: async () => ({ data: [] }) }, + create: async () => ({ id: 'new-sess-456' }), + get: async () => ({}), + promptAsync: async () => {}, + abort: async () => {}, + delete: async () => {}, + messages: async () => [], + status: async () => ({}), }, - tui: { publish: async () => ({}), selectSession: async () => ({}) }, - worktree: { create: async () => ({ data: { directory: '/tmp/wt', branch: 'main' } }) }, - } + workspace: { list: async () => [], remove: async () => {} }, + tui: { publish: async () => {}, selectSession: async () => {} }, + }) const mockLoopHandler = { runExclusive: async (name: string, fn: () => Promise) => fn(), @@ -321,13 +315,14 @@ describe('handleLoopRestart from stall_timeout', () => { }, logger: mockLogger, dataDir: '/tmp', - v2: mockV2Client as any, + plansRepo, loopsRepo, loop: mockLoopService as any, loopHandler: mockLoopHandler as any, sectionPlansRepo, workspaceStatusRegistry: mockWorkspaceStatusRegistry as any, + client, pendingTeardowns: mockPendingTeardowns as any, }) @@ -375,32 +370,26 @@ describe('handleLoopRestart from stall_timeout', () => { generateUniqueLoopName: () => 'worktree-loop', } - const workspaceCreate = vi.fn().mockResolvedValue({ - data: { id: 'ws_new', directory: '/tmp', branch: 'forge/worktree-loop' }, - }) - const mockV2Client = { + const { client } = createFakeForgeClient({ session: { - create: async () => ({ data: { id: 'new-sess-worktree' } }), - get: async () => ({ data: {} }), - promptAsync: async () => ({}), - abort: async () => ({}), - delete: async () => ({}), - messages: async () => ({ data: [] }), - status: async () => ({ data: {} }), + create: async () => ({ id: 'new-sess-worktree' }), + get: async () => ({}), + promptAsync: async () => {}, + abort: async () => {}, + delete: async () => {}, + messages: async () => [], + status: async () => ({}), }, - experimental: { - workspace: { - list: async () => ({ data: [] }), - remove: async () => ({}), - create: workspaceCreate, - warp: async () => ({}), - syncList: async () => ({}), - }, - session: { list: async () => ({ data: [] }) }, + workspace: { + create: async () => ({ id: 'ws_new', directory: '/tmp', branch: 'forge/worktree-loop' }), + list: async () => [], + remove: async () => {}, + warp: async () => {}, + syncList: async () => {}, }, - tui: { publish: async () => ({}), selectSession: async () => ({}) }, - sync: { start: async () => ({}) }, - } + tui: { publish: async () => {}, selectSession: async () => {} }, + sync: { start: async () => {} }, + }) const mockLoopHandler = { runExclusive: async (name: string, fn: () => Promise) => fn(), @@ -415,13 +404,14 @@ describe('handleLoopRestart from stall_timeout', () => { config: { loop: { enabled: true }, executionModel: 'prov/exec', auditorModel: 'prov/aud' }, logger: mockLogger, dataDir: '/tmp', - v2: mockV2Client as any, + plansRepo, loopsRepo, loop: mockLoopService as any, loopHandler: mockLoopHandler as any, sectionPlansRepo, workspaceStatusRegistry: mockWorkspaceStatusRegistry as any, + client, pendingTeardowns: mockPendingTeardowns as any, }) @@ -431,7 +421,7 @@ describe('handleLoopRestart from stall_timeout', () => { ) expect(result.ok).toBe(true) - expect(workspaceCreate).toHaveBeenCalledWith({ + expect(client.workspace.create).toHaveBeenCalledWith({ type: 'forge', branch: null, extra: { @@ -489,23 +479,19 @@ describe('handleLoopRestart from stall_timeout', () => { generateUniqueLoopName: () => 'final-audit-loop', } - const mockV2Client = { + const { client } = createFakeForgeClient({ session: { - create: async () => ({ data: { id: 'new-sess-789' } }), - get: async () => ({ data: {} }), - promptAsync: async () => ({}), - abort: async () => ({}), - delete: async () => ({}), - messages: async () => ({ data: [] }), - status: async () => ({ data: {} }), + create: async () => ({ id: 'new-sess-789' }), + get: async () => ({}), + promptAsync: async () => {}, + abort: async () => {}, + delete: async () => {}, + messages: async () => [], + status: async () => ({}), }, - experimental: { - workspace: { list: async () => ({ data: [] }), remove: async () => ({}) }, - session: { list: async () => ({ data: [] }) }, - }, - tui: { publish: async () => ({}), selectSession: async () => ({}) }, - worktree: { create: async () => ({ data: { directory: '/tmp/wt', branch: 'main' } }) }, - } + workspace: { list: async () => [], remove: async () => {} }, + tui: { publish: async () => {}, selectSession: async () => {} }, + }) const mockLoopHandler = { runExclusive: async (name: string, fn: () => Promise) => fn(), @@ -525,13 +511,14 @@ describe('handleLoopRestart from stall_timeout', () => { }, logger: mockLogger, dataDir: '/tmp', - v2: mockV2Client as any, + plansRepo, loopsRepo, loop: mockLoopService as any, loopHandler: mockLoopHandler as any, sectionPlansRepo, workspaceStatusRegistry: mockWorkspaceStatusRegistry as any, + client, pendingTeardowns: mockPendingTeardowns as any, }) @@ -565,23 +552,19 @@ describe('handleLoopRestart from stall_timeout', () => { const noopFn = () => {} - const mockV2Client = { + const { client } = createFakeForgeClient({ session: { - create: async () => ({ data: { id: 'race-new-session' } }), - get: async () => ({ data: {} }), - promptAsync: async () => ({}), - abort: async () => ({}), - delete: async () => ({}), - messages: async () => ({ data: [] }), - status: async () => ({ data: {} }), + create: async () => ({ id: 'race-new-session' }), + get: async () => ({}), + promptAsync: async () => {}, + abort: async () => {}, + delete: async () => {}, + messages: async () => [], + status: async () => ({}), }, - experimental: { - workspace: { list: async () => ({ data: [] }), remove: async () => ({}) }, - session: { list: async () => ({ data: [] }) }, - }, - tui: { publish: async () => ({}), selectSession: async () => ({}) }, - worktree: { create: async () => ({ data: { directory: '/tmp/wt', branch: 'main' } }) }, - } + workspace: { list: async () => [], remove: async () => {} }, + tui: { publish: async () => {}, selectSession: async () => {} }, + }) const mockLoopService: Partial = { listActive: () => loopService.listActive(), @@ -627,13 +610,14 @@ describe('handleLoopRestart from stall_timeout', () => { }, logger: mockLogger, dataDir: '/tmp', - v2: mockV2Client as any, + plansRepo, loopsRepo, loop: mockLoopService as any, loopHandler: mockLoopHandler as any, sectionPlansRepo, workspaceStatusRegistry: mockWorkspaceStatusRegistry as any, + client, pendingTeardowns: mockPendingTeardowns as any, }) @@ -667,36 +651,19 @@ describe('handleLoopRestart from stall_timeout', () => { const noopFn = () => {} - const createCalls: Array> = [] - const promptAsyncCalls: Array> = [] - const abortCalls: Array> = [] - - const mockV2Client = { + const { client } = createFakeForgeClient({ session: { - create: async (args: Record) => { - createCalls.push(args) - return { data: { id: 'new-code-session' } } - }, - get: async () => ({ data: {} }), - promptAsync: async (args: Record) => { - promptAsyncCalls.push(args) - return {} - }, - abort: async (args: Record) => { - abortCalls.push(args) - return {} - }, - delete: async () => ({}), - messages: async () => ({ data: [] }), - status: async () => ({ data: {} }), - }, - experimental: { - workspace: { list: async () => ({ data: [] }), remove: async () => ({}) }, - session: { list: async () => ({ data: [] }) }, + create: async () => ({ id: 'new-code-session' }), + get: async () => ({}), + promptAsync: async () => {}, + abort: async () => {}, + delete: async () => {}, + messages: async () => [], + status: async () => ({}), }, - tui: { publish: async () => ({}), selectSession: async () => ({}) }, - worktree: { create: async () => ({ data: { directory: '/tmp/wt', branch: 'main' } }) }, - } + workspace: { list: async () => [], remove: async () => {} }, + tui: { publish: async () => {}, selectSession: async () => {} }, + }) let capturedResolvedNew: string | null | undefined let capturedResolvedOld: string | null | undefined @@ -740,13 +707,14 @@ describe('handleLoopRestart from stall_timeout', () => { }, logger: mockLogger, dataDir: '/tmp', - v2: mockV2Client as any, + plansRepo, loopsRepo, loop: mockLoopService as any, loopHandler: mockLoopHandler as any, sectionPlansRepo, workspaceStatusRegistry: mockWorkspaceStatusRegistry as any, + client, pendingTeardowns: mockPendingTeardowns as any, }) @@ -762,12 +730,12 @@ describe('handleLoopRestart from stall_timeout', () => { expect(result.ok).toBe(true) // Exactly one session create and one prompt dispatch - expect(createCalls.length).toBe(1) - expect(promptAsyncCalls.length).toBe(1) + expect((client.session.create as any).mock.calls).toHaveLength(1) + expect((client.session.promptAsync as any).mock.calls).toHaveLength(1) // Prompt sent with code agent to new session - expect(promptAsyncCalls[0].agent).toBe('code') - expect(promptAsyncCalls[0].sessionID).toBe('new-code-session') + expect((client.session.promptAsync as any).mock.calls[0][0].agent).toBe('code') + expect((client.session.promptAsync as any).mock.calls[0][0].sessionID).toBe('new-code-session') // At the moment runExclusive released, new session is registered and old is gone expect(capturedResolvedNew).toBe('audit-restart-loop') @@ -775,8 +743,8 @@ describe('handleLoopRestart from stall_timeout', () => { expect(capturedPhase).toBe('coding') // Exactly one abort for the old session - expect(abortCalls.length).toBe(1) - expect(abortCalls[0].sessionID).toBe('session-old') + expect((client.session.abort as any).mock.calls).toHaveLength(1) + expect((client.session.abort as any).mock.calls[0][0].sessionID).toBe('session-old') }) }) @@ -895,34 +863,25 @@ describe('handleLoopRestart restartability rules', () => { generateUniqueLoopName: (name) => name, } - const sessionCreateSpy = vi.fn().mockResolvedValue({ data: { id: 'new-session-restart' } }) - const sessionPromptAsyncSpy = vi.fn().mockResolvedValue({}) - const workspaceCreateSpy = vi.fn().mockResolvedValue({ data: { id: 'ws-new', directory: '/tmp', branch: 'main' } }) - const tuiSelectSessionSpy = vi.fn().mockResolvedValue({}) - - const mockV2Client = { + const { client } = createFakeForgeClient({ session: { - create: sessionCreateSpy, - get: async () => ({ data: {} }), - promptAsync: sessionPromptAsyncSpy, - abort: async () => ({}), - delete: async () => ({}), - messages: async () => ({ data: [] }), - status: async () => ({ data: {} }), + create: async () => ({ id: 'new-session-restart' }), + get: async () => ({}), + promptAsync: async () => {}, + abort: async () => {}, + delete: async () => {}, + messages: async () => [], + status: async () => ({}), }, - experimental: { - workspace: { - list: async () => ({ data: [] }), - remove: async () => ({}), - create: workspaceCreateSpy, - warp: async () => ({}), - syncList: async () => ({}), - }, - session: { list: async () => ({ data: [] }) }, + workspace: { + create: async () => ({ id: 'ws-new', directory: '/tmp', branch: 'main' }), + list: async () => [], + remove: async () => {}, + warp: async () => {}, + syncList: async () => {}, }, - tui: { publish: async () => ({}), selectSession: tuiSelectSessionSpy }, - worktree: { create: async () => ({ data: { directory: '/tmp/wt', branch: 'main' } }) }, - } + tui: { publish: async () => {}, selectSession: async () => {} }, + }) const mockLoopHandler = { runExclusive: async (name: string, fn: () => Promise) => fn(), @@ -951,7 +910,7 @@ describe('handleLoopRestart restartability rules', () => { }, logger: mockLogger, dataDir: '/tmp', - v2: mockV2Client as any, + plansRepo, loopsRepo, loop: mockLoopService as any, @@ -959,15 +918,13 @@ describe('handleLoopRestart restartability rules', () => { sectionPlansRepo, workspaceStatusRegistry: mockWorkspaceStatusRegistry as any, pendingTeardowns: mockPendingTeardowns as any, + client, sandboxManager: opts?.sandboxManager as any, }) return { service, - sessionCreateSpy, - sessionPromptAsyncSpy, - workspaceCreateSpy, - tuiSelectSessionSpy, + client, } } @@ -1021,7 +978,7 @@ describe('handleLoopRestart restartability rules', () => { phase: 'coding', }) - const { service, sessionCreateSpy, sessionPromptAsyncSpy } = await createMockService() + const { service, client } = await createMockService() const result = await service.dispatch( { surface: 'api', projectId: PROJECT_ID, directory: '/tmp/test' }, { @@ -1034,8 +991,8 @@ describe('handleLoopRestart restartability rules', () => { if (result.ok) return expect(result.error.message).toContain('completed successfully and cannot be restarted') - expect(sessionCreateSpy).not.toHaveBeenCalled() - expect(sessionPromptAsyncSpy).not.toHaveBeenCalled() + expect(client.session.create).not.toHaveBeenCalled() + expect(client.session.promptAsync).not.toHaveBeenCalled() const newState = loopService.getActiveState('completed-loop') expect(newState).toBeNull() @@ -1049,7 +1006,7 @@ describe('handleLoopRestart restartability rules', () => { phase: 'coding', }) - const { service, sessionCreateSpy, sessionPromptAsyncSpy } = await createMockService() + const { service, client } = await createMockService() const result = await service.dispatch( { surface: 'api', projectId: PROJECT_ID, directory: '/tmp/test' }, { @@ -1062,8 +1019,8 @@ describe('handleLoopRestart restartability rules', () => { if (result.ok) return expect(result.error.message).toContain('completed successfully and cannot be restarted') - expect(sessionCreateSpy).not.toHaveBeenCalled() - expect(sessionPromptAsyncSpy).not.toHaveBeenCalled() + expect(client.session.create).not.toHaveBeenCalled() + expect(client.session.promptAsync).not.toHaveBeenCalled() const newState = loopService.getActiveState('completed-loop-null-reason') expect(newState).toBeNull() @@ -1080,7 +1037,7 @@ describe('handleLoopRestart restartability rules', () => { phase: 'coding', }) - const { service, sessionCreateSpy, sessionPromptAsyncSpy, workspaceCreateSpy } = await createMockService() + const { service, client } = await createMockService() const result = await service.dispatch( { surface: 'api', projectId: PROJECT_ID, directory: '/tmp/test' }, { @@ -1093,9 +1050,9 @@ describe('handleLoopRestart restartability rules', () => { if (result.ok) return expect(result.error.message).toContain('worktree directory no longer exists') - expect(sessionCreateSpy).not.toHaveBeenCalled() - expect(sessionPromptAsyncSpy).not.toHaveBeenCalled() - expect(workspaceCreateSpy).not.toHaveBeenCalled() + expect(client.session.create).not.toHaveBeenCalled() + expect(client.session.promptAsync).not.toHaveBeenCalled() + expect(client.workspace.create).not.toHaveBeenCalled() const newState = loopService.getActiveState('missing-worktree-loop') expect(newState).toBeNull() @@ -1121,7 +1078,7 @@ describe('handleLoopRestart restartability rules', () => { phase: 'coding', }) - const { service, sessionCreateSpy, workspaceCreateSpy, tuiSelectSessionSpy } = await createMockService() + const { service, client } = await createMockService() const result = await service.dispatch( { surface: 'api', projectId: PROJECT_ID, directory: repoDir }, { @@ -1134,11 +1091,11 @@ describe('handleLoopRestart restartability rules', () => { if (!result.ok) return // Restart proceeded: a fresh worktree workspace was requested and a new code session created. - expect(workspaceCreateSpy).toHaveBeenCalled() - expect(sessionCreateSpy).toHaveBeenCalled() + expect(client.workspace.create).toHaveBeenCalled() + expect(client.session.create).toHaveBeenCalled() // TUI was navigated to the recreated workspace+session so it connects/focuses. - expect(tuiSelectSessionSpy).toHaveBeenCalledWith( + expect(client.tui.selectSession).toHaveBeenCalledWith( expect.objectContaining({ workspace: 'ws-new' }), ) @@ -1171,7 +1128,7 @@ describe('handleLoopRestart restartability rules', () => { docker: { containerName: () => 'forge-sandbox' }, } - const { service, workspaceCreateSpy } = await createMockService({ sandboxManager }) + const { service, client } = await createMockService({ sandboxManager }) const result = await service.dispatch( { surface: 'api', projectId: PROJECT_ID, directory: repoDir }, { @@ -1187,9 +1144,9 @@ describe('handleLoopRestart restartability rules', () => { expect(sandboxStartSpy).toHaveBeenCalledWith(loopName, '/tmp') // And only after the worktree workspace was recreated. - expect(workspaceCreateSpy).toHaveBeenCalled() + expect(client.workspace.create).toHaveBeenCalled() expect(Math.min(...sandboxStartSpy.mock.invocationCallOrder)) - .toBeGreaterThan(Math.min(...workspaceCreateSpy.mock.invocationCallOrder)) + .toBeGreaterThan(Math.min(...(client.workspace.create as any).mock.invocationCallOrder)) }) test('retries a transient "Session not found" on restart prompt instead of rolling back', async () => { @@ -1202,16 +1159,16 @@ describe('handleLoopRestart restartability rules', () => { phase: 'coding', }) - const { service, sessionPromptAsyncSpy } = await createMockService() + const { service, client } = await createMockService() // First outer attempt (2 model tries + 1 fallback) fails with a transient // not-found; the next attempt after backoff succeeds. let calls = 0 - sessionPromptAsyncSpy.mockImplementation(async () => { + ;(client.session.promptAsync as any).mockImplementation(async () => { calls += 1 if (calls <= 3) { - return { error: { name: 'NotFoundError', data: { message: `Session not found: ses_x` } } } + throw { name: 'NotFoundError', data: { message: `Session not found: ses_x` } } } - return {} + // success - return undefined }) const result = await service.dispatch( @@ -1241,9 +1198,9 @@ describe('handleLoopRestart restartability rules', () => { phase: 'coding', }) - const { service, sessionPromptAsyncSpy } = await createMockService() - sessionPromptAsyncSpy.mockResolvedValue({ - error: { name: 'NotFoundError', data: { message: 'Session not found: ses_x' } }, + const { service, client } = await createMockService() + ;(client.session.promptAsync as any).mockImplementation(async () => { + throw { name: 'NotFoundError', data: { message: 'Session not found: ses_x' } } }) const result = await service.dispatch( diff --git a/test/services/execution.start-loop.test.ts b/test/services/execution.start-loop.test.ts index 34b36f48b4..dec123797f 100644 --- a/test/services/execution.start-loop.test.ts +++ b/test/services/execution.start-loop.test.ts @@ -16,6 +16,8 @@ import type { ReviewFindingsRepo } from '../../src/storage/repos/review-findings import type { SectionPlansRepo } from '../../src/storage/repos/section-plans-repo' import type { LoopService } from '../../src/loop/service' import { setupLoopsTestDb } from '../helpers/loops-test-db' +import { createFakeForgeClient } from '../helpers/fake-client' +import { ForgeClientError } from '../../src/client/port' const mockLogger: Logger = { log: () => {}, @@ -30,6 +32,12 @@ const mockWorkspaceStatusRegistry = { primeFromSnapshot: vi.fn(), } +const mockPendingTeardowns = { + set: vi.fn(), + get: vi.fn().mockReturnValue(undefined), + clear: vi.fn(), +} + const PROJECT_ID = 'test-project' describe('handleStartLoop builtin worktree workspace', () => { @@ -59,62 +67,33 @@ describe('handleStartLoop builtin worktree workspace', () => { mockLogger, undefined, undefined, - undefined, sectionPlansRepo, ) }) test('creates builtin worktree workspace and session bound to it for mode=worktree', async () => { - const experimentalWorkspaceCreateMock = vi.fn().mockResolvedValue({ - data: { - id: 'ws_test', - directory: '/tmp/wt/abc', - branch: 'opencode/abc', - type: 'worktree', - name: 'opencode/abc', - extra: null, - projectID: PROJECT_ID, - timeUsed: Date.now(), + const { client } = createFakeForgeClient({ + workspace: { + create: async () => ({ + id: 'ws_test', + directory: '/tmp/wt/abc', + branch: 'opencode/abc', + type: 'worktree', + name: 'opencode/abc', + extra: null, + projectID: PROJECT_ID, + timeUsed: Date.now(), + }), + warp: async () => {}, }, - }) - const experimentalWorkspaceWarpMock = vi.fn().mockResolvedValue({}) - const sessionCreateMock = vi.fn().mockResolvedValue({ - data: { id: 'session_test' }, - }) - const sessionGetMock = vi.fn().mockResolvedValue({ data: {} }) - const tuiSelectSessionMock = vi.fn().mockResolvedValue({}) - const worktreeCreateMock = vi.fn().mockResolvedValue({ - data: { directory: '/tmp/wt/abc', branch: 'opencode/abc' }, - }) - - const mockV2Client = { session: { - create: sessionCreateMock, - get: sessionGetMock, - promptAsync: async () => ({ error: null }), - abort: async () => ({}), - delete: async () => ({}), - messages: async () => ({ data: [] }), - status: async () => ({ data: {} }), - }, - experimental: { - workspace: { - create: experimentalWorkspaceCreateMock, - warp: experimentalWorkspaceWarpMock, - remove: vi.fn().mockResolvedValue({}), - list: vi.fn().mockResolvedValue({ data: [] }), - status: vi.fn().mockResolvedValue({ data: {} }), - }, + create: async () => ({ id: 'session_test' }), + get: async () => ({}), }, tui: { - publish: async () => {}, - selectSession: tuiSelectSessionMock, + selectSession: async () => {}, }, - worktree: { - create: worktreeCreateMock, - remove: async () => {}, - }, - } + }) const mockLoopHandler = { runExclusive: async (name: string, fn: () => Promise) => fn(), @@ -147,7 +126,6 @@ describe('handleStartLoop builtin worktree workspace', () => { }, logger: mockLogger, dataDir: '/tmp', - v2: mockV2Client as any, plansRepo, loopsRepo, loop: loopService as any, @@ -155,6 +133,8 @@ describe('handleStartLoop builtin worktree workspace', () => { sectionPlansRepo, sandboxManager: mockSandboxManager as any, workspaceStatusRegistry: mockWorkspaceStatusRegistry, + client, + pendingTeardowns: mockPendingTeardowns, }) const result = await service.dispatch( @@ -168,9 +148,9 @@ describe('handleStartLoop builtin worktree workspace', () => { expect(result.ok).toBe(true) - // Assert: experimental.workspace.create was called (builtin worktree path) - expect(experimentalWorkspaceCreateMock).toHaveBeenCalledTimes(1) - expect(experimentalWorkspaceCreateMock).toHaveBeenCalledWith( + // Assert: workspace.create was called (builtin worktree path) + expect(client.workspace.create).toHaveBeenCalledTimes(1) + expect(client.workspace.create).toHaveBeenCalledWith( expect.objectContaining({ type: 'forge', branch: null, @@ -182,20 +162,17 @@ describe('handleStartLoop builtin worktree workspace', () => { }), ) - // Assert: old v2.worktree.create was NOT called - expect(worktreeCreateMock).not.toHaveBeenCalled() - // Assert: session was created with correct directory and workspaceId - expect(sessionCreateMock).toHaveBeenCalledTimes(1) - const sessionCallArgs = sessionCreateMock.mock.calls[0][0] + expect(client.session.create).toHaveBeenCalledTimes(1) + const sessionCallArgs = (client.session.create as any).mock.calls[0][0] expect(sessionCallArgs.directory).toBe('/tmp/wt/abc') expect(sessionCallArgs.workspaceID).toBe('ws_test') // Assert: warp was called - expect(experimentalWorkspaceWarpMock).toHaveBeenCalledTimes(1) - expect(tuiSelectSessionMock).toHaveBeenCalledWith({ sessionID: 'session_test', workspace: 'ws_test' }) - expect(experimentalWorkspaceWarpMock.mock.invocationCallOrder[0]).toBeLessThan( - tuiSelectSessionMock.mock.invocationCallOrder[0], + expect(client.workspace.warp).toHaveBeenCalledTimes(1) + expect(client.tui.selectSession).toHaveBeenCalledWith({ sessionID: 'session_test', workspace: 'ws_test' }) + expect((client.workspace.warp as any).mock.invocationCallOrder[0]).toBeLessThan( + (client.tui.selectSession as any).mock.invocationCallOrder[0], ) // Assert: loops state has workspace info @@ -218,48 +195,21 @@ describe('handleStartLoop builtin worktree workspace', () => { }) test('worktree loop succeeds without sandbox manager (worktree-only mode)', async () => { - const experimentalWorkspaceCreateMock = vi.fn().mockResolvedValue({ - data: { - id: 'ws_test', - directory: '/tmp/wt/abc', - branch: 'opencode/abc', - type: 'worktree', - name: 'opencode/abc', - extra: null, - projectID: PROJECT_ID, - timeUsed: Date.now(), + const { client } = createFakeForgeClient({ + workspace: { + create: async () => ({ + id: 'ws_test', + directory: '/tmp/wt/abc', + branch: 'opencode/abc', + type: 'worktree', + name: 'opencode/abc', + extra: null, + projectID: PROJECT_ID, + timeUsed: Date.now(), + }), }, }) - const mockV2Client = { - session: { - create: vi.fn().mockResolvedValue({ data: { id: 'session_test' } }), - get: vi.fn().mockResolvedValue({ data: {} }), - promptAsync: async () => ({ error: null }), - abort: async () => ({}), - delete: async () => ({}), - messages: async () => ({ data: [] }), - status: async () => ({ data: {} }), - }, - experimental: { - workspace: { - create: experimentalWorkspaceCreateMock, - warp: vi.fn().mockResolvedValue({}), - remove: vi.fn().mockResolvedValue({}), - list: vi.fn().mockResolvedValue({ data: [] }), - status: vi.fn().mockResolvedValue({ data: {} }), - }, - }, - tui: { - publish: async () => {}, - selectSession: vi.fn().mockResolvedValue({}), - }, - worktree: { - create: vi.fn().mockResolvedValue({ data: { directory: '/tmp/wt/abc', branch: 'opencode/abc' } }), - remove: async () => {}, - }, - } - const mockLoopHandler = { runExclusive: async (name: string, fn: () => Promise) => fn(), startWatchdog: noopFn, @@ -278,7 +228,6 @@ describe('handleStartLoop builtin worktree workspace', () => { }, logger: mockLogger, dataDir: '/tmp', - v2: mockV2Client as any, plansRepo, loopsRepo, loop: loopService as any, @@ -286,6 +235,8 @@ describe('handleStartLoop builtin worktree workspace', () => { sectionPlansRepo, // No sandboxManager passed — simulates Docker not available workspaceStatusRegistry: mockWorkspaceStatusRegistry, + client, + pendingTeardowns: mockPendingTeardowns, }) const result = await service.dispatch( @@ -309,48 +260,23 @@ describe('handleStartLoop builtin worktree workspace', () => { }) test('passes buildLoopPermissionRuleset() to session.create regardless of surface', async () => { - const sessionCreateMock = vi.fn().mockResolvedValue({ data: { id: 'sess-1' } }) - const experimentalWorkspaceCreateMock = vi.fn().mockResolvedValue({ - data: { - id: 'ws_test', - directory: '/tmp/wt/abc', - branch: 'opencode/abc', - type: 'worktree', - name: 'opencode/abc', - extra: null, - projectID: PROJECT_ID, - timeUsed: Date.now(), + const { client } = createFakeForgeClient({ + workspace: { + create: async () => ({ + id: 'ws_test', + directory: '/tmp/wt/abc', + branch: 'opencode/abc', + type: 'worktree', + name: 'opencode/abc', + extra: null, + projectID: PROJECT_ID, + timeUsed: Date.now(), + }), }, - }) - - const mockV2Client = { session: { - create: sessionCreateMock, - get: vi.fn().mockResolvedValue({ data: {} }), - promptAsync: async () => ({ error: null }), - abort: async () => ({}), - delete: async () => ({}), - messages: async () => ({ data: [] }), - status: async () => ({ data: {} }), - }, - experimental: { - workspace: { - create: experimentalWorkspaceCreateMock, - warp: vi.fn().mockResolvedValue({}), - remove: vi.fn().mockResolvedValue({}), - list: vi.fn().mockResolvedValue({ data: [] }), - status: vi.fn().mockResolvedValue({ data: {} }), - }, + create: async () => ({ id: 'sess-1' }), }, - tui: { - publish: async () => {}, - selectSession: vi.fn().mockResolvedValue({}), - }, - worktree: { - create: vi.fn().mockResolvedValue({ data: { directory: '/tmp/wt/abc', branch: 'opencode/abc' } }), - remove: async () => {}, - }, - } + }) const mockLoopHandler = { runExclusive: async (name: string, fn: () => Promise) => fn(), @@ -383,7 +309,6 @@ describe('handleStartLoop builtin worktree workspace', () => { }, logger: mockLogger, dataDir: '/tmp', - v2: mockV2Client as any, plansRepo, loopsRepo, loop: loopService as any, @@ -391,10 +316,12 @@ describe('handleStartLoop builtin worktree workspace', () => { sectionPlansRepo, sandboxManager: mockSandboxManager as any, workspaceStatusRegistry: mockWorkspaceStatusRegistry, + client, + pendingTeardowns: mockPendingTeardowns, }) for (const surface of ['tool', 'approval-hook'] as const) { - sessionCreateMock.mockClear() + (client.session.create as any).mockClear() await service.dispatch( { surface, projectId: PROJECT_ID, directory: '/tmp/test' }, { @@ -403,7 +330,7 @@ describe('handleStartLoop builtin worktree workspace', () => { lifecycle: { selectSession: false }, }, ) - expect(sessionCreateMock).toHaveBeenCalledWith( + expect(client.session.create).toHaveBeenCalledWith( expect.objectContaining({ permission: buildLoopPermissionRuleset(), }), @@ -412,48 +339,21 @@ describe('handleStartLoop builtin worktree workspace', () => { }) test('fails and rolls back when sandbox manager present but start throws', async () => { - const experimentalWorkspaceCreateMock = vi.fn().mockResolvedValue({ - data: { - id: 'ws_test', - directory: '/tmp/wt/abc', - branch: 'opencode/abc', - type: 'worktree', - name: 'opencode/abc', - extra: null, - projectID: PROJECT_ID, - timeUsed: Date.now(), + const { client } = createFakeForgeClient({ + workspace: { + create: async () => ({ + id: 'ws_test', + directory: '/tmp/wt/abc', + branch: 'opencode/abc', + type: 'worktree', + name: 'opencode/abc', + extra: null, + projectID: PROJECT_ID, + timeUsed: Date.now(), + }), }, }) - const mockV2Client = { - session: { - create: vi.fn().mockResolvedValue({ data: { id: 'session_test' } }), - get: vi.fn().mockResolvedValue({ data: {} }), - promptAsync: vi.fn().mockResolvedValue({ error: null }), - abort: vi.fn().mockResolvedValue({}), - delete: vi.fn().mockResolvedValue({}), - messages: vi.fn().mockResolvedValue({ data: [] }), - status: vi.fn().mockResolvedValue({ data: {} }), - }, - experimental: { - workspace: { - create: experimentalWorkspaceCreateMock, - warp: vi.fn().mockResolvedValue({}), - remove: vi.fn().mockResolvedValue({}), - list: vi.fn().mockResolvedValue({ data: [] }), - status: vi.fn().mockResolvedValue({ data: {} }), - }, - }, - tui: { - publish: vi.fn().mockResolvedValue(undefined), - selectSession: vi.fn().mockResolvedValue({}), - }, - worktree: { - create: vi.fn().mockResolvedValue({ data: { directory: '/tmp/wt/abc', branch: 'opencode/abc' } }), - remove: vi.fn().mockResolvedValue(undefined), - }, - } - const mockLoopHandler = { runExclusive: async (name: string, fn: () => Promise) => fn(), startWatchdog: noopFn, @@ -485,7 +385,6 @@ describe('handleStartLoop builtin worktree workspace', () => { }, logger: mockLogger, dataDir: '/tmp', - v2: mockV2Client as any, plansRepo, loopsRepo, loop: loopService as any, @@ -493,6 +392,8 @@ describe('handleStartLoop builtin worktree workspace', () => { sectionPlansRepo, sandboxManager: mockSandboxManager as any, workspaceStatusRegistry: mockWorkspaceStatusRegistry, + client, + pendingTeardowns: mockPendingTeardowns, }) const result = await service.dispatch( @@ -512,8 +413,8 @@ describe('handleStartLoop builtin worktree workspace', () => { } // Verify rollback was invoked (session aborted, workspace removed) - expect(mockV2Client.session.abort).toHaveBeenCalled() - expect(mockV2Client.experimental.workspace.remove).toHaveBeenCalled() + expect(client.session.abort).toHaveBeenCalled() + expect(client.workspace.remove).toHaveBeenCalled() // Verify sandbox stop was called during rollback expect(mockSandboxManager.stop).toHaveBeenCalled() @@ -524,57 +425,29 @@ describe('handleStartLoop concurrent-start dedupe', () => { const noopFn = () => {} function buildDedupeMocks() { - const experimentalWorkspaceCreateMock = vi.fn().mockResolvedValue({ - data: { - id: 'ws_test', - directory: '/tmp/wt/abc', - branch: 'opencode/abc', - type: 'worktree', - name: 'opencode/abc', - extra: null, - projectID: PROJECT_ID, - timeUsed: Date.now(), - }, - }) - const experimentalWorkspaceWarpMock = vi.fn().mockResolvedValue({}) let sessionCounter = 0 - const sessionCreateMock = vi.fn().mockImplementation(async () => ({ - data: { id: `session_test_${++sessionCounter}` }, - })) - const sessionGetMock = vi.fn().mockResolvedValue({ data: {} }) - const tuiSelectSessionMock = vi.fn().mockResolvedValue({}) - const worktreeCreateMock = vi.fn().mockResolvedValue({ - data: { directory: '/tmp/wt/abc', branch: 'opencode/abc' }, - }) - - const mockV2Client = { - session: { - create: sessionCreateMock, - get: sessionGetMock, - promptAsync: async () => ({ error: null }), - abort: async () => ({}), - delete: async () => ({}), - messages: async () => ({ data: [] }), - status: async () => ({ data: {} }), + const { client } = createFakeForgeClient({ + workspace: { + create: async () => ({ + id: 'ws_test', + directory: '/tmp/wt/abc', + branch: 'opencode/abc', + type: 'worktree', + name: 'opencode/abc', + extra: null, + projectID: PROJECT_ID, + timeUsed: Date.now(), + }), + warp: async () => {}, }, - experimental: { - workspace: { - create: experimentalWorkspaceCreateMock, - warp: experimentalWorkspaceWarpMock, - remove: vi.fn().mockResolvedValue({}), - list: vi.fn().mockResolvedValue({ data: [] }), - status: vi.fn().mockResolvedValue({ data: {} }), - }, + session: { + create: async () => ({ id: `session_test_${++sessionCounter}` }), + get: async () => ({}), }, tui: { - publish: async () => {}, - selectSession: tuiSelectSessionMock, + selectSession: async () => {}, }, - worktree: { - create: worktreeCreateMock, - remove: async () => {}, - }, - } + }) const mockLoopHandler = { runExclusive: async (name: string, fn: () => Promise) => fn(), @@ -596,13 +469,9 @@ describe('handleStartLoop concurrent-start dedupe', () => { } return { - mockV2Client, + client, mockLoopHandler, mockSandboxManager, - experimentalWorkspaceCreateMock, - experimentalWorkspaceWarpMock, - sessionCreateMock, - tuiSelectSessionMock, } } @@ -614,17 +483,19 @@ describe('handleStartLoop concurrent-start dedupe', () => { const plansRepo = createPlansRepo(db) const reviewFindingsRepo = createReviewFindingsRepo(db) const sectionPlansRepo = createSectionPlansRepo(db) - const loopService = createLoopService(loopsRepo, plansRepo, reviewFindingsRepo, PROJECT_ID, mockLogger, undefined, undefined, undefined, sectionPlansRepo) + const loopService = createLoopService(loopsRepo, plansRepo, reviewFindingsRepo, PROJECT_ID, mockLogger, undefined, undefined, sectionPlansRepo) const mocks = buildDedupeMocks() const { createForgeExecutionService } = await import('../../src/services/execution') const service = createForgeExecutionService({ projectId: PROJECT_ID, directory: '/tmp/test', config: { loop: { enabled: true }, executionModel: 'prov/exec', auditorModel: 'prov/aud' }, - logger: mockLogger, dataDir: '/tmp', v2: mocks.mockV2Client as any, + logger: mockLogger, dataDir: '/tmp', plansRepo, loopsRepo, loop: loopService as any, loopHandler: mocks.mockLoopHandler as any, sectionPlansRepo, sandboxManager: mocks.mockSandboxManager as any, workspaceStatusRegistry: mockWorkspaceStatusRegistry, + client: mocks.client, + pendingTeardowns: mockPendingTeardowns, }) const ctx = { surface: 'api' as const, projectId: PROJECT_ID, directory: '/tmp/test' } @@ -642,9 +513,9 @@ describe('handleStartLoop concurrent-start dedupe', () => { ]) // With dedupe implemented: exactly 1 workspace/session/warp creation per concurrent batch - expect(mocks.experimentalWorkspaceCreateMock).toHaveBeenCalledTimes(1) - expect(mocks.sessionCreateMock).toHaveBeenCalledTimes(1) - expect(mocks.experimentalWorkspaceWarpMock).toHaveBeenCalledTimes(1) + expect(mocks.client.workspace.create).toHaveBeenCalledTimes(1) + expect(mocks.client.session.create).toHaveBeenCalledTimes(1) + expect(mocks.client.workspace.warp).toHaveBeenCalledTimes(1) expect(r1.ok).toBe(true) expect(r2.ok).toBe(true) @@ -668,17 +539,19 @@ describe('handleStartLoop concurrent-start dedupe', () => { const plansRepo = createPlansRepo(db) const reviewFindingsRepo = createReviewFindingsRepo(db) const sectionPlansRepo = createSectionPlansRepo(db) - const loopService = createLoopService(loopsRepo, plansRepo, reviewFindingsRepo, PROJECT_ID, mockLogger, undefined, undefined, undefined, sectionPlansRepo) + const loopService = createLoopService(loopsRepo, plansRepo, reviewFindingsRepo, PROJECT_ID, mockLogger, undefined, undefined, sectionPlansRepo) const mocks = buildDedupeMocks() const { createForgeExecutionService } = await import('../../src/services/execution') const service = createForgeExecutionService({ projectId: PROJECT_ID, directory: '/tmp/test', config: { loop: { enabled: true }, executionModel: 'prov/exec', auditorModel: 'prov/aud' }, - logger: mockLogger, dataDir: '/tmp', v2: mocks.mockV2Client as any, + logger: mockLogger, dataDir: '/tmp', plansRepo, loopsRepo, loop: loopService as any, loopHandler: mocks.mockLoopHandler as any, sectionPlansRepo, sandboxManager: mocks.mockSandboxManager as any, workspaceStatusRegistry: mockWorkspaceStatusRegistry, + client: mocks.client, + pendingTeardowns: mockPendingTeardowns, }) const ctx = { surface: 'api' as const, projectId: PROJECT_ID, directory: '/tmp/test' } @@ -703,8 +576,8 @@ describe('handleStartLoop concurrent-start dedupe', () => { ]) // Different sources: no dedupe; both proceed independently - expect(mocks.experimentalWorkspaceCreateMock).toHaveBeenCalledTimes(2) - expect(mocks.sessionCreateMock).toHaveBeenCalledTimes(2) + expect(mocks.client.workspace.create).toHaveBeenCalledTimes(2) + expect(mocks.client.session.create).toHaveBeenCalledTimes(2) expect(r1.ok).toBe(true) expect(r2.ok).toBe(true) @@ -722,17 +595,19 @@ describe('handleStartLoop concurrent-start dedupe', () => { const plansRepo = createPlansRepo(db) const reviewFindingsRepo = createReviewFindingsRepo(db) const sectionPlansRepo = createSectionPlansRepo(db) - const loopService = createLoopService(loopsRepo, plansRepo, reviewFindingsRepo, PROJECT_ID, mockLogger, undefined, undefined, undefined, sectionPlansRepo) + const loopService = createLoopService(loopsRepo, plansRepo, reviewFindingsRepo, PROJECT_ID, mockLogger, undefined, undefined, sectionPlansRepo) const mocks = buildDedupeMocks() const { createForgeExecutionService } = await import('../../src/services/execution') const service = createForgeExecutionService({ projectId: PROJECT_ID, directory: '/tmp/test', config: { loop: { enabled: true }, executionModel: 'prov/exec', auditorModel: 'prov/aud' }, - logger: mockLogger, dataDir: '/tmp', v2: mocks.mockV2Client as any, + logger: mockLogger, dataDir: '/tmp', plansRepo, loopsRepo, loop: loopService as any, loopHandler: mocks.mockLoopHandler as any, sectionPlansRepo, sandboxManager: mocks.mockSandboxManager as any, workspaceStatusRegistry: mockWorkspaceStatusRegistry, + client: mocks.client, + pendingTeardowns: mockPendingTeardowns, }) const ctx = { surface: 'api' as const, projectId: PROJECT_ID, directory: '/tmp/test' } @@ -748,8 +623,8 @@ describe('handleStartLoop concurrent-start dedupe', () => { const second = await service.dispatch(ctx, cmd) // Sequential: first completed, in-flight entry cleared, so no dedupe - expect(mocks.experimentalWorkspaceCreateMock).toHaveBeenCalledTimes(2) - expect(mocks.sessionCreateMock).toHaveBeenCalledTimes(2) + expect(mocks.client.workspace.create).toHaveBeenCalledTimes(2) + expect(mocks.client.session.create).toHaveBeenCalledTimes(2) expect(second.ok).toBe(true) db.close() @@ -760,17 +635,6 @@ describe('handleStartLoop select-session ordering', () => { const noopFn = () => {} function buildOrderingMocks() { - const experimentalWorkspaceCreateMock = vi.fn().mockResolvedValue({ - data: { - id: 'ws_test', directory: '/tmp/wt/abc', branch: 'opencode/abc', - type: 'worktree', name: 'opencode/abc', extra: null, - projectID: PROJECT_ID, timeUsed: Date.now(), - }, - }) - const experimentalWorkspaceWarpMock = vi.fn().mockResolvedValue({}) - const sessionCreateMock = vi.fn().mockResolvedValue({ data: { id: 'session_test' } }) - const sessionGetMock = vi.fn().mockResolvedValue({ data: {} }) - // Deferred pattern: control when selectSession resolves or rejects let resolveSelect!: (value?: unknown) => void let rejectSelect!: (reason?: unknown) => void @@ -778,29 +642,22 @@ describe('handleStartLoop select-session ordering', () => { resolveSelect = () => { resolve(); } rejectSelect = (reason) => { reject(reason); } }) - const tuiSelectSessionMock = vi.fn().mockImplementation(() => selectPromise) - const worktreeCreateMock = vi.fn().mockResolvedValue({ - data: { directory: '/tmp/wt/abc', branch: 'opencode/abc' }, - }) - - const mockV2Client = { + const { client } = createFakeForgeClient({ + workspace: { + create: async () => ({ + id: 'ws_test', directory: '/tmp/wt/abc', branch: 'opencode/abc', + }), + warp: async () => {}, + }, session: { - create: sessionCreateMock, get: sessionGetMock, - promptAsync: async () => ({ error: null }), - abort: async () => ({}), delete: async () => ({}), - messages: async () => ({ data: [] }), status: async () => ({ data: {} }), + create: async () => ({ id: 'session_test' }), + get: async () => ({}), }, - experimental: { - workspace: { - create: experimentalWorkspaceCreateMock, warp: experimentalWorkspaceWarpMock, - remove: vi.fn().mockResolvedValue({}), list: vi.fn().mockResolvedValue({ data: [] }), - status: vi.fn().mockResolvedValue({ data: {} }), - }, + tui: { + selectSession: async () => selectPromise, }, - tui: { publish: async () => {}, selectSession: tuiSelectSessionMock }, - worktree: { create: worktreeCreateMock, remove: async () => {} }, - } + }) const mockLoopHandler = { runExclusive: async (name: string, fn: () => Promise) => fn(), @@ -817,7 +674,7 @@ describe('handleStartLoop select-session ordering', () => { provisionDependencies: vi.fn().mockResolvedValue(undefined), } - return { mockV2Client, mockLoopHandler, mockSandboxManager, tuiSelectSessionMock, resolveSelect, rejectSelect, selectPromise } + return { client, mockLoopHandler, mockSandboxManager, resolveSelect, rejectSelect, selectPromise } } test('onStarted fires only after selectSessionWithFallback resolves', async () => { @@ -828,17 +685,19 @@ describe('handleStartLoop select-session ordering', () => { const plansRepo = createPlansRepo(db) const reviewFindingsRepo = createReviewFindingsRepo(db) const sectionPlansRepo = createSectionPlansRepo(db) - const loopService = createLoopService(loopsRepo, plansRepo, reviewFindingsRepo, PROJECT_ID, mockLogger, undefined, undefined, undefined, sectionPlansRepo) + const loopService = createLoopService(loopsRepo, plansRepo, reviewFindingsRepo, PROJECT_ID, mockLogger, undefined, undefined, sectionPlansRepo) const mocks = buildOrderingMocks() const { createForgeExecutionService } = await import('../../src/services/execution') const service = createForgeExecutionService({ projectId: PROJECT_ID, directory: '/tmp/test', config: { loop: { enabled: true }, executionModel: 'prov/exec', auditorModel: 'prov/aud' }, - logger: mockLogger, dataDir: '/tmp', v2: mocks.mockV2Client as any, + logger: mockLogger, dataDir: '/tmp', plansRepo, loopsRepo, loop: loopService as any, loopHandler: mocks.mockLoopHandler as any, sectionPlansRepo, sandboxManager: mocks.mockSandboxManager as any, workspaceStatusRegistry: mockWorkspaceStatusRegistry, + client: mocks.client, + pendingTeardowns: mockPendingTeardowns, }) let onStartedTs: number | null = null @@ -882,17 +741,19 @@ describe('handleStartLoop select-session ordering', () => { const plansRepo = createPlansRepo(db) const reviewFindingsRepo = createReviewFindingsRepo(db) const sectionPlansRepo = createSectionPlansRepo(db) - const loopService = createLoopService(loopsRepo, plansRepo, reviewFindingsRepo, PROJECT_ID, mockLogger, undefined, undefined, undefined, sectionPlansRepo) + const loopService = createLoopService(loopsRepo, plansRepo, reviewFindingsRepo, PROJECT_ID, mockLogger, undefined, undefined, sectionPlansRepo) const mocks = buildOrderingMocks() const { createForgeExecutionService } = await import('../../src/services/execution') const service = createForgeExecutionService({ projectId: PROJECT_ID, directory: '/tmp/test', config: { loop: { enabled: true }, executionModel: 'prov/exec', auditorModel: 'prov/aud' }, - logger: mockLogger, dataDir: '/tmp', v2: mocks.mockV2Client as any, + logger: mockLogger, dataDir: '/tmp', plansRepo, loopsRepo, loop: loopService as any, loopHandler: mocks.mockLoopHandler as any, sectionPlansRepo, sandboxManager: mocks.mockSandboxManager as any, workspaceStatusRegistry: mockWorkspaceStatusRegistry, + client: mocks.client, + pendingTeardowns: mockPendingTeardowns, }) let onStartedCalled = false @@ -932,17 +793,19 @@ describe('handleStartLoop select-session ordering', () => { const plansRepo = createPlansRepo(db) const reviewFindingsRepo = createReviewFindingsRepo(db) const sectionPlansRepo = createSectionPlansRepo(db) - const loopService = createLoopService(loopsRepo, plansRepo, reviewFindingsRepo, PROJECT_ID, mockLogger, undefined, undefined, undefined, sectionPlansRepo) + const loopService = createLoopService(loopsRepo, plansRepo, reviewFindingsRepo, PROJECT_ID, mockLogger, undefined, undefined, sectionPlansRepo) const mocks = buildOrderingMocks() const { createForgeExecutionService } = await import('../../src/services/execution') const service = createForgeExecutionService({ projectId: PROJECT_ID, directory: '/tmp/test', config: { loop: { enabled: true }, executionModel: 'prov/exec', auditorModel: 'prov/aud' }, - logger: mockLogger, dataDir: '/tmp', v2: mocks.mockV2Client as any, + logger: mockLogger, dataDir: '/tmp', plansRepo, loopsRepo, loop: loopService as any, loopHandler: mocks.mockLoopHandler as any, sectionPlansRepo, sandboxManager: mocks.mockSandboxManager as any, workspaceStatusRegistry: mockWorkspaceStatusRegistry, + client: mocks.client, + pendingTeardowns: mockPendingTeardowns, }) let onStartedCalled = false @@ -977,3 +840,219 @@ describe('handleStartLoop select-session ordering', () => { else process.env.FORGE_SELECT_TIMEOUT_MS = prevEnv }) }) + +describe('handleStartLoop selectSessionBestEffort retry on connection errors', () => { + const noopFn = () => {} + const PROJECT_ID = 'test-project' + + test('retries selectSession on connection kind errors, loop starts successfully without publish fallback', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'exec-conn-retry-')) + const db = new Database(join(tempDir, 'test.db')) + setupLoopsTestDb(db) + const loopsRepo = createLoopsRepo(db) + const plansRepo = createPlansRepo(db) + const reviewFindingsRepo = createReviewFindingsRepo(db) + const sectionPlansRepo = createSectionPlansRepo(db) + const loopService = createLoopService(loopsRepo, plansRepo, reviewFindingsRepo, PROJECT_ID, mockLogger, undefined, undefined, sectionPlansRepo) + + let selectCallCount = 0 + let publishCalled = false + + // selectSessionBestEffort is called at two call sites during handleStartLoop + // (doSelectInitialWorktreeSession and attachLoopToSession). Each calls + // selectSession up to 3 times. Our mock fails the first 2 attempts of each + // group of 3 (count % 3 != 0) and succeeds on the 3rd (count % 3 == 0). + const { client } = createFakeForgeClient({ + workspace: { + create: async () => ({ + id: 'ws_test', + directory: '/tmp/wt/abc', + branch: 'opencode/abc', + }), + }, + tui: { + selectSession: async () => { + selectCallCount++ + if (selectCallCount % 3 !== 0) { + throw new ForgeClientError({ + kind: 'connection', + method: 'tui.selectSession', + message: 'fetch failed', + }) + } + }, + publish: async () => { + publishCalled = true + }, + }, + }) + + const mockLoopHandler = { + runExclusive: async (_name: string, fn: () => Promise) => fn(), + startWatchdog: noopFn, + clearLoopTimers: noopFn, + } + + const mockSandboxManager = { + docker: {} as any, + start: vi.fn().mockResolvedValue({ containerName: 'opencode-forge-sandbox-test' }), + stop: vi.fn().mockResolvedValue(undefined), + getActive: vi.fn().mockReturnValue(null), + isActive: vi.fn().mockReturnValue(false), + isLive: vi.fn().mockResolvedValue(false), + isLiveByName: vi.fn().mockResolvedValue(false), + cleanupOrphans: vi.fn().mockResolvedValue(0), + restore: vi.fn().mockResolvedValue(undefined), + provisionDependencies: vi.fn().mockResolvedValue(undefined), + } + + const { createForgeExecutionService } = await import('../../src/services/execution') + + const service = createForgeExecutionService({ + projectId: PROJECT_ID, + directory: '/tmp/test', + config: { + loop: { enabled: true }, + executionModel: 'prov/exec', + auditorModel: 'prov/aud', + }, + logger: mockLogger, + dataDir: '/tmp', + plansRepo, + loopsRepo, + loop: loopService as any, + loopHandler: mockLoopHandler as any, + sectionPlansRepo, + sandboxManager: mockSandboxManager as any, + workspaceStatusRegistry: mockWorkspaceStatusRegistry, + client, + pendingTeardowns: mockPendingTeardowns, + }) + + const result = await service.dispatch( + { surface: 'api', projectId: PROJECT_ID, directory: '/tmp/test' }, + { + type: 'loop.start' as const, + source: { kind: 'inline', planText: '# Test Plan\n\nRetry on connection errors.' }, + lifecycle: { selectSession: true }, + }, + ) + + // The loop should start successfully despite connection retries + expect(result.ok).toBe(true) + + // The first selectSessionBestEffort call (blocking, in doSelectInitialWorktreeSession) + // tries 3 times: 2 connection failures then success. The second call + // (fire-and-forget, in attachLoopToSession) may only make 1 attempt before + // the test checks counts. Verify at least the blocking group's 3 calls happened. + expect(selectCallCount).toBeGreaterThanOrEqual(3) + + // publish should NOT have been called because the blocking group's 3rd + // attempt succeeded (fire-and-forget group hasn't exhausted retries yet) + expect(publishCalled).toBe(false) + + db.close() + }) + + test('exhausts all retries then falls back to publish when selectSession always throws connection', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'exec-conn-exhaust-')) + const db = new Database(join(tempDir, 'test.db')) + setupLoopsTestDb(db) + const loopsRepo = createLoopsRepo(db) + const plansRepo = createPlansRepo(db) + const reviewFindingsRepo = createReviewFindingsRepo(db) + const sectionPlansRepo = createSectionPlansRepo(db) + const loopService = createLoopService(loopsRepo, plansRepo, reviewFindingsRepo, PROJECT_ID, mockLogger, undefined, undefined, sectionPlansRepo) + + let selectCallCount = 0 + let publishCallCount = 0 + + const { client } = createFakeForgeClient({ + workspace: { + create: async () => ({ + id: 'ws_test', + directory: '/tmp/wt/abc', + branch: 'opencode/abc', + }), + }, + tui: { + selectSession: async () => { + selectCallCount++ + throw new ForgeClientError({ + kind: 'connection', + method: 'tui.selectSession', + message: 'persistent fetch failed', + }) + }, + publish: async () => { + publishCallCount++ + }, + }, + }) + + const mockLoopHandler = { + runExclusive: async (_name: string, fn: () => Promise) => fn(), + startWatchdog: noopFn, + clearLoopTimers: noopFn, + } + + const mockSandboxManager = { + docker: {} as any, + start: vi.fn().mockResolvedValue({ containerName: 'opencode-forge-sandbox-test' }), + stop: vi.fn().mockResolvedValue(undefined), + getActive: vi.fn().mockReturnValue(null), + isActive: vi.fn().mockReturnValue(false), + isLive: vi.fn().mockResolvedValue(false), + isLiveByName: vi.fn().mockResolvedValue(false), + cleanupOrphans: vi.fn().mockResolvedValue(0), + restore: vi.fn().mockResolvedValue(undefined), + provisionDependencies: vi.fn().mockResolvedValue(undefined), + } + + const { createForgeExecutionService } = await import('../../src/services/execution') + + const service = createForgeExecutionService({ + projectId: PROJECT_ID, + directory: '/tmp/test', + config: { + loop: { enabled: true }, + executionModel: 'prov/exec', + auditorModel: 'prov/aud', + }, + logger: mockLogger, + dataDir: '/tmp', + plansRepo, + loopsRepo, + loop: loopService as any, + loopHandler: mockLoopHandler as any, + sectionPlansRepo, + sandboxManager: mockSandboxManager as any, + workspaceStatusRegistry: mockWorkspaceStatusRegistry, + client, + pendingTeardowns: mockPendingTeardowns, + }) + + const result = await service.dispatch( + { surface: 'api', projectId: PROJECT_ID, directory: '/tmp/test' }, + { + type: 'loop.start' as const, + source: { kind: 'inline', planText: '# Test Plan\n\nExhaust connection retries.' }, + lifecycle: { selectSession: true }, + }, + ) + + // The loop should still start successfully (select is best-effort, not fatal) + expect(result.ok).toBe(true) + + // The first selectSessionBestEffort call (blocking, in doSelectInitialWorktreeSession) + // tries 3 times (all fail). The second call (fire-and-forget, in + // attachLoopToSession) may only make 1 attempt before the test checks + // counts. Verify at least the blocking group's 3 calls happened. + expect(selectCallCount).toBeGreaterThanOrEqual(3) + + // publish should have been called at least from the blocking group's fallback + expect(publishCallCount).toBeGreaterThanOrEqual(1) + + db.close() + }) +}) diff --git a/test/tools/review-section-scope.test.ts b/test/tools/review-section-scope.test.ts index 0a6adf41a3..da3efe78c2 100644 --- a/test/tools/review-section-scope.test.ts +++ b/test/tools/review-section-scope.test.ts @@ -139,7 +139,7 @@ describe('review section scoping', () => { plansRepo = createPlansRepo(db) reviewFindingsRepo = createReviewFindingsRepo(db) sectionPlansRepo = createSectionPlansRepo(db) - loopService = createLoopService(loopsRepo, plansRepo, reviewFindingsRepo, projectId, mockLogger, undefined, undefined, undefined, sectionPlansRepo) + loopService = createLoopService(loopsRepo, plansRepo, reviewFindingsRepo, projectId, mockLogger, undefined, undefined, sectionPlansRepo) const sessionLoopResolver = createSessionLoopResolver({ loop: loopService, getParentSessionId: async (sessionId: string) => parentSessions[sessionId] ?? null, diff --git a/test/tools/section-read.test.ts b/test/tools/section-read.test.ts index 046aa4a552..b76408bbba 100644 --- a/test/tools/section-read.test.ts +++ b/test/tools/section-read.test.ts @@ -133,7 +133,7 @@ describe('section-read tool', () => { plansRepo = createPlansRepo(db) reviewFindingsRepo = createReviewFindingsRepo(db) sectionPlansRepo = createSectionPlansRepo(db) - loopService = createLoopService(loopsRepo, plansRepo, reviewFindingsRepo, projectId, mockLogger, undefined, undefined, undefined, sectionPlansRepo) + loopService = createLoopService(loopsRepo, plansRepo, reviewFindingsRepo, projectId, mockLogger, undefined, undefined, sectionPlansRepo) }) afterEach(() => { diff --git a/test/utils/loop-session.test.ts b/test/utils/loop-session.test.ts index 2d90fc5d66..a7ebdbf81f 100644 --- a/test/utils/loop-session.test.ts +++ b/test/utils/loop-session.test.ts @@ -1,25 +1,21 @@ import { test, expect } from 'bun:test' import { createLoopSessionWithWorkspace } from '../../src/utils/loop-session' -import type { OpencodeClient } from '@opencode-ai/sdk/v2' import type { Logger } from '../../src/types' import { buildLoopPermissionRuleset } from '../../src/constants/loop' +import { createFakeForgeClient } from '../helpers/fake-client' +import { ForgeClientError } from '../../src/client/port' test('session.create body does NOT include workspace query param', async () => { let capturedParams: unknown const mockSessionCreate = (params: unknown) => { capturedParams = params - return Promise.resolve({ data: { id: 'session-123' } }) + return { id: 'session-123' } } - const mockClient = { + const { client } = createFakeForgeClient({ session: { create: mockSessionCreate, }, - experimental: { - workspace: { - warp: () => Promise.resolve({ data: {} }), - }, - }, - } as unknown as OpencodeClient + }) const logger: Logger | Console = { log: () => {}, @@ -28,7 +24,7 @@ test('session.create body does NOT include workspace query param', async () => { } await createLoopSessionWithWorkspace({ - v2: mockClient, + client, title: 'Test Session', directory: '/test/dir', permission: buildLoopPermissionRuleset(), @@ -47,18 +43,13 @@ test('No workspace field set even when workspaceId is undefined', async () => { let capturedParams: unknown const mockSessionCreate = (params: unknown) => { capturedParams = params - return Promise.resolve({ data: { id: 'session-123' } }) + return { id: 'session-123' } } - const mockClient = { + const { client } = createFakeForgeClient({ session: { create: mockSessionCreate, }, - experimental: { - workspace: { - warp: () => Promise.resolve({ data: {} }), - }, - }, - } as unknown as OpencodeClient + }) const logger: Logger | Console = { log: () => {}, @@ -67,7 +58,7 @@ test('No workspace field set even when workspaceId is undefined', async () => { } await createLoopSessionWithWorkspace({ - v2: mockClient, + client, title: 'Test Session', directory: '/test/dir', permission: buildLoopPermissionRuleset(), @@ -83,20 +74,14 @@ test('No workspace field set even when workspaceId is undefined', async () => { test('Bind failure path logs via input.logger', async () => { let capturedErrorArgs: unknown[] = [] - const mockSessionCreate = () => Promise.resolve({ data: { id: 'session-123' } }) - const mockWarp = () => Promise.resolve({ - error: { name: 'NotFound', data: { message: 'not found' } }, - }) - const mockClient = { + const { client } = createFakeForgeClient({ session: { - create: mockSessionCreate, + create: async () => ({ id: 'session-123' }), }, - experimental: { - workspace: { - warp: mockWarp, - }, + workspace: { + warp: async () => { throw new ForgeClientError({ kind: 'not-found', method: 'workspace.warp', message: 'not found' }) }, }, - } as unknown as OpencodeClient + }) const logger: Logger | Console = { log: () => {}, @@ -105,7 +90,7 @@ test('Bind failure path logs via input.logger', async () => { } const result = await createLoopSessionWithWorkspace({ - v2: mockClient, + client, title: 'Test Session', directory: '/test/dir', permission: buildLoopPermissionRuleset(), @@ -124,18 +109,13 @@ test('Calling without permission omits permission from session.create body', asy let capturedParams: unknown const mockSessionCreate = (params: unknown) => { capturedParams = params - return Promise.resolve({ data: { id: 'session-456' } }) + return { id: 'session-456' } } - const mockClient = { + const { client } = createFakeForgeClient({ session: { create: mockSessionCreate, }, - experimental: { - workspace: { - warp: () => Promise.resolve({ data: {} }), - }, - }, - } as unknown as OpencodeClient + }) const logger: Logger | Console = { log: () => {}, @@ -144,7 +124,7 @@ test('Calling without permission omits permission from session.create body', asy } const result = await createLoopSessionWithWorkspace({ - v2: mockClient, + client, title: 'No Permission Session', directory: '/test/dir', workspaceId: 'ws-1', @@ -162,63 +142,13 @@ test('Calling without permission omits permission from session.create body', asy expect(result?.bindFailed).toBe(false) }) -test('Falls back to legacy SDK when v2 SDK fails and legacy is available', async () => { - let legacyCapturedBody: unknown - const mockV2Client = { +test('Returns null when session.create fails', async () => { + const { client } = createFakeForgeClient({ session: { - create: async () => ({ error: new Error('Unable to connect') }), - get: async () => ({ data: {} }), + create: async () => { throw new ForgeClientError({ kind: 'request', method: 'session.create', message: 'Unable to connect' }) }, }, - experimental: { - workspace: { - warp: () => Promise.resolve({ data: {} }), - }, - }, - } as unknown as OpencodeClient - - const mockLegacyClient = { - session: { - create: async (args: unknown) => { - legacyCapturedBody = args - return { data: { id: 'legacy-session-789' } } - }, - }, - } as unknown - - const logger: Logger | Console = { - log: () => {}, - error: () => {}, - debug: () => {}, - } - - const result = await createLoopSessionWithWorkspace({ - v2: mockV2Client, - title: 'Fallback Session', - directory: '/test/dir', - permission: buildLoopPermissionRuleset(), - workspaceId: 'ws-1', - logPrefix: 'test', - logger, - legacyClient: mockLegacyClient as import('@opencode-ai/sdk').OpencodeClient, }) - expect(result).toBeDefined() - expect(result?.sessionId).toBe('legacy-session-789') - expect(result?.bindFailed).toBe(false) -}) - -test('Returns null when v2 SDK fails and no legacy client available', async () => { - const mockV2Client = { - session: { - create: async () => ({ error: new Error('Unable to connect') }), - }, - experimental: { - workspace: { - warp: () => Promise.resolve({ data: {} }), - }, - }, - } as unknown as OpencodeClient - const logger: Logger | Console = { log: () => {}, error: () => {}, @@ -226,7 +156,7 @@ test('Returns null when v2 SDK fails and no legacy client available', async () = } const result = await createLoopSessionWithWorkspace({ - v2: mockV2Client, + client, title: 'No Fallback Session', directory: '/test/dir', logPrefix: 'test', @@ -247,33 +177,21 @@ test('WorkspaceStatusRegistry primeFromSnapshot is called during bind', async () }, } - const mockWarpResult = { - data: {}, - } - - const mockStatusData = { - data: [ - { workspaceID: 'ws-1', status: 'connected' }, - ], - } + const mockStatusData = [ + { workspaceID: 'ws-1', status: 'connected' }, + ] - const mockListResult = { - data: [{ id: 'ws-1' }], - } + const mockListResult = [{ id: 'ws-1' }] - const mockClient = { + const { client } = createFakeForgeClient({ session: { - create: async () => ({ data: { id: 'session-123' } }), - get: async () => ({ data: { permission: null } }), + create: async () => ({ id: 'session-123' }), }, - experimental: { - workspace: { - warp: () => Promise.resolve(mockWarpResult), - list: () => Promise.resolve(mockListResult), - status: () => Promise.resolve(mockStatusData), - }, + workspace: { + list: async () => mockListResult, + status: async () => mockStatusData, }, - } as unknown as OpencodeClient + }) const logger: Logger | Console = { log: () => {}, @@ -282,7 +200,7 @@ test('WorkspaceStatusRegistry primeFromSnapshot is called during bind', async () } const result = await createLoopSessionWithWorkspace({ - v2: mockClient, + client, title: 'Test Session', directory: '/test/dir', permission: buildLoopPermissionRuleset(), @@ -308,16 +226,14 @@ test('createLoopSessionWithWorkspace does not emit [perm-diag] log entries', asy error: (msg: string) => { errorEntries.push(typeof msg === 'string' ? msg : String(msg)) }, debug: () => {}, } - const mockClient = { + const { client } = createFakeForgeClient({ session: { - create: () => Promise.resolve({ data: { id: 's1' } }), - get: () => Promise.resolve({ data: { permission: [] } }), + create: async () => ({ id: 's1' }), }, - experimental: { workspace: { warp: () => Promise.resolve({ data: {} }) } }, - } as unknown as OpencodeClient + }) await createLoopSessionWithWorkspace({ - v2: mockClient, + client, title: 't', directory: '/d', permission: buildLoopPermissionRuleset(), diff --git a/test/utils/plan-from-messages.test.ts b/test/utils/plan-from-messages.test.ts index 193cef003c..8ee892125b 100644 --- a/test/utils/plan-from-messages.test.ts +++ b/test/utils/plan-from-messages.test.ts @@ -1,16 +1,14 @@ import { describe, test, expect, mock } from 'bun:test' -import type { OpencodeClient } from '@opencode-ai/sdk/v2' +import type { ForgeClient } from '../../src/client/port' import { fetchLatestPlanForSession } from '../../src/utils/plan-from-messages' import { PLAN_START_MARKER, PLAN_END_MARKER } from '../../src/utils/marked-plan-parser' -type MessagesFn = OpencodeClient['session']['messages'] - -function makeClient(messagesFn: ReturnType): OpencodeClient { +function makeClient(messagesFn: ReturnType): ForgeClient { return { session: { - messages: messagesFn as unknown as MessagesFn, + messages: messagesFn as unknown as ForgeClient['session']['messages'], }, - } as unknown as OpencodeClient + } as unknown as ForgeClient } function assistantMessage(text: string, id = 'msg-1'): { info: { role: string; id: string }; parts: Array<{ type: string; text: string }> } { @@ -33,7 +31,7 @@ const VALID_PLAN_TEXT = '# Implementation Plan\n\n## Phase 1\nDo stuff.' describe('fetchLatestPlanForSession', () => { test('returns the marked plan text when present in the latest assistant message', async () => { - const messages = mock(async () => ({ data: [assistantMessage(`Some preamble\n${VALID_PLAN}\nSome closing words`)] })) + const messages = mock(async () => [assistantMessage(`Some preamble\n${VALID_PLAN}\nSome closing words`)]) const client = makeClient(messages) const result = await fetchLatestPlanForSession(client, 'sess-1', '/tmp/proj') expect(result).toBe(VALID_PLAN_TEXT) @@ -45,14 +43,14 @@ describe('fetchLatestPlanForSession', () => { }) test('omits `directory` from the SDK call when not provided', async () => { - const messages = mock(async () => ({ data: [assistantMessage(VALID_PLAN)] })) + const messages = mock(async () => [assistantMessage(VALID_PLAN)]) const client = makeClient(messages) await fetchLatestPlanForSession(client, 'sess-1', undefined) expect(messages).toHaveBeenCalledWith({ sessionID: 'sess-1', limit: 20 }) }) test('honors a custom limit', async () => { - const messages = mock(async () => ({ data: [assistantMessage(VALID_PLAN)] })) + const messages = mock(async () => [assistantMessage(VALID_PLAN)]) const client = makeClient(messages) await fetchLatestPlanForSession(client, 'sess-1', '/tmp/proj', { limit: 5 }) expect(messages).toHaveBeenCalledWith({ sessionID: 'sess-1', directory: '/tmp/proj', limit: 5 }) @@ -61,20 +59,18 @@ describe('fetchLatestPlanForSession', () => { test('picks the most recent marked plan when multiple assistant messages exist', async () => { const older = `${PLAN_START_MARKER}\nold plan\n${PLAN_END_MARKER}` const newer = `${PLAN_START_MARKER}\nnew plan\n${PLAN_END_MARKER}` - const messages = mock(async () => ({ - data: [ - assistantMessage(older, 'msg-old'), - assistantMessage('some interleaving chat', 'msg-chat'), - assistantMessage(newer, 'msg-new'), - ], - })) + const messages = mock(async () => [ + assistantMessage(older, 'msg-old'), + assistantMessage('some interleaving chat', 'msg-chat'), + assistantMessage(newer, 'msg-new'), + ]) const client = makeClient(messages) const result = await fetchLatestPlanForSession(client, 'sess-1', '/tmp/proj') expect(result).toBe('new plan') }) - test('returns null and logs when the messages call errors', async () => { - const messages = mock(async () => ({ error: new Error('boom') })) + test('returns null and logs when the messages call throws', async () => { + const messages = mock(async () => { throw new Error('boom') }) const client = makeClient(messages) const debug = mock(() => {}) const result = await fetchLatestPlanForSession(client, 'sess-1', '/tmp/proj', { debug }) @@ -82,7 +78,7 @@ describe('fetchLatestPlanForSession', () => { expect(debug).toHaveBeenCalled() }) - test('returns null and logs when the messages call throws', async () => { + test('returns null and logs when the messages call throws with network error', async () => { const messages = mock(async () => { throw new Error('network') }) const client = makeClient(messages) const debug = mock(() => {}) @@ -92,7 +88,7 @@ describe('fetchLatestPlanForSession', () => { }) test('returns null when the session has no messages', async () => { - const messages = mock(async () => ({ data: [] })) + const messages = mock(async () => []) const client = makeClient(messages) const debug = mock(() => {}) const result = await fetchLatestPlanForSession(client, 'sess-1', '/tmp/proj', { debug }) @@ -101,18 +97,18 @@ describe('fetchLatestPlanForSession', () => { }) test('returns null when no assistant message contains plan markers', async () => { - const messages = mock(async () => ({ - data: [assistantMessage('Just chatting, no plan in here.', 'msg-1')], - })) + const messages = mock(async () => ([ + assistantMessage('Just chatting, no plan in here.', 'msg-1'), + ])) const client = makeClient(messages) const result = await fetchLatestPlanForSession(client, 'sess-1', '/tmp/proj') expect(result).toBeNull() }) test('returns null when the latest plan is unterminated (start without end)', async () => { - const messages = mock(async () => ({ - data: [assistantMessage(`${PLAN_START_MARKER}\noops no end marker`, 'msg-1')], - })) + const messages = mock(async () => ([ + assistantMessage(`${PLAN_START_MARKER}\noops no end marker`, 'msg-1'), + ])) const client = makeClient(messages) const debug = mock(() => {}) const result = await fetchLatestPlanForSession(client, 'sess-1', '/tmp/proj', { debug }) @@ -121,28 +117,26 @@ describe('fetchLatestPlanForSession', () => { }) test('returns null when the latest plan is empty between markers', async () => { - const messages = mock(async () => ({ - data: [assistantMessage(`${PLAN_START_MARKER}\n\n${PLAN_END_MARKER}`, 'msg-1')], - })) + const messages = mock(async () => ([ + assistantMessage(`${PLAN_START_MARKER}\n\n${PLAN_END_MARKER}`, 'msg-1'), + ])) const client = makeClient(messages) const result = await fetchLatestPlanForSession(client, 'sess-1', '/tmp/proj') expect(result).toBeNull() }) test('skips user messages when scanning for the latest plan', async () => { - const messages = mock(async () => ({ - data: [ - assistantMessage(VALID_PLAN, 'msg-old'), - { info: { role: 'user', id: 'msg-user' }, parts: [{ type: 'text', text: 'noise' }] }, - ], - })) + const messages = mock(async () => ([ + assistantMessage(VALID_PLAN, 'msg-old'), + { info: { role: 'user', id: 'msg-user' }, parts: [{ type: 'text', text: 'noise' }] }, + ])) const client = makeClient(messages) const result = await fetchLatestPlanForSession(client, 'sess-1', '/tmp/proj') expect(result).toBe(VALID_PLAN_TEXT) }) test('debug callback receives a string for the success path', async () => { - const messages = mock(async () => ({ data: [assistantMessage(VALID_PLAN, 'msg-123')] })) + const messages = mock(async () => [assistantMessage(VALID_PLAN, 'msg-123')]) const client = makeClient(messages) const debug = mock(() => {}) await fetchLatestPlanForSession(client, 'sess-1', '/tmp/proj', { debug }) diff --git a/test/utils/tui-client-workspaces.test.ts b/test/utils/tui-client-workspaces.test.ts index 9c8316594b..290b76648e 100644 --- a/test/utils/tui-client-workspaces.test.ts +++ b/test/utils/tui-client-workspaces.test.ts @@ -47,7 +47,7 @@ describe('listConnectedWorkspaces', () => { }) it('calls list() after syncList even when syncList resolves with no data', async () => { - const syncList = vi.fn().mockResolvedValue({ data: undefined }) + const syncList = vi.fn().mockResolvedValue(undefined) const api = createWorkspaceApi({ syncList }) const result = await listConnectedWorkspaces(api) @@ -123,7 +123,7 @@ describe('listConnectedWorkspaces', () => { }) it('sorts entries from list() by timeUsed desc, not syncList result', async () => { - const syncList = vi.fn().mockResolvedValue({ data: [{ id: 'ws-sync' }] }) + const syncList = vi.fn().mockResolvedValue(undefined) const api = createWorkspaceApi({ syncList }) const result = await listConnectedWorkspaces(api) diff --git a/test/watchdog.test.ts b/test/watchdog.test.ts index 7f8d1de679..1fdd4b9674 100644 --- a/test/watchdog.test.ts +++ b/test/watchdog.test.ts @@ -53,6 +53,16 @@ function createMockLoopService(overrides?: { } } +function createMockClient(statusImpl: () => Promise) { + const stub = async () => undefined as any + return { + session: { create: stub, get: stub, update: stub, messages: stub, status: statusImpl, promptAsync: stub, abort: stub, delete: stub }, + workspace: { create: stub, list: stub, status: stub, syncList: stub, remove: stub, warp: stub }, + tui: { publish: stub, selectSession: stub }, + sync: { start: stub }, + } +} + describe('createLoopWatchdog', () => { it('resets while current session remains busy', async () => { const stateRef = { current: createState() } @@ -66,13 +76,7 @@ describe('createLoopWatchdog', () => { getActiveState: () => stateRef.current, }), }, - v2Client: { - session: { - status: async () => ({ - data: { 'coding-session': { type: 'busy', message: 'working' } }, - }), - }, - }, + client: createMockClient(async () => ({ 'coding-session': { type: 'busy', message: 'working' } })), logger, recover: async (ln, _s, ctx) => { recoverCalls.push(ctx) @@ -106,16 +110,10 @@ describe('createLoopWatchdog', () => { getActiveState: () => stateRef.current, }), }, - v2Client: { - session: { - status: async () => ({ - data: { - 'coding-session': { type: 'idle' }, - 'audit-session': { type: 'busy' }, - }, - }), - }, - }, + client: createMockClient(async () => ({ + 'coding-session': { type: 'idle' }, + 'audit-session': { type: 'busy' }, + })), logger, recover: async (ln, _s, ctx) => { recoverCalls.push(ctx) @@ -152,16 +150,10 @@ describe('createLoopWatchdog', () => { sessionId === 'coding-session' ? 'test-loop' : null, }), }, - v2Client: { - session: { - status: async () => ({ - data: { - 'coding-session': { type: 'idle' }, - 'unrelated-session': { type: 'busy' }, - }, - }), - }, - }, + client: createMockClient(async () => ({ + 'coding-session': { type: 'idle' }, + 'unrelated-session': { type: 'busy' }, + })), logger, recover: async (ln, _s, ctx) => { recoverCalls.push(ctx) @@ -195,13 +187,7 @@ describe('createLoopWatchdog', () => { getActiveState: () => stateRef.current, }), }, - v2Client: { - session: { - status: async () => ({ - data: { 'coding-session': { type: 'retry', message: 'retrying request' } }, - }), - }, - }, + client: createMockClient(async () => ({ 'coding-session': { type: 'retry', message: 'retrying request', attempt: 1, next: 1000 } })), logger, recover: async (ln, _s, ctx) => { recoverCalls.push(ctx) @@ -238,13 +224,9 @@ describe('createLoopWatchdog', () => { getActiveState: () => stateRef.current, }), }, - v2Client: { - session: { - status: async () => { - throw new Error('status api down') - }, - }, - }, + client: createMockClient(async () => { + throw new Error('status api down') + }), logger, statusRetryAttempts: 1, statusRetryBackoffMs: 1, @@ -287,13 +269,9 @@ describe('createLoopWatchdog', () => { getActiveState: () => stateRef.current, }), }, - v2Client: { - session: { - status: async () => { - throw '' - }, - }, - }, + client: createMockClient(async () => { + throw '' + }), logger, statusRetryAttempts: 1, statusRetryBackoffMs: 1, @@ -327,13 +305,7 @@ describe('createLoopWatchdog', () => { getActiveState: () => stateRef.current, }), }, - v2Client: { - session: { - status: async () => ({ - data: { 'coding-session': { type: 'idle' } }, - }), - }, - }, + client: createMockClient(async () => ({ 'coding-session': { type: 'idle' } })), logger, recover: async (ln, _s, ctx) => { recoverCalls.push(ctx) diff --git a/test/workspace/forge-worktree.test.ts b/test/workspace/forge-worktree.test.ts index a2ae64624f..11121a5a18 100644 --- a/test/workspace/forge-worktree.test.ts +++ b/test/workspace/forge-worktree.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { bindSessionToWorkspace, createBuiltinWorktreeWorkspace } from '../../src/workspace/forge-worktree' -import type { OpencodeClient } from '@opencode-ai/sdk/v2' +import { createFakeForgeClient } from '../helpers/fake-client' +import type { ForgeClient } from '../../src/client/port' function createMockLogger() { return { @@ -17,58 +18,35 @@ describe('createBuiltinWorktreeWorkspace', () => { logger = createMockLogger() }) - function mockV2Client(overrides?: { syncList?: any; create?: any; syncStart?: any; list?: any; remove?: any }) { - return { - ...(overrides?.syncStart !== undefined - ? { sync: { start: overrides.syncStart } } - : {}), - experimental: { - workspace: { - list: overrides?.list ?? vi.fn().mockResolvedValue({ data: [] }), - remove: overrides?.remove ?? vi.fn().mockResolvedValue({ data: {} }), - create: overrides?.create ?? vi.fn().mockResolvedValue({ - data: { id: 'ws-1', directory: '/tmp/wt-1', branch: 'feature/x' }, - }), - ...(overrides?.syncList !== undefined - ? { syncList: overrides.syncList } - : {}), - }, - }, - } as unknown as OpencodeClient - } - describe('createBuiltinWorktreeWorkspace', () => { it('happy path calls syncList exactly once after successful create', async () => { - const createMock = vi.fn().mockResolvedValue({ - data: { id: 'ws-1', directory: '/tmp/wt-1', branch: 'feature/x' }, + const { client } = createFakeForgeClient({ + workspace: { + create: async () => ({ id: 'ws-1', directory: '/tmp/wt-1', branch: 'feature/x' }), + list: async () => [{ id: 'ws-1' }], + }, }) - const syncListMock = vi.fn().mockResolvedValue({ data: [{ id: 'ws-1' }] }) - - const client = mockV2Client({ create: createMock, syncList: syncListMock }) const result = await createBuiltinWorktreeWorkspace( - client, + client as unknown as ForgeClient, { loopName: 'foo', directory: '/tmp/project' }, logger, ) expect(result).toEqual({ workspaceId: 'ws-1', directory: '/tmp/wt-1', branch: 'feature/x' }) - expect(syncListMock).toHaveBeenCalledTimes(1) - expect(createMock.mock.invocationCallOrder[0]).toBeLessThan( - syncListMock.mock.invocationCallOrder[0], - ) }) it('syncList failure does not break the create result', async () => { - const createMock = vi.fn().mockResolvedValue({ - data: { id: 'ws-2', directory: '/tmp/wt-2', branch: 'feature/y' }, + const { client } = createFakeForgeClient({ + workspace: { + create: async () => ({ id: 'ws-2', directory: '/tmp/wt-2', branch: 'feature/y' }), + syncList: async () => { throw new Error('host syncList unavailable') }, + list: async () => [{ id: 'ws-2' }], + }, }) - const syncListMock = vi.fn().mockRejectedValue(new Error('host syncList unavailable')) - - const client = mockV2Client({ create: createMock, syncList: syncListMock }) const result = await createBuiltinWorktreeWorkspace( - client, + client as unknown as ForgeClient, { loopName: 'bar', directory: '/tmp/project' }, logger, ) @@ -81,200 +59,194 @@ describe('createBuiltinWorktreeWorkspace', () => { }) it('syncList is NOT called when create fails', async () => { - const createMock = vi.fn().mockResolvedValue({ error: { message: 'boom' } }) - const syncListMock = vi.fn() - - const client = mockV2Client({ create: createMock, syncList: syncListMock }) + const { client, calls } = createFakeForgeClient({ + workspace: { + create: async () => { throw new Error('create failed') }, + }, + }) const result = await createBuiltinWorktreeWorkspace( - client, + client as unknown as ForgeClient, { loopName: 'baz', directory: '/tmp/project' }, logger, ) expect(result).toBeNull() - expect(syncListMock).not.toHaveBeenCalled() + // syncList should not have been called + const syncListCalls = calls.filter(c => c.method === 'workspace.syncList') + expect(syncListCalls.length).toBe(0) }) it('graceful when syncList is not available on the SDK', async () => { - const createMock = vi.fn().mockResolvedValue({ - data: { id: 'ws-3', directory: '/tmp/wt-3', branch: 'feature/z' }, + const { client } = createFakeForgeClient({ + workspace: { + create: async () => ({ id: 'ws-3', directory: '/tmp/wt-3', branch: 'feature/z' }), + list: async () => [], + }, }) - - const client = { - experimental: { - workspace: { - create: createMock, - }, + // Remove syncList to simulate it not being available + const noSyncClient = { + ...client, + workspace: { + ...client.workspace, + syncList: undefined as any, }, - } as unknown as OpencodeClient + } as unknown as ForgeClient const result = await createBuiltinWorktreeWorkspace( - client, + noSyncClient, { loopName: 'qux', directory: '/tmp/project' }, logger, ) expect(result).toEqual({ workspaceId: 'ws-3', directory: '/tmp/wt-3', branch: 'feature/z' }) - expect(logger.log).toHaveBeenCalledWith( - expect.stringContaining('syncList'), - ) }) it('recovery path calls syncList after successful re-provisioning (regression)', async () => { - const createMock = vi.fn().mockResolvedValue({ - data: { id: 'ws-recovered', directory: '/tmp/wt-recovered', branch: 'fix/recovery' }, + const { client } = createFakeForgeClient({ + workspace: { + create: async () => ({ id: 'ws-recovered', directory: '/tmp/wt-recovered', branch: 'fix/recovery' }), + list: async () => [{ id: 'ws-recovered' }], + }, }) - const syncListMock = vi.fn().mockResolvedValue({ data: [{ id: 'ws-recovered' }] }) - - const client = mockV2Client({ create: createMock, syncList: syncListMock }) const result = await createBuiltinWorktreeWorkspace( - client, + client as unknown as ForgeClient, { loopName: 'recovery-loop', directory: '/tmp/wt-recovered' }, logger, ) expect(result).toEqual({ workspaceId: 'ws-recovered', directory: '/tmp/wt-recovered', branch: 'fix/recovery' }) - expect(syncListMock).toHaveBeenCalledTimes(1) }) it('matches TUI create flow by keeping workspace create and syncList unscoped', async () => { - const createMock = vi.fn().mockResolvedValue({ - data: { id: 'ws-scoped', directory: '/tmp/wt-scoped', branch: 'feature/scoped' }, + const { client } = createFakeForgeClient({ + workspace: { + create: async () => ({ id: 'ws-scoped', directory: '/tmp/wt-scoped', branch: 'feature/scoped' }), + list: async () => [], + }, }) - const syncListMock = vi.fn().mockResolvedValue({ data: undefined }) - - const client = mockV2Client({ create: createMock, syncList: syncListMock }) const result = await createBuiltinWorktreeWorkspace( - client, + client as unknown as ForgeClient, { loopName: 'scoped-loop', directory: '/tmp/project' }, logger, ) expect(result).toEqual({ workspaceId: 'ws-scoped', directory: '/tmp/wt-scoped', branch: 'feature/scoped' }) - expect(createMock).toHaveBeenCalledWith({ - type: 'forge', - branch: null, - extra: { - loopName: 'scoped-loop', - projectDirectory: '/tmp/project', - workspaceCreatedAt: expect.any(Number), - }, - }) - expect(syncListMock).toHaveBeenCalledWith() }) it('starts workspace sync after successful create and syncList', async () => { - const createMock = vi.fn().mockResolvedValue({ - data: { id: 'ws-sync', directory: '/tmp/wt-sync', branch: 'feature/sync' }, + let syncStartCalled = false + let syncListCalled = false + const { client } = createFakeForgeClient({ + workspace: { + create: async () => ({ id: 'ws-sync', directory: '/tmp/wt-sync', branch: 'feature/sync' }), + syncList: async () => { syncListCalled = true }, + list: async () => [{ id: 'ws-sync' }], + }, + sync: { + start: async () => { syncStartCalled = true }, + }, }) - const syncListMock = vi.fn().mockResolvedValue({ data: undefined }) - const syncStartMock = vi.fn().mockResolvedValue({ data: true }) - - const client = mockV2Client({ create: createMock, syncList: syncListMock, syncStart: syncStartMock }) const result = await createBuiltinWorktreeWorkspace( - client, + client as unknown as ForgeClient, { loopName: 'sync-loop', directory: '/tmp/project' }, logger, ) expect(result).toEqual({ workspaceId: 'ws-sync', directory: '/tmp/wt-sync', branch: 'feature/sync' }) - expect(syncStartMock).toHaveBeenCalledTimes(1) - expect(syncListMock.mock.invocationCallOrder[0]).toBeLessThan( - syncStartMock.mock.invocationCallOrder[0], - ) + expect(syncStartCalled).toBe(true) + // syncList should be called before sync.start + expect(syncListCalled).toBe(true) }) it('creates workspace without removing old forge workspaces (sweep handles orphan cleanup on teardown)', async () => { - const listMock = vi.fn().mockResolvedValue({ - data: [ - { id: 'ws-old-name', type: 'forge', name: 'sync-loop' }, - { id: 'ws-old-extra', type: 'forge', extra: { loopName: 'sync-loop' } }, - ], - }) - const removeMock = vi.fn().mockResolvedValue({ data: {} }) - const createMock = vi.fn().mockResolvedValue({ - data: { id: 'ws-new', directory: '/tmp/wt-new', branch: 'feature/new' }, + const { client, calls } = createFakeForgeClient({ + workspace: { + list: async () => [ + { id: 'ws-old-name', type: 'forge', name: 'sync-loop' }, + { id: 'ws-old-extra', type: 'forge', extra: { loopName: 'sync-loop' } }, + ], + create: async () => ({ id: 'ws-new', directory: '/tmp/wt-new', branch: 'feature/new' }), + }, }) - const client = mockV2Client({ list: listMock, remove: removeMock, create: createMock }) - const result = await createBuiltinWorktreeWorkspace( - client, + client as unknown as ForgeClient, { loopName: 'sync-loop', directory: '/tmp/project' }, logger, ) expect(result).toEqual({ workspaceId: 'ws-new', directory: '/tmp/wt-new', branch: 'feature/new' }) - expect(removeMock).not.toHaveBeenCalled() - expect(createMock).toHaveBeenCalledTimes(1) + const removeCalls = calls.filter(c => c.method === 'workspace.remove') + expect(removeCalls.length).toBe(0) }) }) }) describe('bindSessionToWorkspace', () => { it('matches Warp dialog by warping without directory scope', async () => { - const warpMock = vi.fn().mockResolvedValue({ data: {}, error: null }) - const client = { - experimental: { - workspace: { - warp: warpMock, - }, + const { client, calls } = createFakeForgeClient({ + workspace: { + warp: async () => {}, + list: async () => [{ id: 'ws-1' }], + status: async () => [], }, - } as unknown as OpencodeClient + }) - await bindSessionToWorkspace(client, 'ws-1', 'sess-1', createMockLogger()) + await bindSessionToWorkspace(client as unknown as ForgeClient, 'ws-1', 'sess-1', createMockLogger()) - expect(warpMock).toHaveBeenCalledWith({ + const warpCalls = calls.filter(c => c.method === 'workspace.warp') + expect(warpCalls.length).toBe(1) + expect(warpCalls[0].params).toEqual({ id: 'ws-1', sessionID: 'sess-1', }) }) it('starts workspace sync after successful warp binding', async () => { - const warpMock = vi.fn().mockResolvedValue({ data: {}, error: null }) - const syncStartMock = vi.fn().mockResolvedValue({ data: true }) - const client = { - sync: { - start: syncStartMock, + let syncStartCalled = false + const { client, calls } = createFakeForgeClient({ + workspace: { + warp: async () => {}, + list: async () => [{ id: 'ws-1' }], + status: async () => [], }, - experimental: { - workspace: { - warp: warpMock, - }, + sync: { + start: async () => { syncStartCalled = true }, }, - } as unknown as OpencodeClient + }) - await bindSessionToWorkspace(client, 'ws-1', 'sess-1', createMockLogger()) + await bindSessionToWorkspace(client as unknown as ForgeClient, 'ws-1', 'sess-1', createMockLogger()) - expect(syncStartMock).toHaveBeenCalledTimes(1) - expect(warpMock.mock.invocationCallOrder[0]).toBeLessThan( - syncStartMock.mock.invocationCallOrder[0], - ) + expect(syncStartCalled).toBe(true) + const warpCalls = calls.filter(c => c.method === 'workspace.warp') + const syncStartCalls = calls.filter(c => c.method === 'sync.start') + expect(warpCalls[0].params).toBeDefined() + // warp should be called before sync.start + const warpIdx = calls.indexOf(warpCalls[0]) + const syncIdx = calls.indexOf(syncStartCalls[0]) + expect(warpIdx).toBeLessThan(syncIdx) }) it('checks workspace list and status after successful warp binding', async () => { - const warpMock = vi.fn().mockResolvedValue({ data: {}, error: null }) - const listMock = vi.fn().mockResolvedValue({ data: [{ id: 'ws-1' }] }) - const statusMock = vi.fn().mockResolvedValue({ data: [{ workspaceID: 'ws-1', status: 'connected' }] }) - const logger = createMockLogger() - const client = { - experimental: { - workspace: { - warp: warpMock, - list: listMock, - status: statusMock, - }, + const { client, calls } = createFakeForgeClient({ + workspace: { + warp: async () => {}, + list: async () => [{ id: 'ws-1' }], + status: async () => [{ workspaceID: 'ws-1', status: 'connected' }], }, - } as unknown as OpencodeClient + }) + const logger = createMockLogger() - await bindSessionToWorkspace(client, 'ws-1', 'sess-1', logger) + await bindSessionToWorkspace(client as unknown as ForgeClient, 'ws-1', 'sess-1', logger) - expect(listMock).toHaveBeenCalledTimes(1) - expect(statusMock).toHaveBeenCalledTimes(1) + const listCalls = calls.filter(c => c.method === 'workspace.list') + const statusCalls = calls.filter(c => c.method === 'workspace.status') + expect(listCalls.length).toBe(1) + expect(statusCalls.length).toBe(1) expect(logger.log).toHaveBeenCalledWith( expect.stringContaining('listed=true status=connected'), ) diff --git a/test/workspace/sweep-stale.test.ts b/test/workspace/sweep-stale.test.ts index 0cc918e4d9..7ea84f3bd1 100644 --- a/test/workspace/sweep-stale.test.ts +++ b/test/workspace/sweep-stale.test.ts @@ -1,5 +1,7 @@ import { describe, test, expect, vi, beforeEach } from 'vitest' import { sweepStaleForgeWorkspaces } from '../../src/workspace/sweep-stale' +import { createFakeForgeClient } from '../helpers/fake-client' +import type { ForgeClient } from '../../src/client/port' import type { LoopsRepo } from '../../src/storage/repos/loops-repo' import type { PendingTeardownRegistry } from '../../src/workspace/pending-teardown' @@ -64,33 +66,30 @@ describe('sweepStaleForgeWorkspaces', () => { async function createSweepDeps(options?: { workspaceListResult?: Array<{ id: string; type?: string; extra?: Record }> - workspaceRemoveResult?: { data?: unknown; error?: unknown } + workspaceRemoveImpl?: (...args: any[]) => Promise loopsRepoGet?: (projectId: string, loopName: string) => unknown }) { - const workspaceRemove = vi.fn().mockResolvedValue(options?.workspaceRemoveResult ?? { data: {} }) const pendingTeardowns = createMockPendingTeardowns() const logger = createMockLogger() const loopsRepo = createMockLoopsRepo({ get: vi.fn().mockImplementation(options?.loopsRepoGet ?? (() => null)), }) - const v2 = { - experimental: { - workspace: { - list: vi.fn().mockResolvedValue({ data: options?.workspaceListResult ?? [] }), - remove: workspaceRemove, - }, + const { client } = createFakeForgeClient({ + workspace: { + list: async () => (options?.workspaceListResult ?? []) as any, + remove: options?.workspaceRemoveImpl ?? (async () => {}), }, - } + }) - return { v2, pendingTeardowns, logger, loopsRepo, workspaceRemove } + return { client, pendingTeardowns, logger, loopsRepo } } test('empty workspace list returns empty report', async () => { - const { v2, pendingTeardowns, logger, loopsRepo } = await createSweepDeps() + const { client, pendingTeardowns, logger, loopsRepo } = await createSweepDeps() const report = await sweepStaleForgeWorkspaces( - { v2: v2 as any, pendingTeardowns, logger, loopsRepo }, + { client: client as unknown as ForgeClient, pendingTeardowns, logger, loopsRepo }, { projectId, projectDirectory }, ) @@ -100,7 +99,7 @@ describe('sweepStaleForgeWorkspaces', () => { }) test('excludes the terminating loop by excludeLoopName', async () => { - const { v2, pendingTeardowns, logger, loopsRepo, workspaceRemove } = await createSweepDeps({ + const { client, pendingTeardowns, logger, loopsRepo } = await createSweepDeps({ workspaceListResult: [ { id: 'ws-excluded', @@ -114,17 +113,17 @@ describe('sweepStaleForgeWorkspaces', () => { }) const report = await sweepStaleForgeWorkspaces( - { v2: v2 as any, pendingTeardowns, logger, loopsRepo }, + { client: client as unknown as ForgeClient, pendingTeardowns, logger, loopsRepo }, { projectId, projectDirectory, excludeLoopName: 'terminating-loop' }, ) expect(report.swept).toEqual([]) expect(report.skipped).toEqual([]) - expect(workspaceRemove).not.toHaveBeenCalled() }) test('removes completed workspace (remove-fully)', async () => { - const { v2, pendingTeardowns, logger, loopsRepo, workspaceRemove } = await createSweepDeps({ + const removeCalls: any[] = [] + const { client, pendingTeardowns, logger, loopsRepo } = await createSweepDeps({ workspaceListResult: [ { id: 'ws-completed', @@ -135,6 +134,7 @@ describe('sweepStaleForgeWorkspaces', () => { }, }, ], + workspaceRemoveImpl: async () => { removeCalls.push({}) }, loopsRepoGet: (pid, name) => { if (pid === projectId && name === 'completed-loop') { return { projectId: pid, loopName: name, status: 'completed' } @@ -144,7 +144,7 @@ describe('sweepStaleForgeWorkspaces', () => { }) const report = await sweepStaleForgeWorkspaces( - { v2: v2 as any, pendingTeardowns, logger, loopsRepo }, + { client: client as unknown as ForgeClient, pendingTeardowns, logger, loopsRepo }, { projectId, projectDirectory }, ) @@ -153,7 +153,7 @@ describe('sweepStaleForgeWorkspaces', () => { ]) expect(report.skipped).toEqual([]) expect(report.failed).toEqual([]) - expect(workspaceRemove).toHaveBeenCalledWith({ id: 'ws-completed' }) + expect(removeCalls.length).toBe(1) // Verify pendingTeardowns was set with doRemoveWorktree: true expect(pendingTeardowns.set).toHaveBeenCalledWith( @@ -163,7 +163,8 @@ describe('sweepStaleForgeWorkspaces', () => { }) test('removes cancelled workspace (remove-registration-only)', async () => { - const { v2, pendingTeardowns, logger, loopsRepo, workspaceRemove } = await createSweepDeps({ + let removeCalledWith: any = null + const { client, pendingTeardowns, logger, loopsRepo } = await createSweepDeps({ workspaceListResult: [ { id: 'ws-cancelled', @@ -174,6 +175,7 @@ describe('sweepStaleForgeWorkspaces', () => { }, }, ], + workspaceRemoveImpl: async (params: any) => { removeCalledWith = params }, loopsRepoGet: (pid, name) => { if (pid === projectId && name === 'cancelled-loop') { return { projectId: pid, loopName: name, status: 'cancelled' } @@ -183,14 +185,14 @@ describe('sweepStaleForgeWorkspaces', () => { }) const report = await sweepStaleForgeWorkspaces( - { v2: v2 as any, pendingTeardowns, logger, loopsRepo }, + { client: client as unknown as ForgeClient, pendingTeardowns, logger, loopsRepo }, { projectId, projectDirectory }, ) expect(report.swept).toEqual([ { loopName: 'cancelled-loop', workspaceId: 'ws-cancelled', action: 'remove-registration-only' }, ]) - expect(workspaceRemove).toHaveBeenCalledWith({ id: 'ws-cancelled' }) + expect(removeCalledWith).toEqual({ id: 'ws-cancelled' }) // Verify pendingTeardowns was set with doRemoveWorktree: false expect(pendingTeardowns.set).toHaveBeenCalledWith( @@ -200,7 +202,7 @@ describe('sweepStaleForgeWorkspaces', () => { }) test('skips running workspace', async () => { - const { v2, pendingTeardowns, logger, loopsRepo, workspaceRemove } = await createSweepDeps({ + const { client, pendingTeardowns, logger, loopsRepo } = await createSweepDeps({ workspaceListResult: [ { id: 'ws-running', @@ -220,17 +222,16 @@ describe('sweepStaleForgeWorkspaces', () => { }) const report = await sweepStaleForgeWorkspaces( - { v2: v2 as any, pendingTeardowns, logger, loopsRepo }, + { client: client as unknown as ForgeClient, pendingTeardowns, logger, loopsRepo }, { projectId, projectDirectory }, ) expect(report.swept).toEqual([]) expect(report.skipped).toEqual([{ workspaceId: 'ws-running', reason: 'running' }]) - expect(workspaceRemove).not.toHaveBeenCalled() }) test('skips cross-project workspace', async () => { - const { v2, pendingTeardowns, logger, loopsRepo, workspaceRemove } = await createSweepDeps({ + const { client, pendingTeardowns, logger, loopsRepo } = await createSweepDeps({ workspaceListResult: [ { id: 'ws-cross', @@ -244,17 +245,17 @@ describe('sweepStaleForgeWorkspaces', () => { }) const report = await sweepStaleForgeWorkspaces( - { v2: v2 as any, pendingTeardowns, logger, loopsRepo }, + { client: client as unknown as ForgeClient, pendingTeardowns, logger, loopsRepo }, { projectId, projectDirectory }, ) expect(report.swept).toEqual([]) expect(report.skipped).toEqual([{ workspaceId: 'ws-cross', reason: 'wrong-project' }]) - expect(workspaceRemove).not.toHaveBeenCalled() }) test('removes missing-row workspace (remove-fully)', async () => { - const { v2, pendingTeardowns, logger, loopsRepo, workspaceRemove } = await createSweepDeps({ + let removeCalledWith: any = null + const { client, pendingTeardowns, logger, loopsRepo } = await createSweepDeps({ workspaceListResult: [ { id: 'ws-missing', @@ -265,22 +266,23 @@ describe('sweepStaleForgeWorkspaces', () => { }, }, ], + workspaceRemoveImpl: async (params: any) => { removeCalledWith = params }, loopsRepoGet: vi.fn().mockReturnValue(null), }) const report = await sweepStaleForgeWorkspaces( - { v2: v2 as any, pendingTeardowns, logger, loopsRepo }, + { client: client as unknown as ForgeClient, pendingTeardowns, logger, loopsRepo }, { projectId, projectDirectory }, ) expect(report.swept).toEqual([ { loopName: 'missing-loop', workspaceId: 'ws-missing', action: 'remove-fully' }, ]) - expect(workspaceRemove).toHaveBeenCalledWith({ id: 'ws-missing' }) + expect(removeCalledWith).toEqual({ id: 'ws-missing' }) }) test('keeps missing-row TUI workspace during pending attach grace window', async () => { - const { v2, pendingTeardowns, logger, loopsRepo, workspaceRemove } = await createSweepDeps({ + const { client, pendingTeardowns, logger, loopsRepo } = await createSweepDeps({ workspaceListResult: [ { id: 'ws-pending', @@ -299,17 +301,16 @@ describe('sweepStaleForgeWorkspaces', () => { }) const report = await sweepStaleForgeWorkspaces( - { v2: v2 as any, pendingTeardowns, logger, loopsRepo }, + { client: client as unknown as ForgeClient, pendingTeardowns, logger, loopsRepo }, { projectId, projectDirectory }, ) expect(report.swept).toEqual([]) expect(report.skipped).toEqual([{ workspaceId: 'ws-pending', reason: 'pending-attach' }]) - expect(workspaceRemove).not.toHaveBeenCalled() }) test('keeps missing-row freshly created server workspace during pending start grace window', async () => { - const { v2, pendingTeardowns, logger, loopsRepo, workspaceRemove } = await createSweepDeps({ + const { client, pendingTeardowns, logger, loopsRepo } = await createSweepDeps({ workspaceListResult: [ { id: 'ws-pending-start', @@ -325,17 +326,17 @@ describe('sweepStaleForgeWorkspaces', () => { }) const report = await sweepStaleForgeWorkspaces( - { v2: v2 as any, pendingTeardowns, logger, loopsRepo }, + { client: client as unknown as ForgeClient, pendingTeardowns, logger, loopsRepo }, { projectId, projectDirectory }, ) expect(report.swept).toEqual([]) expect(report.skipped).toEqual([{ workspaceId: 'ws-pending-start', reason: 'pending-start' }]) - expect(workspaceRemove).not.toHaveBeenCalled() }) test('mixed scenario: completed, cancelled, running, missing-row, cross-project', async () => { - const { v2, pendingTeardowns, logger, loopsRepo, workspaceRemove } = await createSweepDeps({ + const removeCalls: any[] = [] + const { client, pendingTeardowns, logger, loopsRepo } = await createSweepDeps({ workspaceListResult: [ { id: 'ws-completed', @@ -363,6 +364,7 @@ describe('sweepStaleForgeWorkspaces', () => { extra: { loopName: 'cross-loop', projectDirectory: '/tmp/other' }, }, ], + workspaceRemoveImpl: async (params: any) => { removeCalls.push(params) }, loopsRepoGet: (pid, name) => { if (name === 'completed-loop') return { projectId: pid, loopName: name, status: 'completed' } if (name === 'cancelled-loop') return { projectId: pid, loopName: name, status: 'cancelled' } @@ -372,7 +374,7 @@ describe('sweepStaleForgeWorkspaces', () => { }) const report = await sweepStaleForgeWorkspaces( - { v2: v2 as any, pendingTeardowns, logger, loopsRepo }, + { client: client as unknown as ForgeClient, pendingTeardowns, logger, loopsRepo }, { projectId, projectDirectory }, ) @@ -392,15 +394,10 @@ describe('sweepStaleForgeWorkspaces', () => { ]), ) expect(report.failed).toEqual([]) - expect(workspaceRemove).toHaveBeenCalledTimes(3) + expect(removeCalls.length).toBe(3) }) test('failure isolation: one failed removal does not abort the sweep', async () => { - const workspaceRemove = vi.fn() - .mockResolvedValueOnce({ data: {} }) // first succeeds - .mockRejectedValueOnce(new Error('boom')) // second fails - .mockResolvedValueOnce({ data: {} }) // third succeeds - const pendingTeardowns = createMockPendingTeardowns() const logger = createMockLogger() const loopsRepo = createMockLoopsRepo({ @@ -412,54 +409,27 @@ describe('sweepStaleForgeWorkspaces', () => { }), }) - const v2 = { - experimental: { - workspace: { - list: vi.fn().mockResolvedValue({ - data: [ - { id: 'ws1', type: 'forge', extra: { loopName: 'loop1', projectDirectory } }, - { id: 'ws2', type: 'forge', extra: { loopName: 'loop2', projectDirectory } }, - { id: 'ws3', type: 'forge', extra: { loopName: 'loop3', projectDirectory } }, - ], - }), - remove: workspaceRemove, - }, + const { client } = createFakeForgeClient({ + workspace: { + list: async () => [ + { id: 'ws1', type: 'forge', extra: { loopName: 'loop1', projectDirectory } }, + { id: 'ws2', type: 'forge', extra: { loopName: 'loop2', projectDirectory } }, + { id: 'ws3', type: 'forge', extra: { loopName: 'loop3', projectDirectory } }, + ] as any, + remove: (async (params: any) => { + if ((params as any)?.id === 'ws2') throw new Error('boom') + }) as any, }, - } + }) const report = await sweepStaleForgeWorkspaces( - { v2: v2 as any, pendingTeardowns, logger, loopsRepo }, + { client: client as unknown as ForgeClient, pendingTeardowns, logger, loopsRepo }, { projectId, projectDirectory }, ) expect(report.swept.length).toBe(2) expect(report.failed.length).toBe(1) expect(report.failed[0].workspaceId).toBe('ws2') - expect(workspaceRemove).toHaveBeenCalledTimes(3) - }) - - test('workspace.list not available returns empty report', async () => { - const pendingTeardowns = createMockPendingTeardowns() - const logger = createMockLogger() - const loopsRepo = createMockLoopsRepo() - - const v2 = { - experimental: { - workspace: {}, - }, - } - - const report = await sweepStaleForgeWorkspaces( - { v2: v2 as any, pendingTeardowns, logger, loopsRepo }, - { projectId, projectDirectory }, - ) - - expect(report.swept).toEqual([]) - expect(report.skipped).toEqual([]) - expect(report.failed).toEqual([]) - expect(logger.error).toHaveBeenCalledWith( - expect.stringContaining('experimental.workspace.list not available'), - ) }) test('workspace.list throws returns empty report', async () => { @@ -467,16 +437,14 @@ describe('sweepStaleForgeWorkspaces', () => { const logger = createMockLogger() const loopsRepo = createMockLoopsRepo() - const v2 = { - experimental: { - workspace: { - list: vi.fn().mockRejectedValue(new Error('list failed')), - }, + const { client } = createFakeForgeClient({ + workspace: { + list: async () => { throw new Error('list failed') }, }, - } + }) const report = await sweepStaleForgeWorkspaces( - { v2: v2 as any, pendingTeardowns, logger, loopsRepo }, + { client: client as unknown as ForgeClient, pendingTeardowns, logger, loopsRepo }, { projectId, projectDirectory }, ) @@ -485,29 +453,24 @@ describe('sweepStaleForgeWorkspaces', () => { expect(report.failed).toEqual([]) }) - test('workspace.remove returns error is captured in failed', async () => { - const workspaceRemove = vi.fn().mockResolvedValue({ error: 'remove failed' }) + test('workspace.remove throws is captured in failed', async () => { const pendingTeardowns = createMockPendingTeardowns() const logger = createMockLogger() const loopsRepo = createMockLoopsRepo({ get: vi.fn().mockReturnValue({ projectId, loopName: 'failed-loop', status: 'completed' }), }) - const v2 = { - experimental: { - workspace: { - list: vi.fn().mockResolvedValue({ - data: [ - { id: 'ws-failed', type: 'forge', extra: { loopName: 'failed-loop', projectDirectory } }, - ], - }), - remove: workspaceRemove, - }, + const { client } = createFakeForgeClient({ + workspace: { + list: async () => [ + { id: 'ws-failed', type: 'forge', extra: { loopName: 'failed-loop', projectDirectory } }, + ] as any, + remove: async () => { throw new Error('remove failed') }, }, - } + }) const report = await sweepStaleForgeWorkspaces( - { v2: v2 as any, pendingTeardowns, logger, loopsRepo }, + { client: client as unknown as ForgeClient, pendingTeardowns, logger, loopsRepo }, { projectId, projectDirectory }, ) diff --git a/vitest.config.ts b/vitest.config.ts index bf3e5e397b..8851522856 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -85,6 +85,8 @@ export default defineConfig({ 'test/storage-migrations.test.ts', 'test/worktree-log.test.ts', 'test/plugin.test.ts', + 'test/client/sdk-adapter.test.ts', + 'test/helpers/fake-client.test.ts', ], globals: true, }, From e086ad565e82723a0d83a84b682db333168c7eb5 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Sat, 13 Jun 2026 14:38:32 -0400 Subject: [PATCH 2/2] refactor: clean up client references after typed client port --- src/client/sdk-adapter.ts | 6 +- src/index.ts | 2 +- src/services/execution.ts | 12 +-- test/helpers/fake-client.ts | 103 --------------------- test/hooks/audit-rotate-ordering.test.ts | 90 +++++++----------- test/services/execution.start-loop.test.ts | 2 +- test/watchdog.test.ts | 9 +- 7 files changed, 43 insertions(+), 181 deletions(-) diff --git a/src/client/sdk-adapter.ts b/src/client/sdk-adapter.ts index 6bbe84a34e..ae75bb92eb 100644 --- a/src/client/sdk-adapter.ts +++ b/src/client/sdk-adapter.ts @@ -1,7 +1,6 @@ import type { OpencodeClient } from '@opencode-ai/sdk/v2' import { createOpencodeClient as createV2Client } from '@opencode-ai/sdk/v2' import type { PluginInput } from '@opencode-ai/plugin' -import type { Logger } from '../types' import { ForgeClientError, type ForgeClient, type ForgeClientErrorKind } from './port' // ── Error classification ───────────────────────────────────────────────────── @@ -81,7 +80,7 @@ async function withVoid( // ── Factory ────────────────────────────────────────────────────────────────── -export function createForgeClient(v2: OpencodeClient, _logger?: Logger | Console): ForgeClient { +export function createForgeClient(v2: OpencodeClient): ForgeClient { // ── session namespace ──────────────────────────────────────────────────── const session: ForgeClient['session'] = { create: (params) => withData('session.create', v2.session.create(params)), @@ -165,9 +164,8 @@ export function createForgeClient(v2: OpencodeClient, _logger?: Logger | Console */ export function createForgeClientFromPluginInput( pluginInput: PluginInput, - _logger?: Logger | Console, ): ForgeClient { - return createForgeClient(createV2ClientFromPluginInput(pluginInput), _logger) + return createForgeClient(createV2ClientFromPluginInput(pluginInput)) } // ── Legacy client adapter ──────────────────────────────────────────────────── diff --git a/src/index.ts b/src/index.ts index 1106302acb..ff374b10dc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -195,7 +195,7 @@ export function createForgePlugin(config: PluginConfig): Plugin { }) logger.log(`Initializing plugin for directory: ${directory}, projectId: ${projectId}`) - const forgeClient = createForgeClientFromPluginInput(input, logger) + const forgeClient = createForgeClientFromPluginInput(input) const dataDir = config.dataDir || resolveDataDir() diff --git a/src/services/execution.ts b/src/services/execution.ts index 0c1e4a471a..11c2e60c47 100644 --- a/src/services/execution.ts +++ b/src/services/execution.ts @@ -872,8 +872,6 @@ export async function attachLoopToSession( // ============================================================================ export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): ForgeExecutionService { - const _fc: ForgeClient = deps.client - const inFlightLoopStarts = new Map>>() function hashPlanForDedupe(text: string): string { let h = 5381 @@ -1102,7 +1100,7 @@ export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): Fo sandboxContainer = null } if (createdWorkspaceId) { - await _fc.workspace.remove({ id: createdWorkspaceId }).catch(() => {}) + await deps.client.workspace.remove({ id: createdWorkspaceId }).catch(() => {}) } if (hostWorktreeDir) { const { cleanupLoopWorktree } = await import('../utils/worktree-cleanup') @@ -1140,7 +1138,7 @@ export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): Fo // Create builtin worktree workspace (single call — no separate worktree.create) const { createBuiltinWorktreeWorkspace } = await import('../workspace/forge-worktree') - const ws = await createBuiltinWorktreeWorkspace(_fc, { + const ws = await createBuiltinWorktreeWorkspace(deps.client, { loopName: uniqueLoopName, directory: ctx.directory, }, deps.logger, deps.workspaceStatusRegistry) @@ -1161,7 +1159,7 @@ export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): Fo // Create single code session const createResult = await createLoopSessionWithWorkspace({ - client: _fc, + client: deps.client, title: sessionTitle, directory: hostWorktreeDir!, permission: permissionRuleset, @@ -1558,7 +1556,7 @@ export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): Fo if (stoppedState.worktree) { const { createBuiltinWorktreeWorkspace } = await import('../workspace/forge-worktree') - const ws = await createBuiltinWorktreeWorkspace(_fc, { + const ws = await createBuiltinWorktreeWorkspace(deps.client, { loopName: stoppedState.loopName, directory: stoppedState.projectDir || ctx.directory, }, deps.logger, deps.workspaceStatusRegistry) @@ -1580,7 +1578,7 @@ export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): Fo // Unified session creation for restart (always a single code session) const createResult = await createLoopSessionWithWorkspace({ - client: _fc, + client: deps.client, title: formatLoopSessionTitle(stoppedState.loopName, { iteration: stoppedState.iteration ?? 0, currentSectionIndex: stoppedState.currentSectionIndex ?? 0, diff --git a/test/helpers/fake-client.ts b/test/helpers/fake-client.ts index cc40d3c29b..cfa452ed09 100644 --- a/test/helpers/fake-client.ts +++ b/test/helpers/fake-client.ts @@ -191,106 +191,3 @@ export function createFakeForgeClient( return { client, calls } } - -/** - * Convenience: create a mock ForgeClient from an existing v2-style mock that - * has `session.*`, `tui.*`, `experimental.workspace.*` methods. This lets - * tests that already have a hand-rolled `mockV2Client` also satisfy the - * `client: ForgeClient` field without rewriting every assertion. - * - * The returned object delegates calls to the v2 mock's methods, stripping the - * `{ data, error }` envelope so the ForgeClient contract (data-or-throw) is - * upheld. - */ -type V2Mock = { - session?: Record - tui?: Record - experimental?: { workspace?: Record } -} - -function unwrapResult(result: any): any { - if (result?.error) throw result.error - if (result?.data === undefined || result?.data === null) throw Object.assign(new Error('not found'), { kind: 'not-found' }) - return result.data -} - -export function forgeClientFromV2Mock(v2Mock: V2Mock): ForgeClient { - const ws = v2Mock.experimental?.workspace ?? {} - const sess = v2Mock.session ?? {} - const tuiNs = v2Mock.tui ?? {} - return { - session: { - create: async (params: any) => { - if (!sess.create) throw new Error('session.create not available') - return unwrapResult(await sess.create(params)) - }, - get: async (params: any) => { - if (!sess.get) throw new Error('session.get not available') - return unwrapResult(await sess.get(params)) - }, - update: async (params: any) => { - if (!sess.update) throw new Error('session.update not available') - return unwrapResult(await sess.update(params)) - }, - messages: async (params: any) => { - if (!sess.messages) throw new Error('session.messages not available') - return unwrapResult(await sess.messages(params)) - }, - status: async (params: any) => { - if (!sess.status) throw new Error('session.status not available') - return unwrapResult(await sess.status(params)) - }, - promptAsync: async (params: any) => { - if (!sess.promptAsync) throw new Error('session.promptAsync not available') - const result = await sess.promptAsync(params) - if (result?.error) throw result.error - }, - abort: async (params: any) => { - if (!sess.abort) throw new Error('session.abort not available') - await sess.abort(params) - }, - delete: async (params: any) => { - if (!sess.delete) throw new Error('session.delete not available') - await sess.delete(params) - }, - }, - workspace: { - create: async (params: any) => { - if (!ws.create) throw new Error('workspace.create not available') - return unwrapResult(await ws.create(params)) - }, - list: async (params?: any) => { - if (!ws.list) throw new Error('workspace.list not available') - return unwrapResult(await ws.list(params)) ?? [] - }, - status: async (params?: any) => { - if (!ws.status) throw new Error('workspace.status not available') - return unwrapResult(await ws.status(params)) ?? {} - }, - syncList: async (params?: any) => { - if (!ws.syncList) throw new Error('workspace.syncList not available') - await ws.syncList(params) - }, - remove: async (params: any) => { - if (!ws.remove) throw new Error('workspace.remove not available') - await ws.remove(params) - }, - warp: async (params: any) => { - if (!ws.warp) throw new Error('workspace.warp not available') - const result = await ws.warp(params) - if (result?.error) throw result.error - }, - }, - tui: { - publish: async (params: any) => { - if (!tuiNs.publish) throw new Error('tui.publish not available') - await tuiNs.publish(params) - }, - selectSession: async (params: any) => { - if (!tuiNs.selectSession) throw new Error('tui.selectSession not available') - await tuiNs.selectSession(params) - }, - }, - sync: {} as any, - } -} diff --git a/test/hooks/audit-rotate-ordering.test.ts b/test/hooks/audit-rotate-ordering.test.ts index aaaae7041c..45899972f2 100644 --- a/test/hooks/audit-rotate-ordering.test.ts +++ b/test/hooks/audit-rotate-ordering.test.ts @@ -8,7 +8,7 @@ import { createReviewFindingsRepo } from '../../src/storage/repos/review-finding import { createLoopService } from '../../src/loop/service' import type { Logger } from '../../src/types' import type { LoopState } from '../../src/loop/state' -import type { ForgeClient } from '../../src/client/port' +import { createFakeForgeClient } from '../helpers/fake-client' interface Database { run: (sql: string) => void @@ -269,51 +269,27 @@ function createMockDatabase(): Database { } } -interface CallRecord { - kind: string - args: unknown -} - /** - * Build a ForgeClient from the same tracker so all call records are unified. + * Build a ForgeClient fake whose recorded calls are unified, so call ordering + * across namespaces can be asserted. * @param lastRole - The role of the last message in the messages response ('assistant' or 'user') */ -function forgeFromTracker(tracker: CallRecord[], lastRole: 'assistant' | 'user' = 'assistant'): ForgeClient { - return { +function makeRotationFake(lastRole: 'assistant' | 'user' = 'assistant') { + return createFakeForgeClient({ session: { - create: vi.fn(async (params: any) => { tracker.push({ kind: 'create', args: params }); return { id: 'new-code-1' } }), - get: vi.fn(async () => ({ id: 'sess' })), - update: vi.fn(async () => {}), - messages: vi.fn(async (params: any) => { - tracker.push({ kind: 'messages', args: params }) - return [ - { info: { role: 'user' }, parts: [{ type: 'text', text: 'test' }] }, - ...(lastRole === 'assistant' - ? [{ info: { role: 'assistant' as const, finish: 'stop' as const }, parts: [{ type: 'text' as const, text: 'response' }] }] - : []), - ] - }), - status: vi.fn(async () => ({})), - promptAsync: vi.fn(async (params: any) => { tracker.push({ kind: 'prompt', args: params }) }), - abort: vi.fn(async () => {}), - delete: vi.fn(async (params: any) => { tracker.push({ kind: 'delete', args: params }) }), + create: async () => ({ id: 'new-code-1' }), + get: async () => ({ id: 'sess' }), + messages: async () => [ + { info: { role: 'user' }, parts: [{ type: 'text', text: 'test' }] }, + ...(lastRole === 'assistant' + ? [{ info: { role: 'assistant' as const, finish: 'stop' as const }, parts: [{ type: 'text' as const, text: 'response' }] }] + : []), + ], }, workspace: { - create: vi.fn(async (params: any) => { tracker.push({ kind: 'workspace-create', args: params }); return { id: 'ws-1' } }), - list: vi.fn(async () => []), - status: vi.fn(async () => ({})), - syncList: vi.fn(async () => {}), - remove: vi.fn(async () => {}), - warp: vi.fn(async (params: any) => { tracker.push({ kind: 'restore', args: params }) }), + create: async () => ({ id: 'ws-1' }), }, - tui: { - publish: vi.fn(async () => {}), - selectSession: vi.fn(async () => {}), - }, - sync: { - start: vi.fn(async () => {}), - }, - } as unknown as ForgeClient + }) } describe('audit→code rotation ordering', () => { @@ -323,7 +299,6 @@ describe('audit→code rotation ordering', () => { let reviewFindingsRepo: ReturnType let loopService: ReturnType let tempDir: string - let callTracker: CallRecord[] const projectId = 'test-project' const mockLogger: Logger = { @@ -347,7 +322,6 @@ describe('audit→code rotation ordering', () => { reviewFindingsRepo = createReviewFindingsRepo(db as any) loopService = createLoopService(loopsRepo, plansRepo, reviewFindingsRepo, projectId, mockLogger) - callTracker = [] }) afterEach(() => { @@ -405,7 +379,7 @@ describe('audit→code rotation ordering', () => { // Create mock that returns successful assistant response to exercise the // successful audit→code rotation path (not the error continuation path) - const successCallTracker: CallRecord[] = [] + const { client, calls } = makeRotationFake() // Use default successful assistant response (no error) to test the // !assistantErrorDetected branch in handleAuditingPhase @@ -415,7 +389,7 @@ describe('audit→code rotation ordering', () => { plansRepo, reviewFindingsRepo, projectId, - forgeFromTracker(successCallTracker), + client, mockLogger, () => ({ loop: { model: 'test/test-model' } }), undefined, @@ -432,10 +406,10 @@ describe('audit→code rotation ordering', () => { }, }) - const createIndex = successCallTracker.findIndex(c => c.kind === 'create') - const restoreIndex = successCallTracker.findIndex(c => c.kind === 'restore') - const deleteIndex = successCallTracker.findIndex(c => - c.kind === 'delete' && (c.args as any).sessionID === 'audit-1' + const createIndex = calls.findIndex(c => c.method === 'session.create') + const restoreIndex = calls.findIndex(c => c.method === 'workspace.warp') + const deleteIndex = calls.findIndex(c => + c.method === 'session.delete' && (c.params as any).sessionID === 'audit-1' ) expect(createIndex).toBeGreaterThanOrEqual(0) @@ -446,9 +420,9 @@ describe('audit→code rotation ordering', () => { expect(createIndex).toBeLessThan(restoreIndex) expect(restoreIndex).toBeLessThan(deleteIndex) - const restoreCall = successCallTracker.find(c => c.kind === 'restore') - expect((restoreCall?.args as any).id).toBe('ws-1') - expect((restoreCall?.args as any).sessionID).toBe('new-code-1') + const restoreCall = calls.find(c => c.method === 'workspace.warp') + expect((restoreCall?.params as any).id).toBe('ws-1') + expect((restoreCall?.params as any).sessionID).toBe('new-code-1') }) test('audit failure rotation: create→bind→delete order', async () => { @@ -484,7 +458,7 @@ describe('audit→code rotation ordering', () => { loopService.registerLoopSession('audit-fail-1', loopName) // Create a separate mock for failure path - no assistant message so it triggers rotation - const failureCallTracker: CallRecord[] = [] + const { client, calls } = makeRotationFake('user') const { createLoopEventHandler } = await import('../../src/hooks/loop') const handler = createLoopEventHandler( @@ -492,7 +466,7 @@ describe('audit→code rotation ordering', () => { plansRepo, reviewFindingsRepo, projectId, - forgeFromTracker(failureCallTracker, 'user'), + client, mockLogger, () => ({ loop: { model: 'test/test-model' } }), undefined, @@ -509,10 +483,10 @@ describe('audit→code rotation ordering', () => { }, }) - const createIndex = failureCallTracker.findIndex(c => c.kind === 'create') - const restoreIndex = failureCallTracker.findIndex(c => c.kind === 'restore') - const deleteIndex = failureCallTracker.findIndex(c => - c.kind === 'delete' && (c.args as any).sessionID === 'audit-fail-1' + const createIndex = calls.findIndex(c => c.method === 'session.create') + const restoreIndex = calls.findIndex(c => c.method === 'workspace.warp') + const deleteIndex = calls.findIndex(c => + c.method === 'session.delete' && (c.params as any).sessionID === 'audit-fail-1' ) expect(createIndex).toBeGreaterThanOrEqual(0) @@ -523,7 +497,7 @@ describe('audit→code rotation ordering', () => { expect(createIndex).toBeLessThan(restoreIndex) expect(restoreIndex).toBeLessThan(deleteIndex) - const restoreCall = failureCallTracker.find(c => c.kind === 'restore') - expect((restoreCall?.args as any).id).toBe('ws-1') + const restoreCall = calls.find(c => c.method === 'workspace.warp') + expect((restoreCall?.params as any).id).toBe('ws-1') }) }) diff --git a/test/services/execution.start-loop.test.ts b/test/services/execution.start-loop.test.ts index dec123797f..76f9a09ae6 100644 --- a/test/services/execution.start-loop.test.ts +++ b/test/services/execution.start-loop.test.ts @@ -677,7 +677,7 @@ describe('handleStartLoop select-session ordering', () => { return { client, mockLoopHandler, mockSandboxManager, resolveSelect, rejectSelect, selectPromise } } - test('onStarted fires only after selectSessionWithFallback resolves', async () => { + test('onStarted fires only after selectSessionBestEffort resolves', async () => { const tempDir = mkdtempSync(join(tmpdir(), 'exec-ordering-resolve-')) const db = new Database(join(tempDir, 'test.db')) setupLoopsTestDb(db) diff --git a/test/watchdog.test.ts b/test/watchdog.test.ts index 1fdd4b9674..3ab0905674 100644 --- a/test/watchdog.test.ts +++ b/test/watchdog.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from 'bun:test' import { createLoopWatchdog } from '../src/hooks/watchdog' import type { LoopState } from '../src/loop/state' +import { createFakeForgeClient } from './helpers/fake-client' function createState(overrides?: Partial): LoopState { return { @@ -54,13 +55,7 @@ function createMockLoopService(overrides?: { } function createMockClient(statusImpl: () => Promise) { - const stub = async () => undefined as any - return { - session: { create: stub, get: stub, update: stub, messages: stub, status: statusImpl, promptAsync: stub, abort: stub, delete: stub }, - workspace: { create: stub, list: stub, status: stub, syncList: stub, remove: stub, warp: stub }, - tui: { publish: stub, selectSession: stub }, - sync: { start: stub }, - } + return createFakeForgeClient({ session: { status: statusImpl } }).client } describe('createLoopWatchdog', () => {