mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-23 18:55:37 -04:00
fix(core,tui): resolve stuck compacting status with terminal compaction events
This commit is contained in:
@@ -3914,6 +3914,19 @@ export type EventSubscribeOutput =
|
||||
readonly recent: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "session.next.compaction.failed"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly timestamp: number
|
||||
readonly sessionID: string
|
||||
readonly messageID: string
|
||||
readonly reason: "auto" | "manual"
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -466,6 +466,18 @@ export namespace Compaction {
|
||||
},
|
||||
})
|
||||
export type Ended = typeof Ended.Type
|
||||
|
||||
// Live-only: a failed compaction produces no durable message, but clients that
|
||||
// reacted to Started need a terminal signal.
|
||||
export const Failed = Event.define({
|
||||
type: "session.next.compaction.failed",
|
||||
schema: {
|
||||
...Base,
|
||||
messageID: SessionMessage.ID,
|
||||
reason: Started.data.fields.reason,
|
||||
},
|
||||
})
|
||||
export type Failed = typeof Failed.Type
|
||||
}
|
||||
|
||||
export namespace RevertEvent {
|
||||
@@ -549,6 +561,7 @@ export const Definitions = Event.inventory(
|
||||
Compaction.Started,
|
||||
Compaction.Delta,
|
||||
Compaction.Ended,
|
||||
Compaction.Failed,
|
||||
RevertEvent.Staged,
|
||||
RevertEvent.Cleared,
|
||||
RevertEvent.Committed,
|
||||
|
||||
@@ -9,11 +9,11 @@ import { WorkspaceEvent } from "../src/workspace-event.js"
|
||||
|
||||
describe("public event manifest", () => {
|
||||
test("owns the complete public event surface", () => {
|
||||
expect(EventManifest.ServerDefinitions.filter((definition) => definition.type !== "agent.updated").length).toBe(63)
|
||||
expect(EventManifest.ServerDefinitions.filter((definition) => definition.type !== "agent.updated").length).toBe(66)
|
||||
expect(EventManifest.ServerDefinitions.filter((definition) => definition.type === "agent.updated")).toEqual([
|
||||
Agent.Event.Updated,
|
||||
])
|
||||
expect(EventManifest.Definitions.filter((definition) => definition.type !== "agent.updated").length).toBe(93)
|
||||
expect(EventManifest.Definitions.filter((definition) => definition.type !== "agent.updated").length).toBe(97)
|
||||
expect(EventManifest.Definitions.filter((definition) => definition.type === "agent.updated")).toEqual([
|
||||
Agent.Event.Updated,
|
||||
])
|
||||
@@ -29,7 +29,7 @@ describe("public event manifest", () => {
|
||||
SessionV1.Event.Diff,
|
||||
SessionV1.Event.Error,
|
||||
])
|
||||
expect(Array.from(EventManifest.Latest.keys()).filter((type) => type !== "agent.updated").length).toBe(93)
|
||||
expect(Array.from(EventManifest.Latest.keys()).filter((type) => type !== "agent.updated").length).toBe(97)
|
||||
expect(EventManifest.Latest.get("agent.updated")).toBe(Agent.Event.Updated)
|
||||
expect(Agent.Event.Updated.durable).toBeUndefined()
|
||||
expect(EventManifest.Durable.has("agent.updated")).toBe(false)
|
||||
|
||||
@@ -399,6 +399,8 @@ import type {
|
||||
V2SessionSwitchAgentResponses,
|
||||
V2SessionSwitchModelErrors,
|
||||
V2SessionSwitchModelResponses,
|
||||
V2SessionSyntheticErrors,
|
||||
V2SessionSyntheticResponses,
|
||||
V2SessionWaitErrors,
|
||||
V2SessionWaitResponses,
|
||||
V2ShellCreateErrors,
|
||||
@@ -5816,6 +5818,47 @@ export class Session3 extends HeyApiClient {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Add synthetic message
|
||||
*
|
||||
* Append a synthetic message to a session and resume execution.
|
||||
*/
|
||||
public synthetic<ThrowOnError extends boolean = false>(
|
||||
parameters: {
|
||||
sessionID: string
|
||||
text?: string
|
||||
description?: string
|
||||
metadata?: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams(
|
||||
[parameters],
|
||||
[
|
||||
{
|
||||
args: [
|
||||
{ in: "path", key: "sessionID" },
|
||||
{ in: "body", key: "text" },
|
||||
{ in: "body", key: "description" },
|
||||
{ in: "body", key: "metadata" },
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
return (options?.client ?? this.client).post<V2SessionSyntheticResponses, V2SessionSyntheticErrors, ThrowOnError>({
|
||||
url: "/api/session/{sessionID}/synthetic",
|
||||
...options,
|
||||
...params,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options?.headers,
|
||||
...params.headers,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact session
|
||||
*
|
||||
|
||||
@@ -49,6 +49,7 @@ export type Event =
|
||||
| EventSessionNextCompactionStarted
|
||||
| EventSessionNextCompactionDelta
|
||||
| EventSessionNextCompactionEnded
|
||||
| EventSessionNextCompactionFailed
|
||||
| EventSessionNextRevertStaged
|
||||
| EventSessionNextRevertCleared
|
||||
| EventSessionNextRevertCommitted
|
||||
@@ -942,6 +943,9 @@ export type GlobalEvent = {
|
||||
messageID: string
|
||||
text: string
|
||||
description?: string
|
||||
metadata?: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
| {
|
||||
@@ -1229,6 +1233,16 @@ export type GlobalEvent = {
|
||||
recent: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
id: string
|
||||
type: "session.next.compaction.failed"
|
||||
properties: {
|
||||
timestamp: number
|
||||
sessionID: string
|
||||
messageID: string
|
||||
reason: "auto" | "manual"
|
||||
}
|
||||
}
|
||||
| {
|
||||
id: string
|
||||
type: "session.next.revert.staged"
|
||||
@@ -3032,6 +3046,7 @@ export type V2Event =
|
||||
| SessionNextCompactionStarted
|
||||
| SessionNextCompactionDelta
|
||||
| SessionNextCompactionEnded
|
||||
| SessionNextCompactionFailed
|
||||
| SessionNextRevertStaged
|
||||
| SessionNextRevertCleared
|
||||
| SessionNextRevertCommitted
|
||||
@@ -3618,6 +3633,9 @@ export type SyncEventSessionNextSynthetic = {
|
||||
messageID: string
|
||||
text: string
|
||||
description?: string
|
||||
metadata?: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4583,6 +4601,9 @@ export type SessionNextSynthetic = {
|
||||
messageID: string
|
||||
text: string
|
||||
description?: string
|
||||
metadata?: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5664,6 +5685,26 @@ export type SessionNextCompactionDelta = {
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionNextCompactionFailed = {
|
||||
id: string
|
||||
metadata?: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
type: "session.next.compaction.failed"
|
||||
durable?: {
|
||||
aggregateID: string
|
||||
seq: number
|
||||
version: number
|
||||
}
|
||||
location?: LocationRef
|
||||
data: {
|
||||
timestamp: number
|
||||
sessionID: string
|
||||
messageID: string
|
||||
reason: "auto" | "manual"
|
||||
}
|
||||
}
|
||||
|
||||
export type MessagePartDelta = {
|
||||
id: string
|
||||
metadata?: {
|
||||
@@ -6806,6 +6847,9 @@ export type EventSessionNextSynthetic = {
|
||||
messageID: string
|
||||
text: string
|
||||
description?: string
|
||||
metadata?: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7117,6 +7161,17 @@ export type EventSessionNextCompactionEnded = {
|
||||
}
|
||||
}
|
||||
|
||||
export type EventSessionNextCompactionFailed = {
|
||||
id: string
|
||||
type: "session.next.compaction.failed"
|
||||
properties: {
|
||||
timestamp: number
|
||||
sessionID: string
|
||||
messageID: string
|
||||
reason: "auto" | "manual"
|
||||
}
|
||||
}
|
||||
|
||||
export type EventSessionNextRevertStaged = {
|
||||
id: string
|
||||
type: "session.next.revert.staged"
|
||||
@@ -12284,6 +12339,47 @@ export type V2SessionSkillResponses = {
|
||||
|
||||
export type V2SessionSkillResponse = V2SessionSkillResponses[keyof V2SessionSkillResponses]
|
||||
|
||||
export type V2SessionSyntheticData = {
|
||||
body: {
|
||||
text: string
|
||||
description?: string
|
||||
metadata?: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
path: {
|
||||
sessionID: string
|
||||
}
|
||||
query?: never
|
||||
url: "/api/session/{sessionID}/synthetic"
|
||||
}
|
||||
|
||||
export type V2SessionSyntheticErrors = {
|
||||
/**
|
||||
* InvalidRequestError
|
||||
*/
|
||||
400: InvalidRequestError
|
||||
/**
|
||||
* UnauthorizedError
|
||||
*/
|
||||
401: UnauthorizedError
|
||||
/**
|
||||
* SessionNotFoundError
|
||||
*/
|
||||
404: SessionNotFoundError
|
||||
}
|
||||
|
||||
export type V2SessionSyntheticError = V2SessionSyntheticErrors[keyof V2SessionSyntheticErrors]
|
||||
|
||||
export type V2SessionSyntheticResponses = {
|
||||
/**
|
||||
* <No Content>
|
||||
*/
|
||||
204: void
|
||||
}
|
||||
|
||||
export type V2SessionSyntheticResponse = V2SessionSyntheticResponses[keyof V2SessionSyntheticResponses]
|
||||
|
||||
export type V2SessionCompactData = {
|
||||
body?: never
|
||||
path: {
|
||||
|
||||
@@ -535,7 +535,14 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
break
|
||||
case "session.next.compaction.delta":
|
||||
break
|
||||
case "session.next.compaction.failed":
|
||||
// Started marked the session running; without this a failed compaction
|
||||
// leaves the session spinning forever. Mid-run auto compaction is
|
||||
// followed by step events that set the status again.
|
||||
setStore("session", "status", event.data.sessionID, "idle")
|
||||
break
|
||||
case "session.next.compaction.ended":
|
||||
setStore("session", "status", event.data.sessionID, "idle")
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.append(draft, index, {
|
||||
id: event.data.messageID,
|
||||
|
||||
@@ -248,6 +248,41 @@ test("tracks session status from active sessions and execution events", async ()
|
||||
},
|
||||
})
|
||||
await wait(() => data.session.status("session-failed") === "idle")
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_compaction_started",
|
||||
type: "session.next.compaction.started",
|
||||
data: { sessionID: "session-compacting", messageID: "message-compaction", timestamp: 5, reason: "manual" },
|
||||
})
|
||||
await wait(() => data.session.status("session-compacting") === "running")
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_compaction_ended",
|
||||
type: "session.next.compaction.ended",
|
||||
data: {
|
||||
sessionID: "session-compacting",
|
||||
messageID: "message-compaction",
|
||||
timestamp: 6,
|
||||
reason: "manual",
|
||||
text: "summary",
|
||||
recent: "",
|
||||
},
|
||||
})
|
||||
await wait(() => data.session.status("session-compacting") === "idle")
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_compaction_started_again",
|
||||
type: "session.next.compaction.started",
|
||||
data: { sessionID: "session-compacting", messageID: "message-compaction-2", timestamp: 7, reason: "manual" },
|
||||
})
|
||||
await wait(() => data.session.status("session-compacting") === "running")
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_compaction_failed",
|
||||
type: "session.next.compaction.failed",
|
||||
data: { sessionID: "session-compacting", messageID: "message-compaction-2", timestamp: 8, reason: "manual" },
|
||||
})
|
||||
await wait(() => data.session.status("session-compacting") === "idle")
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user