mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-10 19:06:24 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71d7a84685 |
@@ -1,74 +0,0 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { sessionID, setupTimeline, userMessage } from "../performance/timeline-stability/fixture"
|
||||
|
||||
test("keeps a submitted prompt in place while its optimistic rows are measured", async ({ page }) => {
|
||||
await setupTimeline(page, { messages: [userMessage()], seedHistory: true })
|
||||
const release = Promise.withResolvers<void>()
|
||||
await page.route(`**/api/session/${sessionID}/prompt`, async (route) => {
|
||||
if (route.request().method() !== "POST") return route.fallback()
|
||||
await release.promise
|
||||
return route.fallback()
|
||||
})
|
||||
|
||||
const editor = page.locator('[data-component="composer"]').getByRole("textbox")
|
||||
await expect(editor).toBeEditable()
|
||||
await editor.fill("Observe optimistic prompt spacing.")
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.locator("[data-timeline-virtual-content]").evaluate((element) => {
|
||||
const root = element.parentElement!
|
||||
return root.scrollHeight - root.clientHeight - root.scrollTop
|
||||
}),
|
||||
)
|
||||
.toBe(0)
|
||||
|
||||
const observation = await page.evaluateHandle(() => {
|
||||
const frames: { prompt?: number; working: boolean }[] = []
|
||||
let frame = 0
|
||||
const sample = () => {
|
||||
const prompt = [...document.querySelectorAll<HTMLElement>('[data-timeline-row="UserMessage"]')].find((row) =>
|
||||
row.textContent?.includes("Observe optimistic prompt spacing."),
|
||||
)
|
||||
frames.push({
|
||||
...(prompt ? { prompt: prompt.getBoundingClientRect().y } : {}),
|
||||
working: !!document.querySelector('[data-component="session-working"]'),
|
||||
})
|
||||
frame = requestAnimationFrame(sample)
|
||||
}
|
||||
frame = requestAnimationFrame(sample)
|
||||
return {
|
||||
stop: () => {
|
||||
cancelAnimationFrame(frame)
|
||||
return frames
|
||||
},
|
||||
}
|
||||
})
|
||||
const requested = page.waitForRequest(
|
||||
(request) => request.method() === "POST" && new URL(request.url()).pathname === `/api/session/${sessionID}/prompt`,
|
||||
)
|
||||
try {
|
||||
await editor.press("Enter")
|
||||
await requested
|
||||
const prompt = page
|
||||
.locator('[data-timeline-row="UserMessage"]')
|
||||
.filter({ hasText: "Observe optimistic prompt spacing." })
|
||||
await expect(prompt).toBeInViewport()
|
||||
await expect(page.locator('[data-component="session-working"]')).toBeVisible()
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.locator("[data-timeline-virtual-content]").evaluate((element) => {
|
||||
const root = element.parentElement!
|
||||
return root.scrollHeight - root.clientHeight - root.scrollTop
|
||||
}),
|
||||
)
|
||||
.toBe(0)
|
||||
const frames = await observation.evaluate((value) => value.stop())
|
||||
expect(frames.some((frame) => frame.working && frame.prompt === undefined)).toBe(false)
|
||||
const positions = frames.flatMap((frame) => (frame.prompt === undefined ? [] : [frame.prompt]))
|
||||
expect(positions.length).toBeGreaterThan(0)
|
||||
expect(new Set(positions).size).toBe(1)
|
||||
} finally {
|
||||
release.resolve()
|
||||
await observation.dispose()
|
||||
}
|
||||
})
|
||||
@@ -122,15 +122,7 @@ test("renders compaction progress, summary, and outcome in order", async ({ page
|
||||
)
|
||||
await expect(compaction.getByRole("heading", { name: "Checkpoint" })).toBeVisible()
|
||||
await expect(compaction).toContainText("Streamed implementation details.")
|
||||
const running = compaction.getByRole("status").getByLabel("Compacting", { exact: true })
|
||||
await expect(running).toBeVisible()
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const summary = await compaction.locator('[data-component="text-part"]').boundingBox()
|
||||
const status = await running.boundingBox()
|
||||
return !!summary && !!status && status.y >= summary.y + summary.height
|
||||
})
|
||||
.toBe(true)
|
||||
await expect(compaction.getByRole("status").getByLabel("Compacting", { exact: true })).toBeVisible()
|
||||
await expect(compaction.getByText("Session compacted", { exact: true })).toHaveCount(0)
|
||||
|
||||
await timeline.send(
|
||||
|
||||
@@ -88,12 +88,8 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
|
||||
if (value.mode === "normal" && !command) {
|
||||
session.handoff?.set(handoffMessage(value))
|
||||
const optimisticBusy = !input.adapter.working()
|
||||
if (optimisticBusy && input.adapter.kind === "new-session")
|
||||
session.data.session.setStatus(session.id, "running")
|
||||
const sending = sendPrompt(session, value, input.adapter.controls().model.selection.trackSessionCommit, () => {
|
||||
if (optimisticBusy && input.adapter.kind === "active-session")
|
||||
session.data.session.setStatus(session.id, "running")
|
||||
}).then(
|
||||
if (optimisticBusy) session.data.session.setStatus(session.id, "running")
|
||||
const sending = sendPrompt(session, value, input.adapter.controls().model.selection.trackSessionCommit).then(
|
||||
() => ({ ok: true as const }),
|
||||
(error) => ({ ok: false as const, error }),
|
||||
)
|
||||
@@ -363,8 +359,7 @@ async function applySelection(
|
||||
async function sendPrompt(
|
||||
session: ComposerSession,
|
||||
value: ComposerSubmission,
|
||||
track: ModelSelection["trackSessionCommit"] | undefined,
|
||||
onAdmit: () => void,
|
||||
track?: ModelSelection["trackSessionCommit"],
|
||||
) {
|
||||
const request = await buildSubmissionRequest(session, value)
|
||||
// Switching agent or model reconfigures the session immediately, and with it
|
||||
@@ -394,9 +389,7 @@ async function sendPrompt(
|
||||
},
|
||||
},
|
||||
}
|
||||
const sending = session.data.session.prompt(admission).catch(() => session.data.session.prompt(admission))
|
||||
onAdmit()
|
||||
await sending
|
||||
await session.data.session.prompt(admission).catch(() => session.data.session.prompt(admission))
|
||||
}
|
||||
|
||||
async function buildSubmissionRequest(session: ComposerSession, value: ComposerSubmission) {
|
||||
|
||||
@@ -515,19 +515,6 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
<div
|
||||
ref={(value) => {
|
||||
element = value
|
||||
if (row()._tag !== "UserMessage" || !addedKeys.has(rowProps.rowKey) || !input.pinned() || coldPending)
|
||||
return
|
||||
// The optimistic row can paint before ResizeObserver corrects the tail estimates.
|
||||
// Measure the mounted tail and pin it in this render's microtask instead.
|
||||
queueMicrotask(() => {
|
||||
if (!input.pinned() || !virtualContent?.isConnected) return
|
||||
virtualizer.elementsCache.forEach((item) => {
|
||||
if (item.isConnected) virtualizer.resizeItem(virtualizer.indexFromElement(item), item.offsetHeight)
|
||||
})
|
||||
virtualizer.resizeItem(item().index, element.offsetHeight)
|
||||
virtualContent.style.height = `${virtualizer.getTotalSize()}px`
|
||||
virtualizer.scrollToEnd()
|
||||
})
|
||||
}}
|
||||
data-index={item().index}
|
||||
style={{ "min-height": ready() ? undefined : `${initialItem.size}px` }}
|
||||
|
||||
@@ -17,7 +17,6 @@ import type { PromptInput } from "@opencode/schema/prompt-input"
|
||||
import type { AgentAttachment } from "@opencode/schema/prompt"
|
||||
import type { Skill } from "@opencode/schema/skill"
|
||||
import type { Event } from "@opencode/schema/event"
|
||||
import type { FileDiff } from "@opencode/schema/file-diff"
|
||||
import type { InstructionEntry } from "@opencode/schema/instruction-entry"
|
||||
import type { Schema } from "effect"
|
||||
import type { EventLog } from "@opencode/schema/event-log"
|
||||
@@ -37,6 +36,7 @@ import type { PtyTicket } from "@opencode/schema/pty-ticket"
|
||||
import type { Reference } from "@opencode/schema/reference"
|
||||
import type { Worktree } from "@opencode/schema/worktree"
|
||||
import type { Vcs } from "@opencode/schema/vcs"
|
||||
import type { FileDiff } from "@opencode/schema/file-diff"
|
||||
import type { WebSearch } from "@opencode/schema/websearch"
|
||||
import type { Config } from "@opencode/schema/config"
|
||||
|
||||
@@ -360,15 +360,6 @@ export type SessionContextInput = { readonly sessionID: Session.ID }
|
||||
export type SessionContextOutput = ReadonlyArray<SessionMessage.Info>
|
||||
export type SessionContextOperation<E = never> = (input: SessionContextInput) => Effect.Effect<SessionContextOutput, E>
|
||||
|
||||
export type SessionDiffInput = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly messageID?: SessionMessage.ID | undefined
|
||||
readonly to?: SessionMessage.ID | undefined
|
||||
readonly context?: number | undefined
|
||||
}
|
||||
export type SessionDiffOutput = ReadonlyArray<FileDiff.Info>
|
||||
export type SessionDiffOperation<E = never> = (input: SessionDiffInput) => Effect.Effect<SessionDiffOutput, E>
|
||||
|
||||
export type SessionInboxListInput = { readonly sessionID: Session.ID }
|
||||
export type SessionInboxListOutput = ReadonlyArray<SessionInbox.Info>
|
||||
export type SessionInboxListOperation<E = never> = (
|
||||
@@ -1148,7 +1139,6 @@ export interface SessionApi<E = never> {
|
||||
readonly commit: SessionRevertCommitOperation<E>
|
||||
}
|
||||
readonly context: SessionContextOperation<E>
|
||||
readonly diff: SessionDiffOperation<E>
|
||||
readonly inbox: {
|
||||
readonly list: SessionInboxListOperation<E>
|
||||
readonly cancel: SessionInboxCancelOperation<E>
|
||||
|
||||
@@ -68,8 +68,6 @@ import type {
|
||||
SessionRevertCommitOutput,
|
||||
SessionContextInput,
|
||||
SessionContextOutput,
|
||||
SessionDiffInput,
|
||||
SessionDiffOutput,
|
||||
SessionInboxListInput,
|
||||
SessionInboxListOutput,
|
||||
SessionInboxCancelInput,
|
||||
@@ -594,17 +592,6 @@ const EndpointSessionContext = (raw: RawClient["server.session"]) => (input: Ses
|
||||
),
|
||||
)
|
||||
|
||||
const EndpointSessionDiff = (raw: RawClient["server.session"]) => (input: SessionDiffInput) =>
|
||||
preserveEffect<SessionDiffOutput>()(
|
||||
raw["session.diff"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
query: { messageID: input["messageID"], to: input["to"], context: input["context"] },
|
||||
}).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const EndpointSessionInboxList = (raw: RawClient["server.session"]) => (input: SessionInboxListInput) =>
|
||||
preserveEffect<SessionInboxListOutput>()(
|
||||
raw["session.inbox.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||
@@ -744,7 +731,6 @@ const adaptGroupSession = (raw: RawClient["server.session"]) => ({
|
||||
commit: EndpointSessionRevertCommit(raw),
|
||||
},
|
||||
context: EndpointSessionContext(raw),
|
||||
diff: EndpointSessionDiff(raw),
|
||||
inbox: {
|
||||
list: EndpointSessionInboxList(raw),
|
||||
cancel: EndpointSessionInboxCancel(raw),
|
||||
|
||||
@@ -62,8 +62,6 @@ import type {
|
||||
SessionRevertCommitOutput,
|
||||
SessionContextInput,
|
||||
SessionContextOutput,
|
||||
SessionDiffInput,
|
||||
SessionDiffOutput,
|
||||
SessionInboxListInput,
|
||||
SessionInboxListOutput,
|
||||
SessionInboxCancelInput,
|
||||
@@ -844,18 +842,6 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
diff: (input: SessionDiffInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: SessionDiffOutput }>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/diff`,
|
||||
query: { messageID: input["messageID"], to: input["to"], context: input["context"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 401, 404, 500],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
inbox: {
|
||||
list: (input: SessionInboxListInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: SessionInboxListOutput }>(
|
||||
|
||||
@@ -147,14 +147,6 @@ export type SessionProviderContextProvenance = {
|
||||
endpoint: string
|
||||
}
|
||||
|
||||
export type SessionMessageIdle = {
|
||||
id: string
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
time: { created: number }
|
||||
type: "idle"
|
||||
outcome: "succeeded" | "failed" | "interrupted"
|
||||
}
|
||||
|
||||
export type SessionActive = { type: "running" }
|
||||
|
||||
export type SessionInboxDelivery = "steer" | "queue"
|
||||
@@ -2202,7 +2194,6 @@ export type SessionMessageInfo =
|
||||
| SessionMessageShell
|
||||
| SessionMessageAssistant
|
||||
| SessionMessageCompaction
|
||||
| SessionMessageIdle
|
||||
|
||||
export type SessionMessageContentUpdated = {
|
||||
id: string
|
||||
@@ -3161,13 +3152,6 @@ export type SessionImportInput = {
|
||||
}
|
||||
}
|
||||
)
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly time: { readonly created: number }
|
||||
readonly type: "idle"
|
||||
readonly outcome: "succeeded" | "failed" | "interrupted"
|
||||
}
|
||||
>
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
|
||||
}["info"]
|
||||
@@ -3473,13 +3457,6 @@ export type SessionImportInput = {
|
||||
}
|
||||
}
|
||||
)
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly time: { readonly created: number }
|
||||
readonly type: "idle"
|
||||
readonly outcome: "succeeded" | "failed" | "interrupted"
|
||||
}
|
||||
>
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
|
||||
}["messages"]
|
||||
@@ -3785,13 +3762,6 @@ export type SessionImportInput = {
|
||||
}
|
||||
}
|
||||
)
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly time: { readonly created: number }
|
||||
readonly type: "idle"
|
||||
readonly outcome: "succeeded" | "failed" | "interrupted"
|
||||
}
|
||||
>
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
|
||||
}["location"]
|
||||
@@ -4281,27 +4251,6 @@ export type SessionContextInput = { readonly sessionID: { readonly sessionID: st
|
||||
|
||||
export type SessionContextOutput = { data: Array<SessionMessageInfo> }["data"]
|
||||
|
||||
export type SessionDiffInput = {
|
||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||
readonly messageID?: {
|
||||
readonly messageID?: string | undefined
|
||||
readonly to?: string | undefined
|
||||
readonly context?: number | undefined
|
||||
}["messageID"]
|
||||
readonly to?: {
|
||||
readonly messageID?: string | undefined
|
||||
readonly to?: string | undefined
|
||||
readonly context?: number | undefined
|
||||
}["to"]
|
||||
readonly context?: {
|
||||
readonly messageID?: string | undefined
|
||||
readonly to?: string | undefined
|
||||
readonly context?: number | undefined
|
||||
}["context"]
|
||||
}
|
||||
|
||||
export type SessionDiffOutput = { data: Array<FileDiffInfo> }["data"]
|
||||
|
||||
export type SessionInboxListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
|
||||
|
||||
export type SessionInboxListOutput = { data: Array<SessionInboxInfo> }["data"]
|
||||
|
||||
@@ -1024,18 +1024,6 @@ export function createData(config: CreateDataInput) {
|
||||
if (currentAssistant) currentAssistant.retry = undefined
|
||||
})
|
||||
if (event.type === "session.execution.interrupted" && event.data.reason === "shutdown") return
|
||||
// Mirror the projected idle marker so turn boundaries match before the next message read.
|
||||
message.insert(event.data.sessionID, {
|
||||
id: messageIDFromEvent(event.id),
|
||||
type: "idle",
|
||||
outcome:
|
||||
event.type === "session.execution.succeeded"
|
||||
? "succeeded"
|
||||
: event.type === "session.execution.failed"
|
||||
? "failed"
|
||||
: "interrupted",
|
||||
time: { created: event.created },
|
||||
})
|
||||
// An event can overtake the first read; queue a revalidation when that read is still active.
|
||||
if (!store.session.info[event.data.sessionID] && !sync.has(`session:${event.data.sessionID}`)) return
|
||||
result.session.invalidate(event.data.sessionID)
|
||||
|
||||
@@ -58,7 +58,7 @@ ultimate source of truth.
|
||||
- [x] Predeclare `let` and `const` bindings in every lexical scope, including program/block bodies, switch bodies, and
|
||||
loop headers, so reads before initialization and self- or cross-referential initializers observe the JavaScript
|
||||
temporal dead zone.
|
||||
- [x] Function declarations are hoisted across all cases of a `switch`, like any other statement list.
|
||||
- [ ] Hoist function declarations accepted directly in switch cases.
|
||||
- [x] Computed object destructuring keys such as `const { [field]: value } = record`.
|
||||
- [x] Object destructuring from arrays, such as `const { length } = values`.
|
||||
- [x] Array binding and assignment destructuring from strings, Maps, Sets, URLSearchParams, custom synchronous
|
||||
@@ -364,14 +364,6 @@ ultimate source of truth.
|
||||
`entries`, `toString`, and `size`.
|
||||
- [x] URL values serialize to their href; URLSearchParams serialize to `{}`.
|
||||
|
||||
## Web platform helpers
|
||||
|
||||
- [x] `atob` and `btoa` with forgiving-base64 decoding and WebIDL string conversion; invalid input throws an Error
|
||||
named `InvalidCharacterError`, since there is no `DOMException`.
|
||||
- [x] `crypto.randomUUID()`.
|
||||
- [ ] `crypto.getRandomValues` and `crypto.subtle`, `TextEncoder`/`TextDecoder`, and `Blob`: these need a binary
|
||||
value type, which the JSON-like data model does not have yet.
|
||||
|
||||
## Errors and diagnostics
|
||||
|
||||
- [x] `Error`, `TypeError`, `RangeError`, `SyntaxError`, `ReferenceError`, `EvalError`, and `URIError`, callable with
|
||||
@@ -388,6 +380,6 @@ ultimate source of truth.
|
||||
shift them. The diagnostic names the rejected node type and attaches a short orientation to the supported
|
||||
subset; this matrix is the full reference.
|
||||
- [x] Model-visible host failure messages and underlying causes, including output-validation errors.
|
||||
- [x] Caught errors do not distinguish user throws, interpreter failures, and tool failures; a program sees one
|
||||
Error-shaped value with `name` and `message` in `catch`, rejection handlers, and `Promise.allSettled` reasons.
|
||||
This is deliberate: the program should handle a failure the same way regardless of where it originated.
|
||||
- [ ] Distinguish user-thrown failures from interpreter defects and explicit tool refusals from internal tool
|
||||
failures; preserve those categories in caught errors, promise rejection handlers, and `Promise.allSettled`
|
||||
reasons.
|
||||
|
||||
@@ -11,7 +11,6 @@ import { regexpGlobal } from "../stdlib/regexp.js"
|
||||
import { stringGlobal } from "../stdlib/string.js"
|
||||
import { uriGlobal, urlGlobal, urlSearchParamsGlobal } from "../stdlib/url.js"
|
||||
import { coercion, errorConstructors } from "../stdlib/value.js"
|
||||
import { atobGlobal, btoaGlobal, cryptoGlobal } from "../stdlib/web.js"
|
||||
import { ToolReference } from "../tool-runtime.js"
|
||||
import { errorGlobal } from "./errors.js"
|
||||
import { HostFunction } from "./host.js"
|
||||
@@ -72,8 +71,5 @@ export const globals = <R>(host: Host<R>): ReadonlyArray<readonly [string, unkno
|
||||
["encodeURIComponent", uriGlobal("encodeURIComponent")],
|
||||
["decodeURI", uriGlobal("decodeURI")],
|
||||
["decodeURIComponent", uriGlobal("decodeURIComponent")],
|
||||
["atob", atobGlobal],
|
||||
["btoa", btoaGlobal],
|
||||
["crypto", cryptoGlobal],
|
||||
...[...errorConstructors].map((name) => [name, errorGlobal(name, host.runner)] as const),
|
||||
]
|
||||
|
||||
@@ -171,8 +171,6 @@ const collectPatternNames = (pattern: Pattern, out: Array<string> = []): Array<s
|
||||
}
|
||||
|
||||
// `var` names declared anywhere in a function body except inside nested functions, which own theirs.
|
||||
// Memoized per body: a function's var names never change, and hoisting runs on every call.
|
||||
const varNames = new WeakMap<ReadonlyArray<Statement | ModuleDeclaration>, ReadonlyArray<string>>()
|
||||
const collectVarNames = (
|
||||
node: Statement | ModuleDeclaration | null | undefined,
|
||||
out: Array<string> = [],
|
||||
@@ -448,14 +446,12 @@ class Frame<R> {
|
||||
// Hoisted `var` bindings start undefined, or copy a same-named parameter. Function bodies hoist
|
||||
// into their own scope above the parameters so closures in parameter defaults keep seeing outer names.
|
||||
private hoistVars(statements: ReadonlyArray<Statement | ModuleDeclaration>, parameters?: Map<string, Binding>): void {
|
||||
const names =
|
||||
varNames.get(statements) ??
|
||||
statements.reduce<Array<string>>((out, statement) => collectVarNames(statement, out), [])
|
||||
varNames.set(statements, names)
|
||||
const scope = this.scopes.current()
|
||||
for (const name of names) {
|
||||
if (scope.has(name)) continue
|
||||
scope.set(name, { mutable: true, value: parameters?.get(name)?.value, initialized: true })
|
||||
for (const statement of statements) {
|
||||
for (const name of collectVarNames(statement)) {
|
||||
if (scope.has(name)) continue
|
||||
scope.set(name, { mutable: true, value: parameters?.get(name)?.value, initialized: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -496,9 +492,7 @@ class Frame<R> {
|
||||
self.scopes.push()
|
||||
return yield* Effect.gen(function* () {
|
||||
const cases = node.cases
|
||||
const statements = cases.flatMap((branch) => branch.consequent)
|
||||
self.predeclareLexical(statements)
|
||||
self.hoistFunctions(statements)
|
||||
self.predeclareLexical(cases.flatMap((branch) => branch.consequent))
|
||||
let defaultIndex: number | undefined
|
||||
let selected: number | undefined
|
||||
for (const [index, branch] of cases.entries()) {
|
||||
@@ -1655,8 +1649,16 @@ class Frame<R> {
|
||||
})
|
||||
if (fn.generator) return Effect.succeed(this.createGenerator(invocation, run, fn.async))
|
||||
if (!fn.async) return run
|
||||
return this.runtime.promises.createWithSelf((self) =>
|
||||
Effect.flatMap(run, (value) => resolvePromiseValue(invocation.runtime.runner, value, fn.body, self)),
|
||||
// The initial yield assigns the promise before the body can self-resolve.
|
||||
const box: { promise?: Values.Promise } = {}
|
||||
return Effect.map(
|
||||
this.createPromise(
|
||||
Effect.flatMap(run, (value) => resolvePromiseValue(invocation.runtime.runner, value, fn.body, box)),
|
||||
),
|
||||
(promise) => {
|
||||
box.promise = promise
|
||||
return promise
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { HostNamespace, sync } from "../interpreter/host.js"
|
||||
import { InterpreterRuntimeError } from "../interpreter/model.js"
|
||||
import { coerceToString } from "./value.js"
|
||||
|
||||
// WebIDL DOMString conversion: a missing argument is a TypeError, anything else stringifies.
|
||||
const base64 = (name: "atob" | "btoa") =>
|
||||
sync(name, (args, node) => {
|
||||
if (args.length === 0) {
|
||||
throw new InterpreterRuntimeError(`${name} requires 1 argument, but only 0 were provided.`, node).as("TypeError")
|
||||
}
|
||||
const input = coerceToString(args[0])
|
||||
try {
|
||||
return name === "atob" ? atob(input) : btoa(input)
|
||||
} catch {
|
||||
throw new InterpreterRuntimeError("The string contains invalid characters.", node).as("InvalidCharacterError")
|
||||
}
|
||||
})
|
||||
|
||||
export const atobGlobal = base64("atob")
|
||||
export const btoaGlobal = base64("btoa")
|
||||
|
||||
export const cryptoGlobal = new HostNamespace("crypto", {
|
||||
randomUUID: sync("crypto.randomUUID", () => crypto.randomUUID()),
|
||||
})
|
||||
@@ -1,11 +0,0 @@
|
||||
# The 3-Clause BSD License
|
||||
|
||||
Copyright © web-platform-tests contributors
|
||||
|
||||
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
|
||||
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
|
||||
3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
-82
@@ -1,82 +0,0 @@
|
||||
[
|
||||
["", []],
|
||||
["abcd", [105, 183, 29]],
|
||||
[" abcd", [105, 183, 29]],
|
||||
["abcd ", [105, 183, 29]],
|
||||
[" abcd===", null],
|
||||
["abcd=== ", null],
|
||||
["abcd ===", null],
|
||||
["a", null],
|
||||
["ab", [105]],
|
||||
["abc", [105, 183]],
|
||||
["abcde", null],
|
||||
["𐀀", null],
|
||||
["=", null],
|
||||
["==", null],
|
||||
["===", null],
|
||||
["====", null],
|
||||
["=====", null],
|
||||
["a=", null],
|
||||
["a==", null],
|
||||
["a===", null],
|
||||
["a====", null],
|
||||
["a=====", null],
|
||||
["ab=", null],
|
||||
["ab==", [105]],
|
||||
["ab===", null],
|
||||
["ab====", null],
|
||||
["ab=====", null],
|
||||
["abc=", [105, 183]],
|
||||
["abc==", null],
|
||||
["abc===", null],
|
||||
["abc====", null],
|
||||
["abc=====", null],
|
||||
["abcd=", null],
|
||||
["abcd==", null],
|
||||
["abcd===", null],
|
||||
["abcd====", null],
|
||||
["abcd=====", null],
|
||||
["abcde=", null],
|
||||
["abcde==", null],
|
||||
["abcde===", null],
|
||||
["abcde====", null],
|
||||
["abcde=====", null],
|
||||
["=a", null],
|
||||
["=a=", null],
|
||||
["a=b", null],
|
||||
["a=b=", null],
|
||||
["ab=c", null],
|
||||
["ab=c=", null],
|
||||
["abc=d", null],
|
||||
["abc=d=", null],
|
||||
["ab\u000Bcd", null],
|
||||
["ab\u3000cd", null],
|
||||
["ab\u3001cd", null],
|
||||
["ab\tcd", [105, 183, 29]],
|
||||
["ab\ncd", [105, 183, 29]],
|
||||
["ab\fcd", [105, 183, 29]],
|
||||
["ab\rcd", [105, 183, 29]],
|
||||
["ab cd", [105, 183, 29]],
|
||||
["ab\u00a0cd", null],
|
||||
["ab\t\n\f\r cd", [105, 183, 29]],
|
||||
[" \t\n\f\r ab\t\n\f\r cd\t\n\f\r ", [105, 183, 29]],
|
||||
["ab\t\n\f\r =\t\n\f\r =\t\n\f\r ", [105]],
|
||||
["A", null],
|
||||
["/A", [252]],
|
||||
["//A", [255, 240]],
|
||||
["///A", [255, 255, 192]],
|
||||
["////A", null],
|
||||
["/", null],
|
||||
["A/", [3]],
|
||||
["AA/", [0, 15]],
|
||||
["AAAA/", null],
|
||||
["AAA/", [0, 0, 63]],
|
||||
["\u0000nonsense", null],
|
||||
["abcd\u0000nonsense", null],
|
||||
["YQ", [97]],
|
||||
["YR", [97]],
|
||||
["~~", null],
|
||||
["..", null],
|
||||
["--", null],
|
||||
["__", null]
|
||||
]
|
||||
@@ -233,10 +233,3 @@ describe("var semantics beyond Test262", () => {
|
||||
expect(await value(`function* gen() { var t = 1; yield t; var t = 2; yield t } return [...gen()]`)).toEqual([1, 2])
|
||||
})
|
||||
})
|
||||
|
||||
describe("switch case function hoisting", () => {
|
||||
test("function declarations are visible across all cases before their statement runs", async () => {
|
||||
expect(await value(`switch (1) { case 1: return foo(); function foo() { return "hoisted" } }`)).toBe("hoisted")
|
||||
expect(await value(`switch (2) { case 1: function foo() { return "a" } break; case 2: return foo() }`)).toBe("a")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
/**
|
||||
* Portions adapted from web-platform-tests at revision 863077959ca8c1a7ceecfbe2534b75d2527b9013:
|
||||
* - html/webappapis/atob/base64.any.js (btoa reference encoder, input list, and atob WebIDL cases)
|
||||
* - fetch/data-urls/resources/base64.json (copied to fixtures/wpt-base64.json)
|
||||
* - WebCryptoAPI/randomUUID.https.any.js
|
||||
*
|
||||
* Copyright © web-platform-tests contributors. Governed by the 3-Clause BSD license in LICENSE.wpt.
|
||||
*
|
||||
* `assert_throws_dom("InvalidCharacterError", …)` becomes a check on `error.name`: CodeMode has no
|
||||
* DOMException, so the name is carried on a plain Error.
|
||||
*/
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { CodeMode } from "../src/index.js"
|
||||
|
||||
const base64Cases = (await Bun.file(new URL("./fixtures/wpt-base64.json", import.meta.url)).json()) as Array<
|
||||
[string, Array<number> | null]
|
||||
>
|
||||
|
||||
const value = async (code: string) => {
|
||||
const result = await Effect.runPromise(CodeMode.execute({ code, tools: {} }))
|
||||
if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`)
|
||||
return result.value
|
||||
}
|
||||
|
||||
// The reference encoder from base64.any.js, run inside the interpreter so btoa is checked against
|
||||
// an independent implementation rather than against the host's btoa.
|
||||
const referenceEncoder = `
|
||||
function btoaLookup(idx) {
|
||||
if (idx < 26) return String.fromCharCode(idx + "A".charCodeAt(0))
|
||||
if (idx < 52) return String.fromCharCode(idx - 26 + "a".charCodeAt(0))
|
||||
if (idx < 62) return String.fromCharCode(idx - 52 + "0".charCodeAt(0))
|
||||
if (idx == 62) return "+"
|
||||
if (idx == 63) return "/"
|
||||
}
|
||||
function mybtoa(s) {
|
||||
s = String(s)
|
||||
for (var i = 0; i < s.length; i++) if (s.charCodeAt(i) > 255) return "INVALID_CHARACTER_ERR"
|
||||
var out = ""
|
||||
for (var i = 0; i < s.length; i += 3) {
|
||||
var groupsOfSix = [undefined, undefined, undefined, undefined]
|
||||
groupsOfSix[0] = s.charCodeAt(i) >> 2
|
||||
groupsOfSix[1] = (s.charCodeAt(i) & 0x03) << 4
|
||||
if (s.length > i + 1) {
|
||||
groupsOfSix[1] |= s.charCodeAt(i + 1) >> 4
|
||||
groupsOfSix[2] = (s.charCodeAt(i + 1) & 0x0f) << 2
|
||||
}
|
||||
if (s.length > i + 2) {
|
||||
groupsOfSix[2] |= s.charCodeAt(i + 2) >> 6
|
||||
groupsOfSix[3] = s.charCodeAt(i + 2) & 0x3f
|
||||
}
|
||||
for (var j = 0; j < groupsOfSix.length; j++) {
|
||||
out += typeof groupsOfSix[j] == "undefined" ? "=" : btoaLookup(groupsOfSix[j])
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
function testBtoa(input) {
|
||||
var expected = mybtoa(input)
|
||||
if (expected === "INVALID_CHARACTER_ERR") {
|
||||
try { btoa(input) } catch (error) { return error.name === "InvalidCharacterError" ? "ok" : error.name }
|
||||
return "did not throw"
|
||||
}
|
||||
if (btoa(input) !== expected) return "btoa mismatch"
|
||||
if (atob(btoa(input)) !== String(input)) return "roundtrip mismatch"
|
||||
return "ok"
|
||||
}
|
||||
`
|
||||
|
||||
describe("btoa WPT parity (html/webappapis/atob/base64.any.js)", () => {
|
||||
test("every input encodes like the reference encoder and round-trips through atob", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
${referenceEncoder}
|
||||
var tests = ["עברית", "", "ab", "abc", "abcd", "abcde", "\\xff\\xff\\xc0", "\\0a", "a\\0b",
|
||||
undefined, null, 7, 12, 1.5, true, false, NaN, +Infinity, -Infinity, 0, -0]
|
||||
for (var i = 0; i < 258; i++) tests.push(String.fromCharCode(i))
|
||||
tests.push(String.fromCharCode(10000), String.fromCharCode(65534), String.fromCharCode(65535))
|
||||
tests.push(String.fromCharCode(0xd800, 0xdc00))
|
||||
var everything = ""
|
||||
for (var i = 0; i < 256; i++) everything += String.fromCharCode(i)
|
||||
tests.push(everything)
|
||||
return tests.map(testBtoa).filter((outcome) => outcome !== "ok")
|
||||
`),
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("atob WPT parity (fetch/data-urls/resources/base64.json)", () => {
|
||||
const idlCases: Array<[unknown, Array<number> | null]> = [
|
||||
[undefined, null],
|
||||
[null, [158, 233, 101]],
|
||||
[7, null],
|
||||
[12, [215]],
|
||||
[1.5, null],
|
||||
[true, [182, 187]],
|
||||
[false, null],
|
||||
[NaN, [53, 163]],
|
||||
[Infinity, [34, 119, 226, 158, 43, 114]],
|
||||
[-Infinity, null],
|
||||
[0, null],
|
||||
[-0, null],
|
||||
]
|
||||
|
||||
test(`${base64Cases.length} forgiving-base64 inputs decode to the expected bytes or throw InvalidCharacterError`, async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const cases = ${JSON.stringify(base64Cases)}
|
||||
return cases.flatMap(([input, output]) => {
|
||||
try {
|
||||
const result = atob(input)
|
||||
if (output === null) return [[input, "expected throw"]]
|
||||
const bytes = Array.from({ length: result.length }, (_, i) => result.charCodeAt(i))
|
||||
return JSON.stringify(bytes) === JSON.stringify(output) ? [] : [[input, bytes]]
|
||||
} catch (error) {
|
||||
return output === null && error.name === "InvalidCharacterError" ? [] : [[input, error.name]]
|
||||
}
|
||||
})
|
||||
`),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test("WebIDL argument conversion stringifies non-string inputs", async () => {
|
||||
const literal = (input: unknown) =>
|
||||
Object.is(input, -0)
|
||||
? "-0"
|
||||
: typeof input === "number" || input === undefined
|
||||
? String(input)
|
||||
: JSON.stringify(input)
|
||||
expect(
|
||||
await value(`
|
||||
const cases = [${idlCases.map(([input, output]) => `[${literal(input)}, ${JSON.stringify(output)}]`).join(",")}]
|
||||
return cases.flatMap(([input, output]) => {
|
||||
try {
|
||||
const result = atob(input)
|
||||
if (output === null) return [[String(input), "expected throw"]]
|
||||
// The source loop checks only the listed prefix of the decoded bytes.
|
||||
const bytes = output.map((_, i) => result.charCodeAt(i))
|
||||
return JSON.stringify(bytes) === JSON.stringify(output) ? [] : [[String(input), bytes]]
|
||||
} catch (error) {
|
||||
return output === null && error.name === "InvalidCharacterError" ? [] : [[String(input), error.name]]
|
||||
}
|
||||
})
|
||||
`),
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("crypto.randomUUID WPT parity (WebCryptoAPI/randomUUID.https.any.js)", () => {
|
||||
test("namespace format, version, and variant bits over 256 iterations without collision", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const uuids = new Set()
|
||||
const randomUUID = () => {
|
||||
const uuid = crypto.randomUUID()
|
||||
if (uuids.has(uuid)) throw new Error("uuid collision " + uuid)
|
||||
uuids.add(uuid)
|
||||
return uuid
|
||||
}
|
||||
const UUIDRegex = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/
|
||||
let format = true, version = true, variant = true
|
||||
for (let i = 0; i < 256; i++) format = format && UUIDRegex.test(randomUUID())
|
||||
for (let i = 0; i < 256; i++) version = version && (parseInt(randomUUID().split("-")[2].slice(0, 2), 16) & 0b11110000) === 0b01000000
|
||||
for (let i = 0; i < 256; i++) variant = variant && (parseInt(randomUUID().split("-")[3].slice(0, 2), 16) & 0b11000000) === 0b10000000
|
||||
return [format, version, variant, uuids.size]
|
||||
`),
|
||||
).toEqual([true, true, true, 768])
|
||||
})
|
||||
})
|
||||
+64
-75
@@ -9,7 +9,6 @@ import { AppProcess } from "@opencode/util/process"
|
||||
import { makeGlobalNode } from "@opencode/util/effect/app-node"
|
||||
import { File } from "./file.js"
|
||||
import { KeyedMutex } from "./effect/keyed-mutex.js"
|
||||
import { VcsPatch } from "./vcs/patch.js"
|
||||
|
||||
export class Repository extends Schema.Class<Repository>("Git.Repository")({
|
||||
worktree: AbsolutePath,
|
||||
@@ -309,7 +308,7 @@ const layer = Layer.effect(
|
||||
operationName: OperationError["operation"],
|
||||
repository: Repository,
|
||||
args: string[],
|
||||
options?: { stdin?: string; env?: Record<string, string>; maxOutputBytes?: number },
|
||||
options?: { stdin?: string; env?: Record<string, string> },
|
||||
) {
|
||||
const result = yield* proc
|
||||
.run(
|
||||
@@ -318,7 +317,7 @@ const layer = Layer.effect(
|
||||
env: options?.env,
|
||||
extendEnv: true,
|
||||
}),
|
||||
{ stdin: options?.stdin, maxOutputBytes: options?.maxOutputBytes },
|
||||
{ stdin: options?.stdin },
|
||||
)
|
||||
.pipe(
|
||||
Effect.mapError(
|
||||
@@ -332,8 +331,7 @@ const layer = Layer.effect(
|
||||
),
|
||||
)
|
||||
const text = result.stdout.toString("utf8")
|
||||
if (result.exitCode === 0)
|
||||
return { text, stderr: result.stderr.toString("utf8"), truncated: result.stdoutTruncated }
|
||||
if (result.exitCode === 0) return { text, stderr: result.stderr.toString("utf8") }
|
||||
return yield* new OperationError({
|
||||
operation: operationName,
|
||||
directory: repository.worktree,
|
||||
@@ -387,7 +385,9 @@ const layer = Layer.effect(
|
||||
maximumUntrackedFileBytes?: number
|
||||
}) {
|
||||
const list = (args: string[]) =>
|
||||
repositoryOperation("refresh", input.repository, args).pipe(Effect.map((result) => nuls(result.text)))
|
||||
repositoryOperation("refresh", input.repository, args).pipe(
|
||||
Effect.map((result) => result.text.split("\0").filter(Boolean)),
|
||||
)
|
||||
const [tracked, untracked] = yield* Effect.all(
|
||||
[
|
||||
list(["diff-files", "--name-only", "-z", "--", input.scope]),
|
||||
@@ -464,7 +464,13 @@ const layer = Layer.effect(
|
||||
directory: input.repository.worktree,
|
||||
message: result.stderr.toString("utf8").trim() || "Failed to check ignored paths",
|
||||
})
|
||||
return new Set(nuls(result.stdout.toString("utf8")).map((file) => RelativePath.make(file)))
|
||||
return new Set(
|
||||
result.stdout
|
||||
.toString("utf8")
|
||||
.split("\0")
|
||||
.filter(Boolean)
|
||||
.map((file) => RelativePath.make(file)),
|
||||
)
|
||||
})
|
||||
|
||||
const writeTree = Effect.fn("Git.tree.write")(function* (repository: Repository) {
|
||||
@@ -493,23 +499,19 @@ const layer = Layer.effect(
|
||||
to: TreeID
|
||||
}) {
|
||||
// Undo needs both paths of a rename, not only its destination.
|
||||
return nuls(
|
||||
(yield* repositoryOperation("list_files", input.repository, [
|
||||
"diff",
|
||||
"--name-only",
|
||||
"--no-renames",
|
||||
"-z",
|
||||
input.from,
|
||||
input.to,
|
||||
])).text,
|
||||
).map((file) => RelativePath.make(file))
|
||||
return (yield* repositoryOperation("list_files", input.repository, [
|
||||
"diff",
|
||||
"--name-only",
|
||||
"--no-renames",
|
||||
"-z",
|
||||
input.from,
|
||||
input.to,
|
||||
])).text
|
||||
.split("\0")
|
||||
.filter(Boolean)
|
||||
.map((file) => RelativePath.make(file))
|
||||
})
|
||||
|
||||
/**
|
||||
* Three batched invocations over the tree pair instead of three per file. An
|
||||
* explicit empty selection diffs nothing; an absent one diffs every changed path.
|
||||
* Patch output is capped like VCS diffs: files past the cap get an empty patch.
|
||||
*/
|
||||
const treeDiff = Effect.fn("Git.tree.diff")(function* (input: {
|
||||
repository: Repository
|
||||
from: TreeID
|
||||
@@ -517,57 +519,49 @@ const layer = Layer.effect(
|
||||
context?: number
|
||||
paths?: readonly RelativePath[]
|
||||
}) {
|
||||
if (input.paths?.length === 0) return []
|
||||
const args = ["--no-renames", input.from, input.to, "--", ...(input.paths ?? [])]
|
||||
// Patch headers have no -z form: unquoted paths keep chunksByFile matching non-ASCII names.
|
||||
const [names, numbers, patch] = yield* Effect.all(
|
||||
[
|
||||
repositoryOperation("diff", input.repository, ["diff", "--name-status", "-z", ...args]),
|
||||
repositoryOperation("diff", input.repository, ["diff", "--numstat", "-z", ...args]),
|
||||
repositoryOperation(
|
||||
const paths = input.paths ?? (yield* treeFiles(input))
|
||||
return yield* Effect.forEach(paths, (file) =>
|
||||
Effect.gen(function* () {
|
||||
const statusText = (yield* repositoryOperation("diff", input.repository, [
|
||||
"diff",
|
||||
input.repository,
|
||||
["-c", "core.quotepath=false", "diff", "--no-ext-diff", `--unified=${input.context ?? 3}`, ...args],
|
||||
{ maxOutputBytes: VcsPatch.MAX_TOTAL_PATCH_BYTES },
|
||||
),
|
||||
],
|
||||
{ concurrency: 3 },
|
||||
)
|
||||
const statuses = nuls(names.text)
|
||||
const files = statuses.flatMap((code, index) => {
|
||||
const file = statuses[index + 1]
|
||||
if (index % 2 !== 0 || !file) return []
|
||||
return [
|
||||
{
|
||||
file: RelativePath.make(file),
|
||||
status: code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified",
|
||||
} as const,
|
||||
]
|
||||
})
|
||||
const stats = new Map(
|
||||
nuls(numbers.text).flatMap((line) => {
|
||||
const [additions, deletions, ...file] = line.split("\t")
|
||||
if (!additions || !deletions || file.length === 0) return []
|
||||
return [
|
||||
[
|
||||
file.join("\t"),
|
||||
additions === "-" || deletions === "-"
|
||||
? { binary: true, additions: 0, deletions: 0 }
|
||||
: { binary: false, additions: Number(additions), deletions: Number(deletions) },
|
||||
] as const,
|
||||
]
|
||||
"--name-status",
|
||||
"--no-renames",
|
||||
input.from,
|
||||
input.to,
|
||||
"--",
|
||||
file,
|
||||
])).text.trim()
|
||||
const status = statusText.startsWith("A") ? "added" : statusText.startsWith("D") ? "deleted" : "modified"
|
||||
const stats = (yield* repositoryOperation("diff", input.repository, [
|
||||
"diff",
|
||||
"--numstat",
|
||||
"--no-renames",
|
||||
input.from,
|
||||
input.to,
|
||||
"--",
|
||||
file,
|
||||
])).text.split("\t")
|
||||
const binary = stats[0] === "-" || stats[1] === "-"
|
||||
const patch = binary
|
||||
? ""
|
||||
: (yield* repositoryOperation("diff", input.repository, [
|
||||
"diff",
|
||||
`--unified=${input.context ?? 3}`,
|
||||
"--no-renames",
|
||||
input.from,
|
||||
input.to,
|
||||
"--",
|
||||
file,
|
||||
])).text
|
||||
return {
|
||||
file,
|
||||
status,
|
||||
additions: binary ? 0 : Number(stats[0] ?? 0),
|
||||
deletions: binary ? 0 : Number(stats[1] ?? 0),
|
||||
patch,
|
||||
} satisfies File.Diff
|
||||
}),
|
||||
)
|
||||
const patches = VcsPatch.chunksByFile(patch, (index) => files[index]?.file)
|
||||
return files.map((entry) => {
|
||||
const stat = stats.get(entry.file)
|
||||
return {
|
||||
...entry,
|
||||
additions: stat?.additions ?? 0,
|
||||
deletions: stat?.deletions ?? 0,
|
||||
patch: stat?.binary ? "" : (patches.get(entry.file) ?? VcsPatch.emptyPatch(entry.file)),
|
||||
} satisfies File.Diff
|
||||
})
|
||||
})
|
||||
|
||||
const hasEntry = Effect.fnUntraced(function* (repository: Repository, tree: TreeID, file: RelativePath) {
|
||||
@@ -739,11 +733,6 @@ function execute(cwd: string, proc: AppProcess.Interface, args: string[]) {
|
||||
)
|
||||
}
|
||||
|
||||
/** Split NUL-terminated git output into its records. */
|
||||
function nuls(text: string) {
|
||||
return text.split("\0").filter(Boolean)
|
||||
}
|
||||
|
||||
function resolvePath(cwd: string, value: string) {
|
||||
const trimmed = value.replace(/[\r\n]+$/, "")
|
||||
if (!trimmed) return cwd
|
||||
|
||||
@@ -178,29 +178,50 @@ export const AzurePlugin = define({
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
|
||||
// Entra bearer tokens are minted per request from the target URL's scope, so they are injected at the
|
||||
// transport hooks rather than stored as a credential.
|
||||
const bearer = Effect.fn(function* (url: string) {
|
||||
const connection = yield* ctx.integration.connection.active(Provider.ID.azure)
|
||||
const credential = connection
|
||||
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.orElseSucceed(() => undefined))
|
||||
: undefined
|
||||
if (credential?.type !== "oauth" || credential.methodID !== methodID) return
|
||||
const target = new URL(url)
|
||||
const scope =
|
||||
target.hostname.endsWith(".services.ai.azure.com") && !target.pathname.startsWith("/models")
|
||||
? foundryScope
|
||||
: cognitiveScope
|
||||
const current = yield* token(scope).pipe(Effect.orDie)
|
||||
return `Bearer ${current.access}`
|
||||
})
|
||||
yield* ctx.session.hook(
|
||||
"http.request",
|
||||
(evt) =>
|
||||
Effect.gen(function* () {
|
||||
if (evt.model.providerID !== Provider.ID.azure) return
|
||||
const connection = yield* ctx.integration.connection.active(Provider.ID.azure)
|
||||
const credential = connection
|
||||
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.orElseSucceed(() => undefined))
|
||||
: undefined
|
||||
if (credential?.type !== "oauth" || credential.methodID !== methodID) return
|
||||
const url = new URL(evt.request.url)
|
||||
const scope =
|
||||
url.hostname.endsWith(".services.ai.azure.com") && !url.pathname.startsWith("/models")
|
||||
? foundryScope
|
||||
: cognitiveScope
|
||||
const current = yield* token(scope).pipe(Effect.orDie)
|
||||
const authorization = yield* bearer(evt.request.url)
|
||||
if (!authorization) return
|
||||
evt.request.headers.delete("api-key")
|
||||
evt.request.headers.delete("x-api-key")
|
||||
evt.request.headers.set("authorization", `Bearer ${current.access}`)
|
||||
evt.request.headers.set("authorization", authorization)
|
||||
evt.request.headers.set("user-agent", App.useragent(ctx.app))
|
||||
}),
|
||||
{ providerID: Provider.ID.azure },
|
||||
)
|
||||
yield* ctx.session.hook(
|
||||
"experimental.ws.handshake",
|
||||
(evt) =>
|
||||
Effect.gen(function* () {
|
||||
if (evt.model.providerID !== Provider.ID.azure) return
|
||||
const authorization = yield* bearer(evt.url)
|
||||
if (!authorization) return
|
||||
delete evt.headers["api-key"]
|
||||
delete evt.headers["x-api-key"]
|
||||
evt.headers.authorization = authorization
|
||||
evt.headers["user-agent"] = App.useragent(ctx.app)
|
||||
}),
|
||||
{ providerID: Provider.ID.azure },
|
||||
)
|
||||
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
|
||||
@@ -54,11 +54,8 @@ import { SessionModelTransport } from "./session/model-transport.js"
|
||||
import { llmClient } from "./effect/app-node-platform.js"
|
||||
import { Snapshot } from "./snapshot.js"
|
||||
import { Session } from "./session/session.js"
|
||||
import { SessionDiff, TurnRangeError } from "./session/diff.js"
|
||||
import { LocationServiceMap } from "./location-service-map.js"
|
||||
import { FSUtil } from "@opencode/util/fs-util"
|
||||
import type { EventLog } from "@opencode/schema/event-log"
|
||||
import type { FileDiff } from "@opencode/schema/file-diff"
|
||||
import { Job } from "./job.js"
|
||||
import type { Command } from "./command.js"
|
||||
import { SessionEnvironment } from "./session/environment.js"
|
||||
@@ -110,7 +107,6 @@ export {
|
||||
type InboxItemRef = { readonly sessionID: SessionSchema.ID; readonly inboxID: SessionMessage.ID }
|
||||
|
||||
export { DestinationNotFoundError, DestinationNotDirectoryError, DestinationUnavailableError }
|
||||
export { TurnRangeError }
|
||||
|
||||
export interface Interface {
|
||||
readonly list: (input?: ListInput) => Effect.Effect<{
|
||||
@@ -137,13 +133,6 @@ export interface Interface {
|
||||
readonly context: (
|
||||
sessionID: SessionSchema.ID,
|
||||
) => Effect.Effect<SessionMessage.Info[], NotFoundError | MessageDecodeError>
|
||||
/** Structured diffs of the files changed by a turn or range of turns; see `SessionDiff.turn`. */
|
||||
readonly diff: (input: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly messageID?: SessionMessage.ID
|
||||
readonly to?: SessionMessage.ID
|
||||
readonly context?: number
|
||||
}) => Effect.Effect<readonly FileDiff.Info[], NotFoundError | MessageNotFoundError | TurnRangeError | Snapshot.Error>
|
||||
/**
|
||||
* Durable admitted session work not yet visible in projected history,
|
||||
* ordered by admission. Includes unpromoted user and synthetic inputs and
|
||||
@@ -232,7 +221,6 @@ const layer = Layer.effect(
|
||||
const moves = yield* SessionMove.Service
|
||||
const jobs = yield* Job.Service
|
||||
const environments = yield* SessionEnvironment.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const sessions = yield* Session.make()
|
||||
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
|
||||
|
||||
@@ -364,17 +352,6 @@ const layer = Layer.effect(
|
||||
yield* result.get(sessionID)
|
||||
return yield* store.context(sessionID)
|
||||
}),
|
||||
diff: Effect.fn("Session.diff")(function* (input) {
|
||||
const session = yield* result.get(input.sessionID)
|
||||
const active = yield* execution.isActive(input.sessionID)
|
||||
return yield* SessionDiff.turn(db, locations, {
|
||||
session,
|
||||
active,
|
||||
messageID: input.messageID,
|
||||
to: input.to,
|
||||
context: input.context,
|
||||
})
|
||||
}),
|
||||
inbox: (sessionID) => sessions.forSession(sessionID).inbox(),
|
||||
cancelInbox: (input) => sessions.forSession(input.sessionID).cancelInbox(input.inboxID),
|
||||
steerInbox: (input) => sessions.forSession(input.sessionID).steerInbox(input.inboxID),
|
||||
@@ -463,7 +440,6 @@ export const node: LayerNode.Provider<Service, never, typeof Node.tags.values.gl
|
||||
SessionInbox.node,
|
||||
SessionMove.node,
|
||||
SessionProjector.node,
|
||||
LocationServiceMap.node,
|
||||
FSUtil.node,
|
||||
App.node,
|
||||
],
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
export * as SessionDiff from "./diff.js"
|
||||
|
||||
import { and, asc, eq, gt, inArray, lt, or, sql } from "drizzle-orm"
|
||||
import { Context, Effect, Schema } from "effect"
|
||||
import { Location } from "@opencode/schema/location"
|
||||
import { Database } from "../database/database.js"
|
||||
import { LocationServiceMap } from "../location-service-map.js"
|
||||
import { Snapshot } from "../snapshot.js"
|
||||
import { PATCH_CONTEXT_LINES } from "../vcs/patch.js"
|
||||
import { MessageNotFoundError } from "./error.js"
|
||||
import { SessionMessage } from "./message.js"
|
||||
import { SessionSchema } from "./schema.js"
|
||||
import { SessionMessageTable } from "./sql.js"
|
||||
|
||||
export class TurnRangeError extends Schema.TaggedError<TurnRangeError>()("Session.TurnRangeError", {
|
||||
sessionID: SessionSchema.ID,
|
||||
field: Schema.Literals(["messageID", "to"]),
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
const decodeLocation = Schema.decodeUnknownSync(Schema.fromJsonString(Location.Ref))
|
||||
|
||||
/**
|
||||
* Diff the files changed by the turn containing a user message. A turn runs from
|
||||
* the first prompt after the Session was last idle until the next idle marker, so
|
||||
* prompts steered in while it was busy belong to the same turn; `to` extends the
|
||||
* range through the turn containing a later user message. Compares the range's
|
||||
* first recorded start snapshot with its last recorded end snapshot; only a step
|
||||
* still running in the active Session compares against the working copy. Like VCS
|
||||
* diffs, an omitted `context` yields full-file patches.
|
||||
*
|
||||
* A Session without any idle marker predates them, so its prompts span until the
|
||||
* next user message instead.
|
||||
*
|
||||
* Snapshot trees live in the repository of the Location that captured them, so a
|
||||
* range spanning a location switch is rejected rather than diffed wrongly.
|
||||
*/
|
||||
export const turn = Effect.fn("SessionDiff.turn")(function* (
|
||||
db: Database.Interface["db"],
|
||||
locations: Context.Service.Shape<typeof LocationServiceMap.Service>,
|
||||
input: {
|
||||
readonly session: SessionSchema.Info
|
||||
/** The process is currently executing this Session. */
|
||||
readonly active: boolean
|
||||
readonly messageID?: SessionMessage.ID
|
||||
readonly to?: SessionMessage.ID
|
||||
readonly context?: number
|
||||
},
|
||||
) {
|
||||
const sessionID = input.session.id
|
||||
const rows = yield* db
|
||||
.select({ id: SessionMessageTable.id, type: SessionMessageTable.type, seq: SessionMessageTable.seq })
|
||||
.from(SessionMessageTable)
|
||||
.where(
|
||||
and(
|
||||
eq(SessionMessageTable.session_id, sessionID),
|
||||
or(
|
||||
inArray(SessionMessageTable.type, ["user", "idle"]),
|
||||
input.messageID ? eq(SessionMessageTable.id, input.messageID) : undefined,
|
||||
input.to ? eq(SessionMessageTable.id, input.to) : undefined,
|
||||
),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(SessionMessageTable.seq))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
const users = rows.filter((row) => row.type === "user")
|
||||
const markers = rows.filter((row) => row.type === "idle")
|
||||
const resolve = Effect.fn(function* (field: "messageID" | "to", id: SessionMessage.ID) {
|
||||
const row = rows.find((row) => row.id === id)
|
||||
if (!row) return yield* new MessageNotFoundError({ sessionID, messageID: id })
|
||||
if (row.type !== "user")
|
||||
return yield* new TurnRangeError({ sessionID, field, message: `Message ${id} is not a user message` })
|
||||
return row
|
||||
})
|
||||
const anchor = input.messageID ? yield* resolve("messageID", input.messageID) : users[users.length - 1]
|
||||
if (!anchor) return []
|
||||
const last = input.to ? yield* resolve("to", input.to) : anchor
|
||||
if (last.seq < anchor.seq)
|
||||
return yield* new TurnRangeError({ sessionID, field: "to", message: `Message ${last.id} precedes ${anchor.id}` })
|
||||
// Without any marker, history predates idle markers and a prompt's turn ends at the next prompt.
|
||||
const legacy = markers.length === 0
|
||||
// The turn opens with the first prompt after the previous idle marker; the anchor itself is the latest candidate.
|
||||
const opened = markers.findLast((row) => row.seq < anchor.seq)?.seq ?? -1
|
||||
const start = legacy ? anchor.seq : (users.find((row) => row.seq > opened)?.seq ?? anchor.seq)
|
||||
const end = legacy ? users.find((row) => row.seq > last.seq)?.seq : markers.find((row) => row.seq > last.seq)?.seq
|
||||
const steps = yield* db
|
||||
.select({
|
||||
seq: SessionMessageTable.seq,
|
||||
start: sql<string | null>`json_extract(${SessionMessageTable.data}, '$.snapshot.start')`,
|
||||
end: sql<string | null>`json_extract(${SessionMessageTable.data}, '$.snapshot.end')`,
|
||||
completed: sql<number | null>`json_extract(${SessionMessageTable.data}, '$.time.completed')`,
|
||||
})
|
||||
.from(SessionMessageTable)
|
||||
.where(
|
||||
and(
|
||||
eq(SessionMessageTable.session_id, sessionID),
|
||||
eq(SessionMessageTable.type, "assistant"),
|
||||
gt(SessionMessageTable.seq, start),
|
||||
end === undefined ? undefined : lt(SessionMessageTable.seq, end),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(SessionMessageTable.seq))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
const first = steps[0]
|
||||
const final = steps[steps.length - 1]
|
||||
const from = steps.find((step) => step.start)?.start
|
||||
if (!first || !final || !from) return []
|
||||
const switches = yield* db
|
||||
.select({
|
||||
seq: SessionMessageTable.seq,
|
||||
location: sql<string>`json_extract(${SessionMessageTable.data}, '$.location')`,
|
||||
previous: sql<string | null>`json_extract(${SessionMessageTable.data}, '$.previous.location')`,
|
||||
})
|
||||
.from(SessionMessageTable)
|
||||
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "location-switched")))
|
||||
.orderBy(asc(SessionMessageTable.seq))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
if (switches.some((row) => row.seq > first.seq && row.seq < final.seq))
|
||||
return yield* new TurnRangeError({ sessionID, field: "to", message: "Turn range spans a location change" })
|
||||
const before = switches.findLast((row) => row.seq < first.seq)?.location
|
||||
const after = switches.find((row) => row.seq > first.seq)?.previous
|
||||
const location = before ? decodeLocation(before) : after ? decodeLocation(after) : input.session.location
|
||||
const recorded = steps.findLast((step) => step.end)?.end
|
||||
return yield* Effect.gen(function* () {
|
||||
const snapshot = yield* Snapshot.Service
|
||||
const running = input.active && final.completed === null
|
||||
const to = running ? ((yield* snapshot.capture()) ?? recorded) : recorded
|
||||
if (!to) return []
|
||||
return yield* snapshot.diff({
|
||||
from: Snapshot.ID.make(from),
|
||||
to: Snapshot.ID.make(to),
|
||||
context: input.context ?? PATCH_CONTEXT_LINES,
|
||||
})
|
||||
}).pipe(Effect.provide(locations.get(location)))
|
||||
})
|
||||
@@ -60,21 +60,6 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||
)
|
||||
})
|
||||
|
||||
const idle = (outcome: SessionMessage.Idle["outcome"]) =>
|
||||
clearCurrentRetry.pipe(
|
||||
Effect.andThen(
|
||||
adapter.appendMessage(
|
||||
SessionMessage.Idle.make({
|
||||
id: SessionMessage.ID.fromEvent(event.id),
|
||||
type: "idle",
|
||||
outcome,
|
||||
metadata: event.metadata,
|
||||
time: { created },
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const project = pipe(
|
||||
Match.type<SessionEvent.DurableEvent>(),
|
||||
Match.discriminatorsExhaustive("type")({
|
||||
@@ -138,11 +123,9 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||
"session.inbox.cancelled": () => Effect.void,
|
||||
"session.inbox.delivery.changed": () => Effect.void,
|
||||
"session.execution.started": () => Effect.void,
|
||||
"session.execution.succeeded": () => idle("succeeded"),
|
||||
"session.execution.failed": () => idle("failed"),
|
||||
// Shutdown keeps the execution claim and the resumed drain continues the turn.
|
||||
"session.execution.interrupted": (event) =>
|
||||
event.data.reason === "shutdown" ? clearCurrentRetry : idle("interrupted"),
|
||||
"session.execution.succeeded": () => clearCurrentRetry,
|
||||
"session.execution.failed": () => clearCurrentRetry,
|
||||
"session.execution.interrupted": () => clearCurrentRetry,
|
||||
"session.instructions.updated": (event) => {
|
||||
if (event.data.text === undefined) return Effect.void
|
||||
return adapter.appendMessage(
|
||||
|
||||
@@ -316,17 +316,28 @@ export const layer = Layer.effect(
|
||||
return HttpClientResponse.fromWeb(sent, after.response)
|
||||
}).pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause)))))
|
||||
: undefined
|
||||
// HTTP hooks must observe every request, so they keep the provider on HTTP.
|
||||
const webSocket =
|
||||
input.webSocket === "session" &&
|
||||
!hasHttpHooks &&
|
||||
model.capabilities.responsesWebsockets === true &&
|
||||
model.websocket
|
||||
input.webSocket === "session" && model.capabilities.responsesWebsockets === true && model.websocket
|
||||
const interceptor: SessionModelTransport.Interceptor = {
|
||||
handshake: (connect) =>
|
||||
hooks.trigger("session", "experimental.ws.handshake", {
|
||||
...scope,
|
||||
url: connect.url,
|
||||
headers: connect.headers,
|
||||
}),
|
||||
send: (frame, mode) =>
|
||||
hooks.trigger("session", "experimental.ws.send", { ...scope, mode, frame }).pipe(Effect.map((e) => e.frame)),
|
||||
receive: (frame) =>
|
||||
hooks.trigger("session", "experimental.ws.receive", { ...scope, frame }).pipe(Effect.map((e) => e.frame)),
|
||||
}
|
||||
|
||||
return {
|
||||
event: shaped,
|
||||
request,
|
||||
options: { ...(http ? { http } : {}), ...(webSocket ? { webSocket: transport.bind(session.id) } : {}) },
|
||||
options: {
|
||||
...(http ? { http } : {}),
|
||||
...(webSocket ? { webSocket: transport.bind(session.id, interceptor) } : {}),
|
||||
},
|
||||
retry: (event: Parameters<Prepared["retry"]>[0]) =>
|
||||
hooks.trigger("session", "retry", event).pipe(Effect.asVoid),
|
||||
// Permission.assert and the question tool throw declines as defects so tools cannot
|
||||
|
||||
@@ -2,6 +2,7 @@ export * as SessionModelTransport from "./model-transport.js"
|
||||
|
||||
import {
|
||||
WebSocketTransport,
|
||||
type ChannelCreate,
|
||||
type ChannelObservation,
|
||||
type ChannelCheckpoint,
|
||||
type WebSocketChannelExchange,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
import { AIError, AIErrorReason, TransportError, type TransportOperation } from "@opencode/ai"
|
||||
import { Hash } from "@opencode/util/hash"
|
||||
import { Cause, Clock, Context, Effect, Fiber, Layer, Metric, Queue, Scope, Semaphore, Stream } from "effect"
|
||||
import { Headers } from "effect/unstable/http"
|
||||
import { Socket } from "effect/unstable/socket"
|
||||
import { makeGlobalNode } from "@opencode/util/effect/app-node"
|
||||
import { SessionSchema } from "./schema.js"
|
||||
@@ -52,8 +54,18 @@ interface State {
|
||||
channel?: Channel
|
||||
}
|
||||
|
||||
/** Per-exchange plugin hooks. `handshake` output selects the connection; frames are what crosses the wire. */
|
||||
export interface Interceptor {
|
||||
readonly handshake: (connect: {
|
||||
readonly url: string
|
||||
readonly headers: Record<string, string>
|
||||
}) => Effect.Effect<{ readonly url: string; readonly headers: Record<string, string> }>
|
||||
readonly send: (frame: string, mode: ChannelCreate["mode"]) => Effect.Effect<string>
|
||||
readonly receive: (frame: string) => Effect.Effect<string>
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly bind: (sessionID: SessionSchema.ID) => WebSocketChannelExecutor
|
||||
readonly bind: (sessionID: SessionSchema.ID, interceptor?: Interceptor) => WebSocketChannelExecutor
|
||||
readonly close: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||
readonly closeAll: Effect.Effect<void>
|
||||
}
|
||||
@@ -267,7 +279,8 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
|
||||
const start = Effect.fn("SessionModelTransport.start")(function* (
|
||||
owner: State,
|
||||
exchange: WebSocketChannelExchange,
|
||||
input: WebSocketChannelExchange,
|
||||
interceptor?: Interceptor,
|
||||
) {
|
||||
if (owner.closed)
|
||||
return yield* transportError("Session WebSocket owner is closed", {
|
||||
@@ -276,7 +289,16 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
phase: "queue",
|
||||
delivery: "not-sent",
|
||||
})
|
||||
if (owner.httpFallback) return fallback(exchange)
|
||||
if (owner.httpFallback) return fallback(input)
|
||||
const handshake = interceptor
|
||||
? yield* interceptor.handshake({ url: input.connect.url, headers: { ...input.connect.headers } })
|
||||
: undefined
|
||||
const exchange: WebSocketChannelExchange = handshake
|
||||
? {
|
||||
...input,
|
||||
connect: { ...input.connect, url: handshake.url, headers: Headers.fromInput(handshake.headers) },
|
||||
}
|
||||
: input
|
||||
const key = affinity(exchange)
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
const current = owner.channel
|
||||
@@ -337,6 +359,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
Effect.onInterrupt(() => closeChannel(owner, channel)),
|
||||
)
|
||||
if (create.mode === "full") channel.checkpoint = undefined
|
||||
const message = interceptor ? yield* interceptor.send(create.message, create.mode) : create.message
|
||||
yield* Effect.logDebug("session websocket sending", {
|
||||
sessionTransport: "websocket",
|
||||
phase: "send",
|
||||
@@ -347,7 +370,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
delivery: "send-attempted",
|
||||
}
|
||||
channel.active = active
|
||||
const sent = yield* channel.connection.sendText(create.message).pipe(
|
||||
const sent = yield* channel.connection.sendText(message).pipe(
|
||||
Effect.withSpan("SessionModelTransport.send"),
|
||||
Effect.onInterrupt(() => closeChannel(owner, channel)),
|
||||
Effect.result,
|
||||
@@ -388,6 +411,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
}),
|
||||
),
|
||||
}),
|
||||
Stream.mapEffect((frame) => (interceptor ? interceptor.receive(frame) : Effect.succeed(frame))),
|
||||
Stream.mapEffect((frame) => exchange.driver.observe(create, frame)),
|
||||
Stream.tap((observation) =>
|
||||
Effect.sync(() => {
|
||||
@@ -465,7 +489,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
return { frames, complete, http: channel.connection.http }
|
||||
})
|
||||
|
||||
const bind = (sessionID: SessionSchema.ID): WebSocketChannelExecutor => ({
|
||||
const bind = (sessionID: SessionSchema.ID, interceptor?: Interceptor): WebSocketChannelExecutor => ({
|
||||
execute: (exchange) => {
|
||||
const owner = state(sessionID)
|
||||
let execution: WebSocketChannelExecution | undefined
|
||||
@@ -475,7 +499,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
},
|
||||
frames: Stream.unwrap(
|
||||
Effect.acquireRelease(owner.lock.take(1), () => owner.lock.release(1), { interruptible: true }).pipe(
|
||||
Effect.andThen(start(owner, exchange)),
|
||||
Effect.andThen(start(owner, exchange, interceptor)),
|
||||
Effect.tap((started) =>
|
||||
Effect.sync(() => {
|
||||
execution = started
|
||||
|
||||
@@ -226,7 +226,6 @@ function toLLMMessage(message: SessionMessage.Info, model: Model.Ref, providerMe
|
||||
switch (message.type) {
|
||||
case "agent-switched":
|
||||
case "model-switched":
|
||||
case "idle":
|
||||
return []
|
||||
case "location-switched":
|
||||
return [
|
||||
|
||||
@@ -131,55 +131,38 @@ const layer = Layer.effect(
|
||||
)
|
||||
})
|
||||
|
||||
const comparison = Effect.fnUntraced(function* (operation: "files" | "diff", input: CompareInput) {
|
||||
const compare = Effect.fnUntraced(function* (operation: "files" | "diff", input: CompareInput) {
|
||||
const repo = yield* repository.pipe(Effect.mapError((cause) => failure(operation, cause)))
|
||||
return {
|
||||
source: repo.source,
|
||||
const comparison = {
|
||||
repository: repo.snapshotRepository,
|
||||
from: Git.TreeID.make(input.from),
|
||||
to: Git.TreeID.make(input.to),
|
||||
}
|
||||
})
|
||||
|
||||
// Snapshots track every scoped file; the source repository's ignore rules decide what callers see.
|
||||
const ignored = Effect.fnUntraced(function* (
|
||||
operation: "files" | "diff",
|
||||
source: Git.Repository,
|
||||
paths: readonly RelativePath[],
|
||||
) {
|
||||
return yield* git.index
|
||||
.ignored({ repository: source, paths })
|
||||
const files = yield* git.tree.files(comparison).pipe(Effect.mapError((cause) => failure(operation, cause)))
|
||||
const ignored = yield* git.index
|
||||
.ignored({ repository: repo.source, paths: files })
|
||||
.pipe(Effect.mapError((cause) => failure(operation, cause)))
|
||||
return {
|
||||
input: comparison,
|
||||
files,
|
||||
ignored,
|
||||
}
|
||||
})
|
||||
|
||||
const files = Effect.fn("Snapshot.files")(function* (input: CompareInput) {
|
||||
const compared = yield* comparison("files", input)
|
||||
const changed = yield* git.tree
|
||||
.files({ repository: compared.repository, from: compared.from, to: compared.to })
|
||||
.pipe(Effect.mapError((cause) => failure("files", cause)))
|
||||
const skipped = yield* ignored("files", compared.source, changed)
|
||||
return changed.filter((file) => !skipped.has(file))
|
||||
const comparison = yield* compare("files", input)
|
||||
return comparison.files.filter((file) => !comparison.ignored.has(file))
|
||||
})
|
||||
|
||||
const diff = Effect.fn("Snapshot.diff")(function* (input: DiffInput) {
|
||||
if (input.paths?.length === 0) return []
|
||||
const compared = yield* comparison("diff", input)
|
||||
// Only an explicit selection becomes a pathspec; ignored paths are dropped from the result instead.
|
||||
const diffs = yield* git.tree
|
||||
const comparison = yield* compare("diff", input)
|
||||
return yield* git.tree
|
||||
.diff({
|
||||
repository: compared.repository,
|
||||
from: compared.from,
|
||||
to: compared.to,
|
||||
...comparison.input,
|
||||
context: input.context,
|
||||
paths: input.paths,
|
||||
paths: (input.paths ?? comparison.files).filter((file) => !comparison.ignored.has(file)),
|
||||
})
|
||||
.pipe(Effect.mapError((cause) => failure("diff", cause)))
|
||||
const skipped = yield* ignored(
|
||||
"diff",
|
||||
compared.source,
|
||||
diffs.map((file) => RelativePath.make(file.file)),
|
||||
)
|
||||
return diffs.filter((file) => !skipped.has(RelativePath.make(file.file)))
|
||||
})
|
||||
|
||||
const plan = Effect.fnUntraced(function* (worktree: AbsolutePath, input: RestoreInput) {
|
||||
|
||||
@@ -6,7 +6,6 @@ import { Effect } from "effect"
|
||||
import { LayerNode } from "@opencode/util/effect/layer-node"
|
||||
import { Git } from "@opencode/core/git"
|
||||
import { AbsolutePath, RelativePath } from "@opencode/core/schema"
|
||||
import { VcsPatch } from "@opencode/core/vcs/patch"
|
||||
import { branch, commit, initRepo, read, withRemote } from "./fixture/git"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
@@ -197,42 +196,6 @@ describe("Git trees", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("caps batched tree patches, keeps per-file stats past the cap, and matches non-ASCII names", () =>
|
||||
Effect.gen(function* () {
|
||||
const root = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||
)
|
||||
yield* Effect.promise(() => initRepo(root.path))
|
||||
const git = yield* Git.Service
|
||||
const repository = yield* git.repo.discover(AbsolutePath.make(root.path))
|
||||
if (!repository) throw new Error("Repository not found")
|
||||
const before = yield* git.tree.capture({ repository, scopes: [RelativePath.make(".")] })
|
||||
const lines = Math.ceil(VcsPatch.MAX_TOTAL_PATCH_BYTES / 80) + 1
|
||||
yield* Effect.promise(async () => {
|
||||
await Bun.write(path.join(root.path, "a-small.txt"), "small\n")
|
||||
await Bun.write(path.join(root.path, "b-large.txt"), `${"x".repeat(79)}\n`.repeat(lines))
|
||||
await Bun.write(path.join(root.path, "c-binary.bin"), new Uint8Array([0, 1, 2, 3]))
|
||||
await Bun.write(path.join(root.path, "a-caf\u00e9.txt"), "caf\u00e9\n")
|
||||
})
|
||||
const after = yield* git.tree.capture({ repository, scopes: [RelativePath.make(".")] })
|
||||
|
||||
const diffs = yield* git.tree.diff({ repository, from: before, to: after, context: 0 })
|
||||
expect(diffs.map((item) => [item.file, item.status, item.additions, item.deletions])).toEqual([
|
||||
["a-caf\u00e9.txt", "added", 1, 0],
|
||||
["a-small.txt", "added", 1, 0],
|
||||
["b-large.txt", "added", lines, 0],
|
||||
["c-binary.bin", "added", 0, 0],
|
||||
])
|
||||
// Patch headers are not NUL-delimited; a quoted (octal-escaped) header would orphan this chunk.
|
||||
expect(diffs[0]?.patch).toContain("+caf\u00e9\n")
|
||||
expect(diffs[1]?.patch).toContain("+small\n")
|
||||
expect(diffs[2]?.patch).toBe(VcsPatch.emptyPatch("b-large.txt"))
|
||||
expect(diffs[3]?.patch).toBe("")
|
||||
expect(yield* git.tree.diff({ repository, from: before, to: after, paths: [] })).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("captures, compares, previews, and restores scoped trees", () =>
|
||||
Effect.gen(function* () {
|
||||
const root = yield* Effect.acquireRelease(
|
||||
|
||||
@@ -303,6 +303,20 @@ describe("AzurePlugin", () => {
|
||||
})
|
||||
expect(foundry.request.headers.get("authorization")).toBe("Bearer https://ai.azure.com/.default-token")
|
||||
expect(foundry.request.headers.has("x-api-key")).toBe(false)
|
||||
|
||||
const handshake = yield* hooks.trigger("session", "experimental.ws.handshake", {
|
||||
sessionID: Session.ID.make("ses_azure_ws"),
|
||||
agent: Agent.ID.make("build"),
|
||||
model,
|
||||
kind: "primary",
|
||||
url: "wss://test-resource.openai.azure.com/openai/v1/responses",
|
||||
headers: { "api-key": "stored-token", "x-keep": "yes" },
|
||||
})
|
||||
expect(handshake.headers).toMatchObject({
|
||||
authorization: "Bearer https://cognitiveservices.azure.com/.default-token",
|
||||
"x-keep": "yes",
|
||||
})
|
||||
expect(handshake.headers["api-key"]).toBeUndefined()
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,198 +0,0 @@
|
||||
import { $ } from "bun"
|
||||
import { describe, expect } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { Agent } from "@opencode/core/agent"
|
||||
import { Bus } from "@opencode/core/bus"
|
||||
import { Database } from "@opencode/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
|
||||
import { LocationServiceMap } from "@opencode/core/location-service-map"
|
||||
import { Model } from "@opencode/core/model"
|
||||
import { Plugin } from "@opencode/core/plugin"
|
||||
import { Provider } from "@opencode/core/provider"
|
||||
import { AbsolutePath } from "@opencode/core/schema"
|
||||
import { Session } from "@opencode/core/session"
|
||||
import { SessionDiff } from "@opencode/core/session/diff"
|
||||
import { SessionEvent } from "@opencode/core/session/event"
|
||||
import { SessionExecution } from "@opencode/core/session/execution"
|
||||
import { SessionInbox } from "@opencode/core/session/inbox"
|
||||
import { SessionMessage } from "@opencode/core/session/message"
|
||||
import { SessionProjector } from "@opencode/core/session/projector"
|
||||
import { Snapshot } from "@opencode/core/snapshot"
|
||||
import { Money } from "@opencode/schema/money"
|
||||
import { LayerNode } from "@opencode/util/effect/layer-node"
|
||||
import { Global } from "@opencode/util/global"
|
||||
import { tempGlobalLayer } from "./fixture/global"
|
||||
import { offlineModels } from "./fixture/models"
|
||||
import { tmpdirScoped } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SessionProjector.node, Session.node, LocationServiceMap.node]),
|
||||
[Global.node.replace(tempGlobalLayer), SessionExecution.node.replace(SessionExecution.noopLayer), offlineModels],
|
||||
),
|
||||
)
|
||||
|
||||
const summarize = (file: { file: string; status: string; additions: number; deletions: number }) => [
|
||||
file.file,
|
||||
file.status,
|
||||
file.additions,
|
||||
file.deletions,
|
||||
]
|
||||
|
||||
describe("Session.diff", () => {
|
||||
it.live(
|
||||
"diffs the busy period containing a user message and ranges across later turns",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped()
|
||||
const directory = path.join(tmp.path, "project")
|
||||
const write = (name: string, content: string) => () => Bun.write(path.join(directory, name), content)
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(directory)
|
||||
await write("first.txt", "first\n")()
|
||||
await write("second.txt", "second\n")()
|
||||
await write("manual.txt", "manual\n")()
|
||||
await $`git init -q`.cwd(directory).quiet()
|
||||
await $`git -c core.fsmonitor=false add .`.cwd(directory).quiet()
|
||||
})
|
||||
const sessions = yield* Session.Service
|
||||
const database = yield* Database.Service
|
||||
const bus = yield* Bus.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const created = yield* sessions.create({ location: { directory: AbsolutePath.make(directory) } })
|
||||
const diff = (input?: { messageID?: SessionMessage.ID; to?: SessionMessage.ID }) =>
|
||||
sessions
|
||||
.diff({ sessionID: created.id, context: 0, ...input })
|
||||
.pipe(Effect.map((files) => files.map(summarize)))
|
||||
expect(yield* diff()).toEqual([])
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
yield* plugins.awaitActivation
|
||||
const snapshot = yield* Snapshot.Service
|
||||
const usage = {
|
||||
cost: Money.USD.zero,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
}
|
||||
const prompt = Effect.fn(function* (text: string) {
|
||||
const admitted = yield* sessions.prompt({ sessionID: created.id, text, resume: false })
|
||||
yield* SessionInbox.promote(database.db, bus, created.id, "steer")
|
||||
return admitted.id
|
||||
})
|
||||
const step = Effect.fn(function* (edit: () => Promise<unknown>, end: "recorded" | "unrecorded" | "running") {
|
||||
const before = yield* snapshot.capture()
|
||||
if (!before) throw new Error("Start snapshot missing")
|
||||
const assistantMessageID = SessionMessage.ID.create()
|
||||
yield* bus.publish(SessionEvent.Step.Started, {
|
||||
sessionID: created.id,
|
||||
assistantMessageID,
|
||||
agent: Agent.defaultID,
|
||||
model: { id: Model.ID.make("test-model"), providerID: Provider.ID.make("test-provider") },
|
||||
snapshot: before,
|
||||
})
|
||||
yield* Effect.promise(edit)
|
||||
if (end === "running") return assistantMessageID
|
||||
const after = end === "recorded" ? yield* snapshot.capture() : undefined
|
||||
yield* bus.publish(SessionEvent.Step.Ended, {
|
||||
sessionID: created.id,
|
||||
assistantMessageID,
|
||||
finish: "stop",
|
||||
...usage,
|
||||
snapshot: after,
|
||||
files: after && before ? yield* snapshot.files({ from: before, to: after }) : undefined,
|
||||
})
|
||||
return assistantMessageID
|
||||
})
|
||||
|
||||
const idle = (outcome: "succeeded" | "failed") =>
|
||||
outcome === "succeeded"
|
||||
? bus.publish(SessionEvent.Execution.Succeeded, { sessionID: created.id })
|
||||
: bus.publish(SessionEvent.Execution.Failed, {
|
||||
sessionID: created.id,
|
||||
error: { type: "unknown", message: "failed" },
|
||||
})
|
||||
|
||||
// Before any idle marker exists, a prompt's turn ends at the next prompt.
|
||||
const first = yield* prompt("Edit the first file")
|
||||
const firstStep = yield* step(write("first.txt", "first edited\n"), "recorded")
|
||||
// Edits made while idle are not a turn's work, but a range spanning them still sees them.
|
||||
yield* Effect.promise(write("manual.txt", "manual edited\n"))
|
||||
const second = yield* prompt("Edit the second file")
|
||||
yield* step(write("second.txt", "second edited\n"), "recorded")
|
||||
expect(yield* diff()).toEqual([["second.txt", "modified", 1, 1]])
|
||||
expect(yield* diff({ messageID: first })).toEqual([["first.txt", "modified", 1, 1]])
|
||||
|
||||
// Once markers exist, a turn spans a whole busy period, steers included; earlier history merges into the first one.
|
||||
yield* idle("succeeded")
|
||||
const third = yield* prompt("Add a third file")
|
||||
yield* step(write("third.txt", "third\n"), "recorded")
|
||||
const steer = yield* prompt("Also add a fourth file")
|
||||
yield* step(write("fourth.txt", "fourth\n"), "recorded")
|
||||
yield* idle("failed")
|
||||
const busy = [
|
||||
["fourth.txt", "added", 1, 0],
|
||||
["third.txt", "added", 1, 0],
|
||||
]
|
||||
expect(yield* diff()).toEqual(busy)
|
||||
expect(yield* diff({ messageID: steer })).toEqual(busy)
|
||||
expect(yield* diff({ messageID: second })).toEqual([
|
||||
["first.txt", "modified", 1, 1],
|
||||
["manual.txt", "modified", 1, 1],
|
||||
["second.txt", "modified", 1, 1],
|
||||
])
|
||||
expect(yield* diff({ messageID: first, to: third })).toEqual([
|
||||
["first.txt", "modified", 1, 1],
|
||||
["fourth.txt", "added", 1, 0],
|
||||
["manual.txt", "modified", 1, 1],
|
||||
["second.txt", "modified", 1, 1],
|
||||
["third.txt", "added", 1, 0],
|
||||
])
|
||||
const full = yield* sessions.diff({ sessionID: created.id, messageID: first })
|
||||
expect(full[0]?.patch).toContain("-first\n+first edited\n")
|
||||
expect(yield* diff({ messageID: steer, to: second }).pipe(Effect.flip)).toMatchObject({
|
||||
_tag: "Session.TurnRangeError",
|
||||
field: "to",
|
||||
})
|
||||
expect(yield* diff({ messageID: firstStep }).pipe(Effect.flip)).toMatchObject({
|
||||
_tag: "Session.TurnRangeError",
|
||||
field: "messageID",
|
||||
})
|
||||
expect(yield* diff({ messageID: SessionMessage.ID.create() }).pipe(Effect.flip)).toMatchObject({
|
||||
_tag: "Session.MessageNotFoundError",
|
||||
})
|
||||
|
||||
// A completed step without an end snapshot falls back to the last recorded end.
|
||||
yield* prompt("Edit both files again")
|
||||
yield* step(write("first.txt", "first edited twice\n"), "recorded")
|
||||
yield* step(write("second.txt", "second edited twice\n"), "unrecorded")
|
||||
yield* idle("succeeded")
|
||||
expect(yield* diff()).toEqual([["first.txt", "modified", 1, 1]])
|
||||
|
||||
// Only a step still running in the active session compares against the working copy.
|
||||
yield* prompt("Delete the manual file")
|
||||
yield* step(() => fs.rm(path.join(directory, "manual.txt")), "running")
|
||||
expect(yield* diff()).toEqual([])
|
||||
const session = yield* sessions.get(created.id)
|
||||
const live = yield* SessionDiff.turn(database.db, locations, { session, active: true, context: 0 })
|
||||
expect(live.map(summarize)).toEqual([["manual.txt", "deleted", 0, 1]])
|
||||
|
||||
// Reverting removes later history, markers included; a fork keeps the copied turns.
|
||||
yield* sessions.revert.stage({ sessionID: created.id, messageID: steer, files: false })
|
||||
yield* sessions.revert.commit(created.id)
|
||||
expect(yield* diff()).toEqual([["third.txt", "added", 1, 0]])
|
||||
expect(yield* diff({ messageID: steer }).pipe(Effect.flip)).toMatchObject({
|
||||
_tag: "Session.MessageNotFoundError",
|
||||
})
|
||||
const forked = yield* sessions.fork({ sessionID: created.id, boundary: { type: "through" } })
|
||||
expect((yield* sessions.diff({ sessionID: forked.id, context: 0 })).map(summarize)).toEqual([
|
||||
["third.txt", "added", 1, 0],
|
||||
])
|
||||
}).pipe(Effect.provide(LocationServiceMap.Service.get(created.location)))
|
||||
}),
|
||||
// Real Location/plugin startup and Git snapshots can exceed five seconds under CI load.
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
})
|
||||
@@ -561,9 +561,7 @@ describe("SessionRestart background recovery", () => {
|
||||
expect(yield* restarted.pendingBackground).toEqual([])
|
||||
expect(yield* SessionInbox.list(database.db, sessionID)).toHaveLength(delivered ? 0 : 1)
|
||||
yield* SessionInbox.promote(database.db, bus, sessionID, "steer")
|
||||
// Recovery ends a busy period, so an idle marker follows the notification.
|
||||
const messages = (yield* sessions.messages({ sessionID })).filter((message) => message.type !== "idle")
|
||||
expect(messages).toMatchObject([
|
||||
expect(yield* sessions.messages({ sessionID })).toMatchObject([
|
||||
{
|
||||
id: background.notificationID,
|
||||
type: "synthetic",
|
||||
@@ -571,6 +569,7 @@ describe("SessionRestart background recovery", () => {
|
||||
metadata: { state: "completed" },
|
||||
},
|
||||
])
|
||||
expect(yield* sessions.messages({ sessionID })).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -79,4 +79,68 @@ describe("SessionModelRequest HTTP hooks", () => {
|
||||
)
|
||||
}).pipe(Effect.provideService(SessionModelTransport.Service, transport)),
|
||||
)
|
||||
|
||||
it.effect("runs experimental.ws hooks through the transport interceptor alongside http hooks", () =>
|
||||
Effect.gen(function* () {
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const seen: Array<string> = []
|
||||
yield* hooks.register("session", "http.request", () => Effect.void)
|
||||
yield* hooks.register("session", "experimental.ws.handshake", (event) =>
|
||||
Effect.sync(() => {
|
||||
seen.push(`handshake:${event.kind}:${event.agent}`)
|
||||
event.url = `${event.url}?hooked`
|
||||
event.headers.authorization = "Bearer hooked"
|
||||
}),
|
||||
)
|
||||
yield* hooks.register("session", "experimental.ws.send", (event) =>
|
||||
Effect.sync(() => {
|
||||
seen.push(`send:${event.kind}:${event.mode}`)
|
||||
event.frame = `${event.frame}:sent`
|
||||
}),
|
||||
)
|
||||
yield* hooks.register("session", "experimental.ws.receive", (event) =>
|
||||
Effect.sync(() => {
|
||||
seen.push(`receive:${event.kind}`)
|
||||
event.frame = `${event.frame}:received`
|
||||
}),
|
||||
)
|
||||
let interceptor: SessionModelTransport.Interceptor | undefined
|
||||
const capturing = SessionModelTransport.Service.of({
|
||||
bind: (_sessionID, bound) => {
|
||||
interceptor = bound
|
||||
return { execute: () => Effect.die("unused WebSocket execution") }
|
||||
},
|
||||
close: () => Effect.void,
|
||||
closeAll: Effect.void,
|
||||
})
|
||||
const requests = yield* SessionModelRequest.Service.pipe(
|
||||
Effect.provide(SessionModelRequest.layer),
|
||||
Effect.provideService(SessionModelTransport.Service, capturing),
|
||||
)
|
||||
const prepared = yield* requests.compaction({
|
||||
session,
|
||||
agent: Agent.ID.make("build"),
|
||||
model: SessionRunnerModel.resolved(OpenAIChat.route.model({ id: "gpt-5.5", provider: "test" }), {
|
||||
capabilities: { tools: true, input: ["text"], output: ["text"], responsesWebsockets: true },
|
||||
cost: [],
|
||||
limit: { context: 200_000, output: 32_000 },
|
||||
websocket: true,
|
||||
}),
|
||||
system: [],
|
||||
messages: [],
|
||||
webSocket: "session",
|
||||
})
|
||||
expect(prepared.options.http).toBeDefined()
|
||||
expect(prepared.options.webSocket).toBeDefined()
|
||||
if (!interceptor) throw new Error("Expected the transport to receive an interceptor")
|
||||
|
||||
expect(yield* interceptor.handshake({ url: "wss://example.test/v1/responses", headers: {} })).toMatchObject({
|
||||
url: "wss://example.test/v1/responses?hooked",
|
||||
headers: { authorization: "Bearer hooked" },
|
||||
})
|
||||
expect(yield* interceptor.send("frame", "incremental")).toBe("frame:sent")
|
||||
expect(yield* interceptor.receive("frame")).toBe("frame:received")
|
||||
expect(seen).toEqual(["handshake:compaction:build", "send:compaction:incremental", "receive:compaction"])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -822,6 +822,36 @@ describe("SessionModelTransport", () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("runs interceptors on the handshake and both frame directions", async () => {
|
||||
const fixture = automatic()
|
||||
const seen: Array<string> = []
|
||||
let authorization = "one"
|
||||
|
||||
await run(
|
||||
fixture.connector,
|
||||
Effect.gen(function* () {
|
||||
const transport = yield* SessionModelTransport.Service
|
||||
const executor = transport.bind(session, {
|
||||
handshake: (connect) =>
|
||||
Effect.succeed({ url: `${connect.url}?hooked`, headers: { ...connect.headers, authorization } }),
|
||||
send: (frame, mode) => Effect.succeed(`${frame}:${mode}`),
|
||||
receive: (frame) =>
|
||||
Effect.sync(() => {
|
||||
seen.push(frame)
|
||||
return frame.toUpperCase()
|
||||
}),
|
||||
})
|
||||
expect(yield* collect(executor, exchange("first"))).toEqual(["COMPLETED:FIRST:FULL"])
|
||||
authorization = "two"
|
||||
expect(yield* collect(executor, exchange("second"))).toEqual(["COMPLETED:SECOND:FULL"])
|
||||
expect(seen).toEqual(["completed:first:full", "completed:second:full"])
|
||||
expect(fixture.connections).toHaveLength(2)
|
||||
expect(fixture.connections.map((item) => item.headers.authorization)).toEqual(["one", "two"])
|
||||
expect(fixture.connections.map((item) => item.sent)).toEqual([["first:full"], ["second:full"]])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("rotates when the connection exceeds its requested age limit", async () => {
|
||||
const fixture = automatic()
|
||||
|
||||
|
||||
@@ -86,6 +86,34 @@ export interface SessionHttpResponse {
|
||||
response: Response
|
||||
}
|
||||
|
||||
/** Connection a WebSocket request needs. Changing `url` or `headers` reopens the Session's socket. */
|
||||
export interface SessionWebSocketHandshake {
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly model: Model.Ref
|
||||
readonly kind: SessionRequestKind
|
||||
url: string
|
||||
headers: Record<string, string>
|
||||
}
|
||||
|
||||
export interface SessionWebSocketSend {
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly model: Model.Ref
|
||||
readonly kind: SessionRequestKind
|
||||
/** Incremental frames carry only what changed since the provider's last checkpoint. */
|
||||
readonly mode: "full" | "incremental"
|
||||
frame: string
|
||||
}
|
||||
|
||||
export interface SessionWebSocketReceive {
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly model: Model.Ref
|
||||
readonly kind: SessionRequestKind
|
||||
frame: string
|
||||
}
|
||||
|
||||
export type SessionRetryDecision = { retry: false } | { retry: true; delay: number }
|
||||
|
||||
export interface SessionRetry {
|
||||
@@ -106,6 +134,9 @@ export interface SessionHooks {
|
||||
readonly "model.request": SessionModelRequest
|
||||
readonly "http.request": SessionHttpRequest
|
||||
readonly "http.response": SessionHttpResponse
|
||||
readonly "experimental.ws.handshake": SessionWebSocketHandshake
|
||||
readonly "experimental.ws.send": SessionWebSocketSend
|
||||
readonly "experimental.ws.receive": SessionWebSocketReceive
|
||||
readonly retry: SessionRetry
|
||||
}
|
||||
|
||||
|
||||
@@ -86,6 +86,34 @@ export interface SessionHttpResponse {
|
||||
response: Response
|
||||
}
|
||||
|
||||
/** Connection a WebSocket request needs. Changing `url` or `headers` reopens the Session's socket. */
|
||||
export interface SessionWebSocketHandshake {
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly model: Model.Ref
|
||||
readonly kind: SessionRequestKind
|
||||
url: string
|
||||
headers: Record<string, string>
|
||||
}
|
||||
|
||||
export interface SessionWebSocketSend {
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly model: Model.Ref
|
||||
readonly kind: SessionRequestKind
|
||||
/** Incremental frames carry only what changed since the provider's last checkpoint. */
|
||||
readonly mode: "full" | "incremental"
|
||||
frame: string
|
||||
}
|
||||
|
||||
export interface SessionWebSocketReceive {
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly model: Model.Ref
|
||||
readonly kind: SessionRequestKind
|
||||
frame: string
|
||||
}
|
||||
|
||||
export type SessionRetryDecision = { retry: false } | { retry: true; delay: number }
|
||||
|
||||
export interface SessionRetry {
|
||||
@@ -106,6 +134,9 @@ export interface SessionHooks {
|
||||
readonly "model.request": SessionModelRequest
|
||||
readonly "http.request": SessionHttpRequest
|
||||
readonly "http.response": SessionHttpResponse
|
||||
readonly "experimental.ws.handshake": SessionWebSocketHandshake
|
||||
readonly "experimental.ws.send": SessionWebSocketSend
|
||||
readonly "experimental.ws.receive": SessionWebSocketReceive
|
||||
readonly retry: SessionRetry
|
||||
}
|
||||
|
||||
|
||||
@@ -3242,152 +3242,6 @@
|
||||
"summary": "Get session context"
|
||||
}
|
||||
},
|
||||
"/api/session/{sessionID}/diff": {
|
||||
"get": {
|
||||
"tags": ["session"],
|
||||
"operationId": "v2.session.diff",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "sessionID",
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"pattern": "^ses"
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "messageID",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"pattern": "^msg_"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "User message whose turn to diff. Defaults to the turn of the newest user message."
|
||||
},
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "to",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"pattern": "^msg_"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Later user message whose turn ends the range. Defaults to the turn of `messageID` alone."
|
||||
},
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "context",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Unchanged lines around each hunk. Omit for full-file patches."
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/FileDiff.Info"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["data"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "MessageNotFoundError | SessionNotFoundError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/MessageNotFoundErrorEncoded"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "UnknownError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnknownErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Structured per-file diffs of the files a turn changed. A turn runs from the first prompt after the session was last idle until its next idle marker, so prompts steered in while it was busy belong to the same turn; `to` extends the range through a later turn. Compares the range's first recorded snapshot with its last; a step still running in the active session compares against the working copy. Ranges that span a location change are rejected. In sessions without any idle marker, a prompt's turn spans until the next user message.",
|
||||
"summary": "Diff session turns"
|
||||
}
|
||||
},
|
||||
"/api/session/{sessionID}/inbox": {
|
||||
"get": {
|
||||
"tags": ["session"],
|
||||
@@ -18540,38 +18394,6 @@
|
||||
"required": ["type", "id", "time", "status", "reason", "summary", "recent"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Session.Message.Idle": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^msg_"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object"
|
||||
},
|
||||
"time": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"created": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": ["created"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["idle"]
|
||||
},
|
||||
"outcome": {
|
||||
"type": "string",
|
||||
"enum": ["succeeded", "failed", "interrupted"]
|
||||
}
|
||||
},
|
||||
"required": ["id", "time", "type", "outcome"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Session.Message.Info": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -18603,9 +18425,6 @@
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Session.Message.Compaction"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Session.Message.Idle"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -30,7 +30,6 @@ import { Model } from "@opencode/schema/model"
|
||||
import { Location } from "@opencode/schema/location"
|
||||
import { SessionEvent } from "@opencode/schema/session-event"
|
||||
import { EventLog } from "@opencode/schema/event-log"
|
||||
import { FileDiff } from "@opencode/schema/file-diff"
|
||||
|
||||
const ParentIDFilter = Schema.Union([
|
||||
Session.ID,
|
||||
@@ -522,31 +521,6 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("session.diff", "/api/session/:sessionID/diff", {
|
||||
params: { sessionID: Session.ID },
|
||||
query: Schema.Struct({
|
||||
messageID: Schema.optional(SessionMessage.ID).annotate({
|
||||
description: "User message whose turn to diff. Defaults to the turn of the newest user message.",
|
||||
}),
|
||||
to: Schema.optional(SessionMessage.ID).annotate({
|
||||
description: "Later user message whose turn ends the range. Defaults to the turn of `messageID` alone.",
|
||||
}),
|
||||
context: Schema.NumberFromString.pipe(Schema.decodeTo(NonNegativeInt), Schema.optional).annotate({
|
||||
description: "Unchanged lines around each hunk. Omit for full-file patches.",
|
||||
}),
|
||||
}),
|
||||
success: Schema.Struct({ data: Schema.Array(FileDiff.Info) }),
|
||||
error: [InvalidRequestError, MessageNotFoundError, SessionNotFoundError, UnknownError],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.diff",
|
||||
summary: "Diff session turns",
|
||||
description:
|
||||
"Structured per-file diffs of the files a turn changed. A turn runs from the first prompt after the session was last idle until its next idle marker, so prompts steered in while it was busy belong to the same turn; `to` extends the range through a later turn. Compares the range's first recorded snapshot with its last; a step still running in the active session compares against the working copy. Ranges that span a location change are rejected. In sessions without any idle marker, a prompt's turn spans until the next user message.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("session.inbox.list", "/api/session/:sessionID/inbox", {
|
||||
params: { sessionID: Session.ID },
|
||||
|
||||
@@ -280,18 +280,6 @@ export const Compaction = Schema.Union([CompactionRunning, CompactionCompleted,
|
||||
)
|
||||
export type Compaction = CompactionRunning | CompactionCompleted | CompactionFailed
|
||||
|
||||
/**
|
||||
* Marks the Session going idle: every step since the previous marker belongs to
|
||||
* one turn, including prompts steered in while it was busy. A shutdown does not
|
||||
* record one, since the resumed execution continues the same turn.
|
||||
*/
|
||||
export interface Idle extends Schema.Schema.Type<typeof Idle> {}
|
||||
export const Idle = Schema.Struct({
|
||||
...Base,
|
||||
type: Schema.tag("idle"),
|
||||
outcome: Schema.Literals(["succeeded", "failed", "interrupted"]),
|
||||
}).annotate({ identifier: "Session.Message.Idle" })
|
||||
|
||||
export const Info = Schema.Union([
|
||||
AgentSelected,
|
||||
ModelSelected,
|
||||
@@ -303,7 +291,6 @@ export const Info = Schema.Union([
|
||||
Shell,
|
||||
Assistant,
|
||||
Compaction,
|
||||
Idle,
|
||||
]).annotate({ identifier: "Session.Message.Info" })
|
||||
export type Info =
|
||||
| AgentSelected
|
||||
@@ -316,5 +303,4 @@ export type Info =
|
||||
| Shell
|
||||
| Assistant
|
||||
| Compaction
|
||||
| Idle
|
||||
export type Type = Info["type"]
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Session } from "@opencode/core/session"
|
||||
import type { Snapshot } from "@opencode/core/snapshot"
|
||||
import { MessageNotFoundError, SessionNotFoundError, UnknownError } from "@opencode/protocol/errors"
|
||||
import { SessionNotFoundError, UnknownError } from "@opencode/protocol/errors"
|
||||
import { Effect } from "effect"
|
||||
|
||||
export function missingSession(error: Session.NotFoundError) {
|
||||
@@ -10,14 +9,6 @@ export function missingSession(error: Session.NotFoundError) {
|
||||
})
|
||||
}
|
||||
|
||||
export function missingMessage(error: Session.MessageNotFoundError) {
|
||||
return new MessageNotFoundError({
|
||||
sessionID: error.sessionID,
|
||||
messageID: error.messageID,
|
||||
message: `Message not found: ${error.messageID}`,
|
||||
})
|
||||
}
|
||||
|
||||
export function failedMessageDecode(error: Session.MessageDecodeError) {
|
||||
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
|
||||
return Effect.logError("failed to decode session message").pipe(
|
||||
@@ -27,16 +18,3 @@ export function failedMessageDecode(error: Session.MessageDecodeError) {
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** Snapshot repositories are host state clients cannot repair, so surface only a log reference. */
|
||||
export function failedSnapshot(operation: string, sessionID: Session.ID) {
|
||||
return (error: Snapshot.Error) => {
|
||||
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
|
||||
return Effect.logError(`failed to ${operation}`, { cause: error }).pipe(
|
||||
Effect.annotateLogs({ ref, sessionID }),
|
||||
Effect.andThen(
|
||||
Effect.fail(new UnknownError({ message: "Unexpected server error. Check server logs for details.", ref })),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,9 +17,10 @@ import {
|
||||
ServiceUnavailableError,
|
||||
SessionBusyError,
|
||||
SkillNotFoundError,
|
||||
UnknownError,
|
||||
} from "@opencode/protocol/errors"
|
||||
import { AbsolutePath } from "@opencode/core/schema"
|
||||
import { failedMessageDecode, failedSnapshot, missingMessage, missingSession } from "./session-error"
|
||||
import { failedMessageDecode, missingSession } from "./session-error"
|
||||
|
||||
const DefaultSessionsLimit = 50
|
||||
|
||||
@@ -211,7 +212,15 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
||||
return {
|
||||
data: yield* session.fork({ sessionID: ctx.params.sessionID, boundary: ctx.payload.boundary }).pipe(
|
||||
Effect.catchTag("Session.NotFoundError", missingSession),
|
||||
Effect.catchTag("Session.MessageNotFoundError", missingMessage),
|
||||
Effect.catchTag(
|
||||
"Session.MessageNotFoundError",
|
||||
(error) =>
|
||||
new MessageNotFoundError({
|
||||
sessionID: error.sessionID,
|
||||
messageID: error.messageID,
|
||||
message: `Message not found: ${error.messageID}`,
|
||||
}),
|
||||
),
|
||||
Effect.catchTag(
|
||||
"Session.ForkEmptyError",
|
||||
(error) => new InvalidRequestError({ message: error.message, kind: "empty_session" }),
|
||||
@@ -439,14 +448,32 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
||||
files: ctx.payload.files,
|
||||
})
|
||||
return {
|
||||
data: yield* session.revert
|
||||
.stage({ ...ctx.params, ...ctx.payload })
|
||||
.pipe(
|
||||
Effect.catchTag("Session.NotFoundError", missingSession),
|
||||
Effect.catchTag("Session.MessageNotFoundError", missingMessage),
|
||||
Effect.catchTag("Session.BusyError", busySession),
|
||||
Effect.catchTag("Snapshot.Error", failedSnapshot("stage session revert", ctx.params.sessionID)),
|
||||
data: yield* session.revert.stage({ ...ctx.params, ...ctx.payload }).pipe(
|
||||
Effect.catchTag("Session.NotFoundError", missingSession),
|
||||
Effect.catchTag(
|
||||
"Session.MessageNotFoundError",
|
||||
(error) =>
|
||||
new MessageNotFoundError({
|
||||
sessionID: error.sessionID,
|
||||
messageID: error.messageID,
|
||||
message: `Message not found: ${error.messageID}`,
|
||||
}),
|
||||
),
|
||||
Effect.catchTag("Session.BusyError", busySession),
|
||||
Effect.catchTag("Snapshot.Error", (error) => {
|
||||
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
|
||||
return Effect.logError("failed to stage session revert", { cause: error }).pipe(
|
||||
Effect.andThen(
|
||||
Effect.fail(
|
||||
new UnknownError({
|
||||
message: "Unexpected server error. Check server logs for details.",
|
||||
ref,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -454,13 +481,23 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
||||
"session.revert.clear",
|
||||
Effect.fn(function* (ctx) {
|
||||
yield* Effect.log("session.revert.clear", { sessionID: ctx.params.sessionID })
|
||||
yield* session.revert
|
||||
.clear(ctx.params.sessionID)
|
||||
.pipe(
|
||||
Effect.catchTag("Session.NotFoundError", missingSession),
|
||||
Effect.catchTag("Session.BusyError", busySession),
|
||||
Effect.catchTag("Snapshot.Error", failedSnapshot("clear session revert", ctx.params.sessionID)),
|
||||
)
|
||||
yield* session.revert.clear(ctx.params.sessionID).pipe(
|
||||
Effect.catchTag("Session.NotFoundError", missingSession),
|
||||
Effect.catchTag("Session.BusyError", busySession),
|
||||
Effect.catchTag("Snapshot.Error", (error) => {
|
||||
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
|
||||
return Effect.logError("failed to clear session revert", { cause: error }).pipe(
|
||||
Effect.andThen(
|
||||
Effect.fail(
|
||||
new UnknownError({
|
||||
message: "Unexpected server error. Check server logs for details.",
|
||||
ref,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
return HttpApiSchema.NoContent.make()
|
||||
}),
|
||||
)
|
||||
@@ -490,22 +527,6 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
||||
}
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"session.diff",
|
||||
Effect.fn(function* (ctx) {
|
||||
return {
|
||||
data: yield* session.diff({ sessionID: ctx.params.sessionID, ...ctx.query }).pipe(
|
||||
Effect.catchTag("Session.NotFoundError", missingSession),
|
||||
Effect.catchTag("Session.MessageNotFoundError", missingMessage),
|
||||
Effect.catchTag(
|
||||
"Session.TurnRangeError",
|
||||
(error) => new InvalidRequestError({ message: error.message, field: error.field }),
|
||||
),
|
||||
Effect.catchTag("Snapshot.Error", failedSnapshot("diff session turn", ctx.params.sessionID)),
|
||||
),
|
||||
}
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"session.inbox.list",
|
||||
Effect.fn(function* (ctx) {
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
import { expect, setDefaultTimeout } from "bun:test"
|
||||
import { Agent } from "@opencode/core/agent"
|
||||
import { Bus } from "@opencode/core/bus"
|
||||
import { Model } from "@opencode/core/model"
|
||||
import { Provider } from "@opencode/core/provider"
|
||||
import { Session } from "@opencode/core/session"
|
||||
import { SessionEvent } from "@opencode/core/session/event"
|
||||
import { SessionExecution } from "@opencode/core/session/execution"
|
||||
import { SessionMessage } from "@opencode/core/session/message"
|
||||
import { Money } from "@opencode/schema/money"
|
||||
import { makeGlobalNode } from "@opencode/util/effect/app-node"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { tmpdir } from "../../core/test/fixture/tmpdir"
|
||||
import { it } from "../../core/test/lib/effect"
|
||||
import { ServerFetch } from "../src/fetch"
|
||||
|
||||
setDefaultTimeout(30_000)
|
||||
|
||||
it.live("serves turn diffs by user message with range validation", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-session-diff-")))
|
||||
const ids = { user: SessionMessage.ID.create(), assistant: SessionMessage.ID.create() }
|
||||
// Deliver the prompt and one step the way the runner would, without a model.
|
||||
const execution = Layer.effect(
|
||||
SessionExecution.Service,
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
return SessionExecution.Service.of({
|
||||
active: Effect.succeed(new Set()),
|
||||
isActive: () => Effect.succeed(false),
|
||||
resume: () => Effect.void,
|
||||
wake: (sessionID) =>
|
||||
Effect.gen(function* () {
|
||||
yield* bus.publish(SessionEvent.InboxDelivered, { sessionID, inboxID: ids.user })
|
||||
yield* bus.publish(SessionEvent.Step.Started, {
|
||||
sessionID,
|
||||
assistantMessageID: ids.assistant,
|
||||
agent: Agent.defaultID,
|
||||
model: { id: Model.ID.make("model"), providerID: Provider.ID.make("provider") },
|
||||
})
|
||||
yield* bus.publish(SessionEvent.Step.Ended, {
|
||||
sessionID,
|
||||
assistantMessageID: ids.assistant,
|
||||
finish: "stop",
|
||||
cost: Money.USD.zero,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
})
|
||||
}),
|
||||
interrupt: () => Effect.succeed(false),
|
||||
awaitIdle: () => Effect.void,
|
||||
})
|
||||
}),
|
||||
)
|
||||
const handler = yield* ServerFetch.make(
|
||||
{
|
||||
app: { version: "test-version" },
|
||||
database: { path: ":memory:" },
|
||||
fs: { filewatcher: false },
|
||||
models: { fetch: false },
|
||||
},
|
||||
{
|
||||
overrides: [
|
||||
SessionExecution.node.replace(
|
||||
makeGlobalNode({ service: SessionExecution.Service, layer: execution, deps: [Bus.node] }),
|
||||
),
|
||||
],
|
||||
},
|
||||
)
|
||||
const request = (path: string, body?: unknown) =>
|
||||
Effect.promise(async () => {
|
||||
const response = await handler(
|
||||
new Request(`http://opencode.local${path}`, {
|
||||
method: body === undefined ? "GET" : "POST",
|
||||
headers: body === undefined ? undefined : { "content-type": "application/json" },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
}),
|
||||
)
|
||||
return { status: response.status, body: (await response.json()) as Record<string, unknown> }
|
||||
})
|
||||
const created = yield* request("/api/session", { location: { directory: tmp.path } })
|
||||
const sessionID = Session.ID.make((created.body.data as { id: string }).id)
|
||||
const diff = (query = "") => request(`/api/session/${sessionID}/diff${query}`)
|
||||
|
||||
expect(yield* diff()).toEqual({ status: 200, body: { data: [] } })
|
||||
expect((yield* request(`/api/session/${sessionID}/prompt`, { id: ids.user, text: "prompt" })).status).toBe(200)
|
||||
// Not a git repository, so steps record no snapshots and the turn has no diff.
|
||||
expect(yield* diff(`?messageID=${ids.user}&context=3`)).toEqual({ status: 200, body: { data: [] } })
|
||||
expect(yield* diff(`?messageID=${ids.assistant}`)).toMatchObject({
|
||||
status: 400,
|
||||
body: { _tag: "InvalidRequestError", field: "messageID" },
|
||||
})
|
||||
expect(yield* diff(`?messageID=${SessionMessage.ID.create()}`)).toMatchObject({
|
||||
status: 404,
|
||||
body: { _tag: "MessageNotFoundError" },
|
||||
})
|
||||
expect((yield* request(`/api/session/${Session.ID.create()}/diff`)).status).toBe(404)
|
||||
}),
|
||||
)
|
||||
@@ -427,17 +427,6 @@ export function SessionCompactionMessage(props: { message: SessionMessageCompact
|
||||
<div class="py-2">
|
||||
<TimelineSeparator label={i18n.t("ui.messagePart.compaction.started")} />
|
||||
</div>
|
||||
<Show when={summary().trim()}>
|
||||
<div data-component="text-part" data-timeline-part-id={props.message.id}>
|
||||
<div data-slot="text-part-body">
|
||||
<PacedMarkdown
|
||||
text={summary()}
|
||||
cacheKey={props.message.id}
|
||||
streaming={props.message.status === "running"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={props.message.status === "running"}>
|
||||
<div role="status" class="py-2">
|
||||
<BasicTool
|
||||
@@ -449,6 +438,17 @@ export function SessionCompactionMessage(props: { message: SessionMessageCompact
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={summary().trim()}>
|
||||
<div data-component="text-part" data-timeline-part-id={props.message.id}>
|
||||
<div data-slot="text-part-body">
|
||||
<PacedMarkdown
|
||||
text={summary()}
|
||||
cacheKey={props.message.id}
|
||||
streaming={props.message.status === "running"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={props.message.status !== "running"}>
|
||||
<div class="py-2">
|
||||
<TimelineSeparator label={label()} />
|
||||
|
||||
@@ -16,7 +16,7 @@ export { TimelineRow, type PartGroup, type PartRef, type TimelineRowMap }
|
||||
|
||||
export type ReasoningMode = "hidden" | "compact" | "full"
|
||||
|
||||
type Notice = Exclude<SessionMessageInfo, { type: "user" | "assistant" | "shell" | "idle" }>
|
||||
type Notice = Exclude<SessionMessageInfo, { type: "user" | "assistant" | "shell" }>
|
||||
type Entry = { type: "assistant"; message: SessionMessageAssistant } | { type: "notice"; message: Notice }
|
||||
type Content = SessionMessageAssistant["content"][number]
|
||||
type GroupRow = Extract<TimelineRow.TimelineRow, { _tag: "AssistantPart" }>
|
||||
@@ -765,8 +765,7 @@ function record(value: unknown): value is Record<string, unknown> {
|
||||
}
|
||||
|
||||
function isNotice(message: SessionMessageInfo): message is Notice {
|
||||
if (message.type === "user" || message.type === "assistant" || message.type === "shell" || message.type === "idle")
|
||||
return false
|
||||
if (message.type === "user" || message.type === "assistant" || message.type === "shell") return false
|
||||
if (message.type !== "synthetic") return true
|
||||
return !!message.description?.trim() || timelineNoticeRequired(message)
|
||||
}
|
||||
|
||||
@@ -305,7 +305,6 @@ export function reduceSessionRows(messages: SessionMessageInfo[], inputs = new S
|
||||
...messages.filter(isInput),
|
||||
].reduce<SessionRow[]>((rows, message) => {
|
||||
if (message.type !== "assistant") {
|
||||
if (message.type === "idle") return rows
|
||||
if (message.type === "synthetic" && !message.description?.trim()) return rows
|
||||
if (message.type === "compaction" && message.status === "completed" && usage) usage.previousTurnCache = undefined
|
||||
if (!pending.has(message.id)) completePrevious(rows)
|
||||
|
||||
@@ -3242,152 +3242,6 @@
|
||||
"summary": "Get session context"
|
||||
}
|
||||
},
|
||||
"/api/session/{sessionID}/diff": {
|
||||
"get": {
|
||||
"tags": ["session"],
|
||||
"operationId": "v2.session.diff",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "sessionID",
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"pattern": "^ses"
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "messageID",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"pattern": "^msg_"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "User message whose turn to diff. Defaults to the turn of the newest user message."
|
||||
},
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "to",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"pattern": "^msg_"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Later user message whose turn ends the range. Defaults to the turn of `messageID` alone."
|
||||
},
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "context",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Unchanged lines around each hunk. Omit for full-file patches."
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/FileDiff.Info"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["data"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "MessageNotFoundError | SessionNotFoundError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/MessageNotFoundErrorEncoded"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "UnknownError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnknownErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Structured per-file diffs of the files a turn changed. A turn runs from the first prompt after the session was last idle until its next idle marker, so prompts steered in while it was busy belong to the same turn; `to` extends the range through a later turn. Compares the range's first recorded snapshot with its last; a step still running in the active session compares against the working copy. Ranges that span a location change are rejected. In sessions without any idle marker, a prompt's turn spans until the next user message.",
|
||||
"summary": "Diff session turns"
|
||||
}
|
||||
},
|
||||
"/api/session/{sessionID}/inbox": {
|
||||
"get": {
|
||||
"tags": ["session"],
|
||||
@@ -18540,38 +18394,6 @@
|
||||
"required": ["type", "id", "time", "status", "reason", "summary", "recent"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Session.Message.Idle": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^msg_"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object"
|
||||
},
|
||||
"time": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"created": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": ["created"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["idle"]
|
||||
},
|
||||
"outcome": {
|
||||
"type": "string",
|
||||
"enum": ["succeeded", "failed", "interrupted"]
|
||||
}
|
||||
},
|
||||
"required": ["id", "time", "type", "outcome"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Session.Message.Info": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -18603,9 +18425,6 @@
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Session.Message.Compaction"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Session.Message.Idle"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -3242,152 +3242,6 @@
|
||||
"summary": "Get session context"
|
||||
}
|
||||
},
|
||||
"/api/session/{sessionID}/diff": {
|
||||
"get": {
|
||||
"tags": ["session"],
|
||||
"operationId": "v2.session.diff",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "sessionID",
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"pattern": "^ses"
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "messageID",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"pattern": "^msg_"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "User message whose turn to diff. Defaults to the turn of the newest user message."
|
||||
},
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "to",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"pattern": "^msg_"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Later user message whose turn ends the range. Defaults to the turn of `messageID` alone."
|
||||
},
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "context",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Unchanged lines around each hunk. Omit for full-file patches."
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/FileDiff.Info"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["data"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "MessageNotFoundError | SessionNotFoundError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/MessageNotFoundErrorEncoded"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "UnknownError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnknownErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Structured per-file diffs of the files a turn changed. A turn runs from the first prompt after the session was last idle until its next idle marker, so prompts steered in while it was busy belong to the same turn; `to` extends the range through a later turn. Compares the range's first recorded snapshot with its last; a step still running in the active session compares against the working copy. Ranges that span a location change are rejected. In sessions without any idle marker, a prompt's turn spans until the next user message.",
|
||||
"summary": "Diff session turns"
|
||||
}
|
||||
},
|
||||
"/api/session/{sessionID}/inbox": {
|
||||
"get": {
|
||||
"tags": ["session"],
|
||||
@@ -18540,38 +18394,6 @@
|
||||
"required": ["type", "id", "time", "status", "reason", "summary", "recent"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Session.Message.Idle": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^msg_"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object"
|
||||
},
|
||||
"time": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"created": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": ["created"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["idle"]
|
||||
},
|
||||
"outcome": {
|
||||
"type": "string",
|
||||
"enum": ["succeeded", "failed", "interrupted"]
|
||||
}
|
||||
},
|
||||
"required": ["id", "time", "type", "outcome"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Session.Message.Info": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -18603,9 +18425,6 @@
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Session.Message.Compaction"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Session.Message.Idle"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -1151,6 +1151,24 @@ effect: (ctx) =>
|
||||
}),
|
||||
```
|
||||
|
||||
WebSocket providers do not issue one HTTP request per model call, so the HTTP hooks never see that traffic. Three
|
||||
experimental hooks cover it: `experimental.ws.handshake` runs once per model call with the URL and headers the connection
|
||||
needs (changing either reopens the session's socket), `experimental.ws.send` runs on the outbound frame, and
|
||||
`experimental.ws.receive` on every inbound frame. Incremental `send` frames carry only what changed since the provider's
|
||||
last checkpoint; rewriting them changes what the provider sees without changing what OpenCode believes it sent.
|
||||
|
||||
```ts
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* ctx.session.hook("experimental.ws.handshake", (event) =>
|
||||
Effect.sync(() => {
|
||||
event.headers.authorization = `Bearer ${token}`
|
||||
}),
|
||||
)
|
||||
yield* ctx.session.hook("experimental.ws.receive", (event) => Effect.log(event.frame))
|
||||
}),
|
||||
```
|
||||
|
||||
Override the retry decision for a provider failure or replace its delay in milliseconds. The hook runs after OpenCode
|
||||
classifies the failure and proposes its policy, but before any retry is scheduled. It does not expose how OpenCode
|
||||
internally performs the next attempt.
|
||||
@@ -1191,6 +1209,9 @@ interface SessionHooks {
|
||||
readonly "model.request": SessionModelRequest
|
||||
readonly "http.request": SessionHttpRequest
|
||||
readonly "http.response": SessionHttpResponse
|
||||
readonly "experimental.ws.handshake": SessionWebSocketHandshake
|
||||
readonly "experimental.ws.send": SessionWebSocketSend
|
||||
readonly "experimental.ws.receive": SessionWebSocketReceive
|
||||
readonly retry: SessionRetry
|
||||
}
|
||||
|
||||
|
||||
@@ -1294,6 +1294,32 @@ await ctx.session.hook("http.response", (event) => {
|
||||
})
|
||||
```
|
||||
|
||||
#### WebSocket (experimental)
|
||||
|
||||
Providers that stream over a WebSocket do not issue one HTTP request per model call, so `http.request` and
|
||||
`http.response` never see that traffic. Three experimental hooks cover it instead. `experimental.ws.handshake` runs
|
||||
once per model call with the URL and headers the connection needs; changing either reopens the session's socket.
|
||||
`experimental.ws.send` runs on the outbound frame, and `experimental.ws.receive` on every inbound frame. All three carry
|
||||
the same `sessionID`, `agent`, `model`, and `kind` as the HTTP hooks.
|
||||
|
||||
```ts
|
||||
await ctx.session.hook("experimental.ws.handshake", (event) => {
|
||||
event.headers.authorization = `Bearer ${token}`
|
||||
})
|
||||
|
||||
await ctx.session.hook("experimental.ws.send", (event) => {
|
||||
if (event.mode === "full") event.frame = redact(event.frame)
|
||||
})
|
||||
|
||||
await ctx.session.hook("experimental.ws.receive", (event) => {
|
||||
log(event.frame)
|
||||
})
|
||||
```
|
||||
|
||||
`send` frames in `"incremental"` mode carry only what changed since the provider's last checkpoint. Rewriting them
|
||||
changes what the provider sees without changing what OpenCode believes it sent, so treat them as read-only unless you
|
||||
also handle the resulting drift.
|
||||
|
||||
#### Retry policy
|
||||
|
||||
Override the retry decision for a provider failure or replace its delay in milliseconds. The hook runs after OpenCode
|
||||
@@ -1339,6 +1365,9 @@ interface SessionHooks {
|
||||
"model.request": SessionModelRequestHook
|
||||
"http.request": SessionHttpRequestHook
|
||||
"http.response": SessionHttpResponseHook
|
||||
"experimental.ws.handshake": SessionWebSocketHandshakeHook
|
||||
"experimental.ws.send": SessionWebSocketSendHook
|
||||
"experimental.ws.receive": SessionWebSocketReceiveHook
|
||||
retry: SessionRetryHook
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user