Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
13 changes: 13 additions & 0 deletions packages/client/src/generated/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
12 changes: 11 additions & 1 deletion packages/core/src/session/compaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/session/message-updater.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
77 changes: 75 additions & 2 deletions packages/core/test/session-compaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"),
})
Expand Down Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions packages/schema/src/session-event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -549,6 +561,7 @@ export const Definitions = Event.inventory(
Compaction.Started,
Compaction.Delta,
Compaction.Ended,
Compaction.Failed,
RevertEvent.Staged,
RevertEvent.Cleared,
RevertEvent.Committed,
Expand Down
6 changes: 3 additions & 3 deletions packages/schema/test/event-manifest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
])
Expand All @@ -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)
Expand Down
43 changes: 43 additions & 0 deletions packages/sdk/js/src/v2/gen/sdk.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,8 @@ import type {
V2SessionSwitchAgentResponses,
V2SessionSwitchModelErrors,
V2SessionSwitchModelResponses,
V2SessionSyntheticErrors,
V2SessionSyntheticResponses,
V2SessionWaitErrors,
V2SessionWaitResponses,
V2ShellCreateErrors,
Expand Down Expand Up @@ -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
*
Expand Down
Loading
Loading