diff --git a/backend/src/db/change-walkthroughs.ts b/backend/src/db/change-walkthroughs.ts index 8f4ee6728..bca5b5f7a 100644 --- a/backend/src/db/change-walkthroughs.ts +++ b/backend/src/db/change-walkthroughs.ts @@ -1,16 +1,56 @@ import type { Database } from 'bun:sqlite' import { ChangeWalkthroughSchema, + WalkthroughGenerationErrorSchema, + WALKTHROUGH_GENERATION_TIMEOUT_MS, + parseWalkthroughSourceKey, walkthroughSourceKey, type ChangeWalkthrough, + type WalkthroughGenerationError, + type WalkthroughSource, + type WalkthroughStatus, } from '@opencode-manager/shared/schemas' interface ChangeWalkthroughRow { session_id: string source_key: string diff_hash: string - payload: string + generation_id: string + walkthrough_session_id: string | null + status: string + artifact_path: string | null + published_at: number | null + error: string | null + model: string | null created_at: number + updated_at: number + execution_started_at: number | null +} + +const ROW_COLUMNS = 'session_id, source_key, diff_hash, generation_id, walkthrough_session_id, status, artifact_path, published_at, error, model, created_at, updated_at, execution_started_at' + +export interface ChangeWalkthroughPatch { + walkthroughSessionId?: string | null + status?: WalkthroughStatus + publishedAt?: number | null + executionStartedAt?: number | null + error?: WalkthroughGenerationError | null + updatedAt?: number +} + +export interface ChangeWalkthroughUpdateOptions { + walkthroughSessionId?: string + running?: boolean +} + +export interface ClaimChangeWalkthroughGenerationInput { + sessionId: string + source: WalkthroughSource + diffHash: string + generationId: string + artifactPath: string + model: string | null + now: number } export function ensureChangeWalkthroughTable(db: Database): void { @@ -24,44 +64,174 @@ export function ensureChangeWalkthroughTable(db: Database): void { `) } +function parseStoredError(raw: string | null): WalkthroughGenerationError | null { + if (raw === null) { + return null + } + try { + const parsed = WalkthroughGenerationErrorSchema.safeParse(JSON.parse(raw)) + return parsed.success ? parsed.data : null + } catch { + return null + } +} + +function mapRow(row: ChangeWalkthroughRow): ChangeWalkthrough | null { + const source = parseWalkthroughSourceKey(row.source_key) + if (!source) { + return null + } + const parsed = ChangeWalkthroughSchema.safeParse({ + sessionId: row.session_id, + source, + diffHash: row.diff_hash, + walkthroughSessionId: row.walkthrough_session_id, + status: row.status, + artifactPath: row.artifact_path, + publishedAt: row.published_at, + executionStartedAt: row.execution_started_at, + error: parseStoredError(row.error), + model: row.model, + createdAt: row.created_at, + updatedAt: row.updated_at, + }) + return parsed.success ? parsed.data : null +} + export function getChangeWalkthrough( db: Database, sessionId: string, sourceKey: string, ): ChangeWalkthrough | null { const row = db - .prepare( - 'SELECT session_id, source_key, diff_hash, payload, created_at FROM change_walkthroughs WHERE session_id = ? AND source_key = ?', - ) + .prepare(`SELECT ${ROW_COLUMNS} FROM change_walkthroughs WHERE session_id = ? AND source_key = ?`) .get(sessionId, sourceKey) as ChangeWalkthroughRow | undefined + return row ? mapRow(row) : null +} + +export function getChangeWalkthroughByWalkthroughSession( + db: Database, + walkthroughSessionId: string, +): { walkthrough: ChangeWalkthrough; generationId: string } | null { + const row = db + .prepare(`SELECT ${ROW_COLUMNS} FROM change_walkthroughs WHERE walkthrough_session_id = ?`) + .get(walkthroughSessionId) as ChangeWalkthroughRow | undefined if (!row) { return null } - try { - return ChangeWalkthroughSchema.parse(JSON.parse(row.payload)) - } catch { + const walkthrough = mapRow(row) + if (!walkthrough) { return null } + return { walkthrough, generationId: row.generation_id } } -export function deleteChangeWalkthroughs(db: Database, sessionId: string): void { - db.prepare('DELETE FROM change_walkthroughs WHERE session_id = ?').run(sessionId) -} - -export function saveChangeWalkthrough(db: Database, walkthrough: ChangeWalkthrough): void { - const sourceKey = walkthroughSourceKey(walkthrough.source) - db.prepare(` - INSERT INTO change_walkthroughs(session_id, source_key, diff_hash, payload, created_at) - VALUES(?,?,?,?,?) +export function claimChangeWalkthroughGeneration( + db: Database, + input: ClaimChangeWalkthroughGenerationInput, +): boolean { + const result = db.prepare(` + INSERT INTO change_walkthroughs( + session_id, source_key, diff_hash, generation_id, walkthrough_session_id, status, + artifact_path, published_at, error, model, created_at, updated_at, execution_started_at + ) VALUES(?,?,?,?,NULL,'generating',?,NULL,NULL,?,?,?,NULL) ON CONFLICT(session_id, source_key) DO UPDATE SET diff_hash = excluded.diff_hash, - payload = excluded.payload, - created_at = excluded.created_at + generation_id = excluded.generation_id, + walkthrough_session_id = NULL, + status = 'generating', + artifact_path = excluded.artifact_path, + published_at = NULL, + error = NULL, + model = excluded.model, + updated_at = excluded.updated_at, + execution_started_at = NULL + WHERE change_walkthroughs.status != 'generating' OR change_walkthroughs.updated_at < ? `).run( - walkthrough.sessionId, - sourceKey, - walkthrough.diffHash, - JSON.stringify(walkthrough), - walkthrough.createdAt, + input.sessionId, + walkthroughSourceKey(input.source), + input.diffHash, + input.generationId, + input.artifactPath, + input.model, + input.now, + input.now, + input.now - WALKTHROUGH_GENERATION_TIMEOUT_MS, ) + return result.changes > 0 +} + +export function updateChangeWalkthrough( + db: Database, + sessionId: string, + sourceKey: string, + expectedStatuses: WalkthroughStatus[], + patch: ChangeWalkthroughPatch, + options: ChangeWalkthroughUpdateOptions = {}, +): ChangeWalkthrough | null { + const assignments: string[] = [] + const params: (string | number | null)[] = [] + + if (patch.walkthroughSessionId !== undefined) { + assignments.push('walkthrough_session_id = ?') + params.push(patch.walkthroughSessionId) + } + if (patch.status !== undefined) { + assignments.push('status = ?') + params.push(patch.status) + } + if (patch.publishedAt !== undefined) { + assignments.push('published_at = ?') + params.push(patch.publishedAt) + } + if (patch.executionStartedAt !== undefined) { + assignments.push('execution_started_at = ?') + params.push(patch.executionStartedAt) + } + if (patch.error !== undefined) { + assignments.push('error = ?') + params.push(patch.error === null ? null : JSON.stringify(patch.error)) + } + if (patch.updatedAt !== undefined) { + assignments.push('updated_at = ?') + params.push(patch.updatedAt) + } + + if (assignments.length === 0 || expectedStatuses.length === 0) { + return null + } + + const placeholders = expectedStatuses.map(() => '?').join(', ') + const conditions = ['session_id = ?', 'source_key = ?', `status IN (${placeholders})`] + const conditionParams: (string | number | null)[] = [sessionId, sourceKey, ...expectedStatuses] + if (options.walkthroughSessionId !== undefined) { + conditions.push('walkthrough_session_id = ?') + conditionParams.push(options.walkthroughSessionId) + } + if (options.running !== undefined) { + conditions.push(options.running ? 'execution_started_at IS NOT NULL' : 'execution_started_at IS NULL') + } + + const result = db + .prepare(`UPDATE change_walkthroughs SET ${assignments.join(', ')} WHERE ${conditions.join(' AND ')}`) + .run(...params, ...conditionParams) + + if (result.changes === 0) { + return null + } + return getChangeWalkthrough(db, sessionId, sourceKey) +} + +export function listChangeWalkthroughs(db: Database, sessionId: string): ChangeWalkthrough[] { + const rows = db + .prepare(`SELECT ${ROW_COLUMNS} FROM change_walkthroughs WHERE session_id = ?`) + .all(sessionId) as ChangeWalkthroughRow[] + return rows.flatMap((row) => { + const mapped = mapRow(row) + return mapped ? [mapped] : [] + }) +} + +export function deleteChangeWalkthroughs(db: Database, sessionId: string): void { + db.prepare('DELETE FROM change_walkthroughs WHERE session_id = ?').run(sessionId) } diff --git a/backend/src/db/migrations/202610151200-change-walkthrough-sessions.ts b/backend/src/db/migrations/202610151200-change-walkthrough-sessions.ts new file mode 100644 index 000000000..cfa8c9633 --- /dev/null +++ b/backend/src/db/migrations/202610151200-change-walkthrough-sessions.ts @@ -0,0 +1,36 @@ +import type { Migration } from '../migration-runner' + +const migration: Migration = { + id: '202610151200-change-walkthrough-sessions', + up(db) { + const columns = db.prepare('PRAGMA table_info(change_walkthroughs)').all() as Array<{ name: string }> + if (columns.some((column) => column.name === 'generation_id')) { + return + } + + db.run('DROP TABLE IF EXISTS change_walkthroughs') + db.run(` + CREATE TABLE change_walkthroughs ( + session_id TEXT NOT NULL, + source_key TEXT NOT NULL, + diff_hash TEXT NOT NULL, + generation_id TEXT NOT NULL, + walkthrough_session_id TEXT, + status TEXT NOT NULL, + artifact_path TEXT, + published_at INTEGER, + error TEXT, + model TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (session_id, source_key) + ) + `) + db.run('CREATE INDEX change_walkthroughs_walkthrough_session ON change_walkthroughs(walkthrough_session_id)') + }, + down(db) { + db.run('DROP TABLE IF EXISTS change_walkthroughs') + }, +} + +export default migration diff --git a/backend/src/db/migrations/202610161200-change-walkthrough-execution.ts b/backend/src/db/migrations/202610161200-change-walkthrough-execution.ts new file mode 100644 index 000000000..9acb622de --- /dev/null +++ b/backend/src/db/migrations/202610161200-change-walkthrough-execution.ts @@ -0,0 +1,22 @@ +import type { Migration } from '../migration-runner' + +interface ColumnInfo { + name: string +} + +const migration: Migration = { + id: '202610161200-change-walkthrough-execution', + + up(db) { + const columns = db.prepare('PRAGMA table_info(change_walkthroughs)').all() as ColumnInfo[] + if (!columns.some((column) => column.name === 'execution_started_at')) { + db.run('ALTER TABLE change_walkthroughs ADD COLUMN execution_started_at INTEGER') + } + }, + + down(db) { + void db + }, +} + +export default migration diff --git a/backend/src/db/migrations/index.ts b/backend/src/db/migrations/index.ts index a59c19d00..b04854719 100644 --- a/backend/src/db/migrations/index.ts +++ b/backend/src/db/migrations/index.ts @@ -31,6 +31,8 @@ import migration202610061700 from './202610061700-multi-run-fusions' import migration202610061701 from './202610061701-change-walkthroughs' import migration202610081200 from './202610081200-change-walkthrough-sources' import migration202610141200 from './202610141200-session-sandbox-modes' +import migration202610151200 from './202610151200-change-walkthrough-sessions' +import migration202610161200 from './202610161200-change-walkthrough-execution' export const allMigrations: Migration[] = [ migration001, @@ -65,4 +67,6 @@ export const allMigrations: Migration[] = [ migration202610061701, migration202610081200, migration202610141200, + migration202610151200, + migration202610161200, ] diff --git a/backend/src/index.ts b/backend/src/index.ts index d77451342..c08933600 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -43,6 +43,7 @@ import { createSessionSandboxRoutes } from './routes/session-sandbox' import { createSessionGoalRoutes } from './routes/session-goals' import { createMultiRunRoutes } from './routes/multi-runs' import { createChangeWalkthroughRoutes } from './routes/change-walkthroughs' +import { getChangeWalkthroughByWalkthroughSession } from './db/change-walkthroughs' import { createLogRoutes } from './routes/logs' import { createPreviewRoutes, createPreviewAvailability } from './routes/preview' import { createPreviewGatewayApp, PreviewSessionStore } from './services/preview/gateway' @@ -88,6 +89,7 @@ import { getTrustedOrigins, ENV } from '@opencode-manager/shared/config/env' +import { NotificationEventType } from '@opencode-manager/shared/schemas' const { PORT, HOST } = ENV.SERVER @@ -304,6 +306,12 @@ notificationService.addEventSuppressor(async (event, sessionId) => { return (await sessionPermissionModeService.getEffectiveMode(sessionId)).mode === 'auto' }) +notificationService.addEventSuppressor(async (event, sessionId) => { + if (!sessionId) return false + if (event.type !== NotificationEventType.SESSION_IDLE && event.type !== NotificationEventType.SESSION_FAILED) return false + return getChangeWalkthroughByWalkthroughSession(db, sessionId) !== null +}) + if (ENV.VAPID.PUBLIC_KEY && ENV.VAPID.PRIVATE_KEY) { if (!ENV.VAPID.SUBJECT) { logger.warn('VAPID_SUBJECT is not set — push notifications require a mailto: subject (e.g. mailto:you@example.com)') diff --git a/backend/src/routes/change-walkthroughs.ts b/backend/src/routes/change-walkthroughs.ts index 10b960cd2..add6c10eb 100644 --- a/backend/src/routes/change-walkthroughs.ts +++ b/backend/src/routes/change-walkthroughs.ts @@ -2,6 +2,7 @@ import { Hono } from 'hono' import { DEFAULT_WALKTHROUGH_SOURCE, GenerateChangeWalkthroughRequestSchema, + RefineChangeWalkthroughRequestSchema, parseWalkthroughSourceKey, } from '@opencode-manager/shared/schemas' import type { ChangeWalkthroughService } from '../services/change-walkthroughs' @@ -19,8 +20,7 @@ export function createChangeWalkthroughRoutes(service: ChangeWalkthroughService) } try { - const hunksFor = c.req.query('hunksFor') - const state = await service.getState(c.req.param('sessionId'), source, hunksFor) + const state = await service.getState(c.req.param('sessionId'), source) return c.json(state) } catch (error) { return handleServiceError(c, error, 'Failed to read change walkthrough', ServiceError) @@ -41,14 +41,28 @@ export function createChangeWalkthroughRoutes(service: ChangeWalkthroughService) } }) - app.delete('/:sessionId', (c) => { + app.post('/:sessionId/refine', async (c) => { + const parsed = await parseJsonBody(c, RefineChangeWalkthroughRequestSchema) + if (!parsed.ok) { + return parsed.response + } + + try { + const state = await service.refine(c.req.param('sessionId'), parsed.data) + return c.json(state, 202) + } catch (error) { + return handleServiceError(c, error, 'Failed to refine the change walkthrough', ServiceError) + } + }) + + app.delete('/:sessionId', async (c) => { const sourceParam = c.req.query('source') const source = sourceParam === undefined ? DEFAULT_WALKTHROUGH_SOURCE : parseWalkthroughSourceKey(sourceParam) if (!source) { return c.json({ error: 'Invalid walkthrough source' }, 400) } - service.cancelGeneration(c.req.param('sessionId'), source) + await service.cancelGeneration(c.req.param('sessionId'), source) return c.body(null, 204) }) diff --git a/backend/src/routes/internal/index.ts b/backend/src/routes/internal/index.ts index c6677c73d..00c3a718c 100644 --- a/backend/src/routes/internal/index.ts +++ b/backend/src/routes/internal/index.ts @@ -21,6 +21,7 @@ import { createInternalSessionArtifactRoutes } from './session-artifacts' import { createSessionGoalRoutes } from '../session-goals' import { createMultiRunRoutes } from '../multi-runs' import { createChangeWalkthroughRoutes } from '../change-walkthroughs' +import { createInternalWalkthroughSessionRoutes } from './walkthrough-sessions' import type { SessionPermissionModeService } from '../../services/session-permission-modes' import type { SessionSandboxService } from '../../services/session-sandbox' import type { RepoWorkspaceService } from '../../services/repo-workspace' @@ -61,6 +62,7 @@ export function createInternalRoutes( app.route('/sessions', createInternalSessionRoutes(db, openCodeClient, permissionModes, repoWorkspaces)) app.route('/assistant', createInternalAssistantRoutes(openCodeClient)) app.route('/change-walkthroughs', createChangeWalkthroughRoutes(changeWalkthroughService)) + app.route('/walkthrough-sessions', createInternalWalkthroughSessionRoutes(changeWalkthroughService)) app.route('/session-artifacts', createInternalSessionArtifactRoutes(openCodeClient)) app.route('/git-credentials', createInternalGitCredentialsRoutes(db)) app.route('/sandbox', createInternalSandboxRoutes(db, sessionSandbox)) diff --git a/backend/src/routes/internal/walkthrough-sessions.ts b/backend/src/routes/internal/walkthrough-sessions.ts new file mode 100644 index 000000000..96fddb14a --- /dev/null +++ b/backend/src/routes/internal/walkthrough-sessions.ts @@ -0,0 +1,34 @@ +import { Hono } from 'hono' +import { WalkthroughChangesParamsSchema } from '@opencode-manager/shared/schemas' +import type { ChangeWalkthroughService } from '../../services/change-walkthroughs' +import { handleServiceError, parseJsonBody } from '../../utils/route-helpers' +import { ServiceError } from '../../utils/service-error' + +export function createInternalWalkthroughSessionRoutes(service: ChangeWalkthroughService) { + const app = new Hono() + + app.post('/:sessionId/changes', async (c) => { + const parsed = await parseJsonBody(c, WalkthroughChangesParamsSchema, { allowEmpty: true }) + if (!parsed.ok) { + return parsed.response + } + + try { + const text = await service.readChangesForWalkthroughSession(c.req.param('sessionId'), parsed.data.file) + return c.json({ text }) + } catch (error) { + return handleServiceError(c, error, 'Failed to read walkthrough changes', ServiceError) + } + }) + + app.post('/:sessionId/publish', async (c) => { + try { + const result = await service.publishFromWalkthroughSession(c.req.param('sessionId')) + return c.json(result) + } catch (error) { + return handleServiceError(c, error, 'Failed to publish the walkthrough', ServiceError) + } + }) + + return app +} diff --git a/backend/src/services/change-walkthroughs.ts b/backend/src/services/change-walkthroughs.ts index ab577009b..7a4d9fa55 100644 --- a/backend/src/services/change-walkthroughs.ts +++ b/backend/src/services/change-walkthroughs.ts @@ -1,49 +1,53 @@ import type { Database } from 'bun:sqlite' -import { createHash } from 'node:crypto' -import { z } from 'zod' +import { randomUUID } from 'node:crypto' +import { mkdir, rm } from 'node:fs/promises' +import path from 'node:path' import { formatOpenCodeModelRef, isSessionNotFoundError, + openCodeLocation, parseOpenCodeModelRef, type FileDiffInfo, type ModelRef, type SessionInfo, } from '@opencode-manager/shared/opencode' -import { getOpenCodeGlobalConfigPath } from '@opencode-manager/shared/config/env' +import { getOpenCodeGlobalConfigPath, getWalkthroughsPath } from '@opencode-manager/shared/config/env' import { DEFAULT_WALKTHROUGH_SOURCE, - MECHANICAL_TEXT_BUDGET, - WALKTHROUGH_DIFF_MAX_CHARS, - WALKTHROUGH_HUNK_MAX_CHARS, - WALKTHROUGH_MAX_STOPS, - WALKTHROUGH_OUTLINE_LINE_MAX_CHARS, - WALKTHROUGH_OUTLINE_PREVIEW_LINES, - WALKTHROUGH_TEXT_MAX_CHARS, + WALKTHROUGH_GENERATION_TIMEOUT_MS, + buildSchedulePermissionRuleset, describeWalkthroughSource, - walkthroughHunksIdentity, walkthroughSourceKey, type ChangeWalkthrough, - type ChangeWalkthroughStateWire, - type ChangeWalkthroughWire, + type ChangeWalkthroughState, type GenerateChangeWalkthroughRequest, + type RefineChangeWalkthroughRequest, + type SchedulePermissionRuleset, type WalkthroughGenerationError, - type WalkthroughHunk, - type WalkthroughOmittedFile, type WalkthroughSource, - type WalkthroughStop, } from '@opencode-manager/shared/schemas' -import { countDiffLineChanges, parseDiffLines, splitDiffHunks, type DiffHunk } from '@opencode-manager/shared/utils' -import { deleteChangeWalkthroughs, getChangeWalkthrough, saveChangeWalkthrough } from '../db/change-walkthroughs' +import { + claimChangeWalkthroughGeneration, + deleteChangeWalkthroughs, + getChangeWalkthrough, + getChangeWalkthroughByWalkthroughSession, + listChangeWalkthroughs, + updateChangeWalkthrough, + type ChangeWalkthroughPatch, +} from '../db/change-walkthroughs' import { getErrorMessage } from '../utils/error-utils' import { ServiceError } from '../utils/service-error' -import { truncateText } from '../utils/text-truncate' -import { parseFirstJsonObject } from '../utils/json-extract' -import { mapWithConcurrency, createLimiter } from '../utils/concurrency' import { logger } from '../utils/logger' -import { GenerateTextTimeoutError, generateTextWithTimeout } from './opencode/generate-text' import { resolveOpenCodeModel } from './opencode-models' import type { OpenCodeClient } from './opencode/client' import { ChangeWalkthroughError } from './change-walkthrough-error' +import { + buildWalkthroughInput, + computeChangesHash, + formatWalkthroughFileChanges, + formatWalkthroughOutline, +} from './walkthrough-outline' +import { assertHtmlArtifactFile } from './session-artifacts' import { readWalkthroughChanges, fetchPullRequestRef, deletePullRequestRef } from './walkthrough-sources' import { resolveRepoForDirectory } from './repo' import type { GitService } from './git/GitService' @@ -51,113 +55,58 @@ import type { GitAuthService } from './git-auth' import type { SettingsService } from './settings' import type { SSEEvent } from './sse-aggregator' -const DEFAULT_TIMEOUT_MS = 120_000 -const WALKTHROUGH_CALL_ATTEMPTS = 2 -const WALKTHROUGH_EXPLAIN_CONCURRENCY = 4 -const WALKTHROUGH_SAVE_EVERY_STOPS = 4 -const WALKTHROUGH_PROMPT_VERSION = '2' -const HUNK_TRUNCATION_MARKER = '\n[hunk truncated]' -const TEXT_TRUNCATION_MARKER = '' -const REMAINING_STOP_TITLE = 'Remaining changes' -const REMAINING_STOP_EXPLANATION = 'These changes were not covered by the generated walkthrough.' -const MECHANICAL_STOP_TITLE = 'Mechanical changes' -const MECHANICAL_STOP_EXPLANATION = - 'Lock files, snapshots and generated files. Their contents were not sent to the model.' -const MECHANICAL_ONLY_SUMMARY = 'Only lock files, snapshots or generated files changed.' -const MECHANICAL_PARTIAL_SUMMARY = - 'Some changes were omitted. Only lock files, snapshots or generated files are shown.' -const OUTLINE_RETRY_DEPTHS = [3, 1, 0] -const WALKTHROUGH_MODEL_LIMITER = createLimiter(WALKTHROUGH_EXPLAIN_CONCURRENCY) - -const MECHANICAL_LOCKFILE_NAMES = new Set([ - 'pnpm-lock.yaml', - 'package-lock.json', - 'npm-shrinkwrap.json', - 'yarn.lock', - 'bun.lock', - 'bun.lockb', - 'Cargo.lock', - 'Gemfile.lock', - 'composer.lock', - 'poetry.lock', - 'uv.lock', - 'Pipfile.lock', - 'go.sum', - 'flake.lock', -]) - -const MECHANICAL_SUFFIXES = ['.snap', '.min.js', '.min.css', '.map'] - -export function isMechanicalChangePath(file: string): boolean { - const segments = file.split('/') - const basename = segments.at(-1) ?? file - if (MECHANICAL_LOCKFILE_NAMES.has(basename)) { - return true - } - if (MECHANICAL_SUFFIXES.some((suffix) => file.endsWith(suffix))) { - return true - } - return segments.includes('__snapshots__') -} - -const modelStopSchema = z.object({ - title: z.string().catch(''), - explanation: z.string().catch(''), - hunkIds: z.array(z.string()).catch([]), -}) - -const modelResponseSchema = z.object({ - summary: z.string().catch(''), - stops: z.array(modelStopSchema), -}) - -export interface WalkthroughInput { - hunks: WalkthroughHunk[] - mechanicalHunks: WalkthroughHunk[] - omittedFiles: WalkthroughOmittedFile[] -} - -export interface ParsedWalkthrough { - summary: string - stops: WalkthroughStop[] -} +const WALKTHROUGH_TIMEOUT_MESSAGE = 'The walkthrough session did not publish within 15 minutes' export interface ChangeWalkthroughServiceOptions { - timeoutMs?: number -} - -export function computeChangesHash(changes: FileDiffInfo[]): string { - const hash = createHash('sha256') - for (const change of changes) { - hash.update(`${change.file}\0${change.status}\0${change.patch}\n`) - } - return hash.digest('hex') -} - -export function computeHunkId(file: string, status: FileDiffInfo['status'], hunkText: string): string { - const body = hunkText.split('\n').slice(1).join('\n') - const digest = createHash('sha256').update(`${file}\0${status}\0${body}`).digest('hex') - return `h_${digest.slice(0, 12)}` -} - -export function computeStopId(hunkIds: string[]): string { - const digest = createHash('sha256').update([...hunkIds].sort().join('\n')).digest('hex') - return `s_${digest.slice(0, 12)}` -} - -function computeExplanationKey(modelKey: string, hunkIds: string[]): string { - const digest = createHash('sha256') - .update(`${WALKTHROUGH_PROMPT_VERSION}\0${modelKey}\0${[...hunkIds].sort().join('\n')}`) - .digest('hex') - return digest + generationTimeoutMs?: number + walkthroughsRoot?: string } -function computeModelKey(model: ModelRef): string { - return formatOpenCodeModelRef(model) +export function buildWalkthroughSessionPermissions(outputDir: string): SchedulePermissionRuleset { + const normalized = path.resolve(outputDir).replaceAll('\\', '/') + const ruleset = buildSchedulePermissionRuleset(null) + ruleset.push({ action: 'edit', resource: '*', effect: 'deny' }) + ruleset.push({ action: 'external_directory', resource: `${normalized}/*`, effect: 'allow' }) + ruleset.push({ action: 'edit', resource: `${normalized}/*`, effect: 'allow' }) + return ruleset } -function truncateWalkthroughText(text: string): string { - return truncateText(text, WALKTHROUGH_TEXT_MAX_CHARS, TEXT_TRUNCATION_MARKER).text +export function buildWalkthroughSessionPrompt({ + title, + sourceDescription, + artifactPath, +}: { + title: string + sourceDescription: string + artifactPath: string +}): string { + return [ + `You are building an interactive HTML walkthrough for a reviewer of "${title}" (${sourceDescription}).`, + '## Getting the changes', + [ + '- Call the `ocm` tool with the `walkthrough_changes` action to get an outline of the changed files and their hunks.', + '- Call `walkthrough_changes` again with `{ "file": "" }` to get one changed file\'s full hunks.', + '- Read surrounding code with the file tools when you need context. Repository edits are denied, so never modify repository files.', + ].join('\n'), + '## Reading order', + [ + '- Order the walkthrough by what best explains the change: contracts and data model first, then core logic, then callers and UI, then tests and config.', + '- Group related hunks, possibly across files, and explain intent and impact rather than restating the code.', + ].join('\n'), + '## Output', + [ + `- Write one self-contained HTML file at exactly this absolute path: ${artifactPath}`, + '- Write and update the HTML file with the file write/edit tools, not the shell, because the shell may not be able to reach that path.', + '- Inline all CSS and JavaScript. Load third-party libraries only from public CDNs. Do not write sibling files.', + '- Do not use forms, popups, cookies or local storage.', + '- Show the real diff lines with add/remove styling and a navigable overview of the change.', + ].join('\n'), + '## Publishing', + [ + '- Call the `ocm` tool with the `publish_walkthrough` action as soon as a first version is usable, and again after significant revisions.', + '- End with a one-paragraph summary reply.', + ].join('\n'), + ].join('\n\n') } function entryKey(sessionId: string, source: WalkthroughSource): string { @@ -187,574 +136,577 @@ function noTextChangesMessage(source: WalkthroughSource): string { return `The ${describeWalkthroughSource(source)} ${sourceChangesVerb(source)} no text changes` } -function applyReuse(stop: WalkthroughStop, reusable: Map, modelKey: string): WalkthroughStop { - const key = computeExplanationKey(modelKey, stop.hunkIds) - const explanation = reusable.get(key) - if (explanation !== undefined) { - return { ...stop, explanation, status: 'ready', explanationKey: key } - } - return stop.status === 'ready' ? { ...stop, explanationKey: key } : stop -} - -function isRemainingPlaceholder(stop: WalkthroughStop): boolean { - return stop.title === REMAINING_STOP_TITLE && stop.explanation === REMAINING_STOP_EXPLANATION -} - -function applySingleCallKeys(stop: WalkthroughStop, modelKey: string): WalkthroughStop { - if (stop.status !== 'ready' || stop.explanationKey !== null || isRemainingPlaceholder(stop)) { - return stop +function toGenerationError(error: unknown): WalkthroughGenerationError { + if (error instanceof ServiceError) { + return { + message: error.message, + ...(error.code ? { code: error.code } : {}), + ...(error.details !== undefined ? { details: error.details } : {}), + } } - return { ...stop, explanationKey: computeExplanationKey(modelKey, stop.hunkIds) } -} - -function buildWalkthroughPreamble(title: string): string { - return [ - `You are writing a change walkthrough for a reviewer who reads it top to bottom to understand the change to "${title}".`, - '## How to write the walkthrough', - [ - '- Order the stops by the reading order that best explains the change: contracts and data model first, then core logic, then callers and UI, then tests and config.', - '- Group related hunks, possibly across files.', - ].join('\n'), - ].join('\n') -} - -function buildWalkthroughInstructions(title: string): string { - return [ - buildWalkthroughPreamble(title), - [ - '- Reference only the hunk ids given below, each at most once.', - `- Use at most ${WALKTHROUGH_MAX_STOPS} stops.`, - '- Explain intent and impact rather than restating the code.', - '- Respond with only the JSON {"summary": string, "stops": [{"title": string, "explanation": string, "hunkIds": string[]}]}.', - ].join('\n'), - '## Hunks', - ].join('\n') -} -export function formatHunkOutline( - hunk: WalkthroughHunk, - previewLines = WALKTHROUGH_OUTLINE_PREVIEW_LINES, -): string { - const { added, removed } = countDiffLineChanges(hunk.text) - const head = `### ${hunk.id} ${hunk.file} (${hunk.status}) ${hunk.header} +${added} -${removed}` - const preview = parseDiffLines(hunk.text) - .filter((line) => line.type === 'add' || line.type === 'remove') - .slice(0, previewLines) - .map((line) => - truncateText( - `${line.type === 'add' ? '+' : '-'}${line.content}`, - WALKTHROUGH_OUTLINE_LINE_MAX_CHARS, - TEXT_TRUNCATION_MARKER, - ).text, - ) - return [head, ...preview].join('\n') + return { message: getErrorMessage(error) || 'Failed to generate the change walkthrough' } } -function formatHunkBlock(hunk: WalkthroughHunk): string { - return `### ${hunk.id} ${hunk.file} (${hunk.status})\n\n\`\`\`diff\n${hunk.text}\n\`\`\`` -} +export class ChangeWalkthroughService { + private readonly currentHashes = new Map() + private readonly hashEpochs = new Map() + private readonly pendingHashReads = new Map() + private readonly pullRequestRefs = new Map>() + private readonly timers = new Map>() + private readonly changesSnapshots = new Map() + private readonly generationTimeoutMs: number + private readonly walkthroughsRoot: string -function classifyOmittedFileReason(patch: string): WalkthroughOmittedFile['reason'] { - const binary = patch.includes('Binary files') || patch.includes('GIT binary patch') - if (!binary && patch.includes('rename to ')) { - return 'renamed' - } - if (!binary && (patch.includes('old mode ') || patch.includes('new mode '))) { - return 'modeChange' + constructor( + private readonly db: Database, + private readonly openCodeClient: OpenCodeClient, + private readonly settingsService: SettingsService, + private readonly gitAuthService: GitAuthService, + private readonly gitService: GitService, + options: ChangeWalkthroughServiceOptions = {}, + ) { + this.generationTimeoutMs = options.generationTimeoutMs ?? WALKTHROUGH_GENERATION_TIMEOUT_MS + this.walkthroughsRoot = options.walkthroughsRoot ?? getWalkthroughsPath() } - return 'binary' -} -export function buildWalkthroughInput(changes: FileDiffInfo[]): WalkthroughInput { - const hunks: WalkthroughHunk[] = [] - const mechanicalHunks: WalkthroughHunk[] = [] - const omittedFiles: WalkthroughOmittedFile[] = [] - const hunkIdCounts = new Map() - let mechanicalTextLength = 0 - - const nextHunkId = (file: string, status: FileDiffInfo['status'], text: string): string => { - const baseId = computeHunkId(file, status, text) - const count = (hunkIdCounts.get(baseId) ?? 0) + 1 - hunkIdCounts.set(baseId, count) - return count === 1 ? baseId : `${baseId}_${count}` - } + async getState( + sessionId: string, + source: WalkthroughSource = DEFAULT_WALKTHROUGH_SOURCE, + ): Promise { + const session = await this.readSession(sessionId) + const key = entryKey(sessionId, source) - const prepareHunks = (change: FileDiffInfo, fileHunks: DiffHunk[]): WalkthroughHunk[] => - fileHunks.map((hunk) => { - const truncated = truncateText(hunk.text, WALKTHROUGH_HUNK_MAX_CHARS, HUNK_TRUNCATION_MARKER) - return { - id: nextHunkId(change.file, change.status, hunk.text), - file: change.file, - status: change.status, - header: hunk.header, - text: truncated.text, - truncated: truncated.truncated, - } satisfies WalkthroughHunk - }) + let walkthrough = getChangeWalkthrough(this.db, sessionId, walkthroughSourceKey(source)) + if (this.hasGenerationTimedOut(walkthrough)) { + await this.expireGeneration(sessionId, source) + walkthrough = getChangeWalkthrough(this.db, sessionId, walkthroughSourceKey(source)) + } - const buildMechanicalHunk = (change: FileDiffInfo, fileHunks: DiffHunk[]): WalkthroughHunk => { - const text = fileHunks.map((hunk) => hunk.text).join('\n') + const currentDiffHash = await this.readCurrentDiffHash(session, source, key) return { - id: nextHunkId(change.file, change.status, text), - file: change.file, - status: change.status, - header: fileHunks[0]!.header, - text, - truncated: false, - additions: change.additions, - deletions: change.deletions, - } satisfies WalkthroughHunk + walkthrough, + currentDiffHash, + stale: walkthrough !== null && currentDiffHash !== null && walkthrough.diffHash !== currentDiffHash, + generating: walkthrough?.status === 'generating', + running: walkthrough !== null && walkthrough.executionStartedAt !== null, + error: walkthrough?.status === 'failed' ? walkthrough.error : null, + } } - const buildMechanicalSummaryHunk = (change: FileDiffInfo): WalkthroughHunk => ({ - id: nextHunkId(change.file, change.status, ''), - file: change.file, - status: change.status, - header: '', - text: '', - truncated: true, - additions: change.additions, - deletions: change.deletions, - }) - - changes.forEach((change) => { - const mechanical = isMechanicalChangePath(change.file) - const fileHunks = splitDiffHunks(change.patch) - if (fileHunks.length === 0) { - omittedFiles.push({ file: change.file, reason: classifyOmittedFileReason(change.patch) }) - return - } + async startGeneration( + sessionId: string, + request: GenerateChangeWalkthroughRequest, + ): Promise { + const source = request.source ?? DEFAULT_WALKTHROUGH_SOURCE + const key = entryKey(sessionId, source) + const session = await this.readSession(sessionId) - if (mechanical) { - const text = fileHunks.map((hunk) => hunk.text).join('\n') - if (mechanicalTextLength + text.length <= MECHANICAL_TEXT_BUDGET) { - mechanicalHunks.push(buildMechanicalHunk(change, fileHunks)) - mechanicalTextLength += text.length - } else { - mechanicalHunks.push(buildMechanicalSummaryHunk(change)) - } - return + if (source.kind === 'pullRequest') { + await this.fetchPullRequestChanges(session, source) } - hunks.push(...prepareHunks(change, fileHunks)) - }) + const changes = await this.readChanges(session, source) - return { hunks, mechanicalHunks, omittedFiles } -} + if (changes.length === 0) { + throw new ChangeWalkthroughError(noChangesMessage(source), 409, { + code: 'WALKTHROUGH_NO_CHANGES', + }) + } -function createStop({ - hunkIds, - title, - explanation, - status, -}: { - hunkIds: string[] - title: string - explanation: string - status: WalkthroughStop['status'] -}): WalkthroughStop { - return { - id: computeStopId(hunkIds), - title, - explanation, - hunkIds, - status, - explanationKey: null, - } -} + const input = buildWalkthroughInput(changes) + if (input.hunks.length === 0 && input.mechanicalHunks.length === 0) { + throw new ChangeWalkthroughError(noTextChangesMessage(source), 409, { + code: 'WALKTHROUGH_NO_TEXT_CHANGES', + }) + } -function buildMechanicalStop(hunks: WalkthroughHunk[]): WalkthroughStop { - return createStop({ - hunkIds: hunks.map((hunk) => hunk.id), - title: MECHANICAL_STOP_TITLE, - explanation: MECHANICAL_STOP_EXPLANATION, - status: 'ready', - }) -} + const diffHash = computeChangesHash(changes) + this.currentHashes.set(key, diffHash) -export function buildWalkthroughPrompt({ title, hunks }: { title: string; hunks: WalkthroughHunk[] }): string { - const instructions = buildWalkthroughInstructions(title) - const blocks = hunks.map((hunk) => formatHunkBlock(hunk)) + const previous = getChangeWalkthrough(this.db, sessionId, walkthroughSourceKey(source)) + if (previous?.status === 'ready' && previous.diffHash === diffHash && !request.regenerate) { + return this.getState(sessionId, source) + } + if (previous?.status === 'generating' && previous.updatedAt + this.generationTimeoutMs >= Date.now()) { + return this.getState(sessionId, source) + } - return [instructions, ...blocks].join('\n\n') -} + const model = await this.resolveWalkthroughModel(session, request.model) + const generationId = randomUUID() + const outputDir = path.join(this.walkthroughsRoot, sessionId, generationId) + const artifactPath = path.join(outputDir, 'index.html') + await mkdir(outputDir, { recursive: true }) -export interface WalkthroughOutline { - hunks: WalkthroughHunk[] - omittedFiles: WalkthroughOmittedFile[] - outline: string -} + const claimed = claimChangeWalkthroughGeneration(this.db, { + sessionId, + source, + diffHash, + generationId, + artifactPath, + model: formatOpenCodeModelRef(model), + now: Date.now(), + }) + if (!claimed) { + await rm(outputDir, { recursive: true, force: true }) + return this.getState(sessionId, source) + } -function groupHunksByFile(hunks: WalkthroughHunk[]): WalkthroughHunk[][] { - const groups: WalkthroughHunk[][] = [] - for (const hunk of hunks) { - const last = groups.at(-1) - if (last && last[0]!.file === hunk.file) { - last.push(hunk) - } else { - groups.push([hunk]) + if (previous?.walkthroughSessionId) { + this.changesSnapshots.delete(previous.walkthroughSessionId) + await this.interruptWalkthroughSession( + previous.walkthroughSessionId, + 'Failed to interrupt the superseded walkthrough session', + ) + } + if (previous?.artifactPath) { + const previousDir = path.dirname(previous.artifactPath) + if (previousDir !== outputDir) { + await rm(previousDir, { recursive: true, force: true }).catch((error) => { + logger.warn('Failed to remove a superseded walkthrough generation directory', getErrorMessage(error)) + }) + } } - } - return groups -} -function buildWalkthroughPlanRules(title: string): string { - return [ - buildWalkthroughPreamble(title), - [ - '- Assign every hunk id to exactly one stop.', - '- Reference each hunk id at most once.', - `- Use at most ${WALKTHROUGH_MAX_STOPS} stops.`, - '- Respond with only the JSON {"summary": string, "stops": [{"title": string, "hunkIds": string[]}]}.', - ].join('\n'), - ].join('\n') -} + let walkthroughSessionId: string | undefined + try { + const title = walkthroughTitle(session, source) + const walkthroughSession = await this.openCodeClient.api.session.create({ + title: `Walkthrough · ${title}`, + model, + ...openCodeLocation(session.location.directory), + permissions: buildWalkthroughSessionPermissions(outputDir), + }) + walkthroughSessionId = walkthroughSession.id -export function buildWalkthroughPlanPrompt({ - title, - outline, - previousStops, -}: { - title: string - outline: string - previousStops: Array<{ title: string; hunkIds: string[] }> -}): string { - const prompt = buildWalkthroughPlanSections(title, outline, previousStops).join('\n\n') - if (previousStops.length > 0 && prompt.length > WALKTHROUGH_DIFF_MAX_CHARS) { - return buildWalkthroughPlanSections(title, outline, []).join('\n\n') - } - return prompt -} + const recorded = updateChangeWalkthrough(this.db, sessionId, walkthroughSourceKey(source), ['generating'], { + walkthroughSessionId, + executionStartedAt: Date.now(), + updatedAt: Date.now(), + }) + if (!recorded) { + this.changesSnapshots.delete(walkthroughSessionId) + await this.interruptWalkthroughSession( + walkthroughSessionId, + 'Failed to interrupt a cancelled walkthrough session', + ) + await rm(outputDir, { recursive: true, force: true }) + return this.getState(sessionId, source) + } -function buildPreviousStopsSection(previousStops: Array<{ title: string; hunkIds: string[] }>): string { - return [ - '## Previous stops (keep a grouping when it still explains the change)', - ...previousStops.map((stop) => `- ${stop.title}: ${stop.hunkIds.join(', ')}`), - ].join('\n') -} + this.changesSnapshots.set(walkthroughSessionId, changes) -function buildWalkthroughPlanSections( - title: string, - outline: string, - previousStops: Array<{ title: string; hunkIds: string[] }>, -): string[] { - const sections = [buildWalkthroughPlanRules(title)] - if (previousStops.length > 0) { - sections.push(buildPreviousStopsSection(previousStops)) - } - sections.push(`## Outline\n${outline}`) - return sections -} + await this.openCodeClient.api.session.prompt({ + sessionID: walkthroughSessionId, + text: buildWalkthroughSessionPrompt({ + title, + sourceDescription: describeWalkthroughSource(source), + artifactPath, + }), + }) -export function buildWalkthroughOutline( - input: WalkthroughInput, - title: string, - previousStops: Array<{ title: string; hunkIds: string[] }> = [], -): WalkthroughOutline { - const groups = groupHunksByFile(input.hunks) - const reservedHint = - previousStops.length > 0 ? '\n\n'.length + buildPreviousStopsSection(previousStops).length : 0 - const prefixLength = buildWalkthroughPlanRules(title).length + '\n\n## Outline\n'.length + reservedHint - - const pack = (previewLines: number) => { - let outline = '' - const hunks: WalkthroughHunk[] = [] - const omittedFiles: WalkthroughOmittedFile[] = [] - let exhausted = false - for (const fileHunks of groups) { - if (exhausted) { - omittedFiles.push({ file: fileHunks[0]!.file, reason: 'budget' }) - continue - } - const block = fileHunks.map((hunk) => formatHunkOutline(hunk, previewLines)).join('\n') - const next = outline.length === 0 ? block : `${outline}\n\n${block}` - if (prefixLength + next.length > WALKTHROUGH_DIFF_MAX_CHARS) { - exhausted = true - omittedFiles.push({ file: fileHunks[0]!.file, reason: 'budget' }) - continue + const current = getChangeWalkthrough(this.db, sessionId, walkthroughSourceKey(source)) + if (!current || current.status !== 'generating' || current.walkthroughSessionId !== walkthroughSessionId) { + this.changesSnapshots.delete(walkthroughSessionId) + await this.interruptWalkthroughSession( + walkthroughSessionId, + 'Failed to interrupt a cancelled walkthrough session', + ) + return this.getState(sessionId, source) } - outline = next - hunks.push(...fileHunks) - } - return { hunks, omittedFiles, outline } - } - let packed = pack(WALKTHROUGH_OUTLINE_PREVIEW_LINES) - if (packed.omittedFiles.length > 0) { - for (const previewLines of OUTLINE_RETRY_DEPTHS) { - const candidate = pack(previewLines) - packed = candidate - if (candidate.omittedFiles.length === 0) { - break + this.startTimeout(sessionId, source) + } catch (error) { + this.clearTimeout(entryKey(sessionId, source)) + if (walkthroughSessionId) { + this.changesSnapshots.delete(walkthroughSessionId) + await this.interruptWalkthroughSession( + walkthroughSessionId, + 'Failed to interrupt a failed walkthrough session', + ) } + await rm(outputDir, { recursive: true, force: true }) + const generationError = toGenerationError(error) + updateChangeWalkthrough(this.db, sessionId, walkthroughSourceKey(source), ['generating'], { + status: 'failed', + error: generationError, + executionStartedAt: null, + updatedAt: Date.now(), + }) + throw new ChangeWalkthroughError( + generationError.message || 'Failed to start the walkthrough session', + 502, + { code: 'WALKTHROUGH_SESSION_START_FAILED' }, + ) } - } - return { - hunks: packed.hunks, - omittedFiles: [...input.omittedFiles, ...packed.omittedFiles], - outline: packed.outline, + return this.getState(sessionId, source) } -} -export function fitsSingleCall(input: WalkthroughInput, title: string): boolean { - return buildWalkthroughPrompt({ title, hunks: input.hunks }).length <= WALKTHROUGH_DIFF_MAX_CHARS -} + async cancelGeneration(sessionId: string, source: WalkthroughSource = DEFAULT_WALKTHROUGH_SOURCE): Promise { + const record = getChangeWalkthrough(this.db, sessionId, walkthroughSourceKey(source)) + if (!record || (record.status !== 'generating' && record.executionStartedAt === null)) { + return false + } + const updated = await this.settleGeneration(record, { status: 'cancelled' }, { interrupt: true }) + return updated !== null + } + + async publishFromWalkthroughSession(walkthroughSessionId: string): Promise<{ path: string }> { + const { walkthrough } = this.requireWalkthroughSession(walkthroughSessionId) + const sourceKey = walkthroughSourceKey(walkthrough.source) + + const artifactPath = walkthrough.artifactPath + if (!artifactPath) { + throw new ChangeWalkthroughError('The walkthrough has no artifact file yet', 404) + } + await assertHtmlArtifactFile(artifactPath, artifactPath) + + const now = Date.now() + const updated = updateChangeWalkthrough( + this.db, + walkthrough.sessionId, + sourceKey, + ['generating', 'ready', 'failed'], + { + status: 'ready', + publishedAt: now, + error: null, + updatedAt: now, + }, + { walkthroughSessionId }, + ) + if (!updated) { + throw new ChangeWalkthroughError('This walkthrough was superseded by a newer generation', 409, { + code: 'WALKTHROUGH_SUPERSEDED', + }) + } -interface ModelStop { - title: string - explanation: string - hunkIds: string[] -} + return { path: artifactPath } + } -function parseModelResponse(text: string): { summary: string; stops: ModelStop[] } | null { - return parseFirstJsonObject(text, modelResponseSchema) -} + async readChangesForWalkthroughSession(walkthroughSessionId: string, file?: string): Promise { + const { walkthrough } = this.requireWalkthroughSession(walkthroughSessionId) + const session = await this.readSession(walkthrough.sessionId) + const changes = await this.readWalkthroughSessionChanges(walkthroughSessionId, walkthrough, session) -function collectModelStops( - modelStops: ModelStop[], - hunks: WalkthroughHunk[], - buildStop: (modelStop: ModelStop | null, hunkIds: string[]) => WalkthroughStop, -): WalkthroughStop[] { - const validIds = new Set(hunks.map((hunk) => hunk.id)) - const referenced = new Set() - const stops: WalkthroughStop[] = [] - - for (const modelStop of modelStops) { - if (stops.length >= WALKTHROUGH_MAX_STOPS) { - break + if (file === undefined) { + return formatWalkthroughOutline({ + title: walkthroughTitle(session, walkthrough.source), + sourceDescription: describeWalkthroughSource(walkthrough.source), + changes, + }) } - const hunkIds: string[] = [] - for (const id of modelStop.hunkIds) { - if (!validIds.has(id) || referenced.has(id)) { - continue - } - referenced.add(id) - hunkIds.push(id) + const text = formatWalkthroughFileChanges(changes, file) + if (text === null) { + throw new ChangeWalkthroughError(`The file is not part of the changes: ${file}`, 404, { + code: 'WALKTHROUGH_FILE_NOT_CHANGED', + }) } + return text + } - if (hunkIds.length > 0) { - stops.push(buildStop(modelStop, hunkIds)) + async refine(sessionId: string, request: RefineChangeWalkthroughRequest): Promise { + const source = request.source ?? DEFAULT_WALKTHROUGH_SOURCE + const sourceKey = walkthroughSourceKey(source) + const record = getChangeWalkthrough(this.db, sessionId, sourceKey) + if (!record || !record.walkthroughSessionId) { + throw new ChangeWalkthroughError('The walkthrough has not been started yet', 409, { + code: 'WALKTHROUGH_NOT_STARTED', + }) } - } - if (stops.length === 0) { - return stops - } + const now = Date.now() + const claimed = updateChangeWalkthrough( + this.db, + sessionId, + sourceKey, + ['ready', 'failed', 'cancelled'], + { + status: 'generating', + executionStartedAt: now, + updatedAt: now, + }, + { running: false }, + ) + if (!claimed) { + throw new ChangeWalkthroughError('The walkthrough is still being built', 409, { code: 'WALKTHROUGH_BUSY' }) + } - const unreferenced = hunks.filter((hunk) => !referenced.has(hunk.id)).map((hunk) => hunk.id) - if (unreferenced.length > 0) { - stops.push(buildStop(null, unreferenced)) - } + this.changesSnapshots.delete(record.walkthroughSessionId) - return stops -} + const artifactPath = record.artifactPath ?? '' + try { + await this.openCodeClient.api.session.prompt({ + sessionID: record.walkthroughSessionId, + text: [ + request.text, + `Keep the walkthrough at ${artifactPath} and call publish_walkthrough again once it is updated.`, + ].join('\n\n'), + }) + } catch (error) { + updateChangeWalkthrough( + this.db, + sessionId, + sourceKey, + ['generating'], + { + status: record.status, + executionStartedAt: null, + updatedAt: record.updatedAt, + }, + { walkthroughSessionId: record.walkthroughSessionId, running: true }, + ) + if (isSessionNotFoundError(error)) { + throw new ChangeWalkthroughError('The walkthrough session no longer exists', 409, { + code: 'WALKTHROUGH_SESSION_MISSING', + }) + } + throw new ChangeWalkthroughError(getErrorMessage(error) || 'Failed to send the refine prompt', 502) + } -export function parseWalkthroughResponse(text: string, hunks: WalkthroughHunk[]): ParsedWalkthrough | null { - const parsed = parseModelResponse(text) - if (!parsed) { - return null + this.startTimeout(sessionId, source) + return this.getState(sessionId, source) } - const stops = collectModelStops(parsed.stops, hunks, (modelStop, hunkIds) => - modelStop - ? createStop({ - hunkIds, - title: truncateWalkthroughText(modelStop.title), - explanation: truncateWalkthroughText(modelStop.explanation), - status: 'ready', + /** Applies session lifecycle events to the stored walkthrough records and their output directories. */ + handleEvent(event: SSEEvent): void { + switch (event.type) { + case 'session.deleted': { + const { sessionID } = event.data + if (this.failDeletedWalkthroughSession(sessionID)) { + return + } + this.clearSessionState(sessionID) + this.deleteSessionPullRequestRefs(sessionID) + this.interruptSessionWalkthroughs(sessionID) + deleteChangeWalkthroughs(this.db, sessionID) + void rm(path.join(this.walkthroughsRoot, sessionID), { recursive: true, force: true }).catch((error) => { + logger.error('Failed to remove a deleted session walkthrough directory', error) }) - : createStop({ - hunkIds, - title: REMAINING_STOP_TITLE, - explanation: REMAINING_STOP_EXPLANATION, - status: 'ready', - }), - ) - - if (stops.length === 0) { - return null + return + } + case 'session.execution.started': + this.invalidateCurrentHash(entryKey(event.data.sessionID, DEFAULT_WALKTHROUGH_SOURCE)) + return + case 'session.execution.succeeded': + case 'session.execution.failed': + case 'session.execution.interrupted': + this.invalidateCurrentHash(entryKey(event.data.sessionID, DEFAULT_WALKTHROUGH_SOURCE)) + this.finishWalkthroughSession(event.data.sessionID) + return + case 'session.revert.staged': + case 'session.revert.committed': + case 'session.revert.cleared': + this.invalidateCurrentHash(entryKey(event.data.sessionID, DEFAULT_WALKTHROUGH_SOURCE)) + return + default: + return + } } - return { - summary: truncateWalkthroughText(parsed.summary), - stops, + private failDeletedWalkthroughSession(walkthroughSessionId: string): boolean { + const found = getChangeWalkthroughByWalkthroughSession(this.db, walkthroughSessionId) + if (!found) { + return false + } + const { walkthrough } = found + if (walkthrough.status === 'generating' || walkthrough.executionStartedAt !== null) { + void this.settleGeneration(walkthrough, { + status: 'failed', + error: { message: 'The walkthrough session was deleted', code: 'WALKTHROUGH_SESSION_MISSING' }, + }, { interrupt: false }).catch((error) => { + logger.error('Failed to settle a deleted walkthrough session', error) + }) + } + this.changesSnapshots.delete(walkthroughSessionId) + return true } -} -export function parseWalkthroughPlan(text: string, hunks: WalkthroughHunk[]): ParsedWalkthrough | null { - const parsed = parseModelResponse(text) - if (!parsed) { - return null + private finishWalkthroughSession(walkthroughSessionId: string): void { + const found = getChangeWalkthroughByWalkthroughSession(this.db, walkthroughSessionId) + if (!found) { + return + } + const { walkthrough } = found + if (walkthrough.status !== 'generating' && walkthrough.executionStartedAt === null) { + return + } + void this.settleGeneration(walkthrough, { + status: 'failed', + error: { + message: 'The walkthrough session finished without publishing a walkthrough', + code: 'WALKTHROUGH_NOT_PUBLISHED', + }, + }, { interrupt: false }).catch((error) => { + logger.error('Failed to settle a finished walkthrough session', error) + }) } - const stops = collectModelStops(parsed.stops, hunks, (modelStop, hunkIds) => - createStop({ - hunkIds, - title: modelStop ? truncateWalkthroughText(modelStop.title) : REMAINING_STOP_TITLE, - explanation: '', - status: 'pending', - }), - ) - - if (stops.length === 0) { - return null + private interruptSessionWalkthroughs(sessionId: string): void { + for (const record of listChangeWalkthroughs(this.db, sessionId)) { + if (record.walkthroughSessionId) { + this.changesSnapshots.delete(record.walkthroughSessionId) + } + if ((record.status !== 'generating' && record.executionStartedAt === null) || !record.walkthroughSessionId) { + continue + } + void this.openCodeClient.api.session.interrupt({ sessionID: record.walkthroughSessionId }).catch((error) => { + logger.warn('Failed to interrupt a walkthrough session of a deleted session', getErrorMessage(error)) + }) + } } - return { - summary: truncateWalkthroughText(parsed.summary), - stops, + private clearSessionState(sessionId: string): void { + const prefix = `${sessionId}\0` + const keys = new Set([ + ...this.currentHashes.keys(), + ...this.hashEpochs.keys(), + ...this.pendingHashReads.keys(), + ...this.timers.keys(), + ]) + for (const key of keys) { + if (!key.startsWith(prefix)) { + continue + } + this.invalidateCurrentHash(key) + this.clearTimeout(key) + } } -} - -const explanationSchema = z.object({ - explanation: z.string().catch(''), -}) -function parseWalkthroughExplanation(text: string): string | null { - const parsed = parseFirstJsonObject(text, explanationSchema) - if (!parsed) { - return null + private startTimeout(sessionId: string, source: WalkthroughSource): void { + const key = entryKey(sessionId, source) + this.clearTimeout(key) + const timer = setTimeout(() => { + this.timers.delete(key) + void this.expireGeneration(sessionId, source).catch((error) => { + logger.error('Failed to expire a change walkthrough generation', error) + }) + }, this.generationTimeoutMs) + timer.unref?.() + this.timers.set(key, timer) } - const explanation = parsed.explanation.trim() - if (explanation.length === 0) { - return null + private clearTimeout(key: string): void { + const timer = this.timers.get(key) + if (timer === undefined) { + return + } + clearTimeout(timer) + this.timers.delete(key) } - return truncateWalkthroughText(explanation) -} - -function buildWalkthroughExplainPrompt({ - title, - summary, - stop, - hunks, -}: { - title: string - summary: string - stop: WalkthroughStop - hunks: WalkthroughHunk[] -}): string { - const rule = 'Respond with only the JSON {"explanation": string}.' - const stopHunks = hunks.filter((hunk) => stop.hunkIds.includes(hunk.id)) - const context = [ - buildWalkthroughPreamble(title), - `## Walkthrough summary\n${summary}`, - `## Stop: ${stop.title}`, - ].join('\n\n') - - const sections = [context] - let shown = 0 - - for (const hunk of stopHunks) { - const block = formatHunkBlock(hunk) - const omittedCount = stopHunks.length - shown - 1 - const candidate = [...sections, block, rule] - if (omittedCount > 0) { - candidate.push(`${omittedCount} more hunks in this stop are not shown.`) + private async expireGeneration(sessionId: string, source: WalkthroughSource): Promise { + const record = getChangeWalkthrough(this.db, sessionId, walkthroughSourceKey(source)) + if (!record || (record.status !== 'generating' && record.executionStartedAt === null)) { + return } - if (candidate.join('\n\n').length > WALKTHROUGH_DIFF_MAX_CHARS) { - break + await this.settleGeneration(record, { + status: 'failed', + error: { message: WALKTHROUGH_TIMEOUT_MESSAGE, code: 'WALKTHROUGH_TIMEOUT' }, + }, { interrupt: true }) + } + + private async settleGeneration( + record: ChangeWalkthrough, + fallbackPatch: ChangeWalkthroughPatch, + options: { interrupt: boolean }, + ): Promise { + const now = Date.now() + const patch: ChangeWalkthroughPatch = record.publishedAt !== null + ? { status: 'ready', executionStartedAt: null, updatedAt: now } + : { ...fallbackPatch, executionStartedAt: null, updatedAt: now } + const updated = updateChangeWalkthrough( + this.db, + record.sessionId, + walkthroughSourceKey(record.source), + record.status === 'generating' ? ['generating'] : ['ready', 'failed', 'cancelled'], + patch, + { + ...(record.walkthroughSessionId ? { walkthroughSessionId: record.walkthroughSessionId } : {}), + ...(record.status === 'generating' ? {} : { running: true }), + }, + ) + if (record.walkthroughSessionId) { + this.changesSnapshots.delete(record.walkthroughSessionId) } - sections.push(block) - shown += 1 - } - - if (shown < stopHunks.length) { - sections.push(`${stopHunks.length - shown} more hunks in this stop are not shown.`) - } - sections.push(rule) - return sections.join('\n\n') -} - -interface InFlightGeneration { - modelStarted: Promise - result: Promise<{ walkthrough: ChangeWalkthrough; created: boolean }> - walkthrough: ChangeWalkthrough | null - controller: AbortController -} - -class WalkthroughCallSkippedError extends Error { - constructor() { - super('Walkthrough model call skipped') - this.name = 'WalkthroughCallSkippedError' - } -} - -export class WalkthroughCancelledError extends Error { - constructor() { - super('Walkthrough generation stopped') - this.name = 'WalkthroughCancelledError' + if (updated) { + this.clearTimeout(entryKey(record.sessionId, record.source)) + } + if (options.interrupt && record.walkthroughSessionId) { + await this.interruptWalkthroughSession(record.walkthroughSessionId, 'Failed to interrupt a walkthrough session') + } + return updated } -} -function toGenerationError(error: unknown): WalkthroughGenerationError { - if (error instanceof ServiceError) { - return { - message: error.message, - ...(error.code ? { code: error.code } : {}), - ...(error.details !== undefined ? { details: error.details } : {}), + private async interruptWalkthroughSession(walkthroughSessionId: string, message: string): Promise { + try { + await this.openCodeClient.api.session.interrupt({ sessionID: walkthroughSessionId }) + } catch (error) { + logger.warn(message, getErrorMessage(error)) } } - return { message: getErrorMessage(error) || 'Failed to generate the change walkthrough' } -} - -export class ChangeWalkthroughService { - private readonly inFlight = new Map() - private readonly failures = new Map() - private readonly deletedDuringGeneration = new Set() - private readonly currentHashes = new Map() - private readonly hashEpochs = new Map() - private readonly pendingHashReads = new Map() - private readonly pullRequestRefs = new Map>() - private readonly timeoutMs: number - - constructor( - private readonly db: Database, - private readonly openCodeClient: OpenCodeClient, - private readonly settingsService: SettingsService, - private readonly gitAuthService: GitAuthService, - private readonly gitService: GitService, - options: ChangeWalkthroughServiceOptions = {}, - ) { - this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS + private requireWalkthroughSession( + walkthroughSessionId: string, + ): { walkthrough: ChangeWalkthrough; generationId: string } { + const found = getChangeWalkthroughByWalkthroughSession(this.db, walkthroughSessionId) + if (!found) { + throw new ChangeWalkthroughError('This session is not a walkthrough session', 404, { + code: 'WALKTHROUGH_NOT_A_WALKTHROUGH_SESSION', + }) + } + return found } - async getState( - sessionId: string, - source: WalkthroughSource = DEFAULT_WALKTHROUGH_SOURCE, - hunksFor?: string, - ): Promise { - const session = await this.readSession(sessionId) - const key = entryKey(sessionId, source) - - const currentDiffHash = this.inFlight.has(key) - ? this.currentHashes.get(key) ?? null - : await this.readCurrentDiffHash(session, source, key) + private hasGenerationTimedOut(walkthrough: ChangeWalkthrough | null): boolean { + if (!walkthrough) { + return false + } + const now = Date.now() + if (walkthrough.status === 'generating' && walkthrough.updatedAt + this.generationTimeoutMs < now) { + return true + } + return walkthrough.executionStartedAt !== null && walkthrough.executionStartedAt + this.generationTimeoutMs < now + } - const entry = this.inFlight.get(key) - const walkthrough = - entry?.walkthrough ?? getChangeWalkthrough(this.db, sessionId, walkthroughSourceKey(source)) - return { - walkthrough: this.omitHunks(walkthrough, hunksFor), - currentDiffHash, - stale: walkthrough !== null && currentDiffHash !== null && walkthrough.diffHash !== currentDiffHash, - generating: this.inFlight.has(key), - error: this.failures.get(key) ?? null, + private async readWalkthroughSessionChanges( + walkthroughSessionId: string, + walkthrough: ChangeWalkthrough, + session: SessionInfo, + ): Promise { + const snapshot = this.changesSnapshots.get(walkthroughSessionId) + if (snapshot) { + return snapshot + } + const executionStartedAt = walkthrough.executionStartedAt + const changes = await this.readChanges(session, walkthrough.source) + if (executionStartedAt !== null) { + const current = getChangeWalkthroughByWalkthroughSession(this.db, walkthroughSessionId) + if (current?.walkthrough.executionStartedAt === executionStartedAt) { + this.changesSnapshots.set(walkthroughSessionId, changes) + } } + return changes } - private omitHunks( - walkthrough: ChangeWalkthrough | null, - hunksFor: string | undefined, - ): ChangeWalkthroughWire | null { - if (!walkthrough || hunksFor === undefined || walkthroughHunksIdentity(walkthrough) !== hunksFor) { - return walkthrough + private async readChanges(session: SessionInfo, source: WalkthroughSource): Promise { + try { + return await readWalkthroughChanges({ + client: this.openCodeClient, + session, + source, + gitEnv: this.gitAuthService.getGitEnvironment(true), + }) + } catch (error) { + if (error instanceof ChangeWalkthroughError) { + throw error + } + throw new ChangeWalkthroughError(getErrorMessage(error) || 'Failed to read session changes', 502, { + code: 'WALKTHROUGH_CHANGES_UNAVAILABLE', + }) } - return { ...walkthrough, hunks: undefined } } private async readCurrentDiffHash( @@ -772,12 +724,7 @@ export class ChangeWalkthroughService { const epoch = this.hashEpochs.get(key) ?? 0 this.pendingHashReads.set(key, (this.pendingHashReads.get(key) ?? 0) + 1) try { - const changes = await readWalkthroughChanges({ - client: this.openCodeClient, - session, - source, - gitEnv: this.gitAuthService.getGitEnvironment(true), - }) + const changes = await this.readChanges(session, source) const hash = computeChangesHash(changes) if ((this.hashEpochs.get(key) ?? 0) === epoch) { this.currentHashes.set(key, hash) @@ -811,93 +758,6 @@ export class ChangeWalkthroughService { } } - generate( - sessionId: string, - request: GenerateChangeWalkthroughRequest, - ): Promise<{ walkthrough: ChangeWalkthrough; created: boolean }> { - return this.begin(sessionId, request).result - } - - async startGeneration(sessionId: string, request: GenerateChangeWalkthroughRequest): Promise { - const entry = this.begin(sessionId, request) - try { - await Promise.race([entry.modelStarted, entry.result]) - } catch (error) { - if (!(error instanceof WalkthroughCancelledError)) { - throw error - } - } - return this.getState(sessionId, request.source ?? DEFAULT_WALKTHROUGH_SOURCE) - } - - /** - * Stops an in-flight generation for a session and source. The in-flight entry is removed - * synchronously so the next read reports `generating: false`, the partial walkthrough is - * persisted, and the abort signal stops the model calls that are still running. - */ - cancelGeneration(sessionId: string, source: WalkthroughSource = DEFAULT_WALKTHROUGH_SOURCE): boolean { - const key = entryKey(sessionId, source) - const entry = this.inFlight.get(key) - if (!entry) { - return false - } - this.inFlight.delete(key) - if (entry.walkthrough) { - this.saveIfSessionLive(entry.walkthrough) - } - entry.controller.abort() - return true - } - - /** Removes the stored walkthroughs of a deleted session, including one still being generated. */ - handleEvent(event: SSEEvent): void { - switch (event.type) { - case 'session.deleted': { - const { sessionID } = event.data - const prefix = `${sessionID}\0` - for (const key of this.inFlight.keys()) { - if (key.startsWith(prefix)) { - this.deletedDuringGeneration.add(key) - } - } - this.clearSessionState(sessionID) - this.deleteSessionPullRequestRefs(sessionID) - deleteChangeWalkthroughs(this.db, sessionID) - return - } - case 'session.execution.started': - case 'session.execution.succeeded': - case 'session.execution.failed': - case 'session.execution.interrupted': - case 'session.revert.staged': - case 'session.revert.committed': - case 'session.revert.cleared': - this.invalidateCurrentHash(entryKey(event.data.sessionID, DEFAULT_WALKTHROUGH_SOURCE)) - return - default: - return - } - } - - private clearSessionState(sessionId: string): void { - const prefix = `${sessionId}\0` - const keys = new Set([ - ...this.inFlight.keys(), - ...this.failures.keys(), - ...this.currentHashes.keys(), - ...this.hashEpochs.keys(), - ...this.pendingHashReads.keys(), - ]) - for (const key of keys) { - if (!key.startsWith(prefix)) { - continue - } - this.inFlight.delete(key) - this.failures.delete(key) - this.invalidateCurrentHash(key) - } - } - private recordPullRequestRef(sessionId: string, directory: string, ref: string): void { const entry = `${directory}\0${ref}` const existing = this.pullRequestRefs.get(sessionId) @@ -923,49 +783,6 @@ export class ChangeWalkthroughService { } } - private begin(sessionId: string, request: GenerateChangeWalkthroughRequest): InFlightGeneration { - const source = request.source ?? DEFAULT_WALKTHROUGH_SOURCE - const key = entryKey(sessionId, source) - const existing = this.inFlight.get(key) - if (existing) { - return existing - } - - this.failures.delete(key) - - let resolveModelStarted: () => void = () => {} - const modelStarted = new Promise((resolve) => { - resolveModelStarted = resolve - }) - let modelCalled = false - const markModelStarted = () => { - modelCalled = true - resolveModelStarted() - } - - const entry = { modelStarted, walkthrough: null, controller: new AbortController() } as InFlightGeneration - this.inFlight.set(key, entry) - - entry.result = this.runGenerate(sessionId, source, request, markModelStarted, entry, entry.controller.signal) - .catch((error) => { - if (modelCalled && !(error instanceof WalkthroughCancelledError)) { - this.failures.set(key, toGenerationError(error)) - } - throw error - }) - .finally(() => { - if (this.inFlight.get(key) === entry) { - this.inFlight.delete(key) - } - this.deletedDuringGeneration.delete(key) - this.invalidateCurrentHash(key) - }) - - entry.result.catch(() => {}) - - return entry - } - private async fetchPullRequestChanges( session: SessionInfo, source: Extract, @@ -988,376 +805,6 @@ export class ChangeWalkthroughService { } } - private throwIfCancelled(signal: AbortSignal): void { - if (signal.aborted) { - throw new WalkthroughCancelledError() - } - } - - private async runGenerate( - sessionId: string, - source: WalkthroughSource, - request: GenerateChangeWalkthroughRequest, - onModelStart: () => void, - entry: InFlightGeneration, - signal: AbortSignal, - ): Promise<{ walkthrough: ChangeWalkthrough; created: boolean }> { - const session = await this.readSession(sessionId) - this.throwIfCancelled(signal) - const key = entryKey(sessionId, source) - - if (source.kind === 'pullRequest') { - await this.fetchPullRequestChanges(session, source) - this.throwIfCancelled(signal) - } - - let changes: FileDiffInfo[] - try { - changes = await readWalkthroughChanges({ - client: this.openCodeClient, - session, - source, - gitEnv: this.gitAuthService.getGitEnvironment(true), - }) - } catch (error) { - if (error instanceof ChangeWalkthroughError) { - throw error - } - throw new ChangeWalkthroughError(getErrorMessage(error) || 'Failed to read session changes', 502, { - code: 'WALKTHROUGH_CHANGES_UNAVAILABLE', - }) - } - this.throwIfCancelled(signal) - - if (changes.length === 0) { - throw new ChangeWalkthroughError(noChangesMessage(source), 409, { - code: 'WALKTHROUGH_NO_CHANGES', - }) - } - - const diffHash = computeChangesHash(changes) - this.currentHashes.set(key, diffHash) - - const previous = getChangeWalkthrough(this.db, sessionId, walkthroughSourceKey(source)) - const title = walkthroughTitle(session, source) - - if (previous && previous.diffHash === diffHash && !request.regenerate) { - if (previous.stops.every((stop) => stop.status === 'ready')) { - return { walkthrough: previous, created: false } - } - - const model = await this.resolveWalkthroughModel(session, request.model, signal) - const storedModel = formatOpenCodeModelRef(model) - const resumingStops = previous.stops.map((stop) => - stop.status === 'ready' ? stop : { ...stop, status: 'pending' as const }, - ) - const resuming: ChangeWalkthrough = { ...previous, model: storedModel, stops: resumingStops } - if (this.deletedDuringGeneration.has(key)) { - throw new ChangeWalkthroughError('Session not found', 404) - } - entry.walkthrough = resuming - - const resumed = await this.explainStops( - model, - title, - previous.summary, - resumingStops.filter((stop) => stop.status !== 'ready'), - previous.hunks, - resuming, - onModelStart, - entry, - signal, - ) - if (this.deletedDuringGeneration.has(key)) { - throw new ChangeWalkthroughError('Session not found', 404) - } - return { walkthrough: resumed, created: true } - } - - const model = await this.resolveWalkthroughModel(session, request.model, signal) - const modelKey = computeModelKey(model) - const storedModel = formatOpenCodeModelRef(model) - - const input = buildWalkthroughInput(changes) - const { hunks, mechanicalHunks, omittedFiles } = input - if (hunks.length === 0 && mechanicalHunks.length === 0) { - throw new ChangeWalkthroughError(noTextChangesMessage(source), 409, { - code: 'WALKTHROUGH_NO_TEXT_CHANGES', - }) - } - - const mechanicalStop = mechanicalHunks.length > 0 ? buildMechanicalStop(mechanicalHunks) : null - - const store = (fields: { - summary: string - stops: WalkthroughStop[] - hunks: WalkthroughHunk[] - omittedFiles: WalkthroughOmittedFile[] - }): ChangeWalkthrough => { - this.throwIfCancelled(signal) - const walkthrough: ChangeWalkthrough = { - sessionId, - source, - diffHash, - model: storedModel, - summary: fields.summary, - stops: fields.stops, - hunks: fields.hunks, - omittedFiles: fields.omittedFiles, - createdAt: Date.now(), - } - - if (!this.saveIfSessionLive(walkthrough)) { - throw new ChangeWalkthroughError('Session not found', 404) - } - return walkthrough - } - - const reusable = new Map() - if (!request.regenerate && previous) { - for (const stop of previous.stops) { - if (stop.status === 'ready' && stop.explanationKey) { - reusable.set(stop.explanationKey, stop.explanation) - } - } - } - - const inputHunkIds = new Set(hunks.map((hunk) => hunk.id)) - const previousStops = - !request.regenerate && previous - ? previous.stops - .filter((stop) => stop.hunkIds.length > 0 && stop.hunkIds.every((id) => inputHunkIds.has(id))) - .map((stop) => ({ title: stop.title, hunkIds: stop.hunkIds })) - : [] - - const outline = buildWalkthroughOutline(input, title, previousStops) - if (hunks.length === 0 || outline.hunks.length === 0) { - if (!mechanicalStop) { - throw new ChangeWalkthroughError('These changes are too large to walk through', 413, { - code: 'WALKTHROUGH_CONTEXT_LIMIT', - details: { omittedFiles: outline.omittedFiles }, - }) - } - - return { - walkthrough: store({ - summary: - hunks.length === 0 && outline.omittedFiles.every((file) => isMechanicalChangePath(file.file)) - ? MECHANICAL_ONLY_SUMMARY - : MECHANICAL_PARTIAL_SUMMARY, - stops: [mechanicalStop], - hunks: mechanicalHunks, - omittedFiles: outline.omittedFiles, - }), - created: true, - } - } - - if (fitsSingleCall(input, title)) { - const prompt = buildWalkthroughPrompt({ title, hunks }) - onModelStart() - - const parsed = await this.callModelParsed( - prompt, - model, - (text) => parseWalkthroughResponse(text, hunks), - signal, - ) - - const stops = parsed.stops.map((stop) => applySingleCallKeys(stop, modelKey)) - return { - walkthrough: store({ - summary: parsed.summary, - stops: mechanicalStop ? [...stops, mechanicalStop] : stops, - hunks: [...hunks, ...mechanicalHunks], - omittedFiles, - }), - created: true, - } - } - - const outlineIds = new Set(outline.hunks.map((hunk) => hunk.id)) - const planPreviousStops = previousStops.filter((stop) => - stop.hunkIds.every((id) => outlineIds.has(id)), - ) - - const planPrompt = buildWalkthroughPlanPrompt({ - title, - outline: outline.outline, - previousStops: planPreviousStops, - }) - onModelStart() - - const planned = await this.callModelParsed( - planPrompt, - model, - (text) => parseWalkthroughPlan(text, outline.hunks), - signal, - ) - - const stops = planned.stops.map((stop) => applyReuse(stop, reusable, modelKey)) - let walkthrough: ChangeWalkthrough = store({ - summary: planned.summary, - stops: mechanicalStop ? [...stops, mechanicalStop] : stops, - hunks: [...outline.hunks, ...mechanicalHunks], - omittedFiles: outline.omittedFiles, - }) - entry.walkthrough = walkthrough - - const pending = stops.filter((stop) => stop.status !== 'ready') - if (pending.length > 0) { - walkthrough = await this.explainStops( - model, - title, - planned.summary, - pending, - outline.hunks, - walkthrough, - onModelStart, - entry, - signal, - ) - } - - if (this.deletedDuringGeneration.has(key)) { - throw new ChangeWalkthroughError('Session not found', 404) - } - - return { walkthrough, created: true } - } - - private async explainStops( - model: ModelRef, - title: string, - summary: string, - stops: WalkthroughStop[], - hunks: WalkthroughHunk[], - walkthrough: ChangeWalkthrough, - onModelStart: () => void, - entry: InFlightGeneration, - signal: AbortSignal, - ): Promise { - const modelKey = computeModelKey(model) - const key = entryKey(walkthrough.sessionId, walkthrough.source) - const total = stops.length - let completed = 0 - let current = walkthrough - - await mapWithConcurrency(stops, WALKTHROUGH_EXPLAIN_CONCURRENCY, async (stop) => { - if (signal.aborted) { - return - } - const explainPrompt = buildWalkthroughExplainPrompt({ title, summary, stop, hunks }) - onModelStart() - - let explained: WalkthroughStop - try { - const explanation = await this.callModelParsed( - explainPrompt, - model, - parseWalkthroughExplanation, - signal, - () => this.deletedDuringGeneration.has(key), - ) - explained = { - ...stop, - explanation, - status: 'ready', - explanationKey: computeExplanationKey(modelKey, stop.hunkIds), - } - } catch (error) { - if (error instanceof WalkthroughCallSkippedError || error instanceof WalkthroughCancelledError) { - return - } - logger.warn('Failed to explain a change walkthrough stop', getErrorMessage(error)) - explained = { ...stop, status: 'failed' } - } - - if (signal.aborted) { - return - } - current = this.replaceStop(current, explained) - entry.walkthrough = current - completed += 1 - if (completed % WALKTHROUGH_SAVE_EVERY_STOPS === 0 || completed === total) { - this.saveIfSessionLive(current) - } - }) - - return current - } - - private async callModelParsed( - prompt: string, - model: ModelRef, - parse: (text: string) => T | null, - signal: AbortSignal, - shouldSkip?: () => boolean, - ): Promise { - let lastError = new ChangeWalkthroughError('Failed to generate the change walkthrough', 502) - - for (let attempt = 0; attempt < WALKTHROUGH_CALL_ATTEMPTS; attempt += 1) { - if (signal.aborted) { - throw new WalkthroughCancelledError() - } - let text: string - try { - text = await WALKTHROUGH_MODEL_LIMITER.run(() => { - if (shouldSkip?.()) { - throw new WalkthroughCallSkippedError() - } - if (signal.aborted) { - throw new WalkthroughCancelledError() - } - return generateTextWithTimeout(this.openCodeClient, { prompt, model }, this.timeoutMs, signal) - }) - } catch (error) { - if (error instanceof WalkthroughCallSkippedError) { - throw error - } - if (error instanceof WalkthroughCancelledError || signal.aborted) { - throw new WalkthroughCancelledError() - } - lastError = - error instanceof GenerateTextTimeoutError - ? new ChangeWalkthroughError('Generating the change walkthrough timed out', 504, { - code: 'WALKTHROUGH_TIMEOUT', - }) - : new ChangeWalkthroughError( - getErrorMessage(error) || 'Failed to generate the change walkthrough', - 502, - ) - continue - } - - const parsed = parse(text) - if (parsed !== null) { - return parsed - } - - lastError = new ChangeWalkthroughError('The model did not return a usable change walkthrough', 502, { - code: 'WALKTHROUGH_UNPARSEABLE', - }) - } - - throw lastError - } - - private replaceStop(walkthrough: ChangeWalkthrough, stop: WalkthroughStop): ChangeWalkthrough { - return { - ...walkthrough, - stops: walkthrough.stops.map((existing) => (existing.id === stop.id ? stop : existing)), - } - } - - private saveIfSessionLive(walkthrough: ChangeWalkthrough): boolean { - if (this.deletedDuringGeneration.has(entryKey(walkthrough.sessionId, walkthrough.source))) { - return false - } - saveChangeWalkthrough(this.db, walkthrough) - return true - } - private async readSession(sessionId: string): Promise { try { return await this.openCodeClient.api.session.get({ sessionID: sessionId }) @@ -1370,16 +817,12 @@ export class ChangeWalkthroughService { } /** - * Resolves the model for one generation, preferring the configured walkthrough model, then the request model, then - * the session model. Waits once for OpenCode's lazily-loaded global catalog so an explicit model is registered before - * the first call. A failed wait is swallowed because generation proceeds and lets OpenCode surface its own error; the - * wait is per generation, never per stop. + * Resolves the model for the walkthrough session, preferring the configured walkthrough model, then the request + * model, then the target session model. Waits once for OpenCode's lazily-loaded global catalog so an explicit model + * is registered before the session is created. A failed wait is swallowed because generation proceeds and lets + * OpenCode surface its own error. */ - private async resolveWalkthroughModel( - session: SessionInfo, - requestModel?: string, - signal?: AbortSignal, - ): Promise { + private async resolveWalkthroughModel(session: SessionInfo, requestModel?: string): Promise { const configured = this.settingsService.getSettings().preferences.walkthroughModel?.trim() const parsedConfigured = configured ? parseOpenCodeModelRef(configured) : undefined const parsedRequest = requestModel ? parseOpenCodeModelRef(requestModel) : undefined @@ -1388,39 +831,23 @@ export class ChangeWalkthroughService { try { await resolveOpenCodeModel(this.openCodeClient, getOpenCodeGlobalConfigPath(), { preferredModel: formatOpenCodeModelRef(preferred), - ...(signal ? { signal } : {}), }) } catch { - if (signal?.aborted) { - throw new WalkthroughCancelledError() - } return preferred } - if (signal?.aborted) { - throw new WalkthroughCancelledError() - } return preferred } + let resolved try { - resolved = await resolveOpenCodeModel( - this.openCodeClient, - getOpenCodeGlobalConfigPath(), - signal ? { signal } : undefined, - ) + resolved = await resolveOpenCodeModel(this.openCodeClient, getOpenCodeGlobalConfigPath()) } catch (error) { - if (signal?.aborted) { - throw new WalkthroughCancelledError() - } throw new ChangeWalkthroughError( getErrorMessage(error) || 'Failed to resolve the walkthrough model', 502, { code: 'WALKTHROUGH_MODEL_UNAVAILABLE' }, ) } - if (signal?.aborted) { - throw new WalkthroughCancelledError() - } return resolved.variant ? { providerID: resolved.providerID, id: resolved.id, variant: resolved.variant } diff --git a/backend/src/services/opencode-manager-tool-plugin.ts b/backend/src/services/opencode-manager-tool-plugin.ts index c48758c96..d2427ea6a 100644 --- a/backend/src/services/opencode-manager-tool-plugin.ts +++ b/backend/src/services/opencode-manager-tool-plugin.ts @@ -5,7 +5,9 @@ import { MANAGER_PROMPT_CLIENTS, MANAGER_TOOL_ACTIONS, MANAGER_TOOL_NAME, + PublishWalkthroughParamsSchema, ShowArtifactParamsSchema, + WalkthroughChangesParamsSchema, WalkthroughSourceSchema, } from '@opencode-manager/shared/schemas' @@ -78,13 +80,15 @@ const ManagerToolWalkthroughParamsSchema = z source: WalkthroughSourceSchema.optional().describe('The change source to walk through. Defaults to the changes in this session.'), }) .strict() - .describe("Request the change Walkthrough for this session, generating it if needed. It opens in the user's Manager window when this session is open on desktop; otherwise the user can open it from this tool call.") + .describe("Start a separate walkthrough session that builds an interactive walkthrough UI for this session's changes, generating it if needed. It opens in the user's Manager walkthrough panel when this session is open on desktop; otherwise the user can open it from this tool call.") const MANAGER_TOOL_ACTION_PARAMS_SCHEMAS: Record<(typeof MANAGER_TOOL_ACTIONS)[number], z.ZodType> = { send_notification: ManagerToolNotificationParamsSchema, request: ManagerToolRequestParamsSchema, open_walkthrough: ManagerToolWalkthroughParamsSchema, show_artifact: ShowArtifactParamsSchema.describe('Show a self-contained HTML file the agent wrote to the user, rendered inline in the Manager chat in a sandboxed frame.'), + walkthrough_changes: WalkthroughChangesParamsSchema.describe('Read the changed files of the walkthrough. Omit params.file for an outline of every changed file; pass params.file to get one file\'s full hunks.'), + publish_walkthrough: PublishWalkthroughParamsSchema.describe('Publish the walkthrough HTML file you wrote so the Manager walkthrough panel renders it.'), } export function parseAllowedRoute(route: string): { method: string; path: string } { @@ -119,6 +123,8 @@ function buildManagerToolInputJsonSchema(): Record { MANAGER_TOOL_ACTION_PARAMS_SCHEMAS.request, MANAGER_TOOL_ACTION_PARAMS_SCHEMAS.open_walkthrough, MANAGER_TOOL_ACTION_PARAMS_SCHEMAS.show_artifact, + MANAGER_TOOL_ACTION_PARAMS_SCHEMAS.walkthrough_changes, + MANAGER_TOOL_ACTION_PARAMS_SCHEMAS.publish_walkthrough, ]) .describe('The parameters for the chosen action.'), }) @@ -139,7 +145,9 @@ function buildManagerToolDescription(): string { 'Actions:', '- send_notification: send a push notification to every device the user has registered.', '- request: call an allow-listed internal API route to read and manage settings, the OpenCode configuration file, repos (list, inspect, clone from a git URL with POST /repos and body { repoUrl, branch?, directoryName? }), OpenCode workspaces, sessions (list, create, follow up, read the latest reply, fork), and schedules.', - "- open_walkthrough: request the change Walkthrough for the current session, generating it if needed. It opens in the user's Manager window when this session is open on desktop; otherwise the user opens it from this tool call. Use when the user asks to walk through, review or explain the changes.", + "- open_walkthrough: start a separate walkthrough session that builds an interactive walkthrough UI for the current session's changes, opened in the Manager walkthrough panel. It opens in the user's Manager window when this session is open on desktop; otherwise the user opens it from this tool call. Use when the user asks to walk through, review or explain the changes.", + "- walkthrough_changes: only valid inside a walkthrough session. Call it with no arguments for an outline of the changed files, or with { file } for one file's full hunks.", + '- publish_walkthrough: only valid inside a walkthrough session. Call it after writing the walkthrough HTML to publish it to the Manager walkthrough panel.', '- show_artifact: write a self-contained .html file with your file tools, then call show_artifact with its path to show it to the user, rendered inline in this session\'s Manager chat inside a sandboxed frame. Edit the file to fix or change it (no need to resend content). Inline your own CSS and JavaScript; libraries may load from public CDNs. Forms, popups, cookies and local storage are blocked. Single file only: sibling .js and .css files will not load.', 'Allowed request routes:', ] @@ -282,13 +290,9 @@ var ACTIONS = { var state = await postInternalApi('/change-walkthroughs/' + encodeURIComponent(context.sessionID), body, context.signal) var hint = ' It opens in the Manager window when this session is open on desktop; otherwise the user can open it from this tool call.' if (state.generating === true) { - return 'Requested the walkthrough; it is generating.' + hint + return 'Started a walkthrough session that builds an interactive walkthrough.' + hint } - var stops = state.walkthrough && Array.isArray(state.walkthrough.stops) ? state.walkthrough.stops.length : 0 - if (state.walkthrough) { - return 'Requested the walkthrough (' + stops + ' stops).' + hint - } - return 'Requested the walkthrough.' + hint + return 'The walkthrough is ready.' + hint }, }, show_artifact: { @@ -304,6 +308,22 @@ var ACTIONS = { } }, }, + walkthrough_changes: { + run: async function (params, context) { + var body = params.file === undefined ? undefined : { file: params.file } + var result = await postInternalApi('/walkthrough-sessions/' + encodeURIComponent(context.sessionID) + '/changes', body, context.signal) + return result.text + }, + }, + publish_walkthrough: { + run: async function (params, context) { + var result = await postInternalApi('/walkthrough-sessions/' + encodeURIComponent(context.sessionID) + '/publish', undefined, context.signal) + return { + content: 'Published the walkthrough from ' + result.path + '. The Manager walkthrough panel renders the current file; edit it and call publish_walkthrough again after significant changes.', + metadata: { path: result.path }, + } + }, + }, } function assertParams(actionName, params) { diff --git a/backend/src/services/opencode/generate-text.ts b/backend/src/services/opencode/generate-text.ts index 5ebcf2eef..4d4b3f6fc 100644 --- a/backend/src/services/opencode/generate-text.ts +++ b/backend/src/services/opencode/generate-text.ts @@ -12,20 +12,11 @@ export class GenerateTextTimeoutError extends Error { export async function generateTextWithTimeout( client: OpenCodeClient, - input: { prompt: string; model?: ModelRef }, + input: { prompt: string }, timeoutMs: number, - signal?: AbortSignal, ): Promise { const controller = new AbortController() let timer: ReturnType | undefined - const abortFromSignal = () => controller.abort(signal?.reason) - if (signal) { - if (signal.aborted) { - controller.abort(signal.reason) - } else { - signal.addEventListener('abort', abortFromSignal, { once: true }) - } - } const timeout = new Promise((_resolve, reject) => { timer = setTimeout(() => { controller.abort() @@ -38,16 +29,15 @@ export async function generateTextWithTimeout( return text } finally { if (timer) clearTimeout(timer) - signal?.removeEventListener('abort', abortFromSignal) } } async function generateText( client: OpenCodeClient, - input: { prompt: string; model?: ModelRef }, + input: { prompt: string }, signal: AbortSignal, ): Promise<{ text: string }> { - const model = input.model ?? await resolveGenerateModel(client, signal) + const model = await resolveGenerateModel(client, signal) return client.api.generate.text({ prompt: input.prompt, model }, { signal }) } diff --git a/backend/src/services/session-artifacts.ts b/backend/src/services/session-artifacts.ts index 12a80af2d..61b399f76 100644 --- a/backend/src/services/session-artifacts.ts +++ b/backend/src/services/session-artifacts.ts @@ -14,6 +14,28 @@ export class SessionArtifactError extends ServiceError { } } +/** + * Validates that a resolved path is a regular `.html`/`.htm` file within the artifact size limit. + * This is the single owner of the HTML artifact file rules; callers that already know the path is + * contained pass the lexical display path for error messages. + */ +export async function assertHtmlArtifactFile(filePath: string, displayPath: string): Promise { + const stats = await fs.stat(filePath).catch(() => { + throw new SessionArtifactError(`Artifact file not found: ${displayPath}`, 404) + }) + if (!stats.isFile()) { + throw new SessionArtifactError(`Artifact file not found: ${displayPath}`, 404) + } + + if (!ARTIFACT_EXTENSIONS.has(path.extname(filePath).toLowerCase())) { + throw new SessionArtifactError('Artifact must be an .html file', 400) + } + + if (stats.size > ARTIFACT_LIMITS.FILE_MAX_BYTES) { + throw new SessionArtifactError('Artifact file exceeds 5 MB', 413) + } +} + /** * Resolves an `ocm` `show_artifact` path to the absolute path of a self-contained HTML file inside the session * directory, rejecting paths that escape it (including through symlinks), non-HTML files and oversized files. @@ -48,20 +70,7 @@ export async function resolveSessionArtifactFile( throw new SessionArtifactError(`Artifact path must be inside the session directory ${sessionDirectory}`, 400) } - const stats = await fs.stat(realFile).catch(() => { - throw new SessionArtifactError(`Artifact file not found: ${artifactPath}`, 404) - }) - if (!stats.isFile()) { - throw new SessionArtifactError(`Artifact file not found: ${artifactPath}`, 404) - } - - if (!ARTIFACT_EXTENSIONS.has(path.extname(realFile).toLowerCase())) { - throw new SessionArtifactError('Artifact must be an .html file', 400) - } - - if (stats.size > ARTIFACT_LIMITS.FILE_MAX_BYTES) { - throw new SessionArtifactError('Artifact file exceeds 5 MB', 413) - } + await assertHtmlArtifactFile(realFile, artifactPath) return resolved } diff --git a/backend/src/services/walkthrough-outline.ts b/backend/src/services/walkthrough-outline.ts new file mode 100644 index 000000000..53ab55a3d --- /dev/null +++ b/backend/src/services/walkthrough-outline.ts @@ -0,0 +1,307 @@ +import { createHash } from 'node:crypto' +import { + MECHANICAL_TEXT_BUDGET, + WALKTHROUGH_DIFF_MAX_CHARS, + WALKTHROUGH_HUNK_MAX_CHARS, + WALKTHROUGH_OUTLINE_LINE_MAX_CHARS, + WALKTHROUGH_OUTLINE_PREVIEW_LINES, + type WalkthroughHunk, + type WalkthroughOmittedFile, +} from '@opencode-manager/shared/schemas' +import type { FileDiffInfo } from '@opencode-manager/shared/opencode' +import { countDiffLineChanges, parseDiffLines, splitDiffHunks, type DiffHunk } from '@opencode-manager/shared/utils' +import { truncateText } from '../utils/text-truncate' + +const HUNK_TRUNCATION_MARKER = '\n[hunk truncated]' +const OUTPUT_TRUNCATION_MARKER = '\n[output truncated]' +const TEXT_TRUNCATION_MARKER = '' +const OUTLINE_RETRY_DEPTHS = [3, 1, 0] + +const MECHANICAL_LOCKFILE_NAMES = new Set([ + 'pnpm-lock.yaml', + 'package-lock.json', + 'npm-shrinkwrap.json', + 'yarn.lock', + 'bun.lock', + 'bun.lockb', + 'Cargo.lock', + 'Gemfile.lock', + 'composer.lock', + 'poetry.lock', + 'uv.lock', + 'Pipfile.lock', + 'go.sum', + 'flake.lock', +]) + +const MECHANICAL_SUFFIXES = ['.snap', '.min.js', '.min.css', '.map'] + +export interface WalkthroughInput { + hunks: WalkthroughHunk[] + mechanicalHunks: WalkthroughHunk[] + omittedFiles: WalkthroughOmittedFile[] +} + +export interface WalkthroughOutline { + hunks: WalkthroughHunk[] + omittedFiles: WalkthroughOmittedFile[] + outline: string +} + +export interface FormatWalkthroughOutlineOptions { + title: string + sourceDescription: string + changes: FileDiffInfo[] +} + +export function isMechanicalChangePath(file: string): boolean { + const segments = file.split('/') + const basename = segments.at(-1) ?? file + if (MECHANICAL_LOCKFILE_NAMES.has(basename)) { + return true + } + if (MECHANICAL_SUFFIXES.some((suffix) => file.endsWith(suffix))) { + return true + } + return segments.includes('__snapshots__') +} + +export function computeChangesHash(changes: FileDiffInfo[]): string { + const hash = createHash('sha256') + for (const change of changes) { + hash.update(`${change.file}\0${change.status}\0${change.patch}\n`) + } + return hash.digest('hex') +} + +export function computeHunkId(file: string, status: FileDiffInfo['status'], hunkText: string): string { + const body = hunkText.split('\n').slice(1).join('\n') + const digest = createHash('sha256').update(`${file}\0${status}\0${body}`).digest('hex') + return `h_${digest.slice(0, 12)}` +} + +export function buildWalkthroughInput(changes: FileDiffInfo[]): WalkthroughInput { + const hunks: WalkthroughHunk[] = [] + const mechanicalHunks: WalkthroughHunk[] = [] + const omittedFiles: WalkthroughOmittedFile[] = [] + const hunkIdCounts = new Map() + let mechanicalTextLength = 0 + + const nextHunkId = (file: string, status: FileDiffInfo['status'], text: string): string => { + const baseId = computeHunkId(file, status, text) + const count = (hunkIdCounts.get(baseId) ?? 0) + 1 + hunkIdCounts.set(baseId, count) + return count === 1 ? baseId : `${baseId}_${count}` + } + + const prepareHunks = (change: FileDiffInfo, fileHunks: DiffHunk[]): WalkthroughHunk[] => + fileHunks.map((hunk) => { + const truncated = truncateText(hunk.text, WALKTHROUGH_HUNK_MAX_CHARS, HUNK_TRUNCATION_MARKER) + return { + id: nextHunkId(change.file, change.status, hunk.text), + file: change.file, + status: change.status, + header: hunk.header, + text: truncated.text, + truncated: truncated.truncated, + } satisfies WalkthroughHunk + }) + + const buildMechanicalHunk = (change: FileDiffInfo, fileHunks: DiffHunk[]): WalkthroughHunk => { + const text = fileHunks.map((hunk) => hunk.text).join('\n') + return { + id: nextHunkId(change.file, change.status, text), + file: change.file, + status: change.status, + header: fileHunks[0]!.header, + text, + truncated: false, + additions: change.additions, + deletions: change.deletions, + } satisfies WalkthroughHunk + } + + const buildMechanicalSummaryHunk = (change: FileDiffInfo): WalkthroughHunk => ({ + id: nextHunkId(change.file, change.status, ''), + file: change.file, + status: change.status, + header: '', + text: '', + truncated: true, + additions: change.additions, + deletions: change.deletions, + }) + + changes.forEach((change) => { + const mechanical = isMechanicalChangePath(change.file) + const fileHunks = splitDiffHunks(change.patch) + if (fileHunks.length === 0) { + omittedFiles.push({ file: change.file, reason: classifyOmittedFileReason(change.patch) }) + return + } + + if (mechanical) { + const text = fileHunks.map((hunk) => hunk.text).join('\n') + if (mechanicalTextLength + text.length <= MECHANICAL_TEXT_BUDGET) { + mechanicalHunks.push(buildMechanicalHunk(change, fileHunks)) + mechanicalTextLength += text.length + } else { + mechanicalHunks.push(buildMechanicalSummaryHunk(change)) + } + return + } + + hunks.push(...prepareHunks(change, fileHunks)) + }) + + return { hunks, mechanicalHunks, omittedFiles } +} + +export function formatHunkOutline( + hunk: WalkthroughHunk, + previewLines = WALKTHROUGH_OUTLINE_PREVIEW_LINES, +): string { + const { added, removed } = countDiffLineChanges(hunk.text) + const head = `### ${hunk.id} ${hunk.file} (${hunk.status}) ${hunk.header} +${added} -${removed}` + const preview = parseDiffLines(hunk.text) + .filter((line) => line.type === 'add' || line.type === 'remove') + .slice(0, previewLines) + .map((line) => + truncateText( + `${line.type === 'add' ? '+' : '-'}${line.content}`, + WALKTHROUGH_OUTLINE_LINE_MAX_CHARS, + TEXT_TRUNCATION_MARKER, + ).text, + ) + return [head, ...preview].join('\n') +} + +export function formatHunkBlock(hunk: WalkthroughHunk): string { + return `### ${hunk.id} ${hunk.file} (${hunk.status})\n\n\`\`\`diff\n${hunk.text}\n\`\`\`` +} + +function classifyOmittedFileReason(patch: string): WalkthroughOmittedFile['reason'] { + const binary = patch.includes('Binary files') || patch.includes('GIT binary patch') + if (!binary && patch.includes('rename to ')) { + return 'renamed' + } + if (!binary && (patch.includes('old mode ') || patch.includes('new mode '))) { + return 'modeChange' + } + return 'binary' +} + +function groupHunksByFile(hunks: WalkthroughHunk[]): WalkthroughHunk[][] { + const groups: WalkthroughHunk[][] = [] + for (const hunk of hunks) { + const last = groups.at(-1) + if (last && last[0]!.file === hunk.file) { + last.push(hunk) + } else { + groups.push([hunk]) + } + } + return groups +} + +export function buildWalkthroughOutline(input: WalkthroughInput, prefixLength: number): WalkthroughOutline { + const groups = groupHunksByFile(input.hunks) + + const pack = (previewLines: number) => { + let outline = '' + const hunks: WalkthroughHunk[] = [] + const omittedFiles: WalkthroughOmittedFile[] = [] + let exhausted = false + for (const fileHunks of groups) { + if (exhausted) { + omittedFiles.push({ file: fileHunks[0]!.file, reason: 'budget' }) + continue + } + const block = fileHunks.map((hunk) => formatHunkOutline(hunk, previewLines)).join('\n') + const next = outline.length === 0 ? block : `${outline}\n\n${block}` + if (prefixLength + next.length > WALKTHROUGH_DIFF_MAX_CHARS) { + exhausted = true + omittedFiles.push({ file: fileHunks[0]!.file, reason: 'budget' }) + continue + } + outline = next + hunks.push(...fileHunks) + } + return { hunks, omittedFiles, outline } + } + + let packed = pack(WALKTHROUGH_OUTLINE_PREVIEW_LINES) + if (packed.omittedFiles.length > 0) { + for (const previewLines of OUTLINE_RETRY_DEPTHS) { + const candidate = pack(previewLines) + packed = candidate + if (candidate.omittedFiles.length === 0) { + break + } + } + } + + return { + hunks: packed.hunks, + omittedFiles: [...input.omittedFiles, ...packed.omittedFiles], + outline: packed.outline, + } +} + +function formatMechanicalFiles(hunks: WalkthroughHunk[]): string { + if (hunks.length === 0) { + return '' + } + return [ + '## Mechanical files', + ...hunks.map((hunk) => `- ${hunk.file} (${hunk.status}, +${hunk.additions ?? 0}/-${hunk.deletions ?? 0})`), + ].join('\n') +} + +function formatOmittedFiles(files: WalkthroughOmittedFile[]): string { + if (files.length === 0) { + return '' + } + return ['## Omitted files', ...files.map((file) => `- ${file.file} (${file.reason})`)].join('\n') +} + +export function formatWalkthroughOutline({ title, sourceDescription, changes }: FormatWalkthroughOutlineOptions): string { + const input = buildWalkthroughInput(changes) + const header = [ + `# ${title}`, + sourceDescription, + `${changes.length} files, ${input.hunks.length} text hunks. Call walkthrough_changes with { file } for a file's full hunks.`, + ].join('\n\n') + const mechanical = formatMechanicalFiles(input.mechanicalHunks) + const prefixLength = + [header, mechanical, formatOmittedFiles(input.omittedFiles)].filter((section) => section.length > 0).join('\n\n') + .length + '\n\n## Outline\n'.length + const outline = buildWalkthroughOutline(input, prefixLength) + + const sections = [header] + if (mechanical.length > 0) { + sections.push(mechanical) + } + const omitted = formatOmittedFiles(outline.omittedFiles) + if (omitted.length > 0) { + sections.push(omitted) + } + sections.push(`## Outline\n${outline.outline}`) + + return truncateText(sections.join('\n\n'), WALKTHROUGH_DIFF_MAX_CHARS, OUTPUT_TRUNCATION_MARKER).text +} + +export function formatWalkthroughFileChanges(changes: FileDiffInfo[], file: string): string | null { + const change = changes.find((candidate) => candidate.file === file) + if (!change) { + return null + } + const input = buildWalkthroughInput([change]) + const blocks = [...input.hunks, ...input.mechanicalHunks].map((hunk) => + hunk.text.length === 0 + ? `${hunk.file} (${hunk.status}, +${hunk.additions ?? 0}/-${hunk.deletions ?? 0})` + : formatHunkBlock(hunk), + ) + const text = blocks.length > 0 ? blocks.join('\n\n') : `${change.file} (${change.status}) has no textual changes` + return truncateText(text, WALKTHROUGH_DIFF_MAX_CHARS, OUTPUT_TRUNCATION_MARKER).text +} diff --git a/backend/src/utils/concurrency.ts b/backend/src/utils/concurrency.ts deleted file mode 100644 index e071dbfc1..000000000 --- a/backend/src/utils/concurrency.ts +++ /dev/null @@ -1,67 +0,0 @@ -export async function mapWithConcurrency( - items: T[], - limit: number, - fn: (item: T, index: number) => Promise, -): Promise { - const results = new Array(items.length) - const workerCount = Math.max(1, Math.min(limit, items.length)) - let nextIndex = 0 - - const worker = async (): Promise => { - while (true) { - const index = nextIndex - nextIndex += 1 - if (index >= items.length) { - return - } - results[index] = await fn(items[index]!, index) - } - } - - await Promise.all(Array.from({ length: workerCount }, () => worker())) - return results -} - -export interface Limiter { - readonly activeCount: number - run(task: () => Promise): Promise -} - -export function createLimiter(limit: number): Limiter { - const max = Math.max(1, limit) - let active = 0 - const waiters: Array<() => void> = [] - - const acquire = (): Promise => { - if (active < max) { - active += 1 - return Promise.resolve() - } - return new Promise((resolve) => { - waiters.push(resolve) - }) - } - - const release = (): void => { - const next = waiters.shift() - if (next) { - next() - return - } - active -= 1 - } - - return { - get activeCount(): number { - return active - }, - async run(task: () => Promise): Promise { - await acquire() - try { - return await task() - } finally { - release() - } - }, - } -} diff --git a/backend/test/db/change-walkthroughs.test.ts b/backend/test/db/change-walkthroughs.test.ts index 5214160ab..014b77248 100644 --- a/backend/test/db/change-walkthroughs.test.ts +++ b/backend/test/db/change-walkthroughs.test.ts @@ -1,56 +1,48 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { Database } from 'bun:sqlite' -import type { ChangeWalkthrough } from '@opencode-manager/shared/schemas' +import { WALKTHROUGH_GENERATION_TIMEOUT_MS } from '@opencode-manager/shared/schemas' import { + claimChangeWalkthroughGeneration, deleteChangeWalkthroughs, ensureChangeWalkthroughTable, getChangeWalkthrough, - saveChangeWalkthrough, + getChangeWalkthroughByWalkthroughSession, + listChangeWalkthroughs, + updateChangeWalkthrough, + type ClaimChangeWalkthroughGenerationInput, } from '../../src/db/change-walkthroughs' import { migrate } from '../../src/db/migration-runner' import { allMigrations } from '../../src/db/migrations' -import migration202610081200 from '../../src/db/migrations/202610081200-change-walkthrough-sources' +import migration202610151200 from '../../src/db/migrations/202610151200-change-walkthrough-sessions' +import migration202610161200 from '../../src/db/migrations/202610161200-change-walkthrough-execution' const SESSION_ID = 'ses_walkthrough' const SESSION_SOURCE = 'session' const STAGED_SOURCE = 'staged' +const NOW = 10_000_000 -function walkthrough(overrides: Partial = {}): ChangeWalkthrough { +function claim( + overrides: Partial = {}, +): ClaimChangeWalkthroughGenerationInput { return { sessionId: SESSION_ID, source: { kind: 'session' }, diffHash: 'hash-1', - model: null, - summary: 'A short summary', - stops: [ - { id: 's_1', title: 'First stop', explanation: 'Why it matters', hunkIds: ['h_1'], status: 'ready', explanationKey: null }, - ], - hunks: [ - { - id: 'h_1', - file: 'src/app.ts', - status: 'modified', - header: '@@ -1,2 +1,2 @@', - text: '@@ -1,2 +1,2 @@\n-const a = 1;\n+const a = 2;', - truncated: false, - }, - ], - omittedFiles: [{ file: 'assets/logo.png', reason: 'binary' }], - createdAt: 1_000, + generationId: 'gen-1', + artifactPath: '/workspace/walkthroughs/ses_walkthrough/gen-1/index.html', + model: 'anthropic/claude', + now: NOW, ...overrides, } } -function walkthroughTableExists(db: Database): boolean { - return Boolean( - db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'change_walkthroughs'").get(), - ) -} - -function tableColumns(db: Database): string[] { - return (db.prepare('PRAGMA table_info(change_walkthroughs)').all() as Array<{ name: string }>).map( - (column) => column.name, - ) +function insertRow(db: Database, sourceKey: string): void { + db.prepare(` + INSERT INTO change_walkthroughs( + session_id, source_key, diff_hash, generation_id, walkthrough_session_id, status, + artifact_path, published_at, error, model, created_at, updated_at + ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?) + `).run(SESSION_ID, sourceKey, 'hash-1', 'gen-1', null, 'generating', null, null, null, null, NOW, NOW) } describe('change walkthroughs', () => { @@ -65,123 +57,277 @@ describe('change walkthroughs', () => { db.close() }) - it('creates the change_walkthroughs table with per-source storage', () => { - expect(walkthroughTableExists(db)).toBe(true) - expect(tableColumns(db)).toContain('source_key') + it('creates the sessions table with the expected columns, primary key, and index', () => { + const columns = db.prepare('PRAGMA table_info(change_walkthroughs)').all() as Array<{ + name: string + pk: number + }> + const names = columns.map((column) => column.name) + expect(names).toEqual( + expect.arrayContaining([ + 'generation_id', + 'walkthrough_session_id', + 'status', + 'artifact_path', + 'published_at', + 'error', + 'model', + 'updated_at', + 'execution_started_at', + ]), + ) + + const primaryKey = columns + .filter((column) => column.pk > 0) + .sort((a, b) => a.pk - b.pk) + .map((column) => column.name) + expect(primaryKey).toEqual(['session_id', 'source_key']) + + const indexes = db.prepare('PRAGMA index_list(change_walkthroughs)').all() as Array<{ name: string }> + expect(indexes.map((index) => index.name)).toContain('change_walkthroughs_walkthrough_session') }) - it('upgrades an existing single-source table and stays idempotent', () => { - const legacy = new Database(':memory:') - try { - ensureChangeWalkthroughTable(legacy) + it('applies the migration up idempotently', () => { + expect(() => migration202610151200.up(db)).not.toThrow() + }) - migrate(legacy, allMigrations) - expect(tableColumns(legacy)).toContain('source_key') + it('adds the execution_started_at column idempotently', () => { + const columns = db.prepare('PRAGMA table_info(change_walkthroughs)').all() as Array<{ name: string }> + expect(columns.map((column) => column.name)).toContain('execution_started_at') - expect(() => migrate(legacy, allMigrations)).not.toThrow() - expect(() => migration202610081200.up(legacy)).not.toThrow() - expect(tableColumns(legacy)).toContain('source_key') - } finally { - legacy.close() - } + expect(() => migration202610161200.up(db)).not.toThrow() }) - it('creates the old table idempotently', () => { - expect(() => ensureChangeWalkthroughTable(db)).not.toThrow() - expect(walkthroughTableExists(db)).toBe(true) + it('claims a generation on a fresh row', () => { + expect(claimChangeWalkthroughGeneration(db, claim())).toBe(true) + + const stored = getChangeWalkthrough(db, SESSION_ID, SESSION_SOURCE) + expect(stored).toMatchObject({ + sessionId: SESSION_ID, + source: { kind: 'session' }, + diffHash: 'hash-1', + status: 'generating', + walkthroughSessionId: null, + publishedAt: null, + executionStartedAt: null, + error: null, + model: 'anthropic/claude', + artifactPath: '/workspace/walkthroughs/ses_walkthrough/gen-1/index.html', + createdAt: NOW, + updatedAt: NOW, + }) }) - it('returns null for a session without a stored walkthrough', () => { - expect(getChangeWalkthrough(db, 'ses_missing', SESSION_SOURCE)).toBeNull() + it('rejects a second claim while a fresh generation is in progress', () => { + expect(claimChangeWalkthroughGeneration(db, claim())).toBe(true) + expect(claimChangeWalkthroughGeneration(db, claim({ generationId: 'gen-2', now: NOW + 1 }))).toBe(false) }) - it('round-trips a stored walkthrough', () => { - const stored = walkthrough() - saveChangeWalkthrough(db, stored) - - expect(getChangeWalkthrough(db, SESSION_ID, SESSION_SOURCE)).toEqual(stored) - }) - - it('reads a walkthrough whose mechanical hunks predate hunk counts', () => { - const legacy = walkthrough({ - stops: [ - { - id: 's_mech', - title: 'Mechanical changes', - explanation: 'Lock files', - hunkIds: ['h_lock'], - status: 'ready', - explanationKey: null, - }, - ], - hunks: [ - { - id: 'h_lock', - file: 'pnpm-lock.yaml', - status: 'modified', - header: '@@ -1 +1 @@', - text: '@@ -1 +1 @@\n-old-lock\n+new-lock', - truncated: false, - }, - ], + it.each(['ready', 'failed', 'cancelled'] as const)('reclaims a row whose status is %s', (status) => { + expect(claimChangeWalkthroughGeneration(db, claim())).toBe(true) + updateChangeWalkthrough(db, SESSION_ID, SESSION_SOURCE, ['generating'], { + status, + walkthroughSessionId: 'ses_generated', + publishedAt: NOW + 5, + error: status === 'failed' ? { message: 'boom' } : null, + updatedAt: NOW + 10, }) - saveChangeWalkthrough(db, legacy) + + expect( + claimChangeWalkthroughGeneration( + db, + claim({ + generationId: 'gen-2', + diffHash: 'hash-2', + artifactPath: '/workspace/walkthroughs/ses_walkthrough/gen-2/index.html', + now: NOW + 20, + }), + ), + ).toBe(true) const stored = getChangeWalkthrough(db, SESSION_ID, SESSION_SOURCE) - expect(stored).toEqual(legacy) - expect(stored!.hunks[0]!.additions).toBeUndefined() + expect(stored).toMatchObject({ + status: 'generating', + diffHash: 'hash-2', + artifactPath: '/workspace/walkthroughs/ses_walkthrough/gen-2/index.html', + walkthroughSessionId: null, + publishedAt: null, + executionStartedAt: null, + error: null, + createdAt: NOW, + updatedAt: NOW + 20, + }) }) - it('stores walkthroughs for two sources of one session side by side', () => { - saveChangeWalkthrough(db, walkthrough({ diffHash: 'hash-session' })) - saveChangeWalkthrough(db, walkthrough({ source: { kind: 'staged' }, diffHash: 'hash-staged', summary: 'Staged' })) + it('rejects a stale claim within the timeout but allows one after it', () => { + expect(claimChangeWalkthroughGeneration(db, claim())).toBe(true) + expect( + claimChangeWalkthroughGeneration( + db, + claim({ generationId: 'gen-2', now: NOW + WALKTHROUGH_GENERATION_TIMEOUT_MS }), + ), + ).toBe(false) + expect( + claimChangeWalkthroughGeneration( + db, + claim({ generationId: 'gen-3', now: NOW + WALKTHROUGH_GENERATION_TIMEOUT_MS + 1 }), + ), + ).toBe(true) + }) + + it('stores both sources of one session as separate rows', () => { + expect(claimChangeWalkthroughGeneration(db, claim())).toBe(true) + expect( + claimChangeWalkthroughGeneration(db, claim({ source: { kind: 'staged' }, diffHash: 'hash-2' })), + ).toBe(true) - expect(getChangeWalkthrough(db, SESSION_ID, SESSION_SOURCE)?.diffHash).toBe('hash-session') - expect(getChangeWalkthrough(db, SESSION_ID, STAGED_SOURCE)?.diffHash).toBe('hash-staged') + const list = listChangeWalkthroughs(db, SESSION_ID) + expect(list).toHaveLength(2) + expect(list.map((walkthrough) => walkthrough.source.kind).sort()).toEqual(['session', 'staged']) }) - it('keeps a single row holding the latest payload on repeated saves of one source', () => { - saveChangeWalkthrough(db, walkthrough()) - saveChangeWalkthrough(db, walkthrough({ diffHash: 'hash-2', summary: 'Updated', createdAt: 2_000 })) + it('returns null for an unknown session or source', () => { + expect(getChangeWalkthrough(db, 'ses_missing', SESSION_SOURCE)).toBeNull() + }) - const count = db - .prepare('SELECT COUNT(*) AS count FROM change_walkthroughs WHERE session_id = ?') - .get(SESSION_ID) as { count: number } - expect(count.count).toBe(1) + it('returns null when the stored source key is unparseable', () => { + insertRow(db, 'bogus') + expect(getChangeWalkthrough(db, SESSION_ID, 'bogus')).toBeNull() + }) - const stored = getChangeWalkthrough(db, SESSION_ID, SESSION_SOURCE) - expect(stored).toMatchObject({ diffHash: 'hash-2', summary: 'Updated', createdAt: 2_000 }) + it('finds a walkthrough by its generation session id', () => { + expect(claimChangeWalkthroughGeneration(db, claim())).toBe(true) + updateChangeWalkthrough(db, SESSION_ID, SESSION_SOURCE, ['generating'], { + walkthroughSessionId: 'ses_generated', + updatedAt: NOW + 1, + }) + + const found = getChangeWalkthroughByWalkthroughSession(db, 'ses_generated') + expect(found?.generationId).toBe('gen-1') + expect(found?.walkthrough.source.kind).toBe('session') + + expect(getChangeWalkthroughByWalkthroughSession(db, 'ses_unknown')).toBeNull() }) - it('deletes every source of the given session', () => { - saveChangeWalkthrough(db, walkthrough()) - saveChangeWalkthrough(db, walkthrough({ source: { kind: 'staged' } })) - saveChangeWalkthrough(db, walkthrough({ sessionId: 'ses_other' })) + it('does not update a row whose status is not expected', () => { + expect(claimChangeWalkthroughGeneration(db, claim())).toBe(true) - deleteChangeWalkthroughs(db, SESSION_ID) + expect( + updateChangeWalkthrough(db, SESSION_ID, SESSION_SOURCE, ['ready'], { + status: 'ready', + updatedAt: NOW + 1, + }), + ).toBeNull() + expect(getChangeWalkthrough(db, SESSION_ID, SESSION_SOURCE)?.status).toBe('generating') + }) - expect(getChangeWalkthrough(db, SESSION_ID, SESSION_SOURCE)).toBeNull() - expect(getChangeWalkthrough(db, SESSION_ID, STAGED_SOURCE)).toBeNull() - expect(getChangeWalkthrough(db, 'ses_other', SESSION_SOURCE)).not.toBeNull() + it('updates only while the walkthrough session id matches the guard', () => { + expect(claimChangeWalkthroughGeneration(db, claim())).toBe(true) + updateChangeWalkthrough(db, SESSION_ID, SESSION_SOURCE, ['generating'], { + walkthroughSessionId: 'ses_a', + updatedAt: NOW + 1, + }) + + expect( + updateChangeWalkthrough( + db, + SESSION_ID, + SESSION_SOURCE, + ['generating'], + { status: 'ready', updatedAt: NOW + 2 }, + { walkthroughSessionId: 'ses_b' }, + ), + ).toBeNull() + expect(getChangeWalkthrough(db, SESSION_ID, SESSION_SOURCE)).toMatchObject({ + status: 'generating', + walkthroughSessionId: 'ses_a', + }) + + expect( + updateChangeWalkthrough( + db, + SESSION_ID, + SESSION_SOURCE, + ['generating'], + { status: 'ready', updatedAt: NOW + 3 }, + { walkthroughSessionId: 'ses_a' }, + )?.status, + ).toBe('ready') }) - it('ignores deleting a session without a stored walkthrough', () => { - expect(() => deleteChangeWalkthroughs(db, 'ses_missing')).not.toThrow() + it('guards updates by whether an execution is running', () => { + expect(claimChangeWalkthroughGeneration(db, claim())).toBe(true) + updateChangeWalkthrough(db, SESSION_ID, SESSION_SOURCE, ['generating'], { + walkthroughSessionId: 'ses_a', + executionStartedAt: NOW + 1, + updatedAt: NOW + 1, + }) + + expect( + updateChangeWalkthrough( + db, + SESSION_ID, + SESSION_SOURCE, + ['generating'], + { status: 'ready', updatedAt: NOW + 2 }, + { running: false }, + ), + ).toBeNull() + + expect( + updateChangeWalkthrough( + db, + SESSION_ID, + SESSION_SOURCE, + ['generating'], + { status: 'ready', executionStartedAt: null, updatedAt: NOW + 3 }, + { running: true }, + ), + ).toMatchObject({ status: 'ready', executionStartedAt: null }) }) - it('treats a corrupt payload as missing', () => { - db.prepare( - 'INSERT INTO change_walkthroughs(session_id, source_key, diff_hash, payload, created_at) VALUES(?,?,?,?,?)', - ).run(SESSION_ID, SESSION_SOURCE, 'hash-1', '{not json', 1_000) + it('updates a row whose status matches and persists the patch', () => { expect(claimChangeWalkthroughGeneration(db, claim())).toBe(true) - expect(getChangeWalkthrough(db, SESSION_ID, SESSION_SOURCE)).toBeNull() + const failed = updateChangeWalkthrough(db, SESSION_ID, SESSION_SOURCE, ['generating'], { + status: 'failed', + error: { message: 'boom', code: 'E_FAIL' }, + updatedAt: NOW + 1, + }) + expect(failed?.status).toBe('failed') + expect(failed?.error).toEqual({ message: 'boom', code: 'E_FAIL' }) + expect(getChangeWalkthrough(db, SESSION_ID, SESSION_SOURCE)?.error).toEqual({ + message: 'boom', + code: 'E_FAIL', + }) + + const cleared = updateChangeWalkthrough(db, SESSION_ID, SESSION_SOURCE, ['failed'], { + error: null, + updatedAt: NOW + 2, + }) + expect(cleared?.error).toBeNull() + expect(getChangeWalkthrough(db, SESSION_ID, SESSION_SOURCE)?.error).toBeNull() + }) + + it('returns null when there is nothing to update', () => { + expect(claimChangeWalkthroughGeneration(db, claim())).toBe(true) + expect(updateChangeWalkthrough(db, SESSION_ID, SESSION_SOURCE, ['generating'], {})).toBeNull() + expect(updateChangeWalkthrough(db, SESSION_ID, SESSION_SOURCE, [], { status: 'ready' })).toBeNull() }) - it('treats a payload that fails schema validation as missing', () => { - db.prepare( - 'INSERT INTO change_walkthroughs(session_id, source_key, diff_hash, payload, created_at) VALUES(?,?,?,?,?)', - ).run(SESSION_ID, SESSION_SOURCE, 'hash-1', JSON.stringify({ sessionId: SESSION_ID }), 1_000) + it('deletes every source of one session but not another', () => { + claimChangeWalkthroughGeneration(db, claim()) + claimChangeWalkthroughGeneration(db, claim({ source: { kind: 'staged' } })) + claimChangeWalkthroughGeneration(db, claim({ sessionId: 'ses_other' })) + + deleteChangeWalkthroughs(db, SESSION_ID) expect(getChangeWalkthrough(db, SESSION_ID, SESSION_SOURCE)).toBeNull() + expect(getChangeWalkthrough(db, SESSION_ID, STAGED_SOURCE)).toBeNull() + expect(getChangeWalkthrough(db, 'ses_other', SESSION_SOURCE)).not.toBeNull() + }) + + it('creates the table idempotently', () => { + expect(() => ensureChangeWalkthroughTable(db)).not.toThrow() + expect(() => ensureChangeWalkthroughTable(db)).not.toThrow() }) }) diff --git a/backend/test/routes/change-walkthroughs.test.ts b/backend/test/routes/change-walkthroughs.test.ts index d1c427d53..10adb63bc 100644 --- a/backend/test/routes/change-walkthroughs.test.ts +++ b/backend/test/routes/change-walkthroughs.test.ts @@ -1,189 +1,56 @@ -import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { Hono } from 'hono' -import { Database } from 'bun:sqlite' -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import path from 'node:path' -import type { FileDiffInfo, SessionInfo } from '@opencode-manager/shared/opencode' -import { walkthroughHunksIdentity } from '@opencode-manager/shared/schemas' -import type { ChangeWalkthrough } from '@opencode-manager/shared/schemas' -import { migrate } from '../../src/db/migration-runner' -import { allMigrations } from '../../src/db/migrations' -import { saveChangeWalkthrough } from '../../src/db/change-walkthroughs' -import { ChangeWalkthroughService, computeChangesHash, computeHunkId } from '../../src/services/change-walkthroughs' -import { SettingsService } from '../../src/services/settings' +import type { ChangeWalkthroughState } from '@opencode-manager/shared/schemas' import { createChangeWalkthroughRoutes } from '../../src/routes/change-walkthroughs' -import type { OpenCodeClient } from '../../src/services/opencode/client' -import type { GitService } from '../../src/services/git/GitService' -import { stubLoadedModelCatalog } from '../helpers/stub-opencode-client' -import { createCommittedRepo, createGitAuthService, git, uniqueName } from '../helpers/git-fixtures' +import { ChangeWalkthroughError } from '../../src/services/change-walkthrough-error' +import type { ChangeWalkthroughService } from '../../src/services/change-walkthroughs' const SESSION_ID = 'ses_walkthrough' -function change(file: string, patch: string, status: FileDiffInfo['status'] = 'modified'): FileDiffInfo { - return { file, patch, additions: 1, deletions: 1, status } -} - -const CHANGES: FileDiffInfo[] = [change('src/a.ts', '@@ -1,2 +1,2 @@\n-const a = 1;\n+const a = 2;')] - -const HUNK_ID = computeHunkId(CHANGES[0]!.file, CHANGES[0]!.status, CHANGES[0]!.patch) - -const MODEL_REPLY = JSON.stringify({ - summary: 'A summary', - stops: [{ title: 'First', explanation: 'Why', hunkIds: [HUNK_ID] }], -}) - -interface FakeSession { - info?: SessionInfo | Error - changes?: FileDiffInfo[] | Error -} - -function createFakeClient(sessions: Record) { - const generateCalls: string[] = [] - let generateImpl: (prompt: string) => Promise = async () => MODEL_REPLY - - const client = { - api: { - ...stubLoadedModelCatalog(), - session: { - get: vi.fn(async ({ sessionID }: { sessionID: string }) => { - const config = sessions[sessionID] - if (!config) { - throw Object.assign(new Error('Session not found'), { _tag: 'SessionNotFoundError' }) - } - if (config.info instanceof Error) { - throw config.info - } - return config.info ?? ({ id: sessionID, title: `Title ${sessionID}` } as SessionInfo) - }), - diff: vi.fn(async ({ sessionID }: { sessionID: string }) => { - const config = sessions[sessionID] - if (config?.changes instanceof Error) { - throw config.changes - } - return config?.changes ?? [] - }), - }, - message: { - list: vi.fn(async ({ sessionID }: { sessionID: string }) => { - if (!sessions[sessionID]) { - return { data: [], cursor: {} } - } - return { data: [{ id: 'msg-1', type: 'user', time: { created: 0 }, text: 'x' }], cursor: {} } - }), - }, - generate: { - text: vi.fn(async (input: { prompt: string }) => { - generateCalls.push(input.prompt) - return { text: await generateImpl(input.prompt) } - }), - }, - }, - forwardRaw: vi.fn(), - } as unknown as OpenCodeClient - +function state(overrides: Partial = {}): ChangeWalkthroughState { return { - client, - generateCalls, - setGenerateImpl: (impl: (prompt: string) => Promise) => { - generateImpl = impl - }, + walkthrough: null, + currentDiffHash: 'hash', + stale: false, + generating: false, + running: false, + error: null, + ...overrides, } } -const routeRepoRoot = mkdtempSync(path.join(tmpdir(), 'walkthrough-routes-')) - -afterAll(() => { - rmSync(routeRepoRoot, { recursive: true, force: true }) -}) - -function createRepoWithStagedChange(): string { - const repo = path.join(routeRepoRoot, uniqueName('repo')) - createCommittedRepo(repo) - writeFileSync(path.join(repo, 'a.txt'), 'one\n') - git(['add', 'a.txt'], repo) - git(['commit', '-m', 'add a'], repo) - writeFileSync(path.join(repo, 'a.txt'), 'two\n') - git(['add', 'a.txt'], repo) - return repo -} - -function replyForPrompt(prompt: string, summary = 'A summary'): string { - const ids = [...prompt.matchAll(/^### (h_\S+) /gm)].map((match) => match[1]!) - return JSON.stringify({ summary, stops: [{ title: 'A', explanation: 'Why', hunkIds: ids }] }) -} - -function createDeferred(): { promise: Promise; resolve: (value: T) => void } { - let resolve!: (value: T) => void - const promise = new Promise((res) => { - resolve = res - }) - return { promise, resolve } -} - describe('change walkthrough routes', () => { - let db: Database - let sessions: Record - let fake: ReturnType + let getState: ReturnType + let startGeneration: ReturnType + let cancelGeneration: ReturnType + let refine: ReturnType let app: Hono beforeEach(() => { - db = new Database(':memory:') - migrate(db, allMigrations) - sessions = { [SESSION_ID]: { changes: CHANGES } } - fake = createFakeClient(sessions) - const service = new ChangeWalkthroughService( - db, - fake.client, - new SettingsService(db), - createGitAuthService(), - { fetchRemoteRef: vi.fn(async () => {}) } as unknown as GitService, - ) + getState = vi.fn(async () => state()) + startGeneration = vi.fn(async () => state({ generating: true })) + cancelGeneration = vi.fn(async () => true) + refine = vi.fn(async () => state({ generating: true })) app = new Hono() - app.route('/change-walkthroughs', createChangeWalkthroughRoutes(service)) - }) - - afterEach(() => { - db.close() + app.route( + '/change-walkthroughs', + createChangeWalkthroughRoutes({ getState, startGeneration, cancelGeneration, refine } as unknown as ChangeWalkthroughService), + ) }) - it('GET returns the state for a session', async () => { + it('GET returns the state for the default source', async () => { const res = await app.request(`/change-walkthroughs/${SESSION_ID}`) expect(res.status).toBe(200) - const body = (await res.json()) as { - walkthrough: unknown - currentDiffHash: string | null - stale: boolean - generating: boolean - error: unknown - } - expect(body.walkthrough).toBeNull() - expect(body.stale).toBe(false) - expect(body.currentDiffHash).toEqual(expect.any(String)) - expect(body.generating).toBe(false) - expect(body.error).toBeNull() - }) - - it('GET returns 404 for a missing session', async () => { - const res = await app.request('/change-walkthroughs/ses_missing') - - expect(res.status).toBe(404) - await expect(res.json()).resolves.toMatchObject({ error: 'Session not found' }) + await expect(res.json()).resolves.toEqual(state()) + expect(getState).toHaveBeenCalledWith(SESSION_ID, { kind: 'session' }) }) - it('GET reads the diff of the requested source', async () => { - const repo = createRepoWithStagedChange() - sessions[SESSION_ID]!.info = { id: SESSION_ID, title: 'Title', location: { directory: repo } } as SessionInfo + it('GET reads the requested source', async () => { + const res = await app.request(`/change-walkthroughs/${SESSION_ID}?source=staged`) - const sessionRes = await app.request(`/change-walkthroughs/${SESSION_ID}`) - const sessionBody = (await sessionRes.json()) as { currentDiffHash: string | null } - const stagedRes = await app.request(`/change-walkthroughs/${SESSION_ID}?source=staged`) - const stagedBody = (await stagedRes.json()) as { currentDiffHash: string | null } - - expect(stagedRes.status).toBe(200) - expect(typeof stagedBody.currentDiffHash).toBe('string') - expect(stagedBody.currentDiffHash).not.toBe(sessionBody.currentDiffHash) + expect(res.status).toBe(200) + expect(getState).toHaveBeenCalledWith(SESSION_ID, { kind: 'staged' }) }) it('GET rejects an invalid source with 400', async () => { @@ -191,54 +58,19 @@ describe('change walkthrough routes', () => { expect(res.status).toBe(400) await expect(res.json()).resolves.toMatchObject({ error: 'Invalid walkthrough source' }) + expect(getState).not.toHaveBeenCalled() }) - it('GET omits hunks when hunksFor matches the stored identity and returns them otherwise', async () => { - const stored: ChangeWalkthrough = { - sessionId: SESSION_ID, - source: { kind: 'session' }, - diffHash: computeChangesHash(CHANGES), - model: null, - summary: 'A summary', - stops: [ - { id: 's_1', title: 'First', explanation: 'Why', hunkIds: [HUNK_ID], status: 'ready', explanationKey: null }, - ], - hunks: [ - { - id: HUNK_ID, - file: CHANGES[0]!.file, - status: CHANGES[0]!.status, - header: '@@ -1,2 +1,2 @@', - text: CHANGES[0]!.patch, - truncated: false, - }, - ], - omittedFiles: [], - createdAt: 42, - } - saveChangeWalkthrough(db, stored) - const identity = walkthroughHunksIdentity(stored) - - const fullRes = await app.request(`/change-walkthroughs/${SESSION_ID}`) - const compactRes = await app.request( - `/change-walkthroughs/${SESSION_ID}?hunksFor=${encodeURIComponent(identity)}`, - ) - const mismatchRes = await app.request(`/change-walkthroughs/${SESSION_ID}?hunksFor=other`) + it('GET maps a service error', async () => { + getState.mockRejectedValueOnce(new ChangeWalkthroughError('Session not found', 404)) - const full = (await fullRes.json()) as { walkthrough: { hunks?: unknown[] } } - const compact = (await compactRes.json()) as { walkthrough: { hunks?: unknown[] } } - const mismatch = (await mismatchRes.json()) as { walkthrough: { hunks?: unknown[] } } + const res = await app.request('/change-walkthroughs/ses_missing') - expect(full.walkthrough.hunks).toHaveLength(1) - expect(compact.walkthrough.hunks).toBeUndefined() - expect(mismatch.walkthrough.hunks).toHaveLength(1) + expect(res.status).toBe(404) + await expect(res.json()).resolves.toMatchObject({ error: 'Session not found' }) }) - it('POST stores the walkthrough under the requested source', async () => { - const repo = createRepoWithStagedChange() - sessions[SESSION_ID]!.info = { id: SESSION_ID, title: 'Title', location: { directory: repo } } as SessionInfo - fake.setGenerateImpl(async (prompt) => replyForPrompt(prompt, 'Staged summary')) - + it('POST returns 202 while generating and forwards the body', async () => { const res = await app.request(`/change-walkthroughs/${SESSION_ID}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -246,206 +78,148 @@ describe('change walkthrough routes', () => { }) expect(res.status).toBe(202) - - await vi.waitFor(async () => { - const getRes = await app.request(`/change-walkthroughs/${SESSION_ID}?source=staged`) - const state = (await getRes.json()) as { walkthrough: { summary: string } | null } - expect(state.walkthrough?.summary).toBe('Staged summary') - }) - }) - - it('POST returns 202 while generating, then GET reads the walkthrough back', async () => { - let resolveGenerate: (text: string) => void = () => {} - fake.setGenerateImpl(() => new Promise((resolve) => { - resolveGenerate = resolve - })) - - const postRes = await app.request(`/change-walkthroughs/${SESSION_ID}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({}), - }) - - expect(postRes.status).toBe(202) - const body = (await postRes.json()) as { generating: boolean; walkthrough: unknown } - expect(body.generating).toBe(true) - expect(body.walkthrough).toBeNull() - - resolveGenerate(MODEL_REPLY) - - await vi.waitFor(async () => { - const getRes = await app.request(`/change-walkthroughs/${SESSION_ID}`) - const state = (await getRes.json()) as { walkthrough: { summary: string } | null; generating: boolean } - expect(state.generating).toBe(false) - expect(state.walkthrough?.summary).toBe('A summary') - }) + expect(startGeneration).toHaveBeenCalledWith(SESSION_ID, { source: { kind: 'staged' } }) }) it('POST accepts an empty body', async () => { - let resolveGenerate: (text: string) => void = () => {} - fake.setGenerateImpl(() => new Promise((resolve) => { - resolveGenerate = resolve - })) - const res = await app.request(`/change-walkthroughs/${SESSION_ID}`, { method: 'POST' }) expect(res.status).toBe(202) - - resolveGenerate(MODEL_REPLY) - await vi.waitFor(async () => { - const getRes = await app.request(`/change-walkthroughs/${SESSION_ID}`) - const state = (await getRes.json()) as { walkthrough: unknown } - expect(state.walkthrough).not.toBeNull() - }) + expect(startGeneration).toHaveBeenCalledWith(SESSION_ID, {}) }) - it('POST returns 200 with the stored walkthrough without a second model call', async () => { - await app.request(`/change-walkthroughs/${SESSION_ID}`, { method: 'POST' }) - await vi.waitFor(async () => { - const getRes = await app.request(`/change-walkthroughs/${SESSION_ID}`) - const state = (await getRes.json()) as { walkthrough: unknown } - expect(state.walkthrough).not.toBeNull() - }) + it('POST returns 200 when the walkthrough is ready', async () => { + startGeneration.mockResolvedValueOnce(state({ generating: false })) - const second = await app.request(`/change-walkthroughs/${SESSION_ID}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({}), - }) + const res = await app.request(`/change-walkthroughs/${SESSION_ID}`, { method: 'POST' }) - expect(second.status).toBe(200) - const body = (await second.json()) as { walkthrough: { summary: string }; generating: boolean } - expect(body.walkthrough.summary).toBe('A summary') - expect(body.generating).toBe(false) - expect(fake.generateCalls).toHaveLength(1) + expect(res.status).toBe(200) }) - it('POST regenerates when asked', async () => { - await app.request(`/change-walkthroughs/${SESSION_ID}`, { method: 'POST' }) - await vi.waitFor(async () => { - const getRes = await app.request(`/change-walkthroughs/${SESSION_ID}`) - const state = (await getRes.json()) as { walkthrough: unknown } - expect(state.walkthrough).not.toBeNull() - }) - - let resolveGenerate: (text: string) => void = () => {} - fake.setGenerateImpl(() => new Promise((resolve) => { - resolveGenerate = resolve - })) - - const res = await app.request(`/change-walkthroughs/${SESSION_ID}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ regenerate: true }), - }) + it('POST returns 409 with the service code', async () => { + startGeneration.mockRejectedValueOnce( + new ChangeWalkthroughError('No changes', 409, { code: 'WALKTHROUGH_NO_TEXT_CHANGES' }), + ) - expect(res.status).toBe(202) + const res = await app.request(`/change-walkthroughs/${SESSION_ID}`, { method: 'POST' }) - resolveGenerate(MODEL_REPLY) - await vi.waitFor(() => expect(fake.generateCalls).toHaveLength(2)) + expect(res.status).toBe(409) + await expect(res.json()).resolves.toMatchObject({ code: 'WALKTHROUGH_NO_TEXT_CHANGES' }) }) - it('POST rejects an invalid body with 400', async () => { + it('POST rejects malformed JSON with 400', async () => { const res = await app.request(`/change-walkthroughs/${SESSION_ID}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ regenerate: 'yes' }), + body: '{not json', }) expect(res.status).toBe(400) + expect(startGeneration).not.toHaveBeenCalled() }) - it('POST rejects malformed JSON with 400', async () => { + it('POST rejects an invalid body with 400', async () => { const res = await app.request(`/change-walkthroughs/${SESSION_ID}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: '{not json', + body: JSON.stringify({ regenerate: 'yes' }), }) expect(res.status).toBe(400) + expect(startGeneration).not.toHaveBeenCalled() }) - it('POST returns 409 with a code when there are no text changes', async () => { - sessions[SESSION_ID]!.changes = [ - change('assets/logo.png', 'diff --git a/assets/logo.png b/assets/logo.png\nBinary files differ'), - ] + it('POST returns 404 for a missing session', async () => { + startGeneration.mockRejectedValueOnce(new ChangeWalkthroughError('Session not found', 404)) - const res = await app.request(`/change-walkthroughs/${SESSION_ID}`, { method: 'POST' }) + const res = await app.request('/change-walkthroughs/ses_missing', { method: 'POST' }) - expect(res.status).toBe(409) - await expect(res.json()).resolves.toMatchObject({ code: 'WALKTHROUGH_NO_TEXT_CHANGES' }) + expect(res.status).toBe(404) }) - it('POST records an unparseable model reply that GET surfaces', async () => { - fake.setGenerateImpl(async () => 'not json') - - const res = await app.request(`/change-walkthroughs/${SESSION_ID}`, { method: 'POST' }) - expect(res.status).toBe(202) + it('DELETE awaits the cancel and returns 204', async () => { + let resolveCancel: (value: boolean) => void = () => {} + cancelGeneration.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveCancel = resolve + }), + ) - await vi.waitFor(async () => { - const getRes = await app.request(`/change-walkthroughs/${SESSION_ID}`) - const state = (await getRes.json()) as { generating: boolean; error: { code?: string } | null } - expect(state.generating).toBe(false) - expect(state.error?.code).toBe('WALKTHROUGH_UNPARSEABLE') + let settled = false + const pending = Promise.resolve(app.request(`/change-walkthroughs/${SESSION_ID}`, { method: 'DELETE' })).then((res) => { + settled = true + return res }) - }) - it('POST returns 404 for a missing session', async () => { - const res = await app.request('/change-walkthroughs/ses_missing', { method: 'POST' }) + await vi.waitFor(() => expect(cancelGeneration).toHaveBeenCalledWith(SESSION_ID, { kind: 'session' })) + await Promise.resolve() + expect(settled).toBe(false) - expect(res.status).toBe(404) + resolveCancel(true) + const res = await pending + expect(res.status).toBe(204) }) - it('DELETE stops an in-flight generation', async () => { - fake.setGenerateImpl(() => new Promise(() => {})) + it('DELETE rejects an invalid source with 400', async () => { + const res = await app.request(`/change-walkthroughs/${SESSION_ID}?source=branch:-x`, { method: 'DELETE' }) - const postRes = await app.request(`/change-walkthroughs/${SESSION_ID}`, { method: 'POST' }) - expect(postRes.status).toBe(202) + expect(res.status).toBe(400) + expect(cancelGeneration).not.toHaveBeenCalled() + }) - const res = await app.request(`/change-walkthroughs/${SESSION_ID}`, { method: 'DELETE' }) - expect(res.status).toBe(204) + it('POST refine returns 202 and forwards the text', async () => { + const res = await app.request(`/change-walkthroughs/${SESSION_ID}/refine`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text: 'Explain the risk section' }), + }) - const getRes = await app.request(`/change-walkthroughs/${SESSION_ID}`) - const state = (await getRes.json()) as { generating: boolean; error: unknown } - expect(state.generating).toBe(false) - expect(state.error).toBeNull() + expect(res.status).toBe(202) + await expect(res.json()).resolves.toMatchObject({ generating: true }) + expect(refine).toHaveBeenCalledWith(SESSION_ID, { text: 'Explain the risk section' }) }) - it('resolves a pending POST with the stopped state after DELETE cancels it', async () => { - const diff = createDeferred() - const diffMock = vi.mocked(fake.client.api.session.diff) - diffMock.mockImplementationOnce(() => diff.promise) - - const postPromise = app.request(`/change-walkthroughs/${SESSION_ID}`, { + it('POST refine forwards the requested source', async () => { + await app.request(`/change-walkthroughs/${SESSION_ID}/refine`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({}), + body: JSON.stringify({ source: { kind: 'staged' }, text: 'more' }), }) - await vi.waitFor(() => expect(diffMock).toHaveBeenCalledTimes(1)) - - const deleteRes = await app.request(`/change-walkthroughs/${SESSION_ID}`, { method: 'DELETE' }) - expect(deleteRes.status).toBe(204) + expect(refine).toHaveBeenCalledWith(SESSION_ID, { source: { kind: 'staged' }, text: 'more' }) + }) - diff.resolve(CHANGES) + it('POST refine rejects empty text with 400', async () => { + const res = await app.request(`/change-walkthroughs/${SESSION_ID}/refine`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text: ' ' }), + }) - const postRes = await postPromise - expect(postRes.status).toBe(200) - const body = (await postRes.json()) as { generating: boolean; walkthrough: unknown; error: unknown } - expect(body.generating).toBe(false) - expect(body.walkthrough).toBeNull() - expect(body.error).toBeNull() + expect(res.status).toBe(400) + expect(refine).not.toHaveBeenCalled() }) - it('DELETE is a no-op when nothing is generating', async () => { - const res = await app.request(`/change-walkthroughs/${SESSION_ID}`, { method: 'DELETE' }) + it('POST refine rejects an empty body with 400', async () => { + const res = await app.request(`/change-walkthroughs/${SESSION_ID}/refine`, { method: 'POST' }) - expect(res.status).toBe(204) + expect(res.status).toBe(400) + expect(refine).not.toHaveBeenCalled() }) - it('DELETE rejects an invalid source with 400', async () => { - const res = await app.request(`/change-walkthroughs/${SESSION_ID}?source=branch:-x`, { method: 'DELETE' }) + it('POST refine maps a service error', async () => { + refine.mockRejectedValueOnce( + new ChangeWalkthroughError('The walkthrough is still being built', 409, { code: 'WALKTHROUGH_BUSY' }), + ) - expect(res.status).toBe(400) + const res = await app.request(`/change-walkthroughs/${SESSION_ID}/refine`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text: 'more' }), + }) + + expect(res.status).toBe(409) + await expect(res.json()).resolves.toMatchObject({ code: 'WALKTHROUGH_BUSY' }) }) }) diff --git a/backend/test/routes/internal-change-walkthroughs.test.ts b/backend/test/routes/internal-change-walkthroughs.test.ts index 98c6eb08a..c5e234550 100644 --- a/backend/test/routes/internal-change-walkthroughs.test.ts +++ b/backend/test/routes/internal-change-walkthroughs.test.ts @@ -79,6 +79,6 @@ describe('internal/change-walkthroughs routes', () => { }) expect(res.status).toBe(200) - expect(getState).toHaveBeenCalledWith(SESSION_ID, { kind: 'staged' }, undefined) + expect(getState).toHaveBeenCalledWith(SESSION_ID, { kind: 'staged' }) }) }) diff --git a/backend/test/routes/internal-walkthrough-sessions.test.ts b/backend/test/routes/internal-walkthrough-sessions.test.ts new file mode 100644 index 000000000..94f9ad8f2 --- /dev/null +++ b/backend/test/routes/internal-walkthrough-sessions.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { Hono } from 'hono' +import { Database } from 'bun:sqlite' +import { allMigrations } from '../../src/db/migrations' +import { getOrCreateInternalToken } from '../../src/services/internal-token' +import { migrate } from '../../src/db/migration-runner' +import type { ChangeWalkthroughService } from '../../src/services/change-walkthroughs' +import { ChangeWalkthroughError } from '../../src/services/change-walkthrough-error' +import { createInternalTestApp } from '../helpers/internal-test-app' + +const SESSION_ID = 'ses_walk' + +describe('internal/walkthrough-sessions routes', () => { + let db: Database + let app: Hono + let token: string + let readChangesForWalkthroughSession: ReturnType + let publishFromWalkthroughSession: ReturnType + + beforeEach(() => { + db = new Database(':memory:') + migrate(db, allMigrations) + + readChangesForWalkthroughSession = vi.fn(async () => '## Outline') + publishFromWalkthroughSession = vi.fn(async () => ({ path: '/workspace/walkthroughs/ses_walk/gen/index.html' })) + + app = new Hono() + app.route( + '/api/internal', + createInternalTestApp(db, { + changeWalkthroughService: { + readChangesForWalkthroughSession, + publishFromWalkthroughSession, + } as unknown as ChangeWalkthroughService, + }), + ) + token = getOrCreateInternalToken(db) + }) + + it('POST /api/internal/walkthrough-sessions/:id/changes returns 401 without a bearer token', async () => { + const res = await app.request(`/api/internal/walkthrough-sessions/${SESSION_ID}/changes`, { method: 'POST' }) + + expect(res.status).toBe(401) + expect(readChangesForWalkthroughSession).not.toHaveBeenCalled() + }) + + it('POST /changes returns the outline with an empty body', async () => { + const res = await app.request(`/api/internal/walkthrough-sessions/${SESSION_ID}/changes`, { + method: 'POST', + headers: { authorization: `Bearer ${token}` }, + }) + + expect(res.status).toBe(200) + await expect(res.json()).resolves.toEqual({ text: '## Outline' }) + expect(readChangesForWalkthroughSession).toHaveBeenCalledWith(SESSION_ID, undefined) + }) + + it('POST /changes forwards the requested file', async () => { + readChangesForWalkthroughSession.mockResolvedValueOnce('diff') + + const res = await app.request(`/api/internal/walkthrough-sessions/${SESSION_ID}/changes`, { + method: 'POST', + headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, + body: JSON.stringify({ file: 'src/a.ts' }), + }) + + expect(res.status).toBe(200) + await expect(res.json()).resolves.toEqual({ text: 'diff' }) + expect(readChangesForWalkthroughSession).toHaveBeenCalledWith(SESSION_ID, 'src/a.ts') + }) + + it('POST /changes maps a service error', async () => { + readChangesForWalkthroughSession.mockRejectedValueOnce( + new ChangeWalkthroughError('This session is not a walkthrough session', 404, { code: 'WALKTHROUGH_NOT_A_WALKTHROUGH_SESSION' }), + ) + + const res = await app.request(`/api/internal/walkthrough-sessions/${SESSION_ID}/changes`, { + method: 'POST', + headers: { authorization: `Bearer ${token}` }, + }) + + expect(res.status).toBe(404) + await expect(res.json()).resolves.toMatchObject({ + error: 'This session is not a walkthrough session', + code: 'WALKTHROUGH_NOT_A_WALKTHROUGH_SESSION', + }) + }) + + it('POST /publish returns the artifact path', async () => { + const res = await app.request(`/api/internal/walkthrough-sessions/${SESSION_ID}/publish`, { + method: 'POST', + headers: { authorization: `Bearer ${token}` }, + }) + + expect(res.status).toBe(200) + await expect(res.json()).resolves.toEqual({ path: '/workspace/walkthroughs/ses_walk/gen/index.html' }) + expect(publishFromWalkthroughSession).toHaveBeenCalledWith(SESSION_ID) + }) + + it('POST /publish maps a service error', async () => { + publishFromWalkthroughSession.mockRejectedValueOnce( + new ChangeWalkthroughError('This session is not a walkthrough session', 404, { code: 'WALKTHROUGH_NOT_A_WALKTHROUGH_SESSION' }), + ) + + const res = await app.request(`/api/internal/walkthrough-sessions/${SESSION_ID}/publish`, { + method: 'POST', + headers: { authorization: `Bearer ${token}` }, + }) + + expect(res.status).toBe(404) + await expect(res.json()).resolves.toMatchObject({ error: 'This session is not a walkthrough session' }) + }) +}) diff --git a/backend/test/services/change-walkthroughs.test.ts b/backend/test/services/change-walkthroughs.test.ts index b7f79ad00..0acb59131 100644 --- a/backend/test/services/change-walkthroughs.test.ts +++ b/backend/test/services/change-walkthroughs.test.ts @@ -1,20 +1,15 @@ import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import path from 'node:path' import { Database } from 'bun:sqlite' -import type { FileDiffInfo, ModelInfo, ModelRef, SessionInfo, SessionMessageInfo } from '@opencode-manager/shared/opencode' +import type { FileDiffInfo, ModelInfo, SessionInfo, SessionMessageInfo } from '@opencode-manager/shared/opencode' +import { WALKTHROUGH_GENERATION_TIMEOUT_MS, type SchedulePermissionRule } from '@opencode-manager/shared/schemas' import { - MECHANICAL_TEXT_BUDGET, - WALKTHROUGH_DIFF_MAX_CHARS, - WALKTHROUGH_HUNK_MAX_CHARS, - WALKTHROUGH_MAX_STOPS, - WALKTHROUGH_OUTLINE_LINE_MAX_CHARS, - WALKTHROUGH_OUTLINE_PREVIEW_LINES, - WALKTHROUGH_TEXT_MAX_CHARS, - type WalkthroughHunk, -} from '@opencode-manager/shared/schemas' -import { getChangeWalkthrough, saveChangeWalkthrough, deleteChangeWalkthroughs } from '../../src/db/change-walkthroughs' + claimChangeWalkthroughGeneration, + getChangeWalkthrough, + updateChangeWalkthrough, +} from '../../src/db/change-walkthroughs' import { migrate } from '../../src/db/migration-runner' import { allMigrations } from '../../src/db/migrations' import type { OpenCodeClient } from '../../src/services/opencode/client' @@ -25,51 +20,13 @@ import { stubLoadedModelCatalog } from '../helpers/stub-opencode-client' import { createCommittedRepo, createGitAuthService, createOrigin, cloneOrigin, git, uniqueName } from '../helpers/git-fixtures' import { ChangeWalkthroughService, - WalkthroughCancelledError, - buildWalkthroughInput, - buildWalkthroughOutline, - buildWalkthroughPlanPrompt, - buildWalkthroughPrompt, - computeChangesHash, - computeHunkId, - computeStopId, - fitsSingleCall, - formatHunkOutline, - isMechanicalChangePath, - parseWalkthroughPlan, - parseWalkthroughResponse, + buildWalkthroughSessionPermissions, + buildWalkthroughSessionPrompt, } from '../../src/services/change-walkthroughs' -import { ChangeWalkthroughError } from '../../src/services/change-walkthrough-error' +import { computeChangesHash } from '../../src/services/walkthrough-outline' const SESSION_ID = 'ses_walkthrough' -function abortable(promise: Promise, signal?: AbortSignal): Promise { - if (!signal) return promise - if (signal.aborted) return Promise.reject(new DOMException('Aborted', 'AbortError')) - return new Promise((resolve, reject) => { - const onAbort = () => reject(new DOMException('Aborted', 'AbortError')) - signal.addEventListener('abort', onAbort, { once: true }) - promise.then( - (value) => { - signal.removeEventListener('abort', onAbort) - resolve(value) - }, - (error) => { - signal.removeEventListener('abort', onAbort) - reject(error) - }, - ) - }) -} - -function createDeferred(): { promise: Promise; resolve: (value: T) => void } { - let resolve!: (value: T) => void - const promise = new Promise((res) => { - resolve = res - }) - return { promise, resolve } -} - function userMessage(id: string): SessionMessageInfo { return { id, type: 'user', time: { created: 0 }, text: id } as SessionMessageInfo } @@ -82,101 +39,24 @@ function hunkPatch(line: number): string { return `@@ -${line},2 +${line},2 @@\n-const a${line} = 1;\n+const a${line} = 2;` } -function bigHunk(index: number, size = WALKTHROUGH_HUNK_MAX_CHARS): string { - return `@@ -${index},1 +${index},1 @@\n+${'x'.repeat(size)}_${index}` -} - -function longHunk(index: number): string { - const lines = Array.from( - { length: 6 }, - (_, line) => `+${'x'.repeat(200)}_${index}_${line}`, - ) - return `@@ -${index + 1},6 +${index + 1},6 @@\n${lines.join('\n')}` -} - -function longPatch(count: number, offset = 0): string { - return Array.from({ length: count }, (_, index) => longHunk(offset + index)).join('\n') -} - -function modelReply( - stops: Array<{ title: string; explanation: string; hunkIds: string[] }>, - summary = 'Summary', -): string { - return JSON.stringify({ summary, stops }) -} - -function planReply( - stops: Array<{ title: string; hunkIds: string[] }>, - summary = 'Summary', -): string { - return JSON.stringify({ summary, stops }) -} - -function hunkFixture(file: string, text: string, status: FileDiffInfo['status'] = 'modified'): WalkthroughHunk { - return { - id: computeHunkId(file, status, text), - file, - status, - header: text.split('\n')[0] ?? '', - text, - truncated: false, - } -} - const THREE_HUNKS: FileDiffInfo[] = [ change('src/a.ts', `${hunkPatch(1)}\n${hunkPatch(10)}`), change('src/b.ts', hunkPatch(1)), ] -const THREE_HUNK_IDS = [ - computeHunkId('src/a.ts', 'modified', hunkPatch(1)), - computeHunkId('src/a.ts', 'modified', hunkPatch(10)), - computeHunkId('src/b.ts', 'modified', hunkPatch(1)), -] - -const PLAN_MARKER = 'Assign every hunk id' -const EXPLANATION_REPLY = JSON.stringify({ explanation: 'Why' }) - -const LARGE_CHANGES: FileDiffInfo[] = [ - change('src/a.ts', [0, 1, 2, 3].map((index) => bigHunk(index)).join('\n')), - change('src/b.ts', [10, 11, 12, 13].map((index) => bigHunk(index)).join('\n')), - change('src/c.ts', [20, 21, 22, 23].map((index) => bigHunk(index)).join('\n')), -] - -const LARGE_PLAN_REPLY = planReply([ - { title: 'A', hunkIds: [0, 1, 2, 3].map((index) => computeHunkId('src/a.ts', 'modified', bigHunk(index))) }, - { title: 'B', hunkIds: [10, 11, 12, 13].map((index) => computeHunkId('src/b.ts', 'modified', bigHunk(index))) }, - { title: 'C', hunkIds: [20, 21, 22, 23].map((index) => computeHunkId('src/c.ts', 'modified', bigHunk(index))) }, -]) - -const EDITED_A_HUNKS = [0, 1, 2, 3].map((index) => bigHunk(index, WALKTHROUGH_HUNK_MAX_CHARS - 10)) - -const EDITED_LARGE_CHANGES: FileDiffInfo[] = [ - change('src/a.ts', EDITED_A_HUNKS.join('\n')), - change('src/b.ts', [10, 11, 12, 13].map((index) => bigHunk(index)).join('\n')), - change('src/c.ts', [20, 21, 22, 23].map((index) => bigHunk(index)).join('\n')), -] - -const EDITED_LARGE_PLAN_REPLY = planReply([ - { title: 'A', hunkIds: EDITED_A_HUNKS.map((hunk) => computeHunkId('src/a.ts', 'modified', hunk)) }, - { title: 'B', hunkIds: [10, 11, 12, 13].map((index) => computeHunkId('src/b.ts', 'modified', bigHunk(index))) }, - { title: 'C', hunkIds: [20, 21, 22, 23].map((index) => computeHunkId('src/c.ts', 'modified', bigHunk(index))) }, -]) - -function explanationFor(prompt: string): string { - const title = prompt.match(/## Stop: (.+)/)?.[1] ?? '' - return JSON.stringify({ explanation: `explained-${title}` }) -} - interface FakeSession { info?: SessionInfo | Error changes?: FileDiffInfo[] | Error } function createFakeClient(sessions: Record) { - const generateCalls: string[] = [] - const generateModels: Array = [] - let generateImpl: (prompt: string) => Promise = async () => modelReply([]) + const createCalls: Array> = [] + const promptCalls: Array<{ sessionID: string; text: string }> = [] + const interruptCalls: string[] = [] + let createSeq = 0 + let createImpl: (input: Record) => Promise<{ id: string; title?: string }> = async () => ({ + id: `ses_walk_${++createSeq}`, + }) const client = { api: { @@ -194,7 +74,11 @@ function createFakeClient(sessions: Record) { if (config.info instanceof Error) { throw config.info } - return config.info ?? ({ id: sessionID, title: `Title ${sessionID}` } as SessionInfo) + return config.info ?? ({ + id: sessionID, + title: `Title ${sessionID}`, + location: { directory: '/abs/repo' }, + } as SessionInfo) }), diff: vi.fn(async ({ sessionID }: { sessionID: string }) => { const config = sessions[sessionID] @@ -203,6 +87,16 @@ function createFakeClient(sessions: Record) { } return config?.changes ?? [] }), + create: vi.fn(async (input: Record) => { + createCalls.push(input) + return createImpl(input) + }), + prompt: vi.fn(async (input: { sessionID: string; text: string }) => { + promptCalls.push(input) + }), + interrupt: vi.fn(async ({ sessionID }: { sessionID: string }) => { + interruptCalls.push(sessionID) + }), }, message: { list: vi.fn(async ({ sessionID }: { sessionID: string }) => { @@ -212,23 +106,17 @@ function createFakeClient(sessions: Record) { return { data: [userMessage('msg-1')], cursor: {} } }), }, - generate: { - text: vi.fn(async (input: { prompt: string; model?: ModelRef }, options?: { signal?: AbortSignal }) => { - generateCalls.push(input.prompt) - generateModels.push(input.model) - return { text: await abortable(generateImpl(input.prompt), options?.signal) } - }), - }, }, forwardRaw: vi.fn(), } as unknown as OpenCodeClient return { client, - generateCalls, - generateModels, - setGenerateImpl: (impl: (prompt: string) => Promise) => { - generateImpl = impl + createCalls, + promptCalls, + interruptCalls, + setCreateImpl: (impl: (input: Record) => Promise<{ id: string; title?: string }>) => { + createImpl = impl }, } } @@ -250,9 +138,11 @@ function sessionEvent(type: string, sessionID: string): SSEEvent { } const walkthroughRepoRoot = mkdtempSync(path.join(tmpdir(), 'walkthrough-service-')) +const walkthroughsRoot = mkdtempSync(path.join(tmpdir(), 'walkthroughs-root-')) afterAll(() => { rmSync(walkthroughRepoRoot, { recursive: true, force: true }) + rmSync(walkthroughsRoot, { recursive: true, force: true }) }) function createChangedRepo(): string { @@ -291,2178 +181,832 @@ function sessionAt(directory: string, id: string = SESSION_ID): SessionInfo { return { id, title: 'Title', location: { directory } } as SessionInfo } -function replyForPrompt(prompt: string): string { - const ids = [...prompt.matchAll(/^### (h_\S+) /gm)].map((match) => match[1]!) - return modelReply([{ title: 'A', explanation: 'x', hunkIds: ids }]) +function outputDirFromPermissions(permissions: SchedulePermissionRule[]): string { + const rule = permissions.at(-2)! + return rule.resource.slice(0, -'/*'.length) } -describe('computeChangesHash', () => { - it('is stable for identical changes', () => { - const changes = [change('src/a.ts', hunkPatch(1))] - expect(computeChangesHash(changes)).toBe(computeChangesHash(changes)) - }) +function artifactPathFor(call: Record): string { + const outputDir = outputDirFromPermissions(call.permissions as SchedulePermissionRule[]) + return path.join(outputDir, 'index.html') +} - it('changes when any field changes', () => { - const base = change('src/a.ts', hunkPatch(1)) - expect(computeChangesHash([base])).not.toBe(computeChangesHash([change('src/a.ts', hunkPatch(2))])) - expect(computeChangesHash([base])).not.toBe(computeChangesHash([change('src/b.ts', hunkPatch(1))])) - expect(computeChangesHash([base])).not.toBe( - computeChangesHash([change('src/a.ts', hunkPatch(1), 'added')]), - ) - }) +function writeArtifact(call: Record): string { + const artifactPath = artifactPathFor(call) + writeFileSync(artifactPath, 'walkthrough') + return artifactPath +} - it('is order sensitive', () => { - const a = change('src/a.ts', hunkPatch(1)) - const b = change('src/b.ts', hunkPatch(1)) - expect(computeChangesHash([a, b])).not.toBe(computeChangesHash([b, a])) - }) -}) +describe('buildWalkthroughSessionPermissions', () => { + it('denies edits everywhere, allows the output directory, and keeps the unattended baseline', () => { + const ruleset = buildWalkthroughSessionPermissions('/tmp/out/dir') -describe('isMechanicalChangePath', () => { - it('matches lockfiles by basename', () => { - const lockfiles = [ - 'pnpm-lock.yaml', - 'package-lock.json', - 'npm-shrinkwrap.json', - 'yarn.lock', - 'bun.lock', - 'bun.lockb', - 'Cargo.lock', - 'Gemfile.lock', - 'composer.lock', - 'poetry.lock', - 'uv.lock', - 'Pipfile.lock', - 'go.sum', - 'flake.lock', - 'deep/nested/pnpm-lock.yaml', - ] - expect(lockfiles.every(isMechanicalChangePath)).toBe(true) + expect(ruleset).toContainEqual({ action: 'edit', resource: '*', effect: 'deny' }) + expect(ruleset).toContainEqual({ action: 'external_directory', resource: '*', effect: 'deny' }) + expect(ruleset.at(-2)).toEqual({ action: 'external_directory', resource: '/tmp/out/dir/*', effect: 'allow' }) + expect(ruleset.at(-1)).toEqual({ action: 'edit', resource: '/tmp/out/dir/*', effect: 'allow' }) }) - it('matches generated suffixes and snapshot directories', () => { - expect(isMechanicalChangePath('src/__snapshots__/a.ts.snap')).toBe(true) - expect(isMechanicalChangePath('src/__snapshots__/a.ts')).toBe(true) - expect(isMechanicalChangePath('dist/bundle.min.js')).toBe(true) - expect(isMechanicalChangePath('dist/app.min.css')).toBe(true) - expect(isMechanicalChangePath('dist/bundle.js.map')).toBe(true) - }) + it('normalizes the output directory', () => { + const ruleset = buildWalkthroughSessionPermissions('/tmp/out/../out/dir/') - it('does not match ordinary source and config files', () => { - expect(isMechanicalChangePath('src/lock.ts')).toBe(false) - expect(isMechanicalChangePath('package.json')).toBe(false) - expect(isMechanicalChangePath('src/snapshots/a.ts')).toBe(false) - expect(isMechanicalChangePath('src/map.ts')).toBe(false) + expect(ruleset.at(-2)!.resource).toBe('/tmp/out/dir/*') }) }) -describe('buildWalkthroughInput', () => { - it('assigns a distinct content id and the file to each hunk', () => { - const { hunks, omittedFiles } = buildWalkthroughInput([ - change('src/a.ts', `${hunkPatch(1)}\n${hunkPatch(10)}`), - change('src/b.ts', hunkPatch(1)), - ]) - - expect(omittedFiles).toEqual([]) - expect(hunks.map((hunk) => hunk.file)).toEqual(['src/a.ts', 'src/a.ts', 'src/b.ts']) - const ids = hunks.map((hunk) => hunk.id) - expect(ids.every((id) => id.startsWith('h_'))).toBe(true) - expect(new Set(ids).size).toBe(3) - }) +describe('buildWalkthroughSessionPrompt', () => { + it('states the role, the data actions, the reading order and the output file', () => { + const prompt = buildWalkthroughSessionPrompt({ + title: 'My session', + sourceDescription: 'the changes in this session', + artifactPath: '/tmp/walkthroughs/ses_1/gen/index.html', + }) - it('keeps a hunk id when an earlier hunk in the file changes', () => { - const before = buildWalkthroughInput([ - change( - 'src/a.ts', - '@@ -1,2 +1,2 @@\n-const a = 1;\n+const a = 2;\n@@ -10,2 +10,2 @@\n-const b = 1;\n+const b = 2;', - ), - ]) - const after = buildWalkthroughInput([ - change( - 'src/a.ts', - '@@ -1,3 +1,3 @@\n-const a = 1;\n+const a = 2;\n+const extra = 3;\n@@ -11,2 +11,2 @@\n-const b = 1;\n+const b = 2;', - ), - ]) - - expect(before.hunks).toHaveLength(2) - expect(after.hunks).toHaveLength(2) - expect(after.hunks[0]!.id).not.toBe(before.hunks[0]!.id) - expect(after.hunks[1]!.id).toBe(before.hunks[1]!.id) + expect(prompt).toContain('My session') + expect(prompt).toContain('the changes in this session') + expect(prompt).toContain('walkthrough_changes') + expect(prompt).toContain('publish_walkthrough') + expect(prompt).toContain('contracts and data model first') + expect(prompt).toContain('self-contained HTML file') + expect(prompt).toContain('/tmp/walkthroughs/ses_1/gen/index.html') }) +}) - it('suffixes duplicate hunk content', () => { - const { hunks } = buildWalkthroughInput([change('src/a.ts', `${hunkPatch(1)}\n${hunkPatch(1)}`)]) +describe('ChangeWalkthroughService', () => { + let db: Database + let sessions: Record + let fake: ReturnType + let service: ChangeWalkthroughService + let gitService: GitService + let fetchRemoteRef: ReturnType - expect(hunks).toHaveLength(2) - expect(hunks[0]!.id).not.toBe(hunks[1]!.id) - expect(hunks[1]!.id).toBe(`${hunks[0]!.id}_2`) + beforeEach(() => { + db = createTestDb() + sessions = { [SESSION_ID]: { changes: THREE_HUNKS, info: sessionAt('/abs/repo') } } + fake = createFakeClient(sessions) + fetchRemoteRef = vi.fn(async () => {}) + gitService = { fetchRemoteRef } as unknown as GitService + service = new ChangeWalkthroughService(db, fake.client, new SettingsService(db), createGitAuthService(), gitService, { + walkthroughsRoot, + }) }) - it('omits files with no hunks as binary', () => { - const { hunks, omittedFiles } = buildWalkthroughInput([ - change('assets/logo.png', 'diff --git a/assets/logo.png b/assets/logo.png\nBinary files differ'), - change('src/a.ts', hunkPatch(1)), - ]) - - expect(hunks).toHaveLength(1) - expect(omittedFiles).toEqual([{ file: 'assets/logo.png', reason: 'binary' }]) + afterEach(() => { + db.close() }) - it('truncates an oversized hunk and marks it', () => { - const longLine = `+${'x'.repeat(WALKTHROUGH_HUNK_MAX_CHARS + 100)}` - const { hunks } = buildWalkthroughInput([change('src/a.ts', `@@ -1 +1 @@\n${longLine}`)]) - - expect(hunks[0]!.truncated).toBe(true) - expect(hunks[0]!.text).toContain('[hunk truncated]') + it('throws 404 for a missing session on GET', async () => { + await expect(service.getState('ses_missing')).rejects.toMatchObject({ status: 404 }) }) - it('separates mechanical hunks from the prompt hunks', () => { - const { hunks, mechanicalHunks, omittedFiles } = buildWalkthroughInput([ - change('src/a.ts', hunkPatch(1)), - change('pnpm-lock.yaml', hunkPatch(2)), - ]) + it('reports no walkthrough and a current hash on GET', async () => { + const state = await service.getState(SESSION_ID) - expect(hunks.map((hunk) => hunk.file)).toEqual(['src/a.ts']) - expect(mechanicalHunks.map((hunk) => hunk.file)).toEqual(['pnpm-lock.yaml']) - expect(omittedFiles).toEqual([]) + expect(state.walkthrough).toBeNull() + expect(state.stale).toBe(false) + expect(state.currentDiffHash).toBe(computeChangesHash(THREE_HUNKS)) + expect(state.generating).toBe(false) + expect(state.error).toBeNull() }) - it('keeps mechanical hunk text within the budget and summarizes every file with counts', () => { - const oversizedPatch = Array.from({ length: 40 }, (_, index) => bigHunk(index)).join('\n') - const { mechanicalHunks, omittedFiles } = buildWalkthroughInput([ - { file: 'pnpm-lock.yaml', patch: oversizedPatch, additions: 1200, deletions: 800, status: 'modified' }, - { file: 'src/__snapshots__/a.ts.snap', patch: hunkPatch(1), additions: 2, deletions: 3, status: 'modified' }, - ]) - - expect(mechanicalHunks.map((hunk) => hunk.file)).toEqual([ - 'pnpm-lock.yaml', - 'src/__snapshots__/a.ts.snap', - ]) - expect(mechanicalHunks.reduce((total, hunk) => total + hunk.text.length, 0)).toBeLessThanOrEqual( - MECHANICAL_TEXT_BUDGET, - ) - expect(mechanicalHunks[0]).toMatchObject({ text: '', truncated: true, additions: 1200, deletions: 800 }) - expect(mechanicalHunks[1]).toMatchObject({ additions: 2, deletions: 3 }) - expect(mechanicalHunks[1]!.text).toContain('@@') - expect(omittedFiles).toEqual([]) - }) + it('creates exactly one walkthrough session and returns generating', async () => { + const state = await service.startGeneration(SESSION_ID, {}) - it('stores mechanical hunk text and counts while within the budget', () => { - const { mechanicalHunks } = buildWalkthroughInput([ - { - file: 'pnpm-lock.yaml', - patch: `${hunkPatch(1)}\n${hunkPatch(2)}`, - additions: 2, - deletions: 2, - status: 'modified', - }, - ]) + expect(fake.createCalls).toHaveLength(1) + const created = fake.createCalls[0]! + expect(created.title).toBe('Walkthrough · Title') + expect(created.model).toEqual({ providerID: 'openai', id: 'gpt-5-mini' }) + expect(created.location).toEqual({ directory: '/abs/repo' }) - expect(mechanicalHunks).toHaveLength(1) - expect(mechanicalHunks[0]!.text).toContain('@@ -1,2 +1,2 @@') - expect(mechanicalHunks[0]!.text).toContain('@@ -2,2 +2,2 @@') - expect(mechanicalHunks[0]!.additions).toBe(2) - expect(mechanicalHunks[0]!.deletions).toBe(2) - }) + const permissions = created.permissions as SchedulePermissionRule[] + const outputDir = outputDirFromPermissions(permissions) + expect(outputDir.startsWith(path.join(walkthroughsRoot, SESSION_ID))).toBe(true) + expect(existsSync(outputDir)).toBe(true) - it('omits a binary mechanical file as binary', () => { - const { mechanicalHunks, omittedFiles } = buildWalkthroughInput([ - change('pnpm-lock.yaml', 'diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml\nBinary files differ'), - ]) + expect(fake.promptCalls).toHaveLength(1) + expect(fake.promptCalls[0]!.sessionID).toBe('ses_walk_1') + expect(fake.promptCalls[0]!.text).toContain(path.join(outputDir, 'index.html')) - expect(mechanicalHunks).toEqual([]) - expect(omittedFiles).toEqual([{ file: 'pnpm-lock.yaml', reason: 'binary' }]) + expect(state.generating).toBe(true) + expect(state.walkthrough?.status).toBe('generating') + expect(state.walkthrough?.walkthroughSessionId).toBe('ses_walk_1') + expect(state.walkthrough?.artifactPath).toBe(path.join(outputDir, 'index.html')) }) - it('labels a pure rename as renamed', () => { - const { omittedFiles } = buildWalkthroughInput([ - change( - 'src/new.ts', - 'diff --git a/src/old.ts b/src/new.ts\nsimilarity index 100%\nrename from src/old.ts\nrename to src/new.ts', - ), - ]) + it('uses the source description in the session title for a git source', async () => { + const repo = createChangedRepo() + sessions[SESSION_ID] = { changes: THREE_HUNKS, info: sessionAt(repo) } + + await service.startGeneration(SESSION_ID, { source: { kind: 'uncommitted' } }) - expect(omittedFiles).toEqual([{ file: 'src/new.ts', reason: 'renamed' }]) + expect(fake.createCalls[0]!.title).toBe('Walkthrough · Title — uncommitted changes') }) - it('labels a mode-only change as modeChange', () => { - const { omittedFiles } = buildWalkthroughInput([ - change('script.sh', 'diff --git a/script.sh b/script.sh\nold mode 100644\nnew mode 100755'), - ]) + it('does not create another session while one is generating', async () => { + await service.startGeneration(SESSION_ID, {}) + const second = await service.startGeneration(SESSION_ID, {}) - expect(omittedFiles).toEqual([{ file: 'script.sh', reason: 'modeChange' }]) + expect(fake.createCalls).toHaveLength(1) + expect(second.generating).toBe(true) }) -}) -describe('buildWalkthroughOutline', () => { - const smallChange = (index: number) => change(`src/f${index}.ts`, hunkPatch(index)) + it('returns the ready record without a new session when the diff is unchanged', async () => { + await service.startGeneration(SESSION_ID, {}) + updateChangeWalkthrough(db, SESSION_ID, 'session', ['generating'], { + status: 'ready', + publishedAt: 1, + updatedAt: Date.now(), + }) - it('fits 300 small files and lists every hunk id', () => { - const input = buildWalkthroughInput(Array.from({ length: 300 }, (_, index) => smallChange(index))) - const { hunks, omittedFiles, outline } = buildWalkthroughOutline(input, 'My session') + const state = await service.startGeneration(SESSION_ID, {}) - expect(omittedFiles).toEqual([]) - expect(hunks).toHaveLength(300) - for (const hunk of input.hunks) { - expect(outline).toContain(hunk.id) - } - expect(fitsSingleCall(input, 'My session')).toBe(true) - expect( - buildWalkthroughPlanPrompt({ title: 'My session', outline, previousStops: [] }).length, - ).toBeLessThanOrEqual(WALKTHROUGH_DIFF_MAX_CHARS) + expect(fake.createCalls).toHaveLength(1) + expect(state.generating).toBe(false) + expect(state.walkthrough?.status).toBe('ready') }) - it('omits trailing files as budget and never half-includes a file', () => { - const input = buildWalkthroughInput( - Array.from({ length: 400 }, (_, index) => change(`src/f${index}.ts`, longPatch(3, index * 3))), - ) - const { omittedFiles, outline } = buildWalkthroughOutline(input, 'My session') - - expect(omittedFiles.some((file) => file.reason === 'budget')).toBe(true) - - const idsByFile = new Map() - for (const hunk of input.hunks) { - const ids = idsByFile.get(hunk.file) ?? [] - ids.push(hunk.id) - idsByFile.set(hunk.file, ids) - } + it('spawns a new session and generation directory when regenerate is set', async () => { + await service.startGeneration(SESSION_ID, {}) + const firstDir = outputDirFromPermissions(fake.createCalls[0]!.permissions as SchedulePermissionRule[]) + updateChangeWalkthrough(db, SESSION_ID, 'session', ['generating'], { + status: 'ready', + publishedAt: 1, + updatedAt: Date.now(), + }) - const includedFiles = new Set() - for (const [file, ids] of idsByFile) { - const present = ids.filter((id) => outline.includes(id)).length - expect(present === 0 || present === ids.length).toBe(true) - if (present > 0) { - includedFiles.add(file) - } - } + await service.startGeneration(SESSION_ID, { regenerate: true }) - expect(omittedFiles.map((file) => file.file)).toEqual( - [...idsByFile.keys()].filter((file) => !includedFiles.has(file)), - ) + expect(fake.createCalls).toHaveLength(2) + const secondDir = outputDirFromPermissions(fake.createCalls[1]!.permissions as SchedulePermissionRule[]) + expect(secondDir).not.toBe(firstDir) + expect(existsSync(secondDir)).toBe(true) }) - it('falls back to header-only outlines when a single file does not fit at the default preview depth', () => { - const input = buildWalkthroughInput([change('src/huge.ts', longPatch(100))]) - const { hunks, omittedFiles, outline } = buildWalkthroughOutline(input, 'My session') + it('spawns a new session when the diff changed', async () => { + await service.startGeneration(SESSION_ID, {}) + updateChangeWalkthrough(db, SESSION_ID, 'session', ['generating'], { + status: 'ready', + publishedAt: 1, + updatedAt: Date.now(), + }) + sessions[SESSION_ID]!.changes = [change('src/a.ts', hunkPatch(99))] + + await service.startGeneration(SESSION_ID, {}) - expect(hunks).toHaveLength(100) - expect(omittedFiles).toEqual([]) - for (const hunk of input.hunks) { - expect(outline).toContain(hunk.id) - } + expect(fake.createCalls).toHaveLength(2) }) - it('keeps mechanical hunks out of the outline', () => { - const input = buildWalkthroughInput([ - change('src/a.ts', hunkPatch(1)), - change('pnpm-lock.yaml', hunkPatch(2)), - ]) - const { omittedFiles, outline } = buildWalkthroughOutline(input, 'My session') - - expect(input.mechanicalHunks).toHaveLength(1) - expect(outline).not.toContain(input.mechanicalHunks[0]!.id) - expect(outline).not.toContain('pnpm-lock.yaml') - expect(omittedFiles).toEqual([]) - }) + it('rejects with 409 and creates no session when there are no changes', async () => { + sessions[SESSION_ID]!.changes = [] - it('reserves the title in the budget so a long title admits less content', () => { - const input = buildWalkthroughInput(Array.from({ length: 800 }, (_, index) => smallChange(index))) - const longTitle = 'T'.repeat(20_000) - - const withLongTitle = buildWalkthroughOutline(input, longTitle) - const withShortTitle = buildWalkthroughOutline(input, 'short') - - expect(withShortTitle.omittedFiles).toEqual([]) - expect(withLongTitle.hunks.length).toBeLessThan(withShortTitle.hunks.length) - expect( - buildWalkthroughPlanPrompt({ - title: longTitle, - outline: withLongTitle.outline, - previousStops: [], - }).length, - ).toBeLessThanOrEqual(WALKTHROUGH_DIFF_MAX_CHARS) + await expect(service.startGeneration(SESSION_ID, {})).rejects.toMatchObject({ + status: 409, + code: 'WALKTHROUGH_NO_CHANGES', + }) + expect(fake.createCalls).toHaveLength(0) }) - it('bounds the included hunks and omits the remaining files as budget', () => { - const input = buildWalkthroughInput( - Array.from({ length: 200 }, (_, index) => change(`src/f${index}.ts`, longPatch(6, index * 6))), - ) - const { hunks, omittedFiles, outline } = buildWalkthroughOutline(input, 'My session') + it('rejects with 409 and creates no session when there are no text changes', async () => { + sessions[SESSION_ID]!.changes = [ + change('assets/logo.png', 'diff --git a/assets/logo.png b/assets/logo.png\nBinary files differ'), + ] - expect(hunks.length).toBeLessThan(input.hunks.length) - expect(omittedFiles.filter((file) => file.reason === 'budget').length).toBeGreaterThan(0) - expect(omittedFiles.every((file) => file.reason === 'budget')).toBe(true) - expect(outline.length).toBeLessThanOrEqual(WALKTHROUGH_DIFF_MAX_CHARS) + await expect(service.startGeneration(SESSION_ID, {})).rejects.toMatchObject({ + status: 409, + code: 'WALKTHROUGH_NO_TEXT_CHANGES', + }) + expect(fake.createCalls).toHaveLength(0) }) - it('never half-includes a file', () => { - const input = buildWalkthroughInput([ - change('src/small.ts', hunkPatch(1)), - change('src/huge.ts', longPatch(2000)), - ]) - const { outline } = buildWalkthroughOutline(input, 'My session') + it('marks the record failed when session.create fails and reports the error', async () => { + fake.setCreateImpl(async () => { + throw new Error('create failed') + }) - const hugeIds = input.hunks.filter((hunk) => hunk.file === 'src/huge.ts').map((hunk) => hunk.id) - expect(hugeIds.length).toBeGreaterThan(0) - expect(hugeIds.every((id) => !outline.includes(id))).toBe(true) - }) + await expect(service.startGeneration(SESSION_ID, {})).rejects.toMatchObject({ + status: 502, + code: 'WALKTHROUGH_SESSION_START_FAILED', + }) - it('keeps the outline within budget for many small hunks behind a very long path', () => { - const longPath = `src/${'p'.repeat(996)}.ts` - const manyHunks = Array.from( - { length: 300 }, - (_, index) => `@@ -${index + 1} +${index + 1} @@\n+line${index}`, - ).join('\n') - const input = buildWalkthroughInput([ - change('src/small.ts', hunkPatch(1)), - change(longPath, manyHunks), - ]) - const { hunks, omittedFiles, outline } = buildWalkthroughOutline(input, 'My session') - - expect(hunks.length).toBeGreaterThan(0) - expect(omittedFiles).toEqual([{ file: longPath, reason: 'budget' }]) - expect( - buildWalkthroughPlanPrompt({ title: 'My session', outline, previousStops: [] }).length, - ).toBeLessThanOrEqual(WALKTHROUGH_DIFF_MAX_CHARS) + const state = await service.getState(SESSION_ID) + expect(state.generating).toBe(false) + expect(state.error).toMatchObject({ message: 'create failed' }) }) - it('retries at a shallower preview depth so every file fits when the single call does not', () => { - const input = buildWalkthroughInput( - Array.from({ length: 70 }, (_, index) => change(`src/f${index}.ts`, longPatch(1, index))), - ) - - expect(fitsSingleCall(input, 'My session')).toBe(false) + it('marks the record failed when the prompt fails and reports the error', async () => { + vi.mocked(fake.client.api.session.prompt).mockRejectedValueOnce(new Error('prompt failed')) - const { hunks, omittedFiles } = buildWalkthroughOutline(input, 'My session') + await expect(service.startGeneration(SESSION_ID, {})).rejects.toMatchObject({ + status: 502, + code: 'WALKTHROUGH_SESSION_START_FAILED', + }) - expect(omittedFiles).toEqual([]) - expect(hunks).toHaveLength(70) + const state = await service.getState(SESSION_ID) + expect(state.walkthrough?.status).toBe('failed') + expect(state.error).toMatchObject({ message: 'prompt failed' }) }) - it('reserves room for the previous-stops hint', () => { - const input = buildWalkthroughInput( - Array.from({ length: 1000 }, (_, index) => smallChange(index)), - ) - const previousStops = [{ title: 'A'.repeat(5000), hunkIds: [input.hunks[0]!.id] }] - const { outline } = buildWalkthroughOutline(input, 'My session', previousStops) - const prompt = buildWalkthroughPlanPrompt({ title: 'My session', outline, previousStops }) - - expect(prompt).toContain('Previous stops') - expect(prompt.length).toBeLessThanOrEqual(WALKTHROUGH_DIFF_MAX_CHARS) - }) -}) + it('removes the generation directory and interrupts the session when the prompt fails', async () => { + vi.mocked(fake.client.api.session.prompt).mockRejectedValueOnce(new Error('prompt failed')) -describe('formatHunkOutline', () => { - it('renders the id, file, status, header and change counts', () => { - const hunk = hunkFixture('src/a.ts', '@@ -1,2 +1,2 @@\n-const a = 1;\n+const a = 2;') - const [first] = formatHunkOutline(hunk).split('\n') + await expect(service.startGeneration(SESSION_ID, {})).rejects.toMatchObject({ status: 502 }) - expect(first).toBe(`### ${hunk.id} src/a.ts (modified) @@ -1,2 +1,2 @@ +1 -1`) + const outputDir = outputDirFromPermissions(fake.createCalls[0]!.permissions as SchedulePermissionRule[]) + expect(existsSync(outputDir)).toBe(false) + expect(fake.interruptCalls).toContain('ses_walk_1') }) - it('previews changed lines and counts content lines that start with -- or ++', () => { - const hunk = hunkFixture( - 'src/a.ts', - '@@ -1,4 +1,4 @@\n--- a/x\n+++ b/x\n-const a = 1;\n+const a = 2;\n-const b = 1;\n+const b = 2;', - ) + it('rejects with 502 when reading changes fails', async () => { + sessions[SESSION_ID]!.changes = new Error('diff failed') - const single = formatHunkOutline(hunk, 1).split('\n') - expect(single).toHaveLength(2) - expect(single[1]).toBe('--- a/x') - - const full = formatHunkOutline(hunk).split('\n') - expect(full).toContain('--- a/x') - expect(full).toContain('+++ b/x') - expect(full).toContain('-const a = 1;') - expect(full).toContain('+const a = 2;') - expect(full).toContain('-const b = 1;') - expect(full).toContain('+const b = 2;') - expect(full[0]).toContain('+3 -3') + await expect(service.startGeneration(SESSION_ID, {})).rejects.toMatchObject({ + status: 502, + code: 'WALKTHROUGH_CHANGES_UNAVAILABLE', + }) + expect(fake.createCalls).toHaveLength(0) }) - it('defaults to the shared preview line count', () => { - const text = `@@ -1,8 +1,8 @@\n${Array.from({ length: 8 }, (_, index) => `+line${index}`).join('\n')}` - const hunk = hunkFixture('src/a.ts', text) - - expect(formatHunkOutline(hunk).split('\n')).toHaveLength(1 + WALKTHROUGH_OUTLINE_PREVIEW_LINES) - }) + it('resolves the walkthrough model preference over the request and session model', async () => { + new SettingsService(db).updateSettings({ walkthroughModel: 'openai/gpt-5' }) + sessions[SESSION_ID]!.info = { + id: SESSION_ID, + title: 'Title', + model: { providerID: 'anthropic', id: 'claude-sonnet-4' }, + location: { directory: '/abs/repo' }, + } as SessionInfo - it('truncates a long changed line to the line max', () => { - const hunk = hunkFixture('src/a.ts', `@@ -1 +1 @@\n+${'x'.repeat(200)}`) - const line = formatHunkOutline(hunk).split('\n')[1]! + await service.startGeneration(SESSION_ID, { model: 'openai/gpt-5-mini' }) - expect(line).toHaveLength(WALKTHROUGH_OUTLINE_LINE_MAX_CHARS) + expect(fake.createCalls[0]!.model).toEqual({ providerID: 'openai', id: 'gpt-5' }) }) -}) -describe('buildWalkthroughPlanPrompt', () => { - const outline = '### h_1 src/a.ts (modified) @@ -1 +1 @@ +1 -1\n+const a = 2;' + it('uses the request model over the session model', async () => { + sessions[SESSION_ID]!.info = { + id: SESSION_ID, + title: 'Title', + model: { providerID: 'anthropic', id: 'claude-sonnet-4' }, + location: { directory: '/abs/repo' }, + } as SessionInfo - it('contains the preamble, the plan rules and the outline', () => { - const prompt = buildWalkthroughPlanPrompt({ title: 'My session', outline, previousStops: [] }) + await service.startGeneration(SESSION_ID, { model: 'openai/gpt-5-mini' }) - expect(prompt).toContain('My session') - expect(prompt).toContain('Assign every hunk id') - expect(prompt).toContain(`at most ${WALKTHROUGH_MAX_STOPS} stops`) - expect(prompt).toContain(outline) + expect(fake.createCalls[0]!.model).toEqual({ providerID: 'openai', id: 'gpt-5-mini' }) }) - it('lists previous stops when given', () => { - const prompt = buildWalkthroughPlanPrompt({ - title: 'My session', - outline, - previousStops: [{ title: 'A', hunkIds: ['h_1'] }], - }) + it('caches the current hash until a session execution event', async () => { + await service.getState(SESSION_ID) + await service.getState(SESSION_ID) - expect(prompt).toContain('Previous stops') - expect(prompt).toContain('A: h_1') + expect(vi.mocked(fake.client.api.session.diff)).toHaveBeenCalledTimes(1) }) - it('drops previous stops when they would push the prompt over the diff budget', () => { - const prompt = buildWalkthroughPlanPrompt({ - title: 'My session', - outline, - previousStops: [{ title: 'T'.repeat(WALKTHROUGH_DIFF_MAX_CHARS), hunkIds: ['h_1'] }], - }) + it('recomputes the hash after session.execution.succeeded and reports stale', async () => { + await service.startGeneration(SESSION_ID, {}) - expect(prompt).not.toContain('Previous stops') - expect(prompt).toContain(outline) - expect(prompt.length).toBeLessThanOrEqual(WALKTHROUGH_DIFF_MAX_CHARS) - }) -}) + sessions[SESSION_ID]!.changes = [change('src/a.ts', hunkPatch(99))] + const cached = await service.getState(SESSION_ID) + expect(cached.stale).toBe(false) -describe('parseWalkthroughPlan', () => { - const hunks: WalkthroughHunk[] = [ - hunkFixture('src/a.ts', hunkPatch(1)), - hunkFixture('src/a.ts', hunkPatch(2)), - hunkFixture('src/b.ts', hunkPatch(3)), - ] - const idA0 = hunks[0]!.id - const idA1 = hunks[1]!.id - const idB0 = hunks[2]!.id - - it('drops unknown ids and repeat references, first occurrence wins', () => { - const parsed = parseWalkthroughPlan( - planReply([ - { title: 'A', hunkIds: [idA0, 'nope'] }, - { title: 'B', hunkIds: [idA0, idA1] }, - ]), - hunks, - ) + service.handleEvent(sessionEvent('session.execution.succeeded', SESSION_ID)) - expect(parsed?.stops.map((stop) => stop.hunkIds)).toEqual([[idA0], [idA1], [idB0]]) + const refreshed = await service.getState(SESSION_ID) + expect(refreshed.stale).toBe(true) + expect(refreshed.currentDiffHash).toBe(computeChangesHash([change('src/a.ts', hunkPatch(99))])) }) - it('drops stops left empty', () => { - const parsed = parseWalkthroughPlan( - planReply([ - { title: 'A', hunkIds: ['nope'] }, - { title: 'B', hunkIds: [idA0, idA1, idB0] }, - ]), - hunks, - ) + it('reports a null current hash when changes cannot be read', async () => { + sessions[SESSION_ID]!.changes = new Error('diff failed') - expect(parsed?.stops.map((stop) => stop.title)).toEqual(['B']) + const state = await service.getState(SESSION_ID) + expect(state.currentDiffHash).toBeNull() + expect(state.stale).toBe(false) }) - it('appends unreferenced hunks as a pending remaining stop', () => { - const parsed = parseWalkthroughPlan(planReply([{ title: 'A', hunkIds: [idA0] }]), hunks) - - expect(parsed?.stops.at(-1)).toEqual({ - id: computeStopId([idA1, idB0]), - title: 'Remaining changes', - explanation: '', - hunkIds: [idA1, idB0], - status: 'pending', - explanationKey: null, - }) - }) + it('cancels a generating walkthrough and interrupts its session', async () => { + await service.startGeneration(SESSION_ID, {}) - it('does not append a remaining stop when every hunk is covered', () => { - const parsed = parseWalkthroughPlan( - planReply([{ title: 'A', hunkIds: [idA0, idA1, idB0] }]), - hunks, - ) + expect(await service.cancelGeneration(SESSION_ID)).toBe(true) - expect(parsed?.stops).toHaveLength(1) - expect(parsed?.stops[0]!.title).toBe('A') + const state = await service.getState(SESSION_ID) + expect(state.generating).toBe(false) + expect(state.walkthrough?.status).toBe('cancelled') + expect(fake.interruptCalls).toContain('ses_walk_1') }) - it('caps model stops at 20 and truncates titles', () => { - const manyHunks: WalkthroughHunk[] = Array.from( - { length: WALKTHROUGH_MAX_STOPS + 5 }, - (_, index) => hunkFixture('src/a.ts', hunkPatch(index)), - ) - const stops = manyHunks.map((hunk) => ({ - title: 't'.repeat(WALKTHROUGH_TEXT_MAX_CHARS + 50), - hunkIds: [hunk.id], - })) - const parsed = parseWalkthroughPlan(planReply(stops), manyHunks) - - const modelStops = parsed!.stops.filter((stop) => stop.title !== 'Remaining changes') - expect(modelStops).toHaveLength(WALKTHROUGH_MAX_STOPS) - expect(modelStops[0]!.title.length).toBeLessThanOrEqual(WALKTHROUGH_TEXT_MAX_CHARS) + it('returns false from cancel when nothing is generating', async () => { + expect(await service.cancelGeneration(SESSION_ID)).toBe(false) }) - it('marks model stops pending with an empty explanation', () => { - const parsed = parseWalkthroughPlan( - planReply([ - { title: 'A', hunkIds: [idA0] }, - { title: 'B', hunkIds: [idA1] }, - ]), - hunks, + it('expires a generating record older than the timeout on read', async () => { + const shortTimeout = new ChangeWalkthroughService( + db, + fake.client, + new SettingsService(db), + createGitAuthService(), + gitService, + { walkthroughsRoot, generationTimeoutMs: 50 }, ) + await shortTimeout.startGeneration(SESSION_ID, {}) + updateChangeWalkthrough(db, SESSION_ID, 'session', ['generating'], { + updatedAt: Date.now() - 10_000, + }) - expect(parsed!.stops.every((stop) => stop.status === 'pending')).toBe(true) - expect(parsed!.stops.every((stop) => stop.explanation === '')).toBe(true) - }) + const state = await shortTimeout.getState(SESSION_ID) - it('returns null when the response is unparseable', () => { - expect(parseWalkthroughPlan('not json at all', hunks)).toBeNull() - expect(parseWalkthroughPlan('{not json', hunks)).toBeNull() - expect(parseWalkthroughPlan('{"summary":"x"}', hunks)).toBeNull() + expect(state.generating).toBe(false) + expect(state.error).toMatchObject({ code: 'WALKTHROUGH_TIMEOUT' }) + await vi.waitFor(() => expect(fake.interruptCalls).toContain('ses_walk_1')) }) - it('returns null when no model stop survives', () => { - expect(parseWalkthroughPlan(planReply([{ title: 'A', hunkIds: ['nope'] }]), hunks)).toBeNull() - }) -}) + describe('per-source storage', () => { + it('stores walkthroughs per source', async () => { + const repo = createChangedRepo() + sessions[SESSION_ID] = { changes: THREE_HUNKS, info: sessionAt(repo) } -describe('buildWalkthroughPrompt', () => { - const hunks: WalkthroughHunk[] = [hunkFixture('src/a.ts', hunkPatch(1))] + await service.startGeneration(SESSION_ID, {}) + await service.startGeneration(SESSION_ID, { source: { kind: 'uncommitted' } }) + + const sessionStored = getChangeWalkthrough(db, SESSION_ID, 'session') + const uncommittedStored = getChangeWalkthrough(db, SESSION_ID, 'uncommitted') + expect(sessionStored).not.toBeNull() + expect(uncommittedStored).not.toBeNull() + expect(uncommittedStored!.source).toEqual({ kind: 'uncommitted' }) + }) - it('lists every hunk with its id, file and status in a diff block', () => { - const prompt = buildWalkthroughPrompt({ title: 'My session', hunks }) + it('deletes every source when the session is deleted', async () => { + const repo = createChangedRepo() + sessions[SESSION_ID] = { changes: THREE_HUNKS, info: sessionAt(repo) } - expect(prompt).toContain(`### ${hunks[0]!.id} src/a.ts (modified)`) - expect(prompt).toContain('```diff') - expect(prompt).toContain(hunkPatch(1)) - expect(prompt).toContain('My session') - }) + await service.startGeneration(SESSION_ID, {}) + await service.startGeneration(SESSION_ID, { source: { kind: 'uncommitted' } }) - it('states the stop limit and the response shape', () => { - const prompt = buildWalkthroughPrompt({ title: 'My session', hunks }) + service.handleEvent(sessionEvent('session.deleted', SESSION_ID)) - expect(prompt).toContain(`at most ${WALKTHROUGH_MAX_STOPS} stops`) - expect(prompt).toContain('"stops"') + expect(getChangeWalkthrough(db, SESSION_ID, 'session')).toBeNull() + expect(getChangeWalkthrough(db, SESSION_ID, 'uncommitted')).toBeNull() + }) }) -}) -describe('parseWalkthroughResponse', () => { - const hunks: WalkthroughHunk[] = [ - hunkFixture('src/a.ts', hunkPatch(1)), - hunkFixture('src/a.ts', hunkPatch(2)), - hunkFixture('src/b.ts', hunkPatch(3)), - ] - const idA0 = hunks[0]!.id - const idA1 = hunks[1]!.id - const idB0 = hunks[2]!.id - - it('drops unknown ids and repeat references, first occurrence wins', () => { - const parsed = parseWalkthroughResponse( - modelReply([ - { title: 'A', explanation: 'first', hunkIds: [idA0, 'nope'] }, - { title: 'B', explanation: 'second', hunkIds: [idA0, idA1] }, - ]), - hunks, - ) + describe('pull request sources', () => { + it('fetches the refs during generation and deletes them with the session', async () => { + const repo = createPullRequestRepo() + sessions[SESSION_ID] = { changes: THREE_HUNKS, info: sessionAt(repo) } + fetchRemoteRef.mockImplementation(async (target: { fullPath: string }, remote: string, refspecs: string[]) => { + git(['fetch', remote, ...refspecs], target.fullPath) + }) - expect(parsed?.stops.map((stop) => stop.hunkIds)).toEqual([[idA0], [idA1], [idB0]]) - }) + const headRef = `refs/ocm-walkthrough/${SESSION_ID}/pr/1/head` + const baseRef = `refs/ocm-walkthrough/${SESSION_ID}/pr/1/base/main` - it('drops stops left empty', () => { - const parsed = parseWalkthroughResponse( - modelReply([ - { title: 'A', explanation: 'first', hunkIds: ['nope'] }, - { title: 'B', explanation: 'second', hunkIds: [idA0, idA1, idB0] }, - ]), - hunks, - ) + await service.startGeneration(SESSION_ID, { source: { kind: 'pullRequest', number: 1, base: 'main' } }) - expect(parsed?.stops.map((stop) => stop.title)).toEqual(['B']) - }) + expect(fetchRemoteRef).toHaveBeenCalledTimes(1) + expect(refExists(repo, headRef)).toBe(true) + expect(refExists(repo, baseRef)).toBe(true) - it('appends unreferenced hunks as a final remaining stop', () => { - const parsed = parseWalkthroughResponse( - modelReply([{ title: 'A', explanation: 'first', hunkIds: [idA0] }]), - hunks, - ) + service.handleEvent(sessionEvent('session.deleted', SESSION_ID)) - expect(parsed?.stops.at(-1)).toEqual({ - id: computeStopId([idA1, idB0]), - title: 'Remaining changes', - explanation: 'These changes were not covered by the generated walkthrough.', - hunkIds: [idA1, idB0], - status: 'ready', - explanationKey: null, + await vi.waitFor(() => { + expect(refExists(repo, headRef)).toBe(false) + expect(refExists(repo, baseRef)).toBe(false) + }) }) }) - it('does not append a remaining stop when every hunk is covered', () => { - const parsed = parseWalkthroughResponse( - modelReply([{ title: 'A', explanation: 'first', hunkIds: [idA0, idA1, idB0] }]), - hunks, - ) + it('clears the cached hash and its epoch when the session is deleted', async () => { + await service.startGeneration(SESSION_ID, {}) + await service.getState(SESSION_ID) + + const internals = service as unknown as { + currentHashes: Map + hashEpochs: Map + } + expect(internals.currentHashes.size).toBeGreaterThan(0) + + service.handleEvent(sessionEvent('session.deleted', SESSION_ID)) - expect(parsed?.stops).toHaveLength(1) - expect(parsed?.stops[0]!.title).toBe('A') + expect(internals.currentHashes.size).toBe(0) + expect(internals.hashEpochs.size).toBe(0) }) - it('caps model stops and text lengths', () => { - const manyHunks: WalkthroughHunk[] = Array.from( - { length: WALKTHROUGH_MAX_STOPS + 5 }, - (_, index) => hunkFixture('src/a.ts', hunkPatch(index)), - ) - const stops = manyHunks.map((hunk, index) => ({ - title: `Stop ${index}`, - explanation: 'e'.repeat(WALKTHROUGH_TEXT_MAX_CHARS + 50), - hunkIds: [hunk.id], + it('drops a hash read that completes after the session is deleted', async () => { + const diffMock = vi.mocked(fake.client.api.session.diff) + let resolveDiff: (changes: FileDiffInfo[]) => void = () => {} + diffMock.mockImplementationOnce(() => new Promise((resolve) => { + resolveDiff = resolve })) - const parsed = parseWalkthroughResponse( - modelReply(stops, 's'.repeat(WALKTHROUGH_TEXT_MAX_CHARS + 50)), - manyHunks, - ) - const modelStops = parsed!.stops.filter((stop) => stop.title !== 'Remaining changes') - expect(modelStops).toHaveLength(WALKTHROUGH_MAX_STOPS) - expect(parsed!.summary.length).toBeLessThanOrEqual(WALKTHROUGH_TEXT_MAX_CHARS) - expect(modelStops[0]!.explanation.length).toBeLessThanOrEqual(WALKTHROUGH_TEXT_MAX_CHARS) - }) + const pending = service.getState(SESSION_ID) + await vi.waitFor(() => expect(diffMock).toHaveBeenCalledTimes(1)) - it('returns null when the response is unparseable', () => { - expect(parseWalkthroughResponse('not json at all', hunks)).toBeNull() - expect(parseWalkthroughResponse('{not json', hunks)).toBeNull() - expect(parseWalkthroughResponse('{"summary":"x"}', hunks)).toBeNull() - }) + service.handleEvent(sessionEvent('session.deleted', SESSION_ID)) + resolveDiff(THREE_HUNKS) + await pending - it('returns null when no model stop survives', () => { - expect(parseWalkthroughResponse(modelReply([{ title: 'A', explanation: 'x', hunkIds: ['nope'] }]), hunks)).toBeNull() + const internals = service as unknown as { + currentHashes: Map + hashEpochs: Map + pendingHashReads: Map + } + expect(internals.currentHashes.size).toBe(0) + expect(internals.hashEpochs.size).toBe(0) + expect(internals.pendingHashReads.size).toBe(0) }) -}) -describe('ChangeWalkthroughService', () => { - let db: Database - let sessions: Record - let fake: ReturnType - let service: ChangeWalkthroughService - let gitService: GitService - let fetchRemoteRef: ReturnType - - const threeHunks: FileDiffInfo[] = THREE_HUNKS - - beforeEach(() => { - db = createTestDb() - sessions = { [SESSION_ID]: { changes: threeHunks } } - fake = createFakeClient(sessions) - fetchRemoteRef = vi.fn(async () => {}) - gitService = { fetchRemoteRef } as unknown as GitService - service = new ChangeWalkthroughService(db, fake.client, new SettingsService(db), createGitAuthService(), gitService) - }) - - afterEach(() => { - db.close() - }) - - it('throws 404 for a missing session on GET', async () => { - await expect(service.getState('ses_missing')).rejects.toMatchObject({ status: 404 }) - }) - - it('reports no walkthrough and a current hash on GET', async () => { - const state = await service.getState(SESSION_ID) - - expect(state.walkthrough).toBeNull() - expect(state.stale).toBe(false) - expect(state.currentDiffHash).toBe(computeChangesHash(threeHunks)) - expect(state.generating).toBe(false) - expect(state.error).toBeNull() - }) - - it('reports stale when the changes differ from the stored hash', async () => { - fake.setGenerateImpl(async () => modelReply([{ title: 'A', explanation: 'x', hunkIds: THREE_HUNK_IDS }])) - await service.generate(SESSION_ID, {}) - - sessions[SESSION_ID]!.changes = [change('src/a.ts', hunkPatch(99))] - - const state = await service.getState(SESSION_ID) - expect(state.stale).toBe(true) - expect(state.currentDiffHash).toBe(computeChangesHash([change('src/a.ts', hunkPatch(99))])) - }) - - it('reports a null current hash when changes cannot be read', async () => { - sessions[SESSION_ID]!.changes = new Error('diff failed') - - const state = await service.getState(SESSION_ID) - expect(state.currentDiffHash).toBeNull() - expect(state.stale).toBe(false) - }) - - it('does not read changes while generating', async () => { - let resolveGenerate: (text: string) => void = () => {} - fake.setGenerateImpl(() => new Promise((resolve) => { - resolveGenerate = resolve - })) - - const pending = service.generate(SESSION_ID, {}) - await vi.waitFor(() => expect(fake.generateCalls).toHaveLength(1)) - - const diff = vi.mocked(fake.client.api.session.diff) - const messages = vi.mocked(fake.client.api.message.list) - const diffCalls = diff.mock.calls.length - const messageCalls = messages.mock.calls.length - - await service.getState(SESSION_ID) - await service.getState(SESSION_ID) - - expect(diff.mock.calls.length).toBe(diffCalls) - expect(messages.mock.calls.length).toBe(messageCalls) - - resolveGenerate(modelReply([{ title: 'A', explanation: 'x', hunkIds: THREE_HUNK_IDS }])) - await pending - }) - - it('caches the current hash until a session execution event', async () => { - await service.getState(SESSION_ID) - await service.getState(SESSION_ID) - - expect(vi.mocked(fake.client.api.session.diff)).toHaveBeenCalledTimes(1) - expect(vi.mocked(fake.client.api.message.list)).toHaveBeenCalledTimes(2) - }) - - it('recomputes the hash after session.execution.succeeded', async () => { - fake.setGenerateImpl(async () => modelReply([{ title: 'A', explanation: 'x', hunkIds: THREE_HUNK_IDS }])) - await service.generate(SESSION_ID, {}) - - await service.getState(SESSION_ID) - sessions[SESSION_ID]!.changes = [change('src/a.ts', hunkPatch(99))] - - const cached = await service.getState(SESSION_ID) - expect(cached.stale).toBe(false) - - service.handleEvent(sessionEvent('session.execution.succeeded', SESSION_ID)) - - const refreshed = await service.getState(SESSION_ID) - expect(refreshed.stale).toBe(true) - expect(refreshed.currentDiffHash).toBe(computeChangesHash([change('src/a.ts', hunkPatch(99))])) - }) - - it.each(['session.revert.staged', 'session.revert.committed', 'session.revert.cleared'])( - 'recomputes the hash after %s', - async (eventType) => { - fake.setGenerateImpl(async () => modelReply([{ title: 'A', explanation: 'x', hunkIds: THREE_HUNK_IDS }])) - await service.generate(SESSION_ID, {}) - - await service.getState(SESSION_ID) - sessions[SESSION_ID]!.changes = [change('src/a.ts', hunkPatch(99))] - - const cached = await service.getState(SESSION_ID) - expect(cached.stale).toBe(false) - - service.handleEvent(sessionEvent(eventType, SESSION_ID)) - - const refreshed = await service.getState(SESSION_ID) - expect(refreshed.stale).toBe(true) - expect(refreshed.currentDiffHash).toBe(computeChangesHash([change('src/a.ts', hunkPatch(99))])) - }, - ) - - it('stores stops covering every hunk exactly once', async () => { - fake.setGenerateImpl(async () => modelReply([{ title: 'A', explanation: 'x', hunkIds: [THREE_HUNK_IDS[0]!, THREE_HUNK_IDS[2]!] }])) - - const { walkthrough, created } = await service.generate(SESSION_ID, {}) - - expect(created).toBe(true) - const ids = walkthrough.stops.flatMap((stop) => stop.hunkIds) - expect(new Set(ids).size).toBe(ids.length) - expect(new Set(ids)).toEqual(new Set(THREE_HUNK_IDS)) - expect(walkthrough.stops.at(-1)!.title).toBe('Remaining changes') - }) - - it('keeps mechanical hunks out of the prompt and appends a mechanical stop', async () => { - const lockText = hunkPatch(7) - sessions[SESSION_ID]!.changes = [change('src/a.ts', hunkPatch(1)), change('pnpm-lock.yaml', lockText)] - fake.setGenerateImpl(async () => - modelReply([{ title: 'A', explanation: 'x', hunkIds: [computeHunkId('src/a.ts', 'modified', hunkPatch(1))] }]), - ) - - const { walkthrough } = await service.generate(SESSION_ID, {}) - - expect(fake.generateCalls).toHaveLength(1) - expect(fake.generateCalls[0]).not.toContain('pnpm-lock.yaml') - const mechanical = walkthrough.stops.at(-1)! - expect(mechanical.title).toBe('Mechanical changes') - expect(mechanical.hunkIds).toEqual([computeHunkId('pnpm-lock.yaml', 'modified', lockText)]) - - const referenced = walkthrough.stops.flatMap((stop) => stop.hunkIds) - expect(new Set(referenced).size).toBe(referenced.length) - expect(new Set(referenced)).toEqual(new Set(walkthrough.hunks.map((hunk) => hunk.id))) - }) - - it('bounds stored mechanical text yet lists every mechanical file with counts', async () => { - const oversizedPatch = Array.from({ length: 40 }, (_, index) => bigHunk(index)).join('\n') - sessions[SESSION_ID]!.changes = [ - change('src/a.ts', hunkPatch(1)), - { file: 'pnpm-lock.yaml', patch: oversizedPatch, additions: 1200, deletions: 800, status: 'modified' }, - { file: 'src/__snapshots__/a.ts.snap', patch: hunkPatch(2), additions: 2, deletions: 3, status: 'modified' }, - ] - fake.setGenerateImpl(async () => - modelReply([{ title: 'A', explanation: 'x', hunkIds: [computeHunkId('src/a.ts', 'modified', hunkPatch(1))] }]), - ) - - const { walkthrough } = await service.generate(SESSION_ID, {}) - - const mechanicalHunks = walkthrough.hunks.filter((hunk) => isMechanicalChangePath(hunk.file)) - expect(mechanicalHunks.map((hunk) => hunk.file)).toEqual([ - 'pnpm-lock.yaml', - 'src/__snapshots__/a.ts.snap', - ]) - expect(mechanicalHunks.reduce((total, hunk) => total + hunk.text.length, 0)).toBeLessThanOrEqual( - MECHANICAL_TEXT_BUDGET, - ) - expect(mechanicalHunks[0]).toMatchObject({ text: '', additions: 1200, deletions: 800 }) - expect(mechanicalHunks[1]).toMatchObject({ additions: 2, deletions: 3 }) - - const mechanicalStop = walkthrough.stops.find((stop) => stop.title === 'Mechanical changes')! - expect(new Set(mechanicalStop.hunkIds)).toEqual(new Set(mechanicalHunks.map((hunk) => hunk.id))) - }) - - it('stores only a mechanical stop without a model call', async () => { - sessions[SESSION_ID]!.changes = [ - change('pnpm-lock.yaml', hunkPatch(1)), - change('src/__snapshots__/a.ts.snap', hunkPatch(2)), - ] - - const { walkthrough, created } = await service.generate(SESSION_ID, {}) - - expect(created).toBe(true) - expect(fake.generateCalls).toHaveLength(0) - expect(walkthrough.summary).toBe('Only lock files, snapshots or generated files changed.') - expect(walkthrough.stops).toHaveLength(1) - expect(walkthrough.stops[0]!.title).toBe('Mechanical changes') - expect(walkthrough.stops[0]!.hunkIds).toHaveLength(2) - expect(new Set(walkthrough.stops[0]!.hunkIds)).toEqual( - new Set(walkthrough.hunks.map((hunk) => hunk.id)), - ) - }) - - it('writes a truthful summary when source files are omitted but mechanical files changed', async () => { - const file = 'src/huge.ts' - const patch = Array.from( - { length: 2000 }, - (_, index) => `@@ -${index + 1},1 +${index + 1},1 @@\n+line${index}`, - ).join('\n') - sessions[SESSION_ID]!.changes = [change(file, patch), change('pnpm-lock.yaml', hunkPatch(1))] - - const { walkthrough } = await service.generate(SESSION_ID, {}) - - expect(walkthrough.summary).not.toBe('Only lock files, snapshots or generated files changed.') - expect(walkthrough.stops.map((stop) => stop.title)).toContain('Mechanical changes') - expect(fake.generateCalls).toHaveLength(0) - }) - - it('does not claim mechanical-only when a binary source file was omitted', async () => { - sessions[SESSION_ID]!.changes = [ - change('assets/logo.png', 'diff --git a/assets/logo.png b/assets/logo.png\nBinary files differ'), - change('pnpm-lock.yaml', hunkPatch(1)), - ] - - const { walkthrough } = await service.generate(SESSION_ID, {}) - - expect(walkthrough.summary).not.toBe('Only lock files, snapshots or generated files changed.') - expect(walkthrough.stops[0]!.title).toBe('Mechanical changes') - }) - - it('never reuses the single-call remaining placeholder as a real explanation', async () => { - const remainingIds = [THREE_HUNK_IDS[1]!, THREE_HUNK_IDS[2]!] - fake.setGenerateImpl(async () => - modelReply([{ title: 'A', explanation: 'x', hunkIds: [THREE_HUNK_IDS[0]!] }]), - ) - - const first = await service.generate(SESSION_ID, {}) - const firstRemaining = first.walkthrough.stops.at(-1)! - expect(firstRemaining.title).toBe('Remaining changes') - expect(firstRemaining.explanationKey).toBeNull() - - sessions[SESSION_ID]!.changes = [ - change('src/a.ts', `${hunkPatch(99)}\n${hunkPatch(10)}`), - change('src/b.ts', hunkPatch(1)), - ] - fake.setGenerateImpl(async () => modelReply([{ title: 'B', explanation: 'fresh', hunkIds: remainingIds }])) - - const second = await service.generate(SESSION_ID, {}) - - const stopB = second.walkthrough.stops.find((stop) => stop.title === 'B')! - expect(stopB.explanation).toBe('fresh') - const placeholderStop = second.walkthrough.stops.find( - (stop) => stop.explanation === 'These changes were not covered by the generated walkthrough.', - ) - expect(placeholderStop?.explanationKey).toBeNull() - }) - - it('returns the stored walkthrough without a model call when changes are unchanged', async () => { - fake.setGenerateImpl(async () => modelReply([{ title: 'A', explanation: 'x', hunkIds: THREE_HUNK_IDS }])) - const first = await service.generate(SESSION_ID, {}) - const modelListCallsAfterFirst = vi.mocked(fake.client.api.model.list).mock.calls.length - - const second = await service.generate(SESSION_ID, {}) - - expect(second.created).toBe(false) - expect(second.walkthrough).toEqual(first.walkthrough) - expect(fake.generateCalls).toHaveLength(1) - expect(vi.mocked(fake.client.api.model.list)).toHaveBeenCalledTimes(modelListCallsAfterFirst) - }) - - it('calls the model again when regenerate is set', async () => { - fake.setGenerateImpl(async () => modelReply([{ title: 'A', explanation: 'x', hunkIds: THREE_HUNK_IDS }])) - await service.generate(SESSION_ID, {}) - - await service.generate(SESSION_ID, { regenerate: true }) - - expect(fake.generateCalls).toHaveLength(2) - }) - - it('generates with the session selected model', async () => { - sessions[SESSION_ID]!.info = { - id: SESSION_ID, - title: 'Title', - model: { providerID: 'anthropic', id: 'claude-sonnet-4' }, - } as SessionInfo - fake.setGenerateImpl(async () => modelReply([{ title: 'A', explanation: 'x', hunkIds: THREE_HUNK_IDS }])) - - await service.generate(SESSION_ID, {}) - - expect(fake.generateModels[0]).toEqual({ providerID: 'anthropic', id: 'claude-sonnet-4' }) - }) - - it('falls back to the resolved default when the session has no model', async () => { - fake.setGenerateImpl(async () => modelReply([{ title: 'A', explanation: 'x', hunkIds: THREE_HUNK_IDS }])) - - await service.generate(SESSION_ID, {}) - - expect(fake.generateModels[0]).toEqual({ providerID: 'openai', id: 'gpt-5-mini' }) - }) - - it('uses the walkthrough model preference', async () => { - new SettingsService(db).updateSettings({ walkthroughModel: 'openai/gpt-5-mini' }) - fake.setGenerateImpl(async () => modelReply([{ title: 'A', explanation: 'x', hunkIds: THREE_HUNK_IDS }])) - - const { walkthrough } = await service.generate(SESSION_ID, {}) - - expect(fake.generateModels.every((model) => model?.providerID === 'openai' && model.id === 'gpt-5-mini')).toBe(true) - expect(fake.generateModels.length).toBeGreaterThan(0) - expect(walkthrough.model).toBe('openai/gpt-5-mini') - }) - - it('ignores an invalid walkthrough model preference', async () => { - new SettingsService(db).updateSettings({ walkthroughModel: 'not-a-ref' }) - sessions[SESSION_ID]!.info = { - id: SESSION_ID, - title: 'Title', - model: { providerID: 'anthropic', id: 'claude-sonnet-4' }, - } as SessionInfo - fake.setGenerateImpl(async () => modelReply([{ title: 'A', explanation: 'x', hunkIds: THREE_HUNK_IDS }])) - - await service.generate(SESSION_ID, {}) - - expect(fake.generateModels[0]).toEqual({ providerID: 'anthropic', id: 'claude-sonnet-4' }) - }) - - it('uses the request model over the session model', async () => { - sessions[SESSION_ID]!.info = { - id: SESSION_ID, - title: 'Title', - model: { providerID: 'anthropic', id: 'claude-sonnet-4' }, - } as SessionInfo - fake.setGenerateImpl(async () => modelReply([{ title: 'A', explanation: 'x', hunkIds: THREE_HUNK_IDS }])) - - await service.generate(SESSION_ID, { model: 'openai/gpt-5-mini' }) - - expect(fake.generateModels[0]).toEqual({ providerID: 'openai', id: 'gpt-5-mini' }) - }) - - it('prefers the walkthrough model preference over the request model', async () => { - new SettingsService(db).updateSettings({ walkthroughModel: 'openai/gpt-5' }) - fake.setGenerateImpl(async () => modelReply([{ title: 'A', explanation: 'x', hunkIds: THREE_HUNK_IDS }])) - - await service.generate(SESSION_ID, { model: 'anthropic/claude-sonnet-4' }) - - expect(fake.generateModels[0]).toEqual({ providerID: 'openai', id: 'gpt-5' }) - }) - - it('reuses the resolved default model for every stop', async () => { - sessions[SESSION_ID]!.changes = LARGE_CHANGES - fake.setGenerateImpl((prompt) => - Promise.resolve(prompt.includes(PLAN_MARKER) ? LARGE_PLAN_REPLY : EXPLANATION_REPLY), - ) - - await service.generate(SESSION_ID, {}) - - expect(fake.generateModels).toHaveLength(4) - expect( - fake.generateModels.every((model) => model?.providerID === 'openai' && model.id === 'gpt-5-mini'), - ).toBe(true) - }) - - it('waits for an explicit model once per generation, not per stop', async () => { - sessions[SESSION_ID]!.changes = LARGE_CHANGES - sessions[SESSION_ID]!.info = { - id: SESSION_ID, - title: 'Title', - model: { providerID: 'openai', id: 'gpt-5-mini' }, - } as SessionInfo - fake.setGenerateImpl((prompt) => - Promise.resolve(prompt.includes(PLAN_MARKER) ? LARGE_PLAN_REPLY : EXPLANATION_REPLY), - ) - - await service.generate(SESSION_ID, {}) - - expect(fake.generateModels).toHaveLength(4) - expect( - fake.generateModels.every((model) => model?.providerID === 'openai' && model.id === 'gpt-5-mini'), - ).toBe(true) - expect(vi.mocked(fake.client.api.model.list)).toHaveBeenCalledTimes(1) - }) - - it('includes the resolved default model in the explanation key', async () => { - fake.setGenerateImpl(async () => modelReply([{ title: 'A', explanation: 'x', hunkIds: THREE_HUNK_IDS }])) - - const first = await service.generate(SESSION_ID, {}) - const firstKey = first.walkthrough.stops[0]!.explanationKey - - vi.mocked(fake.client.api.model.list).mockResolvedValue({ - data: [{ providerID: 'openai', id: 'gpt-5', enabled: true }], - } as never) - vi.mocked(fake.client.api.model.default).mockResolvedValue({ - data: { providerID: 'openai', id: 'gpt-5', enabled: true }, - } as never) - - const second = await service.generate(SESSION_ID, { regenerate: true }) - const secondKey = second.walkthrough.stops[0]!.explanationKey - - expect(firstKey).not.toBeNull() - expect(secondKey).not.toBeNull() - expect(firstKey).not.toBe(secondKey) - }) - - it('coalesces concurrent generation into one model call', async () => { - let resolveGenerate: (text: string) => void = () => {} - const gate = new Promise((resolve) => { - resolveGenerate = resolve - }) - fake.setGenerateImpl(() => gate) - - const first = service.generate(SESSION_ID, {}) - const second = service.generate(SESSION_ID, {}) - resolveGenerate(modelReply([{ title: 'A', explanation: 'x', hunkIds: THREE_HUNK_IDS }])) - - const [firstResult, secondResult] = await Promise.all([first, second]) - expect(firstResult).toBe(secondResult) - expect(fake.generateCalls).toHaveLength(1) - }) - - it('rejects with 409 when the session has no changes', async () => { - sessions[SESSION_ID]!.changes = [] - - await expect(service.generate(SESSION_ID, {})).rejects.toMatchObject({ - status: 409, - code: 'WALKTHROUGH_NO_CHANGES', - }) - }) - - it('names the source in the no-changes message', async () => { - const repo = createChangedRepo() - sessions[SESSION_ID] = { changes: threeHunks, info: sessionAt(repo) } - - await expect(service.generate(SESSION_ID, { source: { kind: 'staged' } })).rejects.toMatchObject({ - status: 409, - code: 'WALKTHROUGH_NO_CHANGES', - message: 'The staged changes contain no changes to walk through', - }) - }) - - it('names the source in the no-text-changes message', async () => { - const repo = path.join(walkthroughRepoRoot, uniqueName('repo')) - createCommittedRepo(repo) - writeFileSync(path.join(repo, 'logo.png'), Buffer.from([0x00, 0x89, 0x50, 0x4e, 0x47])) - git(['add', 'logo.png'], repo) - sessions[SESSION_ID] = { changes: threeHunks, info: sessionAt(repo) } - - await expect(service.generate(SESSION_ID, { source: { kind: 'staged' } })).rejects.toMatchObject({ - status: 409, - code: 'WALKTHROUGH_NO_TEXT_CHANGES', - message: 'The staged changes contain no text changes', - }) - }) - - it('rejects with 409 when every change is binary', async () => { - sessions[SESSION_ID]!.changes = [ - change('assets/logo.png', 'diff --git a/assets/logo.png b/assets/logo.png\nBinary files differ'), - ] - - await expect(service.generate(SESSION_ID, {})).rejects.toMatchObject({ - status: 409, - code: 'WALKTHROUGH_NO_TEXT_CHANGES', - }) - }) - - it('rejects with 413 and the omitted files when the outline admits no hunk', async () => { - const file = 'src/huge.ts' - const patch = Array.from( - { length: 2000 }, - (_, index) => `@@ -${index + 1},1 +${index + 1},1 @@\n+line${index}`, - ).join('\n') - sessions[SESSION_ID]!.changes = [change(file, patch)] - - await expect(service.generate(SESSION_ID, {})).rejects.toMatchObject({ - status: 413, - code: 'WALKTHROUGH_CONTEXT_LIMIT', - details: { omittedFiles: [{ file, reason: 'budget' }] }, - }) - expect(fake.generateCalls).toHaveLength(0) - }) - - it('rejects with 502 when the model response is unparseable', async () => { - fake.setGenerateImpl(async () => 'not json') - - await expect(service.generate(SESSION_ID, {})).rejects.toMatchObject({ - status: 502, - code: 'WALKTHROUGH_UNPARSEABLE', - }) - expect(fake.generateCalls).toHaveLength(2) - }) - - it('rejects with 504 when generation times out', async () => { - const timingOut = new ChangeWalkthroughService(db, fake.client, new SettingsService(db), createGitAuthService(), gitService, { timeoutMs: 5 }) - fake.setGenerateImpl(() => new Promise(() => {})) - - await expect(timingOut.generate(SESSION_ID, {})).rejects.toMatchObject({ - status: 504, - code: 'WALKTHROUGH_TIMEOUT', - }) - expect(fake.generateCalls).toHaveLength(2) - }) - - it('rejects with 502 when the model call fails', async () => { - fake.setGenerateImpl(async () => { - throw new Error('model unavailable') - }) - - await expect(service.generate(SESSION_ID, {})).rejects.toMatchObject({ status: 502 }) - expect(fake.generateCalls).toHaveLength(2) - }) - - it('retries an unparseable reply once', async () => { - let calls = 0 - fake.setGenerateImpl(async () => { - calls += 1 - return calls === 1 ? 'not json' : modelReply([{ title: 'A', explanation: 'x', hunkIds: THREE_HUNK_IDS }]) - }) - - const { walkthrough, created } = await service.generate(SESSION_ID, {}) - - expect(created).toBe(true) - expect(fake.generateCalls).toHaveLength(2) - expect(walkthrough.stops).toHaveLength(1) - }) - - it('retries a failed call once', async () => { - let calls = 0 - fake.setGenerateImpl(async () => { - calls += 1 - if (calls === 1) { - throw new Error('model unavailable') - } - return modelReply([{ title: 'A', explanation: 'x', hunkIds: THREE_HUNK_IDS }]) - }) - - const { walkthrough, created } = await service.generate(SESSION_ID, {}) - - expect(created).toBe(true) - expect(fake.generateCalls).toHaveLength(2) - expect(walkthrough.stops).toHaveLength(1) - }) - - it('rejects with 502 when reading changes fails', async () => { - sessions[SESSION_ID]!.changes = new Error('diff failed') - - await expect(service.generate(SESSION_ID, {})).rejects.toMatchObject({ - status: 502, - code: 'WALKTHROUGH_CHANGES_UNAVAILABLE', - }) - }) - - it('rejects with 404 when generating for a missing session', async () => { - await expect(service.generate('ses_missing', {})).rejects.toBeInstanceOf(ChangeWalkthroughError) - await expect(service.generate('ses_missing', {})).rejects.toMatchObject({ status: 404 }) - }) - - it('plans then explains each stop', async () => { - sessions[SESSION_ID]!.changes = LARGE_CHANGES - fake.setGenerateImpl((prompt) => - Promise.resolve(prompt.includes(PLAN_MARKER) ? LARGE_PLAN_REPLY : EXPLANATION_REPLY), - ) - - const { walkthrough, created } = await service.generate(SESSION_ID, {}) - - expect(created).toBe(true) - expect(walkthrough.stops.map((stop) => stop.title)).toEqual(['A', 'B', 'C']) - expect(walkthrough.stops.every((stop) => stop.status === 'ready')).toBe(true) - expect(walkthrough.stops.every((stop) => stop.explanation === 'Why')).toBe(true) - expect(fake.generateCalls).toHaveLength(4) - expect(fake.generateCalls.filter((prompt) => prompt.includes(PLAN_MARKER))).toHaveLength(1) - }) - - it('exposes pending stops while explaining', async () => { - sessions[SESSION_ID]!.changes = LARGE_CHANGES - const explainResolvers: Array<(text: string) => void> = [] - fake.setGenerateImpl((prompt) => { - if (prompt.includes(PLAN_MARKER)) { - return Promise.resolve(LARGE_PLAN_REPLY) - } - return new Promise((resolve) => { - explainResolvers.push(resolve) - }) - }) - - const pending = service.generate(SESSION_ID, {}) - - await vi.waitFor(async () => { - const state = await service.getState(SESSION_ID) - expect(state.generating).toBe(true) - expect(state.walkthrough?.stops).toHaveLength(3) - expect(state.walkthrough?.stops.every((stop) => stop.status === 'pending')).toBe(true) - }) - - await vi.waitFor(() => expect(explainResolvers).toHaveLength(3)) - for (const resolve of explainResolvers) { - resolve(EXPLANATION_REPLY) - } - - const { walkthrough } = await pending - expect(walkthrough.stops.every((stop) => stop.status === 'ready')).toBe(true) - }) - - it('marks a stop failed when its explanation fails twice', async () => { - sessions[SESSION_ID]!.changes = LARGE_CHANGES - fake.setGenerateImpl((prompt) => { - if (prompt.includes(PLAN_MARKER)) { - return Promise.resolve(LARGE_PLAN_REPLY) - } - if (prompt.includes('## Stop: B')) { - return Promise.resolve('not json') - } - return Promise.resolve(EXPLANATION_REPLY) - }) - - const { walkthrough, created } = await service.generate(SESSION_ID, {}) - - expect(created).toBe(true) - const byTitle = new Map(walkthrough.stops.map((stop) => [stop.title, stop])) - expect(byTitle.get('B')?.status).toBe('failed') - expect(byTitle.get('A')?.status).toBe('ready') - expect(byTitle.get('C')?.status).toBe('ready') - expect(fake.generateCalls.filter((prompt) => prompt.includes('## Stop: B'))).toHaveLength(2) - }) - - it('reuses explanations for unchanged stops after an edit', async () => { - sessions[SESSION_ID]!.changes = LARGE_CHANGES - fake.setGenerateImpl((prompt) => - Promise.resolve(prompt.includes(PLAN_MARKER) ? LARGE_PLAN_REPLY : explanationFor(prompt)), - ) - - const first = await service.generate(SESSION_ID, {}) - expect(first.walkthrough.stops.every((stop) => stop.status === 'ready')).toBe(true) - const callsAfterFirst = fake.generateCalls.length - expect(callsAfterFirst).toBe(4) - - sessions[SESSION_ID]!.changes = EDITED_LARGE_CHANGES - fake.setGenerateImpl((prompt) => - Promise.resolve(prompt.includes(PLAN_MARKER) ? EDITED_LARGE_PLAN_REPLY : explanationFor(prompt)), - ) - - const second = await service.generate(SESSION_ID, {}) - - expect(second.created).toBe(true) - const secondCalls = fake.generateCalls.slice(callsAfterFirst) - expect(secondCalls).toHaveLength(2) - expect(secondCalls.filter((prompt) => prompt.includes(PLAN_MARKER))).toHaveLength(1) - expect(secondCalls.filter((prompt) => prompt.includes('## Stop: A'))).toHaveLength(1) - expect(secondCalls.filter((prompt) => prompt.includes('## Stop: B'))).toHaveLength(0) - expect(secondCalls.filter((prompt) => prompt.includes('## Stop: C'))).toHaveLength(0) - - const byTitle = new Map(second.walkthrough.stops.map((stop) => [stop.title, stop])) - expect(byTitle.get('A')!.explanation).toBe('explained-A') - expect(byTitle.get('B')!.explanation).toBe('explained-B') - expect(byTitle.get('C')!.explanation).toBe('explained-C') - expect(second.walkthrough.stops.every((stop) => stop.status === 'ready')).toBe(true) - }) - - it('retries only failed stops on an unchanged diff', async () => { - sessions[SESSION_ID]!.changes = LARGE_CHANGES - fake.setGenerateImpl((prompt) => { - if (prompt.includes(PLAN_MARKER)) { - return Promise.resolve(LARGE_PLAN_REPLY) - } - if (prompt.includes('## Stop: B')) { - return Promise.resolve('not json') - } - return Promise.resolve(explanationFor(prompt)) - }) - - const first = await service.generate(SESSION_ID, {}) - const firstByTitle = new Map(first.walkthrough.stops.map((stop) => [stop.title, stop])) - expect(firstByTitle.get('B')!.status).toBe('failed') - const callsAfterFirst = fake.generateCalls.length - - fake.setGenerateImpl((prompt) => - Promise.resolve(prompt.includes(PLAN_MARKER) ? LARGE_PLAN_REPLY : explanationFor(prompt)), - ) - - const second = await service.generate(SESSION_ID, {}) - - expect(second.created).toBe(true) - const secondCalls = fake.generateCalls.slice(callsAfterFirst) - expect(secondCalls).toHaveLength(1) - expect(secondCalls[0]).toContain('## Stop: B') - expect(secondCalls.filter((prompt) => prompt.includes(PLAN_MARKER))).toHaveLength(0) - - const secondByTitle = new Map(second.walkthrough.stops.map((stop) => [stop.title, stop])) - expect(secondByTitle.get('B')!.status).toBe('ready') - expect(secondByTitle.get('A')!.explanation).toBe('explained-A') - expect(secondByTitle.get('C')!.explanation).toBe('explained-C') - }) - - it('regenerate ignores previous explanations', async () => { - sessions[SESSION_ID]!.changes = LARGE_CHANGES - fake.setGenerateImpl((prompt) => - Promise.resolve(prompt.includes(PLAN_MARKER) ? LARGE_PLAN_REPLY : explanationFor(prompt)), - ) - - await service.generate(SESSION_ID, {}) - const callsAfterFirst = fake.generateCalls.length - - await service.generate(SESSION_ID, { regenerate: true }) - - expect(fake.generateCalls.length - callsAfterFirst).toBe(4) - }) - - it('marks resumed stops pending while they are being explained', async () => { - sessions[SESSION_ID]!.changes = LARGE_CHANGES - fake.setGenerateImpl((prompt) => { - if (prompt.includes(PLAN_MARKER)) { - return Promise.resolve(LARGE_PLAN_REPLY) - } - if (prompt.includes('## Stop: B')) { - return Promise.resolve('not json') - } - return Promise.resolve(explanationFor(prompt)) - }) - - const first = await service.generate(SESSION_ID, {}) - expect(first.walkthrough.stops.find((stop) => stop.title === 'B')!.status).toBe('failed') - - let resolveExplain: (text: string) => void = () => {} - fake.setGenerateImpl((prompt) => { - if (prompt.includes('## Stop: B')) { - return new Promise((resolve) => { - resolveExplain = resolve - }) - } - return Promise.resolve(explanationFor(prompt)) - }) - - const pending = service.generate(SESSION_ID, {}) - - await vi.waitFor(async () => { - const state = await service.getState(SESSION_ID) - expect(state.walkthrough?.stops.find((stop) => stop.title === 'B')?.status).toBe('pending') - }) - - resolveExplain(EXPLANATION_REPLY) - const resumed = await pending - expect(resumed.walkthrough.stops.find((stop) => stop.title === 'B')!.status).toBe('ready') - }) - - it('does not reuse explanations across models', async () => { - sessions[SESSION_ID]!.info = { - id: SESSION_ID, - title: 'Title', - model: { providerID: 'anthropic', id: 'claude-sonnet-4' }, - } as SessionInfo - sessions[SESSION_ID]!.changes = LARGE_CHANGES - fake.setGenerateImpl((prompt) => - Promise.resolve(prompt.includes(PLAN_MARKER) ? LARGE_PLAN_REPLY : explanationFor(prompt)), - ) - - await service.generate(SESSION_ID, {}) - const callsAfterFirst = fake.generateCalls.length - - sessions[SESSION_ID]!.info = { - id: SESSION_ID, - title: 'Title', - model: { providerID: 'openai', id: 'gpt-5' }, - } as SessionInfo - sessions[SESSION_ID]!.changes = EDITED_LARGE_CHANGES - fake.setGenerateImpl((prompt) => - Promise.resolve(prompt.includes(PLAN_MARKER) ? EDITED_LARGE_PLAN_REPLY : explanationFor(prompt)), - ) - - await service.generate(SESSION_ID, {}) - - expect(fake.generateCalls.length - callsAfterFirst).toBe(4) - }) - - it('lists surviving previous stops in the plan prompt', async () => { - sessions[SESSION_ID]!.changes = LARGE_CHANGES - fake.setGenerateImpl((prompt) => - Promise.resolve(prompt.includes(PLAN_MARKER) ? LARGE_PLAN_REPLY : explanationFor(prompt)), - ) - - await service.generate(SESSION_ID, {}) - const callsAfterFirst = fake.generateCalls.length - - sessions[SESSION_ID]!.changes = EDITED_LARGE_CHANGES - fake.setGenerateImpl((prompt) => - Promise.resolve(prompt.includes(PLAN_MARKER) ? EDITED_LARGE_PLAN_REPLY : explanationFor(prompt)), - ) - - await service.generate(SESSION_ID, {}) - - const planPrompt = fake.generateCalls.slice(callsAfterFirst).find((prompt) => prompt.includes(PLAN_MARKER)) - expect(planPrompt).toBeDefined() - expect(planPrompt).toContain('Previous stops') - expect(planPrompt).toContain('- B:') - expect(planPrompt).toContain('- C:') - expect(planPrompt).not.toContain('- A:') - }) - - it('keeps a single call for small diffs', async () => { - fake.setGenerateImpl(async () => - modelReply([{ title: 'A', explanation: 'x', hunkIds: THREE_HUNK_IDS }]), - ) - - const { walkthrough } = await service.generate(SESSION_ID, {}) - - expect(fake.generateCalls).toHaveLength(1) - expect(walkthrough.stops).toHaveLength(1) - expect(walkthrough.stops[0]!.status).toBe('ready') - }) - - it('does not cache a diff read that an execution event invalidated', async () => { - const diffMock = vi.mocked(fake.client.api.session.diff) - let resolveDiff: (changes: FileDiffInfo[]) => void = () => {} - diffMock.mockImplementationOnce( - () => new Promise((resolve) => { - resolveDiff = resolve - }), - ) - - const pending = service.getState(SESSION_ID) - await vi.waitFor(() => expect(diffMock).toHaveBeenCalledTimes(1)) - service.handleEvent(sessionEvent('session.execution.succeeded', SESSION_ID)) - resolveDiff(threeHunks) - await pending - - await service.getState(SESSION_ID) - expect(diffMock).toHaveBeenCalledTimes(2) - }) - - describe('startGeneration', () => { - const coveringReply = modelReply([{ title: 'A', explanation: 'x', hunkIds: THREE_HUNK_IDS }]) - - it('reports generating while the model call is pending, then the stored walkthrough', async () => { - let resolveGenerate: (text: string) => void = () => {} - fake.setGenerateImpl(() => new Promise((resolve) => { - resolveGenerate = resolve - })) - - const state = await service.startGeneration(SESSION_ID, {}) - expect(state.generating).toBe(true) - expect(state.walkthrough).toBeNull() - expect(state.error).toBeNull() + describe('walkthrough session lifecycle', () => { + it('rejects a publish before the artifact exists and keeps generating', async () => { + await service.startGeneration(SESSION_ID, {}) - await vi.waitFor(() => expect(fake.generateCalls).toHaveLength(1)) - resolveGenerate(coveringReply) + await expect(service.publishFromWalkthroughSession('ses_walk_1')).rejects.toMatchObject({ status: 404 }) - await vi.waitFor(async () => { - const settled = await service.getState(SESSION_ID) - expect(settled.generating).toBe(false) - expect(settled.walkthrough).not.toBeNull() - }) + const state = await service.getState(SESSION_ID) + expect(state.walkthrough?.status).toBe('generating') }) - it('stores a mechanical-only walkthrough without a model call', async () => { - sessions[SESSION_ID]!.changes = [change('pnpm-lock.yaml', hunkPatch(1))] + it('publishes an existing artifact and reports ready with the artifact path', async () => { + await service.startGeneration(SESSION_ID, {}) + const artifactPath = writeArtifact(fake.createCalls[0]!) - const state = await service.startGeneration(SESSION_ID, {}) + const result = await service.publishFromWalkthroughSession('ses_walk_1') - expect(fake.generateCalls).toHaveLength(0) + expect(result.path).toBe(artifactPath) + const state = await service.getState(SESSION_ID) expect(state.generating).toBe(false) - expect(state.walkthrough?.stops).toHaveLength(1) - expect(state.walkthrough?.stops[0]!.title).toBe('Mechanical changes') + expect(state.walkthrough?.status).toBe('ready') + expect(state.walkthrough?.publishedAt).not.toBeNull() + expect(state.walkthrough?.artifactPath).toBe(artifactPath) }) - it('rejects with 409 when the session has no changes', async () => { - sessions[SESSION_ID]!.changes = [] - - await expect(service.startGeneration(SESSION_ID, {})).rejects.toMatchObject({ - status: 409, - code: 'WALKTHROUGH_NO_CHANGES', + it('rejects a publish for an unknown walkthrough session', async () => { + await expect(service.publishFromWalkthroughSession('ses_other')).rejects.toMatchObject({ + status: 404, + code: 'WALKTHROUGH_NOT_A_WALKTHROUGH_SESSION', }) - sessions[SESSION_ID]!.changes = threeHunks - expect((await service.getState(SESSION_ID)).error).toBeNull() }) - it('records an unparseable model failure on the state', async () => { - fake.setGenerateImpl(async () => 'not json') - + it('rejects a publish for a cancelled walkthrough', async () => { await service.startGeneration(SESSION_ID, {}) + writeArtifact(fake.createCalls[0]!) + await service.cancelGeneration(SESSION_ID) - await vi.waitFor(async () => { - const settled = await service.getState(SESSION_ID) - expect(settled.generating).toBe(false) - expect(settled.error).toMatchObject({ code: 'WALKTHROUGH_UNPARSEABLE' }) + await expect(service.publishFromWalkthroughSession('ses_walk_1')).rejects.toMatchObject({ + status: 409, + code: 'WALKTHROUGH_SUPERSEDED', }) }) - it('clears a previous error when a new generation starts', async () => { - fake.setGenerateImpl(async () => 'not json') + it('rejects a publish from an old walkthrough session when a regenerate claims the record mid-flight', async () => { await service.startGeneration(SESSION_ID, {}) - await vi.waitFor(async () => { - expect((await service.getState(SESSION_ID)).error).not.toBeNull() - }) - - let resolveGenerate: (text: string) => void = () => {} - fake.setGenerateImpl(() => new Promise((resolve) => { - resolveGenerate = resolve - })) - const state = await service.startGeneration(SESSION_ID, { regenerate: true }) + writeArtifact(fake.createCalls[0]!) + + const publish = service.publishFromWalkthroughSession('ses_walk_1') + expect( + claimChangeWalkthroughGeneration(db, { + sessionId: SESSION_ID, + source: { kind: 'session' }, + diffHash: 'hash-new', + generationId: 'gen-new', + artifactPath: '/workspace/walkthroughs/gen-new/index.html', + model: null, + now: Date.now() + WALKTHROUGH_GENERATION_TIMEOUT_MS + 1, + }), + ).toBe(true) - expect(state.generating).toBe(true) - expect(state.error).toBeNull() + await expect(publish).rejects.toMatchObject({ status: 409, code: 'WALKTHROUGH_SUPERSEDED' }) - await vi.waitFor(() => expect(fake.generateCalls.length).toBeGreaterThan(2)) - resolveGenerate(coveringReply) - await vi.waitFor(async () => { - expect((await service.getState(SESSION_ID)).generating).toBe(false) - }) + const row = getChangeWalkthrough(db, SESSION_ID, 'session') + expect(row?.status).toBe('generating') + expect(row?.walkthroughSessionId).toBeNull() }) - it('clears the recorded error when the session is deleted', async () => { - fake.setGenerateImpl(async () => 'not json') + it('interrupts the previous walkthrough session when regenerating a ready walkthrough', async () => { await service.startGeneration(SESSION_ID, {}) - await vi.waitFor(async () => { - expect((await service.getState(SESSION_ID)).error).not.toBeNull() + const firstDir = outputDirFromPermissions(fake.createCalls[0]!.permissions as SchedulePermissionRule[]) + updateChangeWalkthrough(db, SESSION_ID, 'session', ['generating'], { + status: 'ready', + publishedAt: 1, + updatedAt: Date.now(), }) - service.handleEvent(sessionEvent('session.deleted', SESSION_ID)) - - expect((await service.getState(SESSION_ID)).error).toBeNull() - }) - }) - - describe('handleEvent', () => { - const coveringReply = modelReply([{ title: 'A', explanation: 'x', hunkIds: THREE_HUNK_IDS }]) - - it('deletes the stored walkthrough when its session is deleted', async () => { - fake.setGenerateImpl(async () => coveringReply) - await service.generate(SESSION_ID, {}) - - service.handleEvent(sessionEvent('session.deleted', SESSION_ID)) - - expect(getChangeWalkthrough(db, SESSION_ID, 'session')).toBeNull() - }) - - it('keeps the stored walkthrough for other session events', async () => { - fake.setGenerateImpl(async () => coveringReply) - await service.generate(SESSION_ID, {}) - - service.handleEvent(sessionEvent('session.execution.succeeded', SESSION_ID)) - service.handleEvent(sessionEvent('session.deleted', 'ses_other')) + await service.startGeneration(SESSION_ID, { regenerate: true }) - expect(getChangeWalkthrough(db, SESSION_ID, 'session')).not.toBeNull() + expect(fake.createCalls).toHaveLength(2) + expect(fake.interruptCalls).toContain('ses_walk_1') + expect(existsSync(firstDir)).toBe(false) }) - it('does not store a walkthrough whose session was deleted during generation', async () => { - let resolveGenerate: (text: string) => void = () => {} - fake.setGenerateImpl(() => new Promise((resolve) => { - resolveGenerate = resolve + it('cancels a generation interrupted before the walkthrough session is recorded', async () => { + let resolveCreate: (session: { id: string }) => void = () => {} + fake.setCreateImpl(() => new Promise((resolve) => { + resolveCreate = resolve })) - const pending = service.generate(SESSION_ID, {}) - await vi.waitFor(() => expect(fake.generateCalls).toHaveLength(1)) - service.handleEvent(sessionEvent('session.deleted', SESSION_ID)) - resolveGenerate(coveringReply) - - await expect(pending).rejects.toMatchObject({ status: 404 }) - expect(getChangeWalkthrough(db, SESSION_ID, 'session')).toBeNull() - }) + const starting = service.startGeneration(SESSION_ID, {}) + await vi.waitFor(() => expect(fake.createCalls).toHaveLength(1)) - it('stores walkthroughs again after an earlier deletion of the same session id', async () => { - service.handleEvent(sessionEvent('session.deleted', SESSION_ID)) - fake.setGenerateImpl(async () => coveringReply) + expect(await service.cancelGeneration(SESSION_ID)).toBe(true) - await service.generate(SESSION_ID, {}) + resolveCreate({ id: 'ses_walk_late' }) + await starting - expect(getChangeWalkthrough(db, SESSION_ID, 'session')).not.toBeNull() + expect(fake.promptCalls).toHaveLength(0) + expect(fake.interruptCalls).toContain('ses_walk_late') + const state = await service.getState(SESSION_ID) + expect(state.walkthrough?.status).toBe('cancelled') }) - it('skips queued stop explanations once the session is deleted', async () => { - sessions[SESSION_ID]!.changes = LARGE_CHANGES - const planStops = Array.from({ length: 8 }, (_, index) => { - const file = index < 4 ? 'src/a.ts' : 'src/b.ts' - const hunk = index < 4 ? index : index - 4 + 10 - return { title: `S${index}`, hunkIds: [computeHunkId(file, 'modified', bigHunk(hunk))] } - }) - const explainResolvers: Array<(text: string) => void> = [] - fake.setGenerateImpl((prompt) => { - if (prompt.includes(PLAN_MARKER)) { - return Promise.resolve(planReply(planStops)) - } - return new Promise((resolve) => { - explainResolvers.push(resolve) - }) + it('keeps the cancelled status when the interrupt event arrives during cancel', async () => { + await service.startGeneration(SESSION_ID, {}) + vi.mocked(fake.client.api.session.interrupt).mockImplementation(async ({ sessionID }) => { + service.handleEvent(sessionEvent('session.execution.interrupted', sessionID)) + return { interrupted: true } }) - const pending = service.generate(SESSION_ID, {}) - - await vi.waitFor(() => expect(explainResolvers).toHaveLength(4)) - service.handleEvent(sessionEvent('session.deleted', SESSION_ID)) - for (const resolve of explainResolvers) { - resolve(EXPLANATION_REPLY) - } + expect(await service.cancelGeneration(SESSION_ID)).toBe(true) - await expect(pending).rejects.toMatchObject({ status: 404 }) - expect(fake.generateCalls.filter((prompt) => prompt.includes('## Stop:'))).toHaveLength(4) + const state = await service.getState(SESSION_ID) + expect(state.walkthrough?.status).toBe('cancelled') + expect(state.error).toBeNull() }) - it('clears the cached hash and its epoch when the session is deleted', async () => { - fake.setGenerateImpl(async () => coveringReply) - await service.generate(SESSION_ID, {}) - await service.getState(SESSION_ID) - - const internals = service as unknown as { - currentHashes: Map - hashEpochs: Map - } - expect(internals.currentHashes.size).toBeGreaterThan(0) - - service.handleEvent(sessionEvent('session.deleted', SESSION_ID)) + it('restores the previous status when the refine prompt fails', async () => { + await service.startGeneration(SESSION_ID, {}) + writeArtifact(fake.createCalls[0]!) + await service.publishFromWalkthroughSession('ses_walk_1') + service.handleEvent(sessionEvent('session.execution.succeeded', 'ses_walk_1')) - expect(internals.currentHashes.size).toBe(0) - expect(internals.hashEpochs.size).toBe(0) - }) + vi.mocked(fake.client.api.session.prompt).mockRejectedValueOnce(new Error('prompt failed')) - it('does not track a hash epoch for a session with no cached hash', () => { - service.handleEvent(sessionEvent('session.execution.succeeded', SESSION_ID)) + await expect(service.refine(SESSION_ID, { text: 'more' })).rejects.toMatchObject({ status: 502 }) - const internals = service as unknown as { hashEpochs: Map } - expect(internals.hashEpochs.size).toBe(0) + const state = await service.getState(SESSION_ID) + expect(state.walkthrough?.status).toBe('ready') + expect(state.generating).toBe(false) + expect(state.running).toBe(false) }) - it('does not cache a hash from overlapping reads invalidated between completions', async () => { - const diffMock = vi.mocked(fake.client.api.session.diff) - const resolvers: Array<(changes: FileDiffInfo[]) => void> = [] - diffMock.mockImplementation(() => new Promise((resolve) => { - resolvers.push(resolve) - })) - - const first = service.getState(SESSION_ID) - const second = service.getState(SESSION_ID) - await vi.waitFor(() => expect(resolvers).toHaveLength(2)) - - service.handleEvent(sessionEvent('session.execution.succeeded', SESSION_ID)) - - resolvers[0]!(threeHunks) - resolvers[1]!(threeHunks) - await Promise.all([first, second]) + it('marks failed when the walkthrough session finishes without publishing', async () => { + await service.startGeneration(SESSION_ID, {}) - const internals = service as unknown as { - currentHashes: Map - hashEpochs: Map - } - expect(internals.currentHashes.size).toBe(0) - expect(internals.hashEpochs.size).toBe(0) + service.handleEvent(sessionEvent('session.execution.succeeded', 'ses_walk_1')) - diffMock.mockResolvedValue([change('src/a.ts', hunkPatch(99))]) - const refreshed = await service.getState(SESSION_ID) - expect(refreshed.currentDiffHash).toBe(computeChangesHash([change('src/a.ts', hunkPatch(99))])) + const state = await service.getState(SESSION_ID) + expect(state.walkthrough?.status).toBe('failed') + expect(state.error).toMatchObject({ code: 'WALKTHROUGH_NOT_PUBLISHED' }) }) - it('drops a hash read that completes after the session is deleted', async () => { - const diffMock = vi.mocked(fake.client.api.session.diff) - let resolveDiff: (changes: FileDiffInfo[]) => void = () => {} - diffMock.mockImplementationOnce(() => new Promise((resolve) => { - resolveDiff = resolve - })) - - const pending = service.getState(SESSION_ID) - await vi.waitFor(() => expect(diffMock).toHaveBeenCalledTimes(1)) + it('keeps ready when the walkthrough session finishes after publishing', async () => { + await service.startGeneration(SESSION_ID, {}) + writeArtifact(fake.createCalls[0]!) + await service.publishFromWalkthroughSession('ses_walk_1') - service.handleEvent(sessionEvent('session.deleted', SESSION_ID)) - resolveDiff(threeHunks) - await pending + service.handleEvent(sessionEvent('session.execution.succeeded', 'ses_walk_1')) - const internals = service as unknown as { - currentHashes: Map - hashEpochs: Map - pendingHashReads: Map - } - expect(internals.currentHashes.size).toBe(0) - expect(internals.hashEpochs.size).toBe(0) - expect(internals.pendingHashReads.size).toBe(0) + const state = await service.getState(SESSION_ID) + expect(state.walkthrough?.status).toBe('ready') + expect(state.error).toBeNull() + expect(state.running).toBe(false) }) - }) - - describe('per-source storage', () => { - it('stores walkthroughs per source', async () => { - const repo = createChangedRepo() - sessions[SESSION_ID] = { changes: threeHunks, info: sessionAt(repo) } - fake.setGenerateImpl(async (prompt) => replyForPrompt(prompt)) - await service.generate(SESSION_ID, {}) - await service.generate(SESSION_ID, { source: { kind: 'uncommitted' } }) - - const sessionStored = getChangeWalkthrough(db, SESSION_ID, 'session') - const uncommittedStored = getChangeWalkthrough(db, SESSION_ID, 'uncommitted') - expect(sessionStored).not.toBeNull() - expect(uncommittedStored).not.toBeNull() - expect(sessionStored!.diffHash).not.toBe(uncommittedStored!.diffHash) - expect(uncommittedStored!.source).toEqual({ kind: 'uncommitted' }) + it('keeps the agent running after publishing and lets the timer settle it', async () => { + vi.useFakeTimers() + try { + const timed = new ChangeWalkthroughService( + db, + fake.client, + new SettingsService(db), + createGitAuthService(), + gitService, + { walkthroughsRoot, generationTimeoutMs: 1000 }, + ) + await timed.startGeneration(SESSION_ID, {}) + writeArtifact(fake.createCalls[0]!) + + await timed.publishFromWalkthroughSession('ses_walk_1') + + const published = await timed.getState(SESSION_ID) + expect(published.walkthrough?.status).toBe('ready') + expect(published.generating).toBe(false) + expect(published.running).toBe(true) + + await vi.advanceTimersByTimeAsync(1000) + + const settled = await timed.getState(SESSION_ID) + expect(settled.walkthrough?.status).toBe('ready') + expect(settled.generating).toBe(false) + expect(settled.running).toBe(false) + expect(fake.interruptCalls).toContain('ses_walk_1') + } finally { + vi.useRealTimers() + } }) - it('deletes every source when the session is deleted', async () => { - const repo = createChangedRepo() - sessions[SESSION_ID] = { changes: threeHunks, info: sessionAt(repo) } - fake.setGenerateImpl(async (prompt) => replyForPrompt(prompt)) - - await service.generate(SESSION_ID, {}) - await service.generate(SESSION_ID, { source: { kind: 'uncommitted' } }) + it('cancels a published walkthrough that is still running', async () => { + await service.startGeneration(SESSION_ID, {}) + writeArtifact(fake.createCalls[0]!) + await service.publishFromWalkthroughSession('ses_walk_1') - service.handleEvent(sessionEvent('session.deleted', SESSION_ID)) + expect(await service.cancelGeneration(SESSION_ID)).toBe(true) - expect(getChangeWalkthrough(db, SESSION_ID, 'session')).toBeNull() - expect(getChangeWalkthrough(db, SESSION_ID, 'uncommitted')).toBeNull() + const state = await service.getState(SESSION_ID) + expect(state.walkthrough?.status).toBe('ready') + expect(state.generating).toBe(false) + expect(state.running).toBe(false) + expect(fake.interruptCalls).toContain('ses_walk_1') }) - }) - - describe('pull request sources', () => { - it('does not fetch on GET and tolerates a missing pull request ref', async () => { - const repo = createChangedRepo() - sessions[SESSION_ID] = { changes: threeHunks, info: sessionAt(repo) } - - const state = await service.getState(SESSION_ID, { kind: 'pullRequest', number: 1, base: 'main' }) - expect(fetchRemoteRef).not.toHaveBeenCalled() - expect(state.error).toBeNull() - expect(state.currentDiffHash).toBeNull() - expect(state.stale).toBe(false) - }) + it('rejects a refine while the agent is still running after publishing', async () => { + await service.startGeneration(SESSION_ID, {}) + writeArtifact(fake.createCalls[0]!) + await service.publishFromWalkthroughSession('ses_walk_1') - it('fetches once during generation, reads the ref on GET, and deletes it with the session', async () => { - const repo = createPullRequestRepo() - sessions[SESSION_ID] = { changes: threeHunks, info: sessionAt(repo) } - fetchRemoteRef.mockImplementation(async (target: { fullPath: string }, remote: string, refspecs: string[]) => { - git(['fetch', remote, ...refspecs], target.fullPath) + await expect(service.refine(SESSION_ID, { text: 'more' })).rejects.toMatchObject({ + status: 409, + code: 'WALKTHROUGH_BUSY', }) - fake.setGenerateImpl(async (prompt) => replyForPrompt(prompt)) - - const headRef = `refs/ocm-walkthrough/${SESSION_ID}/pr/1/head` - const baseRef = `refs/ocm-walkthrough/${SESSION_ID}/pr/1/base/main` - - await service.generate(SESSION_ID, { source: { kind: 'pullRequest', number: 1, base: 'main' } }) - - expect(fetchRemoteRef).toHaveBeenCalledTimes(1) - expect(refExists(repo, headRef)).toBe(true) - expect(refExists(repo, baseRef)).toBe(true) - - fetchRemoteRef.mockClear() - const state = await service.getState(SESSION_ID, { kind: 'pullRequest', number: 1, base: 'main' }) + }) - expect(fetchRemoteRef).not.toHaveBeenCalled() - expect(state.error).toBeNull() - expect(state.currentDiffHash).not.toBeNull() + it('interrupts a published walkthrough session when the target session is deleted', async () => { + await service.startGeneration(SESSION_ID, {}) + writeArtifact(fake.createCalls[0]!) + await service.publishFromWalkthroughSession('ses_walk_1') + const before = fake.interruptCalls.length service.handleEvent(sessionEvent('session.deleted', SESSION_ID)) - await vi.waitFor(() => { - expect(refExists(repo, headRef)).toBe(false) - expect(refExists(repo, baseRef)).toBe(false) - }) + expect(fake.interruptCalls).toHaveLength(before + 1) + expect(fake.interruptCalls).toContain('ses_walk_1') }) - it('keeps another session refs when one session is deleted', async () => { - const repo = createPullRequestRepo() - const secondSessionId = 'ses_walkthrough_2' - sessions[SESSION_ID] = { changes: threeHunks, info: sessionAt(repo) } - sessions[secondSessionId] = { changes: threeHunks, info: sessionAt(repo, secondSessionId) } - fetchRemoteRef.mockImplementation(async (target: { fullPath: string }, remote: string, refspecs: string[]) => { - git(['fetch', remote, ...refspecs], target.fullPath) - }) - fake.setGenerateImpl(async (prompt) => replyForPrompt(prompt)) - - await service.generate(SESSION_ID, { source: { kind: 'pullRequest', number: 1, base: 'main' } }) - await service.generate(secondSessionId, { source: { kind: 'pullRequest', number: 1, base: 'main' } }) - - const firstHead = `refs/ocm-walkthrough/${SESSION_ID}/pr/1/head` - const firstBase = `refs/ocm-walkthrough/${SESSION_ID}/pr/1/base/main` - const secondHead = `refs/ocm-walkthrough/${secondSessionId}/pr/1/head` - const secondBase = `refs/ocm-walkthrough/${secondSessionId}/pr/1/base/main` + it('reuses the changes snapshot during a run and re-reads after refine', async () => { + await service.startGeneration(SESSION_ID, {}) + const diffMock = vi.mocked(fake.client.api.session.diff) + expect(diffMock).toHaveBeenCalledTimes(1) - expect(firstHead).not.toBe(secondHead) - expect(refExists(repo, firstHead)).toBe(true) - expect(refExists(repo, secondHead)).toBe(true) + await service.readChangesForWalkthroughSession('ses_walk_1') + await service.readChangesForWalkthroughSession('ses_walk_1', 'src/a.ts') + expect(diffMock).toHaveBeenCalledTimes(1) - service.handleEvent(sessionEvent('session.deleted', SESSION_ID)) + service.handleEvent(sessionEvent('session.execution.succeeded', 'ses_walk_1')) + await service.refine(SESSION_ID, { text: 'more' }) - await vi.waitFor(() => { - expect(refExists(repo, firstHead)).toBe(false) - expect(refExists(repo, firstBase)).toBe(false) - }) - expect(refExists(repo, secondHead)).toBe(true) - expect(refExists(repo, secondBase)).toBe(true) + await service.readChangesForWalkthroughSession('ses_walk_1') + expect(diffMock).toHaveBeenCalledTimes(2) }) - it('prefers the upstream remote when generating', async () => { - const repo = createPullRequestRepo() - const originUrl = git(['remote', 'get-url', 'origin'], repo) - git(['remote', 'add', 'upstream', originUrl], repo) - sessions[SESSION_ID] = { changes: threeHunks, info: sessionAt(repo) } - fake.setGenerateImpl(async (prompt) => replyForPrompt(prompt)) - - await service - .generate(SESSION_ID, { source: { kind: 'pullRequest', number: 1, base: 'main' } }) - .catch(() => {}) + it('does not retain a changes snapshot when the run settles during the read', async () => { + await service.startGeneration(SESSION_ID, {}) + const internals = service as unknown as { changesSnapshots: Map } + internals.changesSnapshots.clear() - expect(fetchRemoteRef).toHaveBeenCalledTimes(1) - expect(fetchRemoteRef.mock.calls[0]?.[1]).toBe('upstream') - }) - }) + const diffMock = vi.mocked(fake.client.api.session.diff) + let resolveDiff: (changes: FileDiffInfo[]) => void = () => {} + diffMock.mockImplementationOnce(() => new Promise((resolve) => { + resolveDiff = resolve + })) - describe('progressive persistence and in-memory state', () => { - it('writes once after the plan, every four explained stops, and at the end', async () => { - sessions[SESSION_ID]!.changes = LARGE_CHANGES - const hunkIds = [ - ...[0, 1, 2, 3].map((index) => computeHunkId('src/a.ts', 'modified', bigHunk(index))), - ...[10, 11, 12, 13].map((index) => computeHunkId('src/b.ts', 'modified', bigHunk(index))), - ...[20, 21, 22, 23].map((index) => computeHunkId('src/c.ts', 'modified', bigHunk(index))), - ] - const planStops = Array.from({ length: 10 }, (_, index) => ({ title: `S${index}`, hunkIds: [] as string[] })) - hunkIds.forEach((id, index) => { - planStops[Math.min(index, planStops.length - 1)]!.hunkIds.push(id) - }) + const pending = service.readChangesForWalkthroughSession('ses_walk_1') + await vi.waitFor(() => expect(diffMock).toHaveBeenCalledTimes(2)) - fake.setGenerateImpl((prompt) => - Promise.resolve(prompt.includes(PLAN_MARKER) ? planReply(planStops) : EXPLANATION_REPLY), - ) + service.handleEvent(sessionEvent('session.execution.succeeded', 'ses_walk_1')) + expect(internals.changesSnapshots.has('ses_walk_1')).toBe(false) - const prepare = vi.spyOn(db, 'prepare') - try { - const { walkthrough } = await service.generate(SESSION_ID, {}) + resolveDiff(THREE_HUNKS) + await pending - expect(walkthrough.stops).toHaveLength(10) - expect(walkthrough.stops.every((stop) => stop.status === 'ready')).toBe(true) + expect(internals.changesSnapshots.has('ses_walk_1')).toBe(false) - const writes = prepare.mock.calls.filter( - ([sql]) => typeof sql === 'string' && sql.includes('INSERT INTO change_walkthroughs'), - ).length - expect(writes).toBe(4) - } finally { - prepare.mockRestore() - } + await service.readChangesForWalkthroughSession('ses_walk_1') + expect(diffMock).toHaveBeenCalledTimes(3) }) - it('serves an in-progress walkthrough from memory after its row is removed', async () => { - sessions[SESSION_ID]!.changes = LARGE_CHANGES - const explainResolvers: Array<(text: string) => void> = [] - fake.setGenerateImpl((prompt) => { - if (prompt.includes(PLAN_MARKER)) { - return Promise.resolve(LARGE_PLAN_REPLY) - } - return new Promise((resolve) => { - explainResolvers.push(resolve) - }) - }) - - const pending = service.generate(SESSION_ID, {}) - - await vi.waitFor(async () => { - const state = await service.getState(SESSION_ID) - expect(state.walkthrough?.stops).toHaveLength(3) - expect(state.walkthrough?.stops.every((stop) => stop.status === 'pending')).toBe(true) - }) + it('leaves a cancelled record unchanged when the interrupted event arrives', async () => { + await service.startGeneration(SESSION_ID, {}) + await service.cancelGeneration(SESSION_ID) - deleteChangeWalkthroughs(db, SESSION_ID) + service.handleEvent(sessionEvent('session.execution.interrupted', 'ses_walk_1')) const state = await service.getState(SESSION_ID) - expect(state.walkthrough?.stops).toHaveLength(3) + expect(state.walkthrough?.status).toBe('cancelled') + }) - await vi.waitFor(() => expect(explainResolvers).toHaveLength(3)) - for (const resolve of explainResolvers) { - resolve(EXPLANATION_REPLY) + it('expires a generating record when the timer fires and interrupts the session', async () => { + vi.useFakeTimers() + try { + const timed = new ChangeWalkthroughService( + db, + fake.client, + new SettingsService(db), + createGitAuthService(), + gitService, + { walkthroughsRoot, generationTimeoutMs: 1000 }, + ) + await timed.startGeneration(SESSION_ID, {}) + + await vi.advanceTimersByTimeAsync(1000) + + const state = await timed.getState(SESSION_ID) + expect(state.generating).toBe(false) + expect(state.error).toMatchObject({ code: 'WALKTHROUGH_TIMEOUT' }) + expect(fake.interruptCalls).toContain('ses_walk_1') + } finally { + vi.useRealTimers() } - await pending }) - it('resumes a walkthrough row saved after the plan', async () => { - sessions[SESSION_ID]!.changes = LARGE_CHANGES - const input = buildWalkthroughInput(LARGE_CHANGES) - const savedStops = input.hunks.slice(0, 3).map((hunk, index) => ({ - id: computeStopId([hunk.id]), - title: `S${index}`, - explanation: '', - hunkIds: [hunk.id], - status: 'pending' as const, - explanationKey: null, - })) - saveChangeWalkthrough(db, { - sessionId: SESSION_ID, - source: { kind: 'session' }, - diffHash: computeChangesHash(LARGE_CHANGES), - model: null, - summary: 'Saved plan', - stops: savedStops, - hunks: input.hunks, - omittedFiles: [], - createdAt: 1, - }) - fake.setGenerateImpl((prompt) => Promise.resolve(explanationFor(prompt))) - - const stored = getChangeWalkthrough(db, SESSION_ID, 'session') - expect(stored?.diffHash).toBe(computeChangesHash(LARGE_CHANGES)) - expect(stored?.stops).toHaveLength(3) + it('refines a ready walkthrough by prompting the same session', async () => { + await service.startGeneration(SESSION_ID, {}) + writeArtifact(fake.createCalls[0]!) + await service.publishFromWalkthroughSession('ses_walk_1') + service.handleEvent(sessionEvent('session.execution.succeeded', 'ses_walk_1')) + const before = fake.promptCalls.length - const { walkthrough, created } = await service.generate(SESSION_ID, {}) + const state = await service.refine(SESSION_ID, { text: 'Explain the risk section' }) - expect(created).toBe(true) - expect(walkthrough.stops).toHaveLength(3) - expect(walkthrough.stops.every((stop) => stop.status === 'ready')).toBe(true) - expect(fake.generateCalls.filter((prompt) => prompt.includes(PLAN_MARKER))).toHaveLength(0) - expect(fake.generateCalls).toHaveLength(3) + expect(fake.promptCalls).toHaveLength(before + 1) + expect(fake.promptCalls.at(-1)!.sessionID).toBe('ses_walk_1') + expect(fake.promptCalls.at(-1)!.text).toContain('Explain the risk section') + expect(state.generating).toBe(true) + expect(state.running).toBe(true) + expect(state.walkthrough?.status).toBe('generating') }) - }) - describe('global model call limiter', () => { - it('never exceeds four in-flight model calls across concurrent generations', async () => { - const secondSession = 'ses_walkthrough_second' - sessions[SESSION_ID] = { changes: LARGE_CHANGES } - sessions[secondSession] = { changes: LARGE_CHANGES } - - const planStops = Array.from({ length: 8 }, (_, index) => { - const file = index < 4 ? 'src/a.ts' : 'src/b.ts' - const hunk = index < 4 ? index : index - 4 + 10 - return { title: `S${index}`, hunkIds: [computeHunkId(file, 'modified', bigHunk(hunk))] } - }) + it('rejects a refine while the walkthrough is generating', async () => { + await service.startGeneration(SESSION_ID, {}) - let inFlight = 0 - let maxInFlight = 0 - fake.setGenerateImpl(async (prompt) => { - inFlight += 1 - maxInFlight = Math.max(maxInFlight, inFlight) - await new Promise((resolve) => setTimeout(resolve, 2)) - inFlight -= 1 - return prompt.includes(PLAN_MARKER) ? planReply(planStops) : EXPLANATION_REPLY + await expect(service.refine(SESSION_ID, { text: 'more' })).rejects.toMatchObject({ + status: 409, + code: 'WALKTHROUGH_BUSY', }) - - await Promise.all([service.generate(SESSION_ID, {}), service.generate(secondSession, {})]) - - expect(maxInFlight).toBeLessThanOrEqual(4) - expect(maxInFlight).toBeGreaterThanOrEqual(2) }) - it('skips a queued explain call when the session is deleted while waiting', async () => { - const victim = 'ses_walkthrough_victim' - const victimChanges = [ - change('victim/a.ts', Array.from({ length: 8 }, (_, index) => bigHunk(index)).join('\n')), - ] - const victimPlan = planReply( - Array.from({ length: 4 }, (_, group) => ({ - title: `V${group}`, - hunkIds: [group * 2, group * 2 + 1].map((hunk) => - computeHunkId('victim/a.ts', 'modified', bigHunk(hunk)), - ), - })), - ) - - sessions[SESSION_ID] = { changes: LARGE_CHANGES } - sessions[victim] = { changes: victimChanges } - - let saturatorExplainStarted = 0 - let victimExplainStarted = 0 - const saturatorResolvers: Array<(text: string) => void> = [] - const victimResolvers: Array<(text: string) => void> = [] - - fake.setGenerateImpl((prompt) => { - if (prompt.includes('victim/')) { - if (prompt.includes('## Stop:')) { - victimExplainStarted += 1 - return new Promise((resolve) => { - victimResolvers.push(resolve) - }) - } - return Promise.resolve(victimPlan) - } - if (prompt.includes(PLAN_MARKER)) { - return Promise.resolve(LARGE_PLAN_REPLY) - } - saturatorExplainStarted += 1 - return new Promise((resolve) => { - saturatorResolvers.push(resolve) - }) + it('rejects a refine when no walkthrough session exists', async () => { + await expect(service.refine(SESSION_ID, { text: 'more' })).rejects.toMatchObject({ + status: 409, + code: 'WALKTHROUGH_NOT_STARTED', }) - - const saturator = service.generate(SESSION_ID, {}) - await vi.waitFor(() => expect(saturatorExplainStarted).toBe(3)) - - const victimRun = service.generate(victim, {}) - await vi.waitFor(() => expect(victimExplainStarted).toBe(1)) - - service.handleEvent(sessionEvent('session.deleted', victim)) - - for (const resolve of saturatorResolvers) { - resolve(EXPLANATION_REPLY) - } - for (const resolve of victimResolvers) { - resolve(EXPLANATION_REPLY) - } - - await saturator - await expect(victimRun).rejects.toMatchObject({ status: 404 }) - expect(victimExplainStarted).toBe(1) }) - }) - describe('cancelGeneration', () => { - it('stops a pending generation without recording an error', async () => { - fake.setGenerateImpl(() => new Promise(() => {})) - - const pending = service.generate(SESSION_ID, {}) - await vi.waitFor(() => expect(fake.generateCalls).toHaveLength(1)) + it('marks failed when the walkthrough session is deleted while generating', async () => { + await service.startGeneration(SESSION_ID, {}) - expect(service.cancelGeneration(SESSION_ID)).toBe(true) - await expect(pending).rejects.toBeInstanceOf(WalkthroughCancelledError) + service.handleEvent(sessionEvent('session.deleted', 'ses_walk_1')) const state = await service.getState(SESSION_ID) - expect(state.generating).toBe(false) - expect(state.error).toBeNull() - }) - - it('returns false when nothing is generating', () => { - expect(service.cancelGeneration(SESSION_ID)).toBe(false) + expect(state.walkthrough?.status).toBe('failed') + expect(state.error).toMatchObject({ code: 'WALKTHROUGH_SESSION_MISSING' }) + expect(state.running).toBe(false) + expect(getChangeWalkthrough(db, SESSION_ID, 'session')).not.toBeNull() }) - it('keeps explained stops and persists the partial walkthrough', async () => { - sessions[SESSION_ID]!.changes = LARGE_CHANGES - fake.setGenerateImpl((prompt) => { - if (prompt.includes(PLAN_MARKER)) { - return Promise.resolve(LARGE_PLAN_REPLY) - } - return new Promise(() => {}) - }) - - const pending = service.generate(SESSION_ID, {}) - - await vi.waitFor(async () => { - const state = await service.getState(SESSION_ID) - expect(state.generating).toBe(true) - expect(state.walkthrough?.stops).toHaveLength(3) - expect(state.walkthrough?.stops.every((stop) => stop.status === 'pending')).toBe(true) - }) - - expect(service.cancelGeneration(SESSION_ID)).toBe(true) + it('settles a published walkthrough and clears its timer when the walkthrough session is deleted', async () => { + await service.startGeneration(SESSION_ID, {}) + writeArtifact(fake.createCalls[0]!) + await service.publishFromWalkthroughSession('ses_walk_1') + const internals = service as unknown as { timers: Map> } + expect(internals.timers.size).toBe(1) - await vi.waitFor(async () => { - expect((await service.getState(SESSION_ID)).generating).toBe(false) - }) + service.handleEvent(sessionEvent('session.deleted', 'ses_walk_1')) const state = await service.getState(SESSION_ID) - expect(state.error).toBeNull() - const stored = getChangeWalkthrough(db, SESSION_ID, 'session') - expect(stored).not.toBeNull() - expect(stored!.stops.every((stop) => stop.status === 'pending')).toBe(true) - - await pending - }) - - it('aborts only the requested source', async () => { - fake.setGenerateImpl(() => new Promise(() => {})) - - const pending = service.generate(SESSION_ID, {}) - await vi.waitFor(() => expect(fake.generateCalls).toHaveLength(1)) - - expect(service.cancelGeneration(SESSION_ID, { kind: 'staged' })).toBe(false) - expect((await service.getState(SESSION_ID)).generating).toBe(true) - - expect(service.cancelGeneration(SESSION_ID)).toBe(true) - await expect(pending).rejects.toBeInstanceOf(WalkthroughCancelledError) + expect(state.walkthrough?.status).toBe('ready') + expect(state.generating).toBe(false) + expect(state.running).toBe(false) + expect(internals.timers.size).toBe(0) + expect(getChangeWalkthrough(db, SESSION_ID, 'session')).not.toBeNull() }) - }) - - describe('cancellation before the model call', () => { - it('does not persist a mechanical-only walkthrough cancelled during the changes read', async () => { - sessions[SESSION_ID]!.info = { - id: SESSION_ID, - title: 'Title', - model: { providerID: 'anthropic', id: 'claude-sonnet-4' }, - } as SessionInfo - fake.setGenerateImpl(async () => modelReply([{ title: 'A', explanation: 'x', hunkIds: THREE_HUNK_IDS }])) - const previous = await service.generate(SESSION_ID, {}) - expect(previous.created).toBe(true) - - const mechanical = [change('pnpm-lock.yaml', hunkPatch(1))] - sessions[SESSION_ID]!.changes = mechanical - const diff = createDeferred() - const diffMock = vi.mocked(fake.client.api.session.diff) - diffMock.mockImplementationOnce(() => diff.promise) - const pending = service.generate(SESSION_ID, {}) - await vi.waitFor(() => expect(diffMock).toHaveBeenCalledTimes(2)) + it('removes the walkthrough directory when the target session is deleted', async () => { + await service.startGeneration(SESSION_ID, {}) + const targetRoot = path.join(walkthroughsRoot, SESSION_ID) + expect(existsSync(targetRoot)).toBe(true) - expect(service.cancelGeneration(SESSION_ID)).toBe(true) - diff.resolve(mechanical) + service.handleEvent(sessionEvent('session.deleted', SESSION_ID)) - await expect(pending).rejects.toBeInstanceOf(WalkthroughCancelledError) - expect(fake.generateCalls).toHaveLength(1) - expect(getChangeWalkthrough(db, SESSION_ID, 'session')).toEqual(previous.walkthrough) + await vi.waitFor(() => expect(existsSync(targetRoot)).toBe(false)) }) - it('cancels while resolving the preferred model without persisting the mechanical-only changes', async () => { - sessions[SESSION_ID]!.info = { - id: SESSION_ID, - title: 'Title', - model: { providerID: 'anthropic', id: 'claude-sonnet-4' }, - } as SessionInfo - sessions[SESSION_ID]!.changes = [change('pnpm-lock.yaml', hunkPatch(1))] - - const config = createDeferred>>() - const configMock = vi.mocked(fake.client.api.config.get) - configMock.mockImplementationOnce(() => config.promise) + it('returns the outline, a file hunk and 404 for an unknown file', async () => { + await service.startGeneration(SESSION_ID, {}) - const pending = service.generate(SESSION_ID, {}) - await vi.waitFor(() => expect(configMock).toHaveBeenCalledTimes(1)) + const outline = await service.readChangesForWalkthroughSession('ses_walk_1') + expect(outline).toContain('## Outline') + expect(outline).toContain('src/a.ts') - expect(service.cancelGeneration(SESSION_ID)).toBe(true) - config.resolve([]) + const fileText = await service.readChangesForWalkthroughSession('ses_walk_1', 'src/a.ts') + expect(fileText).toContain('const a1 = 2') - await expect(pending).rejects.toBeInstanceOf(WalkthroughCancelledError) - expect(fake.generateCalls).toHaveLength(0) - expect(getChangeWalkthrough(db, SESSION_ID, 'session')).toBeNull() - expect((await service.getState(SESSION_ID)).error).toBeNull() + await expect(service.readChangesForWalkthroughSession('ses_walk_1', 'nope.ts')).rejects.toMatchObject({ + status: 404, + code: 'WALKTHROUGH_FILE_NOT_CHANGED', + }) }) - it('cancels while resolving the default model without a 502 failure', async () => { - const config = createDeferred>>() - const configMock = vi.mocked(fake.client.api.config.get) - configMock.mockImplementationOnce(() => config.promise) - - const pending = service.generate(SESSION_ID, {}) - await vi.waitFor(() => expect(configMock).toHaveBeenCalledTimes(1)) - - expect(service.cancelGeneration(SESSION_ID)).toBe(true) - config.resolve([]) - - await expect(pending).rejects.toBeInstanceOf(WalkthroughCancelledError) - expect(fake.generateCalls).toHaveLength(0) - expect((await service.getState(SESSION_ID)).error).toBeNull() + it('rejects reading changes for an unknown walkthrough session', async () => { + await expect(service.readChangesForWalkthroughSession('ses_other')).rejects.toMatchObject({ + status: 404, + code: 'WALKTHROUGH_NOT_A_WALKTHROUGH_SESSION', + }) }) }) }) diff --git a/backend/test/services/opencode-manager-tool-plugin.test.ts b/backend/test/services/opencode-manager-tool-plugin.test.ts index 65c3e5903..3b068d16f 100644 --- a/backend/test/services/opencode-manager-tool-plugin.test.ts +++ b/backend/test/services/opencode-manager-tool-plugin.test.ts @@ -65,6 +65,14 @@ function artifactParamsSchema(tool: GeneratedTool): JsonSchema { return toolInputSchema(tool).properties?.params?.anyOf?.[3] ?? {} } +function changesParamsSchema(tool: GeneratedTool): JsonSchema { + return toolInputSchema(tool).properties?.params?.anyOf?.[4] ?? {} +} + +function publishParamsSchema(tool: GeneratedTool): JsonSchema { + return toolInputSchema(tool).properties?.params?.anyOf?.[5] ?? {} +} + async function runToolResult( tool: GeneratedTool, input: unknown, @@ -170,8 +178,8 @@ describe('ocm-manager plugin', () => { expect(schema.type).toBe('object') expect(schema.required).toEqual(['action', 'params']) expect(schema.additionalProperties).toBe(false) - expect(schema.properties?.action?.enum).toEqual(['send_notification', 'request', 'open_walkthrough', 'show_artifact']) - expect(schema.properties?.params?.anyOf).toHaveLength(4) + expect(schema.properties?.action?.enum).toEqual(['send_notification', 'request', 'open_walkthrough', 'show_artifact', 'walkthrough_changes', 'publish_walkthrough']) + expect(schema.properties?.params?.anyOf).toHaveLength(6) expect(notificationParamsSchema(tool).required).toEqual(['title', 'body']) expect(requestParamsSchema(tool).required).toEqual(['method', 'path']) expect(walkthroughParamsSchema(tool).required).toBeUndefined() @@ -190,16 +198,30 @@ describe('ocm-manager plugin', () => { 'branch', 'pullRequest', ]) + expect(changesParamsSchema(tool).required).toBeUndefined() + expect(changesParamsSchema(tool).additionalProperties).toBe(false) + expect(changesParamsSchema(tool).properties?.file?.maxLength).toBe(1000) + expect(changesParamsSchema(tool).properties?.file?.minLength).toBe(1) + expect(publishParamsSchema(tool).required).toBeUndefined() + expect(publishParamsSchema(tool).additionalProperties).toBe(false) }) it('lists the open_walkthrough action in the description', async () => { const tool = await loadTool(configHome) expect(tool.description).toContain('open_walkthrough') - expect(tool.description).toContain('request the change Walkthrough for the current session') + expect(tool.description).toContain('start a separate walkthrough session') expect(tool.description).toContain('when this session is open on desktop') }) + it('lists the walkthrough data actions in the description', async () => { + const tool = await loadTool(configHome) + + expect(tool.description).toContain('walkthrough_changes') + expect(tool.description).toContain('publish_walkthrough') + expect(tool.description).toContain('only valid inside a walkthrough session') + }) + it('lists the show_artifact action in the description', async () => { const tool = await loadTool(configHome) @@ -316,7 +338,7 @@ describe('ocm-manager plugin', () => { expect(init.headers['content-type']).toBe('application/json') expect(JSON.parse(init.body)).toEqual({ source: { kind: 'staged' } }) expect(result.content).toBe( - 'Requested the walkthrough (2 stops). It opens in the Manager window when this session is open on desktop; otherwise the user can open it from this tool call.', + 'The walkthrough is ready. It opens in the Manager window when this session is open on desktop; otherwise the user can open it from this tool call.', ) expect(result.metadata).toBeUndefined() }) @@ -328,7 +350,7 @@ describe('ocm-manager plugin', () => { const result = await runToolResult(tool, { action: 'open_walkthrough', params: {} }, { sessionID: 'ses_abc' }) expect(result.content).toBe( - 'Requested the walkthrough; it is generating. It opens in the Manager window when this session is open on desktop; otherwise the user can open it from this tool call.', + 'Started a walkthrough session that builds an interactive walkthrough. It opens in the Manager window when this session is open on desktop; otherwise the user can open it from this tool call.', ) expect(result.metadata).toBeUndefined() }) @@ -347,6 +369,53 @@ describe('ocm-manager plugin', () => { expect(init.body).toBeUndefined() }) + it('requests the outline through walkthrough_changes with no body', async () => { + const fetchMock = jsonResponse({ text: '## Outline' }) + vi.stubGlobal('fetch', fetchMock) + const tool = await loadTool(configHome) + + await expect(runTool(tool, { action: 'walkthrough_changes', params: {} }, { sessionID: 'ses_abc' })).resolves.toBe('## Outline') + + expect(fetchMock).toHaveBeenCalledTimes(1) + const [url, init] = fetchMock.mock.calls[0] ?? [] + expect(url).toBe('http://localhost:5003/api/internal/walkthrough-sessions/ses_abc/changes') + expect(init.method).toBe('POST') + expect(init.headers.Authorization).toBe('Bearer secret-token') + expect(init.headers['content-type']).toBeUndefined() + expect(init.body).toBeUndefined() + }) + + it('requests one file through walkthrough_changes', async () => { + const fetchMock = jsonResponse({ text: 'diff' }) + vi.stubGlobal('fetch', fetchMock) + const tool = await loadTool(configHome) + + await runTool(tool, { action: 'walkthrough_changes', params: { file: 'src/a.ts' } }, { sessionID: 'ses_abc' }) + + expect(fetchMock).toHaveBeenCalledTimes(1) + const [url, init] = fetchMock.mock.calls[0] ?? [] + expect(url).toBe('http://localhost:5003/api/internal/walkthrough-sessions/ses_abc/changes') + expect(init.headers['content-type']).toBe('application/json') + expect(JSON.parse(init.body)).toEqual({ file: 'src/a.ts' }) + }) + + it('publishes the walkthrough through the internal API and reports the path', async () => { + const fetchMock = jsonResponse({ path: '/workspace/walkthroughs/ses_abc/gen/index.html' }) + vi.stubGlobal('fetch', fetchMock) + const tool = await loadTool(configHome) + + const result = await runToolResult(tool, { action: 'publish_walkthrough', params: {} }, { sessionID: 'ses_abc' }) + + expect(fetchMock).toHaveBeenCalledTimes(1) + const [url, init] = fetchMock.mock.calls[0] ?? [] + expect(url).toBe('http://localhost:5003/api/internal/walkthrough-sessions/ses_abc/publish') + expect(init.method).toBe('POST') + expect(result.content).toBe( + 'Published the walkthrough from /workspace/walkthroughs/ses_abc/gen/index.html. The Manager walkthrough panel renders the current file; edit it and call publish_walkthrough again after significant changes.', + ) + expect(result.metadata).toEqual({ path: '/workspace/walkthroughs/ses_abc/gen/index.html' }) + }) + it('resolves the artifact file through the internal API and reports the resolved path', async () => { const fetchMock = jsonResponse({ path: '/workspace/repos/app/report.html' }) vi.stubGlobal('fetch', fetchMock) @@ -442,6 +511,8 @@ describe('ocm-manager plugin', () => { it('allows every route in the exported allow list', async () => { const tool = await loadTool(configHome) + expect(MANAGER_TOOL_ALLOWED_ROUTES.some((route) => route.includes('/walkthrough-sessions'))).toBe(false) + for (const route of MANAGER_TOOL_ALLOWED_ROUTES) { const { method, path: pattern } = parseAllowedRoute(route) const path = pattern.split('/').map((segment) => (segment === '*' ? 'x' : segment)).join('/') @@ -498,6 +569,8 @@ describe('ocm-manager plugin', () => { ['POST', '/multi-runs/1/entries/2/discard'], ['GET', '/change-walkthroughs/x'], ['POST', '/change-walkthroughs/x'], + ['POST', '/walkthrough-sessions/x/changes'], + ['POST', '/walkthrough-sessions/x/publish'], ] as const for (const [method, path] of deniedRoutes) { @@ -723,7 +796,7 @@ describe.skipIf(SHIPPED_OPENCODE_BIN === null)('ocm-manager plugin against the s expect(managerTool?.function?.parameters).toMatchObject({ type: 'object', properties: { - action: { type: 'string', enum: ['send_notification', 'request', 'open_walkthrough', 'show_artifact'] }, + action: { type: 'string', enum: ['send_notification', 'request', 'open_walkthrough', 'show_artifact', 'walkthrough_changes', 'publish_walkthrough'] }, params: { anyOf: [ { @@ -761,6 +834,17 @@ describe.skipIf(SHIPPED_OPENCODE_BIN === null)('ocm-manager plugin against the s path: { type: 'string', minLength: 1, maxLength: 1000 }, }, }, + { + type: 'object', + additionalProperties: false, + properties: { + file: { type: 'string', minLength: 1, maxLength: 1000 }, + }, + }, + { + type: 'object', + additionalProperties: false, + }, ], }, }, diff --git a/backend/test/services/opencode/generate-text.test.ts b/backend/test/services/opencode/generate-text.test.ts index bd0cc711e..688f6c145 100644 --- a/backend/test/services/opencode/generate-text.test.ts +++ b/backend/test/services/opencode/generate-text.test.ts @@ -77,16 +77,6 @@ describe('generateTextWithTimeout', () => { expect(calls[0]?.input.model).toEqual({ providerID: 'openai', id: 'gpt-5-mini' }) }) - it('forwards the model when provided without reading the catalog', async () => { - const { client, calls, modelList } = makeClient(async () => ({ text: 'hello' })) - const model: ModelRef = { providerID: 'anthropic', id: 'claude-sonnet-4' } - - await generateTextWithTimeout(client, { prompt: 'write', model }, 1000) - - expect(calls[0]?.input).toEqual({ prompt: 'write', model }) - expect(modelList).not.toHaveBeenCalled() - }) - it('rejects with GenerateTextTimeoutError and aborts the request after the timeout', async () => { vi.useFakeTimers() const { client, calls } = makeClient(() => new Promise(() => {})) diff --git a/backend/test/services/walkthrough-outline.test.ts b/backend/test/services/walkthrough-outline.test.ts new file mode 100644 index 000000000..b9bdac404 --- /dev/null +++ b/backend/test/services/walkthrough-outline.test.ts @@ -0,0 +1,532 @@ +import { describe, expect, it } from 'vitest' +import type { FileDiffInfo } from '@opencode-manager/shared/opencode' +import { + MECHANICAL_TEXT_BUDGET, + WALKTHROUGH_DIFF_MAX_CHARS, + WALKTHROUGH_HUNK_MAX_CHARS, + WALKTHROUGH_OUTLINE_LINE_MAX_CHARS, + WALKTHROUGH_OUTLINE_PREVIEW_LINES, + type WalkthroughHunk, +} from '@opencode-manager/shared/schemas' +import { + buildWalkthroughInput, + buildWalkthroughOutline, + computeChangesHash, + computeHunkId, + formatHunkBlock, + formatHunkOutline, + formatWalkthroughFileChanges, + formatWalkthroughOutline, + isMechanicalChangePath, +} from '../../src/services/walkthrough-outline' + +function change(file: string, patch: string, status: FileDiffInfo['status'] = 'modified'): FileDiffInfo { + return { file, patch, additions: 1, deletions: 1, status } +} + +function hunkPatch(line: number): string { + return `@@ -${line},2 +${line},2 @@\n-const a${line} = 1;\n+const a${line} = 2;` +} + +function bigHunk(index: number, size = WALKTHROUGH_HUNK_MAX_CHARS): string { + return `@@ -${index},1 +${index},1 @@\n+${'x'.repeat(size)}_${index}` +} + +function longHunk(index: number): string { + const lines = Array.from( + { length: 6 }, + (_, line) => `+${'x'.repeat(200)}_${index}_${line}`, + ) + return `@@ -${index + 1},6 +${index + 1},6 @@\n${lines.join('\n')}` +} + +function longPatch(count: number, offset = 0): string { + return Array.from({ length: count }, (_, index) => longHunk(offset + index)).join('\n') +} + +function hunkFixture(file: string, text: string, status: FileDiffInfo['status'] = 'modified'): WalkthroughHunk { + return { + id: computeHunkId(file, status, text), + file, + status, + header: text.split('\n')[0] ?? '', + text, + truncated: false, + } +} + +describe('computeChangesHash', () => { + it('is stable for identical changes', () => { + const changes = [change('src/a.ts', hunkPatch(1))] + expect(computeChangesHash(changes)).toBe(computeChangesHash(changes)) + }) + + it('changes when any field changes', () => { + const base = change('src/a.ts', hunkPatch(1)) + expect(computeChangesHash([base])).not.toBe(computeChangesHash([change('src/a.ts', hunkPatch(2))])) + expect(computeChangesHash([base])).not.toBe(computeChangesHash([change('src/b.ts', hunkPatch(1))])) + expect(computeChangesHash([base])).not.toBe( + computeChangesHash([change('src/a.ts', hunkPatch(1), 'added')]), + ) + }) + + it('is order sensitive', () => { + const a = change('src/a.ts', hunkPatch(1)) + const b = change('src/b.ts', hunkPatch(1)) + expect(computeChangesHash([a, b])).not.toBe(computeChangesHash([b, a])) + }) +}) + +describe('isMechanicalChangePath', () => { + it('matches lockfiles by basename', () => { + const lockfiles = [ + 'pnpm-lock.yaml', + 'package-lock.json', + 'npm-shrinkwrap.json', + 'yarn.lock', + 'bun.lock', + 'bun.lockb', + 'Cargo.lock', + 'Gemfile.lock', + 'composer.lock', + 'poetry.lock', + 'uv.lock', + 'Pipfile.lock', + 'go.sum', + 'flake.lock', + 'deep/nested/pnpm-lock.yaml', + ] + expect(lockfiles.every(isMechanicalChangePath)).toBe(true) + }) + + it('matches generated suffixes and snapshot directories', () => { + expect(isMechanicalChangePath('src/__snapshots__/a.ts.snap')).toBe(true) + expect(isMechanicalChangePath('src/__snapshots__/a.ts')).toBe(true) + expect(isMechanicalChangePath('dist/bundle.min.js')).toBe(true) + expect(isMechanicalChangePath('dist/app.min.css')).toBe(true) + expect(isMechanicalChangePath('dist/bundle.js.map')).toBe(true) + }) + + it('does not match ordinary source and config files', () => { + expect(isMechanicalChangePath('src/lock.ts')).toBe(false) + expect(isMechanicalChangePath('package.json')).toBe(false) + expect(isMechanicalChangePath('src/snapshots/a.ts')).toBe(false) + expect(isMechanicalChangePath('src/map.ts')).toBe(false) + }) +}) + +describe('buildWalkthroughInput', () => { + it('assigns a distinct content id and the file to each hunk', () => { + const { hunks, omittedFiles } = buildWalkthroughInput([ + change('src/a.ts', `${hunkPatch(1)}\n${hunkPatch(10)}`), + change('src/b.ts', hunkPatch(1)), + ]) + + expect(omittedFiles).toEqual([]) + expect(hunks.map((hunk) => hunk.file)).toEqual(['src/a.ts', 'src/a.ts', 'src/b.ts']) + const ids = hunks.map((hunk) => hunk.id) + expect(ids.every((id) => id.startsWith('h_'))).toBe(true) + expect(new Set(ids).size).toBe(3) + }) + + it('keeps a hunk id when an earlier hunk in the file changes', () => { + const before = buildWalkthroughInput([ + change( + 'src/a.ts', + '@@ -1,2 +1,2 @@\n-const a = 1;\n+const a = 2;\n@@ -10,2 +10,2 @@\n-const b = 1;\n+const b = 2;', + ), + ]) + const after = buildWalkthroughInput([ + change( + 'src/a.ts', + '@@ -1,3 +1,3 @@\n-const a = 1;\n+const a = 2;\n+const extra = 3;\n@@ -11,2 +11,2 @@\n-const b = 1;\n+const b = 2;', + ), + ]) + + expect(before.hunks).toHaveLength(2) + expect(after.hunks).toHaveLength(2) + expect(after.hunks[0]!.id).not.toBe(before.hunks[0]!.id) + expect(after.hunks[1]!.id).toBe(before.hunks[1]!.id) + }) + + it('suffixes duplicate hunk content', () => { + const { hunks } = buildWalkthroughInput([change('src/a.ts', `${hunkPatch(1)}\n${hunkPatch(1)}`)]) + + expect(hunks).toHaveLength(2) + expect(hunks[0]!.id).not.toBe(hunks[1]!.id) + expect(hunks[1]!.id).toBe(`${hunks[0]!.id}_2`) + }) + + it('omits files with no hunks as binary', () => { + const { hunks, omittedFiles } = buildWalkthroughInput([ + change('assets/logo.png', 'diff --git a/assets/logo.png b/assets/logo.png\nBinary files differ'), + change('src/a.ts', hunkPatch(1)), + ]) + + expect(hunks).toHaveLength(1) + expect(omittedFiles).toEqual([{ file: 'assets/logo.png', reason: 'binary' }]) + }) + + it('truncates an oversized hunk and marks it', () => { + const longLine = `+${'x'.repeat(WALKTHROUGH_HUNK_MAX_CHARS + 100)}` + const { hunks } = buildWalkthroughInput([change('src/a.ts', `@@ -1 +1 @@\n${longLine}`)]) + + expect(hunks[0]!.truncated).toBe(true) + expect(hunks[0]!.text).toContain('[hunk truncated]') + }) + + it('separates mechanical hunks from the prompt hunks', () => { + const { hunks, mechanicalHunks, omittedFiles } = buildWalkthroughInput([ + change('src/a.ts', hunkPatch(1)), + change('pnpm-lock.yaml', hunkPatch(2)), + ]) + + expect(hunks.map((hunk) => hunk.file)).toEqual(['src/a.ts']) + expect(mechanicalHunks.map((hunk) => hunk.file)).toEqual(['pnpm-lock.yaml']) + expect(omittedFiles).toEqual([]) + }) + + it('keeps mechanical hunk text within the budget and summarizes every file with counts', () => { + const oversizedPatch = Array.from({ length: 40 }, (_, index) => bigHunk(index)).join('\n') + const { mechanicalHunks, omittedFiles } = buildWalkthroughInput([ + { file: 'pnpm-lock.yaml', patch: oversizedPatch, additions: 1200, deletions: 800, status: 'modified' }, + { file: 'src/__snapshots__/a.ts.snap', patch: hunkPatch(1), additions: 2, deletions: 3, status: 'modified' }, + ]) + + expect(mechanicalHunks.map((hunk) => hunk.file)).toEqual([ + 'pnpm-lock.yaml', + 'src/__snapshots__/a.ts.snap', + ]) + expect(mechanicalHunks.reduce((total, hunk) => total + hunk.text.length, 0)).toBeLessThanOrEqual( + MECHANICAL_TEXT_BUDGET, + ) + expect(mechanicalHunks[0]).toMatchObject({ text: '', truncated: true, additions: 1200, deletions: 800 }) + expect(mechanicalHunks[1]).toMatchObject({ additions: 2, deletions: 3 }) + expect(mechanicalHunks[1]!.text).toContain('@@') + expect(omittedFiles).toEqual([]) + }) + + it('stores mechanical hunk text and counts while within the budget', () => { + const { mechanicalHunks } = buildWalkthroughInput([ + { + file: 'pnpm-lock.yaml', + patch: `${hunkPatch(1)}\n${hunkPatch(2)}`, + additions: 2, + deletions: 2, + status: 'modified', + }, + ]) + + expect(mechanicalHunks).toHaveLength(1) + expect(mechanicalHunks[0]!.text).toContain('@@ -1,2 +1,2 @@') + expect(mechanicalHunks[0]!.text).toContain('@@ -2,2 +2,2 @@') + expect(mechanicalHunks[0]!.additions).toBe(2) + expect(mechanicalHunks[0]!.deletions).toBe(2) + }) + + it('omits a binary mechanical file as binary', () => { + const { mechanicalHunks, omittedFiles } = buildWalkthroughInput([ + change('pnpm-lock.yaml', 'diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml\nBinary files differ'), + ]) + + expect(mechanicalHunks).toEqual([]) + expect(omittedFiles).toEqual([{ file: 'pnpm-lock.yaml', reason: 'binary' }]) + }) + + it('labels a pure rename as renamed', () => { + const { omittedFiles } = buildWalkthroughInput([ + change( + 'src/new.ts', + 'diff --git a/src/old.ts b/src/new.ts\nsimilarity index 100%\nrename from src/old.ts\nrename to src/new.ts', + ), + ]) + + expect(omittedFiles).toEqual([{ file: 'src/new.ts', reason: 'renamed' }]) + }) + + it('labels a mode-only change as modeChange', () => { + const { omittedFiles } = buildWalkthroughInput([ + change('script.sh', 'diff --git a/script.sh b/script.sh\nold mode 100644\nnew mode 100755'), + ]) + + expect(omittedFiles).toEqual([{ file: 'script.sh', reason: 'modeChange' }]) + }) +}) + +describe('formatHunkOutline', () => { + it('renders the id, file, status, header and change counts', () => { + const hunk = hunkFixture('src/a.ts', '@@ -1,2 +1,2 @@\n-const a = 1;\n+const a = 2;') + const [first] = formatHunkOutline(hunk).split('\n') + + expect(first).toBe(`### ${hunk.id} src/a.ts (modified) @@ -1,2 +1,2 @@ +1 -1`) + }) + + it('previews changed lines and counts content lines that start with -- or ++', () => { + const hunk = hunkFixture( + 'src/a.ts', + '@@ -1,4 +1,4 @@\n--- a/x\n+++ b/x\n-const a = 1;\n+const a = 2;\n-const b = 1;\n+const b = 2;', + ) + + const single = formatHunkOutline(hunk, 1).split('\n') + expect(single).toHaveLength(2) + expect(single[1]).toBe('--- a/x') + + const full = formatHunkOutline(hunk).split('\n') + expect(full).toContain('--- a/x') + expect(full).toContain('+++ b/x') + expect(full).toContain('-const a = 1;') + expect(full).toContain('+const a = 2;') + expect(full).toContain('-const b = 1;') + expect(full).toContain('+const b = 2;') + expect(full[0]).toContain('+3 -3') + }) + + it('defaults to the shared preview line count', () => { + const text = `@@ -1,8 +1,8 @@\n${Array.from({ length: 8 }, (_, index) => `+line${index}`).join('\n')}` + const hunk = hunkFixture('src/a.ts', text) + + expect(formatHunkOutline(hunk).split('\n')).toHaveLength(1 + WALKTHROUGH_OUTLINE_PREVIEW_LINES) + }) + + it('truncates a long changed line to the line max', () => { + const hunk = hunkFixture('src/a.ts', `@@ -1 +1 @@\n+${'x'.repeat(200)}`) + const line = formatHunkOutline(hunk).split('\n')[1]! + + expect(line).toHaveLength(WALKTHROUGH_OUTLINE_LINE_MAX_CHARS) + }) +}) + +describe('buildWalkthroughOutline', () => { + it('packs every text hunk when the prefix is empty', () => { + const input = buildWalkthroughInput( + Array.from({ length: 300 }, (_, index) => change(`src/f${index}.ts`, hunkPatch(index))), + ) + const { hunks, omittedFiles, outline } = buildWalkthroughOutline(input, 0) + + expect(omittedFiles).toEqual([]) + expect(hunks).toHaveLength(300) + for (const hunk of input.hunks) { + expect(outline).toContain(hunk.id) + } + }) + + it('omits trailing files as budget and never half-includes a file', () => { + const input = buildWalkthroughInput( + Array.from({ length: 400 }, (_, index) => change(`src/f${index}.ts`, longPatch(3, index * 3))), + ) + const { omittedFiles, outline } = buildWalkthroughOutline(input, 0) + + expect(omittedFiles.some((file) => file.reason === 'budget')).toBe(true) + + const idsByFile = new Map() + for (const hunk of input.hunks) { + const ids = idsByFile.get(hunk.file) ?? [] + ids.push(hunk.id) + idsByFile.set(hunk.file, ids) + } + + const includedFiles = new Set() + for (const [file, ids] of idsByFile) { + const present = ids.filter((id) => outline.includes(id)).length + expect(present === 0 || present === ids.length).toBe(true) + if (present > 0) { + includedFiles.add(file) + } + } + + expect(omittedFiles.map((file) => file.file)).toEqual( + [...idsByFile.keys()].filter((file) => !includedFiles.has(file)), + ) + }) + + it('falls back to header-only outlines when a single file does not fit at the default preview depth', () => { + const input = buildWalkthroughInput([change('src/huge.ts', longPatch(100))]) + const { hunks, omittedFiles, outline } = buildWalkthroughOutline(input, 0) + + expect(hunks).toHaveLength(100) + expect(omittedFiles).toEqual([]) + for (const hunk of input.hunks) { + expect(outline).toContain(hunk.id) + } + }) + + it('keeps mechanical hunks out of the outline', () => { + const input = buildWalkthroughInput([ + change('src/a.ts', hunkPatch(1)), + change('pnpm-lock.yaml', hunkPatch(2)), + ]) + const { omittedFiles, outline } = buildWalkthroughOutline(input, 0) + + expect(input.mechanicalHunks).toHaveLength(1) + expect(outline).not.toContain(input.mechanicalHunks[0]!.id) + expect(outline).not.toContain('pnpm-lock.yaml') + expect(omittedFiles).toEqual([]) + }) + + it('admits less content behind a larger prefix', () => { + const input = buildWalkthroughInput( + Array.from({ length: 800 }, (_, index) => change(`src/f${index}.ts`, hunkPatch(index))), + ) + + const withoutPrefix = buildWalkthroughOutline(input, 0) + const withPrefix = buildWalkthroughOutline(input, 20_000) + + expect(withPrefix.hunks.length).toBeLessThan(withoutPrefix.hunks.length) + expect(withPrefix.omittedFiles.some((file) => file.reason === 'budget')).toBe(true) + expect(buildWalkthroughOutline(input, 0).outline.length).toBeLessThanOrEqual(WALKTHROUGH_DIFF_MAX_CHARS) + }) + + it('bounds the included hunks and omits the remaining files as budget', () => { + const input = buildWalkthroughInput( + Array.from({ length: 200 }, (_, index) => change(`src/f${index}.ts`, longPatch(6, index * 6))), + ) + const { hunks, omittedFiles, outline } = buildWalkthroughOutline(input, 0) + + expect(hunks.length).toBeLessThan(input.hunks.length) + expect(omittedFiles.filter((file) => file.reason === 'budget').length).toBeGreaterThan(0) + expect(omittedFiles.every((file) => file.reason === 'budget')).toBe(true) + expect(outline.length).toBeLessThanOrEqual(WALKTHROUGH_DIFF_MAX_CHARS) + }) + + it('never half-includes a file', () => { + const input = buildWalkthroughInput([ + change('src/small.ts', hunkPatch(1)), + change('src/huge.ts', longPatch(2000)), + ]) + const { outline } = buildWalkthroughOutline(input, 0) + + const hugeIds = input.hunks.filter((hunk) => hunk.file === 'src/huge.ts').map((hunk) => hunk.id) + expect(hugeIds.length).toBeGreaterThan(0) + expect(hugeIds.every((id) => !outline.includes(id))).toBe(true) + }) + + it('keeps the outline within budget for many small hunks behind a very long path', () => { + const longPath = `src/${'p'.repeat(996)}.ts` + const manyHunks = Array.from( + { length: 300 }, + (_, index) => `@@ -${index + 1} +${index + 1} @@\n+line${index}`, + ).join('\n') + const input = buildWalkthroughInput([ + change('src/small.ts', hunkPatch(1)), + change(longPath, manyHunks), + ]) + const { hunks, omittedFiles, outline } = buildWalkthroughOutline(input, 0) + + expect(hunks.length).toBeGreaterThan(0) + expect(omittedFiles).toEqual([{ file: longPath, reason: 'budget' }]) + expect(outline.length).toBeLessThanOrEqual(WALKTHROUGH_DIFF_MAX_CHARS) + }) + + it('retries at a shallower preview depth so every file fits', () => { + const input = buildWalkthroughInput( + Array.from({ length: 70 }, (_, index) => change(`src/f${index}.ts`, longPatch(1, index))), + ) + const { hunks, omittedFiles } = buildWalkthroughOutline(input, 0) + + expect(omittedFiles).toEqual([]) + expect(hunks).toHaveLength(70) + }) +}) + +describe('formatWalkthroughOutline', () => { + it('renders the header, the mechanical listing and the outline', () => { + const changes = [change('src/a.ts', hunkPatch(1)), change('pnpm-lock.yaml', hunkPatch(2))] + const text = formatWalkthroughOutline({ + title: 'My session', + sourceDescription: 'the changes in this session', + changes, + }) + + expect(text).toContain('# My session') + expect(text).toContain('the changes in this session') + expect(text).toContain('2 files, 1 text hunks.') + expect(text).toContain("Call walkthrough_changes with { file } for a file's full hunks.") + expect(text).toContain('## Mechanical files') + expect(text).toContain('- pnpm-lock.yaml (modified, +1/-1)') + expect(text).toContain('## Outline') + expect(text).toContain(computeHunkId('src/a.ts', 'modified', hunkPatch(1))) + expect(text).not.toContain(hunkPatch(2)) + }) + + it('lists omitted files with their reasons', () => { + const changes = [ + change('assets/logo.png', 'diff --git a/assets/logo.png b/assets/logo.png\nBinary files differ'), + change( + 'src/new.ts', + 'diff --git a/src/old.ts b/src/new.ts\nsimilarity index 100%\nrename from src/old.ts\nrename to src/new.ts', + ), + ] + const text = formatWalkthroughOutline({ + title: 'My session', + sourceDescription: 'the changes in this session', + changes, + }) + + expect(text).toContain('## Omitted files') + expect(text).toContain('- assets/logo.png (binary)') + expect(text).toContain('- src/new.ts (renamed)') + }) + + it('never exceeds the budget plus the truncation marker', () => { + const changes = Array.from( + { length: 4000 }, + (_, index) => change(`src/f${index}.ts`, longPatch(3, index * 3)), + ) + const text = formatWalkthroughOutline({ + title: 'My session', + sourceDescription: 'the changes in this session', + changes, + }) + + expect(text.length).toBeLessThanOrEqual(WALKTHROUGH_DIFF_MAX_CHARS + '\n[output truncated]'.length) + }) +}) + +describe('formatWalkthroughFileChanges', () => { + it('returns the full hunks for a known file', () => { + const changes = [ + change('src/a.ts', `${hunkPatch(1)}\n${hunkPatch(10)}`), + change('src/b.ts', hunkPatch(20)), + ] + const text = formatWalkthroughFileChanges(changes, 'src/a.ts')! + + expect(text).toContain(hunkPatch(1)) + expect(text).toContain(hunkPatch(10)) + expect(text).not.toContain(hunkPatch(20)) + expect(text).toContain('```diff') + expect(text).toContain(formatHunkBlock(hunkFixture('src/a.ts', hunkPatch(1)))) + }) + + it('returns null for an unknown file', () => { + const changes = [change('src/a.ts', hunkPatch(1))] + + expect(formatWalkthroughFileChanges(changes, 'src/nope.ts')).toBeNull() + }) + + it('returns the text of a mechanical file within the budget', () => { + const changes = [change('pnpm-lock.yaml', hunkPatch(1))] + const text = formatWalkthroughFileChanges(changes, 'pnpm-lock.yaml')! + + expect(text).toContain(hunkPatch(1)) + expect(text).toContain('```diff') + }) + + it('returns a counts-only line for an oversized mechanical file', () => { + const oversized = Array.from({ length: 40 }, (_, index) => bigHunk(index)).join('\n') + const changes = [ + { file: 'pnpm-lock.yaml', patch: oversized, additions: 1200, deletions: 800, status: 'modified' }, + ] as FileDiffInfo[] + const text = formatWalkthroughFileChanges(changes, 'pnpm-lock.yaml')! + + expect(text).toBe('pnpm-lock.yaml (modified, +1200/-800)') + expect(text).not.toContain('@@') + }) + + it('truncates to the budget plus the truncation marker', () => { + const changes = [change('src/a.ts', longPatch(2000))] + const text = formatWalkthroughFileChanges(changes, 'src/a.ts')! + + expect(text.length).toBe(WALKTHROUGH_DIFF_MAX_CHARS + '\n[output truncated]'.length) + expect(text.endsWith('\n[output truncated]')).toBe(true) + }) +}) diff --git a/backend/test/utils/concurrency.test.ts b/backend/test/utils/concurrency.test.ts deleted file mode 100644 index 3ced1a5be..000000000 --- a/backend/test/utils/concurrency.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { createLimiter, mapWithConcurrency } from '../../src/utils/concurrency' - -describe('mapWithConcurrency', () => { - it('keeps result order', async () => { - const result = await mapWithConcurrency([1, 2, 3, 4, 5], 2, async (item) => item * 2) - expect(result).toEqual([2, 4, 6, 8, 10]) - }) - - it('runs at most limit calls at once', async () => { - let inFlight = 0 - let maxInFlight = 0 - - await mapWithConcurrency([1, 2, 3, 4, 5, 6], 2, async (item) => { - inFlight += 1 - maxInFlight = Math.max(maxInFlight, inFlight) - await new Promise((resolve) => setTimeout(resolve, 1)) - inFlight -= 1 - return item - }) - - expect(maxInFlight).toBe(2) - }) - - it('propagates a rejection', async () => { - await expect( - mapWithConcurrency([1, 2, 3], 2, async (item) => { - if (item === 2) { - throw new Error('boom') - } - return item - }), - ).rejects.toThrow('boom') - }) - - it('passes the index to the callback', async () => { - const result = await mapWithConcurrency(['a', 'b'], 2, async (item, index) => `${item}${index}`) - expect(result).toEqual(['a0', 'b1']) - }) -}) - -describe('createLimiter', () => { - it('runs at most limit tasks at once', async () => { - const limiter = createLimiter(2) - let inFlight = 0 - let maxInFlight = 0 - - const task = async (value: number): Promise => { - inFlight += 1 - maxInFlight = Math.max(maxInFlight, inFlight) - await new Promise((resolve) => setTimeout(resolve, 1)) - inFlight -= 1 - return value - } - - const results = await Promise.all([1, 2, 3, 4, 5].map((value) => limiter.run(() => task(value)))) - - expect(results).toEqual([1, 2, 3, 4, 5]) - expect(maxInFlight).toBe(2) - expect(limiter.activeCount).toBe(0) - }) - - it('queues tasks beyond the limit until a slot frees', async () => { - const limiter = createLimiter(1) - const order: string[] = [] - let releaseFirst: () => void = () => {} - const first = limiter.run(async () => { - order.push('first') - await new Promise((resolve) => { - releaseFirst = resolve - }) - }) - - const second = limiter.run(async () => { - order.push('second') - }) - - await new Promise((resolve) => setTimeout(resolve, 0)) - expect(order).toEqual(['first']) - releaseFirst() - await Promise.all([first, second]) - expect(order).toEqual(['first', 'second']) - }) - - it('releases the slot when a task rejects', async () => { - const limiter = createLimiter(1) - - await expect(limiter.run(async () => { - throw new Error('boom') - })).rejects.toThrow('boom') - - await expect(limiter.run(async () => 'ok')).resolves.toBe('ok') - expect(limiter.activeCount).toBe(0) - }) -}) diff --git a/frontend/src/api/changeWalkthroughs.ts b/frontend/src/api/changeWalkthroughs.ts index 082e14dd8..0d8c18498 100644 --- a/frontend/src/api/changeWalkthroughs.ts +++ b/frontend/src/api/changeWalkthroughs.ts @@ -3,8 +3,8 @@ import { API_BASE_URL } from '@/config' import { walkthroughSourceKey } from '@opencode-manager/shared/schemas' import type { ChangeWalkthroughState, - ChangeWalkthroughStateWire, GenerateChangeWalkthroughRequest, + RefineChangeWalkthroughRequest, WalkthroughSource, } from '@opencode-manager/shared/schemas' @@ -15,13 +15,9 @@ function walkthroughUrl(sessionId: string): string { export async function getChangeWalkthrough( sessionId: string, source: WalkthroughSource, - hunksFor?: string, -): Promise { - return fetchWrapper(walkthroughUrl(sessionId), { - params: { - source: walkthroughSourceKey(source), - ...(hunksFor === undefined ? {} : { hunksFor }), - }, +): Promise { + return fetchWrapper(walkthroughUrl(sessionId), { + params: { source: walkthroughSourceKey(source) }, }) } @@ -36,6 +32,17 @@ export async function generateChangeWalkthrough( }) } +export async function refineChangeWalkthrough( + sessionId: string, + request: RefineChangeWalkthroughRequest, +): Promise { + return fetchWrapper(`${walkthroughUrl(sessionId)}/refine`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + }) +} + export async function cancelChangeWalkthrough( sessionId: string, source: WalkthroughSource, diff --git a/frontend/src/components/file-browser/FilePreview.tsx b/frontend/src/components/file-browser/FilePreview.tsx index 9fd935eaf..70ee4879d 100644 --- a/frontend/src/components/file-browser/FilePreview.tsx +++ b/frontend/src/components/file-browser/FilePreview.tsx @@ -5,6 +5,7 @@ import type { FileInfo } from '@/types/files' import { getFileApiUrl } from '@/api/files' import { saveFileFromUrl } from '@/lib/download' import { VirtualizedTextView, type VirtualizedTextViewHandle } from '@/components/ui/virtualized-text-view' +import { SandboxedHtmlFrame } from '@/components/ui/sandboxed-html-frame' import { MarkdownRenderer } from './MarkdownRenderer' @@ -234,14 +235,7 @@ export const FilePreview = memo(function FilePreview({ file, hideHeader = false, } if (showHtmlPreview) { - return ( - ' - mocks.getChangeWalkthrough.mockResolvedValue( - state({ - walkthrough: { - ...walkthrough, - summary: `Summary ${payload}`, - stops: [ - { id: 's_x', title: 'Stop', explanation: `Explanation ${payload}`, hunkIds: ['f0h0'], status: 'ready', explanationKey: null }, - ], - }, - }), - ) - const user = userEvent.setup() - const { container } = renderSheet() - - expect(await screen.findByText(/Summary/)).toBeInTheDocument() - expect(container.querySelector('iframe')).toBeNull() - await user.click(screen.getByRole('button', { name: /next/i })) - expect(await screen.findByText(/Explanation/)).toBeInTheDocument() - expect(container.querySelector('iframe')).toBeNull() - expect(container.querySelector('script')).toBeNull() - expect(document.documentElement.dataset.walkthroughProbe).toBeUndefined() - }) - it.each([ ['WALKTHROUGH_NO_CHANGES', 'This session has no changes to walk through'], ['WALKTHROUGH_NO_TEXT_CHANGES', 'This session has no text changes to walk through'], @@ -832,7 +546,7 @@ describe('ChangesWalkthroughSheet', () => { await user.click(generate) - expect(await screen.findByText('This change adds a greeting.')).toBeInTheDocument() + expect(await screen.findByTitle('Change walkthrough')).toHaveAttribute('sandbox', 'allow-scripts') expect(screen.queryByText(serverMessage)).not.toBeInTheDocument() }) }) diff --git a/frontend/src/components/session/ChangesWalkthroughSheet.tsx b/frontend/src/components/session/ChangesWalkthroughSheet.tsx index e1d091bfb..0dba12e99 100644 --- a/frontend/src/components/session/ChangesWalkthroughSheet.tsx +++ b/frontend/src/components/session/ChangesWalkthroughSheet.tsx @@ -1,48 +1,32 @@ -import { createContext, memo, useCallback, useContext, useEffect, useRef, useState } from 'react' -import type { ReactNode, RefObject } from 'react' -import { ChevronDown, ChevronLeft, ChevronRight, Loader2, RefreshCw, Square } from 'lucide-react' +import { createContext, memo, useCallback, useContext, useEffect, useState } from 'react' +import type { ReactNode } from 'react' +import { Loader2, RefreshCw, Square } from 'lucide-react' import { SideDrawer, SideDrawerHeader } from '@/components/ui/side-drawer' import { Button } from '@/components/ui/button' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu' -import { Badge } from '@/components/ui/badge' import { Input } from '@/components/ui/input' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' -import { DiffLines } from '@/components/file-browser/DiffLines' -import { ScheduleRunMarkdown } from '@/components/schedules/ScheduleRunMarkdown' +import { Textarea } from '@/components/ui/textarea' +import { SandboxedHtmlFrame } from '@/components/ui/sandboxed-html-frame' +import { getFileApiUrl } from '@/api/files' import { useCancelChangeWalkthrough, useChangeWalkthrough, useGenerateChangeWalkthrough, + useRefineChangeWalkthrough, } from '@/hooks/useChangeWalkthrough' -import { GIT_STATUS_COLORS, GIT_STATUS_LABELS } from '@/lib/git-status-styles' -import { cn } from '@/lib/utils' import { DEFAULT_WALKTHROUGH_SOURCE, describeWalkthroughSource, toWalkthroughSource, walkthroughSourceKey, - WalkthroughOmittedFileSchema, -} from '@opencode-manager/shared/schemas' -import type { - ChangeWalkthrough, - WalkthroughHunk, - WalkthroughOmittedFile, - WalkthroughSource, - WalkthroughStop, + WALKTHROUGH_REFINE_MAX_CHARS, } from '@opencode-manager/shared/schemas' +import type { ChangeWalkthrough, WalkthroughSource } from '@opencode-manager/shared/schemas' import { formatOpenCodeModelRef, type ModelRef } from '@opencode-manager/shared/opencode' interface WalkthroughErrorLike { message?: string code?: string - details?: unknown } function errorCode(error: unknown): string | undefined { @@ -54,20 +38,7 @@ function isNoChangesError(error: unknown): boolean { return code === 'WALKTHROUGH_NO_CHANGES' || code === 'WALKTHROUGH_NO_TEXT_CHANGES' } -function isContextLimitError(error: unknown): boolean { - return errorCode(error) === 'WALKTHROUGH_CONTEXT_LIMIT' -} - -function omittedFilesFromError(error: unknown): WalkthroughOmittedFile[] { - const details = (error as { details?: { omittedFiles?: unknown } } | null)?.details - const parsed = WalkthroughOmittedFileSchema.array().safeParse(details?.omittedFiles) - return parsed.success ? parsed.data : [] -} - function walkthroughErrorMessage(error: unknown): string { - if (isContextLimitError(error)) { - return 'These changes are too large to walk through' - } const message = (error as WalkthroughErrorLike | null)?.message if (isNoChangesError(error)) { return message || 'This session has no text changes to walk through' @@ -75,74 +46,20 @@ function walkthroughErrorMessage(error: unknown): string { return message || 'Failed to load the walkthrough' } -const OMITTED_REASON_LABELS: Record = { - binary: 'binary file', - budget: 'over the diff budget', - renamed: 'renamed', - modeChange: 'mode change', -} - -function OmittedFiles({ files }: { files: WalkthroughOmittedFile[] }) { - return ( -
-

Omitted files

- {files.map((omitted) => ( -

- {omitted.file} — {OMITTED_REASON_LABELS[omitted.reason]} -

- ))} -
- ) -} - -function isMechanicalHunk(hunk: WalkthroughHunk): boolean { - return hunk.additions !== undefined || hunk.deletions !== undefined -} - -function MechanicalHunks({ hunks }: { hunks: WalkthroughHunk[] }) { - return ( -
    - {hunks.map((hunk) => ( -
  • -
    - - {hunk.file} - - - {GIT_STATUS_LABELS[hunk.status]} - - +{hunk.additions ?? 0} - -{hunk.deletions ?? 0} -
    - {hunk.text.length > 0 ? ( -
    - -
    - ) : null} -
  • - ))} -
- ) -} - interface ChangesWalkthroughContextValue { isLoading: boolean walkthrough: ChangeWalkthrough | null stale: boolean generating: boolean + running: boolean error: unknown - contextLimitFiles: WalkthroughOmittedFile[] generate: () => void regenerate: () => void stop: () => void stopping: boolean - stops: WalkthroughStop[] - stopIndex: number | null - selectedStop: WalkthroughStop | null - selectedHunks: WalkthroughHunk[] - selectStop: (index: number | null) => void - scrollRef: RefObject - failedStopCount: number + refine: (text: string) => void + refining: boolean + onOpenSession?: (walkthroughSessionId: string) => void source: WalkthroughSource setSource: (source: WalkthroughSource) => void sourceKind: WalkthroughSource['kind'] @@ -259,6 +176,7 @@ interface ChangesWalkthroughProviderProps { active: boolean sourceRequest?: WalkthroughSourceRequest model?: ModelRef | null + onOpenSession?: (walkthroughSessionId: string) => void children: ReactNode } @@ -268,6 +186,7 @@ export function ChangesWalkthroughProvider({ active, sourceRequest, model, + onOpenSession, children, }: ChangesWalkthroughProviderProps) { const [source, setSource] = useState(DEFAULT_WALKTHROUGH_SOURCE) @@ -278,17 +197,18 @@ export function ChangesWalkthroughProvider({ const stateQuery = useChangeWalkthrough(sessionId, active, source) const generate = useGenerateChangeWalkthrough(sessionId, source) const cancel = useCancelChangeWalkthrough(sessionId, source) + const refineMutation = useRefineChangeWalkthrough(sessionId, source) const stop = useCallback(() => cancel.mutate(), [cancel]) const resetGenerate = generate.reset - const [stopIndex, setStopIndex] = useState(null) - const scrollRef = useRef(null) + const resetRefine = refineMutation.reset const state = stateQuery.data const walkthrough = state?.walkthrough ?? null const stale = state?.stale ?? false - const generating = generate.isPending || (state?.generating ?? false) - const error = generate.error ?? stateQuery.error ?? (generating ? null : state?.error ?? null) - const contextLimitFiles = isContextLimitError(error) ? omittedFilesFromError(error) : [] + const generating = generate.isPending || refineMutation.isPending || (state?.generating ?? false) + const running = refineMutation.isPending || (state?.running ?? false) + const error = + generate.error ?? refineMutation.error ?? stateQuery.error ?? (generating ? null : state?.error ?? null) const selectedSource = toWalkthroughSource({ kind: sourceKind, base: baseInput, number: Number(numberInput) }) const sourcePending = @@ -297,12 +217,8 @@ export function ChangesWalkthroughProvider({ useEffect(() => { resetGenerate() - }, [active, sessionId, sourceKey, resetGenerate]) - - useEffect(() => { - setStopIndex(null) - scrollRef.current?.scrollTo?.({ top: 0 }) - }, [active, sessionId, sourceKey, walkthrough?.createdAt]) + resetRefine() + }, [active, sessionId, sourceKey, resetGenerate, resetRefine]) useEffect(() => { if (!sourceRequest) return @@ -318,43 +234,22 @@ export function ChangesWalkthroughProvider({ } }, [sourceRequest]) - const hunksById = new Map(walkthrough?.hunks.map((hunk) => [hunk.id, hunk]) ?? []) - const stops = walkthrough?.stops ?? [] - const clampedIndex = stopIndex === null || stops.length === 0 ? null : Math.min(stopIndex, stops.length - 1) - const selectedStop = clampedIndex === null ? null : stops[clampedIndex] ?? null - const selectedHunks = (selectedStop?.hunkIds ?? []) - .map((id) => hunksById.get(id)) - .filter((hunk): hunk is WalkthroughHunk => hunk !== undefined) - - const failedStopCount = stops.filter( - (stop) => stop.status === 'failed' || (stop.status === 'pending' && !generating), - ).length - const modelRequest = model ? { model: formatOpenCodeModelRef(model) } : {} - const selectStop = useCallback((index: number | null) => { - setStopIndex(index) - scrollRef.current?.scrollTo?.({ top: 0 }) - }, []) - const value: ChangesWalkthroughContextValue = { isLoading: stateQuery.isLoading, walkthrough, stale, generating, + running, error, - contextLimitFiles, generate: () => generate.mutate(modelRequest), regenerate: () => generate.mutate({ ...modelRequest, regenerate: true }), stop, stopping: cancel.isPending, - stops, - stopIndex: clampedIndex, - selectedStop, - selectedHunks, - selectStop, - scrollRef, - failedStopCount, + refine: (text: string) => refineMutation.mutate(text), + refining: refineMutation.isPending, + onOpenSession, source, setSource, sourceKind, @@ -369,62 +264,11 @@ export function ChangesWalkthroughProvider({ return {children} } -/** Previous/next page controls (overview, then each stop) for the walkthrough chrome; renders nothing until a walkthrough has stops. */ -export const ChangesWalkthroughNav = memo(function ChangesWalkthroughNav() { - const { stops, stopIndex, selectStop } = useChangesWalkthrough() - - if (stops.length === 0) return null - - return ( -
- - - - - - - selectStop(value === 'overview' ? null : Number(value))} - > - Overview - - {stops.map((stop, index) => ( - - {index + 1}. {stop.title} - - ))} - - - - -
- ) -}) - -export const ChangesWalkthroughStop = memo(function ChangesWalkthroughStop() { - const { generating, stopping, stop } = useChangesWalkthrough() +/** Stops the in-flight walkthrough session from the chrome; renders nothing when the walkthrough is neither generating nor running. */ +export const ChangesWalkthroughCancel = memo(function ChangesWalkthroughCancel() { + const { generating, running, stopping, stop } = useChangesWalkthrough() - if (!generating) return null + if (!generating && !running) return null return ( ) : null} - {showInitialSkeleton ? ( + {isLoading && !walkthrough && !generating ? ( +

+ Loading the walkthrough… +

+ ) : null} + + {!isLoading && !walkthrough && !generating ? (
-

- {generating ? 'Planning the walkthrough…' : 'Loading the walkthrough…'} +

+ Build an interactive walkthrough of {describeWalkthroughSource(source)}.

- +
- ) : ( - <> - {generating ? ( -
- - {`Generating walkthrough… ${readyStopCount} of ${stops.length} stops explained`} -
+ ) : null} + + {generating && !published ? ( +
+ + + Building the walkthrough in a separate session… + + {openSession ? ( + ) : null} +
+ ) : null} + + {published && artifactUrl !== null ? ( +
+
+ + + {openSession ? ( + + ) : null} + {generating || running ? ( + + Refining… + + ) : null} +
+ +
+ ) : null} - {!walkthrough ? ( -
-

- Generate a step-by-step walkthrough of {describeWalkthroughSource(source)}. -

- -
- ) : ( -
- {stale && !generating ? ( -
-

- Changes have been updated since this walkthrough was generated -

- -
- ) : null} - - {failedStopCount > 0 && !generating ? ( -
-

Some stops could not be explained

- -
- ) : null} - - {selectedStop ? ( -
-

{selectedStop.title}

- {selectedStop.status === 'ready' ? ( - - ) : generating && selectedStop.status === 'pending' ? ( -
-

- Explaining this stop… -

- -
- ) : ( -

- {generating ? 'Explaining this stop…' : 'This stop could not be explained.'} -

- )} - {mechanicalHunks.length > 0 ? : null} - {diffHunks.map((hunk) => ( -
-
- - {hunk.file} - - {hunk.truncated ? truncated : null} - - {GIT_STATUS_LABELS[hunk.status]} - -
-
- -
-
- ))} -
- ) : ( - <> - - - {stops.length > 0 ? ( -
    - {stops.map((stop, index) => ( -
  1. - -
  2. - ))} -
- ) : null} - - {walkthrough.omittedFiles.length > 0 ? : null} - - )} -
- )} - - )} + {walkthroughSessionId && !generating && !running ? ( +
{ + event.preventDefault() + const text = refineText.trim() + if (text.length === 0 || refining) return + refine(text) + setRefineText('') + }} + > +