Files
openchamber/packages/web/server/lib/walkthrough/index.js
T
Serhii DziupinandSerhii Dziupin 86e6a2ae76 Remove verified dead declarations (#2714)
* chore: remove verified dead declarations

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* chore: narrow unused internal exports

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* chore: remove newly exposed dead helpers

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* chore: remove unused deep-link serializer

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* test: drop two tests that assert on copies of the code

mainLayoutMobileSidebarMount read MainLayout.tsx and SessionSidebar.tsx as
strings and asserted on source substrings down to exact indentation, so it
failed on formatting rather than behaviour. useProjectSessionSelection.test
reimplemented the hook's visitNodes logic inside the test file and asserted
against that copy, so it could not observe the hook at all.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* test: repair sync suites that had rotted while unrunnable

No runner executed packages/ui, so these drifted from the source unnoticed:
two imported helpers that are no longer exported, one directory-store stub
predated the session field routeMessage reads, and the WebSocket fake missed
the mandatory url-token mint plus the close event the socket wrapper reads.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* test: stop the web suite failing on timeouts and a hand-copied mock

The Git suites drive a real git binary, so the 5s default made a valid suite
fail differently per run. The gitApiHttp mock listed ~70 export names by hand
and fell behind the source; it now derives every stub from the real module,
which the added shared-UI aliases make resolvable.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* test: run every suite from one command and in CI

packages/ui (232 files) and packages/vscode (22) had no test script at all, CI
ran neither, and 9 vscode files could never run because Node cannot resolve
their extensionless TypeScript imports. Three electron files sat outside every
script list, one of them importing vitest, which that package does not depend
on. A runner gives each file its own process, since these suites keep
module-level singletons and fail by load order when sharing one.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* chore: delete a superseded repro harness and a completed plan

The issue-2638 harness needed lsof, overrode process.platform and spawned real
servers, and nothing referenced it; event-stream/rebind.test.js now covers the
same hub-pinned-to-the-old-port behaviour. The pairing v2 plan described relay
and the pairing UI as out of scope, both of which shipped.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* docs: point at the theme tools and record the github barrel invariant

convert-vscode-theme and harmonize-theme were referenced nowhere, so the
theme-authoring reference now names them. The github barrel is loaded through
await import('./index.js') and destructured per route, which no static report
can see; documenting that is what stops the next cleanup from deleting it.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* test: repair merge drift in bridge and route-registry mocks

upstream/main gained upsertProviderConfig on bridge-system-runtime and a
PATCH scheduled-task route after this branch forked. Their test doubles
were never updated to match:
- bridge-system-runtime.test.js: add upsertProviderConfig to the
  opencodeConfig mock so the import resolves.
- sse-routes.test.js: add app.patch to the route registry stub.

---------

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
2026-08-13 15:30:54 +03:00

602 lines
22 KiB
JavaScript

import { getRepositoryRoot } from '../git/service.js';
import { describeSmallModel, generateSmallModelText } from '../small-model/index.js';
import { buildDigest } from './digest.js';
import { indexHunks } from './hunks.js';
import { normalizeLanguage } from './languages.js';
import { buildPrompt, JSON_SHAPE_INSTRUCTION } from './prompt.js';
import { normalizeWalkthrough, parseModelJson, responseSchema } from './schema.js';
import {
buildCacheKey,
pruneMissingRepositories,
readCachedWalkthrough,
readPointer,
writeCachedWalkthrough,
writePointer,
} from './store.js';
import { readWalkthroughModelOverride } from './model-settings.js';
import { loadSourceSections, parseSource, sourceKey, WalkthroughSourceError } from './sources.js';
// Walkthrough generation is always user-initiated and never automatic: it costs
// tokens, and a background regeneration on every keystroke would be a way to
// spend a budget without anyone deciding to.
// This module is imported lazily, which means module-level work lands on the
// first walkthrough request. Housekeeping has no business being there, so it is
// deferred and never awaited: the request proceeds immediately and the prune
// interleaves behind it.
setTimeout(() => {
void pruneMissingRepositories().catch(() => {
// Housekeeping failing is not worth surfacing or retrying.
});
}, 0).unref?.();
// A hang guard, not a pace-setter. Losing a nearly-finished generation wastes
// real money and minutes, while an over-long deadline only holds a job slot, so
// this errs long. It scales because a three-hunk edit and a 500-hunk pull
// request have no business sharing a deadline.
const GENERATION_TIMEOUT_BASE_MS = 120_000;
const GENERATION_TIMEOUT_PER_HUNK_MS = 1_000;
const GENERATION_TIMEOUT_MAX_MS = 900_000;
const generationTimeoutMs = (hunkCount) => Math.min(
GENERATION_TIMEOUT_MAX_MS,
GENERATION_TIMEOUT_BASE_MS + Math.max(0, hunkCount) * GENERATION_TIMEOUT_PER_HUNK_MS,
);
// A full walkthrough is a few thousand tokens of JSON. The budget exists for
// what comes before it: reasoning models spend the same allowance thinking and
// return nothing when it runs out, which is a bill for no answer.
//
// So the ask is derived from the model rather than fixed. A flat 24k was the
// same number for a 64k-context model and for one that admits to 384k output
// tokens, and on the latter it was the only reason generation failed.
//
// The reserve subtracted from the input budget is the same number, always: ask
// for more than was reserved and a large diff overruns the context mid-answer,
// which surfaces as a truncation bug rather than a budgeting one.
const MIN_OUTPUT_TOKENS = 24_000;
// A ceiling, because the reserve is taken out of the input allowance: a model
// that would let us ask for 384k tokens of answer would also let us spend a
// third of a million tokens of context reserving them, and no walkthrough needs
// that much thinking.
const MAX_OUTPUT_TOKENS = 96_000;
// Above this share of the context, the reserve starts costing more diff than
// the extra room is worth.
const OUTPUT_CONTEXT_SHARE = 0.25;
/**
* Answer allowance for a specific model: as much as it admits it can emit,
* bounded by a share of its context and never below what this feature always
* asked for.
*/
const walkthroughOutputTokens = ({ contextTokens, outputTokenLimit }) => {
const wanted = Math.min(
MAX_OUTPUT_TOKENS,
Math.max(MIN_OUTPUT_TOKENS, Math.floor((Number(contextTokens) || 0) * OUTPUT_CONTEXT_SHARE)),
);
// A model whose own limit is below the floor gets its limit: asking for more
// than a provider allows is rejected outright by some and ignored by others.
return Number(outputTokenLimit) > 0 ? Math.min(wanted, Number(outputTokenLimit)) : wanted;
};
const fail = (message, statusCode, extra = {}) =>
Object.assign(new Error(message), { statusCode, ...extra });
// Generation outlives the request that started it.
//
// A dropped connection and a deliberate cancel look identical at the socket, so
// tying the work to the request lifetime meant an accidental refresh threw away
// a minute of paid-for work. Jobs are keyed by repository + source, so a client
// that comes back attaches to the running job instead of starting a second one,
// and cancelling is an explicit request rather than a side effect of leaving.
const jobs = new Map();
// Providers that answered a schema request with a 4xx. Retrying the schema on
// every generation means paying for a call we already know will fail, so the
// refusal is remembered and the fallback goes first next time.
//
// Process-lifetime only, on purpose: a provider that gains structured-output
// support should not need a settings change to be tried again — a restart is
// enough, and the cost of one wasted first attempt after that is small.
const schemaRefusedBy = new Set();
const modelKey = (model) => `${model.providerID}/${model.modelID}`;
const jobKey = (repoRoot, sourceKeyValue) => `${repoRoot}\0${sourceKeyValue}`;
/**
* Coarse stages, reported so a long wait is legible.
*
* Only phases a person can actually wait on are named. Building the digest and
* reading the cache take single-digit milliseconds; giving them their own rows
* would imply progress where there is none. `retrying` appears only when a
* provider rejects the schema and the prompt-side fallback runs.
*/
const setStage = (repoRoot, sourceKeyValue, stage) => {
const job = jobs.get(jobKey(repoRoot, sourceKeyValue));
if (job) job.stage = stage;
};
/**
* Current stage of a running generation, or `null` when nothing is running.
* Reads memory only — no git, no network — so it is cheap to poll.
*/
export function getGenerationStage(repoRoot, sourceKeyValue) {
return jobs.get(jobKey(repoRoot, sourceKeyValue))?.stage ?? null;
}
/**
* Whether a generation is currently running for a source. Lets a reconnecting
* client show progress instead of an empty panel.
*/
export function isGenerating(repoRoot, sourceKeyValue) {
return jobs.has(jobKey(repoRoot, sourceKeyValue));
}
/**
* Resolve the pair the job registry is keyed by, for callers that need to look
* a job up without doing any diff work.
*/
export async function getRepositoryRootFor(directory, rawSource) {
const source = parseSource(rawSource);
return { repoRoot: await getRepositoryRoot(directory), sourceKey: sourceKey(source) };
}
/**
* Stop a running generation. Only an explicit request does this — leaving the
* page does not.
*/
export async function cancelWalkthroughGeneration({ directory, source: rawSource }) {
const source = parseSource(rawSource);
const repoRoot = await getRepositoryRoot(directory);
const job = jobs.get(jobKey(repoRoot, sourceKey(source)));
if (!job) return { cancelled: false };
job.controller.abort();
return { cancelled: true };
}
const modelLabel = (model) => `${model.providerID}/${model.modelID}`;
/**
* Resolve the model for this feature: the walkthrough override when set,
* otherwise whatever the small-model chain resolves to.
*/
/**
* Resolve the model for this feature. An explicit per-review choice outranks the
* saved setting, which in turn outranks the small-model chain — the user picking
* a roomier model for a risky change is the most specific intent there is.
*/
const resolveModel = (directory, explicitModel) => describeSmallModel({
directory,
outputReserveTokens: walkthroughOutputTokens,
overrideModel: explicitModel || readWalkthroughModelOverride(),
});
export const __testing = { generationTimeoutMs, walkthroughOutputTokens };
/**
* Current diff for a source, parsed into files and hunks.
*/
async function loadCurrentDiff(directory, source, deps) {
const { sections } = await loadSourceSections(directory, source, deps);
const built = buildDigest(sections);
return built;
}
const stopHunkIds = (walkthrough) =>
walkthrough.chapters.flatMap((chapter) => chapter.stops.flatMap((stop) => stop.hunkIds));
/**
* Compare a stored walkthrough against the diff as it is right now.
*
* Staleness is not a heuristic here: a hunk id is a hash of the hunk's content,
* so an anchor that no longer resolves is proof that the code it described has
* changed or gone. Anchors that still resolve are still accurate.
*/
function resolveAgainstCurrent(walkthrough, hunkIndex) {
const missingHunkIds = [];
const staleStopIds = [];
for (const chapter of walkthrough.chapters) {
for (const stop of chapter.stops) {
const missing = stop.hunkIds.filter((id) => !hunkIndex.has(id));
if (missing.length === 0) continue;
missingHunkIds.push(...missing);
staleStopIds.push(stop.id);
}
}
const covered = new Set(stopHunkIds(walkthrough));
const uncoveredHunkIds = [...hunkIndex.keys()].filter((id) => !covered.has(id));
return {
isStale: missingHunkIds.length > 0,
missingHunkIds,
staleStopIds,
uncoveredHunkIds,
};
}
const serializeHunks = (files) => files.flatMap((file) => file.hunks.map((hunk) => ({
id: hunk.id,
path: file.path,
oldPath: file.oldPath || null,
status: file.status,
scope: file.scope,
header: hunk.header,
newStart: hunk.newStart,
added: hunk.added,
deleted: hunk.deleted,
patch: hunk.patch,
})));
/**
* Read the last walkthrough for a source, resolved against the current diff.
* Never generates and never spends tokens.
*/
export async function getWalkthrough({ directory, source: rawSource, model: explicitModel, language: rawLanguage }, deps = {}) {
const source = parseSource(rawSource);
const repoRoot = await getRepositoryRoot(directory);
const key = sourceKey(source);
const language = normalizeLanguage(rawLanguage);
const pointer = readPointer(repoRoot, key);
// One diff, one model lookup, both answers. These used to be separate
// endpoints the client called in parallel, which meant every panel open ran
// the whole git pipeline twice.
const [built, model] = await Promise.all([
loadCurrentDiff(directory, source, deps),
resolveModel(directory, explicitModel).catch(() => null),
]);
const { files } = built;
const hunkIndex = indexHunks(files);
const readiness = computeReadiness({ ...built, model, source, language });
const base = {
source,
hunks: serializeHunks(files),
hunkCount: hunkIndex.size,
readiness,
generating: isGenerating(repoRoot, key),
};
// Ask the cache for *this* request before falling back to the pointer.
//
// The pointer only knows which walkthrough was generated here last, which
// after a model or language switch is the answer to a different question:
// the panel would keep showing the English review while the picker said
// Ukrainian, even though the Ukrainian one was sitting in the cache. The key
// is computed from the diff this read already parsed, so this costs a file
// read and no git work at all.
const requestedKey = model
? buildCacheKey({
repoRoot,
sourceKey: key,
providerID: model.providerID,
modelID: model.modelID,
language,
files,
})
: null;
const requested = requestedKey ? readCachedWalkthrough(requestedKey) : null;
const entry = requested ?? (pointer ? readCachedWalkthrough(pointer.cacheKey) : null);
if (!entry) {
// No pointer, or the pointer outlived its entry (eviction, manual cleanup).
// "No walkthrough" is the truthful answer either way; the pointer is left
// for the next generation to overwrite.
return { ...base, walkthrough: null };
}
// Showing it makes it the last walkthrough shown here, and a regeneration
// re-authors from whatever the reader is actually looking at. Only written
// when it moved, so an unchanged read stays a pure read.
if (requested && pointer?.cacheKey !== requestedKey) {
writePointer(repoRoot, key, {
repoRoot,
sourceKey: key,
cacheKey: requestedKey,
generatedAt: requested.generatedAt,
});
}
return {
...base,
walkthrough: entry.walkthrough,
model: entry.model,
// The language the text on screen is actually written in, which is not
// necessarily the one being asked for now. The picker needs the difference:
// it is what lets it default to what produced this rather than to a setting.
language: entry.language ?? null,
generatedAt: entry.generatedAt,
...resolveAgainstCurrent(entry.walkthrough, hunkIndex),
};
}
/**
* Whether the resolved model can do this job, computed from a digest the caller
* already built.
*
* Folded into the walkthrough read rather than living on its own endpoint: both
* answers need the same diff, and computing it twice doubled the git work on
* every panel open.
*/
function computeReadiness({ model, digest, files, fileCount, hunkCount, generatedFileCount, source, language }) {
if (!model) return { ready: false, reason: 'no-model' };
if (hunkCount === 0) {
// "Only a lockfile changed" is a different answer from "nothing changed",
// and the user can act on it (commit and move on) rather than wonder why
// the review refuses.
const reason = files.length > 0 && generatedFileCount === files.length ? 'only-generated' : 'empty-diff';
return { ready: false, reason, model, generatedFileCount };
}
// A resolved override/config model can still have no usable login. Refuse up
// front and omit the model — offering an unauthenticated selection in the
// picker is what made the old raw auth error feel like a product bug.
if (model.hasLogin === false) {
return { ready: false, reason: 'no-provider-login' };
}
// Built with the same language the generation would use: the instruction is
// part of the prompt, so a readiness answer computed without it would be
// measuring a request nobody is going to send.
const { prompt, system } = buildPrompt({ digest, fileCount, hunkCount, source, language });
const requiredChars = prompt.length + system.length;
if (model.structuredOutput === false) {
return { ready: false, reason: 'structured-output-unsupported', model, requiredChars };
}
if (requiredChars > model.inputCharBudget) {
return {
ready: false,
reason: 'context-too-small',
model,
requiredChars,
availableChars: model.inputCharBudget,
};
}
return { ready: true, model, requiredChars, availableChars: model.inputCharBudget, hunkCount, fileCount };
}
/**
* Generate a walkthrough for a source.
*
* Returns the cached entry when the diff, model, and prompt are all unchanged —
* which also means returning to a previous state of the working tree costs
* nothing.
*/
export async function generateWalkthrough({ directory, source: rawSource, force = false, model: explicitModel, language: rawLanguage }, deps = {}) {
const source = parseSource(rawSource);
const repoRoot = await getRepositoryRoot(directory);
const key = sourceKey(source);
const language = normalizeLanguage(rawLanguage);
// Attach to a running job rather than starting a second one. A user who
// refreshed and pressed the button again wants the answer, not two bills.
const existing = jobs.get(jobKey(repoRoot, key));
if (existing) return existing.promise;
const controller = new AbortController();
const promise = runGeneration({ directory, source, repoRoot, key, force, explicitModel, language, signal: controller.signal }, deps)
.finally(() => {
if (jobs.get(jobKey(repoRoot, key))?.controller === controller) {
jobs.delete(jobKey(repoRoot, key));
}
});
jobs.set(jobKey(repoRoot, key), { controller, promise, stage: 'collecting' });
return promise;
}
async function runGeneration({ directory, source, repoRoot, key, force, explicitModel, language, signal }, deps) {
const model = await resolveModel(directory, explicitModel);
if (!model) {
throw fail('No model is available — sign in to a provider first', 404, { code: 'no-model' });
}
if (model.hasLogin === false) {
throw fail(
`No OpenCode login found for provider "${model.providerID}" — sign in or choose a different model`,
401,
{ code: 'no-provider-login', model },
);
}
const { digest, files, idByAlias, fileCount, hunkCount, generatedFileCount } = await loadCurrentDiff(directory, source, deps);
setStage(repoRoot, key, 'asking');
if (hunkCount === 0) {
if (files.length > 0 && generatedFileCount === files.length) {
throw fail('Only generated files changed — there is nothing to review', 400, { code: 'only-generated' });
}
throw fail('There are no changes to review', 400, { code: 'empty-diff' });
}
const cacheKey = buildCacheKey({
repoRoot,
sourceKey: key,
providerID: model.providerID,
modelID: model.modelID,
language,
files,
});
const hunkIndex = indexHunks(files);
if (!force) {
const cached = readCachedWalkthrough(cacheKey);
if (cached) {
writePointer(repoRoot, key, {
repoRoot,
sourceKey: key,
cacheKey,
generatedAt: cached.generatedAt,
});
return {
source,
walkthrough: cached.walkthrough,
model: cached.model,
language: cached.language ?? null,
generatedAt: cached.generatedAt,
fromCache: true,
hunks: serializeHunks(files),
hunkCount,
...resolveAgainstCurrent(cached.walkthrough, hunkIndex),
};
}
}
// A forced regeneration hands the model its own previous narrative so it can
// keep what is still true instead of starting from a blank page. The old
// anchors are deliberately not included — they belong to code that has moved.
let previousWalkthrough = null;
const pointer = readPointer(repoRoot, key);
if (pointer) {
const previousEntry = readCachedWalkthrough(pointer.cacheKey);
if (previousEntry && previousEntry.cacheKey !== cacheKey) {
previousWalkthrough = previousEntry.walkthrough;
}
}
const { prompt, system } = buildPrompt({ digest, fileCount, hunkCount, source, previousWalkthrough, language });
if (model.structuredOutput === false) {
throw fail(
`${modelLabel(model)} cannot produce structured output — choose a different small model`,
409,
{ code: 'structured-output-unsupported', model },
);
}
const run = (options) => generateSmallModelText({
prompt: options.prompt,
system: options.system,
directory,
model: `${model.providerID}/${model.modelID}`,
responseSchema: options.responseSchema,
onOverflow: 'error',
timeoutMs: generationTimeoutMs(hunkCount),
// The number the input budget was already reduced by, not a fresh guess.
maxOutputTokens: model.outputTokens ?? MIN_OUTPUT_TOKENS,
signal,
});
// Roughly half the catalog does not declare `structured_output`, and some of
// those providers reject the schema outright. A rejected request shape is not
// a dead end: the shape can travel in the prompt instead, and the response
// parser is already tolerant of imperfect JSON.
const withSchema = () => run({ prompt, system, responseSchema });
const withoutSchema = () => run({
prompt,
system: `${system}\n${JSON_SHAPE_INSTRUCTION}`,
responseSchema: undefined,
});
const asRequestFailure = (error) => {
if (error?.code === 'context-too-small') {
return fail(error.message, 409, {
code: 'context-too-small',
model,
requiredChars: error.requiredChars,
availableChars: error.availableChars,
});
}
if (error?.code === 'output-exhausted') {
return fail(error.message, 409, { code: 'output-exhausted', model });
}
if (error?.code === 'no-provider-login') {
return fail(error.message, 401, { code: 'no-provider-login', model });
}
return null;
};
const refusesSchema = (error) => error?.code === 'structured-output-unsupported'
|| (Number(error?.status) >= 400 && Number(error?.status) < 500);
let raw;
let usedSchema = false;
if (schemaRefusedBy.has(modelKey(model))) {
// Already known to refuse: skip straight to the fallback rather than pay
// for a call whose failure is a foregone conclusion.
setStage(repoRoot, key, 'retrying');
try {
raw = await withoutSchema();
} catch (error) {
throw asRequestFailure(error) ?? error;
}
} else {
try {
raw = await withSchema();
usedSchema = true;
} catch (error) {
const failure = asRequestFailure(error);
if (failure) throw failure;
if (!refusesSchema(error)) throw error;
schemaRefusedBy.add(modelKey(model));
setStage(repoRoot, key, 'retrying');
try {
raw = await withoutSchema();
} catch (fallbackError) {
throw asRequestFailure(fallbackError) ?? fallbackError;
}
}
}
setStage(repoRoot, key, 'assembling');
let walkthrough;
try {
walkthrough = normalizeWalkthrough(parseModelJson(raw.text), idByAlias);
} catch (error) {
// Without schema support the model was asked for JSON in prose and did not
// deliver: that is a capability problem the user can fix by switching model,
// so it gets the picker rather than a parser error.
if (!usedSchema) {
throw fail(
`${modelLabel(model)} could not return the structured response a walkthrough needs`,
409,
{ code: 'structured-output-unsupported', model },
);
}
throw fail(
`${modelLabel(model)} did not return a usable walkthrough — try a different small model`,
502,
{ code: 'invalid-walkthrough', model, cause: error?.message },
);
}
const generatedAt = new Date().toISOString();
const entry = {
cacheKey,
generatedAt,
repoRoot,
sourceKey: key,
model: { providerID: model.providerID, modelID: model.modelID, source: model.source },
language,
walkthrough,
};
// A failed write costs a regeneration next time; it must never fail the
// request that already produced a good walkthrough.
writeCachedWalkthrough(cacheKey, entry);
writePointer(repoRoot, key, { repoRoot, sourceKey: key, cacheKey, generatedAt });
return {
source,
walkthrough,
model: entry.model,
language,
generatedAt,
fromCache: false,
hunks: serializeHunks(files),
hunkCount,
...resolveAgainstCurrent(walkthrough, hunkIndex),
};
}
export { WalkthroughSourceError };