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 },
+ )
+})