From a10acefef644de0118821856d19b3f61069d5e2d Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Wed, 3 Jun 2026 19:03:33 -0400 Subject: [PATCH 1/3] feat: capture user-pasted plans from chat messages Adds server-side capture of user-pasted plans (marked with ---plan-start/---plan-end) from chat messages, persists them to the plans repo, and prompts the architect to ask how to execute. - inspectLatestPastedPlan finds plans in the newest user message only (not scanning older messages of the same role) - Handles both streaming (message.part.updated) and completion (message.updated) paths - Captures are skipped when a loop is active on the session - TUI-side plan store (tui-plan-store.ts) provides direct SQLite access so captured plans survive message compaction - TUI prefers persisted plan over message-parsed plan in execute-plan flow - fetchLatestPlanForSession falls back to user-pasted plans when no assistant plan exists - Refactored inspectLatestMarkedPlan into a generic role-based helper --- bun.lock | 8 +- package.json | 2 +- src/hooks/plan-capture.ts | 146 ++++- src/services/plan-capture.ts | 28 +- src/tui.tsx | 16 +- src/utils/marked-plan-parser.ts | 28 +- src/utils/plan-from-messages.ts | 48 +- src/utils/tui-plan-store.ts | 104 ++++ test/plan-capture.test.ts | 797 +++++++++++++++++++++++++- test/utils/plan-from-messages.test.ts | 41 ++ 10 files changed, 1183 insertions(+), 35 deletions(-) create mode 100644 src/utils/tui-plan-store.ts 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/hooks/plan-capture.ts b/src/hooks/plan-capture.ts index 3a24ae2e16..03319adeaf 100644 --- a/src/hooks/plan-capture.ts +++ b/src/hooks/plan-capture.ts @@ -1,6 +1,7 @@ import type { ToolContext } from '../tools/types' -import { captureLatestPlanForSession, captureMarkedPlanTextForSession } from '../services/plan-capture' +import { captureLatestPlanForSession, captureMarkedPlanTextForSession, capturePastedPlanForSession } from '../services/plan-capture' import { PLAN_END_MARKER, PLAN_START_MARKER } from '../utils/marked-plan-parser' +import { PLAN_EXECUTION_LABELS } from '../utils/plan-execution' const MESSAGE_PART_UPDATED_EVENT = 'message.part.updated' const MESSAGE_UPDATED_EVENT = 'message.updated' @@ -12,7 +13,7 @@ interface MessagePartUpdatedEvent { interface MessageUpdatedEvent { type: typeof MESSAGE_UPDATED_EVENT - properties?: { sessionID?: string; info?: { id?: string; role?: string; time?: { created?: number; completed?: number } } } + properties?: { sessionID?: string; info?: { id?: string; role?: string; agent?: string; time?: { created?: number; completed?: number } } } } type PlanCaptureEvent = MessagePartUpdatedEvent | MessageUpdatedEvent | { type: string; properties?: Record } @@ -25,6 +26,20 @@ function isMessageUpdatedEvent(event: PlanCaptureEvent): event is MessageUpdated return event.type === MESSAGE_UPDATED_EVENT } +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) +} + +const promptedPlanKeys = new Set() +// Tracks plans pre-captured by the streaming branch so the completion handler +// can distinguish "already current because streaming just wrote it" from +// "already current from prior storage" and prompt accordingly. +const streamingCapturedPlanKeys = new Set() + export function createPlanCaptureEventHook(ctx: ToolContext) { const { v2, input: { client }, logger, plansRepo, projectId, directory } = ctx @@ -39,6 +54,16 @@ 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 and avoids bypassing the loop guard + // in handleUserMessageCompleted) + 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 }, @@ -49,6 +74,8 @@ export function createPlanCaptureEventHook(ctx: ToolContext) { if (result.status === 'captured') { logger.log(`plan-capture: captured marked plan from message part for session ${sessionID}`) + const planKey = `${sessionID}:${hashPlanText(result.planText)}` + streamingCapturedPlanKeys.add(planKey) } else if (result.status === 'invalid') { logger.log(`plan-capture: streaming branch saw invalid plan for session ${sessionID}: ${result.reason}`) } @@ -86,6 +113,120 @@ 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') return + + // Skip if session has an active loop + const loop = ctx.loop + if (loop) { + const loopName = loop.resolveLoopName(sessionID) + const state = loopName ? loop.getActiveState(loopName) : null + if (state?.active) { + logger.log(`plan-capture: session ${sessionID} has active loop, skipping user paste capture`) + return + } + } + + try { + const result = await capturePastedPlanForSession( + { v2, client, plansRepo, projectId, directory, logger }, + sessionID + ) + + if (result.status === 'captured') { + logger.log(`plan-capture: captured pasted plan from user message for session ${sessionID}`) + await triggerPasteApprovalQuestion(sessionID, result.planText) + } else if (result.status === 'already-current') { + const planKey = `${sessionID}:${hashPlanText(result.planText)}` + if (streamingCapturedPlanKeys.has(planKey)) { + // Streaming branch pre-captured this plan but did not prompt; + // treat as freshly captured and prompt now. + streamingCapturedPlanKeys.delete(planKey) + logger.log(`plan-capture: streaming pre-captured plan for session ${sessionID}, prompting now`) + await triggerPasteApprovalQuestion(sessionID, result.planText) + } else { + // Plan was already stored prior to this event flow — skip prompt + // to avoid re-prompting the same plan on every user message. + logger.log(`plan-capture: plan already stored for session ${sessionID}, skipping prompt`) + } + } else if (result.status === 'invalid') { + logger.log(`plan-capture: invalid pasted plan in session ${sessionID}: ${result.reason}`) + ctx.v2.tui?.publish({ + directory: ctx.directory, + body: { + type: 'tui.toast.show', + properties: { + title: 'Forge plan execution', + message: `Invalid pasted plan markers: ${result.reason}`, + variant: 'error', + duration: 5000, + }, + }, + }).catch((err: unknown) => { + logger.error('plan-capture: failed to publish error toast', err as Error) + }) + } + // already-current, not-found, read-failed: return without prompting + } 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 + } + 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.` + + const legacyClient = ctx.input?.client + if (legacyClient) { + try { + logger.log(`plan-capture: prompting architect via legacy client for ${sessionID}`) + const legacyResult = await legacyClient.session.promptAsync({ + path: { id: sessionID }, + query: { directory: ctx.directory }, + body: { + agent: 'architect', + parts: [{ type: 'text' as const, text: prompt }], + }, + } as Parameters[0]) as unknown as Promise<{ data?: unknown; error?: unknown }> + if (!(legacyResult as { error?: unknown })?.error) { + logger.log(`plan-capture: architect prompted via legacy client for ${sessionID}`) + return + } + logger.error('plan-capture: legacy promptAsync returned error', (legacyResult as { error?: unknown }).error) + } catch (err) { + logger.error('plan-capture: legacy promptAsync threw', err) + } + } + + // Fallback to v2 + try { + logger.log(`plan-capture: falling back to v2 promptAsync for ${sessionID}`) + const v2Result = await v2.session.promptAsync({ + sessionID, + directory: ctx.directory, + agent: 'architect', + parts: [{ type: 'text' as const, text: prompt }], + }) + if ((v2Result as { error?: unknown })?.error) { + logger.error('plan-capture: v2 promptAsync returned error', (v2Result as { error?: unknown }).error) + return + } + logger.log(`plan-capture: architect prompted via v2 for ${sessionID}`) + } catch (err) { + logger.error('plan-capture: v2 promptAsync threw', err) + } + } + return async (eventInput: { event: PlanCaptureEvent }) => { const event = eventInput.event if (!event) return @@ -97,6 +238,7 @@ export function createPlanCaptureEventHook(ctx: ToolContext) { if (isMessageUpdatedEvent(event)) { await handleAssistantMessageCompleted(event) + await handleUserMessageCompleted(event) return } } diff --git a/src/services/plan-capture.ts b/src/services/plan-capture.ts index e1ca9c1252..4724598797 100644 --- a/src/services/plan-capture.ts +++ b/src/services/plan-capture.ts @@ -3,7 +3,7 @@ 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' +import { extractMarkedPlan, inspectLatestMarkedPlan, inspectLatestPastedPlan, sanitizePlanPaths } from '../utils/marked-plan-parser' export interface CaptureLatestPlanDeps { v2: ToolContext['v2'] @@ -133,3 +133,29 @@ export async function captureLatestPlanForSession( deps.logger.log(`plan-capture: no valid marked plan found in session ${sessionID}`) return { status: 'not-found' } } + +export async function capturePastedPlanForSession( + deps: CaptureLatestPlanDeps, + sessionID: string +): Promise { + const read = await readRecentMessages(deps, sessionID) + if (read.status === 'read-failed') return read + if (read.status === 'missing') { + deps.logger.log(`plan-capture: no messages found for session ${sessionID}`) + return { status: 'not-found' } + } + + const inspection = inspectLatestPastedPlan(read.messages) + + if (inspection.status === 'found') { + return writeCapturedPlanForSession(deps, sessionID, inspection.planText, inspection.messageId) + } + + if (inspection.status === 'invalid') { + deps.logger.log(`plan-capture: invalid pasted plan in session ${sessionID}: ${inspection.reason}`) + return { status: 'invalid', reason: inspection.reason } + } + + deps.logger.log(`plan-capture: no valid pasted plan found in session ${sessionID}`) + return { status: 'not-found' } +} 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/tui-plan-store.ts b/src/utils/tui-plan-store.ts new file mode 100644 index 0000000000..cf08f3e2bd --- /dev/null +++ b/src/utils/tui-plan-store.ts @@ -0,0 +1,104 @@ +/** + * 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 { join } from 'path' +import { resolveDataDir } from '../storage' +import { createPlansRepo } from '../storage/repos/plans-repo' + +function getDbPath(): string { + return join(resolveDataDir(), 'forge.db') +} + +/** + * 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 = new Database(dbPath, { readonly: true }) + 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 = new Database(dbPath, { readonly: true }) + 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..a6f4214f19 100644 --- a/test/plan-capture.test.ts +++ b/test/plan-capture.test.ts @@ -4,11 +4,12 @@ import { normalizePastedPlanText, messageText, inspectLatestMarkedPlan, + inspectLatestPastedPlan, PLAN_START_MARKER, PLAN_END_MARKER, type PlanCaptureMessage, } from '../src/utils/marked-plan-parser' -import { captureMarkedPlanTextForSession, captureLatestPlanForSession } from '../src/services/plan-capture' +import { captureMarkedPlanTextForSession, captureLatestPlanForSession, capturePastedPlanForSession } from '../src/services/plan-capture' import { createPlanCaptureEventHook } from '../src/hooks/plan-capture' describe('extractMarkedPlan', () => { @@ -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() @@ -548,6 +691,204 @@ describe('captureLatestPlanForSession legacy client fallback', () => { }) }) +describe('capturePastedPlanForSession', () => { + function createFakePlansRepo() { + const plans = new Map() + return { + writeForSession: (_projectId: string, sessionId: string, content: string) => { + plans.set(sessionId, { content, updatedAt: Date.now() }) + }, + 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: () => {}, + } + + test('captures a valid pasted plan from the newest user message', async () => { + const plansRepo = createFakePlansRepo() + const deps = { + v2: { session: { messages: async () => ({ + data: [{ + info: { role: 'user', id: 'msg-1' }, + parts: [{ type: 'text', text: `${PLAN_START_MARKER}\n# Pasted Plan\n\n## Step 1\n- Do it\n${PLAN_END_MARKER}` }], + }], + }) } }, + client: { session: { messages: async () => ({ data: [] }) } }, + plansRepo, + projectId: 'test-project', + directory: '/tmp/project', + logger, + } + + const result = await capturePastedPlanForSession(deps as any, 'session-pp-1') + expect(result.status).toBe('captured') + if (result.status === 'captured') { + expect(result.planText).toBe('# Pasted Plan\n\n## Step 1\n- Do it') + } + expect(plansRepo.getForSession('test-project', 'session-pp-1')?.content).toBe('# Pasted Plan\n\n## Step 1\n- Do it') + }) + + test('returns already-current when identical plan is already stored', async () => { + const plansRepo = createFakePlansRepo() + const planText = `${PLAN_START_MARKER}\n# Same Plan\n\nContent\n${PLAN_END_MARKER}` + const messageData = [{ + info: { role: 'user', id: 'msg-same' }, + parts: [{ type: 'text', text: planText }], + }] + const deps = { + v2: { session: { messages: async () => ({ data: messageData }) } }, + client: { session: { messages: async () => ({ data: [] }) } }, + plansRepo, + projectId: 'test-project', + directory: '/tmp/project', + logger, + } + + // First call captures + const first = await capturePastedPlanForSession(deps as any, 'session-pp-same') + expect(first.status).toBe('captured') + + // Second call with same content should be already-current + const second = await capturePastedPlanForSession(deps as any, 'session-pp-same') + expect(second.status).toBe('already-current') + if (second.status === 'already-current') { + expect(second.planText).toBe('# Same Plan\n\nContent') + } + }) + + test('returns not-found when user message has no markers', async () => { + const plansRepo = createFakePlansRepo() + const deps = { + v2: { session: { messages: async () => ({ + data: [{ + info: { role: 'user', id: 'msg-nope' }, + parts: [{ type: 'text', text: 'Just some plain text without markers' }], + }], + }) } }, + client: { session: { messages: async () => ({ data: [] }) } }, + plansRepo, + projectId: 'test-project', + directory: '/tmp/project', + logger, + } + + const result = await capturePastedPlanForSession(deps as any, 'session-pp-nope') + expect(result.status).toBe('not-found') + expect(plansRepo.getForSession('test-project', 'session-pp-nope')).toBeNull() + }) + + test('returns not-found when newest user message has no markers but older user message has a plan', async () => { + const plansRepo = createFakePlansRepo() + const deps = { + v2: { session: { messages: async () => ({ + data: [ + { + 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' }], + }, + ], + }) } }, + client: { session: { messages: async () => ({ data: [] }) } }, + plansRepo, + projectId: 'test-project', + directory: '/tmp/project', + logger, + } + + const result = await capturePastedPlanForSession(deps as any, 'session-pp-regression') + // Should NOT capture the older plan — only the newest user message is considered + expect(result.status).toBe('not-found') + expect(plansRepo.getForSession('test-project', 'session-pp-regression')).toBeNull() + }) + + test('returns invalid on malformed markers in user message', async () => { + const plansRepo = createFakePlansRepo() + const deps = { + v2: { session: { messages: async () => ({ + data: [{ + info: { role: 'user', id: 'msg-bad' }, + parts: [{ type: 'text', text: `${PLAN_START_MARKER}\nNo end marker` }], + }], + }) } }, + client: { session: { messages: async () => ({ data: [] }) } }, + plansRepo, + projectId: 'test-project', + directory: '/tmp/project', + logger, + } + + const result = await capturePastedPlanForSession(deps as any, 'session-pp-bad') + expect(result.status).toBe('invalid') + if (result.status === 'invalid') { + expect(result.reason).toBe('unterminated') + } + expect(plansRepo.getForSession('test-project', 'session-pp-bad')).toBeNull() + }) + + test('returns not-found when messages array is empty', async () => { + const plansRepo = createFakePlansRepo() + const deps = { + v2: { session: { messages: async () => ({ data: [] }) } }, + client: { session: { messages: async () => ({ data: [] }) } }, + plansRepo, + projectId: 'test-project', + directory: '/tmp/project', + logger, + } + + const result = await capturePastedPlanForSession(deps as any, 'session-pp-empty') + expect(result.status).toBe('not-found') + }) + + test('falls back to legacy client when v2 returns empty data', async () => { + const plansRepo = createFakePlansRepo() + const deps = { + v2: { session: { messages: async () => ({ data: [] }) } }, + client: { session: { messages: async () => ({ + data: [{ + info: { role: 'user', id: 'msg-fb' }, + parts: [{ type: 'text', text: `${PLAN_START_MARKER}\nFallback Pasted Plan\n${PLAN_END_MARKER}` }], + }], + }) } }, + plansRepo, + projectId: 'test-project', + directory: '/tmp/project', + logger, + } + + const result = await capturePastedPlanForSession(deps as any, 'session-pp-fb') + expect(result.status).toBe('captured') + expect(plansRepo.getForSession('test-project', 'session-pp-fb')?.content).toBe('Fallback Pasted Plan') + }) + + test('returns not-found when both v2 and legacy return empty', async () => { + const plansRepo = createFakePlansRepo() + const deps = { + v2: { session: { messages: async () => ({ data: [] }) } }, + client: { session: { messages: async () => ({ data: [] }) } }, + plansRepo, + projectId: 'test-project', + directory: '/tmp/project', + logger, + } + + const result = await capturePastedPlanForSession(deps as any, 'session-pp-both-empty') + expect(result.status).toBe('not-found') + }) +}) + describe('plan capture trigger on assistant message completion', () => { function createFakePlansRepo() { const plans = new Map() @@ -590,7 +931,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('ignores message.updated when role is user and no markers present (no plan written)', async () => { const plansRepo = createFakePlansRepo() let messagesCalls = 0 const hook = createPlanCaptureEventHook({ @@ -607,8 +948,10 @@ 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 } } } } }) + // No plan written because messages are empty expect(plansRepo.getForSession('test-project', 'session-mu-user')).toBeNull() - expect(messagesCalls).toBe(0) + // User messages are now handled, so messages are fetched + expect(messagesCalls).toBeGreaterThanOrEqual(1) }) test('ignores message.updated when time.completed is undefined (streaming, not finished)', async () => { @@ -663,3 +1006,451 @@ 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), + }, + } + } + + test('(a) captures plan and prompts architect on user message with valid plan', async () => { + const promptAsyncCalls: Array<{ agent?: string }> = [] + const ctx = makeHookCtx({ + messagesData: [{ + info: { role: 'user', id: 'msg-user-a' }, + parts: [{ type: 'text', text: MARKED_PLAN }], + }], + promptAsyncCalls, + }) + const hook = createPlanCaptureEventHook(ctx as any) + + await hook({ + event: { + type: 'message.updated', + properties: { + sessionID: 'session-up-a', + info: { role: 'user', id: 'msg-user-a', time: { created: 1, completed: 2 } }, + }, + }, + }) + + // Plan should be captured + expect(ctx.plansRepo.getForSession('test-project', 'session-up-a')?.content).toBe(EXPECTED_PLAN) + // promptAsync should have been called with agent: 'architect' + expect(promptAsyncCalls.length).toBeGreaterThanOrEqual(1) + const architectCall = promptAsyncCalls.find(c => c.agent === 'architect') + expect(architectCall).toBeDefined() + }) + + test('(b) firing same event again does not prompt a second time (already-current)', async () => { + const promptAsyncCalls: Array<{ agent?: string }> = [] + const ctx = makeHookCtx({ + messagesData: [{ + info: { role: 'user', id: 'msg-user-b' }, + parts: [{ type: 'text', text: MARKED_PLAN }], + }], + promptAsyncCalls, + }) + const hook = createPlanCaptureEventHook(ctx as any) + + // First call: capture + prompt + await hook({ + event: { + type: 'message.updated', + properties: { + sessionID: 'session-up-b', + info: { role: 'user', id: 'msg-user-b', time: { created: 1, completed: 2 } }, + }, + }, + }) + const firstPromptCount = promptAsyncCalls.filter(c => c.agent === 'architect').length + expect(firstPromptCount).toBe(1) + + // Second call with same event: no new prompt + await hook({ + event: { + type: 'message.updated', + properties: { + sessionID: 'session-up-b', + info: { role: 'user', id: 'msg-user-b', time: { created: 1, completed: 2 } }, + }, + }, + }) + const secondPromptCount = promptAsyncCalls.filter(c => c.agent === 'architect').length + expect(secondPromptCount).toBe(1) + }) + + test('(c) user message without markers does not write or prompt', async () => { + const promptAsyncCalls: Array<{ agent?: string }> = [] + const ctx = makeHookCtx({ + messagesData: [{ + info: { role: 'user', id: 'msg-user-c' }, + parts: [{ type: 'text', text: 'Just some plain text with no markers' }], + }], + promptAsyncCalls, + }) + const hook = createPlanCaptureEventHook(ctx as any) + + await hook({ + event: { + type: 'message.updated', + properties: { + sessionID: 'session-up-c', + info: { role: 'user', id: 'msg-user-c', time: { created: 1, completed: 2 } }, + }, + }, + }) + + expect(ctx.plansRepo.getForSession('test-project', 'session-up-c')).toBeNull() + expect(promptAsyncCalls.length).toBe(0) + }) + + test('(d) when loop is active, no capture and no prompt', async () => { + const promptAsyncCalls: Array<{ agent?: string }> = [] + const ctx = makeHookCtx({ + messagesData: [{ + info: { role: 'user', id: 'msg-user-d' }, + parts: [{ type: 'text', text: MARKED_PLAN }], + }], + promptAsyncCalls, + resolveLoopName: () => 'my-loop', + getActiveState: () => ({ active: true, sessionId: 'session-up-d' }), + }) + const hook = createPlanCaptureEventHook(ctx as any) + + await hook({ + event: { + type: 'message.updated', + properties: { + sessionID: 'session-up-d', + info: { role: 'user', id: 'msg-user-d', time: { created: 1, completed: 2 } }, + }, + }, + }) + + expect(ctx.plansRepo.getForSession('test-project', 'session-up-d')).toBeNull() + expect(promptAsyncCalls.length).toBe(0) + }) + + test('(f) newest user message without markers does not capture older user plan', async () => { + const promptAsyncCalls: Array<{ agent?: string }> = [] + const ctx = makeHookCtx({ + messagesData: [ + { + info: { role: 'user', id: 'msg-old-f' }, + parts: [{ type: 'text', text: MARKED_PLAN }], + }, + { + info: { role: 'user', id: 'msg-newest-f' }, + parts: [{ type: 'text', text: 'Just some plain text without markers' }], + }, + ], + promptAsyncCalls, + }) + const hook = createPlanCaptureEventHook(ctx as any) + + await hook({ + event: { + type: 'message.updated', + properties: { + sessionID: 'session-up-f', + info: { role: 'user', id: 'msg-newest-f', time: { created: 1, completed: 2 } }, + }, + }, + }) + + // Should NOT capture the older plan — only the newest user message is considered + expect(ctx.plansRepo.getForSession('test-project', 'session-up-f')).toBeNull() + expect(promptAsyncCalls.length).toBe(0) + }) + + test('(e) user message without time.completed is ignored', async () => { + const promptAsyncCalls: Array<{ agent?: string }> = [] + let messagesCalls = 0 + const ctx = makeHookCtx({ + messagesData: [{ + info: { role: 'user', id: 'msg-user-e' }, + parts: [{ type: 'text', text: MARKED_PLAN }], + }], + promptAsyncCalls, + }) + // Override v2.session.messages to track calls + ctx.v2.session.messages = async () => { + messagesCalls++ + return { data: [] } + } + const hook = createPlanCaptureEventHook(ctx as any) + + await hook({ + event: { + type: 'message.updated', + properties: { + sessionID: 'session-up-e', + info: { role: 'user', id: 'msg-user-e', time: { created: 1 } }, // no completed + }, + }, + }) + + expect(ctx.plansRepo.getForSession('test-project', 'session-up-e')).toBeNull() + expect(messagesCalls).toBe(0) + expect(promptAsyncCalls.length).toBe(0) + }) + + test('(g) streaming path captures user paste + completion handler prompts architect once', async () => { + const promptAsyncCalls: Array<{ agent?: string }> = [] + const ctx = makeHookCtx({ + messagesData: [{ + info: { role: 'user', id: 'msg-stream-g' }, + parts: [{ type: 'text', text: MARKED_PLAN }], + }], + promptAsyncCalls, + }) + const hook = createPlanCaptureEventHook(ctx as any) + + // Simulate message.part.updated with a complete user-pasted marked plan + // (the streaming path fires first with the full text containing both markers) + await hook({ + event: { + type: 'message.part.updated', + properties: { + sessionID: 'session-stream-g', + part: { type: 'text', messageID: 'msg-stream-g', text: MARKED_PLAN }, + }, + }, + }) + + // Plan should be stored by streaming path + expect(ctx.plansRepo.getForSession('test-project', 'session-stream-g')?.content).toBe(EXPECTED_PLAN) + + // Now simulate message.updated completing the user message + await hook({ + event: { + type: 'message.updated', + properties: { + sessionID: 'session-stream-g', + info: { role: 'user', id: 'msg-stream-g', time: { created: 1, completed: 2 } }, + }, + }, + }) + + // Plan should still be the same (deduped write — already-current) + expect(ctx.plansRepo.getForSession('test-project', 'session-stream-g')?.content).toBe(EXPECTED_PLAN) + // Architect should be prompted exactly once (streaming pre-capture tracks the plan + // key so the completion handler treats already-current as freshly captured and prompts) + const architectCalls = promptAsyncCalls.filter(c => c.agent === 'architect') + expect(architectCalls.length).toBe(1) + }) + + test('(h) streaming path with active loop: no capture, no prompt', async () => { + const promptAsyncCalls: Array<{ agent?: string }> = [] + const ctx = makeHookCtx({ + messagesData: [{ + info: { role: 'user', id: 'msg-loop-h' }, + parts: [{ type: 'text', text: MARKED_PLAN }], + }], + promptAsyncCalls, + resolveLoopName: () => 'test-loop', + getActiveState: () => ({ active: true, sessionId: 'session-loop-h' }), + }) + const hook = createPlanCaptureEventHook(ctx as any) + + // Simulate message.part.updated with a complete user-pasted marked plan + await hook({ + event: { + type: 'message.part.updated', + properties: { + sessionID: 'session-loop-h', + part: { type: 'text', messageID: 'msg-loop-h', text: MARKED_PLAN }, + }, + }, + }) + + // Plan should NOT be stored (loop guard in streaming path blocks capture) + expect(ctx.plansRepo.getForSession('test-project', 'session-loop-h')).toBeNull() + + // Simulate message.updated completing the user message + await hook({ + event: { + type: 'message.updated', + properties: { + sessionID: 'session-loop-h', + info: { role: 'user', id: 'msg-loop-h', time: { created: 1, completed: 2 } }, + }, + }, + }) + + // Still no plan stored, and no prompt fired + expect(ctx.plansRepo.getForSession('test-project', 'session-loop-h')).toBeNull() + expect(promptAsyncCalls.length).toBe(0) + }) + + test('(i) pre-seeded plan in repo: already-current without streaming pre-capture does not prompt', async () => { + const promptAsyncCalls: Array<{ agent?: string }> = [] + const ctx = makeHookCtx({ + messagesData: [{ + info: { role: 'user', id: 'msg-pre-i' }, + parts: [{ type: 'text', text: MARKED_PLAN }], + }], + promptAsyncCalls, + }) + // Pre-seed the plansRepo with the same plan content before any event fires + ctx.plansRepo.writeForSession('test-project', 'session-pre-i', EXPECTED_PLAN) + const hook = createPlanCaptureEventHook(ctx as any) + + // Fire the user completion event + await hook({ + event: { + type: 'message.updated', + properties: { + sessionID: 'session-pre-i', + info: { role: 'user', id: 'msg-pre-i', time: { created: 1, completed: 2 } }, + }, + }, + }) + + // Plan should already be stored (pre-seeded, still the same content) + expect(ctx.plansRepo.getForSession('test-project', 'session-pre-i')?.content).toBe(EXPECTED_PLAN) + // No prompt should be sent — the plan was already stored before this event + 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') + }) }) From 4418ff988cd4a9d4786f282176e8494df20e8638 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Wed, 3 Jun 2026 21:59:01 -0400 Subject: [PATCH 2/3] refactor: extract plan-hash and prompt-agent utils, simplify plan capture hooks --- src/dashboard/launch.ts | 8 ++-- src/hooks/plan-approval.ts | 11 +---- src/hooks/plan-capture.ts | 87 ++++++++++++++++-------------------- src/services/execution.ts | 8 +--- src/services/plan-capture.ts | 40 +++++++---------- src/storage/database.ts | 15 +++++++ src/storage/index.ts | 2 +- src/utils/plan-hash.ts | 11 +++++ src/utils/prompt-agent.ts | 63 ++++++++++++++++++++++++++ src/utils/sandbox-ready.ts | 4 +- src/utils/tui-loop-store.ts | 7 ++- src/utils/tui-plan-store.ts | 9 ++-- 12 files changed, 160 insertions(+), 105 deletions(-) create mode 100644 src/utils/plan-hash.ts create mode 100644 src/utils/prompt-agent.ts 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 03319adeaf..cb4c5fa46e 100644 --- a/src/hooks/plan-capture.ts +++ b/src/hooks/plan-capture.ts @@ -2,6 +2,8 @@ import type { ToolContext } from '../tools/types' import { captureLatestPlanForSession, captureMarkedPlanTextForSession, capturePastedPlanForSession } 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' @@ -13,7 +15,7 @@ interface MessagePartUpdatedEvent { interface MessageUpdatedEvent { type: typeof MESSAGE_UPDATED_EVENT - properties?: { sessionID?: string; info?: { id?: string; role?: string; agent?: string; time?: { created?: number; completed?: number } } } + properties?: { sessionID?: string; info?: { id?: string; role?: string; time?: { created?: number; completed?: number } } } } type PlanCaptureEvent = MessagePartUpdatedEvent | MessageUpdatedEvent | { type: string; properties?: Record } @@ -26,12 +28,13 @@ function isMessageUpdatedEvent(event: PlanCaptureEvent): event is MessageUpdated return event.type === MESSAGE_UPDATED_EVENT } -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) +const PLAN_KEY_CAP = 1000 + +// Caps an in-memory dedup Set so a long-lived process cannot grow it without +// bound; clearing on overflow only risks an occasional duplicate prompt/capture. +function trackKey(set: Set, key: string): void { + if (set.size > PLAN_KEY_CAP) set.clear() + set.add(key) } const promptedPlanKeys = new Set() @@ -39,6 +42,11 @@ const promptedPlanKeys = new Set() // can distinguish "already current because streaming just wrote it" from // "already current from prior storage" and prompt accordingly. const streamingCapturedPlanKeys = new Set() +// Message ids whose streamed text parts were observed, and (subset) those whose +// parts contained both plan markers. Lets the completion handler skip a +// session.messages() fetch for messages positively seen without any markers. +const seenPartMessageIds = new Set() +const markerPartMessageIds = new Set() export function createPlanCaptureEventHook(ctx: ToolContext) { const { v2, input: { client }, logger, plansRepo, projectId, directory } = ctx @@ -51,8 +59,11 @@ export function createPlanCaptureEventHook(ctx: ToolContext) { const sessionID = event.properties?.sessionID const part = event.properties?.part if (!sessionID || part?.type !== 'text' || !part.text) return + const partKey = part.messageID ? `${sessionID}:${part.messageID}` : null + if (partKey) trackKey(seenPartMessageIds, partKey) if (!part.text.includes(PLAN_START_MARKER)) return if (!part.text.includes(PLAN_END_MARKER)) return + if (partKey) trackKey(markerPartMessageIds, partKey) // Skip capture if session has an active loop (prevents user-pasted plans // from being captured during loops and avoids bypassing the loop guard @@ -75,7 +86,7 @@ export function createPlanCaptureEventHook(ctx: ToolContext) { if (result.status === 'captured') { logger.log(`plan-capture: captured marked plan from message part for session ${sessionID}`) const planKey = `${sessionID}:${hashPlanText(result.planText)}` - streamingCapturedPlanKeys.add(planKey) + trackKey(streamingCapturedPlanKeys, planKey) } else if (result.status === 'invalid') { logger.log(`plan-capture: streaming branch saw invalid plan for session ${sessionID}: ${result.reason}`) } @@ -130,6 +141,21 @@ export function createPlanCaptureEventHook(ctx: ToolContext) { } } + // If the streaming branch already observed this message's text parts and + // none contained plan markers, there is nothing to capture — skip the + // session.messages() fetch entirely. When no parts were observed (e.g. the + // message arrived without part events), fall through and fetch as before. + const partKey = info?.id ? `${sessionID}:${info.id}` : null + if (partKey && seenPartMessageIds.has(partKey) && !markerPartMessageIds.has(partKey)) { + seenPartMessageIds.delete(partKey) + logger.log(`plan-capture: message ${info?.id} had no plan markers in streamed parts, skipping fetch`) + return + } + if (partKey) { + seenPartMessageIds.delete(partKey) + markerPartMessageIds.delete(partKey) + } + try { const result = await capturePastedPlanForSession( { v2, client, plansRepo, projectId, directory, logger }, @@ -181,50 +207,15 @@ export function createPlanCaptureEventHook(ctx: ToolContext) { logger.log(`plan-capture: already prompted for plan ${planKey}, skipping`) return } - promptedPlanKeys.add(planKey) + trackKey(promptedPlanKeys, 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.` - const legacyClient = ctx.input?.client - if (legacyClient) { - try { - logger.log(`plan-capture: prompting architect via legacy client for ${sessionID}`) - const legacyResult = await legacyClient.session.promptAsync({ - path: { id: sessionID }, - query: { directory: ctx.directory }, - body: { - agent: 'architect', - parts: [{ type: 'text' as const, text: prompt }], - }, - } as Parameters[0]) as unknown as Promise<{ data?: unknown; error?: unknown }> - if (!(legacyResult as { error?: unknown })?.error) { - logger.log(`plan-capture: architect prompted via legacy client for ${sessionID}`) - return - } - logger.error('plan-capture: legacy promptAsync returned error', (legacyResult as { error?: unknown }).error) - } catch (err) { - logger.error('plan-capture: legacy promptAsync threw', err) - } - } - - // Fallback to v2 - try { - logger.log(`plan-capture: falling back to v2 promptAsync for ${sessionID}`) - const v2Result = await v2.session.promptAsync({ - sessionID, - directory: ctx.directory, - agent: 'architect', - parts: [{ type: 'text' as const, text: prompt }], - }) - if ((v2Result as { error?: unknown })?.error) { - logger.error('plan-capture: v2 promptAsync returned error', (v2Result as { error?: unknown }).error) - return - } - logger.log(`plan-capture: architect prompted via v2 for ${sessionID}`) - } catch (err) { - logger.error('plan-capture: v2 promptAsync threw', err) - } + await promptAgentViaClientThenV2( + { legacyClient: ctx.input?.client, v2, logger, directory: ctx.directory }, + { sessionID, agent: 'architect', prompt } + ) } return async (eventInput: { event: PlanCaptureEvent }) => { 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/services/plan-capture.ts b/src/services/plan-capture.ts index 4724598797..83de4a2905 100644 --- a/src/services/plan-capture.ts +++ b/src/services/plan-capture.ts @@ -108,9 +108,11 @@ async function readRecentMessages( } } -export async function captureLatestPlanForSession( +async function captureInspectedPlanForSession( deps: CaptureLatestPlanDeps, - sessionID: string + sessionID: string, + inspect: (messages: PlanCaptureMessage[]) => ReturnType, + label: string ): Promise { const read = await readRecentMessages(deps, sessionID) if (read.status === 'read-failed') return read @@ -119,43 +121,31 @@ export async function captureLatestPlanForSession( return { status: 'not-found' } } - const inspection = inspectLatestMarkedPlan(read.messages) + const inspection = inspect(read.messages) if (inspection.status === 'found') { return writeCapturedPlanForSession(deps, sessionID, inspection.planText, inspection.messageId) } if (inspection.status === 'invalid') { - deps.logger.log(`plan-capture: invalid marked plan in session ${sessionID}: ${inspection.reason}`) + deps.logger.log(`plan-capture: invalid ${label} plan in session ${sessionID}: ${inspection.reason}`) return { status: 'invalid', reason: inspection.reason } } - deps.logger.log(`plan-capture: no valid marked plan found in session ${sessionID}`) + deps.logger.log(`plan-capture: no valid ${label} plan found in session ${sessionID}`) return { status: 'not-found' } } -export async function capturePastedPlanForSession( +export async function captureLatestPlanForSession( deps: CaptureLatestPlanDeps, sessionID: string ): Promise { - const read = await readRecentMessages(deps, sessionID) - if (read.status === 'read-failed') return read - if (read.status === 'missing') { - deps.logger.log(`plan-capture: no messages found for session ${sessionID}`) - return { status: 'not-found' } - } - - const inspection = inspectLatestPastedPlan(read.messages) - - if (inspection.status === 'found') { - return writeCapturedPlanForSession(deps, sessionID, inspection.planText, inspection.messageId) - } - - if (inspection.status === 'invalid') { - deps.logger.log(`plan-capture: invalid pasted plan in session ${sessionID}: ${inspection.reason}`) - return { status: 'invalid', reason: inspection.reason } - } + return captureInspectedPlanForSession(deps, sessionID, inspectLatestMarkedPlan, 'marked') +} - deps.logger.log(`plan-capture: no valid pasted plan found in session ${sessionID}`) - return { status: 'not-found' } +export async function capturePastedPlanForSession( + deps: CaptureLatestPlanDeps, + sessionID: string +): Promise { + return captureInspectedPlanForSession(deps, sessionID, inspectLatestPastedPlan, 'pasted') } 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/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 index cf08f3e2bd..9df2ce0bf4 100644 --- a/src/utils/tui-plan-store.ts +++ b/src/utils/tui-plan-store.ts @@ -9,12 +9,11 @@ import { Database } from 'bun:sqlite' import { existsSync } from 'fs' -import { join } from 'path' -import { resolveDataDir } from '../storage' +import { resolveForgeDbPath, openForgeDatabaseReadonly } from '../storage' import { createPlansRepo } from '../storage/repos/plans-repo' function getDbPath(): string { - return join(resolveDataDir(), 'forge.db') + return resolveForgeDbPath() } /** @@ -27,7 +26,7 @@ export function readPlan(projectId: string, sessionId: string): string | null { let db: Database | null = null try { - db = new Database(dbPath, { readonly: true }) + db = openForgeDatabaseReadonly(dbPath) const plansRepo = createPlansRepo(db) const row = plansRepo.getForSession(projectId, sessionId) return row?.content ?? null @@ -51,7 +50,7 @@ export function readPlanForAnyProject(sessionId: string): { projectId: string; c let db: Database | null = null try { - db = new Database(dbPath, { readonly: true }) + db = openForgeDatabaseReadonly(dbPath) const stmt = db.prepare( 'SELECT project_id, content FROM plans WHERE session_id = ? ORDER BY updated_at DESC LIMIT 1' ) From e30b3aab3885c37d9478f85442f93fec427949bf Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Thu, 4 Jun 2026 00:08:08 -0400 Subject: [PATCH 3/3] refactor: simplify plan capture with direct stash lookup, remove pasted-plan service --- src/hooks/plan-capture.ts | 116 ++------- src/services/plan-capture.ts | 28 +- test/plan-capture.test.ts | 487 +++++------------------------------ 3 files changed, 96 insertions(+), 535 deletions(-) diff --git a/src/hooks/plan-capture.ts b/src/hooks/plan-capture.ts index cb4c5fa46e..8bacffc54e 100644 --- a/src/hooks/plan-capture.ts +++ b/src/hooks/plan-capture.ts @@ -1,5 +1,5 @@ import type { ToolContext } from '../tools/types' -import { captureLatestPlanForSession, captureMarkedPlanTextForSession, capturePastedPlanForSession } from '../services/plan-capture' +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' @@ -30,23 +30,19 @@ function isMessageUpdatedEvent(event: PlanCaptureEvent): event is MessageUpdated const PLAN_KEY_CAP = 1000 -// Caps an in-memory dedup Set so a long-lived process cannot grow it without -// bound; clearing on overflow only risks an occasional duplicate prompt/capture. -function trackKey(set: Set, key: string): void { - if (set.size > PLAN_KEY_CAP) set.clear() - set.add(key) +// 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() -// Tracks plans pre-captured by the streaming branch so the completion handler -// can distinguish "already current because streaming just wrote it" from -// "already current from prior storage" and prompt accordingly. -const streamingCapturedPlanKeys = new Set() -// Message ids whose streamed text parts were observed, and (subset) those whose -// parts contained both plan markers. Lets the completion handler skip a -// session.messages() fetch for messages positively seen without any markers. -const seenPartMessageIds = new Set() -const markerPartMessageIds = 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 @@ -59,15 +55,11 @@ export function createPlanCaptureEventHook(ctx: ToolContext) { const sessionID = event.properties?.sessionID const part = event.properties?.part if (!sessionID || part?.type !== 'text' || !part.text) return - const partKey = part.messageID ? `${sessionID}:${part.messageID}` : null - if (partKey) trackKey(seenPartMessageIds, partKey) if (!part.text.includes(PLAN_START_MARKER)) return if (!part.text.includes(PLAN_END_MARKER)) return - if (partKey) trackKey(markerPartMessageIds, partKey) // Skip capture if session has an active loop (prevents user-pasted plans - // from being captured during loops and avoids bypassing the loop guard - // in handleUserMessageCompleted) + // from being captured during loops). const loop = ctx.loop if (loop) { const loopName = loop.resolveLoopName(sessionID) @@ -83,10 +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}`) - const planKey = `${sessionID}:${hashPlanText(result.planText)}` - trackKey(streamingCapturedPlanKeys, planKey) + 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}`) } @@ -128,74 +123,18 @@ export function createPlanCaptureEventHook(ctx: ToolContext) { const sessionID = event.properties?.sessionID const info = event.properties?.info - if (!sessionID || info?.role !== 'user' || typeof info?.time?.completed !== 'number') return + if (!sessionID || info?.role !== 'user' || typeof info?.time?.completed !== 'number' || !info.id) return - // Skip if session has an active loop - const loop = ctx.loop - if (loop) { - const loopName = loop.resolveLoopName(sessionID) - const state = loopName ? loop.getActiveState(loopName) : null - if (state?.active) { - logger.log(`plan-capture: session ${sessionID} has active loop, skipping user paste capture`) - return - } - } - - // If the streaming branch already observed this message's text parts and - // none contained plan markers, there is nothing to capture — skip the - // session.messages() fetch entirely. When no parts were observed (e.g. the - // message arrived without part events), fall through and fetch as before. - const partKey = info?.id ? `${sessionID}:${info.id}` : null - if (partKey && seenPartMessageIds.has(partKey) && !markerPartMessageIds.has(partKey)) { - seenPartMessageIds.delete(partKey) - logger.log(`plan-capture: message ${info?.id} had no plan markers in streamed parts, skipping fetch`) - return - } - if (partKey) { - seenPartMessageIds.delete(partKey) - markerPartMessageIds.delete(partKey) - } + // 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 { - const result = await capturePastedPlanForSession( - { v2, client, plansRepo, projectId, directory, logger }, - sessionID - ) - - if (result.status === 'captured') { - logger.log(`plan-capture: captured pasted plan from user message for session ${sessionID}`) - await triggerPasteApprovalQuestion(sessionID, result.planText) - } else if (result.status === 'already-current') { - const planKey = `${sessionID}:${hashPlanText(result.planText)}` - if (streamingCapturedPlanKeys.has(planKey)) { - // Streaming branch pre-captured this plan but did not prompt; - // treat as freshly captured and prompt now. - streamingCapturedPlanKeys.delete(planKey) - logger.log(`plan-capture: streaming pre-captured plan for session ${sessionID}, prompting now`) - await triggerPasteApprovalQuestion(sessionID, result.planText) - } else { - // Plan was already stored prior to this event flow — skip prompt - // to avoid re-prompting the same plan on every user message. - logger.log(`plan-capture: plan already stored for session ${sessionID}, skipping prompt`) - } - } else if (result.status === 'invalid') { - logger.log(`plan-capture: invalid pasted plan in session ${sessionID}: ${result.reason}`) - ctx.v2.tui?.publish({ - directory: ctx.directory, - body: { - type: 'tui.toast.show', - properties: { - title: 'Forge plan execution', - message: `Invalid pasted plan markers: ${result.reason}`, - variant: 'error', - duration: 5000, - }, - }, - }).catch((err: unknown) => { - logger.error('plan-capture: failed to publish error toast', err as Error) - }) - } - // already-current, not-found, read-failed: return without prompting + logger.log(`plan-capture: user-pasted plan completed for session ${sessionID}, prompting architect`) + await triggerPasteApprovalQuestion(sessionID, planText) } catch (error) { logCaptureError(sessionID, error) } @@ -207,7 +146,8 @@ export function createPlanCaptureEventHook(ctx: ToolContext) { logger.log(`plan-capture: already prompted for plan ${planKey}, skipping`) return } - trackKey(promptedPlanKeys, planKey) + 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.` diff --git a/src/services/plan-capture.ts b/src/services/plan-capture.ts index 83de4a2905..e1ca9c1252 100644 --- a/src/services/plan-capture.ts +++ b/src/services/plan-capture.ts @@ -3,7 +3,7 @@ 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, inspectLatestPastedPlan, sanitizePlanPaths } from '../utils/marked-plan-parser' +import { extractMarkedPlan, inspectLatestMarkedPlan, sanitizePlanPaths } from '../utils/marked-plan-parser' export interface CaptureLatestPlanDeps { v2: ToolContext['v2'] @@ -108,11 +108,9 @@ async function readRecentMessages( } } -async function captureInspectedPlanForSession( +export async function captureLatestPlanForSession( deps: CaptureLatestPlanDeps, - sessionID: string, - inspect: (messages: PlanCaptureMessage[]) => ReturnType, - label: string + sessionID: string ): Promise { const read = await readRecentMessages(deps, sessionID) if (read.status === 'read-failed') return read @@ -121,31 +119,17 @@ async function captureInspectedPlanForSession( return { status: 'not-found' } } - const inspection = inspect(read.messages) + const inspection = inspectLatestMarkedPlan(read.messages) if (inspection.status === 'found') { return writeCapturedPlanForSession(deps, sessionID, inspection.planText, inspection.messageId) } if (inspection.status === 'invalid') { - deps.logger.log(`plan-capture: invalid ${label} plan in session ${sessionID}: ${inspection.reason}`) + deps.logger.log(`plan-capture: invalid marked plan in session ${sessionID}: ${inspection.reason}`) return { status: 'invalid', reason: inspection.reason } } - deps.logger.log(`plan-capture: no valid ${label} plan found in session ${sessionID}`) + deps.logger.log(`plan-capture: no valid marked plan found in session ${sessionID}`) return { status: 'not-found' } } - -export async function captureLatestPlanForSession( - deps: CaptureLatestPlanDeps, - sessionID: string -): Promise { - return captureInspectedPlanForSession(deps, sessionID, inspectLatestMarkedPlan, 'marked') -} - -export async function capturePastedPlanForSession( - deps: CaptureLatestPlanDeps, - sessionID: string -): Promise { - return captureInspectedPlanForSession(deps, sessionID, inspectLatestPastedPlan, 'pasted') -} diff --git a/test/plan-capture.test.ts b/test/plan-capture.test.ts index a6f4214f19..e46da0207a 100644 --- a/test/plan-capture.test.ts +++ b/test/plan-capture.test.ts @@ -9,7 +9,7 @@ import { PLAN_END_MARKER, type PlanCaptureMessage, } from '../src/utils/marked-plan-parser' -import { captureMarkedPlanTextForSession, captureLatestPlanForSession, capturePastedPlanForSession } from '../src/services/plan-capture' +import { captureMarkedPlanTextForSession, captureLatestPlanForSession } from '../src/services/plan-capture' import { createPlanCaptureEventHook } from '../src/hooks/plan-capture' describe('extractMarkedPlan', () => { @@ -691,204 +691,6 @@ describe('captureLatestPlanForSession legacy client fallback', () => { }) }) -describe('capturePastedPlanForSession', () => { - function createFakePlansRepo() { - const plans = new Map() - return { - writeForSession: (_projectId: string, sessionId: string, content: string) => { - plans.set(sessionId, { content, updatedAt: Date.now() }) - }, - 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: () => {}, - } - - test('captures a valid pasted plan from the newest user message', async () => { - const plansRepo = createFakePlansRepo() - const deps = { - v2: { session: { messages: async () => ({ - data: [{ - info: { role: 'user', id: 'msg-1' }, - parts: [{ type: 'text', text: `${PLAN_START_MARKER}\n# Pasted Plan\n\n## Step 1\n- Do it\n${PLAN_END_MARKER}` }], - }], - }) } }, - client: { session: { messages: async () => ({ data: [] }) } }, - plansRepo, - projectId: 'test-project', - directory: '/tmp/project', - logger, - } - - const result = await capturePastedPlanForSession(deps as any, 'session-pp-1') - expect(result.status).toBe('captured') - if (result.status === 'captured') { - expect(result.planText).toBe('# Pasted Plan\n\n## Step 1\n- Do it') - } - expect(plansRepo.getForSession('test-project', 'session-pp-1')?.content).toBe('# Pasted Plan\n\n## Step 1\n- Do it') - }) - - test('returns already-current when identical plan is already stored', async () => { - const plansRepo = createFakePlansRepo() - const planText = `${PLAN_START_MARKER}\n# Same Plan\n\nContent\n${PLAN_END_MARKER}` - const messageData = [{ - info: { role: 'user', id: 'msg-same' }, - parts: [{ type: 'text', text: planText }], - }] - const deps = { - v2: { session: { messages: async () => ({ data: messageData }) } }, - client: { session: { messages: async () => ({ data: [] }) } }, - plansRepo, - projectId: 'test-project', - directory: '/tmp/project', - logger, - } - - // First call captures - const first = await capturePastedPlanForSession(deps as any, 'session-pp-same') - expect(first.status).toBe('captured') - - // Second call with same content should be already-current - const second = await capturePastedPlanForSession(deps as any, 'session-pp-same') - expect(second.status).toBe('already-current') - if (second.status === 'already-current') { - expect(second.planText).toBe('# Same Plan\n\nContent') - } - }) - - test('returns not-found when user message has no markers', async () => { - const plansRepo = createFakePlansRepo() - const deps = { - v2: { session: { messages: async () => ({ - data: [{ - info: { role: 'user', id: 'msg-nope' }, - parts: [{ type: 'text', text: 'Just some plain text without markers' }], - }], - }) } }, - client: { session: { messages: async () => ({ data: [] }) } }, - plansRepo, - projectId: 'test-project', - directory: '/tmp/project', - logger, - } - - const result = await capturePastedPlanForSession(deps as any, 'session-pp-nope') - expect(result.status).toBe('not-found') - expect(plansRepo.getForSession('test-project', 'session-pp-nope')).toBeNull() - }) - - test('returns not-found when newest user message has no markers but older user message has a plan', async () => { - const plansRepo = createFakePlansRepo() - const deps = { - v2: { session: { messages: async () => ({ - data: [ - { - 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' }], - }, - ], - }) } }, - client: { session: { messages: async () => ({ data: [] }) } }, - plansRepo, - projectId: 'test-project', - directory: '/tmp/project', - logger, - } - - const result = await capturePastedPlanForSession(deps as any, 'session-pp-regression') - // Should NOT capture the older plan — only the newest user message is considered - expect(result.status).toBe('not-found') - expect(plansRepo.getForSession('test-project', 'session-pp-regression')).toBeNull() - }) - - test('returns invalid on malformed markers in user message', async () => { - const plansRepo = createFakePlansRepo() - const deps = { - v2: { session: { messages: async () => ({ - data: [{ - info: { role: 'user', id: 'msg-bad' }, - parts: [{ type: 'text', text: `${PLAN_START_MARKER}\nNo end marker` }], - }], - }) } }, - client: { session: { messages: async () => ({ data: [] }) } }, - plansRepo, - projectId: 'test-project', - directory: '/tmp/project', - logger, - } - - const result = await capturePastedPlanForSession(deps as any, 'session-pp-bad') - expect(result.status).toBe('invalid') - if (result.status === 'invalid') { - expect(result.reason).toBe('unterminated') - } - expect(plansRepo.getForSession('test-project', 'session-pp-bad')).toBeNull() - }) - - test('returns not-found when messages array is empty', async () => { - const plansRepo = createFakePlansRepo() - const deps = { - v2: { session: { messages: async () => ({ data: [] }) } }, - client: { session: { messages: async () => ({ data: [] }) } }, - plansRepo, - projectId: 'test-project', - directory: '/tmp/project', - logger, - } - - const result = await capturePastedPlanForSession(deps as any, 'session-pp-empty') - expect(result.status).toBe('not-found') - }) - - test('falls back to legacy client when v2 returns empty data', async () => { - const plansRepo = createFakePlansRepo() - const deps = { - v2: { session: { messages: async () => ({ data: [] }) } }, - client: { session: { messages: async () => ({ - data: [{ - info: { role: 'user', id: 'msg-fb' }, - parts: [{ type: 'text', text: `${PLAN_START_MARKER}\nFallback Pasted Plan\n${PLAN_END_MARKER}` }], - }], - }) } }, - plansRepo, - projectId: 'test-project', - directory: '/tmp/project', - logger, - } - - const result = await capturePastedPlanForSession(deps as any, 'session-pp-fb') - expect(result.status).toBe('captured') - expect(plansRepo.getForSession('test-project', 'session-pp-fb')?.content).toBe('Fallback Pasted Plan') - }) - - test('returns not-found when both v2 and legacy return empty', async () => { - const plansRepo = createFakePlansRepo() - const deps = { - v2: { session: { messages: async () => ({ data: [] }) } }, - client: { session: { messages: async () => ({ data: [] }) } }, - plansRepo, - projectId: 'test-project', - directory: '/tmp/project', - logger, - } - - const result = await capturePastedPlanForSession(deps as any, 'session-pp-both-empty') - expect(result.status).toBe('not-found') - }) -}) - describe('plan capture trigger on assistant message completion', () => { function createFakePlansRepo() { const plans = new Map() @@ -931,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 and no markers present (no plan written)', async () => { + test('user message.updated with no stashed plan does not fetch or write', async () => { const plansRepo = createFakePlansRepo() let messagesCalls = 0 const hook = createPlanCaptureEventHook({ @@ -948,10 +750,10 @@ 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 } } } } }) - // No plan written because messages are empty + // 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() - // User messages are now handled, so messages are fetched - expect(messagesCalls).toBeGreaterThanOrEqual(1) + expect(messagesCalls).toBe(0) }) test('ignores message.updated when time.completed is undefined (streaming, not finished)', async () => { @@ -1070,304 +872,139 @@ describe('user paste capture trigger', () => { } } - test('(a) captures plan and prompts architect on user message with valid plan', async () => { + 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({ - messagesData: [{ - info: { role: 'user', id: 'msg-user-a' }, - parts: [{ type: 'text', text: MARKED_PLAN }], - }], - promptAsyncCalls, - }) + const ctx = makeHookCtx({ promptAsyncCalls }) const hook = createPlanCaptureEventHook(ctx as any) - await hook({ - event: { - type: 'message.updated', - properties: { - sessionID: 'session-up-a', - info: { role: 'user', id: 'msg-user-a', time: { created: 1, completed: 2 } }, - }, - }, - }) - - // Plan should be captured + 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) - // promptAsync should have been called with agent: 'architect' - expect(promptAsyncCalls.length).toBeGreaterThanOrEqual(1) - const architectCall = promptAsyncCalls.find(c => c.agent === 'architect') - expect(architectCall).toBeDefined() + + await hook(userCompletionEvent('session-up-a', 'msg-user-a') as any) + expect(architectCount(promptAsyncCalls)).toBe(1) }) - test('(b) firing same event again does not prompt a second time (already-current)', async () => { + test('(b) completing the same message twice prompts only once', async () => { const promptAsyncCalls: Array<{ agent?: string }> = [] - const ctx = makeHookCtx({ - messagesData: [{ - info: { role: 'user', id: 'msg-user-b' }, - parts: [{ type: 'text', text: MARKED_PLAN }], - }], - promptAsyncCalls, - }) + const ctx = makeHookCtx({ promptAsyncCalls }) const hook = createPlanCaptureEventHook(ctx as any) - // First call: capture + prompt - await hook({ - event: { - type: 'message.updated', - properties: { - sessionID: 'session-up-b', - info: { role: 'user', id: 'msg-user-b', time: { created: 1, completed: 2 } }, - }, - }, - }) - const firstPromptCount = promptAsyncCalls.filter(c => c.agent === 'architect').length - expect(firstPromptCount).toBe(1) + await hook(streamingEvent('session-up-b', 'msg-user-b', MARKED_PLAN) as any) - // Second call with same event: no new prompt - await hook({ - event: { - type: 'message.updated', - properties: { - sessionID: 'session-up-b', - info: { role: 'user', id: 'msg-user-b', time: { created: 1, completed: 2 } }, - }, - }, - }) - const secondPromptCount = promptAsyncCalls.filter(c => c.agent === 'architect').length - expect(secondPromptCount).toBe(1) + 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 write or prompt', async () => { + test('(c) user message without markers does not capture or prompt', async () => { const promptAsyncCalls: Array<{ agent?: string }> = [] - const ctx = makeHookCtx({ - messagesData: [{ - info: { role: 'user', id: 'msg-user-c' }, - parts: [{ type: 'text', text: 'Just some plain text with no markers' }], - }], - promptAsyncCalls, - }) + const ctx = makeHookCtx({ promptAsyncCalls }) const hook = createPlanCaptureEventHook(ctx as any) - await hook({ - event: { - type: 'message.updated', - properties: { - sessionID: 'session-up-c', - info: { role: 'user', id: 'msg-user-c', time: { created: 1, completed: 2 } }, - }, - }, - }) + 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, no capture and no prompt', async () => { + test('(d) when loop is active, streaming does not capture and completion does not prompt', async () => { const promptAsyncCalls: Array<{ agent?: string }> = [] const ctx = makeHookCtx({ - messagesData: [{ - info: { role: 'user', id: 'msg-user-d' }, - parts: [{ type: 'text', text: MARKED_PLAN }], - }], promptAsyncCalls, resolveLoopName: () => 'my-loop', getActiveState: () => ({ active: true, sessionId: 'session-up-d' }), }) const hook = createPlanCaptureEventHook(ctx as any) - await hook({ - event: { - type: 'message.updated', - properties: { - sessionID: 'session-up-d', - info: { role: 'user', id: 'msg-user-d', time: { created: 1, completed: 2 } }, - }, - }, - }) + 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('(f) newest user message without markers does not capture older user plan', async () => { + test('(e) completing a markerless message does not prompt for an earlier message\u2019s plan', async () => { const promptAsyncCalls: Array<{ agent?: string }> = [] - const ctx = makeHookCtx({ - messagesData: [ - { - info: { role: 'user', id: 'msg-old-f' }, - parts: [{ type: 'text', text: MARKED_PLAN }], - }, - { - info: { role: 'user', id: 'msg-newest-f' }, - parts: [{ type: 'text', text: 'Just some plain text without markers' }], - }, - ], - promptAsyncCalls, - }) + const ctx = makeHookCtx({ promptAsyncCalls }) const hook = createPlanCaptureEventHook(ctx as any) - await hook({ - event: { - type: 'message.updated', - properties: { - sessionID: 'session-up-f', - info: { role: 'user', id: 'msg-newest-f', time: { created: 1, completed: 2 } }, - }, - }, - }) + // 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) - // Should NOT capture the older plan — only the newest user message is considered - expect(ctx.plansRepo.getForSession('test-project', 'session-up-f')).toBeNull() + // The newer message has nothing stashed, so no prompt fires. expect(promptAsyncCalls.length).toBe(0) }) - test('(e) user message without time.completed is ignored', async () => { + test('(f) user message without time.completed is ignored', async () => { const promptAsyncCalls: Array<{ agent?: string }> = [] - let messagesCalls = 0 - const ctx = makeHookCtx({ - messagesData: [{ - info: { role: 'user', id: 'msg-user-e' }, - parts: [{ type: 'text', text: MARKED_PLAN }], - }], - promptAsyncCalls, - }) - // Override v2.session.messages to track calls - ctx.v2.session.messages = async () => { - messagesCalls++ - return { data: [] } - } + const ctx = makeHookCtx({ promptAsyncCalls }) const hook = createPlanCaptureEventHook(ctx as any) - await hook({ - event: { - type: 'message.updated', - properties: { - sessionID: 'session-up-e', - info: { role: 'user', id: 'msg-user-e', time: { created: 1 } }, // no completed - }, - }, - }) + 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(ctx.plansRepo.getForSession('test-project', 'session-up-e')).toBeNull() - expect(messagesCalls).toBe(0) expect(promptAsyncCalls.length).toBe(0) }) - test('(g) streaming path captures user paste + completion handler prompts architect once', async () => { + test('(g) streaming then user completion prompts architect exactly once', async () => { const promptAsyncCalls: Array<{ agent?: string }> = [] - const ctx = makeHookCtx({ - messagesData: [{ - info: { role: 'user', id: 'msg-stream-g' }, - parts: [{ type: 'text', text: MARKED_PLAN }], - }], - promptAsyncCalls, - }) + const ctx = makeHookCtx({ promptAsyncCalls }) const hook = createPlanCaptureEventHook(ctx as any) - // Simulate message.part.updated with a complete user-pasted marked plan - // (the streaming path fires first with the full text containing both markers) - await hook({ - event: { - type: 'message.part.updated', - properties: { - sessionID: 'session-stream-g', - part: { type: 'text', messageID: 'msg-stream-g', text: MARKED_PLAN }, - }, - }, - }) - - // Plan should be stored by streaming path + 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) - // Now simulate message.updated completing the user message - await hook({ - event: { - type: 'message.updated', - properties: { - sessionID: 'session-stream-g', - info: { role: 'user', id: 'msg-stream-g', time: { created: 1, completed: 2 } }, - }, - }, - }) + await hook(userCompletionEvent('session-stream-g', 'msg-stream-g') as any) - // Plan should still be the same (deduped write — already-current) expect(ctx.plansRepo.getForSession('test-project', 'session-stream-g')?.content).toBe(EXPECTED_PLAN) - // Architect should be prompted exactly once (streaming pre-capture tracks the plan - // key so the completion handler treats already-current as freshly captured and prompts) - const architectCalls = promptAsyncCalls.filter(c => c.agent === 'architect') - expect(architectCalls.length).toBe(1) + expect(architectCount(promptAsyncCalls)).toBe(1) }) - test('(h) streaming path with active loop: no capture, no prompt', async () => { + test('(h) streaming with an active loop: no capture, no prompt', async () => { const promptAsyncCalls: Array<{ agent?: string }> = [] const ctx = makeHookCtx({ - messagesData: [{ - info: { role: 'user', id: 'msg-loop-h' }, - parts: [{ type: 'text', text: MARKED_PLAN }], - }], promptAsyncCalls, resolveLoopName: () => 'test-loop', getActiveState: () => ({ active: true, sessionId: 'session-loop-h' }), }) const hook = createPlanCaptureEventHook(ctx as any) - // Simulate message.part.updated with a complete user-pasted marked plan - await hook({ - event: { - type: 'message.part.updated', - properties: { - sessionID: 'session-loop-h', - part: { type: 'text', messageID: 'msg-loop-h', text: MARKED_PLAN }, - }, - }, - }) - - // Plan should NOT be stored (loop guard in streaming path blocks capture) + await hook(streamingEvent('session-loop-h', 'msg-loop-h', MARKED_PLAN) as any) expect(ctx.plansRepo.getForSession('test-project', 'session-loop-h')).toBeNull() - // Simulate message.updated completing the user message - await hook({ - event: { - type: 'message.updated', - properties: { - sessionID: 'session-loop-h', - info: { role: 'user', id: 'msg-loop-h', time: { created: 1, completed: 2 } }, - }, - }, - }) - - // Still no plan stored, and no prompt fired + 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) pre-seeded plan in repo: already-current without streaming pre-capture does not prompt', async () => { + 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({ - messagesData: [{ - info: { role: 'user', id: 'msg-pre-i' }, - parts: [{ type: 'text', text: MARKED_PLAN }], - }], - promptAsyncCalls, - }) - // Pre-seed the plansRepo with the same plan content before any event fires + const ctx = makeHookCtx({ promptAsyncCalls }) ctx.plansRepo.writeForSession('test-project', 'session-pre-i', EXPECTED_PLAN) const hook = createPlanCaptureEventHook(ctx as any) - // Fire the user completion event - await hook({ - event: { - type: 'message.updated', - properties: { - sessionID: 'session-pre-i', - info: { role: 'user', id: 'msg-pre-i', time: { created: 1, completed: 2 } }, - }, - }, - }) + // 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) - // Plan should already be stored (pre-seeded, still the same content) expect(ctx.plansRepo.getForSession('test-project', 'session-pre-i')?.content).toBe(EXPECTED_PLAN) - // No prompt should be sent — the plan was already stored before this event expect(promptAsyncCalls.length).toBe(0) }) })