diff --git a/AGENTS.md b/AGENTS.md index 0a81fbc57..f0f7252c7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -98,6 +98,29 @@ const table = sqliteTable("session", { }) ``` +### Reading a nullable column + +Two independent absences meet in one expression, and only one of them is +`undefined`. `.get()` yields `undefined` when no row matches — Drizzle normalises +the driver's `null` there — while a nullable column's SQL `NULL` arrives as +`null`. So `row?.some_column` is `T | null | undefined`. + +When a caller only asks "is there a value", flatten to `undefined`, and write the +flattening as an annotation rather than an `as` cast: + +```ts +// Good — the compiler enforces it; deleting the `?? undefined` is a type error +const boundary: MessageID | undefined = row?.last_checkpoint_message_id ?? undefined + +// Bad — the cast removes `null` from the union without converting anything, +// so the declared type is untrue at runtime +return row?.last_checkpoint_message_id as MessageID | undefined +``` + +Discriminate a possibly-absent value with truthiness or `== null`, never with +`=== undefined` / `!== undefined`. Because `null !== undefined` is `true`, such a +guard typechecks, reads correctly in review, and does nothing. + ## Testing - Avoid mocks as much as possible diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/footer.ts b/packages/opencode/src/cli/cmd/tui/component/prompt/footer.ts new file mode 100644 index 000000000..352d8c160 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/footer.ts @@ -0,0 +1,22 @@ +import { Locale } from "@/util" + +/** + * Cell budget for the ephemeral status message in the prompt footer. Sized so + * the message plus the spinner still leaves room for `esc interrupt` and the + * context counter on an 80-column terminal. + */ +export const STATUS_MESSAGE_MAX = 48 + +/** + * The footer packs the spinner + status message onto the same row as the context + * counter (`52.4K/960K (5%)`). A long server-supplied status string wrapped over + * several lines and squeezed that row until the counter rendered clipped + * (`52.4K/96`). Clamp the message — and flatten any newlines — so a status + * string can never cost the counter its cells. + */ +export function clampStatusMessage(message: string | undefined) { + if (!message) return undefined + const flat = message.replace(/\s+/g, " ").trim() + if (!flat) return undefined + return Locale.truncate(flat, STATUS_MESSAGE_MAX) +} diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 2b5e0e789..24a8010e1 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -18,6 +18,7 @@ import { useKeybind } from "@tui/context/keybind" import { usePromptHistory, type PromptInfo } from "./history" import { assign, expandPlaceholders } from "./part" import { usePromptStash } from "./stash" +import { clampStatusMessage } from "./footer" import { DialogStash } from "../dialog-stash" import { type AutocompleteRef, Autocomplete } from "./autocomplete" import { useCommandDialog } from "../dialog-command" @@ -1897,11 +1898,13 @@ export function Prompt(props: PromptProps) { {(() => { const busyMessage = createMemo(() => { const s = status() - return s.type === "busy" ? s.message : undefined + return s.type === "busy" ? clampStatusMessage(s.message) : undefined }) return ( - {busyMessage()} + + {busyMessage()} + ) })()} @@ -1992,7 +1995,10 @@ export function Prompt(props: PromptProps) { {(item) => ( - + // flexShrink=0: the context counter is the one number the + // footer must never clip (`52.4K/96` instead of + // `52.4K/960K`); the hints beside it can give way first. + {[item().context, item().cost].filter(Boolean).join(" · ")} )} diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 5c4561557..462bcda7b 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -155,6 +155,21 @@ export function bucketMessages( return out } +/** + * A `session.status` event is authoritative for the WHOLE status object. + * + * Solid's store setter merges plain objects into the existing node + * (`mergeStoreNode` only writes `Object.keys(next)`), so writing a bare + * `{ type: "busy" }` — which is what the runner emits at the start of every turn + * (session/run-state.ts:74) — inherits the `message` of whatever status was + * written before it. That latched `/rebuild` outcome text + * (session/prompt.ts:4173) into the following turn's spinner. `reconcile()` + * drops the fields the new status omits, so each status stands alone. + */ +export function nextSessionStatus(status: SessionStatus) { + return reconcile(status) +} + export const { use: useSync, provider: SyncProvider } = createSimpleContext({ name: "Sync", init: () => { @@ -452,7 +467,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ } case "session.status": { - setStore("session_status", event.properties.sessionID, event.properties.status) + setStore("session_status", event.properties.sessionID, nextSessionStatus(event.properties.status)) break } diff --git a/packages/opencode/src/cli/cmd/tui/i18n/en.ts b/packages/opencode/src/cli/cmd/tui/i18n/en.ts index 039767425..5154882f4 100644 --- a/packages/opencode/src/cli/cmd/tui/i18n/en.ts +++ b/packages/opencode/src/cli/cmd/tui/i18n/en.ts @@ -560,6 +560,10 @@ export const dict: Record = { // Session badges "tui.session.badge.auto": "Auto", + // Context rebuild boundary marker (inserted by /rebuild) + "tui.session.rebuild_boundary.label": "context rebuilt", + "tui.session.rebuild_boundary.detail": "earlier messages summarized", + // Workspace trust "trust.title": "Accessing workspace:", "trust.safety_check": "Quick safety check: Is this a project you created or one you trust? (Like your own code, a well-known open source project, or work from your team). If not, take a moment to review what's in this folder first.", diff --git a/packages/opencode/src/cli/cmd/tui/i18n/zh.ts b/packages/opencode/src/cli/cmd/tui/i18n/zh.ts index 7347d5fe4..c067ca8f6 100644 --- a/packages/opencode/src/cli/cmd/tui/i18n/zh.ts +++ b/packages/opencode/src/cli/cmd/tui/i18n/zh.ts @@ -581,6 +581,10 @@ export const dict = { // Session badges "tui.session.badge.auto": "自动", + // Context rebuild boundary marker (inserted by /rebuild) + "tui.session.rebuild_boundary.label": "上下文已重建", + "tui.session.rebuild_boundary.detail": "较早消息已摘要", + // Workspace trust "trust.title": "访问工作区:", "trust.safety_check": "安全确认:这是你自己创建或信任的项目吗?(如你自己的代码、知名开源项目或团队内部项目)。如果不是,请先检查此目录下的内容。", diff --git a/packages/opencode/src/cli/cmd/tui/i18n/zht.ts b/packages/opencode/src/cli/cmd/tui/i18n/zht.ts index 23cc47497..e74894971 100644 --- a/packages/opencode/src/cli/cmd/tui/i18n/zht.ts +++ b/packages/opencode/src/cli/cmd/tui/i18n/zht.ts @@ -550,6 +550,10 @@ export const dict = { // Session badges "tui.session.badge.auto": "自動", + // Context rebuild boundary marker (inserted by /rebuild) + "tui.session.rebuild_boundary.label": "上下文已重建", + "tui.session.rebuild_boundary.detail": "較早訊息已摘要", + // Workspace trust "trust.title": "存取工作區:", "trust.safety_check": "安全確認:這是你自己建立或信任的專案嗎?(如你自己的程式碼、知名開源專案或團隊內部專案)。如果不是,請先檢查此目錄下的內容。", diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index bc38ccb43..201167b53 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -1536,7 +1536,16 @@ function UserMessage(props: { return parsed ? [parsed] : [] })[0] }) + // A context rebuild (`/rebuild`) inserts a single user message carrying a + // `checkpoint` part plus `synthetic: true` text parts (the rendered context + // and index). Neither renders — `checkpoint` has no PART_MAPPING entry and + // synthetic text is excluded from `text()` above — so the boundary used to be + // completely invisible in the transcript, unlike compaction which at least + // leaves a visible summary message behind. Surface it as a one-line marker + // row so the user can see that a rebuild happened and where. + const rebuildBoundary = createMemo(() => props.parts.some((x) => x.type === "checkpoint")) const { theme } = useTheme() + const t = useLanguage().t const [hover, setHover] = createSignal(false) const queued = createMemo(() => props.pending && props.message.id > props.pending) const color = createMemo(() => local.agent.color(props.message.agent)) @@ -1603,6 +1612,17 @@ function UserMessage(props: { ) }} + + + + + {" "} + ⟲ {t("tui.session.rebuild_boundary.label")}{" "} + + {t("tui.session.rebuild_boundary.detail")} + + + { - const next = await models(provider as any, { auth: pluginAuth }) + const next = await models(provider, { auth: pluginAuth }) return Object.fromEntries( Object.entries(next).map(([id, model]) => [ id, diff --git a/packages/opencode/src/session/checkpoint.ts b/packages/opencode/src/session/checkpoint.ts index 8b39ef138..d4151ae37 100644 --- a/packages/opencode/src/session/checkpoint.ts +++ b/packages/opencode/src/session/checkpoint.ts @@ -1419,7 +1419,36 @@ export const layer: Layer.Layer< .get(), ), ) - return row?.last_checkpoint_message_id as MessageID | undefined + // Two independent absences meet in this one expression, and only one of + // them is `undefined`: + // + // row is undefined -> no such session row. Drizzle normalises the + // driver's null to undefined here (measured: + // bun:sqlite's .get() returns null, Drizzle + // returns undefined), so this half is honest. + // column is null -> the session exists but no writer has set the + // watermark yet. SQL NULL, faithfully mapped to + // JS null, because the column is nullable + // (session.sql.ts: text().$type() + // with no .notNull()). + // + // So `row?.last_checkpoint_message_id` is `MessageID | null | undefined`. + // Callers only ever ask "is there a boundary to rebuild from", for which + // the last two are the same answer, so flattening to `undefined` is right + // — it also matches Drizzle's own row-level convention. + // + // The flattening is written as an ANNOTATION rather than an `as` cast on + // purpose. A cast would let the union be narrowed by assertion, which is + // how this went wrong before: the previous version claimed + // `as MessageID | undefined` while returning `null`, and a caller that + // then wrote `boundary !== undefined` got a condition that is always true + // — a guard that typechecks, reads correctly, and does nothing. Every + // other caller happened to test truthiness (`!boundary` at prompt.ts:413, + // `watermarkBefore ?` at :1131, `boundaryID ?` in nudgedSinceBoundary) and + // so never noticed. With an annotation, dropping the `?? undefined` is a + // compile error instead of a silent lie. + const boundary: MessageID | undefined = row?.last_checkpoint_message_id ?? undefined + return boundary }) const isWriterRunning = Effect.fn("SessionCheckpoint.isWriterRunning")(function* (sessionID: SessionID) { diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 3955d8039..7463ea91f 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -429,6 +429,142 @@ export const layer = Layer.effect( return inserted }) + // Upper bound on how long a rebuild may block waiting for a checkpoint + // writer it started itself. `waitForWriter` takes NO timeout argument — its + // own 5-min bound is hardcoded at checkpoint.ts:986 — so the bound is + // applied by wrapping the call below. + // + // MANUAL: 5 min, matching waitForWriter's internal bound (i.e. unchanged + // behaviour). A human just typed /rebuild and is watching a spinner. + // + // AUTO: 3 min. The auto path fires mid-turn WITHOUT being asked, so the + // stall is unsolicited and must not be as generous as the manual one. + // 180s is exactly the top of the 60-180s band the writer documents for + // itself (checkpoint.ts:981), so it admits every writer that behaves as + // designed while refusing to hold an unrequested turn for the extra two + // minutes a watching human would tolerate. Abandoning the wait does not + // cancel the writer — it keeps running detached — so a bound that is too + // tight costs one degraded turn, not the checkpoint itself. + const MANUAL_WRITER_WAIT_MS = 300_000 + const AUTO_WRITER_WAIT_MS = 180_000 + + /** + * Outcome of a rebuild attempt that is allowed to WRITE a checkpoint first. + * + * Named for what the attempt DID, not for the state it started in: reading a + * call site, `writer-failed` has to say that a writer was started and awaited + * and only then gave up. An earlier name (`no-checkpoint`) described the entry + * condition instead, which made `if (attempt === "no-checkpoint") compact()` + * read as "no checkpoint, so compact immediately" — the writer attempt is + * invisible at the call site, and that is exactly how it was misread. + * + * - "rebuilt" a boundary was inserted; context is freed. + * - "writer-failed" there was no checkpoint, so a writer WAS STARTED AND + * AWAITED here (bounded by `writerWaitMs`), and it then + * failed / never ran / the bound expired. This is the ONLY + * state in which a caller may fall back to compaction. + * - "insert-failed" a checkpoint DOES exist but the boundary insert still + * refused (degraded, e.g. renderRebuildContext empty). + * Callers must report this honestly and must NOT compact. + */ + type RebuildAttempt = "rebuilt" | "writer-failed" | "insert-failed" + + // The single place that decides whether a rebuild may degrade to + // compaction. Every caller — both auto context-overflow sites and the + // manual /rebuild command — goes through here, so the fallback condition + // is ONE condition rather than several lookalikes that can drift apart. + // + // Ordering matters: we try the on-disk checkpoint FIRST and only start a + // writer when there is no checkpoint at all. When a checkpoint already + // exists this deliberately does not block on an in-flight writer that is + // producing a fresher one — that separate, unchanged policy is documented + // on rebuildFromCheckpoint above and is NOT the justification for waiting + // here. Waiting here is justified only by the no-checkpoint case, where the + // alternative is `compaction.create`, which inserts a bare boundary marker + // and therefore drops all pre-boundary history with no summary at all. + const rebuildEnsuringCheckpoint = Effect.fn("SessionPrompt.rebuildEnsuringCheckpoint")(function* (input: { + sessionID: SessionID + msgs: MessageV2.WithParts[] + agentID?: string + agent: string + model: { providerID: string; id: string } + /** Upper bound on the writer wait; see {AUTO,MANUAL}_WRITER_WAIT_MS. */ + writerWaitMs: number + /** Run once, immediately before the wait begins, to explain the stall. */ + onWaitingForWriter?: Effect.Effect + }) { + // 1. Whatever is already on disk. + if (yield* rebuildFromCheckpoint(input).pipe(Effect.catch(() => Effect.succeed(false)))) + return "rebuilt" as const + + // 2. Distinguish "nothing to rebuild from" (may compact) from "checkpoint + // present but the insert failed" (must not compact). + // + // `hasCheckpoint` alone is NOT that distinction: it is a bare + // `Bun.file(...).exists()` (checkpoint.ts:1021), and + // `tryStartCheckpointWriter` scaffolds an EMPTY TEMPLATE at + // checkpoint.ts:650 *before* spawning the writer. Since + // `prune.fireCheckpoints` (prune.ts:289) runs immediately before the + // overflow check, "template on disk, watermark not yet written" is a + // NORMAL arrival state — and keying on the bare check classified it as + // `insert-failed`, which skipped the start-and-wait below entirely and + // silently defeated this whole helper. A usable checkpoint therefore + // requires the boundary too, which is exactly what + // `rebuildFromCheckpoint` needs (it reads `lastBoundary` at :411). + const hasCP = yield* checkpoint + .hasCheckpoint(input.sessionID) + .pipe(Effect.catch(() => Effect.succeed(false))) + const boundary = hasCP + ? yield* checkpoint.lastBoundary(input.sessionID).pipe(Effect.catch(() => Effect.succeed(undefined))) + : undefined + // Predicate note: this MUST be the same truthiness test that + // `rebuildFromCheckpoint` applies to the same value (`if (!boundary)` + // at :413), NOT `boundary !== undefined`. `lastBoundary` reads a + // nullable column and returned JS `null` for an unset watermark + // (checkpoint.ts:1422 — its declared `MessageID | undefined` was an + // unchecked cast), so `!== undefined` was true for EVERY session with a + // file on disk and this guard degenerated into the bare + // `hasCheckpoint` check it was written to replace. + if (hasCP && boundary) return "insert-failed" as const + + // 3. No checkpoint → produce one on the spot. Reentrancy: the + // isWriterRunning probe skips a redundant request, and + // tryStartCheckpointWriter is itself safe under concurrency — it + // returns "queued" instead of forking a second writer — so this can + // never start two writers for one session even when reached from + // successive runLoop iterations. + const writerRunning = yield* checkpoint + .isWriterRunning(input.sessionID) + .pipe(Effect.catch(() => Effect.succeed(false))) + if (!writerRunning) { + // promptOps is declared in TryStartCheckpointWriterInput but never read + // by the writer (it spawns as a subagent via spawnRef), so a stub + // suffices. + yield* checkpoint + .tryStartCheckpointWriter({ + sessionID: input.sessionID, + model: { providerID: input.model.providerID, modelID: input.model.id }, + promptOps: {} as never, + }) + .pipe(Effect.catch(() => Effect.succeed<"started" | "queued" | "skipped">("skipped"))) + } + + if (input.onWaitingForWriter) yield* input.onWaitingForWriter + + const writerOutcome = yield* checkpoint + .waitForWriter(input.sessionID) + .pipe( + Effect.timeout(input.writerWaitMs), + Effect.catch(() => Effect.succeed<"success" | "failure" | "no-writer">("failure")), + ) + if (writerOutcome !== "success") return "writer-failed" as const + + // 4. Writer wrote a checkpoint — rebuild from it. + if (yield* rebuildFromCheckpoint(input).pipe(Effect.catch(() => Effect.succeed(false)))) + return "rebuilt" as const + return "insert-failed" as const + }) + const resolvePromptParts = Effect.fn("SessionPrompt.resolvePromptParts")(function* (template: string) { const ctx = yield* InstanceState.context const parts: PromptInput["parts"] = [{ type: "text", text: template }] @@ -3340,34 +3476,56 @@ NOTE: At any point in time through this workflow you should feel free to ask the // Main-agent overflow: insert a checkpoint boundary marker (never // deletes DB messages) so the next iteration rebuilds from the - // freshest checkpoint. Shared with the manual `/rebuild` command via - // rebuildFromCheckpoint so logic/boundary conditions can't drift. - // Falls back to compaction only when no boundary can be produced. - const inserted = yield* rebuildFromCheckpoint({ + // freshest checkpoint. When NO checkpoint exists yet this now starts + // a writer and waits for it (bounded) rather than degrading + // immediately — the same on-the-spot behaviour the manual /rebuild + // command has, via the shared rebuildEnsuringCheckpoint helper so + // logic/boundary conditions can't drift. + const attempt: RebuildAttempt = yield* rebuildEnsuringCheckpoint({ sessionID, msgs, agentID: lastUser.agentID, agent: lastUser.agent, model: { providerID: model.providerID, id: model.id }, + writerWaitMs: AUTO_WRITER_WAIT_MS, + // The turn is mid-flight, so explain the stall: without this the + // TUI would sit on a bare spinner for minutes with no reason. + onWaitingForWriter: status + .set(sessionID, { type: "busy", message: "Writing checkpoint\u2026" }) + .pipe(Effect.catch(() => Effect.void)), }) - if (inserted) { + if (attempt === "rebuilt") { skipOverflowCheck = true continue } - // F39: no checkpoint — fall back to compaction (LLM-driven lossy summary). - // Better than mechanical trim: preserves semantic content via summary. - yield* compaction - .create({ - sessionID, - agent: lastUser.agent, - model: { providerID: model.providerID, modelID: model.id }, - auto: true, - agentID: lastUser.agentID, - }) - .pipe(Effect.ignore) - skipOverflowCheck = true - continue + // A writer was started and awaited above (AUTO_WRITER_WAIT_MS) and + // still produced nothing — the ONE condition that may compact. + if (attempt === "writer-failed") { + // THE single compaction fallback: no checkpoint existed AND the + // writer failed / never ran / the bound expired. Note this is a + // bare boundary insert, not an LLM summary — everything before it + // is dropped unsummarized (compaction.ts:499, message-v2.ts:1037) + // — which is exactly why we tried to write a checkpoint first. + yield* compaction + .create({ + sessionID, + agent: lastUser.agent, + model: { providerID: model.providerID, modelID: model.id }, + auto: true, + agentID: lastUser.agentID, + }) + .pipe(Effect.ignore) + skipOverflowCheck = true + continue + } + + // "insert-failed": a checkpoint DOES exist, so compaction is not + // permitted here — it would amputate history we hold a usable + // checkpoint for. Nothing freed context, so do NOT `continue` into + // an identical overflow check; fall through and let the model call + // proceed. The provider-signalled overflow handler below is the + // backstop if the request is actually rejected. } skipOverflowCheck = false @@ -3917,30 +4075,37 @@ NOTE: At any point in time through this workflow you should feel free to ask the } // Main-agent provider-signalled overflow: prefer rebuild over - // compaction. Shared with the manual `/rebuild` command via - // rebuildFromCheckpoint (does not block on the writer; uses the - // on-disk checkpoint). Fall back to compaction only when no - // boundary can be produced. - const inserted2 = yield* rebuildFromCheckpoint({ + // compaction, via the same shared rebuildEnsuringCheckpoint helper + // the token-threshold path and manual /rebuild use — so the + // compaction fallback stays ONE condition, not three lookalikes. + const attempt2: RebuildAttempt = yield* rebuildEnsuringCheckpoint({ sessionID, msgs, agentID: lastUser.agentID, agent: lastUser.agent, model: { providerID: model.providerID, id: model.id }, + writerWaitMs: AUTO_WRITER_WAIT_MS, + onWaitingForWriter: status + .set(sessionID, { type: "busy", message: "Writing checkpoint\u2026" }) + .pipe(Effect.catch(() => Effect.void)), }) - if (inserted2) return "continue" as const + if (attempt2 === "rebuilt") return "continue" as const - // F39: no checkpoint — fall back to compaction (LLM-driven lossy summary). - yield* compaction - .create({ - sessionID, - agent: lastUser.agent, - model: { providerID: model.providerID, modelID: model.id }, - auto: true, - overflow: true, - agentID: lastUser.agentID, - }) - .pipe(Effect.ignore) + // Same as above: the writer ran and failed — not "no checkpoint". + if (attempt2 === "writer-failed") { + // THE single compaction fallback (see the token-threshold site). + yield* compaction + .create({ + sessionID, + agent: lastUser.agent, + model: { providerID: model.providerID, modelID: model.id }, + auto: true, + overflow: true, + agentID: lastUser.agentID, + }) + .pipe(Effect.ignore) + } + // "insert-failed" → a checkpoint exists; must not compact. } return "continue" as const }).pipe(Effect.ensuring(instruction.clear(handle.message.id))) @@ -4135,41 +4300,131 @@ NOTE: At any point in time through this workflow you should feel free to ask the yield* goal.set(input.sessionID, condition) } - // /rebuild — manually rebuild the conversation context now, from the - // latest checkpoint. Reuses the SAME rebuildFromCheckpoint step as the - // automatic overflow path (identical logic + boundary conditions), so a - // user-triggered rebuild behaves exactly like an auto one: it inserts a - // checkpoint boundary at the watermark (recent messages after it are kept - // verbatim; earlier ones collapse to the checkpoint summary on the next - // turn). If no usable checkpoint exists yet, tell the user rather than - // silently doing nothing — the first checkpoint has to be produced by - // normal turns before there is anything to rebuild from. + // /rebuild — manually rebuild the conversation context ON THE SPOT, + // from the latest checkpoint. Implements the 3-case checkpoint-freshness + // semantics: + // 1. Usable checkpoint exists, no writer running → rebuild immediately. + // 2. No usable checkpoint → start a writer and wait for it, then rebuild. + // 3. Checkpoint exists + writer in-flight → wait (with timeout), rebuild + // with the fresher checkpoint if it arrives, else fall back to existing. + // Cases 1-3 live in the shared rebuildEnsuringCheckpoint helper, which the + // auto context-overflow paths use too, so the manual and automatic + // behaviours cannot drift and there is exactly ONE compaction fallback + // condition (no checkpoint AND the writer failed) in this file. + // + // Manual /rebuild mirrors the AUTO rebuild/compaction path exactly: it + // inserts the legitimate rebuild BOUNDARY (a role:"user" message carrying + // a `checkpoint` part, via rebuildFromCheckpoint → insertRebuildBoundary) + // and then lets the session settle — WITHOUT fabricating a second, + // standalone user turn. The auto path (~prompt.ts:3205/3778) `continue`s + // the runLoop because it has a PENDING user message to answer; a manual + // /rebuild is a user-initiated maintenance action with NO pending + // question, so after inserting the boundary it simply returns to idle + // (no model turn, no auto-reply). + // + // The outcome ("context rebuilt" / "compacted instead because the writer + // failed" / "checkpoint written but rebuild failed") is surfaced to the + // user through the SessionStatus / Bus status channel — the same + // busy-status mechanism that drives "Rebuilding context…" / + // "Writing checkpoint…" — NOT through a persisted synthetic user message. + // The busy status is set BEFORE any work so the TUI spinner lights up + // immediately; because the runLoop is never entered, its onIdle won't + // clear busy status, so every return path clears idle explicitly. if (input.command === Command.Default.REBUILD) { const msgs = yield* sessions.messages({ sessionID: input.sessionID, agentID: "main" }) const lastUser = msgs.findLast((m) => m.info.role === "user") const model = yield* lastModel(input.sessionID) - const inserted = yield* rebuildFromCheckpoint({ + + // Emit the terminal outcome on the status channel, then return to idle. + // Returns the message the handler should hand back (never a fabricated + // user turn): the freshly-inserted boundary on success, else the + // existing last user message so callers still receive a WithParts. + const settle = Effect.fn("SessionPrompt.rebuild.settle")(function* (message: string) { + yield* status.set(input.sessionID, { type: "busy", message }).pipe(Effect.catch(() => Effect.void)) + yield* status.set(input.sessionID, { type: "idle" }).pipe(Effect.catch(() => Effect.void)) + }) + const compactedInsteadMsg = + "No checkpoint could be written (the checkpoint writer failed), so the context was compacted instead — earlier messages were dropped rather than rebuilt from a checkpoint." + const rebuildFailedMsg = + "A checkpoint was written but the context could not be rebuilt from it. Context is unchanged and nothing was compacted — retry /rebuild, or report this if it repeats." + const rebuiltMsg = + "Context rebuilt from the latest checkpoint. Recent messages are preserved; earlier context is now summarized." + + // Set busy status so the TUI shows a spinner while we wait on the + // writer (cases 2/3) or assemble context (case 1). + yield* status.set(input.sessionID, { type: "busy", message: "Rebuilding context\u2026" }).pipe( + Effect.catch(() => Effect.void), + ) + + // Cases 1-3 all run through the shared rebuildEnsuringCheckpoint helper: + // it rebuilds from an existing checkpoint, or — on a cold session — spawns + // a writer, waits for it (bounded), and rebuilds from the fresh + // checkpoint. That is the user-decided semantics: /rebuild on a cold + // session produces the first checkpoint on the spot rather than deferring. + const attempt: RebuildAttempt = yield* rebuildEnsuringCheckpoint({ sessionID: input.sessionID, msgs, agentID: lastUser?.info.agentID ?? "main", agent: agentName, model: { providerID: model.providerID, id: model.modelID }, - }).pipe(Effect.catch(() => Effect.succeed(false))) - return yield* prompt({ - sessionID: input.sessionID, - messageID: input.messageID, - agent: agentName, - parts: [ - { - type: "text", - text: inserted - ? "Context rebuilt from the latest checkpoint. Recent messages are preserved; earlier context is now summarized." - : "No checkpoint is available to rebuild from yet — continue the conversation and a checkpoint will be written automatically.", - synthetic: true, - }, - ], - noReply: true, - }) + writerWaitMs: MANUAL_WRITER_WAIT_MS, + onWaitingForWriter: status + .set(input.sessionID, { type: "busy", message: "Writing checkpoint\u2026" }) + .pipe(Effect.catch(() => Effect.void)), + }).pipe(Effect.catch(() => Effect.succeed("insert-failed" as const))) + + // A writer was started and awaited above (MANUAL_WRITER_WAIT_MS) and + // still produced nothing — only then may /rebuild degrade to compaction. + if (attempt === "writer-failed") { + // No checkpoint AND the writer genuinely failed / never ran / the bound + // expired — the ONE fallback condition, shared with the auto overflow + // paths. An earlier revision of this branch deliberately did NOT + // compact here, reasoning that /rebuild means "rebuild from a + // checkpoint" so substituting a lossy summary would misreport what + // happened. The user overruled that tradeoff: if the writer genuinely + // failed, a truncating compaction beats doing nothing. We keep the + // report honest by naming the substitution on the status channel + // instead of silently swapping the mechanism, and — per the branch's + // existing noReply decision (3244ca732) — fabricate neither an + // assistant reply nor a synthetic user turn. + yield* compaction + .create({ + sessionID: input.sessionID, + agent: agentName, + model: { providerID: model.providerID, modelID: model.modelID }, + // Not user-requested: the user asked for a rebuild, the system + // chose this degradation. + auto: true, + agentID: lastUser?.info.agentID ?? "main", + }) + .pipe(Effect.ignore) + yield* settle(compactedInsteadMsg) + return lastUser ?? msgs[msgs.length - 1]! + } + + if (attempt === "insert-failed") { + // A checkpoint EXISTS but the boundary insert refused (e.g. + // renderRebuildContext returned empty — degraded state). NOT a + // fallback case: compacting would drop history that a usable + // checkpoint was available for. Report the degraded state accurately + // and return to idle. + yield* settle(rebuildFailedMsg) + return lastUser ?? msgs[msgs.length - 1]! + } + + // Boundary inserted (Step A — the shared, correct mechanism). A MANUAL + // /rebuild is a user action whose whole intent is to free/rebuild the + // context: the user asked no question, so the model must NOT reply and + // NO second user turn is fabricated. We surface the "context rebuilt" + // outcome on the status channel and return the boundary message itself + // (the newest role:"user" message carrying a checkpoint part), then go + // idle. The runLoop is never entered — mirroring the transparent + // boundary insertion the auto/compaction paths perform, minus their + // pending-message `continue`. + yield* settle(rebuiltMsg) + const after = yield* sessions.messages({ sessionID: input.sessionID, agentID: "main" }) + const boundaryMessage = after.findLast((m) => m.parts.some((p) => p.type === "checkpoint")) + return boundaryMessage ?? lastUser ?? after[after.length - 1]! } const raw = input.arguments.match(argsRegex) ?? [] diff --git a/packages/opencode/test/cli/cmd/tui/prompt-footer-status.test.ts b/packages/opencode/test/cli/cmd/tui/prompt-footer-status.test.ts new file mode 100644 index 000000000..985df4731 --- /dev/null +++ b/packages/opencode/test/cli/cmd/tui/prompt-footer-status.test.ts @@ -0,0 +1,35 @@ +import { describe, test, expect } from "bun:test" +import { clampStatusMessage, STATUS_MESSAGE_MAX } from "../../../../src/cli/cmd/tui/component/prompt/footer" + +describe("clampStatusMessage", () => { + test("an over-long status cannot outgrow the footer budget", () => { + const long = + "Context rebuilt from the latest checkpoint. Recent messages are preserved; earlier context is now summarized." + expect(long.length).toBeGreaterThan(STATUS_MESSAGE_MAX) + const out = clampStatusMessage(long)! + expect(out.length).toBe(STATUS_MESSAGE_MAX) + expect(out.endsWith("\u2026")).toBe(true) + expect(long.startsWith(out.slice(0, -1))).toBe(true) + }) + + test("short statuses pass through untouched", () => { + expect(clampStatusMessage("Rebuilding context\u2026")).toBe("Rebuilding context\u2026") + expect(clampStatusMessage("Writing checkpoint\u2026")).toBe("Writing checkpoint\u2026") + }) + + test("newlines are flattened so the status can never claim extra rows", () => { + expect(clampStatusMessage("Rebuilding\ncontext\u2026")).toBe("Rebuilding context\u2026") + expect(clampStatusMessage(" Rebuilding context\u2026 ")).toBe("Rebuilding context\u2026") + }) + + test("empty and missing statuses render nothing", () => { + expect(clampStatusMessage(undefined)).toBeUndefined() + expect(clampStatusMessage("")).toBeUndefined() + expect(clampStatusMessage(" \n ")).toBeUndefined() + }) + + test("the budget leaves room for the spinner, interrupt hint and context counter on 80 columns", () => { + // "⠋ " + message + "esc interrupt" + "52.4K/960K (5%)" + expect(STATUS_MESSAGE_MAX + 2 + "esc interrupt".length + "52.4K/960K (5%)".length).toBeLessThanOrEqual(80) + }) +}) diff --git a/packages/opencode/test/cli/cmd/tui/rebuild-boundary-marker.test.ts b/packages/opencode/test/cli/cmd/tui/rebuild-boundary-marker.test.ts new file mode 100644 index 000000000..948c69189 --- /dev/null +++ b/packages/opencode/test/cli/cmd/tui/rebuild-boundary-marker.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from "bun:test" +import { dict as en } from "../../../../src/cli/cmd/tui/i18n/en" +import { dict as zh } from "../../../../src/cli/cmd/tui/i18n/zh" +import { dict as zht } from "../../../../src/cli/cmd/tui/i18n/zht" + +// A `/rebuild` inserts one user message carrying a `checkpoint` part plus +// synthetic text parts. The TUI renders it as a one-line marker row in +// UserMessage (routes/session/index.tsx) so the boundary is visible in the +// transcript. The label copy is localized; the marker row would render an empty +// badge if these keys ever went missing, so pin them here. +const KEYS = ["tui.session.rebuild_boundary.label", "tui.session.rebuild_boundary.detail"] as const + +describe("rebuild boundary marker copy", () => { + test("english copy names the rebuild and what happened to earlier messages", () => { + expect(en["tui.session.rebuild_boundary.label"]).toBe("context rebuilt") + expect(en["tui.session.rebuild_boundary.detail"]).toBe("earlier messages summarized") + }) + + test("localized dictionaries define the marker copy", () => { + for (const key of KEYS) { + for (const [name, dict] of Object.entries({ en, zh, zht })) { + expect(dict[key], `${name} is missing ${key}`).toBeTruthy() + } + // Untranslated keys silently fall back to English via the `base` merge in + // context/language.tsx, so an English string here means a missed + // translation rather than a crash — assert it is actually translated. + expect(zh[key]).not.toBe(en[key]) + expect(zht[key]).not.toBe(en[key]) + } + }) +}) diff --git a/packages/opencode/test/cli/tui/session-status-store.test.ts b/packages/opencode/test/cli/tui/session-status-store.test.ts new file mode 100644 index 000000000..1f78b6e65 --- /dev/null +++ b/packages/opencode/test/cli/tui/session-status-store.test.ts @@ -0,0 +1,71 @@ +import { describe, test, expect } from "bun:test" +import { createRoot } from "solid-js" +import { createStore } from "solid-js/store" +import { nextSessionStatus } from "../../../src/cli/cmd/tui/context/sync" + +// The TUI stores every `session.status` event in a solid store keyed by session. +// Solid MERGES plain objects into the existing node, so a status that omits a +// field used to inherit it from the previous status — that is how the /rebuild +// outcome sentence latched into the following turn's spinner. +function harness() { + return createRoot((dispose) => { + const [store, setStore] = createStore<{ session_status: Record }>({ session_status: {} }) + return { + store, + apply: (sessionID: string, status: Parameters[0]) => + setStore("session_status", sessionID, nextSessionStatus(status)), + dispose, + } + }) +} + +describe("nextSessionStatus", () => { + const REBUILT = + "Context rebuilt from the latest checkpoint. Recent messages are preserved; earlier context is now summarized." + + test("a following turn's bare busy status does not inherit the rebuild message", () => { + const h = harness() + h.apply("ses_1", { type: "busy", message: REBUILT }) + expect(h.store.session_status["ses_1"]).toEqual({ type: "busy", message: REBUILT }) + + // /rebuild settles to idle immediately after emitting its outcome. + h.apply("ses_1", { type: "idle" }) + expect(h.store.session_status["ses_1"]).toEqual({ type: "idle" }) + + // The next turn: the runner emits a bare busy (session/run-state.ts:74). + h.apply("ses_1", { type: "busy" }) + expect((h.store.session_status["ses_1"] as { message?: string }).message).toBeUndefined() + h.dispose() + }) + + test("idle clears a busy message", () => { + const h = harness() + h.apply("ses_2", { type: "busy", message: "Writing checkpoint\u2026" }) + h.apply("ses_2", { type: "idle" }) + expect(h.store.session_status["ses_2"]).toEqual({ type: "idle" }) + h.dispose() + }) + + test("busy replaces an earlier busy message instead of keeping it", () => { + const h = harness() + h.apply("ses_3", { type: "busy", message: "Rebuilding context\u2026" }) + h.apply("ses_3", { type: "busy", message: "Writing checkpoint\u2026" }) + expect(h.store.session_status["ses_3"]).toEqual({ type: "busy", message: "Writing checkpoint\u2026" }) + h.dispose() + }) + + test("retry fields do not survive into the next status", () => { + const h = harness() + h.apply("ses_4", { type: "retry", attempt: 2, message: "boom", next: 1000 }) + h.apply("ses_4", { type: "busy" }) + expect(h.store.session_status["ses_4"]).toEqual({ type: "busy" }) + h.dispose() + }) + + test("first status for an unseen session is stored as-is", () => { + const h = harness() + h.apply("ses_5", { type: "busy", message: "Rebuilding context\u2026" }) + expect(h.store.session_status["ses_5"]).toEqual({ type: "busy", message: "Rebuilding context\u2026" }) + h.dispose() + }) +}) diff --git a/packages/opencode/test/command/rebuild.test.ts b/packages/opencode/test/command/rebuild.test.ts index d83ba101b..456a322c8 100644 --- a/packages/opencode/test/command/rebuild.test.ts +++ b/packages/opencode/test/command/rebuild.test.ts @@ -7,23 +7,40 @@ describe("/rebuild command", () => { expect(Command.Default.REBUILD).toBe("rebuild") }) - test("prompt.ts wires a /rebuild special-case that reuses the shared rebuildFromCheckpoint helper", async () => { + test("prompt.ts wires a /rebuild special-case that reuses the shared rebuild helpers", async () => { // Source-level guard (mirrors the repo's other prompt.ts wiring guards). // The /rebuild command must (a) exist as a special-case in SessionPrompt.command, - // (b) call the SAME rebuildFromCheckpoint helper the automatic overflow path - // uses (so logic/boundary conditions can't drift), and (c) report both the - // success and no-checkpoint outcomes to the user rather than silently no-op. + // (b) call the SAME helpers the automatic overflow path uses (so logic/boundary + // conditions can't drift), and (c) report its outcomes to the user rather than + // silently no-op. + // + // DELIBERATE UPDATE: (b) used to require a direct rebuildFromCheckpoint( call + // inside the REBUILD block, and (c) used to require the string + // "No checkpoint is available to rebuild from yet — continue the + // conversation and a checkpoint will be written automatically." + // Both statements are now wrong, not merely reshaped: + // - the manual block calls rebuildEnsuringCheckpoint, which wraps + // rebuildFromCheckpoint and adds the start-a-writer-and-wait step the auto + // overflow path now shares; + // - that no-checkpoint string was deleted because the exit it could still + // reach is "a checkpoint WAS written but the rebuild failed", where + // advising the user to keep talking so a checkpoint gets written is false. + // The outcomes are still asserted, against the messages that now exist. const promptSrc = await Bun.file( path.join(import.meta.dir, "..", "..", "src", "session", "prompt.ts"), ).text() // (a) special-case dispatch on the rebuild command expect(promptSrc).toContain("input.command === Command.Default.REBUILD") - // (b) reuses the shared helper (defined once, called by both auto + manual) + // (b) reuses the shared helpers (each defined once, called by auto + manual) expect(promptSrc).toContain("const rebuildFromCheckpoint = Effect.fn") - expect(promptSrc).toMatch(/if\s*\(input\.command === Command\.Default\.REBUILD\)[\s\S]*?rebuildFromCheckpoint\(/) - // (c) both outcomes surfaced to the user + expect(promptSrc).toContain("const rebuildEnsuringCheckpoint = Effect.fn") + expect(promptSrc).toMatch( + /if\s*\(input\.command === Command\.Default\.REBUILD\)[\s\S]*?rebuildEnsuringCheckpoint\(/, + ) + // (c) all three outcomes surfaced to the user expect(promptSrc).toContain("Context rebuilt from the latest checkpoint") - expect(promptSrc).toContain("No checkpoint is available to rebuild from yet") + expect(promptSrc).toContain("the context was compacted instead") + expect(promptSrc).toContain("could not be rebuilt from it") }) }) diff --git a/packages/opencode/test/session/auto-overflow-writer-first.test.ts b/packages/opencode/test/session/auto-overflow-writer-first.test.ts new file mode 100644 index 000000000..d178329f4 --- /dev/null +++ b/packages/opencode/test/session/auto-overflow-writer-first.test.ts @@ -0,0 +1,510 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { Deferred, Effect, Layer } from "effect" +import * as fs from "fs/promises" +import path from "path" +import { GlobalBus } from "../../src/bus/global" +import { Database, desc, eq } from "../../src/storage" +import { Instance } from "../../src/project/instance" +import { Session } from "../../src/session" +import { SessionPrompt } from "../../src/session/prompt" +import { MessageTable, SessionTable } from "../../src/session/session.sql" +import { checkpointPath } from "../../src/session/checkpoint-paths" +import { spawnRef } from "../../src/actor/spawn-ref" +import type { AgentOutcome } from "../../src/actor/spawn" +import { MessageID, PartID, SessionID } from "../../src/session/schema" +import { ModelID, ProviderID } from "../../src/provider/schema" +import { tmpdir } from "../fixture/fixture" +import { Log } from "../../src/util" + +void Log.init({ print: false }) + +const ref = { + providerID: ProviderID.make("alibaba"), + modelID: ModelID.make("qwen-plus"), +} + +afterEach(async () => { + await Instance.disposeAll() +}) + +function run(fx: Effect.Effect) { + return Effect.runPromise( + fx.pipe(Effect.scoped, Effect.provide(Layer.mergeAll(SessionPrompt.defaultLayer, Session.defaultLayer))), + ) +} + +function chat(text: string): ReadableStream { + const payload = + [ + `data: ${JSON.stringify({ + id: "chatcmpl-1", + object: "chat.completion.chunk", + choices: [{ delta: { role: "assistant" } }], + })}`, + `data: ${JSON.stringify({ + id: "chatcmpl-1", + object: "chat.completion.chunk", + choices: [{ delta: { content: text } }], + })}`, + `data: ${JSON.stringify({ + id: "chatcmpl-1", + object: "chat.completion.chunk", + choices: [{ delta: {}, finish_reason: "stop" }], + })}`, + "data: [DONE]", + ].join("\n\n") + "\n\n" + const encoder = new TextEncoder() + return new ReadableStream({ + start(ctrl) { + ctrl.enqueue(encoder.encode(payload)) + ctrl.close() + }, + }) +} + +function startLLM(reply: string) { + let calls = 0 + const server = Bun.serve({ + port: 0, + fetch(req) { + const url = new URL(req.url) + if (!url.pathname.endsWith("/chat/completions")) return new Response("not found", { status: 404 }) + calls++ + return new Response(chat(reply), { status: 200, headers: { "Content-Type": "text/event-stream" } }) + }, + }) + return { + origin: server.url.origin, + get calls() { + return calls + }, + stop: () => server.stop(true), + } +} + +type SpawnImpl = NonNullable + +function withSpawnRef(impl: SpawnImpl | undefined, body: () => Promise): Promise { + const prev = spawnRef.current + spawnRef.current = impl + return body().finally(() => { + spawnRef.current = prev + }) +} + +/** + * Writer stub that behaves like a REAL writer: it does not finish instantly. + * It resolves `delayMs` later, and only then writes real checkpoint content and + * advances the watermark. + * + * The delay is load-bearing for what this file proves. prune.fireCheckpoints + * runs immediately BEFORE the overflow check (prune.ts:289) and already calls + * tryStartCheckpointWriter, which scaffolds an empty template file + * (checkpoint.ts:650). So at the moment the overflow check runs, the realistic + * state is: checkpoint FILE exists, watermark NOT yet set, writer in flight — + * which is what rebuildFromCheckpoint reports as "nothing usable". An + * instant-success stub would have already advanced the watermark by then, so the + * old code would have rebuilt too and the test would prove nothing. + */ +function writerThatWritesCheckpointAfter(marker: string, delayMs: number): SpawnImpl { + let counter = 0 + return { + spawn: (input) => + Effect.gen(function* () { + counter += 1 + const parent = (input.parentSessionID ?? input.sessionID) as SessionID + const outcome = yield* Deferred.make() + yield* Effect.forkDetach( + Effect.gen(function* () { + yield* Effect.sleep(delayMs) + const cpFile = checkpointPath(parent) + yield* Effect.promise(() => fs.mkdir(path.dirname(cpFile), { recursive: true })) + yield* Effect.promise(() => + fs.writeFile(cpFile, `# Session checkpoint\n\n## §1 Active intent\n${marker}\n`), + ) + const last = yield* Effect.sync(() => + Database.use((db) => + db + .select({ id: MessageTable.id }) + .from(MessageTable) + .where(eq(MessageTable.session_id, parent)) + .orderBy(desc(MessageTable.id)) + .limit(1) + .get(), + ), + ) + if (last?.id) { + yield* Effect.sync(() => + Database.use((db) => + db + .update(SessionTable) + .set({ last_checkpoint_message_id: last.id }) + .where(eq(SessionTable.id, parent)) + .run(), + ), + ) + } + yield* Deferred.succeed(outcome, { status: "success" as const }) + }), + ) + return { actorID: `${input.agentType}-${counter}`, sessionID: input.sessionID, outcome } + }), + cancel: () => Effect.void, + getForkContext: () => Effect.succeed(undefined), + } as SpawnImpl +} + +function writerThatFails(): SpawnImpl { + let counter = 0 + return { + spawn: (input) => + Effect.gen(function* () { + counter += 1 + const outcome = yield* Deferred.make() + yield* Deferred.succeed(outcome, { status: "failure" as const, error: "writer blew up" }) + return { actorID: `${input.agentType}-${counter}`, sessionID: input.sessionID, outcome } + }), + cancel: () => Effect.void, + getForkContext: () => Effect.succeed(undefined), + } as SpawnImpl +} + +// Shrink the usable window so a seeded token count trips +// SessionOverflow.isOverflow deterministically. reserves() = compaction.reserved +// (100) + a 20_000 output reservation (this model publishes no limit.input), so +// max_context must exceed ~20_100 to be honoured at all. It must ALSO leave +// usable > 13_000, or SessionPrune.resolveThresholds refuses the window +// ("too small for checkpoints"). 40_000 satisfies both: usable = 40_000 - +// 20_100 = 19_900, against the seeded 50_000 tokens. +function mimocodeConfig(baseURL: string) { + return JSON.stringify({ + $schema: "https://opencode.ai/config.json", + enabled_providers: ["alibaba"], + provider: { alibaba: { options: { apiKey: "test-key", baseURL: `${baseURL}/v1` } } }, + agent: { build: { model: "alibaba/qwen-plus" } }, + compaction: { reserved: 100, max_context: 40000 }, + }) +} + +async function seedUserMessage(sessionID: SessionID, text: string) { + const msg = await Effect.runPromise( + Session.Service.use((s) => + s.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID, + // F49+F50: the main agent's messages carry agentID "main", and the + // runLoop reads its slice with agentID "main" — seeds must match or + // they are invisible to the overflow check. + agentID: "main", + agent: "build", + model: ref, + time: { created: Date.now() }, + }), + ).pipe(Effect.provide(Session.defaultLayer)), + ) + await Effect.runPromise( + Session.Service.use((s) => + s.updatePart({ id: PartID.ascending(), messageID: msg.id, sessionID, type: "text", text }), + ).pipe(Effect.provide(Session.defaultLayer)), + ) + return msg +} + +/** + * Seed a COMPLETED assistant turn reporting a token count far above the usable + * window. The runLoop reads exactly this message as `lastFinished` and feeds its + * tokens to the overflow check, so this is what makes the next prompt overflow. + */ +async function seedFinishedAssistant(sessionID: SessionID, parentID: MessageID, totalTokens: number) { + const msg = await Effect.runPromise( + Session.Service.use((s) => + s.updateMessage({ + id: MessageID.ascending(), + role: "assistant", + sessionID, + parentID, + agentID: "main", + agent: "build", + mode: "build", + modelID: ref.modelID, + providerID: ref.providerID, + path: { cwd: "/tmp", root: "/tmp" }, + cost: 0, + finish: "stop", + tokens: { total: totalTokens, input: totalTokens, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: Date.now(), completed: Date.now() }, + }), + ).pipe(Effect.provide(Session.defaultLayer)), + ) + await Effect.runPromise( + Session.Service.use((s) => + s.updatePart({ + id: PartID.ascending(), + messageID: msg.id, + sessionID, + type: "text", + text: "a very long prior answer", + }), + ).pipe(Effect.provide(Session.defaultLayer)), + ) + return msg +} + +// These tests drive the REAL main-agent context-overflow path inside +// SessionPrompt's runLoop (prompt.ts, the `overflowCheck(...) || +// maxThresholdCrossed(...)` branch) against a scripted LLM stub, and assert on +// what the session ends up containing: a `checkpoint` boundary part (rebuild) vs +// a `compaction` boundary part (degradation). +// +// The behaviour under test is the fix for the auto/manual asymmetry: both paths +// share rebuildFromCheckpoint, which only checks hasCheckpoint and returns +// false. Manual /rebuild handled `false` by writing a checkpoint on the spot and +// waiting; the auto path used to give up and call compaction.create. Now both go +// through rebuildEnsuringCheckpoint, and compaction is reachable from exactly +// ONE condition: no checkpoint AND the writer failed / never ran / the wait bound +// expired. +// +// Why this matters more than it looks: compaction.create is NOT an LLM +// summarizer (measured p50 0.240ms). It inserts a bare `compaction` marker that +// MessageV2.filterCompacted breaks at, so every pre-boundary message is dropped +// with no summary at all. Degrading is therefore a real loss, not a cheaper +// summary. +describe("Auto context overflow: write a checkpoint before degrading to compaction", () => { + test( + "no checkpoint + writer succeeds → inserts a checkpoint boundary and does NOT compact", + async () => { + const llm = startLLM("post-rebuild-reply") + const writer = writerThatWritesCheckpointAfter("AUTO_OVERFLOW_FRESH_CHECKPOINT", 400) + const seen: Array = [] + const onEvent = (e: { + payload?: { type?: string; properties?: { status?: { type?: string; message?: string } } } + }) => { + if (e?.payload?.type === "session.status" && e.payload.properties?.status?.type === "busy") { + seen.push(e.payload.properties.status.message) + } + } + GlobalBus.on("event", onEvent) + try { + await using tmp = await tmpdir({ + git: true, + init: (dir) => Bun.write(path.join(dir, "mimocode.json"), mimocodeConfig(llm.origin)), + }) + + await withSpawnRef(writer, () => + Instance.provide({ + directory: tmp.path, + fn: () => + run( + Effect.gen(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const info = yield* sessions.create({ title: "auto-overflow-rebuild" }) + + // Cold session (no checkpoint file) whose last completed + // assistant turn already blew the usable window. + const first = yield* Effect.promise(() => seedUserMessage(info.id, "earlier question")) + yield* Effect.promise(() => seedFinishedAssistant(info.id, first.id, 50_000)) + + yield* prompt.prompt({ + sessionID: info.id, + parts: [{ type: "text", text: "next question that overflows" }], + agent: "build", + }) + + const after = yield* sessions.messages({ sessionID: info.id }) + + // The overflow was resolved by a REBUILD: a checkpoint + // boundary landed… + const checkpoints = after.filter((m) => m.parts.some((p) => p.type === "checkpoint")) + expect(checkpoints.length).toBe(1) + expect(checkpoints[0]!.info.role).toBe("user") + + // …and NOT by degrading to compaction. This is the assertion + // that fails before the fix: the old code called + // compaction.create the moment rebuildFromCheckpoint said no. + const compactions = after.filter((m) => m.parts.some((p) => p.type === "compaction")) + expect(compactions.length).toBe(0) + + // A writer actually settled: the checkpoint watermark is now + // set. Before the fix nothing waited for it, so at the moment + // the overflow check ran the watermark was still unset — which + // is exactly why rebuildFromCheckpoint returned false and the + // old code compacted. + const watermark = yield* Effect.sync(() => + Database.use((db) => + db + .select({ id: SessionTable.last_checkpoint_message_id }) + .from(SessionTable) + .where(eq(SessionTable.id, info.id)) + .get(), + ), + ) + expect(watermark?.id).toBeTruthy() + + // The boundary is the message the watermark points at or later + // — i.e. the rebuild used the checkpoint, not a guess. + expect(checkpoints[0]!.parts.some((p) => p.type === "checkpoint")).toBe(true) + }), + ), + }), + ) + } finally { + GlobalBus.off("event", onEvent) + await llm.stop() + } + + // A multi-minute mid-turn wait must be explained, not look frozen. + expect(seen).toContain("Writing checkpoint\u2026") + }, + { timeout: 60_000 }, + ) + + test( + "no checkpoint + writer genuinely fails → STILL falls back to compaction", + async () => { + const llm = startLLM("post-compaction-reply") + // Writer spawns and reports failure — the genuine-failure case, which is + // the ONLY condition allowed to reach compaction. + const writer = writerThatFails() + try { + await using tmp = await tmpdir({ + git: true, + init: (dir) => Bun.write(path.join(dir, "mimocode.json"), mimocodeConfig(llm.origin)), + }) + + await withSpawnRef(writer, () => + Instance.provide({ + directory: tmp.path, + fn: () => + run( + Effect.gen(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const info = yield* sessions.create({ title: "auto-overflow-compaction" }) + + const first = yield* Effect.promise(() => seedUserMessage(info.id, "earlier question")) + yield* Effect.promise(() => seedFinishedAssistant(info.id, first.id, 50_000)) + + yield* prompt.prompt({ + sessionID: info.id, + parts: [{ type: "text", text: "next question that overflows" }], + agent: "build", + }) + + const after = yield* sessions.messages({ sessionID: info.id }) + + // Writer failed and no checkpoint existed → degrade. + const compactions = after.filter((m) => m.parts.some((p) => p.type === "compaction")) + expect(compactions.length).toBe(1) + + // No checkpoint boundary, because no checkpoint was written. + const checkpoints = after.filter((m) => m.parts.some((p) => p.type === "checkpoint")) + expect(checkpoints.length).toBe(0) + }), + ), + }), + ) + } finally { + await llm.stop() + } + }, + { timeout: 60_000 }, + ) + + // The arrival state this guards is NORMAL, not degraded, which is what makes + // it easy to misclassify. `prune.fireCheckpoints` (prune.ts:289) runs + // immediately BEFORE the overflow check, and `tryStartCheckpointWriter` + // scaffolds an EMPTY TEMPLATE (checkpoint.ts:650) *before* spawning the + // writer. So by the time the overflow check runs, "checkpoint file on disk, + // watermark not yet written" is the ordinary case. + // + // Two successive bugs lived here, and the second one hid inside the fix for + // the first: + // + // 1. The discriminator keyed on bare `hasCheckpoint` — literally + // `Bun.file(...).exists()` (checkpoint.ts:1021) — so the scaffolded + // template counted as a usable checkpoint and the state was reported as + // `insert-failed`. + // 2. Re-keying it on `lastBoundary` was right in substance but was written as + // `boundary !== undefined`, and `lastBoundary` returned JS `null` for an + // unset watermark (a nullable column behind an unchecked + // `as MessageID | undefined` cast, checkpoint.ts:1422). `null !== undefined` + // is true, so the new guard was a NO-OP and behaved exactly like (1). + // + // Both bugs present identically and silently: `insert-failed` is the one + // outcome that neither rebuilds nor compacts (prompt.ts:3515-3521 deliberately + // falls through to the model call), so the overflow is simply left unresolved + // — zero checkpoints AND zero compactions, no error anywhere. That signature + // is why this needs a test rather than a code reading: the sibling test above + // passes with either bug in place, because it seeds no file at all. + test( + "a scaffolded-but-empty checkpoint file still starts and awaits the writer", + async () => { + const llm = startLLM("post-rebuild-reply") + const writer = writerThatWritesCheckpointAfter("SCAFFOLD_THEN_REAL_CHECKPOINT", 400) + try { + await using tmp = await tmpdir({ + git: true, + init: (dir) => Bun.write(path.join(dir, "mimocode.json"), mimocodeConfig(llm.origin)), + }) + + await withSpawnRef(writer, () => + Instance.provide({ + directory: tmp.path, + fn: () => + run( + Effect.gen(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const info = yield* sessions.create({ title: "auto-overflow-scaffolded" }) + + const first = yield* Effect.promise(() => seedUserMessage(info.id, "earlier question")) + yield* Effect.promise(() => seedFinishedAssistant(info.id, first.id, 50_000)) + + // Exactly what tryStartCheckpointWriter leaves behind before + // the writer has produced anything: the file exists, the + // watermark does not. + const file = checkpointPath(info.id) + yield* Effect.promise(() => fs.mkdir(path.dirname(file), { recursive: true })) + yield* Effect.promise(() => Bun.write(file, "# Session checkpoint\n")) + + yield* prompt.prompt({ + sessionID: info.id, + parts: [{ type: "text", text: "next question that overflows" }], + agent: "build", + }) + + const after = yield* sessions.messages({ sessionID: info.id }) + + const checkpoints = after.filter((m) => m.parts.some((p) => p.type === "checkpoint")) + const compactions = after.filter((m) => m.parts.some((p) => p.type === "compaction")) + // A writer was started and awaited, and the rebuild used its + // output. Both numbers matter: 0/0 is the silent-fallthrough + // signature of the bug, and 0/1 would mean it degraded. + expect(checkpoints.length).toBe(1) + expect(compactions.length).toBe(0) + + // The scaffolded file must NOT have been mistaken for a usable + // checkpoint: a watermark exists only because a writer settled. + const watermark = yield* Effect.sync(() => + Database.use((db) => + db + .select({ id: SessionTable.last_checkpoint_message_id }) + .from(SessionTable) + .where(eq(SessionTable.id, info.id)) + .get(), + ), + ) + expect(watermark?.id).toBeTruthy() + }), + ), + }), + ) + } finally { + await llm.stop() + } + }, + { timeout: 60_000 }, + ) +}) diff --git a/packages/opencode/test/session/prompt-rebuild-reset.test.ts b/packages/opencode/test/session/prompt-rebuild-reset.test.ts index b901a274a..1d7eb13c4 100644 --- a/packages/opencode/test/session/prompt-rebuild-reset.test.ts +++ b/packages/opencode/test/session/prompt-rebuild-reset.test.ts @@ -54,21 +54,30 @@ describe("F1 — prune resetThresholds clears sticky maxCrossed", () => { Effect.gen(function* () { // Source-level regression guard (F1). The site-1 main rebuild path now // delegates the boundary insert + threshold reset to the shared - // rebuildFromCheckpoint helper (reused by the /rebuild command), then - // sets skipOverflowCheck and continues. Assert BOTH halves of the - // invariant: (a) the shared helper resets thresholds after a successful - // insert; (b) site-1 sets skipOverflowCheck=true then continue after - // calling rebuildFromCheckpoint — so the loop can't immediately - // re-trigger overflow on the same crossed thresholds. + // rebuildFromCheckpoint helper, which is itself wrapped by + // rebuildEnsuringCheckpoint (the start-a-writer-and-wait step reused by + // the /rebuild command). Assert BOTH halves of the invariant: (a) the + // shared helper resets thresholds after a successful insert; (b) site-1 + // sets skipOverflowCheck=true then continue on a successful rebuild — so + // the loop can't immediately re-trigger overflow on the same crossed + // thresholds. + // + // DELIBERATE UPDATE: (b)'s pattern used to be + // const inserted = yield* rebuildFromCheckpoint(…) + // if (inserted) { skipOverflowCheck = true; continue } + // Site-1 now calls rebuildEnsuringCheckpoint and branches on a + // discriminated outcome instead of a boolean, so the old regex matched a + // code shape that no longer exists. The INVARIANT is unchanged and still + // asserted; only the shape it is expressed in moved. const promptSrc = yield* Effect.promise(() => Bun.file(`${import.meta.dir}/../../src/session/prompt.ts`).text(), ) expect(promptSrc).not.toContain("Do NOT reset thresholds here") // (a) shared helper resets thresholds on a successful insert. expect(promptSrc).toMatch(/if\s*\(inserted\)\s+yield\*\s+prune\.resetThresholds\(input\.sessionID\)/) - // (b) site-1 guards on the helper result, then skips + continues. + // (b) site-1 guards on the helper's outcome, then skips + continues. expect(promptSrc).toMatch( - /const\s+inserted\s*=\s*yield\*\s+rebuildFromCheckpoint\([\s\S]*?\)\s*\n\s*if\s*\(inserted\)\s*\{\s*\n\s*skipOverflowCheck\s*=\s*true\s*\n\s*continue/, + /const\s+attempt:\s*RebuildAttempt\s*=\s*yield\*\s+rebuildEnsuringCheckpoint\([\s\S]*?\)\s*\n\s*if\s*\(attempt\s*===\s*"rebuilt"\)\s*\{\s*\n\s*skipOverflowCheck\s*=\s*true\s*\n\s*continue/, ) }), ), diff --git a/packages/opencode/test/session/rebuild-boundary-model-context.test.ts b/packages/opencode/test/session/rebuild-boundary-model-context.test.ts new file mode 100644 index 000000000..2bbc91aef --- /dev/null +++ b/packages/opencode/test/session/rebuild-boundary-model-context.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, test } from "bun:test" +import { MessageV2 } from "../../src/session/message-v2" +import type { Provider } from "../../src/provider" +import { ModelID, ProviderID } from "../../src/provider/schema" +import { SessionID, MessageID, PartID } from "../../src/session/schema" + +// Pins the AI-facing semantics of the two context-boundary mechanisms so a +// future "let's make rebuild look like compaction" refactor cannot silently +// change what the model receives: +// +// compaction — boundary user message carries only a `compaction` part, which +// becomes the bare label "Summary of previous conversation:" +// (message-v2.ts). The actual summary text lives in a SEPARATE +// `summary: true` assistant message written by processCompaction +// (compaction.ts), and that assistant turn is NOT filtered out. +// +// rebuild — boundary user message carries a `checkpoint` part (label +// "Summary of previous conversation from checkpoint files:") PLUS the +// rendered checkpoint index / rebuild context as `synthetic: true` text +// parts. Synthetic text is excluded from the TUI transcript but IS sent to +// the model: the user-part filter is `!part.ignored`, not `!part.synthetic`. +// +// Net: both boundaries put a real summary into the model context. Rebuild's +// arrives inline on the boundary user turn; compaction's arrives on the +// following assistant turn. + +const sessionID = SessionID.make("session") +const providerID = ProviderID.make("test") + +const model: Provider.Model = { + id: ModelID.make("test-model"), + providerID, + api: { id: "test-model", url: "https://example.com", npm: "@ai-sdk/openai" }, + name: "Test Model", + capabilities: { + temperature: true, + reasoning: false, + attachment: false, + toolcall: true, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + limit: { context: 0, input: 0, output: 0 }, + status: "active", + options: {}, + headers: {}, + release_date: "2026-01-01", +} + +function userInfo(id: string): MessageV2.User { + return { + id, + sessionID, + role: "user", + time: { created: 0 }, + agent: "user", + model: { providerID, modelID: ModelID.make("test") }, + tools: {}, + mode: "", + } as unknown as MessageV2.User +} + +function summaryAssistantInfo(id: string, parentID: string): MessageV2.Assistant { + return { + id, + sessionID, + role: "assistant", + time: { created: 0 }, + parentID, + modelID: model.api.id, + providerID: model.providerID, + mode: "compaction", + agent: "compaction", + summary: true, + path: { cwd: "/", root: "/" }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + } as unknown as MessageV2.Assistant +} + +function basePart(messageID: string, id: string) { + return { + id: PartID.make(id), + sessionID, + messageID: MessageID.make(messageID), + } +} + +const INDEX_TEXT = "## Checkpoint\n\nDirectory: /tmp/cp/\n" +const REBUILD_TEXT = "## Rebuild context\n\nprior turns summarized here\n" +const COMPACTION_SUMMARY_TEXT = "The user asked for X; we did Y." + +describe("context boundaries: what reaches the model", () => { + test("rebuild boundary sends the checkpoint label AND the synthetic rebuild content", async () => { + const boundaryID = "m-rebuild-boundary" + const messages = await MessageV2.toModelMessages( + [ + { + info: userInfo(boundaryID), + parts: [ + { + ...basePart(boundaryID, "p1"), + type: "checkpoint", + checkpointDir: "", + checkpointNumber: 0, + coveredUpTo: MessageID.make("m-old"), + }, + { ...basePart(boundaryID, "p2"), type: "text", synthetic: true, text: INDEX_TEXT }, + { ...basePart(boundaryID, "p3"), type: "text", synthetic: true, text: REBUILD_TEXT }, + ] as MessageV2.Part[], + }, + ], + model, + ) + + expect(messages).toHaveLength(1) + expect(messages[0].role).toBe("user") + const rendered = JSON.stringify(messages[0].content) + expect(rendered).toContain("Summary of previous conversation from checkpoint files:") + // `synthetic: true` hides these from the transcript, never from the model. + expect(rendered).toContain("## Checkpoint") + expect(rendered).toContain("prior turns summarized here") + }) + + test("compaction boundary sends a bare label; the summary rides on the assistant turn", async () => { + const boundaryID = "m-compaction-boundary" + const summaryID = "m-compaction-summary" + const messages = await MessageV2.toModelMessages( + [ + { + info: userInfo(boundaryID), + parts: [{ ...basePart(boundaryID, "p1"), type: "compaction", auto: false }] as MessageV2.Part[], + }, + { + info: summaryAssistantInfo(summaryID, boundaryID), + parts: [ + { ...basePart(summaryID, "p2"), type: "text", text: COMPACTION_SUMMARY_TEXT }, + ] as MessageV2.Part[], + }, + ], + model, + ) + + expect(messages).toHaveLength(2) + expect(messages[0].role).toBe("user") + expect(JSON.stringify(messages[0].content)).toContain("Summary of previous conversation:") + // `summary: true` is NOT a filter — the compaction summary is a real + // assistant turn in the model context. + expect(messages[1].role).toBe("assistant") + expect(JSON.stringify(messages[1].content)).toContain(COMPACTION_SUMMARY_TEXT) + }) + + test("filterCompacted keeps the compaction summary assistant message after the boundary", () => { + const boundaryID = "m-compaction-boundary" + const summaryID = "m-compaction-summary" + // stream() yields newest-first; filterCompacted stops at the boundary and reverses. + const window = MessageV2.filterCompacted([ + { + info: summaryAssistantInfo(summaryID, boundaryID), + parts: [{ ...basePart(summaryID, "p2"), type: "text", text: COMPACTION_SUMMARY_TEXT }] as MessageV2.Part[], + }, + { + info: userInfo(boundaryID), + parts: [{ ...basePart(boundaryID, "p1"), type: "compaction", auto: false }] as MessageV2.Part[], + }, + { + info: userInfo("m-ancient"), + parts: [{ ...basePart("m-ancient", "p0"), type: "text", text: "dropped" }] as MessageV2.Part[], + }, + ]) + + expect(window.map((m) => String(m.info.id))).toEqual([boundaryID, summaryID]) + }) +}) diff --git a/packages/opencode/test/session/rebuild-on-the-spot.test.ts b/packages/opencode/test/session/rebuild-on-the-spot.test.ts new file mode 100644 index 000000000..802c33710 --- /dev/null +++ b/packages/opencode/test/session/rebuild-on-the-spot.test.ts @@ -0,0 +1,603 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { Deferred, Effect, Layer } from "effect" +import * as fs from "fs/promises" +import path from "path" +import { Command } from "../../src/command" +import { GlobalBus } from "../../src/bus/global" +import { Database, desc, eq } from "../../src/storage" +import { Instance } from "../../src/project/instance" +import { Session } from "../../src/session" +import { SessionPrompt } from "../../src/session/prompt" +import { MessageTable, SessionTable } from "../../src/session/session.sql" +import { checkpointPath } from "../../src/session/checkpoint-paths" +import { spawnRef } from "../../src/actor/spawn-ref" +import type { AgentOutcome } from "../../src/actor/spawn" +import { MessageID, PartID, SessionID } from "../../src/session/schema" +import { ModelID, ProviderID } from "../../src/provider/schema" +import { tmpdir } from "../fixture/fixture" +import { Log } from "../../src/util" + +void Log.init({ print: false }) + +const ref = { + providerID: ProviderID.make("alibaba"), + modelID: ModelID.make("qwen-plus"), +} + +afterEach(async () => { + await Instance.disposeAll() +}) + +function run(fx: Effect.Effect) { + return Effect.runPromise( + fx.pipe(Effect.scoped, Effect.provide(Layer.mergeAll(SessionPrompt.defaultLayer, Session.defaultLayer))), + ) +} + +/** OpenAI-compatible SSE for a plain text stop response. */ +function chat(text: string): ReadableStream { + const payload = + [ + `data: ${JSON.stringify({ + id: "chatcmpl-1", + object: "chat.completion.chunk", + choices: [{ delta: { role: "assistant" } }], + })}`, + `data: ${JSON.stringify({ + id: "chatcmpl-1", + object: "chat.completion.chunk", + choices: [{ delta: { content: text } }], + })}`, + `data: ${JSON.stringify({ + id: "chatcmpl-1", + object: "chat.completion.chunk", + choices: [{ delta: {}, finish_reason: "stop" }], + })}`, + "data: [DONE]", + ].join("\n\n") + "\n\n" + const encoder = new TextEncoder() + return new ReadableStream({ + start(ctrl) { + ctrl.enqueue(encoder.encode(payload)) + ctrl.close() + }, + }) +} + +/** Start a Bun HTTP mock that streams `reply` for every /chat/completions call. */ +function startLLM(reply: string) { + let calls = 0 + const server = Bun.serve({ + port: 0, + fetch(req) { + const url = new URL(req.url) + if (!url.pathname.endsWith("/chat/completions")) return new Response("not found", { status: 404 }) + calls++ + return new Response(chat(reply), { status: 200, headers: { "Content-Type": "text/event-stream" } }) + }, + }) + return { + origin: server.url.origin, + get calls() { + return calls + }, + stop: () => server.stop(true), + } +} + +// ---- spawnRef seam control ---------------------------------------------- +// tryStartCheckpointWriter resolves the checkpoint-writer subagent through the +// process-wide spawnRef.current seam (late-bound to break an Actor↔SessionPrompt +// layer cycle). Because it is a module global, its value leaks across tests in +// the same process, so each case-2 test sets it explicitly (and restores it) +// rather than depending on ambient state. +type SpawnImpl = NonNullable + +function withSpawnRef(impl: SpawnImpl | undefined, body: () => Promise): Promise { + const prev = spawnRef.current + spawnRef.current = impl + return body().finally(() => { + spawnRef.current = prev + }) +} + +// A spawn stub emulating a successful checkpoint-writer run: on spawn it writes +// a real (non-template) checkpoint file for the PARENT session, then resolves +// the outcome to success. This drives the real case-2 path end-to-end +// (hasCheckpoint=false → tryStartCheckpointWriter → waitForWriter → success → +// rebuildFromCheckpoint) without a slow real LLM writer round-trip. +// +// The parent's checkpoint watermark (last_checkpoint_message_id) is what +// rebuildFromCheckpoint's lastBoundary reads. In production the writer runs for +// tens of seconds, so tryStartCheckpointWriter's settlement fiber advances the +// watermark long before waitForWriter returns. This stub is near-instant, so it +// advances the watermark itself to the session's last message — matching what a +// real settled writer leaves behind — rather than racing the settlement fiber. +function writerThatWritesCheckpoint(marker: string): SpawnImpl { + let counter = 0 + return { + spawn: (input) => + Effect.gen(function* () { + counter += 1 + const parent = (input.parentSessionID ?? input.sessionID) as SessionID + const outcome = yield* Deferred.make() + const cpFile = checkpointPath(parent) + yield* Effect.promise(() => fs.mkdir(path.dirname(cpFile), { recursive: true })) + yield* Effect.promise(() => + fs.writeFile(cpFile, `# Session checkpoint\n\n## §1 Active intent\n${marker}\n`), + ) + // Advance the watermark to the newest message, as a settled writer does. + const last = yield* Effect.sync(() => + Database.use((db) => + db + .select({ id: MessageTable.id }) + .from(MessageTable) + .where(eq(MessageTable.session_id, parent)) + .orderBy(desc(MessageTable.id)) + .limit(1) + .get(), + ), + ) + if (last?.id) { + yield* Effect.sync(() => + Database.use((db) => + db + .update(SessionTable) + .set({ last_checkpoint_message_id: last.id }) + .where(eq(SessionTable.id, parent)) + .run(), + ), + ) + } + yield* Deferred.succeed(outcome, { status: "success" as const }) + return { actorID: `${input.agentType}-${counter}`, sessionID: input.sessionID, outcome } + }), + cancel: () => Effect.void, + getForkContext: () => Effect.succeed(undefined), + } as SpawnImpl +} + +function mimocodeConfig(baseURL: string) { + return JSON.stringify({ + $schema: "https://opencode.ai/config.json", + enabled_providers: ["alibaba"], + provider: { alibaba: { options: { apiKey: "test-key", baseURL: `${baseURL}/v1` } } }, + agent: { build: { model: "alibaba/qwen-plus" } }, + }) +} + +async function seedUserMessage(sessionID: SessionID, text: string) { + const msg = await Effect.runPromise( + Session.Service.use((s) => + s.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID, + agent: "build", + model: ref, + time: { created: Date.now() }, + }), + ).pipe(Effect.provide(Session.defaultLayer)), + ) + await Effect.runPromise( + Session.Service.use((s) => + s.updatePart({ + id: PartID.ascending(), + messageID: msg.id, + sessionID, + type: "text", + text, + }), + ).pipe(Effect.provide(Session.defaultLayer)), + ) + return msg +} + +// These tests drive the REAL /rebuild handler in SessionPrompt.command (the +// same code path a user hits by running `/rebuild`) against a scripted LLM +// stub, and assert on observable runtime behavior — inserted boundary +// messages, the returned message, whether the model was called, and the busy +// status events published on the Bus. They intentionally avoid grepping +// prompt.ts source text (which verifies nothing and breaks on refactors) per +// AGENTS.md: "Test actual implementation, do not duplicate logic into tests". +// +// The core invariant they enforce is the real fix for #1752: a manual +// /rebuild inserts the legitimate rebuild BOUNDARY (a role:"user" message +// carrying a `checkpoint` part) and NOTHING ELSE — it must NOT fabricate a +// second, standalone "Context rebuilt…" user turn (the band-aid the earlier +// noReply approach left persisted), and it must NOT produce an assistant +// reply. Exactly ONE new message (the boundary) lands, mirroring the +// transparent boundary insertion the auto/compaction paths perform. The +// outcome is surfaced on the SessionStatus / Bus status channel, not as a +// persisted user message. +describe("Manual /rebuild: on-the-spot rebuild driven through SessionPrompt.command", () => { + test( + "case 1: checkpoint on disk + no writer → inserts EXACTLY the boundary (no fabricated user turn, no reply)", + async () => { + const llm = startLLM("rebuilt-reply-from-model") + const seen: Array = [] + const lifecycle: Array = [] + const onEvent = (e: { + payload?: { type?: string; properties?: { status?: { type?: string; message?: string } } } + }) => { + if (e?.payload?.type !== "session.status") return + lifecycle.push(e.payload.properties?.status?.type) + if (e.payload.properties?.status?.type === "busy") { + seen.push(e.payload.properties.status.message) + } + } + GlobalBus.on("event", onEvent) + try { + await using tmp = await tmpdir({ + git: true, + init: (dir) => Bun.write(path.join(dir, "mimocode.json"), mimocodeConfig(llm.origin)), + }) + + await Instance.provide({ + directory: tmp.path, + fn: () => + run( + Effect.gen(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const info = yield* sessions.create({ title: "rebuild-case-1" }) + + yield* Effect.promise(() => seedUserMessage(info.id, "turn one")) + yield* Effect.promise(() => seedUserMessage(info.id, "turn two")) + const boundaryMsg = yield* Effect.promise(() => seedUserMessage(info.id, "turn three")) + + // Real (non-template) checkpoint on disk so renderRebuildContext + // produces non-empty content and the boundary can be inserted. + const cpFile = checkpointPath(info.id) + yield* Effect.promise(() => fs.mkdir(path.dirname(cpFile), { recursive: true })) + yield* Effect.promise(() => + fs.writeFile( + cpFile, + "# Session checkpoint\n\n## §1 Active intent\nRebuild the context from this checkpoint.\n", + ), + ) + + // Seed the checkpoint watermark the same way a settled writer does + // (SessionTable.last_checkpoint_message_id) so lastBoundary resolves + // and the handler takes the has-checkpoint → rebuild path. + yield* Effect.sync(() => + Database.use((db) => + db + .update(SessionTable) + .set({ last_checkpoint_message_id: boundaryMsg.id }) + .where(eq(SessionTable.id, info.id)) + .run(), + ), + ) + + const before = yield* sessions.messages({ sessionID: info.id }) + const countBefore = before.length + + // Drive the real handler. + const result = yield* prompt.command({ + sessionID: info.id, + command: Command.Default.REBUILD, + arguments: "", + agent: "build", + }) + + // A MANUAL /rebuild must NOT enter the runLoop: the user asked + // no question, so the LLM was never called. + expect(result.info.role).not.toBe("assistant") + expect(llm.calls).toBe(0) + + const after = yield* sessions.messages({ sessionID: info.id }) + + // EXACTLY ONE new message landed — the rebuild boundary — and + // nothing else. This is the crux of the #1752 fix: no fabricated + // second "Context rebuilt…" user turn. + expect(after.length).toBe(countBefore + 1) + + // That one new message IS the boundary: role "user" carrying a + // `checkpoint` part (the shared, correct mechanism). + const boundaries = after.filter((m) => m.parts.some((p) => p.type === "checkpoint")) + expect(boundaries.length).toBe(1) + expect(boundaries[0]!.info.role).toBe("user") + + // The handler returns the boundary message itself, not a + // fabricated note. + expect(result.parts.some((p) => p.type === "checkpoint")).toBe(true) + + // No fabricated standalone "Context rebuilt…" user turn is + // persisted anywhere (the band-aid the old path left behind). + const fabricated = after.some( + (m) => + !m.parts.some((p) => p.type === "checkpoint") && + m.parts.some( + (p) => p.type === "text" && p.text.includes("Context rebuilt from the latest checkpoint"), + ), + ) + expect(fabricated).toBe(false) + + // No assistant reply carrying the scripted text landed in the DB. + const replied = after.some((m) => + m.parts.some((p) => p.type === "text" && p.text.includes("rebuilt-reply-from-model")), + ) + expect(replied).toBe(false) + + // Original conversation preserved (3 seeded users still there). + const userCountBefore = before.filter((m) => m.info.role === "user").length + const userCountAfter = after.filter((m) => m.info.role === "user").length + expect(userCountAfter).toBeGreaterThanOrEqual(userCountBefore) + + // Outcome surfaced on the status channel (not a persisted user + // message): the terminal "context rebuilt" message was emitted. + expect( + seen.some((m) => m?.includes("Context rebuilt from the latest checkpoint")), + ).toBe(true) + + // …and the status is CLEARED again: /rebuild must settle to idle + // so the outcome text cannot leak into the following turn. + expect(lifecycle.at(-1)).toBe("idle") + }), + ), + }) + } finally { + GlobalBus.off("event", onEvent) + await llm.stop() + } + }, + { timeout: 30_000 }, + ) + + test( + "case 2: no checkpoint → spawns a writer, waits, then inserts EXACTLY the fresh boundary (no fabricated turn, no reply)", + async () => { + const llm = startLLM("case2-model-reply") + // The writer stub writes a real checkpoint on spawn and reports success, + // exercising the handler's spawn→wait→rebuild path for real. + const writer = writerThatWritesCheckpoint("CASE2_FRESH_CHECKPOINT_BODY") + const seen: Array = [] + const onEvent = (e: { + payload?: { type?: string; properties?: { status?: { type?: string; message?: string } } } + }) => { + if (e?.payload?.type === "session.status" && e.payload.properties?.status?.type === "busy") { + seen.push(e.payload.properties.status.message) + } + } + GlobalBus.on("event", onEvent) + try { + await using tmp = await tmpdir({ + git: true, + init: (dir) => Bun.write(path.join(dir, "mimocode.json"), mimocodeConfig(llm.origin)), + }) + + await withSpawnRef(writer, () => + Instance.provide({ + directory: tmp.path, + fn: () => + run( + Effect.gen(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const info = yield* sessions.create({ title: "rebuild-case-2" }) + yield* Effect.promise(() => seedUserMessage(info.id, "cold session, no checkpoint yet")) + yield* Effect.promise(() => seedUserMessage(info.id, "second turn on the cold session")) + + const before = yield* sessions.messages({ sessionID: info.id }) + const countBefore = before.length + + // Cold session: no checkpoint file exists up front. + const result = yield* prompt.command({ + sessionID: info.id, + command: Command.Default.REBUILD, + arguments: "", + agent: "build", + }) + + // A MANUAL /rebuild must NOT reply: the LLM is not called. + expect(result.info.role).not.toBe("assistant") + expect(llm.calls).toBe(0) + + const after = yield* sessions.messages({ sessionID: info.id }) + + // EXACTLY ONE new message: the boundary rebuilt from the + // freshly-written checkpoint. No fabricated "Context rebuilt…" + // user turn. + expect(after.length).toBe(countBefore + 1) + + const boundaries = after.filter((m) => m.parts.some((p) => p.type === "checkpoint")) + expect(boundaries.length).toBe(1) + expect(boundaries[0]!.info.role).toBe("user") + expect(result.parts.some((p) => p.type === "checkpoint")).toBe(true) + + const fabricated = after.some( + (m) => + !m.parts.some((p) => p.type === "checkpoint") && + m.parts.some( + (p) => p.type === "text" && p.text.includes("Context rebuilt from the latest checkpoint"), + ), + ) + expect(fabricated).toBe(false) + + const replied = after.some((m) => + m.parts.some((p) => p.type === "text" && p.text.includes("case2-model-reply")), + ) + expect(replied).toBe(false) + + // Outcome surfaced on the status channel, not a persisted user + // message. + expect( + seen.some((m) => m?.includes("Context rebuilt from the latest checkpoint")), + ).toBe(true) + }), + ), + }), + ) + } finally { + GlobalBus.off("event", onEvent) + await llm.stop() + } + }, + { timeout: 30_000 }, + ) + + // DELIBERATE REWRITE — this test previously encoded the OPPOSITE behaviour. + // + // It used to be titled "case 2 fallback: no checkpoint + no spawnable writer → + // surfaces the no-checkpoint outcome on the status channel, persists nothing" + // and asserted `after.length === countBefore` — i.e. that a manual /rebuild + // whose writer could not run must NOT compact. That encoded a deliberate + // tradeoff: /rebuild means "rebuild from a checkpoint", so substituting a + // lossy summary would misreport what happened. + // + // The user overruled that tradeoff: when there is no checkpoint AND the writer + // genuinely fails, a truncating compaction beats doing nothing. That is now the + // single compaction fallback condition, shared with the auto overflow paths. + // The test is rewritten rather than edited quietly, because the assertion it + // used to make is now a statement of the wrong behaviour. + // + // What is PRESERVED from the old test, and still asserted below: the handler + // must not enter the runLoop, must not produce an assistant reply, and must not + // fabricate a synthetic user turn — only the boundary marker may be persisted. + test( + "case 2 fallback: no checkpoint + no spawnable writer → compacts instead, and says so on the status channel", + async () => { + const llm = startLLM("should-not-be-used-as-a-reply") + const seen: Array = [] + const onEvent = (e: { + payload?: { type?: string; properties?: { status?: { type?: string; message?: string } } } + }) => { + if (e?.payload?.type === "session.status" && e.payload.properties?.status?.type === "busy") { + seen.push(e.payload.properties.status.message) + } + } + GlobalBus.on("event", onEvent) + try { + await using tmp = await tmpdir({ + git: true, + init: (dir) => Bun.write(path.join(dir, "mimocode.json"), mimocodeConfig(llm.origin)), + }) + + // Force NO writer: with spawnRef unset, tryStartCheckpointWriter cannot + // spawn and waitForWriter resolves "no-writer" → the genuine + // writer-failure case, the ONLY one allowed to reach compaction. + await withSpawnRef(undefined, () => + Instance.provide({ + directory: tmp.path, + fn: () => + run( + Effect.gen(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const info = yield* sessions.create({ title: "rebuild-case-2-fallback" }) + yield* Effect.promise(() => seedUserMessage(info.id, "cold session, no checkpoint yet")) + + const before = yield* sessions.messages({ sessionID: info.id }) + const countBefore = before.length + + const result = yield* prompt.command({ + sessionID: info.id, + command: Command.Default.REBUILD, + arguments: "", + agent: "build", + }) + + // Did NOT enter the runLoop (no reply produced). + expect(result.info.role).not.toBe("assistant") + + const after = yield* sessions.messages({ sessionID: info.id }) + + // The writer could not run and no checkpoint existed, so the + // context was compacted: exactly ONE new message, carrying a + // `compaction` part. + expect(after.length).toBe(countBefore + 1) + const compactions = after.filter((m) => m.parts.some((p) => p.type === "compaction")) + expect(compactions.length).toBe(1) + + // No rebuild boundary — there was nothing to rebuild from. + const boundaries = after.filter((m) => m.parts.some((p) => p.type === "checkpoint")) + expect(boundaries.length).toBe(0) + + // Still no fabricated user turn carrying the outcome text. + const fabricated = after.some((m) => + m.parts.some((p) => p.type === "text" && p.text.includes("the context was compacted instead")), + ) + expect(fabricated).toBe(false) + + // Still no assistant reply. + const modelReplied = after.some((m) => + m.parts.some((p) => p.type === "text" && p.text.includes("should-not-be-used-as-a-reply")), + ) + expect(modelReplied).toBe(false) + + // The substitution is NAMED on the status channel rather than + // silently swapping the mechanism the user asked for. + expect(seen.some((m) => m?.includes("the context was compacted instead"))).toBe(true) + }), + ), + }), + ) + } finally { + GlobalBus.off("event", onEvent) + await llm.stop() + } + }, + { timeout: 30_000 }, + ) + + test( + "busy status carries descriptive messages while the handler runs (observed on the Bus, not source text)", + async () => { + const llm = startLLM("busy-path-reply") + const writer = writerThatWritesCheckpoint("BUSY_CHECKPOINT_BODY") + const seen: Array = [] + // SessionStatus.set publishes on the instance Bus which also mirrors every + // event onto the process-wide GlobalBus. Subscribing here captures the + // real busy-status messages the handler emits, regardless of which Bus + // layer instance SessionPrompt.defaultLayer wired internally. + const onEvent = (e: { payload?: { type?: string; properties?: { status?: { type?: string; message?: string } } } }) => { + if (e?.payload?.type === "session.status" && e.payload.properties?.status?.type === "busy") { + seen.push(e.payload.properties.status.message) + } + } + GlobalBus.on("event", onEvent) + try { + await using tmp = await tmpdir({ + git: true, + init: (dir) => Bun.write(path.join(dir, "mimocode.json"), mimocodeConfig(llm.origin)), + }) + + await withSpawnRef(writer, () => + Instance.provide({ + directory: tmp.path, + fn: () => + run( + Effect.gen(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + + // Cold session → exercises BOTH busy messages: "Rebuilding + // context…" (set first) then "Writing checkpoint…" (set while + // waiting on the writer that this test provides). + const info = yield* sessions.create({ title: "rebuild-busy" }) + yield* Effect.promise(() => seedUserMessage(info.id, "no checkpoint here either")) + yield* Effect.promise(() => seedUserMessage(info.id, "second turn")) + + yield* prompt.command({ + sessionID: info.id, + command: Command.Default.REBUILD, + arguments: "", + agent: "build", + }) + }), + ), + }), + ) + } finally { + GlobalBus.off("event", onEvent) + await llm.stop() + } + + // The handler set busy with the human-readable messages the TUI shows. + expect(seen).toContain("Rebuilding context\u2026") + expect(seen).toContain("Writing checkpoint\u2026") + }, + { timeout: 30_000 }, + ) +})