mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-03 00:36:20 -04:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b353795792 | |||
| 358d4746a9 | |||
| ce2e301e24 | |||
| fbe7f26e71 | |||
| ecb5754f4c | |||
| 36f8cb7054 | |||
| 4c45a5cf23 |
@@ -108,7 +108,6 @@
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/server": "workspace:*",
|
||||
"@opencode-ai/tui": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -1006,7 +1005,6 @@
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/simulation": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
|
||||
@@ -36,7 +36,6 @@
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/server": "workspace:*",
|
||||
"@opencode-ai/tui": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { EOL } from "os"
|
||||
import { Effect } from "effect"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||
import { OpenCode } from "@opencode-ai/client"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { Service } from "@opencode-ai/client/effect"
|
||||
@@ -12,11 +12,11 @@ export default Runtime.handler(
|
||||
const options = yield* ServiceConfig.options()
|
||||
const found = yield* Service.discover(options)
|
||||
const endpoint = found ?? (yield* Service.start(options))
|
||||
const client = createOpencodeClient({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
const response = yield* Effect.promise(() => client.v2.agent.list({ location: { directory: process.cwd() } }))
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
const response = yield* Effect.promise(() => client.agent.list({ location: { directory: process.cwd() } }))
|
||||
process.stdout.write(
|
||||
JSON.stringify(
|
||||
response.data?.data.toSorted((a, b) => a.id.localeCompare(b.id)),
|
||||
response.data.toSorted((a, b) => a.id.localeCompare(b.id)),
|
||||
null,
|
||||
2,
|
||||
) + EOL,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { EOL } from "node:os"
|
||||
import { Effect } from "effect"
|
||||
import {
|
||||
createOpencodeClient,
|
||||
OpenCode,
|
||||
type IntegrationAttemptStatus,
|
||||
type IntegrationOAuthMethod,
|
||||
type OpencodeClient,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
type OpenCodeClient,
|
||||
} from "@opencode-ai/client"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { Service } from "@opencode-ai/client/effect"
|
||||
@@ -20,7 +20,7 @@ export default Runtime.handler(
|
||||
const options = yield* ServiceConfig.options()
|
||||
const found = yield* Service.discover(options)
|
||||
const endpoint = found ?? (yield* Service.start(options))
|
||||
const client = createOpencodeClient({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
|
||||
const integration = yield* resolveIntegration(client, input.name, location)
|
||||
if (!integration)
|
||||
@@ -32,10 +32,9 @@ export default Runtime.handler(
|
||||
return yield* Effect.fail(new Error(`MCP server "${input.name}" is not an OAuth-capable remote server`))
|
||||
|
||||
const started = yield* Effect.promise(() =>
|
||||
client.v2.integration.connect.oauth({ integrationID: integration.id, methodID: method.id, inputs: {}, location }),
|
||||
client.integration.connect.oauth({ integrationID: integration.id, methodID: method.id, inputs: {}, location }),
|
||||
)
|
||||
const attempt = started.data?.data
|
||||
if (!attempt) return yield* Effect.fail(new Error(started.error?.message ?? "Failed to start OAuth attempt"))
|
||||
const attempt = started.data
|
||||
if (attempt.mode === "code")
|
||||
return yield* Effect.fail(new Error("This server requires manual code entry, which the CLI does not support"))
|
||||
|
||||
@@ -52,13 +51,14 @@ export default Runtime.handler(
|
||||
)
|
||||
|
||||
const poll = (
|
||||
client: OpencodeClient,
|
||||
client: OpenCodeClient,
|
||||
attemptID: string,
|
||||
): Effect.Effect<Exclude<IntegrationAttemptStatus, { status: "pending" }>> =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* Effect.promise(() => client.v2.integration.attempt.status({ attemptID, location }))
|
||||
const status = response.data?.data
|
||||
if (!status || status.status === "pending") {
|
||||
const status = yield* Effect.promise(() => client.integration.attempt.status({ attemptID, location })).pipe(
|
||||
Effect.map((result) => result.data),
|
||||
)
|
||||
if (status.status === "pending") {
|
||||
yield* Effect.sleep("1 second")
|
||||
return yield* poll(client, attemptID)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { EOL } from "node:os"
|
||||
import { Effect } from "effect"
|
||||
import { createOpencodeClient, type McpServer } from "@opencode-ai/sdk/v2/client"
|
||||
import { OpenCode, type McpServer } from "@opencode-ai/client"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { Service } from "@opencode-ai/client/effect"
|
||||
@@ -12,9 +12,9 @@ export default Runtime.handler(
|
||||
const options = yield* ServiceConfig.options()
|
||||
const found = yield* Service.discover(options)
|
||||
const endpoint = found ?? (yield* Service.start(options))
|
||||
const client = createOpencodeClient({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
const response = yield* Effect.promise(() => client.v2.mcp.list({ location: { directory: process.cwd() } }))
|
||||
const servers = (response.data?.data ?? []).toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
const response = yield* Effect.promise(() => client.mcp.list({ location: { directory: process.cwd() } }))
|
||||
const servers = response.data.toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
if (servers.length === 0) {
|
||||
process.stdout.write("No MCP servers configured" + EOL)
|
||||
return
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { EOL } from "node:os"
|
||||
import { Effect } from "effect"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||
import { OpenCode } from "@opencode-ai/client"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { Service } from "@opencode-ai/client/effect"
|
||||
@@ -15,7 +15,7 @@ export default Runtime.handler(
|
||||
const options = yield* ServiceConfig.options()
|
||||
const found = yield* Service.discover(options)
|
||||
const endpoint = found ?? (yield* Service.start(options))
|
||||
const client = createOpencodeClient({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
|
||||
const integration = yield* resolveIntegration(client, input.name, location)
|
||||
if (!integration) {
|
||||
@@ -31,7 +31,7 @@ export default Runtime.handler(
|
||||
|
||||
yield* Effect.forEach(
|
||||
credentials,
|
||||
(connection) => Effect.promise(() => client.v2.credential.remove({ credentialID: connection.id, location })),
|
||||
(connection) => Effect.promise(() => client.credential.remove({ credentialID: connection.id, location })),
|
||||
{ discard: true },
|
||||
)
|
||||
process.stdout.write(`Removed OAuth credentials for ${input.name}` + EOL)
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { Effect } from "effect"
|
||||
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||
import type { OpenCodeClient } from "@opencode-ai/client"
|
||||
|
||||
// Resolve through the MCP-owned integrationID rather than matching integration names: the shared
|
||||
// integration registry also holds provider/plugin integrations, whose names could collide with a server.
|
||||
// Fails when the server is unknown; returns undefined when the server has no integration (e.g. a local
|
||||
// or anonymous server), leaving that case for the caller to interpret.
|
||||
export const resolveIntegration = (client: OpencodeClient, name: string, location: { directory: string }) =>
|
||||
export const resolveIntegration = (client: OpenCodeClient, name: string, location: { directory: string }) =>
|
||||
Effect.gen(function* () {
|
||||
const servers = yield* Effect.promise(() => client.v2.mcp.list({ location }))
|
||||
const server = (servers.data?.data ?? []).find((entry) => entry.name === name)
|
||||
const servers = yield* Effect.promise(() => client.mcp.list({ location }))
|
||||
const server = servers.data.find((entry) => entry.name === name)
|
||||
if (!server) return yield* Effect.fail(new Error(`MCP server not found: ${name}`))
|
||||
const integrationID = server.integrationID
|
||||
if (!integrationID) return undefined
|
||||
const found = yield* Effect.promise(() => client.v2.integration.get({ integrationID, location }))
|
||||
return found.data?.data
|
||||
return yield* Effect.promise(() => client.integration.get({ integrationID, location })).pipe(
|
||||
Effect.map((result) => result.data ?? undefined),
|
||||
)
|
||||
})
|
||||
|
||||
+121
-298
@@ -1,7 +1,7 @@
|
||||
// Demo mode for testing direct interactive mode without a real SDK.
|
||||
//
|
||||
// Enabled with `--demo`. Intercepts prompt submissions and generates synthetic
|
||||
// SDK events that feed through the real reducer and footer pipeline. This
|
||||
// Enabled with `--demo`. Intercepts prompt submissions and drives the same
|
||||
// presentation commits and footer actions as the live transport. This
|
||||
// lets you test scrollback formatting, permission UI, question UI, and tool
|
||||
// snapshots without making actual model calls. Pass a demo slash command as
|
||||
// the initial interactive message to trigger a preview immediately.
|
||||
@@ -15,10 +15,18 @@
|
||||
// Demo mode also handles permission and question replies locally, completing
|
||||
// or failing the synthetic tool parts as appropriate.
|
||||
import path from "path"
|
||||
import type { Event, ToolPart } from "@opencode-ai/sdk/v2"
|
||||
import { createSessionData, reduceSessionData, type SessionData } from "./session-data"
|
||||
import type { PermissionV2Request, QuestionV2Request } from "@opencode-ai/client/promise"
|
||||
import { writeSessionOutput } from "./stream"
|
||||
import type { FooterApi, PermissionReply, QuestionReject, QuestionReply, RunPrompt, StreamCommit } from "./types"
|
||||
import { toolCommit } from "./stream-v2.subagent"
|
||||
import type {
|
||||
FooterApi,
|
||||
MiniToolPart,
|
||||
PermissionReply,
|
||||
QuestionReject,
|
||||
QuestionReply,
|
||||
RunPrompt,
|
||||
StreamCommit,
|
||||
} from "./types"
|
||||
|
||||
const KINDS = [
|
||||
"markdown",
|
||||
@@ -124,7 +132,7 @@ type Permit = {
|
||||
ref: Ref
|
||||
permission: string
|
||||
patterns: string[]
|
||||
metadata?: Record<string, unknown>
|
||||
metadata?: PermissionV2Request["metadata"]
|
||||
always: string[]
|
||||
done: Perm["done"]
|
||||
}
|
||||
@@ -132,9 +140,7 @@ type Permit = {
|
||||
type State = {
|
||||
id: string
|
||||
thinking: boolean
|
||||
data: SessionData
|
||||
footer: FooterApi
|
||||
limits: () => Record<string, number>
|
||||
msg: number
|
||||
part: number
|
||||
call: number
|
||||
@@ -142,12 +148,12 @@ type State = {
|
||||
ask: number
|
||||
perms: Map<string, Perm>
|
||||
asks: Map<string, Ask>
|
||||
started: Set<string>
|
||||
}
|
||||
|
||||
type Input = {
|
||||
sessionID: string
|
||||
thinking: boolean
|
||||
limits: () => Record<string, number>
|
||||
footer: FooterApi
|
||||
}
|
||||
|
||||
@@ -255,185 +261,69 @@ function take(state: State, key: "msg" | "part" | "call" | "perm" | "ask", prefi
|
||||
return `demo_${prefix}_${state[key]}`
|
||||
}
|
||||
|
||||
function feed(state: State, event: Event): void {
|
||||
const out = reduceSessionData({
|
||||
data: state.data,
|
||||
event,
|
||||
sessionID: state.id,
|
||||
thinking: state.thinking,
|
||||
limits: state.limits(),
|
||||
})
|
||||
state.data = out.data
|
||||
function present(state: State, commits: StreamCommit[], view?: QuestionV2Request | PermissionV2Request): void {
|
||||
writeSessionOutput(
|
||||
{ footer: state.footer },
|
||||
{
|
||||
footer: state.footer,
|
||||
commits,
|
||||
footer: view
|
||||
? {
|
||||
view: "action" in view ? { type: "permission", request: view } : { type: "question", request: view },
|
||||
patch: { status: "action" in view ? "awaiting permission" : "awaiting answer" },
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
out,
|
||||
)
|
||||
}
|
||||
|
||||
function clearBlocker(state: State): void {
|
||||
writeSessionOutput(
|
||||
{ footer: state.footer },
|
||||
{ commits: [], footer: { view: { type: "prompt" }, patch: { status: "" } } },
|
||||
)
|
||||
}
|
||||
|
||||
function open(state: State): string {
|
||||
const id = take(state, "msg", "msg")
|
||||
feed(state, {
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
sessionID: state.id,
|
||||
info: {
|
||||
id,
|
||||
sessionID: state.id,
|
||||
role: "assistant",
|
||||
time: {
|
||||
created: Date.now(),
|
||||
},
|
||||
parentID: `user_${id}`,
|
||||
modelID: "demo",
|
||||
providerID: "demo",
|
||||
mode: "demo",
|
||||
agent: "demo",
|
||||
path: {
|
||||
cwd: process.cwd(),
|
||||
root: process.cwd(),
|
||||
},
|
||||
cost: 0.001,
|
||||
tokens: {
|
||||
input: 120,
|
||||
output: 320,
|
||||
reasoning: 80,
|
||||
cache: {
|
||||
read: 0,
|
||||
write: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as Event)
|
||||
return id
|
||||
return take(state, "msg", "msg")
|
||||
}
|
||||
|
||||
async function emitText(state: State, body: string, signal?: AbortSignal): Promise<void> {
|
||||
const msg = open(state)
|
||||
const part = take(state, "part", "part")
|
||||
const start = Date.now()
|
||||
|
||||
feed(state, {
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
sessionID: state.id,
|
||||
time: Date.now(),
|
||||
part: {
|
||||
id: part,
|
||||
sessionID: state.id,
|
||||
messageID: msg,
|
||||
type: "text",
|
||||
text: "",
|
||||
time: {
|
||||
start,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as Event)
|
||||
|
||||
let next = ""
|
||||
for (const item of split(body)) {
|
||||
if (signal?.aborted) {
|
||||
return
|
||||
}
|
||||
|
||||
next += item
|
||||
feed(state, {
|
||||
type: "message.part.delta",
|
||||
properties: {
|
||||
sessionID: state.id,
|
||||
messageID: msg,
|
||||
partID: part,
|
||||
field: "text",
|
||||
delta: item,
|
||||
},
|
||||
} as Event)
|
||||
present(state, [{ kind: "assistant", source: "assistant", text: item, phase: "progress", messageID: msg, partID: part }])
|
||||
await wait(45, signal)
|
||||
}
|
||||
|
||||
feed(state, {
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
sessionID: state.id,
|
||||
time: Date.now(),
|
||||
part: {
|
||||
id: part,
|
||||
sessionID: state.id,
|
||||
messageID: msg,
|
||||
type: "text",
|
||||
text: next,
|
||||
time: {
|
||||
start,
|
||||
end: Date.now(),
|
||||
},
|
||||
},
|
||||
},
|
||||
} as Event)
|
||||
}
|
||||
|
||||
async function emitReasoning(state: State, body: string, signal?: AbortSignal): Promise<void> {
|
||||
const msg = open(state)
|
||||
const part = take(state, "part", "part")
|
||||
const start = Date.now()
|
||||
|
||||
feed(state, {
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
sessionID: state.id,
|
||||
time: Date.now(),
|
||||
part: {
|
||||
id: part,
|
||||
sessionID: state.id,
|
||||
messageID: msg,
|
||||
type: "reasoning",
|
||||
text: "",
|
||||
time: {
|
||||
start,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as Event)
|
||||
|
||||
let next = ""
|
||||
let first = true
|
||||
for (const item of split(body)) {
|
||||
if (signal?.aborted) {
|
||||
return
|
||||
}
|
||||
|
||||
next += item
|
||||
feed(state, {
|
||||
type: "message.part.delta",
|
||||
properties: {
|
||||
sessionID: state.id,
|
||||
messageID: msg,
|
||||
partID: part,
|
||||
field: "text",
|
||||
delta: item,
|
||||
},
|
||||
} as Event)
|
||||
if (state.thinking) {
|
||||
present(state, [
|
||||
{
|
||||
kind: "reasoning",
|
||||
source: "reasoning",
|
||||
text: first ? `Thinking: ${item.replace(/\[REDACTED\]/g, "")}` : item.replace(/\[REDACTED\]/g, ""),
|
||||
phase: "progress",
|
||||
messageID: msg,
|
||||
partID: part,
|
||||
},
|
||||
])
|
||||
first = false
|
||||
}
|
||||
await wait(45, signal)
|
||||
}
|
||||
|
||||
feed(state, {
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
sessionID: state.id,
|
||||
time: Date.now(),
|
||||
part: {
|
||||
id: part,
|
||||
sessionID: state.id,
|
||||
messageID: msg,
|
||||
type: "reasoning",
|
||||
text: next,
|
||||
time: {
|
||||
start,
|
||||
end: Date.now(),
|
||||
},
|
||||
},
|
||||
},
|
||||
} as Event)
|
||||
}
|
||||
|
||||
function make(state: State, tool: string, input: Record<string, unknown>): Ref {
|
||||
@@ -448,29 +338,23 @@ function make(state: State, tool: string, input: Record<string, unknown>): Ref {
|
||||
}
|
||||
|
||||
function startTool(state: State, ref: Ref, metadata: Record<string, unknown> = {}): void {
|
||||
feed(state, {
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
sessionID: state.id,
|
||||
time: Date.now(),
|
||||
part: {
|
||||
id: ref.part,
|
||||
sessionID: state.id,
|
||||
messageID: ref.msg,
|
||||
type: "tool",
|
||||
callID: ref.call,
|
||||
tool: ref.tool,
|
||||
state: {
|
||||
status: "running",
|
||||
input: ref.input,
|
||||
metadata,
|
||||
time: {
|
||||
start: ref.start,
|
||||
},
|
||||
state.started.add(ref.part)
|
||||
present(
|
||||
state,
|
||||
[
|
||||
toolCommit(
|
||||
{
|
||||
id: ref.part,
|
||||
sessionID: state.id,
|
||||
messageID: ref.msg,
|
||||
callID: ref.call,
|
||||
tool: ref.tool,
|
||||
state: { status: "running", input: ref.input, metadata, time: { start: ref.start } },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as Event)
|
||||
"start",
|
||||
),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
function askPermission(state: State, item: Permit): void {
|
||||
@@ -482,21 +366,15 @@ function askPermission(state: State, item: Permit): void {
|
||||
done: item.done,
|
||||
})
|
||||
|
||||
feed(state, {
|
||||
type: "permission.asked",
|
||||
properties: {
|
||||
id,
|
||||
sessionID: state.id,
|
||||
permission: item.permission,
|
||||
patterns: item.patterns,
|
||||
metadata: item.metadata ?? {},
|
||||
always: item.always,
|
||||
tool: {
|
||||
messageID: item.ref.msg,
|
||||
callID: item.ref.call,
|
||||
},
|
||||
},
|
||||
} as Event)
|
||||
present(state, [], {
|
||||
id,
|
||||
sessionID: state.id,
|
||||
action: item.permission,
|
||||
resources: item.patterns,
|
||||
metadata: item.metadata ?? {},
|
||||
save: item.always,
|
||||
source: { type: "tool", messageID: item.ref.msg, callID: item.ref.call },
|
||||
})
|
||||
}
|
||||
|
||||
function doneTool(
|
||||
@@ -508,77 +386,53 @@ function doneTool(
|
||||
metadata?: Record<string, unknown>
|
||||
},
|
||||
): void {
|
||||
feed(state, {
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
sessionID: state.id,
|
||||
time: Date.now(),
|
||||
part: {
|
||||
id: ref.part,
|
||||
sessionID: state.id,
|
||||
messageID: ref.msg,
|
||||
type: "tool",
|
||||
callID: ref.call,
|
||||
tool: ref.tool,
|
||||
state: {
|
||||
status: "completed",
|
||||
input: ref.input,
|
||||
output: output.output,
|
||||
title: output.title,
|
||||
metadata: output.metadata ?? {},
|
||||
time: {
|
||||
start: ref.start,
|
||||
end: Date.now(),
|
||||
},
|
||||
},
|
||||
},
|
||||
if (!state.started.has(ref.part)) startTool(state, ref)
|
||||
const part: MiniToolPart = {
|
||||
id: ref.part,
|
||||
sessionID: state.id,
|
||||
messageID: ref.msg,
|
||||
callID: ref.call,
|
||||
tool: ref.tool,
|
||||
state: {
|
||||
status: "completed",
|
||||
input: ref.input,
|
||||
output: output.output,
|
||||
title: output.title,
|
||||
metadata: output.metadata ?? {},
|
||||
time: { start: ref.start, end: Date.now() },
|
||||
},
|
||||
} as Event)
|
||||
}
|
||||
present(state, [toolCommit(part, output.output ? "progress" : "final")])
|
||||
}
|
||||
|
||||
function failTool(state: State, ref: Ref, error: string): void {
|
||||
feed(state, {
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
sessionID: state.id,
|
||||
time: Date.now(),
|
||||
part: {
|
||||
id: ref.part,
|
||||
sessionID: state.id,
|
||||
messageID: ref.msg,
|
||||
type: "tool",
|
||||
callID: ref.call,
|
||||
tool: ref.tool,
|
||||
state: {
|
||||
status: "error",
|
||||
input: ref.input,
|
||||
error,
|
||||
metadata: {},
|
||||
time: {
|
||||
start: ref.start,
|
||||
end: Date.now(),
|
||||
if (!state.started.has(ref.part)) startTool(state, ref)
|
||||
present(
|
||||
state,
|
||||
[
|
||||
toolCommit(
|
||||
{
|
||||
id: ref.part,
|
||||
sessionID: state.id,
|
||||
messageID: ref.msg,
|
||||
callID: ref.call,
|
||||
tool: ref.tool,
|
||||
state: {
|
||||
status: "error",
|
||||
input: ref.input,
|
||||
error,
|
||||
metadata: {},
|
||||
time: { start: ref.start, end: Date.now() },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as Event)
|
||||
"final",
|
||||
),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
function emitError(state: State, text: string): void {
|
||||
const event = {
|
||||
id: `session.error:${state.id}:${Date.now()}`,
|
||||
type: "session.error",
|
||||
properties: {
|
||||
sessionID: state.id,
|
||||
error: {
|
||||
name: "UnknownError",
|
||||
data: {
|
||||
message: text,
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Event
|
||||
feed(state, event)
|
||||
present(state, [{ kind: "error", source: "system", text, phase: "start" }])
|
||||
}
|
||||
|
||||
async function emitBash(state: State, signal?: AbortSignal): Promise<void> {
|
||||
@@ -685,7 +539,7 @@ function emitTask(state: State): void {
|
||||
start: Date.now(),
|
||||
},
|
||||
},
|
||||
} satisfies ToolPart
|
||||
} satisfies MiniToolPart
|
||||
showSubagent(state, {
|
||||
sessionID: "sub_demo_1",
|
||||
partID: ref.part,
|
||||
@@ -979,18 +833,12 @@ function emitQuestion(state: State, kind: QuestionKind = "multi"): void {
|
||||
const id = take(state, "ask", "ask")
|
||||
state.asks.set(id, { ref })
|
||||
|
||||
feed(state, {
|
||||
type: "question.asked",
|
||||
properties: {
|
||||
id,
|
||||
sessionID: state.id,
|
||||
questions,
|
||||
tool: {
|
||||
messageID: ref.msg,
|
||||
callID: ref.call,
|
||||
},
|
||||
},
|
||||
} as Event)
|
||||
present(state, [], {
|
||||
id,
|
||||
sessionID: state.id,
|
||||
questions,
|
||||
tool: { messageID: ref.msg, callID: ref.call },
|
||||
})
|
||||
}
|
||||
|
||||
async function emitFmt(state: State, kind: string, body: string, signal?: AbortSignal): Promise<boolean> {
|
||||
@@ -1089,9 +937,7 @@ export function createRunDemo(input: Input) {
|
||||
const state: State = {
|
||||
id: input.sessionID,
|
||||
thinking: input.thinking,
|
||||
data: createSessionData(),
|
||||
footer: input.footer,
|
||||
limits: input.limits,
|
||||
msg: 0,
|
||||
part: 0,
|
||||
call: 0,
|
||||
@@ -1099,6 +945,7 @@ export function createRunDemo(input: Input) {
|
||||
ask: 0,
|
||||
perms: new Map(),
|
||||
asks: new Map(),
|
||||
started: new Set(),
|
||||
}
|
||||
|
||||
const start = async (): Promise<void> => {
|
||||
@@ -1166,16 +1013,7 @@ export function createRunDemo(input: Input) {
|
||||
}
|
||||
|
||||
state.perms.delete(input.requestID)
|
||||
const event = {
|
||||
id: `permission.replied:${input.requestID}:${Date.now()}`,
|
||||
type: "permission.replied",
|
||||
properties: {
|
||||
sessionID: state.id,
|
||||
requestID: input.requestID,
|
||||
reply: input.reply,
|
||||
},
|
||||
} satisfies Event
|
||||
feed(state, event)
|
||||
clearBlocker(state)
|
||||
|
||||
if (input.reply === "reject") {
|
||||
failTool(state, item.ref, input.message || "permission rejected")
|
||||
@@ -1193,16 +1031,7 @@ export function createRunDemo(input: Input) {
|
||||
}
|
||||
|
||||
state.asks.delete(input.requestID)
|
||||
const event = {
|
||||
id: `question.replied:${input.requestID}:${Date.now()}`,
|
||||
type: "question.replied",
|
||||
properties: {
|
||||
sessionID: state.id,
|
||||
requestID: input.requestID,
|
||||
answers: input.answers,
|
||||
},
|
||||
} satisfies Event
|
||||
feed(state, event)
|
||||
clearBlocker(state)
|
||||
doneTool(state, ask.ref, {
|
||||
title: "question",
|
||||
output: "",
|
||||
@@ -1220,13 +1049,7 @@ export function createRunDemo(input: Input) {
|
||||
}
|
||||
|
||||
state.asks.delete(input.requestID)
|
||||
feed(state, {
|
||||
type: "question.rejected",
|
||||
properties: {
|
||||
sessionID: state.id,
|
||||
requestID: input.requestID,
|
||||
},
|
||||
} as Event)
|
||||
clearBlocker(state)
|
||||
failTool(state, ask.ref, "question rejected")
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
import type { TextareaRenderable } from "@opentui/core"
|
||||
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
|
||||
import { For, Match, Show, Switch, createEffect, createMemo, createSignal } from "solid-js"
|
||||
import type { PermissionRequest } from "@opencode-ai/sdk/v2"
|
||||
import type { PermissionV2Request } from "@opencode-ai/client/promise"
|
||||
import {
|
||||
createPermissionBodyState,
|
||||
permissionAlwaysLines,
|
||||
@@ -130,7 +130,7 @@ export function RejectField(props: {
|
||||
}
|
||||
|
||||
export function RunPermissionBody(props: {
|
||||
request: PermissionRequest
|
||||
request: PermissionV2Request
|
||||
theme: RunFooterTheme
|
||||
block: RunBlockTheme
|
||||
diffStyle?: RunDiffStyle
|
||||
@@ -142,7 +142,7 @@ export function RunPermissionBody(props: {
|
||||
const ft = createMemo(() => toolFiletype(info().file))
|
||||
const narrow = createMemo(() => footerWidthPolicy(dims().width).dialog.narrow)
|
||||
const opts = createMemo(() =>
|
||||
permissionOptions(state().stage).filter((option) => option !== "always" || props.request.always.length > 0),
|
||||
permissionOptions(state().stage).filter((option) => option !== "always" || (props.request.save?.length ?? 0) > 0),
|
||||
)
|
||||
const busy = createMemo(() => state().submitting)
|
||||
const title = createMemo(() => {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
import type { TextareaRenderable } from "@opentui/core"
|
||||
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
|
||||
import { For, Show, createEffect, createMemo, createSignal } from "solid-js"
|
||||
import type { QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||
import type { QuestionV2Request } from "@opencode-ai/client/promise"
|
||||
import {
|
||||
createQuestionBodyState,
|
||||
questionConfirm,
|
||||
@@ -45,7 +45,7 @@ import type { RunFooterTheme } from "./theme"
|
||||
import type { QuestionReject, QuestionReply } from "./types"
|
||||
|
||||
export function RunQuestionBody(props: {
|
||||
request: QuestionRequest
|
||||
request: QuestionV2Request
|
||||
theme: RunFooterTheme
|
||||
onReply: (input: QuestionReply) => void | Promise<void>
|
||||
onReject: (input: QuestionReject) => void | Promise<void>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { EventSubscribeOutput, OpenCodeClient } from "@opencode-ai/client/promise"
|
||||
import type { ReasoningPart, StepFinishPart, StepStartPart, TextPart, ToolPart } from "@opencode-ai/sdk/v2"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { EOL } from "node:os"
|
||||
import { UI } from "./ui"
|
||||
import type { MiniToolPart } from "./types"
|
||||
|
||||
type Model = {
|
||||
providerID: string
|
||||
@@ -28,8 +28,8 @@ type Input = {
|
||||
auto: boolean
|
||||
/** True when the client is attached to a shared server rather than an exclusive in-process one. */
|
||||
attached: boolean
|
||||
renderTool: (part: ToolPart) => Promise<void>
|
||||
renderToolError: (part: ToolPart) => Promise<void>
|
||||
renderTool: (part: MiniToolPart) => Promise<void>
|
||||
renderToolError: (part: MiniToolPart) => Promise<void>
|
||||
}
|
||||
|
||||
type StartedPart = {
|
||||
@@ -77,7 +77,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||
return true
|
||||
}
|
||||
|
||||
const writeText = (part: TextPart, timestamp: number) => {
|
||||
const writeText = (part: { text: string; [key: string]: unknown }, timestamp: number) => {
|
||||
if (emit("text", timestamp, { part })) return
|
||||
const text = part.text.trim()
|
||||
if (!text) return
|
||||
@@ -169,7 +169,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||
if (!promoted) continue
|
||||
|
||||
if (event.type === "session.step.started") {
|
||||
const part: StepStartPart = {
|
||||
const part = {
|
||||
id: partID(event.id),
|
||||
sessionID: input.sessionID,
|
||||
messageID: event.data.assistantMessageID,
|
||||
@@ -191,7 +191,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||
if (event.type === "session.text.ended") {
|
||||
const started = starts.get("text")
|
||||
starts.delete("text")
|
||||
const part: TextPart = {
|
||||
const part = {
|
||||
id: started?.id ?? partID(event.id),
|
||||
sessionID: input.sessionID,
|
||||
messageID: event.data.assistantMessageID,
|
||||
@@ -210,7 +210,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||
if (event.type === "session.reasoning.ended" && input.thinking) {
|
||||
const started = starts.get("reasoning")
|
||||
starts.delete("reasoning")
|
||||
const part: ReasoningPart = {
|
||||
const part = {
|
||||
id: started?.id ?? partID(event.id),
|
||||
sessionID: input.sessionID,
|
||||
messageID: event.data.assistantMessageID,
|
||||
@@ -263,7 +263,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||
}
|
||||
if (event.type === "session.tool.success") {
|
||||
const current = tools.get(event.data.callID) ?? fallbackTool(event)
|
||||
const part: ToolPart = {
|
||||
const part: MiniToolPart = {
|
||||
id: current.id,
|
||||
sessionID: input.sessionID,
|
||||
messageID: event.data.assistantMessageID,
|
||||
@@ -296,7 +296,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||
if (event.type === "session.tool.failed") {
|
||||
const current = tools.get(event.data.callID) ?? fallbackTool(event)
|
||||
const error = event.data.error.message
|
||||
const part: ToolPart = {
|
||||
const part: MiniToolPart = {
|
||||
id: current.id,
|
||||
sessionID: input.sessionID,
|
||||
messageID: event.data.assistantMessageID,
|
||||
@@ -325,7 +325,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||
}
|
||||
|
||||
if (event.type === "session.step.ended") {
|
||||
const part: StepFinishPart = {
|
||||
const part = {
|
||||
id: partID(event.id),
|
||||
sessionID: input.sessionID,
|
||||
messageID: event.data.assistantMessageID,
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
//
|
||||
// permissionInfo() extracts display info (icon, title, lines, diff) from
|
||||
// the request, delegating to tool.ts for tool-specific formatting.
|
||||
import type { PermissionRequest } from "@opencode-ai/sdk/v2"
|
||||
import type { PermissionV2Request } from "@opencode-ai/client/promise"
|
||||
import type { PermissionReply } from "./types"
|
||||
import { toolPath, toolPermissionInfo } from "./tool"
|
||||
|
||||
@@ -55,7 +55,7 @@ function text(v: unknown): string {
|
||||
return typeof v === "string" ? v : ""
|
||||
}
|
||||
|
||||
function data(request: PermissionRequest): Dict {
|
||||
function data(request: PermissionV2Request): Dict {
|
||||
const meta = dict(request.metadata)
|
||||
return {
|
||||
...meta,
|
||||
@@ -63,8 +63,8 @@ function data(request: PermissionRequest): Dict {
|
||||
}
|
||||
}
|
||||
|
||||
function patterns(request: PermissionRequest): string[] {
|
||||
return request.patterns.filter((item): item is string => typeof item === "string")
|
||||
function patterns(request: PermissionV2Request): string[] {
|
||||
return request.resources.filter((item): item is string => typeof item === "string")
|
||||
}
|
||||
|
||||
export function createPermissionBodyState(requestID: string): PermissionBodyState {
|
||||
@@ -89,15 +89,15 @@ export function permissionOptions(stage: PermissionStage): PermissionOption[] {
|
||||
return []
|
||||
}
|
||||
|
||||
export function permissionInfo(request: PermissionRequest): PermissionInfo {
|
||||
export function permissionInfo(request: PermissionV2Request): PermissionInfo {
|
||||
const pats = patterns(request)
|
||||
const input = data(request)
|
||||
const info = toolPermissionInfo(request.permission, input, dict(request.metadata), pats)
|
||||
const info = toolPermissionInfo(request.action, input, dict(request.metadata), pats)
|
||||
if (info) {
|
||||
return info
|
||||
}
|
||||
|
||||
if (request.permission === "external_directory") {
|
||||
if (request.action === "external_directory") {
|
||||
const meta = dict(request.metadata)
|
||||
const raw = text(meta.parentDir) || text(meta.filepath) || pats[0] || ""
|
||||
const dir = raw.includes("*") ? raw.slice(0, raw.indexOf("*")).replace(/[\\/]+$/, "") : raw
|
||||
@@ -108,7 +108,7 @@ export function permissionInfo(request: PermissionRequest): PermissionInfo {
|
||||
}
|
||||
}
|
||||
|
||||
if (request.permission === "doom_loop") {
|
||||
if (request.action === "doom_loop") {
|
||||
return {
|
||||
icon: "⟳",
|
||||
title: "Continue after repeated failures",
|
||||
@@ -118,19 +118,20 @@ export function permissionInfo(request: PermissionRequest): PermissionInfo {
|
||||
|
||||
return {
|
||||
icon: "⚙",
|
||||
title: `Call tool ${request.permission}`,
|
||||
lines: [`Tool: ${request.permission}`],
|
||||
title: `Call tool ${request.action}`,
|
||||
lines: [`Tool: ${request.action}`],
|
||||
}
|
||||
}
|
||||
|
||||
export function permissionAlwaysLines(request: PermissionRequest): string[] {
|
||||
if (request.always.length === 1 && request.always[0] === "*") {
|
||||
return [`This will allow ${request.permission} until OpenCode is restarted.`]
|
||||
export function permissionAlwaysLines(request: PermissionV2Request): string[] {
|
||||
const save = request.save ?? []
|
||||
if (save.length === 1 && save[0] === "*") {
|
||||
return [`This will allow ${request.action} until OpenCode is restarted.`]
|
||||
}
|
||||
|
||||
return [
|
||||
"This will allow the following patterns until OpenCode is restarted.",
|
||||
...request.always.map((item) => `- ${item}`),
|
||||
...save.map((item) => `- ${item}`),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
//
|
||||
// Custom answers: if a question has custom=true, an extra "Type your own
|
||||
// answer" option appears. Selecting it enters editing mode with a text field.
|
||||
import type { QuestionInfo, QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||
import type { QuestionV2Info, QuestionV2Request } from "@opencode-ai/client/promise"
|
||||
import type { QuestionReject, QuestionReply } from "./types"
|
||||
|
||||
export type QuestionBodyState = {
|
||||
@@ -51,23 +51,23 @@ export function questionSync(state: QuestionBodyState, requestID: string): Quest
|
||||
return createQuestionBodyState(requestID)
|
||||
}
|
||||
|
||||
export function questionSingle(request: QuestionRequest): boolean {
|
||||
export function questionSingle(request: QuestionV2Request): boolean {
|
||||
return request.questions.length === 1 && request.questions[0]?.multiple !== true
|
||||
}
|
||||
|
||||
export function questionTabs(request: QuestionRequest): number {
|
||||
export function questionTabs(request: QuestionV2Request): number {
|
||||
return questionSingle(request) ? 1 : request.questions.length + 1
|
||||
}
|
||||
|
||||
export function questionConfirm(request: QuestionRequest, state: QuestionBodyState): boolean {
|
||||
export function questionConfirm(request: QuestionV2Request, state: QuestionBodyState): boolean {
|
||||
return !questionSingle(request) && state.tab === request.questions.length
|
||||
}
|
||||
|
||||
export function questionInfo(request: QuestionRequest, state: QuestionBodyState): QuestionInfo | undefined {
|
||||
export function questionInfo(request: QuestionV2Request, state: QuestionBodyState): QuestionV2Info | undefined {
|
||||
return request.questions[state.tab]
|
||||
}
|
||||
|
||||
export function questionCustom(request: QuestionRequest, state: QuestionBodyState): boolean {
|
||||
export function questionCustom(request: QuestionV2Request, state: QuestionBodyState): boolean {
|
||||
return questionInfo(request, state)?.custom !== false
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ export function questionPicked(state: QuestionBodyState): boolean {
|
||||
return state.answers[state.tab]?.includes(value) ?? false
|
||||
}
|
||||
|
||||
export function questionOther(request: QuestionRequest, state: QuestionBodyState): boolean {
|
||||
export function questionOther(request: QuestionV2Request, state: QuestionBodyState): boolean {
|
||||
const info = questionInfo(request, state)
|
||||
if (!info || info.custom === false) {
|
||||
return false
|
||||
@@ -93,7 +93,7 @@ export function questionOther(request: QuestionRequest, state: QuestionBodyState
|
||||
return state.selected === info.options.length
|
||||
}
|
||||
|
||||
export function questionTotal(request: QuestionRequest, state: QuestionBodyState): number {
|
||||
export function questionTotal(request: QuestionV2Request, state: QuestionBodyState): number {
|
||||
const info = questionInfo(request, state)
|
||||
if (!info) {
|
||||
return 0
|
||||
@@ -156,7 +156,7 @@ export function questionStoreCustom(state: QuestionBodyState, tab: number, text:
|
||||
|
||||
function questionPick(
|
||||
state: QuestionBodyState,
|
||||
request: QuestionRequest,
|
||||
request: QuestionV2Request,
|
||||
answer: string,
|
||||
custom = false,
|
||||
): QuestionStep {
|
||||
@@ -204,7 +204,7 @@ function questionToggle(state: QuestionBodyState, answer: string): QuestionBodyS
|
||||
return storeAnswers(state, state.tab, list)
|
||||
}
|
||||
|
||||
export function questionMove(state: QuestionBodyState, request: QuestionRequest, dir: -1 | 1): QuestionBodyState {
|
||||
export function questionMove(state: QuestionBodyState, request: QuestionV2Request, dir: -1 | 1): QuestionBodyState {
|
||||
const total = questionTotal(request, state)
|
||||
if (total === 0) {
|
||||
return state
|
||||
@@ -216,7 +216,7 @@ export function questionMove(state: QuestionBodyState, request: QuestionRequest,
|
||||
}
|
||||
}
|
||||
|
||||
export function questionSelect(state: QuestionBodyState, request: QuestionRequest): QuestionStep {
|
||||
export function questionSelect(state: QuestionBodyState, request: QuestionV2Request): QuestionStep {
|
||||
const info = questionInfo(request, state)
|
||||
if (!info) {
|
||||
return { state }
|
||||
@@ -255,7 +255,7 @@ export function questionSelect(state: QuestionBodyState, request: QuestionReques
|
||||
return questionPick(state, request, option.label)
|
||||
}
|
||||
|
||||
export function questionSave(state: QuestionBodyState, request: QuestionRequest): QuestionStep {
|
||||
export function questionSave(state: QuestionBodyState, request: QuestionV2Request): QuestionStep {
|
||||
const info = questionInfo(request, state)
|
||||
if (!info) {
|
||||
return { state }
|
||||
@@ -305,20 +305,20 @@ export function questionSave(state: QuestionBodyState, request: QuestionRequest)
|
||||
return questionPick(state, request, value, true)
|
||||
}
|
||||
|
||||
export function questionSubmit(request: QuestionRequest, state: QuestionBodyState): QuestionReply {
|
||||
export function questionSubmit(request: QuestionV2Request, state: QuestionBodyState): QuestionReply {
|
||||
return {
|
||||
requestID: request.id,
|
||||
answers: questionAnswers(state, request.questions.length),
|
||||
}
|
||||
}
|
||||
|
||||
export function questionReject(request: QuestionRequest): QuestionReject {
|
||||
export function questionReject(request: QuestionV2Request): QuestionReject {
|
||||
return {
|
||||
requestID: request.id,
|
||||
}
|
||||
}
|
||||
|
||||
export function questionHint(request: QuestionRequest, state: QuestionBodyState): string {
|
||||
export function questionHint(request: QuestionV2Request, state: QuestionBodyState): string {
|
||||
if (state.submitting) {
|
||||
return "Waiting for question event..."
|
||||
}
|
||||
|
||||
@@ -2,13 +2,13 @@ import { Service } from "@opencode-ai/client/effect"
|
||||
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import type { ToolPart } from "@opencode-ai/sdk/v2"
|
||||
import { open } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { Server } from "../services/server"
|
||||
import { loadRunAgents, waitForCatalogReady } from "./catalog.shared"
|
||||
import { runNonInteractivePrompt } from "./noninteractive"
|
||||
import { toolInlineInfo } from "./tool"
|
||||
import type { MiniToolPart } from "./types"
|
||||
import { UI } from "./ui"
|
||||
|
||||
export type RunCommandInput = {
|
||||
@@ -224,7 +224,7 @@ function isBinaryContent(bytes: Uint8Array) {
|
||||
return bytes.reduce((count, byte) => count + Number(byte < 9 || (byte > 13 && byte < 32)), 0) / bytes.length > 0.3
|
||||
}
|
||||
|
||||
async function renderTool(part: ToolPart) {
|
||||
async function renderTool(part: MiniToolPart) {
|
||||
const info = toolInlineInfo(part)
|
||||
if (info.mode === "block") {
|
||||
UI.empty()
|
||||
@@ -240,7 +240,7 @@ async function renderTool(part: ToolPart) {
|
||||
)
|
||||
}
|
||||
|
||||
async function renderToolError(part: ToolPart) {
|
||||
async function renderToolError(part: MiniToolPart) {
|
||||
const info = toolInlineInfo(part)
|
||||
UI.println(UI.Style.TEXT_NORMAL + "✗", UI.Style.TEXT_NORMAL + `${info.title} failed`)
|
||||
}
|
||||
|
||||
@@ -547,7 +547,6 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
||||
footer,
|
||||
sessionID: state.sessionID,
|
||||
thinking: input.thinking,
|
||||
limits: () => state.limits,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,15 +1,10 @@
|
||||
// Session message extraction and prompt history.
|
||||
//
|
||||
// Fetches session messages from the SDK and extracts user turn text for
|
||||
// the prompt history ring. Also finds the most recently used variant for
|
||||
// the current model so the footer can pre-select it.
|
||||
import type { SessionMessageInfo, SessionMessageUser } from "@opencode-ai/client/promise"
|
||||
import { promptCopy, promptSame } from "./prompt.shared"
|
||||
import type { Message, Part } from "@opencode-ai/sdk/v2"
|
||||
import type { RunInput, RunPrompt } from "./types"
|
||||
|
||||
const LIMIT = 200
|
||||
|
||||
export type SessionMessages = Array<{ info: Message; parts: Part[] }>
|
||||
export type SessionMessages = SessionMessageInfo[]
|
||||
|
||||
type Turn = {
|
||||
prompt: RunPrompt
|
||||
@@ -25,133 +20,42 @@ export type RunSession = {
|
||||
variant?: string
|
||||
}
|
||||
|
||||
function fileName(url: string, filename?: string) {
|
||||
if (filename) {
|
||||
return filename
|
||||
}
|
||||
|
||||
try {
|
||||
const next = new URL(url)
|
||||
if (next.protocol !== "file:") {
|
||||
return url
|
||||
}
|
||||
|
||||
const name = next.pathname.split("/").at(-1)
|
||||
if (name) {
|
||||
return decodeURIComponent(name)
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
function fileSource(
|
||||
part: Extract<SessionMessages[number]["parts"][number], { type: "file" }>,
|
||||
text: { start: number; end: number; value: string },
|
||||
) {
|
||||
if (part.source) {
|
||||
return {
|
||||
...structuredClone(part.source),
|
||||
text,
|
||||
}
|
||||
}
|
||||
|
||||
function messagePrompt(message: SessionMessageUser): RunPrompt {
|
||||
return {
|
||||
type: "file" as const,
|
||||
path: part.filename ?? part.url,
|
||||
text,
|
||||
}
|
||||
}
|
||||
|
||||
export function messagePrompt(msg: SessionMessages[number]): RunPrompt {
|
||||
const parts: RunPrompt["parts"] = []
|
||||
let text = msg.parts
|
||||
.filter((part): part is Extract<SessionMessages[number]["parts"][number], { type: "text" }> => {
|
||||
return part.type === "text" && !part.synthetic
|
||||
})
|
||||
.map((part) => part.text)
|
||||
.join("")
|
||||
let cursor = Bun.stringWidth(text)
|
||||
const used: Array<{ start: number; end: number }> = []
|
||||
|
||||
const take = (value: string): { start: number; end: number; value: string } | undefined => {
|
||||
let from = 0
|
||||
while (true) {
|
||||
const idx = text.indexOf(value, from)
|
||||
if (idx === -1) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const start = Bun.stringWidth(text.slice(0, idx))
|
||||
const end = start + Bun.stringWidth(value)
|
||||
if (!used.some((item) => item.start < end && start < item.end)) {
|
||||
return { start, end, value }
|
||||
}
|
||||
|
||||
from = idx + value.length
|
||||
}
|
||||
}
|
||||
|
||||
const add = (value: string) => {
|
||||
const gap = text ? " " : ""
|
||||
const start = cursor + Bun.stringWidth(gap)
|
||||
text += gap + value
|
||||
const end = start + Bun.stringWidth(value)
|
||||
cursor = end
|
||||
return { start, end, value }
|
||||
}
|
||||
|
||||
for (const part of msg.parts) {
|
||||
if (part.type === "file") {
|
||||
const next = part.source?.text ? structuredClone(part.source.text) : take("@" + fileName(part.url, part.filename))
|
||||
const span = next ?? add("@" + fileName(part.url, part.filename))
|
||||
used.push({ start: span.start, end: span.end })
|
||||
parts.push({
|
||||
type: "file",
|
||||
mime: part.mime,
|
||||
filename: part.filename,
|
||||
url: part.url,
|
||||
source: fileSource(part, span),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if (part.type !== "agent") {
|
||||
continue
|
||||
}
|
||||
|
||||
const span = part.source ? structuredClone(part.source) : (take("@" + part.name) ?? add("@" + part.name))
|
||||
used.push({ start: span.start, end: span.end })
|
||||
parts.push({
|
||||
type: "agent",
|
||||
name: part.name,
|
||||
source: span,
|
||||
})
|
||||
}
|
||||
|
||||
return { text, parts }
|
||||
}
|
||||
|
||||
function turn(msg: SessionMessages[number]): Turn | undefined {
|
||||
if (msg.info.role !== "user") {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {
|
||||
prompt: messagePrompt(msg),
|
||||
provider: msg.info.model.providerID,
|
||||
model: msg.info.model.modelID,
|
||||
variant: msg.info.model.variant,
|
||||
text: message.text,
|
||||
parts: [
|
||||
...(message.files ?? []).map((file) => ({
|
||||
type: "file" as const,
|
||||
url: file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`,
|
||||
mime: file.mime,
|
||||
filename: file.name,
|
||||
source: file.mention
|
||||
? {
|
||||
type: "file",
|
||||
path: file.name ?? (file.source.type === "uri" ? file.source.uri : "inline attachment"),
|
||||
text: { start: file.mention.start, end: file.mention.end, value: file.mention.text },
|
||||
}
|
||||
: undefined,
|
||||
})),
|
||||
...(message.agents ?? []).map((agent) => ({
|
||||
type: "agent" as const,
|
||||
name: agent.name,
|
||||
source: agent.mention
|
||||
? { start: agent.mention.start, end: agent.mention.end, value: agent.mention.text }
|
||||
: undefined,
|
||||
})),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
export function createSession(messages: SessionMessages): RunSession {
|
||||
return {
|
||||
first: messages.length === 0,
|
||||
turns: messages.flatMap((msg) => {
|
||||
const item = turn(msg)
|
||||
return item ? [item] : []
|
||||
}),
|
||||
turns: messages.flatMap((message) =>
|
||||
message.type === "user"
|
||||
? [{ prompt: messagePrompt(message), provider: undefined, model: undefined, variant: undefined }]
|
||||
: [],
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,89 +68,34 @@ export async function resolveCurrentSession(
|
||||
sdk.message.list({ sessionID, limit, order: "desc" }),
|
||||
sdk.session.get({ sessionID }),
|
||||
])
|
||||
const messages = response.data.toReversed()
|
||||
const current = createSession(response.data.toReversed())
|
||||
return {
|
||||
first: messages.length === 0,
|
||||
turns: messages.flatMap((message) => {
|
||||
if (message.type !== "user") return []
|
||||
return [
|
||||
{
|
||||
prompt: {
|
||||
text: message.text,
|
||||
parts: [
|
||||
...(message.files ?? []).map((file) => ({
|
||||
type: "file" as const,
|
||||
url: file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`,
|
||||
mime: file.mime,
|
||||
filename: file.name,
|
||||
source: file.mention
|
||||
? {
|
||||
type: "file" as const,
|
||||
path: file.name ?? (file.source.type === "uri" ? file.source.uri : "inline attachment"),
|
||||
text: { start: file.mention.start, end: file.mention.end, value: file.mention.text },
|
||||
}
|
||||
: undefined,
|
||||
})),
|
||||
...(message.agents ?? []).map((agent) => ({
|
||||
type: "agent" as const,
|
||||
name: agent.name,
|
||||
source: agent.mention
|
||||
? { start: agent.mention.start, end: agent.mention.end, value: agent.mention.text }
|
||||
: undefined,
|
||||
})),
|
||||
],
|
||||
},
|
||||
provider: session.model?.providerID,
|
||||
model: session.model?.id,
|
||||
variant: session.model?.variant,
|
||||
},
|
||||
]
|
||||
}),
|
||||
...current,
|
||||
turns: current.turns.map((turn) => ({
|
||||
...turn,
|
||||
provider: session.model?.providerID,
|
||||
model: session.model?.id,
|
||||
variant: session.model?.variant,
|
||||
})),
|
||||
...(session.model && {
|
||||
model: {
|
||||
providerID: session.model.providerID,
|
||||
modelID: session.model.id,
|
||||
},
|
||||
model: { providerID: session.model.providerID, modelID: session.model.id },
|
||||
variant: session.model.variant,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
export function sessionHistory(session: RunSession, limit = LIMIT): RunPrompt[] {
|
||||
const out: RunPrompt[] = []
|
||||
|
||||
for (const turn of session.turns) {
|
||||
if (!turn.prompt.text.trim()) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (out[out.length - 1] && promptSame(out[out.length - 1], turn.prompt)) {
|
||||
continue
|
||||
}
|
||||
|
||||
out.push(promptCopy(turn.prompt))
|
||||
}
|
||||
|
||||
return out.slice(-limit)
|
||||
return session.turns
|
||||
.map((turn) => turn.prompt)
|
||||
.filter((prompt) => prompt.text.trim())
|
||||
.filter((prompt, index, prompts) => index === 0 || !promptSame(prompts[index - 1], prompt))
|
||||
.map(promptCopy)
|
||||
.slice(-limit)
|
||||
}
|
||||
|
||||
export function sessionVariant(session: RunSession, model: RunInput["model"]): string | undefined {
|
||||
if (!model) {
|
||||
return undefined
|
||||
}
|
||||
if (!model) return
|
||||
if (session.model?.providerID === model.providerID && session.model.modelID === model.modelID) return session.variant
|
||||
|
||||
if (session.model?.providerID === model.providerID && session.model.modelID === model.modelID) {
|
||||
return session.variant
|
||||
}
|
||||
|
||||
for (let idx = session.turns.length - 1; idx >= 0; idx -= 1) {
|
||||
const turn = session.turns[idx]
|
||||
if (turn.provider !== model.providerID || turn.model !== model.modelID) {
|
||||
continue
|
||||
}
|
||||
|
||||
return turn.variant
|
||||
}
|
||||
|
||||
return undefined
|
||||
return session.turns.findLast((turn) => turn.provider === model.providerID && turn.model === model.modelID)?.variant
|
||||
}
|
||||
|
||||
@@ -15,10 +15,14 @@
|
||||
// Per-child interruption uses `v2.session.interrupt(childID)`. Per-child
|
||||
// backgrounding is intentionally absent: subagent jobs block the parent
|
||||
// session, so only whole-session `v2.session.background(parentID)` exists.
|
||||
import type { EventSubscribeOutput, OpenCodeClient } from "@opencode-ai/client/promise"
|
||||
import type { SessionMessageAssistantTool, SessionMessageInfo, ToolPart } from "@opencode-ai/sdk/v2"
|
||||
import type {
|
||||
EventSubscribeOutput,
|
||||
OpenCodeClient,
|
||||
SessionMessageAssistantTool,
|
||||
SessionMessageInfo,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import { Locale } from "@opencode-ai/tui/util/locale"
|
||||
import type { FooterSubagentDetail, FooterSubagentState, FooterSubagentTab, StreamCommit } from "./types"
|
||||
import type { FooterSubagentDetail, FooterSubagentState, FooterSubagentTab, MiniToolPart, StreamCommit } from "./types"
|
||||
|
||||
const CHILD_MESSAGE_LIMIT = 80
|
||||
const CHILD_FRAME_LIMIT = 80
|
||||
@@ -32,11 +36,11 @@ export function outputText(content: ReadonlyArray<{ type: string; text?: string
|
||||
return content.flatMap((item) => (item.type === "text" && item.text ? [item.text] : [])).join("\n")
|
||||
}
|
||||
|
||||
export function legacyTool(input: {
|
||||
export function miniTool(input: {
|
||||
sessionID: string
|
||||
messageID: string
|
||||
tool: SessionMessageAssistantTool
|
||||
}): ToolPart {
|
||||
}): MiniToolPart {
|
||||
const tool = input.tool
|
||||
const providerCall =
|
||||
tool.executed === undefined && tool.providerState === undefined
|
||||
@@ -109,7 +113,7 @@ export function legacyTool(input: {
|
||||
}
|
||||
}
|
||||
|
||||
export function toolCommit(part: ToolPart, phase: "start" | "progress" | "final"): StreamCommit {
|
||||
export function toolCommit(part: MiniToolPart, phase: "start" | "progress" | "final"): StreamCommit {
|
||||
const status = part.state.status
|
||||
const text =
|
||||
status === "running"
|
||||
@@ -310,7 +314,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
||||
}
|
||||
|
||||
const childTool = (child: ChildState, item: SessionMessageAssistantTool, messageID: string) => {
|
||||
const part = legacyTool({
|
||||
const part = miniTool({
|
||||
sessionID: child.sessionID,
|
||||
messageID,
|
||||
tool: item,
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import type { EventSubscribeOutput, OpenCodeClient } from "@opencode-ai/client/promise"
|
||||
import type {
|
||||
PermissionRequest,
|
||||
QuestionRequest,
|
||||
SessionMessageInfo,
|
||||
EventSubscribeOutput,
|
||||
OpenCodeClient,
|
||||
PermissionV2Request,
|
||||
QuestionV2Request,
|
||||
SessionMessageAssistantTool,
|
||||
} from "@opencode-ai/sdk/v2"
|
||||
SessionMessageInfo,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { blockerStatus, pickBlockerView } from "./session-data"
|
||||
import { writeSessionOutput } from "./stream"
|
||||
import { createSubagentTracker, legacyTool, toolCommit } from "./stream-v2.subagent"
|
||||
import { createSubagentTracker, miniTool, toolCommit } from "./stream-v2.subagent"
|
||||
import type {
|
||||
FooterApi,
|
||||
FooterView,
|
||||
@@ -87,8 +88,6 @@ type ShellWait = {
|
||||
}
|
||||
|
||||
type RunV2Event = EventSubscribeOutput
|
||||
type PermissionV2Request = Extract<RunV2Event, { type: "permission.v2.asked" }>["data"]
|
||||
type QuestionV2Request = Extract<RunV2Event, { type: "question.v2.asked" }>["data"]
|
||||
type PromptFilePart = Extract<RunPromptPart, { type: "file" }>
|
||||
|
||||
type ToolState = {
|
||||
@@ -101,8 +100,8 @@ type ToolState = {
|
||||
}
|
||||
|
||||
type State = {
|
||||
permissions: PermissionRequest[]
|
||||
questions: QuestionRequest[]
|
||||
permissions: PermissionV2Request[]
|
||||
questions: QuestionV2Request[]
|
||||
view: FooterView
|
||||
messageIDs: Set<string>
|
||||
text: Map<string, string>
|
||||
@@ -138,27 +137,6 @@ export function formatUnknownError(error: unknown): string {
|
||||
return "unknown error"
|
||||
}
|
||||
|
||||
function permission(request: PermissionV2Request): PermissionRequest {
|
||||
return {
|
||||
id: request.id,
|
||||
sessionID: request.sessionID,
|
||||
permission: request.action,
|
||||
patterns: [...request.resources],
|
||||
metadata: request.metadata ?? {},
|
||||
always: [...(request.save ?? [])],
|
||||
tool: request.source?.type === "tool" ? request.source : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function question(request: QuestionV2Request): QuestionRequest {
|
||||
return {
|
||||
id: request.id,
|
||||
sessionID: request.sessionID,
|
||||
questions: request.questions.map((item) => ({ ...item, options: item.options.map((option) => ({ ...option })) })),
|
||||
tool: request.tool,
|
||||
}
|
||||
}
|
||||
|
||||
function sessionID(event: RunV2Event) {
|
||||
return "sessionID" in event.data && typeof event.data.sessionID === "string" ? event.data.sessionID : undefined
|
||||
}
|
||||
@@ -229,8 +207,7 @@ function streamPartKey(messageID: string, partID: string) {
|
||||
return `${messageID}\u0000${partID}`
|
||||
}
|
||||
|
||||
// Matches the commit shapes the legacy session-data reducer produced for direct
|
||||
// shell calls: one "start" commit rendering `$ command` and one "progress"
|
||||
// Direct shell calls use one "start" commit rendering `$ command` and one "progress"
|
||||
// commit rendering the merged output (see toolEntryBody in tool.ts).
|
||||
function shellCommit(
|
||||
callID: string,
|
||||
@@ -384,7 +361,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
}
|
||||
|
||||
const renderTool = (messageID: string, item: SessionMessageAssistantTool) => {
|
||||
const part = legacyTool({
|
||||
const part = miniTool({
|
||||
sessionID: input.sessionID,
|
||||
messageID,
|
||||
tool: item,
|
||||
@@ -536,8 +513,8 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
])
|
||||
const projected = structuredClone(messages.data).toReversed() as SessionMessageInfo[]
|
||||
for (const message of projected) renderMessage(message, next.render, next.reuseVisibleWait)
|
||||
state.permissions = permissions.map(permission)
|
||||
state.questions = questions.map(question)
|
||||
state.permissions = permissions
|
||||
state.questions = questions
|
||||
syncBlockers()
|
||||
await subagents.hydrate({ messages: [...projected], active })
|
||||
const running = input.sessionID in active
|
||||
@@ -770,7 +747,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
return
|
||||
}
|
||||
if (event.type === "permission.v2.asked") {
|
||||
if (!state.permissions.some((item) => item.id === event.data.id)) state.permissions.push(permission(event.data))
|
||||
if (!state.permissions.some((item) => item.id === event.data.id)) state.permissions.push(event.data)
|
||||
syncBlockers()
|
||||
return
|
||||
}
|
||||
@@ -780,7 +757,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
return
|
||||
}
|
||||
if (event.type === "question.v2.asked") {
|
||||
if (!state.questions.some((item) => item.id === event.data.id)) state.questions.push(question(event.data))
|
||||
if (!state.questions.some((item) => item.id === event.data.id)) state.questions.push(event.data)
|
||||
syncBlockers()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// Thin bridge between reducer output and the footer API.
|
||||
// Thin bridge between transport output and the footer API.
|
||||
//
|
||||
// The reducers produce StreamCommit[] and an optional FooterOutput (patch +
|
||||
// Transports produce StreamCommit[] and an optional FooterOutput (patch +
|
||||
// view + subagent state). This module forwards them to footer.append() and
|
||||
// footer.event() respectively, adding trace writes along the way. It also
|
||||
// defaults status updates to phase "running" if the caller didn't set a
|
||||
// phase -- a convenience so reducer code doesn't have to repeat that.
|
||||
// phase -- a convenience so transport code doesn't have to repeat that.
|
||||
import type { FooterApi, FooterOutput, FooterPatch, FooterSubagentState, StreamCommit } from "./types"
|
||||
|
||||
type Trace = {
|
||||
@@ -103,9 +103,9 @@ export function traceSubagentState(state: FooterSubagentState) {
|
||||
permissions: state.permissions.map((item) => ({
|
||||
id: item.id,
|
||||
sessionID: item.sessionID,
|
||||
permission: item.permission,
|
||||
patterns: item.patterns,
|
||||
tool: item.tool,
|
||||
action: item.action,
|
||||
resources: item.resources,
|
||||
source: item.source,
|
||||
metadata: item.metadata
|
||||
? {
|
||||
keys: Object.keys(item.metadata),
|
||||
@@ -137,7 +137,7 @@ export function traceFooterOutput(footer?: FooterOutput) {
|
||||
}
|
||||
}
|
||||
|
||||
// Forwards reducer output to the footer: commits go to scrollback, patches update the status bar.
|
||||
// Forwards transport output to the footer: commits go to scrollback, patches update the status bar.
|
||||
export function writeSessionOutput(input: OutputInput, out: StreamOutput): void {
|
||||
for (const commit of out.commits) {
|
||||
input.trace?.write("ui.commit", commit)
|
||||
|
||||
@@ -15,10 +15,9 @@
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import stripAnsi from "strip-ansi"
|
||||
import type { ToolPart } from "@opencode-ai/sdk/v2"
|
||||
import { LANGUAGE_EXTENSIONS } from "@opencode-ai/tui/util/filetype"
|
||||
import { Locale } from "@opencode-ai/tui/util/locale"
|
||||
import type { RunEntryBody, StreamCommit, ToolSnapshot } from "./types"
|
||||
import type { MiniToolPart, RunEntryBody, StreamCommit, ToolSnapshot } from "./types"
|
||||
|
||||
export type ToolView = {
|
||||
output: boolean
|
||||
@@ -1177,7 +1176,7 @@ function rule(name?: string): AnyToolRule | undefined {
|
||||
return TOOL_RULES[name]
|
||||
}
|
||||
|
||||
function frame(part: ToolPart): ToolFrame {
|
||||
function frame(part: MiniToolPart): ToolFrame {
|
||||
const state = dict(part.state)
|
||||
return {
|
||||
raw: "",
|
||||
@@ -1231,7 +1230,7 @@ export function toolStructuredFinal(commit: StreamCommit): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
export function toolInlineInfo(part: ToolPart): ToolInline {
|
||||
export function toolInlineInfo(part: MiniToolPart): ToolInline {
|
||||
const ctx = frame(part)
|
||||
const draw = rule(ctx.name)?.run
|
||||
try {
|
||||
|
||||
@@ -7,12 +7,16 @@
|
||||
//
|
||||
// Data flow through the system:
|
||||
//
|
||||
// SDK events → session-data reducer → StreamCommit[] + FooterOutput
|
||||
// V2 events / demo actions → StreamCommit[] + FooterOutput
|
||||
// → stream.ts bridges to footer API
|
||||
// → footer.ts queues commits and patches the footer view
|
||||
// → OpenTUI split-footer renderer writes to terminal
|
||||
import type { OpenCodeClient, ReferenceListOutput } from "@opencode-ai/client/promise"
|
||||
import type { FilePart, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2"
|
||||
import type {
|
||||
OpenCodeClient,
|
||||
PermissionV2Request,
|
||||
QuestionV2Request,
|
||||
ReferenceListOutput,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import type { TuiConfig } from "@opencode-ai/tui/config/v1"
|
||||
|
||||
export type RunFilePart = {
|
||||
@@ -30,7 +34,11 @@ export type RunPromptPart =
|
||||
url: string
|
||||
filename?: string
|
||||
mime?: string
|
||||
source?: FilePart["source"]
|
||||
source?: {
|
||||
type: string
|
||||
text: { start: number; end: number; value: string }
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
| { type: "agent"; name: string; source?: { start: number; end: number; value: string } }
|
||||
|
||||
@@ -210,6 +218,41 @@ export type ToolQuestionSnapshot = {
|
||||
|
||||
export type ToolSnapshot = ToolCodeSnapshot | ToolDiffSnapshot | ToolTaskSnapshot | ToolQuestionSnapshot
|
||||
|
||||
export type MiniToolState =
|
||||
| { status: "pending"; input: Record<string, unknown>; raw?: string }
|
||||
| {
|
||||
status: "running"
|
||||
input: Record<string, unknown>
|
||||
title?: string
|
||||
metadata?: Record<string, unknown>
|
||||
time: { start: number }
|
||||
}
|
||||
| {
|
||||
status: "completed"
|
||||
input: Record<string, unknown>
|
||||
output: string
|
||||
title?: string
|
||||
metadata?: Record<string, unknown>
|
||||
time: { start: number; end: number }
|
||||
}
|
||||
| {
|
||||
status: "error"
|
||||
input: Record<string, unknown>
|
||||
error: string
|
||||
metadata?: Record<string, unknown>
|
||||
time: { start: number; end: number }
|
||||
}
|
||||
|
||||
export type MiniToolPart = {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
type?: "tool"
|
||||
callID: string
|
||||
tool: string
|
||||
state: MiniToolState
|
||||
}
|
||||
|
||||
export type EntryLayout = "inline" | "block"
|
||||
|
||||
export type RunEntryBody =
|
||||
@@ -220,13 +263,13 @@ export type RunEntryBody =
|
||||
| { type: "structured"; snapshot: ToolSnapshot }
|
||||
|
||||
// Which interactive surface the footer is showing. Only one view is active at
|
||||
// a time. The reducer drives transitions: when a permission arrives the view
|
||||
// a time. The transport drives transitions: when a permission arrives the view
|
||||
// switches to "permission", and when the permission resolves it falls back to
|
||||
// "prompt".
|
||||
export type FooterView =
|
||||
| { type: "prompt" }
|
||||
| { type: "permission"; request: PermissionRequest }
|
||||
| { type: "question"; request: QuestionRequest }
|
||||
| { type: "permission"; request: PermissionV2Request }
|
||||
| { type: "question"; request: QuestionV2Request }
|
||||
|
||||
export type FooterPromptRoute =
|
||||
| { type: "composer" }
|
||||
@@ -259,11 +302,11 @@ export type FooterSubagentDetail = {
|
||||
export type FooterSubagentState = {
|
||||
tabs: FooterSubagentTab[]
|
||||
details: Record<string, FooterSubagentDetail>
|
||||
permissions: PermissionRequest[]
|
||||
questions: QuestionRequest[]
|
||||
permissions: PermissionV2Request[]
|
||||
questions: QuestionV2Request[]
|
||||
}
|
||||
|
||||
// The reducer emits this alongside scrollback commits so the footer can update in the same frame.
|
||||
// The transport emits this alongside scrollback commits so the footer can update in the same frame.
|
||||
export type FooterOutput = {
|
||||
patch?: FooterPatch
|
||||
view?: FooterView
|
||||
@@ -357,8 +400,8 @@ export type StreamSource = "assistant" | "reasoning" | "tool" | "system"
|
||||
|
||||
export type StreamToolState = "running" | "completed" | "error"
|
||||
|
||||
// A single append-only commit to scrollback. The session-data reducer produces
|
||||
// these from SDK events, and RunFooter.append() queues them for the next
|
||||
// A single append-only commit to scrollback. The transport produces these from
|
||||
// V2 events, and RunFooter.append() queues them for the next
|
||||
// microtask flush. Once flushed, they become immutable terminal scrollback
|
||||
// rows -- they cannot be rewritten.
|
||||
export type StreamCommit = {
|
||||
@@ -370,7 +413,7 @@ export type StreamCommit = {
|
||||
messageID?: string
|
||||
partID?: string
|
||||
tool?: string
|
||||
part?: ToolPart
|
||||
part?: MiniToolPart
|
||||
interrupted?: boolean
|
||||
toolState?: StreamToolState
|
||||
toolError?: string
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
|
||||
## Future Design Notes
|
||||
|
||||
- If a captured user-visible output channel returns (an earlier `output.text`/`output.file`/`output.image` API was removed from v1), keep `output` as its name, distinct from the program return value: `return` stays the structured result for the model, while `output.*` describes artifacts the host may render into a conversation or UI after execution. Keep this host-neutral and let applications decide how captured output is delivered. In v1, hosts collect media host-side (outside the sandbox) instead.
|
||||
- Improve the sandbox failure taxonomy. Distinguish parse/compile mistakes, unsupported syntax, user-thrown errors, invalid returned data, tool refusal, tool internal failure, timeout, and genuine runtime defects so agents can recover accurately instead of treating everything as a generic execution failure.
|
||||
- If a captured user-visible output channel returns (an earlier `output.text`/`output.file`/`output.image` API was removed from v1), keep `output` as its name, distinct from the program return value: `return` stays the structured result for the model, while `output.*` describes artifacts the host may render into a conversation or UI after execution. Keep this host-neutral and let applications decide how captured output is delivered. In v1, hosts collect media host-side (outside CodeMode) instead.
|
||||
- Improve the failure taxonomy. Distinguish parse/compile mistakes, unsupported syntax, user-thrown errors, invalid returned data, tool refusal, tool internal failure, timeout, and genuine runtime defects so agents can recover accurately instead of treating everything as a generic execution failure.
|
||||
- Preserve the public/private error split. Tool authors should be able to return a safe model-visible message while retaining a private cause for host diagnostics. Unknown host failures must remain sanitized by default.
|
||||
- Think deliberately about richer binary boundaries before allowing `Blob`, `File`, `ArrayBuffer`, streams, or typed arrays beyond today's JSON-like values. If CodeMode supports binary tool args/results, use explicit tagged data shapes and clear size limits rather than relying on ambient runtime serialization.
|
||||
- Keep host capabilities explicit. Globals such as `fetch`, `crypto`, filesystem handles, extra modules, or network clients should be opt-in runtime capabilities with obvious policy defaults, not ambient authority. Default to unavailable unless a host deliberately provides the capability.
|
||||
|
||||
@@ -20,7 +20,7 @@ ultimate source of truth.
|
||||
- [x] Top-level `await` and `return` through the program's implicit async-function scope.
|
||||
- [x] Explicit `return`, final top-level expression as a REPL-style result, and `null` when no value is produced.
|
||||
- [x] JSON-like host boundaries with `undefined` and non-finite numbers normalized to `null`.
|
||||
- [x] Live Date, RegExp, Map, Set, URL, and URLSearchParams values inside the sandbox.
|
||||
- [x] Live Date, RegExp, Map, Set, URL, and URLSearchParams values inside CodeMode.
|
||||
- [x] Tool calls through the host-provided `tools` tree only.
|
||||
- [x] The global `search(...)` built-in: synchronous tool discovery that counts as an admitted tool call and is
|
||||
shadowable by program declarations like other globals.
|
||||
@@ -94,7 +94,7 @@ ultimate source of truth.
|
||||
- [x] Optional property access and optional calls.
|
||||
- [x] Function/tool calls and spread arguments.
|
||||
- [x] Sequence expressions (the comma operator).
|
||||
- [x] `await` for sandbox promises; a plain value passes through unchanged, though every `await` still defers its
|
||||
- [x] `await` for CodeMode promises; a plain value passes through unchanged, though every `await` still defers its
|
||||
continuation one reaction turn.
|
||||
- [x] `new` for Error types, Date, RegExp, Map, Set, URL, URLSearchParams, and Promise.
|
||||
- [x] Arithmetic operators: `+`, `-`, `*`, `/`, `%`, and `**`.
|
||||
@@ -109,7 +109,7 @@ ultimate source of truth.
|
||||
|
||||
## Promises and tools
|
||||
|
||||
- [x] Tool calls start eagerly and return supervised, run-once sandbox promises.
|
||||
- [x] Tool calls start eagerly and return supervised, run-once CodeMode promises.
|
||||
- [x] Direct `await`, repeated awaits, and implicit resolution when a promise is returned from a function/program.
|
||||
- [x] `Promise.resolve` and `Promise.reject`.
|
||||
- [x] `Promise.all`, `Promise.allSettled`, `Promise.race`, and `Promise.any` over supported collections containing
|
||||
@@ -148,7 +148,7 @@ ultimate source of truth.
|
||||
- [x] Computed property names and object spread.
|
||||
- [x] `Object.keys`, `Object.values`, `Object.entries`, `Object.hasOwn`, `Object.assign`, and `Object.fromEntries`.
|
||||
- [x] `Object.keys` over arrays and tool references.
|
||||
- [x] Object identity is preserved by in-sandbox Object helpers.
|
||||
- [x] Object identity is preserved by in-CodeMode Object helpers.
|
||||
- [x] Blocked access to `__proto__`, `constructor`, and `prototype`.
|
||||
- [ ] `Object.is`; runtime and tool-reference identity semantics need to be defined first.
|
||||
- [ ] `Object.groupBy`.
|
||||
|
||||
@@ -13,13 +13,13 @@ import {
|
||||
import { rejectCircularInsertion } from "./references.js"
|
||||
import { isBlockedMember, type SafeObject } from "../tool-runtime.js"
|
||||
import {
|
||||
SandboxDate,
|
||||
SandboxMap,
|
||||
SandboxPromise,
|
||||
SandboxRegExp,
|
||||
SandboxSet,
|
||||
SandboxURL,
|
||||
SandboxURLSearchParams,
|
||||
CodeModeDate,
|
||||
CodeModeMap,
|
||||
CodeModePromise,
|
||||
CodeModeRegExp,
|
||||
CodeModeSet,
|
||||
CodeModeURL,
|
||||
CodeModeURLSearchParams,
|
||||
} from "../values.js"
|
||||
import { invokeDateMethod, invokeDateStatic } from "../stdlib/date.js"
|
||||
import { invokeJsonMethod } from "../stdlib/json.js"
|
||||
@@ -33,7 +33,7 @@ import { boundedData, coerceToNumber, coerceToString, invokeCoercion } from "../
|
||||
|
||||
export type CallbackRunner<R> = {
|
||||
readonly invokeFunction: (fn: CodeModeFunction, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
|
||||
readonly settlePromise: (promise: SandboxPromise) => Effect.Effect<unknown, unknown, never>
|
||||
readonly settlePromise: (promise: CodeModePromise) => Effect.Effect<unknown, unknown, never>
|
||||
}
|
||||
|
||||
export const invokeIntrinsic = <R>(
|
||||
@@ -57,22 +57,22 @@ export const invokeIntrinsic = <R>(
|
||||
if (Array.isArray(ref.receiver)) {
|
||||
return invokeArrayMethod(runner, ref.receiver, ref.name, args, node)
|
||||
}
|
||||
if (ref.receiver instanceof SandboxDate) {
|
||||
if (ref.receiver instanceof CodeModeDate) {
|
||||
return Effect.succeed(invokeDateMethod(ref.receiver, ref.name, node))
|
||||
}
|
||||
if (ref.receiver instanceof SandboxRegExp) {
|
||||
if (ref.receiver instanceof CodeModeRegExp) {
|
||||
return Effect.succeed(invokeRegExpMethod(ref.receiver, ref.name, args, node))
|
||||
}
|
||||
if (ref.receiver instanceof SandboxMap) {
|
||||
if (ref.receiver instanceof CodeModeMap) {
|
||||
return invokeMapMethod(runner, ref.receiver, ref.name, args, node)
|
||||
}
|
||||
if (ref.receiver instanceof SandboxSet) {
|
||||
if (ref.receiver instanceof CodeModeSet) {
|
||||
return invokeSetMethod(runner, ref.receiver, ref.name, args, node)
|
||||
}
|
||||
if (ref.receiver instanceof SandboxURL) {
|
||||
if (ref.receiver instanceof CodeModeURL) {
|
||||
return Effect.succeed(invokeURLMethod(ref.receiver, ref.name, node))
|
||||
}
|
||||
if (ref.receiver instanceof SandboxURLSearchParams) {
|
||||
if (ref.receiver instanceof CodeModeURLSearchParams) {
|
||||
return invokeURLSearchParamsMethod(runner, ref.receiver, ref.name, args, node)
|
||||
}
|
||||
throw new InterpreterRuntimeError(`Method '${ref.name}' is not available in CodeMode.`, node)
|
||||
@@ -153,7 +153,7 @@ const invokeStringMethod = (value: string, name: string, args: Array<unknown>, n
|
||||
result = [value]
|
||||
break
|
||||
}
|
||||
if (args[0] instanceof SandboxRegExp) {
|
||||
if (args[0] instanceof CodeModeRegExp) {
|
||||
result = value.split(args[0].regex, optNum(1))
|
||||
break
|
||||
}
|
||||
@@ -181,7 +181,7 @@ const invokeStringMethod = (value: string, name: string, args: Array<unknown>, n
|
||||
break
|
||||
case "replace":
|
||||
case "replaceAll": {
|
||||
if (args[0] instanceof SandboxRegExp) {
|
||||
if (args[0] instanceof CodeModeRegExp) {
|
||||
const pattern = args[0].regex
|
||||
const replacement = str(1)
|
||||
if (name === "replaceAll" && !pattern.global) {
|
||||
@@ -278,13 +278,13 @@ const invokeArrayStatic = (name: string, args: Array<unknown>, node: AstNode): u
|
||||
[supportedSyntaxMessage],
|
||||
)
|
||||
}
|
||||
if (args[0] instanceof SandboxMap) return Array.from(args[0].map.entries(), ([key, item]) => [key, item])
|
||||
if (args[0] instanceof SandboxSet) return Array.from(args[0].set.values())
|
||||
if (args[0] instanceof SandboxURLSearchParams) {
|
||||
if (args[0] instanceof CodeModeMap) return Array.from(args[0].map.entries(), ([key, item]) => [key, item])
|
||||
if (args[0] instanceof CodeModeSet) return Array.from(args[0].set.values())
|
||||
if (args[0] instanceof CodeModeURLSearchParams) {
|
||||
return Array.from(args[0].params.entries(), ([key, value]) => [key, value])
|
||||
}
|
||||
const source = args[0]
|
||||
if (source instanceof SandboxPromise) {
|
||||
if (source instanceof CodeModePromise) {
|
||||
throw new InterpreterRuntimeError(
|
||||
"Array.from received an un-awaited Promise; await it before creating the array.",
|
||||
node,
|
||||
@@ -341,7 +341,7 @@ const invokeStringReplacer = <R>(
|
||||
}
|
||||
|
||||
const pattern = args[0]
|
||||
if (pattern instanceof SandboxRegExp) {
|
||||
if (pattern instanceof CodeModeRegExp) {
|
||||
if (name === "replaceAll" && !pattern.regex.global) {
|
||||
throw new InterpreterRuntimeError(
|
||||
`String.replaceAll requires a regular expression with the global (g) flag: write /${pattern.regex.source}/${pattern.regex.flags}g, or use String.replace to replace only the first match.`,
|
||||
@@ -364,7 +364,7 @@ const invokeStringReplacer = <R>(
|
||||
for (const match of matches) {
|
||||
const replacement = yield* apply(match.args)
|
||||
const resolved =
|
||||
args[1] instanceof CodeModeFunction && args[1].async && replacement instanceof SandboxPromise
|
||||
args[1] instanceof CodeModeFunction && args[1].async && replacement instanceof CodeModePromise
|
||||
? yield* runner.settlePromise(replacement)
|
||||
: replacement
|
||||
output.push(
|
||||
@@ -404,7 +404,7 @@ export const applyCollectionCallback = <R>(
|
||||
|
||||
const invokeMapMethod = <R>(
|
||||
runner: CallbackRunner<R>,
|
||||
target: SandboxMap,
|
||||
target: CodeModeMap,
|
||||
name: string,
|
||||
args: Array<unknown>,
|
||||
node: AstNode,
|
||||
@@ -446,7 +446,7 @@ const invokeMapMethod = <R>(
|
||||
|
||||
const invokeSetMethod = <R>(
|
||||
runner: CallbackRunner<R>,
|
||||
target: SandboxSet,
|
||||
target: CodeModeSet,
|
||||
name: string,
|
||||
args: Array<unknown>,
|
||||
node: AstNode,
|
||||
@@ -485,7 +485,7 @@ const invokeSetMethod = <R>(
|
||||
|
||||
const invokeURLSearchParamsMethod = <R>(
|
||||
runner: CallbackRunner<R>,
|
||||
target: SandboxURLSearchParams,
|
||||
target: CodeModeURLSearchParams,
|
||||
name: string,
|
||||
args: Array<unknown>,
|
||||
node: AstNode,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { SafeObject } from "../tool-runtime.js"
|
||||
import type { SandboxPromise, SandboxURL } from "../values.js"
|
||||
import type { CodeModePromise, CodeModeURL } from "../values.js"
|
||||
|
||||
export type SourcePosition = {
|
||||
line: number
|
||||
@@ -35,7 +35,7 @@ export type StatementResult =
|
||||
| { kind: "continue" }
|
||||
|
||||
export type MemberReference = {
|
||||
target: SafeObject | Array<unknown> | SandboxURL
|
||||
target: SafeObject | Array<unknown> | CodeModeURL
|
||||
key: string | number
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ export type PromiseInstanceMethodName = "then" | "catch" | "finally"
|
||||
|
||||
export class PromiseInstanceMethodReference {
|
||||
constructor(
|
||||
readonly promise: SandboxPromise,
|
||||
readonly promise: CodeModePromise,
|
||||
readonly name: PromiseInstanceMethodName,
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -17,24 +17,24 @@ import { applyCollectionCallback, type CallbackRunner } from "./methods.js"
|
||||
import { typeofValue } from "./references.js"
|
||||
import { spreadItems } from "../stdlib/collections.js"
|
||||
import { createAggregateErrorValue } from "../stdlib/value.js"
|
||||
import { SandboxPromise } from "../values.js"
|
||||
import { CodeModePromise } from "../values.js"
|
||||
|
||||
// Observation only controls rejection reporting; program completion interrupts all promise work.
|
||||
export class PromiseRuntime<R> {
|
||||
private readonly active = new Set<SandboxPromise>()
|
||||
private readonly ids = new WeakMap<SandboxPromise, number>()
|
||||
private readonly observed = new WeakSet<SandboxPromise>()
|
||||
private readonly active = new Set<CodeModePromise>()
|
||||
private readonly ids = new WeakMap<CodeModePromise, number>()
|
||||
private readonly observed = new WeakSet<CodeModePromise>()
|
||||
private readonly failures = new Map<number, Diagnostic>()
|
||||
private nextID = 0
|
||||
|
||||
constructor(private readonly scope: Scope.Scope) {}
|
||||
|
||||
create(effect: Effect.Effect<unknown, unknown, R>): Effect.Effect<SandboxPromise, never, R> {
|
||||
create(effect: Effect.Effect<unknown, unknown, R>): Effect.Effect<CodeModePromise, never, R> {
|
||||
return Effect.suspend(() => {
|
||||
// Allocate before forking so reruns get distinct IDs and diagnostics retain creation order.
|
||||
const id = this.nextID++
|
||||
return Effect.map(Effect.forkIn(effect, this.scope, { startImmediately: true }), (fiber) => {
|
||||
const promise = new SandboxPromise(fiber)
|
||||
const promise = new CodeModePromise(fiber)
|
||||
this.active.add(promise)
|
||||
this.ids.set(promise, id)
|
||||
fiber.addObserver((exit) => {
|
||||
@@ -55,14 +55,14 @@ export class PromiseRuntime<R> {
|
||||
}
|
||||
|
||||
// Observation must be recorded when responsibility transfers, before the consumer fiber runs.
|
||||
markObserved(promise: SandboxPromise): void {
|
||||
markObserved(promise: CodeModePromise): void {
|
||||
this.observed.add(promise)
|
||||
const id = this.ids.get(promise)
|
||||
this.ids.delete(promise)
|
||||
if (id !== undefined) this.failures.delete(id)
|
||||
}
|
||||
|
||||
await(promise: SandboxPromise): Effect.Effect<Exit.Exit<unknown, unknown>> {
|
||||
await(promise: CodeModePromise): Effect.Effect<Exit.Exit<unknown, unknown>> {
|
||||
return Fiber.await(promise.fiber)
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ export const invokePromiseMethod = <R>(
|
||||
): Effect.Effect<unknown, unknown, R> => {
|
||||
if (ref.name === "resolve") {
|
||||
const value = args[0]
|
||||
return value instanceof SandboxPromise ? Effect.succeed(value) : promises.create(Effect.succeed(value))
|
||||
return value instanceof CodeModePromise ? Effect.succeed(value) : promises.create(Effect.succeed(value))
|
||||
}
|
||||
if (ref.name === "reject") {
|
||||
return promises.create(Effect.fail(new ProgramThrow(args[0])))
|
||||
@@ -114,19 +114,19 @@ export const invokePromiseMethod = <R>(
|
||||
const items = Array.from(spread)
|
||||
|
||||
for (const item of items) {
|
||||
if (item instanceof SandboxPromise) promises.markObserved(item)
|
||||
if (item instanceof CodeModePromise) promises.markObserved(item)
|
||||
}
|
||||
|
||||
switch (ref.name) {
|
||||
case "all": {
|
||||
const observations = items.map((item) =>
|
||||
item instanceof SandboxPromise ? Effect.flatten(promises.await(item)) : Effect.succeed(item),
|
||||
item instanceof CodeModePromise ? Effect.flatten(promises.await(item)) : Effect.succeed(item),
|
||||
)
|
||||
return promises.create(settleAfterTurn(Effect.all(observations, { concurrency: "unbounded" })))
|
||||
}
|
||||
case "allSettled": {
|
||||
const observations = items.map((item) =>
|
||||
item instanceof SandboxPromise ? promises.await(item) : Effect.succeed(Exit.succeed(item)),
|
||||
item instanceof CodeModePromise ? promises.await(item) : Effect.succeed(Exit.succeed(item)),
|
||||
)
|
||||
return promises.create(
|
||||
settleAfterTurn(
|
||||
@@ -168,13 +168,13 @@ export const invokePromiseMethod = <R>(
|
||||
)
|
||||
}
|
||||
const observations = items.map((item) =>
|
||||
item instanceof SandboxPromise ? promises.await(item) : Effect.succeed(Exit.succeed(item)),
|
||||
item instanceof CodeModePromise ? promises.await(item) : Effect.succeed(Exit.succeed(item)),
|
||||
)
|
||||
return promises.create(settleAfterTurn(Effect.flatten(Effect.raceAll(observations))))
|
||||
}
|
||||
case "any": {
|
||||
const flipped = items.map((item) =>
|
||||
item instanceof SandboxPromise
|
||||
item instanceof CodeModePromise
|
||||
? Effect.flatMap(promises.await(item), (exit) => {
|
||||
if (Exit.isSuccess(exit)) return Effect.fail(new PromiseAnyFulfilled(exit.value))
|
||||
if (Cause.hasInterruptsOnly(exit.cause)) return Effect.failCause(exit.cause)
|
||||
@@ -201,7 +201,7 @@ export const invokePromiseInstanceMethod = <R>(
|
||||
ref: PromiseInstanceMethodReference,
|
||||
args: Array<unknown>,
|
||||
node: AstNode,
|
||||
): Effect.Effect<SandboxPromise, never, R> => {
|
||||
): Effect.Effect<CodeModePromise, never, R> => {
|
||||
const method = `Promise.prototype.${ref.name}`
|
||||
promises.markObserved(ref.promise)
|
||||
if (ref.name === "finally") {
|
||||
@@ -217,7 +217,7 @@ export const constructPromise = <R>(
|
||||
promises: PromiseRuntime<R>,
|
||||
executor: unknown,
|
||||
node: AstNode,
|
||||
): Effect.Effect<SandboxPromise, unknown, R> => {
|
||||
): Effect.Effect<CodeModePromise, unknown, R> => {
|
||||
if (!(executor instanceof CodeModeFunction)) {
|
||||
throw new InterpreterRuntimeError(
|
||||
"new Promise(...) expects an executor function (e.g. new Promise((resolve, reject) => { ... })).",
|
||||
@@ -226,10 +226,10 @@ export const constructPromise = <R>(
|
||||
}
|
||||
return Effect.gen(function* () {
|
||||
const deferred = Deferred.makeUnsafe<unknown, unknown>()
|
||||
const box: { own?: SandboxPromise } = {}
|
||||
const box: { own?: CodeModePromise } = {}
|
||||
const promise = yield* promises.create(
|
||||
Effect.flatMap(Deferred.await(deferred), (value) => {
|
||||
if (!(value instanceof SandboxPromise)) return Effect.succeed(value)
|
||||
if (!(value instanceof CodeModePromise)) return Effect.succeed(value)
|
||||
if (value === box.own) return Effect.fail(selfResolutionError(node))
|
||||
return runner.settlePromise(value)
|
||||
}),
|
||||
@@ -281,7 +281,7 @@ const reactionHandler = (value: unknown, method: string, node: AstNode): Reactio
|
||||
// Teardown bypasses handlers; settled reactions yield once so handlers never run inline.
|
||||
const reactionExit = <R>(
|
||||
promises: PromiseRuntime<R>,
|
||||
source: SandboxPromise,
|
||||
source: CodeModePromise,
|
||||
): Effect.Effect<Exit.Exit<unknown, unknown>, unknown, R> =>
|
||||
Effect.gen(function* () {
|
||||
const exit = yield* promises.await(source)
|
||||
@@ -293,13 +293,13 @@ const reactionExit = <R>(
|
||||
const chainReaction = <R>(
|
||||
runner: CallbackRunner<R>,
|
||||
promises: PromiseRuntime<R>,
|
||||
source: SandboxPromise,
|
||||
source: CodeModePromise,
|
||||
onFulfilled: ReactionHandler | undefined,
|
||||
onRejected: ReactionHandler | undefined,
|
||||
method: string,
|
||||
node: AstNode,
|
||||
): Effect.Effect<SandboxPromise, never, R> => {
|
||||
const box: { derived?: SandboxPromise } = {}
|
||||
): Effect.Effect<CodeModePromise, never, R> => {
|
||||
const box: { derived?: CodeModePromise } = {}
|
||||
const body = Effect.gen(function* () {
|
||||
const exit = yield* reactionExit(promises, source)
|
||||
const handler = Exit.isSuccess(exit) ? onFulfilled : onRejected
|
||||
@@ -307,7 +307,7 @@ const chainReaction = <R>(
|
||||
const input = Exit.isSuccess(exit) ? exit.value : caughtErrorValue(Cause.squash(exit.cause))
|
||||
const result = yield* applyCollectionCallback(runner, handler, method, node)([input])
|
||||
if (result === box.derived) return yield* Effect.fail(selfResolutionError(node))
|
||||
if (result instanceof SandboxPromise) return yield* runner.settlePromise(result)
|
||||
if (result instanceof CodeModePromise) return yield* runner.settlePromise(result)
|
||||
return result
|
||||
})
|
||||
return Effect.map(promises.create(body), (derived) => {
|
||||
@@ -319,17 +319,17 @@ const chainReaction = <R>(
|
||||
const chainFinally = <R>(
|
||||
runner: CallbackRunner<R>,
|
||||
promises: PromiseRuntime<R>,
|
||||
source: SandboxPromise,
|
||||
source: CodeModePromise,
|
||||
cleanup: ReactionHandler | undefined,
|
||||
method: string,
|
||||
node: AstNode,
|
||||
): Effect.Effect<SandboxPromise, never, R> =>
|
||||
): Effect.Effect<CodeModePromise, never, R> =>
|
||||
promises.create(
|
||||
Effect.gen(function* () {
|
||||
const exit = yield* reactionExit(promises, source)
|
||||
if (cleanup !== undefined) {
|
||||
const result = yield* applyCollectionCallback(runner, cleanup, method, node)([])
|
||||
if (result instanceof SandboxPromise) yield* runner.settlePromise(result)
|
||||
if (result instanceof CodeModePromise) yield* runner.settlePromise(result)
|
||||
}
|
||||
return yield* exit
|
||||
}),
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
UriFunction,
|
||||
} from "./model.js"
|
||||
import { ToolReference } from "../tool-runtime.js"
|
||||
import { isSandboxValue, SandboxPromise } from "../values.js"
|
||||
import { isCodeModeValue, CodeModePromise } from "../values.js"
|
||||
|
||||
export const isRuntimeReference = (value: unknown): boolean =>
|
||||
value instanceof CodeModeFunction ||
|
||||
@@ -26,13 +26,13 @@ export const isRuntimeReference = (value: unknown): boolean =>
|
||||
value instanceof PromiseNamespace ||
|
||||
value instanceof PromiseMethodReference ||
|
||||
value instanceof PromiseInstanceMethodReference ||
|
||||
value instanceof SandboxPromise ||
|
||||
value instanceof CodeModePromise ||
|
||||
value instanceof CoercionFunction ||
|
||||
value instanceof UriFunction ||
|
||||
value instanceof SearchFunction ||
|
||||
value instanceof PromiseCapabilityFunction ||
|
||||
value instanceof ErrorConstructorReference ||
|
||||
isSandboxValue(value)
|
||||
isCodeModeValue(value)
|
||||
|
||||
export const containsRuntimeReference = (value: unknown, seen = new Set<object>()): boolean => {
|
||||
if (isRuntimeReference(value)) return true
|
||||
@@ -46,9 +46,9 @@ export const containsRuntimeReference = (value: unknown, seen = new Set<object>(
|
||||
return contains
|
||||
}
|
||||
|
||||
// Sandbox values are data here, not opaque interpreter references.
|
||||
// CodeMode values are data here, not opaque interpreter references.
|
||||
export const containsOpaqueReference = (value: unknown, seen = new Set<object>()): boolean => {
|
||||
if (isSandboxValue(value)) return false
|
||||
if (isCodeModeValue(value)) return false
|
||||
if (isRuntimeReference(value)) return true
|
||||
if (value === null || typeof value !== "object") return false
|
||||
if (seen.has(value)) return false
|
||||
|
||||
@@ -73,14 +73,14 @@ import {
|
||||
valueConstructors,
|
||||
} from "../stdlib/value.js"
|
||||
import {
|
||||
isSandboxValue,
|
||||
SandboxDate,
|
||||
SandboxMap,
|
||||
SandboxPromise,
|
||||
SandboxRegExp,
|
||||
SandboxSet,
|
||||
SandboxURL,
|
||||
SandboxURLSearchParams,
|
||||
isCodeModeValue,
|
||||
CodeModeDate,
|
||||
CodeModeMap,
|
||||
CodeModePromise,
|
||||
CodeModeRegExp,
|
||||
CodeModeSet,
|
||||
CodeModeURL,
|
||||
CodeModeURLSearchParams,
|
||||
} from "../values.js"
|
||||
|
||||
const instanceofValue = (lhs: unknown, rhs: unknown, node: AstNode): boolean => {
|
||||
@@ -91,24 +91,24 @@ const instanceofValue = (lhs: unknown, rhs: unknown, node: AstNode): boolean =>
|
||||
if (rhs instanceof GlobalNamespace) {
|
||||
switch (rhs.name) {
|
||||
case "Date":
|
||||
return lhs instanceof SandboxDate
|
||||
return lhs instanceof CodeModeDate
|
||||
case "RegExp":
|
||||
return lhs instanceof SandboxRegExp
|
||||
return lhs instanceof CodeModeRegExp
|
||||
case "Map":
|
||||
return lhs instanceof SandboxMap
|
||||
return lhs instanceof CodeModeMap
|
||||
case "Set":
|
||||
return lhs instanceof SandboxSet
|
||||
return lhs instanceof CodeModeSet
|
||||
case "URL":
|
||||
return lhs instanceof SandboxURL
|
||||
return lhs instanceof CodeModeURL
|
||||
case "URLSearchParams":
|
||||
return lhs instanceof SandboxURLSearchParams
|
||||
return lhs instanceof CodeModeURLSearchParams
|
||||
case "Array":
|
||||
return Array.isArray(lhs)
|
||||
case "Object":
|
||||
return lhs !== null && (typeof lhs === "object" || typeofValue(lhs) === "function")
|
||||
}
|
||||
}
|
||||
if (rhs instanceof PromiseNamespace) return lhs instanceof SandboxPromise
|
||||
if (rhs instanceof PromiseNamespace) return lhs instanceof CodeModePromise
|
||||
if (rhs instanceof CoercionFunction && (rhs.name === "Number" || rhs.name === "String" || rhs.name === "Boolean")) {
|
||||
return false
|
||||
}
|
||||
@@ -226,7 +226,7 @@ export class Interpreter<R> {
|
||||
}
|
||||
|
||||
// The implicit async body adopts returned promises before copy-out.
|
||||
if (value instanceof SandboxPromise) value = yield* self.settlePromise(value)
|
||||
if (value instanceof CodeModePromise) value = yield* self.settlePromise(value)
|
||||
return value
|
||||
}).pipe(Effect.ensuring(Effect.sync(() => self.scopes.pop())))
|
||||
}
|
||||
@@ -235,16 +235,16 @@ export class Interpreter<R> {
|
||||
private createToolCallPromise(
|
||||
path: ReadonlyArray<string>,
|
||||
args: Array<unknown>,
|
||||
): Effect.Effect<SandboxPromise, never, R> {
|
||||
): Effect.Effect<CodeModePromise, never, R> {
|
||||
return this.createPromise(Effect.suspend(() => this.invokeTool(path, args)))
|
||||
}
|
||||
|
||||
private createPromise(effect: Effect.Effect<unknown, unknown, R>): Effect.Effect<SandboxPromise, never, R> {
|
||||
private createPromise(effect: Effect.Effect<unknown, unknown, R>): Effect.Effect<CodeModePromise, never, R> {
|
||||
return this.promises.create(effect)
|
||||
}
|
||||
|
||||
// Fiber exits make settlement idempotent; yielding prevents inline continuation.
|
||||
private settlePromise(promise: SandboxPromise): Effect.Effect<unknown, unknown, never> {
|
||||
private settlePromise(promise: CodeModePromise): Effect.Effect<unknown, unknown, never> {
|
||||
const promises = this.promises
|
||||
return Effect.suspend(() => {
|
||||
promises.markObserved(promise)
|
||||
@@ -971,7 +971,7 @@ export class Interpreter<R> {
|
||||
// Await always suspends, including for plain values.
|
||||
const self = this
|
||||
return Effect.flatMap(this.evaluateExpression(getNode(node, "argument")), (value) =>
|
||||
value instanceof SandboxPromise ? self.settlePromise(value) : Effect.as(Effect.yieldNow, value),
|
||||
value instanceof CodeModePromise ? self.settlePromise(value) : Effect.as(Effect.yieldNow, value),
|
||||
)
|
||||
}
|
||||
case "NewExpression":
|
||||
@@ -1019,23 +1019,23 @@ export class Interpreter<R> {
|
||||
throw unsupportedSyntax("NewExpression", node)
|
||||
}
|
||||
|
||||
private constructDate(args: Array<unknown>): SandboxDate {
|
||||
if (args.length === 0) return new SandboxDate(Date.now())
|
||||
private constructDate(args: Array<unknown>): CodeModeDate {
|
||||
if (args.length === 0) return new CodeModeDate(Date.now())
|
||||
if (args.length === 1) {
|
||||
const arg = args[0]
|
||||
if (arg instanceof SandboxDate) return new SandboxDate(arg.time)
|
||||
if (typeof arg === "number") return new SandboxDate(new Date(arg).getTime())
|
||||
if (typeof arg === "string") return new SandboxDate(Date.parse(arg))
|
||||
return new SandboxDate(Number.NaN)
|
||||
if (arg instanceof CodeModeDate) return new CodeModeDate(arg.time)
|
||||
if (typeof arg === "number") return new CodeModeDate(new Date(arg).getTime())
|
||||
if (typeof arg === "string") return new CodeModeDate(Date.parse(arg))
|
||||
return new CodeModeDate(Number.NaN)
|
||||
}
|
||||
const parts = args.map((arg) => coerceToNumber(arg))
|
||||
return new SandboxDate(new Date(...(parts as [number, number])).getTime())
|
||||
return new CodeModeDate(new Date(...(parts as [number, number])).getTime())
|
||||
}
|
||||
|
||||
private constructRegExp(args: Array<unknown>, node: AstNode): SandboxRegExp {
|
||||
private constructRegExp(args: Array<unknown>, node: AstNode): CodeModeRegExp {
|
||||
const first = args[0]
|
||||
const pattern =
|
||||
first instanceof SandboxRegExp ? first.regex.source : first === undefined ? "" : coerceToString(first)
|
||||
first instanceof CodeModeRegExp ? first.regex.source : first === undefined ? "" : coerceToString(first)
|
||||
const flagsArg = args[1]
|
||||
if (flagsArg !== undefined && typeof flagsArg !== "string") {
|
||||
throw new InterpreterRuntimeError(
|
||||
@@ -1043,9 +1043,9 @@ export class Interpreter<R> {
|
||||
node,
|
||||
)
|
||||
}
|
||||
const flags = flagsArg ?? (first instanceof SandboxRegExp ? first.regex.flags : "")
|
||||
const flags = flagsArg ?? (first instanceof CodeModeRegExp ? first.regex.flags : "")
|
||||
try {
|
||||
return new SandboxRegExp(pattern, flags)
|
||||
return new CodeModeRegExp(pattern, flags)
|
||||
} catch (error) {
|
||||
const reason = regexFailureReason(error)
|
||||
throw new InterpreterRuntimeError(
|
||||
@@ -1057,12 +1057,12 @@ export class Interpreter<R> {
|
||||
}
|
||||
}
|
||||
|
||||
private constructMap(init: unknown, node: AstNode): SandboxMap {
|
||||
const target = new SandboxMap()
|
||||
private constructMap(init: unknown, node: AstNode): CodeModeMap {
|
||||
const target = new CodeModeMap()
|
||||
if (init === undefined || init === null) return target
|
||||
const entries = Array.isArray(init)
|
||||
? init
|
||||
: init instanceof SandboxMap
|
||||
: init instanceof CodeModeMap
|
||||
? Array.from(init.map.entries(), ([key, item]): Array<unknown> => [key, item])
|
||||
: undefined
|
||||
if (entries === undefined) {
|
||||
@@ -1080,12 +1080,12 @@ export class Interpreter<R> {
|
||||
return target
|
||||
}
|
||||
|
||||
private constructSet(init: unknown, node: AstNode): SandboxSet {
|
||||
const target = new SandboxSet()
|
||||
private constructSet(init: unknown, node: AstNode): CodeModeSet {
|
||||
const target = new CodeModeSet()
|
||||
if (init === undefined || init === null) return target
|
||||
const items = Array.isArray(init)
|
||||
? init
|
||||
: init instanceof SandboxSet
|
||||
: init instanceof CodeModeSet
|
||||
? Array.from(init.set.values())
|
||||
: typeof init === "string"
|
||||
? Array.from(init)
|
||||
@@ -1097,7 +1097,7 @@ export class Interpreter<R> {
|
||||
return target
|
||||
}
|
||||
|
||||
private constructURL(args: Array<unknown>, node: AstNode): SandboxURL {
|
||||
private constructURL(args: Array<unknown>, node: AstNode): CodeModeURL {
|
||||
if (args.length === 0) {
|
||||
throw new InterpreterRuntimeError("new URL(...) requires a URL string and an optional base URL.", node).as(
|
||||
"TypeError",
|
||||
@@ -1106,7 +1106,7 @@ export class Interpreter<R> {
|
||||
const input = urlArgument(args[0], "new URL input")
|
||||
const base = args[1] === undefined ? undefined : urlArgument(args[1], "new URL base")
|
||||
try {
|
||||
return new SandboxURL(new URL(input, base))
|
||||
return new CodeModeURL(new URL(input, base))
|
||||
} catch {
|
||||
throw new InterpreterRuntimeError(
|
||||
`new URL(...) received an invalid URL${base === undefined ? "" : " or base URL"}.`,
|
||||
@@ -1115,16 +1115,16 @@ export class Interpreter<R> {
|
||||
}
|
||||
}
|
||||
|
||||
private constructURLSearchParams(init: unknown, node: AstNode): SandboxURLSearchParams {
|
||||
if (init === undefined) return new SandboxURLSearchParams(new URLSearchParams())
|
||||
if (init instanceof SandboxURLSearchParams) {
|
||||
return new SandboxURLSearchParams(new URLSearchParams(init.params))
|
||||
private constructURLSearchParams(init: unknown, node: AstNode): CodeModeURLSearchParams {
|
||||
if (init === undefined) return new CodeModeURLSearchParams(new URLSearchParams())
|
||||
if (init instanceof CodeModeURLSearchParams) {
|
||||
return new CodeModeURLSearchParams(new URLSearchParams(init.params))
|
||||
}
|
||||
if (typeof init === "string") return new SandboxURLSearchParams(new URLSearchParams(init))
|
||||
if (typeof init === "string") return new CodeModeURLSearchParams(new URLSearchParams(init))
|
||||
if (init === null || typeof init === "number" || typeof init === "boolean") {
|
||||
return new SandboxURLSearchParams(new URLSearchParams(coerceToString(init)))
|
||||
return new CodeModeURLSearchParams(new URLSearchParams(coerceToString(init)))
|
||||
}
|
||||
if (init instanceof SandboxMap) {
|
||||
if (init instanceof CodeModeMap) {
|
||||
return this.constructURLSearchParams(
|
||||
Array.from(init.map.entries(), ([key, value]) => [key, value]),
|
||||
node,
|
||||
@@ -1143,9 +1143,9 @@ export class Interpreter<R> {
|
||||
string,
|
||||
]
|
||||
})
|
||||
return new SandboxURLSearchParams(new URLSearchParams(entries))
|
||||
return new CodeModeURLSearchParams(new URLSearchParams(entries))
|
||||
}
|
||||
if (isSandboxValue(init)) return new SandboxURLSearchParams(new URLSearchParams())
|
||||
if (isCodeModeValue(init)) return new CodeModeURLSearchParams(new URLSearchParams())
|
||||
const data = boundedData(init, "new URLSearchParams input")
|
||||
if (data === null || typeof data !== "object") {
|
||||
throw new InterpreterRuntimeError(
|
||||
@@ -1153,7 +1153,7 @@ export class Interpreter<R> {
|
||||
node,
|
||||
).as("TypeError")
|
||||
}
|
||||
return new SandboxURLSearchParams(
|
||||
return new CodeModeURLSearchParams(
|
||||
new URLSearchParams(Object.fromEntries(Object.entries(data).map(([key, value]) => [key, coerceToString(value)]))),
|
||||
)
|
||||
}
|
||||
@@ -1176,7 +1176,7 @@ export class Interpreter<R> {
|
||||
// Null-prototype data needs explicit primitive coercion; identity and `in` retain raw objects.
|
||||
// Dates use string coercion for `+` and epoch time elsewhere.
|
||||
const coerceOperand = (operand: unknown): unknown => {
|
||||
if (operand instanceof SandboxDate) return operator === "+" ? coerceToString(operand) : operand.time
|
||||
if (operand instanceof CodeModeDate) return operator === "+" ? coerceToString(operand) : operand.time
|
||||
return operand !== null && typeof operand === "object" ? coerceToString(operand) : operand
|
||||
}
|
||||
const bothObjects = lhs !== null && typeof lhs === "object" && rhs !== null && typeof rhs === "object"
|
||||
@@ -1261,7 +1261,7 @@ export class Interpreter<R> {
|
||||
throw new InterpreterRuntimeError("Unary operators require data values in CodeMode.", node, "InvalidDataValue")
|
||||
}
|
||||
const operand =
|
||||
value instanceof SandboxDate
|
||||
value instanceof CodeModeDate
|
||||
? value.time
|
||||
: value !== null && typeof value === "object"
|
||||
? coerceToString(value)
|
||||
@@ -1520,11 +1520,11 @@ export class Interpreter<R> {
|
||||
})
|
||||
if (!fn.async) return run
|
||||
// The initial yield assigns `box.own` before the body can self-resolve.
|
||||
const box: { own?: SandboxPromise } = {}
|
||||
const box: { own?: CodeModePromise } = {}
|
||||
return Effect.map(
|
||||
this.createPromise(
|
||||
Effect.flatMap(run, (value) => {
|
||||
if (!(value instanceof SandboxPromise)) return Effect.succeed(value)
|
||||
if (!(value instanceof CodeModePromise)) return Effect.succeed(value)
|
||||
if (value === box.own) return Effect.fail(selfResolutionError())
|
||||
return invocation.settlePromise(value)
|
||||
}),
|
||||
@@ -1546,7 +1546,7 @@ export class Interpreter<R> {
|
||||
|
||||
if (property.type === "SpreadElement") {
|
||||
const spread = yield* self.evaluateExpression(getNode(property, "argument"))
|
||||
if (spread === null || spread === undefined || isSandboxValue(spread)) continue
|
||||
if (spread === null || spread === undefined || isCodeModeValue(spread)) continue
|
||||
if (typeof spread !== "object" || Array.isArray(spread) || isRuntimeReference(spread)) {
|
||||
throw new InterpreterRuntimeError(
|
||||
"Object spread requires a data object in CodeMode.",
|
||||
@@ -1748,28 +1748,28 @@ export class Interpreter<R> {
|
||||
if (objectValue.name === "String" && stringStatics.has(key)) return new GlobalMethodReference("String", key)
|
||||
}
|
||||
|
||||
if (objectValue instanceof SandboxDate) {
|
||||
if (objectValue instanceof CodeModeDate) {
|
||||
if (typeof key === "string" && dateMethods.has(key)) return new IntrinsicReference(objectValue, key)
|
||||
return new ComputedValue(undefined)
|
||||
}
|
||||
if (objectValue instanceof SandboxRegExp) {
|
||||
if (objectValue instanceof CodeModeRegExp) {
|
||||
if (typeof key === "string" && regexpProperties.has(key)) {
|
||||
return new ComputedValue((objectValue.regex as unknown as Record<string, unknown>)[key])
|
||||
}
|
||||
if (typeof key === "string" && regexpMethods.has(key)) return new IntrinsicReference(objectValue, key)
|
||||
return new ComputedValue(undefined)
|
||||
}
|
||||
if (objectValue instanceof SandboxMap) {
|
||||
if (objectValue instanceof CodeModeMap) {
|
||||
if (key === "size") return new ComputedValue(objectValue.map.size)
|
||||
if (typeof key === "string" && mapMethods.has(key)) return new IntrinsicReference(objectValue, key)
|
||||
return new ComputedValue(undefined)
|
||||
}
|
||||
if (objectValue instanceof SandboxSet) {
|
||||
if (objectValue instanceof CodeModeSet) {
|
||||
if (key === "size") return new ComputedValue(objectValue.set.size)
|
||||
if (typeof key === "string" && setMethods.has(key)) return new IntrinsicReference(objectValue, key)
|
||||
return new ComputedValue(undefined)
|
||||
}
|
||||
if (objectValue instanceof SandboxURL) {
|
||||
if (objectValue instanceof CodeModeURL) {
|
||||
if (key === "searchParams") {
|
||||
return new ComputedValue(objectValue.searchParams)
|
||||
}
|
||||
@@ -1777,7 +1777,7 @@ export class Interpreter<R> {
|
||||
if (typeof key === "string" && urlProperties.has(key)) return { target: objectValue, key }
|
||||
return new ComputedValue(undefined)
|
||||
}
|
||||
if (objectValue instanceof SandboxURLSearchParams) {
|
||||
if (objectValue instanceof CodeModeURLSearchParams) {
|
||||
if (key === "size") return new ComputedValue(objectValue.params.size)
|
||||
if (typeof key === "string" && urlSearchParamsMethods.has(key)) {
|
||||
return new IntrinsicReference(objectValue, key)
|
||||
@@ -1786,7 +1786,7 @@ export class Interpreter<R> {
|
||||
}
|
||||
|
||||
// Reject unknown promise properties so a missing await cannot hide.
|
||||
if (objectValue instanceof SandboxPromise) {
|
||||
if (objectValue instanceof CodeModePromise) {
|
||||
if (key === "then" || key === "catch" || key === "finally") {
|
||||
return new PromiseInstanceMethodReference(objectValue, key)
|
||||
}
|
||||
@@ -1851,7 +1851,7 @@ export class Interpreter<R> {
|
||||
}
|
||||
return reference.key === "length" ? reference.target.length : reference.target[Number(reference.key)]
|
||||
}
|
||||
if (reference.target instanceof SandboxURL) {
|
||||
if (reference.target instanceof CodeModeURL) {
|
||||
return (reference.target.url as unknown as Record<string, unknown>)[String(reference.key)]
|
||||
}
|
||||
return reference.target[String(reference.key)]
|
||||
@@ -1891,7 +1891,7 @@ export class Interpreter<R> {
|
||||
}
|
||||
const key = Array.isArray(reference.target) ? Number(reference.key) : String(reference.key)
|
||||
const current =
|
||||
reference.target instanceof SandboxURL
|
||||
reference.target instanceof CodeModeURL
|
||||
? (reference.target.url as unknown as Record<string, unknown>)[key]
|
||||
: (reference.target as Record<PropertyKey, unknown>)[key]
|
||||
const { write, next, result } = yield* compute(current)
|
||||
@@ -1915,7 +1915,7 @@ export class Interpreter<R> {
|
||||
target[index] = next
|
||||
return
|
||||
}
|
||||
if (reference.target instanceof SandboxURL) {
|
||||
if (reference.target instanceof CodeModeURL) {
|
||||
const property = key as string
|
||||
if (!urlWritableProperties.has(property)) {
|
||||
throw new InterpreterRuntimeError(`URL.${property} is read-only.`, node).as("TypeError")
|
||||
|
||||
@@ -43,9 +43,9 @@ export const setMethods = new Set(["add", "has", "delete", "clear", "forEach", "
|
||||
export const spreadItems = (value: unknown): Array<unknown> | undefined => {
|
||||
if (Array.isArray(value)) return value
|
||||
if (typeof value === "string") return Array.from(value)
|
||||
if (value instanceof SandboxMap) return Array.from(value.map.entries(), ([key, item]) => [key, item])
|
||||
if (value instanceof SandboxSet) return Array.from(value.set.values())
|
||||
if (value instanceof SandboxURLSearchParams) return Array.from(value.params.entries(), ([key, item]) => [key, item])
|
||||
if (value instanceof CodeModeMap) return Array.from(value.map.entries(), ([key, item]) => [key, item])
|
||||
if (value instanceof CodeModeSet) return Array.from(value.set.values())
|
||||
if (value instanceof CodeModeURLSearchParams) return Array.from(value.params.entries(), ([key, item]) => [key, item])
|
||||
return undefined
|
||||
}
|
||||
import { SandboxMap, SandboxSet, SandboxURLSearchParams } from "../values.js"
|
||||
import { CodeModeMap, CodeModeSet, CodeModeURLSearchParams } from "../values.js"
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { containsOpaqueReference, containsRuntimeReference, isRuntimeReference } from "../interpreter/references.js"
|
||||
import { copyIn, copyOut } from "../tool-runtime.js"
|
||||
import {
|
||||
isSandboxValue,
|
||||
SandboxDate,
|
||||
SandboxMap,
|
||||
SandboxPromise,
|
||||
SandboxRegExp,
|
||||
SandboxSet,
|
||||
SandboxURL,
|
||||
SandboxURLSearchParams,
|
||||
isCodeModeValue,
|
||||
CodeModeDate,
|
||||
CodeModeMap,
|
||||
CodeModePromise,
|
||||
CodeModeRegExp,
|
||||
CodeModeSet,
|
||||
CodeModeURL,
|
||||
CodeModeURLSearchParams,
|
||||
} from "../values.js"
|
||||
import { boundedData, coerceToString } from "./value.js"
|
||||
|
||||
@@ -34,14 +34,14 @@ const formatConsoleValue = (value: unknown, seen: Set<object>, depth: number): s
|
||||
if (typeof value === "string") return JSON.stringify(value)
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value)
|
||||
if (typeof value !== "object") return String(value)
|
||||
if (value instanceof SandboxPromise) return "[Promise (await it to get its value)]"
|
||||
if (value instanceof SandboxDate) return coerceToString(value)
|
||||
if (value instanceof SandboxRegExp) return coerceToString(value)
|
||||
if (value instanceof SandboxURL) return coerceToString(value)
|
||||
if (value instanceof SandboxURLSearchParams) return coerceToString(value)
|
||||
if (value instanceof CodeModePromise) return "[Promise (await it to get its value)]"
|
||||
if (value instanceof CodeModeDate) return coerceToString(value)
|
||||
if (value instanceof CodeModeRegExp) return coerceToString(value)
|
||||
if (value instanceof CodeModeURL) return coerceToString(value)
|
||||
if (value instanceof CodeModeURLSearchParams) return coerceToString(value)
|
||||
if (depth > MAX_CONSOLE_DEPTH) return "..."
|
||||
if (seen.has(value)) return "[Circular]"
|
||||
if (value instanceof SandboxMap) {
|
||||
if (value instanceof CodeModeMap) {
|
||||
seen.add(value)
|
||||
try {
|
||||
const entries = Array.from(value.map.entries(), ([key, item]): Array<unknown> => [key, item])
|
||||
@@ -50,7 +50,7 @@ const formatConsoleValue = (value: unknown, seen: Set<object>, depth: number): s
|
||||
seen.delete(value)
|
||||
}
|
||||
}
|
||||
if (value instanceof SandboxSet) {
|
||||
if (value instanceof CodeModeSet) {
|
||||
seen.add(value)
|
||||
try {
|
||||
return `Set(${value.set.size}) ${formatConsoleValue(Array.from(value.set.values()), seen, depth + 1)}`
|
||||
@@ -100,14 +100,14 @@ const consoleTableRows = (
|
||||
if (Array.isArray(data)) {
|
||||
return data.map((item, index) => ({ index: String(index), values: consoleTableValues(item, columns) }))
|
||||
}
|
||||
if (data !== null && typeof data === "object" && !isSandboxValue(data)) {
|
||||
if (data !== null && typeof data === "object" && !isCodeModeValue(data)) {
|
||||
return Object.entries(data).map(([index, item]) => ({ index, values: consoleTableValues(item, columns) }))
|
||||
}
|
||||
return [{ index: "0", values: { Value: data } }]
|
||||
}
|
||||
|
||||
const consoleTableValues = (value: unknown, columns: ReadonlyArray<string> | undefined): Record<string, unknown> => {
|
||||
if (value !== null && typeof value === "object" && !Array.isArray(value) && !isSandboxValue(value)) {
|
||||
if (value !== null && typeof value === "object" && !Array.isArray(value) && !isCodeModeValue(value)) {
|
||||
const source = value as Record<string, unknown>
|
||||
if (columns !== undefined) return Object.fromEntries(columns.map((column) => [column, source[column]]))
|
||||
return Object.fromEntries(Object.entries(source))
|
||||
|
||||
@@ -36,7 +36,7 @@ export const invokeDateStatic = (name: string, args: Array<unknown>, node: AstNo
|
||||
}
|
||||
}
|
||||
|
||||
export const invokeDateMethod = (value: SandboxDate, name: string, node: AstNode): unknown => {
|
||||
export const invokeDateMethod = (value: CodeModeDate, name: string, node: AstNode): unknown => {
|
||||
const hosted = new Date(value.time)
|
||||
switch (name) {
|
||||
case "getTime":
|
||||
@@ -88,5 +88,5 @@ export const invokeDateMethod = (value: SandboxDate, name: string, node: AstNode
|
||||
}
|
||||
}
|
||||
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
|
||||
import { SandboxDate } from "../values.js"
|
||||
import { CodeModeDate } from "../values.js"
|
||||
import { coerceToNumber, coerceToString } from "./value.js"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
|
||||
import { isBlockedMember } from "../tool-runtime.js"
|
||||
import { isSandboxValue, SandboxMap, SandboxPromise, SandboxSet, SandboxURLSearchParams } from "../values.js"
|
||||
import { isCodeModeValue, CodeModeMap, CodeModePromise, CodeModeSet, CodeModeURLSearchParams } from "../values.js"
|
||||
import { boundedData, coerceToString } from "./value.js"
|
||||
|
||||
export const objectMethodsPreservingIdentity = new Set(["assign", "values", "entries", "fromEntries"])
|
||||
@@ -9,8 +9,8 @@ export const invokeObjectMethod = (name: string, args: Array<unknown>, node: Ast
|
||||
const requireObject = (): Record<string, unknown> => {
|
||||
const input = args[0]
|
||||
if (Array.isArray(input)) return input as unknown as Record<string, unknown>
|
||||
if (isSandboxValue(input)) return {}
|
||||
if (input instanceof SandboxPromise) {
|
||||
if (isCodeModeValue(input)) return {}
|
||||
if (input instanceof CodeModePromise) {
|
||||
throw new InterpreterRuntimeError(
|
||||
`Object.${name} received an un-awaited Promise; await it before inspecting the result.`,
|
||||
node,
|
||||
@@ -46,12 +46,12 @@ export const invokeObjectMethod = (name: string, args: Array<unknown>, node: Ast
|
||||
return Object.hasOwn(requireObject(), String(args[1]))
|
||||
case "assign": {
|
||||
const target = args[0]
|
||||
if (target === null || typeof target !== "object" || Array.isArray(target) || isSandboxValue(target)) {
|
||||
if (target === null || typeof target !== "object" || Array.isArray(target) || isCodeModeValue(target)) {
|
||||
throw new InterpreterRuntimeError("Object.assign expects a data object target.", node)
|
||||
}
|
||||
const out = target as Record<string, unknown>
|
||||
for (const source of args.slice(1)) {
|
||||
if (source === null || source === undefined || isSandboxValue(source)) continue
|
||||
if (source === null || source === undefined || isCodeModeValue(source)) continue
|
||||
if (typeof source !== "object" || Array.isArray(source)) {
|
||||
throw new InterpreterRuntimeError("Object.assign expects data objects.", node)
|
||||
}
|
||||
@@ -60,17 +60,17 @@ export const invokeObjectMethod = (name: string, args: Array<unknown>, node: Ast
|
||||
return out
|
||||
}
|
||||
case "fromEntries": {
|
||||
if (args[0] instanceof SandboxMap) {
|
||||
if (args[0] instanceof CodeModeMap) {
|
||||
const out: Record<string, unknown> = Object.create(null)
|
||||
for (const [key, item] of args[0].map.entries()) addEntry(out, key, item)
|
||||
return out
|
||||
}
|
||||
if (args[0] instanceof SandboxURLSearchParams) {
|
||||
if (args[0] instanceof CodeModeURLSearchParams) {
|
||||
const out: Record<string, unknown> = Object.create(null)
|
||||
for (const [key, value] of args[0].params.entries()) guardedSet(out, key, value)
|
||||
return out
|
||||
}
|
||||
const pairs = args[0] instanceof SandboxSet ? Array.from(args[0].set.values()) : args[0]
|
||||
const pairs = args[0] instanceof CodeModeSet ? Array.from(args[0].set.values()) : args[0]
|
||||
if (!Array.isArray(pairs)) {
|
||||
boundedData(args[0], "Object.fromEntries input")
|
||||
throw new InterpreterRuntimeError("Object.fromEntries expects an array of [key, value] pairs.", node)
|
||||
@@ -78,7 +78,7 @@ export const invokeObjectMethod = (name: string, args: Array<unknown>, node: Ast
|
||||
const out: Record<string, unknown> = Object.create(null)
|
||||
for (const pair of pairs) {
|
||||
const validated = boundedData(pair, "Object.fromEntries entry")
|
||||
if (validated === null || typeof validated !== "object" || isSandboxValue(validated))
|
||||
if (validated === null || typeof validated !== "object" || isCodeModeValue(validated))
|
||||
throw new InterpreterRuntimeError("Object.fromEntries expects [key, value] entry objects.", node)
|
||||
const entry = pair as Record<string, unknown>
|
||||
addEntry(out, entry[0], entry[1])
|
||||
|
||||
@@ -19,7 +19,7 @@ export const escapeRegexHint =
|
||||
'To match special characters like ( ) [ ] { } + * ? . literally, escape them with a backslash (e.g. "\\\\(") or test for them with String.includes instead.'
|
||||
|
||||
export const toHostRegex = (arg: unknown, method: string, node: AstNode, extraFlags = ""): RegExp => {
|
||||
if (arg instanceof SandboxRegExp) return arg.regex
|
||||
if (arg instanceof CodeModeRegExp) return arg.regex
|
||||
if (typeof arg === "string") {
|
||||
try {
|
||||
return new RegExp(arg, extraFlags)
|
||||
@@ -50,7 +50,7 @@ export const matchToValue = (match: RegExpMatchArray): Array<unknown> => {
|
||||
}
|
||||
|
||||
export const invokeRegExpMethod = (
|
||||
value: SandboxRegExp,
|
||||
value: CodeModeRegExp,
|
||||
name: string,
|
||||
args: Array<unknown>,
|
||||
node: AstNode,
|
||||
@@ -70,5 +70,5 @@ export const invokeRegExpMethod = (
|
||||
}
|
||||
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
|
||||
import { isBlockedMember, type SafeObject } from "../tool-runtime.js"
|
||||
import { SandboxRegExp } from "../values.js"
|
||||
import { CodeModeRegExp } from "../values.js"
|
||||
import { coerceToString } from "./value.js"
|
||||
|
||||
@@ -66,7 +66,7 @@ export const invokeUriFunction = (ref: UriFunction, args: Array<unknown>, node:
|
||||
}
|
||||
|
||||
export const urlArgument = (value: unknown, label: string): string =>
|
||||
value instanceof SandboxURL ? value.url.href : uriArgument(value, label)
|
||||
value instanceof CodeModeURL ? value.url.href : uriArgument(value, label)
|
||||
|
||||
export const invokeURLStatic = (name: string, args: Array<unknown>, node: AstNode): unknown => {
|
||||
if (!urlStatics.has(name)) throw new InterpreterRuntimeError(`URL.${name} is not available in CodeMode.`, node)
|
||||
@@ -75,16 +75,16 @@ export const invokeURLStatic = (name: string, args: Array<unknown>, node: AstNod
|
||||
const base = args[1] === undefined ? undefined : urlArgument(args[1], `URL.${name} base`)
|
||||
try {
|
||||
const url = new URL(input, base)
|
||||
return name === "canParse" ? true : new SandboxURL(url)
|
||||
return name === "canParse" ? true : new CodeModeURL(url)
|
||||
} catch {
|
||||
return name === "canParse" ? false : null
|
||||
}
|
||||
}
|
||||
|
||||
export const invokeURLMethod = (value: SandboxURL, name: string, node: AstNode): string => {
|
||||
export const invokeURLMethod = (value: CodeModeURL, name: string, node: AstNode): string => {
|
||||
if (name === "toString" || name === "toJSON") return value.url.href
|
||||
throw new InterpreterRuntimeError(`URL method '${name}' is not available in CodeMode.`, node)
|
||||
}
|
||||
import { type AstNode, InterpreterRuntimeError, UriFunction } from "../interpreter/model.js"
|
||||
import { SandboxURL } from "../values.js"
|
||||
import { CodeModeURL } from "../values.js"
|
||||
import { boundedData, coerceToString } from "./value.js"
|
||||
|
||||
@@ -34,13 +34,13 @@ export const boundedData = (value: unknown, label: string): unknown => copyIn(va
|
||||
export const coerceToString = (value: unknown): string => {
|
||||
if (value === null) return "null"
|
||||
if (value === undefined) return "undefined"
|
||||
if (value instanceof SandboxDate)
|
||||
if (value instanceof CodeModeDate)
|
||||
return Number.isFinite(value.time) ? new Date(value.time).toISOString() : "Invalid Date"
|
||||
if (value instanceof SandboxRegExp) return `/${value.regex.source}/${value.regex.flags}`
|
||||
if (value instanceof SandboxMap) return "[object Map]"
|
||||
if (value instanceof SandboxSet) return "[object Set]"
|
||||
if (value instanceof SandboxURL) return value.url.href
|
||||
if (value instanceof SandboxURLSearchParams) return value.params.toString()
|
||||
if (value instanceof CodeModeRegExp) return `/${value.regex.source}/${value.regex.flags}`
|
||||
if (value instanceof CodeModeMap) return "[object Map]"
|
||||
if (value instanceof CodeModeSet) return "[object Set]"
|
||||
if (value instanceof CodeModeURL) return value.url.href
|
||||
if (value instanceof CodeModeURLSearchParams) return value.params.toString()
|
||||
if (typeof value === "object") {
|
||||
return Array.isArray(value)
|
||||
? value.map((item) => (item === null || item === undefined ? "" : coerceToString(item))).join(",")
|
||||
@@ -50,14 +50,14 @@ export const coerceToString = (value: unknown): string => {
|
||||
}
|
||||
|
||||
export const coerceToNumber = (value: unknown): number => {
|
||||
if (value instanceof SandboxDate) return value.time
|
||||
if (isSandboxValue(value)) return Number.NaN
|
||||
if (value instanceof CodeModeDate) return value.time
|
||||
if (isCodeModeValue(value)) return Number.NaN
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value) ? Number.NaN : Number(value)
|
||||
}
|
||||
|
||||
export const invokeCoercion = (ref: CoercionFunction, args: Array<unknown>, node: AstNode): unknown => {
|
||||
const raw = args[0]
|
||||
if (isSandboxValue(raw)) {
|
||||
if (isCodeModeValue(raw)) {
|
||||
if (ref.name === "Boolean") return true
|
||||
if (ref.name === "Number") return coerceToNumber(raw)
|
||||
if (ref.name === "String") return coerceToString(raw)
|
||||
@@ -80,11 +80,11 @@ export const invokeCoercion = (ref: CoercionFunction, args: Array<unknown>, node
|
||||
import { type AstNode, CoercionFunction, InterpreterRuntimeError } from "../interpreter/model.js"
|
||||
import { copyIn, type SafeObject } from "../tool-runtime.js"
|
||||
import {
|
||||
isSandboxValue,
|
||||
SandboxDate,
|
||||
SandboxMap,
|
||||
SandboxRegExp,
|
||||
SandboxSet,
|
||||
SandboxURL,
|
||||
SandboxURLSearchParams,
|
||||
isCodeModeValue,
|
||||
CodeModeDate,
|
||||
CodeModeMap,
|
||||
CodeModeRegExp,
|
||||
CodeModeSet,
|
||||
CodeModeURL,
|
||||
CodeModeURLSearchParams,
|
||||
} from "../values.js"
|
||||
|
||||
@@ -10,13 +10,13 @@ import {
|
||||
} from "./tool-schema.js"
|
||||
import { isDefinition as isToolDefinition, type Definition } from "./tool.js"
|
||||
import {
|
||||
SandboxDate,
|
||||
SandboxMap,
|
||||
SandboxPromise,
|
||||
SandboxRegExp,
|
||||
SandboxSet,
|
||||
SandboxURL,
|
||||
SandboxURLSearchParams,
|
||||
CodeModeDate,
|
||||
CodeModeMap,
|
||||
CodeModePromise,
|
||||
CodeModeRegExp,
|
||||
CodeModeSet,
|
||||
CodeModeURL,
|
||||
CodeModeURLSearchParams,
|
||||
} from "./values.js"
|
||||
|
||||
const estimateTokens = (input: string) => Math.max(0, Math.round(input.length / 4))
|
||||
@@ -141,16 +141,16 @@ const blockedMemberNames = new Set(["__proto__", "constructor", "prototype"])
|
||||
|
||||
export const isBlockedMember = (name: string): boolean => blockedMemberNames.has(name)
|
||||
|
||||
// Checkpoint mode preserves sandbox values; boundary mode JSON-normalizes them.
|
||||
export const copyIn = (value: unknown, label: string, preserveSandboxValues = false): unknown =>
|
||||
copyBounded(value, label, 0, new Set(), preserveSandboxValues)
|
||||
// Checkpoint mode preserves CodeMode values; boundary mode JSON-normalizes them.
|
||||
export const copyIn = (value: unknown, label: string, preserveCodeModeValues = false): unknown =>
|
||||
copyBounded(value, label, 0, new Set(), preserveCodeModeValues)
|
||||
|
||||
const copyBounded = (
|
||||
value: unknown,
|
||||
label: string,
|
||||
depth: number,
|
||||
seen: Set<object>,
|
||||
preserveSandboxValues: boolean,
|
||||
preserveCodeModeValues: boolean,
|
||||
): unknown => {
|
||||
if (depth > MAX_VALUE_DEPTH) {
|
||||
throw new ToolRuntimeError("InvalidDataValue", `${label} exceeds the maximum value depth of ${MAX_VALUE_DEPTH}.`)
|
||||
@@ -169,55 +169,55 @@ const copyBounded = (
|
||||
throw new ToolRuntimeError("InvalidDataValue", `${label} must contain data only.`)
|
||||
}
|
||||
|
||||
if (value instanceof SandboxPromise) {
|
||||
if (value instanceof CodeModePromise) {
|
||||
throw new ToolRuntimeError(
|
||||
"InvalidDataValue",
|
||||
`${label} contains an un-awaited Promise; await tool calls (e.g. \`const result = await tools.ns.tool(...)\`) before using their results.`,
|
||||
)
|
||||
}
|
||||
|
||||
if (preserveSandboxValues) {
|
||||
if (preserveCodeModeValues) {
|
||||
if (
|
||||
value instanceof SandboxDate ||
|
||||
value instanceof SandboxRegExp ||
|
||||
value instanceof SandboxMap ||
|
||||
value instanceof SandboxSet ||
|
||||
value instanceof SandboxURL ||
|
||||
value instanceof SandboxURLSearchParams
|
||||
value instanceof CodeModeDate ||
|
||||
value instanceof CodeModeRegExp ||
|
||||
value instanceof CodeModeMap ||
|
||||
value instanceof CodeModeSet ||
|
||||
value instanceof CodeModeURL ||
|
||||
value instanceof CodeModeURLSearchParams
|
||||
) {
|
||||
return value
|
||||
}
|
||||
if (value instanceof Date) return new SandboxDate(value.getTime())
|
||||
if (value instanceof RegExp) return new SandboxRegExp(value.source, value.flags)
|
||||
if (value instanceof Date) return new CodeModeDate(value.getTime())
|
||||
if (value instanceof RegExp) return new CodeModeRegExp(value.source, value.flags)
|
||||
if (value instanceof Map) {
|
||||
const wrapped = new SandboxMap()
|
||||
const wrapped = new CodeModeMap()
|
||||
for (const [key, item] of value.entries()) {
|
||||
wrapped.map.set(copyBounded(key, label, depth + 1, seen, true), copyBounded(item, label, depth + 1, seen, true))
|
||||
}
|
||||
return wrapped
|
||||
}
|
||||
if (value instanceof Set) {
|
||||
const wrapped = new SandboxSet()
|
||||
const wrapped = new CodeModeSet()
|
||||
for (const item of value.values()) wrapped.set.add(copyBounded(item, label, depth + 1, seen, true))
|
||||
return wrapped
|
||||
}
|
||||
if (value instanceof URL) return new SandboxURL(new URL(value.href))
|
||||
if (value instanceof URLSearchParams) return new SandboxURLSearchParams(new URLSearchParams(value))
|
||||
if (value instanceof URL) return new CodeModeURL(new URL(value.href))
|
||||
if (value instanceof URLSearchParams) return new CodeModeURLSearchParams(new URLSearchParams(value))
|
||||
}
|
||||
|
||||
if (value instanceof SandboxDate) {
|
||||
if (value instanceof CodeModeDate) {
|
||||
return Number.isFinite(value.time) ? new Date(value.time).toISOString() : null
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return Number.isFinite(value.getTime()) ? value.toISOString() : null
|
||||
}
|
||||
if (value instanceof SandboxURL) return value.url.href
|
||||
if (value instanceof CodeModeURL) return value.url.href
|
||||
if (value instanceof URL) return value.href
|
||||
if (
|
||||
value instanceof SandboxRegExp ||
|
||||
value instanceof SandboxMap ||
|
||||
value instanceof SandboxSet ||
|
||||
value instanceof SandboxURLSearchParams ||
|
||||
value instanceof CodeModeRegExp ||
|
||||
value instanceof CodeModeMap ||
|
||||
value instanceof CodeModeSet ||
|
||||
value instanceof CodeModeURLSearchParams ||
|
||||
value instanceof RegExp ||
|
||||
value instanceof Map ||
|
||||
value instanceof Set ||
|
||||
@@ -233,8 +233,8 @@ const copyBounded = (
|
||||
seen.add(value)
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const copied = value.map((item) => copyBounded(item, label, depth + 1, seen, preserveSandboxValues))
|
||||
if (preserveSandboxValues) {
|
||||
const copied = value.map((item) => copyBounded(item, label, depth + 1, seen, preserveCodeModeValues))
|
||||
if (preserveCodeModeValues) {
|
||||
// Checkpoint copies retain array metadata that boundary copies omit.
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
if (Object.hasOwn(copied, key)) continue
|
||||
@@ -258,7 +258,7 @@ const copyBounded = (
|
||||
if (isBlockedMember(key)) {
|
||||
throw new ToolRuntimeError("InvalidDataValue", `${label} contains blocked property '${key}'.`)
|
||||
}
|
||||
copied[key] = copyBounded(item, label, depth + 1, seen, preserveSandboxValues)
|
||||
copied[key] = copyBounded(item, label, depth + 1, seen, preserveCodeModeValues)
|
||||
}
|
||||
seen.delete(value)
|
||||
return copied
|
||||
|
||||
@@ -1,45 +1,45 @@
|
||||
import type { Fiber } from "effect"
|
||||
|
||||
export class SandboxPromise {
|
||||
export class CodeModePromise {
|
||||
constructor(readonly fiber: Fiber.Fiber<unknown, unknown>) {}
|
||||
}
|
||||
|
||||
export class SandboxDate {
|
||||
export class CodeModeDate {
|
||||
constructor(readonly time: number) {}
|
||||
}
|
||||
|
||||
export class SandboxRegExp {
|
||||
export class CodeModeRegExp {
|
||||
readonly regex: RegExp
|
||||
constructor(pattern: string, flags: string) {
|
||||
this.regex = new RegExp(pattern, flags)
|
||||
}
|
||||
}
|
||||
|
||||
export class SandboxMap {
|
||||
export class CodeModeMap {
|
||||
readonly map = new Map<unknown, unknown>()
|
||||
}
|
||||
|
||||
export class SandboxSet {
|
||||
export class CodeModeSet {
|
||||
readonly set = new Set<unknown>()
|
||||
}
|
||||
|
||||
export class SandboxURLSearchParams {
|
||||
export class CodeModeURLSearchParams {
|
||||
constructor(readonly params: URLSearchParams) {}
|
||||
}
|
||||
|
||||
export class SandboxURL {
|
||||
readonly searchParams: SandboxURLSearchParams
|
||||
export class CodeModeURL {
|
||||
readonly searchParams: CodeModeURLSearchParams
|
||||
constructor(readonly url: URL) {
|
||||
this.searchParams = new SandboxURLSearchParams(url.searchParams)
|
||||
this.searchParams = new CodeModeURLSearchParams(url.searchParams)
|
||||
}
|
||||
}
|
||||
|
||||
export const isSandboxValue = (
|
||||
export const isCodeModeValue = (
|
||||
value: unknown,
|
||||
): value is SandboxDate | SandboxRegExp | SandboxMap | SandboxSet | SandboxURL | SandboxURLSearchParams =>
|
||||
value instanceof SandboxDate ||
|
||||
value instanceof SandboxRegExp ||
|
||||
value instanceof SandboxMap ||
|
||||
value instanceof SandboxSet ||
|
||||
value instanceof SandboxURL ||
|
||||
value instanceof SandboxURLSearchParams
|
||||
): value is CodeModeDate | CodeModeRegExp | CodeModeMap | CodeModeSet | CodeModeURL | CodeModeURLSearchParams =>
|
||||
value instanceof CodeModeDate ||
|
||||
value instanceof CodeModeRegExp ||
|
||||
value instanceof CodeModeMap ||
|
||||
value instanceof CodeModeSet ||
|
||||
value instanceof CodeModeURL ||
|
||||
value instanceof CodeModeURLSearchParams
|
||||
|
||||
@@ -276,7 +276,7 @@ describe("CodeMode console capture", () => {
|
||||
expect(result.logs).toStrictEqual(["NaN", "Infinity -Infinity", '{"ratio":NaN,"bounds":[Infinity]}'])
|
||||
})
|
||||
|
||||
test("renders sandbox values nested inside logged containers", async () => {
|
||||
test("renders CodeMode values nested inside logged containers", async () => {
|
||||
const result = await Effect.runPromise(
|
||||
CodeMode.execute({
|
||||
code: `
|
||||
@@ -311,7 +311,7 @@ describe("CodeMode console capture", () => {
|
||||
expect(result.logs).toStrictEqual(['{"box":Map(1) [["self",[Circular]]]}', '{"fn":[CodeMode reference],"ok":1}'])
|
||||
})
|
||||
|
||||
test("console.table renders sandbox value cells", async () => {
|
||||
test("console.table renders CodeMode value cells", async () => {
|
||||
const result = await Effect.runPromise(
|
||||
CodeMode.execute({
|
||||
code: `
|
||||
|
||||
@@ -8,7 +8,7 @@ import { ToolRuntime } from "../src/tool-runtime.js"
|
||||
// a strict interpreter would throw but idiomatic JS yields undefined / succeeds.
|
||||
//
|
||||
// Note on the result boundary: this package normalizes a bare `undefined` result to `null` when
|
||||
// it crosses out of the sandbox (results are JSON data), so tests asserting an in-sandbox
|
||||
// it crosses out of CodeMode (results are JSON data), so tests asserting an in-CodeMode
|
||||
// `undefined` read check `=== undefined` inside the program and `null` at the boundary.
|
||||
const run = (code: string) => Effect.runPromise(CodeMode.execute({ code, tools: {} }))
|
||||
const value = async (code: string) => {
|
||||
@@ -108,7 +108,7 @@ describe("H1: NaN/Infinity flow as intermediates and normalize to null at the bo
|
||||
expect(await value(`const a = []; return a.length ? a.reduce((s,x)=>s+x,0)/a.length : 0`)).toBe(0)
|
||||
})
|
||||
|
||||
test("a non-finite value becomes null when it leaves the sandbox", async () => {
|
||||
test("a non-finite value becomes null when it leaves CodeMode", async () => {
|
||||
expect(await value(`return 5/0`)).toBeNull()
|
||||
expect(await value(`return 0/0`)).toBeNull()
|
||||
expect(await value(`return Math.max()`)).toBeNull()
|
||||
@@ -116,12 +116,12 @@ describe("H1: NaN/Infinity flow as intermediates and normalize to null at the bo
|
||||
expect(await value(`return { a: Number("x"), b: 2, c: [1/0] }`)).toEqual({ a: null, b: 2, c: [null] })
|
||||
})
|
||||
|
||||
test("NaN and Infinity are usable identifiers and inspectable in-sandbox", async () => {
|
||||
test("NaN and Infinity are usable identifiers and inspectable in-CodeMode", async () => {
|
||||
expect(await value(`return Number.isNaN(NaN)`)).toBe(true)
|
||||
expect(await value(`return Infinity > 1e9`)).toBe(true)
|
||||
expect(await value(`return Number.isFinite(1/0)`)).toBe(false)
|
||||
expect(await value(`return [3,1,2].reduce((a,b)=>Math.max(a,b), -Infinity)`)).toBe(3)
|
||||
// JSON.stringify inside the sandbox matches JS: non-finite serializes to null
|
||||
// JSON.stringify inside CodeMode matches JS: non-finite serializes to null
|
||||
expect(await value(`return JSON.stringify({ x: Number("z") })`)).toBe('{"x":null}')
|
||||
})
|
||||
|
||||
@@ -321,12 +321,12 @@ describe("compound assignment matches its binary operator", () => {
|
||||
return a
|
||||
}
|
||||
|
||||
test("sandbox Date += concatenates its string form, like d = d + 1", async () => {
|
||||
test("CodeMode Date += concatenates its string form, like d = d + 1", async () => {
|
||||
const result = await pair(`let d = new Date(1000); d += 1; return d`, `let d = new Date(1000); d = d + 1; return d`)
|
||||
expect(result).toBe("1970-01-01T00:00:01.000Z1")
|
||||
})
|
||||
|
||||
test("sandbox Date numeric compound ops use its time value", async () => {
|
||||
test("CodeMode Date numeric compound ops use its time value", async () => {
|
||||
expect(
|
||||
await pair(`let d = new Date(1000); d -= 400; return d`, `let d = new Date(1000); d = d - 400; return d`),
|
||||
).toBe(600)
|
||||
|
||||
@@ -212,7 +212,7 @@ describe("Test262 Promise statics", () => {
|
||||
])
|
||||
})
|
||||
|
||||
test("Promise.resolve adopts values and preserves sandbox-promise identity", async () => {
|
||||
test("Promise.resolve adopts values and preserves CodeMode-promise identity", async () => {
|
||||
// Sources:
|
||||
// test/built-ins/Promise/resolve/S25.4.4.5_A2.1_T1.js
|
||||
// test/built-ins/Promise/resolve/resolve-non-obj.js
|
||||
@@ -289,7 +289,7 @@ describe("Test262 Promise statics", () => {
|
||||
// test/built-ins/Promise/all/reject-immed.js
|
||||
// test/built-ins/Promise/allSettled/reject-immed.js
|
||||
// test/built-ins/Promise/race/reject-immed.js
|
||||
// (adapted: immediately-rejecting thenables become sandbox promises that settled,
|
||||
// (adapted: immediately-rejecting thenables become CodeMode promises that settled,
|
||||
// and were even observed, before the combinator call)
|
||||
expect(
|
||||
await value(`
|
||||
@@ -360,7 +360,7 @@ describe("Test262 Promise statics", () => {
|
||||
// test/built-ins/Promise/race/S25.4.4.3_A2.1_T1.js
|
||||
// test/built-ins/Promise/race/S25.4.4.3_A5.1_T1.js
|
||||
// (adapted: upstream requires Promise.race([]) to never settle; CodeMode intentionally
|
||||
// rejects with a catchable diagnostic instead of hanging, so this asserts the sandbox
|
||||
// rejects with a catchable diagnostic instead of hanging, so this asserts CodeMode
|
||||
// divergence rather than the spec never-settles behavior)
|
||||
expect(
|
||||
await value(`
|
||||
@@ -375,7 +375,7 @@ describe("Test262 Promise statics", () => {
|
||||
).toEqual([true, true])
|
||||
})
|
||||
|
||||
test("Promise.resolve passes the same sandbox promise through nested chains", async () => {
|
||||
test("Promise.resolve passes the same CodeMode promise through nested chains", async () => {
|
||||
// Source: test/built-ins/Promise/resolve/S25.4.4.5_A2.2_T1.js
|
||||
// (adapted: no executor construction, and identity is observed with Array includes
|
||||
// because promises are not comparable data values in CodeMode)
|
||||
@@ -1208,7 +1208,7 @@ describe("Test262 AggregateError", () => {
|
||||
|
||||
test("coerces a non-string message to a string", async () => {
|
||||
// Source: test/built-ins/AggregateError/message-method-prop-cast.js (value coercion only; the
|
||||
// upstream object-with-toString case is omitted because the sandbox has no user toString dispatch)
|
||||
// upstream object-with-toString case is omitted because CodeMode has no user toString dispatch)
|
||||
expect(
|
||||
await value(`
|
||||
return [
|
||||
@@ -1408,7 +1408,7 @@ describe("Test262 Promise constructor", () => {
|
||||
|
||||
test.failing("calling Promise without new throws TypeError", async () => {
|
||||
// Source: test/built-ins/Promise/undefined-newtarget.js
|
||||
// The sandbox currently reports a generic Error ("Only tools are callable in CodeMode.").
|
||||
// CodeMode currently reports a generic Error ("Only tools are callable in CodeMode.").
|
||||
expect(
|
||||
await value(`
|
||||
try {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Effect, Schema } from "effect"
|
||||
import { CodeMode, Tool } from "../src/index.js"
|
||||
|
||||
// Standard-library value types: Date, RegExp, Map, Set. Programs use them as ordinary JS;
|
||||
// intra-sandbox checkpoints (Object.* helpers, spread, coercion inputs) preserve the live
|
||||
// intra-CodeMode checkpoints (Object.* helpers, spread, coercion inputs) preserve the live
|
||||
// values, while at the host boundary (final result, tool arguments, JSON.stringify) they
|
||||
// serialize exactly as JSON.stringify would: Date -> ISO string (invalid -> null),
|
||||
// URL -> href, and RegExp/Map/Set/URLSearchParams -> {}.
|
||||
@@ -69,7 +69,7 @@ describe("Date", () => {
|
||||
).toEqual([2024, 2, 5, 6, 7, 8, 9])
|
||||
})
|
||||
|
||||
test("invalid dates yield NaN times, guardable in-sandbox", async () => {
|
||||
test("invalid dates yield NaN times, guardable in-CodeMode", async () => {
|
||||
expect(await value(`return Number.isNaN(new Date("garbage").getTime())`)).toBe(true)
|
||||
expect(await value(`return new Date("garbage").toJSON()`)).toBeNull()
|
||||
})
|
||||
@@ -702,11 +702,11 @@ describe("stdlib integration", () => {
|
||||
expect(await value(`const fn = () => 1; return !fn`)).toBe(false)
|
||||
})
|
||||
|
||||
test("object spread of sandbox values is a no-op, like JS", async () => {
|
||||
test("object spread of CodeMode values is a no-op, like JS", async () => {
|
||||
expect(await value(`return { ...new Map([["a", 1]]), kept: true }`)).toEqual({ kept: true })
|
||||
})
|
||||
|
||||
test("dates inside Map values survive in-sandbox reads", async () => {
|
||||
test("dates inside Map values survive in-CodeMode reads", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const m = new Map([["start", new Date(1000)]])
|
||||
@@ -748,7 +748,7 @@ describe("stdlib integration", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("sandbox values at intra-sandbox checkpoints", () => {
|
||||
describe("CodeMode values at intra-CodeMode checkpoints", () => {
|
||||
test("Object.values/entries keep Dates usable", async () => {
|
||||
expect(await value(`return Object.values({ d: new Date(0) })[0].getTime()`)).toBe(0)
|
||||
expect(await value(`const [key, d] = Object.entries({ d: new Date(0) })[0]; return key + ":" + d.getTime()`)).toBe(
|
||||
@@ -799,7 +799,7 @@ describe("sandbox values at intra-sandbox checkpoints", () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("object and array spread keep sandbox values usable", async () => {
|
||||
test("object and array spread keep CodeMode values usable", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const src = { m: new Map([["a", 1]]) }
|
||||
@@ -811,7 +811,7 @@ describe("sandbox values at intra-sandbox checkpoints", () => {
|
||||
expect(await value(`const list = [new Date(1000)]; const copy = [...list]; return copy[0].getTime()`)).toBe(1000)
|
||||
})
|
||||
|
||||
test("Array.from over arrays keeps nested sandbox values usable", async () => {
|
||||
test("Array.from over arrays keeps nested CodeMode values usable", async () => {
|
||||
expect(await value(`return Array.from([new Date(5)])[0].getTime()`)).toBe(5)
|
||||
})
|
||||
|
||||
@@ -866,7 +866,7 @@ describe("sandbox values at intra-sandbox checkpoints", () => {
|
||||
expect(await value(`return Object.values({ r: /ab+/ })[0].test("abb")`)).toBe(true)
|
||||
})
|
||||
|
||||
test("Object.* helpers see sandbox values as empty objects, never internals", async () => {
|
||||
test("Object.* helpers see CodeMode values as empty objects, never internals", async () => {
|
||||
expect(await value(`return Object.keys(new Map([["a", 1]]))`)).toEqual([])
|
||||
expect(await value(`return Object.values(new Date(0))`)).toEqual([])
|
||||
expect(await value(`return Object.entries(new Set([1]))`)).toEqual([])
|
||||
|
||||
+16
-88
@@ -15,43 +15,20 @@ import type {
|
||||
SharedV3ProviderOptions,
|
||||
} from "@ai-sdk/provider"
|
||||
import {
|
||||
APIError,
|
||||
Authentication,
|
||||
BadRequest,
|
||||
ConnectionError,
|
||||
FinishReason,
|
||||
HttpContext,
|
||||
HttpRequestDetails,
|
||||
HttpResponseDetails,
|
||||
InvalidProviderOutputReason,
|
||||
LLMEvent,
|
||||
MalformedResponse,
|
||||
LLMError,
|
||||
Model,
|
||||
NotFound,
|
||||
ProviderID,
|
||||
ProviderMetadata,
|
||||
ToolResultValue,
|
||||
classifyApiFailure,
|
||||
isLLMError,
|
||||
type LLMError,
|
||||
UnknownProviderReason,
|
||||
type ContentPart,
|
||||
type LLMRequest,
|
||||
type ToolDefinition,
|
||||
type UsageInput,
|
||||
} from "@opencode-ai/llm"
|
||||
import {
|
||||
APICallError,
|
||||
EmptyResponseBodyError,
|
||||
InvalidArgumentError,
|
||||
InvalidPromptError,
|
||||
InvalidResponseDataError,
|
||||
JSONParseError,
|
||||
LoadAPIKeyError,
|
||||
LoadSettingError,
|
||||
NoContentGeneratedError,
|
||||
NoSuchModelError,
|
||||
TypeValidationError,
|
||||
UnsupportedFunctionalityError,
|
||||
} from "@ai-sdk/provider"
|
||||
import { Auth, Endpoint, type AnyRoute } from "@opencode-ai/llm/route"
|
||||
import { Cause, Context, Effect, Layer, Option, Schema, Scope, Stream } from "effect"
|
||||
import { ModelV2 } from "./model"
|
||||
@@ -513,12 +490,12 @@ function streamLanguage(language: LanguageModelV3, options: LanguageModelV3CallO
|
||||
Stream.unwrap(
|
||||
Effect.tryPromise({
|
||||
try: () => language.doStream(options),
|
||||
catch: (error) => llmError(error),
|
||||
catch: (error) => llmError("doStream", error),
|
||||
}).pipe(
|
||||
Effect.map((result) =>
|
||||
Stream.fromReadableStream({
|
||||
evaluate: () => result.stream,
|
||||
onError: (error) => llmError(error),
|
||||
onError: (error) => llmError("readStream", error),
|
||||
}).pipe(
|
||||
Stream.mapEffect((event) => streamPartEvents(state, event)),
|
||||
Stream.flatMap((events) => Stream.fromIterable(events)),
|
||||
@@ -631,7 +608,7 @@ function streamPartEvents(
|
||||
}),
|
||||
])
|
||||
case "error":
|
||||
return Effect.fail(llmError(event.error))
|
||||
return Effect.fail(llmError("stream", event.error))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -689,65 +666,16 @@ function messageValue(input: unknown) {
|
||||
}
|
||||
}
|
||||
|
||||
const BODY_LIMIT = 16_384
|
||||
|
||||
const headerRetryAfterMs = (headers: Record<string, string> | undefined) => {
|
||||
if (!headers) return undefined
|
||||
const millis = Number(headers["retry-after-ms"])
|
||||
if (Number.isFinite(millis)) return Math.max(0, millis)
|
||||
const value = headers["retry-after"]
|
||||
if (!value) return undefined
|
||||
const seconds = Number(value)
|
||||
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000)
|
||||
const date = Date.parse(value)
|
||||
if (!Number.isNaN(date)) return Math.max(0, date - Date.now())
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Classify AI SDK failures into the shared `LLMError` union so the synthetic
|
||||
// AI SDK route reports failures identically to native protocol routes. An
|
||||
// `APICallError` without a status code is the AI SDK's representation of a
|
||||
// network-level failure (connect refused, reset, DNS), not an API rejection.
|
||||
function llmError(error: unknown): LLMError {
|
||||
if (isLLMError(error)) return error
|
||||
if (APICallError.isInstance(error)) {
|
||||
if (error.statusCode === undefined) {
|
||||
return new ConnectionError({ message: error.message, url: error.url, cause: error })
|
||||
}
|
||||
return classifyApiFailure({
|
||||
message: error.message,
|
||||
status: error.statusCode,
|
||||
retryAfterMs: headerRetryAfterMs(error.responseHeaders),
|
||||
requestID: error.responseHeaders?.["x-request-id"] ?? error.responseHeaders?.["request-id"],
|
||||
http: new HttpContext({
|
||||
request: new HttpRequestDetails({ method: "POST", url: error.url, headers: {} }),
|
||||
response: new HttpResponseDetails({ status: error.statusCode, headers: error.responseHeaders ?? {} }),
|
||||
body: error.responseBody === undefined ? undefined : error.responseBody.slice(0, BODY_LIMIT),
|
||||
bodyTruncated: error.responseBody !== undefined && error.responseBody.length > BODY_LIMIT ? true : undefined,
|
||||
}),
|
||||
})
|
||||
}
|
||||
if (LoadAPIKeyError.isInstance(error) || LoadSettingError.isInstance(error)) {
|
||||
return new Authentication({ message: error.message })
|
||||
}
|
||||
if (NoSuchModelError.isInstance(error)) return new NotFound({ message: error.message })
|
||||
if (
|
||||
InvalidPromptError.isInstance(error) ||
|
||||
InvalidArgumentError.isInstance(error) ||
|
||||
UnsupportedFunctionalityError.isInstance(error)
|
||||
) {
|
||||
return new BadRequest({ message: error.message })
|
||||
}
|
||||
if (
|
||||
InvalidResponseDataError.isInstance(error) ||
|
||||
JSONParseError.isInstance(error) ||
|
||||
TypeValidationError.isInstance(error) ||
|
||||
EmptyResponseBodyError.isInstance(error) ||
|
||||
NoContentGeneratedError.isInstance(error)
|
||||
) {
|
||||
return new MalformedResponse({ message: error.message })
|
||||
}
|
||||
return new APIError({ message: error instanceof Error ? error.message : String(error) })
|
||||
function llmError(method: string, error: unknown) {
|
||||
const reason =
|
||||
error instanceof LLMError
|
||||
? new InvalidProviderOutputReason({ message: error.message })
|
||||
: new UnknownProviderReason({ message: error instanceof Error ? error.message : String(error) })
|
||||
return new LLMError({
|
||||
module: "AISDK",
|
||||
method,
|
||||
reason,
|
||||
})
|
||||
}
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer: locationLayer, deps: [] })
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as Generate from "./generate"
|
||||
|
||||
import { LLM, LLMClient, type LLMError } from "@opencode-ai/llm"
|
||||
import { LLM, LLMClient, LLMError } from "@opencode-ai/llm"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Catalog } from "./catalog"
|
||||
import { makeLocationNode } from "./effect/app-node"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as SessionCompaction from "./compaction"
|
||||
|
||||
import { LLM, LLMClient, LLMEvent, Message, isLLMError, type LLMError, type LLMRequest, type Model } from "@opencode-ai/llm"
|
||||
import { LLM, LLMClient, LLMError, LLMEvent, Message, type LLMRequest, type Model } from "@opencode-ai/llm"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { Context, Effect, Layer, Stream } from "effect"
|
||||
import { Config } from "../config"
|
||||
@@ -16,6 +16,7 @@ import { Token } from "../util/token"
|
||||
|
||||
const DEFAULT_BUFFER = 20_000
|
||||
const DEFAULT_KEEP_TOKENS = 8_000
|
||||
const OUTPUT_TOKEN_MAX = 32_000
|
||||
const TOOL_OUTPUT_MAX_CHARS = 2_000
|
||||
const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside <template> and keep the section order unchanged. Do not include the <template> tags in your response.
|
||||
<template>
|
||||
@@ -247,6 +248,11 @@ const make = (dependencies: Dependencies) => {
|
||||
)
|
||||
.pipe(
|
||||
Stream.runForEach((event) => {
|
||||
if (LLMEvent.is.providerError(event))
|
||||
failure = {
|
||||
type: event.classification === "context-overflow" ? "provider.invalid-request" : "provider.error",
|
||||
message: event.message,
|
||||
}
|
||||
if (LLMEvent.is.textDelta(event)) {
|
||||
chunks.push(event.text)
|
||||
return dependencies.events.publish(SessionEvent.Compaction.Delta, {
|
||||
@@ -256,7 +262,7 @@ const make = (dependencies: Dependencies) => {
|
||||
}
|
||||
return Effect.void
|
||||
}),
|
||||
Effect.catchIf(isLLMError, (error) =>
|
||||
Effect.catchTag("LLM.Error", (error) =>
|
||||
Effect.sync(() => {
|
||||
failure = toSessionError(error)
|
||||
}),
|
||||
@@ -315,7 +321,7 @@ const make = (dependencies: Dependencies) => {
|
||||
message.type === "assistant" && message.tokens !== undefined,
|
||||
)
|
||||
if (!last) return false
|
||||
const output = input.model.route.defaults.limits?.output ?? 0
|
||||
const output = Math.min(input.model.route.defaults.limits?.output ?? 0, OUTPUT_TOKEN_MAX)
|
||||
const used =
|
||||
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
|
||||
if (used <= 0) return false
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
export * as SessionRunnerLLM from "./llm"
|
||||
|
||||
import { LLM, LLMClient, LLMEvent, Message, SystemPart, isLLMError, type LLMError } from "@opencode-ai/llm"
|
||||
import {
|
||||
LLM,
|
||||
LLMClient,
|
||||
LLMError,
|
||||
LLMEvent,
|
||||
Message,
|
||||
SystemPart,
|
||||
isContextOverflowFailure,
|
||||
type ProviderErrorEvent,
|
||||
} from "@opencode-ai/llm"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Cause, Effect, Exit, Fiber, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
|
||||
@@ -218,10 +227,17 @@ const layer = Layer.effect(
|
||||
// mid-event.
|
||||
const serialized = <A, E, R>(effect: Effect.Effect<A, E, R>) => publication.withPermit(effect)
|
||||
const publish = (event: LLMEvent, error?: SessionError.Error) => serialized(publisher.publish(event, error))
|
||||
let overflowFailure: ProviderErrorEvent | undefined
|
||||
const providerStream = llm.stream(request).pipe(
|
||||
Stream.runForEach((event) =>
|
||||
Effect.gen(function* () {
|
||||
if (publisher.hasProviderError()) return
|
||||
if (overflowFailure || publisher.hasProviderError()) return
|
||||
if (LLMEvent.is.providerError(event)) {
|
||||
if (isContextOverflowFailure(event) && !publisher.hasRetryEvidence()) {
|
||||
overflowFailure = event
|
||||
return
|
||||
}
|
||||
}
|
||||
yield* publish(event)
|
||||
if (event.type !== "tool-call" || event.providerExecuted) return
|
||||
if (!toolMaterialization) {
|
||||
@@ -301,21 +317,22 @@ const layer = Layer.effect(
|
||||
// away non-interrupt failures, so both interrupt checks stay Cause-based.
|
||||
const streamInterrupted = stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)
|
||||
|
||||
const llmFailure = streamFailure !== undefined && isLLMError(streamFailure) ? streamFailure : undefined
|
||||
|
||||
// A context overflow before any assistant output is recoverable: compact and
|
||||
// restart the step instead of surfacing the provider error.
|
||||
if (
|
||||
recoverOverflow &&
|
||||
!publisher.hasRetryEvidence() &&
|
||||
llmFailure?._tag === "LLM.ContextOverflow" &&
|
||||
isContextOverflowFailure(overflowFailure ?? streamFailure) &&
|
||||
(yield* restore(recoverOverflow({ sessionID: session.id, messages: context, model }))).status ===
|
||||
"completed"
|
||||
)
|
||||
return { _tag: "RestartAfterOverflowCompaction", step: currentStep } as const
|
||||
|
||||
// A thrown LLM failure records the assistant failure unless a provider failure
|
||||
// was already recorded from the stream. Terminal publication waits for owned tools.
|
||||
// An unrecovered held-back overflow becomes the step's durable provider error. A
|
||||
// thrown LLM failure records the assistant failure unless a provider error was
|
||||
// already recorded from the stream. Terminal publication waits for owned tools.
|
||||
if (overflowFailure) yield* publish(overflowFailure)
|
||||
const llmFailure = streamFailure instanceof LLMError ? streamFailure : undefined
|
||||
if (llmFailure && !publisher.hasProviderError()) {
|
||||
const error = toSessionError(llmFailure)
|
||||
if (
|
||||
@@ -332,8 +349,7 @@ const layer = Layer.effect(
|
||||
}
|
||||
yield* serialized(publisher.failAssistant(error))
|
||||
}
|
||||
// The provider-failed flag is only set while consuming the stream (content-filter
|
||||
// step finish), so it is final here.
|
||||
// Provider error events only arrive from the stream, so the flag is final here.
|
||||
const providerFailed = publisher.hasProviderError()
|
||||
|
||||
// Settle every owned tool fiber. FiberSet.join returns on the first failure, so retain
|
||||
|
||||
@@ -438,6 +438,10 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
||||
return
|
||||
case "finish":
|
||||
return
|
||||
case "provider-error":
|
||||
providerFailed = true
|
||||
yield* failAssistant({ type: "provider.unknown", message: event.message }, true)
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as SessionRunnerRetry from "./retry"
|
||||
|
||||
import type { LLMError } from "@opencode-ai/llm"
|
||||
import { LLMError } from "@opencode-ai/llm"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { Data, Duration, Effect, Schedule } from "effect"
|
||||
import { EventV2 } from "../../event"
|
||||
@@ -17,33 +17,29 @@ export class RetryableFailure extends Data.TaggedError("SessionRunner.RetryableF
|
||||
}> {}
|
||||
|
||||
export function isRetryable(error: LLMError) {
|
||||
switch (error._tag) {
|
||||
case "LLM.RateLimit":
|
||||
case "LLM.ServerError":
|
||||
case "LLM.ConnectionError":
|
||||
case "LLM.TimeoutError":
|
||||
switch (error.reason._tag) {
|
||||
case "RateLimit":
|
||||
case "ProviderInternal":
|
||||
case "Transport":
|
||||
return true
|
||||
case "LLM.Authentication":
|
||||
case "LLM.PermissionDenied":
|
||||
case "LLM.NotFound":
|
||||
case "LLM.QuotaExceeded":
|
||||
case "LLM.ContentPolicy":
|
||||
case "LLM.ContextOverflow":
|
||||
case "LLM.MalformedResponse":
|
||||
case "LLM.BadRequest":
|
||||
case "LLM.NoRoute":
|
||||
case "LLM.APIError":
|
||||
case "Authentication":
|
||||
case "QuotaExceeded":
|
||||
case "ContentPolicy":
|
||||
case "InvalidProviderOutput":
|
||||
case "InvalidRequest":
|
||||
case "NoRoute":
|
||||
case "UnknownProvider":
|
||||
return false
|
||||
default: {
|
||||
const exhaustive: never = error
|
||||
const exhaustive: never = error.reason
|
||||
return exhaustive
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const retryAfter = (failure: RetryableFailure) => {
|
||||
if (failure.cause._tag === "LLM.RateLimit" || failure.cause._tag === "LLM.ServerError")
|
||||
return failure.cause.retryAfterMs
|
||||
if (failure.cause.reason._tag === "RateLimit" || failure.cause.reason._tag === "ProviderInternal")
|
||||
return failure.cause.reason.retryAfterMs
|
||||
return undefined
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as SessionTitle from "./title"
|
||||
|
||||
import { LLM, LLMClient, LLMEvent, Message, isLLMError, type LLMError, type LLMRequest } from "@opencode-ai/llm"
|
||||
import { LLM, LLMClient, LLMError, LLMEvent, Message, type LLMRequest } from "@opencode-ai/llm"
|
||||
import { Context, DateTime, Effect, Layer, Stream } from "effect"
|
||||
import { AgentV2 } from "../agent"
|
||||
import { Database } from "../database/database"
|
||||
@@ -49,6 +49,7 @@ const make = (dependencies: Dependencies) => {
|
||||
).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (!resolved) return
|
||||
const chunks: string[] = []
|
||||
let failed = false
|
||||
const streamed = yield* dependencies.llm
|
||||
.stream(
|
||||
LLM.request({
|
||||
@@ -60,13 +61,14 @@ const make = (dependencies: Dependencies) => {
|
||||
)
|
||||
.pipe(
|
||||
Stream.runForEach((event) => {
|
||||
if (LLMEvent.is.providerError(event)) failed = true
|
||||
if (LLMEvent.is.textDelta(event)) chunks.push(event.text)
|
||||
return Effect.void
|
||||
}),
|
||||
Effect.as(true),
|
||||
Effect.catchIf(isLLMError, () => Effect.succeed(false)),
|
||||
Effect.catchTag("LLM.Error", () => Effect.succeed(false)),
|
||||
)
|
||||
if (!streamed) return
|
||||
if (!streamed || failed) return
|
||||
const title = chunks
|
||||
.join("")
|
||||
.split("\n")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isLLMError, ToolFailure } from "@opencode-ai/llm"
|
||||
import { LLMError, ToolFailure } from "@opencode-ai/llm"
|
||||
import { Tool } from "@opencode-ai/plugin/v2/effect/tool"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { PermissionV2 } from "../permission"
|
||||
@@ -9,38 +9,30 @@ import { AgentNotFoundError, StepFailedError, UserInterruptedError } from "./err
|
||||
import { SessionRunnerModel } from "./runner/model"
|
||||
|
||||
export function toSessionError(cause: unknown): SessionError.Error {
|
||||
if (isLLMError(cause)) {
|
||||
switch (cause._tag) {
|
||||
case "LLM.RateLimit":
|
||||
return { type: "provider.rate-limit", message: cause.message }
|
||||
case "LLM.Authentication":
|
||||
return { type: "provider.auth", message: cause.message }
|
||||
case "LLM.PermissionDenied":
|
||||
return { type: "provider.auth", message: cause.message }
|
||||
case "LLM.NotFound":
|
||||
return { type: "provider.not-found", message: cause.message }
|
||||
case "LLM.QuotaExceeded":
|
||||
return { type: "provider.quota", message: cause.message }
|
||||
case "LLM.ContentPolicy":
|
||||
return { type: "provider.content-filter", message: cause.message }
|
||||
case "LLM.ContextOverflow":
|
||||
return { type: "provider.context-overflow", message: cause.message }
|
||||
case "LLM.ConnectionError":
|
||||
return { type: "provider.transport", message: cause.message }
|
||||
case "LLM.TimeoutError":
|
||||
return { type: "provider.timeout", message: cause.message }
|
||||
case "LLM.ServerError":
|
||||
return { type: "provider.internal", message: cause.message }
|
||||
case "LLM.MalformedResponse":
|
||||
return { type: "provider.invalid-output", message: cause.message }
|
||||
case "LLM.BadRequest":
|
||||
return { type: "provider.invalid-request", message: cause.message }
|
||||
case "LLM.NoRoute":
|
||||
return { type: "provider.no-route", message: cause.message }
|
||||
case "LLM.APIError":
|
||||
return { type: "provider.unknown", message: cause.message }
|
||||
if (cause instanceof LLMError) {
|
||||
switch (cause.reason._tag) {
|
||||
case "RateLimit":
|
||||
return { type: "provider.rate-limit", message: cause.reason.message }
|
||||
case "Authentication":
|
||||
return { type: "provider.auth", message: cause.reason.message }
|
||||
case "QuotaExceeded":
|
||||
return { type: "provider.quota", message: cause.reason.message }
|
||||
case "ContentPolicy":
|
||||
return { type: "provider.content-filter", message: cause.reason.message }
|
||||
case "Transport":
|
||||
return { type: "provider.transport", message: cause.reason.message }
|
||||
case "ProviderInternal":
|
||||
return { type: "provider.internal", message: cause.reason.message }
|
||||
case "InvalidProviderOutput":
|
||||
return { type: "provider.invalid-output", message: cause.reason.message }
|
||||
case "InvalidRequest":
|
||||
return { type: "provider.invalid-request", message: cause.reason.message }
|
||||
case "NoRoute":
|
||||
return { type: "provider.no-route", message: cause.reason.message }
|
||||
case "UnknownProvider":
|
||||
return { type: "provider.unknown", message: cause.reason.message }
|
||||
default: {
|
||||
const exhaustive: never = cause
|
||||
const exhaustive: never = cause.reason
|
||||
return exhaustive
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,18 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
APIError,
|
||||
Authentication,
|
||||
BadRequest,
|
||||
ConnectionError,
|
||||
ContentPolicy,
|
||||
ContextOverflow,
|
||||
MalformedResponse,
|
||||
AuthenticationReason,
|
||||
ContentPolicyReason,
|
||||
InvalidProviderOutputReason,
|
||||
InvalidRequestReason,
|
||||
LLMError,
|
||||
NoRouteReason,
|
||||
ModelID,
|
||||
NoRoute,
|
||||
NotFound,
|
||||
PermissionDenied,
|
||||
ProviderID,
|
||||
QuotaExceeded,
|
||||
RateLimit,
|
||||
RouteID,
|
||||
ServerError,
|
||||
TimeoutError,
|
||||
ProviderInternalReason,
|
||||
QuotaExceededReason,
|
||||
RateLimitReason,
|
||||
TransportReason,
|
||||
UnknownProviderReason,
|
||||
ToolFailure,
|
||||
} from "@opencode-ai/llm"
|
||||
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
@@ -24,33 +20,39 @@ import { Tool } from "@opencode-ai/plugin/v2/effect/tool"
|
||||
import { toSessionError } from "@opencode-ai/core/session/to-session-error"
|
||||
import { SessionRunnerRetry } from "@opencode-ai/core/session/runner/retry"
|
||||
|
||||
const llm = (reason: LLMError["reason"]) => new LLMError({ module: "test", method: "stream", reason })
|
||||
|
||||
describe("toSessionError", () => {
|
||||
test("maps every LLM error tag to the open wire type", () => {
|
||||
expect(toSessionError(new RateLimit({ message: "rate", retryAfterMs: 123 }))).toEqual({
|
||||
test("maps every LLM reason to the open wire type", () => {
|
||||
expect(toSessionError(llm(new RateLimitReason({ message: "rate", retryAfterMs: 123 })))).toEqual({
|
||||
type: "provider.rate-limit",
|
||||
message: "rate",
|
||||
})
|
||||
expect(toSessionError(new Authentication({ message: "auth" })).type).toBe("provider.auth")
|
||||
expect(toSessionError(new PermissionDenied({ message: "forbidden" })).type).toBe("provider.auth")
|
||||
expect(toSessionError(new NotFound({ message: "missing" })).type).toBe("provider.not-found")
|
||||
expect(toSessionError(new QuotaExceeded({ message: "quota" })).type).toBe("provider.quota")
|
||||
expect(toSessionError(new ContentPolicy({ message: "blocked" })).type).toBe("provider.content-filter")
|
||||
expect(toSessionError(new ContextOverflow({ message: "too long" })).type).toBe("provider.context-overflow")
|
||||
expect(toSessionError(new ConnectionError({ message: "reset" })).type).toBe("provider.transport")
|
||||
expect(toSessionError(new TimeoutError({ message: "timed out" })).type).toBe("provider.timeout")
|
||||
expect(toSessionError(new ServerError({ message: "internal", status: 500 })).type).toBe("provider.internal")
|
||||
expect(toSessionError(new MalformedResponse({ message: "output" })).type).toBe("provider.invalid-output")
|
||||
expect(toSessionError(new BadRequest({ message: "request" })).type).toBe("provider.invalid-request")
|
||||
expect(toSessionError(llm(new AuthenticationReason({ message: "auth", kind: "invalid" }))).type).toBe(
|
||||
"provider.auth",
|
||||
)
|
||||
expect(toSessionError(llm(new QuotaExceededReason({ message: "quota" }))).type).toBe("provider.quota")
|
||||
expect(toSessionError(llm(new ContentPolicyReason({ message: "blocked" }))).type).toBe("provider.content-filter")
|
||||
expect(toSessionError(llm(new TransportReason({ message: "transport" }))).type).toBe("provider.transport")
|
||||
expect(toSessionError(llm(new ProviderInternalReason({ message: "internal", status: 500 }))).type).toBe(
|
||||
"provider.internal",
|
||||
)
|
||||
expect(toSessionError(llm(new InvalidProviderOutputReason({ message: "output" }))).type).toBe(
|
||||
"provider.invalid-output",
|
||||
)
|
||||
expect(toSessionError(llm(new InvalidRequestReason({ message: "request" }))).type).toBe("provider.invalid-request")
|
||||
expect(
|
||||
toSessionError(
|
||||
new NoRoute({
|
||||
route: RouteID.make("route"),
|
||||
provider: ProviderID.make("provider"),
|
||||
model: ModelID.make("model"),
|
||||
}),
|
||||
llm(
|
||||
new NoRouteReason({
|
||||
route: "route",
|
||||
provider: ProviderID.make("provider"),
|
||||
model: ModelID.make("model"),
|
||||
}),
|
||||
),
|
||||
).type,
|
||||
).toBe("provider.no-route")
|
||||
expect(toSessionError(new APIError({ message: "unknown", status: 418 })).type).toBe("provider.unknown")
|
||||
expect(toSessionError(llm(new UnknownProviderReason({ message: "unknown" }))).type).toBe("provider.unknown")
|
||||
})
|
||||
|
||||
test("preserves the permission rejection type without exposing internal fields", () => {
|
||||
@@ -69,31 +71,23 @@ describe("toSessionError", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("retries only rate limits, server errors, connection failures, and timeouts", () => {
|
||||
test("retries only rate limits, provider-internal failures, and transport failures", () => {
|
||||
const eligible = [
|
||||
new RateLimit({ message: "rate" }),
|
||||
new ServerError({ message: "internal", status: 500 }),
|
||||
new ConnectionError({ message: "reset" }),
|
||||
new TimeoutError({ message: "timed out" }),
|
||||
llm(new RateLimitReason({ message: "rate" })),
|
||||
llm(new ProviderInternalReason({ message: "internal", status: 500 })),
|
||||
llm(new TransportReason({ message: "transport" })),
|
||||
]
|
||||
const ineligible = [
|
||||
new Authentication({ message: "auth" }),
|
||||
new PermissionDenied({ message: "forbidden" }),
|
||||
new NotFound({ message: "missing" }),
|
||||
new QuotaExceeded({ message: "quota" }),
|
||||
new ContentPolicy({ message: "blocked" }),
|
||||
new ContextOverflow({ message: "too long" }),
|
||||
new MalformedResponse({ message: "output" }),
|
||||
new BadRequest({ message: "request" }),
|
||||
new NoRoute({
|
||||
route: RouteID.make("route"),
|
||||
provider: ProviderID.make("provider"),
|
||||
model: ModelID.make("model"),
|
||||
}),
|
||||
new APIError({ message: "unknown" }),
|
||||
llm(new AuthenticationReason({ message: "auth", kind: "invalid" })),
|
||||
llm(new QuotaExceededReason({ message: "quota" })),
|
||||
llm(new ContentPolicyReason({ message: "blocked" })),
|
||||
llm(new InvalidProviderOutputReason({ message: "output" })),
|
||||
llm(new InvalidRequestReason({ message: "request" })),
|
||||
llm(new NoRouteReason({ route: "route", provider: ProviderID.make("provider"), model: ModelID.make("model") })),
|
||||
llm(new UnknownProviderReason({ message: "unknown" })),
|
||||
]
|
||||
|
||||
expect(eligible.map(SessionRunnerRetry.isRetryable)).toEqual([true, true, true, true])
|
||||
expect(ineligible.map(SessionRunnerRetry.isRetryable)).toEqual(ineligible.map(() => false))
|
||||
expect(eligible.map(SessionRunnerRetry.isRetryable)).toEqual([true, true, true])
|
||||
expect(ineligible.map(SessionRunnerRetry.isRetryable)).toEqual([false, false, false, false, false, false, false])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { ConnectionError } from "@opencode-ai/llm"
|
||||
import { LLMError, TransportReason } from "@opencode-ai/llm"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
@@ -25,10 +25,17 @@ const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, Event
|
||||
describe("SessionExecution lifecycle", () => {
|
||||
test("classifies success and typed failure terminals", () => {
|
||||
expect(SessionExecution.terminal(Exit.succeed(undefined))).toEqual({ type: "succeeded" })
|
||||
expect(SessionExecution.terminal(Exit.fail(new ConnectionError({ message: "Disconnected" })))).toEqual({
|
||||
type: "failed",
|
||||
error: { type: "provider.transport", message: "Disconnected" },
|
||||
})
|
||||
expect(
|
||||
SessionExecution.terminal(
|
||||
Exit.fail(
|
||||
new LLMError({
|
||||
module: "test",
|
||||
method: "stream",
|
||||
reason: new TransportReason({ message: "Disconnected" }),
|
||||
}),
|
||||
),
|
||||
),
|
||||
).toEqual({ type: "failed", error: { type: "provider.transport", message: "Disconnected" } })
|
||||
const storage = new ToolOutputStore.StorageError({ operation: "encode", cause: new Error("invalid output") })
|
||||
expect(SessionExecution.terminal(Exit.fail(storage))).toEqual({
|
||||
type: "failed",
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
APIError,
|
||||
BadRequest,
|
||||
ConnectionError,
|
||||
ContextOverflow,
|
||||
LLMClient,
|
||||
LLMError,
|
||||
LLMEvent,
|
||||
MalformedResponse,
|
||||
Model,
|
||||
RateLimit,
|
||||
ToolFailure,
|
||||
TransportReason,
|
||||
InvalidProviderOutputReason,
|
||||
InvalidRequestReason,
|
||||
RateLimitReason,
|
||||
type LLMClientShape,
|
||||
type LLMError,
|
||||
type LLMRequest,
|
||||
} from "@opencode-ai/llm"
|
||||
import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat"
|
||||
@@ -71,9 +69,8 @@ import { asc, eq } from "drizzle-orm"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const requests: LLMRequest[] = []
|
||||
type ScriptedResponse = LLMEvent[] | Stream.Stream<LLMEvent, LLMError>
|
||||
let response: LLMEvent[] = []
|
||||
let responses: ScriptedResponse[] | undefined
|
||||
let responses: LLMEvent[][] | undefined
|
||||
let responseStream: Stream.Stream<LLMEvent, LLMError> | undefined
|
||||
let responseStreams: Stream.Stream<LLMEvent, LLMError>[] | undefined
|
||||
let streamGate: Deferred.Deferred<void> | undefined
|
||||
@@ -96,12 +93,9 @@ const client = Layer.succeed(
|
||||
responseStream = undefined
|
||||
return stream
|
||||
}
|
||||
const scripted = responses === undefined ? response : (responses.shift() ?? [])
|
||||
const events = streamFailure
|
||||
? Stream.fail(streamFailure)
|
||||
: Array.isArray(scripted)
|
||||
? Stream.fromIterable(scripted)
|
||||
: scripted
|
||||
: Stream.fromIterable(responses === undefined ? response : (responses.shift() ?? []))
|
||||
if (!streamGate) return events
|
||||
return Stream.unwrap(
|
||||
(streamStarted ? Deferred.succeed(streamStarted, undefined) : Effect.void).pipe(
|
||||
@@ -145,6 +139,11 @@ const compactModel = Model.make({
|
||||
provider: "fake",
|
||||
route: OpenAIChat.route.with({ limits: { context: 4_000, output: 50 } }),
|
||||
})
|
||||
const fullOutputModel = Model.make({
|
||||
id: "full-output",
|
||||
provider: "fake",
|
||||
route: OpenAIChat.route.with({ limits: { context: 262_144, output: 262_144 } }),
|
||||
})
|
||||
const undersizedContextModel = Model.make({
|
||||
id: "undersized-context",
|
||||
provider: "fake",
|
||||
@@ -489,16 +488,26 @@ const setup = Effect.gen(function* () {
|
||||
return yield* SessionV2.Service
|
||||
})
|
||||
|
||||
const providerUnavailable = () => new ConnectionError({ message: "Provider unavailable" })
|
||||
const providerUnavailable = () =>
|
||||
new LLMError({
|
||||
module: "test",
|
||||
method: "stream",
|
||||
reason: new TransportReason({ message: "Provider unavailable" }),
|
||||
})
|
||||
|
||||
const contextOverflow = () => new ContextOverflow({ message: "prompt too long" })
|
||||
const invalidRequest = () =>
|
||||
new LLMError({
|
||||
module: "test",
|
||||
method: "stream",
|
||||
reason: new InvalidRequestReason({ message: "Invalid request" }),
|
||||
})
|
||||
|
||||
const failingResponse = (events: LLMEvent[], failure: LLMError): Stream.Stream<LLMEvent, LLMError> =>
|
||||
Stream.fromIterable(events).pipe(Stream.concat(Stream.fail(failure)))
|
||||
|
||||
const invalidRequest = () => new BadRequest({ message: "Invalid request" })
|
||||
|
||||
const rateLimited = (retryAfterMs?: number) => new RateLimit({ message: "Rate limited", retryAfterMs })
|
||||
const rateLimited = (retryAfterMs?: number) =>
|
||||
new LLMError({
|
||||
module: "test",
|
||||
method: "stream",
|
||||
reason: new RateLimitReason({ message: "Rate limited", retryAfterMs }),
|
||||
})
|
||||
|
||||
const setupOverflowRecovery = Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
@@ -1754,14 +1763,14 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* admit(session, "Earlier question")
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
responseStream = Stream.fail(new APIError({ message: "summary unavailable" }))
|
||||
response = [LLMEvent.providerError({ message: "summary unavailable" })]
|
||||
const compaction = yield* session.compact({ sessionID })
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect((yield* session.messages({ sessionID })).find((message) => message.id === compaction.id)).toMatchObject({
|
||||
type: "compaction",
|
||||
status: "failed",
|
||||
error: { type: "provider.unknown", message: "summary unavailable" },
|
||||
error: { type: "provider.error", message: "summary unavailable" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -1861,6 +1870,25 @@ describe("SessionRunnerLLM", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not compact immediately when the advertised output limit fills the context", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
currentModel = fullOutputModel
|
||||
response = reply.textWithUsage("Earlier answer", "text-full-output-first", 9_500)
|
||||
yield* admit(session, "Earlier question")
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
requests.length = 0
|
||||
response = reply.text("Continued", "text-full-output-final")
|
||||
yield* admit(session, "Continue")
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(userTexts(requests[0])).toContain("Continue")
|
||||
expect(yield* session.context(sessionID)).not.toContainEqual(expect.objectContaining({ type: "compaction" }))
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("stops after required automatic compaction fails", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
@@ -1871,7 +1899,7 @@ describe("SessionRunnerLLM", () => {
|
||||
currentModel = compactModel
|
||||
requests.length = 0
|
||||
responses = [
|
||||
Stream.fail(new BadRequest({ message: "Unsupported parameter: max_output_tokens" })),
|
||||
[LLMEvent.providerError({ message: "Unsupported parameter: max_output_tokens" })],
|
||||
reply.text("Must not run", "text-after-failed-compaction"),
|
||||
]
|
||||
yield* admit(session, "Recent exact request ".repeat(180))
|
||||
@@ -1894,7 +1922,10 @@ describe("SessionRunnerLLM", () => {
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setupOverflowRecovery
|
||||
responses = [
|
||||
failingResponse([LLMEvent.stepStart({ index: 0 })], contextOverflow()),
|
||||
[
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" }),
|
||||
],
|
||||
reply.text("## Objective\n- Recover overflow", "text-summary"),
|
||||
reply.text("Recovered", "text-final"),
|
||||
]
|
||||
@@ -1921,7 +1952,7 @@ describe("SessionRunnerLLM", () => {
|
||||
const session = yield* setupOverflowRecovery
|
||||
currentModel = model
|
||||
responses = [
|
||||
Stream.fail(contextOverflow()),
|
||||
[LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
|
||||
reply.text("## Objective\n- Recover unknown limit", "text-summary-unknown-limit"),
|
||||
reply.text("Recovered", "text-final-unknown-limit"),
|
||||
]
|
||||
@@ -1941,7 +1972,7 @@ describe("SessionRunnerLLM", () => {
|
||||
const session = yield* setupOverflowRecovery
|
||||
currentModel = undersizedContextModel
|
||||
responses = [
|
||||
Stream.fail(contextOverflow()),
|
||||
[LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
|
||||
reply.text("## Objective\n- Recover undersized limit", "text-summary-undersized-limit"),
|
||||
reply.text("Recovered", "text-final-undersized-limit"),
|
||||
]
|
||||
@@ -1959,7 +1990,10 @@ describe("SessionRunnerLLM", () => {
|
||||
it.effect("persists a second context overflow after one recovery", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setupOverflowRecovery
|
||||
const overflow = () => failingResponse([LLMEvent.stepStart({ index: 0 })], contextOverflow())
|
||||
const overflow = () => [
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" }),
|
||||
]
|
||||
responses = [overflow(), reply.text("## Objective\n- Recover once", "text-summary"), overflow()]
|
||||
yield* admit(session, "Continue")
|
||||
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("prompt too long")
|
||||
@@ -1975,7 +2009,16 @@ describe("SessionRunnerLLM", () => {
|
||||
it.effect("recovers once from a raw context overflow failure", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setupOverflowRecovery
|
||||
responseStream = Stream.fail(contextOverflow())
|
||||
responseStream = Stream.fail(
|
||||
new LLMError({
|
||||
module: "test",
|
||||
method: "stream",
|
||||
reason: new InvalidRequestReason({
|
||||
message: "prompt too long",
|
||||
classification: "context-overflow",
|
||||
}),
|
||||
}),
|
||||
)
|
||||
responses = [
|
||||
reply.text("## Objective\n- Recover raw overflow", "text-summary"),
|
||||
reply.text("Recovered", "text-final"),
|
||||
@@ -1994,7 +2037,10 @@ describe("SessionRunnerLLM", () => {
|
||||
it.effect("publishes the original overflow when recovery summarization fails", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setupOverflowRecovery
|
||||
responses = [Stream.fail(contextOverflow()), Stream.fail(new APIError({ message: "summary unavailable" }))]
|
||||
responses = [
|
||||
[LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
|
||||
[LLMEvent.providerError({ message: "summary unavailable" })],
|
||||
]
|
||||
yield* admit(session, "Continue")
|
||||
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("prompt too long")
|
||||
|
||||
@@ -2005,7 +2051,7 @@ describe("SessionRunnerLLM", () => {
|
||||
type: "compaction",
|
||||
status: "failed",
|
||||
reason: "auto",
|
||||
error: { type: "provider.unknown", message: "summary unavailable" },
|
||||
error: { type: "provider.error", message: "summary unavailable" },
|
||||
}),
|
||||
)
|
||||
expect(context.slice(-3)).toMatchObject([
|
||||
@@ -2019,7 +2065,10 @@ describe("SessionRunnerLLM", () => {
|
||||
it.effect("interrupts overflow recovery while the summary provider is running", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setupOverflowRecovery
|
||||
responses = [Stream.fail(contextOverflow()), reply.text("## Objective\n- Interrupted", "text-summary")]
|
||||
responses = [
|
||||
[LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
|
||||
reply.text("## Objective\n- Interrupted", "text-summary"),
|
||||
]
|
||||
const firstGate = yield* Deferred.make<void>()
|
||||
const summaryGate = yield* Deferred.make<void>()
|
||||
streamGate = firstGate
|
||||
@@ -3602,10 +3651,7 @@ describe("SessionRunnerLLM", () => {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Fail durably")
|
||||
|
||||
responseStream = failingResponse(
|
||||
[LLMEvent.stepStart({ index: 0 })],
|
||||
new APIError({ message: "Provider unavailable" }),
|
||||
)
|
||||
response = [LLMEvent.stepStart({ index: 0 }), LLMEvent.providerError({ message: "Provider unavailable" })]
|
||||
|
||||
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider unavailable")
|
||||
|
||||
@@ -3622,7 +3668,7 @@ describe("SessionRunnerLLM", () => {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Fail before step")
|
||||
|
||||
responseStream = Stream.fail(new APIError({ message: "Provider unavailable" }))
|
||||
response = [LLMEvent.providerError({ message: "Provider unavailable" })]
|
||||
|
||||
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider unavailable")
|
||||
|
||||
@@ -3710,15 +3756,13 @@ describe("SessionRunnerLLM", () => {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Fail after output")
|
||||
|
||||
responseStream = failingResponse(
|
||||
[
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.textStart({ id: "text-partial" }),
|
||||
LLMEvent.textDelta({ id: "text-partial", text: "Partial" }),
|
||||
LLMEvent.textEnd({ id: "text-partial" }),
|
||||
],
|
||||
contextOverflow(),
|
||||
)
|
||||
response = [
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.textStart({ id: "text-partial" }),
|
||||
LLMEvent.textDelta({ id: "text-partial", text: "Partial" }),
|
||||
LLMEvent.textEnd({ id: "text-partial" }),
|
||||
LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" }),
|
||||
]
|
||||
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("prompt too long")
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
@@ -3872,7 +3916,11 @@ describe("SessionRunnerLLM", () => {
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Call a malformed tool")
|
||||
const failure = new MalformedResponse({ message: "Invalid JSON input for tool call echo" })
|
||||
const failure = new LLMError({
|
||||
module: "test",
|
||||
method: "stream",
|
||||
reason: new InvalidProviderOutputReason({ message: "Invalid JSON input for tool call echo" }),
|
||||
})
|
||||
responseStream = Stream.fromIterable([
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolInputStart({ id: "call-malformed", name: "echo" }),
|
||||
@@ -3911,13 +3959,11 @@ describe("SessionRunnerLLM", () => {
|
||||
toolExecutionGate = yield* Deferred.make<void>()
|
||||
toolExecutionsStarted = yield* Deferred.make<void>()
|
||||
toolExecutionsReady = 1
|
||||
responseStream = failingResponse(
|
||||
[
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolCall({ id: "call-before-provider-error", name: "echo", input: { text: "settled" } }),
|
||||
],
|
||||
new APIError({ message: "Provider unavailable" }),
|
||||
)
|
||||
response = [
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolCall({ id: "call-before-provider-error", name: "echo", input: { text: "settled" } }),
|
||||
LLMEvent.providerError({ message: "Provider unavailable" }),
|
||||
]
|
||||
|
||||
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(toolExecutionsStarted)
|
||||
@@ -3944,10 +3990,11 @@ describe("SessionRunnerLLM", () => {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Fail hosted tool durably")
|
||||
|
||||
responseStream = failingResponse(
|
||||
[LLMEvent.stepStart({ index: 0 }), hostedCall("call-hosted-provider-error", "effect")],
|
||||
new APIError({ message: "Provider unavailable" }),
|
||||
)
|
||||
response = [
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
hostedCall("call-hosted-provider-error", "effect"),
|
||||
LLMEvent.providerError({ message: "Provider unavailable" }),
|
||||
]
|
||||
|
||||
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider unavailable")
|
||||
|
||||
@@ -3974,13 +4021,11 @@ describe("SessionRunnerLLM", () => {
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Defect while provider fails")
|
||||
responseStream = failingResponse(
|
||||
[
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolCall({ id: "call-defect-provider-error", name: "defect", input: {} }),
|
||||
],
|
||||
new APIError({ message: "Provider unavailable" }),
|
||||
)
|
||||
response = [
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolCall({ id: "call-defect-provider-error", name: "defect", input: {} }),
|
||||
LLMEvent.providerError({ message: "Provider unavailable" }),
|
||||
]
|
||||
|
||||
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider unavailable")
|
||||
|
||||
|
||||
@@ -182,8 +182,8 @@ The dependency arrow points down: `providers/*.ts` files import protocol routes
|
||||
- `joinText(parts)` — joins an array of `TextPart` (or anything with a `.text`) with newlines. Use this anywhere a protocol flattens text content into a single string for a provider field.
|
||||
- `parseToolInput(route, name, raw)` — Schema-decodes a tool-call argument string with the canonical "Invalid JSON input for `<route>` tool call `<name>`" error message. Treats empty input as `{}`.
|
||||
- `parseJson(route, raw, message)` — generic JSON-via-Schema decode for non-tool bodies.
|
||||
- `eventError(route, message, ...)` — typed `MalformedResponse` constructor for stream-time decode failures.
|
||||
- `validateWith(decoder)` — maps Schema decode errors to `BadRequest`. `Route.make(...)` uses this for body validation; lower-level routes can reuse it.
|
||||
- `eventError(route, message, ...)` — typed `InvalidProviderOutput` constructor for stream-time decode failures.
|
||||
- `validateWith(decoder)` — maps Schema decode errors to `InvalidRequest`. `Route.make(...)` uses this for body validation; lower-level routes can reuse it.
|
||||
- `matchToolChoice(provider, choice, branches)` — branches over `LLMRequest["toolChoice"]` for provider-specific lowering.
|
||||
|
||||
If you find yourself copying a 3-to-5-line snippet between two protocols, lift it into `ProviderShared` next to these helpers rather than duplicating.
|
||||
@@ -291,7 +291,7 @@ Use this order for every protocol module:
|
||||
|
||||
- Keep protocol files focused on the protocol. Move provider-specific projection, signing, media normalization, or other bulky transformations into `src/protocols/utils/*`.
|
||||
- Use `Effect.fn("Provider.fromRequest")` for request body construction entrypoints. Use `Effect.fn(...)` for event handlers that yield effects; keep purely synchronous handlers as plain functions returning a `StepResult` that the dispatcher lifts via `Effect.succeed(...)`.
|
||||
- Parser state owns terminal information. The state machine records finish reason, usage, and pending tool calls; emit one terminal `finish` event for each completed response. Provider-reported failures (SSE error events, exception frames) fail the stream with a typed `LLMError` via `classifyApiFailure` — never an ordinary event. If a provider splits reason and usage across events, merge them in parser state before flushing.
|
||||
- Parser state owns terminal information. The state machine records finish reason, usage, and pending tool calls; emit one terminal `finish` event (or `provider-error`) for each completed response. If a provider splits reason and usage across events, merge them in parser state before flushing.
|
||||
- Emit exactly one terminal `finish` event for a completed response, normally after a matching `step-finish`. Use `stream.terminal` to stop reading when the provider has a completion sentinel; use `stream.onHalt` when the final event must be flushed after the framed stream ends.
|
||||
- Use shared helpers for repeated protocol policy such as text joining, usage totals, JSON parsing, and tool-call accumulation. `ToolStream` (`protocols/utils/tool-stream.ts`) accumulates streamed tool-call arguments uniformly.
|
||||
- Make intentional provider differences explicit in helper names or comments. If two protocol files differ visually, the reason should be obvious from the names.
|
||||
|
||||
@@ -2,7 +2,7 @@ export { LLMClient } from "./route/client"
|
||||
export { Auth } from "./route/auth"
|
||||
export { Provider } from "./provider"
|
||||
export { ProviderPackage } from "./provider-package"
|
||||
export { classifyApiFailure, isContextOverflow, type ApiFailure } from "./provider-error"
|
||||
export { isContextOverflow, isContextOverflowFailure } from "./provider-error"
|
||||
export type {
|
||||
RouteModelInput,
|
||||
RouteRoutedModelInput,
|
||||
|
||||
+14
-6
@@ -3,8 +3,8 @@ import { LLMClient } from "./route/client"
|
||||
import {
|
||||
GenerationOptions,
|
||||
HttpOptions,
|
||||
MalformedResponse,
|
||||
type LLMError,
|
||||
InvalidProviderOutputReason,
|
||||
LLMError,
|
||||
LLMEvent,
|
||||
LLMRequest,
|
||||
LLMResponse,
|
||||
@@ -121,14 +121,22 @@ const runGenerateObject = Effect.fn("LLM.generateObject")(function* (
|
||||
(event) => LLMEvent.is.toolCall(event) && event.name === GENERATE_OBJECT_TOOL_NAME,
|
||||
)
|
||||
if (!call || !LLMEvent.is.toolCall(call))
|
||||
return yield* new MalformedResponse({
|
||||
message: `generateObject: model did not call the forced \`${GENERATE_OBJECT_TOOL_NAME}\` tool`,
|
||||
return yield* new LLMError({
|
||||
module: "LLM",
|
||||
method: "generateObject",
|
||||
reason: new InvalidProviderOutputReason({
|
||||
message: `generateObject: model did not call the forced \`${GENERATE_OBJECT_TOOL_NAME}\` tool`,
|
||||
}),
|
||||
})
|
||||
const object = yield* tool._decode(call.input).pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new MalformedResponse({
|
||||
message: `generateObject: tool input failed schema decode: ${error.message}`,
|
||||
new LLMError({
|
||||
module: "LLM",
|
||||
method: "generateObject",
|
||||
reason: new InvalidProviderOutputReason({
|
||||
message: `generateObject: tool input failed schema decode: ${error.message}`,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
type ToolResultPart,
|
||||
} from "../schema"
|
||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
|
||||
import { classifyApiFailure } from "../provider-error"
|
||||
import { isContextOverflow } from "../provider-error"
|
||||
import * as Cache from "./utils/cache"
|
||||
import { Lifecycle } from "./utils/lifecycle"
|
||||
import { ToolSchemaProjection } from "./utils/tool-schema"
|
||||
@@ -832,11 +832,15 @@ const providerErrorMessage = (event: AnthropicEvent): string => {
|
||||
return message || type || "Anthropic Messages stream error"
|
||||
}
|
||||
|
||||
const onError = (event: AnthropicEvent) =>
|
||||
classifyApiFailure({
|
||||
message: providerErrorMessage(event),
|
||||
code: event.error?.type,
|
||||
})
|
||||
const onError = (state: ParserState, event: AnthropicEvent): StepResult => [
|
||||
state,
|
||||
[
|
||||
LLMEvent.providerError({
|
||||
message: providerErrorMessage(event),
|
||||
classification: isContextOverflow(event.error?.message ?? "") ? "context-overflow" : undefined,
|
||||
}),
|
||||
],
|
||||
]
|
||||
|
||||
const step = (state: ParserState, event: AnthropicEvent) => {
|
||||
if (event.type === "message_start") return Effect.succeed(onMessageStart(state, event))
|
||||
@@ -844,7 +848,7 @@ const step = (state: ParserState, event: AnthropicEvent) => {
|
||||
if (event.type === "content_block_delta") return onContentBlockDelta(state, event)
|
||||
if (event.type === "content_block_stop") return onContentBlockStop(state, event)
|
||||
if (event.type === "message_delta") return Effect.succeed(onMessageDelta(state, event))
|
||||
if (event.type === "error") return Effect.fail(onError(event))
|
||||
if (event.type === "error") return Effect.succeed(onError(state, event))
|
||||
return Effect.succeed<StepResult>([state, NO_EVENTS])
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
type ToolResultPart,
|
||||
} from "../schema"
|
||||
import { BedrockEventStream } from "./bedrock-event-stream"
|
||||
import { classifyApiFailure } from "../provider-error"
|
||||
import { isContextOverflow } from "../provider-error"
|
||||
import { JsonObject, optionalArray, ProviderShared } from "./shared"
|
||||
import { BedrockAuth } from "./utils/bedrock-auth"
|
||||
import { BedrockCache } from "./utils/bedrock-cache"
|
||||
@@ -586,20 +586,27 @@ const step = (state: ParserState, event: BedrockEvent) =>
|
||||
return [{ ...state, pendingFinish: { reason: state.pendingFinish?.reason ?? "stop", usage } }, []] as const
|
||||
}
|
||||
|
||||
const exception = (
|
||||
[
|
||||
["internalServerException", event.internalServerException],
|
||||
["modelStreamErrorException", event.modelStreamErrorException],
|
||||
["serviceUnavailableException", event.serviceUnavailableException],
|
||||
["throttlingException", event.throttlingException],
|
||||
["validationException", event.validationException],
|
||||
if (event.internalServerException || event.modelStreamErrorException || event.serviceUnavailableException) {
|
||||
const message =
|
||||
event.internalServerException?.message ??
|
||||
event.modelStreamErrorException?.message ??
|
||||
event.serviceUnavailableException?.message ??
|
||||
"Bedrock Converse stream error"
|
||||
return [state, [LLMEvent.providerError({ message })]] as const
|
||||
}
|
||||
|
||||
if (event.validationException || event.throttlingException) {
|
||||
const message =
|
||||
event.validationException?.message ?? event.throttlingException?.message ?? "Bedrock Converse error"
|
||||
return [
|
||||
state,
|
||||
[
|
||||
LLMEvent.providerError({
|
||||
message,
|
||||
classification: event.validationException && isContextOverflow(message) ? "context-overflow" : undefined,
|
||||
}),
|
||||
],
|
||||
] as const
|
||||
).find((entry) => entry[1] !== undefined)
|
||||
if (exception) {
|
||||
return yield* classifyApiFailure({
|
||||
message: exception[1]?.message ?? "Bedrock Converse stream error",
|
||||
code: exception[0],
|
||||
})
|
||||
}
|
||||
|
||||
return [state, []] as const
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
type ToolResultPart,
|
||||
} from "../schema"
|
||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
|
||||
import { classifyApiFailure } from "../provider-error"
|
||||
import { isContextOverflow } from "../provider-error"
|
||||
import { OpenAIOptions } from "./utils/openai-options"
|
||||
import { Lifecycle } from "./utils/lifecycle"
|
||||
import { ToolSchemaProjection } from "./utils/tool-schema"
|
||||
@@ -606,9 +606,9 @@ type StepResult = readonly [ParserState, ReadonlyArray<LLMEvent>]
|
||||
const NO_EVENTS: StepResult["1"] = []
|
||||
|
||||
// `response.completed` / `response.incomplete` are clean finishes that emit a
|
||||
// `finish` event; `response.failed` is a hard failure that fails the stream
|
||||
// with a classified `LLMError`. All three end the stream — kept in one set so
|
||||
// `step` and the protocol's `terminal` predicate stay in sync.
|
||||
// `finish` event; `response.failed` is a hard failure that emits a
|
||||
// `provider-error`. All three end the stream — kept in one set so `step` and
|
||||
// the protocol's `terminal` predicate stay in sync.
|
||||
const TERMINAL_TYPES = new Set(["response.completed", "response.incomplete", "response.failed"])
|
||||
|
||||
const onOutputTextDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
|
||||
@@ -907,11 +907,24 @@ const providerErrorMessage = (event: OpenAIResponsesEvent, fallback: string): st
|
||||
return message || code || fallback
|
||||
}
|
||||
|
||||
const providerError = (event: OpenAIResponsesEvent, fallback: string) =>
|
||||
classifyApiFailure({
|
||||
message: providerErrorMessage(event, fallback),
|
||||
code: event.code || event.error?.code || event.response?.error?.code || undefined,
|
||||
const providerError = (event: OpenAIResponsesEvent, fallback: string) => {
|
||||
const code = event.code || event.error?.code || event.response?.error?.code || undefined
|
||||
const message = providerErrorMessage(event, fallback)
|
||||
return LLMEvent.providerError({
|
||||
message,
|
||||
classification: code === "context_length_exceeded" || isContextOverflow(message) ? "context-overflow" : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const onResponseFailed = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [
|
||||
state,
|
||||
[providerError(event, "OpenAI Responses response failed")],
|
||||
]
|
||||
|
||||
const onError = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [
|
||||
state,
|
||||
[providerError(event, "OpenAI Responses stream error")],
|
||||
]
|
||||
|
||||
const step = (state: ParserState, event: OpenAIResponsesEvent) => {
|
||||
if (event.type === "response.output_text.delta") return Effect.succeed(onOutputTextDelta(state, event))
|
||||
@@ -937,8 +950,8 @@ const step = (state: ParserState, event: OpenAIResponsesEvent) => {
|
||||
if (event.type === "response.output_item.done") return onOutputItemDone(state, event)
|
||||
if (event.type === "response.completed" || event.type === "response.incomplete")
|
||||
return Effect.succeed(onResponseFinish(state, event))
|
||||
if (event.type === "response.failed") return Effect.fail(providerError(event, "OpenAI Responses response failed"))
|
||||
if (event.type === "error") return Effect.fail(providerError(event, "OpenAI Responses stream error"))
|
||||
if (event.type === "response.failed") return Effect.succeed(onResponseFailed(state, event))
|
||||
if (event.type === "error") return Effect.succeed(onError(state, event))
|
||||
return Effect.succeed<StepResult>([state, NO_EVENTS])
|
||||
}
|
||||
|
||||
|
||||
@@ -3,9 +3,9 @@ import { Effect, Schema, Stream } from "effect"
|
||||
import * as Sse from "effect/unstable/encoding/Sse"
|
||||
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||
import {
|
||||
BadRequest,
|
||||
MalformedResponse,
|
||||
type LLMError,
|
||||
InvalidProviderOutputReason,
|
||||
InvalidRequestReason,
|
||||
LLMError,
|
||||
type ContentPart,
|
||||
type LLMRequest,
|
||||
type MediaPart,
|
||||
@@ -88,7 +88,11 @@ export const sumTokens = (...values: ReadonlyArray<number | undefined>): number
|
||||
}
|
||||
|
||||
export const eventError = (route: string, message: string, raw?: string) =>
|
||||
new MalformedResponse({ route, message, raw })
|
||||
new LLMError({
|
||||
module: "ProviderShared",
|
||||
method: "stream",
|
||||
reason: new InvalidProviderOutputReason({ route, message, raw }),
|
||||
})
|
||||
|
||||
export const parseJson = (route: string, input: string, message: string) =>
|
||||
Effect.try({
|
||||
@@ -248,9 +252,15 @@ export const sseFraming = (bytes: Stream.Stream<Uint8Array, LLMError>): Stream.S
|
||||
* Canonical invalid-request constructor. Lift one-line `const invalid =
|
||||
* (message) => invalidRequest(message)` aliases out of every
|
||||
* route so the error constructor lives in one place. If we ever extend
|
||||
* `BadRequest` with route context or trace metadata, the change lands here.
|
||||
* `InvalidRequestReason` with route context or trace metadata, the change
|
||||
* lands here.
|
||||
*/
|
||||
export const invalidRequest = (message: string) => new BadRequest({ message })
|
||||
export const invalidRequest = (message: string) =>
|
||||
new LLMError({
|
||||
module: "ProviderShared",
|
||||
method: "request",
|
||||
reason: new InvalidRequestReason({ message }),
|
||||
})
|
||||
|
||||
export const matchToolChoice = <Auto, None, Required, Tool>(
|
||||
route: string,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import { isLLMError, LLMEvent, type LLMError, type ProviderMetadata, type ToolCall } from "../../schema"
|
||||
import { LLMError, LLMEvent, type ProviderMetadata, type ToolCall } from "../../schema"
|
||||
import { eventError, parseToolInput, type ToolAccumulator } from "../shared"
|
||||
|
||||
type StreamKey = string | number
|
||||
@@ -95,7 +95,7 @@ const appendTool = <K extends StreamKey>(
|
||||
}
|
||||
|
||||
export const isError = <K extends StreamKey>(result: AppendOutcome<K> | LLMError): result is LLMError =>
|
||||
isLLMError(result)
|
||||
result instanceof LLMError
|
||||
|
||||
/**
|
||||
* Register a tool call whose start event arrived before any argument deltas.
|
||||
|
||||
@@ -1,19 +1,5 @@
|
||||
import {
|
||||
APIError,
|
||||
Authentication,
|
||||
BadRequest,
|
||||
ContentPolicy,
|
||||
ContextOverflow,
|
||||
HttpContext,
|
||||
HttpRateLimitDetails,
|
||||
NotFound,
|
||||
PermissionDenied,
|
||||
ProviderMetadata,
|
||||
QuotaExceeded,
|
||||
RateLimit,
|
||||
ServerError,
|
||||
type LLMError,
|
||||
} from "./schema"
|
||||
import { Schema } from "effect"
|
||||
import { LLMError, ProviderErrorEvent } from "./schema"
|
||||
|
||||
const patterns = [
|
||||
/prompt is too long/i,
|
||||
@@ -41,102 +27,7 @@ const patterns = [
|
||||
export const isContextOverflow = (message: string) =>
|
||||
patterns.some((pattern) => pattern.test(message)) || /^4(00|13)\s*(status code)?\s*\(no body\)/i.test(message)
|
||||
|
||||
const OVERFLOW_CODES = new Set(["context_length_exceeded", "model_context_window_exceeded"])
|
||||
const QUOTA_CODES = new Set(["insufficient_quota", "usage_not_included", "billing_error"])
|
||||
const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded/i
|
||||
const CONTENT_POLICY_TEXT = /content[-_\s]?policy|content_filter|safety/i
|
||||
const SERVER_ERROR_STATUS = (status: number) => status >= 500 || status === 529
|
||||
|
||||
const CODE_CLASSIFICATION: Record<string, (input: ApiFailure, common: CommonFields) => LLMError> = {
|
||||
overloaded_error: serverError,
|
||||
api_error: serverError,
|
||||
server_error: serverError,
|
||||
internal_error: serverError,
|
||||
server_is_overloaded: serverError,
|
||||
internalServerException: serverError,
|
||||
serviceUnavailableException: serverError,
|
||||
modelStreamErrorException: serverError,
|
||||
rate_limit_error: rateLimit,
|
||||
rate_limit_exceeded: rateLimit,
|
||||
too_many_requests: rateLimit,
|
||||
throttlingException: rateLimit,
|
||||
authentication_error: (_input, common) => new Authentication(common),
|
||||
permission_error: (_input, common) => new PermissionDenied(common),
|
||||
not_found_error: (_input, common) => new NotFound(common),
|
||||
invalid_request_error: (_input, common) => new BadRequest(common),
|
||||
invalid_prompt: (_input, common) => new BadRequest(common),
|
||||
validationException: (_input, common) => new BadRequest(common),
|
||||
}
|
||||
|
||||
export interface ApiFailure {
|
||||
readonly message: string
|
||||
readonly status?: number | undefined
|
||||
/** Provider machine-readable error code or type string (e.g. `context_length_exceeded`, `overloaded_error`). */
|
||||
readonly code?: string | undefined
|
||||
readonly retryAfterMs?: number | undefined
|
||||
readonly rateLimit?: HttpRateLimitDetails | undefined
|
||||
readonly requestID?: string | undefined
|
||||
readonly http?: HttpContext | undefined
|
||||
readonly providerMetadata?: ProviderMetadata | undefined
|
||||
}
|
||||
|
||||
type CommonFields = {
|
||||
readonly message: string
|
||||
readonly status: number | undefined
|
||||
readonly code: string | undefined
|
||||
readonly requestID: string | undefined
|
||||
readonly http: HttpContext | undefined
|
||||
readonly providerMetadata: ProviderMetadata | undefined
|
||||
}
|
||||
|
||||
function serverError(input: ApiFailure, common: CommonFields) {
|
||||
return new ServerError({ ...common, retryAfterMs: input.retryAfterMs })
|
||||
}
|
||||
|
||||
function rateLimit(input: ApiFailure, common: CommonFields) {
|
||||
return new RateLimit({ ...common, retryAfterMs: input.retryAfterMs, rateLimit: input.rateLimit })
|
||||
}
|
||||
|
||||
/**
|
||||
* One classifier for every failure a remote API deliberately reports.
|
||||
* Protocols call it with in-stream error payloads, the request executor with
|
||||
* non-2xx responses, and the AI SDK adapter with `APICallError`s, so all
|
||||
* three surfaces produce identical `LLMError` tags.
|
||||
*
|
||||
* Precedence: context overflow (most specific, 4xx-scoped), content policy,
|
||||
* HTTP status, provider code, then the generic `APIError` fallback.
|
||||
*/
|
||||
export const classifyApiFailure = (input: ApiFailure): LLMError => {
|
||||
const common: CommonFields = {
|
||||
message: input.message,
|
||||
status: input.status,
|
||||
code: input.code,
|
||||
requestID: input.requestID,
|
||||
http: input.http,
|
||||
providerMetadata: input.providerMetadata,
|
||||
}
|
||||
const body = input.http?.body ?? ""
|
||||
const clientScoped = input.status === undefined || (input.status >= 400 && input.status < 500)
|
||||
if (
|
||||
clientScoped &&
|
||||
((input.code !== undefined && OVERFLOW_CODES.has(input.code)) ||
|
||||
isContextOverflow(input.message) ||
|
||||
(body.length > 0 && isContextOverflow(body)))
|
||||
)
|
||||
return new ContextOverflow(common)
|
||||
if (CONTENT_POLICY_TEXT.test(body.length > 0 ? body : input.message)) return new ContentPolicy(common)
|
||||
if (input.code !== undefined && QUOTA_CODES.has(input.code)) return new QuotaExceeded(common)
|
||||
if (input.status === 401) return new Authentication(common)
|
||||
if (input.status === 403) return new PermissionDenied(common)
|
||||
if (input.status === 404) return new NotFound(common)
|
||||
if (input.status === 429) {
|
||||
if (QUOTA_TEXT.test(body.length > 0 ? body : input.message)) return new QuotaExceeded(common)
|
||||
return rateLimit(input, common)
|
||||
}
|
||||
if (input.status !== undefined && SERVER_ERROR_STATUS(input.status)) return serverError(input, common)
|
||||
if (input.status === 400 || input.status === 409 || input.status === 413 || input.status === 422)
|
||||
return new BadRequest(common)
|
||||
const byCode = input.code === undefined ? undefined : CODE_CLASSIFICATION[input.code]
|
||||
if (byCode) return byCode(input, common)
|
||||
return new APIError(common)
|
||||
}
|
||||
export const isContextOverflowFailure = (failure: unknown) =>
|
||||
failure instanceof LLMError
|
||||
? failure.reason._tag === "InvalidRequest" && failure.reason.classification === "context-overflow"
|
||||
: Schema.is(ProviderErrorEvent)(failure) && failure.classification === "context-overflow"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Config, Effect, Redacted } from "effect"
|
||||
import { Headers } from "effect/unstable/http"
|
||||
import { Authentication, BadRequest, type LLMError, type LLMRequest } from "../schema"
|
||||
import { AuthenticationReason, InvalidRequestReason, LLMError, type LLMRequest } from "../schema"
|
||||
|
||||
export class MissingCredentialError extends Error {
|
||||
readonly _tag = "MissingCredentialError"
|
||||
@@ -135,9 +135,16 @@ export function bearerHeader(name: string, source?: Secret | Credential) {
|
||||
}
|
||||
|
||||
const toLLMError = (error: AuthError): LLMError => {
|
||||
if (error instanceof MissingCredentialError) return new Authentication({ message: error.message })
|
||||
if (error instanceof Config.ConfigError)
|
||||
return new BadRequest({ message: `Failed to resolve auth config: ${error.message}` })
|
||||
if (error instanceof MissingCredentialError || error instanceof Config.ConfigError) {
|
||||
return new LLMError({
|
||||
module: "Auth",
|
||||
method: "apply",
|
||||
reason:
|
||||
error instanceof MissingCredentialError
|
||||
? new AuthenticationReason({ message: error.message, kind: "missing" })
|
||||
: new InvalidRequestReason({ message: `Failed to resolve auth config: ${error.message}` }),
|
||||
})
|
||||
}
|
||||
return error
|
||||
}
|
||||
|
||||
|
||||
@@ -14,11 +14,11 @@ import type { LLMError, LLMEvent, PreparedRequestOf, ProtocolID, ProviderOptions
|
||||
import {
|
||||
GenerationOptions,
|
||||
HttpOptions,
|
||||
isLLMError,
|
||||
LLMRequest,
|
||||
LLMResponse,
|
||||
Model,
|
||||
ModelLimits,
|
||||
LLMError as LLMErrorClass,
|
||||
PreparedRequest,
|
||||
ProviderID,
|
||||
mergeGenerationOptions,
|
||||
@@ -225,7 +225,7 @@ export interface MakeTransportInput<Body, Prepared, Frame, Event, State> {
|
||||
|
||||
const streamError = (route: string, message: string, cause: Cause.Cause<unknown>) => {
|
||||
const failed = cause.reasons.find(Cause.isFailReason)?.error
|
||||
if (failed !== undefined && isLLMError(failed)) return failed
|
||||
if (failed instanceof LLMErrorClass) return failed
|
||||
return ProviderShared.eventError(route, message, Cause.pretty(cause))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Cause, Context, Effect, Layer, Option, Schema } from "effect"
|
||||
import { Cause, Context, Effect, Layer } from "effect"
|
||||
import {
|
||||
FetchHttpClient,
|
||||
Headers,
|
||||
@@ -8,15 +8,21 @@ import {
|
||||
HttpClientResponse,
|
||||
} from "effect/unstable/http"
|
||||
import {
|
||||
ConnectionError,
|
||||
AuthenticationReason,
|
||||
ContentPolicyReason,
|
||||
HttpContext,
|
||||
HttpRateLimitDetails,
|
||||
HttpRequestDetails,
|
||||
HttpResponseDetails,
|
||||
TimeoutError,
|
||||
type LLMError,
|
||||
InvalidRequestReason,
|
||||
LLMError,
|
||||
ProviderInternalReason,
|
||||
QuotaExceededReason,
|
||||
RateLimitReason,
|
||||
TransportReason,
|
||||
UnknownProviderReason,
|
||||
} from "../schema"
|
||||
import { classifyApiFailure } from "../provider-error"
|
||||
import { isContextOverflow } from "../provider-error"
|
||||
|
||||
export interface Interface {
|
||||
readonly execute: (
|
||||
@@ -79,6 +85,8 @@ const requestId = (headers: Record<string, string>) => {
|
||||
)
|
||||
}
|
||||
|
||||
const providerInternalStatus = (status: number) => status === 429 || status === 503 || status === 504 || status === 529
|
||||
|
||||
const retryAfterMs = (headers: Record<string, string>) => {
|
||||
const millis = Number(headers["retry-after-ms"])
|
||||
if (Number.isFinite(millis)) return Math.max(0, millis)
|
||||
@@ -211,21 +219,56 @@ const responseHttp = (input: {
|
||||
rateLimit: input.rateLimit,
|
||||
})
|
||||
|
||||
const decodeBodyJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))
|
||||
|
||||
// Provider machine code from a JSON error body (`error.code` / `error.type`),
|
||||
// fed to the shared classifier so code-based rules (overflow, quota) work on
|
||||
// HTTP rejections too. Truncated or non-JSON bodies yield undefined.
|
||||
const providerCode = (body: string | undefined) => {
|
||||
if (!body) return undefined
|
||||
const decoded = Option.getOrUndefined(decodeBodyJson(body))
|
||||
if (typeof decoded !== "object" || decoded === null) return undefined
|
||||
const error = (decoded as Record<string, unknown>).error
|
||||
if (typeof error !== "object" || error === null) return undefined
|
||||
const fields = error as Record<string, unknown>
|
||||
if (typeof fields.code === "string") return fields.code
|
||||
if (typeof fields.type === "string") return fields.type
|
||||
return undefined
|
||||
const statusReason = (input: {
|
||||
readonly status: number
|
||||
readonly message: string
|
||||
readonly retryAfterMs?: number | undefined
|
||||
readonly rateLimit?: HttpRateLimitDetails | undefined
|
||||
readonly http: HttpContext
|
||||
}) => {
|
||||
const body = input.http.body ?? ""
|
||||
if (/content[-_\s]?policy|content_filter|safety/i.test(body)) {
|
||||
return new ContentPolicyReason({ message: input.message, http: input.http })
|
||||
}
|
||||
if (input.status === 401) {
|
||||
return new AuthenticationReason({ message: input.message, kind: "invalid", http: input.http })
|
||||
}
|
||||
if (input.status === 403) {
|
||||
return new AuthenticationReason({ message: input.message, kind: "insufficient-permissions", http: input.http })
|
||||
}
|
||||
if (input.status === 429) {
|
||||
if (/insufficient[-_\s]?quota|quota[-_\s]?exceeded/i.test(body)) {
|
||||
return new QuotaExceededReason({ message: input.message, http: input.http })
|
||||
}
|
||||
return new RateLimitReason({
|
||||
message: input.message,
|
||||
retryAfterMs: input.retryAfterMs,
|
||||
rateLimit: input.rateLimit,
|
||||
http: input.http,
|
||||
})
|
||||
}
|
||||
if (
|
||||
input.status === 400 ||
|
||||
input.status === 404 ||
|
||||
input.status === 409 ||
|
||||
input.status === 413 ||
|
||||
input.status === 422
|
||||
) {
|
||||
return new InvalidRequestReason({
|
||||
message: input.message,
|
||||
classification: isContextOverflow(body) ? "context-overflow" : undefined,
|
||||
http: input.http,
|
||||
})
|
||||
}
|
||||
if (input.status >= 500 || providerInternalStatus(input.status)) {
|
||||
return new ProviderInternalReason({
|
||||
message: input.message,
|
||||
status: input.status,
|
||||
retryAfterMs: input.retryAfterMs,
|
||||
http: input.http,
|
||||
})
|
||||
}
|
||||
return new UnknownProviderReason({ message: input.message, status: input.status, http: input.http })
|
||||
}
|
||||
|
||||
const statusError =
|
||||
@@ -238,55 +281,58 @@ const statusError =
|
||||
const retryAfter = retryAfterMs(headers)
|
||||
const rateLimit = rateLimitDetails(headers, retryAfter)
|
||||
const details = responseBody(body, request)
|
||||
return yield* classifyApiFailure({
|
||||
status: response.status,
|
||||
message: providerMessage(response.status, details),
|
||||
code: providerCode(details.body),
|
||||
retryAfterMs: retryAfter,
|
||||
rateLimit,
|
||||
requestID: requestId(headers),
|
||||
http: responseHttp({
|
||||
request,
|
||||
response,
|
||||
redactedNames,
|
||||
body: details,
|
||||
requestId: requestId(headers),
|
||||
return yield* new LLMError({
|
||||
module: "RequestExecutor",
|
||||
method: "execute",
|
||||
reason: statusReason({
|
||||
status: response.status,
|
||||
message: providerMessage(response.status, details),
|
||||
retryAfterMs: retryAfter,
|
||||
rateLimit,
|
||||
http: responseHttp({
|
||||
request,
|
||||
response,
|
||||
redactedNames,
|
||||
body: details,
|
||||
requestId: requestId(headers),
|
||||
rateLimit,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
const toHttpError = (redactedNames: ReadonlyArray<string | RegExp>) => (error: unknown) => {
|
||||
const httpContext = (request: HttpClientRequest.HttpClientRequest | undefined) =>
|
||||
request ? new HttpContext({ request: requestDetails(request, redactedNames) }) : undefined
|
||||
const connectionError = (input: {
|
||||
const transportError = (input: {
|
||||
readonly message: string
|
||||
readonly kind?: string | undefined
|
||||
readonly request?: HttpClientRequest.HttpClientRequest | undefined
|
||||
}) =>
|
||||
new ConnectionError({
|
||||
message: input.message,
|
||||
kind: input.kind,
|
||||
url: input.request ? redactUrl(input.request.url) : undefined,
|
||||
http: httpContext(input.request),
|
||||
cause: error,
|
||||
new LLMError({
|
||||
module: "RequestExecutor",
|
||||
method: "execute",
|
||||
reason: new TransportReason({
|
||||
message: input.message,
|
||||
kind: input.kind,
|
||||
url: input.request ? redactUrl(input.request.url) : undefined,
|
||||
http: input.request ? new HttpContext({ request: requestDetails(input.request, redactedNames) }) : undefined,
|
||||
}),
|
||||
})
|
||||
|
||||
if (Cause.isTimeoutError(error)) {
|
||||
return new TimeoutError({ message: error.message })
|
||||
return transportError({ message: error.message, kind: "Timeout" })
|
||||
}
|
||||
if (!HttpClientError.isHttpClientError(error)) {
|
||||
return connectionError({ message: "HTTP transport failed" })
|
||||
return transportError({ message: "HTTP transport failed" })
|
||||
}
|
||||
const request = "request" in error ? error.request : undefined
|
||||
if (error.reason._tag === "TransportError") {
|
||||
return connectionError({
|
||||
return transportError({
|
||||
message: error.reason.description ?? "HTTP transport failed",
|
||||
kind: error.reason._tag,
|
||||
request,
|
||||
})
|
||||
}
|
||||
return connectionError({
|
||||
return transportError({
|
||||
message: `HTTP transport failed: ${error.reason._tag}`,
|
||||
kind: error.reason._tag,
|
||||
request,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Cause, Context, Effect, Layer, Queue, Stream } from "effect"
|
||||
import { Headers } from "effect/unstable/http"
|
||||
import { ConnectionError, type LLMError } from "../../schema"
|
||||
import { LLMError, TransportReason } from "../../schema"
|
||||
import * as HttpTransport from "./http"
|
||||
import type { Transport } from "./index"
|
||||
|
||||
@@ -27,10 +27,15 @@ type WebSocketConstructorWithHeaders = new (
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/LLM/WebSocketExecutor") {}
|
||||
|
||||
const transportError = (
|
||||
_method: string,
|
||||
method: string,
|
||||
message: string,
|
||||
input: { readonly url?: string; readonly kind?: string } = {},
|
||||
) => new ConnectionError({ message, url: input.url, kind: input.kind })
|
||||
) =>
|
||||
new LLMError({
|
||||
module: "WebSocketExecutor",
|
||||
method,
|
||||
reason: new TransportReason({ message, url: input.url, kind: input.kind }),
|
||||
})
|
||||
|
||||
const eventMessage = (event: Event) => {
|
||||
if ("message" in event && typeof event.message === "string") return event.message
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Schema } from "effect"
|
||||
import { ModelID, ProviderID, ProviderMetadata, RouteID } from "./ids"
|
||||
|
||||
export const ProviderFailureClassification = Schema.Literal("context-overflow")
|
||||
export type ProviderFailureClassification = typeof ProviderFailureClassification.Type
|
||||
|
||||
export class HttpRequestDetails extends Schema.Class<HttpRequestDetails>("LLM.HttpRequestDetails")({
|
||||
method: Schema.String,
|
||||
url: Schema.String,
|
||||
@@ -28,150 +31,118 @@ export class HttpContext extends Schema.Class<HttpContext>("LLM.HttpContext")({
|
||||
rateLimit: Schema.optional(HttpRateLimitDetails),
|
||||
}) {}
|
||||
|
||||
/**
|
||||
* Fields shared by every failure the remote API deliberately reported —
|
||||
* whether as a non-2xx response, an SSE error event, a WebSocket error
|
||||
* message, or a binary exception frame. `status` is absent when the error
|
||||
* arrived mid-stream without an HTTP status; `code` carries the provider's
|
||||
* machine-readable error code (e.g. `context_length_exceeded`) when one
|
||||
* exists.
|
||||
*/
|
||||
const apiFailureFields = {
|
||||
export class InvalidRequestReason extends Schema.Class<InvalidRequestReason>("LLM.Error.InvalidRequest")({
|
||||
_tag: Schema.tag("InvalidRequest"),
|
||||
message: Schema.String,
|
||||
status: Schema.optional(Schema.Number),
|
||||
code: Schema.optional(Schema.String),
|
||||
requestID: Schema.optional(Schema.String),
|
||||
http: Schema.optional(HttpContext),
|
||||
parameter: Schema.optional(Schema.String),
|
||||
classification: Schema.optional(ProviderFailureClassification),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
http: Schema.optional(HttpContext),
|
||||
}) {}
|
||||
|
||||
export class NoRouteReason extends Schema.Class<NoRouteReason>("LLM.Error.NoRoute")({
|
||||
_tag: Schema.tag("NoRoute"),
|
||||
route: RouteID,
|
||||
provider: ProviderID,
|
||||
model: ModelID,
|
||||
}) {
|
||||
get message() {
|
||||
return `No LLM route for ${this.provider}/${this.model} using ${this.route}`
|
||||
}
|
||||
}
|
||||
|
||||
/** Provider rejected the request as invalid (400/409/422, `invalid_request_error`, ...). */
|
||||
export class BadRequest extends Schema.TaggedErrorClass<BadRequest>()("LLM.BadRequest", {
|
||||
...apiFailureFields,
|
||||
parameter: Schema.optional(Schema.String),
|
||||
export class AuthenticationReason extends Schema.Class<AuthenticationReason>("LLM.Error.Authentication")({
|
||||
_tag: Schema.tag("Authentication"),
|
||||
message: Schema.String,
|
||||
kind: Schema.Literals(["missing", "invalid", "expired", "insufficient-permissions", "unknown"]),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
http: Schema.optional(HttpContext),
|
||||
}) {}
|
||||
|
||||
/** Credentials are missing, invalid, or expired (401). */
|
||||
export class Authentication extends Schema.TaggedErrorClass<Authentication>()("LLM.Authentication", {
|
||||
...apiFailureFields,
|
||||
}) {}
|
||||
|
||||
/** Authenticated but not allowed (403). */
|
||||
export class PermissionDenied extends Schema.TaggedErrorClass<PermissionDenied>()("LLM.PermissionDenied", {
|
||||
...apiFailureFields,
|
||||
}) {}
|
||||
|
||||
/** Model or endpoint does not exist (404). */
|
||||
export class NotFound extends Schema.TaggedErrorClass<NotFound>()("LLM.NotFound", {
|
||||
...apiFailureFields,
|
||||
}) {}
|
||||
|
||||
/** Transient request throttling (429). Retryable; honor `retryAfterMs` when present. */
|
||||
export class RateLimit extends Schema.TaggedErrorClass<RateLimit>()("LLM.RateLimit", {
|
||||
...apiFailureFields,
|
||||
export class RateLimitReason extends Schema.Class<RateLimitReason>("LLM.Error.RateLimit")({
|
||||
_tag: Schema.tag("RateLimit"),
|
||||
message: Schema.String,
|
||||
retryAfterMs: Schema.optional(Schema.Number),
|
||||
rateLimit: Schema.optional(HttpRateLimitDetails),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
http: Schema.optional(HttpContext),
|
||||
}) {}
|
||||
|
||||
/** Account-level quota or billing exhaustion. Unlike `RateLimit`, waiting does not help. */
|
||||
export class QuotaExceeded extends Schema.TaggedErrorClass<QuotaExceeded>()("LLM.QuotaExceeded", {
|
||||
...apiFailureFields,
|
||||
export class QuotaExceededReason extends Schema.Class<QuotaExceededReason>("LLM.Error.QuotaExceeded")({
|
||||
_tag: Schema.tag("QuotaExceeded"),
|
||||
message: Schema.String,
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
http: Schema.optional(HttpContext),
|
||||
}) {}
|
||||
|
||||
/** Provider refused the content for policy/safety reasons. */
|
||||
export class ContentPolicy extends Schema.TaggedErrorClass<ContentPolicy>()("LLM.ContentPolicy", {
|
||||
...apiFailureFields,
|
||||
export class ContentPolicyReason extends Schema.Class<ContentPolicyReason>("LLM.Error.ContentPolicy")({
|
||||
_tag: Schema.tag("ContentPolicy"),
|
||||
message: Schema.String,
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
http: Schema.optional(HttpContext),
|
||||
}) {}
|
||||
|
||||
/**
|
||||
* The request exceeds the model's context window. Designated tag because
|
||||
* Core recovers from it structurally (compaction) rather than surfacing it.
|
||||
* Upgraded from `BadRequest` by the shared classifier in `provider-error.ts`.
|
||||
*/
|
||||
export class ContextOverflow extends Schema.TaggedErrorClass<ContextOverflow>()("LLM.ContextOverflow", {
|
||||
...apiFailureFields,
|
||||
}) {}
|
||||
|
||||
/** Provider-side failure (5xx, `overloaded_error`, internal exceptions). Retryable. */
|
||||
export class ServerError extends Schema.TaggedErrorClass<ServerError>()("LLM.ServerError", {
|
||||
...apiFailureFields,
|
||||
export class ProviderInternalReason extends Schema.Class<ProviderInternalReason>("LLM.Error.ProviderInternal")({
|
||||
_tag: Schema.tag("ProviderInternal"),
|
||||
message: Schema.String,
|
||||
status: Schema.Number,
|
||||
retryAfterMs: Schema.optional(Schema.Number),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
http: Schema.optional(HttpContext),
|
||||
}) {}
|
||||
|
||||
/** Any other deliberate API rejection that matches no designated tag (402, 405, 410, ...). */
|
||||
export class APIError extends Schema.TaggedErrorClass<APIError>()("LLM.APIError", {
|
||||
...apiFailureFields,
|
||||
}) {}
|
||||
|
||||
/** Communication failed: connect failure, reset, socket close, DNS. No API response involved. */
|
||||
export class ConnectionError extends Schema.TaggedErrorClass<ConnectionError>()("LLM.ConnectionError", {
|
||||
export class TransportReason extends Schema.Class<TransportReason>("LLM.Error.Transport")({
|
||||
_tag: Schema.tag("Transport"),
|
||||
message: Schema.String,
|
||||
kind: Schema.optional(Schema.String),
|
||||
url: Schema.optional(Schema.String),
|
||||
http: Schema.optional(HttpContext),
|
||||
cause: Schema.optional(Schema.Defect()),
|
||||
}) {}
|
||||
|
||||
/** The request or stream read timed out before the provider answered. */
|
||||
export class TimeoutError extends Schema.TaggedErrorClass<TimeoutError>()("LLM.TimeoutError", {
|
||||
message: Schema.String,
|
||||
url: Schema.optional(Schema.String),
|
||||
http: Schema.optional(HttpContext),
|
||||
}) {}
|
||||
|
||||
/**
|
||||
* Transport succeeded but the content broke the protocol contract:
|
||||
* undecodable frames, premature EOF without a terminal `finish`, duplicate
|
||||
* terminals, or output after a terminal event.
|
||||
*/
|
||||
export class MalformedResponse extends Schema.TaggedErrorClass<MalformedResponse>()("LLM.MalformedResponse", {
|
||||
export class InvalidProviderOutputReason extends Schema.Class<InvalidProviderOutputReason>(
|
||||
"LLM.Error.InvalidProviderOutput",
|
||||
)({
|
||||
_tag: Schema.tag("InvalidProviderOutput"),
|
||||
message: Schema.String,
|
||||
route: Schema.optional(Schema.String),
|
||||
raw: Schema.optional(Schema.String),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}) {}
|
||||
|
||||
/** Request construction failed locally: the selected model resolves to no executable route. */
|
||||
export class NoRoute extends Schema.TaggedErrorClass<NoRoute>()("LLM.NoRoute", {
|
||||
route: RouteID,
|
||||
provider: ProviderID,
|
||||
model: ModelID,
|
||||
export class UnknownProviderReason extends Schema.Class<UnknownProviderReason>("LLM.Error.UnknownProvider")({
|
||||
_tag: Schema.tag("UnknownProvider"),
|
||||
message: Schema.String,
|
||||
status: Schema.optional(Schema.Number),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
http: Schema.optional(HttpContext),
|
||||
}) {}
|
||||
|
||||
export const LLMErrorReason = Schema.Union([
|
||||
InvalidRequestReason,
|
||||
NoRouteReason,
|
||||
AuthenticationReason,
|
||||
RateLimitReason,
|
||||
QuotaExceededReason,
|
||||
ContentPolicyReason,
|
||||
ProviderInternalReason,
|
||||
TransportReason,
|
||||
InvalidProviderOutputReason,
|
||||
UnknownProviderReason,
|
||||
]).pipe(Schema.toTaggedUnion("_tag"))
|
||||
export type LLMErrorReason = Schema.Schema.Type<typeof LLMErrorReason>
|
||||
|
||||
export class LLMError extends Schema.TaggedErrorClass<LLMError>()("LLM.Error", {
|
||||
module: Schema.String,
|
||||
method: Schema.String,
|
||||
reason: LLMErrorReason,
|
||||
}) {
|
||||
override readonly cause = this.reason
|
||||
|
||||
override get message() {
|
||||
return `No LLM route for ${this.provider}/${this.model} using ${this.route}`
|
||||
return `${this.module}.${this.method}: ${this.reason.message}`
|
||||
}
|
||||
}
|
||||
|
||||
const members = [
|
||||
BadRequest,
|
||||
Authentication,
|
||||
PermissionDenied,
|
||||
NotFound,
|
||||
RateLimit,
|
||||
QuotaExceeded,
|
||||
ContentPolicy,
|
||||
ContextOverflow,
|
||||
ServerError,
|
||||
APIError,
|
||||
ConnectionError,
|
||||
TimeoutError,
|
||||
MalformedResponse,
|
||||
NoRoute,
|
||||
] as const
|
||||
|
||||
export const LLMErrorSchema = Schema.Union(members)
|
||||
|
||||
/**
|
||||
* Every failure of one LLM request. `LLMEvent` streams carry output only;
|
||||
* all failures — HTTP rejections, in-stream provider error events, transport
|
||||
* failures, and protocol-contract violations — exit through this union on
|
||||
* the stream's error channel.
|
||||
*/
|
||||
export type LLMError = typeof LLMErrorSchema.Type
|
||||
|
||||
export const isLLMError = (value: unknown): value is LLMError =>
|
||||
members.some((member) => value instanceof member)
|
||||
|
||||
/**
|
||||
* Failure type for tool execute handlers. Handlers must map their internal
|
||||
* errors to this shape; the runtime catches `ToolFailure`s and surfaces them
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Schema } from "effect"
|
||||
import { ContentBlockID, FinishReason, ProtocolID, ProviderMetadata, RouteID, ToolCallID } from "./ids"
|
||||
import { ModelSchema } from "./options"
|
||||
import { Message, ToolCallPart, ToolOutput, ToolResultPart, ToolResultValue, type ContentPart } from "./messages"
|
||||
import { ProviderFailureClassification } from "./errors"
|
||||
|
||||
/**
|
||||
* Token usage reported by an LLM provider.
|
||||
@@ -196,6 +197,14 @@ export const Finish = Schema.Struct({
|
||||
}).annotate({ identifier: "LLM.Event.Finish" })
|
||||
export type Finish = Schema.Schema.Type<typeof Finish>
|
||||
|
||||
export const ProviderErrorEvent = Schema.Struct({
|
||||
type: Schema.tag("provider-error"),
|
||||
message: Schema.String,
|
||||
classification: Schema.optional(ProviderFailureClassification),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}).annotate({ identifier: "LLM.Event.ProviderError" })
|
||||
export type ProviderErrorEvent = Schema.Schema.Type<typeof ProviderErrorEvent>
|
||||
|
||||
const llmEventTagged = Schema.Union([
|
||||
StepStart,
|
||||
TextStart,
|
||||
@@ -212,6 +221,7 @@ const llmEventTagged = Schema.Union([
|
||||
ToolError,
|
||||
StepFinish,
|
||||
Finish,
|
||||
ProviderErrorEvent,
|
||||
]).pipe(Schema.toTaggedUnion("type"))
|
||||
|
||||
type WithID<Event extends { readonly id: unknown }, ID> = Omit<Event, "type" | "id"> & { readonly id: ID | string }
|
||||
@@ -261,6 +271,7 @@ export const LLMEvent = Object.assign(llmEventTagged, {
|
||||
...input,
|
||||
usage: input.usage === undefined ? undefined : Usage.from(input.usage),
|
||||
}),
|
||||
providerError: ProviderErrorEvent.make,
|
||||
is: {
|
||||
stepStart: llmEventTagged.guards["step-start"],
|
||||
textStart: llmEventTagged.guards["text-start"],
|
||||
@@ -277,6 +288,7 @@ export const LLMEvent = Object.assign(llmEventTagged, {
|
||||
toolError: llmEventTagged.guards["tool-error"],
|
||||
stepFinish: llmEventTagged.guards["step-finish"],
|
||||
finish: llmEventTagged.guards.finish,
|
||||
providerError: llmEventTagged.guards["provider-error"],
|
||||
},
|
||||
})
|
||||
export type LLMEvent = Schema.Schema.Type<typeof llmEventTagged>
|
||||
@@ -362,6 +374,13 @@ const appendEvent = (state: ResponseState, event: LLMEvent): ResponseState => {
|
||||
finishReason: event.reason,
|
||||
}
|
||||
}
|
||||
if (LLMEvent.is.providerError(event)) {
|
||||
return {
|
||||
...state,
|
||||
events,
|
||||
finishReason: state.finishReason ?? "error",
|
||||
}
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
events,
|
||||
@@ -570,7 +589,7 @@ export namespace LLMResponse {
|
||||
/** Purely fold one provider-neutral event into the attempt assembly state. */
|
||||
export const reduce = reduceResponseState
|
||||
|
||||
/** Return a completed response only after a terminal finish event. */
|
||||
/** Return a completed response only after a terminal finish or provider error. */
|
||||
export const complete = (state: State): LLMResponse | undefined =>
|
||||
state.finishReason === undefined
|
||||
? undefined
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer, Ref } from "effect"
|
||||
import { Headers, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { LLM, isLLMError, type LLMError } from "../src"
|
||||
import { LLM, LLMError } from "../src"
|
||||
import { LLMClient, RequestExecutor } from "../src/route"
|
||||
import * as OpenAIChat from "../src/protocols/openai-chat"
|
||||
import { dynamicResponse } from "./lib/http"
|
||||
@@ -59,12 +59,12 @@ const countedResponsesLayer = (attempts: Ref.Ref<number>, responses: ReadonlyArr
|
||||
)
|
||||
|
||||
const expectLLMError = (error: unknown) => {
|
||||
expect(isLLMError(error)).toBe(true)
|
||||
if (!isLLMError(error)) throw new Error("expected LLMError")
|
||||
expect(error).toBeInstanceOf(LLMError)
|
||||
if (!(error instanceof LLMError)) throw new Error("expected LLMError")
|
||||
return error
|
||||
}
|
||||
|
||||
const errorHttp = (error: LLMError) => ("http" in error ? error.http : undefined)
|
||||
const errorHttp = (error: LLMError) => ("http" in error.reason ? error.reason.http : undefined)
|
||||
|
||||
describe("RequestExecutor", () => {
|
||||
it.effect("classifies context overflow responses", () =>
|
||||
@@ -73,7 +73,7 @@ describe("RequestExecutor", () => {
|
||||
const error = yield* executor.execute(request).pipe(Effect.flip)
|
||||
|
||||
expectLLMError(error)
|
||||
expect(error).toMatchObject({ _tag: "LLM.ContextOverflow" })
|
||||
expect(error.reason).toMatchObject({ _tag: "InvalidRequest", classification: "context-overflow" })
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
responsesLayer([
|
||||
@@ -91,7 +91,8 @@ describe("RequestExecutor", () => {
|
||||
const error = yield* executor.execute(request).pipe(Effect.flip)
|
||||
|
||||
expectLLMError(error)
|
||||
expect(error).toMatchObject({ _tag: "LLM.BadRequest" })
|
||||
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
|
||||
expect("classification" in error.reason ? error.reason.classification : undefined).toBeUndefined()
|
||||
}).pipe(Effect.provide(responsesLayer([new Response("request too large", { status: 413 })]))),
|
||||
)
|
||||
|
||||
@@ -101,7 +102,8 @@ describe("RequestExecutor", () => {
|
||||
const error = yield* executor.execute(request).pipe(Effect.flip)
|
||||
|
||||
expectLLMError(error)
|
||||
expect(error).toMatchObject({ _tag: "LLM.BadRequest" })
|
||||
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
|
||||
expect("classification" in error.reason ? error.reason.classification : undefined).toBeUndefined()
|
||||
}).pipe(Effect.provide(responsesLayer([new Response("invalid parameter", { status: 400 })]))),
|
||||
)
|
||||
|
||||
@@ -112,22 +114,24 @@ describe("RequestExecutor", () => {
|
||||
|
||||
expectLLMError(error)
|
||||
expect(error).toMatchObject({
|
||||
_tag: "LLM.RateLimit",
|
||||
retryAfterMs: 0,
|
||||
rateLimit: { retryAfterMs: 0 },
|
||||
http: {
|
||||
requestId: "req_123",
|
||||
request: {
|
||||
method: "POST",
|
||||
url: "https://provider.test/v1/chat?api_key=%3Credacted%3E&key=%3Credacted%3E&debug=1",
|
||||
headers: { authorization: "<redacted>", "x-safe": "visible" },
|
||||
},
|
||||
response: {
|
||||
status: 429,
|
||||
headers: {
|
||||
"retry-after-ms": "0",
|
||||
"x-request-id": "req_123",
|
||||
"x-api-key": "<redacted>",
|
||||
reason: {
|
||||
_tag: "RateLimit",
|
||||
retryAfterMs: 0,
|
||||
rateLimit: { retryAfterMs: 0 },
|
||||
http: {
|
||||
requestId: "req_123",
|
||||
request: {
|
||||
method: "POST",
|
||||
url: "https://provider.test/v1/chat?api_key=%3Credacted%3E&key=%3Credacted%3E&debug=1",
|
||||
headers: { authorization: "<redacted>", "x-safe": "visible" },
|
||||
},
|
||||
response: {
|
||||
status: 429,
|
||||
headers: {
|
||||
"retry-after-ms": "0",
|
||||
"x-request-id": "req_123",
|
||||
"x-api-key": "<redacted>",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -165,8 +169,8 @@ describe("RequestExecutor", () => {
|
||||
const error = yield* executor.execute(request).pipe(Effect.flip)
|
||||
|
||||
expectLLMError(error)
|
||||
expect(error).toMatchObject({ _tag: "LLM.RateLimit" })
|
||||
expect(error._tag === "LLM.RateLimit" ? error.rateLimit : undefined).toEqual({
|
||||
expect(error.reason).toMatchObject({ _tag: "RateLimit" })
|
||||
expect(error.reason._tag === "RateLimit" ? error.reason.rateLimit : undefined).toEqual({
|
||||
retryAfterMs: 0,
|
||||
limit: { requests: "500", tokens: "30000" },
|
||||
remaining: { requests: "499", tokens: "29900" },
|
||||
@@ -198,7 +202,7 @@ describe("RequestExecutor", () => {
|
||||
const error = yield* executor.execute(request).pipe(Effect.flip)
|
||||
|
||||
expectLLMError(error)
|
||||
expect(error).toMatchObject({ _tag: "LLM.ServerError" })
|
||||
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
|
||||
expect(errorHttp(error)?.rateLimit).toEqual({
|
||||
retryAfterMs: 0,
|
||||
limit: { requests: "100", "input-tokens": "10000" },
|
||||
@@ -241,12 +245,12 @@ describe("RequestExecutor", () => {
|
||||
)
|
||||
|
||||
expectLLMError(error)
|
||||
expect(error).toMatchObject({ _tag: "LLM.ServerError", status: 503 })
|
||||
expect(error.reason).toMatchObject({ _tag: "ProviderInternal", status: 503 })
|
||||
expect(yield* Ref.get(attempts)).toBe(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("marks 504 and 529 status responses as server errors", () =>
|
||||
it.effect("marks 504 and 529 status responses as provider-internal", () =>
|
||||
Effect.gen(function* () {
|
||||
const failWith = (status: number) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -254,7 +258,7 @@ describe("RequestExecutor", () => {
|
||||
const error = yield* executor.execute(request).pipe(Effect.flip)
|
||||
|
||||
expectLLMError(error)
|
||||
expect(error).toMatchObject({ _tag: "LLM.ServerError", status })
|
||||
expect(error.reason).toMatchObject({ _tag: "ProviderInternal", status })
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
responsesLayer([
|
||||
@@ -277,7 +281,7 @@ describe("RequestExecutor", () => {
|
||||
const error = yield* executor.execute(request).pipe(Effect.flip)
|
||||
|
||||
expectLLMError(error)
|
||||
expect(error).toMatchObject({ _tag: "LLM.Authentication" })
|
||||
expect(error.reason).toMatchObject({ _tag: "Authentication" })
|
||||
expect(errorHttp(error)?.bodyTruncated).toBe(true)
|
||||
expect(errorHttp(error)?.body).toHaveLength(16_384)
|
||||
}).pipe(
|
||||
@@ -356,7 +360,7 @@ describe("RequestExecutor", () => {
|
||||
)
|
||||
|
||||
expectLLMError(error)
|
||||
expect(error).toMatchObject({ _tag: "LLM.MalformedResponse" })
|
||||
expect(error.reason).toMatchObject({ _tag: "InvalidProviderOutput" })
|
||||
expect(yield* Ref.get(attempts)).toBe(1)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -149,8 +149,8 @@ describe("request option precedence", () => {
|
||||
}),
|
||||
).pipe(Effect.flip)
|
||||
|
||||
expect(error).toMatchObject({
|
||||
_tag: "LLM.BadRequest",
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "InvalidRequest",
|
||||
message: "http.body cannot overlay protocol-owned field(s): model, messages, tools",
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { isLLMError, LLM, Message, ToolCallPart } from "../../src"
|
||||
import { LLM, LLMError, Message, ToolCallPart } from "../../src"
|
||||
import { LLMClient } from "../../src/route"
|
||||
import * as Anthropic from "../../src/providers/anthropic"
|
||||
import { weatherToolName } from "../recorded-scenarios"
|
||||
@@ -22,9 +22,6 @@ const malformedToolOrderRequest = LLM.request({
|
||||
Message.user("Use that result to answer briefly."),
|
||||
],
|
||||
tools: [{ name: weatherToolName, description: "Get weather", inputSchema: { type: "object", properties: {} } }],
|
||||
// The cassette predates the `cache: "auto"` default; pin the policy off so
|
||||
// the replayed request matches the recorded wire shape.
|
||||
cache: "none",
|
||||
})
|
||||
|
||||
const recorded = recordedTests({
|
||||
@@ -36,17 +33,13 @@ const recorded = recordedTests({
|
||||
})
|
||||
|
||||
describe("Anthropic Messages sad-path recorded", () => {
|
||||
recorded.effect.with(
|
||||
"rejects malformed assistant tool order",
|
||||
// The cassette predates a test rename; keep replaying the existing recording.
|
||||
{ id: "rejects-malformed-assistant-tool-order-without-patch", tags: ["tool", "sad-path"] },
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(malformedToolOrderRequest).pipe(Effect.flip)
|
||||
recorded.effect.with("rejects malformed assistant tool order", { tags: ["tool", "sad-path"] }, () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(malformedToolOrderRequest).pipe(Effect.flip)
|
||||
|
||||
expect(isLLMError(error)).toBe(true)
|
||||
expect(error).toMatchObject({ _tag: "LLM.BadRequest" })
|
||||
expect(error.message).toContain("HTTP 400")
|
||||
}),
|
||||
expect(error).toBeInstanceOf(LLMError)
|
||||
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
|
||||
expect(error.message).toContain("HTTP 400")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { HttpClientRequest } from "effect/unstable/http"
|
||||
import { CacheHint, isLLMError, LLM, Message, ToolCallPart, Usage } from "../../src"
|
||||
import { CacheHint, LLM, LLMError, Message, ToolCallPart, Usage } from "../../src"
|
||||
import { Auth, LLMClient } from "../../src/route"
|
||||
import * as AnthropicMessages from "../../src/protocols/anthropic-messages"
|
||||
import { continuationRequest, nativeAnthropicMessagesContinuation } from "../continuation-scenarios"
|
||||
@@ -484,25 +484,23 @@ describe("Anthropic Messages route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fails the stream for mid-stream provider errors", () =>
|
||||
it.effect("emits provider-error events for mid-stream provider errors", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(sseEvents({ type: "error", error: { type: "overloaded_error", message: "Overloaded" } })),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
// Prefix the error type so consumers can distinguish overloads, rate
|
||||
// limits, and quota errors without parsing the message string.
|
||||
expect(isLLMError(error)).toBe(true)
|
||||
expect(error).toMatchObject({ _tag: "LLM.ServerError", message: "overloaded_error: Overloaded" })
|
||||
expect(response.events).toEqual([{ type: "provider-error", message: "overloaded_error: Overloaded" }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("classifies prompt-too-long provider errors", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents({
|
||||
@@ -511,35 +509,35 @@ describe("Anthropic Messages route", () => {
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error).toMatchObject({
|
||||
_tag: "LLM.ContextOverflow",
|
||||
message: "invalid_request_error: prompt is too long: 210000 tokens",
|
||||
})
|
||||
expect(response.events).toEqual([
|
||||
{
|
||||
type: "provider-error",
|
||||
message: "invalid_request_error: prompt is too long: 210000 tokens",
|
||||
classification: "context-overflow",
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("falls back to error type when no message is present", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents({ type: "error", error: { type: "overloaded_error", message: "" } }))),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error).toMatchObject({ _tag: "LLM.ServerError", message: "overloaded_error" })
|
||||
expect(response.events).toEqual([{ type: "provider-error", message: "overloaded_error" }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("falls back to a stable default when error payload is absent", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents({ type: "error" }))),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error).toMatchObject({ _tag: "LLM.APIError", message: "Anthropic Messages stream error" })
|
||||
expect(response.events).toEqual([{ type: "provider-error", message: "Anthropic Messages stream error" }])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -555,8 +553,8 @@ describe("Anthropic Messages route", () => {
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(isLLMError(error)).toBe(true)
|
||||
expect(error).toMatchObject({ _tag: "LLM.BadRequest" })
|
||||
expect(error).toBeInstanceOf(LLMError)
|
||||
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
|
||||
expect(error.message).toContain("HTTP 400")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@ import { EventStreamCodec } from "@smithy/eventstream-codec"
|
||||
import { fromUtf8, toUtf8 } from "@smithy/util-utf8"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { CacheHint, isLLMError, LLM, Message, ToolCallPart, ToolChoice } from "../../src"
|
||||
import { CacheHint, LLM, Message, ToolCallPart, ToolChoice } from "../../src"
|
||||
import { LLMClient } from "../../src/route"
|
||||
import { AmazonBedrock } from "../../src/providers"
|
||||
import * as BedrockConverse from "../../src/protocols/bedrock-converse"
|
||||
@@ -355,31 +355,33 @@ describe("Bedrock Converse route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fails the stream for throttlingException", () =>
|
||||
it.effect("emits provider-error for throttlingException", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = eventStreamBody(
|
||||
["messageStart", { role: "assistant" }],
|
||||
["throttlingException", { message: "Slow down" }],
|
||||
)
|
||||
const error = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)), Effect.flip)
|
||||
const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
|
||||
|
||||
expect(isLLMError(error)).toBe(true)
|
||||
expect(error).toMatchObject({ _tag: "LLM.RateLimit", message: "Slow down" })
|
||||
expect(response.events.find((event) => event.type === "provider-error")).toEqual({
|
||||
type: "provider-error",
|
||||
message: "Slow down",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("classifies input-too-long validation exceptions", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(baseRequest).pipe(
|
||||
const response = yield* LLMClient.generate(baseRequest).pipe(
|
||||
Effect.provide(
|
||||
fixedBytes(eventStreamBody(["validationException", { message: "Input is too long for requested model" }])),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error).toMatchObject({
|
||||
_tag: "LLM.ContextOverflow",
|
||||
expect(response.events.find((event) => event.type === "provider-error")).toEqual({
|
||||
type: "provider-error",
|
||||
message: "Input is too long for requested model",
|
||||
classification: "context-overflow",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { isLLMError, LLM, Message, ToolCallPart, Usage } from "../../src"
|
||||
import { LLM, LLMError, Message, ToolCallPart, Usage } from "../../src"
|
||||
import { Auth, LLMClient } from "../../src/route"
|
||||
import * as Gemini from "../../src/protocols/gemini"
|
||||
import { ProviderShared } from "../../src/protocols/shared"
|
||||
@@ -560,8 +560,8 @@ describe("Gemini route", () => {
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(isLLMError(error)).toBe(true)
|
||||
expect(error).toMatchObject({ _tag: "LLM.MalformedResponse" })
|
||||
expect(error).toBeInstanceOf(LLMError)
|
||||
expect(error.reason).toMatchObject({ _tag: "InvalidProviderOutput" })
|
||||
expect(error.message).toContain("Invalid google/gemini stream event")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { HttpClientRequest } from "effect/unstable/http"
|
||||
import { isLLMError, LLM, LLMEvent, Message, Model, ToolCallPart, Usage } from "../../src"
|
||||
import { LLM, LLMError, LLMEvent, Message, Model, ToolCallPart, Usage } from "../../src"
|
||||
import * as Azure from "../../src/providers/azure"
|
||||
import * as OpenAI from "../../src/providers/openai"
|
||||
import * as OpenAIChat from "../../src/protocols/openai-chat"
|
||||
@@ -662,8 +662,8 @@ describe("OpenAI Chat route", () => {
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(isLLMError(error)).toBe(true)
|
||||
expect(error).toMatchObject({ _tag: "LLM.BadRequest" })
|
||||
expect(error).toBeInstanceOf(LLMError)
|
||||
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
|
||||
expect(error.message).toContain("HTTP 400")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { ConfigProvider, Effect, Layer, Stream } from "effect"
|
||||
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||
import { isLLMError, LLM, Message, Model, ToolCallPart, Usage } from "../../src"
|
||||
import { LLM, LLMError, Message, Model, ToolCallPart, Usage } from "../../src"
|
||||
import { Auth, LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route"
|
||||
import * as Azure from "../../src/providers/azure"
|
||||
import * as OpenAI from "../../src/providers/openai"
|
||||
@@ -1368,41 +1368,37 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fails the stream for mid-stream provider errors", () =>
|
||||
it.effect("emits provider-error events for mid-stream provider errors", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents({ type: "error", code: "rate_limit_exceeded", message: "Slow down" }))),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
// Prefix the code so consumers see the failure mode, not just the
|
||||
// sometimes-generic provider message. The bare message alone meant
|
||||
// production errors like rate limits were indistinguishable from
|
||||
// unrelated stream failures.
|
||||
expect(isLLMError(error)).toBe(true)
|
||||
expect(error).toMatchObject({ _tag: "LLM.RateLimit", message: "rate_limit_exceeded: Slow down" })
|
||||
expect(response.events).toEqual([{ type: "provider-error", message: "rate_limit_exceeded: Slow down" }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("falls back to error code when no message is present", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents({ type: "error", code: "internal_error" }))),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error).toMatchObject({ _tag: "LLM.ServerError", message: "internal_error" })
|
||||
expect(response.events).toEqual([{ type: "provider-error", message: "internal_error" }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("falls back to error code when message is empty", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents({ type: "error", code: "internal_error", message: "" }))),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error).toMatchObject({ _tag: "LLM.ServerError", message: "internal_error" })
|
||||
expect(response.events).toEqual([{ type: "provider-error", message: "internal_error" }])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1412,7 +1408,7 @@ describe("OpenAI Responses route", () => {
|
||||
// "OpenAI Responses response failed" string, hiding the real cause.
|
||||
it.effect("surfaces response.failed details from response.error", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents({
|
||||
@@ -1424,16 +1420,15 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error).toMatchObject({ _tag: "LLM.ServerError", message: "server_error: Upstream model unavailable" })
|
||||
expect(response.events).toEqual([{ type: "provider-error", message: "server_error: Upstream model unavailable" }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("surfaces response.failed code when no nested message is present", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents({
|
||||
@@ -1442,10 +1437,9 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error).toMatchObject({ _tag: "LLM.BadRequest", message: "invalid_prompt" })
|
||||
expect(response.events).toEqual([{ type: "provider-error", message: "invalid_prompt" }])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1456,7 +1450,7 @@ describe("OpenAI Responses route", () => {
|
||||
// when they bubble up an HTTP error as an SSE `error` event. Honour
|
||||
// both shapes so the user still sees the underlying cause instead
|
||||
// of the catch-all string.
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents({
|
||||
@@ -1465,19 +1459,21 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error).toMatchObject({
|
||||
_tag: "LLM.ContextOverflow",
|
||||
message: "context_length_exceeded: prompt too long",
|
||||
})
|
||||
expect(response.events).toEqual([
|
||||
{
|
||||
type: "provider-error",
|
||||
message: "context_length_exceeded: prompt too long",
|
||||
classification: "context-overflow",
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("surfaces error event details nested under error", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents({
|
||||
@@ -1492,19 +1488,21 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error).toMatchObject({
|
||||
_tag: "LLM.ContextOverflow",
|
||||
message: "context_length_exceeded: prompt too long",
|
||||
})
|
||||
expect(response.events).toEqual([
|
||||
{
|
||||
type: "provider-error",
|
||||
message: "context_length_exceeded: prompt too long",
|
||||
classification: "context-overflow",
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("accepts nullable fields in spec-compliant error events", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents({
|
||||
@@ -1516,43 +1514,39 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error).toMatchObject({ _tag: "LLM.APIError", message: "Something went wrong" })
|
||||
expect(response.events).toEqual([{ type: "provider-error", message: "Something went wrong" }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("falls back to a stable default when error is null", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents({ type: "error", error: null }))),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error).toMatchObject({ _tag: "LLM.APIError", message: "OpenAI Responses stream error" })
|
||||
expect(response.events).toEqual([{ type: "provider-error", message: "OpenAI Responses stream error" }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("falls back to a stable default when both error and response are absent", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents({ type: "error" }))),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error).toMatchObject({ _tag: "LLM.APIError", message: "OpenAI Responses stream error" })
|
||||
expect(response.events).toEqual([{ type: "provider-error", message: "OpenAI Responses stream error" }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("falls back to a stable default when response.failed has no error payload", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents({ type: "response.failed", response: { id: "resp_failed_3" } }))),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error).toMatchObject({ _tag: "LLM.APIError", message: "OpenAI Responses response failed" })
|
||||
expect(response.events).toEqual([{ type: "provider-error", message: "OpenAI Responses response failed" }])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1568,8 +1562,8 @@ describe("OpenAI Responses route", () => {
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(isLLMError(error)).toBe(true)
|
||||
expect(error).toMatchObject({ _tag: "LLM.BadRequest" })
|
||||
expect(error).toBeInstanceOf(LLMError)
|
||||
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
|
||||
expect(error.message).toContain("HTTP 400")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { isLLMError } from "../src/schema"
|
||||
import { LLMError } from "../src/schema"
|
||||
import { ToolStream } from "../src/protocols/utils/tool-stream"
|
||||
import { it } from "./lib/effect"
|
||||
|
||||
@@ -40,9 +40,8 @@ describe("ToolStream", () => {
|
||||
Effect.gen(function* () {
|
||||
const error = ToolStream.appendExisting(ADAPTER, ToolStream.empty<number>(), 0, "{}", "missing tool")
|
||||
|
||||
expect(isLLMError(error)).toBe(true)
|
||||
if (ToolStream.isError(error))
|
||||
expect(error).toMatchObject({ _tag: "LLM.MalformedResponse", message: "missing tool" })
|
||||
expect(error).toBeInstanceOf(LLMError)
|
||||
if (ToolStream.isError(error)) expect(error.reason.message).toBe("missing tool")
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -418,6 +418,9 @@ const layer = Layer.effect(
|
||||
return
|
||||
}
|
||||
|
||||
case "provider-error":
|
||||
throw new Error(value.message)
|
||||
|
||||
case "step-start":
|
||||
if (!ctx.snapshot) ctx.snapshot = yield* snapshot.track()
|
||||
yield* session.updatePart({
|
||||
|
||||
@@ -219,7 +219,8 @@ const fragmentFailureLLM = Layer.succeed(
|
||||
LLMEvent.reasoningDelta({ id: "reasoning-1", text: "thinking" }),
|
||||
LLMEvent.textStart({ id: "text-1" }),
|
||||
LLMEvent.textDelta({ id: "text-1", text: "partial" }),
|
||||
).pipe(Stream.concat(Stream.fail(new Error("provider boom")))),
|
||||
LLMEvent.providerError({ message: "provider boom" }),
|
||||
),
|
||||
}),
|
||||
)
|
||||
const fragmentFailureEnv = LayerNode.compile(root, [...replacements, [LLM.node, fragmentFailureLLM]])
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
"./context/log": "./src/context/log.tsx",
|
||||
"./context/project": "./src/context/project.tsx",
|
||||
"./context/runtime": "./src/context/runtime.tsx",
|
||||
"./context/sdk": "./src/context/sdk.tsx",
|
||||
"./context/client": "./src/context/client.tsx",
|
||||
"./context/theme": "./src/context/theme.tsx",
|
||||
"./context/editor": "./src/context/editor.ts",
|
||||
"./context/clipboard": "./src/context/clipboard.tsx",
|
||||
@@ -55,7 +55,6 @@
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/simulation": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
|
||||
+12
-56
@@ -41,7 +41,7 @@ import { PluginRouteMissing } from "./component/plugin-route-missing"
|
||||
import { ProjectProvider, useProject } from "./context/project"
|
||||
import { EditorContextProvider } from "./context/editor"
|
||||
import { useEvent } from "./context/event"
|
||||
import { SDKProvider, useSDK } from "./context/sdk"
|
||||
import { ClientProvider, useClient } from "./context/client"
|
||||
import { StartupLoading } from "./component/startup-loading"
|
||||
import { Reconnecting } from "./component/reconnecting"
|
||||
import { DataProvider, useData } from "./context/data"
|
||||
@@ -55,7 +55,6 @@ import { DialogStatus } from "./component/dialog-status"
|
||||
import { DialogConfig } from "./component/dialog-config"
|
||||
import { DialogDebug } from "./component/dialog-debug"
|
||||
import { DialogPair, type DialogPairCredentials } from "./component/dialog-pair"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||
import { DialogThemeList } from "./component/dialog-theme-list"
|
||||
import { DialogHelp } from "./ui/dialog-help"
|
||||
import { DialogAgent } from "./component/dialog-agent"
|
||||
@@ -66,8 +65,6 @@ import { Session } from "./routes/session"
|
||||
import { PromptHistoryProvider } from "./component/prompt/history"
|
||||
import { FrecencyProvider } from "./component/prompt/frecency"
|
||||
import { PromptStashProvider } from "./component/prompt/stash"
|
||||
import { DialogAlert } from "./ui/dialog-alert"
|
||||
import { DialogConfirm } from "./ui/dialog-confirm"
|
||||
import { ToastProvider, useToast } from "./ui/toast"
|
||||
import { isDefaultTitle } from "./util/session"
|
||||
import * as Model from "./util/model"
|
||||
@@ -198,7 +195,6 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
const endpoint = await reconnectEndpoint(attempt)
|
||||
const next = { baseUrl: endpoint.url, headers: Service.headers(endpoint) }
|
||||
return {
|
||||
client: createOpencodeClient({ ...next, directory }),
|
||||
api: OpenCode.make(next),
|
||||
}
|
||||
}
|
||||
@@ -336,8 +332,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
}
|
||||
>
|
||||
<PluginRuntimeProvider value={pluginRuntime}>
|
||||
<SDKProvider
|
||||
client={createOpencodeClient({ ...options, directory })}
|
||||
<ClientProvider
|
||||
api={api}
|
||||
reconnect={reconnect}
|
||||
reload={input.server.reload}
|
||||
@@ -377,7 +372,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
</DataProvider>
|
||||
</ProjectProvider>
|
||||
</PermissionProvider>
|
||||
</SDKProvider>
|
||||
</ClientProvider>
|
||||
</PluginRuntimeProvider>
|
||||
</RouteProvider>
|
||||
</ToastProvider>
|
||||
@@ -426,7 +421,7 @@ function App(props: {
|
||||
const local = useLocal()
|
||||
const keymap = useOpencodeKeymap()
|
||||
const event = useEvent()
|
||||
const sdk = useSDK()
|
||||
const client = useClient()
|
||||
const toast = useToast()
|
||||
const themeState = useTheme()
|
||||
const { theme, mode, setMode, locked, lock, unlock } = themeState
|
||||
@@ -475,7 +470,7 @@ function App(props: {
|
||||
route,
|
||||
routes: pluginRuntime.routes,
|
||||
event,
|
||||
sdk,
|
||||
client,
|
||||
project,
|
||||
data,
|
||||
theme: themeState,
|
||||
@@ -585,7 +580,7 @@ function App(props: {
|
||||
if (continued || !args.continue) return
|
||||
continued = true
|
||||
const location = data.location.default()
|
||||
void sdk.api.session
|
||||
void client.api.session
|
||||
.list({
|
||||
limit: 1,
|
||||
order: "desc",
|
||||
@@ -600,7 +595,7 @@ function App(props: {
|
||||
route.navigate({ type: "session", sessionID: match })
|
||||
return
|
||||
}
|
||||
void sdk.api.session
|
||||
void client.api.session
|
||||
.fork({ sessionID: match })
|
||||
.then((result) => route.navigate({ type: "session", sessionID: result.id }))
|
||||
.catch(toast.error)
|
||||
@@ -613,7 +608,7 @@ function App(props: {
|
||||
createEffect(() => {
|
||||
if (forked || !args.sessionID || !args.fork) return
|
||||
forked = true
|
||||
void sdk.api.session
|
||||
void client.api.session
|
||||
.fork({ sessionID: args.sessionID })
|
||||
.then((result) => route.navigate({ type: "session", sessionID: result.id }))
|
||||
.catch(toast.error)
|
||||
@@ -815,7 +810,7 @@ function App(props: {
|
||||
},
|
||||
category: "System",
|
||||
},
|
||||
...(sdk.reload
|
||||
...(client.reload
|
||||
? [
|
||||
{
|
||||
name: "server.reload",
|
||||
@@ -826,7 +821,7 @@ function App(props: {
|
||||
toast.show({ variant: "info", message: "Reloading server...", duration: 30000 })
|
||||
// reload resolves once the replacement service is healthy; the
|
||||
// event stream reattaches through the reconnect loop.
|
||||
await sdk.reload!()
|
||||
await client.reload!()
|
||||
.then(() => toast.show({ variant: "success", message: "Server reloaded" }))
|
||||
.catch(toast.error)
|
||||
},
|
||||
@@ -1092,45 +1087,6 @@ function App(props: {
|
||||
})
|
||||
})
|
||||
|
||||
event.on("installation.update-available", async (evt) => {
|
||||
const version = evt.data.version
|
||||
|
||||
const choice = await DialogConfirm.show(
|
||||
dialog,
|
||||
`Update Available`,
|
||||
`A new release v${version} is available. Would you like to update now?`,
|
||||
"later",
|
||||
)
|
||||
|
||||
if (choice !== true) return
|
||||
|
||||
toast.show({
|
||||
variant: "info",
|
||||
message: `Updating to v${version}...`,
|
||||
duration: 30000,
|
||||
})
|
||||
|
||||
const result = await sdk.client.global.upgrade({ target: version })
|
||||
|
||||
if (result.error || !result.data?.success) {
|
||||
toast.show({
|
||||
variant: "error",
|
||||
title: "Update Failed",
|
||||
message: "Update failed",
|
||||
duration: 10000,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await DialogAlert.show(
|
||||
dialog,
|
||||
"Update Complete",
|
||||
`Successfully updated to OpenCode v${result.data.version}. Please restart the application.`,
|
||||
)
|
||||
|
||||
void exit()
|
||||
})
|
||||
|
||||
const plugin = createMemo(() => {
|
||||
if (!ready()) return
|
||||
if (route.data.type !== "plugin") return
|
||||
@@ -1148,7 +1104,7 @@ function App(props: {
|
||||
clearTimeout(reconnectTimer)
|
||||
reconnectTimer = undefined
|
||||
}
|
||||
const status = sdk.connection.status()
|
||||
const status = client.connection.status()
|
||||
if (status === "connected") {
|
||||
setShowReconnecting(false)
|
||||
return
|
||||
@@ -1211,7 +1167,7 @@ function App(props: {
|
||||
<StartupLoading ready={ready} />
|
||||
</Show>
|
||||
<Show when={showReconnecting()}>
|
||||
<Reconnecting attempt={sdk.connection.attempt()} error={sdk.connection.error()} />
|
||||
<Reconnecting attempt={client.connection.attempt()} error={client.connection.error()} />
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
|
||||
@@ -7,7 +7,8 @@ import {
|
||||
} from "@opentui/core"
|
||||
import { extend, useRenderer } from "@opentui/solid"
|
||||
import { onCleanup, onMount } from "solid-js"
|
||||
import { tint, useTheme } from "../context/theme"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { tint } from "../theme/color"
|
||||
import { GoUpsellArtPainter } from "./bg-pulse-render"
|
||||
|
||||
type GoUpsellArtOptions = RenderableOptions<FrameBufferRenderable> & {
|
||||
|
||||
@@ -8,7 +8,7 @@ import type {
|
||||
import { createMemo, createSignal, onCleanup, onMount, Show } from "solid-js"
|
||||
import { useClipboard } from "../context/clipboard"
|
||||
import { useData } from "../context/data"
|
||||
import { useSDK } from "../context/sdk"
|
||||
import { useClient } from "../context/client"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useBindings } from "../keymap"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
@@ -102,7 +102,7 @@ function manageConnections(
|
||||
) {
|
||||
dialog.replace(() => {
|
||||
const data = useData()
|
||||
const sdk = useSDK()
|
||||
const client = useClient()
|
||||
const toast = useToast()
|
||||
return (
|
||||
<DialogSelect
|
||||
@@ -121,7 +121,7 @@ function manageConnections(
|
||||
title: `Disconnect ${connection.label}`,
|
||||
value: connection.id,
|
||||
onSelect: () => {
|
||||
void sdk.api.credential
|
||||
void client.api.credential
|
||||
.remove({ credentialID: connection.id, location: location(data) })
|
||||
.then(() => disconnected(integration.name, data, dialog, toast))
|
||||
.catch(toast.error)
|
||||
@@ -172,7 +172,7 @@ function KeyMethod(props: {
|
||||
}) {
|
||||
const data = useData()
|
||||
const dialog = useDialog()
|
||||
const sdk = useSDK()
|
||||
const client = useClient()
|
||||
const toast = useToast()
|
||||
const { theme } = useTheme()
|
||||
const [error, setError] = createSignal<string>()
|
||||
@@ -183,7 +183,7 @@ function KeyMethod(props: {
|
||||
placeholder="API key"
|
||||
onConfirm={(key) => {
|
||||
if (!key) return
|
||||
void sdk.api.integration
|
||||
void client.api.integration
|
||||
.connect.key({
|
||||
integrationID: props.integration.id,
|
||||
location: location(data),
|
||||
@@ -218,11 +218,11 @@ function OAuthStarting(props: {
|
||||
}) {
|
||||
const data = useData()
|
||||
const dialog = useDialog()
|
||||
const sdk = useSDK()
|
||||
const client = useClient()
|
||||
const toast = useToast()
|
||||
|
||||
onMount(() => {
|
||||
void sdk.api.integration
|
||||
void client.api.integration
|
||||
.connect.oauth({
|
||||
integrationID: props.integration.id,
|
||||
location: location(data),
|
||||
@@ -267,7 +267,7 @@ function OAuthAuto(props: {
|
||||
}) {
|
||||
const data = useData()
|
||||
const dialog = useDialog()
|
||||
const sdk = useSDK()
|
||||
const client = useClient()
|
||||
const toast = useToast()
|
||||
const clipboard = useClipboard()
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
@@ -291,7 +291,7 @@ function OAuthAuto(props: {
|
||||
}))
|
||||
|
||||
const poll = () => {
|
||||
void sdk.api.integration
|
||||
void client.api.integration
|
||||
.attempt.status({ attemptID: props.attempt.attemptID, location: location(data) })
|
||||
.then((result) => {
|
||||
const status = result.data
|
||||
@@ -318,7 +318,7 @@ function OAuthAuto(props: {
|
||||
onCleanup(() => {
|
||||
if (timer) clearTimeout(timer)
|
||||
if (settled) return
|
||||
void sdk.api.integration.attempt.cancel({ attemptID: props.attempt.attemptID, location: location(data) })
|
||||
void client.api.integration.attempt.cancel({ attemptID: props.attempt.attemptID, location: location(data) })
|
||||
})
|
||||
|
||||
return (
|
||||
@@ -340,7 +340,7 @@ function OAuthCode(props: {
|
||||
}) {
|
||||
const data = useData()
|
||||
const dialog = useDialog()
|
||||
const sdk = useSDK()
|
||||
const client = useClient()
|
||||
const toast = useToast()
|
||||
const { theme } = useTheme()
|
||||
const [error, setError] = createSignal<string>()
|
||||
@@ -348,7 +348,7 @@ function OAuthCode(props: {
|
||||
|
||||
onCleanup(() => {
|
||||
if (settled) return
|
||||
void sdk.api.integration.attempt.cancel({ attemptID: props.attempt.attemptID, location: location(data) })
|
||||
void client.api.integration.attempt.cancel({ attemptID: props.attempt.attemptID, location: location(data) })
|
||||
})
|
||||
|
||||
return (
|
||||
@@ -357,7 +357,7 @@ function OAuthCode(props: {
|
||||
placeholder="Authorization code"
|
||||
onConfirm={(code) => {
|
||||
if (!code) return
|
||||
void sdk.api.integration
|
||||
void client.api.integration
|
||||
.attempt.complete({ attemptID: props.attempt.attemptID, location: location(data), code })
|
||||
.then(() => {
|
||||
settled = true
|
||||
|
||||
@@ -4,7 +4,7 @@ import { createMemo, createResource, createSignal, onMount, Show } from "solid-j
|
||||
import path from "path"
|
||||
import { DialogSelect, type DialogSelectOption } from "../ui/dialog-select"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { useSDK } from "../context/sdk"
|
||||
import { useClient } from "../context/client"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useData } from "../context/data"
|
||||
import { abbreviateHome } from "../runtime"
|
||||
@@ -35,7 +35,7 @@ type DialogMoveSessionProps = {
|
||||
|
||||
export function DialogMoveSession(props: DialogMoveSessionProps) {
|
||||
const dialog = useDialog()
|
||||
const sdk = useSDK()
|
||||
const client = useClient()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const { theme } = useTheme()
|
||||
const sessionData = useData()
|
||||
@@ -63,7 +63,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
||||
const [loadedProject] = createResource(
|
||||
() => (projectContext.project() === undefined ? props.projectID : undefined),
|
||||
(projectID) =>
|
||||
sdk.api.project
|
||||
client.api.project
|
||||
.current({ location: { directory: projectContext.instance.directory() || paths.cwd } })
|
||||
.then((project) => (project.id === projectID ? project.directory : undefined))
|
||||
.catch(() => undefined),
|
||||
@@ -78,11 +78,11 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
||||
async (projectID, info): Promise<ReadonlyArray<ProjectDirectory> | undefined> => {
|
||||
try {
|
||||
const location = { directory: projectContext.instance.directory() || paths.cwd }
|
||||
await sdk.api.projectCopy.refresh({
|
||||
await client.api.projectCopy.refresh({
|
||||
projectID,
|
||||
location,
|
||||
})
|
||||
const directories = await sdk.api.project.directories({
|
||||
const directories = await client.api.project.directories({
|
||||
projectID,
|
||||
location,
|
||||
})
|
||||
@@ -232,7 +232,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
||||
setToDelete(undefined)
|
||||
setRemoving(selected.directory)
|
||||
setWorking(true)
|
||||
const error = await sdk.api.projectCopy
|
||||
const error = await client.api.projectCopy
|
||||
.remove({
|
||||
projectID: props.projectID,
|
||||
location: { directory: projectContext.instance.directory() || paths.cwd },
|
||||
@@ -247,7 +247,9 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
||||
setRemoving(undefined)
|
||||
setWorking(false)
|
||||
if (isRecord(error) && isRecord(error.data) && error.data.forceRequired === true) {
|
||||
const status = await sdk.client.vcs.status({ directory: selected.directory }).catch(() => undefined)
|
||||
const status = await client.api.vcs
|
||||
.status({ location: { directory: selected.directory } })
|
||||
.catch(() => undefined)
|
||||
const choice = await DialogWorkspaceFileChanges.show(dialog, status?.data ?? [], {
|
||||
title: "Delete working copy?",
|
||||
message: "This working copy has file changes. Do you want to delete it anyway?",
|
||||
@@ -257,7 +259,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
||||
return
|
||||
}
|
||||
reopen(selected.directory)
|
||||
const forcedError = await sdk.api.projectCopy
|
||||
const forcedError = await client.api.projectCopy
|
||||
.remove({
|
||||
projectID: props.projectID,
|
||||
location: { directory: projectContext.instance.directory() || paths.cwd },
|
||||
|
||||
@@ -2,7 +2,7 @@ import { TextAttributes } from "@opentui/core"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { createMemo, createResource, createSignal, For, Show } from "solid-js"
|
||||
import { renderUnicodeCompact } from "uqr"
|
||||
import { useSDK } from "../context/sdk"
|
||||
import { useClient } from "../context/client"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { errorMessage } from "../util/error"
|
||||
@@ -13,7 +13,7 @@ export type DialogPairCredentials = {
|
||||
}
|
||||
|
||||
export function DialogPair(props: { credentials?: DialogPairCredentials }) {
|
||||
const sdk = useSDK()
|
||||
const client = useClient()
|
||||
const dialog = useDialog()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const { theme } = useTheme()
|
||||
@@ -25,7 +25,7 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
|
||||
dialog.setCentered(true)
|
||||
|
||||
const [server] = createResource(() =>
|
||||
sdk.api.server
|
||||
client.api.server
|
||||
.get()
|
||||
.catch((error) => {
|
||||
setLoadError(error)
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useData } from "../context/data"
|
||||
import { Locale } from "../util/locale"
|
||||
import { useProject } from "../context/project"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useSDK } from "../context/sdk"
|
||||
import { useClient } from "../context/client"
|
||||
import { useLocal } from "../context/local"
|
||||
import { createDebouncedSignal } from "../util/signal"
|
||||
import { useToast } from "../ui/toast"
|
||||
@@ -23,7 +23,7 @@ export function DialogSessionList() {
|
||||
const data = useData()
|
||||
const project = useProject()
|
||||
const { theme } = useTheme()
|
||||
const sdk = useSDK()
|
||||
const client = useClient()
|
||||
const local = useLocal()
|
||||
const toast = useToast()
|
||||
const [search, setSearch] = createDebouncedSignal("", 150)
|
||||
@@ -36,7 +36,7 @@ export function DialogSessionList() {
|
||||
if (!query) return
|
||||
const location = data.location.default()
|
||||
try {
|
||||
const response = await sdk.api.session.list({
|
||||
const response = await client.api.session.list({
|
||||
search: query,
|
||||
limit: 50,
|
||||
order: "desc",
|
||||
@@ -140,7 +140,7 @@ export function DialogSessionList() {
|
||||
setToDelete(option.value)
|
||||
return
|
||||
}
|
||||
void sdk.api.session.remove({ sessionID: option.value }).catch((error) => {
|
||||
void client.api.session.remove({ sessionID: option.value }).catch((error) => {
|
||||
setToDelete(undefined)
|
||||
toast.show({
|
||||
message: `Failed to delete session: ${errorMessage(error)}`,
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { DialogPrompt } from "../ui/dialog-prompt"
|
||||
import { type DialogContext, useDialog } from "../ui/dialog"
|
||||
import { useSDK } from "../context/sdk"
|
||||
import { useClient } from "../context/client"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { errorMessage } from "../util/error"
|
||||
|
||||
export function DialogSessionRename(props: { sessionID: string; currentTitle?: string }) {
|
||||
const dialog = useDialog()
|
||||
const sdk = useSDK()
|
||||
const client = useClient()
|
||||
const toast = useToast()
|
||||
|
||||
return (
|
||||
@@ -17,7 +17,7 @@ export function DialogSessionRename(props: { sessionID: string; currentTitle?: s
|
||||
onConfirm={(value) => {
|
||||
const title = value.trim()
|
||||
if (!title) return
|
||||
void sdk.api.session
|
||||
void client.api.session
|
||||
.rename({ sessionID: props.sessionID, title })
|
||||
.then(() => dialog.clear())
|
||||
.catch((error) =>
|
||||
|
||||
@@ -2,11 +2,11 @@ import { createMemo, createResource } from "solid-js"
|
||||
import { DialogSelect } from "../ui/dialog-select"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { useProject } from "../context/project"
|
||||
import { useSDK } from "../context/sdk"
|
||||
import { useClient } from "../context/client"
|
||||
import { createStore } from "solid-js/store"
|
||||
|
||||
export function DialogTag(props: { onSelect?: (value: string) => void }) {
|
||||
const sdk = useSDK()
|
||||
const client = useClient()
|
||||
const dialog = useDialog()
|
||||
const project = useProject()
|
||||
|
||||
@@ -17,7 +17,7 @@ export function DialogTag(props: { onSelect?: (value: string) => void }) {
|
||||
const [files] = createResource(
|
||||
() => [store.filter],
|
||||
async () => {
|
||||
const result = await sdk.api.file
|
||||
const result = await client.api.file
|
||||
.find({
|
||||
query: store.filter,
|
||||
type: "file",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { RGBA, TextAttributes } from "@opentui/core"
|
||||
import { For, type JSX } from "solid-js"
|
||||
import { tint, useTheme } from "../context/theme"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { tint } from "../theme/color"
|
||||
import { logo } from "../logo"
|
||||
|
||||
export function Logo() {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { createMemo, createResource, createEffect, onMount, onCleanup, Index, Sh
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useEditorContext } from "../../context/editor"
|
||||
import { useProject } from "../../context/project"
|
||||
import { useSDK } from "../../context/sdk"
|
||||
import { useClient } from "../../context/client"
|
||||
import { useData } from "../../context/data"
|
||||
import { getScrollAcceleration } from "../../util/scroll"
|
||||
import { useTuiPaths } from "../../context/runtime"
|
||||
@@ -84,7 +84,7 @@ export function Autocomplete(props: {
|
||||
promptPartTypeId: () => number
|
||||
}) {
|
||||
const editor = useEditorContext()
|
||||
const sdk = useSDK()
|
||||
const client = useClient()
|
||||
const data = useData()
|
||||
const project = useProject()
|
||||
const slashes = useCommandSlashes()
|
||||
@@ -315,7 +315,7 @@ export function Autocomplete(props: {
|
||||
if (referenceMatch()) return []
|
||||
const { lineRange, baseQuery } = extractLineRange(input.query ?? "")
|
||||
|
||||
const result = await sdk.api.file
|
||||
const result = await client.api.file
|
||||
.find({
|
||||
query: baseQuery,
|
||||
limit: 20,
|
||||
|
||||
@@ -15,12 +15,13 @@ import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { useLocal } from "../../context/local"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { tint, useTheme } from "../../context/theme"
|
||||
import { useTheme } from "../../context/theme"
|
||||
import { tint } from "../../theme/color"
|
||||
import { EmptyBorder, SplitBorder } from "../../ui/border"
|
||||
import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime"
|
||||
import { useClipboard } from "../../context/clipboard"
|
||||
import { Spinner } from "../spinner"
|
||||
import { useSDK } from "../../context/sdk"
|
||||
import { useClient } from "../../context/client"
|
||||
import { useRoute } from "../../context/route"
|
||||
import { useProject } from "../../context/project"
|
||||
import { useEvent } from "../../context/event"
|
||||
@@ -147,7 +148,7 @@ export function Prompt(props: PromptProps) {
|
||||
const paths = useTuiPaths()
|
||||
const terminalEnvironment = useTuiTerminalEnvironment()
|
||||
const clipboard = useClipboard()
|
||||
const sdk = useSDK()
|
||||
const client = useClient()
|
||||
const editor = useEditorContext()
|
||||
const route = useRoute()
|
||||
const project = useProject()
|
||||
@@ -420,7 +421,7 @@ export function Prompt(props: PromptProps) {
|
||||
}, 5000)
|
||||
|
||||
if (store.interrupt >= 2) {
|
||||
void sdk.api.session.interrupt({
|
||||
void client.api.session.interrupt({
|
||||
sessionID: props.sessionID,
|
||||
})
|
||||
setStore("interrupt", 0)
|
||||
@@ -439,7 +440,7 @@ export function Prompt(props: PromptProps) {
|
||||
if (!input.focused) return
|
||||
if (!props.sessionID) return
|
||||
|
||||
void sdk.api.session.background({
|
||||
void client.api.session.background({
|
||||
sessionID: props.sessionID,
|
||||
})
|
||||
dialog.clear()
|
||||
@@ -964,7 +965,7 @@ export function Prompt(props: PromptProps) {
|
||||
finishMoveProgress = Boolean(move.progress())
|
||||
const location = data.location.default()
|
||||
|
||||
const created = await sdk.api.session
|
||||
const created = await client.api.session
|
||||
.create({
|
||||
location: directory ? { directory } : location,
|
||||
agent: agent.id,
|
||||
@@ -1008,7 +1009,7 @@ export function Prompt(props: PromptProps) {
|
||||
|
||||
if (store.mode === "shell") {
|
||||
move.startSubmit()
|
||||
void sdk.api.session.shell({
|
||||
void client.api.session.shell({
|
||||
sessionID,
|
||||
command: inputText,
|
||||
})
|
||||
@@ -1027,7 +1028,7 @@ export function Prompt(props: PromptProps) {
|
||||
const restOfInput = firstLineEnd === -1 ? "" : inputText.slice(firstLineEnd + 1)
|
||||
const args = firstLineArgs.join(" ") + (restOfInput ? "\n" + restOfInput : "")
|
||||
|
||||
void sdk.api.session
|
||||
void client.api.session
|
||||
.command({
|
||||
sessionID,
|
||||
command: command.slice(1),
|
||||
@@ -1047,7 +1048,7 @@ export function Prompt(props: PromptProps) {
|
||||
)
|
||||
) {
|
||||
move.startSubmit()
|
||||
void sdk.api.session.skill({
|
||||
void client.api.session.skill({
|
||||
sessionID,
|
||||
skill: inputText.split("\n")[0].split(" ")[0].slice(1),
|
||||
})
|
||||
@@ -1058,20 +1059,20 @@ export function Prompt(props: PromptProps) {
|
||||
session = data.session.get(sessionID)
|
||||
}
|
||||
if (session?.agent !== agent.id) {
|
||||
await sdk.api.session.switchAgent({ sessionID, agent: agent.id })
|
||||
await client.api.session.switchAgent({ sessionID, agent: agent.id })
|
||||
}
|
||||
if (
|
||||
session?.model?.providerID !== selectedModel.providerID ||
|
||||
session.model.id !== selectedModel.modelID ||
|
||||
session.model.variant !== variant
|
||||
) {
|
||||
await sdk.api.session.switchModel({
|
||||
await client.api.session.switchModel({
|
||||
sessionID,
|
||||
model: { providerID: selectedModel.providerID, id: selectedModel.modelID, variant },
|
||||
})
|
||||
}
|
||||
if (session?.revert) {
|
||||
const error = await sdk.api.session.revert.commit({ sessionID }).then(
|
||||
const error = await client.api.session.revert.commit({ sessionID }).then(
|
||||
() => undefined,
|
||||
(error) => error,
|
||||
)
|
||||
@@ -1082,7 +1083,7 @@ export function Prompt(props: PromptProps) {
|
||||
}
|
||||
if (pendingEditorSelection) {
|
||||
// Keep editor context hidden while admitting it before the corresponding user prompt.
|
||||
const error = await sdk.api.session
|
||||
const error = await client.api.session
|
||||
.synthetic({
|
||||
sessionID,
|
||||
text: formatEditorContext(pendingEditorSelection),
|
||||
@@ -1097,7 +1098,7 @@ export function Prompt(props: PromptProps) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
const error = await sdk.api.session
|
||||
const error = await client.api.session
|
||||
.prompt({
|
||||
sessionID,
|
||||
text: inputText,
|
||||
|
||||
@@ -3,7 +3,7 @@ import path from "path"
|
||||
import { useTuiPaths } from "../../context/runtime"
|
||||
import { errorMessage } from "../../util/error"
|
||||
import { useDialog } from "../../ui/dialog"
|
||||
import { useSDK } from "../../context/sdk"
|
||||
import { useClient } from "../../context/client"
|
||||
import { useToast } from "../../ui/toast"
|
||||
import { DialogMoveSession, type MoveSessionSelection } from "../dialog-move-session"
|
||||
import { DialogWorkspaceFileChanges } from "../dialog-workspace-file-changes"
|
||||
@@ -17,7 +17,7 @@ function moveReminderText(directory: string) {
|
||||
|
||||
export function usePromptMove(input: { projectID: () => string | undefined; sessionID: () => string | undefined }) {
|
||||
const dialog = useDialog()
|
||||
const sdk = useSDK()
|
||||
const client = useClient()
|
||||
const toast = useToast()
|
||||
const homeDestination = useHomeSessionDestination()
|
||||
const project = useProject()
|
||||
@@ -33,7 +33,7 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
|
||||
setCreating(true)
|
||||
setProgress("Creating copy")
|
||||
try {
|
||||
const result = await sdk.api.projectCopy.create({
|
||||
const result = await client.api.projectCopy.create({
|
||||
projectID,
|
||||
location: { directory: project.instance.directory() || paths.cwd },
|
||||
strategy: "git_worktree",
|
||||
@@ -44,7 +44,7 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
|
||||
if (!directory) throw new Error("No project copy directory returned")
|
||||
|
||||
// Call a location-based route to make sure it's bootstrapped before moving on.
|
||||
await sdk.api.location.get({ location: { directory } })
|
||||
await client.api.location.get({ location: { directory } })
|
||||
|
||||
setProgress("Creating session")
|
||||
return directory
|
||||
@@ -98,7 +98,9 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
|
||||
|
||||
async function moveExistingSession(sessionID: string, selection: MoveSessionSelection) {
|
||||
const session = await resolveSession(sessionID)
|
||||
const status = await sdk.client.vcs.status({ directory: session?.location.directory }).catch(() => undefined)
|
||||
const status = await client.api.vcs
|
||||
.status({ location: session?.location.directory ? { directory: session.location.directory } : undefined })
|
||||
.catch(() => undefined)
|
||||
const choice = status?.data?.length ? await DialogWorkspaceFileChanges.show(dialog, status.data) : "no"
|
||||
if (!choice) return
|
||||
dialog.clear()
|
||||
@@ -110,8 +112,8 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
|
||||
}
|
||||
setProgress("Moving session")
|
||||
try {
|
||||
await sdk.api.session.move({ sessionID, destination: { directory }, moveChanges: choice === "yes" })
|
||||
await sdk.api.session
|
||||
await client.api.session.move({ sessionID, destination: { directory }, moveChanges: choice === "yes" })
|
||||
await client.api.session
|
||||
.synthetic({ sessionID, text: moveReminderText(directory), resume: false })
|
||||
.catch(() => undefined)
|
||||
dialog.clear()
|
||||
@@ -129,7 +131,7 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
|
||||
if (projectID) return projectID
|
||||
const sessionID = input.sessionID()
|
||||
if (sessionID) return (await resolveSession(sessionID))?.projectID
|
||||
return sdk.api.project
|
||||
return client.api.project
|
||||
.current({ location: { directory: project.instance.directory() || paths.cwd } })
|
||||
.then((project) => project.id)
|
||||
.catch(() => undefined)
|
||||
|
||||
@@ -14,7 +14,10 @@ export function Spinner(props: { children?: JSX.Element; color?: RGBA }) {
|
||||
const config = useConfig().data
|
||||
const color = () => props.color ?? theme.textMuted
|
||||
return (
|
||||
<Show when={config.animations ?? true} fallback={<text fg={color()}>⋯ {props.children}</text>}>
|
||||
<Show
|
||||
when={config.animations ?? true}
|
||||
fallback={<text fg={color()}>{props.children ? <>⋯ {props.children}</> : "⋯"}</text>}
|
||||
>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<spinner frames={SPINNER_FRAMES} interval={80} color={color()} />
|
||||
<Show when={props.children}>
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import type { OpenCodeClient, OpenCodeEvent } from "@opencode-ai/client"
|
||||
import type { OpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||
import { onCleanup, onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { useLog } from "./log"
|
||||
|
||||
export type SDKConnectionStatus = "connected" | "connecting" | "reconnecting"
|
||||
export type SDKConnectionEvent = {
|
||||
export type ClientConnectionStatus = "connected" | "connecting" | "reconnecting"
|
||||
export type ClientConnectionEvent = {
|
||||
readonly type: "client.connection"
|
||||
readonly created: number
|
||||
readonly data: {
|
||||
@@ -17,27 +16,25 @@ export type SDKConnectionEvent = {
|
||||
}
|
||||
}
|
||||
|
||||
type SDKEventMap = { [Type in OpenCodeEvent["type"]]: Extract<OpenCodeEvent, { type: Type }> }
|
||||
type ClientEventMap = { [Type in OpenCodeEvent["type"]]: Extract<OpenCodeEvent, { type: Type }> }
|
||||
const connectTimeout = 2_000
|
||||
const connectionHistoryLimit = 50
|
||||
|
||||
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||
name: "SDK",
|
||||
export const { use: useClient, provider: ClientProvider } = createSimpleContext({
|
||||
name: "Client",
|
||||
init: (props: {
|
||||
client: OpencodeClient
|
||||
api: OpenCodeClient
|
||||
reconnect?: (attempt: number) => Promise<{ client: OpencodeClient; api: OpenCodeClient }>
|
||||
reconnect?: (attempt: number) => Promise<{ api: OpenCodeClient }>
|
||||
// Stops and starts the managed service; present only in service mode.
|
||||
reload?: () => Promise<void>
|
||||
}) => {
|
||||
const log = useLog({ component: "sdk" })
|
||||
const log = useLog({ component: "client" })
|
||||
const abort = new AbortController()
|
||||
const history: SDKConnectionEvent[] = []
|
||||
let client = props.client
|
||||
const history: ClientConnectionEvent[] = []
|
||||
let api = props.api
|
||||
const events = createGlobalEmitter<SDKEventMap>()
|
||||
const events = createGlobalEmitter<ClientEventMap>()
|
||||
const [connection, setConnection] = createStore<{
|
||||
status: SDKConnectionStatus
|
||||
status: ClientConnectionStatus
|
||||
attempt: number
|
||||
error?: string
|
||||
}>({
|
||||
@@ -46,7 +43,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||
})
|
||||
let stream: AbortController | undefined
|
||||
|
||||
function record(status: SDKConnectionEvent["data"]["status"], attempt: number, error?: string) {
|
||||
function record(status: ClientConnectionEvent["data"]["status"], attempt: number, error?: string) {
|
||||
history.push({ type: "client.connection", created: Date.now(), data: { status, attempt, error } })
|
||||
if (history.length > connectionHistoryLimit) history.shift()
|
||||
}
|
||||
@@ -121,7 +118,6 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||
const next = await props.reconnect(attempt).catch(() => undefined)
|
||||
if (abort.signal.aborted || controller.signal.aborted) return
|
||||
if (next) {
|
||||
client = next.client
|
||||
api = next.api
|
||||
}
|
||||
}
|
||||
@@ -144,9 +140,6 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||
})
|
||||
|
||||
return {
|
||||
get client() {
|
||||
return client
|
||||
},
|
||||
get api() {
|
||||
return api
|
||||
},
|
||||
@@ -30,7 +30,7 @@ import type {
|
||||
import type { Data } from "@opencode-ai/plugin/v2/tui/context"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { useSDK } from "./sdk"
|
||||
import { useClient } from "./client"
|
||||
import { createSignal, onCleanup } from "solid-js"
|
||||
|
||||
export type DataSessionStatus = "idle" | "running"
|
||||
@@ -110,7 +110,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
location: {},
|
||||
})
|
||||
|
||||
const sdk = useSDK()
|
||||
const client = useClient()
|
||||
const [defaultLocation, setDefaultLocation] = createSignal<LocationRef>({
|
||||
directory: process.cwd(),
|
||||
})
|
||||
@@ -328,7 +328,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
time: { created: event.created },
|
||||
})
|
||||
})
|
||||
void sdk.api.session
|
||||
void client.api.session
|
||||
.message({ sessionID: event.data.sessionID, messageID: messageIDFromEvent(event.id) })
|
||||
.then((item) => {
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
@@ -851,8 +851,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
}
|
||||
|
||||
const result = {
|
||||
on: sdk.event.on,
|
||||
listen: sdk.event.listen,
|
||||
on: client.event.on,
|
||||
listen: client.event.listen,
|
||||
session: {
|
||||
list() {
|
||||
return Object.values(store.session.info).toSorted((a, b) => b.time.updated - a.time.updated)
|
||||
@@ -899,7 +899,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
return store.session.pending[sessionID] ?? []
|
||||
},
|
||||
async refresh(sessionID: string) {
|
||||
const pending = await sdk.api.session.pending.list({ sessionID })
|
||||
const pending = await client.api.session.pending.list({ sessionID })
|
||||
setStore("session", "pending", sessionID, reconcile(pending))
|
||||
setStore(
|
||||
"session",
|
||||
@@ -916,7 +916,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
},
|
||||
},
|
||||
async refresh(sessionID: string) {
|
||||
setStore("session", "info", sessionID, await sdk.api.session.get({ sessionID }))
|
||||
setStore("session", "info", sessionID, await client.api.session.get({ sessionID }))
|
||||
registerSession(sessionID)
|
||||
},
|
||||
message: {
|
||||
@@ -929,7 +929,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
return position === undefined ? undefined : messages?.[position]
|
||||
},
|
||||
async refresh(sessionID: string) {
|
||||
const messages = (await sdk.api.message.list({ sessionID, limit: 200, order: "desc" })).data.toReversed()
|
||||
const messages = (await client.api.message.list({ sessionID, limit: 200, order: "desc" })).data.toReversed()
|
||||
messageIndex.set(sessionID, new Map(messages.map((message, index) => [message.id, index])))
|
||||
setStore("session", "message", sessionID, reconcile(messages))
|
||||
},
|
||||
@@ -939,7 +939,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
return store.session.permission[sessionID]
|
||||
},
|
||||
async refresh(sessionID: string) {
|
||||
setStore("session", "permission", sessionID, await sdk.api.permission.list({ sessionID }))
|
||||
setStore("session", "permission", sessionID, await client.api.permission.list({ sessionID }))
|
||||
},
|
||||
},
|
||||
form: {
|
||||
@@ -952,7 +952,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
},
|
||||
async refresh(sessionID: string, ref?: LocationRef) {
|
||||
if (sessionID === "global") {
|
||||
const response = await sdk.api.form.request.list({ location: locationQuery(ref ?? defaultLocation()) })
|
||||
const response = await client.api.form.request.list({ location: locationQuery(ref ?? defaultLocation()) })
|
||||
const location = {
|
||||
directory: response.location.directory,
|
||||
workspaceID: response.location.workspaceID,
|
||||
@@ -966,7 +966,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
])
|
||||
return
|
||||
}
|
||||
setStore("session", "form", sessionID, await sdk.api.form.list({ sessionID }))
|
||||
setStore("session", "form", sessionID, await client.api.form.list({ sessionID }))
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -976,7 +976,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
return store.project.permission[projectID]
|
||||
},
|
||||
async refresh(projectID: string) {
|
||||
setStore("project", "permission", projectID, await sdk.api.permission.saved.list({ projectID }))
|
||||
setStore("project", "permission", projectID, await client.api.permission.saved.list({ projectID }))
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -990,7 +990,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
.find((shell) => shell !== undefined)
|
||||
},
|
||||
async refresh(ref?: LocationRef) {
|
||||
const result = await sdk.api.shell.list({ location: locationQuery(ref) })
|
||||
const result = await client.api.shell.list({ location: locationQuery(ref) })
|
||||
const key = locationKey(result.location)
|
||||
setStore("location", key, {
|
||||
...store.location[key],
|
||||
@@ -1003,7 +1003,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
return defaultLocation()
|
||||
},
|
||||
async refresh(ref?: LocationRef) {
|
||||
const location = await sdk.api.location.get({ location: locationQuery(ref ?? defaultLocation()) })
|
||||
const location = await client.api.location.get({ location: locationQuery(ref ?? defaultLocation()) })
|
||||
const key = locationKey(location)
|
||||
if (!store.location[key]) setStore("location", key, {})
|
||||
if (!ref) setDefaultLocation({ directory: location.directory, workspaceID: location.workspaceID })
|
||||
@@ -1013,7 +1013,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
return store.location[locationKey(location ?? defaultLocation())]?.agent
|
||||
},
|
||||
async refresh(ref?: LocationRef) {
|
||||
const result = await sdk.api.agent.list({ location: locationQuery(ref ?? defaultLocation()) })
|
||||
const result = await client.api.agent.list({ location: locationQuery(ref ?? defaultLocation()) })
|
||||
const key = locationKey(result.location)
|
||||
setStore("location", key, { ...store.location[key], agent: result.data })
|
||||
},
|
||||
@@ -1023,7 +1023,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
return store.location[locationKey(location ?? defaultLocation())]?.command
|
||||
},
|
||||
async refresh(ref?: LocationRef) {
|
||||
const result = await sdk.api.command.list({ location: locationQuery(ref ?? defaultLocation()) })
|
||||
const result = await client.api.command.list({ location: locationQuery(ref ?? defaultLocation()) })
|
||||
const key = locationKey(result.location)
|
||||
setStore("location", key, { ...store.location[key], command: result.data })
|
||||
},
|
||||
@@ -1033,7 +1033,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
return store.location[locationKey(location ?? defaultLocation())]?.integration
|
||||
},
|
||||
async refresh(ref?: LocationRef) {
|
||||
const result = await sdk.api.integration.list({ location: locationQuery(ref ?? defaultLocation()) })
|
||||
const result = await client.api.integration.list({ location: locationQuery(ref ?? defaultLocation()) })
|
||||
const key = locationKey(result.location)
|
||||
setStore("location", key, { ...store.location[key], integration: result.data })
|
||||
},
|
||||
@@ -1044,7 +1044,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
return store.location[locationKey(location ?? defaultLocation())]?.mcp?.server
|
||||
},
|
||||
async refresh(ref?: LocationRef) {
|
||||
const result = await sdk.api.mcp.list({ location: locationQuery(ref) })
|
||||
const result = await client.api.mcp.list({ location: locationQuery(ref) })
|
||||
const key = locationKey(result.location)
|
||||
setStore("location", key, {
|
||||
...store.location[key],
|
||||
@@ -1057,7 +1057,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
return store.location[locationKey(location ?? defaultLocation())]?.mcp?.resource
|
||||
},
|
||||
async refresh(ref?: LocationRef) {
|
||||
const result = await sdk.api.mcp.resource.catalog({ location: locationQuery(ref) })
|
||||
const result = await client.api.mcp.resource.catalog({ location: locationQuery(ref) })
|
||||
const key = locationKey(result.location)
|
||||
setStore("location", key, {
|
||||
...store.location[key],
|
||||
@@ -1071,7 +1071,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
return store.location[locationKey(location ?? defaultLocation())]?.model
|
||||
},
|
||||
async refresh(ref?: LocationRef) {
|
||||
const result = await sdk.api.model.list({ location: locationQuery(ref ?? defaultLocation()) })
|
||||
const result = await client.api.model.list({ location: locationQuery(ref ?? defaultLocation()) })
|
||||
const key = locationKey(result.location)
|
||||
setStore("location", key, { ...store.location[key], model: result.data })
|
||||
},
|
||||
@@ -1081,7 +1081,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
return store.location[locationKey(location ?? defaultLocation())]?.provider
|
||||
},
|
||||
async refresh(ref?: LocationRef) {
|
||||
const result = await sdk.api.provider.list({ location: locationQuery(ref ?? defaultLocation()) })
|
||||
const result = await client.api.provider.list({ location: locationQuery(ref ?? defaultLocation()) })
|
||||
const key = locationKey(result.location)
|
||||
setStore("location", key, { ...store.location[key], provider: result.data })
|
||||
},
|
||||
@@ -1091,7 +1091,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
return store.location[locationKey(location ?? defaultLocation())]?.reference
|
||||
},
|
||||
async refresh(ref?: LocationRef) {
|
||||
const result = await sdk.api.reference.list({ location: locationQuery(ref ?? defaultLocation()) })
|
||||
const result = await client.api.reference.list({ location: locationQuery(ref ?? defaultLocation()) })
|
||||
const key = locationKey(result.location)
|
||||
setStore("location", key, { ...store.location[key], reference: result.data })
|
||||
},
|
||||
@@ -1101,7 +1101,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
return store.location[locationKey(location ?? defaultLocation())]?.skill
|
||||
},
|
||||
async refresh(ref?: LocationRef) {
|
||||
const result = await sdk.api.skill.list({ location: locationQuery(ref ?? defaultLocation()) })
|
||||
const result = await client.api.skill.list({ location: locationQuery(ref ?? defaultLocation()) })
|
||||
const key = locationKey(result.location)
|
||||
setStore("location", key, { ...store.location[key], skill: result.data })
|
||||
},
|
||||
@@ -1113,7 +1113,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
async function bootstrap() {
|
||||
if (bootstrapping) return bootstrapping
|
||||
bootstrapping = Promise.allSettled([
|
||||
sdk.api.session
|
||||
client.api.session
|
||||
.list({
|
||||
limit: 50,
|
||||
order: "desc",
|
||||
@@ -1130,7 +1130,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
)
|
||||
for (const session of response.data) registerSession(session.id)
|
||||
}),
|
||||
sdk.api.permission.request.list({ location: locationQuery(defaultLocation()) }).then((response) => {
|
||||
client.api.permission.request.list({ location: locationQuery(defaultLocation()) }).then((response) => {
|
||||
const permissions = response.data.reduce<Record<string, PermissionV2Request[]>>(
|
||||
(result, request) => ({
|
||||
...result,
|
||||
@@ -1140,7 +1140,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
)
|
||||
setStore("session", "permission", reconcile(permissions))
|
||||
}),
|
||||
sdk.api.form.request.list({ location: locationQuery(defaultLocation()) }).then((response) => {
|
||||
client.api.form.request.list({ location: locationQuery(defaultLocation()) }).then((response) => {
|
||||
const location = {
|
||||
directory: response.location.directory,
|
||||
workspaceID: response.location.workspaceID,
|
||||
@@ -1193,7 +1193,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
}
|
||||
|
||||
function refreshActive() {
|
||||
void sdk.api.session
|
||||
void client.api.session
|
||||
.active()
|
||||
.then((active) => {
|
||||
setStore(
|
||||
@@ -1206,7 +1206,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
}
|
||||
|
||||
onCleanup(
|
||||
sdk.event.listen(({ details }) => {
|
||||
client.event.listen(({ details }) => {
|
||||
if (details.type === "server.connected") {
|
||||
const messages = connected ? Object.keys(store.session.message) : []
|
||||
const compactions = connected ? Object.keys(store.session.compaction) : []
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { OpenCodeEvent } from "@opencode-ai/client"
|
||||
import { useSDK } from "./sdk"
|
||||
import { useClient } from "./client"
|
||||
|
||||
type EventMetadata = {
|
||||
directory: string | undefined
|
||||
@@ -7,10 +7,10 @@ type EventMetadata = {
|
||||
}
|
||||
|
||||
export function useEvent() {
|
||||
const sdk = useSDK()
|
||||
const client = useClient()
|
||||
|
||||
function subscribe(handler: (event: OpenCodeEvent, metadata: EventMetadata) => void) {
|
||||
return sdk.event.listen(({ details }) => {
|
||||
return client.event.listen(({ details }) => {
|
||||
if (details.type === "server.connected") return
|
||||
handler(details, { directory: details.location?.directory, workspace: details.location?.workspaceID })
|
||||
})
|
||||
@@ -20,7 +20,7 @@ export function useEvent() {
|
||||
type: T,
|
||||
handler: (event: Extract<OpenCodeEvent, { type: T }>, metadata: EventMetadata) => void,
|
||||
) {
|
||||
return sdk.event.on(type, (event) => {
|
||||
return client.event.on(type, (event) => {
|
||||
handler(event, { directory: event.location?.directory, workspace: event.location?.workspaceID })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useEvent } from "./event"
|
||||
import path from "path"
|
||||
import { useTuiPaths } from "./runtime"
|
||||
import { useArgs } from "./args"
|
||||
import { useSDK } from "./sdk"
|
||||
import { useClient } from "./client"
|
||||
import { RGBA } from "@opentui/core"
|
||||
import { readJson, writeJsonAtomic } from "../util/persistence"
|
||||
import { useTheme } from "./theme"
|
||||
@@ -52,7 +52,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
name: "Local",
|
||||
init: () => {
|
||||
const data = useData()
|
||||
const sdk = useSDK()
|
||||
const client = useClient()
|
||||
const toast = useToast()
|
||||
const theme = useTheme().theme
|
||||
const route = useRoute()
|
||||
@@ -493,22 +493,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
|
||||
const session = createSession()
|
||||
|
||||
const mcp = {
|
||||
isEnabled(name: string) {
|
||||
return data.location.mcp.server.list()?.find((item) => item.name === name)?.status.status === "connected"
|
||||
},
|
||||
async toggle(name: string) {
|
||||
const status = data.location.mcp.server.list()?.find((item) => item.name === name)?.status.status
|
||||
if (status === "connected") {
|
||||
// Disable: disconnect the MCP
|
||||
await sdk.client.mcp.disconnect({ name })
|
||||
} else {
|
||||
// Enable/Retry: connect the MCP (handles disabled, failed, and other states)
|
||||
await sdk.client.mcp.connect({ name })
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const value = agent.current()
|
||||
if (!value?.model) return
|
||||
@@ -523,7 +507,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
const result = {
|
||||
model,
|
||||
agent,
|
||||
mcp,
|
||||
session,
|
||||
permission,
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { batch } from "solid-js"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { useSDK } from "./sdk"
|
||||
import { useClient } from "./client"
|
||||
|
||||
export const { use: useProject, provider: ProjectProvider } = createSimpleContext({
|
||||
name: "Project",
|
||||
init: () => {
|
||||
const sdk = useSDK()
|
||||
const client = useClient()
|
||||
|
||||
const defaultPath = {
|
||||
home: "",
|
||||
@@ -33,8 +33,8 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex
|
||||
async function sync() {
|
||||
const workspace = store.workspace.current
|
||||
const location = { workspace }
|
||||
const current = await sdk.api.location.get({ location })
|
||||
const directories = await sdk.api.project.directories({ projectID: current.project.id, location })
|
||||
const current = await client.api.location.get({ location })
|
||||
const directories = await client.api.project.directories({ projectID: current.project.id, location })
|
||||
batch(() => {
|
||||
setStore(
|
||||
"instance",
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
addTheme,
|
||||
allThemes,
|
||||
generateSyntax,
|
||||
generateSystem,
|
||||
hasTheme,
|
||||
isTheme,
|
||||
resolveTheme,
|
||||
@@ -13,11 +12,10 @@ import {
|
||||
setCustomThemes,
|
||||
setSystemTheme,
|
||||
subscribeThemes,
|
||||
terminalMode,
|
||||
tint,
|
||||
upsertTheme,
|
||||
type ThemeJson,
|
||||
} from "../theme"
|
||||
import { generateSystem, terminalMode } from "../theme/system"
|
||||
import { createEffect, createMemo, onCleanup, onMount } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { createSimpleContext } from "./helper"
|
||||
@@ -63,13 +61,10 @@ export {
|
||||
addTheme,
|
||||
allThemes,
|
||||
generateSyntax,
|
||||
generateSystem,
|
||||
hasTheme,
|
||||
isTheme,
|
||||
resolveTheme,
|
||||
selectedForeground,
|
||||
terminalMode,
|
||||
tint,
|
||||
upsertTheme,
|
||||
type Theme,
|
||||
type ThemeJson,
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { PluginRuntime } from "../plugin/runtime"
|
||||
import HomeFooter from "./home/footer"
|
||||
import HomeTips from "./home/tips"
|
||||
import SidebarContext from "./sidebar/context"
|
||||
import SidebarFiles from "./sidebar/files"
|
||||
import SidebarFooter from "./sidebar/footer"
|
||||
import SidebarLsp from "./sidebar/lsp"
|
||||
import SidebarMcp from "./sidebar/mcp"
|
||||
@@ -26,7 +25,6 @@ export function createBuiltinPlugins(): BuiltinTuiPlugin[] {
|
||||
SidebarContext,
|
||||
SidebarMcp,
|
||||
SidebarLsp,
|
||||
SidebarFiles,
|
||||
SidebarFooter,
|
||||
Notifications,
|
||||
PluginManager,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user