diff --git a/bun.lock b/bun.lock index ba73638d09..d28b4c38d2 100644 --- a/bun.lock +++ b/bun.lock @@ -20,7 +20,7 @@ "@typescript-eslint/eslint-plugin": "^8.58.0", "@typescript-eslint/parser": "^8.58.0", "better-sqlite3": "^12.9.0", - "bun-types": "latest", + "bun-types": "^1.3.14", "eslint": "^10.2.0", "eslint-plugin-solid": "^0.14.5", "solid-js": "^1.9.12", @@ -429,7 +429,7 @@ "bun-ffi-structs": ["bun-ffi-structs@0.1.2", "", { "peerDependencies": { "typescript": "5.9.3" } }, "sha512-Lh1oQAYHDcnesJauieA4UNkWGXY9hYck7OA5IaRwE3Bp6K2F2pJSNYqq+hIy7P3uOvo3km3oxS8304g5gDMl/w=="], - "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "25.5.0" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], "bun-webgpu": ["bun-webgpu@0.1.5", "", { "dependencies": { "@webgpu/types": "0.1.69" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "0.1.6", "bun-webgpu-darwin-x64": "0.1.6", "bun-webgpu-linux-x64": "0.1.6", "bun-webgpu-win32-x64": "0.1.6" } }, "sha512-91/K6S5whZKX7CWAm9AylhyKrLGRz6BUiiPiM/kXadSnD4rffljCD/q9cNFftm5YXhx4MvLqw33yEilxogJvwA=="], @@ -921,8 +921,6 @@ "babel-plugin-jsx-dom-expressions/@babel/helper-module-imports": ["@babel/helper-module-imports@7.18.6", "", { "dependencies": { "@babel/types": "7.29.0" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="], - "bun-types/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "7.18.2" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], - "eslint/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "glob/minimatch": ["minimatch@8.0.7", "", { "dependencies": { "brace-expansion": "2.0.3" } }, "sha512-V+1uQNdzybxa14e/p00HZnQNNcTjnRJjDxg2V8wtkjFctq4M7hXFws4oekyTP0Jebeq7QYtpFyOeBAjc88zvYg=="], @@ -965,8 +963,6 @@ "@opentui/keymap/@opentui/core/diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="], - "bun-types/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], - "glob/minimatch/brace-expansion": ["brace-expansion@2.0.3", "", { "dependencies": { "balanced-match": "1.0.2" } }, "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA=="], "pkg-up/find-up/locate-path": ["locate-path@3.0.0", "", { "dependencies": { "p-locate": "3.0.0", "path-exists": "3.0.0" } }, "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A=="], diff --git a/package.json b/package.json index 32ac674d23..ed5776dee8 100644 --- a/package.json +++ b/package.json @@ -74,7 +74,7 @@ "@typescript-eslint/eslint-plugin": "^8.58.0", "@typescript-eslint/parser": "^8.58.0", "better-sqlite3": "^12.9.0", - "bun-types": "latest", + "bun-types": "^1.3.14", "eslint": "^10.2.0", "eslint-plugin-solid": "^0.14.5", "solid-js": "^1.9.12", diff --git a/src/dashboard/launch.ts b/src/dashboard/launch.ts index 84c61dc76c..1c0743634c 100644 --- a/src/dashboard/launch.ts +++ b/src/dashboard/launch.ts @@ -1,8 +1,6 @@ -import { Database } from 'bun:sqlite' import { existsSync } from 'fs' -import { join } from 'path' import { platform } from 'os' -import { resolveDataDir } from '../storage/database' +import { resolveForgeDbPath, openForgeDatabaseReadonly } from '../storage/database' import { createRequestHandler } from './server' export interface DashboardServerHandle { @@ -23,7 +21,7 @@ const DEFAULT_MAX_ATTEMPTS = 10 export function resolveDashboardDbPath(explicit?: string): string { if (explicit) return explicit if (process.env.FORGE_DB) return process.env.FORGE_DB - return join(resolveDataDir(), 'forge.db') + return resolveForgeDbPath() } function isAddrInUse(err: unknown): boolean { @@ -48,7 +46,7 @@ export function startDashboardServer(options: StartDashboardOptions = {}): Dashb const basePort = options.port ?? DEFAULT_PORT const maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS - const db = new Database(dbPath, { readonly: true }) + const db = openForgeDatabaseReadonly(dbPath) const handler = createRequestHandler(db) for (let attempt = 0; attempt < maxAttempts; attempt++) { diff --git a/src/hooks/plan-approval.ts b/src/hooks/plan-approval.ts index 0a718fdfca..e16cc1d7de 100644 --- a/src/hooks/plan-approval.ts +++ b/src/hooks/plan-approval.ts @@ -2,6 +2,7 @@ import type { ToolContext } from '../tools/types' import type { Hooks } from '@opencode-ai/plugin' import { parseModelString, retryWithModelFallback } from '../utils/model-fallback' import { extractPlanExecutionMetadata, PLAN_EXECUTION_LABELS, type PlanExecutionLabel } from '../utils/plan-execution' +import { hashPlanText } from '../utils/plan-hash' import { buildStartLoopCommand, createForgeExecutionService, type ForgeExecutionRequestContext } from '../services/execution' function publishPlanApprovalToast( @@ -115,14 +116,6 @@ async function resolveBlockedLoopToolState( return null } -function hashApprovalPlan(planText: string): string { - let hash = 5381 - for (let i = 0; i < planText.length; i += 1) { - hash = ((hash << 5) + hash) ^ planText.charCodeAt(i) - } - return (hash >>> 0).toString(36) -} - function claimApprovalCall(ctx: ToolContext, input: { sessionID: string }, label: string, planKey: string): boolean { let processed = processedApprovalCalls.get(ctx) if (!processed) { @@ -145,7 +138,7 @@ function resolveCurrentSessionPlan(ctx: ToolContext, sessionID: string): { conte if (!plan) return null return { content: plan.content, - key: hashApprovalPlan(plan.content), + key: hashPlanText(plan.content), } } diff --git a/src/hooks/plan-capture.ts b/src/hooks/plan-capture.ts index 3a24ae2e16..8bacffc54e 100644 --- a/src/hooks/plan-capture.ts +++ b/src/hooks/plan-capture.ts @@ -1,6 +1,9 @@ import type { ToolContext } from '../tools/types' import { captureLatestPlanForSession, captureMarkedPlanTextForSession } from '../services/plan-capture' import { PLAN_END_MARKER, PLAN_START_MARKER } from '../utils/marked-plan-parser' +import { PLAN_EXECUTION_LABELS } from '../utils/plan-execution' +import { hashPlanText } from '../utils/plan-hash' +import { promptAgentViaClientThenV2 } from '../utils/prompt-agent' const MESSAGE_PART_UPDATED_EVENT = 'message.part.updated' const MESSAGE_UPDATED_EVENT = 'message.updated' @@ -25,6 +28,22 @@ function isMessageUpdatedEvent(event: PlanCaptureEvent): event is MessageUpdated return event.type === MESSAGE_UPDATED_EVENT } +const PLAN_KEY_CAP = 1000 + +// Caps an in-memory map so a long-lived process cannot grow it without bound; +// clearing on overflow only risks an occasional duplicate prompt. +function trackEntry(map: Map, key: K, value: V): void { + if (map.size > PLAN_KEY_CAP) map.clear() + map.set(key, value) +} + +// Dedupes architect prompts by `${sessionID}:${planHash}`. +const promptedPlanKeys = new Set() +// Plans captured by the streaming branch, keyed by `${sessionID}:${messageID}`, +// awaiting the message's completion so the (role-aware) completion handler can +// prompt the architect without re-reading the conversation. +const pendingUserPastePlans = new Map() + export function createPlanCaptureEventHook(ctx: ToolContext) { const { v2, input: { client }, logger, plansRepo, projectId, directory } = ctx @@ -39,6 +58,15 @@ export function createPlanCaptureEventHook(ctx: ToolContext) { if (!part.text.includes(PLAN_START_MARKER)) return if (!part.text.includes(PLAN_END_MARKER)) return + // Skip capture if session has an active loop (prevents user-pasted plans + // from being captured during loops). + const loop = ctx.loop + if (loop) { + const loopName = loop.resolveLoopName(sessionID) + const state = loopName ? loop.getActiveState(loopName) : null + if (state?.active) return + } + try { const result = captureMarkedPlanTextForSession( { plansRepo, projectId, directory, logger }, @@ -47,8 +75,13 @@ export function createPlanCaptureEventHook(ctx: ToolContext) { part.messageID ) + // Stash freshly captured plans so the completion handler can prompt the + // architect once it knows the message role (this event carries no role). if (result.status === 'captured') { logger.log(`plan-capture: captured marked plan from message part for session ${sessionID}`) + if (part.messageID) { + trackEntry(pendingUserPastePlans, `${sessionID}:${part.messageID}`, result.planText) + } } else if (result.status === 'invalid') { logger.log(`plan-capture: streaming branch saw invalid plan for session ${sessionID}: ${result.reason}`) } @@ -86,6 +119,45 @@ export function createPlanCaptureEventHook(ctx: ToolContext) { } } + async function handleUserMessageCompleted(event: MessageUpdatedEvent) { + const sessionID = event.properties?.sessionID + const info = event.properties?.info + + if (!sessionID || info?.role !== 'user' || typeof info?.time?.completed !== 'number' || !info.id) return + + // The streaming branch already captured any pasted plan and stashed it under + // this message id. Now that we know the role is `user`, prompt the architect. + const stashKey = `${sessionID}:${info.id}` + const planText = pendingUserPastePlans.get(stashKey) + if (!planText) return + pendingUserPastePlans.delete(stashKey) + + try { + logger.log(`plan-capture: user-pasted plan completed for session ${sessionID}, prompting architect`) + await triggerPasteApprovalQuestion(sessionID, planText) + } catch (error) { + logCaptureError(sessionID, error) + } + } + + async function triggerPasteApprovalQuestion(sessionID: string, planText: string) { + const planKey = `${sessionID}:${hashPlanText(planText)}` + if (promptedPlanKeys.has(planKey)) { + logger.log(`plan-capture: already prompted for plan ${planKey}, skipping`) + return + } + if (promptedPlanKeys.size > PLAN_KEY_CAP) promptedPlanKeys.clear() + promptedPlanKeys.add(planKey) + + const optionsList = PLAN_EXECUTION_LABELS.join(', ') + const prompt = `A user pasted an implementation plan into this session. The plan has already been captured. Do NOT re-plan or modify it. Immediately call the \`question\` tool exactly once to ask how to execute it, with these three options as labels: ${optionsList}. Ask only this question and take no other action.` + + await promptAgentViaClientThenV2( + { legacyClient: ctx.input?.client, v2, logger, directory: ctx.directory }, + { sessionID, agent: 'architect', prompt } + ) + } + return async (eventInput: { event: PlanCaptureEvent }) => { const event = eventInput.event if (!event) return @@ -97,6 +169,7 @@ export function createPlanCaptureEventHook(ctx: ToolContext) { if (isMessageUpdatedEvent(event)) { await handleAssistantMessageCompleted(event) + await handleUserMessageCompleted(event) return } } diff --git a/src/services/execution.ts b/src/services/execution.ts index 308772ed19..19391b1be5 100644 --- a/src/services/execution.ts +++ b/src/services/execution.ts @@ -31,6 +31,7 @@ import { } from '../loop/in-flight-guard' import { getRestartability, type RestartBlockedReason } from '../loop/restartability' import { resolveHostSessionDirectory } from '../utils/resolve-project-root' +import { hashPlanText } from '../utils/plan-hash' // ============================================================================ // Surface Types - Identifies the caller boundary @@ -1062,11 +1063,6 @@ export async function attachLoopToSession( export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): ForgeExecutionService { const inFlightLoopStarts = new Map>>() - function hashPlanForDedupe(text: string): string { - let h = 5381 - for (let i = 0; i < text.length; i += 1) h = ((h << 5) + h) ^ text.charCodeAt(i) - return (h >>> 0).toString(36) - } async function handlePlanNewSession( ctx: ForgeExecutionRequestContext, @@ -1236,7 +1232,7 @@ export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): Fo const uniqueLoopName = deps.loop.generateUniqueLoopName(command.loopName ?? executionName) // In-flight dedupe: suppress concurrent starts for the same source - const dedupeKey = `${ctx.projectId}::${command.hostSessionId ?? ctx.sourceSessionId ?? ''}::${hashPlanForDedupe(planText)}` + const dedupeKey = `${ctx.projectId}::${command.hostSessionId ?? ctx.sourceSessionId ?? ''}::${hashPlanText(planText)}` const existing = inFlightLoopStarts.get(dedupeKey) if (existing) { deps.logger.log(`handleStartLoop: dedupe — concurrent start suppressed for key=${dedupeKey}`) diff --git a/src/storage/database.ts b/src/storage/database.ts index a29cda93c2..8e9c8db67b 100644 --- a/src/storage/database.ts +++ b/src/storage/database.ts @@ -23,6 +23,21 @@ export function resolveLogPath(): string { return join(resolveDataDir(), 'logs', 'forge.log') } +export function resolveForgeDbPath(): string { + return join(resolveDataDir(), 'forge.db') +} + +/** + * Opens the Forge database read-only with a busy timeout so concurrent reads + * (e.g. from the TUI or dashboard) coexist safely with the server's writer. + * The caller owns the connection and must close it. + */ +export function openForgeDatabaseReadonly(dbPath: string = resolveForgeDbPath()): Database { + const db = new Database(dbPath, { readonly: true }) + db.run('PRAGMA busy_timeout=5000') + return db +} + function runMigrations(db: Database): void { db.run(` CREATE TABLE IF NOT EXISTS migrations ( diff --git a/src/storage/index.ts b/src/storage/index.ts index f0392c0c70..b2719ef295 100644 --- a/src/storage/index.ts +++ b/src/storage/index.ts @@ -1,4 +1,4 @@ -export { initializeDatabase, closeDatabase, resolveDataDir, resolveLogPath } from './database' +export { initializeDatabase, closeDatabase, resolveDataDir, resolveLogPath, resolveForgeDbPath, openForgeDatabaseReadonly } from './database' export { createLoopsRepo } from './repos/loops-repo' export type { LoopRow } from './repos/loops-repo' diff --git a/src/tui.tsx b/src/tui.tsx index 796b913c54..a0bbe9ef79 100644 --- a/src/tui.tsx +++ b/src/tui.tsx @@ -11,6 +11,7 @@ import { ExecutePlanPanel } from './tui/execute-plan-panel' import { attachLoopSessionFollower, getCurrentRouteSessionId } from './tui/session-follow' import { openInBrowser, startDashboardServer, type DashboardServerHandle } from './dashboard/launch' import { fetchLatestPlanForSession } from './utils/plan-from-messages' +import { readPlan } from './utils/tui-plan-store' import { normalizePastedPlanText } from './utils/marked-plan-parser' type TuiKeybinds = { @@ -369,7 +370,20 @@ const tui: TuiPlugin = async (api) => { const currentClient = await ensureClient() if (!currentClient) return - const planText = await fetchLatestPlanForSession(api.client, sessionID, directory) + // Prefer the persisted captured plan (written by Phase 3 server-side + // capture) so that a plan survives message compaction, SDK limit + // changes, or transient read failures. + let planText: string | null = null + if (currentClient.projectId) { + planText = readPlan(currentClient.projectId, sessionID) + } + + // Fall back to parsing chat messages (covers legacy sessions and + // plans that were never captured server-side). + if (!planText) { + planText = await fetchLatestPlanForSession(api.client, sessionID, directory) + } + if (!planText) { api.ui.toast({ message: 'No plan in current session — paste one to execute', diff --git a/src/utils/marked-plan-parser.ts b/src/utils/marked-plan-parser.ts index bc3655d114..c8fb9cb605 100644 --- a/src/utils/marked-plan-parser.ts +++ b/src/utils/marked-plan-parser.ts @@ -130,14 +130,15 @@ export function messageText(message: PlanCaptureMessage): string { return textParts.join('\n') } -export function inspectLatestMarkedPlan(messages: PlanCaptureMessage[]): LatestMarkedPlanInspection { - const repaired = inspectLatestPlanCompletedByLaterEndMarker(messages) - if (repaired) return repaired - +function inspectLatestMarkedPlanForRole( + messages: PlanCaptureMessage[], + role: 'assistant' | 'user', + firstOnly?: boolean, +): LatestMarkedPlanInspection { for (let i = messages.length - 1; i >= 0; i--) { const message = messages[i] - if (message.info.role !== 'assistant') { + if (message.info.role !== role) { continue } @@ -159,11 +160,28 @@ export function inspectLatestMarkedPlan(messages: PlanCaptureMessage[]): LatestM messageId: message.info.id, } } + + // When firstOnly is set, stop at the first matching role message even if no + // markers are found — prevents scanning older messages of the same role. + if (firstOnly) { + return { status: 'missing' } + } } return { status: 'missing' } } +export function inspectLatestMarkedPlan(messages: PlanCaptureMessage[]): LatestMarkedPlanInspection { + const repaired = inspectLatestPlanCompletedByLaterEndMarker(messages) + if (repaired) return repaired + + return inspectLatestMarkedPlanForRole(messages, 'assistant') +} + +export function inspectLatestPastedPlan(messages: PlanCaptureMessage[]): LatestMarkedPlanInspection { + return inspectLatestMarkedPlanForRole(messages, 'user', true) +} + function inspectLatestPlanCompletedByLaterEndMarker(messages: PlanCaptureMessage[]): LatestMarkedPlanInspection | null { let latestEndOnly: { text: string; messageId?: string } | undefined diff --git a/src/utils/plan-from-messages.ts b/src/utils/plan-from-messages.ts index 30ce4e1e0e..5a11ded537 100644 --- a/src/utils/plan-from-messages.ts +++ b/src/utils/plan-from-messages.ts @@ -1,19 +1,21 @@ /** - * TUI helper that fetches the latest marked plan for a session by reading - * the assistant's chat history over the OpenCode SDK and running the same - * parser the server uses (`inspectLatestMarkedPlan`). + * TUI helper that fetches the latest plan for a session by reading chat + * history over the OpenCode SDK and running the same parser the server + * uses. Checks assistant messages first (the normal case); falls back to + * user-pasted plans so that a plan a user pasted into chat input — captured + * server-side by Phase 3 — is also discoverable when the TUI user triggers + * {@code forge.plan.execute}. * - * Used by the standalone execution-dialog trigger introduced in Phase D - * (when the in-TUI plan viewer was removed). Returning `null` covers all - * non-found cases (network failure, no messages, no marked plan, invalid - * marked plan) so the caller can toast a single "no plan in current - * session" message — the precise reason ends up in the log via the - * provided `debug` callback when supplied. + * Returning `null` covers all non-found cases (network failure, no messages, + * no marked plan, invalid marked plan) so the caller can toast a single + * "no plan in current session" message — the precise reason ends up in the + * log via the provided `debug` callback when supplied. */ import type { OpencodeClient } from '@opencode-ai/sdk/v2' import { inspectLatestMarkedPlan, + inspectLatestPastedPlan, type PlanCaptureMessage, } from './marked-plan-parser' @@ -61,15 +63,29 @@ export async function fetchLatestPlanForSession( return null } - const inspection = inspectLatestMarkedPlan(messages) - if (inspection.status === 'found') { - debug(`fetchLatestPlanForSession: found plan in session ${sessionID} (message ${inspection.messageId ?? 'unknown'})`) - return inspection.planText + // Prefer assistant-generated plans (the normal case). + const assistantInspection = inspectLatestMarkedPlan(messages) + if (assistantInspection.status === 'found') { + debug(`fetchLatestPlanForSession: found plan in session ${sessionID} (message ${assistantInspection.messageId ?? 'unknown'})`) + return assistantInspection.planText } - if (inspection.status === 'invalid') { - debug(`fetchLatestPlanForSession: invalid marked plan in session ${sessionID}: ${inspection.reason}`) + if (assistantInspection.status === 'invalid') { + debug(`fetchLatestPlanForSession: invalid marked plan in session ${sessionID}: ${assistantInspection.reason}`) return null } - debug(`fetchLatestPlanForSession: no marked plan in session ${sessionID}`) + + // Fallback: check the newest user message for a pasted plan (Phase 3 + // server-side capture writes these into user messages). + const pastedInspection = inspectLatestPastedPlan(messages) + if (pastedInspection.status === 'found') { + debug(`fetchLatestPlanForSession: found pasted plan in session ${sessionID} (message ${pastedInspection.messageId ?? 'unknown'})`) + return pastedInspection.planText + } + if (pastedInspection.status === 'invalid') { + debug(`fetchLatestPlanForSession: invalid pasted plan in session ${sessionID}: ${pastedInspection.reason}`) + return null + } + + debug(`fetchLatestPlanForSession: no marked or pasted plan in session ${sessionID}`) return null } diff --git a/src/utils/plan-hash.ts b/src/utils/plan-hash.ts new file mode 100644 index 0000000000..52a157febf --- /dev/null +++ b/src/utils/plan-hash.ts @@ -0,0 +1,11 @@ +/** + * Stable djb2-xor hash of plan text, used for dedupe/idempotency keys across + * plan capture, execution, and approval flows. Returns a base36 string. + */ +export function hashPlanText(planText: string): string { + let hash = 5381 + for (let i = 0; i < planText.length; i += 1) { + hash = ((hash << 5) + hash) ^ planText.charCodeAt(i) + } + return (hash >>> 0).toString(36) +} diff --git a/src/utils/prompt-agent.ts b/src/utils/prompt-agent.ts new file mode 100644 index 0000000000..9c8b65de7a --- /dev/null +++ b/src/utils/prompt-agent.ts @@ -0,0 +1,63 @@ +import type { ToolContext } from '../tools/types' +import type { Logger } from '../types' +import type { PluginInput } from '@opencode-ai/plugin' + +export interface PromptAgentDeps { + /** Legacy in-process client, tried first when present. */ + legacyClient?: PluginInput['client'] + /** OpenCode v2 client used as the fallback transport. */ + v2: ToolContext['v2'] + logger: Logger + directory: string +} + +export interface PromptAgentArgs { + sessionID: string + agent: string + prompt: string +} + +/** + * Sends a prompt to an agent within an existing session, trying the legacy + * in-process client first (when available) and falling back to the v2 client. + * Each transport's success/error is logged. Resolves once a transport either + * succeeds or both have been exhausted; failures are swallowed (logged only). + */ +export async function promptAgentViaClientThenV2( + deps: PromptAgentDeps, + args: PromptAgentArgs, +): Promise { + const { legacyClient, v2, logger, directory } = deps + const { sessionID, agent, prompt } = args + const parts = [{ type: 'text' as const, text: prompt }] + + if (legacyClient) { + try { + logger.log(`prompt-agent: prompting ${agent} via legacy client for ${sessionID}`) + const legacyResult = await legacyClient.session.promptAsync({ + path: { id: sessionID }, + query: { directory }, + body: { agent, parts }, + } as Parameters[0]) as unknown as { data?: unknown; error?: unknown } + if (!legacyResult?.error) { + logger.log(`prompt-agent: ${agent} prompted via legacy client for ${sessionID}`) + return + } + logger.error('prompt-agent: legacy promptAsync returned error', legacyResult.error) + } catch (err) { + logger.error('prompt-agent: legacy promptAsync threw', err) + } + } + + try { + logger.log(`prompt-agent: falling back to v2 promptAsync for ${sessionID}`) + const v2Result = await v2.session.promptAsync({ sessionID, directory, agent, parts }) + if ((v2Result as { error?: unknown })?.error) { + logger.error('prompt-agent: v2 promptAsync returned error', (v2Result as { error?: unknown }).error) + return + } + logger.log(`prompt-agent: ${agent} prompted via v2 for ${sessionID}`) + } catch (err) { + logger.error('prompt-agent: v2 promptAsync threw', err) + } +} diff --git a/src/utils/sandbox-ready.ts b/src/utils/sandbox-ready.ts index 8c727192aa..57009241cd 100644 --- a/src/utils/sandbox-ready.ts +++ b/src/utils/sandbox-ready.ts @@ -7,6 +7,7 @@ import { Database } from 'bun:sqlite' import { existsSync } from 'fs' +import { openForgeDatabaseReadonly } from '../storage' export interface WaitForSandboxOptions { projectId: string @@ -42,8 +43,7 @@ export async function waitForSandboxReady(opts: WaitForSandboxOptions): Promise< let db: Database | null = null try { - db = new Database(dbPath, { readonly: true }) - db.run('PRAGMA busy_timeout=5000') + db = openForgeDatabaseReadonly(dbPath) while (true) { // Query for loop sandbox_container from loops table diff --git a/src/utils/tui-loop-store.ts b/src/utils/tui-loop-store.ts index 735717f121..a5660b3a3f 100644 --- a/src/utils/tui-loop-store.ts +++ b/src/utils/tui-loop-store.ts @@ -7,14 +7,13 @@ import { Database } from 'bun:sqlite' import { existsSync } from 'fs' -import { join } from 'path' -import { resolveDataDir } from '../storage' +import { resolveForgeDbPath, openForgeDatabaseReadonly } from '../storage' import { createLoopsRepo } from '../storage/repos/loops-repo' import { createSectionPlansRepo } from '../storage/repos/section-plans-repo' import type { LoopInfo } from './tui-models' function getDbPath(): string { - return join(resolveDataDir(), 'forge.db') + return resolveForgeDbPath() } const cap200 = (s: string | null | undefined): string | null => @@ -72,7 +71,7 @@ export function fetchLoopsList(projectId: string, dbPathOverride?: string): Loop let db: Database | null = null try { - db = new Database(dbPath, { readonly: true }) + db = openForgeDatabaseReadonly(dbPath) const loopsRepo = createLoopsRepo(db) const sectionPlansRepo = createSectionPlansRepo(db) diff --git a/src/utils/tui-plan-store.ts b/src/utils/tui-plan-store.ts new file mode 100644 index 0000000000..9df2ce0bf4 --- /dev/null +++ b/src/utils/tui-plan-store.ts @@ -0,0 +1,103 @@ +/** + * TUI-side persisted plan store access. + * + * Opens the Forge SQLite database read-only (and read-write for mutations) + * to read/write plan data directly, mirroring the server-side PlansRepo + * access pattern. This allows the TUI to retrieve a server-captured plan + * without parsing chat messages. + */ + +import { Database } from 'bun:sqlite' +import { existsSync } from 'fs' +import { resolveForgeDbPath, openForgeDatabaseReadonly } from '../storage' +import { createPlansRepo } from '../storage/repos/plans-repo' + +function getDbPath(): string { + return resolveForgeDbPath() +} + +/** + * Read the persisted plan for a specific project + session. + * Returns the plan content, or null if none exists. + */ +export function readPlan(projectId: string, sessionId: string): string | null { + const dbPath = getDbPath() + if (!existsSync(dbPath)) return null + + let db: Database | null = null + try { + db = openForgeDatabaseReadonly(dbPath) + const plansRepo = createPlansRepo(db) + const row = plansRepo.getForSession(projectId, sessionId) + return row?.content ?? null + } catch { + return null + } finally { + try { db?.close() } catch {} + } +} + +/** + * Read the persisted plan for a session across all projects. + * Returns the content and projectId, or null. + * + * Uses a direct SQL query because the PlansRepo interface is scoped + * to a single projectId. + */ +export function readPlanForAnyProject(sessionId: string): { projectId: string; content: string } | null { + const dbPath = getDbPath() + if (!existsSync(dbPath)) return null + + let db: Database | null = null + try { + db = openForgeDatabaseReadonly(dbPath) + const stmt = db.prepare( + 'SELECT project_id, content FROM plans WHERE session_id = ? ORDER BY updated_at DESC LIMIT 1' + ) + const row = stmt.get(sessionId) as { project_id: string; content: string } | undefined + if (!row) return null + return { projectId: row.project_id, content: row.content } + } catch { + return null + } finally { + try { db?.close() } catch {} + } +} + +/** + * Write a plan for a specific project + session. + */ +export function writePlan(projectId: string, sessionId: string, content: string): void { + const dbPath = getDbPath() + if (!existsSync(dbPath)) return + + let db: Database | null = null + try { + db = new Database(dbPath) + const plansRepo = createPlansRepo(db) + plansRepo.writeForSession(projectId, sessionId, content) + } catch { + // silently fail + } finally { + try { db?.close() } catch {} + } +} + +/** + * Delete a plan for a specific project + session. + */ +export function deletePlan(projectId: string, sessionId: string): void { + const dbPath = getDbPath() + if (!existsSync(dbPath)) return + + let db: Database | null = null + try { + db = new Database(dbPath) + const plansRepo = createPlansRepo(db) + plansRepo.deleteForSession(projectId, sessionId) + } catch { + // silently fail + } finally { + try { db?.close() } catch {} + } +} diff --git a/test/plan-capture.test.ts b/test/plan-capture.test.ts index e8cb1887af..e46da0207a 100644 --- a/test/plan-capture.test.ts +++ b/test/plan-capture.test.ts @@ -4,6 +4,7 @@ import { normalizePastedPlanText, messageText, inspectLatestMarkedPlan, + inspectLatestPastedPlan, PLAN_START_MARKER, PLAN_END_MARKER, type PlanCaptureMessage, @@ -380,6 +381,148 @@ describe('inspectLatestMarkedPlan', () => { }) }) +describe('inspectLatestPastedPlan', () => { + test('finds plan in newest user message', () => { + const messages: PlanCaptureMessage[] = [ + { + info: { role: 'user', id: 'msg-1' }, + parts: [{ type: 'text', text: `Earlier\n${PLAN_START_MARKER}\nEarlier Plan\n${PLAN_END_MARKER}` }], + }, + { + info: { role: 'user', id: 'msg-2' }, + parts: [{ type: 'text', text: `Latest\n${PLAN_START_MARKER}\nLatest Plan\n${PLAN_END_MARKER}` }], + }, + ] + + const result = inspectLatestPastedPlan(messages) + expect(result.status).toBe('found') + if (result.status === 'found') { + expect(result.planText).toBe('Latest Plan') + expect(result.messageId).toBe('msg-2') + } + }) + + test('ignores assistant-only plan and returns missing', () => { + const messages: PlanCaptureMessage[] = [ + { + info: { role: 'assistant', id: 'msg-1' }, + parts: [{ type: 'text', text: `${PLAN_START_MARKER}\nAssistant Plan\n${PLAN_END_MARKER}` }], + }, + ] + + const result = inspectLatestPastedPlan(messages) + expect(result.status).toBe('missing') + }) + + test('picks newest user message when multiple user messages have plans', () => { + const messages: PlanCaptureMessage[] = [ + { + info: { role: 'user', id: 'msg-1' }, + parts: [{ type: 'text', text: `${PLAN_START_MARKER}\nFirst User Plan\n${PLAN_END_MARKER}` }], + }, + { + info: { role: 'assistant', id: 'msg-2' }, + parts: [{ type: 'text', text: `${PLAN_START_MARKER}\nAssistant Plan\n${PLAN_END_MARKER}` }], + }, + { + info: { role: 'user', id: 'msg-3' }, + parts: [{ type: 'text', text: `${PLAN_START_MARKER}\nSecond User Plan\n${PLAN_END_MARKER}` }], + }, + ] + + const result = inspectLatestPastedPlan(messages) + expect(result.status).toBe('found') + if (result.status === 'found') { + expect(result.planText).toBe('Second User Plan') + expect(result.messageId).toBe('msg-3') + } + }) + + test('returns invalid on multiple markers in user message', () => { + const messages: PlanCaptureMessage[] = [ + { + info: { role: 'user', id: 'msg-1' }, + parts: [{ + type: 'text', + text: `${PLAN_START_MARKER}\nPlan A\n${PLAN_END_MARKER}\n${PLAN_START_MARKER}\nPlan B\n${PLAN_END_MARKER}`, + }], + }, + ] + + const result = inspectLatestPastedPlan(messages) + expect(result.status).toBe('invalid') + if (result.status === 'invalid') { + expect(result.reason).toBe('multiple') + } + }) + + test('returns invalid on unterminated markers in user message', () => { + const messages: PlanCaptureMessage[] = [ + { + info: { role: 'user', id: 'msg-1' }, + parts: [{ type: 'text', text: `${PLAN_START_MARKER}\nNo end marker here` }], + }, + ] + + const result = inspectLatestPastedPlan(messages) + expect(result.status).toBe('invalid') + if (result.status === 'invalid') { + expect(result.reason).toBe('unterminated') + } + }) + + test('returns missing when user message has no markers', () => { + const messages: PlanCaptureMessage[] = [ + { + info: { role: 'user', id: 'msg-1' }, + parts: [{ type: 'text', text: 'Just plain text without markers' }], + }, + ] + + const result = inspectLatestPastedPlan(messages) + expect(result.status).toBe('missing') + }) + + test('returns missing when newest user message has no markers even if older user message has a plan', () => { + const messages: PlanCaptureMessage[] = [ + { + info: { role: 'user', id: 'msg-old' }, + parts: [{ type: 'text', text: `${PLAN_START_MARKER}\nOlder Plan\n${PLAN_END_MARKER}` }], + }, + { + info: { role: 'user', id: 'msg-newest' }, + parts: [{ type: 'text', text: 'Just plain text without markers' }], + }, + ] + + const result = inspectLatestPastedPlan(messages) + // Should NOT find the older plan — only the newest user message is considered + expect(result.status).toBe('missing') + }) + + test('does not repair split markers across user messages (returns invalid)', () => { + const messages: PlanCaptureMessage[] = [ + { + info: { role: 'user', id: 'msg-1' }, + parts: [{ type: 'text', text: `${PLAN_START_MARKER}\nPlan body without end marker` }], + }, + { + info: { role: 'user', id: 'msg-2' }, + parts: [{ type: 'text', text: PLAN_END_MARKER }], + }, + ] + + const result = inspectLatestPastedPlan(messages) + expect(result.status).toBe('invalid') + }) + + test('returns missing when messages array is empty', () => { + const messages: PlanCaptureMessage[] = [] + const result = inspectLatestPastedPlan(messages) + expect(result.status).toBe('missing') + }) +}) + describe('marked plan persistence', () => { function createFakePlansRepo() { const plans = new Map() @@ -590,7 +733,7 @@ describe('plan capture trigger on assistant message completion', () => { expect(plansRepo.getForSession('test-project', 'session-mu-1')?.content).toBe('# Completed Plan\n\n## Verification\n- bun test') }) - test('ignores message.updated when role is user', async () => { + test('user message.updated with no stashed plan does not fetch or write', async () => { const plansRepo = createFakePlansRepo() let messagesCalls = 0 const hook = createPlanCaptureEventHook({ @@ -607,6 +750,8 @@ describe('plan capture trigger on assistant message completion', () => { await hook({ event: { type: 'message.updated', properties: { sessionID: 'session-mu-user', info: { role: 'user', id: 'msg', time: { created: 1, completed: 2 } } } } }) + // Nothing was captured server-side; the completion handler only prompts + // from the streaming stash, so it never re-reads the conversation. expect(plansRepo.getForSession('test-project', 'session-mu-user')).toBeNull() expect(messagesCalls).toBe(0) }) @@ -663,3 +808,286 @@ describe('plan capture trigger on assistant message completion', () => { expect(afterCompletion?.updatedAt).toBe(captured?.updatedAt) }) }) + +describe('user paste capture trigger', () => { + function createFakePlansRepo() { + const plans = new Map() + let nextUpdatedAt = 1 + return { + writeForSession: (_projectId: string, sessionId: string, content: string) => { + plans.set(sessionId, { content, updatedAt: nextUpdatedAt++ }) + }, + getForSession: (_projectId: string, sessionId: string) => { + const row = plans.get(sessionId) + if (!row) return null + return { projectId: 'test-project', loopName: null, sessionId, content: row.content, updatedAt: row.updatedAt } + }, + } + } + + const logger = { + log: () => {}, + error: () => {}, + debug: () => {}, + } + + const MARKED_PLAN = `${PLAN_START_MARKER}\n# Pasted User Plan\n\n## Step 1\n- Do it\n${PLAN_END_MARKER}` + const EXPECTED_PLAN = '# Pasted User Plan\n\n## Step 1\n- Do it' + + function makeHookCtx(overrides: { + messagesData?: Array<{ info: Record; parts: Array<{ type: string; text: string }> }> + promptAsyncCalls?: Array<{ agent?: string }> + resolveLoopName?: () => string | null | undefined + getActiveState?: () => { active?: boolean; sessionId?: string } | null + }) { + const calls = overrides.promptAsyncCalls ?? [] + const spy = async (args: any) => { + const agent = args?.body?.agent ?? args?.agent + calls.push({ agent }) + return {} + } + return { + v2: { + session: { + messages: async () => ({ data: overrides.messagesData ?? [{ info: { role: 'user', id: 'msg' }, parts: [{ type: 'text', text: MARKED_PLAN }] }] }), + promptAsync: spy, + }, + }, + input: { + client: { + session: { + messages: async () => ({ data: [] }), + promptAsync: spy, + }, + }, + }, + plansRepo: createFakePlansRepo(), + projectId: 'test-project', + directory: '/tmp/project', + logger, + loop: { + resolveLoopName: overrides.resolveLoopName ?? (() => undefined), + getActiveState: overrides.getActiveState ?? (() => null), + }, + } + } + + function streamingEvent(sessionID: string, messageID: string, text: string) { + return { event: { type: 'message.part.updated', properties: { sessionID, part: { type: 'text', messageID, text } } } } + } + + function userCompletionEvent(sessionID: string, id: string) { + return { event: { type: 'message.updated', properties: { sessionID, info: { role: 'user', id, time: { created: 1, completed: 2 } } } } } + } + + const architectCount = (calls: Array<{ agent?: string }>) => calls.filter(c => c.agent === 'architect').length + + test('(a) streaming captures the plan; user completion prompts the architect', async () => { + const promptAsyncCalls: Array<{ agent?: string }> = [] + const ctx = makeHookCtx({ promptAsyncCalls }) + const hook = createPlanCaptureEventHook(ctx as any) + + await hook(streamingEvent('session-up-a', 'msg-user-a', MARKED_PLAN) as any) + // Streaming captured the plan into the store + expect(ctx.plansRepo.getForSession('test-project', 'session-up-a')?.content).toBe(EXPECTED_PLAN) + + await hook(userCompletionEvent('session-up-a', 'msg-user-a') as any) + expect(architectCount(promptAsyncCalls)).toBe(1) + }) + + test('(b) completing the same message twice prompts only once', async () => { + const promptAsyncCalls: Array<{ agent?: string }> = [] + const ctx = makeHookCtx({ promptAsyncCalls }) + const hook = createPlanCaptureEventHook(ctx as any) + + await hook(streamingEvent('session-up-b', 'msg-user-b', MARKED_PLAN) as any) + + await hook(userCompletionEvent('session-up-b', 'msg-user-b') as any) + expect(architectCount(promptAsyncCalls)).toBe(1) + + await hook(userCompletionEvent('session-up-b', 'msg-user-b') as any) + expect(architectCount(promptAsyncCalls)).toBe(1) + }) + + test('(c) user message without markers does not capture or prompt', async () => { + const promptAsyncCalls: Array<{ agent?: string }> = [] + const ctx = makeHookCtx({ promptAsyncCalls }) + const hook = createPlanCaptureEventHook(ctx as any) + + await hook(streamingEvent('session-up-c', 'msg-user-c', 'Just some plain text with no markers') as any) + await hook(userCompletionEvent('session-up-c', 'msg-user-c') as any) + + expect(ctx.plansRepo.getForSession('test-project', 'session-up-c')).toBeNull() + expect(promptAsyncCalls.length).toBe(0) + }) + + test('(d) when loop is active, streaming does not capture and completion does not prompt', async () => { + const promptAsyncCalls: Array<{ agent?: string }> = [] + const ctx = makeHookCtx({ + promptAsyncCalls, + resolveLoopName: () => 'my-loop', + getActiveState: () => ({ active: true, sessionId: 'session-up-d' }), + }) + const hook = createPlanCaptureEventHook(ctx as any) + + await hook(streamingEvent('session-up-d', 'msg-user-d', MARKED_PLAN) as any) + await hook(userCompletionEvent('session-up-d', 'msg-user-d') as any) + + expect(ctx.plansRepo.getForSession('test-project', 'session-up-d')).toBeNull() + expect(promptAsyncCalls.length).toBe(0) + }) + + test('(e) completing a markerless message does not prompt for an earlier message\u2019s plan', async () => { + const promptAsyncCalls: Array<{ agent?: string }> = [] + const ctx = makeHookCtx({ promptAsyncCalls }) + const hook = createPlanCaptureEventHook(ctx as any) + + // An earlier message streamed a plan (stashed under its own id)... + await hook(streamingEvent('session-up-e', 'msg-old-e', MARKED_PLAN) as any) + // ...but a different, markerless message completes. + await hook(userCompletionEvent('session-up-e', 'msg-newest-e') as any) + + // The newer message has nothing stashed, so no prompt fires. + expect(promptAsyncCalls.length).toBe(0) + }) + + test('(f) user message without time.completed is ignored', async () => { + const promptAsyncCalls: Array<{ agent?: string }> = [] + const ctx = makeHookCtx({ promptAsyncCalls }) + const hook = createPlanCaptureEventHook(ctx as any) + + await hook(streamingEvent('session-up-f', 'msg-user-f', MARKED_PLAN) as any) + // Completion event arrives without time.completed (still streaming) + await hook({ event: { type: 'message.updated', properties: { sessionID: 'session-up-f', info: { role: 'user', id: 'msg-user-f', time: { created: 1 } } } } } as any) + + expect(promptAsyncCalls.length).toBe(0) + }) + + test('(g) streaming then user completion prompts architect exactly once', async () => { + const promptAsyncCalls: Array<{ agent?: string }> = [] + const ctx = makeHookCtx({ promptAsyncCalls }) + const hook = createPlanCaptureEventHook(ctx as any) + + await hook(streamingEvent('session-stream-g', 'msg-stream-g', MARKED_PLAN) as any) + expect(ctx.plansRepo.getForSession('test-project', 'session-stream-g')?.content).toBe(EXPECTED_PLAN) + + await hook(userCompletionEvent('session-stream-g', 'msg-stream-g') as any) + + expect(ctx.plansRepo.getForSession('test-project', 'session-stream-g')?.content).toBe(EXPECTED_PLAN) + expect(architectCount(promptAsyncCalls)).toBe(1) + }) + + test('(h) streaming with an active loop: no capture, no prompt', async () => { + const promptAsyncCalls: Array<{ agent?: string }> = [] + const ctx = makeHookCtx({ + promptAsyncCalls, + resolveLoopName: () => 'test-loop', + getActiveState: () => ({ active: true, sessionId: 'session-loop-h' }), + }) + const hook = createPlanCaptureEventHook(ctx as any) + + await hook(streamingEvent('session-loop-h', 'msg-loop-h', MARKED_PLAN) as any) + expect(ctx.plansRepo.getForSession('test-project', 'session-loop-h')).toBeNull() + + await hook(userCompletionEvent('session-loop-h', 'msg-loop-h') as any) + expect(ctx.plansRepo.getForSession('test-project', 'session-loop-h')).toBeNull() + expect(promptAsyncCalls.length).toBe(0) + }) + + test('(i) re-pasting an already-stored plan does not prompt (already-current is not stashed)', async () => { + const promptAsyncCalls: Array<{ agent?: string }> = [] + const ctx = makeHookCtx({ promptAsyncCalls }) + ctx.plansRepo.writeForSession('test-project', 'session-pre-i', EXPECTED_PLAN) + const hook = createPlanCaptureEventHook(ctx as any) + + // Streaming sees the same plan content that is already stored -> already-current + await hook(streamingEvent('session-pre-i', 'msg-pre-i', MARKED_PLAN) as any) + await hook(userCompletionEvent('session-pre-i', 'msg-pre-i') as any) + + expect(ctx.plansRepo.getForSession('test-project', 'session-pre-i')?.content).toBe(EXPECTED_PLAN) + expect(promptAsyncCalls.length).toBe(0) + }) +}) + +describe('TUI persisted-plan-first fallback', () => { + const PERSISTED_PLAN = '# Persisted Plan\n\n## Phase 1\nAlready captured.' + const MESSAGE_PLAN = '# Message Plan\n\n## Phase 1\nFrom messages.' + + function createFakePlansRepo() { + const plans = new Map() + let nextUpdatedAt = 1 + return { + writeForSession: (_projectId: string, sessionId: string, content: string) => { + plans.set(sessionId, { content, updatedAt: nextUpdatedAt++ }) + }, + getForSession: (_projectId: string, sessionId: string) => { + const row = plans.get(sessionId) + if (!row) return null + return { projectId: 'test-project', loopName: null, sessionId, content: row.content, updatedAt: row.updatedAt } + }, + } + } + + function makeMessagePlan(): string { + return `${PLAN_START_MARKER}\n${MESSAGE_PLAN}\n${PLAN_END_MARKER}` + } + + test('returns persisted plan when it exists (ignores message plan)', async () => { + const plansRepo = createFakePlansRepo() + plansRepo.writeForSession('test-project', 'session-pp-1', PERSISTED_PLAN) + + // Simulate the runExecutePlan resolution order: + // 1. Try persisted store first + const planText = plansRepo.getForSession('test-project', 'session-pp-1')?.content ?? null + expect(planText).toBe(PERSISTED_PLAN) + + // 2. Would NOT fall back to message parsing because persisted exists + // (fetchLatestPlanForSession would not be called) + }) + + test('falls back to message inspection when no persisted plan exists', async () => { + const plansRepo = createFakePlansRepo() + + // No persisted plan for this session + const persisted = plansRepo.getForSession('test-project', 'session-pp-2') + expect(persisted).toBeNull() + + // Simulate the fallback: inspect messages (represented here by + // directly using the parser that fetchLatestPlanForSession uses) + const { inspectLatestPastedPlan } = await import('../src/utils/marked-plan-parser') + const messages: PlanCaptureMessage[] = [{ + info: { role: 'user', id: 'msg-pp-2' }, + parts: [{ type: 'text', text: makeMessagePlan() }], + }] + const inspection = inspectLatestPastedPlan(messages) + expect(inspection.status).toBe('found') + if (inspection.status === 'found') { + expect(inspection.planText).toBe(MESSAGE_PLAN) + } + }) + + test('persisted plan wins over message plan when both exist', async () => { + const plansRepo = createFakePlansRepo() + plansRepo.writeForSession('test-project', 'session-pp-3', PERSISTED_PLAN) + + // Simulate the runExecutePlan resolution order: + // 1. Try persisted store first + const fromPersisted = plansRepo.getForSession('test-project', 'session-pp-3')?.content + expect(fromPersisted).toBe(PERSISTED_PLAN) + + // 2. Would NOT fall back to messages — persisted exists + // If we DID inspect messages we'd get a different plan, proving + // the ordering matters. + const { inspectLatestPastedPlan } = await import('../src/utils/marked-plan-parser') + const messages: PlanCaptureMessage[] = [{ + info: { role: 'user', id: 'msg-pp-3' }, + parts: [{ type: 'text', text: makeMessagePlan() }], + }] + const messagePlan = inspectLatestPastedPlan(messages) + expect(messagePlan.status).toBe('found') + if (messagePlan.status === 'found') { + // Both plans exist but persisted is preferred — they differ + expect(fromPersisted).not.toBe(messagePlan.planText) + } + }) +}) diff --git a/test/utils/plan-from-messages.test.ts b/test/utils/plan-from-messages.test.ts index 193cef003c..52acfd8a53 100644 --- a/test/utils/plan-from-messages.test.ts +++ b/test/utils/plan-from-messages.test.ts @@ -20,6 +20,13 @@ function assistantMessage(text: string, id = 'msg-1'): { info: { role: string; i } } +function userMessage(text: string, id = 'msg-user-1'): { info: { role: string; id: string }; parts: Array<{ type: string; text: string }> } { + return { + info: { role: 'user', id }, + parts: [{ type: 'text', text }], + } +} + const VALID_PLAN = [ PLAN_START_MARKER, '# Implementation Plan', @@ -150,4 +157,38 @@ describe('fetchLatestPlanForSession', () => { const args = debug.mock.calls[0] expect(typeof args[0]).toBe('string') }) + + test('finds user-pasted plan when only user messages contain a marked plan (fallback)', async () => { + const messages = mock(async () => ({ + data: [userMessage(`${PLAN_START_MARKER}\nUser Pasted Plan\n${PLAN_END_MARKER}`, 'msg-user')], + })) + const client = makeClient(messages) + const debug = mock(() => {}) + const result = await fetchLatestPlanForSession(client, 'sess-1', '/tmp/proj', { debug }) + expect(result).toBe('User Pasted Plan') + expect(debug).toHaveBeenCalled() + }) + + test('finds user-pasted plan when no assistant plan exists', async () => { + const messages = mock(async () => ({ + data: [userMessage(`${PLAN_START_MARKER}\nUser Pasted Plan\n${PLAN_END_MARKER}`, 'msg-user')], + })) + const client = makeClient(messages) + const result = await fetchLatestPlanForSession(client, 'sess-1', '/tmp/proj') + // After fix: should find the user-pasted plan + expect(result).toBe('User Pasted Plan') + }) + + test('prefers assistant plan over user-pasted plan when both exist', async () => { + const messages = mock(async () => ({ + data: [ + userMessage(`${PLAN_START_MARKER}\nUser Plan\n${PLAN_END_MARKER}`, 'msg-user'), + assistantMessage(`${PLAN_START_MARKER}\nAssistant Plan\n${PLAN_END_MARKER}`, 'msg-assistant'), + ], + })) + const client = makeClient(messages) + const result = await fetchLatestPlanForSession(client, 'sess-1', '/tmp/proj') + // Assistant plan should take priority + expect(result).toBe('Assistant Plan') + }) })