diff --git a/packages/client/src/generated/types.ts b/packages/client/src/generated/types.ts index 1999704bcc..74e75390a3 100644 --- a/packages/client/src/generated/types.ts +++ b/packages/client/src/generated/types.ts @@ -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 } diff --git a/packages/core/src/session/compaction.ts b/packages/core/src/session/compaction.ts index 99c45cbb52..4bd35841d8 100644 --- a/packages/core/src/session/compaction.ts +++ b/packages/core/src/session/compaction.ts @@ -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, diff --git a/packages/core/src/session/message-updater.ts b/packages/core/src/session/message-updater.ts index e12c24162c..1272e0f2cf 100644 --- a/packages/core/src/session/message-updater.ts +++ b/packages/core/src/session/message-updater.ts @@ -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({ diff --git a/packages/core/test/session-compaction.test.ts b/packages/core/test/session-compaction.test.ts index 4a7bc426b7..1d1683ee7d 100644 --- a/packages/core/test/session-compaction.test.ts +++ b/packages/core/test/session-compaction.test.ts @@ -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 diff --git a/packages/schema/src/session-event.ts b/packages/schema/src/session-event.ts index 5013dddee9..ad4882694b 100644 --- a/packages/schema/src/session-event.ts +++ b/packages/schema/src/session-event.ts @@ -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, diff --git a/packages/schema/test/event-manifest.test.ts b/packages/schema/test/event-manifest.test.ts index 5243fb69e8..88fb646b59 100644 --- a/packages/schema/test/event-manifest.test.ts +++ b/packages/schema/test/event-manifest.test.ts @@ -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) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 145ae005b3..1a82fca91c 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -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( + parameters: { + sessionID: string + text?: string + description?: string + metadata?: { + [key: string]: unknown + } + }, + options?: Options, + ) { + 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({ + url: "/api/session/{sessionID}/synthetic", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + /** * Compact session * diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 55eac01f48..9b32479a22 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -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 = { + /** + * + */ + 204: void +} + +export type V2SessionSyntheticResponse = V2SessionSyntheticResponses[keyof V2SessionSyntheticResponses] + export type V2SessionCompactData = { body?: never path: { diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 88e3649e63..c1c58fb1a4 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -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, diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index ed4f407993..e7a2bd98db 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -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() }