diff --git a/packages/client/src/generated-effect/client.ts b/packages/client/src/generated-effect/client.ts index 172eb4ca228e..4856a3ccde78 100644 --- a/packages/client/src/generated-effect/client.ts +++ b/packages/client/src/generated-effect/client.ts @@ -149,12 +149,24 @@ 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 limit?: Endpoint0_13Request["query"]["limit"] readonly after?: Endpoint0_13Request["query"]["after"] } const Endpoint0_13 = (raw: RawClient["server.session"]) => (input: Endpoint0_13Input) => + raw["session.history"]({ + params: { sessionID: input.sessionID }, + query: { limit: input.limit, after: input.after }, + }).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 +174,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 +204,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..6366fbb56051 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: { limit: input.limit, after: input.after }, + 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..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 @@ -591,9 +591,469 @@ export type SessionsContextOutput = { > }["data"] +export type SessionsHistoryInput = { + readonly sessionID: { readonly sessionID: string }["sessionID"] + 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 = { + readonly data: ReadonlyArray< + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.agent.switched" + 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 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; readonly version: number } + 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; readonly version: number } + 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; readonly version: number } + 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; readonly version: number } + 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; readonly version: number } + 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; readonly version: number } + 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; readonly version: number } + 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; readonly version: number } + 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; readonly version: number } + 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; readonly version: number } + 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; readonly version: number } + 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; readonly version: number } + 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; readonly version: number } + 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; readonly version: number } + 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; readonly version: number } + 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; readonly version: number } + 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; readonly version: number } + 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; readonly version: number } + 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; readonly version: number } + 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; readonly version: number } + 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; readonly version: number } + 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; readonly version: number } + 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; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly reason: "auto" | "manual" + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.compaction.ended" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly reason: "auto" | "manual" + readonly 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; readonly version: number } + 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; readonly version: number } + 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; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { readonly timestamp: number; readonly sessionID: string; readonly messageID: 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/test/effect.test.ts b/packages/client/test/effect.test.ts index 6ca73c22f46a..865da23b930d 100644 --- a/packages/client/test/effect.test.ts +++ b/packages/client/test/effect.test.ts @@ -1,7 +1,16 @@ 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 { + AbsolutePath, + Agent, + Location, + Model, + OpenCode, + Prompt, + Session, + SessionMessage, +} from "../src/effect" test("sessions.get returns the decoded Effect projection", async () => { const httpClient = HttpClient.make((request) => @@ -16,6 +25,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")) { @@ -28,6 +39,20 @@ 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( + historyPage === 1 + ? { data: [modelSwitchedEvent], hasMore: true } + : { data: [], hasMore: false }, + ), + ), + ) + } if (url.includes("/prompt")) { return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(admission))) } @@ -72,6 +97,18 @@ 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, + limit: 1, + }) + const historyNext = history.hasMore + ? yield* client.sessions.history({ + sessionID: Session.ID.make("ses_test"), + after: history.data.at(-1)?.durable?.seq, + limit: 2, + }) + : undefined const events = yield* client.sessions .events({ sessionID: Session.ID.make("ses_test"), after: 0 }) .pipe(Stream.runCollect) @@ -80,7 +117,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, 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) @@ -92,10 +129,39 @@ 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.data[0].data.timestamp)).toBe(1_717_171_717_000) + 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 SessionNotFoundError", async () => { + const httpClient = HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json( + { _tag: "SessionNotFoundError", sessionID: "ses_missing", message: "Session not found" }, + { status: 404 }, + ), + ), + ), + ) + 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_missing"), + }) + .pipe(Effect.flip) + }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise) + + expect(error._tag).toBe("SessionNotFoundError") +}) + const session = { data: { id: "ses_test", diff --git a/packages/client/test/promise.test.ts b/packages/client/test/promise.test.ts index 952a3fc0d78a..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 { isUnauthorizedError, OpenCode } from "../src" +import { isSessionNotFoundError, isUnauthorizedError, OpenCode } from "../src" test("sessions.get returns the wire projection", async () => { const client = OpenCode.make({ @@ -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) => { @@ -29,6 +30,14 @@ test("session methods use the public HTTP contract", async () => { headers: { "content-type": "text/event-stream" }, }) } + if (url.includes("/history")) { + historyPage++ + return Response.json( + historyPage === 1 + ? { data: [modelSwitchedEvent], hasMore: true } + : { data: [], hasMore: false }, + ) + } 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 }) @@ -39,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" }) @@ -55,8 +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 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" }) @@ -65,6 +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(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([ @@ -77,6 +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&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"], @@ -104,6 +122,24 @@ test("middleware errors remain declared client errors", async () => { } }) +test("sessions.history decodes SessionNotFoundError", async () => { + const client = OpenCode.make({ + baseUrl: "http://localhost:3000", + fetch: async () => + Response.json( + { _tag: "SessionNotFoundError", sessionID: "ses_missing", message: "Session not found" }, + { status: 404 }, + ), + }) + + try { + await client.sessions.history({ sessionID: "ses_missing" }) + throw new Error("Expected request to fail") + } catch (error) { + expect(isSessionNotFoundError(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..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 } 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,6 +47,66 @@ export class InvalidDurableEventError extends Schema.TaggedErrorClass { + 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 limit: number + readonly manifest: { + readonly definitions: ReadonlyMap + readonly schema: Schema.Decoder + } + }, +) { + const after = input.after ?? -1 + 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())), + ), + ) + .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({ + 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, + hasMore: rows.length > input.limit, + } +}) + export const define = Event.define export const versionedType = Event.versionedType @@ -459,19 +519,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..8d9ad9cd9fa8 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 @@ -131,6 +132,14 @@ export interface Interface { sessionID: SessionSchema.ID after?: number }) => Stream.Stream + readonly history: (input: { + sessionID: SessionSchema.ID + after?: number + limit: number + }) => Effect.Effect< + { events: ReadonlyArray; hasMore: boolean }, + NotFoundError + > readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: string }) => Effect.Effect readonly switchModel: (input: { sessionID: SessionSchema.ID @@ -347,6 +356,14 @@ 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) + return yield* EventV2.readAggregate(db, { + ...input, + aggregateID: input.sessionID, + manifest: SessionDurable, + }) + }), 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..8cee6546b0e1 --- /dev/null +++ b/packages/core/test/session-history.test.ts @@ -0,0 +1,174 @@ +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 { 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( + 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 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 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: [], 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) + }), + ) + + 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 after = first.events.at(-1)?.durable?.seq + const second = yield* session.history({ + sessionID: created.id, + after, + limit: 2, + }) + const sequence = [...first.events, ...second.events].map((event) => event.durable?.seq) + + 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("includes events committed between 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 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.events.at(-1)?.durable?.seq, + limit: 10, + }) + + 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("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 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(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) + }), + ) + + 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/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 d3c8981a64b1..b8368383d09b 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.data) + check(typeof body.hasMore === "boolean", "Expected a history exhaustion signal") + }, + "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 sequence" })) + .at((ctx) => ({ + path: `${route("/api/session/{sessionID}/history", { sessionID: ctx.state.id })}?after=-1`, + 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..8ce85ef79686 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, 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,7 +68,13 @@ 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)) + }), } }), ) @@ -77,6 +84,13 @@ const SessionActive = Schema.Struct({ type: Schema.Literal("running"), }).annotate({ identifier: "SessionActive" }) +const SessionHistoryLimit = PositiveInt.check(Schema.isLessThanOrEqualTo(100)) + +export const SessionHistoryQuery = Schema.Struct({ + limit: Schema.NumberFromString.pipe(Schema.decodeTo(SessionHistoryLimit), Schema.optional), + after: Schema.NumberFromString.pipe(Schema.decodeTo(NonNegativeInt), Schema.optional), +}) + const SessionsQueryCursor = SessionsCursor.annotate({ description: "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response.", }) @@ -289,6 +303,26 @@ export const makeSessionGroup = (sessionLo }), ), ) + .add( + HttpApiEndpoint.get("session.history", "/api/session/:sessionID/history", { + params: { sessionID: Session.ID }, + query: SessionHistoryQuery, + success: Schema.Struct({ + data: Schema.Array(SessionEvent.Durable), + hasMore: Schema.Boolean, + }).annotate({ identifier: "SessionHistory" }), + error: SessionNotFoundError, + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.history", + summary: "Get session history", + description: + "Read one finite page of public durable Session events after an exclusive aggregate sequence. Newly committed events may appear on later pages.", + }), + ), + ) .add( HttpApiEndpoint.get("session.events", "/api/session/:sessionID/event", { params: { sessionID: Session.ID }, diff --git a/packages/protocol/test/session-cursor.test.ts b/packages/protocol/test/session-cursor.test.ts index 60755f63a598..2680c962e140 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 { SessionHistoryQuery, SessionsCursor } from "../src/groups/session" import { Session } from "@opencode-ai/schema/session" describe("SessionsCursor", () => { @@ -16,3 +16,11 @@ describe("SessionsCursor", () => { expect(await Effect.runPromise(SessionsCursor.parse(cursor))).toEqual(input) }) }) + +describe("SessionHistoryQuery", () => { + test("decodes numeric paging inputs", async () => { + const query = await Effect.runPromise(Schema.decodeUnknownEffect(SessionHistoryQuery)({ after: "3", limit: "10" })) + + expect(query).toEqual({ after: 3, limit: 10 }) + }) +}) 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..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, }) @@ -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/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/script/build.ts b/packages/sdk/js/script/build.ts index 72f4e3f3e993..79e0879c9e14 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,29 @@ 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") +} +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 // 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 73fb66227161..9ed0084aac84 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,38 @@ export class Session3 extends HeyApiClient { }) } + /** + * Get session history + * + * 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?: number + after?: number + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "limit" }, + { in: "query", key: "after" }, + ], + }, + ], + ) + 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..097b68152ed3 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -2731,6 +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 = { + data: Array + hasMore: boolean +} + +export type SessionDurableEvent1 = string + export type SessionMessagesResponse = { data: Array cursor: { @@ -4057,8 +4094,8 @@ export type SessionNextAgentSwitched = { type: "session.next.agent.switched" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number + version: number } location?: LocationRef data: { @@ -4077,8 +4114,8 @@ export type SessionNextModelSwitched = { type: "session.next.model.switched" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number + version: number } location?: LocationRef data: { @@ -4097,8 +4134,8 @@ export type SessionNextMoved = { type: "session.next.moved" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number + version: number } location?: LocationRef data: { @@ -4117,8 +4154,8 @@ export type SessionNextPrompted = { type: "session.next.prompted" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number + version: number } location?: LocationRef data: { @@ -4138,8 +4175,8 @@ export type SessionNextPromptAdmitted = { type: "session.next.prompt.admitted" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number + version: number } location?: LocationRef data: { @@ -4159,8 +4196,8 @@ export type SessionNextContextUpdated = { type: "session.next.context.updated" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number + version: number } location?: LocationRef data: { @@ -4179,8 +4216,8 @@ export type SessionNextSynthetic = { type: "session.next.synthetic" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number + version: number } location?: LocationRef data: { @@ -4199,8 +4236,8 @@ export type SessionNextShellStarted = { type: "session.next.shell.started" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number + version: number } location?: LocationRef data: { @@ -4220,8 +4257,8 @@ export type SessionNextShellEnded = { type: "session.next.shell.ended" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number + version: number } location?: LocationRef data: { @@ -4240,8 +4277,8 @@ export type SessionNextStepStarted = { type: "session.next.step.started" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number + version: number } location?: LocationRef data: { @@ -4262,8 +4299,8 @@ export type SessionNextStepEnded = { type: "session.next.step.ended" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number + version: number } location?: LocationRef data: { @@ -4294,8 +4331,8 @@ export type SessionNextStepFailed = { type: "session.next.step.failed" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number + version: number } location?: LocationRef data: { @@ -4314,8 +4351,8 @@ export type SessionNextTextStarted = { type: "session.next.text.started" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number + version: number } location?: LocationRef data: { @@ -4334,8 +4371,8 @@ export type SessionNextTextEnded = { type: "session.next.text.ended" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number + version: number } location?: LocationRef data: { @@ -4355,8 +4392,8 @@ export type SessionNextToolInputStarted = { type: "session.next.tool.input.started" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number + version: number } location?: LocationRef data: { @@ -4376,8 +4413,8 @@ export type SessionNextToolInputEnded = { type: "session.next.tool.input.ended" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number + version: number } location?: LocationRef data: { @@ -4397,8 +4434,8 @@ export type SessionNextToolCalled = { type: "session.next.tool.called" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number + version: number } location?: LocationRef data: { @@ -4425,8 +4462,8 @@ export type SessionNextToolProgress = { type: "session.next.tool.progress" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number + version: number } location?: LocationRef data: { @@ -4449,8 +4486,8 @@ export type SessionNextToolSuccess = { type: "session.next.tool.success" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number + version: number } location?: LocationRef data: { @@ -4479,8 +4516,8 @@ export type SessionNextToolFailed = { type: "session.next.tool.failed" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number + version: number } location?: LocationRef data: { @@ -4505,8 +4542,8 @@ export type SessionNextReasoningStarted = { type: "session.next.reasoning.started" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number + version: number } location?: LocationRef data: { @@ -4526,8 +4563,8 @@ export type SessionNextReasoningEnded = { type: "session.next.reasoning.ended" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number + version: number } location?: LocationRef data: { @@ -4548,8 +4585,8 @@ export type SessionNextRetried = { type: "session.next.retried" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number + version: number } location?: LocationRef data: { @@ -4568,8 +4605,8 @@ export type SessionNextCompactionStarted = { type: "session.next.compaction.started" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number + version: number } location?: LocationRef data: { @@ -4588,8 +4625,8 @@ export type SessionNextCompactionEnded = { type: "session.next.compaction.ended" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number + version: number } location?: LocationRef data: { @@ -4610,8 +4647,8 @@ export type SessionNextRevertStaged = { type: "session.next.revert.staged" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number + version: number } location?: LocationRef data: { @@ -4629,8 +4666,8 @@ export type SessionNextRevertCleared = { type: "session.next.revert.cleared" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number + version: number } location?: LocationRef data: { @@ -4647,8 +4684,8 @@ export type SessionNextRevertCommitted = { type: "session.next.revert.committed" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number + version: number } location?: LocationRef data: { @@ -12384,6 +12421,44 @@ export type V2SessionContextResponses = { export type V2SessionContextResponse = V2SessionContextResponses[keyof V2SessionContextResponses] +export type V2SessionHistoryData = { + body?: never + path: { + sessionID: string + } + query?: { + limit?: number + after?: number + } + url: "/api/session/{sessionID}/history" +} + +export type V2SessionHistoryErrors = { + /** + * InvalidRequestError + */ + 400: 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: { @@ -12419,7 +12494,7 @@ export type V2SessionEventsResponses = { 200: { id: string event: string - data: string + data: SessionDurableEvent1 } } 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 602673e1ff77..5b7d354b04fc 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,31 @@ 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, + limit: ctx.query.limit ?? DefaultSessionHistoryLimit, + }) + .pipe( + Effect.map((page) => ({ + data: page.events, + hasMore: page.hasMore, + })), + Effect.catchTag( + "Session.NotFoundError", + (error) => + new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }), + ), + ) + }), + ) .handle( "session.events", Effect.fn((ctx) => diff --git a/specs/v2/schema-changelog.md b/specs/v2/schema-changelog.md index 9f0cc83856a7..379c675d3dbb 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 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. + ## 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..f3c4211c6c60 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?, 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. + 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.