diff --git a/packages/app/e2e/snap/compaction-divider.snap.ts b/packages/app/e2e/snap/compaction-divider.snap.ts new file mode 100644 index 000000000..7afc8fc54 --- /dev/null +++ b/packages/app/e2e/snap/compaction-divider.snap.ts @@ -0,0 +1,142 @@ +import type { Page } from "@playwright/test" +import { test } from "../fixtures" +import { composeGrid, snapOutputPath, type Shot } from "./_compose" + +test.use({ viewport: { width: 1100, height: 400 }, deviceScaleFactor: 2 }) + +const SEED_REPLY = "Acknowledged. Seeded turn ready for compaction." +const SUMMARY_TEXT = [ + "## Goal", + "- Validate the compaction divider rendering", + "", + "## Progress", + "### Done", + "- Seeded one user turn", +].join("\n") + +async function seedTurn( + sdk: ReturnType, + directory: string, + sessionID: string, + prompt: string, +) { + await sdk.session.prompt({ + sessionID, + directory, + parts: [{ type: "text", text: prompt }], + }) +} + +async function captureDivider(page: Page, name: string): Promise { + const divider = page.locator('[data-slot="session-turn-compaction"]').last() + await divider.waitFor({ state: "visible", timeout: 30_000 }) + // Hide the Solid toast region so the page's "Response ready" notifications + // do not leak into the divider screenshot. The toasts are position:fixed and + // would otherwise overlap the divider's bounding box. + await page.addStyleTag({ + content: '[data-sonner-toaster], [role="region"][aria-label*="Notifications"] { display: none !important; }', + }) + return { name, buf: await divider.screenshot() } +} + +async function waitForState(page: Page, state: string, timeoutMs: number) { + await page.waitForFunction( + (expected) => { + const part = document.querySelector( + '[data-slot="session-turn-compaction"] [data-component="compaction-part"]', + ) + const current = part?.getAttribute("data-state") + return current === expected + }, + state, + { timeout: timeoutMs }, + ) +} + +// Real production snap for the compaction divider across all four states. +// Each state runs in its own session so the divider's data-state attribute +// transitions are isolated from siblings. +test("compaction-divider", async ({ page, project, assistant }) => { + test.setTimeout(360_000) + + await project.open() + const { directory } = project + const projectSdk = project.sdk + + const shots: Shot[] = [] + + // ── DONE ─────────────────────────────────────────────────────────────────── + await assistant.reply(SEED_REPLY) + const doneSession = await projectSdk.session.create({ directory, title: "snap compaction-done" }) + const doneSessionID = doneSession.data?.id + if (!doneSessionID) throw new Error("session.create returned no id (done)") + await seedTurn(projectSdk, directory, doneSessionID, "Seed for done") + await project.gotoSession(doneSessionID) + await assistant.reply(SUMMARY_TEXT) + await projectSdk.session.summarize({ + sessionID: doneSessionID, + providerID: "opencode", + modelID: "big-pickle", + }) + await waitForState(page, "done", 45_000) + shots.push(await captureDivider(page, "done")) + + // ── FAILED ───────────────────────────────────────────────────────────────── + // HTTP 400 from the LLM endpoint. The OpenAI client wraps it as APIError + // with isRetryable=false; retry.ts L63 returns undefined immediately, so + // the schedule yields Cause.done(0) and Effect.catch(halt) writes the + // error onto the placeholder summary assistant. Divider reads `failed`. + await assistant.reply(SEED_REPLY) + const failedSession = await projectSdk.session.create({ directory, title: "snap compaction-failed" }) + const failedSessionID = failedSession.data?.id + if (!failedSessionID) throw new Error("session.create returned no id (failed)") + await seedTurn(projectSdk, directory, failedSessionID, "Seed for failed") + await project.gotoSession(failedSessionID) + await assistant.error(400, { error: { type: "BadRequest", message: "Compaction model rejected the request" } }) + // Summarize must now surface the failure: the route reads the placeholder's + // `error` field after the loop returns and rethrows as UnknownError, so + // SDK callers cannot silently see `true` for a visibly failed compaction. + let summarizeFailureSurfaced = false + try { + await projectSdk.session.summarize({ + sessionID: failedSessionID, + providerID: "opencode", + modelID: "big-pickle", + }) + } catch { + summarizeFailureSurfaced = true + } + if (!summarizeFailureSurfaced) throw new Error("summarize should reject when compaction fails pre-summary") + await waitForState(page, "failed", 45_000) + shots.push(await captureDivider(page, "failed")) + + // ── PENDING + ABORTED ────────────────────────────────────────────────────── + // hang() returns Stream.never so the compaction streams forever; the + // placeholder summary assistant stays in pending. After capturing pending + // we call session.abort which trips Effect.onInterrupt in compaction.ts, + // writing MessageAbortedError onto the placeholder. + await assistant.reply(SEED_REPLY) + const pendingSession = await projectSdk.session.create({ directory, title: "snap compaction-pending" }) + const pendingSessionID = pendingSession.data?.id + if (!pendingSessionID) throw new Error("session.create returned no id (pending)") + await seedTurn(projectSdk, directory, pendingSessionID, "Seed for pending") + await project.gotoSession(pendingSessionID) + await assistant.hang() + // Fire-and-forget: summarize returns once the request is accepted, the + // actual compaction call hangs on the LLM stream. + void projectSdk.session.summarize({ + sessionID: pendingSessionID, + providerID: "opencode", + modelID: "big-pickle", + }) + await waitForState(page, "pending", 45_000) + shots.push(await captureDivider(page, "pending")) + + await projectSdk.session.abort({ sessionID: pendingSessionID, directory }) + await waitForState(page, "aborted", 45_000) + shots.push(await captureDivider(page, "aborted")) + + const out = snapOutputPath("compaction-divider") + await composeGrid(shots, out, { cols: 2 }) + process.stdout.write(`\n[snap] compaction-divider grid -> ${out}\n\n`) +}) diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx index 00bed0cf6..807fd2af5 100644 --- a/packages/app/src/pages/session/use-session-commands.tsx +++ b/packages/app/src/pages/session/use-session-commands.tsx @@ -368,7 +368,11 @@ export const useSessionCommands = (actions: SessionCommandContext) => { title: language.t("command.session.compact"), description: language.t("command.session.compact.description"), slash: "compact", - disabled: !params.id || visibleUserMessages().length === 0, + // Server rejects compact-while-busy with Session.BusyError (mapped to 400). + // Hide the slash entry and grey the command-palette row so the route is + // only reachable from idle; bypass paths (CLI / scripts) still get the + // honest 400 instead of the pre-fix silent success. + disabled: !params.id || visibleUserMessages().length === 0 || isWorkInFlightStatus(status()), onSelect: compact, }), sessionCommand({ diff --git a/packages/opencode/src/effect/runner.ts b/packages/opencode/src/effect/runner.ts index bdfee8a29..bcbb96a3c 100644 --- a/packages/opencode/src/effect/runner.ts +++ b/packages/opencode/src/effect/runner.ts @@ -4,7 +4,10 @@ import type { LifecycleRequest } from "@/session/lifecycle-provenance" export interface Runner { readonly state: State readonly busy: boolean - readonly ensureRunning: (work: Effect.Effect) => Effect.Effect + readonly ensureRunning: ( + work: Effect.Effect, + options?: { rejectIfBusy?: boolean }, + ) => Effect.Effect readonly startShell: (work: Effect.Effect, options?: { ready?: Deferred.Deferred }) => Effect.Effect readonly cancel: Effect.Effect readonly cancelWith: (meta?: InterruptMeta) => Effect.Effect @@ -162,10 +165,21 @@ export const make = ( const awaitShellReady = (shell: ShellHandle) => Deferred.await(shell.ready).pipe(Effect.raceFirst(Fiber.await(shell.fiber).pipe(Effect.asVoid)), Effect.ignore) - const ensureRunning = (work: Effect.Effect) => + const ensureRunning = (work: Effect.Effect, options?: { rejectIfBusy?: boolean }) => SynchronizedRef.modifyEffect( ref, Effect.fnUntraced(function* (st) { + // rejectIfBusy lives in the atomic ref-modify so the check can't race + // with an Idle→Running transition started by another caller. Throwing + // synchronously here (via opts.busy()) lets `loop({ prelude })` refuse + // to silently no-op when the runner is already executing other work — + // otherwise the prelude effect (e.g. writing a compaction marker) + // would be dropped and the route would resolve `true` for a session + // that never ran the requested action. + if (options?.rejectIfBusy && st._tag !== "Idle") { + if (opts?.busy) opts.busy() + throw new Error("Runner is busy") + } switch (st._tag) { case "Running": case "ShellThenRun": diff --git a/packages/opencode/src/server/instance/session.ts b/packages/opencode/src/server/instance/session.ts index a50cfd29d..fe9e3dfc3 100644 --- a/packages/opencode/src/server/instance/session.ts +++ b/packages/opencode/src/server/instance/session.ts @@ -7,7 +7,6 @@ import { Session } from "../../session" import { MessageV2 } from "../../session/message-v2" import { SessionPrompt } from "../../session/prompt" import { SessionRunState } from "@/session/run-state" -import { SessionCompaction } from "../../session/compaction" import { SessionRevert } from "../../session/revert" import { SessionShare } from "@/share/session" import { Export } from "@/session/export" @@ -18,7 +17,6 @@ import { SessionSummary } from "@/session/summary" import { Todo } from "../../session/todo" import { Effect } from "effect" import { AppRuntime } from "../../effect/app-runtime" -import { Agent } from "../../agent/agent" import { Command } from "../../command" import { Log } from "@opencode-ai/core/util/log" import { Permission } from "@/permission" @@ -1012,27 +1010,52 @@ export const SessionRoutes = lazy(() => async (c) => { const sessionID = c.req.valid("param").sessionID const body = c.req.valid("json") - const session = await Session.get(sessionID) - await SessionRevert.cleanup(session) - const msgs = await Session.messages({ sessionID }) - let currentAgent = await Agent.defaultAgent() - for (let i = msgs.length - 1; i >= 0; i--) { - const info = msgs[i].info - if (info.role === "user") { - currentAgent = info.agent || (await Agent.defaultAgent()) - break - } - } - await SessionCompaction.create({ + // Marker creation runs inside the loop's runner-protected work effect + // (see SessionPrompt.loop). That gives us four guarantees the + // pre-refactor route lacked: (1) status flips to busy *before* the + // compaction part event reaches clients so the divider doesn't flash + // the legacy-orphan "failed" frame; (2) a cancel arriving while the + // marker is being written hits a Running runner and interrupts the + // fiber instead of being silently dropped by SessionRunState.cancel; + // (3) the prelude path uses rejectIfBusy, so summarize calls that + // arrive while another run is in flight throw Session.BusyError + // (mapped to 400) instead of resolving `true` without writing the + // marker. Clients should queue the action and retry once the session + // goes idle; (4) revert.cleanup and agent derivation live inside + // the work effect, so a busy-rejected compact leaves session state + // untouched and the agent is picked from the post-cleanup message + // list (matters when the session has been reverted). + await SessionPrompt.loop({ sessionID, - agent: currentAgent, - model: { - providerID: body.providerID, - modelID: body.modelID, + prelude: { + type: "compaction", + model: { + providerID: body.providerID, + modelID: body.modelID, + }, + auto: body.auto, }, - auto: body.auto, }) - await SessionPrompt.loop({ sessionID }) + // Compaction is fire-and-forget at the loop level: a pre-summary + // failure writes `error` onto the placeholder summary assistant and + // returns "stop" without throwing, so summarize would otherwise + // resolve `true` for a session that visibly failed. Surface the + // error so SDK callers can branch on it. User-initiated aborts are + // not failures from the route's perspective. + const finalMsgs = await Session.messages({ sessionID }) + for (let i = finalMsgs.length - 1; i >= 0; i--) { + const info = finalMsgs[i].info + if (info.role !== "assistant" || info.mode !== "compaction") continue + if (info.error && info.error.name !== "MessageAbortedError") { + const raw = (info.error.data as { message?: unknown } | undefined)?.message + const reason = + typeof raw === "string" && raw.trim().length > 0 + ? raw.trim() + : `Compaction failed (${info.error.name})` + throw new NamedError.Unknown({ message: reason }) + } + break + } return c.json(true) }, ) diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 9751c126a..4c0c42cd2 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -370,85 +370,11 @@ export const layer: Layer.Layer< const userMessage = parent.info const compactionPart = parent.parts.find((part): part is MessageV2.CompactionPart => part.type === "compaction") - let messages = input.messages - let replay: - | { - info: MessageV2.User - parts: MessageV2.Part[] - } - | undefined - if (input.overflow) { - const idx = input.messages.findIndex((m) => m.info.id === input.parentID) - for (let i = idx - 1; i >= 0; i--) { - const msg = input.messages[i] - if (msg.info.role === "user" && !msg.parts.some((p) => p.type === "compaction")) { - replay = { info: msg.info, parts: msg.parts } - messages = input.messages.slice(0, i) - break - } - } - const hasContent = - replay && messages.some((m) => m.info.role === "user" && !m.parts.some((p) => p.type === "compaction")) - if (!hasContent) { - replay = undefined - messages = input.messages - } - } - - const agent = yield* agents.get("compaction") - const model = agent.model - ? yield* provider.getModel(agent.model.providerID, agent.model.modelID) - : yield* provider.getModel(userMessage.model.providerID, userMessage.model.modelID) - const cfg = yield* config.get() - const history = compactionPart && messages.at(-1)?.info.id === input.parentID ? messages.slice(0, -1) : messages - const prior = completedCompactions(history) - const hidden = new Set(prior.flatMap((item) => [item.userIndex, item.assistantIndex])) - // Hide all messages already covered by the latest compaction's summary - // (everything before its `tail_start_id`). Otherwise repeated compactions - // re-summarise the same history each round, growing the prompt and - // eventually defeating compaction's purpose. - const latestPrior = prior.at(-1) - // A completed compaction can carry a `tailStartId` (some history kept - // verbatim past the boundary) OR clear it to mark "the summary covered - // everything before this compaction's user turn". Both cases need to - // hide the now-summarised history; without the second branch a - // fully-summarising compaction would silently re-feed all prior turns - // to the next round. - if (latestPrior?.summary) { - if (latestPrior.tailStartId) { - const tailIndex = history.findIndex((m) => m.info.id === latestPrior.tailStartId) - if (tailIndex > 0) { - for (let i = 0; i < tailIndex; i++) hidden.add(i) - } - } else { - for (let i = 0; i < latestPrior.userIndex; i++) hidden.add(i) - } - } - const previousSummary = latestPrior?.summary - const selected = yield* select({ - messages: history.filter((_, index) => !hidden.has(index)), - cfg, - model, - }) - const previousTailStartId = compactionPart?.tail_start_id ?? latestPrior?.tailStartId - const stalledTailBoundary = - input.auto && - previousTailStartId !== undefined && - selected.tail_start_id !== undefined && - selected.tail_start_id <= previousTailStartId - // Allow plugins to inject context or replace compaction prompt. - const compacting = yield* plugin.trigger( - "experimental.session.compacting", - { sessionID: input.sessionID }, - { context: [], prompt: undefined }, - ) - const nextPrompt = compacting.prompt ?? buildPrompt({ previousSummary, context: compacting.context }) - const msgs = structuredClone(selected.head) - yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs }) - const modelMessages = yield* MessageV2.toModelMessagesEffect(msgs, model, { - stripMedia: true, - toolOutputMaxChars: TOOL_OUTPUT_MAX_CHARS, - }) + // Resolve exec early so the placeholder assistant message can be created + // before any failure-prone step runs. The frontend state machine relies + // on this message existing as a carrier for `error`/`finish="error"` when + // pre-summary steps (agents/provider/select/plugin/toModelMessages) + // throw — otherwise the compaction divider would stay `pending` forever. let exec = input.executionContext if (!exec) exec = (yield* session.get(input.sessionID)).executionContext const msg: MessageV2.Assistant = { @@ -471,41 +397,182 @@ export const layer: Layer.Layer< reasoning: 0, cache: { read: 0, write: 0 }, }, - modelID: model.id, - providerID: model.providerID, + // Provisional — overwritten with the compaction agent's resolved model + // once `agents.get`/`provider.getModel` succeed inside the wrapped step. + modelID: userMessage.model.modelID, + providerID: userMessage.model.providerID, time: { created: Date.now(), }, } yield* session.updateMessage(msg) - if (stalledTailBoundary) { + + const outcome = yield* Effect.gen(function* () { + let messages = input.messages + let replay: + | { + info: MessageV2.User + parts: MessageV2.Part[] + } + | undefined + if (input.overflow) { + const idx = input.messages.findIndex((m) => m.info.id === input.parentID) + for (let i = idx - 1; i >= 0; i--) { + const m = input.messages[i] + if (m.info.role === "user" && !m.parts.some((p) => p.type === "compaction")) { + replay = { info: m.info, parts: m.parts } + messages = input.messages.slice(0, i) + break + } + } + const hasContent = + replay && messages.some((m) => m.info.role === "user" && !m.parts.some((p) => p.type === "compaction")) + if (!hasContent) { + replay = undefined + messages = input.messages + } + } + + const agent = yield* agents.get("compaction") + const model = agent.model + ? yield* provider.getModel(agent.model.providerID, agent.model.modelID) + : yield* provider.getModel(userMessage.model.providerID, userMessage.model.modelID) + // Now that the real compaction model is known, replace the + // provisional model on the placeholder so downstream UI/storage + // reflects what actually ran. + msg.modelID = model.id + msg.providerID = model.providerID + yield* session.updateMessage(msg) + + const cfg = yield* config.get() + const history = + compactionPart && messages.at(-1)?.info.id === input.parentID ? messages.slice(0, -1) : messages + const prior = completedCompactions(history) + const hidden = new Set(prior.flatMap((item) => [item.userIndex, item.assistantIndex])) + // Hide all messages already covered by the latest compaction's summary + // (everything before its `tail_start_id`). Otherwise repeated compactions + // re-summarise the same history each round, growing the prompt and + // eventually defeating compaction's purpose. + const latestPrior = prior.at(-1) + // A completed compaction can carry a `tailStartId` (some history kept + // verbatim past the boundary) OR clear it to mark "the summary covered + // everything before this compaction's user turn". Both cases need to + // hide the now-summarised history; without the second branch a + // fully-summarising compaction would silently re-feed all prior turns + // to the next round. + if (latestPrior?.summary) { + if (latestPrior.tailStartId) { + const tailIndex = history.findIndex((m) => m.info.id === latestPrior.tailStartId) + if (tailIndex > 0) { + for (let i = 0; i < tailIndex; i++) hidden.add(i) + } + } else { + for (let i = 0; i < latestPrior.userIndex; i++) hidden.add(i) + } + } + const previousSummary = latestPrior?.summary + const selected = yield* select({ + messages: history.filter((_, index) => !hidden.has(index)), + cfg, + model, + }) + const previousTailStartId = compactionPart?.tail_start_id ?? latestPrior?.tailStartId + const stalledTailBoundary = + input.auto && + previousTailStartId !== undefined && + selected.tail_start_id !== undefined && + selected.tail_start_id <= previousTailStartId + if (stalledTailBoundary) { + return { ok: true as const, stalled: true as const, tail_start_id: selected.tail_start_id } + } + // Allow plugins to inject context or replace compaction prompt. + const compacting = yield* plugin.trigger( + "experimental.session.compacting", + { sessionID: input.sessionID }, + { context: [], prompt: undefined }, + ) + const nextPrompt = compacting.prompt ?? buildPrompt({ previousSummary, context: compacting.context }) + const msgs = structuredClone(selected.head) + yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs }) + const modelMessages = yield* MessageV2.toModelMessagesEffect(msgs, model, { + stripMedia: true, + toolOutputMaxChars: TOOL_OUTPUT_MAX_CHARS, + }) + const processor = yield* processors.create({ + assistantMessage: msg, + sessionID: input.sessionID, + model, + }) + const result = yield* processor.process({ + user: userMessage, + agent, + sessionID: input.sessionID, + tools: {}, + system: [], + messages: [ + ...modelMessages, + { + role: "user", + content: [{ type: "text", text: nextPrompt }], + }, + ], + model, + }) + return { ok: true as const, stalled: false as const, result, processor, replay, selected } + }).pipe( + Effect.catch((error: unknown) => Effect.succeed({ ok: false as const, error })), + // Interrupts (abort signal) bypass `Effect.catch` since they live on + // the Cause channel, not the error channel. Without this finalizer + // the placeholder would persist with no error/finish — the divider + // state machine would read it as `pending` forever after an abort. + Effect.onInterrupt(() => + Effect.gen(function* () { + if (msg.error || msg.finish) return + msg.error = new MessageV2.AbortedError({ + message: "Compaction aborted", + }).toObject() + msg.finish = "error" + // Terminal state — must have `time.completed` so the UI's + // `pending` memo (driven by `allMessages()`, which includes + // the summary assistant) does not keep treating this turn + // as in-flight. + msg.time.completed = Date.now() + yield* session.updateMessage(msg) + }), + ), + ) + + if (!outcome.ok) { + // Pre-summary failure (agents/provider/select/plugin/toModelMessages + // /processors.create threw before the processor could attach an error + // through its own cleanup path). Surface it on the placeholder so the + // UI state machine can render `failed`. + msg.error = MessageV2.fromError(outcome.error, { + providerID: userMessage.model.providerID, + aborted: false, + }) + msg.finish = "error" + // Terminal state — same reasoning as the onInterrupt branch above. + msg.time.completed = Date.now() + yield* session.updateMessage(msg) + return "stop" + } + + if (outcome.stalled) { + // Auto compaction selected the same (or earlier) tail boundary as the + // previous round — running the summarizer again would just produce the + // same output and never let new turns land. Mark the placeholder as + // overflow so the divider surfaces it and stop. msg.error = new MessageV2.ContextOverflowError({ - message: `Auto compaction could not make progress: retained tail boundary did not advance (${selected.tail_start_id})`, + message: `Auto compaction could not make progress: retained tail boundary did not advance (${outcome.tail_start_id})`, }).toObject() msg.finish = "error" + msg.time.completed = Date.now() yield* session.updateMessage(msg) return "stop" } - const processor = yield* processors.create({ - assistantMessage: msg, - sessionID: input.sessionID, - model, - }) - const result = yield* processor.process({ - user: userMessage, - agent, - sessionID: input.sessionID, - tools: {}, - system: [], - messages: [ - ...modelMessages, - { - role: "user", - content: [{ type: "text", text: nextPrompt }], - }, - ], - model, - }) + + const { result, processor, replay, selected } = outcome if (result === "compact") { processor.message.error = new MessageV2.ContextOverflowError({ @@ -544,6 +611,11 @@ export const layer: Layer.Layer< format: original.format, tools: original.tools, system: original.system, + // Marks this as a re-injected copy of the original overflow message + // so the UI can keep the turn row (assistants still hang off it via + // parentID) but hide the user body — the visible duplicate of the + // original turn is the "showing twice" symptom we're removing. + replay: true, }) for (const part of replay.parts) { if (part.type === "compaction") continue diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 0793e0be0..3aebdd2ea 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -439,6 +439,7 @@ export const User = Base.extend({ locale: z.string().optional(), system: z.string().optional(), tools: z.record(z.string(), z.boolean()).optional(), + replay: z.boolean().optional(), }).meta({ ref: "UserMessage", }) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 438e8bb5d..2fdc0617e 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -2232,6 +2232,101 @@ NOTE: At any point in time through this workflow you should feel free to ask the }) => Effect.gen(function* () { interruptedSessions.add(input.sessionID) + // Sweep any pending compaction marker first — a user message with + // a `compaction` part but no summary assistant child. Covers two + // race shapes: (1) marker just written, processCompaction has not + // reached its placeholder yet; (2) a normal SessionPrompt.prompt + // landed while compaction was running and persisted its user + // message before awaitRun, so currentTurnTarget now points at + // that newer user instead of the marker. Both leave the marker + // orphaned and the divider would render `failed` even though the + // cancel was a clean abort. + // + // Semantic boundary — only handle the *newest* compaction marker, + // and only if it lacks a summary child. Do NOT iterate older + // markers looking for any orphan. A historical orphan (e.g. left + // by a crashed prior session) is rendered `failed` by the divider + // because it *actually* failed; rewriting it as `aborted` here + // would attribute a past crash to the current cancel and stamp + // it with this cancel's `propagation_point`, which is a lie. + // + // The "newest marker = the marker this cancel can be attributed + // to" invariant rests on two upstream guarantees: (a) the Runner + // is per-session and serial — only one work effect can be writing + // a marker at any moment; (b) the prelude path uses rejectIfBusy + // (run-state.ts ~L173), so a second /summarize cannot land + // concurrently and produce a competing newer marker. If either + // guarantee weakens, this sweep would need run-local attribution + // (e.g. capture a high-water-mark message id at work entry and + // only match markers above it). + const pendingMarker = yield* sessions.findMessage(input.sessionID, (m) => + m.info.role === "user" && m.parts.some((p) => p.type === "compaction"), + ) + if (Option.isSome(pendingMarker)) { + const markerInfo = pendingMarker.value.info + if (markerInfo.role === "user") { + const summaryChild = yield* sessions.findMessage( + input.sessionID, + (m) => + m.info.role === "assistant" && + m.info.parentID === markerInfo.id && + m.info.summary === true, + ) + if (Option.isNone(summaryChild)) { + const sess = yield* sessions.get(input.sessionID) + const exec = sess.executionContext + const recordedAt = meta?.recordedAt ?? Date.now() + const abortError = new MessageV2.AbortedError({ message: "Compaction aborted" }) + const placeholder: MessageV2.Assistant = { + id: MessageID.ascending(), + role: "assistant", + parentID: markerInfo.id, + sessionID: input.sessionID, + mode: "compaction", + agent: "compaction", + variant: markerInfo.model.variant, + summary: true, + path: { + cwd: exec.activeDirectory, + root: exec.ownerDirectory, + }, + cost: 0, + tokens: { + input: 0, + output: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + modelID: markerInfo.model.modelID, + providerID: markerInfo.model.providerID, + time: { + created: recordedAt, + completed: recordedAt, + }, + error: abortError.toObject(), + finish: "error", + diagnostics: { + abort: { + source: meta?.source, + reason: meta?.reason, + title_generation_state: titleGenerationStateAtAbort( + titleGenerationProgress.get(input.sessionID), + recordedAt, + ), + propagation_point: + meta?.propagationPoint ?? "session.prompt.loop.onInterrupt.compaction_prelude", + error_name: abortError.name, + error_message: "Compaction aborted", + via_ctx_abort: meta?.viaCtxAbort, + recorded_at: recordedAt, + }, + }, + } + yield* sessions.updateMessage(placeholder) + return { info: placeholder, parts: [] } + } + } + } const assistant = yield* currentTurnTarget(input.sessionID) if (assistant.info.role === "assistant") { const error = assistant.info.error @@ -2264,7 +2359,62 @@ NOTE: At any point in time through this workflow you should feel free to ask the } return assistant }) - return yield* state.ensureRunning(input.sessionID, onInterrupt, runLoop(input.sessionID)) + const work = Effect.gen(function* () { + // Two reasons busy goes first. (1) The compaction part event must + // not race ahead of `session.status: busy` — the divider's + // "no summary + not working" branch would otherwise flash the + // legacy-orphan failed state for one render frame. (2) The work + // effect runs inside the Runner's fiber, so a cancel arriving here + // hits a Running runner and Fiber.interrupt fires — SessionRunState + // .cancel's no-runner path can't silently drop the abort. + yield* status.set(input.sessionID, { type: "busy" }) + if (input.prelude?.type === "compaction") { + // revert.cleanup is part of the prelude's atomic transaction: a + // busy-rejected /summarize must leave revert state untouched, so + // the cleanup runs only after the Runner has won the Idle slot. + // Previously this lived in the route handler, which meant a + // BusyError-rejected compact had already mutated session.revert + // by the time the rejection fired. + yield* revert.cleanup(yield* sessions.get(input.sessionID)) + // Agent derivation must read the post-cleanup message list — a + // reverted session has discarded-but-still-physically-present + // user messages, and revert.cleanup is what drops them. Picking + // the agent from the latest remaining user keeps /summarize + // honoring the revert point's last active agent. + let agent = input.prelude.agent + if (!agent) { + const msgs = yield* sessions.messages({ sessionID: input.sessionID }) + agent = yield* agents.defaultAgent() + for (let i = msgs.length - 1; i >= 0; i--) { + const info = msgs[i].info + if (info.role === "user") { + agent = info.agent || agent + break + } + } + } + yield* compaction.create({ + sessionID: input.sessionID, + agent, + model: input.prelude.model, + auto: input.prelude.auto, + }) + } + return yield* runLoop(input.sessionID) + }) + // rejectIfBusy is the prelude path's safety net: a prelude's side + // effects (writing the compaction marker) only run when ensureRunning + // actually executes `work`, and that only happens from the Idle branch + // of the runner's atomic ref-modify. Without this flag a `loop` call + // that arrives while another run is in flight would silently + // `awaitRun(existing)` and resolve to the previous run's result — the + // requested compaction would never happen, but the route would return + // `true`. UI callers handle the resulting `Session.BusyError` (mapped + // to HTTP 400 by middleware) by queuing the compact action through the + // followup machinery and auto-retrying after the session idles. + return yield* state.ensureRunning(input.sessionID, onInterrupt, work, { + rejectIfBusy: input.prelude !== undefined, + }) }) const shell: (input: ShellInput) => Effect.Effect = Effect.fn("SessionPrompt.shell")( @@ -2551,6 +2701,20 @@ export async function cancel(sessionID: SessionID, options?: { source?: string } export const LoopInput = z.object({ sessionID: SessionID.zod, + // Optional setup that must run inside the Runner's fiber — keeps the + // cancel-during-setup signal alive (see loop() above). + prelude: z + .object({ + type: z.literal("compaction"), + // Optional: when omitted the loop derives the agent from the last + // user message AFTER revert.cleanup has run. Derivation must happen + // post-cleanup or a reverted session would pick the agent off a + // discarded user message. + agent: z.string().optional(), + model: z.object({ providerID: ProviderID.zod, modelID: ModelID.zod }), + auto: z.boolean(), + }) + .optional(), }) export async function loop(input: z.infer) { diff --git a/packages/opencode/src/session/run-state.ts b/packages/opencode/src/session/run-state.ts index 30068150b..3adfd8a73 100644 --- a/packages/opencode/src/session/run-state.ts +++ b/packages/opencode/src/session/run-state.ts @@ -14,6 +14,7 @@ export interface Interface { sessionID: SessionID, onInterrupt: (meta?: InterruptMeta) => Effect.Effect, work: Effect.Effect, + options?: { rejectIfBusy?: boolean }, ) => Effect.Effect readonly startShell: ( sessionID: SessionID, @@ -111,8 +112,9 @@ export const layer = Layer.effect( sessionID: SessionID, onInterrupt: (meta?: InterruptMeta) => Effect.Effect, work: Effect.Effect, + options?: { rejectIfBusy?: boolean }, ) { - return yield* (yield* runner(sessionID, onInterrupt)).ensureRunning(work) + return yield* (yield* runner(sessionID, onInterrupt)).ensureRunning(work, options) }) const startShell = Effect.fn("SessionRunState.startShell")(function* ( diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index 4c02fada3..35820bb32 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -1544,7 +1544,11 @@ describe("session.compaction.process", () => { }) }) - test("does not leave a summary assistant when aborted before processor setup", async () => { + test("aborting before processor setup leaves a summary assistant tagged with MessageAbortedError", async () => { + // The placeholder summary assistant is intentionally created up-front so + // the UI divider state machine has a carrier for `error`/`finish="error"` + // when pre-summary steps abort or throw. Without it the divider would + // stay `pending` forever. See 2026-05-21-compaction-ui-design.md. const ready = defer() await using tmp = await tmpdir({ git: true }) @@ -1589,7 +1593,16 @@ describe("session.compaction.process", () => { expect(await run).toBe("stop") const all = await svc.messages({ sessionID: session.id }) - expect(all.some((msg) => msg.info.role === "assistant" && msg.info.summary)).toBe(false) + const summary = all.find((m) => m.info.role === "assistant" && m.info.summary) + expect(summary).toBeDefined() + if (summary?.info.role === "assistant") { + expect(summary.info.error?.name).toBe("MessageAbortedError") + expect(summary.info.finish).toBe("error") + // Terminal state must carry `time.completed` so the UI's pending + // memo (which scans `allMessages()` for assistants without a + // completion stamp) does not keep treating the turn as in-flight. + expect(typeof summary.info.time.completed).toBe("number") + } } finally { abort.abort() await rt.dispose() diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts index 64eea38eb..e062d21f6 100644 --- a/packages/opencode/test/session/prompt-effect.test.ts +++ b/packages/opencode/test/session/prompt-effect.test.ts @@ -1625,6 +1625,307 @@ it.live( ), ) +it.live( + "cancel during compaction prelude interrupts the run", + () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Compaction prelude cancel" }) + yield* seed(chat.id, { finish: "stop" }) + yield* llm.hang + + // The prelude runs inside the Runner's fiber, so cancel below would + // be silently dropped by SessionRunState.cancel (no-runner path) if + // it ran outside ensureRunning's protection. Reaching llm.wait(1) + // proves the prelude wrote the marker, runLoop entered, and the + // runner is in Running state — exactly the window the pre-refactor + // route exposed when status was set busy before the runner existed. + const fiber = yield* prompt + .loop({ + sessionID: chat.id, + prelude: { + type: "compaction", + agent: "build", + model: ref, + auto: false, + }, + }) + .pipe(Effect.forkChild) + yield* llm.wait(1) + const cancelled = yield* prompt.cancel(chat.id) + expect(cancelled).toBe(true) + + const exit = yield* Fiber.await(fiber) + expect(Exit.isSuccess(exit)).toBe(true) + + const msgs = yield* sessions.messages({ sessionID: chat.id }) + expect(msgs.some((m) => m.parts.some((p) => p.type === "compaction"))).toBe(true) + const summary = msgs.find((m) => m.info.role === "assistant" && m.info.summary === true) + expect(summary).toBeDefined() + if (summary?.info.role === "assistant") { + expect(summary.info.error?.name).toBe("MessageAbortedError") + } + }), + { git: true, config: providerCfg }, + ), +) + +it.live( + "cancel after compaction marker but before placeholder yields aborted carrier", + () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Compaction prelude race cancel" }) + yield* seed(chat.id, { finish: "stop" }) + yield* llm.hang + + const fiber = yield* prompt + .loop({ + sessionID: chat.id, + prelude: { type: "compaction", agent: "build", model: ref, auto: false }, + }) + .pipe(Effect.forkChild) + + // Polling targets the precise race window: marker present, summary + // placeholder not yet written. Breaking only on that combined state + // (rather than the marker alone) guarantees the cancel lands inside + // the window the new onInterrupt fallback was added to cover. + // observedRaceWindow distinguishes "loop hit the window then broke" + // from "deadline expired" — a setup failure (placeholder beat + // polling) fails explicitly here instead of producing a confusing + // propagation_point mismatch downstream. + const deadline = Date.now() + 5000 + let observedRaceWindow = false + while (Date.now() < deadline) { + const snapshot = yield* sessions.messages({ sessionID: chat.id }) + const hasMarker = snapshot.some((m) => m.parts.some((p) => p.type === "compaction")) + const hasPlaceholder = snapshot.some((m) => m.info.role === "assistant" && m.info.summary === true) + if (hasMarker && !hasPlaceholder) { + observedRaceWindow = true + break + } + yield* Effect.sleep("1 millis") + } + expect(observedRaceWindow).toBe(true) + + const cancelled = yield* prompt.cancel(chat.id) + expect(cancelled).toBe(true) + + const exit = yield* Fiber.await(fiber) + expect(Exit.isSuccess(exit)).toBe(true) + + const msgs = yield* sessions.messages({ sessionID: chat.id }) + const marker = msgs.find((m) => m.parts.some((p) => p.type === "compaction")) + expect(marker).toBeDefined() + const summary = msgs.find((m) => m.info.role === "assistant" && m.info.summary === true) + expect(summary).toBeDefined() + if (summary?.info.role === "assistant" && marker) { + expect(summary.info.error?.name).toBe("MessageAbortedError") + expect(summary.info.finish).toBe("error") + expect(typeof summary.info.time.completed).toBe("number") + expect(summary.info.parentID).toBe(marker.info.id) + // Locks the new onInterrupt fallback branch — if the test ever + // misses the race window and the existing processCompaction + // finalizer handles the cancel, propagation_point would differ. + expect(summary.info.diagnostics?.abort?.propagation_point).toBe( + "session.prompt.loop.onInterrupt.compaction_prelude", + ) + } + }), + { git: true, config: providerCfg }, + ), +) + +it.live( + "cancel after a queued user message still resolves the orphaned compaction marker", + () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Compaction queued-prompt race cancel" }) + yield* seed(chat.id, { finish: "stop" }) + yield* llm.hang + + const fiber = yield* prompt + .loop({ + sessionID: chat.id, + prelude: { type: "compaction", agent: "build", model: ref, auto: false }, + }) + .pipe(Effect.forkChild) + + const deadline = Date.now() + 5000 + let observedRaceWindow = false + while (Date.now() < deadline) { + const snapshot = yield* sessions.messages({ sessionID: chat.id }) + const hasMarker = snapshot.some((m) => m.parts.some((p) => p.type === "compaction")) + const hasPlaceholder = snapshot.some((m) => m.info.role === "assistant" && m.info.summary === true) + if (hasMarker && !hasPlaceholder) { + observedRaceWindow = true + break + } + yield* Effect.sleep("1 millis") + } + expect(observedRaceWindow).toBe(true) + + // Inject a queued user message ahead of the cancel — mirrors what + // SessionPrompt.prompt does when it lands while compaction is + // running: createUserMessage persists the new user before + // ensureRunning awaitRuns the existing run. After this, + // currentTurnTarget returns the queued user instead of the + // marker, so the older "current turn is marker" gate would skip + // the carrier write and leave the marker as an orphan rendering + // `failed`. The sweep must find the marker regardless. + const queuedUser = yield* user(chat.id, "queued prompt") + + const cancelled = yield* prompt.cancel(chat.id) + expect(cancelled).toBe(true) + + const exit = yield* Fiber.await(fiber) + expect(Exit.isSuccess(exit)).toBe(true) + + const msgs = yield* sessions.messages({ sessionID: chat.id }) + const marker = msgs.find((m) => m.parts.some((p) => p.type === "compaction")) + expect(marker).toBeDefined() + expect(queuedUser.id).not.toBe(marker?.info.id) + const summary = msgs.find( + (m) => + m.info.role === "assistant" && + m.info.summary === true && + m.info.parentID === marker?.info.id, + ) + expect(summary).toBeDefined() + if (summary?.info.role === "assistant" && marker) { + expect(summary.info.error?.name).toBe("MessageAbortedError") + expect(summary.info.finish).toBe("error") + expect(typeof summary.info.time.completed).toBe("number") + expect(summary.info.diagnostics?.abort?.propagation_point).toBe( + "session.prompt.loop.onInterrupt.compaction_prelude", + ) + } + }), + { git: true, config: providerCfg }, + ), +) + +it.live( + "prelude derives compaction agent from the post-cleanup latest user", + () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Prelude agent derivation" }) + + // userOne uses the default "build" agent — this is the agent + // /summarize must pick after revert.cleanup drops everything + // newer than userOne. + const userOne = yield* user(chat.id, "first") + + // userTwo is written directly with a different agent. revert + // points back to userOne, so cleanup removes userTwo before + // the prelude derives its agent. Pre-cleanup derivation (the + // regression this test locks against) would have picked + // "ninja" off the still-present userTwo. + yield* sessions.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID: chat.id, + agent: "ninja", + model: ref, + time: { created: Date.now() }, + }) + + yield* sessions.setRevert({ + sessionID: chat.id, + revert: { messageID: userOne.id }, + summary: { additions: 0, deletions: 0, files: 0 }, + }) + + yield* llm.hang + + const fiber = yield* prompt + .loop({ + sessionID: chat.id, + prelude: { type: "compaction", model: ref, auto: false }, + }) + .pipe(Effect.forkChild) + + // Poll until the marker is written, then cancel — only the + // marker's agent matters for this assertion; the rest of the + // run can abort. + const deadline = Date.now() + 5000 + let marker: MessageV2.WithParts | undefined + while (Date.now() < deadline) { + const snapshot = yield* sessions.messages({ sessionID: chat.id }) + marker = snapshot.find((m) => m.parts.some((p) => p.type === "compaction")) + if (marker) break + yield* Effect.sleep("1 millis") + } + expect(marker).toBeDefined() + + yield* prompt.cancel(chat.id) + const exit = yield* Fiber.await(fiber) + expect(Exit.isSuccess(exit)).toBe(true) + + // After revert.cleanup, userTwo ("ninja") is gone; userOne + // ("build") is the latest remaining user, so the marker must + // record "build". + if (marker?.info.role === "user") { + expect(marker.info.agent).toBe("build") + } + }), + { git: true, config: providerCfg }, + ), +) + +it.live( + "loop rejects compaction prelude when a run is already in flight", + () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Compaction prelude busy" }) + yield* llm.hang + yield* user(chat.id, "hi") + + // Hold a normal prompt run in Running so ensureRunning sees a non-Idle + // state when the prelude call arrives. Pre-fix, the second call would + // awaitRun(existing) and silently resolve `true` without writing the + // marker — this asserts rejectIfBusy fires instead. + const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) + yield* llm.wait(1) + + const before = yield* sessions.messages({ sessionID: chat.id }) + const compactionBefore = before.filter((m) => m.parts.some((p) => p.type === "compaction")).length + + const exit = yield* prompt + .loop({ + sessionID: chat.id, + prelude: { type: "compaction", agent: "build", model: ref, auto: false }, + }) + .pipe(Effect.exit) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + expect(Cause.squash(exit.cause)).toBeInstanceOf(Session.BusyError) + } + + const after = yield* sessions.messages({ sessionID: chat.id }) + const compactionAfter = after.filter((m) => m.parts.some((p) => p.type === "compaction")).length + expect(compactionAfter).toBe(compactionBefore) + + yield* prompt.cancel(chat.id) + yield* Fiber.await(fiber) + }), + { git: true, config: providerCfg }, + ), +) + it.live( "cancel preserves explicit caller source in abort diagnostics", () => diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 88cac8492..400e802e9 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -477,6 +477,7 @@ export type UserMessage = { tools?: { [key: string]: boolean } + replay?: boolean } export type AssistantMessage = { diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index 0cd4db8e5..5d3a55851 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -301,8 +301,33 @@ [data-slot="compaction-part-label"] { flex: 0 0 auto; + display: inline-flex; + align-items: center; + gap: 6px; white-space: nowrap; text-align: center; + color: var(--fg-weak); + } + + [data-slot="compaction-part-icon"] { + width: 14px; + height: 14px; + display: inline-flex; + flex: 0 0 auto; + } + + [data-slot="compaction-part-elapsed"] { + color: var(--fg-weaker); + font-variant-numeric: tabular-nums; + } + + &[data-state="failed"] { + [data-slot="compaction-part-line"] { + background: color-mix(in srgb, var(--error) 35%, transparent); + } + [data-slot="compaction-part-label"] { + color: var(--error); + } } } diff --git a/packages/ui/src/components/message-part/parts/compaction-and-divider.tsx b/packages/ui/src/components/message-part/parts/compaction-and-divider.tsx index 7d5b141eb..95b252ee8 100644 --- a/packages/ui/src/components/message-part/parts/compaction-and-divider.tsx +++ b/packages/ui/src/components/message-part/parts/compaction-and-divider.tsx @@ -1,13 +1,39 @@ +import { Show } from "solid-js" import { useI18n } from "../../../context/i18n" +import { Icon } from "../../icon" +import { TextShimmer } from "../../text-shimmer" +import type { CompactionDividerState } from "../../session-turn-compaction" import { registerPartComponent } from "../registry" -export function MessageDivider(props: { label: string }) { +export function MessageDivider(props: { + label: string + state?: CompactionDividerState + elapsed?: string +}) { + const state = () => props.state + const isPending = () => state() === "pending" + const isAborted = () => state() === "aborted" + const isFailed = () => state() === "failed" return ( -
+
- - {props.label} + + + + + + + + {props.label}} + > + + + {props.elapsed} + +
diff --git a/packages/ui/src/components/session-turn-compaction-contract.test.ts b/packages/ui/src/components/session-turn-compaction-contract.test.ts new file mode 100644 index 000000000..ac25927fc --- /dev/null +++ b/packages/ui/src/components/session-turn-compaction-contract.test.ts @@ -0,0 +1,92 @@ +/** + * Compaction UI behavioural contracts on session-turn.tsx — guards the + * decisions in docs/superpowers/specs/2026-05-21-compaction-ui-design.md so + * future edits cannot silently regress them. Renders nothing on purpose + * (bun test resolves solid-js to its SSR no-op; createEffect won't fire), so + * each contract is asserted against the source text instead of behaviour. + */ +import { expect, test } from "bun:test" +import { readFileSync } from "node:fs" + +const turn = readFileSync(new URL("./session-turn.tsx", import.meta.url), "utf8") +const divider = readFileSync(new URL("./message-part/parts/compaction-and-divider.tsx", import.meta.url), "utf8") + +test("raw and visible assistant message memos are split, summary stays only in raw", () => { + expect(turn).toContain("const rawAssistantMessages = createMemo") + expect(turn).toContain("const visibleAssistantMessages = createMemo") + expect(turn).toMatch(/rawAssistantMessages\(\)\.filter\(\(m\)\s*=>\s*m\.summary\s*!==\s*true\)/) +}) + +test("compactionSummary uses the raw list (the only legitimate consumer of summary assistants)", () => { + expect(turn).toContain("const compactionSummary = createMemo") + expect(turn).toMatch(/rawAssistantMessages\(\)\.find\(\(m\)\s*=>\s*m\.summary\s*===\s*true\)/) +}) + +test("no remaining call site references the legacy `assistantMessages()` memo", () => { + expect(turn).not.toMatch(/\bassistantMessages\(\)/) +}) + +test("every prior derivation now reads visibleAssistantMessages — leaks would silently surface the summary", () => { + // turnInProgress / interrupted / error / showAssistantCopyPartID / turnDurationMs / assistantDerived / render + const visibleCalls = turn.match(/visibleAssistantMessages\(\)/g) + expect(visibleCalls?.length ?? 0).toBeGreaterThanOrEqual(7) +}) + +test("divider state machine reads from session-turn-compaction helpers and threads working()", () => { + // isWorking (session busy/retry AND this is the active turn) disambiguates + // "no summary yet" between the live race window and legacy orphans + // (pre-PR pre-summary failures left no placeholder, even when the orphan + // is the latest turn and a position-only heuristic would miss it). + expect(turn).toContain("compactionDividerState({ summaryAssistant: compactionSummary(), isWorking: working() })") + expect(turn).toContain("compactionDividerLabelKey({ state, error: summary?.error })") +}) + +test("showThinking suppresses while compaction divider is pending — divider already runs its own shimmer", () => { + expect(turn).toMatch(/if\s*\(\s*compactionDivider\(\)\s*===\s*"pending"\s*\)\s*return\s*false/) +}) + +test("hideUserBody covers placeholder, replay flag, and 'every part is compaction_continue synthetic'", () => { + // every(...) not some(...) — diagnostics reminders inject synthetic parts too + expect(turn).toContain("const hideUserBody = createMemo") + expect(turn).toContain('ps[0]?.type === "compaction"') + expect(turn).toContain("msg.replay === true") + expect(turn).toMatch(/ps\.every\(/) + expect(turn).not.toMatch(/ps\.some\(/) + expect(turn).toContain("compaction_continue") + expect(turn).toContain("part.synthetic === true") +}) + +test("turn row is preserved — only the inner message-content body hides", () => { + // hideUserBody guards Message rendering; the surrounding session-turn-message-container stays so + // child assistants attached via parentID keep their render slot. + expect(turn).toMatch(/Show when=\{!hideUserBody\(\)\}>\s*
{ + // error() pulls from visibleAssistantMessages — summary is filtered there. + expect(turn).toMatch(/visibleAssistantMessages\(\)\.find\(\(m\)\s*=>\s*m\.error\s*&&\s*m\.error\.name\s*!==\s*"MessageAbortedError"\s*\)/) +}) + +test("compaction elapsed signal cleans up its interval when state leaves pending or component unmounts", () => { + expect(turn).toContain("const [compactionElapsedSec, setCompactionElapsedSec] = createSignal(0)") + expect(turn).toContain("setInterval") + expect(turn).toContain("onCleanup(() => clearInterval(interval))") + // Reset to 0 on non-pending so the timer doesn't stick. + expect(turn).toMatch(/state\s*!==\s*"pending".*setCompactionElapsedSec\(0\)/s) +}) + +test("MessageDivider renders icons through the real icon registry, not inline SVG", () => { + expect(divider).toContain('Icon name="circle-ban-sign"') + expect(divider).toContain('Icon name="circle-x"') + expect(divider).toContain("TextShimmer text={props.label} active={true}") +}) + +test("MessageDivider exposes data-state for the four-state stylesheet (default stays 'static')", () => { + expect(divider).toContain('data-state={state() ?? "static"}') +}) + +test("registered compaction-part component still falls back to the static done label so the part-registry contract holds", () => { + expect(divider).toContain('registerPartComponent("compaction"') + expect(divider).toContain('i18n.t("ui.messagePart.compaction")') +}) diff --git a/packages/ui/src/components/session-turn-compaction.test.ts b/packages/ui/src/components/session-turn-compaction.test.ts new file mode 100644 index 000000000..c7cbebd1f --- /dev/null +++ b/packages/ui/src/components/session-turn-compaction.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, test } from "bun:test" +import type { AssistantMessage, UserMessage } from "@opencode-ai/sdk/v2/client" +import { + compactionDividerLabelKey, + compactionDividerState, + compactionElapsedSeconds, + formatCompactionElapsed, +} from "./session-turn-compaction" + +function user(overrides: Partial = {}): UserMessage { + return { + id: "u1", + sessionID: "s1", + role: "user", + time: { created: 10_000 }, + agent: "build", + model: { providerID: "anthropic", modelID: "claude-opus-4-7" }, + ...overrides, + } as UserMessage +} + +function summary(overrides: Partial = {}): AssistantMessage { + return { + id: "a1", + sessionID: "s1", + role: "assistant", + parentID: "u1", + mode: "compaction", + agent: "compaction", + summary: true, + path: { cwd: "/", root: "/" }, + cost: 0, + tokens: { output: 0, input: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: "claude-opus-4-7", + providerID: "anthropic", + time: { created: 11_000 }, + ...overrides, + } as AssistantMessage +} + +describe("compactionDividerState", () => { + test("no summary assistant + session is working on this turn → pending (race window)", () => { + // Placeholder is about to land — the divider should shimmer until either + // the summary streams in or terminal state is written. + expect(compactionDividerState({ summaryAssistant: undefined, isWorking: true })).toBe("pending") + }) + + test("no summary assistant + session is idle on this turn → failed (legacy orphan)", () => { + // Pre-PR pre-summary failures (agents.get / provider.getModel / select / + // plugin / toModelMessages / processors.create) never wrote a summary + // assistant. Once the session stops working on this turn — regardless of + // whether the orphan is the latest turn or not — the divider must not + // shimmer forever. + expect(compactionDividerState({ summaryAssistant: undefined, isWorking: false })).toBe("failed") + expect(compactionDividerState({ summaryAssistant: undefined })).toBe("failed") + }) + + test("abort error → aborted (even when time.completed is set)", () => { + const s = summary({ + error: { name: "MessageAbortedError", data: { message: "abort" } }, + time: { created: 11_000, completed: 12_000 }, + }) + expect(compactionDividerState({ summaryAssistant: s })).toBe("aborted") + }) + + test("non-abort error → failed (even when time.completed is set)", () => { + const s = summary({ + error: { name: "APIError", data: { message: "boom", isRetryable: false } }, + time: { created: 11_000, completed: 12_000 }, + }) + expect(compactionDividerState({ summaryAssistant: s })).toBe("failed") + }) + + test("time.completed set without error → done", () => { + const s = summary({ time: { created: 11_000, completed: 12_000 } }) + expect(compactionDividerState({ summaryAssistant: s })).toBe("done") + }) + + test("summary streaming (no completed, no error) → pending", () => { + const s = summary({ time: { created: 11_000 } }) + expect(compactionDividerState({ summaryAssistant: s })).toBe("pending") + }) +}) + +describe("compactionDividerLabelKey", () => { + test("pending returns pending key", () => { + expect(compactionDividerLabelKey({ state: "pending" })).toEqual({ + key: "ui.messagePart.compaction.pending", + }) + }) + + test("done returns the existing done key", () => { + expect(compactionDividerLabelKey({ state: "done" })).toEqual({ + key: "ui.messagePart.compaction", + }) + }) + + test("aborted returns aborted key", () => { + expect(compactionDividerLabelKey({ state: "aborted" })).toEqual({ + key: "ui.messagePart.compaction.aborted", + }) + }) + + test("failed + ContextOverflowError → context overflow key with no reason", () => { + expect( + compactionDividerLabelKey({ + state: "failed", + error: { name: "ContextOverflowError", message: "too large" }, + }), + ).toEqual({ key: "ui.messagePart.compaction.failedContextOverflow" }) + }) + + test("failed + APIError (real NamedError shape) → reason from error.data.message", () => { + // NamedError.toObject() returns { name, data: { message, ... } }. + // Reading `error.message` (top-level) was always undefined and the + // label rendered as just "Compaction failed:" with no reason. + const result = compactionDividerLabelKey({ + state: "failed", + error: { + name: "APIError", + data: { message: "stream closed", isRetryable: false, providerID: "anthropic" }, + }, + }) + expect(result).toEqual({ key: "ui.messagePart.compaction.failed", params: { reason: "stream closed" } }) + }) + + test("failed + top-level message (synthetic helper shape) → still resolves via fallback", () => { + const result = compactionDividerLabelKey({ + state: "failed", + error: { name: "APIError", message: "synthetic" }, + }) + expect(result).toEqual({ key: "ui.messagePart.compaction.failed", params: { reason: "synthetic" } }) + }) + + test("failed + data.message wins over top-level message when both present", () => { + const result = compactionDividerLabelKey({ + state: "failed", + error: { name: "APIError", message: "old", data: { message: "new" } }, + }) + expect(result).toEqual({ key: "ui.messagePart.compaction.failed", params: { reason: "new" } }) + }) + + test("failed + MessageOutputLengthError (empty data) → failedUnknown (no trailing colon)", () => { + // OutputLengthError = NamedError.create("MessageOutputLengthError", z.object({})) + // so error.data is {} and reason resolves to "". The default template + // would render "Compaction failed:" with a dangling colon — use the + // no-colon variant instead. + const result = compactionDividerLabelKey({ + state: "failed", + error: { name: "MessageOutputLengthError", data: {} }, + }) + expect(result).toEqual({ key: "ui.messagePart.compaction.failedUnknown" }) + }) + + test("failed + whitespace-only reason → failedUnknown", () => { + const result = compactionDividerLabelKey({ + state: "failed", + error: { name: "APIError", data: { message: " " } }, + }) + expect(result).toEqual({ key: "ui.messagePart.compaction.failedUnknown" }) + }) +}) + +describe("compactionElapsedSeconds", () => { + test("pending without summary → counts from user time.created", () => { + expect( + compactionElapsedSeconds({ + state: "pending", + summaryAssistant: undefined, + compactionUserMessage: user({ time: { created: 10_000 } }), + now: 13_000, + }), + ).toBe(3) + }) + + test("pending with summary → counts from summary time.created", () => { + expect( + compactionElapsedSeconds({ + state: "pending", + summaryAssistant: summary({ time: { created: 11_500 } }), + compactionUserMessage: user({ time: { created: 10_000 } }), + now: 13_000, + }), + ).toBe(1) + }) + + test("non-pending state → 0", () => { + expect( + compactionElapsedSeconds({ + state: "done", + summaryAssistant: summary(), + compactionUserMessage: user(), + now: 99_000, + }), + ).toBe(0) + }) + + test("negative clock drift clamps to 0", () => { + expect( + compactionElapsedSeconds({ + state: "pending", + summaryAssistant: undefined, + compactionUserMessage: user({ time: { created: 10_000 } }), + now: 9_000, + }), + ).toBe(0) + }) +}) + +describe("formatCompactionElapsed", () => { + test("< 60s → seconds form", () => { + expect(formatCompactionElapsed(0)).toBe("0s") + expect(formatCompactionElapsed(45)).toBe("45s") + expect(formatCompactionElapsed(59)).toBe("59s") + }) + + test(">= 60s → minutes + seconds form", () => { + expect(formatCompactionElapsed(60)).toBe("1m 0s") + expect(formatCompactionElapsed(78)).toBe("1m 18s") + expect(formatCompactionElapsed(3_661)).toBe("61m 1s") + }) +}) diff --git a/packages/ui/src/components/session-turn-compaction.ts b/packages/ui/src/components/session-turn-compaction.ts new file mode 100644 index 000000000..5e96b4d22 --- /dev/null +++ b/packages/ui/src/components/session-turn-compaction.ts @@ -0,0 +1,91 @@ +import type { AssistantMessage, UserMessage } from "@opencode-ai/sdk/v2/client" + +export type CompactionDividerState = "pending" | "done" | "aborted" | "failed" + +export type CompactionDividerLabel = + | { key: "ui.messagePart.compaction.pending" } + | { key: "ui.messagePart.compaction" } + | { key: "ui.messagePart.compaction.aborted" } + | { key: "ui.messagePart.compaction.failed"; params: { reason: string } } + | { key: "ui.messagePart.compaction.failedUnknown" } + | { key: "ui.messagePart.compaction.failedContextOverflow" } + +// Order matters: processor.cleanup() writes `time.completed` on abort/error +// paths too, so checking `time.completed` first would misclassify +// aborted/failed as `done`. Match in this exact order. +export function compactionDividerState(input: { + summaryAssistant: AssistantMessage | undefined + // True when the session runtime is currently doing work for this turn + // (busy/retry + this is the active turn). Used to disambiguate "no + // summary yet" between the live race window (placeholder about to land) + // and legacy orphans (pre-PR pre-summary failures never wrote a + // placeholder; the session is now idle on this turn). + // Position-based heuristics (e.g. "is this the latest turn") miss the + // case where the orphan IS the latest turn and the session is idle. + isWorking?: boolean +}): CompactionDividerState { + const summary = input.summaryAssistant + if (!summary) { + // Legacy data: pre-PR pre-summary failures (agents.get / provider.getModel + // / select / plugin / toModelMessages / processors.create) returned + // before the placeholder summary assistant was written. Those orphans + // would otherwise shimmer as "pending" forever. If the session isn't + // actively working on this turn, mark it failed instead. + if (!input.isWorking) return "failed" + return "pending" + } + if (summary.error?.name === "MessageAbortedError") return "aborted" + if (summary.error) return "failed" + if (typeof summary.time.completed === "number") return "done" + return "pending" +} + +export function compactionDividerLabelKey(input: { + state: CompactionDividerState + // NamedError.toObject() shape: { name, data: { message, ... } }. The + // top-level `message` was an early helper-only shape that never matches + // real assistant errors — kept as a fallback so unit tests with synthetic + // shapes don't have to wrap everything in `data`. + error?: { name?: string; message?: string; data?: { message?: string } & Record } | null +}): CompactionDividerLabel { + switch (input.state) { + case "pending": + return { key: "ui.messagePart.compaction.pending" } + case "done": + return { key: "ui.messagePart.compaction" } + case "aborted": + return { key: "ui.messagePart.compaction.aborted" } + case "failed": { + const name = input.error?.name + if (name === "ContextOverflowError") { + return { key: "ui.messagePart.compaction.failedContextOverflow" } + } + const reason = (input.error?.data?.message ?? input.error?.message ?? "").trim() + // Some NamedError variants (e.g. MessageOutputLengthError) carry empty + // `data`, so reason is "". The default template "Compaction failed: {{reason}}" + // would render with a trailing colon — drop to a no-colon variant. + if (!reason) return { key: "ui.messagePart.compaction.failedUnknown" } + return { key: "ui.messagePart.compaction.failed", params: { reason } } + } + } +} + +export function compactionElapsedSeconds(input: { + state: CompactionDividerState + summaryAssistant: AssistantMessage | undefined + compactionUserMessage: UserMessage + now: number +}): number { + if (input.state !== "pending") return 0 + const start = input.summaryAssistant?.time.created ?? input.compactionUserMessage.time.created + if (typeof start !== "number") return 0 + const seconds = Math.floor((input.now - start) / 1000) + return seconds < 0 ? 0 : seconds +} + +export function formatCompactionElapsed(seconds: number): string { + if (seconds < 60) return `${seconds}s` + const minutes = Math.floor(seconds / 60) + const remainder = seconds % 60 + return `${minutes}m ${remainder}s` +} diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index e92bd349a..277bdf033 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -10,7 +10,7 @@ import { useData } from "../context" import { isWorkInFlightStatus } from "../util/session-status" import { Binary } from "@opencode-ai/core/util/binary" -import { createEffect, createMemo, createSignal, ParentProps, Show } from "solid-js" +import { createEffect, createMemo, createSignal, onCleanup, ParentProps, Show } from "solid-js" import { AssistantParts, Message, MessageDivider, PART_MAPPING, type UserActions } from "./message-part" import { Card } from "./card" import { Icon } from "./icon" @@ -22,6 +22,13 @@ import { useI18n } from "../context/i18n" import { hasVisibleTurnChanges, type TurnChangeActions, type TurnChangeDisplay } from "./session-turn-changes" import { SessionTurnChangesPanel } from "./session-turn-changes-panel" import { SessionTurnDiffs } from "./session-turn-diffs" +import { + compactionDividerLabelKey, + compactionDividerState, + compactionElapsedSeconds, + formatCompactionElapsed, + type CompactionDividerState, +} from "./session-turn-compaction" function record(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value) @@ -254,7 +261,11 @@ export function SessionTurn( .reverse() }) - const assistantMessages = createMemo( + // `rawAssistantMessages` keeps the compaction summary message visible to the + // divider state machine. Every other derivation reads `visibleAssistantMessages` + // so the summary never leaks into "Thinking…", error cards, copy targets, + // turn-duration math, etc. — the compaction divider is its only carrier. + const rawAssistantMessages = createMemo( () => { if (props.assistantMessages !== undefined) return props.assistantMessages const msg = message() @@ -272,24 +283,54 @@ export function SessionTurn( { equals: same }, ) + const visibleAssistantMessages = createMemo( + () => rawAssistantMessages().filter((m) => m.summary !== true), + emptyAssistant, + { equals: same }, + ) + + const compactionSummary = createMemo(() => rawAssistantMessages().find((m) => m.summary === true)) + const turnChange = createMemo(() => props.turnChanges?.[props.messageID]) const [turnExpanded, setTurnExpanded] = createSignal([]) const turnInProgress = createMemo(() => { - const messages = assistantMessages() + const messages = visibleAssistantMessages() if (!messages.length) return false return messages.some((item) => typeof item.time.completed !== "number") }) - const interrupted = createMemo(() => assistantMessages().some((m) => m.error?.name === "MessageAbortedError")) + const interrupted = createMemo(() => + visibleAssistantMessages().some((m) => m.error?.name === "MessageAbortedError"), + ) + const status = createMemo(() => { + if (props.status !== undefined) return props.status + if (typeof props.active === "boolean" && !props.active) return idle + return data.store.session_status[props.sessionID] ?? idle + }) + const working = createMemo(() => isWorkInFlightStatus(status()) && active()) + const compactionDivider = createMemo(() => { + if (!compaction()) return undefined + return compactionDividerState({ summaryAssistant: compactionSummary(), isWorking: working() }) + }) + const compactionLabel = createMemo(() => { + const state = compactionDivider() + if (!state) return undefined + const summary = compactionSummary() + const label = compactionDividerLabelKey({ state, error: summary?.error }) + if (label.key === "ui.messagePart.compaction.failed") { + return i18n.t(label.key, label.params) + } + return i18n.t(label.key) + }) const divider = createMemo(() => { - if (compaction()) return i18n.t("ui.messagePart.compaction") + if (compactionDivider()) return compactionLabel() ?? "" if (interrupted()) return i18n.t("ui.message.interrupted") return "" }) const error = createMemo( - () => assistantMessages().find((m) => m.error && m.error.name !== "MessageAbortedError")?.error, + () => visibleAssistantMessages().find((m) => m.error && m.error.name !== "MessageAbortedError")?.error, ) const showAssistantCopyPartID = createMemo(() => { - const messages = assistantMessages() + const messages = visibleAssistantMessages() for (let i = messages.length - 1; i >= 0; i--) { const message = messages[i] @@ -313,12 +354,6 @@ export function SessionTurn( return unwrap(String(msg)) }) - const status = createMemo(() => { - if (props.status !== undefined) return props.status - if (typeof props.active === "boolean" && !props.active) return idle - return data.store.session_status[props.sessionID] ?? idle - }) - const working = createMemo(() => isWorkInFlightStatus(status()) && active()) const visibleTurnChange = createMemo(() => { const current = turnChange() if (!hasVisibleTurnChanges(current) || working() || turnInProgress()) return @@ -334,7 +369,7 @@ export function SessionTurn( const start = message()?.time.created if (typeof start !== "number") return undefined - const end = assistantMessages().reduce((max, item) => { + const end = visibleAssistantMessages().reduce((max, item) => { const completed = item.time.completed if (typeof completed !== "number") return max if (max === undefined) return completed @@ -349,7 +384,7 @@ export function SessionTurn( let visible = 0 let reason: string | undefined const show = showReasoningSummaries() - for (const message of assistantMessages()) { + for (const message of visibleAssistantMessages()) { for (const part of list(data.store.part?.[message.id], emptyParts)) { if (partState(part, show) === "visible") { visible++ @@ -365,12 +400,69 @@ export function SessionTurn( const assistantVisible = createMemo(() => assistantDerived().visible) const reasoningHeading = createMemo(() => assistantDerived().reason) const showThinking = createMemo(() => { + // Compaction pending shows its own running indicator through the + // divider's shimmer + elapsed timer; don't double up. + if (compactionDivider() === "pending") return false if (!working() || !!error()) return false if (status().type === "retry") return false if (showReasoningSummaries()) return assistantVisible() === 0 return true }) + const [compactionElapsedSec, setCompactionElapsedSec] = createSignal(0) + createEffect(() => { + const state = compactionDivider() + const summary = compactionSummary() + const userMsg = message() + if (state !== "pending" || !userMsg) { + setCompactionElapsedSec(0) + return + } + setCompactionElapsedSec( + compactionElapsedSeconds({ + state, + summaryAssistant: summary, + compactionUserMessage: userMsg, + now: Date.now(), + }), + ) + const interval = setInterval(() => { + setCompactionElapsedSec( + compactionElapsedSeconds({ + state, + summaryAssistant: summary, + compactionUserMessage: userMsg, + now: Date.now(), + }), + ) + }, 1000) + onCleanup(() => clearInterval(interval)) + }) + const compactionElapsedLabel = createMemo(() => { + if (compactionDivider() !== "pending") return undefined + return formatCompactionElapsed(compactionElapsedSec()) + }) + + // Compaction placeholders (user message whose only part is the compaction + // marker) and re-injected continuation messages (auto-continue synthetic + // text or replay-tagged user) keep their turn row so child assistants render + // through `parentID`, but their user body is suppressed — the divider alone + // represents the compaction event. + const hideUserBody = createMemo(() => { + const ps = parts() + if (ps.length === 1 && ps[0]?.type === "compaction") return true + const msg = message() + if (!msg) return false + if (msg.replay === true) return true + if (ps.length === 0) return false + return ps.every( + (part) => + part.type === "text" && + part.synthetic === true && + (part.metadata as { compaction_continue?: unknown } | undefined)?.compaction_continue === true, + ) + }) + const autoScroll = createAutoScroll({ working, onUserInteracted: props.onUserInteracted, @@ -394,18 +486,24 @@ export function SessionTurn( data-slot="session-turn-message-container" class={props.classes?.container} > -
- -
+ +
+ +
+
- +
- 0}> + 0}>
= { "This question was cancelled before it was answered. Ask again below if you want to continue.", "ui.messagePart.questions.pendingMarker": "↓ Pending question — answer below", "ui.messagePart.compaction": "Session compacted", + "ui.messagePart.compaction.pending": "Compacting conversation", + "ui.messagePart.compaction.aborted": "Compaction cancelled", + "ui.messagePart.compaction.failed": "Compaction failed: {{reason}}", + "ui.messagePart.compaction.failedUnknown": "Compaction failed", + "ui.messagePart.compaction.failedContextOverflow": + "Compaction failed: conversation too large, please start a new session", "ui.messagePart.context.read.one": "Read {{count}} file", "ui.messagePart.context.read.other": "Read {{count}} files", "ui.messagePart.context.search.one": "Searched {{count}} time", diff --git a/packages/ui/src/i18n/zh.ts b/packages/ui/src/i18n/zh.ts index 2acbbc1dd..882f1e98f 100644 --- a/packages/ui/src/i18n/zh.ts +++ b/packages/ui/src/i18n/zh.ts @@ -97,6 +97,11 @@ export const dict = { "ui.messagePart.questions.interrupted": "这个问题已取消,尚未收到回答。如需继续,请在下方重新说明。", "ui.messagePart.questions.pendingMarker": "↓ 在下方回答这个问题", "ui.messagePart.compaction": "会话已压缩", + "ui.messagePart.compaction.pending": "正在压缩对话", + "ui.messagePart.compaction.aborted": "压缩已取消", + "ui.messagePart.compaction.failed": "压缩失败:{{reason}}", + "ui.messagePart.compaction.failedUnknown": "压缩失败", + "ui.messagePart.compaction.failedContextOverflow": "压缩失败:对话过大,请新开会话", "ui.messagePart.context.read.one": "读取 {{count}} 个文件", "ui.messagePart.context.read.other": "读取 {{count}} 个文件", "ui.messagePart.context.search.one": "搜索 {{count}} 处匹配",