From 6bae8c78be06facdf52d6365e8064aaa7000d423 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 26 Jun 2026 13:22:01 -0400 Subject: [PATCH 1/5] feat(api): add finite session history --- .../client/src/generated-effect/client.ts | 38 +- packages/client/src/generated/client.ts | 14 + packages/client/src/generated/types.ts | 586 +++++++++++ packages/client/test/effect.test.ts | 37 +- packages/client/test/promise.test.ts | 22 +- packages/core/src/event.ts | 88 +- packages/core/src/session.ts | 38 + packages/core/test/session-history.test.ts | 138 +++ .../test/server/httpapi-exercise/index.ts | 34 + packages/protocol/src/groups/session.ts | 29 +- packages/sdk/js/src/v2/gen/sdk.gen.ts | 36 + packages/sdk/js/src/v2/gen/types.gen.ts | 975 +++++++++++++++--- packages/server/src/handlers/session.ts | 27 + specs/v2/schema-changelog.md | 7 + specs/v2/session.md | 6 +- 15 files changed, 1899 insertions(+), 176 deletions(-) create mode 100644 packages/core/test/session-history.test.ts diff --git a/packages/client/src/generated-effect/client.ts b/packages/client/src/generated-effect/client.ts index 172eb4ca228e..71fa86719bf1 100644 --- a/packages/client/src/generated-effect/client.ts +++ b/packages/client/src/generated-effect/client.ts @@ -149,12 +149,25 @@ const Endpoint0_12 = (raw: RawClient["server.session"]) => (input: Endpoint0_12I Effect.map((value) => value.data), ) -type Endpoint0_13Request = Parameters[0] +type Endpoint0_13Request = Parameters[0] type Endpoint0_13Input = { readonly sessionID: Endpoint0_13Request["params"]["sessionID"] readonly after?: Endpoint0_13Request["query"]["after"] + readonly through?: Endpoint0_13Request["query"]["through"] + readonly limit?: Endpoint0_13Request["query"]["limit"] } const Endpoint0_13 = (raw: RawClient["server.session"]) => (input: Endpoint0_13Input) => + raw["session.history"]({ + params: { sessionID: input.sessionID }, + query: { after: input.after, through: input.through, limit: input.limit }, + }).pipe(Effect.mapError(mapClientError)) + +type Endpoint0_14Request = Parameters[0] +type Endpoint0_14Input = { + readonly sessionID: Endpoint0_14Request["params"]["sessionID"] + readonly after?: Endpoint0_14Request["query"]["after"] +} +const Endpoint0_14 = (raw: RawClient["server.session"]) => (input: Endpoint0_14Input) => Stream.unwrap( raw["session.events"]({ params: { sessionID: input.sessionID }, query: { after: input.after } }).pipe( Effect.mapError(mapClientError), @@ -162,17 +175,17 @@ const Endpoint0_13 = (raw: RawClient["server.session"]) => (input: Endpoint0_13I ), ) -type Endpoint0_14Request = Parameters[0] -type Endpoint0_14Input = { readonly sessionID: Endpoint0_14Request["params"]["sessionID"] } -const Endpoint0_14 = (raw: RawClient["server.session"]) => (input: Endpoint0_14Input) => +type Endpoint0_15Request = Parameters[0] +type Endpoint0_15Input = { readonly sessionID: Endpoint0_15Request["params"]["sessionID"] } +const Endpoint0_15 = (raw: RawClient["server.session"]) => (input: Endpoint0_15Input) => raw["session.interrupt"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError)) -type Endpoint0_15Request = Parameters[0] -type Endpoint0_15Input = { - readonly sessionID: Endpoint0_15Request["params"]["sessionID"] - readonly messageID: Endpoint0_15Request["params"]["messageID"] +type Endpoint0_16Request = Parameters[0] +type Endpoint0_16Input = { + readonly sessionID: Endpoint0_16Request["params"]["sessionID"] + readonly messageID: Endpoint0_16Request["params"]["messageID"] } -const Endpoint0_15 = (raw: RawClient["server.session"]) => (input: Endpoint0_15Input) => +const Endpoint0_16 = (raw: RawClient["server.session"]) => (input: Endpoint0_16Input) => raw["session.message"]({ params: { sessionID: input.sessionID, messageID: input.messageID } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), @@ -192,9 +205,10 @@ const adaptGroup0 = (raw: RawClient["server.session"]) => ({ clear: Endpoint0_10(raw), commit: Endpoint0_11(raw), context: Endpoint0_12(raw), - events: Endpoint0_13(raw), - interrupt: Endpoint0_14(raw), - message: Endpoint0_15(raw), + history: Endpoint0_13(raw), + events: Endpoint0_14(raw), + interrupt: Endpoint0_15(raw), + message: Endpoint0_16(raw), }) const adaptClient = (raw: RawClient) => ({ sessions: adaptGroup0(raw["server.session"]) }) diff --git a/packages/client/src/generated/client.ts b/packages/client/src/generated/client.ts index 2d3cd92ee07d..84bb6a4355ed 100644 --- a/packages/client/src/generated/client.ts +++ b/packages/client/src/generated/client.ts @@ -24,6 +24,8 @@ import type { SessionsCommitOutput, SessionsContextInput, SessionsContextOutput, + SessionsHistoryInput, + SessionsHistoryOutput, SessionsEventsInput, SessionsEventsOutput, SessionsInterruptInput, @@ -324,6 +326,18 @@ export function make(options: ClientOptions) { }, requestOptions, ).then((value) => value.data), + history: (input: SessionsHistoryInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/session/${encodeURIComponent(input.sessionID)}/history`, + query: { after: input.after, through: input.through, limit: input.limit }, + successStatus: 200, + declaredStatuses: [404, 400, 401], + empty: false, + }, + requestOptions, + ), events: (input: SessionsEventsInput, requestOptions?: RequestOptions): AsyncIterable => sse( { diff --git a/packages/client/src/generated/types.ts b/packages/client/src/generated/types.ts index bfd965ae35a6..e97fdfa5dc0b 100644 --- a/packages/client/src/generated/types.ts +++ b/packages/client/src/generated/types.ts @@ -591,6 +591,592 @@ export type SessionsContextOutput = { > }["data"] +export type SessionsHistoryInput = { + readonly sessionID: { readonly sessionID: string }["sessionID"] + readonly after?: { + readonly after?: string | undefined + readonly through?: string | undefined + readonly limit?: string | undefined + }["after"] + readonly through?: { + readonly after?: string | undefined + readonly through?: string | undefined + readonly limit?: string | undefined + }["through"] + readonly limit?: { + readonly after?: string | undefined + readonly through?: string | undefined + readonly limit?: string | undefined + }["limit"] +} + +export type SessionsHistoryOutput = { + readonly events: ReadonlyArray< + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.agent.switched" + readonly durable?: { + readonly aggregateID: string + readonly seq: number | "Infinity" | "-Infinity" | "NaN" + readonly version: number | "Infinity" | "-Infinity" | "NaN" + } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly agent: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.model.switched" + readonly durable?: { + readonly aggregateID: string + readonly seq: number | "Infinity" | "-Infinity" | "NaN" + readonly version: number | "Infinity" | "-Infinity" | "NaN" + } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.moved" + readonly durable?: { + readonly aggregateID: string + readonly seq: number | "Infinity" | "-Infinity" | "NaN" + readonly version: number | "Infinity" | "-Infinity" | "NaN" + } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly location: { readonly directory: string; readonly workspaceID?: string } + readonly subdirectory?: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.prompted" + readonly durable?: { + readonly aggregateID: string + readonly seq: number | "Infinity" | "-Infinity" | "NaN" + readonly version: number | "Infinity" | "-Infinity" | "NaN" + } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly prompt: { + readonly text: string + readonly files?: ReadonlyArray<{ + readonly uri: string + readonly mime: string + readonly name?: string + readonly description?: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly agents?: ReadonlyArray<{ + readonly name: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + } + readonly delivery: "steer" | "queue" + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.prompt.admitted" + readonly durable?: { + readonly aggregateID: string + readonly seq: number | "Infinity" | "-Infinity" | "NaN" + readonly version: number | "Infinity" | "-Infinity" | "NaN" + } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly prompt: { + readonly text: string + readonly files?: ReadonlyArray<{ + readonly uri: string + readonly mime: string + readonly name?: string + readonly description?: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly agents?: ReadonlyArray<{ + readonly name: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + } + readonly delivery: "steer" | "queue" + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.context.updated" + readonly durable?: { + readonly aggregateID: string + readonly seq: number | "Infinity" | "-Infinity" | "NaN" + readonly version: number | "Infinity" | "-Infinity" | "NaN" + } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly text: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.synthetic" + readonly durable?: { + readonly aggregateID: string + readonly seq: number | "Infinity" | "-Infinity" | "NaN" + readonly version: number | "Infinity" | "-Infinity" | "NaN" + } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly text: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.shell.started" + readonly durable?: { + readonly aggregateID: string + readonly seq: number | "Infinity" | "-Infinity" | "NaN" + readonly version: number | "Infinity" | "-Infinity" | "NaN" + } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly callID: string + readonly command: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.shell.ended" + readonly durable?: { + readonly aggregateID: string + readonly seq: number | "Infinity" | "-Infinity" | "NaN" + readonly version: number | "Infinity" | "-Infinity" | "NaN" + } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly callID: string + readonly output: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.step.started" + readonly durable?: { + readonly aggregateID: string + readonly seq: number | "Infinity" | "-Infinity" | "NaN" + readonly version: number | "Infinity" | "-Infinity" | "NaN" + } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly agent: string + readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } + readonly snapshot?: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.step.ended" + readonly durable?: { + readonly aggregateID: string + readonly seq: number | "Infinity" | "-Infinity" | "NaN" + readonly version: number | "Infinity" | "-Infinity" | "NaN" + } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly finish: string + readonly cost: number + readonly tokens: { + readonly input: number + readonly output: number + readonly reasoning: number + readonly cache: { readonly read: number; readonly write: number } + } + readonly snapshot?: string + readonly files?: ReadonlyArray + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.step.failed" + readonly durable?: { + readonly aggregateID: string + readonly seq: number | "Infinity" | "-Infinity" | "NaN" + readonly version: number | "Infinity" | "-Infinity" | "NaN" + } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly error: { readonly type: "unknown"; readonly message: string } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.text.started" + readonly durable?: { + readonly aggregateID: string + readonly seq: number | "Infinity" | "-Infinity" | "NaN" + readonly version: number | "Infinity" | "-Infinity" | "NaN" + } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly textID: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.text.ended" + readonly durable?: { + readonly aggregateID: string + readonly seq: number | "Infinity" | "-Infinity" | "NaN" + readonly version: number | "Infinity" | "-Infinity" | "NaN" + } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly textID: string + readonly text: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.tool.input.started" + readonly durable?: { + readonly aggregateID: string + readonly seq: number | "Infinity" | "-Infinity" | "NaN" + readonly version: number | "Infinity" | "-Infinity" | "NaN" + } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly callID: string + readonly name: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.tool.input.ended" + readonly durable?: { + readonly aggregateID: string + readonly seq: number | "Infinity" | "-Infinity" | "NaN" + readonly version: number | "Infinity" | "-Infinity" | "NaN" + } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly callID: string + readonly text: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.tool.called" + readonly durable?: { + readonly aggregateID: string + readonly seq: number | "Infinity" | "-Infinity" | "NaN" + readonly version: number | "Infinity" | "-Infinity" | "NaN" + } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly callID: string + readonly tool: string + readonly input: { readonly [x: string]: JsonValue } + readonly provider: { + readonly executed: boolean + readonly metadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } + } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.tool.progress" + readonly durable?: { + readonly aggregateID: string + readonly seq: number | "Infinity" | "-Infinity" | "NaN" + readonly version: number | "Infinity" | "-Infinity" | "NaN" + } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly callID: string + readonly structured: { readonly [x: string]: JsonValue } + readonly content: ReadonlyArray< + | { readonly type: "text"; readonly text: string } + | { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string } + > + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.tool.success" + readonly durable?: { + readonly aggregateID: string + readonly seq: number | "Infinity" | "-Infinity" | "NaN" + readonly version: number | "Infinity" | "-Infinity" | "NaN" + } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly callID: string + readonly structured: { readonly [x: string]: JsonValue } + readonly content: ReadonlyArray< + | { readonly type: "text"; readonly text: string } + | { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string } + > + readonly outputPaths?: ReadonlyArray + readonly result?: JsonValue + readonly provider: { + readonly executed: boolean + readonly metadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } + } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.tool.failed" + readonly durable?: { + readonly aggregateID: string + readonly seq: number | "Infinity" | "-Infinity" | "NaN" + readonly version: number | "Infinity" | "-Infinity" | "NaN" + } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly callID: string + readonly error: { readonly type: "unknown"; readonly message: string } + readonly result?: JsonValue + readonly provider: { + readonly executed: boolean + readonly metadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } + } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.reasoning.started" + readonly durable?: { + readonly aggregateID: string + readonly seq: number | "Infinity" | "-Infinity" | "NaN" + readonly version: number | "Infinity" | "-Infinity" | "NaN" + } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly reasoningID: string + readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.reasoning.ended" + readonly durable?: { + readonly aggregateID: string + readonly seq: number | "Infinity" | "-Infinity" | "NaN" + readonly version: number | "Infinity" | "-Infinity" | "NaN" + } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly reasoningID: string + readonly text: string + readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.retried" + readonly durable?: { + readonly aggregateID: string + readonly seq: number | "Infinity" | "-Infinity" | "NaN" + readonly version: number | "Infinity" | "-Infinity" | "NaN" + } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly attempt: number + readonly error: { + readonly message: string + readonly statusCode?: number + readonly isRetryable: boolean + readonly responseHeaders?: { readonly [x: string]: string } + readonly responseBody?: string + readonly metadata?: { readonly [x: string]: string } + } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.compaction.started" + readonly durable?: { + readonly aggregateID: string + readonly seq: number | "Infinity" | "-Infinity" | "NaN" + readonly version: number | "Infinity" | "-Infinity" | "NaN" + } + 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]: JsonValue } + readonly type: "session.next.compaction.ended" + readonly durable?: { + readonly aggregateID: string + readonly seq: number | "Infinity" | "-Infinity" | "NaN" + readonly version: number | "Infinity" | "-Infinity" | "NaN" + } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly reason: "auto" | "manual" + readonly text: string + readonly recent: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.revert.staged" + readonly durable?: { + readonly aggregateID: string + readonly seq: number | "Infinity" | "-Infinity" | "NaN" + readonly version: number | "Infinity" | "-Infinity" | "NaN" + } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly revert: { + readonly messageID: string + readonly partID?: string + readonly snapshot?: string + readonly diff?: string + readonly files?: ReadonlyArray<{ + readonly path: string + readonly status: "added" | "modified" | "deleted" + readonly additions: number + readonly deletions: number + readonly patch: string + }> + } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.revert.cleared" + readonly durable?: { + readonly aggregateID: string + readonly seq: number | "Infinity" | "-Infinity" | "NaN" + readonly version: number | "Infinity" | "-Infinity" | "NaN" + } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { readonly timestamp: number; readonly sessionID: string } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.revert.committed" + readonly durable?: { + readonly aggregateID: string + readonly seq: number | "Infinity" | "-Infinity" | "NaN" + readonly version: number | "Infinity" | "-Infinity" | "NaN" + } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { readonly timestamp: number; readonly sessionID: string; readonly messageID: string } + } + > + readonly through: number + readonly nextAfter?: number +} + export type SessionsEventsInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] readonly after?: { readonly after?: string | undefined }["after"] diff --git a/packages/client/test/effect.test.ts b/packages/client/test/effect.test.ts index 6ca73c22f46a..4108df89625d 100644 --- a/packages/client/test/effect.test.ts +++ b/packages/client/test/effect.test.ts @@ -28,6 +28,14 @@ test("session methods retain decoded Effect inputs and outputs", async () => { ), ) } + if (url.includes("/history")) { + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json({ events: [modelSwitchedEvent], through: 2, nextAfter: 1 }), + ), + ) + } if (url.includes("/prompt")) { return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(admission))) } @@ -72,6 +80,12 @@ test("session methods retain decoded Effect inputs and outputs", async () => { yield* client.sessions.compact({ sessionID: Session.ID.make("ses_test") }) yield* client.sessions.wait({ sessionID: Session.ID.make("ses_test") }) const context = yield* client.sessions.context({ sessionID: Session.ID.make("ses_test") }) + const history = yield* client.sessions.history({ + sessionID: Session.ID.make("ses_test"), + after: 0, + through: 2, + limit: 1, + }) const events = yield* client.sessions .events({ sessionID: Session.ID.make("ses_test"), after: 0 }) .pipe(Stream.runCollect) @@ -80,7 +94,7 @@ test("session methods retain decoded Effect inputs and outputs", async () => { sessionID: Session.ID.make("ses_test"), messageID: SessionMessage.ID.make("msg_model"), }) - return { page, active, created, admitted, context, events, message } + return { page, active, created, admitted, context, history, events, message } }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise) expect(DateTime.toEpochMillis(result.page.data[0].time.created)).toBe(1_717_171_717_000) @@ -92,10 +106,31 @@ test("session methods retain decoded Effect inputs and outputs", async () => { expect(Object.getPrototypeOf(result.admitted.prompt)).toBe(Object.prototype) expect(DateTime.toEpochMillis(result.admitted.timeCreated)).toBe(1_717_171_717_000) expect(result.context).toEqual([]) + expect(DateTime.toEpochMillis(result.history.events[0].data.timestamp)).toBe(1_717_171_717_000) + expect(result.history).toEqual(expect.objectContaining({ through: 2, nextAfter: 1 })) expect(DateTime.toEpochMillis(result.events[0].data.timestamp)).toBe(1_717_171_717_000) expect(result.message).toEqual(expect.objectContaining({ id: "msg_model", type: "model-switched" })) }) +test("sessions.history retains the typed InvalidCursorError", async () => { + const httpClient = HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json({ _tag: "InvalidCursorError", message: "Invalid cutoff" }, { status: 400 }), + ), + ), + ) + const error = await Effect.gen(function* () { + const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" }) + return yield* client.sessions + .history({ sessionID: Session.ID.make("ses_test"), through: 2 }) + .pipe(Effect.flip) + }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise) + + expect(error._tag).toBe("InvalidCursorError") +}) + const session = { data: { id: "ses_test", diff --git a/packages/client/test/promise.test.ts b/packages/client/test/promise.test.ts index 952a3fc0d78a..0467de98f0cf 100644 --- a/packages/client/test/promise.test.ts +++ b/packages/client/test/promise.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test" -import { isUnauthorizedError, OpenCode } from "../src" +import { isInvalidCursorError, isUnauthorizedError, OpenCode } from "../src" test("sessions.get returns the wire projection", async () => { const client = OpenCode.make({ @@ -29,6 +29,9 @@ test("session methods use the public HTTP contract", async () => { headers: { "content-type": "text/event-stream" }, }) } + if (url.includes("/history")) { + return Response.json({ events: [modelSwitchedEvent], through: 2, nextAfter: 1 }) + } if (url.includes("/prompt")) return Response.json(admission) if (url.includes("/context")) return Response.json({ data: [] }) if (url.includes("/message/")) return Response.json({ data: modelSwitchedMessage }) @@ -55,6 +58,7 @@ test("session methods use the public HTTP contract", async () => { await client.sessions.compact({ sessionID: "ses_test" }) await client.sessions.wait({ sessionID: "ses_test" }) const context = await client.sessions.context({ sessionID: "ses_test" }) + const history = await client.sessions.history({ sessionID: "ses_test", after: "0", through: "2", limit: "1" }) const events = [] for await (const event of client.sessions.events({ sessionID: "ses_test", after: "0" })) events.push(event) await client.sessions.interrupt({ sessionID: "ses_test" }) @@ -65,6 +69,7 @@ test("session methods use the public HTTP contract", async () => { expect(created.id).toBe("ses_test") expect(admitted.id).toBe("msg_test") expect(context).toEqual([]) + expect(history).toEqual({ events: [modelSwitchedEvent], through: 2, nextAfter: 1 }) expect(events).toEqual([modelSwitchedEvent]) expect(message).toEqual(modelSwitchedMessage) expect(requests.map((request) => [request.init?.method, request.url])).toEqual([ @@ -77,6 +82,7 @@ test("session methods use the public HTTP contract", async () => { ["POST", "http://localhost:3000/api/session/ses_test/compact"], ["POST", "http://localhost:3000/api/session/ses_test/wait"], ["GET", "http://localhost:3000/api/session/ses_test/context"], + ["GET", "http://localhost:3000/api/session/ses_test/history?after=0&through=2&limit=1"], ["GET", "http://localhost:3000/api/session/ses_test/event?after=0"], ["POST", "http://localhost:3000/api/session/ses_test/interrupt"], ["GET", "http://localhost:3000/api/session/ses_test/message/msg_model"], @@ -104,6 +110,20 @@ test("middleware errors remain declared client errors", async () => { } }) +test("sessions.history decodes InvalidCursorError", async () => { + const client = OpenCode.make({ + baseUrl: "http://localhost:3000", + fetch: async () => Response.json({ _tag: "InvalidCursorError", message: "Invalid cutoff" }, { status: 400 }), + }) + + try { + await client.sessions.history({ sessionID: "ses_test", through: "2" }) + throw new Error("Expected request to fail") + } catch (error) { + expect(isInvalidCursorError(error)).toBe(true) + } +}) + const session = { data: { id: "ses_test", diff --git a/packages/core/src/event.ts b/packages/core/src/event.ts index 5324c319b076..c5e0fb68bcd9 100644 --- a/packages/core/src/event.ts +++ b/packages/core/src/event.ts @@ -3,7 +3,7 @@ export * as EventV2 from "./event" import { Cause, Context, Effect, Layer, Option, PubSub, Schema, Stream } from "effect" import { Event } from "@opencode-ai/schema/event" import type { Data, Definition, Payload } from "@opencode-ai/schema/event" -import { and, asc, eq, gt } from "drizzle-orm" +import { and, asc, eq, gt, inArray, lte, type SQL } from "drizzle-orm" import { Database } from "./database/database" import { EventSequenceTable, EventTable } from "./event/sql" import { Location } from "./location" @@ -47,6 +47,79 @@ export class InvalidDurableEventError extends Schema.TaggedErrorClass()("EventV2.InvalidCursorError", { + message: Schema.String, +}) {} + +const decodeSerializedEvent = (event: SerializedEvent): Payload => { + const definition = Durable.get(event.type) + if (!definition?.durable) { + throw new InvalidDurableEventError({ type: event.type, message: `Unknown durable event type ${event.type}` }) + } + return { + id: event.id, + type: definition.type, + durable: { aggregateID: event.aggregateID, seq: event.seq, version: definition.durable.version }, + data: Schema.decodeUnknownSync(definition.data)(event.data), + } +} + +export const readAggregate = Effect.fn("EventV2.readAggregate")(function* ( + db: Database.Interface["db"], + input: { + readonly aggregateID: string + readonly after?: number + readonly through?: number + readonly limit: number + readonly types: ReadonlyArray + }, +) { + const after = input.after ?? -1 + if (input.through !== undefined && input.through < after) { + return yield* new InvalidCursorError({ message: "History cutoff must not be less than the cursor" }) + } + return yield* db + .transaction(() => + Effect.gen(function* () { + const head = yield* latestSequence(db, input.aggregateID) + if (input.through !== undefined && input.through > head) { + return yield* new InvalidCursorError({ message: "History cutoff is above the current aggregate head" }) + } + const through = input.through ?? head + const conditions: SQL[] = [ + eq(EventTable.aggregate_id, input.aggregateID), + gt(EventTable.seq, after), + lte(EventTable.seq, through), + inArray(EventTable.type, input.types), + ] + const rows = yield* db + .select() + .from(EventTable) + .where(and(...conditions)) + .orderBy(asc(EventTable.seq)) + .limit(input.limit + 1) + .all() + .pipe(Effect.orDie) + const page = rows.slice(0, input.limit) + const events = page.map((event) => + decodeSerializedEvent({ + id: event.id, + aggregateID: event.aggregate_id, + seq: event.seq, + type: event.type, + data: event.data, + }), + ) + return { + events, + through, + nextAfter: rows.length > input.limit ? events.at(-1)?.durable?.seq : undefined, + } + }), + ) + .pipe(Effect.catchTag("SqlError", Effect.die)) +}) + export const define = Event.define export const versionedType = Event.versionedType @@ -459,19 +532,6 @@ export const layerWith = (options?: LayerOptions) => const streamAll = (): Stream.Stream => Stream.fromPubSub(pubsub.all) - const decodeSerializedEvent = (event: SerializedEvent) => { - const definition = Durable.get(event.type) - if (!definition?.durable) { - throw new InvalidDurableEventError({ type: event.type, message: `Unknown durable event type ${event.type}` }) - } - return { - id: event.id, - type: definition.type, - durable: { aggregateID: event.aggregateID, seq: event.seq, version: definition.durable.version }, - data: Schema.decodeUnknownSync(definition.data)(event.data), - } - } - const readAfter = (aggregateID: string, after: number) => (options?.beforeAggregateRead?.(aggregateID) ?? Effect.void).pipe( Effect.andThen( diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 4ce97e133afe..0f59e07122aa 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -102,6 +102,9 @@ export class PromptConflictError extends Schema.TaggedErrorClass()("Session.InvalidCursorError", { + message: Schema.String, +}) {} export const MessageNotFoundError = SessionRevert.MessageNotFoundError export type MessageNotFoundError = SessionRevert.MessageNotFoundError @@ -131,6 +134,19 @@ export interface Interface { sessionID: SessionSchema.ID after?: number }) => Stream.Stream + readonly history: (input: { + sessionID: SessionSchema.ID + after?: number + through?: number + limit: number + }) => Effect.Effect< + { + events: ReadonlyArray + through: number + nextAfter?: number + }, + NotFoundError | InvalidCursorError + > readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: string }) => Effect.Effect readonly switchModel: (input: { sessionID: SessionSchema.ID @@ -347,6 +363,28 @@ export const layer = Layer.unwrap( .get(input.sessionID) .pipe(Effect.as(events.durable({ aggregateID: input.sessionID, after: input.after }))), ).pipe(Stream.filter((event): event is SessionEvent.DurableEvent => isDurableSessionEvent(event))), + history: Effect.fn("V2Session.history")(function* (input) { + yield* result.get(input.sessionID) + const page = yield* EventV2 + .readAggregate(db, { + ...input, + aggregateID: input.sessionID, + types: SessionEvent.DurableDefinitions.flatMap((definition) => + definition.durable + ? [EventV2.versionedType(definition.type, definition.durable.version)] + : [], + ), + }) + .pipe( + Effect.mapError((error) => new InvalidCursorError({ message: error.message })), + ) + return { + ...page, + events: page.events.filter( + (event): event is SessionEvent.DurableEvent => isDurableSessionEvent(event), + ), + } + }), prompt: Effect.fn("V2Session.prompt")((input) => Effect.uninterruptible( Effect.gen(function* () { diff --git a/packages/core/test/session-history.test.ts b/packages/core/test/session-history.test.ts new file mode 100644 index 000000000000..19a61f201a3c --- /dev/null +++ b/packages/core/test/session-history.test.ts @@ -0,0 +1,138 @@ +import { describe, expect } from "bun:test" +import { Effect, Layer, Schema } from "effect" +import { Database } from "@opencode-ai/core/database/database" +import { EventV2 } from "@opencode-ai/core/event" +import { Location } from "@opencode-ai/core/location" +import { LocationServiceMap } from "@opencode-ai/core/location-layer" +import { ProjectV2 } from "@opencode-ai/core/project" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { SessionV2 } from "@opencode-ai/core/session" +import { SessionExecution } from "@opencode-ai/core/session/execution" +import { SessionProjector } from "@opencode-ai/core/session/projector" +import { SessionStore } from "@opencode-ai/core/session/store" +import { testEffect } from "./lib/effect" + +const projects = Layer.succeed( + ProjectV2.Service, + ProjectV2.Service.of({ + resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }), + directories: () => Effect.succeed([]), + commit: () => Effect.void, + }), +) +const sessions = SessionV2.layer.pipe( + Layer.provide(LocationServiceMap.layer), + Layer.provide(EventV2.defaultLayer), + Layer.provide(Database.defaultLayer), + Layer.provide(SessionStore.defaultLayer), + Layer.provide(projects), + Layer.provide(SessionExecution.noopLayer), +) +const it = testEffect( + Layer.mergeAll( + Database.defaultLayer, + EventV2.defaultLayer, + projects, + SessionProjector.defaultLayer, + SessionStore.defaultLayer, + SessionExecution.noopLayer, + sessions, + ), +) +const location = Location.Ref.make({ directory: AbsolutePath.make("/project") }) + +const GapEvent = EventV2.define({ + type: "test.session.history.gap", + durable: { aggregate: "sessionID", version: 1 }, + schema: { sessionID: SessionV2.ID, value: Schema.String }, +}) + +describe("SessionV2.history", () => { + it.effect("returns a stable cutoff for a Session with no public history", () => + Effect.gen(function* () { + const session = yield* SessionV2.Service + const created = yield* session.create({ location }) + + const first = yield* session.history({ sessionID: created.id, limit: 10 }) + const second = yield* session.history({ sessionID: created.id, through: first.through, limit: 10 }) + + expect(first).toEqual({ events: [], through: 0, nextAfter: undefined }) + expect(second).toEqual(first) + }), + ) + + it.effect("paginates public events in aggregate order across filtered gaps without duplicates", () => + Effect.gen(function* () { + const session = yield* SessionV2.Service + const events = yield* EventV2.Service + const created = yield* session.create({ location }) + yield* session.switchAgent({ sessionID: created.id, agent: "one" }) + yield* events.publish(GapEvent, { sessionID: created.id, value: "filtered" }) + yield* session.switchAgent({ sessionID: created.id, agent: "two" }) + yield* session.switchAgent({ sessionID: created.id, agent: "three" }) + + const first = yield* session.history({ sessionID: created.id, limit: 2 }) + const second = yield* session.history({ + sessionID: created.id, + after: first.nextAfter, + through: first.through, + limit: 2, + }) + const sequence = [...first.events, ...second.events].map((event) => event.durable?.seq) + + expect(first.through).toBe(4) + expect(first.nextAfter).toBe(3) + expect(second.nextAfter).toBeUndefined() + expect(sequence).toEqual([1, 3, 4]) + expect(new Set(sequence).size).toBe(sequence.length) + }), + ) + + it.effect("keeps the first page cutoff when later events commit", () => + Effect.gen(function* () { + const session = yield* SessionV2.Service + const created = yield* session.create({ location }) + yield* session.switchAgent({ sessionID: created.id, agent: "one" }) + yield* session.switchAgent({ sessionID: created.id, agent: "two" }) + + const first = yield* session.history({ sessionID: created.id, limit: 1 }) + yield* session.switchAgent({ sessionID: created.id, agent: "later" }) + const second = yield* session.history({ + sessionID: created.id, + after: first.nextAfter, + through: first.through, + limit: 10, + }) + + expect(first.through).toBe(2) + expect([...first.events, ...second.events].map((event) => event.durable?.seq)).toEqual([1, 2]) + expect(second.nextAfter).toBeUndefined() + }), + ) + + it.effect("rejects invalid cursor combinations with the typed error", () => + Effect.gen(function* () { + const session = yield* SessionV2.Service + const created = yield* session.create({ location }) + + const reversed = yield* session + .history({ sessionID: created.id, after: 1, through: 0, limit: 10 }) + .pipe(Effect.flip) + const future = yield* session.history({ sessionID: created.id, through: 1, limit: 10 }).pipe(Effect.flip) + + expect(reversed._tag).toBe("Session.InvalidCursorError") + expect(future._tag).toBe("Session.InvalidCursorError") + }), + ) + + it.effect("fails with NotFoundError for a missing Session", () => + Effect.gen(function* () { + const session = yield* SessionV2.Service + const error = yield* session + .history({ sessionID: SessionV2.ID.make("ses_missing"), limit: 10 }) + .pipe(Effect.flip) + + expect(error._tag).toBe("Session.NotFoundError") + }), + ) +}) diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index d3c8981a64b1..d95ad4de61d8 100644 --- a/packages/opencode/test/server/httpapi-exercise/index.ts +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -1067,6 +1067,40 @@ const scenarios: Scenario[] = [ headers: ctx.headers(), })) .status(400, undefined, "none"), + http.protected + .get("/api/session/{sessionID}/history", "v2.session.history") + .seeded((ctx) => ctx.session({ title: "Session history" })) + .at((ctx) => ({ + path: `${route("/api/session/{sessionID}/history", { sessionID: ctx.state.id })}?${new URLSearchParams({ + after: "0", + limit: "2", + })}`, + headers: ctx.headers(), + })) + .json( + 200, + (body) => { + object(body) + array(body.events) + if (typeof body.through !== "number") throw new Error("Expected numeric history cutoff") + }, + "none", + ), + http.protected + .get("/api/session/{sessionID}/history", "v2.session.history.missing") + .at((ctx) => ({ + path: route("/api/session/{sessionID}/history", { sessionID: "ses_httpapi_missing" }), + headers: ctx.headers(), + })) + .json(404, object, "status"), + http.protected + .get("/api/session/{sessionID}/history", "v2.session.history.invalid") + .seeded((ctx) => ctx.session({ title: "Invalid history cutoff" })) + .at((ctx) => ({ + path: `${route("/api/session/{sessionID}/history", { sessionID: ctx.state.id })}?through=999999`, + headers: ctx.headers(), + })) + .json(400, object, "status"), http.protected .get("/api/session/{sessionID}/event", "v2.session.events.missing") .at((ctx) => ({ diff --git a/packages/protocol/src/groups/session.ts b/packages/protocol/src/groups/session.ts index 057e08d7c7ba..b4d600da5ec0 100644 --- a/packages/protocol/src/groups/session.ts +++ b/packages/protocol/src/groups/session.ts @@ -3,7 +3,7 @@ import { SessionInput } from "@opencode-ai/schema/session-input" import { PromptInput } from "@opencode-ai/schema/prompt-input" import { Session } from "@opencode-ai/schema/session" import { Project } from "@opencode-ai/schema/project" -import { AbsolutePath, NonNegativeInt, PositiveInt, RelativePath, statics } from "@opencode-ai/schema/schema" +import { AbsolutePath, NonNegativeInt, optional, PositiveInt, RelativePath, statics } from "@opencode-ai/schema/schema" import { Workspace } from "@opencode-ai/schema/workspace" import { Context, Encoding, Result, Schema, Struct } from "effect" import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" @@ -77,6 +77,8 @@ const SessionActive = Schema.Struct({ type: Schema.Literal("running"), }).annotate({ identifier: "SessionActive" }) +const SessionHistoryLimit = PositiveInt.check(Schema.isLessThanOrEqualTo(100)) + const SessionsQueryCursor = SessionsCursor.annotate({ description: "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response.", }) @@ -289,6 +291,31 @@ export const makeSessionGroup = (sessionLo }), ), ) + .add( + HttpApiEndpoint.get("session.history", "/api/session/:sessionID/history", { + params: { sessionID: Session.ID }, + query: { + after: Schema.NumberFromString.pipe(Schema.decodeTo(NonNegativeInt), Schema.optional), + through: Schema.NumberFromString.pipe(Schema.decodeTo(NonNegativeInt), Schema.optional), + limit: Schema.NumberFromString.pipe(Schema.decodeTo(SessionHistoryLimit), Schema.optional), + }, + success: Schema.Struct({ + events: Schema.Array(SessionEvent.Durable), + through: NonNegativeInt, + nextAfter: optional(NonNegativeInt), + }).annotate({ identifier: "SessionHistory" }), + error: [SessionNotFoundError, InvalidCursorError], + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.history", + summary: "Get session history", + description: + "Read one finite page of public durable Session events. Reuse through and pass nextAfter unchanged as the exclusive after cursor; an omitted nextAfter means the fixed snapshot is exhausted.", + }), + ), + ) .add( HttpApiEndpoint.get("session.events", "/api/session/:sessionID/event", { params: { sessionID: Session.ID }, diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 73fb66227161..6a63d404d2d7 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -345,6 +345,8 @@ import type { V2SessionEventsResponses, V2SessionGetErrors, V2SessionGetResponses, + V2SessionHistoryErrors, + V2SessionHistoryResponses, V2SessionInterruptErrors, V2SessionInterruptResponses, V2SessionListErrors, @@ -5710,6 +5712,40 @@ export class Session3 extends HeyApiClient { }) } + /** + * Get session history + * + * Read one finite page of public durable Session events. Reuse through and pass nextAfter unchanged as the exclusive after cursor; an omitted nextAfter means the fixed snapshot is exhausted. + */ + public history( + parameters: { + sessionID: string + after?: string + through?: string + limit?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "after" }, + { in: "query", key: "through" }, + { in: "query", key: "limit" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/api/session/{sessionID}/history", + ...options, + ...params, + }) + } + /** * Subscribe to session events * diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 98335fcab095..c5e1a97fb137 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -2731,6 +2731,41 @@ export type UnknownError1 = { ref?: string } +export type SessionHistory = { + events: Array< + | SessionNextAgentSwitched + | SessionNextModelSwitched + | SessionNextMoved + | SessionNextPrompted + | SessionNextPromptAdmitted + | SessionNextContextUpdated + | SessionNextSynthetic + | SessionNextShellStarted + | SessionNextShellEnded + | SessionNextStepStarted + | SessionNextStepEnded + | SessionNextStepFailed + | SessionNextTextStarted + | SessionNextTextEnded + | SessionNextToolInputStarted + | SessionNextToolInputEnded + | SessionNextToolCalled + | SessionNextToolProgress + | SessionNextToolSuccess + | SessionNextToolFailed + | SessionNextReasoningStarted + | SessionNextReasoningEnded + | SessionNextRetried + | SessionNextCompactionStarted + | SessionNextCompactionEnded + | SessionNextRevertStaged + | SessionNextRevertCleared + | SessionNextRevertCommitted + > + through: number + nextAfter?: number +} + export type SessionMessagesResponse = { data: Array cursor: { @@ -3883,173 +3918,782 @@ export type SessionMessageSynthetic = { time: { created: number } - sessionID: string - text: string - type: "synthetic" + sessionID: string + text: string + type: "synthetic" +} + +export type SessionMessageSystem = { + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + } + type: "system" + text: string +} + +export type SessionMessageShell = { + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + completed?: number + } + type: "shell" + callID: string + command: string + output: string +} + +export type SessionMessageAssistantText = { + type: "text" + id: string + text: string +} + +export type SessionMessageAssistantReasoning = { + type: "reasoning" + id: string + text: string + providerMetadata?: LlmProviderMetadata +} + +export type SessionMessageToolStatePending = { + status: "pending" + input: string +} + +export type SessionMessageToolStateRunning = { + status: "running" + input: { + [key: string]: unknown + } + structured: { + [key: string]: unknown + } + content: Array +} + +export type SessionMessageToolStateCompleted = { + status: "completed" + input: { + [key: string]: unknown + } + attachments?: Array + content: Array + outputPaths?: Array + structured: { + [key: string]: unknown + } + result?: unknown +} + +export type SessionMessageToolStateError = { + status: "error" + input: { + [key: string]: unknown + } + content: Array + structured: { + [key: string]: unknown + } + error: SessionErrorUnknown + result?: unknown +} + +export type SessionMessageAssistantTool = { + type: "tool" + id: string + name: string + provider?: { + executed: boolean + metadata?: LlmProviderMetadata + resultMetadata?: LlmProviderMetadata + } + state: + | SessionMessageToolStatePending + | SessionMessageToolStateRunning + | SessionMessageToolStateCompleted + | SessionMessageToolStateError + time: { + created: number + ran?: number + completed?: number + pruned?: number + } +} + +export type SessionMessageAssistant = { + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + completed?: number + } + type: "assistant" + agent: string + model: ModelRef + content: Array + snapshot?: { + start?: string + end?: string + files?: Array + } + finish?: string + cost?: number + tokens?: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } + error?: SessionErrorUnknown +} + +export type SessionMessageCompaction = { + type: "compaction" + reason: "auto" | "manual" + summary: string + recent: string + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + } +} + +export type SessionMessage = + | SessionMessageAgentSwitched + | SessionMessageModelSwitched + | SessionMessageUser + | SessionMessageSynthetic + | SessionMessageSystem + | SessionMessageShell + | SessionMessageAssistant + | SessionMessageCompaction + +export type SessionNextAgentSwitched = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.agent.switched" + durable?: { + aggregateID: string + seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + messageID: string + agent: string + } +} + +export type SessionNextModelSwitched = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.model.switched" + durable?: { + aggregateID: string + seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + messageID: string + model: ModelRef + } +} + +export type SessionNextMoved = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.moved" + durable?: { + aggregateID: string + seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + location: LocationRef + subdirectory?: string + } +} + +export type SessionNextPrompted = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.prompted" + durable?: { + aggregateID: string + seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + messageID: string + prompt: Prompt + delivery: "steer" | "queue" + } +} + +export type SessionNextPromptAdmitted = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.prompt.admitted" + durable?: { + aggregateID: string + seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + messageID: string + prompt: Prompt + delivery: "steer" | "queue" + } +} + +export type SessionNextContextUpdated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.context.updated" + durable?: { + aggregateID: string + seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + messageID: string + text: string + } +} + +export type SessionNextSynthetic = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.synthetic" + durable?: { + aggregateID: string + seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + messageID: string + text: string + } +} + +export type SessionNextShellStarted = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.shell.started" + durable?: { + aggregateID: string + seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + messageID: string + callID: string + command: string + } +} + +export type SessionNextShellEnded = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.shell.ended" + durable?: { + aggregateID: string + seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + callID: string + output: string + } +} + +export type SessionNextStepStarted = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.step.started" + durable?: { + aggregateID: string + seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + agent: string + model: ModelRef + snapshot?: string + } +} + +export type SessionNextStepEnded = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.step.ended" + durable?: { + aggregateID: string + seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + finish: string + cost: number + tokens: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } + snapshot?: string + files?: Array + } +} + +export type SessionNextStepFailed = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.step.failed" + durable?: { + aggregateID: string + seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + error: SessionErrorUnknown + } +} + +export type SessionNextTextStarted = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.text.started" + durable?: { + aggregateID: string + seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + textID: string + } +} + +export type SessionNextTextEnded = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.text.ended" + durable?: { + aggregateID: string + seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + textID: string + text: string + } +} + +export type SessionNextToolInputStarted = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.tool.input.started" + durable?: { + aggregateID: string + seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + callID: string + name: string + } +} + +export type SessionNextToolInputEnded = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.tool.input.ended" + durable?: { + aggregateID: string + seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + callID: string + text: string + } +} + +export type SessionNextToolCalled = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.tool.called" + durable?: { + aggregateID: string + seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + callID: string + tool: string + input: { + [key: string]: unknown + } + provider: { + executed: boolean + metadata?: LlmProviderMetadata + } + } +} + +export type SessionNextToolProgress = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.tool.progress" + durable?: { + aggregateID: string + seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + callID: string + structured: { + [key: string]: unknown + } + content: Array + } +} + +export type SessionNextToolSuccess = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.tool.success" + durable?: { + aggregateID: string + seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + callID: string + structured: { + [key: string]: unknown + } + content: Array + outputPaths?: Array + result?: unknown + provider: { + executed: boolean + metadata?: LlmProviderMetadata + } + } +} + +export type SessionNextToolFailed = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.tool.failed" + durable?: { + aggregateID: string + seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + callID: string + error: SessionErrorUnknown + result?: unknown + provider: { + executed: boolean + metadata?: LlmProviderMetadata + } + } +} + +export type SessionNextReasoningStarted = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.reasoning.started" + durable?: { + aggregateID: string + seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + reasoningID: string + providerMetadata?: LlmProviderMetadata + } } -export type SessionMessageSystem = { +export type SessionNextReasoningEnded = { id: string metadata?: { [key: string]: unknown } - time: { - created: number + type: "session.next.reasoning.ended" + durable?: { + aggregateID: string + seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + assistantMessageID: string + reasoningID: string + text: string + providerMetadata?: LlmProviderMetadata } - type: "system" - text: string } -export type SessionMessageShell = { +export type SessionNextRetried = { id: string metadata?: { [key: string]: unknown } - time: { - created: number - completed?: number + type: "session.next.retried" + durable?: { + aggregateID: string + seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + attempt: number + error: SessionNextRetryError } - type: "shell" - callID: string - command: string - output: string -} - -export type SessionMessageAssistantText = { - type: "text" - id: string - text: string } -export type SessionMessageAssistantReasoning = { - type: "reasoning" +export type SessionNextCompactionStarted = { id: string - text: string - providerMetadata?: LlmProviderMetadata -} - -export type SessionMessageToolStatePending = { - status: "pending" - input: string -} - -export type SessionMessageToolStateRunning = { - status: "running" - input: { - [key: string]: unknown - } - structured: { + metadata?: { [key: string]: unknown } - content: Array -} - -export type SessionMessageToolStateCompleted = { - status: "completed" - input: { - [key: string]: unknown + type: "session.next.compaction.started" + durable?: { + aggregateID: string + seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" } - attachments?: Array - content: Array - outputPaths?: Array - structured: { - [key: string]: unknown + location?: LocationRef + data: { + timestamp: number + sessionID: string + messageID: string + reason: "auto" | "manual" } - result?: unknown } -export type SessionMessageToolStateError = { - status: "error" - input: { +export type SessionNextCompactionEnded = { + id: string + metadata?: { [key: string]: unknown } - content: Array - structured: { - [key: string]: unknown + type: "session.next.compaction.ended" + durable?: { + aggregateID: string + seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + messageID: string + reason: "auto" | "manual" + text: string + recent: string } - error: SessionErrorUnknown - result?: unknown } -export type SessionMessageAssistantTool = { - type: "tool" +export type SessionNextRevertStaged = { id: string - name: string - provider?: { - executed: boolean - metadata?: LlmProviderMetadata - resultMetadata?: LlmProviderMetadata + metadata?: { + [key: string]: unknown } - state: - | SessionMessageToolStatePending - | SessionMessageToolStateRunning - | SessionMessageToolStateCompleted - | SessionMessageToolStateError - time: { - created: number - ran?: number - completed?: number - pruned?: number + type: "session.next.revert.staged" + durable?: { + aggregateID: string + seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + revert: RevertState } } -export type SessionMessageAssistant = { +export type SessionNextRevertCleared = { id: string metadata?: { [key: string]: unknown } - time: { - created: number - completed?: number - } - type: "assistant" - agent: string - model: ModelRef - content: Array - snapshot?: { - start?: string - end?: string - files?: Array + type: "session.next.revert.cleared" + durable?: { + aggregateID: string + seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" } - finish?: string - cost?: number - tokens?: { - input: number - output: number - reasoning: number - cache: { - read: number - write: number - } + location?: LocationRef + data: { + timestamp: number + sessionID: string } - error?: SessionErrorUnknown } -export type SessionMessageCompaction = { - type: "compaction" - reason: "auto" | "manual" - summary: string - recent: string +export type SessionNextRevertCommitted = { id: string metadata?: { [key: string]: unknown } - time: { - created: number + type: "session.next.revert.committed" + durable?: { + aggregateID: string + seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + messageID: string } } -export type SessionMessage = - | SessionMessageAgentSwitched - | SessionMessageModelSwitched - | SessionMessageUser - | SessionMessageSynthetic - | SessionMessageSystem - | SessionMessageShell - | SessionMessageAssistant - | SessionMessageCompaction - -export type SessionNextAgentSwitched = { +export type SessionNextAgentSwitched1 = { id: string metadata?: { [key: string]: unknown @@ -4069,7 +4713,7 @@ export type SessionNextAgentSwitched = { } } -export type SessionNextModelSwitched = { +export type SessionNextModelSwitched1 = { id: string metadata?: { [key: string]: unknown @@ -4089,7 +4733,7 @@ export type SessionNextModelSwitched = { } } -export type SessionNextMoved = { +export type SessionNextMoved1 = { id: string metadata?: { [key: string]: unknown @@ -4109,7 +4753,7 @@ export type SessionNextMoved = { } } -export type SessionNextPrompted = { +export type SessionNextPrompted1 = { id: string metadata?: { [key: string]: unknown @@ -4130,7 +4774,7 @@ export type SessionNextPrompted = { } } -export type SessionNextPromptAdmitted = { +export type SessionNextPromptAdmitted1 = { id: string metadata?: { [key: string]: unknown @@ -4151,7 +4795,7 @@ export type SessionNextPromptAdmitted = { } } -export type SessionNextContextUpdated = { +export type SessionNextContextUpdated1 = { id: string metadata?: { [key: string]: unknown @@ -4171,7 +4815,7 @@ export type SessionNextContextUpdated = { } } -export type SessionNextSynthetic = { +export type SessionNextSynthetic1 = { id: string metadata?: { [key: string]: unknown @@ -4191,7 +4835,7 @@ export type SessionNextSynthetic = { } } -export type SessionNextShellStarted = { +export type SessionNextShellStarted1 = { id: string metadata?: { [key: string]: unknown @@ -4212,7 +4856,7 @@ export type SessionNextShellStarted = { } } -export type SessionNextShellEnded = { +export type SessionNextShellEnded1 = { id: string metadata?: { [key: string]: unknown @@ -4232,7 +4876,7 @@ export type SessionNextShellEnded = { } } -export type SessionNextStepStarted = { +export type SessionNextStepStarted1 = { id: string metadata?: { [key: string]: unknown @@ -4254,7 +4898,7 @@ export type SessionNextStepStarted = { } } -export type SessionNextStepEnded = { +export type SessionNextStepEnded1 = { id: string metadata?: { [key: string]: unknown @@ -4286,7 +4930,7 @@ export type SessionNextStepEnded = { } } -export type SessionNextStepFailed = { +export type SessionNextStepFailed1 = { id: string metadata?: { [key: string]: unknown @@ -4306,7 +4950,7 @@ export type SessionNextStepFailed = { } } -export type SessionNextTextStarted = { +export type SessionNextTextStarted1 = { id: string metadata?: { [key: string]: unknown @@ -4326,7 +4970,7 @@ export type SessionNextTextStarted = { } } -export type SessionNextTextEnded = { +export type SessionNextTextEnded1 = { id: string metadata?: { [key: string]: unknown @@ -4347,7 +4991,7 @@ export type SessionNextTextEnded = { } } -export type SessionNextToolInputStarted = { +export type SessionNextToolInputStarted1 = { id: string metadata?: { [key: string]: unknown @@ -4368,7 +5012,7 @@ export type SessionNextToolInputStarted = { } } -export type SessionNextToolInputEnded = { +export type SessionNextToolInputEnded1 = { id: string metadata?: { [key: string]: unknown @@ -4389,7 +5033,7 @@ export type SessionNextToolInputEnded = { } } -export type SessionNextToolCalled = { +export type SessionNextToolCalled1 = { id: string metadata?: { [key: string]: unknown @@ -4417,7 +5061,7 @@ export type SessionNextToolCalled = { } } -export type SessionNextToolProgress = { +export type SessionNextToolProgress1 = { id: string metadata?: { [key: string]: unknown @@ -4441,7 +5085,7 @@ export type SessionNextToolProgress = { } } -export type SessionNextToolSuccess = { +export type SessionNextToolSuccess1 = { id: string metadata?: { [key: string]: unknown @@ -4471,7 +5115,7 @@ export type SessionNextToolSuccess = { } } -export type SessionNextToolFailed = { +export type SessionNextToolFailed1 = { id: string metadata?: { [key: string]: unknown @@ -4497,7 +5141,7 @@ export type SessionNextToolFailed = { } } -export type SessionNextReasoningStarted = { +export type SessionNextReasoningStarted1 = { id: string metadata?: { [key: string]: unknown @@ -4518,7 +5162,7 @@ export type SessionNextReasoningStarted = { } } -export type SessionNextReasoningEnded = { +export type SessionNextReasoningEnded1 = { id: string metadata?: { [key: string]: unknown @@ -4540,7 +5184,7 @@ export type SessionNextReasoningEnded = { } } -export type SessionNextRetried = { +export type SessionNextRetried1 = { id: string metadata?: { [key: string]: unknown @@ -4560,7 +5204,7 @@ export type SessionNextRetried = { } } -export type SessionNextCompactionStarted = { +export type SessionNextCompactionStarted1 = { id: string metadata?: { [key: string]: unknown @@ -4580,7 +5224,7 @@ export type SessionNextCompactionStarted = { } } -export type SessionNextCompactionEnded = { +export type SessionNextCompactionEnded1 = { id: string metadata?: { [key: string]: unknown @@ -4602,7 +5246,7 @@ export type SessionNextCompactionEnded = { } } -export type SessionNextRevertStaged = { +export type SessionNextRevertStaged1 = { id: string metadata?: { [key: string]: unknown @@ -4621,7 +5265,7 @@ export type SessionNextRevertStaged = { } } -export type SessionNextRevertCleared = { +export type SessionNextRevertCleared1 = { id: string metadata?: { [key: string]: unknown @@ -4639,7 +5283,7 @@ export type SessionNextRevertCleared = { } } -export type SessionNextRevertCommitted = { +export type SessionNextRevertCommitted1 = { id: string metadata?: { [key: string]: unknown @@ -12384,6 +13028,45 @@ export type V2SessionContextResponses = { export type V2SessionContextResponse = V2SessionContextResponses[keyof V2SessionContextResponses] +export type V2SessionHistoryData = { + body?: never + path: { + sessionID: string + } + query?: { + after?: string + through?: string + limit?: string + } + url: "/api/session/{sessionID}/history" +} + +export type V2SessionHistoryErrors = { + /** + * InvalidCursorError | InvalidRequestError + */ + 400: InvalidCursorError | InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError + */ + 404: SessionNotFoundError +} + +export type V2SessionHistoryError = V2SessionHistoryErrors[keyof V2SessionHistoryErrors] + +export type V2SessionHistoryResponses = { + /** + * SessionHistory + */ + 200: SessionHistory +} + +export type V2SessionHistoryResponse = V2SessionHistoryResponses[keyof V2SessionHistoryResponses] + export type V2SessionEventsData = { body?: never path: { diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts index 602673e1ff77..e2adde484bc8 100644 --- a/packages/server/src/handlers/session.ts +++ b/packages/server/src/handlers/session.ts @@ -14,6 +14,7 @@ import { import { AbsolutePath } from "@opencode-ai/core/schema" const DefaultSessionsLimit = 50 +const DefaultSessionHistoryLimit = 50 export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handlers) => Effect.gen(function* () { @@ -328,6 +329,32 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl } }), ) + .handle( + "session.history", + Effect.fn(function* (ctx) { + return yield* session + .history({ + sessionID: ctx.params.sessionID, + after: ctx.query.after, + through: ctx.query.through, + limit: ctx.query.limit ?? DefaultSessionHistoryLimit, + }) + .pipe( + Effect.catchTag( + "Session.NotFoundError", + (error) => + new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }), + ), + Effect.catchTag( + "Session.InvalidCursorError", + (error) => new InvalidCursorError({ message: error.message }), + ), + ) + }), + ) .handle( "session.events", Effect.fn((ctx) => diff --git a/specs/v2/schema-changelog.md b/specs/v2/schema-changelog.md index 9f0cc83856a7..fd75a0be0c95 100644 --- a/specs/v2/schema-changelog.md +++ b/specs/v2/schema-changelog.md @@ -1,5 +1,12 @@ # V2 Schema Changelog +## 2026-06-26: Add Finite Session History + +- Add `GET /api/session/:sessionID/history` and generated Promise, Effect, and legacy JavaScript client methods. +- Page only public durable Session events with an exclusive aggregate `after`, a fixed aggregate-head `through`, and an explicit `nextAfter` continuation value. +- Keep aggregate gaps legal, cap pages at 100 events, and preserve the existing durable replay-and-tail `sessions.events()` stream unchanged. +- Add no migration or durable-event version; this is a finite read API over the existing event manifest. + ## 2026-06-22: Simplify Session Input Promotion - Keep `session.next.prompt.admitted.1` as the durable, client-visible record of pending Session input. diff --git a/specs/v2/session.md b/specs/v2/session.md index 54daa6971373..b4548b1dc429 100644 --- a/specs/v2/session.md +++ b/specs/v2/session.md @@ -176,6 +176,10 @@ The synchronized `session.next.*` event family and projected Session-message mod The first `sessions.events(...)` contract is durable-only during both replay and live tailing. This keeps one cursor equal to one persisted aggregate sequence and is sufficient for reconnect-safe consumers. A later UI-facing API may optionally interleave live-only deltas while connected, but those fragments must remain explicitly ephemeral: they cannot advance the durable cursor, replay after reconnect, or be mistaken for publication boundaries. +`sessions.history({ sessionID, after?, through?, limit? })` is the finite counterpart for request/response consumers. `after` is an exclusive aggregate sequence and omission starts before sequence zero. The first page captures the current aggregate head as `through`; every later page must reuse that value, so commits above the cutoff cannot enter the chain. Public durable Session events are selected before pagination, which permits gaps from private or historical aggregate events while preserving strictly increasing unique sequences. A present `nextAfter` is passed unchanged as the next exclusive `after`; an omitted `nextAfter` means that fixed snapshot is exhausted. + +The finite endpoint is `GET /api/session/:sessionID/history`, uses the normal Session Location and authorization middleware, defaults to 50 events, and accepts at most 100. It returns only events in the public durable Session schema. The existing `sessions.events()` replay-and-tail stream is unchanged. + Durable event tail wakeups are advisory and edge-triggered. Each active tail owns one sliding-capacity-1 dirty signal for its aggregate and re-queries SQLite after a wake. Repeated commits coalesce while the tail is busy because durable rows, not in-memory notifications, preserve every event and sequence. Subscribe and register the dirty signal before historical replay, then remove it when the tail closes, so replay handoff cannot miss a commit and inactive aggregates retain no wake state. Event replay owner claims are separate from clustered Session execution ownership. The former already fences synchronized projection reconstruction; the latter still needs distributed active-run acquisition, stale-runtime rejection, interruption, and placement orchestration. @@ -206,7 +210,7 @@ The first V2 `apply_patch` leaf supports add, update, and delete hunks. It parse - Keep eager structured local-tool settlement: durably record each complete call, start its child execution immediately, await all started settlements after provider-turn consumption, persist every result, and reload history once before continuation. - Buffer or coalesce streamed deltas before rewriting growing assistant projections. - Revisit additional covering indexes as larger-history query shapes become concrete. -- Expose replayable Session events over HTTP and the generated SDK where remote consumers need them, deciding whether that public cursor should be opaque rather than the embedded API's branded aggregate sequence. +- Design any global multi-Session event stream separately; the finite history API deliberately reads one authorized Session aggregate and does not change global Event publication. - Decide whether UI-facing Session subscriptions should optionally interleave ephemeral deltas while connected without advancing the durable cursor. - Add provider-aware context control for provider-executed tool results. Generic text truncation cannot replace provider-native structured payloads that must round-trip exactly. From 5be0d36e4110345eceb5147b8ddc01ec03ba1768 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 26 Jun 2026 13:23:12 -0400 Subject: [PATCH 2/5] fix(core): validate session history cursor at snapshot --- packages/core/src/event.ts | 23 +++++++++++----------- packages/core/src/session.ts | 9 ++++----- packages/core/test/session-history.test.ts | 2 ++ 3 files changed, 18 insertions(+), 16 deletions(-) diff --git a/packages/core/src/event.ts b/packages/core/src/event.ts index c5e0fb68bcd9..78284b29d3ba 100644 --- a/packages/core/src/event.ts +++ b/packages/core/src/event.ts @@ -3,7 +3,7 @@ export * as EventV2 from "./event" import { Cause, Context, Effect, Layer, Option, PubSub, Schema, Stream } from "effect" import { Event } from "@opencode-ai/schema/event" import type { Data, Definition, Payload } from "@opencode-ai/schema/event" -import { and, asc, eq, gt, inArray, lte, type SQL } from "drizzle-orm" +import { and, asc, eq, gt, inArray, lte } from "drizzle-orm" import { Database } from "./database/database" import { EventSequenceTable, EventTable } from "./event/sql" import { Location } from "./location" @@ -75,9 +75,6 @@ export const readAggregate = Effect.fn("EventV2.readAggregate")(function* ( }, ) { const after = input.after ?? -1 - if (input.through !== undefined && input.through < after) { - return yield* new InvalidCursorError({ message: "History cutoff must not be less than the cursor" }) - } return yield* db .transaction(() => Effect.gen(function* () { @@ -86,16 +83,20 @@ export const readAggregate = Effect.fn("EventV2.readAggregate")(function* ( return yield* new InvalidCursorError({ message: "History cutoff is above the current aggregate head" }) } const through = input.through ?? head - const conditions: SQL[] = [ - eq(EventTable.aggregate_id, input.aggregateID), - gt(EventTable.seq, after), - lte(EventTable.seq, through), - inArray(EventTable.type, input.types), - ] + if (through < after) { + return yield* new InvalidCursorError({ message: "History cutoff must not be less than the cursor" }) + } const rows = yield* db .select() .from(EventTable) - .where(and(...conditions)) + .where( + and( + eq(EventTable.aggregate_id, input.aggregateID), + gt(EventTable.seq, after), + lte(EventTable.seq, through), + inArray(EventTable.type, input.types), + ), + ) .orderBy(asc(EventTable.seq)) .limit(input.limit + 1) .all() diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 0f59e07122aa..d9e9955bba1f 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -105,6 +105,9 @@ export class PromptConflictError extends Schema.TaggedErrorClass()("Session.InvalidCursorError", { message: Schema.String, }) {} +const DurableEventTypes = SessionEvent.DurableDefinitions.flatMap((definition) => + definition.durable ? [EventV2.versionedType(definition.type, definition.durable.version)] : [], +) export const MessageNotFoundError = SessionRevert.MessageNotFoundError export type MessageNotFoundError = SessionRevert.MessageNotFoundError @@ -369,11 +372,7 @@ export const layer = Layer.unwrap( .readAggregate(db, { ...input, aggregateID: input.sessionID, - types: SessionEvent.DurableDefinitions.flatMap((definition) => - definition.durable - ? [EventV2.versionedType(definition.type, definition.durable.version)] - : [], - ), + types: DurableEventTypes, }) .pipe( Effect.mapError((error) => new InvalidCursorError({ message: error.message })), diff --git a/packages/core/test/session-history.test.ts b/packages/core/test/session-history.test.ts index 19a61f201a3c..bc965a863ed5 100644 --- a/packages/core/test/session-history.test.ts +++ b/packages/core/test/session-history.test.ts @@ -119,9 +119,11 @@ describe("SessionV2.history", () => { .history({ sessionID: created.id, after: 1, through: 0, limit: 10 }) .pipe(Effect.flip) const future = yield* session.history({ sessionID: created.id, through: 1, limit: 10 }).pipe(Effect.flip) + const afterHead = yield* session.history({ sessionID: created.id, after: 1, limit: 10 }).pipe(Effect.flip) expect(reversed._tag).toBe("Session.InvalidCursorError") expect(future._tag).toBe("Session.InvalidCursorError") + expect(afterHead._tag).toBe("Session.InvalidCursorError") }), ) From 9e26376ad8f298ae6c398af2a9dac49ff37c220a Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 26 Jun 2026 13:57:12 -0400 Subject: [PATCH 3/5] fix(api): make session history cursors opaque --- .../client/src/generated-effect/client.ts | 25 +- packages/client/src/generated/client.ts | 2 +- packages/client/src/generated/types.ts | 39 +- packages/client/test/effect.test.ts | 28 +- packages/client/test/promise.test.ts | 23 +- packages/core/src/event.ts | 58 +- packages/core/src/session.ts | 31 +- packages/core/test/session-history.test.ts | 37 +- packages/httpapi-codegen/src/index.ts | 99 +- .../httpapi-codegen/test/generate.test.ts | 25 + .../test/server/httpapi-exercise/index.ts | 9 +- packages/protocol/src/groups/session.ts | 72 +- packages/protocol/test/session-cursor.test.ts | 24 +- packages/schema/src/durable-event-manifest.ts | 5 + packages/schema/src/event.ts | 4 +- packages/schema/src/session-event.ts | 4 +- packages/sdk/js/script/build.ts | 36 + packages/sdk/js/src/v2/gen/sdk.gen.ts | 10 +- packages/sdk/js/src/v2/gen/types.gen.ts | 1021 ++++------------- packages/server/src/handlers/session.ts | 19 +- specs/v2/schema-changelog.md | 2 +- specs/v2/session.md | 2 +- 22 files changed, 592 insertions(+), 983 deletions(-) diff --git a/packages/client/src/generated-effect/client.ts b/packages/client/src/generated-effect/client.ts index 71fa86719bf1..6b37d1b25b00 100644 --- a/packages/client/src/generated-effect/client.ts +++ b/packages/client/src/generated-effect/client.ts @@ -70,8 +70,7 @@ const Endpoint0_3 = (raw: RawClient["server.session"]) => (input: Endpoint0_3Inp ) type Endpoint0_4Request = Parameters[0] -type Endpoint0_4Input = { - readonly sessionID: Endpoint0_4Request["params"]["sessionID"] +type Endpoint0_4Input = { readonly sessionID: Endpoint0_4Request["params"]["sessionID"] } & { readonly agent: Endpoint0_4Request["payload"]["agent"] } const Endpoint0_4 = (raw: RawClient["server.session"]) => (input: Endpoint0_4Input) => @@ -80,8 +79,7 @@ const Endpoint0_4 = (raw: RawClient["server.session"]) => (input: Endpoint0_4Inp ) type Endpoint0_5Request = Parameters[0] -type Endpoint0_5Input = { - readonly sessionID: Endpoint0_5Request["params"]["sessionID"] +type Endpoint0_5Input = { readonly sessionID: Endpoint0_5Request["params"]["sessionID"] } & { readonly model: Endpoint0_5Request["payload"]["model"] } const Endpoint0_5 = (raw: RawClient["server.session"]) => (input: Endpoint0_5Input) => @@ -90,8 +88,7 @@ const Endpoint0_5 = (raw: RawClient["server.session"]) => (input: Endpoint0_5Inp ) type Endpoint0_6Request = Parameters[0] -type Endpoint0_6Input = { - readonly sessionID: Endpoint0_6Request["params"]["sessionID"] +type Endpoint0_6Input = { readonly sessionID: Endpoint0_6Request["params"]["sessionID"] } & { readonly id?: Endpoint0_6Request["payload"]["id"] readonly prompt: Endpoint0_6Request["payload"]["prompt"] readonly delivery?: Endpoint0_6Request["payload"]["delivery"] @@ -117,8 +114,7 @@ const Endpoint0_8 = (raw: RawClient["server.session"]) => (input: Endpoint0_8Inp raw["session.wait"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError)) type Endpoint0_9Request = Parameters[0] -type Endpoint0_9Input = { - readonly sessionID: Endpoint0_9Request["params"]["sessionID"] +type Endpoint0_9Input = { readonly sessionID: Endpoint0_9Request["params"]["sessionID"] } & { readonly messageID: Endpoint0_9Request["payload"]["messageID"] readonly files?: Endpoint0_9Request["payload"]["files"] } @@ -152,19 +148,12 @@ const Endpoint0_12 = (raw: RawClient["server.session"]) => (input: Endpoint0_12I type Endpoint0_13Request = Parameters[0] type Endpoint0_13Input = { readonly sessionID: Endpoint0_13Request["params"]["sessionID"] - readonly after?: Endpoint0_13Request["query"]["after"] - readonly through?: Endpoint0_13Request["query"]["through"] - readonly limit?: Endpoint0_13Request["query"]["limit"] -} +} & Endpoint0_13Request["query"] const Endpoint0_13 = (raw: RawClient["server.session"]) => (input: Endpoint0_13Input) => - raw["session.history"]({ - params: { sessionID: input.sessionID }, - query: { after: input.after, through: input.through, limit: input.limit }, - }).pipe(Effect.mapError(mapClientError)) + raw["session.history"]({ params: { sessionID: input.sessionID }, query: input }).pipe(Effect.mapError(mapClientError)) type Endpoint0_14Request = Parameters[0] -type Endpoint0_14Input = { - readonly sessionID: Endpoint0_14Request["params"]["sessionID"] +type Endpoint0_14Input = { readonly sessionID: Endpoint0_14Request["params"]["sessionID"] } & { readonly after?: Endpoint0_14Request["query"]["after"] } const Endpoint0_14 = (raw: RawClient["server.session"]) => (input: Endpoint0_14Input) => diff --git a/packages/client/src/generated/client.ts b/packages/client/src/generated/client.ts index 84bb6a4355ed..c9dcf4e6a816 100644 --- a/packages/client/src/generated/client.ts +++ b/packages/client/src/generated/client.ts @@ -331,7 +331,7 @@ export function make(options: ClientOptions) { { method: "GET", path: `/api/session/${encodeURIComponent(input.sessionID)}/history`, - query: { after: input.after, through: input.through, limit: input.limit }, + query: { limit: input.limit, after: input.after, cursor: input.cursor }, successStatus: 200, declaredStatuses: [404, 400, 401], empty: false, diff --git a/packages/client/src/generated/types.ts b/packages/client/src/generated/types.ts index e97fdfa5dc0b..e026bcc61b76 100644 --- a/packages/client/src/generated/types.ts +++ b/packages/client/src/generated/types.ts @@ -281,15 +281,13 @@ export type SessionsGetOutput = { } }["data"] -export type SessionsSwitchAgentInput = { - readonly sessionID: { readonly sessionID: string }["sessionID"] +export type SessionsSwitchAgentInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } & { readonly agent: { readonly agent: string }["agent"] } export type SessionsSwitchAgentOutput = void -export type SessionsSwitchModelInput = { - readonly sessionID: { readonly sessionID: string }["sessionID"] +export type SessionsSwitchModelInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } & { readonly model: { readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } }["model"] @@ -297,8 +295,7 @@ export type SessionsSwitchModelInput = { export type SessionsSwitchModelOutput = void -export type SessionsPromptInput = { - readonly sessionID: { readonly sessionID: string }["sessionID"] +export type SessionsPromptInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } & { readonly id?: { readonly id?: string | null readonly prompt: { @@ -406,8 +403,7 @@ export type SessionsWaitInput = { readonly sessionID: { readonly sessionID: stri export type SessionsWaitOutput = void -export type SessionsStageInput = { - readonly sessionID: { readonly sessionID: string }["sessionID"] +export type SessionsStageInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } & { readonly messageID: { readonly messageID: string; readonly files?: boolean | undefined }["messageID"] readonly files?: { readonly messageID: string; readonly files?: boolean | undefined }["files"] } @@ -591,23 +587,10 @@ export type SessionsContextOutput = { > }["data"] -export type SessionsHistoryInput = { - readonly sessionID: { readonly sessionID: string }["sessionID"] - readonly after?: { - readonly after?: string | undefined - readonly through?: string | undefined - readonly limit?: string | undefined - }["after"] - readonly through?: { - readonly after?: string | undefined - readonly through?: string | undefined - readonly limit?: string | undefined - }["through"] - readonly limit?: { - readonly after?: string | undefined - readonly through?: string | undefined - readonly limit?: string | undefined - }["limit"] +export type SessionsHistoryInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } & { + readonly limit?: string | undefined + readonly after?: string | undefined + readonly cursor?: string | undefined } export type SessionsHistoryOutput = { @@ -1173,12 +1156,10 @@ export type SessionsHistoryOutput = { readonly data: { readonly timestamp: number; readonly sessionID: string; readonly messageID: string } } > - readonly through: number - readonly nextAfter?: number + readonly cursor?: string } -export type SessionsEventsInput = { - readonly sessionID: { readonly sessionID: string }["sessionID"] +export type SessionsEventsInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } & { readonly after?: { readonly after?: string | undefined }["after"] } diff --git a/packages/client/test/effect.test.ts b/packages/client/test/effect.test.ts index 4108df89625d..f25bc899903d 100644 --- a/packages/client/test/effect.test.ts +++ b/packages/client/test/effect.test.ts @@ -2,6 +2,7 @@ import { expect, test } from "bun:test" import { DateTime, Effect, Stream } from "effect" import { HttpClient, HttpClientResponse } from "effect/unstable/http" import { AbsolutePath, Agent, Location, Model, OpenCode, Prompt, Session, SessionMessage } from "../src/effect" +import { SessionHistoryCursor } from "@opencode-ai/protocol/groups/session" test("sessions.get returns the decoded Effect projection", async () => { const httpClient = HttpClient.make((request) => @@ -16,6 +17,8 @@ test("sessions.get returns the decoded Effect projection", async () => { }) test("session methods retain decoded Effect inputs and outputs", async () => { + const historyQueries: Array> = [] + let historyPage = 0 const httpClient = HttpClient.make((request) => { const url = request.url if (url.includes("/event")) { @@ -29,10 +32,14 @@ test("session methods retain decoded Effect inputs and outputs", async () => { ) } if (url.includes("/history")) { + historyPage++ + historyQueries.push(Object.fromEntries(request.urlParams.params)) return Effect.succeed( HttpClientResponse.fromWeb( request, - Response.json({ events: [modelSwitchedEvent], through: 2, nextAfter: 1 }), + Response.json( + historyPage === 1 ? { events: [modelSwitchedEvent], cursor: "opaque_history_cursor" } : { events: [] }, + ), ), ) } @@ -83,9 +90,15 @@ test("session methods retain decoded Effect inputs and outputs", async () => { const history = yield* client.sessions.history({ sessionID: Session.ID.make("ses_test"), after: 0, - through: 2, limit: 1, }) + const historyNext = history.cursor + ? yield* client.sessions.history({ + sessionID: Session.ID.make("ses_test"), + cursor: history.cursor, + limit: 2, + }) + : undefined const events = yield* client.sessions .events({ sessionID: Session.ID.make("ses_test"), after: 0 }) .pipe(Stream.runCollect) @@ -94,7 +107,7 @@ test("session methods retain decoded Effect inputs and outputs", async () => { sessionID: Session.ID.make("ses_test"), messageID: SessionMessage.ID.make("msg_model"), }) - return { page, active, created, admitted, context, history, events, message } + return { page, active, created, admitted, context, history, historyNext, events, message } }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise) expect(DateTime.toEpochMillis(result.page.data[0].time.created)).toBe(1_717_171_717_000) @@ -107,7 +120,9 @@ test("session methods retain decoded Effect inputs and outputs", async () => { expect(DateTime.toEpochMillis(result.admitted.timeCreated)).toBe(1_717_171_717_000) expect(result.context).toEqual([]) expect(DateTime.toEpochMillis(result.history.events[0].data.timestamp)).toBe(1_717_171_717_000) - expect(result.history).toEqual(expect.objectContaining({ through: 2, nextAfter: 1 })) + expect(result.history).toEqual(expect.objectContaining({ cursor: "opaque_history_cursor" })) + expect(result.historyNext).toEqual({ events: [] }) + expect(historyQueries[1]).toEqual({ limit: "2", cursor: "opaque_history_cursor" }) expect(DateTime.toEpochMillis(result.events[0].data.timestamp)).toBe(1_717_171_717_000) expect(result.message).toEqual(expect.objectContaining({ id: "msg_model", type: "model-switched" })) }) @@ -124,7 +139,10 @@ test("sessions.history retains the typed InvalidCursorError", async () => { const error = await Effect.gen(function* () { const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" }) return yield* client.sessions - .history({ sessionID: Session.ID.make("ses_test"), through: 2 }) + .history({ + sessionID: Session.ID.make("ses_test"), + cursor: SessionHistoryCursor.make({ after: 0, through: 0 }), + }) .pipe(Effect.flip) }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise) diff --git a/packages/client/test/promise.test.ts b/packages/client/test/promise.test.ts index 0467de98f0cf..4d8313c6300c 100644 --- a/packages/client/test/promise.test.ts +++ b/packages/client/test/promise.test.ts @@ -19,6 +19,7 @@ test("sessions.get returns the wire projection", async () => { test("session methods use the public HTTP contract", async () => { const requests: Array<{ url: string; init?: RequestInit }> = [] + let historyPage = 0 const client = OpenCode.make({ baseUrl: "http://localhost:3000", fetch: async (input, init) => { @@ -30,7 +31,10 @@ test("session methods use the public HTTP contract", async () => { }) } if (url.includes("/history")) { - return Response.json({ events: [modelSwitchedEvent], through: 2, nextAfter: 1 }) + historyPage++ + return Response.json( + historyPage === 1 ? { events: [modelSwitchedEvent], cursor: "opaque_history_cursor" } : { events: [] }, + ) } if (url.includes("/prompt")) return Response.json(admission) if (url.includes("/context")) return Response.json({ data: [] }) @@ -58,7 +62,14 @@ test("session methods use the public HTTP contract", async () => { await client.sessions.compact({ sessionID: "ses_test" }) await client.sessions.wait({ sessionID: "ses_test" }) const context = await client.sessions.context({ sessionID: "ses_test" }) - const history = await client.sessions.history({ sessionID: "ses_test", after: "0", through: "2", limit: "1" }) + const history = await client.sessions.history({ sessionID: "ses_test", after: "0", limit: "1" }) + const historyNext = history.cursor + ? await client.sessions.history({ sessionID: "ses_test", cursor: history.cursor, limit: "2" }) + : undefined + if (false) { + // @ts-expect-error after starts a snapshot while cursor continues one + await client.sessions.history({ sessionID: "ses_test", after: "0", cursor: "opaque_history_cursor" }) + } const events = [] for await (const event of client.sessions.events({ sessionID: "ses_test", after: "0" })) events.push(event) await client.sessions.interrupt({ sessionID: "ses_test" }) @@ -69,7 +80,8 @@ test("session methods use the public HTTP contract", async () => { expect(created.id).toBe("ses_test") expect(admitted.id).toBe("msg_test") expect(context).toEqual([]) - expect(history).toEqual({ events: [modelSwitchedEvent], through: 2, nextAfter: 1 }) + expect(history).toEqual({ events: [modelSwitchedEvent], cursor: "opaque_history_cursor" }) + expect(historyNext).toEqual({ events: [] }) expect(events).toEqual([modelSwitchedEvent]) expect(message).toEqual(modelSwitchedMessage) expect(requests.map((request) => [request.init?.method, request.url])).toEqual([ @@ -82,7 +94,8 @@ test("session methods use the public HTTP contract", async () => { ["POST", "http://localhost:3000/api/session/ses_test/compact"], ["POST", "http://localhost:3000/api/session/ses_test/wait"], ["GET", "http://localhost:3000/api/session/ses_test/context"], - ["GET", "http://localhost:3000/api/session/ses_test/history?after=0&through=2&limit=1"], + ["GET", "http://localhost:3000/api/session/ses_test/history?limit=1&after=0"], + ["GET", "http://localhost:3000/api/session/ses_test/history?limit=2&cursor=opaque_history_cursor"], ["GET", "http://localhost:3000/api/session/ses_test/event?after=0"], ["POST", "http://localhost:3000/api/session/ses_test/interrupt"], ["GET", "http://localhost:3000/api/session/ses_test/message/msg_model"], @@ -117,7 +130,7 @@ test("sessions.history decodes InvalidCursorError", async () => { }) try { - await client.sessions.history({ sessionID: "ses_test", through: "2" }) + await client.sessions.history({ sessionID: "ses_test", cursor: "malformed" }) throw new Error("Expected request to fail") } catch (error) { expect(isInvalidCursorError(error)).toBe(true) diff --git a/packages/core/src/event.ts b/packages/core/src/event.ts index 78284b29d3ba..9a0118a71f1c 100644 --- a/packages/core/src/event.ts +++ b/packages/core/src/event.ts @@ -64,21 +64,30 @@ const decodeSerializedEvent = (event: SerializedEvent): Payload => { } } -export const readAggregate = Effect.fn("EventV2.readAggregate")(function* ( +export const readAggregate = Effect.fn("EventV2.readAggregate")(function* ( db: Database.Interface["db"], input: { readonly aggregateID: string readonly after?: number readonly through?: number readonly limit: number - readonly types: ReadonlyArray + readonly manifest: { + readonly definitions: ReadonlyMap + readonly schema: Schema.Decoder + } }, ) { const after = input.after ?? -1 - return yield* db - .transaction(() => + const result = yield* db + .transaction((tx) => Effect.gen(function* () { - const head = yield* latestSequence(db, input.aggregateID) + const sequence = yield* tx + .select({ seq: EventSequenceTable.seq }) + .from(EventSequenceTable) + .where(eq(EventSequenceTable.aggregate_id, input.aggregateID)) + .get() + .pipe(Effect.orDie) + const head = sequence?.seq ?? -1 if (input.through !== undefined && input.through > head) { return yield* new InvalidCursorError({ message: "History cutoff is above the current aggregate head" }) } @@ -86,7 +95,7 @@ export const readAggregate = Effect.fn("EventV2.readAggregate")(function* ( if (through < after) { return yield* new InvalidCursorError({ message: "History cutoff must not be less than the cursor" }) } - const rows = yield* db + const rows = yield* tx .select() .from(EventTable) .where( @@ -94,31 +103,36 @@ export const readAggregate = Effect.fn("EventV2.readAggregate")(function* ( eq(EventTable.aggregate_id, input.aggregateID), gt(EventTable.seq, after), lte(EventTable.seq, through), - inArray(EventTable.type, input.types), + inArray(EventTable.type, Array.from(input.manifest.definitions.keys())), ), ) .orderBy(asc(EventTable.seq)) .limit(input.limit + 1) .all() .pipe(Effect.orDie) - const page = rows.slice(0, input.limit) - const events = page.map((event) => - decodeSerializedEvent({ - id: event.id, - aggregateID: event.aggregate_id, - seq: event.seq, - type: event.type, - data: event.data, - }), - ) - return { - events, - through, - nextAfter: rows.length > input.limit ? events.at(-1)?.durable?.seq : undefined, - } + return { rows, through } }), ) .pipe(Effect.catchTag("SqlError", Effect.die)) + const page = result.rows.slice(0, input.limit) + const decode = Schema.decodeUnknownSync(input.manifest.schema) + const events = page.map((event) => + decode({ + id: event.id, + type: input.manifest.definitions.get(event.type)?.type ?? event.type, + durable: { + aggregateID: event.aggregate_id, + seq: event.seq, + version: input.manifest.definitions.get(event.type)?.durable?.version, + }, + data: event.data, + }), + ) + return { + events, + through: result.through, + nextAfter: result.rows.length > input.limit ? page.at(-1)?.seq : undefined, + } }) export const define = Event.define diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index d9e9955bba1f..f025ed69b252 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -34,6 +34,7 @@ import { Snapshot } from "./snapshot" import { SessionRevert } from "./session/revert" import { Revert } from "@opencode-ai/schema/revert" import { FSUtil } from "./fs-util" +import { SessionDurable } from "@opencode-ai/schema/durable-event-manifest" export const RevertState = Revert.State export type RevertState = Revert.State @@ -105,13 +106,15 @@ export class PromptConflictError extends Schema.TaggedErrorClass()("Session.InvalidCursorError", { message: Schema.String, }) {} -const DurableEventTypes = SessionEvent.DurableDefinitions.flatMap((definition) => - definition.durable ? [EventV2.versionedType(definition.type, definition.durable.version)] : [], -) export const MessageNotFoundError = SessionRevert.MessageNotFoundError export type MessageNotFoundError = SessionRevert.MessageNotFoundError -export type Error = NotFoundError | MessageDecodeError | OperationUnavailableError | PromptConflictError +export type Error = + | NotFoundError + | MessageDecodeError + | OperationUnavailableError + | PromptConflictError + | InvalidCursorError export interface Interface { readonly list: (input?: ListInput) => Effect.Effect @@ -368,21 +371,11 @@ export const layer = Layer.unwrap( ).pipe(Stream.filter((event): event is SessionEvent.DurableEvent => isDurableSessionEvent(event))), history: Effect.fn("V2Session.history")(function* (input) { yield* result.get(input.sessionID) - const page = yield* EventV2 - .readAggregate(db, { - ...input, - aggregateID: input.sessionID, - types: DurableEventTypes, - }) - .pipe( - Effect.mapError((error) => new InvalidCursorError({ message: error.message })), - ) - return { - ...page, - events: page.events.filter( - (event): event is SessionEvent.DurableEvent => isDurableSessionEvent(event), - ), - } + return yield* EventV2.readAggregate(db, { + ...input, + aggregateID: input.sessionID, + manifest: SessionDurable, + }).pipe(Effect.mapError((error) => new InvalidCursorError({ message: error.message }))) }), prompt: Effect.fn("V2Session.prompt")((input) => Effect.uninterruptible( diff --git a/packages/core/test/session-history.test.ts b/packages/core/test/session-history.test.ts index bc965a863ed5..a29e34b857fa 100644 --- a/packages/core/test/session-history.test.ts +++ b/packages/core/test/session-history.test.ts @@ -5,11 +5,13 @@ import { EventV2 } from "@opencode-ai/core/event" import { Location } from "@opencode-ai/core/location" import { LocationServiceMap } from "@opencode-ai/core/location-layer" import { ProjectV2 } from "@opencode-ai/core/project" +import { ProjectTable } from "@opencode-ai/core/project/sql" import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" import { SessionExecution } from "@opencode-ai/core/session/execution" import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionStore } from "@opencode-ai/core/session/store" +import { SessionTable } from "@opencode-ai/core/session/sql" import { testEffect } from "./lib/effect" const projects = Layer.succeed( @@ -48,16 +50,31 @@ const GapEvent = EventV2.define({ }) describe("SessionV2.history", () => { - it.effect("returns a stable cutoff for a Session with no public history", () => + it.effect("returns an exhausted page for a migrated Session with no event sequence", () => Effect.gen(function* () { + const db = (yield* Database.Service).db const session = yield* SessionV2.Service - const created = yield* session.create({ location }) - - const first = yield* session.history({ sessionID: created.id, limit: 10 }) - const second = yield* session.history({ sessionID: created.id, through: first.through, limit: 10 }) - - expect(first).toEqual({ events: [], through: 0, nextAfter: undefined }) - expect(second).toEqual(first) + const sessionID = SessionV2.ID.make("ses_empty_history") + yield* db + .insert(ProjectTable) + .values({ id: ProjectV2.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }) + .onConflictDoNothing() + .run() + yield* db + .insert(SessionTable) + .values({ + id: sessionID, + project_id: ProjectV2.ID.global, + slug: "empty-history", + directory: "/project", + title: "Empty history", + version: "test", + }) + .run() + + const first = yield* session.history({ sessionID, limit: 10 }) + + expect(first).toEqual({ events: [], through: -1, nextAfter: undefined }) }), ) @@ -130,9 +147,7 @@ describe("SessionV2.history", () => { it.effect("fails with NotFoundError for a missing Session", () => Effect.gen(function* () { const session = yield* SessionV2.Service - const error = yield* session - .history({ sessionID: SessionV2.ID.make("ses_missing"), limit: 10 }) - .pipe(Effect.flip) + const error = yield* session.history({ sessionID: SessionV2.ID.make("ses_missing"), limit: 10 }).pipe(Effect.flip) expect(error._tag).toBe("Session.NotFoundError") }), diff --git a/packages/httpapi-codegen/src/index.ts b/packages/httpapi-codegen/src/index.ts index 289884d961a9..20b549f9b50f 100644 --- a/packages/httpapi-codegen/src/index.ts +++ b/packages/httpapi-codegen/src/index.ts @@ -322,28 +322,39 @@ function renderImportedEffectFiles( const rawGroup = group.endpoints[0]?.topLevel ? "RawClient" : `RawClient[${JSON.stringify(group.sourceIdentifier)}]` const methods = group.endpoints.map((item, endpointIndex) => { const prefix = `Endpoint${groupIndex}_${endpointIndex}` + const schemaBySource = { + params: item.params, + query: item.query, + headers: item.headers, + payload: item.payloads[0], + } const request = (["params", "query", "headers", "payload"] as const) .flatMap((source) => { const fields = item.input.filter((field) => field.source === source) if (fields.length === 0) return [] + if (isStructUnion(schemaBySource[source])) return [`${source}: input`] return [ `${source}: { ${fields.map((field) => `${JSON.stringify(field.name)}: input${item.operation.inputMode === "optional" ? "?." : "."}${field.name}`).join(", ")} }`, ] }) .join(", ") - const input = item.input - .map( - (field) => - `readonly ${JSON.stringify(field.name)}${field.optional ? "?" : ""}: ${prefix}Request[${JSON.stringify(field.source)}][${JSON.stringify(field.name)}]`, - ) - .join("; ") + const input = (["params", "query", "headers", "payload"] as const) + .flatMap((source) => { + const fields = item.input.filter((field) => field.source === source) + if (fields.length === 0) return [] + if (isStructUnion(schemaBySource[source])) return [`${prefix}Request[${JSON.stringify(source)}]`] + return [ + `{ ${fields.map((field) => `readonly ${JSON.stringify(field.name)}${field.optional ? "?" : ""}: ${prefix}Request[${JSON.stringify(source)}][${JSON.stringify(field.name)}]`).join("; ")} }`, + ] + }) + .join(" & ") const argument = item.operation.inputMode === "none" ? "" : `input${item.operation.inputMode === "optional" ? "?" : ""}: ${prefix}Input` const rawCall = `raw[${JSON.stringify(item.endpoint.name)}]({ ${request} })` const mapped = `${rawCall}.pipe(Effect.mapError(mapClientError)${item.unwrapData ? ", Effect.map((value) => value.data)" : ""})` - return `${item.operation.inputMode === "none" ? "" : `type ${prefix}Request = Parameters<${rawGroup}[${JSON.stringify(item.endpoint.name)}]>[0]\ntype ${prefix}Input = { ${input} }\n`}const ${prefix} = (raw: ${rawGroup}) => (${argument}) => ${item.operation.success === "stream" ? `Stream.unwrap(${rawCall}.pipe(Effect.mapError(mapClientError), Effect.map((stream) => stream.pipe(Stream.mapError(mapClientError)))))` : mapped}` + return `${item.operation.inputMode === "none" ? "" : `type ${prefix}Request = Parameters<${rawGroup}[${JSON.stringify(item.endpoint.name)}]>[0]\ntype ${prefix}Input = ${input}\n`}const ${prefix} = (raw: ${rawGroup}) => (${argument}) => ${item.operation.success === "stream" ? `Stream.unwrap(${rawCall}.pipe(Effect.mapError(mapClientError), Effect.map((stream) => stream.pipe(Stream.mapError(mapClientError)))))` : mapped}` }) return `${methods.join("\n\n")}\n\nconst adaptGroup${groupIndex} = (raw: ${rawGroup}) => ({ ${group.endpoints.map((item, endpointIndex) => `${JSON.stringify(item.operation.name)}: Endpoint${groupIndex}_${endpointIndex}(raw)`).join(", ")} })` }) @@ -444,14 +455,18 @@ function renderPromiseTypes(groups: ReadonlyArray) { headers: endpoint.headers, payload: endpoint.payloads[0], } - const input = endpoint.input - .map((field) => { - const schema = schemas[field.source] - if (schema === undefined) - throw new GenerationError({ reason: `Missing input schema: ${prefix}.${field.name}` }) - return `readonly ${JSON.stringify(field.name)}${field.optional ? "?" : ""}: (${typeOf(schema)})[${JSON.stringify(field.name)}]` + const input = (["params", "query", "headers", "payload"] as const) + .flatMap((source) => { + const schema = schemas[source] + const fields = endpoint.input.filter((field) => field.source === source) + if (fields.length === 0) return [] + if (schema === undefined) throw new GenerationError({ reason: `Missing input schema: ${prefix}.${source}` }) + if (isStructUnion(schema)) return [`(${typeOf(schema)})`] + return [ + `{ ${fields.map((field) => `readonly ${JSON.stringify(field.name)}${field.optional ? "?" : ""}: (${typeOf(schema)})[${JSON.stringify(field.name)}]`).join("; ")} }`, + ] }) - .join("; ") + .join(" & ") const successSchema = endpoint.successes[0] const success = typeOf( isStreamSchema(successSchema) && successSchema._tag === "StreamSse" @@ -461,7 +476,7 @@ function renderPromiseTypes(groups: ReadonlyArray) { : successSchema, ) return [ - ...(endpoint.operation.inputMode === "none" ? [] : [`export type ${prefix}Input = { ${input} }`]), + ...(endpoint.operation.inputMode === "none" ? [] : [`export type ${prefix}Input = ${input}`]), `export type ${prefix}Output = ${endpoint.unwrapData ? `(${success})["data"]` : success}`, ] }), @@ -764,19 +779,35 @@ export function generate( function inputFields(schema: Schema.Top | undefined, source: InputField["source"], operation: string) { if (schema === undefined) return [] const ast = Schema.toType(schema).ast - if (!SchemaAST.isObjects(ast) || ast.indexSignatures.length > 0) { + const objects = SchemaAST.isUnion(ast) ? ast.types : [ast] + if (objects.some((item) => !SchemaAST.isObjects(item) || item.indexSignatures.length > 0)) { throw new GenerationError({ reason: `Input schema must be a struct: ${operation}.${source}` }) } - return ast.propertySignatures.map((field) => { - if (typeof field.name !== "string") { - throw new GenerationError({ reason: `Input field must have a string name: ${operation}.${source}` }) - } - return { - name: field.name, - source, - optional: SchemaAST.isOptional(field.type), + const names = new Set() + for (const object of objects) { + if (!SchemaAST.isObjects(object)) continue + for (const field of object.propertySignatures) { + if (typeof field.name !== "string") { + throw new GenerationError({ reason: `Input field must have a string name: ${operation}.${source}` }) + } + names.add(field.name) } - }) + } + return Array.from(names, (name) => ({ + name, + source, + optional: objects.some((object) => { + if (!SchemaAST.isObjects(object)) return false + const field = object.propertySignatures.find((field) => field.name === name) + return field === undefined || SchemaAST.isOptional(field.type) + }), + })) +} + +function isStructUnion(schema: Schema.Top | undefined) { + if (schema === undefined) return false + const ast = Schema.toType(schema).ast + return SchemaAST.isUnion(ast) && ast.types.every((item) => SchemaAST.isObjects(item)) } function responseSchemas(schema: Schema.Top, path: string): Array { @@ -1017,15 +1048,20 @@ function renderGroup(group: Group, groupIndex: number) { : `error: ${errorSlots.length === 1 ? errorSlots[0].name : `[${errorSlots.map((slot) => slot.name).join(", ")}]`}`, ].filter((option): option is string => option !== undefined) const schemaBySource = { params, query, headers, payload: payloads[0] } - const inputType = operation.input - .map((field) => { - const slot = schemaBySource[field.source] + const inputType = (["params", "query", "headers", "payload"] as const) + .flatMap((source) => { + const slot = schemaBySource[source] + const fields = operation.input.filter((field) => field.source === source) + if (fields.length === 0) return [] if (slot === undefined) { throw new GenerationError({ reason: `Missing input schema: ${group.identifier}.${endpoint.name}` }) } - return `readonly ${JSON.stringify(field.name)}${field.optional ? "?" : ""}: (typeof ${slot.name}.Type)[${JSON.stringify(field.name)}]` + if (isStructUnion(slot.schema)) return [`typeof ${slot.name}.Type`] + return [ + `{ ${fields.map((field) => `readonly ${JSON.stringify(field.name)}${field.optional ? "?" : ""}: (typeof ${slot.name}.Type)[${JSON.stringify(field.name)}]`).join("; ")} }`, + ] }) - .join("; ") + .join(" & ") const argument = operation.operation.inputMode === "none" ? "" @@ -1034,6 +1070,7 @@ function renderGroup(group: Group, groupIndex: number) { .flatMap((source) => { const slot = schemaBySource[source] if (slot === undefined) return [] + if (isStructUnion(slot.schema)) return [`${source}: input`] const fields = operation.input .filter((field) => field.source === source) .map( @@ -1048,7 +1085,7 @@ function renderGroup(group: Group, groupIndex: number) { declared.length === 0 ? "Schema.Never" : `Schema.Union([${declared.map((slot) => slot.name).join(", ")}])` const rawCall = `raw[${JSON.stringify(endpoint.name)}]({ ${request} })` const mapped = `${rawCall}.pipe(Effect.mapError(map${prefix}Error)${operation.unwrapData ? ", Effect.map((value) => value.data)" : ""})` - const inputDeclaration = operation.operation.inputMode === "none" ? "" : `type ${prefix}Input = { ${inputType} }\n` + const inputDeclaration = operation.operation.inputMode === "none" ? "" : `type ${prefix}Input = ${inputType}\n` adapters.push( `${inputDeclaration}const ${prefix}DeclaredError = ${declaredSchema}\nconst map${prefix}Error = (error: unknown) => HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error) ? new ClientError({ cause: error }) : Schema.is(${prefix}DeclaredError)(error) ? error : new ClientError({ cause: error })\nconst ${prefix} = (raw: RawGroup) => (${argument}) => ${operation.operation.success === "stream" ? `Stream.unwrap(${rawCall}.pipe(Effect.mapError(map${prefix}Error), Effect.map((stream) => stream.pipe(Stream.mapError(map${prefix}Error)))))` : mapped}`, ) diff --git a/packages/httpapi-codegen/test/generate.test.ts b/packages/httpapi-codegen/test/generate.test.ts index 543b2a13f378..aa580e16dce6 100644 --- a/packages/httpapi-codegen/test/generate.test.ts +++ b/packages/httpapi-codegen/test/generate.test.ts @@ -48,6 +48,31 @@ describe("HttpApiCodegen.generate", () => { ) }) + test("preserves union-of-struct query inputs", () => { + const contract = compileContract( + api( + HttpApiEndpoint.get("history", "/session/:sessionID/history", { + params: { sessionID: Schema.String }, + query: Schema.Union([ + Schema.Struct({ after: Schema.optional(Schema.Number), cursor: Schema.optional(Schema.Never) }), + Schema.Struct({ after: Schema.optional(Schema.Never), cursor: Schema.String }), + ]), + success: Schema.String, + }), + ), + ) + + const promise = emitPromise(contract).files.find((file) => file.path === "types.ts")?.content + const effect = emitEffectImported(contract, { module: "@example/api", api: "Api" }).files.find( + (file) => file.path === "client.ts", + )?.content + + expect(promise).toContain("& ({ readonly") + expect(effect).toContain('type Endpoint0_0Input = { readonly "sessionID":') + expect(effect).toContain('& Endpoint0_0Request["query"]') + expect(effect).toContain("query: input") + }) + test("emits an Effect client against an imported authoritative API", () => { const output = emitEffectImported( compileContract( diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index d95ad4de61d8..29c1f4927bbb 100644 --- a/packages/opencode/test/server/httpapi-exercise/index.ts +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -1082,7 +1082,10 @@ const scenarios: Scenario[] = [ (body) => { object(body) array(body.events) - if (typeof body.through !== "number") throw new Error("Expected numeric history cutoff") + check( + body.cursor === undefined || typeof body.cursor === "string", + "Expected an optional opaque history cursor", + ) }, "none", ), @@ -1095,9 +1098,9 @@ const scenarios: Scenario[] = [ .json(404, object, "status"), http.protected .get("/api/session/{sessionID}/history", "v2.session.history.invalid") - .seeded((ctx) => ctx.session({ title: "Invalid history cutoff" })) + .seeded((ctx) => ctx.session({ title: "Invalid history cursor" })) .at((ctx) => ({ - path: `${route("/api/session/{sessionID}/history", { sessionID: ctx.state.id })}?through=999999`, + path: `${route("/api/session/{sessionID}/history", { sessionID: ctx.state.id })}?cursor=malformed`, headers: ctx.headers(), })) .json(400, object, "status"), diff --git a/packages/protocol/src/groups/session.ts b/packages/protocol/src/groups/session.ts index b4d600da5ec0..a6f94165b82c 100644 --- a/packages/protocol/src/groups/session.ts +++ b/packages/protocol/src/groups/session.ts @@ -5,7 +5,7 @@ import { Session } from "@opencode-ai/schema/session" import { Project } from "@opencode-ai/schema/project" import { AbsolutePath, NonNegativeInt, optional, PositiveInt, RelativePath, statics } from "@opencode-ai/schema/schema" import { Workspace } from "@opencode-ai/schema/workspace" -import { Context, Encoding, Result, Schema, Struct } from "effect" +import { Context, Effect, Encoding, Result, Schema, Struct } from "effect" import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { ConflictError, @@ -60,6 +60,7 @@ const SessionsCursorInput = Schema.Union([ const SessionsCursorJson = Schema.fromJsonString(SessionsCursorInput) const encodeSessionsCursor = Schema.encodeSync(SessionsCursorJson) const decodeSessionsCursor = Schema.decodeUnknownEffect(SessionsCursorJson) +const invalidCursor = "Invalid cursor" as const export const SessionsCursor = Schema.String.pipe( Schema.brand("SessionsCursor"), @@ -67,18 +68,74 @@ export const SessionsCursor = Schema.String.pipe( const make = schema.make.bind(schema) return { make: (input: typeof SessionsCursorInput.Type) => make(Encoding.encodeBase64Url(encodeSessionsCursor(input))), - parse: (input: string) => decodeSessionsCursor(Result.getOrThrow(Encoding.decodeBase64UrlString(input))), + parse: (input: string) => + Effect.suspend(() => { + const result = Encoding.decodeBase64UrlString(input) + return Result.isFailure(result) + ? Effect.fail(invalidCursor) + : decodeSessionsCursor(result.success).pipe(Effect.mapError(() => invalidCursor)) + }), } }), ) export type SessionsCursor = typeof SessionsCursor.Type +const SessionHistoryCursorInput = Schema.Struct({ + after: Schema.Int.check(Schema.isGreaterThanOrEqualTo(-1)), + through: Schema.Int.check(Schema.isGreaterThanOrEqualTo(-1)), +}) +const SessionHistoryCursorJson = Schema.fromJsonString(SessionHistoryCursorInput) +const encodeSessionHistoryCursor = Schema.encodeSync(SessionHistoryCursorJson) +const decodeSessionHistoryCursor = Schema.decodeUnknownEffect(SessionHistoryCursorJson) + +export const SessionHistoryCursor = Schema.String.pipe( + Schema.brand("Session.HistoryCursor"), + statics((schema) => { + const make = schema.make.bind(schema) + return { + make: (input: typeof SessionHistoryCursorInput.Type) => + make(Encoding.encodeBase64Url(encodeSessionHistoryCursor(input))), + parse: (input: string) => + Effect.suspend(() => { + const result = Encoding.decodeBase64UrlString(input) + return Result.isFailure(result) + ? Effect.fail(invalidCursor) + : decodeSessionHistoryCursor(result.success).pipe(Effect.mapError(() => invalidCursor)) + }), + } + }), +) +export type SessionHistoryCursor = typeof SessionHistoryCursor.Type + const SessionActive = Schema.Struct({ type: Schema.Literal("running"), }).annotate({ identifier: "SessionActive" }) const SessionHistoryLimit = PositiveInt.check(Schema.isLessThanOrEqualTo(100)) +const SessionHistoryQueryFields = { + limit: SessionHistoryLimit.pipe(Schema.optional), +} + +const SessionHistoryQueryType = Schema.Union([ + Schema.Struct({ + ...SessionHistoryQueryFields, + after: NonNegativeInt.pipe(Schema.optional), + cursor: Schema.Never.pipe(Schema.optional), + }), + Schema.Struct({ + ...SessionHistoryQueryFields, + after: Schema.Never.pipe(Schema.optional), + cursor: SessionHistoryCursor, + }), +]) + +export const SessionHistoryQuery = Schema.Struct({ + limit: Schema.NumberFromString.pipe(Schema.decodeTo(SessionHistoryLimit), Schema.optional), + after: Schema.NumberFromString.pipe(Schema.decodeTo(NonNegativeInt), Schema.optional), + cursor: SessionHistoryCursor.pipe(Schema.optional), +}).pipe(Schema.decodeTo(SessionHistoryQueryType)) + const SessionsQueryCursor = SessionsCursor.annotate({ description: "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response.", }) @@ -294,15 +351,10 @@ export const makeSessionGroup = (sessionLo .add( HttpApiEndpoint.get("session.history", "/api/session/:sessionID/history", { params: { sessionID: Session.ID }, - query: { - after: Schema.NumberFromString.pipe(Schema.decodeTo(NonNegativeInt), Schema.optional), - through: Schema.NumberFromString.pipe(Schema.decodeTo(NonNegativeInt), Schema.optional), - limit: Schema.NumberFromString.pipe(Schema.decodeTo(SessionHistoryLimit), Schema.optional), - }, + query: SessionHistoryQuery, success: Schema.Struct({ events: Schema.Array(SessionEvent.Durable), - through: NonNegativeInt, - nextAfter: optional(NonNegativeInt), + cursor: optional(SessionHistoryCursor), }).annotate({ identifier: "SessionHistory" }), error: [SessionNotFoundError, InvalidCursorError], }) @@ -312,7 +364,7 @@ export const makeSessionGroup = (sessionLo identifier: "v2.session.history", summary: "Get session history", description: - "Read one finite page of public durable Session events. Reuse through and pass nextAfter unchanged as the exclusive after cursor; an omitted nextAfter means the fixed snapshot is exhausted.", + "Read one finite page of public durable Session events. Pass the returned opaque cursor unchanged to continue the fixed snapshot; an omitted cursor means the snapshot is exhausted.", }), ), ) diff --git a/packages/protocol/test/session-cursor.test.ts b/packages/protocol/test/session-cursor.test.ts index 60755f63a598..9c3b02441667 100644 --- a/packages/protocol/test/session-cursor.test.ts +++ b/packages/protocol/test/session-cursor.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" -import { Effect } from "effect" -import { SessionsCursor } from "../src/groups/session" +import { Effect, Schema } from "effect" +import { SessionHistoryCursor, SessionHistoryQuery, SessionsCursor } from "../src/groups/session" import { Session } from "@opencode-ai/schema/session" describe("SessionsCursor", () => { @@ -16,3 +16,23 @@ describe("SessionsCursor", () => { expect(await Effect.runPromise(SessionsCursor.parse(cursor))).toEqual(input) }) }) + +describe("SessionHistoryCursor", () => { + test("round trips the empty aggregate head", async () => { + const cursor = SessionHistoryCursor.make({ after: -1, through: -1 }) + + expect(await Effect.runPromise(SessionHistoryCursor.parse(cursor))).toEqual({ after: -1, through: -1 }) + }) + + test("rejects combining after with cursor", () => { + const cursor = SessionHistoryCursor.make({ after: 1, through: 2 }) + + expect(Schema.is(SessionHistoryQuery)({ after: 0, cursor })).toBe(false) + }) + + test("fails malformed cursors in the typed channel", async () => { + const error = await Effect.runPromise(SessionHistoryCursor.parse("malformed").pipe(Effect.flip)) + + expect(error).toBeDefined() + }) +}) diff --git a/packages/schema/src/durable-event-manifest.ts b/packages/schema/src/durable-event-manifest.ts index ed3fb98bb299..acdcb3e9d568 100644 --- a/packages/schema/src/durable-event-manifest.ts +++ b/packages/schema/src/durable-event-manifest.ts @@ -4,6 +4,11 @@ import { Event } from "./event" import { SessionEvent } from "./session-event" import { SessionV1 } from "./session-v1" +export const SessionDurable = { + definitions: Event.durable(SessionEvent.DurableDefinitions), + schema: SessionEvent.Durable, +} as const + export const Durable = Event.durable([ ...SessionV1.Event.Definitions.filter((definition) => definition.durable !== undefined), ...SessionEvent.DurableDefinitions, diff --git a/packages/schema/src/event.ts b/packages/schema/src/event.ts index 3bd0da9894c2..1a7583729f17 100644 --- a/packages/schema/src/event.ts +++ b/packages/schema/src/event.ts @@ -95,7 +95,7 @@ export function versionedType(type: string, version: number) { return `${type}.${version}` } -export function durable(definitions: ReadonlyArray) { +export function durable>(definitions: Definitions) { return readonlyMap( definitions.reduce((result, definition) => { if (!definition.durable) return result @@ -103,7 +103,7 @@ export function durable(definitions: ReadonlyArray) { if (result.has(key)) throw new Error(`Duplicate durable event definition for ${key}`) result.set(key, definition) return result - }, new Map()), + }, new Map()), ) } diff --git a/packages/schema/src/session-event.ts b/packages/schema/src/session-event.ts index f58c3dcce025..3a559c3e38a4 100644 --- a/packages/schema/src/session-event.ts +++ b/packages/schema/src/session-event.ts @@ -511,7 +511,9 @@ export const Definitions = Event.inventory( RevertEvent.Committed, ) -export const Durable = Schema.Union(DurableDefinitions, { mode: "oneOf" }).pipe(Schema.toTaggedUnion("type")) +export const Durable = Schema.Union(DurableDefinitions, { mode: "oneOf" }) + .pipe(Schema.toTaggedUnion("type")) + .annotate({ identifier: "SessionDurableEvent" }) export type DurableEvent = typeof Durable.Type export const All = Schema.Union(Definitions, { mode: "oneOf" }).pipe(Schema.toTaggedUnion("type")) diff --git a/packages/sdk/js/script/build.ts b/packages/sdk/js/script/build.ts index 72f4e3f3e993..fca5be3a2dc4 100755 --- a/packages/sdk/js/script/build.ts +++ b/packages/sdk/js/script/build.ts @@ -13,6 +13,37 @@ const opencode = path.resolve(dir, "../../opencode") await $`bun dev generate > ${dir}/openapi.json`.cwd(opencode) +const document = (await Bun.file("./openapi.json").json()) as { + components?: { schemas?: Record } + [key: string]: unknown +} +const schemas = document.components?.schemas +if (schemas) { + const reachable = new Set() + const visit = (value: unknown) => { + if (Array.isArray(value)) { + value.forEach(visit) + return + } + if (typeof value !== "object" || value === null) return + for (const [key, child] of Object.entries(value)) { + if (key === "$ref" && typeof child === "string" && child.startsWith("#/components/schemas/")) { + const name = child.slice("#/components/schemas/".length) + if (reachable.has(name)) continue + reachable.add(name) + visit(schemas[name]) + } else { + visit(child) + } + } + } + visit({ ...document, components: { ...document.components, schemas: undefined } }) + for (const name of Object.keys(schemas)) { + if (/^SessionNext\w+1$/.test(name) && !reachable.has(name)) delete schemas[name] + } + await Bun.write("./openapi.json", JSON.stringify(document)) +} + await createClient({ input: "./openapi.json", output: { @@ -40,6 +71,11 @@ await createClient({ ], }) +const generatedTypes = await Bun.file("./src/v2/gen/types.gen.ts").text() +if (/export type SessionNext\w+1 =/.test(generatedTypes)) { + throw new Error("Session history generated duplicate Session event variants") +} + // Patch a @hey-api/openapi-ts codegen bug: SseFn incorrectly passes the // endpoint's TError into the second generic of ServerSentEventsResult, which // is the AsyncGenerator's TReturn slot. Iterator return values have nothing diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 6a63d404d2d7..95714d517e86 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -5715,14 +5715,14 @@ export class Session3 extends HeyApiClient { /** * Get session history * - * Read one finite page of public durable Session events. Reuse through and pass nextAfter unchanged as the exclusive after cursor; an omitted nextAfter means the fixed snapshot is exhausted. + * Read one finite page of public durable Session events. Pass the returned opaque cursor unchanged to continue the fixed snapshot; an omitted cursor means the snapshot is exhausted. */ public history( parameters: { sessionID: string - after?: string - through?: string limit?: string + after?: string + cursor?: string }, options?: Options, ) { @@ -5732,9 +5732,9 @@ export class Session3 extends HeyApiClient { { args: [ { in: "path", key: "sessionID" }, - { in: "query", key: "after" }, - { in: "query", key: "through" }, { in: "query", key: "limit" }, + { in: "query", key: "after" }, + { in: "query", key: "cursor" }, ], }, ], diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index c5e1a97fb137..01767f0057bb 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -2731,41 +2731,43 @@ export type UnknownError1 = { ref?: string } +export type SessionDurableEvent = + | SessionNextAgentSwitched + | SessionNextModelSwitched + | SessionNextMoved + | SessionNextPrompted + | SessionNextPromptAdmitted + | SessionNextContextUpdated + | SessionNextSynthetic + | SessionNextShellStarted + | SessionNextShellEnded + | SessionNextStepStarted + | SessionNextStepEnded + | SessionNextStepFailed + | SessionNextTextStarted + | SessionNextTextEnded + | SessionNextToolInputStarted + | SessionNextToolInputEnded + | SessionNextToolCalled + | SessionNextToolProgress + | SessionNextToolSuccess + | SessionNextToolFailed + | SessionNextReasoningStarted + | SessionNextReasoningEnded + | SessionNextRetried + | SessionNextCompactionStarted + | SessionNextCompactionEnded + | SessionNextRevertStaged + | SessionNextRevertCleared + | SessionNextRevertCommitted + export type SessionHistory = { - events: Array< - | SessionNextAgentSwitched - | SessionNextModelSwitched - | SessionNextMoved - | SessionNextPrompted - | SessionNextPromptAdmitted - | SessionNextContextUpdated - | SessionNextSynthetic - | SessionNextShellStarted - | SessionNextShellEnded - | SessionNextStepStarted - | SessionNextStepEnded - | SessionNextStepFailed - | SessionNextTextStarted - | SessionNextTextEnded - | SessionNextToolInputStarted - | SessionNextToolInputEnded - | SessionNextToolCalled - | SessionNextToolProgress - | SessionNextToolSuccess - | SessionNextToolFailed - | SessionNextReasoningStarted - | SessionNextReasoningEnded - | SessionNextRetried - | SessionNextCompactionStarted - | SessionNextCompactionEnded - | SessionNextRevertStaged - | SessionNextRevertCleared - | SessionNextRevertCommitted - > - through: number - nextAfter?: number + events: Array + cursor?: string } +export type SessionDurableEvent1 = string + export type SessionMessagesResponse = { data: Array cursor: { @@ -3963,737 +3965,128 @@ export type SessionMessageAssistantReasoning = { providerMetadata?: LlmProviderMetadata } -export type SessionMessageToolStatePending = { - status: "pending" - input: string -} - -export type SessionMessageToolStateRunning = { - status: "running" - input: { - [key: string]: unknown - } - structured: { - [key: string]: unknown - } - content: Array -} - -export type SessionMessageToolStateCompleted = { - status: "completed" - input: { - [key: string]: unknown - } - attachments?: Array - content: Array - outputPaths?: Array - structured: { - [key: string]: unknown - } - result?: unknown -} - -export type SessionMessageToolStateError = { - status: "error" - input: { - [key: string]: unknown - } - content: Array - structured: { - [key: string]: unknown - } - error: SessionErrorUnknown - result?: unknown -} - -export type SessionMessageAssistantTool = { - type: "tool" - id: string - name: string - provider?: { - executed: boolean - metadata?: LlmProviderMetadata - resultMetadata?: LlmProviderMetadata - } - state: - | SessionMessageToolStatePending - | SessionMessageToolStateRunning - | SessionMessageToolStateCompleted - | SessionMessageToolStateError - time: { - created: number - ran?: number - completed?: number - pruned?: number - } -} - -export type SessionMessageAssistant = { - id: string - metadata?: { - [key: string]: unknown - } - time: { - created: number - completed?: number - } - type: "assistant" - agent: string - model: ModelRef - content: Array - snapshot?: { - start?: string - end?: string - files?: Array - } - finish?: string - cost?: number - tokens?: { - input: number - output: number - reasoning: number - cache: { - read: number - write: number - } - } - error?: SessionErrorUnknown -} - -export type SessionMessageCompaction = { - type: "compaction" - reason: "auto" | "manual" - summary: string - recent: string - id: string - metadata?: { - [key: string]: unknown - } - time: { - created: number - } -} - -export type SessionMessage = - | SessionMessageAgentSwitched - | SessionMessageModelSwitched - | SessionMessageUser - | SessionMessageSynthetic - | SessionMessageSystem - | SessionMessageShell - | SessionMessageAssistant - | SessionMessageCompaction - -export type SessionNextAgentSwitched = { - id: string - metadata?: { - [key: string]: unknown - } - type: "session.next.agent.switched" - durable?: { - aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - } - location?: LocationRef - data: { - timestamp: number - sessionID: string - messageID: string - agent: string - } -} - -export type SessionNextModelSwitched = { - id: string - metadata?: { - [key: string]: unknown - } - type: "session.next.model.switched" - durable?: { - aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - } - location?: LocationRef - data: { - timestamp: number - sessionID: string - messageID: string - model: ModelRef - } -} - -export type SessionNextMoved = { - id: string - metadata?: { - [key: string]: unknown - } - type: "session.next.moved" - durable?: { - aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - } - location?: LocationRef - data: { - timestamp: number - sessionID: string - location: LocationRef - subdirectory?: string - } -} - -export type SessionNextPrompted = { - id: string - metadata?: { - [key: string]: unknown - } - type: "session.next.prompted" - durable?: { - aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - } - location?: LocationRef - data: { - timestamp: number - sessionID: string - messageID: string - prompt: Prompt - delivery: "steer" | "queue" - } -} - -export type SessionNextPromptAdmitted = { - id: string - metadata?: { - [key: string]: unknown - } - type: "session.next.prompt.admitted" - durable?: { - aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - } - location?: LocationRef - data: { - timestamp: number - sessionID: string - messageID: string - prompt: Prompt - delivery: "steer" | "queue" - } -} - -export type SessionNextContextUpdated = { - id: string - metadata?: { - [key: string]: unknown - } - type: "session.next.context.updated" - durable?: { - aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - } - location?: LocationRef - data: { - timestamp: number - sessionID: string - messageID: string - text: string - } -} - -export type SessionNextSynthetic = { - id: string - metadata?: { - [key: string]: unknown - } - type: "session.next.synthetic" - durable?: { - aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - } - location?: LocationRef - data: { - timestamp: number - sessionID: string - messageID: string - text: string - } -} - -export type SessionNextShellStarted = { - id: string - metadata?: { - [key: string]: unknown - } - type: "session.next.shell.started" - durable?: { - aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - } - location?: LocationRef - data: { - timestamp: number - sessionID: string - messageID: string - callID: string - command: string - } -} - -export type SessionNextShellEnded = { - id: string - metadata?: { - [key: string]: unknown - } - type: "session.next.shell.ended" - durable?: { - aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - } - location?: LocationRef - data: { - timestamp: number - sessionID: string - callID: string - output: string - } -} - -export type SessionNextStepStarted = { - id: string - metadata?: { - [key: string]: unknown - } - type: "session.next.step.started" - durable?: { - aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - } - location?: LocationRef - data: { - timestamp: number - sessionID: string - assistantMessageID: string - agent: string - model: ModelRef - snapshot?: string - } -} - -export type SessionNextStepEnded = { - id: string - metadata?: { - [key: string]: unknown - } - type: "session.next.step.ended" - durable?: { - aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - } - location?: LocationRef - data: { - timestamp: number - sessionID: string - assistantMessageID: string - finish: string - cost: number - tokens: { - input: number - output: number - reasoning: number - cache: { - read: number - write: number - } - } - snapshot?: string - files?: Array - } -} - -export type SessionNextStepFailed = { - id: string - metadata?: { - [key: string]: unknown - } - type: "session.next.step.failed" - durable?: { - aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - } - location?: LocationRef - data: { - timestamp: number - sessionID: string - assistantMessageID: string - error: SessionErrorUnknown - } -} - -export type SessionNextTextStarted = { - id: string - metadata?: { - [key: string]: unknown - } - type: "session.next.text.started" - durable?: { - aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - } - location?: LocationRef - data: { - timestamp: number - sessionID: string - assistantMessageID: string - textID: string - } -} - -export type SessionNextTextEnded = { - id: string - metadata?: { - [key: string]: unknown - } - type: "session.next.text.ended" - durable?: { - aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - } - location?: LocationRef - data: { - timestamp: number - sessionID: string - assistantMessageID: string - textID: string - text: string - } -} - -export type SessionNextToolInputStarted = { - id: string - metadata?: { - [key: string]: unknown - } - type: "session.next.tool.input.started" - durable?: { - aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - } - location?: LocationRef - data: { - timestamp: number - sessionID: string - assistantMessageID: string - callID: string - name: string - } -} - -export type SessionNextToolInputEnded = { - id: string - metadata?: { - [key: string]: unknown - } - type: "session.next.tool.input.ended" - durable?: { - aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - } - location?: LocationRef - data: { - timestamp: number - sessionID: string - assistantMessageID: string - callID: string - text: string - } -} - -export type SessionNextToolCalled = { - id: string - metadata?: { - [key: string]: unknown - } - type: "session.next.tool.called" - durable?: { - aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - } - location?: LocationRef - data: { - timestamp: number - sessionID: string - assistantMessageID: string - callID: string - tool: string - input: { - [key: string]: unknown - } - provider: { - executed: boolean - metadata?: LlmProviderMetadata - } - } -} - -export type SessionNextToolProgress = { - id: string - metadata?: { - [key: string]: unknown - } - type: "session.next.tool.progress" - durable?: { - aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - } - location?: LocationRef - data: { - timestamp: number - sessionID: string - assistantMessageID: string - callID: string - structured: { - [key: string]: unknown - } - content: Array - } -} - -export type SessionNextToolSuccess = { - id: string - metadata?: { - [key: string]: unknown - } - type: "session.next.tool.success" - durable?: { - aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - } - location?: LocationRef - data: { - timestamp: number - sessionID: string - assistantMessageID: string - callID: string - structured: { - [key: string]: unknown - } - content: Array - outputPaths?: Array - result?: unknown - provider: { - executed: boolean - metadata?: LlmProviderMetadata - } - } -} - -export type SessionNextToolFailed = { - id: string - metadata?: { - [key: string]: unknown - } - type: "session.next.tool.failed" - durable?: { - aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - } - location?: LocationRef - data: { - timestamp: number - sessionID: string - assistantMessageID: string - callID: string - error: SessionErrorUnknown - result?: unknown - provider: { - executed: boolean - metadata?: LlmProviderMetadata - } - } -} - -export type SessionNextReasoningStarted = { - id: string - metadata?: { - [key: string]: unknown - } - type: "session.next.reasoning.started" - durable?: { - aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - } - location?: LocationRef - data: { - timestamp: number - sessionID: string - assistantMessageID: string - reasoningID: string - providerMetadata?: LlmProviderMetadata - } -} - -export type SessionNextReasoningEnded = { - id: string - metadata?: { - [key: string]: unknown - } - type: "session.next.reasoning.ended" - durable?: { - aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - } - location?: LocationRef - data: { - timestamp: number - sessionID: string - assistantMessageID: string - reasoningID: string - text: string - providerMetadata?: LlmProviderMetadata - } -} - -export type SessionNextRetried = { - id: string - metadata?: { +export type SessionMessageToolStatePending = { + status: "pending" + input: string +} + +export type SessionMessageToolStateRunning = { + status: "running" + input: { [key: string]: unknown } - type: "session.next.retried" - durable?: { - aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - } - location?: LocationRef - data: { - timestamp: number - sessionID: string - attempt: number - error: SessionNextRetryError + structured: { + [key: string]: unknown } + content: Array } -export type SessionNextCompactionStarted = { - id: string - metadata?: { +export type SessionMessageToolStateCompleted = { + status: "completed" + input: { [key: string]: unknown } - type: "session.next.compaction.started" - durable?: { - aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - } - location?: LocationRef - data: { - timestamp: number - sessionID: string - messageID: string - reason: "auto" | "manual" + attachments?: Array + content: Array + outputPaths?: Array + structured: { + [key: string]: unknown } + result?: unknown } -export type SessionNextCompactionEnded = { - id: string - metadata?: { +export type SessionMessageToolStateError = { + status: "error" + input: { [key: string]: unknown } - type: "session.next.compaction.ended" - durable?: { - aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - } - location?: LocationRef - data: { - timestamp: number - sessionID: string - messageID: string - reason: "auto" | "manual" - text: string - recent: string + content: Array + structured: { + [key: string]: unknown } + error: SessionErrorUnknown + result?: unknown } -export type SessionNextRevertStaged = { +export type SessionMessageAssistantTool = { + type: "tool" id: string - metadata?: { - [key: string]: unknown - } - type: "session.next.revert.staged" - durable?: { - aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + name: string + provider?: { + executed: boolean + metadata?: LlmProviderMetadata + resultMetadata?: LlmProviderMetadata } - location?: LocationRef - data: { - timestamp: number - sessionID: string - revert: RevertState + state: + | SessionMessageToolStatePending + | SessionMessageToolStateRunning + | SessionMessageToolStateCompleted + | SessionMessageToolStateError + time: { + created: number + ran?: number + completed?: number + pruned?: number } } -export type SessionNextRevertCleared = { +export type SessionMessageAssistant = { id: string metadata?: { [key: string]: unknown } - type: "session.next.revert.cleared" - durable?: { - aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + time: { + created: number + completed?: number } - location?: LocationRef - data: { - timestamp: number - sessionID: string + type: "assistant" + agent: string + model: ModelRef + content: Array + snapshot?: { + start?: string + end?: string + files?: Array } + finish?: string + cost?: number + tokens?: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } + error?: SessionErrorUnknown } -export type SessionNextRevertCommitted = { +export type SessionMessageCompaction = { + type: "compaction" + reason: "auto" | "manual" + summary: string + recent: string id: string metadata?: { [key: string]: unknown } - type: "session.next.revert.committed" - durable?: { - aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - } - location?: LocationRef - data: { - timestamp: number - sessionID: string - messageID: string + time: { + created: number } } -export type SessionNextAgentSwitched1 = { +export type SessionMessage = + | SessionMessageAgentSwitched + | SessionMessageModelSwitched + | SessionMessageUser + | SessionMessageSynthetic + | SessionMessageSystem + | SessionMessageShell + | SessionMessageAssistant + | SessionMessageCompaction + +export type SessionNextAgentSwitched = { id: string metadata?: { [key: string]: unknown @@ -4701,8 +4094,8 @@ export type SessionNextAgentSwitched1 = { type: "session.next.agent.switched" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" } location?: LocationRef data: { @@ -4713,7 +4106,7 @@ export type SessionNextAgentSwitched1 = { } } -export type SessionNextModelSwitched1 = { +export type SessionNextModelSwitched = { id: string metadata?: { [key: string]: unknown @@ -4721,8 +4114,8 @@ export type SessionNextModelSwitched1 = { type: "session.next.model.switched" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" } location?: LocationRef data: { @@ -4733,7 +4126,7 @@ export type SessionNextModelSwitched1 = { } } -export type SessionNextMoved1 = { +export type SessionNextMoved = { id: string metadata?: { [key: string]: unknown @@ -4741,8 +4134,8 @@ export type SessionNextMoved1 = { type: "session.next.moved" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" } location?: LocationRef data: { @@ -4753,7 +4146,7 @@ export type SessionNextMoved1 = { } } -export type SessionNextPrompted1 = { +export type SessionNextPrompted = { id: string metadata?: { [key: string]: unknown @@ -4761,8 +4154,8 @@ export type SessionNextPrompted1 = { type: "session.next.prompted" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" } location?: LocationRef data: { @@ -4774,7 +4167,7 @@ export type SessionNextPrompted1 = { } } -export type SessionNextPromptAdmitted1 = { +export type SessionNextPromptAdmitted = { id: string metadata?: { [key: string]: unknown @@ -4782,8 +4175,8 @@ export type SessionNextPromptAdmitted1 = { type: "session.next.prompt.admitted" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" } location?: LocationRef data: { @@ -4795,7 +4188,7 @@ export type SessionNextPromptAdmitted1 = { } } -export type SessionNextContextUpdated1 = { +export type SessionNextContextUpdated = { id: string metadata?: { [key: string]: unknown @@ -4803,8 +4196,8 @@ export type SessionNextContextUpdated1 = { type: "session.next.context.updated" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" } location?: LocationRef data: { @@ -4815,7 +4208,7 @@ export type SessionNextContextUpdated1 = { } } -export type SessionNextSynthetic1 = { +export type SessionNextSynthetic = { id: string metadata?: { [key: string]: unknown @@ -4823,8 +4216,8 @@ export type SessionNextSynthetic1 = { type: "session.next.synthetic" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" } location?: LocationRef data: { @@ -4835,7 +4228,7 @@ export type SessionNextSynthetic1 = { } } -export type SessionNextShellStarted1 = { +export type SessionNextShellStarted = { id: string metadata?: { [key: string]: unknown @@ -4843,8 +4236,8 @@ export type SessionNextShellStarted1 = { type: "session.next.shell.started" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" } location?: LocationRef data: { @@ -4856,7 +4249,7 @@ export type SessionNextShellStarted1 = { } } -export type SessionNextShellEnded1 = { +export type SessionNextShellEnded = { id: string metadata?: { [key: string]: unknown @@ -4864,8 +4257,8 @@ export type SessionNextShellEnded1 = { type: "session.next.shell.ended" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" } location?: LocationRef data: { @@ -4876,7 +4269,7 @@ export type SessionNextShellEnded1 = { } } -export type SessionNextStepStarted1 = { +export type SessionNextStepStarted = { id: string metadata?: { [key: string]: unknown @@ -4884,8 +4277,8 @@ export type SessionNextStepStarted1 = { type: "session.next.step.started" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" } location?: LocationRef data: { @@ -4898,7 +4291,7 @@ export type SessionNextStepStarted1 = { } } -export type SessionNextStepEnded1 = { +export type SessionNextStepEnded = { id: string metadata?: { [key: string]: unknown @@ -4906,8 +4299,8 @@ export type SessionNextStepEnded1 = { type: "session.next.step.ended" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" } location?: LocationRef data: { @@ -4930,7 +4323,7 @@ export type SessionNextStepEnded1 = { } } -export type SessionNextStepFailed1 = { +export type SessionNextStepFailed = { id: string metadata?: { [key: string]: unknown @@ -4938,8 +4331,8 @@ export type SessionNextStepFailed1 = { type: "session.next.step.failed" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" } location?: LocationRef data: { @@ -4950,7 +4343,7 @@ export type SessionNextStepFailed1 = { } } -export type SessionNextTextStarted1 = { +export type SessionNextTextStarted = { id: string metadata?: { [key: string]: unknown @@ -4958,8 +4351,8 @@ export type SessionNextTextStarted1 = { type: "session.next.text.started" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" } location?: LocationRef data: { @@ -4970,7 +4363,7 @@ export type SessionNextTextStarted1 = { } } -export type SessionNextTextEnded1 = { +export type SessionNextTextEnded = { id: string metadata?: { [key: string]: unknown @@ -4978,8 +4371,8 @@ export type SessionNextTextEnded1 = { type: "session.next.text.ended" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" } location?: LocationRef data: { @@ -4991,7 +4384,7 @@ export type SessionNextTextEnded1 = { } } -export type SessionNextToolInputStarted1 = { +export type SessionNextToolInputStarted = { id: string metadata?: { [key: string]: unknown @@ -4999,8 +4392,8 @@ export type SessionNextToolInputStarted1 = { type: "session.next.tool.input.started" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" } location?: LocationRef data: { @@ -5012,7 +4405,7 @@ export type SessionNextToolInputStarted1 = { } } -export type SessionNextToolInputEnded1 = { +export type SessionNextToolInputEnded = { id: string metadata?: { [key: string]: unknown @@ -5020,8 +4413,8 @@ export type SessionNextToolInputEnded1 = { type: "session.next.tool.input.ended" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" } location?: LocationRef data: { @@ -5033,7 +4426,7 @@ export type SessionNextToolInputEnded1 = { } } -export type SessionNextToolCalled1 = { +export type SessionNextToolCalled = { id: string metadata?: { [key: string]: unknown @@ -5041,8 +4434,8 @@ export type SessionNextToolCalled1 = { type: "session.next.tool.called" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" } location?: LocationRef data: { @@ -5061,7 +4454,7 @@ export type SessionNextToolCalled1 = { } } -export type SessionNextToolProgress1 = { +export type SessionNextToolProgress = { id: string metadata?: { [key: string]: unknown @@ -5069,8 +4462,8 @@ export type SessionNextToolProgress1 = { type: "session.next.tool.progress" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" } location?: LocationRef data: { @@ -5085,7 +4478,7 @@ export type SessionNextToolProgress1 = { } } -export type SessionNextToolSuccess1 = { +export type SessionNextToolSuccess = { id: string metadata?: { [key: string]: unknown @@ -5093,8 +4486,8 @@ export type SessionNextToolSuccess1 = { type: "session.next.tool.success" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" } location?: LocationRef data: { @@ -5115,7 +4508,7 @@ export type SessionNextToolSuccess1 = { } } -export type SessionNextToolFailed1 = { +export type SessionNextToolFailed = { id: string metadata?: { [key: string]: unknown @@ -5123,8 +4516,8 @@ export type SessionNextToolFailed1 = { type: "session.next.tool.failed" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" } location?: LocationRef data: { @@ -5141,7 +4534,7 @@ export type SessionNextToolFailed1 = { } } -export type SessionNextReasoningStarted1 = { +export type SessionNextReasoningStarted = { id: string metadata?: { [key: string]: unknown @@ -5149,8 +4542,8 @@ export type SessionNextReasoningStarted1 = { type: "session.next.reasoning.started" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" } location?: LocationRef data: { @@ -5162,7 +4555,7 @@ export type SessionNextReasoningStarted1 = { } } -export type SessionNextReasoningEnded1 = { +export type SessionNextReasoningEnded = { id: string metadata?: { [key: string]: unknown @@ -5170,8 +4563,8 @@ export type SessionNextReasoningEnded1 = { type: "session.next.reasoning.ended" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" } location?: LocationRef data: { @@ -5184,7 +4577,7 @@ export type SessionNextReasoningEnded1 = { } } -export type SessionNextRetried1 = { +export type SessionNextRetried = { id: string metadata?: { [key: string]: unknown @@ -5192,8 +4585,8 @@ export type SessionNextRetried1 = { type: "session.next.retried" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" } location?: LocationRef data: { @@ -5204,7 +4597,7 @@ export type SessionNextRetried1 = { } } -export type SessionNextCompactionStarted1 = { +export type SessionNextCompactionStarted = { id: string metadata?: { [key: string]: unknown @@ -5212,8 +4605,8 @@ export type SessionNextCompactionStarted1 = { type: "session.next.compaction.started" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" } location?: LocationRef data: { @@ -5224,7 +4617,7 @@ export type SessionNextCompactionStarted1 = { } } -export type SessionNextCompactionEnded1 = { +export type SessionNextCompactionEnded = { id: string metadata?: { [key: string]: unknown @@ -5232,8 +4625,8 @@ export type SessionNextCompactionEnded1 = { type: "session.next.compaction.ended" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" } location?: LocationRef data: { @@ -5246,7 +4639,7 @@ export type SessionNextCompactionEnded1 = { } } -export type SessionNextRevertStaged1 = { +export type SessionNextRevertStaged = { id: string metadata?: { [key: string]: unknown @@ -5254,8 +4647,8 @@ export type SessionNextRevertStaged1 = { type: "session.next.revert.staged" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" } location?: LocationRef data: { @@ -5265,7 +4658,7 @@ export type SessionNextRevertStaged1 = { } } -export type SessionNextRevertCleared1 = { +export type SessionNextRevertCleared = { id: string metadata?: { [key: string]: unknown @@ -5273,8 +4666,8 @@ export type SessionNextRevertCleared1 = { type: "session.next.revert.cleared" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" } location?: LocationRef data: { @@ -5283,7 +4676,7 @@ export type SessionNextRevertCleared1 = { } } -export type SessionNextRevertCommitted1 = { +export type SessionNextRevertCommitted = { id: string metadata?: { [key: string]: unknown @@ -5291,8 +4684,8 @@ export type SessionNextRevertCommitted1 = { type: "session.next.revert.committed" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" } location?: LocationRef data: { @@ -13034,9 +12427,9 @@ export type V2SessionHistoryData = { sessionID: string } query?: { - after?: string - through?: string limit?: string + after?: string + cursor?: string } url: "/api/session/{sessionID}/history" } @@ -13102,7 +12495,7 @@ export type V2SessionEventsResponses = { 200: { id: string event: string - data: string + data: SessionDurableEvent1 } } diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts index e2adde484bc8..7c0dd21df872 100644 --- a/packages/server/src/handlers/session.ts +++ b/packages/server/src/handlers/session.ts @@ -2,7 +2,7 @@ import { SessionV2 } from "@opencode-ai/core/session" import { DateTime, Effect, Stream } from "effect" import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" import { Api } from "../api" -import { SessionsCursor } from "@opencode-ai/protocol/groups/session" +import { SessionHistoryCursor, SessionsCursor } from "@opencode-ai/protocol/groups/session" import { ConflictError, InvalidCursorError, @@ -332,14 +332,27 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl .handle( "session.history", Effect.fn(function* (ctx) { + const continuation = + ctx.query.cursor !== undefined + ? yield* SessionHistoryCursor.parse(ctx.query.cursor).pipe( + Effect.mapError(() => new InvalidCursorError({ message: "Invalid cursor" })), + ) + : undefined return yield* session .history({ sessionID: ctx.params.sessionID, - after: ctx.query.after, - through: ctx.query.through, + after: continuation?.after ?? ctx.query.after, + through: continuation?.through, limit: ctx.query.limit ?? DefaultSessionHistoryLimit, }) .pipe( + Effect.map((page) => ({ + events: page.events, + cursor: + page.nextAfter === undefined + ? undefined + : SessionHistoryCursor.make({ after: page.nextAfter, through: page.through }), + })), Effect.catchTag( "Session.NotFoundError", (error) => diff --git a/specs/v2/schema-changelog.md b/specs/v2/schema-changelog.md index fd75a0be0c95..1ddfa3c01df5 100644 --- a/specs/v2/schema-changelog.md +++ b/specs/v2/schema-changelog.md @@ -3,7 +3,7 @@ ## 2026-06-26: Add Finite Session History - Add `GET /api/session/:sessionID/history` and generated Promise, Effect, and legacy JavaScript client methods. -- Page only public durable Session events with an exclusive aggregate `after`, a fixed aggregate-head `through`, and an explicit `nextAfter` continuation value. +- Page only public durable Session events with an exclusive initial `after` and an opaque continuation cursor that preserves the fixed aggregate cutoff. - Keep aggregate gaps legal, cap pages at 100 events, and preserve the existing durable replay-and-tail `sessions.events()` stream unchanged. - Add no migration or durable-event version; this is a finite read API over the existing event manifest. diff --git a/specs/v2/session.md b/specs/v2/session.md index b4548b1dc429..b995335adc13 100644 --- a/specs/v2/session.md +++ b/specs/v2/session.md @@ -176,7 +176,7 @@ The synchronized `session.next.*` event family and projected Session-message mod The first `sessions.events(...)` contract is durable-only during both replay and live tailing. This keeps one cursor equal to one persisted aggregate sequence and is sufficient for reconnect-safe consumers. A later UI-facing API may optionally interleave live-only deltas while connected, but those fragments must remain explicitly ephemeral: they cannot advance the durable cursor, replay after reconnect, or be mistaken for publication boundaries. -`sessions.history({ sessionID, after?, through?, limit? })` is the finite counterpart for request/response consumers. `after` is an exclusive aggregate sequence and omission starts before sequence zero. The first page captures the current aggregate head as `through`; every later page must reuse that value, so commits above the cutoff cannot enter the chain. Public durable Session events are selected before pagination, which permits gaps from private or historical aggregate events while preserving strictly increasing unique sequences. A present `nextAfter` is passed unchanged as the next exclusive `after`; an omitted `nextAfter` means that fixed snapshot is exhausted. +`sessions.history({ sessionID, after?, limit? })` is the finite counterpart for request/response consumers. `after` is an exclusive aggregate sequence used only to start a new snapshot, and omission starts before sequence zero. A returned opaque `cursor` continues that fixed snapshot through `sessions.history({ sessionID, cursor, limit? })`, so commits above the captured cutoff cannot enter the chain. `after` and `cursor` are mutually exclusive. Public durable Session events are selected before pagination, which permits gaps from private or historical aggregate events while preserving strictly increasing unique sequences. An omitted response cursor means that fixed snapshot is exhausted. The finite endpoint is `GET /api/session/:sessionID/history`, uses the normal Session Location and authorization middleware, defaults to 50 events, and accepts at most 100. It returns only events in the public durable Session schema. The existing `sessions.events()` replay-and-tail stream is unchanged. From 0e7e1c2c8d000ccfbf7c3a7419bed94d632fc2d0 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 26 Jun 2026 14:10:57 -0400 Subject: [PATCH 4/5] fix(api): unify session history positions --- packages/client/src/effect.ts | 1 + .../client/src/generated-effect/client.ts | 24 +++-- packages/client/src/generated/client.ts | 2 +- packages/client/src/generated/types.ts | 27 ++--- packages/client/src/index.ts | 1 + packages/client/src/session-history-cursor.ts | 17 ++++ packages/client/test/effect.test.ts | 32 ++++-- packages/client/test/promise.test.ts | 29 +++--- packages/httpapi-codegen/src/index.ts | 99 ++++++------------- .../httpapi-codegen/test/generate.test.ts | 25 ----- .../test/server/httpapi-exercise/index.ts | 9 +- packages/protocol/src/groups/session.ts | 39 +++----- packages/protocol/test/session-cursor.test.ts | 26 +++-- packages/sdk-next/src/index.ts | 1 + packages/sdk-next/test/embedded.test.ts | 5 + packages/sdk/js/package.json | 1 + packages/sdk/js/src/v2/client.ts | 1 + packages/sdk/js/src/v2/gen/sdk.gen.ts | 4 +- packages/sdk/js/src/v2/gen/types.gen.ts | 7 +- .../sdk/js/src/v2/session-history-cursor.ts | 17 ++++ .../js/test/session-history-cursor.test.ts | 7 ++ packages/server/src/handlers/session.ts | 20 ++-- specs/v2/schema-changelog.md | 2 +- specs/v2/session.md | 2 +- 24 files changed, 213 insertions(+), 185 deletions(-) create mode 100644 packages/client/src/session-history-cursor.ts create mode 100644 packages/sdk/js/src/v2/session-history-cursor.ts create mode 100644 packages/sdk/js/test/session-history-cursor.test.ts diff --git a/packages/client/src/effect.ts b/packages/client/src/effect.ts index 6c0aca564e2c..339fb8fd7959 100644 --- a/packages/client/src/effect.ts +++ b/packages/client/src/effect.ts @@ -10,3 +10,4 @@ export { Session } from "@opencode-ai/schema/session" export { SessionInput } from "@opencode-ai/schema/session-input" export { SessionMessage } from "@opencode-ai/schema/session-message" export { Prompt } from "@opencode-ai/schema/prompt" +export { SessionHistoryCursor } from "@opencode-ai/protocol/groups/session" diff --git a/packages/client/src/generated-effect/client.ts b/packages/client/src/generated-effect/client.ts index 6b37d1b25b00..0423cb5e1053 100644 --- a/packages/client/src/generated-effect/client.ts +++ b/packages/client/src/generated-effect/client.ts @@ -70,7 +70,8 @@ const Endpoint0_3 = (raw: RawClient["server.session"]) => (input: Endpoint0_3Inp ) type Endpoint0_4Request = Parameters[0] -type Endpoint0_4Input = { readonly sessionID: Endpoint0_4Request["params"]["sessionID"] } & { +type Endpoint0_4Input = { + readonly sessionID: Endpoint0_4Request["params"]["sessionID"] readonly agent: Endpoint0_4Request["payload"]["agent"] } const Endpoint0_4 = (raw: RawClient["server.session"]) => (input: Endpoint0_4Input) => @@ -79,7 +80,8 @@ const Endpoint0_4 = (raw: RawClient["server.session"]) => (input: Endpoint0_4Inp ) type Endpoint0_5Request = Parameters[0] -type Endpoint0_5Input = { readonly sessionID: Endpoint0_5Request["params"]["sessionID"] } & { +type Endpoint0_5Input = { + readonly sessionID: Endpoint0_5Request["params"]["sessionID"] readonly model: Endpoint0_5Request["payload"]["model"] } const Endpoint0_5 = (raw: RawClient["server.session"]) => (input: Endpoint0_5Input) => @@ -88,7 +90,8 @@ const Endpoint0_5 = (raw: RawClient["server.session"]) => (input: Endpoint0_5Inp ) type Endpoint0_6Request = Parameters[0] -type Endpoint0_6Input = { readonly sessionID: Endpoint0_6Request["params"]["sessionID"] } & { +type Endpoint0_6Input = { + readonly sessionID: Endpoint0_6Request["params"]["sessionID"] readonly id?: Endpoint0_6Request["payload"]["id"] readonly prompt: Endpoint0_6Request["payload"]["prompt"] readonly delivery?: Endpoint0_6Request["payload"]["delivery"] @@ -114,7 +117,8 @@ const Endpoint0_8 = (raw: RawClient["server.session"]) => (input: Endpoint0_8Inp raw["session.wait"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError)) type Endpoint0_9Request = Parameters[0] -type Endpoint0_9Input = { readonly sessionID: Endpoint0_9Request["params"]["sessionID"] } & { +type Endpoint0_9Input = { + readonly sessionID: Endpoint0_9Request["params"]["sessionID"] readonly messageID: Endpoint0_9Request["payload"]["messageID"] readonly files?: Endpoint0_9Request["payload"]["files"] } @@ -148,12 +152,18 @@ const Endpoint0_12 = (raw: RawClient["server.session"]) => (input: Endpoint0_12I type Endpoint0_13Request = Parameters[0] type Endpoint0_13Input = { readonly sessionID: Endpoint0_13Request["params"]["sessionID"] -} & Endpoint0_13Request["query"] + readonly limit?: Endpoint0_13Request["query"]["limit"] + readonly cursor?: Endpoint0_13Request["query"]["cursor"] +} const Endpoint0_13 = (raw: RawClient["server.session"]) => (input: Endpoint0_13Input) => - raw["session.history"]({ params: { sessionID: input.sessionID }, query: input }).pipe(Effect.mapError(mapClientError)) + raw["session.history"]({ + params: { sessionID: input.sessionID }, + query: { limit: input.limit, cursor: input.cursor }, + }).pipe(Effect.mapError(mapClientError)) type Endpoint0_14Request = Parameters[0] -type Endpoint0_14Input = { readonly sessionID: Endpoint0_14Request["params"]["sessionID"] } & { +type Endpoint0_14Input = { + readonly sessionID: Endpoint0_14Request["params"]["sessionID"] readonly after?: Endpoint0_14Request["query"]["after"] } const Endpoint0_14 = (raw: RawClient["server.session"]) => (input: Endpoint0_14Input) => diff --git a/packages/client/src/generated/client.ts b/packages/client/src/generated/client.ts index c9dcf4e6a816..f857701dc933 100644 --- a/packages/client/src/generated/client.ts +++ b/packages/client/src/generated/client.ts @@ -331,7 +331,7 @@ export function make(options: ClientOptions) { { method: "GET", path: `/api/session/${encodeURIComponent(input.sessionID)}/history`, - query: { limit: input.limit, after: input.after, cursor: input.cursor }, + query: { limit: input.limit, cursor: input.cursor }, successStatus: 200, declaredStatuses: [404, 400, 401], empty: false, diff --git a/packages/client/src/generated/types.ts b/packages/client/src/generated/types.ts index e026bcc61b76..98546b68c31d 100644 --- a/packages/client/src/generated/types.ts +++ b/packages/client/src/generated/types.ts @@ -281,13 +281,15 @@ export type SessionsGetOutput = { } }["data"] -export type SessionsSwitchAgentInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } & { +export type SessionsSwitchAgentInput = { + readonly sessionID: { readonly sessionID: string }["sessionID"] readonly agent: { readonly agent: string }["agent"] } export type SessionsSwitchAgentOutput = void -export type SessionsSwitchModelInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } & { +export type SessionsSwitchModelInput = { + readonly sessionID: { readonly sessionID: string }["sessionID"] readonly model: { readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } }["model"] @@ -295,7 +297,8 @@ export type SessionsSwitchModelInput = { readonly sessionID: { readonly sessionI export type SessionsSwitchModelOutput = void -export type SessionsPromptInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } & { +export type SessionsPromptInput = { + readonly sessionID: { readonly sessionID: string }["sessionID"] readonly id?: { readonly id?: string | null readonly prompt: { @@ -403,7 +406,8 @@ export type SessionsWaitInput = { readonly sessionID: { readonly sessionID: stri export type SessionsWaitOutput = void -export type SessionsStageInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } & { +export type SessionsStageInput = { + readonly sessionID: { readonly sessionID: string }["sessionID"] readonly messageID: { readonly messageID: string; readonly files?: boolean | undefined }["messageID"] readonly files?: { readonly messageID: string; readonly files?: boolean | undefined }["files"] } @@ -587,14 +591,14 @@ export type SessionsContextOutput = { > }["data"] -export type SessionsHistoryInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } & { - readonly limit?: string | undefined - readonly after?: string | undefined - readonly cursor?: string | undefined +export type SessionsHistoryInput = { + readonly sessionID: { readonly sessionID: string }["sessionID"] + readonly limit?: { readonly limit?: string | undefined; readonly cursor?: string | undefined }["limit"] + readonly cursor?: { readonly limit?: string | undefined; readonly cursor?: string | undefined }["cursor"] } export type SessionsHistoryOutput = { - readonly events: ReadonlyArray< + readonly data: ReadonlyArray< | { readonly id: string readonly metadata?: { readonly [x: string]: JsonValue } @@ -1156,10 +1160,11 @@ export type SessionsHistoryOutput = { readonly data: { readonly timestamp: number; readonly sessionID: string; readonly messageID: string } } > - readonly cursor?: string + readonly cursor: { readonly next?: string } } -export type SessionsEventsInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } & { +export type SessionsEventsInput = { + readonly sessionID: { readonly sessionID: string }["sessionID"] readonly after?: { readonly after?: string | undefined }["after"] } diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 92e36b1c6054..d67911338df2 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -1 +1,2 @@ export * from "./generated/index" +export { SessionHistoryCursor } from "./session-history-cursor" diff --git a/packages/client/src/session-history-cursor.ts b/packages/client/src/session-history-cursor.ts new file mode 100644 index 000000000000..1d3e7a02de8c --- /dev/null +++ b/packages/client/src/session-history-cursor.ts @@ -0,0 +1,17 @@ +export type SessionHistoryCursor = string + +export const SessionHistoryCursor = { + after(sequence: number): SessionHistoryCursor { + if (!Number.isSafeInteger(sequence) || sequence < 0) { + throw new RangeError("Session history sequence must be a non-negative safe integer") + } + return encode({ after: sequence }) + }, +} + +function encode(value: { readonly after: number }) { + const bytes = new TextEncoder().encode(JSON.stringify(value)) + let binary = "" + for (const byte of bytes) binary += String.fromCharCode(byte) + return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "") +} diff --git a/packages/client/test/effect.test.ts b/packages/client/test/effect.test.ts index f25bc899903d..f7d17b8bf488 100644 --- a/packages/client/test/effect.test.ts +++ b/packages/client/test/effect.test.ts @@ -1,8 +1,17 @@ import { expect, test } from "bun:test" import { DateTime, Effect, Stream } from "effect" import { HttpClient, HttpClientResponse } from "effect/unstable/http" -import { AbsolutePath, Agent, Location, Model, OpenCode, Prompt, Session, SessionMessage } from "../src/effect" -import { SessionHistoryCursor } from "@opencode-ai/protocol/groups/session" +import { + AbsolutePath, + Agent, + Location, + Model, + OpenCode, + Prompt, + Session, + SessionHistoryCursor, + SessionMessage, +} from "../src/effect" test("sessions.get returns the decoded Effect projection", async () => { const httpClient = HttpClient.make((request) => @@ -38,7 +47,9 @@ test("session methods retain decoded Effect inputs and outputs", async () => { HttpClientResponse.fromWeb( request, Response.json( - historyPage === 1 ? { events: [modelSwitchedEvent], cursor: "opaque_history_cursor" } : { events: [] }, + historyPage === 1 + ? { data: [modelSwitchedEvent], cursor: { next: "opaque_history_cursor" } } + : { data: [], cursor: {} }, ), ), ) @@ -89,13 +100,13 @@ test("session methods retain decoded Effect inputs and outputs", async () => { const context = yield* client.sessions.context({ sessionID: Session.ID.make("ses_test") }) const history = yield* client.sessions.history({ sessionID: Session.ID.make("ses_test"), - after: 0, + cursor: SessionHistoryCursor.after(0), limit: 1, }) - const historyNext = history.cursor + const historyNext = history.cursor.next ? yield* client.sessions.history({ sessionID: Session.ID.make("ses_test"), - cursor: history.cursor, + cursor: history.cursor.next, limit: 2, }) : undefined @@ -119,9 +130,10 @@ test("session methods retain decoded Effect inputs and outputs", async () => { expect(Object.getPrototypeOf(result.admitted.prompt)).toBe(Object.prototype) expect(DateTime.toEpochMillis(result.admitted.timeCreated)).toBe(1_717_171_717_000) expect(result.context).toEqual([]) - expect(DateTime.toEpochMillis(result.history.events[0].data.timestamp)).toBe(1_717_171_717_000) - expect(result.history).toEqual(expect.objectContaining({ cursor: "opaque_history_cursor" })) - expect(result.historyNext).toEqual({ events: [] }) + expect(DateTime.toEpochMillis(result.history.data[0].data.timestamp)).toBe(1_717_171_717_000) + expect(result.history).toEqual(expect.objectContaining({ cursor: { next: "opaque_history_cursor" } })) + expect(result.historyNext).toEqual({ data: [], cursor: {} }) + expect(historyQueries[0]).toEqual({ limit: "1", cursor: "eyJhZnRlciI6MH0" }) expect(historyQueries[1]).toEqual({ limit: "2", cursor: "opaque_history_cursor" }) expect(DateTime.toEpochMillis(result.events[0].data.timestamp)).toBe(1_717_171_717_000) expect(result.message).toEqual(expect.objectContaining({ id: "msg_model", type: "model-switched" })) @@ -141,7 +153,7 @@ test("sessions.history retains the typed InvalidCursorError", async () => { return yield* client.sessions .history({ sessionID: Session.ID.make("ses_test"), - cursor: SessionHistoryCursor.make({ after: 0, through: 0 }), + cursor: SessionHistoryCursor.after(0), }) .pipe(Effect.flip) }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise) diff --git a/packages/client/test/promise.test.ts b/packages/client/test/promise.test.ts index 4d8313c6300c..ff641a4c04da 100644 --- a/packages/client/test/promise.test.ts +++ b/packages/client/test/promise.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test" -import { isInvalidCursorError, isUnauthorizedError, OpenCode } from "../src" +import { isInvalidCursorError, isUnauthorizedError, OpenCode, SessionHistoryCursor } from "../src" test("sessions.get returns the wire projection", async () => { const client = OpenCode.make({ @@ -33,7 +33,9 @@ test("session methods use the public HTTP contract", async () => { if (url.includes("/history")) { historyPage++ return Response.json( - historyPage === 1 ? { events: [modelSwitchedEvent], cursor: "opaque_history_cursor" } : { events: [] }, + historyPage === 1 + ? { data: [modelSwitchedEvent], cursor: { next: "opaque_history_cursor" } } + : { data: [], cursor: {} }, ) } if (url.includes("/prompt")) return Response.json(admission) @@ -62,14 +64,11 @@ test("session methods use the public HTTP contract", async () => { await client.sessions.compact({ sessionID: "ses_test" }) await client.sessions.wait({ sessionID: "ses_test" }) const context = await client.sessions.context({ sessionID: "ses_test" }) - const history = await client.sessions.history({ sessionID: "ses_test", after: "0", limit: "1" }) - const historyNext = history.cursor - ? await client.sessions.history({ sessionID: "ses_test", cursor: history.cursor, limit: "2" }) + const initialCursor = SessionHistoryCursor.after(0) + const history = await client.sessions.history({ sessionID: "ses_test", cursor: initialCursor, limit: "1" }) + const historyNext = history.cursor.next + ? await client.sessions.history({ sessionID: "ses_test", cursor: history.cursor.next, limit: "2" }) : undefined - if (false) { - // @ts-expect-error after starts a snapshot while cursor continues one - await client.sessions.history({ sessionID: "ses_test", after: "0", cursor: "opaque_history_cursor" }) - } const events = [] for await (const event of client.sessions.events({ sessionID: "ses_test", after: "0" })) events.push(event) await client.sessions.interrupt({ sessionID: "ses_test" }) @@ -80,8 +79,9 @@ test("session methods use the public HTTP contract", async () => { expect(created.id).toBe("ses_test") expect(admitted.id).toBe("msg_test") expect(context).toEqual([]) - expect(history).toEqual({ events: [modelSwitchedEvent], cursor: "opaque_history_cursor" }) - expect(historyNext).toEqual({ events: [] }) + expect(initialCursor).toBe("eyJhZnRlciI6MH0") + expect(history).toEqual({ data: [modelSwitchedEvent], cursor: { next: "opaque_history_cursor" } }) + expect(historyNext).toEqual({ data: [], cursor: {} }) expect(events).toEqual([modelSwitchedEvent]) expect(message).toEqual(modelSwitchedMessage) expect(requests.map((request) => [request.init?.method, request.url])).toEqual([ @@ -94,7 +94,7 @@ test("session methods use the public HTTP contract", async () => { ["POST", "http://localhost:3000/api/session/ses_test/compact"], ["POST", "http://localhost:3000/api/session/ses_test/wait"], ["GET", "http://localhost:3000/api/session/ses_test/context"], - ["GET", "http://localhost:3000/api/session/ses_test/history?limit=1&after=0"], + ["GET", `http://localhost:3000/api/session/ses_test/history?limit=1&cursor=${initialCursor}`], ["GET", "http://localhost:3000/api/session/ses_test/history?limit=2&cursor=opaque_history_cursor"], ["GET", "http://localhost:3000/api/session/ses_test/event?after=0"], ["POST", "http://localhost:3000/api/session/ses_test/interrupt"], @@ -137,6 +137,11 @@ test("sessions.history decodes InvalidCursorError", async () => { } }) +test("SessionHistoryCursor rejects invalid durable checkpoints", () => { + expect(() => SessionHistoryCursor.after(-1)).toThrow(RangeError) + expect(() => SessionHistoryCursor.after(1.5)).toThrow(RangeError) +}) + const session = { data: { id: "ses_test", diff --git a/packages/httpapi-codegen/src/index.ts b/packages/httpapi-codegen/src/index.ts index 20b549f9b50f..289884d961a9 100644 --- a/packages/httpapi-codegen/src/index.ts +++ b/packages/httpapi-codegen/src/index.ts @@ -322,39 +322,28 @@ function renderImportedEffectFiles( const rawGroup = group.endpoints[0]?.topLevel ? "RawClient" : `RawClient[${JSON.stringify(group.sourceIdentifier)}]` const methods = group.endpoints.map((item, endpointIndex) => { const prefix = `Endpoint${groupIndex}_${endpointIndex}` - const schemaBySource = { - params: item.params, - query: item.query, - headers: item.headers, - payload: item.payloads[0], - } const request = (["params", "query", "headers", "payload"] as const) .flatMap((source) => { const fields = item.input.filter((field) => field.source === source) if (fields.length === 0) return [] - if (isStructUnion(schemaBySource[source])) return [`${source}: input`] return [ `${source}: { ${fields.map((field) => `${JSON.stringify(field.name)}: input${item.operation.inputMode === "optional" ? "?." : "."}${field.name}`).join(", ")} }`, ] }) .join(", ") - const input = (["params", "query", "headers", "payload"] as const) - .flatMap((source) => { - const fields = item.input.filter((field) => field.source === source) - if (fields.length === 0) return [] - if (isStructUnion(schemaBySource[source])) return [`${prefix}Request[${JSON.stringify(source)}]`] - return [ - `{ ${fields.map((field) => `readonly ${JSON.stringify(field.name)}${field.optional ? "?" : ""}: ${prefix}Request[${JSON.stringify(source)}][${JSON.stringify(field.name)}]`).join("; ")} }`, - ] - }) - .join(" & ") + const input = item.input + .map( + (field) => + `readonly ${JSON.stringify(field.name)}${field.optional ? "?" : ""}: ${prefix}Request[${JSON.stringify(field.source)}][${JSON.stringify(field.name)}]`, + ) + .join("; ") const argument = item.operation.inputMode === "none" ? "" : `input${item.operation.inputMode === "optional" ? "?" : ""}: ${prefix}Input` const rawCall = `raw[${JSON.stringify(item.endpoint.name)}]({ ${request} })` const mapped = `${rawCall}.pipe(Effect.mapError(mapClientError)${item.unwrapData ? ", Effect.map((value) => value.data)" : ""})` - return `${item.operation.inputMode === "none" ? "" : `type ${prefix}Request = Parameters<${rawGroup}[${JSON.stringify(item.endpoint.name)}]>[0]\ntype ${prefix}Input = ${input}\n`}const ${prefix} = (raw: ${rawGroup}) => (${argument}) => ${item.operation.success === "stream" ? `Stream.unwrap(${rawCall}.pipe(Effect.mapError(mapClientError), Effect.map((stream) => stream.pipe(Stream.mapError(mapClientError)))))` : mapped}` + return `${item.operation.inputMode === "none" ? "" : `type ${prefix}Request = Parameters<${rawGroup}[${JSON.stringify(item.endpoint.name)}]>[0]\ntype ${prefix}Input = { ${input} }\n`}const ${prefix} = (raw: ${rawGroup}) => (${argument}) => ${item.operation.success === "stream" ? `Stream.unwrap(${rawCall}.pipe(Effect.mapError(mapClientError), Effect.map((stream) => stream.pipe(Stream.mapError(mapClientError)))))` : mapped}` }) return `${methods.join("\n\n")}\n\nconst adaptGroup${groupIndex} = (raw: ${rawGroup}) => ({ ${group.endpoints.map((item, endpointIndex) => `${JSON.stringify(item.operation.name)}: Endpoint${groupIndex}_${endpointIndex}(raw)`).join(", ")} })` }) @@ -455,18 +444,14 @@ function renderPromiseTypes(groups: ReadonlyArray) { headers: endpoint.headers, payload: endpoint.payloads[0], } - const input = (["params", "query", "headers", "payload"] as const) - .flatMap((source) => { - const schema = schemas[source] - const fields = endpoint.input.filter((field) => field.source === source) - if (fields.length === 0) return [] - if (schema === undefined) throw new GenerationError({ reason: `Missing input schema: ${prefix}.${source}` }) - if (isStructUnion(schema)) return [`(${typeOf(schema)})`] - return [ - `{ ${fields.map((field) => `readonly ${JSON.stringify(field.name)}${field.optional ? "?" : ""}: (${typeOf(schema)})[${JSON.stringify(field.name)}]`).join("; ")} }`, - ] + const input = endpoint.input + .map((field) => { + const schema = schemas[field.source] + if (schema === undefined) + throw new GenerationError({ reason: `Missing input schema: ${prefix}.${field.name}` }) + return `readonly ${JSON.stringify(field.name)}${field.optional ? "?" : ""}: (${typeOf(schema)})[${JSON.stringify(field.name)}]` }) - .join(" & ") + .join("; ") const successSchema = endpoint.successes[0] const success = typeOf( isStreamSchema(successSchema) && successSchema._tag === "StreamSse" @@ -476,7 +461,7 @@ function renderPromiseTypes(groups: ReadonlyArray) { : successSchema, ) return [ - ...(endpoint.operation.inputMode === "none" ? [] : [`export type ${prefix}Input = ${input}`]), + ...(endpoint.operation.inputMode === "none" ? [] : [`export type ${prefix}Input = { ${input} }`]), `export type ${prefix}Output = ${endpoint.unwrapData ? `(${success})["data"]` : success}`, ] }), @@ -779,35 +764,19 @@ export function generate( function inputFields(schema: Schema.Top | undefined, source: InputField["source"], operation: string) { if (schema === undefined) return [] const ast = Schema.toType(schema).ast - const objects = SchemaAST.isUnion(ast) ? ast.types : [ast] - if (objects.some((item) => !SchemaAST.isObjects(item) || item.indexSignatures.length > 0)) { + if (!SchemaAST.isObjects(ast) || ast.indexSignatures.length > 0) { throw new GenerationError({ reason: `Input schema must be a struct: ${operation}.${source}` }) } - const names = new Set() - for (const object of objects) { - if (!SchemaAST.isObjects(object)) continue - for (const field of object.propertySignatures) { - if (typeof field.name !== "string") { - throw new GenerationError({ reason: `Input field must have a string name: ${operation}.${source}` }) - } - names.add(field.name) + return ast.propertySignatures.map((field) => { + if (typeof field.name !== "string") { + throw new GenerationError({ reason: `Input field must have a string name: ${operation}.${source}` }) } - } - return Array.from(names, (name) => ({ - name, - source, - optional: objects.some((object) => { - if (!SchemaAST.isObjects(object)) return false - const field = object.propertySignatures.find((field) => field.name === name) - return field === undefined || SchemaAST.isOptional(field.type) - }), - })) -} - -function isStructUnion(schema: Schema.Top | undefined) { - if (schema === undefined) return false - const ast = Schema.toType(schema).ast - return SchemaAST.isUnion(ast) && ast.types.every((item) => SchemaAST.isObjects(item)) + return { + name: field.name, + source, + optional: SchemaAST.isOptional(field.type), + } + }) } function responseSchemas(schema: Schema.Top, path: string): Array { @@ -1048,20 +1017,15 @@ function renderGroup(group: Group, groupIndex: number) { : `error: ${errorSlots.length === 1 ? errorSlots[0].name : `[${errorSlots.map((slot) => slot.name).join(", ")}]`}`, ].filter((option): option is string => option !== undefined) const schemaBySource = { params, query, headers, payload: payloads[0] } - const inputType = (["params", "query", "headers", "payload"] as const) - .flatMap((source) => { - const slot = schemaBySource[source] - const fields = operation.input.filter((field) => field.source === source) - if (fields.length === 0) return [] + const inputType = operation.input + .map((field) => { + const slot = schemaBySource[field.source] if (slot === undefined) { throw new GenerationError({ reason: `Missing input schema: ${group.identifier}.${endpoint.name}` }) } - if (isStructUnion(slot.schema)) return [`typeof ${slot.name}.Type`] - return [ - `{ ${fields.map((field) => `readonly ${JSON.stringify(field.name)}${field.optional ? "?" : ""}: (typeof ${slot.name}.Type)[${JSON.stringify(field.name)}]`).join("; ")} }`, - ] + return `readonly ${JSON.stringify(field.name)}${field.optional ? "?" : ""}: (typeof ${slot.name}.Type)[${JSON.stringify(field.name)}]` }) - .join(" & ") + .join("; ") const argument = operation.operation.inputMode === "none" ? "" @@ -1070,7 +1034,6 @@ function renderGroup(group: Group, groupIndex: number) { .flatMap((source) => { const slot = schemaBySource[source] if (slot === undefined) return [] - if (isStructUnion(slot.schema)) return [`${source}: input`] const fields = operation.input .filter((field) => field.source === source) .map( @@ -1085,7 +1048,7 @@ function renderGroup(group: Group, groupIndex: number) { declared.length === 0 ? "Schema.Never" : `Schema.Union([${declared.map((slot) => slot.name).join(", ")}])` const rawCall = `raw[${JSON.stringify(endpoint.name)}]({ ${request} })` const mapped = `${rawCall}.pipe(Effect.mapError(map${prefix}Error)${operation.unwrapData ? ", Effect.map((value) => value.data)" : ""})` - const inputDeclaration = operation.operation.inputMode === "none" ? "" : `type ${prefix}Input = ${inputType}\n` + const inputDeclaration = operation.operation.inputMode === "none" ? "" : `type ${prefix}Input = { ${inputType} }\n` adapters.push( `${inputDeclaration}const ${prefix}DeclaredError = ${declaredSchema}\nconst map${prefix}Error = (error: unknown) => HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error) ? new ClientError({ cause: error }) : Schema.is(${prefix}DeclaredError)(error) ? error : new ClientError({ cause: error })\nconst ${prefix} = (raw: RawGroup) => (${argument}) => ${operation.operation.success === "stream" ? `Stream.unwrap(${rawCall}.pipe(Effect.mapError(map${prefix}Error), Effect.map((stream) => stream.pipe(Stream.mapError(map${prefix}Error)))))` : mapped}`, ) diff --git a/packages/httpapi-codegen/test/generate.test.ts b/packages/httpapi-codegen/test/generate.test.ts index aa580e16dce6..543b2a13f378 100644 --- a/packages/httpapi-codegen/test/generate.test.ts +++ b/packages/httpapi-codegen/test/generate.test.ts @@ -48,31 +48,6 @@ describe("HttpApiCodegen.generate", () => { ) }) - test("preserves union-of-struct query inputs", () => { - const contract = compileContract( - api( - HttpApiEndpoint.get("history", "/session/:sessionID/history", { - params: { sessionID: Schema.String }, - query: Schema.Union([ - Schema.Struct({ after: Schema.optional(Schema.Number), cursor: Schema.optional(Schema.Never) }), - Schema.Struct({ after: Schema.optional(Schema.Never), cursor: Schema.String }), - ]), - success: Schema.String, - }), - ), - ) - - const promise = emitPromise(contract).files.find((file) => file.path === "types.ts")?.content - const effect = emitEffectImported(contract, { module: "@example/api", api: "Api" }).files.find( - (file) => file.path === "client.ts", - )?.content - - expect(promise).toContain("& ({ readonly") - expect(effect).toContain('type Endpoint0_0Input = { readonly "sessionID":') - expect(effect).toContain('& Endpoint0_0Request["query"]') - expect(effect).toContain("query: input") - }) - test("emits an Effect client against an imported authoritative API", () => { const output = emitEffectImported( compileContract( diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index 29c1f4927bbb..9945e30ca0b7 100644 --- a/packages/opencode/test/server/httpapi-exercise/index.ts +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -1072,7 +1072,7 @@ const scenarios: Scenario[] = [ .seeded((ctx) => ctx.session({ title: "Session history" })) .at((ctx) => ({ path: `${route("/api/session/{sessionID}/history", { sessionID: ctx.state.id })}?${new URLSearchParams({ - after: "0", + cursor: cursor({ after: 0 }), limit: "2", })}`, headers: ctx.headers(), @@ -1081,10 +1081,11 @@ const scenarios: Scenario[] = [ 200, (body) => { object(body) - array(body.events) + array(body.data) + object(body.cursor) check( - body.cursor === undefined || typeof body.cursor === "string", - "Expected an optional opaque history cursor", + body.cursor.next === undefined || typeof body.cursor.next === "string", + "Expected an optional next cursor", ) }, "none", diff --git a/packages/protocol/src/groups/session.ts b/packages/protocol/src/groups/session.ts index a6f94165b82c..887ace968966 100644 --- a/packages/protocol/src/groups/session.ts +++ b/packages/protocol/src/groups/session.ts @@ -82,7 +82,7 @@ export type SessionsCursor = typeof SessionsCursor.Type const SessionHistoryCursorInput = Schema.Struct({ after: Schema.Int.check(Schema.isGreaterThanOrEqualTo(-1)), - through: Schema.Int.check(Schema.isGreaterThanOrEqualTo(-1)), + through: Schema.Int.check(Schema.isGreaterThanOrEqualTo(-1)).pipe(Schema.optional), }) const SessionHistoryCursorJson = Schema.fromJsonString(SessionHistoryCursorInput) const encodeSessionHistoryCursor = Schema.encodeSync(SessionHistoryCursorJson) @@ -93,8 +93,10 @@ export const SessionHistoryCursor = Schema.String.pipe( statics((schema) => { const make = schema.make.bind(schema) return { - make: (input: typeof SessionHistoryCursorInput.Type) => - make(Encoding.encodeBase64Url(encodeSessionHistoryCursor(input))), + after: (sequence: number) => + make( + Encoding.encodeBase64Url(encodeSessionHistoryCursor({ after: Schema.decodeSync(NonNegativeInt)(sequence) })), + ), parse: (input: string) => Effect.suspend(() => { const result = Encoding.decodeBase64UrlString(input) @@ -107,34 +109,21 @@ export const SessionHistoryCursor = Schema.String.pipe( ) export type SessionHistoryCursor = typeof SessionHistoryCursor.Type +export const SessionHistoryCursorInternal = { + next: (after: number, through: number) => + Schema.decodeSync(SessionHistoryCursor)(Encoding.encodeBase64Url(encodeSessionHistoryCursor({ after, through }))), +} + const SessionActive = Schema.Struct({ type: Schema.Literal("running"), }).annotate({ identifier: "SessionActive" }) const SessionHistoryLimit = PositiveInt.check(Schema.isLessThanOrEqualTo(100)) -const SessionHistoryQueryFields = { - limit: SessionHistoryLimit.pipe(Schema.optional), -} - -const SessionHistoryQueryType = Schema.Union([ - Schema.Struct({ - ...SessionHistoryQueryFields, - after: NonNegativeInt.pipe(Schema.optional), - cursor: Schema.Never.pipe(Schema.optional), - }), - Schema.Struct({ - ...SessionHistoryQueryFields, - after: Schema.Never.pipe(Schema.optional), - cursor: SessionHistoryCursor, - }), -]) - export const SessionHistoryQuery = Schema.Struct({ limit: Schema.NumberFromString.pipe(Schema.decodeTo(SessionHistoryLimit), Schema.optional), - after: Schema.NumberFromString.pipe(Schema.decodeTo(NonNegativeInt), Schema.optional), cursor: SessionHistoryCursor.pipe(Schema.optional), -}).pipe(Schema.decodeTo(SessionHistoryQueryType)) +}) const SessionsQueryCursor = SessionsCursor.annotate({ description: "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response.", @@ -353,8 +342,8 @@ export const makeSessionGroup = (sessionLo params: { sessionID: Session.ID }, query: SessionHistoryQuery, success: Schema.Struct({ - events: Schema.Array(SessionEvent.Durable), - cursor: optional(SessionHistoryCursor), + data: Schema.Array(SessionEvent.Durable), + cursor: Schema.Struct({ next: optional(SessionHistoryCursor) }), }).annotate({ identifier: "SessionHistory" }), error: [SessionNotFoundError, InvalidCursorError], }) @@ -364,7 +353,7 @@ export const makeSessionGroup = (sessionLo identifier: "v2.session.history", summary: "Get session history", description: - "Read one finite page of public durable Session events. Pass the returned opaque cursor unchanged to continue the fixed snapshot; an omitted cursor means the snapshot is exhausted.", + "Read one finite page of public durable Session events. Omit cursor to start at the beginning, or pass cursor.next unchanged to continue the fixed snapshot; an omitted cursor.next means the snapshot is exhausted.", }), ), ) diff --git a/packages/protocol/test/session-cursor.test.ts b/packages/protocol/test/session-cursor.test.ts index 9c3b02441667..06b7fc189ecb 100644 --- a/packages/protocol/test/session-cursor.test.ts +++ b/packages/protocol/test/session-cursor.test.ts @@ -1,6 +1,11 @@ import { describe, expect, test } from "bun:test" import { Effect, Schema } from "effect" -import { SessionHistoryCursor, SessionHistoryQuery, SessionsCursor } from "../src/groups/session" +import { + SessionHistoryCursor, + SessionHistoryCursorInternal, + SessionHistoryQuery, + SessionsCursor, +} from "../src/groups/session" import { Session } from "@opencode-ai/schema/session" describe("SessionsCursor", () => { @@ -18,16 +23,23 @@ describe("SessionsCursor", () => { }) describe("SessionHistoryCursor", () => { - test("round trips the empty aggregate head", async () => { - const cursor = SessionHistoryCursor.make({ after: -1, through: -1 }) + test("constructs an initial durable checkpoint without a cutoff", async () => { + const cursor = SessionHistoryCursor.after(1) - expect(await Effect.runPromise(SessionHistoryCursor.parse(cursor))).toEqual({ after: -1, through: -1 }) + expect(String(cursor)).toBe("eyJhZnRlciI6MX0") + expect(await Effect.runPromise(SessionHistoryCursor.parse(cursor))).toEqual({ after: 1 }) }) - test("rejects combining after with cursor", () => { - const cursor = SessionHistoryCursor.make({ after: 1, through: 2 }) + test("round trips an empty aggregate continuation", async () => { + const cursor = SessionHistoryCursorInternal.next(-1, -1) + + expect(await Effect.runPromise(SessionHistoryCursor.parse(cursor))).toEqual({ after: -1, through: -1 }) + }) - expect(Schema.is(SessionHistoryQuery)({ after: 0, cursor })).toBe(false) + test("exposes cursor as the sole optional position", () => { + expect(Schema.is(SessionHistoryQuery)({})).toBe(true) + expect(Schema.is(SessionHistoryQuery)({ cursor: SessionHistoryCursor.after(0) })).toBe(true) + expect("after" in SessionHistoryQuery.fields).toBe(false) }) test("fails malformed cursors in the typed channel", async () => { diff --git a/packages/sdk-next/src/index.ts b/packages/sdk-next/src/index.ts index ec948e03cadd..afcd60718441 100644 --- a/packages/sdk-next/src/index.ts +++ b/packages/sdk-next/src/index.ts @@ -11,6 +11,7 @@ export { Provider, RelativePath, Session, + SessionHistoryCursor, SessionInput, SessionMessage, } from "@opencode-ai/client/effect" diff --git a/packages/sdk-next/test/embedded.test.ts b/packages/sdk-next/test/embedded.test.ts index dbd7ed93788f..9b0314bfedf5 100644 --- a/packages/sdk-next/test/embedded.test.ts +++ b/packages/sdk-next/test/embedded.test.ts @@ -4,6 +4,11 @@ import { tmpdir } from "node:os" import { join } from "node:path" import { Flag } from "@opencode-ai/core/flag/flag" import { Effect, Option, Schema, Stream } from "effect" +import { SessionHistoryCursor } from "../src" + +test("exports the Session history checkpoint constructor", () => { + expect(String(SessionHistoryCursor.after(0))).toBe("eyJhZnRlciI6MH0") +}) test("embedded client uses the real router and handlers", async () => { const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-")) diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 476b2cc5c0ab..de6c62bc2662 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -5,6 +5,7 @@ "type": "module", "license": "MIT", "scripts": { + "test": "bun test", "typecheck": "tsgo --noEmit", "build": "bun ./script/build.ts" }, diff --git a/packages/sdk/js/src/v2/client.ts b/packages/sdk/js/src/v2/client.ts index c1956cffe037..d78577b2160e 100644 --- a/packages/sdk/js/src/v2/client.ts +++ b/packages/sdk/js/src/v2/client.ts @@ -1,5 +1,6 @@ export * from "./gen/types.gen.js" export type { FileSystemEntry as LocationFileSystemEntry } from "./gen/types.gen.js" +export { SessionHistoryCursor } from "./session-history-cursor.js" import { createClient } from "./gen/client/client.gen.js" import { type Config } from "./gen/client/types.gen.js" diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 95714d517e86..754f42d25234 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -5715,13 +5715,12 @@ export class Session3 extends HeyApiClient { /** * Get session history * - * Read one finite page of public durable Session events. Pass the returned opaque cursor unchanged to continue the fixed snapshot; an omitted cursor means the snapshot is exhausted. + * Read one finite page of public durable Session events. Omit cursor to start at the beginning, or pass cursor.next unchanged to continue the fixed snapshot; an omitted cursor.next means the snapshot is exhausted. */ public history( parameters: { sessionID: string limit?: string - after?: string cursor?: string }, options?: Options, @@ -5733,7 +5732,6 @@ export class Session3 extends HeyApiClient { args: [ { in: "path", key: "sessionID" }, { in: "query", key: "limit" }, - { in: "query", key: "after" }, { in: "query", key: "cursor" }, ], }, diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 01767f0057bb..e9215d094d1b 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -2762,8 +2762,10 @@ export type SessionDurableEvent = | SessionNextRevertCommitted export type SessionHistory = { - events: Array - cursor?: string + data: Array + cursor: { + next?: string + } } export type SessionDurableEvent1 = string @@ -12428,7 +12430,6 @@ export type V2SessionHistoryData = { } query?: { limit?: string - after?: string cursor?: string } url: "/api/session/{sessionID}/history" diff --git a/packages/sdk/js/src/v2/session-history-cursor.ts b/packages/sdk/js/src/v2/session-history-cursor.ts new file mode 100644 index 000000000000..1d3e7a02de8c --- /dev/null +++ b/packages/sdk/js/src/v2/session-history-cursor.ts @@ -0,0 +1,17 @@ +export type SessionHistoryCursor = string + +export const SessionHistoryCursor = { + after(sequence: number): SessionHistoryCursor { + if (!Number.isSafeInteger(sequence) || sequence < 0) { + throw new RangeError("Session history sequence must be a non-negative safe integer") + } + return encode({ after: sequence }) + }, +} + +function encode(value: { readonly after: number }) { + const bytes = new TextEncoder().encode(JSON.stringify(value)) + let binary = "" + for (const byte of bytes) binary += String.fromCharCode(byte) + return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "") +} diff --git a/packages/sdk/js/test/session-history-cursor.test.ts b/packages/sdk/js/test/session-history-cursor.test.ts new file mode 100644 index 000000000000..2e5865c9f6d0 --- /dev/null +++ b/packages/sdk/js/test/session-history-cursor.test.ts @@ -0,0 +1,7 @@ +import { expect, test } from "bun:test" +import { SessionHistoryCursor } from "../src/v2/client" + +test("constructs an initial opaque Session history cursor", () => { + expect(SessionHistoryCursor.after(0)).toBe("eyJhZnRlciI6MH0") + expect(() => SessionHistoryCursor.after(-1)).toThrow(RangeError) +}) diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts index 7c0dd21df872..e51aa8a12630 100644 --- a/packages/server/src/handlers/session.ts +++ b/packages/server/src/handlers/session.ts @@ -2,7 +2,11 @@ import { SessionV2 } from "@opencode-ai/core/session" import { DateTime, Effect, Stream } from "effect" import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" import { Api } from "../api" -import { SessionHistoryCursor, SessionsCursor } from "@opencode-ai/protocol/groups/session" +import { + SessionHistoryCursor, + SessionHistoryCursorInternal, + SessionsCursor, +} from "@opencode-ai/protocol/groups/session" import { ConflictError, InvalidCursorError, @@ -341,17 +345,19 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl return yield* session .history({ sessionID: ctx.params.sessionID, - after: continuation?.after ?? ctx.query.after, + after: continuation?.after, through: continuation?.through, limit: ctx.query.limit ?? DefaultSessionHistoryLimit, }) .pipe( Effect.map((page) => ({ - events: page.events, - cursor: - page.nextAfter === undefined - ? undefined - : SessionHistoryCursor.make({ after: page.nextAfter, through: page.through }), + data: page.events, + cursor: { + next: + page.nextAfter === undefined + ? undefined + : SessionHistoryCursorInternal.next(page.nextAfter, page.through), + }, })), Effect.catchTag( "Session.NotFoundError", diff --git a/specs/v2/schema-changelog.md b/specs/v2/schema-changelog.md index 1ddfa3c01df5..30ef351ee31d 100644 --- a/specs/v2/schema-changelog.md +++ b/specs/v2/schema-changelog.md @@ -3,7 +3,7 @@ ## 2026-06-26: Add Finite Session History - Add `GET /api/session/:sessionID/history` and generated Promise, Effect, and legacy JavaScript client methods. -- Page only public durable Session events with an exclusive initial `after` and an opaque continuation cursor that preserves the fixed aggregate cutoff. +- Page only public durable Session events from one optional opaque cursor; omission starts before sequence zero, while `SessionHistoryCursor.after(sequence)` starts from an acknowledged durable checkpoint. - Keep aggregate gaps legal, cap pages at 100 events, and preserve the existing durable replay-and-tail `sessions.events()` stream unchanged. - Add no migration or durable-event version; this is a finite read API over the existing event manifest. diff --git a/specs/v2/session.md b/specs/v2/session.md index b995335adc13..d4456fbe1571 100644 --- a/specs/v2/session.md +++ b/specs/v2/session.md @@ -176,7 +176,7 @@ The synchronized `session.next.*` event family and projected Session-message mod The first `sessions.events(...)` contract is durable-only during both replay and live tailing. This keeps one cursor equal to one persisted aggregate sequence and is sufficient for reconnect-safe consumers. A later UI-facing API may optionally interleave live-only deltas while connected, but those fragments must remain explicitly ephemeral: they cannot advance the durable cursor, replay after reconnect, or be mistaken for publication boundaries. -`sessions.history({ sessionID, after?, limit? })` is the finite counterpart for request/response consumers. `after` is an exclusive aggregate sequence used only to start a new snapshot, and omission starts before sequence zero. A returned opaque `cursor` continues that fixed snapshot through `sessions.history({ sessionID, cursor, limit? })`, so commits above the captured cutoff cannot enter the chain. `after` and `cursor` are mutually exclusive. Public durable Session events are selected before pagination, which permits gaps from private or historical aggregate events while preserving strictly increasing unique sequences. An omitted response cursor means that fixed snapshot is exhausted. +`sessions.history({ sessionID, cursor?, limit? })` is the finite counterpart for request/response consumers. Omitting `cursor` starts before sequence zero. `SessionHistoryCursor.after(sequence)` constructs an opaque initial position after an acknowledged durable sequence; its first read captures the aggregate-head cutoff. A returned `cursor.next` continues that fixed snapshot, so commits above the cutoff cannot enter the chain. Public durable Session events are selected before pagination, which permits gaps from private or historical aggregate events while preserving strictly increasing unique sequences. An omitted `cursor.next` means that fixed snapshot is exhausted. The finite endpoint is `GET /api/session/:sessionID/history`, uses the normal Session Location and authorization middleware, defaults to 50 events, and accepts at most 100. It returns only events in the public durable Session schema. The existing `sessions.events()` replay-and-tail stream is unchanged. From e111f3d76556a903318aaf42f4ca81d9e8908b50 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 26 Jun 2026 14:22:11 -0400 Subject: [PATCH 5/5] fix(api): use moving session history pages --- packages/client/src/effect.ts | 1 - .../client/src/generated-effect/client.ts | 4 +- packages/client/src/generated/client.ts | 2 +- packages/client/src/generated/types.ts | 192 ++++-------------- packages/client/src/index.ts | 1 - packages/client/src/session-history-cursor.ts | 17 -- packages/client/test/effect.test.ts | 31 +-- packages/client/test/promise.test.ts | 44 ++-- packages/core/src/event.ts | 60 ++---- packages/core/src/session.ts | 21 +- packages/core/test/session-history.test.ts | 61 ++++-- packages/httpapi-codegen/src/index.ts | 12 +- .../test/server/httpapi-exercise/index.ts | 12 +- packages/protocol/src/groups/session.ts | 44 +--- packages/protocol/test/session-cursor.test.ts | 34 +--- packages/schema/src/event.ts | 2 +- packages/sdk-next/src/index.ts | 1 - packages/sdk-next/test/embedded.test.ts | 5 - packages/sdk/js/script/build.ts | 18 ++ packages/sdk/js/src/v2/client.ts | 1 - packages/sdk/js/src/v2/gen/sdk.gen.ts | 8 +- packages/sdk/js/src/v2/gen/types.gen.ts | 124 ++++++----- .../sdk/js/src/v2/session-history-cursor.ts | 17 -- .../js/test/session-history-cursor.test.ts | 7 - packages/sdk/js/test/session-history.test.ts | 12 ++ packages/server/src/handlers/session.ts | 26 +-- specs/v2/schema-changelog.md | 2 +- specs/v2/session.md | 2 +- 28 files changed, 261 insertions(+), 500 deletions(-) delete mode 100644 packages/client/src/session-history-cursor.ts delete mode 100644 packages/sdk/js/src/v2/session-history-cursor.ts delete mode 100644 packages/sdk/js/test/session-history-cursor.test.ts create mode 100644 packages/sdk/js/test/session-history.test.ts diff --git a/packages/client/src/effect.ts b/packages/client/src/effect.ts index 339fb8fd7959..6c0aca564e2c 100644 --- a/packages/client/src/effect.ts +++ b/packages/client/src/effect.ts @@ -10,4 +10,3 @@ export { Session } from "@opencode-ai/schema/session" export { SessionInput } from "@opencode-ai/schema/session-input" export { SessionMessage } from "@opencode-ai/schema/session-message" export { Prompt } from "@opencode-ai/schema/prompt" -export { SessionHistoryCursor } from "@opencode-ai/protocol/groups/session" diff --git a/packages/client/src/generated-effect/client.ts b/packages/client/src/generated-effect/client.ts index 0423cb5e1053..4856a3ccde78 100644 --- a/packages/client/src/generated-effect/client.ts +++ b/packages/client/src/generated-effect/client.ts @@ -153,12 +153,12 @@ type Endpoint0_13Request = Parameters (input: Endpoint0_13Input) => raw["session.history"]({ params: { sessionID: input.sessionID }, - query: { limit: input.limit, cursor: input.cursor }, + query: { limit: input.limit, after: input.after }, }).pipe(Effect.mapError(mapClientError)) type Endpoint0_14Request = Parameters[0] diff --git a/packages/client/src/generated/client.ts b/packages/client/src/generated/client.ts index f857701dc933..6366fbb56051 100644 --- a/packages/client/src/generated/client.ts +++ b/packages/client/src/generated/client.ts @@ -331,7 +331,7 @@ export function make(options: ClientOptions) { { method: "GET", path: `/api/session/${encodeURIComponent(input.sessionID)}/history`, - query: { limit: input.limit, cursor: input.cursor }, + query: { limit: input.limit, after: input.after }, successStatus: 200, declaredStatuses: [404, 400, 401], empty: false, diff --git a/packages/client/src/generated/types.ts b/packages/client/src/generated/types.ts index 98546b68c31d..bfa543ea2357 100644 --- a/packages/client/src/generated/types.ts +++ b/packages/client/src/generated/types.ts @@ -67,7 +67,7 @@ export const isUnknownError = (value: unknown): value is UnknownError => export type SessionsListInput = { readonly workspace?: { readonly workspace?: string | undefined - readonly limit?: string | undefined + readonly limit?: number | undefined readonly order?: "asc" | "desc" | undefined readonly search?: string | undefined readonly directory?: string | undefined @@ -77,7 +77,7 @@ export type SessionsListInput = { }["workspace"] readonly limit?: { readonly workspace?: string | undefined - readonly limit?: string | undefined + readonly limit?: number | undefined readonly order?: "asc" | "desc" | undefined readonly search?: string | undefined readonly directory?: string | undefined @@ -87,7 +87,7 @@ export type SessionsListInput = { }["limit"] readonly order?: { readonly workspace?: string | undefined - readonly limit?: string | undefined + readonly limit?: number | undefined readonly order?: "asc" | "desc" | undefined readonly search?: string | undefined readonly directory?: string | undefined @@ -97,7 +97,7 @@ export type SessionsListInput = { }["order"] readonly search?: { readonly workspace?: string | undefined - readonly limit?: string | undefined + readonly limit?: number | undefined readonly order?: "asc" | "desc" | undefined readonly search?: string | undefined readonly directory?: string | undefined @@ -107,7 +107,7 @@ export type SessionsListInput = { }["search"] readonly directory?: { readonly workspace?: string | undefined - readonly limit?: string | undefined + readonly limit?: number | undefined readonly order?: "asc" | "desc" | undefined readonly search?: string | undefined readonly directory?: string | undefined @@ -117,7 +117,7 @@ export type SessionsListInput = { }["directory"] readonly project?: { readonly workspace?: string | undefined - readonly limit?: string | undefined + readonly limit?: number | undefined readonly order?: "asc" | "desc" | undefined readonly search?: string | undefined readonly directory?: string | undefined @@ -127,7 +127,7 @@ export type SessionsListInput = { }["project"] readonly subpath?: { readonly workspace?: string | undefined - readonly limit?: string | undefined + readonly limit?: number | undefined readonly order?: "asc" | "desc" | undefined readonly search?: string | undefined readonly directory?: string | undefined @@ -137,7 +137,7 @@ export type SessionsListInput = { }["subpath"] readonly cursor?: { readonly workspace?: string | undefined - readonly limit?: string | undefined + readonly limit?: number | undefined readonly order?: "asc" | "desc" | undefined readonly search?: string | undefined readonly directory?: string | undefined @@ -593,8 +593,8 @@ export type SessionsContextOutput = { export type SessionsHistoryInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] - readonly limit?: { readonly limit?: string | undefined; readonly cursor?: string | undefined }["limit"] - readonly cursor?: { readonly limit?: string | undefined; readonly cursor?: string | undefined }["cursor"] + readonly limit?: { readonly limit?: number | undefined; readonly after?: number | undefined }["limit"] + readonly after?: { readonly limit?: number | undefined; readonly after?: number | undefined }["after"] } export type SessionsHistoryOutput = { @@ -603,11 +603,7 @@ export type SessionsHistoryOutput = { readonly id: string readonly metadata?: { readonly [x: string]: JsonValue } readonly type: "session.next.agent.switched" - readonly durable?: { - readonly aggregateID: string - readonly seq: number | "Infinity" | "-Infinity" | "NaN" - readonly version: number | "Infinity" | "-Infinity" | "NaN" - } + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly timestamp: number @@ -620,11 +616,7 @@ export type SessionsHistoryOutput = { readonly id: string readonly metadata?: { readonly [x: string]: JsonValue } readonly type: "session.next.model.switched" - readonly durable?: { - readonly aggregateID: string - readonly seq: number | "Infinity" | "-Infinity" | "NaN" - readonly version: number | "Infinity" | "-Infinity" | "NaN" - } + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly timestamp: number @@ -637,11 +629,7 @@ export type SessionsHistoryOutput = { readonly id: string readonly metadata?: { readonly [x: string]: JsonValue } readonly type: "session.next.moved" - readonly durable?: { - readonly aggregateID: string - readonly seq: number | "Infinity" | "-Infinity" | "NaN" - readonly version: number | "Infinity" | "-Infinity" | "NaN" - } + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly timestamp: number @@ -654,11 +642,7 @@ export type SessionsHistoryOutput = { readonly id: string readonly metadata?: { readonly [x: string]: JsonValue } readonly type: "session.next.prompted" - readonly durable?: { - readonly aggregateID: string - readonly seq: number | "Infinity" | "-Infinity" | "NaN" - readonly version: number | "Infinity" | "-Infinity" | "NaN" - } + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly timestamp: number @@ -685,11 +669,7 @@ export type SessionsHistoryOutput = { readonly id: string readonly metadata?: { readonly [x: string]: JsonValue } readonly type: "session.next.prompt.admitted" - readonly durable?: { - readonly aggregateID: string - readonly seq: number | "Infinity" | "-Infinity" | "NaN" - readonly version: number | "Infinity" | "-Infinity" | "NaN" - } + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly timestamp: number @@ -716,11 +696,7 @@ export type SessionsHistoryOutput = { readonly id: string readonly metadata?: { readonly [x: string]: JsonValue } readonly type: "session.next.context.updated" - readonly durable?: { - readonly aggregateID: string - readonly seq: number | "Infinity" | "-Infinity" | "NaN" - readonly version: number | "Infinity" | "-Infinity" | "NaN" - } + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly timestamp: number @@ -733,11 +709,7 @@ export type SessionsHistoryOutput = { readonly id: string readonly metadata?: { readonly [x: string]: JsonValue } readonly type: "session.next.synthetic" - readonly durable?: { - readonly aggregateID: string - readonly seq: number | "Infinity" | "-Infinity" | "NaN" - readonly version: number | "Infinity" | "-Infinity" | "NaN" - } + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly timestamp: number @@ -750,11 +722,7 @@ export type SessionsHistoryOutput = { readonly id: string readonly metadata?: { readonly [x: string]: JsonValue } readonly type: "session.next.shell.started" - readonly durable?: { - readonly aggregateID: string - readonly seq: number | "Infinity" | "-Infinity" | "NaN" - readonly version: number | "Infinity" | "-Infinity" | "NaN" - } + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly timestamp: number @@ -768,11 +736,7 @@ export type SessionsHistoryOutput = { readonly id: string readonly metadata?: { readonly [x: string]: JsonValue } readonly type: "session.next.shell.ended" - readonly durable?: { - readonly aggregateID: string - readonly seq: number | "Infinity" | "-Infinity" | "NaN" - readonly version: number | "Infinity" | "-Infinity" | "NaN" - } + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly timestamp: number @@ -785,11 +749,7 @@ export type SessionsHistoryOutput = { readonly id: string readonly metadata?: { readonly [x: string]: JsonValue } readonly type: "session.next.step.started" - readonly durable?: { - readonly aggregateID: string - readonly seq: number | "Infinity" | "-Infinity" | "NaN" - readonly version: number | "Infinity" | "-Infinity" | "NaN" - } + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly timestamp: number @@ -804,11 +764,7 @@ export type SessionsHistoryOutput = { readonly id: string readonly metadata?: { readonly [x: string]: JsonValue } readonly type: "session.next.step.ended" - readonly durable?: { - readonly aggregateID: string - readonly seq: number | "Infinity" | "-Infinity" | "NaN" - readonly version: number | "Infinity" | "-Infinity" | "NaN" - } + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly timestamp: number @@ -830,11 +786,7 @@ export type SessionsHistoryOutput = { readonly id: string readonly metadata?: { readonly [x: string]: JsonValue } readonly type: "session.next.step.failed" - readonly durable?: { - readonly aggregateID: string - readonly seq: number | "Infinity" | "-Infinity" | "NaN" - readonly version: number | "Infinity" | "-Infinity" | "NaN" - } + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly timestamp: number @@ -847,11 +799,7 @@ export type SessionsHistoryOutput = { readonly id: string readonly metadata?: { readonly [x: string]: JsonValue } readonly type: "session.next.text.started" - readonly durable?: { - readonly aggregateID: string - readonly seq: number | "Infinity" | "-Infinity" | "NaN" - readonly version: number | "Infinity" | "-Infinity" | "NaN" - } + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly timestamp: number @@ -864,11 +812,7 @@ export type SessionsHistoryOutput = { readonly id: string readonly metadata?: { readonly [x: string]: JsonValue } readonly type: "session.next.text.ended" - readonly durable?: { - readonly aggregateID: string - readonly seq: number | "Infinity" | "-Infinity" | "NaN" - readonly version: number | "Infinity" | "-Infinity" | "NaN" - } + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly timestamp: number @@ -882,11 +826,7 @@ export type SessionsHistoryOutput = { readonly id: string readonly metadata?: { readonly [x: string]: JsonValue } readonly type: "session.next.tool.input.started" - readonly durable?: { - readonly aggregateID: string - readonly seq: number | "Infinity" | "-Infinity" | "NaN" - readonly version: number | "Infinity" | "-Infinity" | "NaN" - } + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly timestamp: number @@ -900,11 +840,7 @@ export type SessionsHistoryOutput = { readonly id: string readonly metadata?: { readonly [x: string]: JsonValue } readonly type: "session.next.tool.input.ended" - readonly durable?: { - readonly aggregateID: string - readonly seq: number | "Infinity" | "-Infinity" | "NaN" - readonly version: number | "Infinity" | "-Infinity" | "NaN" - } + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly timestamp: number @@ -918,11 +854,7 @@ export type SessionsHistoryOutput = { readonly id: string readonly metadata?: { readonly [x: string]: JsonValue } readonly type: "session.next.tool.called" - readonly durable?: { - readonly aggregateID: string - readonly seq: number | "Infinity" | "-Infinity" | "NaN" - readonly version: number | "Infinity" | "-Infinity" | "NaN" - } + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly timestamp: number @@ -941,11 +873,7 @@ export type SessionsHistoryOutput = { readonly id: string readonly metadata?: { readonly [x: string]: JsonValue } readonly type: "session.next.tool.progress" - readonly durable?: { - readonly aggregateID: string - readonly seq: number | "Infinity" | "-Infinity" | "NaN" - readonly version: number | "Infinity" | "-Infinity" | "NaN" - } + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly timestamp: number @@ -963,11 +891,7 @@ export type SessionsHistoryOutput = { readonly id: string readonly metadata?: { readonly [x: string]: JsonValue } readonly type: "session.next.tool.success" - readonly durable?: { - readonly aggregateID: string - readonly seq: number | "Infinity" | "-Infinity" | "NaN" - readonly version: number | "Infinity" | "-Infinity" | "NaN" - } + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly timestamp: number @@ -991,11 +915,7 @@ export type SessionsHistoryOutput = { readonly id: string readonly metadata?: { readonly [x: string]: JsonValue } readonly type: "session.next.tool.failed" - readonly durable?: { - readonly aggregateID: string - readonly seq: number | "Infinity" | "-Infinity" | "NaN" - readonly version: number | "Infinity" | "-Infinity" | "NaN" - } + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly timestamp: number @@ -1014,11 +934,7 @@ export type SessionsHistoryOutput = { readonly id: string readonly metadata?: { readonly [x: string]: JsonValue } readonly type: "session.next.reasoning.started" - readonly durable?: { - readonly aggregateID: string - readonly seq: number | "Infinity" | "-Infinity" | "NaN" - readonly version: number | "Infinity" | "-Infinity" | "NaN" - } + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly timestamp: number @@ -1032,11 +948,7 @@ export type SessionsHistoryOutput = { readonly id: string readonly metadata?: { readonly [x: string]: JsonValue } readonly type: "session.next.reasoning.ended" - readonly durable?: { - readonly aggregateID: string - readonly seq: number | "Infinity" | "-Infinity" | "NaN" - readonly version: number | "Infinity" | "-Infinity" | "NaN" - } + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly timestamp: number @@ -1051,11 +963,7 @@ export type SessionsHistoryOutput = { readonly id: string readonly metadata?: { readonly [x: string]: JsonValue } readonly type: "session.next.retried" - readonly durable?: { - readonly aggregateID: string - readonly seq: number | "Infinity" | "-Infinity" | "NaN" - readonly version: number | "Infinity" | "-Infinity" | "NaN" - } + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly timestamp: number @@ -1075,11 +983,7 @@ export type SessionsHistoryOutput = { readonly id: string readonly metadata?: { readonly [x: string]: JsonValue } readonly type: "session.next.compaction.started" - readonly durable?: { - readonly aggregateID: string - readonly seq: number | "Infinity" | "-Infinity" | "NaN" - readonly version: number | "Infinity" | "-Infinity" | "NaN" - } + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly timestamp: number @@ -1092,11 +996,7 @@ export type SessionsHistoryOutput = { readonly id: string readonly metadata?: { readonly [x: string]: JsonValue } readonly type: "session.next.compaction.ended" - readonly durable?: { - readonly aggregateID: string - readonly seq: number | "Infinity" | "-Infinity" | "NaN" - readonly version: number | "Infinity" | "-Infinity" | "NaN" - } + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly timestamp: number @@ -1111,11 +1011,7 @@ export type SessionsHistoryOutput = { readonly id: string readonly metadata?: { readonly [x: string]: JsonValue } readonly type: "session.next.revert.staged" - readonly durable?: { - readonly aggregateID: string - readonly seq: number | "Infinity" | "-Infinity" | "NaN" - readonly version: number | "Infinity" | "-Infinity" | "NaN" - } + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly timestamp: number @@ -1139,11 +1035,7 @@ export type SessionsHistoryOutput = { readonly id: string readonly metadata?: { readonly [x: string]: JsonValue } readonly type: "session.next.revert.cleared" - readonly durable?: { - readonly aggregateID: string - readonly seq: number | "Infinity" | "-Infinity" | "NaN" - readonly version: number | "Infinity" | "-Infinity" | "NaN" - } + 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 } } @@ -1151,21 +1043,17 @@ export type SessionsHistoryOutput = { readonly id: string readonly metadata?: { readonly [x: string]: JsonValue } readonly type: "session.next.revert.committed" - readonly durable?: { - readonly aggregateID: string - readonly seq: number | "Infinity" | "-Infinity" | "NaN" - readonly version: number | "Infinity" | "-Infinity" | "NaN" - } + 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 cursor: { readonly next?: string } + readonly hasMore: boolean } export type SessionsEventsInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] - readonly after?: { readonly after?: string | undefined }["after"] + readonly after?: { readonly after?: number | undefined }["after"] } export type SessionsEventsOutput = diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index d67911338df2..92e36b1c6054 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -1,2 +1 @@ export * from "./generated/index" -export { SessionHistoryCursor } from "./session-history-cursor" diff --git a/packages/client/src/session-history-cursor.ts b/packages/client/src/session-history-cursor.ts deleted file mode 100644 index 1d3e7a02de8c..000000000000 --- a/packages/client/src/session-history-cursor.ts +++ /dev/null @@ -1,17 +0,0 @@ -export type SessionHistoryCursor = string - -export const SessionHistoryCursor = { - after(sequence: number): SessionHistoryCursor { - if (!Number.isSafeInteger(sequence) || sequence < 0) { - throw new RangeError("Session history sequence must be a non-negative safe integer") - } - return encode({ after: sequence }) - }, -} - -function encode(value: { readonly after: number }) { - const bytes = new TextEncoder().encode(JSON.stringify(value)) - let binary = "" - for (const byte of bytes) binary += String.fromCharCode(byte) - return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "") -} diff --git a/packages/client/test/effect.test.ts b/packages/client/test/effect.test.ts index f7d17b8bf488..865da23b930d 100644 --- a/packages/client/test/effect.test.ts +++ b/packages/client/test/effect.test.ts @@ -9,7 +9,6 @@ import { OpenCode, Prompt, Session, - SessionHistoryCursor, SessionMessage, } from "../src/effect" @@ -48,8 +47,8 @@ test("session methods retain decoded Effect inputs and outputs", async () => { request, Response.json( historyPage === 1 - ? { data: [modelSwitchedEvent], cursor: { next: "opaque_history_cursor" } } - : { data: [], cursor: {} }, + ? { data: [modelSwitchedEvent], hasMore: true } + : { data: [], hasMore: false }, ), ), ) @@ -100,13 +99,13 @@ test("session methods retain decoded Effect inputs and outputs", async () => { const context = yield* client.sessions.context({ sessionID: Session.ID.make("ses_test") }) const history = yield* client.sessions.history({ sessionID: Session.ID.make("ses_test"), - cursor: SessionHistoryCursor.after(0), + after: 0, limit: 1, }) - const historyNext = history.cursor.next + const historyNext = history.hasMore ? yield* client.sessions.history({ sessionID: Session.ID.make("ses_test"), - cursor: history.cursor.next, + after: history.data.at(-1)?.durable?.seq, limit: 2, }) : undefined @@ -131,20 +130,23 @@ test("session methods retain decoded Effect inputs and outputs", async () => { expect(DateTime.toEpochMillis(result.admitted.timeCreated)).toBe(1_717_171_717_000) expect(result.context).toEqual([]) expect(DateTime.toEpochMillis(result.history.data[0].data.timestamp)).toBe(1_717_171_717_000) - expect(result.history).toEqual(expect.objectContaining({ cursor: { next: "opaque_history_cursor" } })) - expect(result.historyNext).toEqual({ data: [], cursor: {} }) - expect(historyQueries[0]).toEqual({ limit: "1", cursor: "eyJhZnRlciI6MH0" }) - expect(historyQueries[1]).toEqual({ limit: "2", cursor: "opaque_history_cursor" }) + expect(result.history).toEqual(expect.objectContaining({ hasMore: true })) + expect(result.historyNext).toEqual({ data: [], hasMore: false }) + expect(historyQueries[0]).toEqual({ limit: "1", after: "0" }) + expect(historyQueries[1]).toEqual({ limit: "2", after: "1" }) expect(DateTime.toEpochMillis(result.events[0].data.timestamp)).toBe(1_717_171_717_000) expect(result.message).toEqual(expect.objectContaining({ id: "msg_model", type: "model-switched" })) }) -test("sessions.history retains the typed InvalidCursorError", async () => { +test("sessions.history retains the typed SessionNotFoundError", async () => { const httpClient = HttpClient.make((request) => Effect.succeed( HttpClientResponse.fromWeb( request, - Response.json({ _tag: "InvalidCursorError", message: "Invalid cutoff" }, { status: 400 }), + Response.json( + { _tag: "SessionNotFoundError", sessionID: "ses_missing", message: "Session not found" }, + { status: 404 }, + ), ), ), ) @@ -152,13 +154,12 @@ test("sessions.history retains the typed InvalidCursorError", async () => { const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" }) return yield* client.sessions .history({ - sessionID: Session.ID.make("ses_test"), - cursor: SessionHistoryCursor.after(0), + sessionID: Session.ID.make("ses_missing"), }) .pipe(Effect.flip) }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise) - expect(error._tag).toBe("InvalidCursorError") + expect(error._tag).toBe("SessionNotFoundError") }) const session = { diff --git a/packages/client/test/promise.test.ts b/packages/client/test/promise.test.ts index ff641a4c04da..4f56938024e9 100644 --- a/packages/client/test/promise.test.ts +++ b/packages/client/test/promise.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test" -import { isInvalidCursorError, isUnauthorizedError, OpenCode, SessionHistoryCursor } from "../src" +import { isSessionNotFoundError, isUnauthorizedError, OpenCode } from "../src" test("sessions.get returns the wire projection", async () => { const client = OpenCode.make({ @@ -34,8 +34,8 @@ test("session methods use the public HTTP contract", async () => { historyPage++ return Response.json( historyPage === 1 - ? { data: [modelSwitchedEvent], cursor: { next: "opaque_history_cursor" } } - : { data: [], cursor: {} }, + ? { data: [modelSwitchedEvent], hasMore: true } + : { data: [], hasMore: false }, ) } if (url.includes("/prompt")) return Response.json(admission) @@ -48,7 +48,7 @@ test("session methods use the public HTTP contract", async () => { }, }) - const page = await client.sessions.list({ limit: "10", order: "desc" }) + const page = await client.sessions.list({ limit: 10, order: "desc" }) const active = await client.sessions.active() const created = await client.sessions.create({ location: { directory: "/tmp/project" } }) await client.sessions.switchAgent({ sessionID: "ses_test", agent: "build" }) @@ -64,13 +64,13 @@ test("session methods use the public HTTP contract", async () => { await client.sessions.compact({ sessionID: "ses_test" }) await client.sessions.wait({ sessionID: "ses_test" }) const context = await client.sessions.context({ sessionID: "ses_test" }) - const initialCursor = SessionHistoryCursor.after(0) - const history = await client.sessions.history({ sessionID: "ses_test", cursor: initialCursor, limit: "1" }) - const historyNext = history.cursor.next - ? await client.sessions.history({ sessionID: "ses_test", cursor: history.cursor.next, limit: "2" }) + const history = await client.sessions.history({ sessionID: "ses_test", after: 0, limit: 1 }) + const historyAfter = history.data.at(-1)?.durable?.seq + const historyNext = history.hasMore + ? await client.sessions.history({ sessionID: "ses_test", after: historyAfter, limit: 2 }) : undefined const events = [] - for await (const event of client.sessions.events({ sessionID: "ses_test", after: "0" })) events.push(event) + for await (const event of client.sessions.events({ sessionID: "ses_test", after: 0 })) events.push(event) await client.sessions.interrupt({ sessionID: "ses_test" }) const message = await client.sessions.message({ sessionID: "ses_test", messageID: "msg_model" }) @@ -79,9 +79,8 @@ test("session methods use the public HTTP contract", async () => { expect(created.id).toBe("ses_test") expect(admitted.id).toBe("msg_test") expect(context).toEqual([]) - expect(initialCursor).toBe("eyJhZnRlciI6MH0") - expect(history).toEqual({ data: [modelSwitchedEvent], cursor: { next: "opaque_history_cursor" } }) - expect(historyNext).toEqual({ data: [], cursor: {} }) + expect(history).toEqual({ data: [modelSwitchedEvent], hasMore: true }) + expect(historyNext).toEqual({ data: [], hasMore: false }) expect(events).toEqual([modelSwitchedEvent]) expect(message).toEqual(modelSwitchedMessage) expect(requests.map((request) => [request.init?.method, request.url])).toEqual([ @@ -94,8 +93,8 @@ test("session methods use the public HTTP contract", async () => { ["POST", "http://localhost:3000/api/session/ses_test/compact"], ["POST", "http://localhost:3000/api/session/ses_test/wait"], ["GET", "http://localhost:3000/api/session/ses_test/context"], - ["GET", `http://localhost:3000/api/session/ses_test/history?limit=1&cursor=${initialCursor}`], - ["GET", "http://localhost:3000/api/session/ses_test/history?limit=2&cursor=opaque_history_cursor"], + ["GET", "http://localhost:3000/api/session/ses_test/history?limit=1&after=0"], + ["GET", "http://localhost:3000/api/session/ses_test/history?limit=2&after=1"], ["GET", "http://localhost:3000/api/session/ses_test/event?after=0"], ["POST", "http://localhost:3000/api/session/ses_test/interrupt"], ["GET", "http://localhost:3000/api/session/ses_test/message/msg_model"], @@ -123,25 +122,24 @@ test("middleware errors remain declared client errors", async () => { } }) -test("sessions.history decodes InvalidCursorError", async () => { +test("sessions.history decodes SessionNotFoundError", async () => { const client = OpenCode.make({ baseUrl: "http://localhost:3000", - fetch: async () => Response.json({ _tag: "InvalidCursorError", message: "Invalid cutoff" }, { status: 400 }), + fetch: async () => + Response.json( + { _tag: "SessionNotFoundError", sessionID: "ses_missing", message: "Session not found" }, + { status: 404 }, + ), }) try { - await client.sessions.history({ sessionID: "ses_test", cursor: "malformed" }) + await client.sessions.history({ sessionID: "ses_missing" }) throw new Error("Expected request to fail") } catch (error) { - expect(isInvalidCursorError(error)).toBe(true) + expect(isSessionNotFoundError(error)).toBe(true) } }) -test("SessionHistoryCursor rejects invalid durable checkpoints", () => { - expect(() => SessionHistoryCursor.after(-1)).toThrow(RangeError) - expect(() => SessionHistoryCursor.after(1.5)).toThrow(RangeError) -}) - const session = { data: { id: "ses_test", diff --git a/packages/core/src/event.ts b/packages/core/src/event.ts index 9a0118a71f1c..037cbe439288 100644 --- a/packages/core/src/event.ts +++ b/packages/core/src/event.ts @@ -3,7 +3,7 @@ export * as EventV2 from "./event" import { Cause, Context, Effect, Layer, Option, PubSub, Schema, Stream } from "effect" import { Event } from "@opencode-ai/schema/event" import type { Data, Definition, Payload } from "@opencode-ai/schema/event" -import { and, asc, eq, gt, inArray, lte } from "drizzle-orm" +import { and, asc, eq, gt, inArray } from "drizzle-orm" import { Database } from "./database/database" import { EventSequenceTable, EventTable } from "./event/sql" import { Location } from "./location" @@ -47,10 +47,6 @@ export class InvalidDurableEventError extends Schema.TaggedErrorClass()("EventV2.InvalidCursorError", { - message: Schema.String, -}) {} - const decodeSerializedEvent = (event: SerializedEvent): Payload => { const definition = Durable.get(event.type) if (!definition?.durable) { @@ -69,7 +65,6 @@ export const readAggregate = Effect.fn("EventV2.readAggregate")(function* ( input: { readonly aggregateID: string readonly after?: number - readonly through?: number readonly limit: number readonly manifest: { readonly definitions: ReadonlyMap @@ -78,43 +73,21 @@ export const readAggregate = Effect.fn("EventV2.readAggregate")(function* ( }, ) { const after = input.after ?? -1 - const result = yield* db - .transaction((tx) => - Effect.gen(function* () { - const sequence = yield* tx - .select({ seq: EventSequenceTable.seq }) - .from(EventSequenceTable) - .where(eq(EventSequenceTable.aggregate_id, input.aggregateID)) - .get() - .pipe(Effect.orDie) - const head = sequence?.seq ?? -1 - if (input.through !== undefined && input.through > head) { - return yield* new InvalidCursorError({ message: "History cutoff is above the current aggregate head" }) - } - const through = input.through ?? head - if (through < after) { - return yield* new InvalidCursorError({ message: "History cutoff must not be less than the cursor" }) - } - const rows = yield* tx - .select() - .from(EventTable) - .where( - and( - eq(EventTable.aggregate_id, input.aggregateID), - gt(EventTable.seq, after), - lte(EventTable.seq, through), - inArray(EventTable.type, Array.from(input.manifest.definitions.keys())), - ), - ) - .orderBy(asc(EventTable.seq)) - .limit(input.limit + 1) - .all() - .pipe(Effect.orDie) - return { rows, through } - }), + const rows = yield* db + .select() + .from(EventTable) + .where( + and( + eq(EventTable.aggregate_id, input.aggregateID), + gt(EventTable.seq, after), + inArray(EventTable.type, Array.from(input.manifest.definitions.keys())), + ), ) - .pipe(Effect.catchTag("SqlError", Effect.die)) - const page = result.rows.slice(0, input.limit) + .orderBy(asc(EventTable.seq)) + .limit(input.limit + 1) + .all() + .pipe(Effect.orDie) + const page = rows.slice(0, input.limit) const decode = Schema.decodeUnknownSync(input.manifest.schema) const events = page.map((event) => decode({ @@ -130,8 +103,7 @@ export const readAggregate = Effect.fn("EventV2.readAggregate")(function* ( ) return { events, - through: result.through, - nextAfter: result.rows.length > input.limit ? page.at(-1)?.seq : undefined, + hasMore: rows.length > input.limit, } }) diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index f025ed69b252..8d9ad9cd9fa8 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -103,18 +103,10 @@ export class PromptConflictError extends Schema.TaggedErrorClass()("Session.InvalidCursorError", { - message: Schema.String, -}) {} export const MessageNotFoundError = SessionRevert.MessageNotFoundError export type MessageNotFoundError = SessionRevert.MessageNotFoundError -export type Error = - | NotFoundError - | MessageDecodeError - | OperationUnavailableError - | PromptConflictError - | InvalidCursorError +export type Error = NotFoundError | MessageDecodeError | OperationUnavailableError | PromptConflictError export interface Interface { readonly list: (input?: ListInput) => Effect.Effect @@ -143,15 +135,10 @@ export interface Interface { readonly history: (input: { sessionID: SessionSchema.ID after?: number - through?: number limit: number }) => Effect.Effect< - { - events: ReadonlyArray - through: number - nextAfter?: number - }, - NotFoundError | InvalidCursorError + { events: ReadonlyArray; hasMore: boolean }, + NotFoundError > readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: string }) => Effect.Effect readonly switchModel: (input: { @@ -375,7 +362,7 @@ export const layer = Layer.unwrap( ...input, aggregateID: input.sessionID, manifest: SessionDurable, - }).pipe(Effect.mapError((error) => new InvalidCursorError({ message: error.message }))) + }) }), prompt: Effect.fn("V2Session.prompt")((input) => Effect.uninterruptible( diff --git a/packages/core/test/session-history.test.ts b/packages/core/test/session-history.test.ts index a29e34b857fa..8cee6546b0e1 100644 --- a/packages/core/test/session-history.test.ts +++ b/packages/core/test/session-history.test.ts @@ -74,7 +74,21 @@ describe("SessionV2.history", () => { const first = yield* session.history({ sessionID, limit: 10 }) - expect(first).toEqual({ events: [], through: -1, nextAfter: undefined }) + expect(first).toEqual({ events: [], hasMore: false }) + }), + ) + + it.effect("treats after as an exclusive aggregate sequence", () => + Effect.gen(function* () { + const session = yield* SessionV2.Service + const created = yield* session.create({ location }) + yield* session.switchAgent({ sessionID: created.id, agent: "one" }) + yield* session.switchAgent({ sessionID: created.id, agent: "two" }) + + const page = yield* session.history({ sessionID: created.id, after: 1, limit: 10 }) + + expect(page.events.map((event) => event.durable?.seq)).toEqual([2]) + expect(page.hasMore).toBe(false) }), ) @@ -89,23 +103,22 @@ describe("SessionV2.history", () => { yield* session.switchAgent({ sessionID: created.id, agent: "three" }) const first = yield* session.history({ sessionID: created.id, limit: 2 }) + const after = first.events.at(-1)?.durable?.seq const second = yield* session.history({ sessionID: created.id, - after: first.nextAfter, - through: first.through, + after, limit: 2, }) const sequence = [...first.events, ...second.events].map((event) => event.durable?.seq) - expect(first.through).toBe(4) - expect(first.nextAfter).toBe(3) - expect(second.nextAfter).toBeUndefined() + expect(first.hasMore).toBe(true) + expect(second.hasMore).toBe(false) expect(sequence).toEqual([1, 3, 4]) expect(new Set(sequence).size).toBe(sequence.length) }), ) - it.effect("keeps the first page cutoff when later events commit", () => + it.effect("includes events committed between pages", () => Effect.gen(function* () { const session = yield* SessionV2.Service const created = yield* session.create({ location }) @@ -116,31 +129,37 @@ describe("SessionV2.history", () => { yield* session.switchAgent({ sessionID: created.id, agent: "later" }) const second = yield* session.history({ sessionID: created.id, - after: first.nextAfter, - through: first.through, + after: first.events.at(-1)?.durable?.seq, limit: 10, }) - expect(first.through).toBe(2) - expect([...first.events, ...second.events].map((event) => event.durable?.seq)).toEqual([1, 2]) - expect(second.nextAfter).toBeUndefined() + expect(first.hasMore).toBe(true) + expect([...first.events, ...second.events].map((event) => event.durable?.seq)).toEqual([1, 2, 3]) + expect(second.hasMore).toBe(false) }), ) - it.effect("rejects invalid cursor combinations with the typed error", () => + it.effect("reports exhaustion for exact-limit and limit-plus-one pages", () => Effect.gen(function* () { const session = yield* SessionV2.Service const created = yield* session.create({ location }) + yield* session.switchAgent({ sessionID: created.id, agent: "one" }) + yield* session.switchAgent({ sessionID: created.id, agent: "two" }) - const reversed = yield* session - .history({ sessionID: created.id, after: 1, through: 0, limit: 10 }) - .pipe(Effect.flip) - const future = yield* session.history({ sessionID: created.id, through: 1, limit: 10 }).pipe(Effect.flip) - const afterHead = yield* session.history({ sessionID: created.id, after: 1, limit: 10 }).pipe(Effect.flip) + const exact = yield* session.history({ sessionID: created.id, limit: 2 }) + const oneMore = yield* session.history({ sessionID: created.id, limit: 1 }) + const exhausted = yield* session.history({ + sessionID: created.id, + after: oneMore.events.at(-1)?.durable?.seq, + limit: 1, + }) - expect(reversed._tag).toBe("Session.InvalidCursorError") - expect(future._tag).toBe("Session.InvalidCursorError") - expect(afterHead._tag).toBe("Session.InvalidCursorError") + expect(exact.events).toHaveLength(2) + expect(exact.hasMore).toBe(false) + expect(oneMore.events).toHaveLength(1) + expect(oneMore.hasMore).toBe(true) + expect(exhausted.events).toHaveLength(1) + expect(exhausted.hasMore).toBe(false) }), ) diff --git a/packages/httpapi-codegen/src/index.ts b/packages/httpapi-codegen/src/index.ts index 289884d961a9..704264778c5b 100644 --- a/packages/httpapi-codegen/src/index.ts +++ b/packages/httpapi-codegen/src/index.ts @@ -410,12 +410,12 @@ function renderImportedProjection(groups: ReadonlyArray, endpoints: Reado function renderPromiseTypes(groups: ReadonlyArray) { const types = new Map() - const typeOf = (schema: Schema.Top) => { - const encoded = Schema.toEncoded(schema) - const cached = types.get(encoded.ast) + const typeOf = (schema: Schema.Top, decoded = false) => { + const projected = decoded ? Schema.toType(schema) : Schema.toEncoded(schema) + const cached = types.get(projected.ast) if (cached !== undefined) return cached - const type = structuralType(encoded) - types.set(encoded.ast, type) + const type = structuralType(projected) + types.set(projected.ast, type) return type } const errors = new Map( @@ -449,7 +449,7 @@ function renderPromiseTypes(groups: ReadonlyArray) { const schema = schemas[field.source] if (schema === undefined) throw new GenerationError({ reason: `Missing input schema: ${prefix}.${field.name}` }) - return `readonly ${JSON.stringify(field.name)}${field.optional ? "?" : ""}: (${typeOf(schema)})[${JSON.stringify(field.name)}]` + return `readonly ${JSON.stringify(field.name)}${field.optional ? "?" : ""}: (${typeOf(schema, field.source === "query")})[${JSON.stringify(field.name)}]` }) .join("; ") const successSchema = endpoint.successes[0] diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index 9945e30ca0b7..b8368383d09b 100644 --- a/packages/opencode/test/server/httpapi-exercise/index.ts +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -1072,7 +1072,7 @@ const scenarios: Scenario[] = [ .seeded((ctx) => ctx.session({ title: "Session history" })) .at((ctx) => ({ path: `${route("/api/session/{sessionID}/history", { sessionID: ctx.state.id })}?${new URLSearchParams({ - cursor: cursor({ after: 0 }), + after: "0", limit: "2", })}`, headers: ctx.headers(), @@ -1082,11 +1082,7 @@ const scenarios: Scenario[] = [ (body) => { object(body) array(body.data) - object(body.cursor) - check( - body.cursor.next === undefined || typeof body.cursor.next === "string", - "Expected an optional next cursor", - ) + check(typeof body.hasMore === "boolean", "Expected a history exhaustion signal") }, "none", ), @@ -1099,9 +1095,9 @@ const scenarios: Scenario[] = [ .json(404, object, "status"), http.protected .get("/api/session/{sessionID}/history", "v2.session.history.invalid") - .seeded((ctx) => ctx.session({ title: "Invalid history cursor" })) + .seeded((ctx) => ctx.session({ title: "Invalid history sequence" })) .at((ctx) => ({ - path: `${route("/api/session/{sessionID}/history", { sessionID: ctx.state.id })}?cursor=malformed`, + path: `${route("/api/session/{sessionID}/history", { sessionID: ctx.state.id })}?after=-1`, headers: ctx.headers(), })) .json(400, object, "status"), diff --git a/packages/protocol/src/groups/session.ts b/packages/protocol/src/groups/session.ts index 887ace968966..8ce85ef79686 100644 --- a/packages/protocol/src/groups/session.ts +++ b/packages/protocol/src/groups/session.ts @@ -3,7 +3,7 @@ import { SessionInput } from "@opencode-ai/schema/session-input" import { PromptInput } from "@opencode-ai/schema/prompt-input" import { Session } from "@opencode-ai/schema/session" import { Project } from "@opencode-ai/schema/project" -import { AbsolutePath, NonNegativeInt, optional, PositiveInt, RelativePath, statics } from "@opencode-ai/schema/schema" +import { AbsolutePath, NonNegativeInt, PositiveInt, RelativePath, statics } from "@opencode-ai/schema/schema" import { Workspace } from "@opencode-ai/schema/workspace" import { Context, Effect, Encoding, Result, Schema, Struct } from "effect" import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" @@ -80,40 +80,6 @@ export const SessionsCursor = Schema.String.pipe( ) export type SessionsCursor = typeof SessionsCursor.Type -const SessionHistoryCursorInput = Schema.Struct({ - after: Schema.Int.check(Schema.isGreaterThanOrEqualTo(-1)), - through: Schema.Int.check(Schema.isGreaterThanOrEqualTo(-1)).pipe(Schema.optional), -}) -const SessionHistoryCursorJson = Schema.fromJsonString(SessionHistoryCursorInput) -const encodeSessionHistoryCursor = Schema.encodeSync(SessionHistoryCursorJson) -const decodeSessionHistoryCursor = Schema.decodeUnknownEffect(SessionHistoryCursorJson) - -export const SessionHistoryCursor = Schema.String.pipe( - Schema.brand("Session.HistoryCursor"), - statics((schema) => { - const make = schema.make.bind(schema) - return { - after: (sequence: number) => - make( - Encoding.encodeBase64Url(encodeSessionHistoryCursor({ after: Schema.decodeSync(NonNegativeInt)(sequence) })), - ), - parse: (input: string) => - Effect.suspend(() => { - const result = Encoding.decodeBase64UrlString(input) - return Result.isFailure(result) - ? Effect.fail(invalidCursor) - : decodeSessionHistoryCursor(result.success).pipe(Effect.mapError(() => invalidCursor)) - }), - } - }), -) -export type SessionHistoryCursor = typeof SessionHistoryCursor.Type - -export const SessionHistoryCursorInternal = { - next: (after: number, through: number) => - Schema.decodeSync(SessionHistoryCursor)(Encoding.encodeBase64Url(encodeSessionHistoryCursor({ after, through }))), -} - const SessionActive = Schema.Struct({ type: Schema.Literal("running"), }).annotate({ identifier: "SessionActive" }) @@ -122,7 +88,7 @@ const SessionHistoryLimit = PositiveInt.check(Schema.isLessThanOrEqualTo(100)) export const SessionHistoryQuery = Schema.Struct({ limit: Schema.NumberFromString.pipe(Schema.decodeTo(SessionHistoryLimit), Schema.optional), - cursor: SessionHistoryCursor.pipe(Schema.optional), + after: Schema.NumberFromString.pipe(Schema.decodeTo(NonNegativeInt), Schema.optional), }) const SessionsQueryCursor = SessionsCursor.annotate({ @@ -343,9 +309,9 @@ export const makeSessionGroup = (sessionLo query: SessionHistoryQuery, success: Schema.Struct({ data: Schema.Array(SessionEvent.Durable), - cursor: Schema.Struct({ next: optional(SessionHistoryCursor) }), + hasMore: Schema.Boolean, }).annotate({ identifier: "SessionHistory" }), - error: [SessionNotFoundError, InvalidCursorError], + error: SessionNotFoundError, }) .middleware(sessionLocationMiddleware) .annotateMerge( @@ -353,7 +319,7 @@ export const makeSessionGroup = (sessionLo identifier: "v2.session.history", summary: "Get session history", description: - "Read one finite page of public durable Session events. Omit cursor to start at the beginning, or pass cursor.next unchanged to continue the fixed snapshot; an omitted cursor.next means the snapshot is exhausted.", + "Read one finite page of public durable Session events after an exclusive aggregate sequence. Newly committed events may appear on later pages.", }), ), ) diff --git a/packages/protocol/test/session-cursor.test.ts b/packages/protocol/test/session-cursor.test.ts index 06b7fc189ecb..2680c962e140 100644 --- a/packages/protocol/test/session-cursor.test.ts +++ b/packages/protocol/test/session-cursor.test.ts @@ -1,11 +1,6 @@ import { describe, expect, test } from "bun:test" import { Effect, Schema } from "effect" -import { - SessionHistoryCursor, - SessionHistoryCursorInternal, - SessionHistoryQuery, - SessionsCursor, -} from "../src/groups/session" +import { SessionHistoryQuery, SessionsCursor } from "../src/groups/session" import { Session } from "@opencode-ai/schema/session" describe("SessionsCursor", () => { @@ -22,29 +17,10 @@ describe("SessionsCursor", () => { }) }) -describe("SessionHistoryCursor", () => { - test("constructs an initial durable checkpoint without a cutoff", async () => { - const cursor = SessionHistoryCursor.after(1) +describe("SessionHistoryQuery", () => { + test("decodes numeric paging inputs", async () => { + const query = await Effect.runPromise(Schema.decodeUnknownEffect(SessionHistoryQuery)({ after: "3", limit: "10" })) - expect(String(cursor)).toBe("eyJhZnRlciI6MX0") - expect(await Effect.runPromise(SessionHistoryCursor.parse(cursor))).toEqual({ after: 1 }) - }) - - test("round trips an empty aggregate continuation", async () => { - const cursor = SessionHistoryCursorInternal.next(-1, -1) - - expect(await Effect.runPromise(SessionHistoryCursor.parse(cursor))).toEqual({ after: -1, through: -1 }) - }) - - test("exposes cursor as the sole optional position", () => { - expect(Schema.is(SessionHistoryQuery)({})).toBe(true) - expect(Schema.is(SessionHistoryQuery)({ cursor: SessionHistoryCursor.after(0) })).toBe(true) - expect("after" in SessionHistoryQuery.fields).toBe(false) - }) - - test("fails malformed cursors in the typed channel", async () => { - const error = await Effect.runPromise(SessionHistoryCursor.parse("malformed").pipe(Effect.flip)) - - expect(error).toBeDefined() + expect(query).toEqual({ after: 3, limit: 10 }) }) }) diff --git a/packages/schema/src/event.ts b/packages/schema/src/event.ts index 1a7583729f17..0d6ec9775aa4 100644 --- a/packages/schema/src/event.ts +++ b/packages/schema/src/event.ts @@ -55,7 +55,7 @@ export function define< id: ID, metadata: optional(Schema.Record(Schema.String, Schema.Unknown)), type: Schema.Literal(input.type), - durable: optional(Schema.Struct({ aggregateID: Schema.String, seq: Schema.Number, version: Schema.Number })), + durable: optional(Schema.Struct({ aggregateID: Schema.String, seq: Schema.Int, version: Schema.Int })), location: optional(Location.Ref), data, }) diff --git a/packages/sdk-next/src/index.ts b/packages/sdk-next/src/index.ts index afcd60718441..ec948e03cadd 100644 --- a/packages/sdk-next/src/index.ts +++ b/packages/sdk-next/src/index.ts @@ -11,7 +11,6 @@ export { Provider, RelativePath, Session, - SessionHistoryCursor, SessionInput, SessionMessage, } from "@opencode-ai/client/effect" diff --git a/packages/sdk-next/test/embedded.test.ts b/packages/sdk-next/test/embedded.test.ts index 9b0314bfedf5..dbd7ed93788f 100644 --- a/packages/sdk-next/test/embedded.test.ts +++ b/packages/sdk-next/test/embedded.test.ts @@ -4,11 +4,6 @@ import { tmpdir } from "node:os" import { join } from "node:path" import { Flag } from "@opencode-ai/core/flag/flag" import { Effect, Option, Schema, Stream } from "effect" -import { SessionHistoryCursor } from "../src" - -test("exports the Session history checkpoint constructor", () => { - expect(String(SessionHistoryCursor.after(0))).toBe("eyJhZnRlciI6MH0") -}) test("embedded client uses the real router and handlers", async () => { const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-")) diff --git a/packages/sdk/js/script/build.ts b/packages/sdk/js/script/build.ts index fca5be3a2dc4..79e0879c9e14 100755 --- a/packages/sdk/js/script/build.ts +++ b/packages/sdk/js/script/build.ts @@ -75,6 +75,24 @@ const generatedTypes = await Bun.file("./src/v2/gen/types.gen.ts").text() if (/export type SessionNext\w+1 =/.test(generatedTypes)) { throw new Error("Session history generated duplicate Session event variants") } +const historyTypesPatched = generatedTypes.replace( + /(export type V2SessionHistoryData = \{[\s\S]*?query\?: \{\s*limit\?: )string([;,]\s*after\?: )string/, + "$1number$2number", +) +if (historyTypesPatched === generatedTypes) { + throw new Error("Session history numeric query patch did not apply") +} +await Bun.write("./src/v2/gen/types.gen.ts", historyTypesPatched) + +const generatedSdk = await Bun.file("./src/v2/gen/sdk.gen.ts").text() +const historySdkPatched = generatedSdk.replace( + /(Get session history[\s\S]*?parameters: \{\s*sessionID: string[;,]\s*limit\?: )string([;,]\s*after\?: )string/, + "$1number$2number", +) +if (historySdkPatched === generatedSdk) { + throw new Error("Session history numeric SDK patch did not apply") +} +await Bun.write("./src/v2/gen/sdk.gen.ts", historySdkPatched) // Patch a @hey-api/openapi-ts codegen bug: SseFn incorrectly passes the // endpoint's TError into the second generic of ServerSentEventsResult, which diff --git a/packages/sdk/js/src/v2/client.ts b/packages/sdk/js/src/v2/client.ts index d78577b2160e..c1956cffe037 100644 --- a/packages/sdk/js/src/v2/client.ts +++ b/packages/sdk/js/src/v2/client.ts @@ -1,6 +1,5 @@ export * from "./gen/types.gen.js" export type { FileSystemEntry as LocationFileSystemEntry } from "./gen/types.gen.js" -export { SessionHistoryCursor } from "./session-history-cursor.js" import { createClient } from "./gen/client/client.gen.js" import { type Config } from "./gen/client/types.gen.js" diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 754f42d25234..9ed0084aac84 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -5715,13 +5715,13 @@ export class Session3 extends HeyApiClient { /** * Get session history * - * Read one finite page of public durable Session events. Omit cursor to start at the beginning, or pass cursor.next unchanged to continue the fixed snapshot; an omitted cursor.next means the snapshot is exhausted. + * Read one finite page of public durable Session events after an exclusive aggregate sequence. Newly committed events may appear on later pages. */ public history( parameters: { sessionID: string - limit?: string - cursor?: string + limit?: number + after?: number }, options?: Options, ) { @@ -5732,7 +5732,7 @@ export class Session3 extends HeyApiClient { args: [ { in: "path", key: "sessionID" }, { in: "query", key: "limit" }, - { in: "query", key: "cursor" }, + { in: "query", key: "after" }, ], }, ], diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index e9215d094d1b..097b68152ed3 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -2763,9 +2763,7 @@ export type SessionDurableEvent = export type SessionHistory = { data: Array - cursor: { - next?: string - } + hasMore: boolean } export type SessionDurableEvent1 = string @@ -4096,8 +4094,8 @@ export type SessionNextAgentSwitched = { type: "session.next.agent.switched" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + seq: number + version: number } location?: LocationRef data: { @@ -4116,8 +4114,8 @@ export type SessionNextModelSwitched = { type: "session.next.model.switched" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + seq: number + version: number } location?: LocationRef data: { @@ -4136,8 +4134,8 @@ export type SessionNextMoved = { type: "session.next.moved" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + seq: number + version: number } location?: LocationRef data: { @@ -4156,8 +4154,8 @@ export type SessionNextPrompted = { type: "session.next.prompted" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + seq: number + version: number } location?: LocationRef data: { @@ -4177,8 +4175,8 @@ export type SessionNextPromptAdmitted = { type: "session.next.prompt.admitted" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + seq: number + version: number } location?: LocationRef data: { @@ -4198,8 +4196,8 @@ export type SessionNextContextUpdated = { type: "session.next.context.updated" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + seq: number + version: number } location?: LocationRef data: { @@ -4218,8 +4216,8 @@ export type SessionNextSynthetic = { type: "session.next.synthetic" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + seq: number + version: number } location?: LocationRef data: { @@ -4238,8 +4236,8 @@ export type SessionNextShellStarted = { type: "session.next.shell.started" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + seq: number + version: number } location?: LocationRef data: { @@ -4259,8 +4257,8 @@ export type SessionNextShellEnded = { type: "session.next.shell.ended" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + seq: number + version: number } location?: LocationRef data: { @@ -4279,8 +4277,8 @@ export type SessionNextStepStarted = { type: "session.next.step.started" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + seq: number + version: number } location?: LocationRef data: { @@ -4301,8 +4299,8 @@ export type SessionNextStepEnded = { type: "session.next.step.ended" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + seq: number + version: number } location?: LocationRef data: { @@ -4333,8 +4331,8 @@ export type SessionNextStepFailed = { type: "session.next.step.failed" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + seq: number + version: number } location?: LocationRef data: { @@ -4353,8 +4351,8 @@ export type SessionNextTextStarted = { type: "session.next.text.started" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + seq: number + version: number } location?: LocationRef data: { @@ -4373,8 +4371,8 @@ export type SessionNextTextEnded = { type: "session.next.text.ended" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + seq: number + version: number } location?: LocationRef data: { @@ -4394,8 +4392,8 @@ export type SessionNextToolInputStarted = { type: "session.next.tool.input.started" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + seq: number + version: number } location?: LocationRef data: { @@ -4415,8 +4413,8 @@ export type SessionNextToolInputEnded = { type: "session.next.tool.input.ended" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + seq: number + version: number } location?: LocationRef data: { @@ -4436,8 +4434,8 @@ export type SessionNextToolCalled = { type: "session.next.tool.called" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + seq: number + version: number } location?: LocationRef data: { @@ -4464,8 +4462,8 @@ export type SessionNextToolProgress = { type: "session.next.tool.progress" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + seq: number + version: number } location?: LocationRef data: { @@ -4488,8 +4486,8 @@ export type SessionNextToolSuccess = { type: "session.next.tool.success" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + seq: number + version: number } location?: LocationRef data: { @@ -4518,8 +4516,8 @@ export type SessionNextToolFailed = { type: "session.next.tool.failed" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + seq: number + version: number } location?: LocationRef data: { @@ -4544,8 +4542,8 @@ export type SessionNextReasoningStarted = { type: "session.next.reasoning.started" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + seq: number + version: number } location?: LocationRef data: { @@ -4565,8 +4563,8 @@ export type SessionNextReasoningEnded = { type: "session.next.reasoning.ended" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + seq: number + version: number } location?: LocationRef data: { @@ -4587,8 +4585,8 @@ export type SessionNextRetried = { type: "session.next.retried" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + seq: number + version: number } location?: LocationRef data: { @@ -4607,8 +4605,8 @@ export type SessionNextCompactionStarted = { type: "session.next.compaction.started" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + seq: number + version: number } location?: LocationRef data: { @@ -4627,8 +4625,8 @@ export type SessionNextCompactionEnded = { type: "session.next.compaction.ended" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + seq: number + version: number } location?: LocationRef data: { @@ -4649,8 +4647,8 @@ export type SessionNextRevertStaged = { type: "session.next.revert.staged" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + seq: number + version: number } location?: LocationRef data: { @@ -4668,8 +4666,8 @@ export type SessionNextRevertCleared = { type: "session.next.revert.cleared" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + seq: number + version: number } location?: LocationRef data: { @@ -4686,8 +4684,8 @@ export type SessionNextRevertCommitted = { type: "session.next.revert.committed" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + seq: number + version: number } location?: LocationRef data: { @@ -12429,17 +12427,17 @@ export type V2SessionHistoryData = { sessionID: string } query?: { - limit?: string - cursor?: string + limit?: number + after?: number } url: "/api/session/{sessionID}/history" } export type V2SessionHistoryErrors = { /** - * InvalidCursorError | InvalidRequestError + * InvalidRequestError */ - 400: InvalidCursorError | InvalidRequestError + 400: InvalidRequestError /** * UnauthorizedError */ diff --git a/packages/sdk/js/src/v2/session-history-cursor.ts b/packages/sdk/js/src/v2/session-history-cursor.ts deleted file mode 100644 index 1d3e7a02de8c..000000000000 --- a/packages/sdk/js/src/v2/session-history-cursor.ts +++ /dev/null @@ -1,17 +0,0 @@ -export type SessionHistoryCursor = string - -export const SessionHistoryCursor = { - after(sequence: number): SessionHistoryCursor { - if (!Number.isSafeInteger(sequence) || sequence < 0) { - throw new RangeError("Session history sequence must be a non-negative safe integer") - } - return encode({ after: sequence }) - }, -} - -function encode(value: { readonly after: number }) { - const bytes = new TextEncoder().encode(JSON.stringify(value)) - let binary = "" - for (const byte of bytes) binary += String.fromCharCode(byte) - return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "") -} diff --git a/packages/sdk/js/test/session-history-cursor.test.ts b/packages/sdk/js/test/session-history-cursor.test.ts deleted file mode 100644 index 2e5865c9f6d0..000000000000 --- a/packages/sdk/js/test/session-history-cursor.test.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { expect, test } from "bun:test" -import { SessionHistoryCursor } from "../src/v2/client" - -test("constructs an initial opaque Session history cursor", () => { - expect(SessionHistoryCursor.after(0)).toBe("eyJhZnRlciI6MH0") - expect(() => SessionHistoryCursor.after(-1)).toThrow(RangeError) -}) diff --git a/packages/sdk/js/test/session-history.test.ts b/packages/sdk/js/test/session-history.test.ts new file mode 100644 index 000000000000..44a974333168 --- /dev/null +++ b/packages/sdk/js/test/session-history.test.ts @@ -0,0 +1,12 @@ +import { expect, test } from "bun:test" +import type { V2SessionHistoryData } from "../src/v2/gen/types.gen" + +test("uses numeric Session history positions", () => { + const input = { + path: { sessionID: "ses_test" }, + query: { after: 1, limit: 50 }, + url: "/api/session/{sessionID}/history", + } satisfies V2SessionHistoryData + + expect(input.query.after).toBe(1) +}) diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts index e51aa8a12630..5b7d354b04fc 100644 --- a/packages/server/src/handlers/session.ts +++ b/packages/server/src/handlers/session.ts @@ -2,11 +2,7 @@ import { SessionV2 } from "@opencode-ai/core/session" import { DateTime, Effect, Stream } from "effect" import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" import { Api } from "../api" -import { - SessionHistoryCursor, - SessionHistoryCursorInternal, - SessionsCursor, -} from "@opencode-ai/protocol/groups/session" +import { SessionsCursor } from "@opencode-ai/protocol/groups/session" import { ConflictError, InvalidCursorError, @@ -336,28 +332,16 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl .handle( "session.history", Effect.fn(function* (ctx) { - const continuation = - ctx.query.cursor !== undefined - ? yield* SessionHistoryCursor.parse(ctx.query.cursor).pipe( - Effect.mapError(() => new InvalidCursorError({ message: "Invalid cursor" })), - ) - : undefined return yield* session .history({ sessionID: ctx.params.sessionID, - after: continuation?.after, - through: continuation?.through, + after: ctx.query.after, limit: ctx.query.limit ?? DefaultSessionHistoryLimit, }) .pipe( Effect.map((page) => ({ data: page.events, - cursor: { - next: - page.nextAfter === undefined - ? undefined - : SessionHistoryCursorInternal.next(page.nextAfter, page.through), - }, + hasMore: page.hasMore, })), Effect.catchTag( "Session.NotFoundError", @@ -367,10 +351,6 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl message: `Session not found: ${error.sessionID}`, }), ), - Effect.catchTag( - "Session.InvalidCursorError", - (error) => new InvalidCursorError({ message: error.message }), - ), ) }), ) diff --git a/specs/v2/schema-changelog.md b/specs/v2/schema-changelog.md index 30ef351ee31d..379c675d3dbb 100644 --- a/specs/v2/schema-changelog.md +++ b/specs/v2/schema-changelog.md @@ -3,7 +3,7 @@ ## 2026-06-26: Add Finite Session History - Add `GET /api/session/:sessionID/history` and generated Promise, Effect, and legacy JavaScript client methods. -- Page only public durable Session events from one optional opaque cursor; omission starts before sequence zero, while `SessionHistoryCursor.after(sequence)` starts from an acknowledged durable checkpoint. +- Page public durable Session events after an optional exclusive aggregate sequence, with an explicit `hasMore` exhaustion signal. - Keep aggregate gaps legal, cap pages at 100 events, and preserve the existing durable replay-and-tail `sessions.events()` stream unchanged. - Add no migration or durable-event version; this is a finite read API over the existing event manifest. diff --git a/specs/v2/session.md b/specs/v2/session.md index d4456fbe1571..f3c4211c6c60 100644 --- a/specs/v2/session.md +++ b/specs/v2/session.md @@ -176,7 +176,7 @@ The synchronized `session.next.*` event family and projected Session-message mod The first `sessions.events(...)` contract is durable-only during both replay and live tailing. This keeps one cursor equal to one persisted aggregate sequence and is sufficient for reconnect-safe consumers. A later UI-facing API may optionally interleave live-only deltas while connected, but those fragments must remain explicitly ephemeral: they cannot advance the durable cursor, replay after reconnect, or be mistaken for publication boundaries. -`sessions.history({ sessionID, cursor?, limit? })` is the finite counterpart for request/response consumers. Omitting `cursor` starts before sequence zero. `SessionHistoryCursor.after(sequence)` constructs an opaque initial position after an acknowledged durable sequence; its first read captures the aggregate-head cutoff. A returned `cursor.next` continues that fixed snapshot, so commits above the cutoff cannot enter the chain. Public durable Session events are selected before pagination, which permits gaps from private or historical aggregate events while preserving strictly increasing unique sequences. An omitted `cursor.next` means that fixed snapshot is exhausted. +`sessions.history({ sessionID, after?, limit? })` is the finite counterpart for request/response consumers. `after` is an exclusive aggregate sequence, and omission starts before sequence zero. The response is `{ data, hasMore }`; callers derive the next `after` from the final event's durable sequence when `hasMore` is true. Public durable Session events are selected before pagination, which permits gaps from private or historical aggregate events while preserving strictly increasing unique sequences. The log has a moving head, so events committed between pages may appear on the next page. The finite endpoint is `GET /api/session/:sessionID/history`, uses the normal Session Location and authorization middleware, defaults to 50 events, and accepts at most 100. It returns only events in the public durable Session schema. The existing `sessions.events()` replay-and-tail stream is unchanged.