Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
f2d187f
feat(session): keep compaction summary message for UI state machine
Astro-Han May 21, 2026
2333993
feat(app): collapse compaction surface to a single four-state divider
Astro-Han May 21, 2026
c40ff54
test(app): add compaction-divider snap covering four states
Astro-Han May 21, 2026
0f5fc4e
test(app): hide toast region before compaction-divider snap
Astro-Han May 21, 2026
0e9294d
fix(ui): read compaction error reason from NamedError data.message
Astro-Han May 21, 2026
aa68301
fix(session): stamp time.completed on compaction terminal placeholders
Astro-Han May 21, 2026
146e022
fix(ui): drop trailing colon when compaction error has no reason
Astro-Han May 21, 2026
d92465f
fix(server): surface compaction failure on summarize route
Astro-Han May 21, 2026
17c6428
fix(ui): treat legacy compaction orphans as failed instead of pending
Astro-Han May 21, 2026
16b97fb
fix(ui): gate compaction orphan fallback on session work state
Astro-Han May 21, 2026
4107a85
fix(server): set session busy before manual compaction marker
Astro-Han May 21, 2026
f942bf8
fix(session): run compaction marker inside loop runner
Astro-Han May 21, 2026
73aab58
fix(session): reject compact-while-busy instead of silent no-op
Astro-Han May 21, 2026
0d6be60
fix(app): disable session.compact command while session busy
Astro-Han May 21, 2026
c2120f7
fix(session): write aborted carrier when cancel races compaction plac…
Astro-Han May 21, 2026
41406e9
fix(session): move revert.cleanup into prelude atomic transaction
Astro-Han May 22, 2026
3c2e4b1
fix(session): derive compaction agent after revert.cleanup
Astro-Han May 22, 2026
ba232c6
fix(session): sweep orphan compaction marker on cancel
Astro-Han May 22, 2026
629471c
docs(session): pin compaction sweep semantic boundary
Astro-Han May 22, 2026
4926dc7
test(session): lock agent derivation order against revert.cleanup
Astro-Han May 22, 2026
6d3b237
chore: merge dev for #834 dark + #841 light surface updates
Astro-Han May 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 142 additions & 0 deletions packages/app/e2e/snap/compaction-divider.snap.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import("../utils").createSdk>,
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<Shot> {
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")
Comment thread
Astro-Han marked this conversation as resolved.
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`)
})
6 changes: 5 additions & 1 deletion packages/app/src/pages/session/use-session-commands.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import { useNavigate } from "@solidjs/router"
import { createMediaQuery } from "@solid-primitives/media"
import { useCommand, type CommandOption } from "@/context/command"
Expand Down Expand Up @@ -368,7 +368,11 @@
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({
Expand Down
18 changes: 16 additions & 2 deletions packages/opencode/src/effect/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import type { LifecycleRequest } from "@/session/lifecycle-provenance"
export interface Runner<A, E = never> {
readonly state: State<A, E>
readonly busy: boolean
readonly ensureRunning: (work: Effect.Effect<A, E>) => Effect.Effect<A, E>
readonly ensureRunning: (
work: Effect.Effect<A, E>,
options?: { rejectIfBusy?: boolean },
) => Effect.Effect<A, E>
readonly startShell: (work: Effect.Effect<A, E>, options?: { ready?: Deferred.Deferred<void> }) => Effect.Effect<A, E>
readonly cancel: Effect.Effect<void>
readonly cancelWith: (meta?: InterruptMeta) => Effect.Effect<void>
Expand Down Expand Up @@ -162,10 +165,21 @@ export const make = <A, E = never>(
const awaitShellReady = (shell: ShellHandle<A, E>) =>
Deferred.await(shell.ready).pipe(Effect.raceFirst(Fiber.await(shell.fiber).pipe(Effect.asVoid)), Effect.ignore)

const ensureRunning = (work: Effect.Effect<A, E>) =>
const ensureRunning = (work: Effect.Effect<A, E>, 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":
Expand Down
63 changes: 43 additions & 20 deletions packages/opencode/src/server/instance/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand Down Expand Up @@ -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 })
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
break
}
return c.json(true)
},
)
Expand Down
Loading