Compare commits

..

14 Commits

Author SHA1 Message Date
𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 d16a5226e2 fix(app): resize home session tab indicator 2026-06-26 19:19:44 +00:00
opencode-agent[bot] 2c02f8bace chore: update nix node_modules hashes 2026-06-26 18:53:34 +00:00
Affan Ali 2ec20e576b fix(app): slow tooltip display for models (#30745)
Co-authored-by: affanali2k3 <affanalikhanxx@gmail.com>
2026-06-27 02:53:08 +08:00
opencode-agent[bot] 20f47fec7a chore: generate 2026-06-26 18:51:42 +00:00
Kit Langton 65210f2d97 feat(api): add finite durable session history pages (#34097) 2026-06-26 14:49:47 -04:00
Brendan Allan af0b7ffae7 fix(app): animate prompt selectors after loading (#34101) 2026-06-27 02:36:01 +08:00
opencode-agent[bot] 0e2dd4ad15 chore: generate 2026-06-26 17:56:55 +00:00
Dax Raad 11d2f3e5f8 fix(llm): end reasoning before responses 2026-06-26 13:55:04 -04:00
Dax Raad 1ac6b4bec4 fix(core): authorize external read paths 2026-06-26 13:55:04 -04:00
opencode-agent[bot] 0befd9b049 chore: generate 2026-06-26 17:53:26 +00:00
Dax Raad 850a0dfe7c fix(core): resolve prompt attachment mime types 2026-06-26 13:51:00 -04:00
opencode-agent[bot] 18a419e634 chore: generate 2026-06-26 17:36:45 +00:00
Adam 82f47cb312 feat(stats): add clickable section headings (#34095) 2026-06-26 12:34:44 -05:00
Adam 7b1fe33ed3 fix(data): clarify unchanged rank 2026-06-26 12:29:29 -05:00
69 changed files with 1961 additions and 1776 deletions
+6
View File
@@ -83,6 +83,7 @@
"@types/luxon": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"tw-animate-css": "1.4.0",
"typescript": "catalog:",
"vite": "catalog:",
"vite-plugin-icons-spritesheet": "3.0.1",
@@ -331,6 +332,7 @@
"minimatch": "10.2.5",
"npm-package-arg": "13.0.2",
"semver": "^7.6.3",
"turndown": "7.2.0",
"venice-ai-sdk-provider": "2.0.2",
"which": "6.0.1",
"xdg-basedir": "5.1.0",
@@ -353,6 +355,7 @@
"@types/npm-package-arg": "6.1.4",
"@types/npmcli__arborist": "6.3.3",
"@types/semver": "catalog:",
"@types/turndown": "5.0.5",
"@types/which": "3.0.4",
"drizzle-kit": "catalog:",
},
@@ -988,6 +991,7 @@
"@typescript/native-preview": "catalog:",
"solid-js": "catalog:",
"tailwindcss": "catalog:",
"tw-animate-css": "1.4.0",
"typescript": "catalog:",
"vite": "catalog:",
"vite-plugin-icons-spritesheet": "3.0.1",
@@ -5313,6 +5317,8 @@
"turndown": ["turndown@7.2.0", "", { "dependencies": { "@mixmark-io/domino": "^2.2.0" } }, "sha512-eCZGBN4nNNqM9Owkv9HAtWRYfLA4h909E/WGAWWBpmB275ehNhZyk87/Tpvjbp0jjNl9XwCsbe6bm6CqFsgD+A=="],
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
"tw-to-css": ["tw-to-css@0.0.12", "", { "dependencies": { "postcss": "8.4.31", "postcss-css-variables": "0.18.0", "tailwindcss": "3.3.2" } }, "sha512-rQAsQvOtV1lBkyCw+iypMygNHrShYAItES5r8fMsrhhaj5qrV2LkZyXc8ccEH+u5bFjHjQ9iuxe90I7Kykf6pw=="],
"type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-OiWvZ57vuyHwiIKNtW1n1KX+MLmOXVG3x4fLKvUoGQw=",
"aarch64-linux": "sha256-RnPLxVEg/UsL5IeIFWmXMSLUOG6rVrajYxhyDYj1vTA=",
"aarch64-darwin": "sha256-KPIgcBA0pTFBPrCTSZgIbvEorbtWcMgXvyX9bFAypVs=",
"x86_64-darwin": "sha256-6jVU7/uVId0VD24MVQ8s8Ill5b6PsKdlBgHg+oceKRg="
"x86_64-linux": "sha256-rB/CcrBUQVZ08nBFSYA8u2w88rQmTpKxKPkIreDEKgI=",
"aarch64-linux": "sha256-ZRTphtic8Ip96MnILteFgZAUxjK9O4YfJu2O6u/0H8k=",
"aarch64-darwin": "sha256-VK5XIzraP0HtqnPwPCejiDKer4ewtNtX1vxP5uuyjSk=",
"x86_64-darwin": "sha256-ZLPHqcCZB1EmxQk95cmUpiODTTKOyi7PSF0yr/rDk6Y="
}
}
+1
View File
@@ -38,6 +38,7 @@
"@types/luxon": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"tw-animate-css": "1.4.0",
"typescript": "catalog:",
"vite": "catalog:",
"vite-plugin-icons-spritesheet": "3.0.1",
@@ -59,6 +59,7 @@ const ModelList: Component<{
class="w-full"
placement="right-start"
gutter={12}
openDelay={0}
value={<ModelTooltip model={item} latest={item.latest} free={isFree(item.provider.id, item.cost)} />}
>
{node}
+11 -6
View File
@@ -1343,9 +1343,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
const agentsLoading = () => props.controls.agents.loading
const agentsShouldFadeIn = createMemo((prev) => prev ?? agentsLoading())
const agentsShouldFadeIn = createMemo<boolean>((prev) => prev ?? agentsLoading())
const providersLoading = () => props.controls.model.loading
const providersShouldFadeIn = createMemo((prev) => prev ?? providersLoading())
const providersShouldFadeIn = createMemo<boolean>((prev) => prev ?? providersLoading())
const [promptReady] = createResource(
() => prompt.ready.promise,
@@ -1359,6 +1359,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const modelControlState = createMemo<ComposerModelControlState>(() => ({
loading: providersLoading(),
shouldAnimate: providersShouldFadeIn(),
paid: props.controls.model.paid,
title: language.t("command.model.choose"),
keybind: command.keybind("model.choose"),
@@ -1519,10 +1520,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</Show>
{props.toolbar}
<ComposerModelControl state={modelControlState()} />
<Show when={store.mode !== "shell" && showVariantControl()}>
<Show when={!providersLoading() && store.mode !== "shell" && showVariantControl()}>
<div
data-component="prompt-variant-control"
classList={{
"animate-in fade-in": providersShouldFadeIn(),
"hidden group-hover/prompt-input:block group-focus-within/prompt-input:block":
!props.controls.model.selection.variant.current() && !store.variantOpen,
}}
@@ -1765,7 +1767,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<Show when={!agentsLoading()}>
<div
data-component="prompt-agent-control"
style={agentsShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
classList={{ "animate-in fade-in duration-300": agentsShouldFadeIn() }}
>
<TooltipKeybind
placement="top"
@@ -1794,7 +1796,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<Show when={store.mode !== "shell"}>
<div
data-component="prompt-model-control"
style={providersShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
classList={{ "animate-in fade-in duration-300": providersShouldFadeIn() }}
>
<Show
when={props.controls.model.paid}
@@ -1873,7 +1875,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<Show when={showVariantControl()}>
<div
data-component="prompt-variant-control"
style={providersShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
classList={{ "animate-in fade-in duration-300": providersShouldFadeIn() }}
>
<TooltipKeybind
placement="top"
@@ -1923,6 +1925,7 @@ type ComposerAgentControlState = {
type ComposerModelControlState = {
loading: boolean
shouldAnimate: boolean
paid: boolean
title: string
keybind: string
@@ -1970,6 +1973,7 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) {
variant="ghost"
size="normal"
class="min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group"
classList={{ "animate-in fade-in": props.state.shouldAnimate }}
style={props.state.style}
onClick={props.state.onUnpaidClick}
>
@@ -2000,6 +2004,7 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) {
style: props.state.style,
class:
"min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group",
classList: { "animate-in fade-in": props.state.shouldAnimate },
"data-action": "prompt-model",
}}
onClose={props.state.onClose}
+1 -9
View File
@@ -1,6 +1,7 @@
@import "@opencode-ai/ui/styles/tailwind";
@import "@opencode-ai/session-ui/styles";
@import "@opencode-ai/ui/v2/styles/tailwind.css";
@import "tw-animate-css";
@font-face {
font-family: "JetBrainsMono Nerd Font Mono";
@@ -131,13 +132,4 @@
transform: rotate(360deg);
}
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
}
+2 -2
View File
@@ -857,8 +857,8 @@ function HomeSessionLeading(props: {
<Show when={hasOpenTab()}>
<span
aria-hidden="true"
class="pointer-events-none absolute top-1/2 h-[7px] w-[3px] -translate-y-1/2 rounded-[2px] bg-v2-background-bg-layer-04"
style={{ right: "calc(100% + 5px)" }}
class="pointer-events-none absolute top-1/2 h-3 w-0.5 -translate-y-1/2 rounded-[2px] bg-v2-background-bg-layer-04"
style={{ right: "calc(100% + 4px)" }}
/>
</Show>
<SessionTabAvatar
+25 -12
View File
@@ -149,12 +149,24 @@ const Endpoint0_12 = (raw: RawClient["server.session"]) => (input: Endpoint0_12I
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 = {
readonly sessionID: Endpoint0_13Request["params"]["sessionID"]
readonly limit?: Endpoint0_13Request["query"]["limit"]
readonly after?: Endpoint0_13Request["query"]["after"]
}
const Endpoint0_13 = (raw: RawClient["server.session"]) => (input: Endpoint0_13Input) =>
raw["session.history"]({
params: { sessionID: input.sessionID },
query: { limit: input.limit, after: input.after },
}).pipe(Effect.mapError(mapClientError))
type Endpoint0_14Request = Parameters<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(
raw["session.events"]({ params: { sessionID: input.sessionID }, query: { after: input.after } }).pipe(
Effect.mapError(mapClientError),
@@ -162,17 +174,17 @@ const Endpoint0_13 = (raw: RawClient["server.session"]) => (input: Endpoint0_13I
),
)
type Endpoint0_14Request = Parameters<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))
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(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
@@ -192,9 +204,10 @@ const adaptGroup0 = (raw: RawClient["server.session"]) => ({
clear: Endpoint0_10(raw),
commit: Endpoint0_11(raw),
context: Endpoint0_12(raw),
events: Endpoint0_13(raw),
interrupt: Endpoint0_14(raw),
message: Endpoint0_15(raw),
history: Endpoint0_13(raw),
events: Endpoint0_14(raw),
interrupt: Endpoint0_15(raw),
message: Endpoint0_16(raw),
})
const adaptClient = (raw: RawClient) => ({ sessions: adaptGroup0(raw["server.session"]) })
+14
View File
@@ -24,6 +24,8 @@ import type {
SessionsCommitOutput,
SessionsContextInput,
SessionsContextOutput,
SessionsHistoryInput,
SessionsHistoryOutput,
SessionsEventsInput,
SessionsEventsOutput,
SessionsInterruptInput,
@@ -324,6 +326,18 @@ export function make(options: ClientOptions) {
},
requestOptions,
).then((value) => value.data),
history: (input: SessionsHistoryInput, requestOptions?: RequestOptions) =>
request<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> =>
sse<SessionsEventsOutput>(
{
+469 -13
View File
@@ -67,7 +67,7 @@ export const isUnknownError = (value: unknown): value is UnknownError =>
export type SessionsListInput = {
readonly workspace?: {
readonly workspace?: string | undefined
readonly limit?: string | undefined
readonly limit?: number | undefined
readonly order?: "asc" | "desc" | undefined
readonly search?: string | undefined
readonly directory?: string | undefined
@@ -77,7 +77,7 @@ export type SessionsListInput = {
}["workspace"]
readonly limit?: {
readonly workspace?: string | undefined
readonly limit?: string | undefined
readonly limit?: number | undefined
readonly order?: "asc" | "desc" | undefined
readonly search?: string | undefined
readonly directory?: string | undefined
@@ -87,7 +87,7 @@ export type SessionsListInput = {
}["limit"]
readonly order?: {
readonly workspace?: string | undefined
readonly limit?: string | undefined
readonly limit?: number | undefined
readonly order?: "asc" | "desc" | undefined
readonly search?: string | undefined
readonly directory?: string | undefined
@@ -97,7 +97,7 @@ export type SessionsListInput = {
}["order"]
readonly search?: {
readonly workspace?: string | undefined
readonly limit?: string | undefined
readonly limit?: number | undefined
readonly order?: "asc" | "desc" | undefined
readonly search?: string | undefined
readonly directory?: string | undefined
@@ -107,7 +107,7 @@ export type SessionsListInput = {
}["search"]
readonly directory?: {
readonly workspace?: string | undefined
readonly limit?: string | undefined
readonly limit?: number | undefined
readonly order?: "asc" | "desc" | undefined
readonly search?: string | undefined
readonly directory?: string | undefined
@@ -117,7 +117,7 @@ export type SessionsListInput = {
}["directory"]
readonly project?: {
readonly workspace?: string | undefined
readonly limit?: string | undefined
readonly limit?: number | undefined
readonly order?: "asc" | "desc" | undefined
readonly search?: string | undefined
readonly directory?: string | undefined
@@ -127,7 +127,7 @@ export type SessionsListInput = {
}["project"]
readonly subpath?: {
readonly workspace?: string | undefined
readonly limit?: string | undefined
readonly limit?: number | undefined
readonly order?: "asc" | "desc" | undefined
readonly search?: string | undefined
readonly directory?: string | undefined
@@ -137,7 +137,7 @@ export type SessionsListInput = {
}["subpath"]
readonly cursor?: {
readonly workspace?: string | undefined
readonly limit?: string | undefined
readonly limit?: number | undefined
readonly order?: "asc" | "desc" | undefined
readonly search?: string | undefined
readonly directory?: string | undefined
@@ -305,7 +305,6 @@ export type SessionsPromptInput = {
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 }
@@ -324,7 +323,6 @@ export type SessionsPromptInput = {
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 }
@@ -343,7 +341,6 @@ export type SessionsPromptInput = {
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 }
@@ -362,7 +359,6 @@ export type SessionsPromptInput = {
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 }
@@ -595,9 +591,469 @@ export type SessionsContextOutput = {
>
}["data"]
export type SessionsHistoryInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly limit?: { readonly limit?: number | undefined; readonly after?: number | undefined }["limit"]
readonly after?: { readonly limit?: number | undefined; readonly after?: number | undefined }["after"]
}
export type SessionsHistoryOutput = {
readonly data: ReadonlyArray<
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly type: "session.next.agent.switched"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly timestamp: number
readonly sessionID: string
readonly messageID: string
readonly agent: string
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly type: "session.next.model.switched"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly timestamp: number
readonly sessionID: string
readonly messageID: string
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly type: "session.next.moved"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly timestamp: number
readonly sessionID: string
readonly location: { readonly directory: string; readonly workspaceID?: string }
readonly subdirectory?: string
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly type: "session.next.prompted"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly timestamp: number
readonly sessionID: string
readonly messageID: string
readonly prompt: {
readonly text: string
readonly files?: ReadonlyArray<{
readonly uri: string
readonly mime: string
readonly name?: string
readonly description?: string
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly agents?: ReadonlyArray<{
readonly name: string
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
}>
}
readonly delivery: "steer" | "queue"
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly type: "session.next.prompt.admitted"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly timestamp: number
readonly sessionID: string
readonly messageID: string
readonly prompt: {
readonly text: string
readonly files?: ReadonlyArray<{
readonly uri: string
readonly mime: string
readonly name?: string
readonly description?: string
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly agents?: ReadonlyArray<{
readonly name: string
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
}>
}
readonly delivery: "steer" | "queue"
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly type: "session.next.context.updated"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly timestamp: number
readonly sessionID: string
readonly messageID: string
readonly text: string
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly type: "session.next.synthetic"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly timestamp: number
readonly sessionID: string
readonly messageID: string
readonly text: string
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly type: "session.next.shell.started"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly timestamp: number
readonly sessionID: string
readonly messageID: string
readonly callID: string
readonly command: string
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly type: "session.next.shell.ended"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly timestamp: number
readonly sessionID: string
readonly callID: string
readonly output: string
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly type: "session.next.step.started"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly timestamp: number
readonly sessionID: string
readonly assistantMessageID: string
readonly agent: string
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly snapshot?: string
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly type: "session.next.step.ended"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly timestamp: number
readonly sessionID: string
readonly assistantMessageID: string
readonly finish: string
readonly cost: number
readonly tokens: {
readonly input: number
readonly output: number
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number }
}
readonly snapshot?: string
readonly files?: ReadonlyArray<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 = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly after?: { readonly after?: string | undefined }["after"]
readonly after?: { readonly after?: number | undefined }["after"]
}
export type SessionsEventsOutput =
+56 -1
View File
@@ -16,6 +16,8 @@ test("sessions.get returns the decoded Effect projection", 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 url = request.url
if (url.includes("/event")) {
@@ -28,6 +30,18 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
),
)
}
if (url.includes("/history")) {
historyPage++
historyQueries.push(Object.fromEntries(request.urlParams.params))
return Effect.succeed(
HttpClientResponse.fromWeb(
request,
Response.json(
historyPage === 1 ? { data: [modelSwitchedEvent], hasMore: true } : { data: [], hasMore: false },
),
),
)
}
if (url.includes("/prompt")) {
return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(admission)))
}
@@ -72,6 +86,18 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
yield* client.sessions.compact({ sessionID: Session.ID.make("ses_test") })
yield* client.sessions.wait({ sessionID: Session.ID.make("ses_test") })
const context = yield* client.sessions.context({ sessionID: Session.ID.make("ses_test") })
const history = yield* client.sessions.history({
sessionID: Session.ID.make("ses_test"),
after: 0,
limit: 1,
})
const historyNext = history.hasMore
? yield* client.sessions.history({
sessionID: Session.ID.make("ses_test"),
after: history.data.at(-1)?.durable?.seq,
limit: 2,
})
: undefined
const events = yield* client.sessions
.events({ sessionID: Session.ID.make("ses_test"), after: 0 })
.pipe(Stream.runCollect)
@@ -80,7 +106,7 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
sessionID: Session.ID.make("ses_test"),
messageID: SessionMessage.ID.make("msg_model"),
})
return { page, active, created, admitted, context, events, message }
return { page, active, created, admitted, context, history, historyNext, events, message }
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(DateTime.toEpochMillis(result.page.data[0].time.created)).toBe(1_717_171_717_000)
@@ -92,10 +118,39 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
expect(Object.getPrototypeOf(result.admitted.prompt)).toBe(Object.prototype)
expect(DateTime.toEpochMillis(result.admitted.timeCreated)).toBe(1_717_171_717_000)
expect(result.context).toEqual([])
expect(DateTime.toEpochMillis(result.history.data[0].data.timestamp)).toBe(1_717_171_717_000)
expect(result.history).toEqual(expect.objectContaining({ hasMore: true }))
expect(result.historyNext).toEqual({ data: [], hasMore: false })
expect(historyQueries[0]).toEqual({ limit: "1", after: "0" })
expect(historyQueries[1]).toEqual({ limit: "2", after: "1" })
expect(DateTime.toEpochMillis(result.events[0].data.timestamp)).toBe(1_717_171_717_000)
expect(result.message).toEqual(expect.objectContaining({ id: "msg_model", type: "model-switched" }))
})
test("sessions.history retains the typed SessionNotFoundError", async () => {
const httpClient = HttpClient.make((request) =>
Effect.succeed(
HttpClientResponse.fromWeb(
request,
Response.json(
{ _tag: "SessionNotFoundError", sessionID: "ses_missing", message: "Session not found" },
{ status: 404 },
),
),
),
)
const error = await Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
return yield* client.sessions
.history({
sessionID: Session.ID.make("ses_missing"),
})
.pipe(Effect.flip)
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(error._tag).toBe("SessionNotFoundError")
})
const session = {
data: {
id: "ses_test",
+37 -3
View File
@@ -1,5 +1,5 @@
import { expect, test } from "bun:test"
import { isUnauthorizedError, OpenCode } from "../src"
import { isSessionNotFoundError, isUnauthorizedError, OpenCode } from "../src"
test("sessions.get returns the wire projection", async () => {
const client = OpenCode.make({
@@ -19,6 +19,7 @@ test("sessions.get returns the wire projection", async () => {
test("session methods use the public HTTP contract", async () => {
const requests: Array<{ url: string; init?: RequestInit }> = []
let historyPage = 0
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async (input, init) => {
@@ -29,6 +30,12 @@ test("session methods use the public HTTP contract", async () => {
headers: { "content-type": "text/event-stream" },
})
}
if (url.includes("/history")) {
historyPage++
return Response.json(
historyPage === 1 ? { data: [modelSwitchedEvent], hasMore: true } : { data: [], hasMore: false },
)
}
if (url.includes("/prompt")) return Response.json(admission)
if (url.includes("/context")) return Response.json({ data: [] })
if (url.includes("/message/")) return Response.json({ data: modelSwitchedMessage })
@@ -39,7 +46,7 @@ test("session methods use the public HTTP contract", async () => {
},
})
const page = await client.sessions.list({ limit: "10", order: "desc" })
const page = await client.sessions.list({ limit: 10, order: "desc" })
const active = await client.sessions.active()
const created = await client.sessions.create({ location: { directory: "/tmp/project" } })
await client.sessions.switchAgent({ sessionID: "ses_test", agent: "build" })
@@ -55,8 +62,13 @@ test("session methods use the public HTTP contract", async () => {
await client.sessions.compact({ sessionID: "ses_test" })
await client.sessions.wait({ sessionID: "ses_test" })
const context = await client.sessions.context({ sessionID: "ses_test" })
const history = await client.sessions.history({ sessionID: "ses_test", after: 0, limit: 1 })
const historyAfter = history.data.at(-1)?.durable?.seq
const historyNext = history.hasMore
? await client.sessions.history({ sessionID: "ses_test", after: historyAfter, limit: 2 })
: undefined
const events = []
for await (const event of client.sessions.events({ sessionID: "ses_test", after: "0" })) events.push(event)
for await (const event of client.sessions.events({ sessionID: "ses_test", after: 0 })) events.push(event)
await client.sessions.interrupt({ sessionID: "ses_test" })
const message = await client.sessions.message({ sessionID: "ses_test", messageID: "msg_model" })
@@ -65,6 +77,8 @@ test("session methods use the public HTTP contract", async () => {
expect(created.id).toBe("ses_test")
expect(admitted.id).toBe("msg_test")
expect(context).toEqual([])
expect(history).toEqual({ data: [modelSwitchedEvent], hasMore: true })
expect(historyNext).toEqual({ data: [], hasMore: false })
expect(events).toEqual([modelSwitchedEvent])
expect(message).toEqual(modelSwitchedMessage)
expect(requests.map((request) => [request.init?.method, request.url])).toEqual([
@@ -77,6 +91,8 @@ test("session methods use the public HTTP contract", async () => {
["POST", "http://localhost:3000/api/session/ses_test/compact"],
["POST", "http://localhost:3000/api/session/ses_test/wait"],
["GET", "http://localhost:3000/api/session/ses_test/context"],
["GET", "http://localhost:3000/api/session/ses_test/history?limit=1&after=0"],
["GET", "http://localhost:3000/api/session/ses_test/history?limit=2&after=1"],
["GET", "http://localhost:3000/api/session/ses_test/event?after=0"],
["POST", "http://localhost:3000/api/session/ses_test/interrupt"],
["GET", "http://localhost:3000/api/session/ses_test/message/msg_model"],
@@ -104,6 +120,24 @@ test("middleware errors remain declared client errors", async () => {
}
})
test("sessions.history decodes SessionNotFoundError", async () => {
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async () =>
Response.json(
{ _tag: "SessionNotFoundError", sessionID: "ses_missing", message: "Session not found" },
{ status: 404 },
),
})
try {
await client.sessions.history({ sessionID: "ses_missing" })
throw new Error("Expected request to fail")
} catch (error) {
expect(isSessionNotFoundError(error)).toBe(true)
}
})
const session = {
data: {
id: "ses_test",
+2
View File
@@ -45,6 +45,7 @@
"@types/npm-package-arg": "6.1.4",
"@types/npmcli__arborist": "6.3.3",
"@types/semver": "catalog:",
"@types/turndown": "5.0.5",
"@types/which": "3.0.4",
"@parcel/watcher-darwin-arm64": "2.5.1",
"@parcel/watcher-darwin-x64": "2.5.1",
@@ -116,6 +117,7 @@
"minimatch": "10.2.5",
"npm-package-arg": "13.0.2",
"semver": "^7.6.3",
"turndown": "7.2.0",
"venice-ai-sdk-provider": "2.0.2",
"which": "6.0.1",
"xdg-basedir": "5.1.0",
-35
View File
@@ -1,35 +0,0 @@
# HTML to Markdown renderer
## Goal
Replace Turndown and Domino in V2 Core only when an htmlparser2 event renderer preserves model-readable semantics and improves resource use and shipped size.
## Commands
- `bun run test tool-webfetch.test.ts` from `packages/core`
- `bun build --entrypoints src/tool/html-markdown.ts --outdir <dir> --target node --format esm --minify`
- `bun run build --single --skip-install` from `packages/cli`
## Metrics
- Primary: median conversion throughput after one warmup and nine measured runs.
- Secondary: min/max spread, minified/gzip bundle size, CLI artifact size, and peak RSS where practical.
## Experiment Log
| Experiment | Hypothesis | Before | After | Decision |
| --- | --- | --- | --- | --- |
| Event renderer | Avoiding Domino's DOM lowers conversion cost while retaining semantics. | Turndown 4.23 MiB/s median (72.55 ms, 66.93-109.75) | Candidate 10.12 MiB/s median (30.32 ms, 22.29-83.55) | Keep: 2.39x throughput |
| Safe fences | Fence length derived from code content prevents embedded backticks from closing blocks. | Turndown emitted triple fences around embedded triples | Candidate expands to four backticks | Keep |
| Tables | Row/cell events retain tabular relationships better than flattened cell blocks. | Turndown flattened cells | Candidate emits GFM-readable tables | Keep |
| Malformed inline blocks | Delimiters spanning implied block closes produce malformed Markdown. | Candidate left open emphasis | Candidate drops the delimiter and preserves visible text | Keep |
## Evaluation
Temporary snapshots from Example Domain, MDN's table reference, Python asyncio documentation, RFC 9110, and W3C's forms tutorial were evaluated on 2026-08-12. Candidate output retained the same heading counts on four sites and one additional visible MDN heading, the same link counts on three sites, two additional Python links, and the same fenced-code counts where Turndown recognized fences. Candidate output was 0-3.6% smaller; tables and preformatted code were more explicit. Snapshots and generated output are not committed.
The minified isolated evaluation bundle containing Turndown, Domino, htmlparser2, and both renderers was 311,557 bytes (98,680 gzip). The candidate renderer with htmlparser2 was 61,293 bytes (26,922 gzip). Installed Turndown plus Domino occupied 9,028 KiB; htmlparser2 was already required by Core.
The same-commit macOS arm64 CLI executable was 87,338,978 bytes with Turndown and 87,091,298 bytes with the candidate, a 247,680-byte reduction.
Real-site HTML is temporary evaluation data and is not committed.
+61 -14
View File
@@ -3,7 +3,7 @@ export * as EventV2 from "./event"
import { Cause, Context, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
import { Event } from "@opencode-ai/schema/event"
import type { Data, Definition, Payload } from "@opencode-ai/schema/event"
import { and, asc, eq, gt } from "drizzle-orm"
import { and, asc, eq, gt, inArray } from "drizzle-orm"
import { Database } from "./database/database"
import { EventSequenceTable, EventTable } from "./event/sql"
import { Location } from "./location"
@@ -47,6 +47,66 @@ export class InvalidDurableEventError extends Schema.TaggedErrorClass<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 versionedType = Event.versionedType
@@ -459,19 +519,6 @@ export const layerWith = (options?: LayerOptions) =>
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) =>
(options?.beforeAggregateRead?.(aggregateID) ?? Effect.void).pipe(
Effect.andThen(
+34 -3
View File
@@ -10,6 +10,7 @@ import { ModelV2 } from "./model"
import { Location } from "./location"
import { SessionMessage } from "./session/message"
import { Prompt } from "./session/prompt"
import { PromptInput } from "@opencode-ai/schema/prompt-input"
import { EventV2 } from "./event"
import { Database } from "./database/database"
import { SessionProjector } from "./session/projector"
@@ -32,6 +33,8 @@ import { SessionInput } from "./session/input"
import { Snapshot } from "./snapshot"
import { SessionRevert } from "./session/revert"
import { Revert } from "@opencode-ai/schema/revert"
import { FSUtil } from "./fs-util"
import { SessionDurable } from "@opencode-ai/schema/durable-event-manifest"
export const RevertState = Revert.State
export type RevertState = Revert.State
@@ -129,6 +132,11 @@ export interface Interface {
sessionID: SessionSchema.ID
after?: number
}) => 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 switchModel: (input: {
sessionID: SessionSchema.ID
@@ -137,7 +145,7 @@ export interface Interface {
readonly prompt: (input: {
id?: SessionMessage.ID
sessionID: SessionSchema.ID
prompt: Prompt
prompt: PromptInput.Prompt
delivery?: SessionInput.Delivery
resume?: boolean
}) => Effect.Effect<SessionInput.Admitted, NotFoundError | PromptConflictError>
@@ -345,17 +353,26 @@ export const layer = Layer.unwrap(
.get(input.sessionID)
.pipe(Effect.as(events.durable({ aggregateID: input.sessionID, after: input.after }))),
).pipe(Stream.filter((event): event is SessionEvent.DurableEvent => isDurableSessionEvent(event))),
history: Effect.fn("V2Session.history")(function* (input) {
yield* result.get(input.sessionID)
return yield* EventV2.readAggregate(db, {
...input,
aggregateID: input.sessionID,
manifest: SessionDurable,
})
}),
prompt: Effect.fn("V2Session.prompt")((input) =>
Effect.uninterruptible(
Effect.gen(function* () {
yield* result.get(input.sessionID)
const prompt = resolvePrompt(input.prompt)
const messageID = input.id ?? SessionMessage.ID.create()
const delivery = input.delivery ?? "steer"
const expected = { sessionID: input.sessionID, messageID, prompt: input.prompt, delivery }
const expected = { sessionID: input.sessionID, messageID, prompt, delivery }
const admitted = yield* SessionInput.admit(db, events, {
id: messageID,
sessionID: input.sessionID,
prompt: input.prompt,
prompt,
delivery,
}).pipe(
Effect.catchDefect((defect) =>
@@ -452,3 +469,17 @@ export const defaultLayer = layer.pipe(
Layer.provide(ProjectV2.defaultLayer),
Layer.orDie,
)
const resolvePrompt = (input: PromptInput.Prompt) =>
Prompt.make({
text: input.text,
agents: input.agents,
files: input.files?.map((file) => {
const dataMime = file.uri.match(/^data:([^;,]+)[;,]/i)?.[1]
const target = URL.canParse(file.uri) ? new URL(file.uri).pathname : (file.name ?? file.uri)
return {
...file,
mime: dataMime ?? (target.endsWith("/") ? "application/x-directory" : FSUtil.mimeType(target)),
}
}),
})
-361
View File
@@ -1,361 +0,0 @@
import { Parser } from "htmlparser2"
const omitted = new Set(["script", "style", "noscript", "iframe", "object", "embed", "meta", "link", "template"])
const blocks = new Set([
"address",
"article",
"aside",
"details",
"dialog",
"div",
"dl",
"fieldset",
"figcaption",
"figure",
"footer",
"form",
"header",
"main",
"nav",
"p",
"section",
"summary",
])
type Frame = {
tag: string
suppressed: boolean
link?: { href: string; title?: string }
marker?: { index: number; block: number; value: string }
code?: { inline: boolean; text: string; language?: string }
list?: { ordered: boolean; next: number }
table?: { cells: number; header: boolean; rows: number }
cell?: { start: number }
}
export function convertHTMLToMarkdown(html: string) {
if (hasPathologicalDepth(html)) return extractPathologicalText(html)
const output: string[] = []
const stack: Frame[] = []
let pendingSpace = false
let last = ""
let quoteDepth = 0
let needsQuotePrefix = false
let blockCount = 0
let listDepth = 0
let activeCode: NonNullable<Frame["code"]> | undefined
let activeTable: NonNullable<Frame["table"]> | undefined
let tableDepth = 0
const raw: string[] = []
const append = (value: string) => {
if (!value) return
output.push(value)
last = value.at(-1) ?? last
}
const prefixQuote = () => {
if (!needsQuotePrefix || quoteDepth === 0) return
append(`${"> ".repeat(Math.min(8, quoteDepth))}`)
needsQuotePrefix = false
}
const flushSpace = () => {
if (!pendingSpace) return
const marker = stack.at(-1)?.marker
if (marker && output.length === marker.index + 1 && last !== " " && last !== "\n") {
output[marker.index] = ` ${output[marker.index]}`
pendingSpace = false
return
}
if (last && last !== "\n" && last !== " ") append(" ")
pendingSpace = false
}
const inline = (value: string, open = false) => {
if (open) flushSpace()
prefixQuote()
append(value)
}
const block = () => {
pendingSpace = false
append("\n\n")
blockCount++
needsQuotePrefix = quoteDepth > 0
}
const text = (value: string) => {
if (activeCode) {
activeCode.text += value
return
}
for (const part of value.split(/([\t\n\f\r ]+)/)) {
if (!part) continue
if (/^[\t\n\f\r ]+$/.test(part)) {
pendingSpace = true
continue
}
flushSpace()
prefixQuote()
append(
part
.replace(/([\\`*_[\]<>|])/g, "\\$1")
.replace(/~/g, "\\~")
.replace(/^([#+-])/, "\\$1")
.replace(/^(\d+)\./, "$1\\."),
)
}
}
const destination = (value: string) => value.replace(/([\\()])/g, "\\$1").replace(/[\t\n\r ]+/g, "%20")
const title = (value: string | undefined) => (value ? ` "${value.replace(/([\\"])/g, "\\$1")}"` : "")
const finishCode = (code: NonNullable<Frame["code"]>) => {
let longest = 0
let current = 0
for (const character of code.text) {
current = character === "`" ? current + 1 : 0
longest = Math.max(longest, current)
}
const fence = "`".repeat(Math.max(code.inline ? 1 : 3, longest + 1))
if (code.inline) {
const padding = /^ | $/.test(code.text) && !/^ +$/.test(code.text) ? " " : ""
flushSpace()
inline(`${fence}${padding}${code.text}${padding}${fence}`)
return
}
block()
const value = `${fence}${code.language ?? ""}\n${code.text}${code.text.endsWith("\n") ? "" : "\n"}${fence}`
const quoted = quoteDepth > 0 ? value.replace(/^/gm, `${"> ".repeat(Math.min(8, quoteDepth))}`) : value
const placeholder = `\u0000${raw.length}\u0000`
raw.push(quoted)
append(placeholder)
block()
}
const parser = new Parser({
onopentag(name, attributes) {
const suppressed = (stack.at(-1)?.suppressed ?? false) || omitted.has(name)
const frame: Frame = { tag: name, suppressed }
stack.push(frame)
if (suppressed) return
if (activeCode && !activeCode.inline) {
if (name === "code" && attributes.class) activeCode.language = attributes.class.match(/(?:language-|lang-)([^\s]+)/)?.[1]
return
}
if (name === "pre") {
frame.code = { inline: false, text: "" }
activeCode = frame.code
return
}
if (name === "code") {
frame.code = { inline: true, text: "" }
activeCode = frame.code
return
}
if (/^h[1-6]$/.test(name)) {
block()
inline(`${"#".repeat(Number(name[1]))} `)
return
}
if (blocks.has(name)) {
if (name === "p" && last === " ") return
block()
return
}
if (name === "br") {
pendingSpace = false
inline(" \n")
needsQuotePrefix = quoteDepth > 0
return
}
if (name === "hr") {
block()
inline("---")
block()
return
}
if (name === "strong" || name === "b") {
inline("**", true)
frame.marker = { index: output.length - 1, block: blockCount, value: "**" }
return
}
if (name === "em" || name === "i") {
inline("*", true)
frame.marker = { index: output.length - 1, block: blockCount, value: "*" }
return
}
if (name === "s" || name === "strike" || name === "del") {
inline("~~", true)
frame.marker = { index: output.length - 1, block: blockCount, value: "~~" }
return
}
if (name === "a") {
frame.link = { href: attributes.href ?? "", title: attributes.title }
return inline(`[`, true)
}
if (name === "img") {
inline(`![${(attributes.alt ?? "").replace(/([\\\]])/g, "\\$1")}](${destination(attributes.src ?? "")}${title(attributes.title)})`, true)
return
}
if (name === "blockquote") {
block()
quoteDepth++
needsQuotePrefix = true
return
}
if (name === "ul" || name === "ol") {
frame.list = { ordered: name === "ol", next: Number.parseInt(attributes.start ?? "1") || 1 }
listDepth++
block()
return
}
if (name === "li") {
block()
const list = stack.findLast((item) => item.list)?.list
const marker = list?.ordered ? `${list.next++}.` : "-"
inline(`${" ".repeat(Math.min(8, Math.max(0, listDepth - 1)))}${marker} `)
return
}
if (name === "table") {
tableDepth++
if (tableDepth === 1) {
frame.table = { cells: 0, header: false, rows: 0 }
activeTable = frame.table
block()
} else pendingSpace = true
return
}
if (name === "tr") {
if (tableDepth !== 1) {
pendingSpace = true
return
}
const table = activeTable
pendingSpace = false
if (table && table.rows > 0) {
append("\n")
needsQuotePrefix = quoteDepth > 0
}
inline("|")
return
}
if (name === "th" || name === "td") {
if (tableDepth !== 1) {
pendingSpace = true
return
}
const table = activeTable
if (table) {
table.cells++
table.header ||= name === "th"
}
inline(" ")
frame.cell = { start: output.length }
}
},
ontext(value) {
if (stack.at(-1)?.suppressed) return
text(value)
},
onclosetag(name) {
const frame = stack.pop()
if (!frame || frame.suppressed) return
if (frame.code) {
activeCode = undefined
return finishCode(frame.code)
}
if (name === "strong" || name === "b" || name === "em" || name === "i" || name === "s" || name === "strike" || name === "del") {
const value = name === "strong" || name === "b" ? "**" : name === "em" || name === "i" ? "*" : "~~"
if (frame.marker && frame.marker.block !== blockCount) {
output[frame.marker.index] = ""
return
}
if (frame.marker && output.length === frame.marker.index + 1) {
output[frame.marker.index] = ""
return
}
return inline(value)
}
if (name === "a") {
return inline(`](${destination(frame.link?.href ?? "")}${title(frame.link?.title)})`)
}
if (/^h[1-6]$/.test(name) || blocks.has(name)) return block()
if (name === "blockquote") {
quoteDepth--
return block()
}
if (name === "li") return block()
if (name === "ul" || name === "ol") {
listDepth--
return block()
}
if ((name === "th" || name === "td") && tableDepth === 1) {
if (frame.cell) {
const value = output
.splice(frame.cell.start)
.join("")
.replace(/[\t\r\n ]+/g, " ")
.trim()
.replace(/(?<!\\)\|/g, "\\|")
append(value)
}
return inline(" |")
}
if (name === "tr") {
if (tableDepth !== 1) return
const table = activeTable
if (table && table.rows === 0) {
inline("\n")
needsQuotePrefix = quoteDepth > 0
inline(`|${" --- |".repeat(table.cells)}`)
}
if (table) {
table.rows++
table.cells = 0
}
return
}
if (name === "table") {
tableDepth--
if (tableDepth === 0) {
activeTable = undefined
return block()
}
pendingSpace = true
}
},
})
parser.write(html)
parser.end()
return output
.join("")
.replace(/[ \t]+\n/g, (value) => (value.startsWith(" ") ? " \n" : "\n"))
.replace(/\n{3,}/g, "\n\n")
.trim()
.replace(/\u0000(\d+)\u0000/g, (_, index) => raw[Number(index)] ?? "")
}
function hasPathologicalDepth(html: string) {
let depth = 0
for (const match of html.matchAll(/<\s*(\/)?\s*([a-z][\w:-]*)\b[^>]*>/gi)) {
if (match[1]) depth = Math.max(0, depth - 1)
else if (!/\/$/.test(match[0].slice(0, -1).trim()) && !["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "track", "wbr"].includes(match[2].toLowerCase())) depth++
if (depth > 10_000) return true
}
return false
}
function extractPathologicalText(html: string) {
let output = ""
let suppressed = 0
const parser = new Parser({
onopentag(name) {
if (suppressed > 0 || omitted.has(name)) suppressed++
},
ontext(value) {
if (suppressed === 0) output += value
},
onclosetag() {
if (suppressed > 0) suppressed--
},
})
parser.write(html.replace(/<\/?(?:[^>]+)>/g, (tag) => (omitted.has(tag.match(/^<\/?\s*([^\s/>]+)/)?.[1]?.toLowerCase() ?? "") ? tag : " ")))
parser.end()
return output.replace(/[\t\n\f\r ]+/g, " ").trim()
}
+24 -20
View File
@@ -1,12 +1,10 @@
export * as ReadTool from "./read"
import { ToolFailure } from "@opencode-ai/llm"
import path from "path"
import { Effect, Layer, Schema } from "effect"
import { FileSystem } from "../filesystem"
import { FSUtil } from "../fs-util"
import { Image } from "../image"
import { Location } from "../location"
import { LocationMutation } from "../location-mutation"
import { PermissionV2 } from "../permission"
import { AbsolutePath } from "../schema"
import { ReadToolFileSystem } from "./read-filesystem"
@@ -30,9 +28,8 @@ const Output = Schema.Union([FileSystem.Content, ReadToolFileSystem.TextPage, Re
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
const tools = yield* Tools.Service
const fs = yield* FSUtil.Service
const reader = yield* ReadToolFileSystem.Service
const location = yield* Location.Service
const mutation = yield* LocationMutation.Service
const image = yield* Image.Service
const permission = yield* PermissionV2.Service
@@ -40,7 +37,7 @@ export const layer = Layer.effectDiscard(
.register({
[name]: Tool.make({
description:
"Read a text file or supported image, page through a large UTF-8 text file by line offset, or list a directory page. Relative paths resolve from the current location; absolute paths are read directly.",
"Read a text file or supported image, page through a large UTF-8 text file by line offset, or list a directory page. Relative paths resolve from the current location; absolute paths inside it are accepted, while external absolute paths require external_directory approval.",
input: Input,
output: Output,
toModelOutput: ({ input, output }) => {
@@ -53,27 +50,34 @@ export const layer = Layer.effectDiscard(
},
execute: (input, context) => {
return Effect.gen(function* () {
const absolute = path.resolve(location.directory, input.path)
const selected = path.isAbsolute(input.path) ? path.dirname(absolute) : location.directory
if (!path.isAbsolute(input.path) && !FSUtil.contains(location.directory, absolute))
return yield* Effect.die(new Error("Path escapes the allowed read root"))
const real = yield* fs.realPath(absolute)
const root = yield* fs.realPath(selected)
if (!FSUtil.contains(root, real))
return yield* Effect.die(new Error("Path escapes the allowed read root"))
const resource = path.relative(root, real).replaceAll("\\", "/") || "."
const target = AbsolutePath.make(real)
const type = yield* reader.inspect(target)
const source = {
type: "tool" as const,
messageID: context.assistantMessageID,
callID: context.toolCallID,
}
const target = yield* mutation.resolve({ path: input.path, kind: "directory" })
const external = target.externalDirectory
if (external)
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(external),
sessionID: context.sessionID,
agent: context.agent,
source,
})
const resource = target.resource
const absolute = AbsolutePath.make(target.canonical)
const type = yield* reader.inspect(absolute)
yield* permission.assert({
action: name,
resources: [resource],
save: ["*"],
sessionID: context.sessionID,
agent: context.agent,
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
source,
})
if (type === "directory") return yield* reader.list(target, { offset: input.offset, limit: input.limit })
const content = yield* reader.read(target, resource, {
if (type === "directory")
return yield* reader.list(absolute, { offset: input.offset, limit: input.limit })
const content = yield* reader.read(absolute, resource, {
offset: input.offset,
limit: input.limit,
})
+12 -2
View File
@@ -4,8 +4,8 @@ import { ToolFailure } from "@opencode-ai/llm"
import { Duration, Effect, Layer, Schema } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { Parser } from "htmlparser2"
import TurndownService from "turndown"
import { PermissionV2 } from "../permission"
import { convertHTMLToMarkdown } from "./html-markdown"
import { collectBoundedResponseBody } from "./http-body"
import { Tool } from "./tool"
import { Tools } from "./tools"
@@ -196,4 +196,14 @@ export function extractTextFromHTML(html: string) {
return text.trim()
}
export { convertHTMLToMarkdown }
export function convertHTMLToMarkdown(html: string) {
const turndown = new TurndownService({
headingStyle: "atx",
hr: "---",
bulletListMarker: "-",
codeBlockStyle: "fenced",
emDelimiter: "*",
})
turndown.remove(["script", "style", "meta", "link"])
return turndown.turndown(html)
}
+174
View File
@@ -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")
}),
)
})
+21
View File
@@ -173,6 +173,27 @@ describe("SessionV2.prompt", () => {
}),
)
it.effect("resolves attachment MIME before admission", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const message = yield* session.prompt({
sessionID,
prompt: {
text: "Inspect this image",
files: [{ uri: "data:image/png;base64,aGVsbG8=", name: "image.png" }],
},
resume: false,
})
expect(message.prompt.files).toEqual([
{ uri: "data:image/png;base64,aGVsbG8=", name: "image.png", mime: "image/png" },
])
expect((yield* admitted(message.id))?.prompt.files).toEqual(message.prompt.files)
}),
)
it.effect("streams durable Session events after an aggregate sequence", () =>
Effect.gen(function* () {
yield* setup
+55 -2
View File
@@ -11,6 +11,7 @@ import { PermissionV2 } from "@opencode-ai/core/permission"
import { SessionV2 } from "@opencode-ai/core/session"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Global } from "@opencode-ai/core/global"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { location } from "./fixture/location"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { ReadTool } from "@opencode-ai/core/tool/read"
@@ -97,6 +98,32 @@ const infrastructure = Layer.mergeAll(
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(process.cwd()) }))),
Global.layerWith({ data: Global.Path.data }),
)
const mutation = Layer.succeed(
LocationMutation.Service,
LocationMutation.Service.of({
resolve: (input) => {
if (input.path === missingPath)
return Effect.fail(new LocationMutation.PathError({ path: input.path, reason: "non_directory_ancestor" }))
const canonical = path.resolve(process.cwd(), input.path)
const external = path.isAbsolute(input.path) && !FSUtil.contains(process.cwd(), canonical)
const resource = external ? canonical.replaceAll("\\", "/") : path.relative(process.cwd(), canonical) || "."
const directory = path.dirname(canonical)
const externalResource = path.join(directory, "*").replaceAll("\\", "/")
return Effect.succeed({
canonical,
resource,
externalDirectory: external
? {
action: "external_directory" as const,
directory,
resource: externalResource,
save: externalResource,
}
: undefined,
})
},
}),
)
const unavailableImage = Layer.succeed(
Image.Service,
Image.Service.of({ normalize: () => Effect.fail(new Image.ResizerUnavailableError()) }),
@@ -107,19 +134,21 @@ const read = ReadTool.layer.pipe(
Layer.provide(permission),
Layer.provide(config),
Layer.provide(image),
Layer.provide(mutation),
Layer.provide(infrastructure),
)
const it = testEffect(Layer.mergeAll(registry, reader, permission, config, image, infrastructure, read))
const it = testEffect(Layer.mergeAll(registry, reader, permission, config, image, mutation, infrastructure, read))
const unavailableRead = ReadTool.layer.pipe(
Layer.provide(registry),
Layer.provide(reader),
Layer.provide(permission),
Layer.provide(config),
Layer.provide(unavailableImage),
Layer.provide(mutation),
Layer.provide(infrastructure),
)
const itWithoutResizer = testEffect(
Layer.mergeAll(registry, reader, permission, config, unavailableImage, infrastructure, unavailableRead),
Layer.mergeAll(registry, reader, permission, config, unavailableImage, mutation, infrastructure, unavailableRead),
)
const sessionID = SessionV2.ID.make("ses_read_tool_test")
@@ -174,6 +203,30 @@ describe("ReadTool", () => {
}),
)
it.effect("asks for external_directory approval before reading an external absolute path", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
const external = path.join(path.parse(process.cwd()).root, "external-read", "notes.txt")
expect(
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-external-read", name: "read", input: { path: external } },
}),
).toMatchObject({ type: "json" })
expect(assertions).toMatchObject([
{
sessionID,
action: "external_directory",
resources: [path.join(path.dirname(external), "*").replaceAll("\\", "/")],
},
{ sessionID, action: "read", resources: [external.replaceAll("\\", "/")], save: ["*"] },
])
expect(readCalls).toEqual([{ input: AbsolutePath.make(external), page: { offset: undefined, limit: undefined } }])
}),
)
it.effect("returns a small PNG as native media instead of durable base64 text", () =>
Effect.gen(function* () {
const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
+6 -106
View File
@@ -66,109 +66,9 @@ describe("WebFetchTool helpers", () => {
})
test("ports HTML text and markdown conversions without active content", () => {
const html =
"<h1>Hello</h1><script>bad()</script><p>world <strong>wide</strong> <product-name>today</product-name></p><style>.bad {}</style>"
expect(WebFetchTool.extractTextFromHTML(html)).toBe("Helloworld wide today")
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe("# Hello\n\nworld **wide** today")
})
test("renders headings, inline semantics, links, images, breaks, and thematic breaks", () => {
const html = `<h2>Read <em>this</em></h2><p><a href="https://example.com/a (b)" title="Example">docs</a><br><img src="diagram.png" alt="a ] b"></p><hr><p><del>old</del></p>`
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
`## Read *this*\n\n[docs](https://example.com/a%20\\(b\\) "Example") \n![a \\] b](diagram.png)\n\n---\n\n~~old~~`,
)
})
test("preserves inline and preformatted code verbatim with safe fences", () => {
const html = `<p>Use <code>say(\`hello\`)</code> now.</p><pre><code class="language-ts">const fence = \`\`\`\n&amp; stays decoded</code></pre>`
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
`Use \`\`say(\`hello\`)\`\` now.\n\n\`\`\`\`ts\nconst fence = \`\`\`\n& stays decoded\n\`\`\`\``,
)
})
test("keeps nested ordered and unordered lists structurally readable", () => {
const html = `<ol start="3"><li>alpha<ul><li>nested <strong>item</strong></li></ul></li><li><p>beta first</p><p>beta second</p></li></ol>`
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
`3. alpha\n\n - nested **item**\n\n4. beta first\n\nbeta second`,
)
})
test("renders blockquotes and tables as readable Markdown", () => {
const html = `<blockquote><p>quoted <em>text</em></p><ul><li>point</li></ul></blockquote><table><thead><tr><th>Name</th><th>Value</th></tr></thead><tbody><tr><td>one</td><td><code>1</code></td></tr></tbody></table>`
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
`> quoted *text*\n\n> - point\n\n| Name | Value |\n| --- | --- |\n| one | \`1\` |`,
)
})
test("decodes entities and normalizes prose whitespace without joining words", () => {
const html = `<p>alpha\n <span>&amp; beta</span> <unknown>caf&eacute;</unknown>&nbsp;gamma 😀</p><p>delta</p>`
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(`alpha & beta café gamma 😀\n\ndelta`)
})
test("omits active and fallback content while retaining surrounding prose", () => {
const html = `<p>before <script><b>bad</b></script><style>bad</style><noscript>bad</noscript><iframe>bad</iframe><object>bad</object><embed src="bad"><meta content="bad"><link href="bad"><template>bad</template> after</p>`
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe("before after")
})
test("is deterministic and bounded for malformed maximum-size input", () => {
const html = `<main><p>${"visible &amp; text ".repeat(250_000)}</main></p></unknown>`
const first = WebFetchTool.convertHTMLToMarkdown(html)
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(first)
expect(first.startsWith("visible & text visible & text")).toBe(true)
expect(first.length).toBeLessThanOrEqual(html.length)
})
test("bounds deeply nested list output and fragmented code fences", () => {
const lists = `${"<ul><li>item".repeat(2_000)}${"</li></ul>".repeat(2_000)}`
const quotes = `${"<blockquote><p>item".repeat(2_000)}${"</p></blockquote>".repeat(2_000)}`
const code = `<pre>${"` x ".repeat(250_000)}</pre>`
expect(WebFetchTool.convertHTMLToMarkdown(lists).length).toBeLessThan(lists.length * 4)
expect(WebFetchTool.convertHTMLToMarkdown(quotes).length).toBeLessThan(quotes.length * 4)
expect(() => WebFetchTool.convertHTMLToMarkdown(code)).not.toThrow()
expect(
WebFetchTool.convertHTMLToMarkdown(
"<div>".repeat(20_000) + "safe<script><b>bad</b>&amp;</script><p>tail &amp;</p>",
),
).toBe("safe tail &")
})
test("escapes prose that would otherwise become Markdown structure", () => {
expect(WebFetchTool.convertHTMLToMarkdown(`<p># heading</p><p>1. item</p><p>---</p><p>a | b</p>`)).toBe(
`\\# heading\n\n1\\. item\n\n\\---\n\na \\| b`,
)
})
test("preserves code whitespace and quotes every line of multiline blocks", () => {
const html = `<blockquote><pre>line \n\n\nnext</pre><table><tr><td>a|b</td><td>c</td></tr></table></blockquote>`
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
`> \`\`\`\n> line \n> \n> \n> next\n> \`\`\`\n\n> | a\\|b | c |\n> | --- | --- |`,
)
})
test("keeps visible whitespace around inline emphasis", () => {
expect(WebFetchTool.convertHTMLToMarkdown(`<p>a<strong> b</strong> c a <em>b </em>c</p>`)).toBe(
`a **b** c a *b* c`,
)
})
test("normalizes multiline table cells without changing their columns", () => {
const html = `<table><tr><td>x<br>y</td><td><code>a|b</code></td><td><p>first</p><p>second</p></td></tr></table>`
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
`| x y | \`a\\|b\` | first second |\n| --- | --- | --- |`,
)
})
test("flattens nested tables without corrupting the outer table", () => {
const html = `<table><tr><th>Parent</th><th>Sibling</th></tr><tr><td>Before<table><tr><th>Key</th><th>Value</th></tr><tr><td>A</td><td>1</td></tr></table>After</td><td>Tail</td></tr></table>`
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
`| Parent | Sibling |\n| --- | --- |\n| Before Key Value A 1 After | Tail |`,
)
})
test("escapes tilde fences and removes empty emphasis markers", () => {
expect(WebFetchTool.convertHTMLToMarkdown(`<p>~~~</p><p><strong></strong>content</p><p>~~~</p>`)).toBe(
`\\~\\~\\~\n\ncontent\n\n\\~\\~\\~`,
)
const html = "<h1>Hello</h1><script>bad()</script><p>world <strong>wide</strong></p><style>.bad {}</style>"
expect(WebFetchTool.extractTextFromHTML(html)).toBe("Helloworld wide")
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe("# Hello\n\nworld **wide**")
})
})
@@ -276,7 +176,7 @@ describe("WebFetchTool registration", () => {
}),
)
it.effect("converts deeply nested HTML without overflowing", () =>
it.effect("returns an error result when HTML-to-Markdown conversion throws", () =>
Effect.gen(function* () {
reset()
respond = () =>
@@ -289,8 +189,8 @@ describe("WebFetchTool registration", () => {
const url = "https://1.1.1.1/deep-html"
expect(yield* executeTool(registry, call({ url, format: "markdown" }))).toEqual({
type: "text",
value: "content",
type: "error",
value: `Unable to fetch ${url}`,
})
}),
)
+6 -6
View File
@@ -410,12 +410,12 @@ function renderImportedProjection(groups: ReadonlyArray<Group>, endpoints: Reado
function renderPromiseTypes(groups: ReadonlyArray<Group>) {
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
const type = structuralType(encoded)
types.set(encoded.ast, type)
const type = structuralType(projected)
types.set(projected.ast, type)
return type
}
const errors = new Map(
@@ -449,7 +449,7 @@ function renderPromiseTypes(groups: ReadonlyArray<Group>) {
const schema = schemas[field.source]
if (schema === undefined)
throw new GenerationError({ reason: `Missing input schema: ${prefix}.${field.name}` })
return `readonly ${JSON.stringify(field.name)}${field.optional ? "?" : ""}: (${typeOf(schema)})[${JSON.stringify(field.name)}]`
return `readonly ${JSON.stringify(field.name)}${field.optional ? "?" : ""}: (${typeOf(schema, field.source === "query")})[${JSON.stringify(field.name)}]`
})
.join("; ")
const successSchema = endpoint.successes[0]
+23 -9
View File
@@ -407,21 +407,35 @@ const step = (state: ParserState, event: GeminiEvent) => {
if ("thoughtSignature" in part && part.thoughtSignature && "thought" in part && part.thought)
reasoningSignature = part.thoughtSignature
if ("text" in part && part.text.length > 0) {
lifecycle = part.thought
? Lifecycle.reasoningDelta(
lifecycle,
events,
"reasoning-0",
part.text,
part.thoughtSignature ? googleMetadata({ thoughtSignature: part.thoughtSignature }) : undefined,
)
: Lifecycle.textDelta(lifecycle, events, "text-0", part.text)
if (part.thought) {
lifecycle = Lifecycle.reasoningDelta(
lifecycle,
events,
"reasoning-0",
part.text,
part.thoughtSignature ? googleMetadata({ thoughtSignature: part.thoughtSignature }) : undefined,
)
continue
}
lifecycle = Lifecycle.reasoningEnd(
lifecycle,
events,
"reasoning-0",
reasoningSignature ? googleMetadata({ thoughtSignature: reasoningSignature }) : undefined,
)
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", part.text)
continue
}
if ("functionCall" in part) {
const input = part.functionCall.args
const id = `tool_${nextToolCallId++}`
lifecycle = Lifecycle.reasoningEnd(
lifecycle,
events,
"reasoning-0",
reasoningSignature ? googleMetadata({ thoughtSignature: reasoningSignature }) : undefined,
)
lifecycle = Lifecycle.stepStart(lifecycle, events)
events.push(
LLMEvent.toolCall({
+6 -1
View File
@@ -411,7 +411,12 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
if (delta?.reasoning_content)
lifecycle = Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", delta.reasoning_content)
if (delta?.content) lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content)
if (delta?.content) {
lifecycle = Lifecycle.reasoningEnd(lifecycle, events, "reasoning-0")
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content)
}
if (toolDeltas.length) lifecycle = Lifecycle.reasoningEnd(lifecycle, events, "reasoning-0")
for (const tool of toolDeltas) {
const result = ToolStream.appendOrStart(
+4 -1
View File
@@ -347,10 +347,10 @@ describe("Gemini route", () => {
{ type: "step-start", index: 0 },
{ type: "reasoning-start", id: "reasoning-0" },
{ type: "reasoning-delta", id: "reasoning-0", text: "thinking" },
{ type: "reasoning-end", id: "reasoning-0" },
{ type: "text-start", id: "text-0" },
{ type: "text-delta", id: "text-0", text: "Hello" },
{ type: "text-delta", id: "text-0", text: "!" },
{ type: "reasoning-end", id: "reasoning-0" },
{ type: "text-end", id: "text-0" },
{ type: "step-finish", index: 0, reason: "stop", usage, providerMetadata: undefined },
{
@@ -399,6 +399,9 @@ describe("Gemini route", () => {
providerMetadata: { google: { thoughtSignature: "thought_sig" } },
})
expect(toolCall).toMatchObject({ providerMetadata: { google: { thoughtSignature: "tool_sig" } } })
expect(response.events.findIndex((event) => event.type === "reasoning-end")).toBeLessThan(
response.events.findIndex((event) => event.type === "tool-call"),
)
const prepared = yield* LLMClient.prepare<Gemini.GeminiBody>(
LLM.request({
@@ -542,9 +542,9 @@ describe("OpenAI Chat route", () => {
{ type: "step-start", index: 0 },
{ type: "reasoning-start", id: "reasoning-0" },
{ type: "reasoning-delta", id: "reasoning-0", text: "thinking" },
{ type: "reasoning-end", id: "reasoning-0" },
{ type: "text-start", id: "text-0" },
{ type: "text-delta", id: "text-0", text: "Hello" },
{ type: "reasoning-end", id: "reasoning-0" },
{ type: "text-end", id: "text-0" },
{ type: "step-finish", index: 0, reason: "stop" },
{ type: "finish", reason: "stop" },
@@ -1067,6 +1067,40 @@ const scenarios: Scenario[] = [
headers: ctx.headers(),
}))
.status(400, undefined, "none"),
http.protected
.get("/api/session/{sessionID}/history", "v2.session.history")
.seeded((ctx) => ctx.session({ title: "Session history" }))
.at((ctx) => ({
path: `${route("/api/session/{sessionID}/history", { sessionID: ctx.state.id })}?${new URLSearchParams({
after: "0",
limit: "2",
})}`,
headers: ctx.headers(),
}))
.json(
200,
(body) => {
object(body)
array(body.data)
check(typeof body.hasMore === "boolean", "Expected a history exhaustion signal")
},
"none",
),
http.protected
.get("/api/session/{sessionID}/history", "v2.session.history.missing")
.at((ctx) => ({
path: route("/api/session/{sessionID}/history", { sessionID: "ses_httpapi_missing" }),
headers: ctx.headers(),
}))
.json(404, object, "status"),
http.protected
.get("/api/session/{sessionID}/history", "v2.session.history.invalid")
.seeded((ctx) => ctx.session({ title: "Invalid history sequence" }))
.at((ctx) => ({
path: `${route("/api/session/{sessionID}/history", { sessionID: ctx.state.id })}?after=-1`,
headers: ctx.headers(),
}))
.json(400, object, "status"),
http.protected
.get("/api/session/{sessionID}/event", "v2.session.events.missing")
.at((ctx) => ({
+38 -4
View File
@@ -1,11 +1,11 @@
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { SessionInput } from "@opencode-ai/schema/session-input"
import { Prompt } from "@opencode-ai/schema/prompt"
import { PromptInput } from "@opencode-ai/schema/prompt-input"
import { Session } from "@opencode-ai/schema/session"
import { Project } from "@opencode-ai/schema/project"
import { AbsolutePath, NonNegativeInt, PositiveInt, RelativePath, statics } from "@opencode-ai/schema/schema"
import { Workspace } from "@opencode-ai/schema/workspace"
import { Context, Encoding, Result, Schema, Struct } from "effect"
import { Context, Effect, Encoding, Result, Schema, Struct } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import {
ConflictError,
@@ -60,6 +60,7 @@ const SessionsCursorInput = Schema.Union([
const SessionsCursorJson = Schema.fromJsonString(SessionsCursorInput)
const encodeSessionsCursor = Schema.encodeSync(SessionsCursorJson)
const decodeSessionsCursor = Schema.decodeUnknownEffect(SessionsCursorJson)
const invalidCursor = "Invalid cursor" as const
export const SessionsCursor = Schema.String.pipe(
Schema.brand("SessionsCursor"),
@@ -67,7 +68,13 @@ export const SessionsCursor = Schema.String.pipe(
const make = schema.make.bind(schema)
return {
make: (input: typeof SessionsCursorInput.Type) => make(Encoding.encodeBase64Url(encodeSessionsCursor(input))),
parse: (input: string) => decodeSessionsCursor(Result.getOrThrow(Encoding.decodeBase64UrlString(input))),
parse: (input: string) =>
Effect.suspend(() => {
const result = Encoding.decodeBase64UrlString(input)
return Result.isFailure(result)
? Effect.fail(invalidCursor)
: decodeSessionsCursor(result.success).pipe(Effect.mapError(() => invalidCursor))
}),
}
}),
)
@@ -77,6 +84,13 @@ const SessionActive = Schema.Struct({
type: Schema.Literal("running"),
}).annotate({ identifier: "SessionActive" })
const SessionHistoryLimit = PositiveInt.check(Schema.isLessThanOrEqualTo(100))
export const SessionHistoryQuery = Schema.Struct({
limit: Schema.NumberFromString.pipe(Schema.decodeTo(SessionHistoryLimit), Schema.optional),
after: Schema.NumberFromString.pipe(Schema.decodeTo(NonNegativeInt), Schema.optional),
})
const SessionsQueryCursor = SessionsCursor.annotate({
description: "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response.",
})
@@ -192,7 +206,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
params: { sessionID: Session.ID },
payload: Schema.Struct({
id: SessionMessage.ID.pipe(Schema.optional),
prompt: Prompt,
prompt: PromptInput.Prompt,
delivery: SessionInput.Delivery.pipe(Schema.optional),
resume: Schema.Boolean.pipe(Schema.optional),
}),
@@ -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(
HttpApiEndpoint.get("session.events", "/api/session/:sessionID/event", {
params: { sessionID: Session.ID },
+10 -2
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { SessionsCursor } from "../src/groups/session"
import { Effect, Schema } from "effect"
import { SessionHistoryQuery, SessionsCursor } from "../src/groups/session"
import { Session } from "@opencode-ai/schema/session"
describe("SessionsCursor", () => {
@@ -16,3 +16,11 @@ describe("SessionsCursor", () => {
expect(await Effect.runPromise(SessionsCursor.parse(cursor))).toEqual(input)
})
})
describe("SessionHistoryQuery", () => {
test("decodes numeric paging inputs", async () => {
const query = await Effect.runPromise(Schema.decodeUnknownEffect(SessionHistoryQuery)({ after: "3", limit: "10" }))
expect(query).toEqual({ after: 3, limit: 10 })
})
})
@@ -4,6 +4,11 @@ import { Event } from "./event"
import { SessionEvent } from "./session-event"
import { SessionV1 } from "./session-v1"
export const SessionDurable = {
definitions: Event.durable(SessionEvent.DurableDefinitions),
schema: SessionEvent.Durable,
} as const
export const Durable = Event.durable([
...SessionV1.Event.Definitions.filter((definition) => definition.durable !== undefined),
...SessionEvent.DurableDefinitions,
+3 -3
View File
@@ -55,7 +55,7 @@ export function define<
id: ID,
metadata: optional(Schema.Record(Schema.String, Schema.Unknown)),
type: Schema.Literal(input.type),
durable: optional(Schema.Struct({ aggregateID: Schema.String, seq: Schema.Number, version: Schema.Number })),
durable: optional(Schema.Struct({ aggregateID: Schema.String, seq: Schema.Int, version: Schema.Int })),
location: optional(Location.Ref),
data,
})
@@ -95,7 +95,7 @@ export function versionedType(type: string, version: number) {
return `${type}.${version}`
}
export function durable(definitions: ReadonlyArray<Definition>) {
export function durable<const Definitions extends ReadonlyArray<Definition>>(definitions: Definitions) {
return readonlyMap(
definitions.reduce((result, definition) => {
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}`)
result.set(key, definition)
return result
}, new Map<string, Definition>()),
}, new Map<string, Definitions[number]>()),
)
}
+1
View File
@@ -24,4 +24,5 @@ export { PtyTicket } from "./pty-ticket"
export { Question } from "./question"
export { Workspace } from "./workspace"
export { Prompt, Source, FileAttachment, AgentAttachment } from "./prompt"
export { PromptInput } from "./prompt-input"
export * from "./schema"
+26
View File
@@ -0,0 +1,26 @@
export * as PromptInput from "./prompt-input"
import { Schema } from "effect"
import { AgentAttachment, Source } from "./prompt"
import { optional, statics } from "./schema"
export interface FileAttachment extends Schema.Schema.Type<typeof FileAttachment> {}
export const FileAttachment = Schema.Struct({
uri: Schema.String,
name: Schema.String.pipe(optional),
description: Schema.String.pipe(optional),
source: Source.pipe(optional),
})
.annotate({ identifier: "PromptInput.FileAttachment" })
.pipe(
statics((schema) => ({
create: (input: FileAttachment) => schema.make(input),
})),
)
export interface Prompt extends Schema.Schema.Type<typeof Prompt> {}
export const Prompt = Schema.Struct({
text: Schema.String,
files: Schema.Array(FileAttachment).pipe(optional),
agents: Schema.Array(AgentAttachment).pipe(optional),
}).annotate({ identifier: "PromptInput" })
+3 -1
View File
@@ -511,7 +511,9 @@ export const Definitions = Event.inventory(
RevertEvent.Committed,
)
export const Durable = Schema.Union(DurableDefinitions, { mode: "oneOf" }).pipe(Schema.toTaggedUnion("type"))
export const Durable = Schema.Union(DurableDefinitions, { mode: "oneOf" })
.pipe(Schema.toTaggedUnion("type"))
.annotate({ identifier: "SessionDurableEvent" })
export type DurableEvent = typeof Durable.Type
export const All = Schema.Union(Definitions, { mode: "oneOf" }).pipe(Schema.toTaggedUnion("type"))
+1
View File
@@ -5,6 +5,7 @@
"type": "module",
"license": "MIT",
"scripts": {
"test": "bun test",
"typecheck": "tsgo --noEmit",
"build": "bun ./script/build.ts"
},
+54
View File
@@ -13,6 +13,37 @@ const opencode = path.resolve(dir, "../../opencode")
await $`bun dev generate > ${dir}/openapi.json`.cwd(opencode)
const document = (await Bun.file("./openapi.json").json()) as {
components?: { schemas?: Record<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({
input: "./openapi.json",
output: {
@@ -40,6 +71,29 @@ await createClient({
],
})
const generatedTypes = await Bun.file("./src/v2/gen/types.gen.ts").text()
if (/export type SessionNext\w+1 =/.test(generatedTypes)) {
throw new Error("Session history generated duplicate Session event variants")
}
const historyTypesPatched = generatedTypes.replace(
/(export type V2SessionHistoryData = \{[\s\S]*?query\?: \{\s*limit\?: )string([;,]\s*after\?: )string/,
"$1number$2number",
)
if (historyTypesPatched === generatedTypes) {
throw new Error("Session history numeric query patch did not apply")
}
await Bun.write("./src/v2/gen/types.gen.ts", historyTypesPatched)
const generatedSdk = await Bun.file("./src/v2/gen/sdk.gen.ts").text()
const historySdkPatched = generatedSdk.replace(
/(Get session history[\s\S]*?parameters: \{\s*sessionID: string[;,]\s*limit\?: )string([;,]\s*after\?: )string/,
"$1number$2number",
)
if (historySdkPatched === generatedSdk) {
throw new Error("Session history numeric SDK patch did not apply")
}
await Bun.write("./src/v2/gen/sdk.gen.ts", historySdkPatched)
// Patch a @hey-api/openapi-ts codegen bug: SseFn incorrectly passes the
// endpoint's TError into the second generic of ServerSentEventsResult, which
// is the AsyncGenerator's TReturn slot. Iterator return values have nothing
+36 -2
View File
@@ -142,7 +142,7 @@ import type {
ProjectListResponses,
ProjectUpdateErrors,
ProjectUpdateResponses,
Prompt,
PromptInput,
ProviderAuthErrors,
ProviderAuthResponses,
ProviderListErrors,
@@ -345,6 +345,8 @@ import type {
V2SessionEventsResponses,
V2SessionGetErrors,
V2SessionGetResponses,
V2SessionHistoryErrors,
V2SessionHistoryResponses,
V2SessionInterruptErrors,
V2SessionInterruptResponses,
V2SessionListErrors,
@@ -5621,7 +5623,7 @@ export class Session3 extends HeyApiClient {
parameters: {
sessionID: string
id?: string
prompt?: Prompt
prompt?: PromptInput
delivery?: "steer" | "queue"
resume?: boolean
},
@@ -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
*
+146 -58
View File
@@ -2700,6 +2700,12 @@ export type SessionNotFoundError = {
message: string
}
export type PromptInput = {
text: string
files?: Array<PromptInputFileAttachment>
agents?: Array<PromptAgentAttachment>
}
export type ConflictError = {
_tag: "ConflictError"
message: string
@@ -2725,6 +2731,43 @@ export type UnknownError1 = {
ref?: string
}
export type SessionDurableEvent =
| SessionNextAgentSwitched
| SessionNextModelSwitched
| SessionNextMoved
| SessionNextPrompted
| SessionNextPromptAdmitted
| SessionNextContextUpdated
| SessionNextSynthetic
| SessionNextShellStarted
| SessionNextShellEnded
| SessionNextStepStarted
| SessionNextStepEnded
| SessionNextStepFailed
| SessionNextTextStarted
| SessionNextTextEnded
| SessionNextToolInputStarted
| SessionNextToolInputEnded
| SessionNextToolCalled
| SessionNextToolProgress
| SessionNextToolSuccess
| SessionNextToolFailed
| SessionNextReasoningStarted
| SessionNextReasoningEnded
| SessionNextRetried
| SessionNextCompactionStarted
| SessionNextCompactionEnded
| SessionNextRevertStaged
| SessionNextRevertCleared
| SessionNextRevertCommitted
export type SessionHistory = {
data: Array<SessionDurableEvent>
hasMore: boolean
}
export type SessionDurableEvent1 = string
export type SessionMessagesResponse = {
data: Array<SessionMessage>
cursor: {
@@ -3814,6 +3857,13 @@ export type SessionV2Info = {
revert?: RevertState
}
export type PromptInputFileAttachment = {
uri: string
name?: string
description?: string
source?: PromptSource
}
export type SessionInputAdmitted = {
admittedSeq: number
id: string
@@ -4044,8 +4094,8 @@ export type SessionNextAgentSwitched = {
type: "session.next.agent.switched"
durable?: {
aggregateID: string
seq: number | "NaN" | "Infinity" | "-Infinity"
version: number | "NaN" | "Infinity" | "-Infinity"
seq: number
version: number
}
location?: LocationRef
data: {
@@ -4064,8 +4114,8 @@ export type SessionNextModelSwitched = {
type: "session.next.model.switched"
durable?: {
aggregateID: string
seq: number | "NaN" | "Infinity" | "-Infinity"
version: number | "NaN" | "Infinity" | "-Infinity"
seq: number
version: number
}
location?: LocationRef
data: {
@@ -4084,8 +4134,8 @@ export type SessionNextMoved = {
type: "session.next.moved"
durable?: {
aggregateID: string
seq: number | "NaN" | "Infinity" | "-Infinity"
version: number | "NaN" | "Infinity" | "-Infinity"
seq: number
version: number
}
location?: LocationRef
data: {
@@ -4104,8 +4154,8 @@ export type SessionNextPrompted = {
type: "session.next.prompted"
durable?: {
aggregateID: string
seq: number | "NaN" | "Infinity" | "-Infinity"
version: number | "NaN" | "Infinity" | "-Infinity"
seq: number
version: number
}
location?: LocationRef
data: {
@@ -4125,8 +4175,8 @@ export type SessionNextPromptAdmitted = {
type: "session.next.prompt.admitted"
durable?: {
aggregateID: string
seq: number | "NaN" | "Infinity" | "-Infinity"
version: number | "NaN" | "Infinity" | "-Infinity"
seq: number
version: number
}
location?: LocationRef
data: {
@@ -4146,8 +4196,8 @@ export type SessionNextContextUpdated = {
type: "session.next.context.updated"
durable?: {
aggregateID: string
seq: number | "NaN" | "Infinity" | "-Infinity"
version: number | "NaN" | "Infinity" | "-Infinity"
seq: number
version: number
}
location?: LocationRef
data: {
@@ -4166,8 +4216,8 @@ export type SessionNextSynthetic = {
type: "session.next.synthetic"
durable?: {
aggregateID: string
seq: number | "NaN" | "Infinity" | "-Infinity"
version: number | "NaN" | "Infinity" | "-Infinity"
seq: number
version: number
}
location?: LocationRef
data: {
@@ -4186,8 +4236,8 @@ export type SessionNextShellStarted = {
type: "session.next.shell.started"
durable?: {
aggregateID: string
seq: number | "NaN" | "Infinity" | "-Infinity"
version: number | "NaN" | "Infinity" | "-Infinity"
seq: number
version: number
}
location?: LocationRef
data: {
@@ -4207,8 +4257,8 @@ export type SessionNextShellEnded = {
type: "session.next.shell.ended"
durable?: {
aggregateID: string
seq: number | "NaN" | "Infinity" | "-Infinity"
version: number | "NaN" | "Infinity" | "-Infinity"
seq: number
version: number
}
location?: LocationRef
data: {
@@ -4227,8 +4277,8 @@ export type SessionNextStepStarted = {
type: "session.next.step.started"
durable?: {
aggregateID: string
seq: number | "NaN" | "Infinity" | "-Infinity"
version: number | "NaN" | "Infinity" | "-Infinity"
seq: number
version: number
}
location?: LocationRef
data: {
@@ -4249,8 +4299,8 @@ export type SessionNextStepEnded = {
type: "session.next.step.ended"
durable?: {
aggregateID: string
seq: number | "NaN" | "Infinity" | "-Infinity"
version: number | "NaN" | "Infinity" | "-Infinity"
seq: number
version: number
}
location?: LocationRef
data: {
@@ -4281,8 +4331,8 @@ export type SessionNextStepFailed = {
type: "session.next.step.failed"
durable?: {
aggregateID: string
seq: number | "NaN" | "Infinity" | "-Infinity"
version: number | "NaN" | "Infinity" | "-Infinity"
seq: number
version: number
}
location?: LocationRef
data: {
@@ -4301,8 +4351,8 @@ export type SessionNextTextStarted = {
type: "session.next.text.started"
durable?: {
aggregateID: string
seq: number | "NaN" | "Infinity" | "-Infinity"
version: number | "NaN" | "Infinity" | "-Infinity"
seq: number
version: number
}
location?: LocationRef
data: {
@@ -4321,8 +4371,8 @@ export type SessionNextTextEnded = {
type: "session.next.text.ended"
durable?: {
aggregateID: string
seq: number | "NaN" | "Infinity" | "-Infinity"
version: number | "NaN" | "Infinity" | "-Infinity"
seq: number
version: number
}
location?: LocationRef
data: {
@@ -4342,8 +4392,8 @@ export type SessionNextToolInputStarted = {
type: "session.next.tool.input.started"
durable?: {
aggregateID: string
seq: number | "NaN" | "Infinity" | "-Infinity"
version: number | "NaN" | "Infinity" | "-Infinity"
seq: number
version: number
}
location?: LocationRef
data: {
@@ -4363,8 +4413,8 @@ export type SessionNextToolInputEnded = {
type: "session.next.tool.input.ended"
durable?: {
aggregateID: string
seq: number | "NaN" | "Infinity" | "-Infinity"
version: number | "NaN" | "Infinity" | "-Infinity"
seq: number
version: number
}
location?: LocationRef
data: {
@@ -4384,8 +4434,8 @@ export type SessionNextToolCalled = {
type: "session.next.tool.called"
durable?: {
aggregateID: string
seq: number | "NaN" | "Infinity" | "-Infinity"
version: number | "NaN" | "Infinity" | "-Infinity"
seq: number
version: number
}
location?: LocationRef
data: {
@@ -4412,8 +4462,8 @@ export type SessionNextToolProgress = {
type: "session.next.tool.progress"
durable?: {
aggregateID: string
seq: number | "NaN" | "Infinity" | "-Infinity"
version: number | "NaN" | "Infinity" | "-Infinity"
seq: number
version: number
}
location?: LocationRef
data: {
@@ -4436,8 +4486,8 @@ export type SessionNextToolSuccess = {
type: "session.next.tool.success"
durable?: {
aggregateID: string
seq: number | "NaN" | "Infinity" | "-Infinity"
version: number | "NaN" | "Infinity" | "-Infinity"
seq: number
version: number
}
location?: LocationRef
data: {
@@ -4466,8 +4516,8 @@ export type SessionNextToolFailed = {
type: "session.next.tool.failed"
durable?: {
aggregateID: string
seq: number | "NaN" | "Infinity" | "-Infinity"
version: number | "NaN" | "Infinity" | "-Infinity"
seq: number
version: number
}
location?: LocationRef
data: {
@@ -4492,8 +4542,8 @@ export type SessionNextReasoningStarted = {
type: "session.next.reasoning.started"
durable?: {
aggregateID: string
seq: number | "NaN" | "Infinity" | "-Infinity"
version: number | "NaN" | "Infinity" | "-Infinity"
seq: number
version: number
}
location?: LocationRef
data: {
@@ -4513,8 +4563,8 @@ export type SessionNextReasoningEnded = {
type: "session.next.reasoning.ended"
durable?: {
aggregateID: string
seq: number | "NaN" | "Infinity" | "-Infinity"
version: number | "NaN" | "Infinity" | "-Infinity"
seq: number
version: number
}
location?: LocationRef
data: {
@@ -4535,8 +4585,8 @@ export type SessionNextRetried = {
type: "session.next.retried"
durable?: {
aggregateID: string
seq: number | "NaN" | "Infinity" | "-Infinity"
version: number | "NaN" | "Infinity" | "-Infinity"
seq: number
version: number
}
location?: LocationRef
data: {
@@ -4555,8 +4605,8 @@ export type SessionNextCompactionStarted = {
type: "session.next.compaction.started"
durable?: {
aggregateID: string
seq: number | "NaN" | "Infinity" | "-Infinity"
version: number | "NaN" | "Infinity" | "-Infinity"
seq: number
version: number
}
location?: LocationRef
data: {
@@ -4575,8 +4625,8 @@ export type SessionNextCompactionEnded = {
type: "session.next.compaction.ended"
durable?: {
aggregateID: string
seq: number | "NaN" | "Infinity" | "-Infinity"
version: number | "NaN" | "Infinity" | "-Infinity"
seq: number
version: number
}
location?: LocationRef
data: {
@@ -4597,8 +4647,8 @@ export type SessionNextRevertStaged = {
type: "session.next.revert.staged"
durable?: {
aggregateID: string
seq: number | "NaN" | "Infinity" | "-Infinity"
version: number | "NaN" | "Infinity" | "-Infinity"
seq: number
version: number
}
location?: LocationRef
data: {
@@ -4616,8 +4666,8 @@ export type SessionNextRevertCleared = {
type: "session.next.revert.cleared"
durable?: {
aggregateID: string
seq: number | "NaN" | "Infinity" | "-Infinity"
version: number | "NaN" | "Infinity" | "-Infinity"
seq: number
version: number
}
location?: LocationRef
data: {
@@ -4634,8 +4684,8 @@ export type SessionNextRevertCommitted = {
type: "session.next.revert.committed"
durable?: {
aggregateID: string
seq: number | "NaN" | "Infinity" | "-Infinity"
version: number | "NaN" | "Infinity" | "-Infinity"
seq: number
version: number
}
location?: LocationRef
data: {
@@ -12091,7 +12141,7 @@ export type V2SessionSwitchModelResponse = V2SessionSwitchModelResponses[keyof V
export type V2SessionPromptData = {
body: {
id?: string
prompt: Prompt
prompt: PromptInput
delivery?: "steer" | "queue"
resume?: boolean
}
@@ -12371,6 +12421,44 @@ export type V2SessionContextResponses = {
export type V2SessionContextResponse = V2SessionContextResponses[keyof V2SessionContextResponses]
export type V2SessionHistoryData = {
body?: never
path: {
sessionID: string
}
query?: {
limit?: number
after?: number
}
url: "/api/session/{sessionID}/history"
}
export type V2SessionHistoryErrors = {
/**
* InvalidRequestError
*/
400: InvalidRequestError
/**
* UnauthorizedError
*/
401: UnauthorizedError
/**
* SessionNotFoundError
*/
404: SessionNotFoundError
}
export type V2SessionHistoryError = V2SessionHistoryErrors[keyof V2SessionHistoryErrors]
export type V2SessionHistoryResponses = {
/**
* SessionHistory
*/
200: SessionHistory
}
export type V2SessionHistoryResponse = V2SessionHistoryResponses[keyof V2SessionHistoryResponses]
export type V2SessionEventsData = {
body?: never
path: {
@@ -12406,7 +12494,7 @@ export type V2SessionEventsResponses = {
200: {
id: string
event: string
data: string
data: SessionDurableEvent1
}
}
@@ -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)
})
+301 -1043
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -14,6 +14,7 @@ import {
import { AbsolutePath } from "@opencode-ai/core/schema"
const DefaultSessionsLimit = 50
const DefaultSessionHistoryLimit = 50
export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handlers) =>
Effect.gen(function* () {
@@ -328,6 +329,31 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
}
}),
)
.handle(
"session.history",
Effect.fn(function* (ctx) {
return yield* session
.history({
sessionID: ctx.params.sessionID,
after: ctx.query.after,
limit: ctx.query.limit ?? DefaultSessionHistoryLimit,
})
.pipe(
Effect.map((page) => ({
data: page.events,
hasMore: page.hasMore,
})),
Effect.catchTag(
"Session.NotFoundError",
(error) =>
new SessionNotFoundError({
sessionID: error.sessionID,
message: `Session not found: ${error.sessionID}`,
}),
),
)
}),
)
.handle(
"session.events",
Effect.fn((ctx) =>
+1
View File
@@ -240,6 +240,7 @@ const en = {
"model.noPeersDescription": "Peer rankings appear after usage lands.",
"model.noUsageLastWeek": "No usage last week",
"model.newThisWeek": "New this week",
"model.sameAsPreviousWeek": "Same as previous week",
"model.vsPreviousWeek": "{{change}} vs previous week",
"model.pdf": "PDF",
"format.users": "users",
+1
View File
@@ -221,6 +221,7 @@ export const dict = {
"model.noPeersDescription": "تظهر ترتيبات النماذج المشابهة بعد وصول الاستخدام.",
"model.noUsageLastWeek": "لا يوجد استخدام الأسبوع الماضي",
"model.newThisWeek": "جديد هذا الأسبوع",
"model.sameAsPreviousWeek": "دون تغيير عن الأسبوع السابق",
"model.vsPreviousWeek": "{{change}} مقارنة بالأسبوع السابق",
"model.pdf": "PDF",
"format.users": "مستخدمون",
+1
View File
@@ -223,6 +223,7 @@ export const dict = {
"model.noPeersDescription": "Os rankings de pares aparecem depois que o uso chega.",
"model.noUsageLastWeek": "Sem uso na semana passada",
"model.newThisWeek": "Novo esta semana",
"model.sameAsPreviousWeek": "Igual à semana anterior",
"model.vsPreviousWeek": "{{change}} vs semana anterior",
"model.pdf": "PDF",
"format.users": "usuários",
+1
View File
@@ -222,6 +222,7 @@ export const dict = {
"model.noPeersDescription": "Ranglister over lignende modeller vises, når brug lander.",
"model.noUsageLastWeek": "Ingen brug sidste uge",
"model.newThisWeek": "Ny denne uge",
"model.sameAsPreviousWeek": "Samme som forrige uge",
"model.vsPreviousWeek": "{{change}} vs forrige uge",
"model.pdf": "PDF",
"format.users": "brugere",
+1
View File
@@ -223,6 +223,7 @@ export const dict = {
"model.noPeersDescription": "Vergleichsrankings erscheinen, nachdem Nutzung eingegangen ist.",
"model.noUsageLastWeek": "Keine Nutzung letzte Woche",
"model.newThisWeek": "Neu diese Woche",
"model.sameAsPreviousWeek": "Unverändert zur vorherigen Woche",
"model.vsPreviousWeek": "{{change}} ggü. vorheriger Woche",
"model.pdf": "PDF",
"format.users": "Nutzer",
+1
View File
@@ -222,6 +222,7 @@ export const dict = {
"model.noPeersDescription": "Las clasificaciones de modelos similares aparecen después de que llegue uso.",
"model.noUsageLastWeek": "Sin uso la semana pasada",
"model.newThisWeek": "Nuevo esta semana",
"model.sameAsPreviousWeek": "Igual que la semana anterior",
"model.vsPreviousWeek": "{{change}} vs semana anterior",
"model.pdf": "PDF",
"format.users": "usuarios",
+1
View File
@@ -224,6 +224,7 @@ export const dict = {
"model.noPeersDescription": "Les classements de modèles proches apparaissent après l'arrivée de l'utilisation.",
"model.noUsageLastWeek": "Aucune utilisation la semaine dernière",
"model.newThisWeek": "Nouveau cette semaine",
"model.sameAsPreviousWeek": "Identique à la semaine précédente",
"model.vsPreviousWeek": "{{change}} vs semaine précédente",
"model.pdf": "PDF",
"format.users": "utilisateurs",
+1
View File
@@ -223,6 +223,7 @@ export const dict = {
"model.noPeersDescription": "Le classifiche dei modelli simili appaiono dopo l'arrivo dell'utilizzo.",
"model.noUsageLastWeek": "Nessun utilizzo la scorsa settimana",
"model.newThisWeek": "Nuovo questa settimana",
"model.sameAsPreviousWeek": "Uguale alla settimana precedente",
"model.vsPreviousWeek": "{{change}} vs settimana precedente",
"model.pdf": "PDF",
"format.users": "utenti",
+1
View File
@@ -224,6 +224,7 @@ export const dict = {
"model.noPeersDescription": "使用量が届くと類似モデルのランキングが表示されます。",
"model.noUsageLastWeek": "先週の使用量なし",
"model.newThisWeek": "今週新規",
"model.sameAsPreviousWeek": "前週と同じ",
"model.vsPreviousWeek": "前週比 {{change}}",
"model.pdf": "PDF",
"format.users": "ユーザー",
+1
View File
@@ -224,6 +224,7 @@ export const dict = {
"model.noPeersDescription": "사용량이 들어오면 비슷한 모델 순위가 표시됩니다.",
"model.noUsageLastWeek": "지난주 사용량 없음",
"model.newThisWeek": "이번 주 신규",
"model.sameAsPreviousWeek": "지난주와 동일",
"model.vsPreviousWeek": "지난주 대비 {{change}}",
"model.pdf": "PDF",
"format.users": "사용자",
+1
View File
@@ -222,6 +222,7 @@ export const dict = {
"model.noPeersDescription": "Rangeringer for lignende modeller vises etter at bruk lander.",
"model.noUsageLastWeek": "Ingen bruk forrige uke",
"model.newThisWeek": "Ny denne uken",
"model.sameAsPreviousWeek": "Samme som forrige uke",
"model.vsPreviousWeek": "{{change}} mot forrige uke",
"model.pdf": "PDF",
"format.users": "brukere",
+1
View File
@@ -221,6 +221,7 @@ export const dict = {
"model.noPeersDescription": "Rankingi podobnych modeli pojawią się po nadejściu użycia.",
"model.noUsageLastWeek": "Brak użycia w zeszłym tygodniu",
"model.newThisWeek": "Nowy w tym tygodniu",
"model.sameAsPreviousWeek": "Tak samo jak w poprzednim tygodniu",
"model.vsPreviousWeek": "{{change}} vs poprzedni tydzień",
"model.pdf": "PDF",
"format.users": "użytkownicy",
+1
View File
@@ -223,6 +223,7 @@ export const dict = {
"model.noPeersDescription": "Рейтинги похожих моделей появятся после использования.",
"model.noUsageLastWeek": "Нет использования на прошлой неделе",
"model.newThisWeek": "Новая на этой неделе",
"model.sameAsPreviousWeek": "Без изменений к предыдущей неделе",
"model.vsPreviousWeek": "{{change}} к предыдущей неделе",
"model.pdf": "PDF",
"format.users": "пользователи",
+1
View File
@@ -223,6 +223,7 @@ export const dict = {
"model.noPeersDescription": "อันดับโมเดลใกล้เคียงจะแสดงหลังจากมีการใช้งานเข้ามา",
"model.noUsageLastWeek": "ไม่มีการใช้งานเมื่อสัปดาห์ที่แล้ว",
"model.newThisWeek": "ใหม่ในสัปดาห์นี้",
"model.sameAsPreviousWeek": "เท่าเดิมจากสัปดาห์ก่อน",
"model.vsPreviousWeek": "{{change}} เทียบกับสัปดาห์ก่อน",
"model.pdf": "PDF",
"format.users": "ผู้ใช้",
+1
View File
@@ -223,6 +223,7 @@ export const dict = {
"model.noPeersDescription": "Benzer model sıralamaları kullanım geldikten sonra görünür.",
"model.noUsageLastWeek": "Geçen hafta kullanım yok",
"model.newThisWeek": "Bu hafta yeni",
"model.sameAsPreviousWeek": "Önceki haftayla aynı",
"model.vsPreviousWeek": "önceki haftaya göre {{change}}",
"model.pdf": "PDF",
"format.users": "kullanıcı",
+1
View File
@@ -223,6 +223,7 @@ export const dict = {
"model.noPeersDescription": "Рейтинги схожих моделей з'являться після використання.",
"model.noUsageLastWeek": "Немає використання минулого тижня",
"model.newThisWeek": "Нова цього тижня",
"model.sameAsPreviousWeek": "Без змін відносно попереднього тижня",
"model.vsPreviousWeek": "{{change}} до попереднього тижня",
"model.pdf": "PDF",
"format.users": "користувачі",
+1
View File
@@ -222,6 +222,7 @@ export const dict = {
"model.noPeersDescription": "使用量到达后会显示同类模型排名。",
"model.noUsageLastWeek": "上周无使用量",
"model.newThisWeek": "本周新增",
"model.sameAsPreviousWeek": "与上周相同",
"model.vsPreviousWeek": "较上周 {{change}}",
"model.pdf": "PDF",
"format.users": "用户",
+1
View File
@@ -222,6 +222,7 @@ export const dict = {
"model.noPeersDescription": "使用量到達後會顯示同類模型排名。",
"model.noUsageLastWeek": "上週無使用量",
"model.newThisWeek": "本週新增",
"model.sameAsPreviousWeek": "與上週相同",
"model.vsPreviousWeek": "較上週 {{change}}",
"model.pdf": "PDF",
"format.users": "使用者",
+42 -21
View File
@@ -30,6 +30,7 @@ import {
type ModelCatalogCost,
type ModelCatalogEntry,
} from "../model-catalog"
import { SectionHeading } from "../section-heading"
import { runStatsEffect } from "../../stats-runtime"
import { setStatsPageCacheHeaders } from "../stats-cache"
import {
@@ -205,7 +206,11 @@ function ModelLoading() {
<a data-slot="model-back-link" href={language.route(import.meta.env.BASE_URL)}>
{i18n.t("footer.modelData")}
</a>
<h1>{i18n.t("model.loadingTitle")}</h1>
<h1>
<a data-slot="heading-link" href="#overview">
{i18n.t("model.loadingTitle")}
</a>
</h1>
<p>{i18n.t("model.loadingDescription")}</p>
</div>
</div>
@@ -228,7 +233,11 @@ function ModelNotFound(props: { lab: string; model: string }) {
<a data-slot="model-back-link" href={language.route(import.meta.env.BASE_URL)}>
{i18n.t("footer.modelData")}
</a>
<h1>{props.model || i18n.t("model.fallback")}</h1>
<h1>
<a data-slot="heading-link" href="#overview">
{props.model || i18n.t("model.fallback")}
</a>
</h1>
<p>{i18n.t("model.noMatched", { id: props.lab ? `${props.lab}/${props.model}` : props.model })}</p>
</div>
</div>
@@ -260,7 +269,11 @@ function ModelHero(props: { data: StatsModelData | null; catalog: ModelCatalogEn
</a>
<span data-slot="model-id-tag">{modelId()}</span>
</div>
<h1>{props.catalog?.name ?? props.data?.model ?? i18n.t("model.fallback")}</h1>
<h1>
<a data-slot="heading-link" href="#overview">
{props.catalog?.name ?? props.data?.model ?? i18n.t("model.fallback")}
</a>
</h1>
<Show when={props.data} fallback={<p>{i18n.t("model.catalogFallback")}</p>}>
{(data) => (
<p>
@@ -352,8 +365,12 @@ function CatalogDatum(props: { label: string; value: string }) {
function ModelOverview(props: { data: StatsModelData | null }) {
const i18n = useI18n()
return (
<section data-section="model-panel">
<SectionTitle title={i18n.t("nav.overview")} description={i18n.t("model.overviewDescription")} />
<section id="model-overview" data-section="model-panel">
<SectionTitle
href="#model-overview"
title={i18n.t("nav.overview")}
description={i18n.t("model.overviewDescription")}
/>
<Show
when={props.data}
fallback={
@@ -399,7 +416,7 @@ function ModelUsageSection(props: { data: ModelUsagePoint[] }) {
const i18n = useI18n()
return (
<section id="usage" data-section="model-panel">
<SectionTitle title={i18n.t("nav.usage")} description={i18n.t("model.usageDescription")} />
<SectionTitle href="#usage" title={i18n.t("nav.usage")} description={i18n.t("model.usageDescription")} />
<Show
when={props.data.some((item) => item.tokens > 0)}
fallback={
@@ -416,7 +433,7 @@ function ModelUsersSection(props: { data: ModelUsagePoint[] }) {
const i18n = useI18n()
return (
<section id="users" data-section="model-panel">
<SectionTitle title={i18n.t("model.uniqueUsers")} description={i18n.t("model.usersDescription")} />
<SectionTitle href="#users" title={i18n.t("model.uniqueUsers")} description={i18n.t("model.usersDescription")} />
<Show
when={props.data.some((item) => item.users > 0)}
fallback={
@@ -550,7 +567,11 @@ function ModelEfficiencySection(props: { data: StatsModelData | null; catalog: M
const i18n = useI18n()
return (
<section id="efficiency" data-section="model-panel">
<SectionTitle title={i18n.t("nav.efficiency")} description={i18n.t("model.efficiencyDescription")} />
<SectionTitle
href="#efficiency"
title={i18n.t("nav.efficiency")}
description={i18n.t("model.efficiencyDescription")}
/>
<Show
when={props.data}
fallback={
@@ -623,7 +644,11 @@ function ModelGeoBreakdownSection(props: { data: Record<UsageRange, CountryEntry
setActiveCountry(undefined)
}}
>
<SectionTitle title={i18n.t("nav.geoBreakdown")} description={i18n.t("model.geoDescription")} />
<SectionTitle
href="#geo-breakdown"
title={i18n.t("nav.geoBreakdown")}
description={i18n.t("model.geoDescription")}
/>
<Show
when={data().length > 0}
fallback={<ModelEmptyState title={i18n.t("model.noGeoTitle")} description={i18n.t("model.noGeoDescription")} />}
@@ -788,7 +813,7 @@ function ModelPeersSection(props: { data: StatsModelData | null }) {
const i18n = useI18n()
return (
<section id="peers" data-section="model-panel">
<SectionTitle title={i18n.t("nav.peers")} description={i18n.t("model.peersDescription")} />
<SectionTitle href="#peers" title={i18n.t("nav.peers")} description={i18n.t("model.peersDescription")} />
<Show
when={props.data?.peers.length}
fallback={
@@ -833,12 +858,8 @@ function PeerRow(props: { peer: ModelPeerEntry; active: boolean }) {
)
}
function SectionTitle(props: { title: string; description: string }) {
return (
<p data-slot="section-title">
<strong>{props.title}.</strong> <span>{props.description}</span>
</p>
)
function SectionTitle(props: { href: string; title: string; description: string }) {
return <SectionHeading href={props.href} title={props.title} description={props.description} />
}
function ModelEmptyState(props: { title: string; description: string; compact?: boolean }) {
@@ -912,17 +933,17 @@ function isModelUsageLabelHidden(index: number, count: number) {
return index !== count - 1 && index % interval !== 0
}
function formatRankMove(previousRank: number, rank: number) {
const change = previousRank - rank
function formatRankMove(change: number) {
if (change > 0) return `+${change}`
if (change < 0) return `${change}`
return "0"
return `${change}`
}
function formatModelRankMoveLabel(data: StatsModelData, i18n: ReturnType<typeof useI18n>) {
if (data.rank === null) return i18n.t("model.noUsageLastWeek")
if (data.previousRank === null) return i18n.t("model.newThisWeek")
return i18n.t("model.vsPreviousWeek", { change: formatRankMove(data.previousRank, data.rank) })
const change = data.previousRank - data.rank
if (change === 0) return i18n.t("model.sameAsPreviousWeek")
return i18n.t("model.vsPreviousWeek", { change: formatRankMove(change) })
}
function formatTokens(value: number) {
+26 -11
View File
@@ -20,6 +20,7 @@ import {
type ModelCatalogEntry,
type ModelCatalogLab,
} from "../model-catalog"
import { SectionHeading } from "../section-heading"
import { runStatsEffect } from "../../stats-runtime"
import { setStatsPageCacheHeaders } from "../stats-cache"
import {
@@ -148,7 +149,11 @@ function LabLoading() {
<a data-slot="model-back-link" href={language.route(import.meta.env.BASE_URL)}>
{i18n.t("footer.modelData")}
</a>
<h1>{i18n.t("lab.loadingTitle")}</h1>
<h1>
<a data-slot="heading-link" href="#overview">
{i18n.t("lab.loadingTitle")}
</a>
</h1>
<p>{i18n.t("lab.loadingDescription")}</p>
</div>
</div>
@@ -166,7 +171,11 @@ function LabNotFound(props: { lab: string }) {
<a data-slot="model-back-link" href={language.route(import.meta.env.BASE_URL)}>
{i18n.t("footer.modelData")}
</a>
<h1>{formatCatalogLabName(props.lab)}</h1>
<h1>
<a data-slot="heading-link" href="#overview">
{formatCatalogLabName(props.lab)}
</a>
</h1>
<p>{i18n.t("lab.notFound")}</p>
</div>
</div>
@@ -193,7 +202,11 @@ function LabHero(props: { lab: ModelCatalogLab; stats: StatsLabData | null }) {
</a>
<div data-slot="model-hero-grid">
<div data-slot="model-hero-copy">
<h1>{props.lab.name}</h1>
<h1>
<a data-slot="heading-link" href="#overview">
{props.lab.name}
</a>
</h1>
<div data-slot="model-hero-pattern" aria-hidden="true" />
<p>
{i18n.t("lab.heroPrefix", { count: props.lab.models.length, lab: props.lab.name })}
@@ -235,10 +248,11 @@ function LabUsageSection(props: { lab: ModelCatalogLab; data: StatsLabData | nul
return (
<section id="usage" data-section="model-panel">
<p data-slot="section-title">
<strong>{i18n.t("lab.usageTitle", { lab: props.lab.name })}.</strong>{" "}
<span>{i18n.t("lab.usageDescription")}</span>
</p>
<SectionHeading
href="#usage"
title={i18n.t("lab.usageTitle", { lab: props.lab.name })}
description={i18n.t("lab.usageDescription")}
/>
<Show
when={usage().some((item) => item.tokens > 0)}
fallback={<LabEmptyState title={i18n.t("lab.noUsageTitle")} description={i18n.t("lab.noUsageDescription")} />}
@@ -336,10 +350,11 @@ function LabModelsSection(props: { lab: ModelCatalogLab; usage: LabUsageModelEnt
const usageBySlug = createMemo(() => new Map(props.usage.map((item) => [item.slug, item])))
return (
<section id="models" data-section="model-panel">
<p data-slot="section-title">
<strong>{i18n.t("lab.modelsTitle", { lab: props.lab.name })}.</strong>{" "}
<span>{i18n.t("lab.recentUsageAndLimits")}</span>
</p>
<SectionHeading
href="#models"
title={i18n.t("lab.modelsTitle", { lab: props.lab.name })}
description={i18n.t("lab.recentUsageAndLimits")}
/>
<div data-component="lab-model-grid">
<For each={props.lab.models}>
{(model) => <LabModelCard model={model} usage={usageBySlug().get(model.slug)} />}
+34
View File
@@ -87,6 +87,11 @@
display: none !important;
}
[data-page="stats"] section[id],
[data-page="stats"] [data-component="leaderboard"][id] {
scroll-margin-top: 88px;
}
[data-page="stats"] [data-component="content"] {
color: var(--stats-text);
font-family:
@@ -1781,6 +1786,35 @@
font-weight: 400;
}
[data-page="stats"] [data-slot="heading-link"] {
position: relative;
color: inherit;
text-decoration: none;
}
[data-page="stats"] [data-slot="heading-link"]:hover {
text-decoration: none;
}
[data-page="stats"] [data-slot="heading-link"]:focus-visible {
outline: 1px solid var(--stats-accent);
outline-offset: 4px;
}
[data-page="stats"] [data-slot="heading-anchor"] {
position: absolute;
top: -0.08em;
right: 100%;
margin-right: 0.48em;
color: var(--stats-accent);
opacity: 0;
}
[data-page="stats"] [data-slot="heading-link"]:hover [data-slot="heading-anchor"],
[data-page="stats"] [data-slot="heading-link"]:focus-visible [data-slot="heading-anchor"] {
opacity: 1;
}
[data-page="stats"] [data-component="leaderboard"],
[data-page="stats"] [data-slot="leaderboard-featured"],
[data-page="stats"] [data-slot="leaderboard-compact"],
+51 -18
View File
@@ -32,6 +32,7 @@ import { useI18n } from "../context/i18n"
import { useLanguage } from "../context/language"
import { localizedUrl } from "../lib/language"
import { findModelCatalogEntry, getModelCatalog, type ModelCatalog } from "./model-catalog"
import { SectionHeading } from "./section-heading"
import { setStatsPageCacheHeaders } from "./stats-cache"
import {
applyThemePreference,
@@ -273,7 +274,11 @@ function Hero(props: { updatedAt: string | null }) {
</p>
<div data-slot="hero-canvas">
<div data-slot="hero-pattern" aria-hidden="true" />
<h1>{i18n.t("footer.modelData")}</h1>
<h1>
<a data-slot="heading-link" href="#overview">
{i18n.t("footer.modelData")}
</a>
</h1>
<p data-slot="hero-copy">{i18n.t("home.heroCopy")}</p>
</div>
</section>
@@ -296,7 +301,7 @@ function StatsLoading() {
return (
<>
<Hero updatedAt={null} />
<ChartSection title={i18n.t("home.usageTitle")}>
<ChartSection id="top-models" title={i18n.t("home.usageTitle")}>
<EmptyState title={i18n.t("home.loadingTitle")} description={i18n.t("home.loadingDescription")} />
</ChartSection>
</>
@@ -314,7 +319,15 @@ function ChartSection(props: {
<section id={props.id} data-section="chart">
<div data-slot="section-header">
<div>
<h2>{props.title}</h2>
<h2>
<Show when={props.id} fallback={props.title}>
{(id) => (
<a data-slot="heading-link" href={`#${id()}`}>
{props.title}
</a>
)}
</Show>
</h2>
{props.description && <p>{props.description}</p>}
</div>
{props.controls}
@@ -324,12 +337,8 @@ function ChartSection(props: {
)
}
function SectionTitle(props: { title: string; description: string }) {
return (
<p data-slot="section-title">
<strong>{props.title}.</strong> <span>{props.description}</span>
</p>
)
function SectionTitle(props: { id: string; title: string; description: string }) {
return <SectionHeading href={`#${props.id}`} title={props.title} description={props.description} />
}
function SectionBridge(props: { label: string; href: string }) {
@@ -405,9 +414,13 @@ function TopModelsSection(props: { data: StatsHomeData["usage"]; leaderboard: St
return (
<section id="top-models" data-section="top-models">
<h2 data-slot="top-models-title">
<strong>{i18n.t("nav.topModels")}.</strong> <span>{i18n.t("home.topModelsDescription")}</span>
</h2>
<SectionHeading
as="h2"
slot="top-models-title"
href="#top-models"
title={i18n.t("nav.topModels")}
description={i18n.t("home.topModelsDescription")}
/>
<Show
when={data().some((item) => usageTotal(item) > 0)}
fallback={<EmptyState title={i18n.t("home.noUsageTitle")} description={i18n.t("home.noUsageDescription")} />}
@@ -802,7 +815,11 @@ function UniqueUsersSection(props: { data: StatsHomeData["users"] }) {
return (
<section id="unique-users" data-section="unique-users">
<SectionBridge label={i18n.t("nav.topModels").toUpperCase()} href="#top-models" />
<SectionTitle title={i18n.t("home.uniqueUsersTitle")} description={i18n.t("home.uniqueUsersDescription")} />
<SectionTitle
id="unique-users"
title={i18n.t("home.uniqueUsersTitle")}
description={i18n.t("home.uniqueUsersDescription")}
/>
<Show
when={data().some((item) => usageTotal(item) > 0)}
fallback={
@@ -1073,7 +1090,11 @@ function MarketShareSection(props: { data: StatsHomeData["market"] }) {
}}
>
<SectionBridge label={i18n.t("nav.cacheRatio").toUpperCase()} href="#cache-ratio" />
<SectionTitle title={i18n.t("home.marketShareTitle")} description={i18n.t("home.marketShareDescription")} />
<SectionTitle
id="market-share"
title={i18n.t("home.marketShareTitle")}
description={i18n.t("home.marketShareDescription")}
/>
<Show
when={activeDay()}
fallback={<EmptyState title={i18n.t("home.noMarketTitle")} description={i18n.t("home.noMarketDescription")} />}
@@ -1298,7 +1319,7 @@ function GeoBreakdownSection(props: { data: StatsHomeData["country"] }) {
}}
>
<SectionBridge label={i18n.t("nav.marketShare").toUpperCase()} href="#market-share" />
<SectionTitle title={i18n.t("home.geoTitle")} description={i18n.t("home.geoDescription")} />
<SectionTitle id="geo-breakdown" title={i18n.t("home.geoTitle")} description={i18n.t("home.geoDescription")} />
<Show
when={data().length > 0}
fallback={<EmptyState title={i18n.t("home.noGeoTitle")} description={i18n.t("home.noGeoDescription")} />}
@@ -1583,7 +1604,11 @@ function TokenCostSection(props: { data: StatsHomeData["tokenCost"]; catalog: Mo
return (
<section id="token-cost" data-section="token-cost">
<SectionBridge label={i18n.t("nav.sessionCost").toUpperCase()} href="#session-cost" />
<SectionTitle title={i18n.t("home.tokenCostTitle")} description={i18n.t("home.tokenCostDescription")} />
<SectionTitle
id="token-cost"
title={i18n.t("home.tokenCostTitle")}
description={i18n.t("home.tokenCostDescription")}
/>
<Show
when={visible().length > 0}
fallback={
@@ -1666,7 +1691,11 @@ function CacheRatioSection(props: { data: StatsHomeData["cacheRatio"] }) {
return (
<section id="cache-ratio" data-section="cache-ratio">
<SectionBridge label={i18n.t("nav.tokenCost").toUpperCase()} href="#token-cost" />
<SectionTitle title={i18n.t("home.cacheRatioTitle")} description={i18n.t("home.cacheRatioDescription")} />
<SectionTitle
id="cache-ratio"
title={i18n.t("home.cacheRatioTitle")}
description={i18n.t("home.cacheRatioDescription")}
/>
<Show
when={visible().length > 0}
fallback={<EmptyState title={i18n.t("home.noCacheTitle")} description={i18n.t("home.noCacheDescription")} />}
@@ -1792,7 +1821,11 @@ function SessionCostSection(props: { data: StatsHomeData["sessionCost"] }) {
return (
<section id="session-cost" data-section="session-cost">
<SectionBridge label={i18n.t("nav.topModels").toUpperCase()} href="#top-models" />
<SectionTitle title={i18n.t("home.sessionCostTitle")} description={i18n.t("home.sessionCostDescription")} />
<SectionTitle
id="session-cost"
title={i18n.t("home.sessionCostTitle")}
description={i18n.t("home.sessionCostDescription")}
/>
<Show
when={visible().length > 0}
fallback={
@@ -0,0 +1,24 @@
export function SectionHeading(props: {
href: string
title: string
description: string
as?: "h2" | "p"
slot?: string
}) {
const content = (
<>
<strong>
<a data-slot="heading-link" href={props.href}>
<span data-slot="heading-anchor" aria-hidden="true">
#
</span>
{props.title}.
</a>
</strong>{" "}
<span>{props.description}</span>
</>
)
if (props.as === "h2") return <h2 data-slot={props.slot ?? "section-title"}>{content}</h2>
return <p data-slot={props.slot ?? "section-title"}>{content}</p>
}
+2 -1
View File
@@ -64,15 +64,16 @@
"generate:v2-oc2": "bun run script/build-oc2-v2-overrides.ts"
},
"devDependencies": {
"@solidjs/meta": "catalog:",
"@tailwindcss/vite": "catalog:",
"@tsconfig/node22": "catalog:",
"@types/bun": "catalog:",
"@types/katex": "0.16.7",
"@types/luxon": "catalog:",
"@typescript/native-preview": "catalog:",
"@solidjs/meta": "catalog:",
"solid-js": "catalog:",
"tailwindcss": "catalog:",
"tw-animate-css": "1.4.0",
"typescript": "catalog:",
"vite": "catalog:",
"vite-plugin-icons-spritesheet": "3.0.1",
+7
View File
@@ -1,5 +1,12 @@
# V2 Schema Changelog
## 2026-06-26: Add Finite Session History
- Add `GET /api/session/:sessionID/history` and generated Promise, Effect, and legacy JavaScript client methods.
- Page public durable Session events after an optional exclusive aggregate sequence, with an explicit `hasMore` exhaustion signal.
- Keep aggregate gaps legal, cap pages at 100 events, and preserve the existing durable replay-and-tail `sessions.events()` stream unchanged.
- Add no migration or durable-event version; this is a finite read API over the existing event manifest.
## 2026-06-22: Simplify Session Input Promotion
- Keep `session.next.prompt.admitted.1` as the durable, client-visible record of pending Session input.
+5 -1
View File
@@ -176,6 +176,10 @@ The synchronized `session.next.*` event family and projected Session-message mod
The first `sessions.events(...)` contract is durable-only during both replay and live tailing. This keeps one cursor equal to one persisted aggregate sequence and is sufficient for reconnect-safe consumers. A later UI-facing API may optionally interleave live-only deltas while connected, but those fragments must remain explicitly ephemeral: they cannot advance the durable cursor, replay after reconnect, or be mistaken for publication boundaries.
`sessions.history({ sessionID, after?, limit? })` is the finite counterpart for request/response consumers. `after` is an exclusive aggregate sequence, and omission starts before sequence zero. The response is `{ data, hasMore }`; callers derive the next `after` from the final event's durable sequence when `hasMore` is true. Public durable Session events are selected before pagination, which permits gaps from private or historical aggregate events while preserving strictly increasing unique sequences. The log has a moving head, so events committed between pages may appear on the next page.
The finite endpoint is `GET /api/session/:sessionID/history`, uses the normal Session Location and authorization middleware, defaults to 50 events, and accepts at most 100. It returns only events in the public durable Session schema. The existing `sessions.events()` replay-and-tail stream is unchanged.
Durable event tail wakeups are advisory and edge-triggered. Each active tail owns one sliding-capacity-1 dirty signal for its aggregate and re-queries SQLite after a wake. Repeated commits coalesce while the tail is busy because durable rows, not in-memory notifications, preserve every event and sequence. Subscribe and register the dirty signal before historical replay, then remove it when the tail closes, so replay handoff cannot miss a commit and inactive aggregates retain no wake state.
Event replay owner claims are separate from clustered Session execution ownership. The former already fences synchronized projection reconstruction; the latter still needs distributed active-run acquisition, stale-runtime rejection, interruption, and placement orchestration.
@@ -206,7 +210,7 @@ The first V2 `apply_patch` leaf supports add, update, and delete hunks. It parse
- Keep eager structured local-tool settlement: durably record each complete call, start its child execution immediately, await all started settlements after provider-turn consumption, persist every result, and reload history once before continuation.
- Buffer or coalesce streamed deltas before rewriting growing assistant projections.
- Revisit additional covering indexes as larger-history query shapes become concrete.
- Expose replayable Session events over HTTP and the generated SDK where remote consumers need them, deciding whether that public cursor should be opaque rather than the embedded API's branded aggregate sequence.
- Design any global multi-Session event stream separately; the finite history API deliberately reads one authorized Session aggregate and does not change global Event publication.
- Decide whether UI-facing Session subscriptions should optionally interleave ephemeral deltas while connected without advancing the durable cursor.
- Add provider-aware context control for provider-executed tool results. Generic text truncation cannot replace provider-native structured payloads that must round-trip exactly.