Bläddra i källkod

feat(api): add finite durable session history pages (#34097)

Kit Langton 2 månader sedan
förälder
incheckning
65210f2d97

+ 25 - 12
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),
     Effect.map((value) => value.data),
   )
   )
 
 
-type Endpoint0_13Request = Parameters<RawClient["server.session"]["session.events"]>[0]
+type Endpoint0_13Request = Parameters<RawClient["server.session"]["session.history"]>[0]
 type Endpoint0_13Input = {
 type Endpoint0_13Input = {
   readonly sessionID: Endpoint0_13Request["params"]["sessionID"]
   readonly sessionID: Endpoint0_13Request["params"]["sessionID"]
+  readonly limit?: Endpoint0_13Request["query"]["limit"]
   readonly after?: Endpoint0_13Request["query"]["after"]
   readonly after?: Endpoint0_13Request["query"]["after"]
 }
 }
 const Endpoint0_13 = (raw: RawClient["server.session"]) => (input: Endpoint0_13Input) =>
 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<RawClient["server.session"]["session.events"]>[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(
   Stream.unwrap(
     raw["session.events"]({ params: { sessionID: input.sessionID }, query: { after: input.after } }).pipe(
     raw["session.events"]({ params: { sessionID: input.sessionID }, query: { after: input.after } }).pipe(
       Effect.mapError(mapClientError),
       Effect.mapError(mapClientError),
@@ -162,17 +174,17 @@ const Endpoint0_13 = (raw: RawClient["server.session"]) => (input: Endpoint0_13I
     ),
     ),
   )
   )
 
 
-type Endpoint0_14Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
-type Endpoint0_14Input = { readonly sessionID: Endpoint0_14Request["params"]["sessionID"] }
-const Endpoint0_14 = (raw: RawClient["server.session"]) => (input: Endpoint0_14Input) =>
+type Endpoint0_15Request = Parameters<RawClient["server.session"]["session.interrupt"]>[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))
   raw["session.interrupt"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
 
 
-type Endpoint0_15Request = Parameters<RawClient["server.session"]["session.message"]>[0]
-type Endpoint0_15Input = {
-  readonly sessionID: Endpoint0_15Request["params"]["sessionID"]
-  readonly messageID: Endpoint0_15Request["params"]["messageID"]
+type Endpoint0_16Request = Parameters<RawClient["server.session"]["session.message"]>[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(
   raw["session.message"]({ params: { sessionID: input.sessionID, messageID: input.messageID } }).pipe(
     Effect.mapError(mapClientError),
     Effect.mapError(mapClientError),
     Effect.map((value) => value.data),
     Effect.map((value) => value.data),
@@ -192,9 +204,10 @@ const adaptGroup0 = (raw: RawClient["server.session"]) => ({
   clear: Endpoint0_10(raw),
   clear: Endpoint0_10(raw),
   commit: Endpoint0_11(raw),
   commit: Endpoint0_11(raw),
   context: Endpoint0_12(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"]) })
 const adaptClient = (raw: RawClient) => ({ sessions: adaptGroup0(raw["server.session"]) })

+ 14 - 0
packages/client/src/generated/client.ts

@@ -24,6 +24,8 @@ import type {
   SessionsCommitOutput,
   SessionsCommitOutput,
   SessionsContextInput,
   SessionsContextInput,
   SessionsContextOutput,
   SessionsContextOutput,
+  SessionsHistoryInput,
+  SessionsHistoryOutput,
   SessionsEventsInput,
   SessionsEventsInput,
   SessionsEventsOutput,
   SessionsEventsOutput,
   SessionsInterruptInput,
   SessionsInterruptInput,
@@ -324,6 +326,18 @@ export function make(options: ClientOptions) {
           },
           },
           requestOptions,
           requestOptions,
         ).then((value) => value.data),
         ).then((value) => value.data),
+      history: (input: SessionsHistoryInput, requestOptions?: RequestOptions) =>
+        request<SessionsHistoryOutput>(
+          {
+            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<SessionsEventsOutput> =>
       events: (input: SessionsEventsInput, requestOptions?: RequestOptions): AsyncIterable<SessionsEventsOutput> =>
         sse<SessionsEventsOutput>(
         sse<SessionsEventsOutput>(
           {
           {

+ 469 - 9
packages/client/src/generated/types.ts

@@ -67,7 +67,7 @@ export const isUnknownError = (value: unknown): value is UnknownError =>
 export type SessionsListInput = {
 export type SessionsListInput = {
   readonly workspace?: {
   readonly workspace?: {
     readonly workspace?: string | undefined
     readonly workspace?: string | undefined
-    readonly limit?: string | undefined
+    readonly limit?: number | undefined
     readonly order?: "asc" | "desc" | undefined
     readonly order?: "asc" | "desc" | undefined
     readonly search?: string | undefined
     readonly search?: string | undefined
     readonly directory?: string | undefined
     readonly directory?: string | undefined
@@ -77,7 +77,7 @@ export type SessionsListInput = {
   }["workspace"]
   }["workspace"]
   readonly limit?: {
   readonly limit?: {
     readonly workspace?: string | undefined
     readonly workspace?: string | undefined
-    readonly limit?: string | undefined
+    readonly limit?: number | undefined
     readonly order?: "asc" | "desc" | undefined
     readonly order?: "asc" | "desc" | undefined
     readonly search?: string | undefined
     readonly search?: string | undefined
     readonly directory?: string | undefined
     readonly directory?: string | undefined
@@ -87,7 +87,7 @@ export type SessionsListInput = {
   }["limit"]
   }["limit"]
   readonly order?: {
   readonly order?: {
     readonly workspace?: string | undefined
     readonly workspace?: string | undefined
-    readonly limit?: string | undefined
+    readonly limit?: number | undefined
     readonly order?: "asc" | "desc" | undefined
     readonly order?: "asc" | "desc" | undefined
     readonly search?: string | undefined
     readonly search?: string | undefined
     readonly directory?: string | undefined
     readonly directory?: string | undefined
@@ -97,7 +97,7 @@ export type SessionsListInput = {
   }["order"]
   }["order"]
   readonly search?: {
   readonly search?: {
     readonly workspace?: string | undefined
     readonly workspace?: string | undefined
-    readonly limit?: string | undefined
+    readonly limit?: number | undefined
     readonly order?: "asc" | "desc" | undefined
     readonly order?: "asc" | "desc" | undefined
     readonly search?: string | undefined
     readonly search?: string | undefined
     readonly directory?: string | undefined
     readonly directory?: string | undefined
@@ -107,7 +107,7 @@ export type SessionsListInput = {
   }["search"]
   }["search"]
   readonly directory?: {
   readonly directory?: {
     readonly workspace?: string | undefined
     readonly workspace?: string | undefined
-    readonly limit?: string | undefined
+    readonly limit?: number | undefined
     readonly order?: "asc" | "desc" | undefined
     readonly order?: "asc" | "desc" | undefined
     readonly search?: string | undefined
     readonly search?: string | undefined
     readonly directory?: string | undefined
     readonly directory?: string | undefined
@@ -117,7 +117,7 @@ export type SessionsListInput = {
   }["directory"]
   }["directory"]
   readonly project?: {
   readonly project?: {
     readonly workspace?: string | undefined
     readonly workspace?: string | undefined
-    readonly limit?: string | undefined
+    readonly limit?: number | undefined
     readonly order?: "asc" | "desc" | undefined
     readonly order?: "asc" | "desc" | undefined
     readonly search?: string | undefined
     readonly search?: string | undefined
     readonly directory?: string | undefined
     readonly directory?: string | undefined
@@ -127,7 +127,7 @@ export type SessionsListInput = {
   }["project"]
   }["project"]
   readonly subpath?: {
   readonly subpath?: {
     readonly workspace?: string | undefined
     readonly workspace?: string | undefined
-    readonly limit?: string | undefined
+    readonly limit?: number | undefined
     readonly order?: "asc" | "desc" | undefined
     readonly order?: "asc" | "desc" | undefined
     readonly search?: string | undefined
     readonly search?: string | undefined
     readonly directory?: string | undefined
     readonly directory?: string | undefined
@@ -137,7 +137,7 @@ export type SessionsListInput = {
   }["subpath"]
   }["subpath"]
   readonly cursor?: {
   readonly cursor?: {
     readonly workspace?: string | undefined
     readonly workspace?: string | undefined
-    readonly limit?: string | undefined
+    readonly limit?: number | undefined
     readonly order?: "asc" | "desc" | undefined
     readonly order?: "asc" | "desc" | undefined
     readonly search?: string | undefined
     readonly search?: string | undefined
     readonly directory?: string | undefined
     readonly directory?: string | undefined
@@ -591,9 +591,469 @@ export type SessionsContextOutput = {
   >
   >
 }["data"]
 }["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<string>
+        }
+      }
+    | {
+        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<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.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 = {
 export type SessionsEventsInput = {
   readonly sessionID: { readonly sessionID: string }["sessionID"]
   readonly sessionID: { readonly sessionID: string }["sessionID"]
-  readonly after?: { readonly after?: string | undefined }["after"]
+  readonly after?: { readonly after?: number | undefined }["after"]
 }
 }
 
 
 export type SessionsEventsOutput =
 export type SessionsEventsOutput =

+ 68 - 2
packages/client/test/effect.test.ts

@@ -1,7 +1,16 @@
 import { expect, test } from "bun:test"
 import { expect, test } from "bun:test"
 import { DateTime, Effect, Stream } from "effect"
 import { DateTime, Effect, Stream } from "effect"
 import { HttpClient, HttpClientResponse } from "effect/unstable/http"
 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 () => {
 test("sessions.get returns the decoded Effect projection", async () => {
   const httpClient = HttpClient.make((request) =>
   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 () => {
 test("session methods retain decoded Effect inputs and outputs", async () => {
+  const historyQueries: Array<Record<string, string>> = []
+  let historyPage = 0
   const httpClient = HttpClient.make((request) => {
   const httpClient = HttpClient.make((request) => {
     const url = request.url
     const url = request.url
     if (url.includes("/event")) {
     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")) {
     if (url.includes("/prompt")) {
       return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(admission)))
       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.compact({ sessionID: Session.ID.make("ses_test") })
     yield* client.sessions.wait({ 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 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
     const events = yield* client.sessions
       .events({ sessionID: Session.ID.make("ses_test"), after: 0 })
       .events({ sessionID: Session.ID.make("ses_test"), after: 0 })
       .pipe(Stream.runCollect)
       .pipe(Stream.runCollect)
@@ -80,7 +117,7 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
       sessionID: Session.ID.make("ses_test"),
       sessionID: Session.ID.make("ses_test"),
       messageID: SessionMessage.ID.make("msg_model"),
       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)
   }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
 
 
   expect(DateTime.toEpochMillis(result.page.data[0].time.created)).toBe(1_717_171_717_000)
   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(Object.getPrototypeOf(result.admitted.prompt)).toBe(Object.prototype)
   expect(DateTime.toEpochMillis(result.admitted.timeCreated)).toBe(1_717_171_717_000)
   expect(DateTime.toEpochMillis(result.admitted.timeCreated)).toBe(1_717_171_717_000)
   expect(result.context).toEqual([])
   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(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" }))
   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 = {
 const session = {
   data: {
   data: {
     id: "ses_test",
     id: "ses_test",

+ 39 - 3
packages/client/test/promise.test.ts

@@ -1,5 +1,5 @@
 import { expect, test } from "bun:test"
 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 () => {
 test("sessions.get returns the wire projection", async () => {
   const client = OpenCode.make({
   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 () => {
 test("session methods use the public HTTP contract", async () => {
   const requests: Array<{ url: string; init?: RequestInit }> = []
   const requests: Array<{ url: string; init?: RequestInit }> = []
+  let historyPage = 0
   const client = OpenCode.make({
   const client = OpenCode.make({
     baseUrl: "http://localhost:3000",
     baseUrl: "http://localhost:3000",
     fetch: async (input, init) => {
     fetch: async (input, init) => {
@@ -29,6 +30,14 @@ test("session methods use the public HTTP contract", async () => {
           headers: { "content-type": "text/event-stream" },
           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("/prompt")) return Response.json(admission)
       if (url.includes("/context")) return Response.json({ data: [] })
       if (url.includes("/context")) return Response.json({ data: [] })
       if (url.includes("/message/")) return Response.json({ data: modelSwitchedMessage })
       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 active = await client.sessions.active()
   const created = await client.sessions.create({ location: { directory: "/tmp/project" } })
   const created = await client.sessions.create({ location: { directory: "/tmp/project" } })
   await client.sessions.switchAgent({ sessionID: "ses_test", agent: "build" })
   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.compact({ sessionID: "ses_test" })
   await client.sessions.wait({ sessionID: "ses_test" })
   await client.sessions.wait({ sessionID: "ses_test" })
   const context = await client.sessions.context({ 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 = []
   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" })
   await client.sessions.interrupt({ sessionID: "ses_test" })
   const message = await client.sessions.message({ sessionID: "ses_test", messageID: "msg_model" })
   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(created.id).toBe("ses_test")
   expect(admitted.id).toBe("msg_test")
   expect(admitted.id).toBe("msg_test")
   expect(context).toEqual([])
   expect(context).toEqual([])
+  expect(history).toEqual({ data: [modelSwitchedEvent], hasMore: true })
+  expect(historyNext).toEqual({ data: [], hasMore: false })
   expect(events).toEqual([modelSwitchedEvent])
   expect(events).toEqual([modelSwitchedEvent])
   expect(message).toEqual(modelSwitchedMessage)
   expect(message).toEqual(modelSwitchedMessage)
   expect(requests.map((request) => [request.init?.method, request.url])).toEqual([
   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/compact"],
     ["POST", "http://localhost:3000/api/session/ses_test/wait"],
     ["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/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"],
     ["GET", "http://localhost:3000/api/session/ses_test/event?after=0"],
     ["POST", "http://localhost:3000/api/session/ses_test/interrupt"],
     ["POST", "http://localhost:3000/api/session/ses_test/interrupt"],
     ["GET", "http://localhost:3000/api/session/ses_test/message/msg_model"],
     ["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 = {
 const session = {
   data: {
   data: {
     id: "ses_test",
     id: "ses_test",

+ 61 - 14
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 { Cause, Context, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
 import { Event } from "@opencode-ai/schema/event"
 import { Event } from "@opencode-ai/schema/event"
 import type { Data, Definition, Payload } 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 { Database } from "./database/database"
 import { EventSequenceTable, EventTable } from "./event/sql"
 import { EventSequenceTable, EventTable } from "./event/sql"
 import { Location } from "./location"
 import { Location } from "./location"
@@ -47,6 +47,66 @@ export class InvalidDurableEventError extends Schema.TaggedErrorClass<InvalidDur
   },
   },
 ) {}
 ) {}
 
 
+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* <A>(
+  db: Database.Interface["db"],
+  input: {
+    readonly aggregateID: string
+    readonly after?: number
+    readonly limit: number
+    readonly manifest: {
+      readonly definitions: ReadonlyMap<string, Definition>
+      readonly schema: Schema.Decoder<A, never>
+    }
+  },
+) {
+  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 define = Event.define
 export const versionedType = Event.versionedType
 export const versionedType = Event.versionedType
 
 
@@ -459,19 +519,6 @@ export const layerWith = (options?: LayerOptions) =>
 
 
       const streamAll = (): Stream.Stream<Payload> => Stream.fromPubSub(pubsub.all)
       const streamAll = (): Stream.Stream<Payload> => 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) =>
       const readAfter = (aggregateID: string, after: number) =>
         (options?.beforeAggregateRead?.(aggregateID) ?? Effect.void).pipe(
         (options?.beforeAggregateRead?.(aggregateID) ?? Effect.void).pipe(
           Effect.andThen(
           Effect.andThen(

+ 17 - 0
packages/core/src/session.ts

@@ -34,6 +34,7 @@ import { Snapshot } from "./snapshot"
 import { SessionRevert } from "./session/revert"
 import { SessionRevert } from "./session/revert"
 import { Revert } from "@opencode-ai/schema/revert"
 import { Revert } from "@opencode-ai/schema/revert"
 import { FSUtil } from "./fs-util"
 import { FSUtil } from "./fs-util"
+import { SessionDurable } from "@opencode-ai/schema/durable-event-manifest"
 
 
 export const RevertState = Revert.State
 export const RevertState = Revert.State
 export type RevertState = Revert.State
 export type RevertState = Revert.State
@@ -131,6 +132,14 @@ export interface Interface {
     sessionID: SessionSchema.ID
     sessionID: SessionSchema.ID
     after?: number
     after?: number
   }) => Stream.Stream<SessionEvent.DurableEvent, NotFoundError>
   }) => Stream.Stream<SessionEvent.DurableEvent, NotFoundError>
+  readonly history: (input: {
+    sessionID: SessionSchema.ID
+    after?: number
+    limit: number
+  }) => Effect.Effect<
+    { events: ReadonlyArray<SessionEvent.DurableEvent>; hasMore: boolean },
+    NotFoundError
+  >
   readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: string }) => Effect.Effect<void, NotFoundError>
   readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: string }) => Effect.Effect<void, NotFoundError>
   readonly switchModel: (input: {
   readonly switchModel: (input: {
     sessionID: SessionSchema.ID
     sessionID: SessionSchema.ID
@@ -347,6 +356,14 @@ export const layer = Layer.unwrap(
                   .get(input.sessionID)
                   .get(input.sessionID)
                   .pipe(Effect.as(events.durable({ aggregateID: input.sessionID, after: input.after }))),
                   .pipe(Effect.as(events.durable({ aggregateID: input.sessionID, after: input.after }))),
               ).pipe(Stream.filter((event): event is SessionEvent.DurableEvent => isDurableSessionEvent(event))),
               ).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) =>
             prompt: Effect.fn("V2Session.prompt")((input) =>
               Effect.uninterruptible(
               Effect.uninterruptible(
                 Effect.gen(function* () {
                 Effect.gen(function* () {

+ 174 - 0
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")
+    }),
+  )
+})

+ 6 - 6
packages/httpapi-codegen/src/index.ts

@@ -410,12 +410,12 @@ function renderImportedProjection(groups: ReadonlyArray<Group>, endpoints: Reado
 
 
 function renderPromiseTypes(groups: ReadonlyArray<Group>) {
 function renderPromiseTypes(groups: ReadonlyArray<Group>) {
   const types = new Map<SchemaAST.AST, string>()
   const types = new Map<SchemaAST.AST, string>()
-  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
     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
     return type
   }
   }
   const errors = new Map(
   const errors = new Map(
@@ -449,7 +449,7 @@ function renderPromiseTypes(groups: ReadonlyArray<Group>) {
             const schema = schemas[field.source]
             const schema = schemas[field.source]
             if (schema === undefined)
             if (schema === undefined)
               throw new GenerationError({ reason: `Missing input schema: ${prefix}.${field.name}` })
               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("; ")
           .join("; ")
         const successSchema = endpoint.successes[0]
         const successSchema = endpoint.successes[0]

+ 34 - 0
packages/opencode/test/server/httpapi-exercise/index.ts

@@ -1067,6 +1067,40 @@ const scenarios: Scenario[] = [
       headers: ctx.headers(),
       headers: ctx.headers(),
     }))
     }))
     .status(400, undefined, "none"),
     .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
   http.protected
     .get("/api/session/{sessionID}/event", "v2.session.events.missing")
     .get("/api/session/{sessionID}/event", "v2.session.events.missing")
     .at((ctx) => ({
     .at((ctx) => ({

+ 36 - 2
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 { Project } from "@opencode-ai/schema/project"
 import { AbsolutePath, NonNegativeInt, 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 { 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 { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
 import {
 import {
   ConflictError,
   ConflictError,
@@ -60,6 +60,7 @@ const SessionsCursorInput = Schema.Union([
 const SessionsCursorJson = Schema.fromJsonString(SessionsCursorInput)
 const SessionsCursorJson = Schema.fromJsonString(SessionsCursorInput)
 const encodeSessionsCursor = Schema.encodeSync(SessionsCursorJson)
 const encodeSessionsCursor = Schema.encodeSync(SessionsCursorJson)
 const decodeSessionsCursor = Schema.decodeUnknownEffect(SessionsCursorJson)
 const decodeSessionsCursor = Schema.decodeUnknownEffect(SessionsCursorJson)
+const invalidCursor = "Invalid cursor" as const
 
 
 export const SessionsCursor = Schema.String.pipe(
 export const SessionsCursor = Schema.String.pipe(
   Schema.brand("SessionsCursor"),
   Schema.brand("SessionsCursor"),
@@ -67,7 +68,13 @@ export const SessionsCursor = Schema.String.pipe(
     const make = schema.make.bind(schema)
     const make = schema.make.bind(schema)
     return {
     return {
       make: (input: typeof SessionsCursorInput.Type) => make(Encoding.encodeBase64Url(encodeSessionsCursor(input))),
       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"),
   type: Schema.Literal("running"),
 }).annotate({ identifier: "SessionActive" })
 }).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({
 const SessionsQueryCursor = SessionsCursor.annotate({
   description: "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response.",
   description: "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response.",
 })
 })
@@ -289,6 +303,26 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(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(
     .add(
       HttpApiEndpoint.get("session.events", "/api/session/:sessionID/event", {
       HttpApiEndpoint.get("session.events", "/api/session/:sessionID/event", {
         params: { sessionID: Session.ID },
         params: { sessionID: Session.ID },

+ 10 - 2
packages/protocol/test/session-cursor.test.ts

@@ -1,6 +1,6 @@
 import { describe, expect, test } from "bun:test"
 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"
 import { Session } from "@opencode-ai/schema/session"
 
 
 describe("SessionsCursor", () => {
 describe("SessionsCursor", () => {
@@ -16,3 +16,11 @@ describe("SessionsCursor", () => {
     expect(await Effect.runPromise(SessionsCursor.parse(cursor))).toEqual(input)
     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 })
+  })
+})

+ 5 - 0
packages/schema/src/durable-event-manifest.ts

@@ -4,6 +4,11 @@ import { Event } from "./event"
 import { SessionEvent } from "./session-event"
 import { SessionEvent } from "./session-event"
 import { SessionV1 } from "./session-v1"
 import { SessionV1 } from "./session-v1"
 
 
+export const SessionDurable = {
+  definitions: Event.durable(SessionEvent.DurableDefinitions),
+  schema: SessionEvent.Durable,
+} as const
+
 export const Durable = Event.durable([
 export const Durable = Event.durable([
   ...SessionV1.Event.Definitions.filter((definition) => definition.durable !== undefined),
   ...SessionV1.Event.Definitions.filter((definition) => definition.durable !== undefined),
   ...SessionEvent.DurableDefinitions,
   ...SessionEvent.DurableDefinitions,

+ 3 - 3
packages/schema/src/event.ts

@@ -55,7 +55,7 @@ export function define<
     id: ID,
     id: ID,
     metadata: optional(Schema.Record(Schema.String, Schema.Unknown)),
     metadata: optional(Schema.Record(Schema.String, Schema.Unknown)),
     type: Schema.Literal(input.type),
     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),
     location: optional(Location.Ref),
     data,
     data,
   })
   })
@@ -95,7 +95,7 @@ export function versionedType(type: string, version: number) {
   return `${type}.${version}`
   return `${type}.${version}`
 }
 }
 
 
-export function durable(definitions: ReadonlyArray<Definition>) {
+export function durable<const Definitions extends ReadonlyArray<Definition>>(definitions: Definitions) {
   return readonlyMap(
   return readonlyMap(
     definitions.reduce((result, definition) => {
     definitions.reduce((result, definition) => {
       if (!definition.durable) return result
       if (!definition.durable) return result
@@ -103,7 +103,7 @@ export function durable(definitions: ReadonlyArray<Definition>) {
       if (result.has(key)) throw new Error(`Duplicate durable event definition for ${key}`)
       if (result.has(key)) throw new Error(`Duplicate durable event definition for ${key}`)
       result.set(key, definition)
       result.set(key, definition)
       return result
       return result
-    }, new Map<string, Definition>()),
+    }, new Map<string, Definitions[number]>()),
   )
   )
 }
 }
 
 

+ 3 - 1
packages/schema/src/session-event.ts

@@ -511,7 +511,9 @@ export const Definitions = Event.inventory(
   RevertEvent.Committed,
   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 type DurableEvent = typeof Durable.Type
 
 
 export const All = Schema.Union(Definitions, { mode: "oneOf" }).pipe(Schema.toTaggedUnion("type"))
 export const All = Schema.Union(Definitions, { mode: "oneOf" }).pipe(Schema.toTaggedUnion("type"))

+ 1 - 0
packages/sdk/js/package.json

@@ -5,6 +5,7 @@
   "type": "module",
   "type": "module",
   "license": "MIT",
   "license": "MIT",
   "scripts": {
   "scripts": {
+    "test": "bun test",
     "typecheck": "tsgo --noEmit",
     "typecheck": "tsgo --noEmit",
     "build": "bun ./script/build.ts"
     "build": "bun ./script/build.ts"
   },
   },

+ 54 - 0
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)
 await $`bun dev generate > ${dir}/openapi.json`.cwd(opencode)
 
 
+const document = (await Bun.file("./openapi.json").json()) as {
+  components?: { schemas?: Record<string, unknown> }
+  [key: string]: unknown
+}
+const schemas = document.components?.schemas
+if (schemas) {
+  const reachable = new Set<string>()
+  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({
 await createClient({
   input: "./openapi.json",
   input: "./openapi.json",
   output: {
   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
 // Patch a @hey-api/openapi-ts codegen bug: SseFn incorrectly passes the
 // endpoint's TError into the second generic of ServerSentEventsResult, which
 // endpoint's TError into the second generic of ServerSentEventsResult, which
 // is the AsyncGenerator's TReturn slot. Iterator return values have nothing
 // is the AsyncGenerator's TReturn slot. Iterator return values have nothing

+ 34 - 0
packages/sdk/js/src/v2/gen/sdk.gen.ts

@@ -345,6 +345,8 @@ import type {
   V2SessionEventsResponses,
   V2SessionEventsResponses,
   V2SessionGetErrors,
   V2SessionGetErrors,
   V2SessionGetResponses,
   V2SessionGetResponses,
+  V2SessionHistoryErrors,
+  V2SessionHistoryResponses,
   V2SessionInterruptErrors,
   V2SessionInterruptErrors,
   V2SessionInterruptResponses,
   V2SessionInterruptResponses,
   V2SessionListErrors,
   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<ThrowOnError extends boolean = false>(
+    parameters: {
+      sessionID: string
+      limit?: number
+      after?: number
+    },
+    options?: Options<never, ThrowOnError>,
+  ) {
+    const params = buildClientParams(
+      [parameters],
+      [
+        {
+          args: [
+            { in: "path", key: "sessionID" },
+            { in: "query", key: "limit" },
+            { in: "query", key: "after" },
+          ],
+        },
+      ],
+    )
+    return (options?.client ?? this.client).get<V2SessionHistoryResponses, V2SessionHistoryErrors, ThrowOnError>({
+      url: "/api/session/{sessionID}/history",
+      ...options,
+      ...params,
+    })
+  }
+
   /**
   /**
    * Subscribe to session events
    * Subscribe to session events
    *
    *

+ 132 - 57
packages/sdk/js/src/v2/gen/types.gen.ts

@@ -2731,6 +2731,43 @@ export type UnknownError1 = {
   ref?: string
   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<SessionDurableEvent>
+  hasMore: boolean
+}
+
+export type SessionDurableEvent1 = string
+
 export type SessionMessagesResponse = {
 export type SessionMessagesResponse = {
   data: Array<SessionMessage>
   data: Array<SessionMessage>
   cursor: {
   cursor: {
@@ -4057,8 +4094,8 @@ export type SessionNextAgentSwitched = {
   type: "session.next.agent.switched"
   type: "session.next.agent.switched"
   durable?: {
   durable?: {
     aggregateID: string
     aggregateID: string
-    seq: number | "NaN" | "Infinity" | "-Infinity"
-    version: number | "NaN" | "Infinity" | "-Infinity"
+    seq: number
+    version: number
   }
   }
   location?: LocationRef
   location?: LocationRef
   data: {
   data: {
@@ -4077,8 +4114,8 @@ export type SessionNextModelSwitched = {
   type: "session.next.model.switched"
   type: "session.next.model.switched"
   durable?: {
   durable?: {
     aggregateID: string
     aggregateID: string
-    seq: number | "NaN" | "Infinity" | "-Infinity"
-    version: number | "NaN" | "Infinity" | "-Infinity"
+    seq: number
+    version: number
   }
   }
   location?: LocationRef
   location?: LocationRef
   data: {
   data: {
@@ -4097,8 +4134,8 @@ export type SessionNextMoved = {
   type: "session.next.moved"
   type: "session.next.moved"
   durable?: {
   durable?: {
     aggregateID: string
     aggregateID: string
-    seq: number | "NaN" | "Infinity" | "-Infinity"
-    version: number | "NaN" | "Infinity" | "-Infinity"
+    seq: number
+    version: number
   }
   }
   location?: LocationRef
   location?: LocationRef
   data: {
   data: {
@@ -4117,8 +4154,8 @@ export type SessionNextPrompted = {
   type: "session.next.prompted"
   type: "session.next.prompted"
   durable?: {
   durable?: {
     aggregateID: string
     aggregateID: string
-    seq: number | "NaN" | "Infinity" | "-Infinity"
-    version: number | "NaN" | "Infinity" | "-Infinity"
+    seq: number
+    version: number
   }
   }
   location?: LocationRef
   location?: LocationRef
   data: {
   data: {
@@ -4138,8 +4175,8 @@ export type SessionNextPromptAdmitted = {
   type: "session.next.prompt.admitted"
   type: "session.next.prompt.admitted"
   durable?: {
   durable?: {
     aggregateID: string
     aggregateID: string
-    seq: number | "NaN" | "Infinity" | "-Infinity"
-    version: number | "NaN" | "Infinity" | "-Infinity"
+    seq: number
+    version: number
   }
   }
   location?: LocationRef
   location?: LocationRef
   data: {
   data: {
@@ -4159,8 +4196,8 @@ export type SessionNextContextUpdated = {
   type: "session.next.context.updated"
   type: "session.next.context.updated"
   durable?: {
   durable?: {
     aggregateID: string
     aggregateID: string
-    seq: number | "NaN" | "Infinity" | "-Infinity"
-    version: number | "NaN" | "Infinity" | "-Infinity"
+    seq: number
+    version: number
   }
   }
   location?: LocationRef
   location?: LocationRef
   data: {
   data: {
@@ -4179,8 +4216,8 @@ export type SessionNextSynthetic = {
   type: "session.next.synthetic"
   type: "session.next.synthetic"
   durable?: {
   durable?: {
     aggregateID: string
     aggregateID: string
-    seq: number | "NaN" | "Infinity" | "-Infinity"
-    version: number | "NaN" | "Infinity" | "-Infinity"
+    seq: number
+    version: number
   }
   }
   location?: LocationRef
   location?: LocationRef
   data: {
   data: {
@@ -4199,8 +4236,8 @@ export type SessionNextShellStarted = {
   type: "session.next.shell.started"
   type: "session.next.shell.started"
   durable?: {
   durable?: {
     aggregateID: string
     aggregateID: string
-    seq: number | "NaN" | "Infinity" | "-Infinity"
-    version: number | "NaN" | "Infinity" | "-Infinity"
+    seq: number
+    version: number
   }
   }
   location?: LocationRef
   location?: LocationRef
   data: {
   data: {
@@ -4220,8 +4257,8 @@ export type SessionNextShellEnded = {
   type: "session.next.shell.ended"
   type: "session.next.shell.ended"
   durable?: {
   durable?: {
     aggregateID: string
     aggregateID: string
-    seq: number | "NaN" | "Infinity" | "-Infinity"
-    version: number | "NaN" | "Infinity" | "-Infinity"
+    seq: number
+    version: number
   }
   }
   location?: LocationRef
   location?: LocationRef
   data: {
   data: {
@@ -4240,8 +4277,8 @@ export type SessionNextStepStarted = {
   type: "session.next.step.started"
   type: "session.next.step.started"
   durable?: {
   durable?: {
     aggregateID: string
     aggregateID: string
-    seq: number | "NaN" | "Infinity" | "-Infinity"
-    version: number | "NaN" | "Infinity" | "-Infinity"
+    seq: number
+    version: number
   }
   }
   location?: LocationRef
   location?: LocationRef
   data: {
   data: {
@@ -4262,8 +4299,8 @@ export type SessionNextStepEnded = {
   type: "session.next.step.ended"
   type: "session.next.step.ended"
   durable?: {
   durable?: {
     aggregateID: string
     aggregateID: string
-    seq: number | "NaN" | "Infinity" | "-Infinity"
-    version: number | "NaN" | "Infinity" | "-Infinity"
+    seq: number
+    version: number
   }
   }
   location?: LocationRef
   location?: LocationRef
   data: {
   data: {
@@ -4294,8 +4331,8 @@ export type SessionNextStepFailed = {
   type: "session.next.step.failed"
   type: "session.next.step.failed"
   durable?: {
   durable?: {
     aggregateID: string
     aggregateID: string
-    seq: number | "NaN" | "Infinity" | "-Infinity"
-    version: number | "NaN" | "Infinity" | "-Infinity"
+    seq: number
+    version: number
   }
   }
   location?: LocationRef
   location?: LocationRef
   data: {
   data: {
@@ -4314,8 +4351,8 @@ export type SessionNextTextStarted = {
   type: "session.next.text.started"
   type: "session.next.text.started"
   durable?: {
   durable?: {
     aggregateID: string
     aggregateID: string
-    seq: number | "NaN" | "Infinity" | "-Infinity"
-    version: number | "NaN" | "Infinity" | "-Infinity"
+    seq: number
+    version: number
   }
   }
   location?: LocationRef
   location?: LocationRef
   data: {
   data: {
@@ -4334,8 +4371,8 @@ export type SessionNextTextEnded = {
   type: "session.next.text.ended"
   type: "session.next.text.ended"
   durable?: {
   durable?: {
     aggregateID: string
     aggregateID: string
-    seq: number | "NaN" | "Infinity" | "-Infinity"
-    version: number | "NaN" | "Infinity" | "-Infinity"
+    seq: number
+    version: number
   }
   }
   location?: LocationRef
   location?: LocationRef
   data: {
   data: {
@@ -4355,8 +4392,8 @@ export type SessionNextToolInputStarted = {
   type: "session.next.tool.input.started"
   type: "session.next.tool.input.started"
   durable?: {
   durable?: {
     aggregateID: string
     aggregateID: string
-    seq: number | "NaN" | "Infinity" | "-Infinity"
-    version: number | "NaN" | "Infinity" | "-Infinity"
+    seq: number
+    version: number
   }
   }
   location?: LocationRef
   location?: LocationRef
   data: {
   data: {
@@ -4376,8 +4413,8 @@ export type SessionNextToolInputEnded = {
   type: "session.next.tool.input.ended"
   type: "session.next.tool.input.ended"
   durable?: {
   durable?: {
     aggregateID: string
     aggregateID: string
-    seq: number | "NaN" | "Infinity" | "-Infinity"
-    version: number | "NaN" | "Infinity" | "-Infinity"
+    seq: number
+    version: number
   }
   }
   location?: LocationRef
   location?: LocationRef
   data: {
   data: {
@@ -4397,8 +4434,8 @@ export type SessionNextToolCalled = {
   type: "session.next.tool.called"
   type: "session.next.tool.called"
   durable?: {
   durable?: {
     aggregateID: string
     aggregateID: string
-    seq: number | "NaN" | "Infinity" | "-Infinity"
-    version: number | "NaN" | "Infinity" | "-Infinity"
+    seq: number
+    version: number
   }
   }
   location?: LocationRef
   location?: LocationRef
   data: {
   data: {
@@ -4425,8 +4462,8 @@ export type SessionNextToolProgress = {
   type: "session.next.tool.progress"
   type: "session.next.tool.progress"
   durable?: {
   durable?: {
     aggregateID: string
     aggregateID: string
-    seq: number | "NaN" | "Infinity" | "-Infinity"
-    version: number | "NaN" | "Infinity" | "-Infinity"
+    seq: number
+    version: number
   }
   }
   location?: LocationRef
   location?: LocationRef
   data: {
   data: {
@@ -4449,8 +4486,8 @@ export type SessionNextToolSuccess = {
   type: "session.next.tool.success"
   type: "session.next.tool.success"
   durable?: {
   durable?: {
     aggregateID: string
     aggregateID: string
-    seq: number | "NaN" | "Infinity" | "-Infinity"
-    version: number | "NaN" | "Infinity" | "-Infinity"
+    seq: number
+    version: number
   }
   }
   location?: LocationRef
   location?: LocationRef
   data: {
   data: {
@@ -4479,8 +4516,8 @@ export type SessionNextToolFailed = {
   type: "session.next.tool.failed"
   type: "session.next.tool.failed"
   durable?: {
   durable?: {
     aggregateID: string
     aggregateID: string
-    seq: number | "NaN" | "Infinity" | "-Infinity"
-    version: number | "NaN" | "Infinity" | "-Infinity"
+    seq: number
+    version: number
   }
   }
   location?: LocationRef
   location?: LocationRef
   data: {
   data: {
@@ -4505,8 +4542,8 @@ export type SessionNextReasoningStarted = {
   type: "session.next.reasoning.started"
   type: "session.next.reasoning.started"
   durable?: {
   durable?: {
     aggregateID: string
     aggregateID: string
-    seq: number | "NaN" | "Infinity" | "-Infinity"
-    version: number | "NaN" | "Infinity" | "-Infinity"
+    seq: number
+    version: number
   }
   }
   location?: LocationRef
   location?: LocationRef
   data: {
   data: {
@@ -4526,8 +4563,8 @@ export type SessionNextReasoningEnded = {
   type: "session.next.reasoning.ended"
   type: "session.next.reasoning.ended"
   durable?: {
   durable?: {
     aggregateID: string
     aggregateID: string
-    seq: number | "NaN" | "Infinity" | "-Infinity"
-    version: number | "NaN" | "Infinity" | "-Infinity"
+    seq: number
+    version: number
   }
   }
   location?: LocationRef
   location?: LocationRef
   data: {
   data: {
@@ -4548,8 +4585,8 @@ export type SessionNextRetried = {
   type: "session.next.retried"
   type: "session.next.retried"
   durable?: {
   durable?: {
     aggregateID: string
     aggregateID: string
-    seq: number | "NaN" | "Infinity" | "-Infinity"
-    version: number | "NaN" | "Infinity" | "-Infinity"
+    seq: number
+    version: number
   }
   }
   location?: LocationRef
   location?: LocationRef
   data: {
   data: {
@@ -4568,8 +4605,8 @@ export type SessionNextCompactionStarted = {
   type: "session.next.compaction.started"
   type: "session.next.compaction.started"
   durable?: {
   durable?: {
     aggregateID: string
     aggregateID: string
-    seq: number | "NaN" | "Infinity" | "-Infinity"
-    version: number | "NaN" | "Infinity" | "-Infinity"
+    seq: number
+    version: number
   }
   }
   location?: LocationRef
   location?: LocationRef
   data: {
   data: {
@@ -4588,8 +4625,8 @@ export type SessionNextCompactionEnded = {
   type: "session.next.compaction.ended"
   type: "session.next.compaction.ended"
   durable?: {
   durable?: {
     aggregateID: string
     aggregateID: string
-    seq: number | "NaN" | "Infinity" | "-Infinity"
-    version: number | "NaN" | "Infinity" | "-Infinity"
+    seq: number
+    version: number
   }
   }
   location?: LocationRef
   location?: LocationRef
   data: {
   data: {
@@ -4610,8 +4647,8 @@ export type SessionNextRevertStaged = {
   type: "session.next.revert.staged"
   type: "session.next.revert.staged"
   durable?: {
   durable?: {
     aggregateID: string
     aggregateID: string
-    seq: number | "NaN" | "Infinity" | "-Infinity"
-    version: number | "NaN" | "Infinity" | "-Infinity"
+    seq: number
+    version: number
   }
   }
   location?: LocationRef
   location?: LocationRef
   data: {
   data: {
@@ -4629,8 +4666,8 @@ export type SessionNextRevertCleared = {
   type: "session.next.revert.cleared"
   type: "session.next.revert.cleared"
   durable?: {
   durable?: {
     aggregateID: string
     aggregateID: string
-    seq: number | "NaN" | "Infinity" | "-Infinity"
-    version: number | "NaN" | "Infinity" | "-Infinity"
+    seq: number
+    version: number
   }
   }
   location?: LocationRef
   location?: LocationRef
   data: {
   data: {
@@ -4647,8 +4684,8 @@ export type SessionNextRevertCommitted = {
   type: "session.next.revert.committed"
   type: "session.next.revert.committed"
   durable?: {
   durable?: {
     aggregateID: string
     aggregateID: string
-    seq: number | "NaN" | "Infinity" | "-Infinity"
-    version: number | "NaN" | "Infinity" | "-Infinity"
+    seq: number
+    version: number
   }
   }
   location?: LocationRef
   location?: LocationRef
   data: {
   data: {
@@ -12384,6 +12421,44 @@ export type V2SessionContextResponses = {
 
 
 export type V2SessionContextResponse = V2SessionContextResponses[keyof 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 = {
 export type V2SessionEventsData = {
   body?: never
   body?: never
   path: {
   path: {
@@ -12419,7 +12494,7 @@ export type V2SessionEventsResponses = {
   200: {
   200: {
     id: string
     id: string
     event: string
     event: string
-    data: string
+    data: SessionDurableEvent1
   }
   }
 }
 }
 
 

+ 12 - 0
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)
+})

+ 26 - 0
packages/server/src/handlers/session.ts

@@ -14,6 +14,7 @@ import {
 import { AbsolutePath } from "@opencode-ai/core/schema"
 import { AbsolutePath } from "@opencode-ai/core/schema"
 
 
 const DefaultSessionsLimit = 50
 const DefaultSessionsLimit = 50
+const DefaultSessionHistoryLimit = 50
 
 
 export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handlers) =>
 export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handlers) =>
   Effect.gen(function* () {
   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(
       .handle(
         "session.events",
         "session.events",
         Effect.fn((ctx) =>
         Effect.fn((ctx) =>

+ 7 - 0
specs/v2/schema-changelog.md

@@ -1,5 +1,12 @@
 # V2 Schema Changelog
 # 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
 ## 2026-06-22: Simplify Session Input Promotion
 
 
 - Keep `session.next.prompt.admitted.1` as the durable, client-visible record of pending Session input.
 - Keep `session.next.prompt.admitted.1` as the durable, client-visible record of pending Session input.

+ 5 - 1
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.
 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.
 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.
 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.
 - 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.
 - Buffer or coalesce streamed deltas before rewriting growing assistant projections.
 - Revisit additional covering indexes as larger-history query shapes become concrete.
 - 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.
 - 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.
 - Add provider-aware context control for provider-executed tool results. Generic text truncation cannot replace provider-native structured payloads that must round-trip exactly.