fix(core,tui): resolve stuck compacting status with terminal compaction events

This commit is contained in:
Kit Langton 2026-07-02 08:48:25 -04:00
commit 4f4cb8cb36
10 changed files with 297 additions and 6 deletions

View file

@ -237,7 +237,17 @@ const make = (dependencies: Dependencies) => {
Effect.catchTag("LLM.Error", () => Effect.succeed(false)),
)
const summary = chunks.join("")
if (!summarized || failed || !summary.trim()) return false
if (!summarized || failed || !summary.trim()) {
// Clients react to Started (for example by marking the session as running),
// so a failed summarization still needs a terminal event.
yield* dependencies.events.publish(SessionEvent.Compaction.Failed, {
sessionID: input.sessionID,
messageID,
timestamp: yield* DateTime.now,
reason: input.reason,
})
return false
}
yield* dependencies.events.publish(SessionEvent.Compaction.Ended, {
sessionID: input.sessionID,
messageID,

View file

@ -389,6 +389,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
"session.next.retried": () => Effect.void,
"session.next.compaction.started": () => Effect.void,
"session.next.compaction.delta": () => Effect.void,
"session.next.compaction.failed": () => Effect.void,
"session.next.compaction.ended": (event) => {
return adapter.appendMessage(
SessionMessage.Compaction.make({

View file

@ -19,11 +19,12 @@ import { SessionV2 } from "@opencode-ai/core/session"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { DateTime, Effect, Layer, Stream } from "effect"
import { DateTime, Effect, Fiber, Layer, Stream } from "effect"
import { asc, eq } from "drizzle-orm"
import { testEffect } from "./lib/effect"
let requests: LLMRequest[] = []
let summaryText = "manual summary"
const model = Model.make({
id: "summary-model",
provider: "test",
@ -33,7 +34,7 @@ const client = Layer.mock(LLMClient.Service)({
prepare: () => Effect.die("unused"),
stream: (request: LLMRequest) => {
requests.push(request)
return Stream.make(LLMEvent.textDelta({ id: "summary", text: "manual summary" }))
return Stream.make(LLMEvent.textDelta({ id: "summary", text: summaryText }))
},
generate: () => Effect.die("unused"),
})
@ -66,9 +67,81 @@ test("compaction describes tool media without embedding base64", () => {
expect(serialized).not.toContain(base64)
})
it.effect("manual compaction failure publishes a terminal failed event", () =>
Effect.scoped(
Effect.gen(function* () {
requests = []
summaryText = ""
const db = (yield* Database.Service).db
const compaction = yield* SessionCompaction.Service
const store = yield* SessionStore.Service
const events = yield* EventV2.Service
const sessionID = SessionV2.ID.make("ses_failed_compaction")
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.onConflictDoNothing()
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "failed-compaction",
directory: "/project",
title: "Failed compaction",
version: "test",
})
.run()
.pipe(Effect.orDie)
const failed = yield* events
.subscribe(SessionEvent.Compaction.Failed)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
const session = yield* store
.get(sessionID)
.pipe(
Effect.flatMap((session) =>
session ? Effect.succeed(session) : Effect.die("failed compaction test session missing"),
),
)
expect(
yield* compaction.compactManual({
session,
messages: [
{
id: SessionMessage.ID.create(),
type: "user" as const,
text: "Summarization for this conversation will fail.",
time: { created: DateTime.makeUnsafe(0) },
},
],
}),
).toBe(false)
expect(Array.from(yield* Fiber.join(failed))[0]?.data).toMatchObject({ sessionID, reason: "manual" })
expect(yield* store.context(sessionID)).toEqual([])
// The failed event is live-only; only Started is durably recorded.
expect(
yield* db
.select({ type: EventTable.type })
.from(EventTable)
.where(eq(EventTable.aggregate_id, sessionID))
.orderBy(asc(EventTable.seq))
.all()
.pipe(Effect.orDie),
).toEqual([{ type: EventV2.versionedType(SessionEvent.Compaction.Started.type, 1) }])
summaryText = "manual summary"
}),
),
)
it.effect("manual compaction summarizes short context instead of no-op", () =>
Effect.gen(function* () {
requests = []
summaryText = "manual summary"
const db = (yield* Database.Service).db
const compaction = yield* SessionCompaction.Service
const store = yield* SessionStore.Service