mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-13 04:59:58 -04:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d16a5226e2 | |||
| 2c02f8bace | |||
| 2ec20e576b | |||
| 20f47fec7a | |||
| 65210f2d97 | |||
| af0b7ffae7 | |||
| 0e2dd4ad15 | |||
| 11d2f3e5f8 | |||
| 1ac6b4bec4 | |||
| 0befd9b049 | |||
| 850a0dfe7c | |||
| 18a419e634 | |||
| 82f47cb312 | |||
| 7b1fe33ed3 |
@@ -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
@@ -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="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]) })
|
||||
|
||||
@@ -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>(
|
||||
{
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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)),
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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="
|
||||
|
||||
@@ -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& 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>& beta</span> <unknown>café</unknown> 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 & 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>&</script><p>tail &</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}`,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]>()),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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" })
|
||||
@@ -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"))
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"test": "bun test",
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"build": "bun ./script/build.ts"
|
||||
},
|
||||
|
||||
@@ -13,6 +13,37 @@ const opencode = path.resolve(dir, "../../opencode")
|
||||
|
||||
await $`bun dev generate > ${dir}/openapi.json`.cwd(opencode)
|
||||
|
||||
const document = (await Bun.file("./openapi.json").json()) as {
|
||||
components?: { schemas?: Record<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
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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) =>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -221,6 +221,7 @@ export const dict = {
|
||||
"model.noPeersDescription": "تظهر ترتيبات النماذج المشابهة بعد وصول الاستخدام.",
|
||||
"model.noUsageLastWeek": "لا يوجد استخدام الأسبوع الماضي",
|
||||
"model.newThisWeek": "جديد هذا الأسبوع",
|
||||
"model.sameAsPreviousWeek": "دون تغيير عن الأسبوع السابق",
|
||||
"model.vsPreviousWeek": "{{change}} مقارنة بالأسبوع السابق",
|
||||
"model.pdf": "PDF",
|
||||
"format.users": "مستخدمون",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -224,6 +224,7 @@ export const dict = {
|
||||
"model.noPeersDescription": "使用量が届くと類似モデルのランキングが表示されます。",
|
||||
"model.noUsageLastWeek": "先週の使用量なし",
|
||||
"model.newThisWeek": "今週新規",
|
||||
"model.sameAsPreviousWeek": "前週と同じ",
|
||||
"model.vsPreviousWeek": "前週比 {{change}}",
|
||||
"model.pdf": "PDF",
|
||||
"format.users": "ユーザー",
|
||||
|
||||
@@ -224,6 +224,7 @@ export const dict = {
|
||||
"model.noPeersDescription": "사용량이 들어오면 비슷한 모델 순위가 표시됩니다.",
|
||||
"model.noUsageLastWeek": "지난주 사용량 없음",
|
||||
"model.newThisWeek": "이번 주 신규",
|
||||
"model.sameAsPreviousWeek": "지난주와 동일",
|
||||
"model.vsPreviousWeek": "지난주 대비 {{change}}",
|
||||
"model.pdf": "PDF",
|
||||
"format.users": "사용자",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -223,6 +223,7 @@ export const dict = {
|
||||
"model.noPeersDescription": "Рейтинги похожих моделей появятся после использования.",
|
||||
"model.noUsageLastWeek": "Нет использования на прошлой неделе",
|
||||
"model.newThisWeek": "Новая на этой неделе",
|
||||
"model.sameAsPreviousWeek": "Без изменений к предыдущей неделе",
|
||||
"model.vsPreviousWeek": "{{change}} к предыдущей неделе",
|
||||
"model.pdf": "PDF",
|
||||
"format.users": "пользователи",
|
||||
|
||||
@@ -223,6 +223,7 @@ export const dict = {
|
||||
"model.noPeersDescription": "อันดับโมเดลใกล้เคียงจะแสดงหลังจากมีการใช้งานเข้ามา",
|
||||
"model.noUsageLastWeek": "ไม่มีการใช้งานเมื่อสัปดาห์ที่แล้ว",
|
||||
"model.newThisWeek": "ใหม่ในสัปดาห์นี้",
|
||||
"model.sameAsPreviousWeek": "เท่าเดิมจากสัปดาห์ก่อน",
|
||||
"model.vsPreviousWeek": "{{change}} เทียบกับสัปดาห์ก่อน",
|
||||
"model.pdf": "PDF",
|
||||
"format.users": "ผู้ใช้",
|
||||
|
||||
@@ -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ı",
|
||||
|
||||
@@ -223,6 +223,7 @@ export const dict = {
|
||||
"model.noPeersDescription": "Рейтинги схожих моделей з'являться після використання.",
|
||||
"model.noUsageLastWeek": "Немає використання минулого тижня",
|
||||
"model.newThisWeek": "Нова цього тижня",
|
||||
"model.sameAsPreviousWeek": "Без змін відносно попереднього тижня",
|
||||
"model.vsPreviousWeek": "{{change}} до попереднього тижня",
|
||||
"model.pdf": "PDF",
|
||||
"format.users": "користувачі",
|
||||
|
||||
@@ -222,6 +222,7 @@ export const dict = {
|
||||
"model.noPeersDescription": "使用量到达后会显示同类模型排名。",
|
||||
"model.noUsageLastWeek": "上周无使用量",
|
||||
"model.newThisWeek": "本周新增",
|
||||
"model.sameAsPreviousWeek": "与上周相同",
|
||||
"model.vsPreviousWeek": "较上周 {{change}}",
|
||||
"model.pdf": "PDF",
|
||||
"format.users": "用户",
|
||||
|
||||
@@ -222,6 +222,7 @@ export const dict = {
|
||||
"model.noPeersDescription": "使用量到達後會顯示同類模型排名。",
|
||||
"model.noUsageLastWeek": "上週無使用量",
|
||||
"model.newThisWeek": "本週新增",
|
||||
"model.sameAsPreviousWeek": "與上週相同",
|
||||
"model.vsPreviousWeek": "較上週 {{change}}",
|
||||
"model.pdf": "PDF",
|
||||
"format.users": "使用者",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)} />}
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user