mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-16 22:06:23 +00:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c635a03165 | ||
|
|
99084d8a9b | ||
|
|
c3d278a906 | ||
|
|
e919bcc308 | ||
|
|
0880378abb | ||
|
|
2cdd938152 | ||
|
|
4d73b40e59 | ||
|
|
276f2eef3e | ||
|
|
8806d34ed4 | ||
|
|
cda2bc5100 |
@@ -32,7 +32,7 @@
|
||||
},
|
||||
"packages/ai": {
|
||||
"name": "@opencode/ai",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.5",
|
||||
"dependencies": {
|
||||
"@aws-sdk/credential-providers": "3.1057.0",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -54,7 +54,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode/app",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.5",
|
||||
"dependencies": {
|
||||
"@corvu/drawer": "catalog:",
|
||||
"@dnd-kit/abstract": "0.5.0",
|
||||
@@ -113,7 +113,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@opencode/cli",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.5",
|
||||
"bin": {
|
||||
"opencode2": "./bin/opencode2.cjs",
|
||||
},
|
||||
@@ -177,7 +177,7 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@opencode/client",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.5",
|
||||
"dependencies": {
|
||||
"@opencode/protocol": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -203,7 +203,7 @@
|
||||
},
|
||||
"packages/codemode": {
|
||||
"name": "@opencode/codemode",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.5",
|
||||
"dependencies": {
|
||||
"acorn": "8.15.0",
|
||||
"effect": "catalog:",
|
||||
@@ -217,7 +217,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode/console-app",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.5",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -253,7 +253,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode/console-core",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.5",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -280,7 +280,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode/console-function",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.5",
|
||||
"dependencies": {
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opencode/console-core": "workspace:*",
|
||||
@@ -297,7 +297,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode/console-mail",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.5",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -321,7 +321,7 @@
|
||||
},
|
||||
"packages/console/support": {
|
||||
"name": "@opencode/console-support",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.5",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@opencode/console-core": "workspace:*",
|
||||
@@ -341,7 +341,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode/core",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.5",
|
||||
"dependencies": {
|
||||
"@ai-sdk/cohere": "3.0.27",
|
||||
"@ai-sdk/gateway": "3.0.104",
|
||||
@@ -409,7 +409,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode/desktop",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.5",
|
||||
"dependencies": {
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"electron-context-menu": "4.1.2",
|
||||
@@ -461,7 +461,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode/enterprise",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.5",
|
||||
"dependencies": {
|
||||
"@hono/standard-validator": "catalog:",
|
||||
"@opencode-ai/sdk": "1.18.21",
|
||||
@@ -498,7 +498,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode/function",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.5",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -514,7 +514,7 @@
|
||||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode/http-recorder",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.5",
|
||||
"dependencies": {
|
||||
"@effect/platform-node-shared": "4.0.0-rc.112",
|
||||
},
|
||||
@@ -533,7 +533,7 @@
|
||||
},
|
||||
"packages/httpapi-codegen": {
|
||||
"name": "@opencode/httpapi-codegen",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.5",
|
||||
"dependencies": {
|
||||
"effect": "catalog:",
|
||||
"prettier": "3.6.2",
|
||||
@@ -546,7 +546,7 @@
|
||||
},
|
||||
"packages/latex": {
|
||||
"name": "@opencode/latex",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.5",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -560,7 +560,7 @@
|
||||
},
|
||||
"packages/merman": {
|
||||
"name": "@opencode/merman",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.5",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -575,7 +575,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode/plugin",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.5",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@opencode/ai": "workspace:*",
|
||||
@@ -614,7 +614,7 @@
|
||||
},
|
||||
"packages/plugin-browser": {
|
||||
"name": "@opencode/plugin-browser",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.5",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -644,7 +644,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@opencode/protocol",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.5",
|
||||
"dependencies": {
|
||||
"@opencode/schema": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
@@ -659,7 +659,7 @@
|
||||
},
|
||||
"packages/schema": {
|
||||
"name": "@opencode/schema",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.5",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -683,7 +683,7 @@
|
||||
},
|
||||
"packages/sdk": {
|
||||
"name": "@opencode/sdk",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.5",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -704,7 +704,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@opencode/server",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.5",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@effect/platform-node-shared": "catalog:",
|
||||
@@ -726,7 +726,7 @@
|
||||
},
|
||||
"packages/session-ui": {
|
||||
"name": "@opencode/session-ui",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.5",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode/client": "workspace:*",
|
||||
@@ -761,7 +761,7 @@
|
||||
},
|
||||
"packages/simulation": {
|
||||
"name": "@opencode/simulation",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.5",
|
||||
"dependencies": {
|
||||
"@opencode/ai": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -781,7 +781,7 @@
|
||||
},
|
||||
"packages/stats/app": {
|
||||
"name": "@opencode/stats-app",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.5",
|
||||
"dependencies": {
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@kobalte/core": "catalog:",
|
||||
@@ -815,7 +815,7 @@
|
||||
},
|
||||
"packages/stats/core": {
|
||||
"name": "@opencode/stats-core",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.5",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-athena": "3.933.0",
|
||||
"@planetscale/database": "1.19.0",
|
||||
@@ -834,7 +834,7 @@
|
||||
},
|
||||
"packages/stats/server": {
|
||||
"name": "@opencode/stats-server",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.5",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-firehose": "3.933.0",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -880,7 +880,7 @@
|
||||
},
|
||||
"packages/theme": {
|
||||
"name": "@opencode/theme",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.5",
|
||||
"dependencies": {
|
||||
"@opentui/core": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -894,7 +894,7 @@
|
||||
},
|
||||
"packages/tui": {
|
||||
"name": "@opencode/tui",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.5",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -929,7 +929,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode/ui",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.5",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@pierre/diffs": "catalog:",
|
||||
@@ -964,7 +964,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode/util",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.5",
|
||||
"dependencies": {
|
||||
"@effect/opentelemetry": "catalog:",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -997,7 +997,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode/web",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.5",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -1038,7 +1038,7 @@
|
||||
},
|
||||
"services/update": {
|
||||
"name": "@opencode/update",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.5",
|
||||
"dependencies": {
|
||||
"jose": "6.0.11",
|
||||
"semver": "catalog:",
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "opencode",
|
||||
"description": "AI-powered development tool",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.5",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.4.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.5",
|
||||
"name": "@opencode/ai",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -336,12 +336,18 @@ export type OutputItem = StreamItem & { readonly id: string }
|
||||
// Responses-compatible providers put streaming error details at the top level or
|
||||
// under `error`, and response failures under `response.error`. Accept all three shapes.
|
||||
// https://www.openresponses.org/specification
|
||||
const OpenResponsesErrorPayload = Schema.Struct({
|
||||
const OpenResponsesErrorObject = Schema.Struct({
|
||||
type: optionalNull(Schema.String),
|
||||
code: optionalNull(Schema.String),
|
||||
message: optionalNull(Schema.String),
|
||||
param: optionalNull(Schema.String),
|
||||
})
|
||||
const OpenResponsesErrorPayload = Schema.Union([Schema.String, OpenResponsesErrorObject]).pipe(
|
||||
Schema.decodeTo(OpenResponsesErrorObject, {
|
||||
decode: SchemaGetter.transform((error) => (typeof error === "string" ? { message: error } : error)),
|
||||
encode: SchemaGetter.passthrough(),
|
||||
}),
|
||||
)
|
||||
type OpenResponsesErrorPayload = Schema.Schema.Type<typeof OpenResponsesErrorPayload>
|
||||
|
||||
const WebSocketErrorHeader = Schema.Union([Schema.String, Schema.Number, Schema.Boolean])
|
||||
@@ -434,7 +440,9 @@ export const decodeChannelEvent = (frame: string) =>
|
||||
decodeFrame(frame).pipe(
|
||||
Effect.flatMap((value) =>
|
||||
decodeEventValue(
|
||||
ProviderShared.isRecord(value) && value.type === undefined && ProviderShared.isRecord(value.error)
|
||||
ProviderShared.isRecord(value) &&
|
||||
value.type === undefined &&
|
||||
(typeof value.error === "string" || ProviderShared.isRecord(value.error))
|
||||
? { ...value, type: "error" }
|
||||
: value,
|
||||
),
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { LLM, LLMClient } from "../../src/index.js"
|
||||
import { OpenResponses } from "../../src/protocols/open-responses.js"
|
||||
import { Meta } from "../../src/providers/index.js"
|
||||
import { Azure, Meta, OpenAI, XAI } from "../../src/providers/index.js"
|
||||
import { WebSocketTransport } from "../../src/route.js"
|
||||
import { configure } from "../../src/providers/openai-compatible-responses.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
import { fixedResponse } from "../lib/http.js"
|
||||
@@ -44,11 +45,59 @@ it.effect("normalizes flat errors in shared SSE and WebSocket decoding", () =>
|
||||
|
||||
it.effect("continues to normalize untyped xAI WebSocket errors", () =>
|
||||
Effect.gen(function* () {
|
||||
const frame = { error: { type: "api_error", message: "gRPC error: Response with id=resp_missing not found" } }
|
||||
expect(yield* OpenResponses.decodeChannelEvent(JSON.stringify(frame))).toEqual({ ...frame, type: "error" })
|
||||
const message = "gRPC error: Response with id=resp_missing not found"
|
||||
for (const error of [{ type: "api_error", message }, message]) {
|
||||
expect(yield* OpenResponses.decodeChannelEvent(JSON.stringify({ error }))).toEqual({
|
||||
type: "error",
|
||||
error: typeof error === "string" ? { message } : error,
|
||||
})
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("normalizes string errors in shared SSE and WebSocket decoding", () =>
|
||||
Effect.gen(function* () {
|
||||
for (const decode of [decodeEvent, OpenResponses.decodeChannelEvent]) {
|
||||
expect(yield* decode(JSON.stringify({ type: "error", error: "Gateway failed" }))).toEqual({
|
||||
type: "error",
|
||||
error: { message: "Gateway failed" },
|
||||
})
|
||||
expect(
|
||||
yield* decode(
|
||||
JSON.stringify({ type: "response.failed", response: { id: "resp_failed", error: "Gateway failed" } }),
|
||||
),
|
||||
).toEqual({
|
||||
type: "response.failed",
|
||||
response: { id: "resp_failed", error: { message: "Gateway failed" } },
|
||||
})
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
for (const model of [
|
||||
OpenAI.configure({ apiKey: "fixture" }).responses("gpt-5.6-sol"),
|
||||
XAI.configure({ apiKey: "fixture" }).responses("grok-4.6"),
|
||||
Azure.configure({ apiKey: "fixture", resourceName: "fixture" }).responses("deployment"),
|
||||
]) {
|
||||
it.effect(`preserves string error messages and raw bodies through ${model.provider} Responses`, () =>
|
||||
Effect.gen(function* () {
|
||||
const raw = '{ "type": "error", "error": "Gateway rejected the request", "trace": "original" }'
|
||||
const webSocket = WebSocketTransport.makeDirect({
|
||||
open: () => Effect.succeed({ sendText: () => Effect.void, messages: Stream.make(raw), close: Effect.void }),
|
||||
})
|
||||
for (const options of [{ webSocket }, {}]) {
|
||||
const error = yield* LLMClient.generate(LLM.request({ model, prompt: "Hello" }), options).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents(raw))),
|
||||
Effect.flip,
|
||||
)
|
||||
expect(error.reason._tag).toBe("UnknownProvider")
|
||||
expect(error.message).toBe("Gateway rejected the request")
|
||||
expect(error.reason.body).toBe(raw)
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
it.effect("retains classification and original error bodies through Meta and generic Responses routes", () =>
|
||||
Effect.gen(function* () {
|
||||
const raw = `{
|
||||
|
||||
@@ -405,12 +405,7 @@ test("executes a selected slash command after creating its worktree", async ({ p
|
||||
item: { type: "user", payload: { text: expanded }, delivery: "steer" },
|
||||
},
|
||||
})
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ data: { type: "prompt", inboxID: "msg_workspace_review" } }),
|
||||
})
|
||||
await route.fulfill({ status: 204, headers })
|
||||
})
|
||||
const editor = page.locator('[data-component="composer-editor"]')
|
||||
await editor.fill("/review")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/app",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.5",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/cli",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.5",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { AgentSideConnection, PromptResponse, SessionUpdate } from "@agentclientprotocol/sdk"
|
||||
import type { Command } from "@opencode/schema/command"
|
||||
import type {
|
||||
EventSubscribeOutput,
|
||||
OpenCodeClient,
|
||||
@@ -38,9 +37,6 @@ export type TurnStart =
|
||||
| { readonly type: "skill"; readonly id: string }
|
||||
| { readonly type: "compaction"; readonly id: string }
|
||||
|
||||
/** `prompt` submissions admitted work under the start ID; `immediate` submissions have none to follow. */
|
||||
export type Admission = Command.Outcome["type"]
|
||||
|
||||
export const ChildSessionUpdatesCapability = "opencode/child-session-updates"
|
||||
export const ChildSessionUpdateMethod = "opencode/session/child_update"
|
||||
|
||||
@@ -80,7 +76,8 @@ export async function streamTurn(input: {
|
||||
readonly cwd: string
|
||||
readonly start: TurnStart
|
||||
readonly writeTextFile: boolean
|
||||
readonly submit: (signal: AbortSignal) => Promise<Admission>
|
||||
readonly action?: boolean
|
||||
readonly submit: (signal: AbortSignal) => Promise<unknown>
|
||||
readonly control: TurnControl
|
||||
readonly childSessionUpdate?: (update: ChildSessionUpdate) => Promise<void>
|
||||
readonly connectionSignal?: AbortSignal
|
||||
@@ -117,14 +114,6 @@ export async function streamTurn(input: {
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
// Command-spawned subagents run while the invoking Session is idle, so cancelling the prompt
|
||||
// must interrupt them directly. Sessions that start after cancellation are interrupted on start.
|
||||
const interruptChildren = async () => {
|
||||
await Promise.all(
|
||||
[...openChildren].map((sessionID) => input.client.session.interrupt({ sessionID }).catch(() => {})),
|
||||
)
|
||||
}
|
||||
|
||||
const updateSession = async (value: SessionUpdate, child: ChildSession | undefined, mode: "turn" | "background") => {
|
||||
const projected = child ? projectChildUpdate(value, child) : value
|
||||
if (mode === "turn" && (!child || !input.childSessionUpdate)) {
|
||||
@@ -189,10 +178,9 @@ export async function streamTurn(input: {
|
||||
if (!started) continue
|
||||
|
||||
if (event.type === "session.execution.started") {
|
||||
if (mode === "turn" && control.cancelled) {
|
||||
await input.client.session.interrupt({ sessionID: event.data.sessionID }).catch(() => {})
|
||||
if (child) {
|
||||
await notifyChild(child, { type: "status", status: "running" })
|
||||
}
|
||||
if (child) await notifyChild(child, { type: "status", status: "running" })
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -354,15 +342,11 @@ export async function streamTurn(input: {
|
||||
input.sessionSignal?.removeEventListener("abort", connectionAbort)
|
||||
await stream.return?.(undefined).catch(() => {})
|
||||
}
|
||||
const onCancel = () => void interruptChildren()
|
||||
control.admission.signal.addEventListener("abort", onCancel, { once: true })
|
||||
try {
|
||||
const admission = await input.submit(control.admission.signal).catch((error): Admission => {
|
||||
await input.submit(control.admission.signal).catch((error) => {
|
||||
if (!control.cancelled) throw error
|
||||
// The request may have been admitted before the abort reached the server; keep observing.
|
||||
return "prompt"
|
||||
})
|
||||
if (admission === "immediate") {
|
||||
if (input.action) {
|
||||
streamController.abort()
|
||||
await completed.catch(() => {})
|
||||
return response(undefined, undefined, "succeeded", control.cancelled, undefined)
|
||||
@@ -400,7 +384,6 @@ export async function streamTurn(input: {
|
||||
await completed.catch(() => {})
|
||||
throw error
|
||||
} finally {
|
||||
control.admission.signal.removeEventListener("abort", onCancel)
|
||||
if (!handedOff) await closeStream()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +52,6 @@ import {
|
||||
ChildSessionUpdatesCapability,
|
||||
replayMessages,
|
||||
streamTurn,
|
||||
type Admission,
|
||||
type ChildSessionUpdate,
|
||||
type TurnControl,
|
||||
type TurnStart,
|
||||
@@ -328,6 +327,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
|
||||
cwd: state.cwd,
|
||||
start: prepared.start,
|
||||
writeTextFile: capabilities.writeTextFile,
|
||||
action: prepared.command !== undefined,
|
||||
control,
|
||||
connectionSignal: input.connection.signal,
|
||||
sessionSignal: state.abort.signal,
|
||||
@@ -362,12 +362,7 @@ function preparePrompt(catalog: Catalog, prompt: PromptRequest["prompt"], messag
|
||||
return { start, text, files, synthetic, slash, command }
|
||||
}
|
||||
|
||||
async function submitPrompt(
|
||||
client: OpenCodeClient,
|
||||
session: Attached,
|
||||
prompt: PreparedPrompt,
|
||||
signal: AbortSignal,
|
||||
): Promise<Admission> {
|
||||
async function submitPrompt(client: OpenCodeClient, session: Attached, prompt: PreparedPrompt, signal: AbortSignal) {
|
||||
if (prompt.synthetic.length > 0) {
|
||||
await client.session.synthetic({
|
||||
sessionID: session.id,
|
||||
@@ -377,30 +372,23 @@ async function submitPrompt(
|
||||
resume: false,
|
||||
})
|
||||
}
|
||||
if (prompt.start.type === "compaction") {
|
||||
await client.session.compact({ sessionID: session.id, id: prompt.start.id })
|
||||
return "prompt"
|
||||
}
|
||||
if (prompt.start.type === "compaction") return client.session.compact({ sessionID: session.id, id: prompt.start.id })
|
||||
if (prompt.command) {
|
||||
// The command admits any resulting input under the turn's start ID so the stream can follow it.
|
||||
const outcome = await client.session.command(
|
||||
return client.session.command(
|
||||
{
|
||||
sessionID: session.id,
|
||||
name: prompt.command.name,
|
||||
id: prompt.start.id,
|
||||
text: prompt.slash?.args ?? "",
|
||||
files: prompt.files,
|
||||
delivery: "steer",
|
||||
},
|
||||
{ signal },
|
||||
)
|
||||
return outcome.type
|
||||
}
|
||||
await client.session.prompt(
|
||||
return client.session.prompt(
|
||||
{ sessionID: session.id, id: prompt.start.id, text: prompt.text, files: prompt.files, delivery: "steer" },
|
||||
{ signal },
|
||||
)
|
||||
return "prompt"
|
||||
}
|
||||
|
||||
function turnStart(messageID: string, slash: PreparedPrompt["slash"]): TurnStart {
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
import type { PromptResponse, SessionNotification } from "@agentclientprotocol/sdk"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createAcpFixture, expectOk, initialize, lastUserText, newSession, type ChatRequest } from "./subprocess"
|
||||
|
||||
describe("acp slash command subprocess", () => {
|
||||
test("template command stays pending until its model work completes and streams the output", async () => {
|
||||
const script = scriptedModel()
|
||||
await using fixture = await createAcpFixture({
|
||||
respond: script.respond,
|
||||
config: { commands: { audit: { description: "Audit the change", template: "Audit $ARGUMENTS" } } },
|
||||
})
|
||||
const acp = fixture.spawn()
|
||||
await initialize(acp)
|
||||
const session = await newSession(acp, fixture.home)
|
||||
|
||||
const gate = script.hold()
|
||||
const pending = acp
|
||||
.request<PromptResponse>("session/prompt", {
|
||||
sessionId: session.sessionId,
|
||||
prompt: [{ type: "text", text: "/audit now" }],
|
||||
})
|
||||
.then(expectOk)
|
||||
await gate.started
|
||||
expect(lastUserText(fixture.llm.requests.at(-1)!)).toBe("Audit now")
|
||||
// The response is held, so the prompt must still be pending.
|
||||
expect(await Promise.race([pending.then(() => "settled"), Bun.sleep(200).then(() => "pending")])).toBe("pending")
|
||||
|
||||
gate.release("COMMAND_RESPONSE")
|
||||
expect((await pending).stopReason).toBe("end_turn")
|
||||
const chunk = await acp.waitForNotification<SessionNotification>("session/update", (params) =>
|
||||
isAgentText(params, "COMMAND_RESPONSE"),
|
||||
)
|
||||
expect(chunk.params.sessionId).toBe(session.sessionId)
|
||||
}, 60_000)
|
||||
|
||||
test("subagent command follows the child and the parent's follow-up before completing", async () => {
|
||||
const script = scriptedModel()
|
||||
await using fixture = await createAcpFixture({
|
||||
respond: script.respond,
|
||||
config: {
|
||||
commands: { audit: { description: "Audit in a subagent", template: "Audit $ARGUMENTS", subagent: true } },
|
||||
},
|
||||
})
|
||||
const acp = fixture.spawn()
|
||||
await initialize(acp)
|
||||
const session = await newSession(acp, fixture.home)
|
||||
|
||||
const child = script.hold()
|
||||
const parent = script.hold()
|
||||
const pending = acp
|
||||
.request<PromptResponse>("session/prompt", {
|
||||
sessionId: session.sessionId,
|
||||
prompt: [{ type: "text", text: "/audit now" }],
|
||||
})
|
||||
.then(expectOk)
|
||||
await child.started
|
||||
expect(lastUserText(fixture.llm.requests.at(-1)!)).toBe("You are a subagent spawned by another session.\nAudit now")
|
||||
child.release("CHILD_DONE")
|
||||
await parent.started
|
||||
expect(lastUserText(fixture.llm.requests.at(-1)!)).toContain("CHILD_DONE")
|
||||
expect(await Promise.race([pending.then(() => "settled"), Bun.sleep(200).then(() => "pending")])).toBe("pending")
|
||||
|
||||
parent.release("PARENT_DONE")
|
||||
expect((await pending).stopReason).toBe("end_turn")
|
||||
const childChunk = await acp.waitForNotification<SessionNotification>("session/update", (params) =>
|
||||
isAgentText(params, "CHILD_DONE"),
|
||||
)
|
||||
expect(childChunk.params.update._meta?.["opencode/child-session"]).toMatchObject({ parentID: session.sessionId })
|
||||
const parentChunk = await acp.waitForNotification<SessionNotification>("session/update", (params) =>
|
||||
isAgentText(params, "PARENT_DONE"),
|
||||
)
|
||||
expect(parentChunk.params.update._meta?.["opencode/child-session"]).toBeUndefined()
|
||||
}, 60_000)
|
||||
|
||||
test("cancelling during subagent command work interrupts the child and the parent's follow-up", async () => {
|
||||
const script = scriptedModel()
|
||||
await using fixture = await createAcpFixture({
|
||||
respond: script.respond,
|
||||
config: {
|
||||
commands: { audit: { description: "Audit in a subagent", template: "Audit $ARGUMENTS", subagent: true } },
|
||||
},
|
||||
})
|
||||
const acp = fixture.spawn()
|
||||
await initialize(acp)
|
||||
const session = await newSession(acp, fixture.home)
|
||||
|
||||
const child = script.hold()
|
||||
const parent = script.hold()
|
||||
const pending = acp
|
||||
.request<PromptResponse>("session/prompt", {
|
||||
sessionId: session.sessionId,
|
||||
prompt: [{ type: "text", text: "/audit now" }],
|
||||
})
|
||||
.then(expectOk)
|
||||
await child.started
|
||||
await acp.notify("session/cancel", { sessionId: session.sessionId })
|
||||
|
||||
// Both model responses stay held, so the prompt can only settle by interrupting the child and
|
||||
// the parent's follow-up run that the cancelled-subagent notice wakes.
|
||||
expect((await pending).stopReason).toBe("cancelled")
|
||||
child.release("LATE")
|
||||
parent.release("LATE")
|
||||
const followUp = expectOk(
|
||||
await acp.request<PromptResponse>("session/prompt", {
|
||||
sessionId: session.sessionId,
|
||||
prompt: [{ type: "text", text: "hello again" }],
|
||||
}),
|
||||
)
|
||||
expect(followUp.stopReason).toBe("end_turn")
|
||||
}, 60_000)
|
||||
})
|
||||
|
||||
/** Holds agent-loop model responses in FIFO order; auxiliary requests (no tools) answer immediately. */
|
||||
function scriptedModel() {
|
||||
const holds: Array<{ started: () => void; released: Promise<string> }> = []
|
||||
return {
|
||||
respond: (request: ChatRequest) => {
|
||||
const next = request.tools ? holds.shift() : undefined
|
||||
if (!next) return "accepted"
|
||||
next.started()
|
||||
return next.released
|
||||
},
|
||||
hold() {
|
||||
const started = Promise.withResolvers<void>()
|
||||
const released = Promise.withResolvers<string>()
|
||||
holds.push({ started: started.resolve, released: released.promise })
|
||||
return { started: started.promise, release: released.resolve }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function isAgentText(params: SessionNotification, text: string) {
|
||||
return (
|
||||
params.update.sessionUpdate === "agent_message_chunk" &&
|
||||
params.update.content.type === "text" &&
|
||||
params.update.content.text === text
|
||||
)
|
||||
}
|
||||
@@ -656,7 +656,6 @@ describe("acp event behavior", () => {
|
||||
{ signal },
|
||||
)
|
||||
submitted.resolve()
|
||||
return "prompt"
|
||||
},
|
||||
})
|
||||
|
||||
@@ -695,9 +694,10 @@ describe("acp event behavior", () => {
|
||||
writeTextFile: false,
|
||||
control,
|
||||
submit: (signal) =>
|
||||
fixture.client.session
|
||||
.prompt({ sessionID: "ses_cancel_admission", id: "input_cancel_admission", text: "cancel me" }, { signal })
|
||||
.then(() => "prompt" as const),
|
||||
fixture.client.session.prompt(
|
||||
{ sessionID: "ses_cancel_admission", id: "input_cancel_admission", text: "cancel me" },
|
||||
{ signal },
|
||||
),
|
||||
})
|
||||
|
||||
try {
|
||||
@@ -784,9 +784,7 @@ function turn(input: {
|
||||
control: { cancelled: false, admission: new AbortController() },
|
||||
childSessionUpdate: input.childSessionUpdate,
|
||||
submit: (signal) =>
|
||||
input.fixture.client.session
|
||||
.prompt({ sessionID: input.sessionID, id: input.inboxID, text: "hello" }, { signal })
|
||||
.then(() => "prompt" as const),
|
||||
input.fixture.client.session.prompt({ sessionID: input.sessionID, id: input.inboxID, text: "hello" }, { signal }),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -98,7 +98,7 @@ test("acp prompt resolves after ordered turn updates", async () => {
|
||||
start: { type: "input", id },
|
||||
writeTextFile: false,
|
||||
control: { cancelled: false, admission: new AbortController() },
|
||||
submit: () => client.session.prompt({ sessionID: "ses_test", id, text: "hi" }).then(() => "prompt" as const),
|
||||
submit: () => client.session.prompt({ sessionID: "ses_test", id, text: "hi" }),
|
||||
})
|
||||
|
||||
expect(updates).toEqual([
|
||||
@@ -122,7 +122,7 @@ test("acp prompt resolves after ordered turn updates", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("acp immediate admission resolves without prompt lifecycle events", async () => {
|
||||
test("acp action resolves without prompt lifecycle events", async () => {
|
||||
const encoder = new TextEncoder()
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
@@ -150,8 +150,9 @@ test("acp immediate admission resolves without prompt lifecycle events", async (
|
||||
cwd: "/workspace",
|
||||
start: { type: "input", id: "msg_action" },
|
||||
writeTextFile: false,
|
||||
action: true,
|
||||
control: { cancelled: false, admission: new AbortController() },
|
||||
submit: async () => "immediate",
|
||||
submit: async () => {},
|
||||
})
|
||||
|
||||
expect(response).toMatchObject({ stopReason: "end_turn" })
|
||||
|
||||
@@ -547,10 +547,7 @@ function startTurn(fixture: Fixture, connection: Connection, sessionID: string,
|
||||
start: { type: "input", id: inboxID },
|
||||
writeTextFile: true,
|
||||
control: { cancelled: false, admission: new AbortController() },
|
||||
submit: (signal) =>
|
||||
fixture.client.session
|
||||
.prompt({ sessionID, id: inboxID, text: "hello" }, { signal })
|
||||
.then(() => "prompt" as const),
|
||||
submit: (signal) => fixture.client.session.prompt({ sessionID, id: inboxID, text: "hello" }, { signal }),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ describe("acp service prompt routing and usage", () => {
|
||||
return Response.json({ data: makeSession("ses_routes") })
|
||||
}
|
||||
if (request.method === "POST" && request.path === "/api/session/ses_routes/command") {
|
||||
return Response.json({ data: { type: "immediate" } })
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
if (request.method === "POST" && request.path === "/api/session/ses_routes/compact") {
|
||||
const id = requestID(request)
|
||||
@@ -42,7 +42,6 @@ describe("acp service prompt routing and usage", () => {
|
||||
const compact = fixture.requests.find((request) => request.path === "/api/session/ses_routes/compact")
|
||||
expect(command?.body).toMatchObject({
|
||||
name: "review",
|
||||
id: expect.stringMatching(/^msg_/),
|
||||
text: "now",
|
||||
files: [],
|
||||
delivery: "steer",
|
||||
|
||||
@@ -4,7 +4,6 @@ import type {
|
||||
SessionConfigOption,
|
||||
SessionConfigSelectOption,
|
||||
} from "@agentclientprotocol/sdk"
|
||||
import { Schema } from "effect"
|
||||
import fs from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
@@ -47,7 +46,6 @@ type Waiter = {
|
||||
|
||||
export type AcpProcess = {
|
||||
readonly request: <T>(method: string, params?: unknown) => Promise<JsonRpcResponse<T>>
|
||||
readonly notify: (method: string, params?: unknown) => Promise<void>
|
||||
readonly waitForNotification: <T>(
|
||||
method: string,
|
||||
predicate: (params: T) => boolean,
|
||||
@@ -66,38 +64,7 @@ description: Verifier compatibility skill.
|
||||
# Verifier Skill
|
||||
`
|
||||
|
||||
/** The subset of an OpenAI-compatible chat request that tests inspect. */
|
||||
const ChatRequest = Schema.Struct({
|
||||
messages: Schema.Array(
|
||||
Schema.Struct({
|
||||
role: Schema.String,
|
||||
content: Schema.Union([
|
||||
Schema.String,
|
||||
Schema.Array(Schema.Struct({ text: Schema.String.pipe(Schema.optional) })),
|
||||
]).pipe(Schema.optional),
|
||||
}),
|
||||
),
|
||||
tools: Schema.Array(Schema.Unknown).pipe(Schema.optional),
|
||||
})
|
||||
export type ChatRequest = typeof ChatRequest.Type
|
||||
const decodeChatRequest = Schema.decodeUnknownSync(ChatRequest)
|
||||
|
||||
/** Text of the most recent user message. */
|
||||
export function lastUserText(request: ChatRequest) {
|
||||
const content = request.messages.findLast((message) => message.role === "user")?.content
|
||||
if (!Array.isArray(content)) return content
|
||||
return content.flatMap((part) => (part.text === undefined ? [] : [part.text])).join("")
|
||||
}
|
||||
|
||||
export type FixtureOptions = {
|
||||
readonly skill?: string
|
||||
/** Extra opencode.json entries merged over the verifier config. */
|
||||
readonly config?: Record<string, Schema.Json>
|
||||
/** Produce the scripted completion text; awaiting here holds the model response. */
|
||||
readonly respond?: (request: ChatRequest) => string | Promise<string>
|
||||
}
|
||||
|
||||
export async function createAcpFixture(options: FixtureOptions = {}) {
|
||||
export async function createAcpFixture(options: { readonly skill?: string } = {}) {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-cli-acp-"))
|
||||
const home = path.join(root, "workspace")
|
||||
const config = path.join(root, "config")
|
||||
@@ -109,7 +76,7 @@ export async function createAcpFixture(options: FixtureOptions = {}) {
|
||||
await Bun.write(path.join(skills, "verifier-skill", "SKILL.md"), options.skill)
|
||||
}
|
||||
|
||||
const requests: ChatRequest[] = []
|
||||
const requests: unknown[] = []
|
||||
const llm = Bun.serve({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
@@ -117,19 +84,15 @@ export async function createAcpFixture(options: FixtureOptions = {}) {
|
||||
if (request.method !== "POST" || new URL(request.url).pathname !== "/v1/chat/completions") {
|
||||
return new Response("Not found", { status: 404 })
|
||||
}
|
||||
const body = decodeChatRequest(await request.json())
|
||||
requests.push(body)
|
||||
return new Response(completion(await (options.respond?.(body) ?? "accepted")), {
|
||||
requests.push(await request.json().catch(() => undefined))
|
||||
return new Response(completion("accepted"), {
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
})
|
||||
},
|
||||
})
|
||||
await Bun.write(
|
||||
path.join(config, "opencode.json"),
|
||||
JSON.stringify({
|
||||
...verifierConfig(`http://127.0.0.1:${llm.port}/v1`, options.skill ? skills : undefined),
|
||||
...options.config,
|
||||
}),
|
||||
JSON.stringify(verifierConfig(`http://127.0.0.1:${llm.port}/v1`, options.skill ? skills : undefined)),
|
||||
)
|
||||
await Bun.write(models, "{}")
|
||||
|
||||
@@ -343,12 +306,6 @@ function spawnAcp(input: { readonly env: Record<string, string | undefined> }):
|
||||
if (!isResponse<T>(response)) throw new Error(`Invalid ACP response: ${JSON.stringify(response)}`)
|
||||
return response
|
||||
},
|
||||
async notify(method: string, params?: unknown) {
|
||||
if (inputClosed) throw new Error("ACP stdin is closed")
|
||||
const notification: JsonRpcNotification<unknown> = { jsonrpc: "2.0", method, params }
|
||||
await child.stdin.write(encoder.encode(`${JSON.stringify(notification)}\n`))
|
||||
await child.stdin.flush()
|
||||
},
|
||||
async waitForNotification<T>(method: string, predicate: (params: T) => boolean, timeoutMs = 20_000) {
|
||||
const notification = await take(
|
||||
(message) => isNotification<T>(message) && message.method === method && predicate(message.params),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/client",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.5",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -15,7 +15,6 @@ import type { SessionMessage } from "@opencode/schema/session-message"
|
||||
import type { SessionInbox } from "@opencode/schema/session-inbox"
|
||||
import type { PromptInput } from "@opencode/schema/prompt-input"
|
||||
import type { AgentAttachment } from "@opencode/schema/prompt"
|
||||
import type { Command } from "@opencode/schema/command"
|
||||
import type { Skill } from "@opencode/schema/skill"
|
||||
import type { FileDiff } from "@opencode/schema/file-diff"
|
||||
import type { InstructionEntry } from "@opencode/schema/instruction-entry"
|
||||
@@ -30,6 +29,7 @@ import type { Mcp } from "@opencode/schema/mcp"
|
||||
import type { Credential } from "@opencode/schema/credential"
|
||||
import type { PermissionSaved } from "@opencode/schema/permission-saved"
|
||||
import type { FileSystem } from "@opencode/schema/filesystem"
|
||||
import type { Command } from "@opencode/schema/command"
|
||||
import type { OpenCodeEvent } from "@opencode/protocol/groups/event"
|
||||
import type { Pty } from "@opencode/schema/pty"
|
||||
import type { PtyTicket } from "@opencode/schema/pty-ticket"
|
||||
@@ -266,14 +266,13 @@ export type SessionPromptOperation<E = never> = (input: SessionPromptInput) => E
|
||||
export type SessionCommandInput = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly name: string
|
||||
readonly id?: SessionMessage.ID | undefined
|
||||
readonly text: string
|
||||
readonly files?: ReadonlyArray<PromptInput.FileAttachment> | undefined
|
||||
readonly agents?: ReadonlyArray<AgentAttachment> | undefined
|
||||
readonly skills?: ReadonlyArray<PromptInput.SkillAttachment> | undefined
|
||||
readonly delivery?: SessionInbox.Delivery | undefined
|
||||
}
|
||||
export type SessionCommandOutput = Command.Outcome
|
||||
export type SessionCommandOutput = void
|
||||
export type SessionCommandOperation<E = never> = (input: SessionCommandInput) => Effect.Effect<SessionCommandOutput, E>
|
||||
|
||||
export type SessionSkillInput = {
|
||||
|
||||
@@ -484,17 +484,13 @@ const EndpointSessionCommand = (raw: RawClient["server.session"]) => (input: Ses
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: {
|
||||
name: input["name"],
|
||||
id: input["id"],
|
||||
text: input["text"],
|
||||
files: input["files"],
|
||||
agents: input["agents"],
|
||||
skills: input["skills"],
|
||||
delivery: input["delivery"],
|
||||
},
|
||||
}).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const EndpointSessionSkill = (raw: RawClient["server.session"]) => (input: SessionSkillInput) =>
|
||||
|
||||
@@ -682,25 +682,24 @@ export function make(options: ClientOptions) {
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
command: (input: SessionCommandInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: SessionCommandOutput }>(
|
||||
request<SessionCommandOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/command`,
|
||||
body: {
|
||||
name: input["name"],
|
||||
id: input["id"],
|
||||
text: input["text"],
|
||||
files: input["files"],
|
||||
agents: input["agents"],
|
||||
skills: input["skills"],
|
||||
delivery: input["delivery"],
|
||||
},
|
||||
successStatus: 200,
|
||||
successStatus: 204,
|
||||
declaredStatuses: [400, 401, 404, 500],
|
||||
empty: false,
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
),
|
||||
skill: (input: SessionSkillInput, requestOptions?: RequestOptions) =>
|
||||
request<SessionSkillOutput>(
|
||||
{
|
||||
|
||||
@@ -161,8 +161,6 @@ export type SessionActive = { type: "running" }
|
||||
|
||||
export type SessionInboxDelivery = "steer" | "queue"
|
||||
|
||||
export type CommandOutcome = { type: "immediate" } | { type: "prompt"; inboxID: string }
|
||||
|
||||
export type SessionInboxSyntheticPayload = { text: string; description?: string; metadata?: { [x: string]: JsonValue } }
|
||||
|
||||
export type SessionInboxCompactionPayload = {}
|
||||
@@ -4109,7 +4107,6 @@ export type SessionCommandInput = {
|
||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||
readonly name: {
|
||||
readonly name: string
|
||||
readonly id?: string | null
|
||||
readonly text: string
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly uri: string
|
||||
@@ -4127,29 +4124,8 @@ export type SessionCommandInput = {
|
||||
}>
|
||||
readonly delivery?: ("steer" | "queue") | null
|
||||
}["name"]
|
||||
readonly id?: {
|
||||
readonly name: string
|
||||
readonly id?: string | null
|
||||
readonly text: string
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly uri: string
|
||||
readonly name?: string
|
||||
readonly description?: string
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly agents?: ReadonlyArray<{
|
||||
readonly name: string
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly skills?: ReadonlyArray<{
|
||||
readonly id: string
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly delivery?: ("steer" | "queue") | null
|
||||
}["id"]
|
||||
readonly text: {
|
||||
readonly name: string
|
||||
readonly id?: string | null
|
||||
readonly text: string
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly uri: string
|
||||
@@ -4169,7 +4145,6 @@ export type SessionCommandInput = {
|
||||
}["text"]
|
||||
readonly files?: {
|
||||
readonly name: string
|
||||
readonly id?: string | null
|
||||
readonly text: string
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly uri: string
|
||||
@@ -4189,7 +4164,6 @@ export type SessionCommandInput = {
|
||||
}["files"]
|
||||
readonly agents?: {
|
||||
readonly name: string
|
||||
readonly id?: string | null
|
||||
readonly text: string
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly uri: string
|
||||
@@ -4209,7 +4183,6 @@ export type SessionCommandInput = {
|
||||
}["agents"]
|
||||
readonly skills?: {
|
||||
readonly name: string
|
||||
readonly id?: string | null
|
||||
readonly text: string
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly uri: string
|
||||
@@ -4229,7 +4202,6 @@ export type SessionCommandInput = {
|
||||
}["skills"]
|
||||
readonly delivery?: {
|
||||
readonly name: string
|
||||
readonly id?: string | null
|
||||
readonly text: string
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly uri: string
|
||||
@@ -4249,7 +4221,7 @@ export type SessionCommandInput = {
|
||||
}["delivery"]
|
||||
}
|
||||
|
||||
export type SessionCommandOutput = { data: CommandOutcome }["data"]
|
||||
export type SessionCommandOutput = void
|
||||
|
||||
export type SessionSkillInput = {
|
||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||
|
||||
@@ -13,8 +13,8 @@ The idea of code mode was originally introduced by Cloudflare. See
|
||||
|
||||
## How it differs from JavaScript
|
||||
|
||||
- **Only supported APIs are available.** Programs can use the provided tools and supported JavaScript built-ins. APIs
|
||||
such as `fetch`, timers, `process`, filesystem access, imports, and modules are unavailable.
|
||||
- **Only supported APIs are available.** Programs can use the provided tools, supported JavaScript built-ins, and the
|
||||
globals the host adds through extensions. Timers, `process`, filesystem access, imports, and modules are unavailable.
|
||||
- **Unfinished work is interrupted.** Tool calls and async functions start when called. When the program finishes,
|
||||
anything still running is interrupted. Unhandled rejections from un-awaited promises are returned as warnings.
|
||||
- **REPL-style results.** Without an explicit `return`, the final top-level expression becomes the result. `undefined`
|
||||
@@ -94,11 +94,19 @@ receive `{ extension, name, args }`. An `after` hook also receives how the call
|
||||
`failure` with its error, or `interrupted`). A failing `before` hook denies the call, and the program catches the
|
||||
failure as a thrown error.
|
||||
|
||||
### `Values`
|
||||
### `Extension.make`
|
||||
|
||||
`Values` exports the runtime's non-JSON value classes: `Values.URL`, `Values.URLSearchParams`, `Values.Date`,
|
||||
`Values.RegExp`, `Values.Map`, `Values.Set`, and `Values.Promise`. The interpreter recognizes these by class; a
|
||||
program's `new URL(...)` is a `Values.URL` wrapping the host `URL`. `Values.isValue` narrows to the data-like kinds.
|
||||
Extensions are host functions a program calls directly as globals, such as `fetch`. Unlike tools they are not in the
|
||||
catalog, not counted against `maxToolCalls`, and not described to the model; the host decides what they mean.
|
||||
|
||||
```ts
|
||||
const web = Extension.make({ name: "web", globals: { fetch: (url: string) => globalThis.fetch(url) } })
|
||||
const runtime = CodeMode.make({ tools, extensions: [web] })
|
||||
```
|
||||
|
||||
Every value crossing in either direction is converted, never shared: arguments come in as copies, results go out as
|
||||
copies, and a function inside a result is callable the same way. A global that shadows a built-in or another
|
||||
extension throws at `CodeMode.make`.
|
||||
|
||||
### OpenAPI tools
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
Uint8Array is rejected with a hint to encode as text, and own `__proto__` keys are dropped so merging tool
|
||||
inputs or results cannot replace a prototype. In-program `JSON.stringify` keeps JS behavior except for the
|
||||
Error form and a promise, which is a `TypeError` with an await hint rather than a silent `{}`.
|
||||
- [x] Live Date, RegExp, Map, Set, URL, URLSearchParams, and Uint8Array values inside CodeMode.
|
||||
- [x] Live Date, RegExp, Map, Set, URL, URLSearchParams, Headers, and Uint8Array 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.
|
||||
@@ -47,8 +47,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
## Values and literals
|
||||
|
||||
- [x] `null`, `undefined`, booleans, finite and non-finite numbers, and strings.
|
||||
- [x] Array literals, including holes and spread from arrays, strings, Maps, Sets, URLSearchParams, custom synchronous
|
||||
iterators, and synchronous generators.
|
||||
- [x] Array literals, including holes and spread from arrays, strings, Maps, Sets, URLSearchParams, Headers, custom
|
||||
synchronous iterators, and synchronous generators.
|
||||
- [x] Object literals with shorthand, computed string/number keys, and spread following ToObject: data objects and
|
||||
arrays copy own enumerable keys, strings copy index keys, and other values contribute nothing.
|
||||
- [x] Template literals with interpolation.
|
||||
@@ -95,8 +95,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
- [x] `if`/`else` and conditional expressions.
|
||||
- [x] `switch`, including default clauses and fallthrough.
|
||||
- [x] `for`, `while`, and `do...while`.
|
||||
- [x] `for...of` over arrays, strings, Maps, Sets, URLSearchParams, custom synchronous iterators, and confined
|
||||
synchronous generators. Abrupt completion invokes the iterator's optional `return()`.
|
||||
- [x] `for...of` over arrays, strings, Maps, Sets, URLSearchParams, Headers, custom synchronous iterators, and
|
||||
confined synchronous generators. Abrupt completion invokes the iterator's optional `return()`.
|
||||
- [x] `for...in` over own keys of plain objects, arrays, strings, and tool references; other values iterate nothing.
|
||||
- [x] Unlabeled `break` and `continue`.
|
||||
- [x] `try`, `catch`, optional catch bindings, and `finally`.
|
||||
@@ -127,7 +127,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
string). A detached method loses its receiver, as in JS: `values.filter("abc".includes)` is a `TypeError`
|
||||
because `includes` is called without a string `this`.
|
||||
- [x] Constructors work as callbacks with JS call semantics: `Error` types construct (`messages.map(Error)`),
|
||||
and new-requiring constructors (`Map`, `Set`, `URL`, `URLSearchParams`, `Promise`) throw a `TypeError`,
|
||||
and new-requiring constructors (`Map`, `Set`, `URL`, `URLSearchParams`, `Headers`, `Promise`) throw a `TypeError`,
|
||||
like JS.
|
||||
- [x] Tool references and detached `Promise` statics are rejected as callbacks with a hint to wrap them in an
|
||||
arrow function.
|
||||
@@ -179,10 +179,10 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
- [x] Sequence expressions (the comma operator).
|
||||
- [x] `await` for CodeMode promises and callable thenables; a plain value passes through unchanged, though every
|
||||
`await` still defers its continuation one reaction turn.
|
||||
- [x] `new` for Array, Object, Error types, Date, RegExp, Map, Set, URL, URLSearchParams, and Promise. `new` on any
|
||||
other value throws a catchable `TypeError` naming the callee: other built-in functions such as `Number` say
|
||||
`new` is unsupported and point at the plain call, user-defined functions report the constructor gap below, and
|
||||
non-callable values are not constructors.
|
||||
- [x] `new` for Array, Object, Error types, Date, RegExp, Map, Set, URL, URLSearchParams, Headers, and Promise. `new`
|
||||
on any other value throws a catchable `TypeError` naming the callee: other built-in functions such as `Number`
|
||||
say `new` is unsupported and point at the plain call, user-defined functions report the constructor gap below,
|
||||
and non-callable values are not constructors.
|
||||
- [x] Arithmetic operators: `+`, `-`, `*`, `/`, `%`, and `**`.
|
||||
- [x] Equality and ordering: `==`, `!=`, `===`, `!==`, `<`, `<=`, `>`, and `>=`.
|
||||
- [x] Bitwise operators: `&`, `|`, `^`, `~`, `<<`, `>>`, and `>>>`.
|
||||
@@ -450,7 +450,13 @@ with a hint to encode as text first (`TextDecoder`, `toBase64`, `toHex`).
|
||||
- [x] `crypto.randomUUID()` and `crypto.getRandomValues(uint8Array)`.
|
||||
- [x] `TextEncoder` and `TextDecoder` for UTF-8 only: any other label is a `RangeError`. `TextDecoder` accepts the
|
||||
`fatal` and `ignoreBOM` options; `decode` takes a Uint8Array or nothing.
|
||||
- [ ] `crypto.subtle`, `Blob`, and `TextDecoder` streaming or non-UTF-8 encodings.
|
||||
- [x] `new Headers()` from records, synchronous iterables of pairs, and Headers, wrapping the host's `Headers`: names
|
||||
fold to lowercase, values are normalized and combined, and invalid names or values throw a `TypeError`.
|
||||
- [x] Headers `append`, `delete`, `get`, `getSetCookie`, `has`, `set`, `forEach`, `keys`, `values`, and `entries`;
|
||||
iteration is live and sorted by name, with `set-cookie` values kept apart.
|
||||
- [x] Headers serialize to a `{ name: value }` object in JSON, in results, and in tool arguments.
|
||||
- [ ] `Request`, `Response`, and `Blob`.
|
||||
- [ ] `crypto.subtle` and `TextDecoder` streaming or non-UTF-8 encodings.
|
||||
|
||||
## Extensions
|
||||
|
||||
@@ -460,8 +466,8 @@ Nothing is exposed unless a host provides it; extension calls are not tool calls
|
||||
- [x] Each global is a function, callable but not constructible, run with `this` undefined. A global that shadows
|
||||
a built-in or another extension throws at `make`.
|
||||
- [x] Every value crossing in either direction is converted, never shared: plain objects and arrays are copied,
|
||||
`Date`, `RegExp`, `URL`, `URLSearchParams`, `Map`, `Set`, and `Uint8Array` become fresh copies with their
|
||||
contents converted (a host `ArrayBuffer` comes in as a `Uint8Array`; other typed arrays cannot come out),
|
||||
`Date`, `RegExp`, `URL`, `URLSearchParams`, `Headers`, `Map`, `Set`, and `Uint8Array` become fresh copies with
|
||||
their contents converted (a host `ArrayBuffer` comes in as a `Uint8Array`; other typed arrays cannot come out),
|
||||
errors cross as errors with their name and message, and a `__proto__` key is dropped. Functions, generators,
|
||||
un-awaited promises, and symbols cannot be passed in; a class instance, a symbol, or a BigInt cannot come out.
|
||||
- [x] A host function inside a result becomes a program function whose calls cross the same way, so a result can
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/codemode",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.5",
|
||||
"description": "Effect-native confined code execution over schema-described tools",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
record,
|
||||
SetObj,
|
||||
URLSearchParamsObj,
|
||||
HeadersObj,
|
||||
} from "./interpreter/objects.js"
|
||||
import { typeofValue } from "./interpreter/references.js"
|
||||
|
||||
@@ -69,6 +70,7 @@ const walk = <R>(
|
||||
)
|
||||
}
|
||||
if (boundary && value instanceof URLSearchParamsObj) return value.params.toString()
|
||||
if (value instanceof HeadersObj) return Object.fromEntries(value.headers)
|
||||
const target = boundary && value instanceof SetObj ? new Arr(ctx.builtins.Array, [...value.set]) : value
|
||||
if (stack.has(target)) throw typeError("Converting circular structure to JSON.")
|
||||
stack.add(target)
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
SetObj,
|
||||
URLObj,
|
||||
URLSearchParamsObj,
|
||||
HeadersObj,
|
||||
} from "./objects.js"
|
||||
import { describeValue } from "./references.js"
|
||||
|
||||
@@ -49,6 +50,7 @@ export const extensionGlobals = <R>(
|
||||
if (value instanceof RegExpObj) return new RegExp(value.regex.source, value.regex.flags)
|
||||
if (value instanceof URLObj) return new URL(value.url.href)
|
||||
if (value instanceof URLSearchParamsObj) return new URLSearchParams(value.params)
|
||||
if (value instanceof HeadersObj) return new Headers(value.headers)
|
||||
const next = (item: unknown) => toHost(item, label, depth + 1, seen)
|
||||
if (value instanceof MapObj) return new Map([...value.map].map(([key, item]) => [next(key), next(item)]))
|
||||
if (value instanceof SetObj) return new Set([...value.set].map(next))
|
||||
@@ -96,6 +98,7 @@ export const extensionGlobals = <R>(
|
||||
if (value instanceof URLSearchParams) {
|
||||
return new URLSearchParamsObj(builtins.URLSearchParams, new URLSearchParams(value))
|
||||
}
|
||||
if (value instanceof Headers) return new HeadersObj(builtins.Headers, new Headers(value))
|
||||
const next = (item: unknown, path: string) => fromHost(item, path, depth + 1, seen)
|
||||
if (value instanceof Map) {
|
||||
const wrapped = new MapObj(builtins.Map)
|
||||
|
||||
@@ -11,6 +11,7 @@ import { objectGlobal } from "../stdlib/object.js"
|
||||
import { regexpGlobal } from "../stdlib/regexp.js"
|
||||
import { stringGlobal } from "../stdlib/string.js"
|
||||
import { uriGlobal, urlGlobal, urlSearchParamsGlobal } from "../stdlib/url.js"
|
||||
import { headersGlobal } from "../stdlib/headers.js"
|
||||
import { coercion } from "../stdlib/value.js"
|
||||
import { base64Global, cryptoGlobal } from "../stdlib/web.js"
|
||||
import { ToolReference } from "../tool-runtime.js"
|
||||
@@ -80,6 +81,7 @@ const table: Record<string, Factory> = {
|
||||
Set: (ctx) => setGlobal(ctx),
|
||||
URL: (ctx) => urlGlobal(ctx),
|
||||
URLSearchParams: (ctx) => urlSearchParamsGlobal(ctx),
|
||||
Headers: (ctx) => headersGlobal(ctx),
|
||||
Uint8Array: (ctx) => uint8ArrayGlobal(ctx),
|
||||
TextEncoder: (ctx) => textEncoderGlobal(ctx),
|
||||
TextDecoder: (ctx) => textDecoderGlobal(ctx),
|
||||
|
||||
@@ -84,6 +84,7 @@ import {
|
||||
PromiseObj,
|
||||
SetObj,
|
||||
URLSearchParamsObj,
|
||||
HeadersObj,
|
||||
record,
|
||||
remove,
|
||||
set,
|
||||
@@ -653,7 +654,7 @@ class Frame<R> {
|
||||
const cursor = iterator === undefined ? yield* self.iterate(right, node) : undefined
|
||||
if (iterator === undefined && cursor === undefined) {
|
||||
throw invalidData(
|
||||
`${awaiting ? "for await...of" : "for...of"} requires an array, string, Map, Set, or URLSearchParams, or custom iterator value.`,
|
||||
`${awaiting ? "for await...of" : "for...of"} requires an array, string, Map, Set, URLSearchParams, or Headers, or custom iterator value.`,
|
||||
node,
|
||||
)
|
||||
}
|
||||
@@ -756,9 +757,11 @@ class Frame<R> {
|
||||
? value.set.values()
|
||||
: value instanceof URLSearchParamsObj
|
||||
? value.params.entries()
|
||||
: value instanceof Bytes
|
||||
? value.bytes.values()
|
||||
: undefined
|
||||
: value instanceof HeadersObj
|
||||
? value.headers.entries()
|
||||
: value instanceof Bytes
|
||||
? value.bytes.values()
|
||||
: undefined
|
||||
if (iterator !== undefined) {
|
||||
const proto = this.ctx.builtins.Array
|
||||
return Effect.succeed({
|
||||
@@ -1848,6 +1851,7 @@ class Frame<R> {
|
||||
value instanceof MapObj ||
|
||||
value instanceof SetObj ||
|
||||
value instanceof URLSearchParamsObj ||
|
||||
value instanceof HeadersObj ||
|
||||
value instanceof Bytes
|
||||
) {
|
||||
const cursor = yield* self.iterate(value, node)
|
||||
|
||||
@@ -29,6 +29,7 @@ const builtins = [
|
||||
"Set",
|
||||
"URL",
|
||||
"URLSearchParams",
|
||||
"Headers",
|
||||
"Uint8Array",
|
||||
"TextEncoder",
|
||||
"TextDecoder",
|
||||
@@ -80,6 +81,7 @@ export const createBuiltins = (): Builtins => {
|
||||
Set: plain(),
|
||||
URL: plain(),
|
||||
URLSearchParams: plain(),
|
||||
Headers: plain(),
|
||||
Uint8Array: plain(),
|
||||
TextEncoder: plain(),
|
||||
TextDecoder: plain(),
|
||||
|
||||
@@ -156,6 +156,15 @@ export class URLSearchParamsObj extends Obj {
|
||||
}
|
||||
}
|
||||
|
||||
export class HeadersObj extends Obj {
|
||||
constructor(
|
||||
proto: Obj,
|
||||
readonly headers: Headers,
|
||||
) {
|
||||
super(proto)
|
||||
}
|
||||
}
|
||||
|
||||
export class URLObj extends Obj {
|
||||
readonly searchParams: URLSearchParamsObj
|
||||
constructor(
|
||||
@@ -181,13 +190,14 @@ export class Bytes extends Obj {
|
||||
/** Built-in objects that wrap a host value; data-like, but never plain data. */
|
||||
export const isWrapper = (
|
||||
value: unknown,
|
||||
): value is DateObj | RegExpObj | MapObj | SetObj | URLObj | URLSearchParamsObj | Bytes =>
|
||||
): value is DateObj | RegExpObj | MapObj | SetObj | URLObj | URLSearchParamsObj | HeadersObj | Bytes =>
|
||||
value instanceof DateObj ||
|
||||
value instanceof RegExpObj ||
|
||||
value instanceof MapObj ||
|
||||
value instanceof SetObj ||
|
||||
value instanceof URLObj ||
|
||||
value instanceof URLSearchParamsObj ||
|
||||
value instanceof HeadersObj ||
|
||||
value instanceof Bytes
|
||||
|
||||
const MAX_ARRAY_INDEX = 4_294_967_295
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
SetObj,
|
||||
URLObj,
|
||||
URLSearchParamsObj,
|
||||
HeadersObj,
|
||||
} from "./objects.js"
|
||||
|
||||
/** Values that cannot cross the data boundary. */
|
||||
@@ -85,6 +86,7 @@ export const describeValue = (value: unknown): string => {
|
||||
if (value instanceof SetObj) return "a Set"
|
||||
if (value instanceof URLObj) return "a URL"
|
||||
if (value instanceof URLSearchParamsObj) return "a URLSearchParams"
|
||||
if (value instanceof HeadersObj) return "a Headers"
|
||||
if (value instanceof Bytes) return "a Uint8Array"
|
||||
if (value instanceof GeneratorObj) return "a generator"
|
||||
if (isRuntimeReference(value)) return "a function"
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
SetObj,
|
||||
URLObj,
|
||||
URLSearchParamsObj,
|
||||
HeadersObj,
|
||||
} from "../interpreter/objects.js"
|
||||
import { containsOpaqueReference, isRuntimeReference } from "../interpreter/references.js"
|
||||
import type { Interpreter } from "../interpreter/interpreter.js"
|
||||
@@ -66,6 +67,7 @@ const formatConsoleValue = (value: unknown, seen: Set<object>, depth: number): s
|
||||
if (value instanceof RegExpObj) return coerceToString(value)
|
||||
if (value instanceof URLObj) return coerceToString(value)
|
||||
if (value instanceof URLSearchParamsObj) return coerceToString(value)
|
||||
if (value instanceof HeadersObj) return `Headers ${JSON.stringify(Object.fromEntries(value.headers))}`
|
||||
if (value instanceof Bytes) return `Uint8Array(${value.bytes.length}) [${value.bytes.join(",")}]`
|
||||
if (depth > MAX_CONSOLE_DEPTH) return "..."
|
||||
if (seen.has(value)) return "[Circular]"
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { Effect } from "effect"
|
||||
import { constructor, methods, prototypeFrom, receiver, requiresNew } from "../interpreter/native.js"
|
||||
import { typeError } from "../interpreter/model.js"
|
||||
import { entries, Arr, HeadersObj, Obj } from "../interpreter/objects.js"
|
||||
import { applyCollectionCallback } from "../interpreter/callback.js"
|
||||
import { isRuntimeReference } from "../interpreter/references.js"
|
||||
import type { Interpreter } from "../interpreter/interpreter.js"
|
||||
import { coerceToString } from "./value.js"
|
||||
import { readPairs } from "./url.js"
|
||||
|
||||
// The host validates header names and values and throws its own TypeError; the program gets one of its own.
|
||||
const attempt = <T>(run: () => T): T => {
|
||||
try {
|
||||
return run()
|
||||
} catch (error) {
|
||||
throw typeError(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
}
|
||||
|
||||
const constructHeaders = <R>(ctx: Interpreter<R>, init: unknown, proto: Obj): Effect.Effect<HeadersObj, unknown, R> => {
|
||||
const wrap = (headers: Headers) => new HeadersObj(proto, headers)
|
||||
if (init === undefined) return Effect.succeed(wrap(new Headers()))
|
||||
return Effect.gen(function* () {
|
||||
const pairs = init instanceof Obj ? yield* readPairs(ctx, init, "new Headers(...)") : undefined
|
||||
if (pairs !== undefined) return wrap(attempt(() => new Headers(pairs)))
|
||||
if (!(init instanceof Obj) || isRuntimeReference(init)) {
|
||||
throw typeError("new Headers(...) expects a record of names to values, iterable [name, value] pairs, or Headers.")
|
||||
}
|
||||
return wrap(
|
||||
attempt(() => new Headers(Object.fromEntries(entries(init).map(([key, value]) => [key, coerceToString(value)])))),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export const headersGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
const builtins = ctx.builtins
|
||||
const proto = builtins.Headers
|
||||
const headers = constructor<R>(builtins, proto, {
|
||||
name: "Headers",
|
||||
call: requiresNew("Headers"),
|
||||
construct: (args, newTarget) => constructHeaders(ctx, args[0], prototypeFrom(newTarget, proto)),
|
||||
})
|
||||
const self = (thisValue: unknown, name: string) => receiver(HeadersObj, thisValue, `Headers.prototype.${name}`)
|
||||
const wrap = (items: Array<unknown>) => new Arr(builtins.Array, items)
|
||||
const arg = (args: Array<unknown>, index: number): string => coerceToString(args[index])
|
||||
const requireArgs = (name: string, args: Array<unknown>, count: number): void => {
|
||||
if (args.length < count) throw typeError(`Headers.${name} requires ${count} argument${count === 1 ? "" : "s"}.`)
|
||||
}
|
||||
methods(builtins, proto, [
|
||||
[
|
||||
"append",
|
||||
2,
|
||||
(thisValue, args) => {
|
||||
requireArgs("append", args, 2)
|
||||
const target = self(thisValue, "append").headers
|
||||
return attempt(() => target.append(arg(args, 0), arg(args, 1)))
|
||||
},
|
||||
],
|
||||
[
|
||||
"delete",
|
||||
1,
|
||||
(thisValue, args) => {
|
||||
requireArgs("delete", args, 1)
|
||||
const target = self(thisValue, "delete").headers
|
||||
return attempt(() => target.delete(arg(args, 0)))
|
||||
},
|
||||
],
|
||||
[
|
||||
"get",
|
||||
1,
|
||||
(thisValue, args) => {
|
||||
requireArgs("get", args, 1)
|
||||
const target = self(thisValue, "get").headers
|
||||
return attempt(() => target.get(arg(args, 0)))
|
||||
},
|
||||
],
|
||||
["getSetCookie", 0, (thisValue) => wrap(self(thisValue, "getSetCookie").headers.getSetCookie())],
|
||||
[
|
||||
"has",
|
||||
1,
|
||||
(thisValue, args) => {
|
||||
requireArgs("has", args, 1)
|
||||
const target = self(thisValue, "has").headers
|
||||
return attempt(() => target.has(arg(args, 0)))
|
||||
},
|
||||
],
|
||||
[
|
||||
"set",
|
||||
2,
|
||||
(thisValue, args) => {
|
||||
requireArgs("set", args, 2)
|
||||
const target = self(thisValue, "set").headers
|
||||
return attempt(() => target.set(arg(args, 0), arg(args, 1)))
|
||||
},
|
||||
],
|
||||
["keys", 0, (thisValue) => wrap(Array.from(self(thisValue, "keys").headers.keys()))],
|
||||
["values", 0, (thisValue) => wrap(Array.from(self(thisValue, "values").headers.values()))],
|
||||
[
|
||||
"entries",
|
||||
0,
|
||||
(thisValue) =>
|
||||
wrap(Array.from(self(thisValue, "entries").headers.entries(), ([key, value]) => wrap([key, value]))),
|
||||
],
|
||||
[
|
||||
"forEach",
|
||||
1,
|
||||
(thisValue, args) => {
|
||||
requireArgs("forEach", args, 1)
|
||||
const target = self(thisValue, "forEach")
|
||||
const apply = applyCollectionCallback(ctx, args[0], "Headers.forEach")
|
||||
return Effect.gen(function* () {
|
||||
for (const [key, value] of Array.from(target.headers.entries())) yield* apply([value, key, target])
|
||||
return undefined
|
||||
})
|
||||
},
|
||||
],
|
||||
])
|
||||
return headers
|
||||
}
|
||||
@@ -107,12 +107,10 @@ export const urlGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
return url
|
||||
}
|
||||
|
||||
const readPair = <R>(ctx: Interpreter<R>, value: unknown): Effect.Effect<Array<string>, unknown, R> =>
|
||||
const readPair = <R>(ctx: Interpreter<R>, value: unknown, label: string): Effect.Effect<Array<string>, unknown, R> =>
|
||||
Effect.gen(function* () {
|
||||
const cursor = yield* ctx.iterate(value)
|
||||
if (cursor === undefined) {
|
||||
throw typeError("new URLSearchParams(...) expects iterable [name, value] pairs.")
|
||||
}
|
||||
if (cursor === undefined) throw typeError(`${label} expects iterable [name, value] pairs.`)
|
||||
const items: Array<string> = []
|
||||
while (true) {
|
||||
const step = yield* cursor.next
|
||||
@@ -126,6 +124,29 @@ const readPair = <R>(ctx: Interpreter<R>, value: unknown): Effect.Effect<Array<s
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Reads a synchronous iterable of `[name, value]` pairs as strings; `undefined` when `init` is not iterable. As in
|
||||
* WebIDL, the whole sequence is converted before any pair's length is checked.
|
||||
*/
|
||||
export const readPairs = <R>(
|
||||
ctx: Interpreter<R>,
|
||||
init: unknown,
|
||||
label: string,
|
||||
): Effect.Effect<Array<[string, string]> | undefined, unknown, R> =>
|
||||
Effect.gen(function* () {
|
||||
const cursor = yield* ctx.iterate(init)
|
||||
if (cursor === undefined) return undefined
|
||||
const pairs: Array<Array<string>> = []
|
||||
while (true) {
|
||||
const step = yield* cursor.next
|
||||
if (step.done) {
|
||||
if (pairs.some((entry) => entry.length !== 2)) throw typeError(`${label} expects iterable [name, value] pairs.`)
|
||||
return pairs as Array<[string, string]>
|
||||
}
|
||||
pairs.push(yield* preserveConsumerError(cursor, readPair(ctx, step.value, label)))
|
||||
}
|
||||
})
|
||||
|
||||
const constructURLSearchParams = <R>(
|
||||
ctx: Interpreter<R>,
|
||||
init: unknown,
|
||||
@@ -139,20 +160,8 @@ const constructURLSearchParams = <R>(
|
||||
return Effect.succeed(wrap(new URLSearchParams(coerceToString(init))))
|
||||
}
|
||||
return Effect.gen(function* () {
|
||||
const cursor = yield* ctx.iterate(init)
|
||||
if (cursor !== undefined) {
|
||||
const pairs: Array<Array<string>> = []
|
||||
while (true) {
|
||||
const step = yield* cursor.next
|
||||
if (step.done) {
|
||||
if (pairs.some((entry) => entry.length !== 2)) {
|
||||
throw typeError("new URLSearchParams(...) expects iterable [name, value] pairs.")
|
||||
}
|
||||
return wrap(new URLSearchParams(pairs.map((entry): [string, string] => [entry[0] ?? "", entry[1] ?? ""])))
|
||||
}
|
||||
pairs.push(yield* preserveConsumerError(cursor, readPair(ctx, step.value)))
|
||||
}
|
||||
}
|
||||
const pairs = yield* readPairs(ctx, init, "new URLSearchParams(...)")
|
||||
if (pairs !== undefined) return wrap(new URLSearchParams(pairs))
|
||||
if (isRuntimeReference(init)) {
|
||||
throw typeError("new URLSearchParams(...) expects a query string, data object, or synchronous iterable pairs.")
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
SetObj,
|
||||
URLObj,
|
||||
URLSearchParamsObj,
|
||||
HeadersObj,
|
||||
} from "../interpreter/objects.js"
|
||||
import type { Interpreter } from "../interpreter/interpreter.js"
|
||||
|
||||
@@ -28,6 +29,7 @@ export const coerceToString = (value: unknown): string => {
|
||||
if (value instanceof SetObj) return "[object Set]"
|
||||
if (value instanceof URLObj) return value.url.href
|
||||
if (value instanceof URLSearchParamsObj) return value.params.toString()
|
||||
if (value instanceof HeadersObj) return "[object Headers]"
|
||||
if (value instanceof Bytes) return value.bytes.join(",")
|
||||
if (value instanceof ErrorObj) {
|
||||
// Match Error.prototype.toString: "name: message", or just one when the other is empty.
|
||||
|
||||
@@ -128,6 +128,34 @@ describe("values are converted at the boundary, never shared", () => {
|
||||
expect([...(held[0] as Set<{ z: number }>)][0]).toEqual({ z: 1 })
|
||||
})
|
||||
|
||||
test("Headers cross as copies in both directions", async () => {
|
||||
const stored = new Headers({ "X-A": "1" })
|
||||
const target = CodeMode.make({
|
||||
extensions: [
|
||||
Extension.make({
|
||||
name: "http",
|
||||
globals: {
|
||||
headers: () => stored,
|
||||
keep: (value: Headers) => {
|
||||
held.push(value)
|
||||
return value
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
held.length = 0
|
||||
expect(
|
||||
await value(
|
||||
`const h = headers(); h.set("x-a", "2"); const back = keep(h); back.set("x-a", "3"); return [h instanceof Headers, h.get("x-a"), back === h, back.get("x-a"), [...back]]`,
|
||||
target,
|
||||
),
|
||||
).toEqual([true, "2", false, "3", [["x-a", "3"]]])
|
||||
expect(stored.get("x-a")).toBe("1")
|
||||
expect(held[0]).toBeInstanceOf(Headers)
|
||||
expect((held[0] as Headers).get("x-a")).toBe("2")
|
||||
})
|
||||
|
||||
test("bytes cross as copies in both directions; ArrayBuffer comes in as Uint8Array", async () => {
|
||||
const stored = new Uint8Array([1, 2, 3])
|
||||
const target = CodeMode.make({
|
||||
|
||||
@@ -635,6 +635,154 @@ describe("URL and URI helpers", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("Headers", () => {
|
||||
test("constructs from records, pairs, Maps, and Headers; names fold to lowercase and values combine", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const headers = new Headers({ "Content-Type": "text/plain", "X-Count": 1, "X-Null": null })
|
||||
headers.append("Accept", "text/html")
|
||||
headers.append("accept", "application/json")
|
||||
headers.set("x-count", "2")
|
||||
headers.delete("x-null")
|
||||
const copy = new Headers(headers)
|
||||
copy.set("content-type", "text/html")
|
||||
return {
|
||||
get: headers.get("content-type"),
|
||||
missing: headers.get("x-missing"),
|
||||
combined: headers.get("ACCEPT"),
|
||||
has: [headers.has("Accept"), headers.has("x-null")],
|
||||
count: headers.get("x-count"),
|
||||
copied: [headers.get("content-type"), copy.get("content-type")],
|
||||
pairs: [...new Headers([["b", "2"], ["A", "1"]])],
|
||||
map: [...new Headers(new Map([["k", "v"]]))],
|
||||
keys: headers.keys(),
|
||||
values: headers.values(),
|
||||
entries: headers.entries(),
|
||||
}
|
||||
`),
|
||||
).toEqual({
|
||||
get: "text/plain",
|
||||
missing: null,
|
||||
combined: "text/html, application/json",
|
||||
has: [true, false],
|
||||
count: "2",
|
||||
copied: ["text/plain", "text/html"],
|
||||
pairs: [
|
||||
["a", "1"],
|
||||
["b", "2"],
|
||||
],
|
||||
map: [["k", "v"]],
|
||||
keys: ["accept", "content-type", "x-count"],
|
||||
values: ["text/html, application/json", "text/plain", "2"],
|
||||
entries: [
|
||||
["accept", "text/html, application/json"],
|
||||
["content-type", "text/plain"],
|
||||
["x-count", "2"],
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test("iterates in sorted order everywhere iteration is allowed, and getSetCookie keeps cookies apart", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const headers = new Headers({ b: "2", a: "1" })
|
||||
headers.append("Set-Cookie", "x=1")
|
||||
headers.append("set-cookie", "y=2")
|
||||
const seen = []
|
||||
headers.forEach((value, name, self) => seen.push(name + "=" + value + ":" + (self === headers)))
|
||||
const [first] = headers
|
||||
function* pairs() { yield* headers }
|
||||
return {
|
||||
seen,
|
||||
first,
|
||||
spread: [...headers],
|
||||
from: Array.from(headers).length,
|
||||
generator: [...pairs()].length,
|
||||
object: Object.fromEntries(headers),
|
||||
cookies: headers.getSetCookie(),
|
||||
}
|
||||
`),
|
||||
).toEqual({
|
||||
seen: ["a=1:true", "b=2:true", "set-cookie=x=1:true", "set-cookie=y=2:true"],
|
||||
first: ["a", "1"],
|
||||
spread: [
|
||||
["a", "1"],
|
||||
["b", "2"],
|
||||
["set-cookie", "x=1"],
|
||||
["set-cookie", "y=2"],
|
||||
],
|
||||
from: 4,
|
||||
generator: 4,
|
||||
object: { a: "1", b: "2", "set-cookie": "y=2" },
|
||||
cookies: ["x=1", "y=2"],
|
||||
})
|
||||
})
|
||||
|
||||
test("serializes as a name-to-value object at the boundary and in JSON; prints for console", async () => {
|
||||
const result = await run(`
|
||||
const headers = new Headers({ "X-A": "1", b: "2" })
|
||||
console.log(headers)
|
||||
return { headers, json: JSON.stringify({ headers }), text: String(headers), type: typeof headers, is: headers instanceof Headers }
|
||||
`)
|
||||
expect(result.ok && result.value).toEqual({
|
||||
headers: { b: "2", "x-a": "1" },
|
||||
json: '{"headers":{"b":"2","x-a":"1"}}',
|
||||
text: "[object Headers]",
|
||||
type: "object",
|
||||
is: true,
|
||||
})
|
||||
expect(result.ok && result.logs?.[0]).toBe('Headers {"b":"2","x-a":"1"}')
|
||||
})
|
||||
|
||||
test("rejects what it cannot build from, and invalid names and values, with TypeErrors the program can catch", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
function message(run) {
|
||||
try { run(); return null } catch (error) { return error instanceof TypeError ? error.message : error }
|
||||
}
|
||||
const headers = new Headers()
|
||||
return [
|
||||
message(() => Headers()),
|
||||
message(() => new Headers(null)),
|
||||
message(() => new Headers(1)),
|
||||
message(() => new Headers("a=1")),
|
||||
message(() => new Headers(new Date())),
|
||||
message(() => new Headers(() => 1)),
|
||||
message(() => new Headers([["name"]])),
|
||||
message(() => new Headers([["a", "b", "c"]])),
|
||||
message(() => new Headers({ "bad name": "x" })),
|
||||
message(() => new Headers({ name: "bad\u0000value" })),
|
||||
message(() => headers.get("invalid\u0100")),
|
||||
message(() => headers.has({})),
|
||||
message(() => headers.set("a", "invalid\u0100")),
|
||||
message(() => headers.append("a")),
|
||||
message(() => headers.forEach()),
|
||||
message(() => headers.forEach(1)),
|
||||
message(() => { const get = headers.get; return get("a") }),
|
||||
]
|
||||
`),
|
||||
).toEqual([
|
||||
"Constructor Headers requires 'new'.",
|
||||
"new Headers(...) expects a record of names to values, iterable [name, value] pairs, or Headers.",
|
||||
"new Headers(...) expects a record of names to values, iterable [name, value] pairs, or Headers.",
|
||||
"new Headers(...) expects a record of names to values, iterable [name, value] pairs, or Headers.",
|
||||
"new Headers(...) expects a record of names to values, iterable [name, value] pairs, or Headers.",
|
||||
"new Headers(...) expects a record of names to values, iterable [name, value] pairs, or Headers.",
|
||||
"new Headers(...) expects iterable [name, value] pairs.",
|
||||
"new Headers(...) expects iterable [name, value] pairs.",
|
||||
expect.stringContaining("bad name"),
|
||||
expect.stringContaining("invalid value"),
|
||||
expect.stringContaining("Invalid header name"),
|
||||
expect.stringContaining("[object Object]"),
|
||||
expect.stringContaining("invalid value"),
|
||||
"Headers.append requires 2 arguments.",
|
||||
"Headers.forEach requires 1 argument.",
|
||||
"Headers.forEach expects a function callback.",
|
||||
"Headers.prototype.get called on incompatible receiver undefined.",
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe("Map", () => {
|
||||
test("get/set/has/size with chaining", async () => {
|
||||
expect(
|
||||
|
||||
@@ -3,10 +3,13 @@
|
||||
* - html/webappapis/atob/base64.any.js (btoa reference encoder, input list, and atob WebIDL cases)
|
||||
* - fetch/data-urls/resources/base64.json (copied to fixtures/wpt-base64.json)
|
||||
* - WebCryptoAPI/randomUUID.https.any.js
|
||||
* - fetch/api/headers/{headers-basic,headers-errors}.any.js
|
||||
*
|
||||
* Copyright © web-platform-tests contributors. Governed by the 3-Clause BSD license in LICENSE.wpt.
|
||||
*
|
||||
* `assert_throws_dom("InvalidCharacterError", …)` becomes a check for a TypeError: CodeMode has no DOMException.
|
||||
* Headers cases that need `Symbol.iterator`, iterator objects from `keys()`/`values()`/`entries()` (CodeMode returns
|
||||
* arrays), or a custom iterator on a Headers instance are left out.
|
||||
*/
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
@@ -166,3 +169,225 @@ describe("crypto.randomUUID WPT parity (WebCryptoAPI/randomUUID.https.any.js)",
|
||||
).toEqual([true, true, true, 768])
|
||||
})
|
||||
})
|
||||
|
||||
// Enough of testharness.js to run the Headers files close to verbatim; each `test` records its failure, if any.
|
||||
const testharness = `
|
||||
const failures = []
|
||||
function test(run, name) { try { run() } catch (error) { failures.push(name + ": " + (error && error.message ? error.message : error)) } }
|
||||
function assert_equals(actual, expected, message) { if (actual !== expected) throw new Error((message || "") + " expected " + JSON.stringify(expected) + " got " + JSON.stringify(actual)) }
|
||||
function assert_true(actual, message) { assert_equals(actual, true, message) }
|
||||
function assert_false(actual, message) { assert_equals(actual, false, message) }
|
||||
function assert_array_equals(actual, expected, message) { assert_equals(JSON.stringify(actual), JSON.stringify(expected), message) }
|
||||
function assert_throws_js(type, run) { try { run() } catch (error) { if (error instanceof type) return; throw new Error("threw " + error.name) } throw new Error("did not throw") }
|
||||
function assert_unreached() { throw new Error("unreachable") }
|
||||
`
|
||||
|
||||
describe("Headers WPT parity (fetch/api/headers)", () => {
|
||||
test("headers-basic.any.js", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
${testharness}
|
||||
test(function() { new Headers() }, "Create headers from no parameter")
|
||||
test(function() { new Headers(undefined) }, "Create headers from undefined parameter")
|
||||
test(function() { new Headers({}) }, "Create headers from empty object")
|
||||
var parameters = [null, 1]
|
||||
parameters.forEach(function(parameter) {
|
||||
test(function() { assert_throws_js(TypeError, function() { new Headers(parameter) }) }, "Create headers with " + parameter + " should throw")
|
||||
})
|
||||
var headerDict = {"name1": "value1", "name2": "value2", "name3": "value3", "name4": null, "name5": undefined, "name6": 1, "Content-Type": "value4"}
|
||||
var headerSeq = []
|
||||
for (var name in headerDict) headerSeq.push([name, headerDict[name]])
|
||||
test(function() {
|
||||
var headers = new Headers(headerSeq)
|
||||
for (name in headerDict) assert_equals(headers.get(name), String(headerDict[name]), "name: " + name + " has value: " + headerDict[name])
|
||||
assert_equals(headers.get("length"), null, "init should be treated as a sequence, not as a dictionary")
|
||||
}, "Create headers with sequence")
|
||||
test(function() {
|
||||
var headers = new Headers(headerDict)
|
||||
for (name in headerDict) assert_equals(headers.get(name), String(headerDict[name]), "name: " + name + " has value: " + headerDict[name])
|
||||
}, "Create headers with record")
|
||||
test(function() {
|
||||
var headers = new Headers(headerDict)
|
||||
var headers2 = new Headers(headers)
|
||||
for (name in headerDict) assert_equals(headers2.get(name), String(headerDict[name]), "name: " + name + " has value: " + headerDict[name])
|
||||
}, "Create headers with existing headers")
|
||||
test(function() {
|
||||
var headers = new Headers()
|
||||
for (name in headerDict) {
|
||||
headers.append(name, headerDict[name])
|
||||
assert_equals(headers.get(name), String(headerDict[name]), "name: " + name + " has value: " + headerDict[name])
|
||||
}
|
||||
}, "Check append method")
|
||||
test(function() {
|
||||
var headers = new Headers()
|
||||
for (name in headerDict) {
|
||||
headers.set(name, headerDict[name])
|
||||
assert_equals(headers.get(name), String(headerDict[name]), "name: " + name + " has value: " + headerDict[name])
|
||||
}
|
||||
}, "Check set method")
|
||||
test(function() {
|
||||
var headers = new Headers(headerDict)
|
||||
for (name in headerDict) assert_true(headers.has(name), "headers has name " + name)
|
||||
assert_false(headers.has("nameNotInHeaders"), "headers do not have header: nameNotInHeaders")
|
||||
}, "Check has method")
|
||||
test(function() {
|
||||
var headers = new Headers(headerDict)
|
||||
for (name in headerDict) {
|
||||
assert_true(headers.has(name), "headers have a header: " + name)
|
||||
headers.delete(name)
|
||||
assert_true(!headers.has(name), "headers do not have anymore a header: " + name)
|
||||
}
|
||||
}, "Check delete method")
|
||||
test(function() {
|
||||
var headers = new Headers(headerDict)
|
||||
for (name in headerDict) assert_equals(headers.get(name), String(headerDict[name]), "name: " + name + " has value: " + headerDict[name])
|
||||
assert_equals(headers.get("nameNotInHeaders"), null, "header: nameNotInHeaders has no value")
|
||||
}, "Check get method")
|
||||
var headerEntriesDict = {"name1": "value1", "Name2": "value2", "name": "value3", "content-Type": "value4", "Content-Typ": "value5", "Content-Types": "value6"}
|
||||
var sortedHeaderDict = {}
|
||||
var headerValues = []
|
||||
var sortedHeaderKeys = Object.keys(headerEntriesDict).map(function(value) {
|
||||
sortedHeaderDict[value.toLowerCase()] = headerEntriesDict[value]
|
||||
headerValues.push(headerEntriesDict[value])
|
||||
return value.toLowerCase()
|
||||
}).sort()
|
||||
test(function() {
|
||||
var headers = new Headers(headerEntriesDict)
|
||||
assert_array_equals(headers.keys(), sortedHeaderKeys)
|
||||
for (const key of headers.keys()) assert_true(sortedHeaderKeys.indexOf(key) != -1)
|
||||
}, "Check keys method")
|
||||
test(function() {
|
||||
var headers = new Headers(headerEntriesDict)
|
||||
assert_array_equals(headers.values(), sortedHeaderKeys.map((key) => sortedHeaderDict[key]))
|
||||
for (const value of headers.values()) assert_true(headerValues.indexOf(value) != -1)
|
||||
}, "Check values method")
|
||||
test(function() {
|
||||
var headers = new Headers(headerEntriesDict)
|
||||
assert_array_equals(headers.entries(), sortedHeaderKeys.map((key) => [key, sortedHeaderDict[key]]))
|
||||
for (const entry of headers.entries()) assert_equals(entry[1], sortedHeaderDict[entry[0]])
|
||||
}, "Check entries method")
|
||||
test(function() {
|
||||
var headers = new Headers(headerEntriesDict)
|
||||
assert_array_equals([...headers], sortedHeaderKeys.map((key) => [key, sortedHeaderDict[key]]))
|
||||
}, "Check Symbol.iterator method")
|
||||
test(function() {
|
||||
var headers = new Headers(headerEntriesDict)
|
||||
var index = 0
|
||||
headers.forEach(function(value, key, container) {
|
||||
assert_equals(headers, container)
|
||||
assert_equals(key, sortedHeaderKeys[index])
|
||||
assert_equals(value, sortedHeaderDict[sortedHeaderKeys[index]])
|
||||
index++
|
||||
})
|
||||
assert_equals(index, sortedHeaderKeys.length)
|
||||
}, "Check forEach method")
|
||||
test(() => {
|
||||
const headers = new Headers({"foo": "2", "baz": "1", "BAR": "0"})
|
||||
const actualKeys = []
|
||||
const actualValues = []
|
||||
for (const [header, value] of headers) {
|
||||
actualKeys.push(header)
|
||||
actualValues.push(value)
|
||||
headers.delete("foo")
|
||||
}
|
||||
assert_array_equals(actualKeys, ["bar", "baz"])
|
||||
assert_array_equals(actualValues, ["0", "1"])
|
||||
}, "Iteration skips elements removed while iterating")
|
||||
test(() => {
|
||||
const headers = new Headers({"foo": "2", "baz": "1", "BAR": "0", "quux": "3"})
|
||||
const actualKeys = []
|
||||
const actualValues = []
|
||||
for (const [header, value] of headers) {
|
||||
actualKeys.push(header)
|
||||
actualValues.push(value)
|
||||
if (header === "baz") headers.delete("bar")
|
||||
}
|
||||
assert_array_equals(actualKeys, ["bar", "baz", "quux"])
|
||||
assert_array_equals(actualValues, ["0", "1", "3"])
|
||||
}, "Removing elements already iterated over causes an element to be skipped during iteration")
|
||||
test(() => {
|
||||
const headers = new Headers({"foo": "2", "baz": "1", "BAR": "0", "quux": "3"})
|
||||
const actualKeys = []
|
||||
const actualValues = []
|
||||
for (const [header, value] of headers) {
|
||||
actualKeys.push(header)
|
||||
actualValues.push(value)
|
||||
if (header === "baz") headers.append("X-yZ", "4")
|
||||
}
|
||||
assert_array_equals(actualKeys, ["bar", "baz", "foo", "quux", "x-yz"])
|
||||
assert_array_equals(actualValues, ["0", "1", "2", "3", "4"])
|
||||
}, "Appending a value pair during iteration causes it to be reached during iteration")
|
||||
test(() => {
|
||||
const headers = new Headers({"foo": "2", "baz": "1", "BAR": "0", "quux": "3"})
|
||||
const actualKeys = []
|
||||
const actualValues = []
|
||||
for (const [header, value] of headers) {
|
||||
actualKeys.push(header)
|
||||
actualValues.push(value)
|
||||
if (header === "baz") headers.append("abc", "-1")
|
||||
}
|
||||
assert_array_equals(actualKeys, ["bar", "baz", "baz", "foo", "quux"])
|
||||
assert_array_equals(actualValues, ["0", "1", "1", "2", "3"])
|
||||
}, "Prepending a value pair before the current element position causes it to be skipped during iteration and adds the current element a second time")
|
||||
return failures
|
||||
`),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test("headers-errors.any.js", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
${testharness}
|
||||
test(function() { assert_throws_js(TypeError, function() { new Headers([["name"]]) }) }, "Create headers giving an array having one string as init argument")
|
||||
test(function() { assert_throws_js(TypeError, function() { new Headers([["invalid", "invalidValue1", "invalidValue2"]]) }) }, "Create headers giving an array having three strings as init argument")
|
||||
test(function() { assert_throws_js(TypeError, function() { new Headers([["invalid\u0100", "Value1"]]) }) }, "Create headers giving bad header name as init argument")
|
||||
test(function() { assert_throws_js(TypeError, function() { new Headers([["name", "invalidValue\u0100"]]) }) }, "Create headers giving bad header value as init argument")
|
||||
var badNames = ["invalid\u0100", {}]
|
||||
var badValues = ["invalid\u0100"]
|
||||
badNames.forEach(function(name) {
|
||||
test(function() { var headers = new Headers(); assert_throws_js(TypeError, function() { headers.get(name) }) }, "Check headers get with an invalid name " + name)
|
||||
})
|
||||
badNames.forEach(function(name) {
|
||||
test(function() { var headers = new Headers(); assert_throws_js(TypeError, function() { headers.delete(name) }) }, "Check headers delete with an invalid name " + name)
|
||||
})
|
||||
badNames.forEach(function(name) {
|
||||
test(function() { var headers = new Headers(); assert_throws_js(TypeError, function() { headers.has(name) }) }, "Check headers has with an invalid name " + name)
|
||||
})
|
||||
badNames.forEach(function(name) {
|
||||
test(function() { var headers = new Headers(); assert_throws_js(TypeError, function() { headers.set(name, "Value1") }) }, "Check headers set with an invalid name " + name)
|
||||
})
|
||||
badValues.forEach(function(value) {
|
||||
test(function() { var headers = new Headers(); assert_throws_js(TypeError, function() { headers.set("name", value) }) }, "Check headers set with an invalid value " + value)
|
||||
})
|
||||
badNames.forEach(function(name) {
|
||||
test(function() { var headers = new Headers(); assert_throws_js(TypeError, function() { headers.append("invalid\u0100", "Value1") }) }, "Check headers append with an invalid name " + name)
|
||||
})
|
||||
badValues.forEach(function(value) {
|
||||
test(function() { var headers = new Headers(); assert_throws_js(TypeError, function() { headers.append("name", value) }) }, "Check headers append with an invalid value " + value)
|
||||
})
|
||||
test(function() {
|
||||
var headers = new Headers([["name", "value"]])
|
||||
assert_throws_js(TypeError, function() { headers.forEach() })
|
||||
assert_throws_js(TypeError, function() { headers.forEach(undefined) })
|
||||
assert_throws_js(TypeError, function() { headers.forEach(1) })
|
||||
}, "Headers forEach throws if argument is not callable")
|
||||
test(function() {
|
||||
var headers = new Headers([["name1", "value1"], ["name2", "value2"], ["name3", "value3"]])
|
||||
var counter = 0
|
||||
try {
|
||||
headers.forEach(function(value, name) {
|
||||
counter++
|
||||
if (name == "name2") throw "error"
|
||||
})
|
||||
} catch (e) {
|
||||
assert_equals(counter, 2)
|
||||
assert_equals(e, "error")
|
||||
return
|
||||
}
|
||||
assert_unreached()
|
||||
}, "Headers forEach loop should stop if callback is throwing exception")
|
||||
return failures
|
||||
`),
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-app",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.5",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/console-core",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.5",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-function",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.5",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-mail",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.5",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-support",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.5",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.5",
|
||||
"name": "@opencode/core",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Command } from "@opencode/schema/command"
|
||||
import type { PromptInput } from "@opencode/schema/prompt-input"
|
||||
import type { Session } from "@opencode/schema/session"
|
||||
import type { SessionInbox } from "@opencode/schema/session-inbox"
|
||||
import type { SessionMessage } from "@opencode/schema/session-message"
|
||||
import { makeLocationNode } from "@opencode/util/effect/app-node"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Bus } from "./bus.js"
|
||||
@@ -12,23 +11,10 @@ import { State } from "./state.js"
|
||||
|
||||
export const Info = Command.Info
|
||||
export type Info = Command.Info
|
||||
export const Outcome = Command.Outcome
|
||||
export type Outcome = Command.Outcome
|
||||
export { Event } from "@opencode/schema/command"
|
||||
|
||||
export const immediate: Outcome = { type: "immediate" }
|
||||
|
||||
export function prompted(admitted: { readonly id: SessionMessage.ID }): Outcome {
|
||||
return { type: "prompt", inboxID: admitted.id }
|
||||
}
|
||||
|
||||
export interface Invocation {
|
||||
readonly sessionID: Session.ID
|
||||
/**
|
||||
* Identity for the input this command admits. Commands that prompt a Session must admit
|
||||
* with this ID and report it in their outcome so clients can follow the resulting work.
|
||||
*/
|
||||
readonly messageID: SessionMessage.ID
|
||||
readonly prompt: PromptInput.Prompt
|
||||
readonly delivery: SessionInbox.Delivery
|
||||
}
|
||||
@@ -36,8 +22,7 @@ export interface Invocation {
|
||||
export interface Definition {
|
||||
readonly name: string
|
||||
readonly description?: string
|
||||
/** Resolve with an Outcome to report admitted work; `void` means the command finished immediately. */
|
||||
readonly execute: (input: Invocation) => Effect.Effect<Outcome | void, unknown>
|
||||
readonly execute: (input: Invocation) => Effect.Effect<void, unknown>
|
||||
}
|
||||
|
||||
export type Editor = {
|
||||
@@ -60,7 +45,7 @@ export interface Interface extends State.Transformable<Editor> {
|
||||
readonly execute: (input: {
|
||||
readonly name: string
|
||||
readonly invocation: Invocation
|
||||
}) => Effect.Effect<Outcome, NotFoundError | ExecutionError>
|
||||
}) => Effect.Effect<void, NotFoundError | ExecutionError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Command") {}
|
||||
@@ -97,22 +82,10 @@ export const layer = Layer.effect(
|
||||
const definition = state.get().get(input.name)
|
||||
if (!definition)
|
||||
return yield* new NotFoundError({ command: input.name, message: `Command not found: ${input.name}` })
|
||||
const outcome = yield* definition.execute(input.invocation).pipe(
|
||||
return yield* definition.execute(input.invocation).pipe(
|
||||
Effect.tapError((error) => Effect.logError("command execution failed", { command: input.name, error })),
|
||||
Effect.mapError((error) => new ExecutionError({ command: input.name, message: errorMessage(error) })),
|
||||
)
|
||||
if (outcome === undefined) return immediate
|
||||
// Plugin callbacks cross a JavaScript boundary; only a well-formed outcome is reportable.
|
||||
if (!isOutcome(outcome))
|
||||
return yield* new ExecutionError({ command: input.name, message: "Command returned an invalid outcome" })
|
||||
// Clients follow the invocation ID, so a command that admitted under another ID (typically by
|
||||
// omitting `id: messageID` from its prompt) would leave them waiting forever. Fail loudly instead.
|
||||
if (outcome.type === "prompt" && outcome.inboxID !== input.invocation.messageID)
|
||||
return yield* new ExecutionError({
|
||||
command: input.name,
|
||||
message: `Command admitted ${outcome.inboxID} instead of the invocation message ID ${input.invocation.messageID}`,
|
||||
})
|
||||
return outcome
|
||||
}),
|
||||
})
|
||||
}),
|
||||
@@ -124,8 +97,6 @@ export const node = makeLocationNode({
|
||||
deps: [Bus.node],
|
||||
})
|
||||
|
||||
const isOutcome = Schema.is(Outcome)
|
||||
|
||||
function errorMessage(error: unknown) {
|
||||
if (error instanceof Error) return error.message
|
||||
if (typeof error === "string") return error
|
||||
|
||||
@@ -11,7 +11,6 @@ import { Effect, Option, PubSub, Schema, Stream } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { Agent } from "../../agent.js"
|
||||
import { Config } from "../../config.js"
|
||||
import { Command } from "../../command.js"
|
||||
import { Location } from "../../location.js"
|
||||
import { Session } from "../../session.js"
|
||||
import { SubagentJob } from "../../session/subagent-job.js"
|
||||
@@ -105,10 +104,9 @@ export const Plugin = define({
|
||||
agent: selected.id,
|
||||
model: model ?? selected.info?.model ?? parent.model,
|
||||
})
|
||||
const admitted = yield* sessions.prompt({
|
||||
yield* sessions.prompt({
|
||||
...input.prompt,
|
||||
sessionID: child.id,
|
||||
id: input.messageID,
|
||||
text: ["You are a subagent spawned by another session.", text].join("\n"),
|
||||
resume: false,
|
||||
})
|
||||
@@ -121,22 +119,20 @@ export const Plugin = define({
|
||||
}
|
||||
yield* subagents.start(recovery)
|
||||
yield* subagents.background(recovery)
|
||||
return Command.prompted(admitted)
|
||||
return
|
||||
}
|
||||
if (agent !== undefined) {
|
||||
const session = yield* ctx.session.get({ sessionID: input.sessionID })
|
||||
if (session.agent !== agent) yield* ctx.session.switchAgent({ sessionID: input.sessionID, agent })
|
||||
}
|
||||
if (model !== undefined) yield* ctx.session.switchModel({ sessionID: input.sessionID, model })
|
||||
const admitted = yield* ctx.session.prompt({
|
||||
yield* ctx.session.prompt({
|
||||
...input.prompt,
|
||||
sessionID: input.sessionID,
|
||||
id: input.messageID,
|
||||
text,
|
||||
delivery: input.delivery,
|
||||
})
|
||||
return Command.prompted(admitted)
|
||||
}),
|
||||
}).pipe(Effect.asVoid),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ export * as CommandPlugin from "./command.js"
|
||||
import { define } from "@opencode/plugin/effect/plugin"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Bus } from "../bus.js"
|
||||
import { Command } from "../command.js"
|
||||
import { Location } from "../location.js"
|
||||
import { Mcp } from "../mcp/index.js"
|
||||
import PROMPT_INITIALIZE from "./command/initialize.txt"
|
||||
@@ -35,11 +34,10 @@ export const Plugin = define({
|
||||
.prompt({
|
||||
...input.prompt,
|
||||
sessionID: input.sessionID,
|
||||
id: input.messageID,
|
||||
text: append(PROMPT_INITIALIZE.replace("${path}", location.project.directory), input.prompt.text),
|
||||
delivery: input.delivery,
|
||||
})
|
||||
.pipe(Effect.map(Command.prompted)),
|
||||
.pipe(Effect.asVoid),
|
||||
})
|
||||
editor.add({
|
||||
name: "review",
|
||||
@@ -49,11 +47,10 @@ export const Plugin = define({
|
||||
.prompt({
|
||||
...input.prompt,
|
||||
sessionID: input.sessionID,
|
||||
id: input.messageID,
|
||||
text: append(PROMPT_REVIEW.replace("${path}", location.project.directory), input.prompt.text),
|
||||
delivery: input.delivery,
|
||||
})
|
||||
.pipe(Effect.map(Command.prompted)),
|
||||
.pipe(Effect.asVoid),
|
||||
})
|
||||
for (const prompt of loaded.prompts) {
|
||||
editor.add({
|
||||
@@ -70,18 +67,16 @@ export const Plugin = define({
|
||||
),
|
||||
})
|
||||
if (!result) return yield* Effect.fail(new Error(`MCP prompt not found: ${prompt.server}:${prompt.name}`))
|
||||
const admitted = yield* ctx.session.prompt({
|
||||
yield* ctx.session.prompt({
|
||||
...input.prompt,
|
||||
sessionID: input.sessionID,
|
||||
id: input.messageID,
|
||||
text: result.messages
|
||||
.map((message) => promptMessageText(message.content))
|
||||
.join("\n")
|
||||
.trim(),
|
||||
delivery: input.delivery,
|
||||
})
|
||||
return Command.prompted(admitted)
|
||||
}),
|
||||
}).pipe(Effect.asVoid),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -167,7 +167,8 @@ export const AzurePlugin = define({
|
||||
draft.settings.baseURL,
|
||||
resolveResourceName(draft.settings, resourceName) ?? resourceName,
|
||||
)
|
||||
if (responsesWebSocketCapable(item.provider, draft)) draft.transport = "websocket"
|
||||
if (responsesWebSocketCapable(item.provider, draft))
|
||||
draft.transport = item.provider.transport ?? "websocket"
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,7 +270,7 @@ export const OpenAIPlugin = define({
|
||||
// ChatGPT-plan tokens only authorize codex-eligible models, and the
|
||||
// subscription covers usage, so hide the rest and zero the cost.
|
||||
models.update(model.providerID, model.id, (draft) => {
|
||||
draft.transport = "websocket"
|
||||
draft.transport = models.provider.get(model.providerID)?.provider.transport ?? "websocket"
|
||||
if (!chatgpt) return
|
||||
if (Schema.is(Schema.Struct({ mode: Schema.Literal("pro") }))(draft.body?.reasoning)) {
|
||||
draft.enabled = false
|
||||
|
||||
@@ -98,7 +98,7 @@ export const XAIPlugin = define({
|
||||
yield* ctx.model.transform((models) => {
|
||||
for (const model of models.list(providerID)) {
|
||||
models.update(providerID, model.id, (draft) => {
|
||||
draft.transport = "websocket"
|
||||
draft.transport = models.provider.get(providerID)?.provider.transport ?? "websocket"
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -186,13 +186,12 @@ export interface Interface {
|
||||
readonly command: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
command: string
|
||||
id?: SessionMessage.ID
|
||||
text: string
|
||||
files?: PromptInput.Prompt["files"]
|
||||
agents?: PromptInput.Prompt["agents"]
|
||||
skills?: PromptInput.Prompt["skills"]
|
||||
delivery?: SessionInbox.Delivery
|
||||
}) => Effect.Effect<Command.Outcome, NotFoundError | Command.NotFoundError | Command.ExecutionError>
|
||||
}) => Effect.Effect<void, NotFoundError | Command.NotFoundError | Command.ExecutionError>
|
||||
readonly shell: (
|
||||
input: Parameters<Session.Handle["shell"]>[0] & { sessionID: SessionSchema.ID },
|
||||
) => ReturnType<Session.Handle["shell"]>
|
||||
|
||||
@@ -3,7 +3,6 @@ export * as SessionCommand from "./command.js"
|
||||
import type { PromptInput } from "@opencode/schema/prompt-input"
|
||||
import type { Session } from "@opencode/schema/session"
|
||||
import type { SessionInbox } from "@opencode/schema/session-inbox"
|
||||
import { SessionMessage } from "@opencode/schema/session-message"
|
||||
import { Effect } from "effect"
|
||||
import { Command } from "../command.js"
|
||||
import { Instance } from "../instance/service.js"
|
||||
@@ -12,7 +11,6 @@ import { Plugin } from "../plugin/service.js"
|
||||
export const execute = Effect.fn("SessionCommand.execute")(function* (input: {
|
||||
session: Session.Info
|
||||
command: string
|
||||
id?: SessionMessage.ID
|
||||
text: string
|
||||
files?: PromptInput.Prompt["files"]
|
||||
agents?: PromptInput.Prompt["agents"]
|
||||
@@ -21,11 +19,10 @@ export const execute = Effect.fn("SessionCommand.execute")(function* (input: {
|
||||
}) {
|
||||
const instances = yield* Instance.Service
|
||||
const commands = yield* Plugin.awaitActivation.pipe(Effect.andThen(Command.Service), instances.provide(input.session))
|
||||
return yield* commands.execute({
|
||||
yield* commands.execute({
|
||||
name: input.command,
|
||||
invocation: {
|
||||
sessionID: input.session.id,
|
||||
messageID: input.id ?? SessionMessage.ID.create(),
|
||||
prompt: {
|
||||
text: input.text,
|
||||
files: input.files,
|
||||
|
||||
@@ -2,7 +2,6 @@ import { describe, expect } from "bun:test"
|
||||
import { Command } from "@opencode/core/command"
|
||||
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
|
||||
import { Session } from "@opencode/schema/session"
|
||||
import { SessionMessage } from "@opencode/schema/session-message"
|
||||
import { Effect } from "effect"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
@@ -17,10 +16,7 @@ describe("Command", () => {
|
||||
editor.add({
|
||||
name: "goal",
|
||||
description: "Manage the session goal",
|
||||
execute: (input) =>
|
||||
Effect.sync(() => {
|
||||
calls.push(input)
|
||||
}),
|
||||
execute: (input) => Effect.sync(() => calls.push(input)),
|
||||
})
|
||||
})
|
||||
|
||||
@@ -29,41 +25,14 @@ describe("Command", () => {
|
||||
)
|
||||
const invocation = {
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
messageID: SessionMessage.ID.make("msg_goal"),
|
||||
prompt: { text: "ship it", files: [{ uri: "file:///tmp/plan.md" }] },
|
||||
delivery: "steer" as const,
|
||||
}
|
||||
expect(yield* command.execute({ name: "goal", invocation })).toEqual(Command.immediate)
|
||||
yield* command.execute({ name: "goal", invocation })
|
||||
expect(calls).toEqual([invocation])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("returns prompt outcomes and rejects ones admitted under another message ID", () =>
|
||||
Effect.gen(function* () {
|
||||
const command = yield* Command.Service
|
||||
yield* command.transform((editor) => {
|
||||
editor.add({ name: "ask", execute: (input) => Effect.succeed(Command.prompted({ id: input.messageID })) })
|
||||
editor.add({
|
||||
name: "stray",
|
||||
execute: () => Effect.succeed(Command.prompted({ id: SessionMessage.ID.make("msg_other") })),
|
||||
})
|
||||
})
|
||||
const invocation = {
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
messageID: SessionMessage.ID.make("msg_ask"),
|
||||
prompt: { text: "" },
|
||||
delivery: "steer" as const,
|
||||
}
|
||||
|
||||
expect(yield* command.execute({ name: "ask", invocation })).toEqual(
|
||||
Command.prompted({ id: invocation.messageID }),
|
||||
)
|
||||
const error = yield* command.execute({ name: "stray", invocation }).pipe(Effect.flip)
|
||||
expect(error).toBeInstanceOf(Command.ExecutionError)
|
||||
expect(error.message).toBe("Command admitted msg_other instead of the invocation message ID msg_ask")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replaces commands with later definitions", () =>
|
||||
Effect.gen(function* () {
|
||||
const command = yield* Command.Service
|
||||
@@ -91,7 +60,6 @@ describe("Command", () => {
|
||||
name: "fail",
|
||||
invocation: {
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
messageID: SessionMessage.ID.make("msg_fail"),
|
||||
prompt: { text: "" },
|
||||
delivery: "steer",
|
||||
},
|
||||
|
||||
@@ -13,7 +13,6 @@ import { Model } from "@opencode/core/model"
|
||||
import { Provider } from "@opencode/core/provider"
|
||||
import { AbsolutePath } from "@opencode/core/schema"
|
||||
import { Session } from "@opencode/core/session"
|
||||
import { SessionMessage } from "@opencode/schema/session-message"
|
||||
import { SessionRunnerModel } from "@opencode/core/session/runner/model"
|
||||
import { LayerNode } from "@opencode/util/effect/layer-node"
|
||||
import { Global } from "@opencode/util/global"
|
||||
@@ -84,18 +83,12 @@ describe("command subagents", () => {
|
||||
const gate = yield* llm.gate()
|
||||
|
||||
// This must return while the child's model is still blocked.
|
||||
const outcome = yield* sessions.command({
|
||||
sessionID: parent.id,
|
||||
command: "review",
|
||||
id: SessionMessage.ID.make("msg_review"),
|
||||
text: "changes",
|
||||
})
|
||||
yield* sessions.command({ sessionID: parent.id, command: "review", text: "changes" })
|
||||
yield* gate.started
|
||||
const children = (yield* sessions.list({ parentID: parent.id })).data
|
||||
expect(children).toHaveLength(1)
|
||||
const child = children[0]
|
||||
if (!child) return yield* Effect.die("Expected a child session")
|
||||
expect(outcome).toEqual({ type: "prompt", inboxID: SessionMessage.ID.make("msg_review") })
|
||||
expect(child).toMatchObject({ agent: fixture.agent, model: { id: fixture.model }, title: "Review code" })
|
||||
expect(yield* sessions.get(parent.id)).toMatchObject({ agent: "build", model: parentModel })
|
||||
expect(yield* sessions.context(parent.id)).toEqual([])
|
||||
|
||||
@@ -105,7 +105,7 @@ describe("ConfigCommandPlugin.Plugin", () => {
|
||||
Effect.sync(() => {
|
||||
prompts.push({ text: input.text, delivery: input.delivery })
|
||||
return SessionInbox.User.make({
|
||||
id: input.id ?? SessionMessage.ID.make("msg_test"),
|
||||
id: SessionMessage.ID.make("msg_test"),
|
||||
sessionID: input.sessionID,
|
||||
time: { created: DateTime.makeUnsafe(0) },
|
||||
type: "user",
|
||||
@@ -129,7 +129,6 @@ describe("ConfigCommandPlugin.Plugin", () => {
|
||||
name: "explain",
|
||||
invocation: {
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
messageID: SessionMessage.ID.make("msg_explain"),
|
||||
prompt: { text: item.input },
|
||||
delivery: "queue",
|
||||
},
|
||||
@@ -177,7 +176,7 @@ Review files`,
|
||||
Effect.sync(() => {
|
||||
prompts.push({ text: input.text, files: input.files, delivery: input.delivery })
|
||||
return SessionInbox.User.make({
|
||||
id: input.id ?? SessionMessage.ID.make("msg_test"),
|
||||
id: SessionMessage.ID.make("msg_test"),
|
||||
sessionID: input.sessionID,
|
||||
time: { created: DateTime.makeUnsafe(0) },
|
||||
type: "user",
|
||||
@@ -211,7 +210,6 @@ Review files`,
|
||||
name: "nested/docs",
|
||||
invocation: {
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
messageID: SessionMessage.ID.make("msg_docs"),
|
||||
prompt: { text: "details", files: [{ uri: "file:///tmp/context.md" }] },
|
||||
delivery: "queue",
|
||||
},
|
||||
@@ -238,7 +236,6 @@ Review files`,
|
||||
name: "review",
|
||||
invocation: {
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
messageID: SessionMessage.ID.make("msg_latest"),
|
||||
prompt: { text: "latest" },
|
||||
delivery: "steer",
|
||||
},
|
||||
|
||||
@@ -10,6 +10,9 @@ import { Model } from "@opencode/core/model"
|
||||
import { ModelResolver } from "@opencode/core/model-resolver"
|
||||
import { Plugin } from "@opencode/core/plugin"
|
||||
import { PluginHost } from "@opencode/core/plugin/host"
|
||||
import { AzurePlugin } from "@opencode/core/plugin/provider/azure"
|
||||
import { OpenAIPlugin } from "@opencode/core/plugin/provider/openai"
|
||||
import { XAIPlugin } from "@opencode/core/plugin/provider/xai"
|
||||
import { Provider } from "@opencode/core/provider"
|
||||
import { withEnv } from "../fixture/env"
|
||||
import { testEffect } from "../lib/effect"
|
||||
@@ -106,6 +109,50 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
for (const builtin of [
|
||||
{ id: "openai", model: "gpt-5.6-sol", package: "@opencode/ai/providers/openai/responses", plugin: OpenAIPlugin },
|
||||
{ id: "xai", model: "grok-4.6", package: "@opencode/ai/providers/xai", plugin: XAIPlugin },
|
||||
{ id: "azure", model: "gpt-5.6-sol", package: "@opencode/ai/providers/azure/responses", plugin: AzurePlugin },
|
||||
{ id: "custom-azure", model: "deployment", package: "@opencode/ai/providers/azure/responses", plugin: AzurePlugin },
|
||||
]) {
|
||||
it.live(`provider transport overrides ${builtin.id} defaults while model overrides still win`, () =>
|
||||
Effect.gen(function* () {
|
||||
const providers = yield* Provider.Service
|
||||
const models = yield* Model.Service
|
||||
const plugin = yield* Plugin.Service
|
||||
const host = yield* PluginHost.make(plugin)
|
||||
const providerID = Provider.ID.make(builtin.id)
|
||||
const modelID = Model.ID.make(builtin.model)
|
||||
yield* providers.transform((editor) => {
|
||||
editor.update(providerID, (provider) => {
|
||||
provider.activation = "enabled"
|
||||
provider.package = builtin.package
|
||||
})
|
||||
editor.models.update(providerID, modelID, () => {})
|
||||
})
|
||||
yield* builtin.plugin.effect(host)
|
||||
expect((yield* models.get(providerID, modelID))?.transport).toBe("websocket")
|
||||
|
||||
yield* addPlugin([
|
||||
new Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
providers: {
|
||||
[builtin.id]: {
|
||||
transport: "http",
|
||||
models: { override: { modelID: builtin.model, transport: "websocket" } },
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
])
|
||||
|
||||
expect((yield* models.get(providerID, modelID))?.transport).toBe("http")
|
||||
expect((yield* models.get(providerID, Model.ID.make("override")))?.transport).toBe("websocket")
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
it.effect("adds key auth for custom providers without env credentials", () =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
|
||||
@@ -11,7 +11,6 @@ import { PluginModule } from "@opencode/core/plugin/module"
|
||||
import { Watcher } from "@opencode/core/filesystem/watcher"
|
||||
import { fromPromise } from "@opencode/plugin/promise/adapter"
|
||||
import { Session } from "@opencode/schema/session"
|
||||
import { SessionMessage } from "@opencode/schema/session-message"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { PluginTestLayer } from "./plugin/fixture"
|
||||
|
||||
@@ -498,12 +497,7 @@ it.effect("reloading a plugin replaces its command implementation", () =>
|
||||
])
|
||||
const request = {
|
||||
name: "greet",
|
||||
invocation: {
|
||||
sessionID: Session.ID.make("ses_plugin"),
|
||||
messageID: SessionMessage.ID.make("msg_greet"),
|
||||
prompt: { text: "" },
|
||||
delivery: "steer" as const,
|
||||
},
|
||||
invocation: { sessionID: Session.ID.make("ses_plugin"), prompt: { text: "" }, delivery: "steer" as const },
|
||||
}
|
||||
|
||||
yield* load("1", "before")
|
||||
|
||||
@@ -37,7 +37,6 @@ describe("CommandPlugin.Plugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const command = yield* Command.Service
|
||||
const prompts: {
|
||||
id?: string
|
||||
text: string
|
||||
files?: readonly { readonly uri: string }[]
|
||||
delivery?: "steer" | "queue"
|
||||
@@ -52,9 +51,9 @@ describe("CommandPlugin.Plugin", () => {
|
||||
session: {
|
||||
prompt: (input) =>
|
||||
Effect.sync(() => {
|
||||
prompts.push({ id: input.id, text: input.text, files: input.files, delivery: input.delivery })
|
||||
prompts.push({ text: input.text, files: input.files, delivery: input.delivery })
|
||||
return SessionInbox.User.make({
|
||||
id: input.id ?? SessionMessage.ID.make("msg_test"),
|
||||
id: SessionMessage.ID.make("msg_test"),
|
||||
sessionID: input.sessionID,
|
||||
time: { created: DateTime.makeUnsafe(0) },
|
||||
type: "user",
|
||||
@@ -79,11 +78,10 @@ describe("CommandPlugin.Plugin", () => {
|
||||
name: "review",
|
||||
description: "review changes [commit|branch|pr], defaults to uncommitted",
|
||||
})
|
||||
const outcome = yield* command.execute({
|
||||
yield* command.execute({
|
||||
name: "init",
|
||||
invocation: {
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
messageID: SessionMessage.ID.make("msg_init"),
|
||||
prompt: { text: "extra context", files: [{ uri: "file:///tmp/context.md" }] },
|
||||
delivery: "queue",
|
||||
},
|
||||
@@ -92,7 +90,6 @@ describe("CommandPlugin.Plugin", () => {
|
||||
name: "review",
|
||||
invocation: {
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
messageID: SessionMessage.ID.make("msg_review"),
|
||||
prompt: { text: " branch $& $$ $` $' " },
|
||||
delivery: "steer",
|
||||
},
|
||||
@@ -101,7 +98,6 @@ describe("CommandPlugin.Plugin", () => {
|
||||
name: "init",
|
||||
invocation: {
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
messageID: SessionMessage.ID.make("msg_init_empty"),
|
||||
prompt: { text: "" },
|
||||
delivery: "steer",
|
||||
},
|
||||
@@ -110,33 +106,27 @@ describe("CommandPlugin.Plugin", () => {
|
||||
name: "review",
|
||||
invocation: {
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
messageID: SessionMessage.ID.make("msg_review_empty"),
|
||||
prompt: { text: " " },
|
||||
delivery: "steer",
|
||||
},
|
||||
})
|
||||
expect(outcome).toEqual({ type: "prompt", inboxID: SessionMessage.ID.make("msg_init") })
|
||||
expect(prompts).toEqual([
|
||||
{
|
||||
id: "msg_init",
|
||||
text: PROMPT_INITIALIZE.replace("${path}", project).replaceAll("$ARGUMENTS", "extra context"),
|
||||
files: [{ uri: "file:///tmp/context.md" }],
|
||||
delivery: "queue",
|
||||
},
|
||||
{
|
||||
id: "msg_review",
|
||||
text: PROMPT_REVIEW.replace("${path}", project).replaceAll("$ARGUMENTS", () => "branch $& $$ $` $'"),
|
||||
files: undefined,
|
||||
delivery: "steer",
|
||||
},
|
||||
{
|
||||
id: "msg_init_empty",
|
||||
text: PROMPT_INITIALIZE.replace("${path}", project).replaceAll("$ARGUMENTS", ""),
|
||||
files: undefined,
|
||||
delivery: "steer",
|
||||
},
|
||||
{
|
||||
id: "msg_review_empty",
|
||||
text: PROMPT_REVIEW.replace("${path}", project).replaceAll("$ARGUMENTS", ""),
|
||||
files: undefined,
|
||||
delivery: "steer",
|
||||
|
||||
@@ -586,26 +586,26 @@ describe("OpencodePlugin", () => {
|
||||
expect(yield* websearch.default()).toBeUndefined()
|
||||
|
||||
state.advertised = true
|
||||
yield* TestClock.adjust("9 minutes")
|
||||
yield* TestClock.adjust("50 seconds")
|
||||
yield* drain
|
||||
expect(state.requests).toBe(1)
|
||||
expect(rebuilds).toEqual(initial)
|
||||
expect(yield* websearch.default()).toBeUndefined()
|
||||
|
||||
yield* TestClock.adjust("1 minute")
|
||||
yield* TestClock.adjust("10 seconds")
|
||||
yield* drain
|
||||
expect(state.requests).toBe(2)
|
||||
expect(rebuilds).toEqual({ provider: initial.provider + 1, websearch: initial.websearch + 1 })
|
||||
expect(yield* websearch.default()).toEqual({ id: WebSearch.ID.make("opencode"), name: "OpenCode Web Search" })
|
||||
|
||||
yield* TestClock.adjust("10 minutes")
|
||||
yield* TestClock.adjust("1 minute")
|
||||
yield* drain
|
||||
expect(state.requests).toBe(3)
|
||||
expect(rebuilds).toEqual({ provider: initial.provider + 1, websearch: initial.websearch + 1 })
|
||||
expect(yield* websearch.default()).toEqual({ id: WebSearch.ID.make("opencode"), name: "OpenCode Web Search" })
|
||||
|
||||
state.advertised = false
|
||||
yield* TestClock.adjust("10 minutes")
|
||||
yield* TestClock.adjust("1 minute")
|
||||
yield* drain
|
||||
expect(state.requests).toBe(4)
|
||||
expect(rebuilds).toEqual({ provider: initial.provider + 2, websearch: initial.websearch + 2 })
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode/desktop",
|
||||
"private": true,
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.5",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://opencode.ai",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/enterprise",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.5",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/function",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.5",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.5",
|
||||
"name": "@opencode/http-recorder",
|
||||
"description": "Record and replay Effect HTTP and WebSocket traffic with deterministic cassettes",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/httpapi-codegen",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.5",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/latex",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.5",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/merman",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.5",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/plugin-browser",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.5",
|
||||
"description": "OpenCode's desktop browser plugin",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/plugin",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.5",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,30 +1,20 @@
|
||||
import type { CommandApi } from "@opencode/client/effect/api"
|
||||
import type { Command } from "@opencode/schema/command"
|
||||
import type { PromptInput } from "@opencode/schema/prompt-input"
|
||||
import type { Session } from "@opencode/schema/session"
|
||||
import type { SessionInbox } from "@opencode/schema/session-inbox"
|
||||
import type { SessionMessage } from "@opencode/schema/session-message"
|
||||
import type { Effect } from "effect"
|
||||
import type { Transform } from "./registration.js"
|
||||
|
||||
export interface CommandInvocation {
|
||||
readonly sessionID: Session.ID
|
||||
/**
|
||||
* Identity for the input this command admits. Pass it as the prompt `id` and report it in a
|
||||
* `prompt` outcome so clients can follow the resulting work.
|
||||
*/
|
||||
readonly messageID: SessionMessage.ID
|
||||
readonly prompt: PromptInput.Prompt
|
||||
readonly delivery: SessionInbox.Delivery
|
||||
}
|
||||
|
||||
/** Resolve with `prompt` when the command admitted session input; `void` or `immediate` finish the command now. */
|
||||
export type CommandOutcome = Command.Outcome
|
||||
|
||||
export interface CommandDefinition {
|
||||
readonly name: string
|
||||
readonly description?: string
|
||||
readonly execute: (input: CommandInvocation) => Effect.Effect<CommandOutcome | void, unknown>
|
||||
readonly execute: (input: CommandInvocation) => Effect.Effect<void, unknown>
|
||||
}
|
||||
|
||||
export interface CommandEditor {
|
||||
|
||||
@@ -1,29 +1,19 @@
|
||||
import type { CommandApi } from "@opencode/client/promise/api"
|
||||
import type { Command } from "@opencode/schema/command"
|
||||
import type { PromptInput } from "@opencode/schema/prompt-input"
|
||||
import type { Session } from "@opencode/schema/session"
|
||||
import type { SessionInbox } from "@opencode/schema/session-inbox"
|
||||
import type { SessionMessage } from "@opencode/schema/session-message"
|
||||
import type { Transform } from "./registration.js"
|
||||
|
||||
export interface CommandInvocation {
|
||||
readonly sessionID: Session.ID
|
||||
/**
|
||||
* Identity for the input this command admits. Pass it as the prompt `id` and report it in a
|
||||
* `prompt` outcome so clients can follow the resulting work.
|
||||
*/
|
||||
readonly messageID: SessionMessage.ID
|
||||
readonly prompt: PromptInput.Prompt
|
||||
readonly delivery: SessionInbox.Delivery
|
||||
}
|
||||
|
||||
/** Resolve with `prompt` when the command admitted session input; `void` or `immediate` finish the command now. */
|
||||
export type CommandOutcome = Command.Outcome
|
||||
|
||||
export interface CommandDefinition {
|
||||
readonly name: string
|
||||
readonly description?: string
|
||||
readonly execute: (input: CommandInvocation) => Promise<CommandOutcome | void>
|
||||
readonly execute: (input: CommandInvocation) => Promise<void>
|
||||
}
|
||||
|
||||
export interface CommandEditor {
|
||||
|
||||
@@ -2066,22 +2066,8 @@
|
||||
],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/Command.Outcome"
|
||||
}
|
||||
},
|
||||
"required": ["data"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
"204": {
|
||||
"description": "<No Content>"
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
@@ -2134,7 +2120,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Execute a slash command callback. Returns whether the command finished immediately or admitted session input whose execution continues after this request.",
|
||||
"description": "Execute a slash command callback immediately.",
|
||||
"summary": "Run command",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
@@ -2145,18 +2131,6 @@
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"pattern": "^msg_"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Message ID for input the command admits, so its resulting work can be correlated."
|
||||
},
|
||||
"text": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -12493,37 +12467,6 @@
|
||||
"required": ["name"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Command.Outcome": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["immediate"]
|
||||
}
|
||||
},
|
||||
"required": ["type"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["prompt"]
|
||||
},
|
||||
"inboxID": {
|
||||
"type": "string",
|
||||
"pattern": "^msg_",
|
||||
"description": "Admitted inbox item ID. Must equal the invocation message ID."
|
||||
}
|
||||
},
|
||||
"required": ["type", "inboxID"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"CommandExecutionErrorEncoded": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/protocol",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.5",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -33,7 +33,6 @@ import {
|
||||
UnknownError,
|
||||
} from "../errors.js"
|
||||
import { Agent } from "@opencode/schema/agent"
|
||||
import { Command } from "@opencode/schema/command"
|
||||
import { Skill } from "@opencode/schema/skill"
|
||||
import { Model } from "@opencode/schema/model"
|
||||
import { Permission } from "@opencode/schema/permission"
|
||||
@@ -415,13 +414,10 @@ export const makeSessionGroup = <
|
||||
params: { sessionID: Session.ID },
|
||||
payload: Schema.Struct({
|
||||
name: Schema.String,
|
||||
id: SessionMessage.ID.pipe(Schema.optional).annotate({
|
||||
description: "Message ID for input the command admits, so its resulting work can be correlated.",
|
||||
}),
|
||||
...PromptInput.Prompt.fields,
|
||||
delivery: SessionInbox.Delivery.pipe(Schema.optional),
|
||||
}),
|
||||
success: Schema.Struct({ data: Command.Outcome }),
|
||||
success: HttpApiSchema.NoContent,
|
||||
error: [SessionNotFoundError, CommandNotFoundError, CommandExecutionError],
|
||||
})
|
||||
.middleware(sessionLocationMiddleware)
|
||||
@@ -429,8 +425,7 @@ export const makeSessionGroup = <
|
||||
OpenApi.annotations({
|
||||
identifier: "session.command",
|
||||
summary: "Run command",
|
||||
description:
|
||||
"Execute a slash command callback. Returns whether the command finished immediately or admitted session input whose execution continues after this request.",
|
||||
description: "Execute a slash command callback immediately.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -563,7 +558,9 @@ export const makeSessionGroup = <
|
||||
error: [SessionNotFoundError, SessionBusyError],
|
||||
})
|
||||
.middleware(sessionLocationMiddleware)
|
||||
.annotateMerge(OpenApi.annotations({ identifier: "session.revert.commit", summary: "Commit staged revert" })),
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({ identifier: "session.revert.commit", summary: "Commit staged revert" }),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("session.context", "/api/session/:sessionID/context", {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/schema",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.5",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -3,7 +3,6 @@ export * as Command from "./command.js"
|
||||
import { Schema } from "effect"
|
||||
import { ephemeral, inventory } from "./event.js"
|
||||
import { optional } from "./schema.js"
|
||||
import { SessionMessage } from "./session-message.js"
|
||||
|
||||
const Updated = ephemeral({ type: "command.updated", schema: {} })
|
||||
|
||||
@@ -13,22 +12,6 @@ export const Info = Schema.Struct({
|
||||
description: Schema.String.pipe(optional),
|
||||
}).annotate({ identifier: "Command.Info" })
|
||||
|
||||
/**
|
||||
* What a command did when invoked. `immediate` commands finish inside the request;
|
||||
* `prompt` commands durably admitted session input whose resulting work clients
|
||||
* follow through the invocation message ID.
|
||||
*/
|
||||
export const Outcome = Schema.Union([
|
||||
Schema.Struct({ type: Schema.tag("immediate") }),
|
||||
Schema.Struct({
|
||||
type: Schema.tag("prompt"),
|
||||
inboxID: SessionMessage.ID.annotate({
|
||||
description: "Admitted inbox item ID. Must equal the invocation message ID.",
|
||||
}),
|
||||
}),
|
||||
]).pipe(Schema.toTaggedUnion("type"), Schema.annotate({ identifier: "Command.Outcome" }))
|
||||
export type Outcome = typeof Outcome.Type
|
||||
|
||||
export const Event = {
|
||||
Updated,
|
||||
Definitions: inventory(Updated),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.5",
|
||||
"name": "@opencode/sdk",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/server",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.5",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -336,11 +336,10 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
||||
.handle(
|
||||
"session.command",
|
||||
Effect.fn(function* (ctx) {
|
||||
const outcome = yield* session
|
||||
yield* session
|
||||
.command({
|
||||
sessionID: ctx.params.sessionID,
|
||||
command: ctx.payload.name,
|
||||
id: ctx.payload.id,
|
||||
text: ctx.payload.text,
|
||||
files: ctx.payload.files,
|
||||
agents: ctx.payload.agents,
|
||||
@@ -366,7 +365,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
||||
),
|
||||
),
|
||||
)
|
||||
return { data: outcome }
|
||||
return HttpApiSchema.NoContent.make()
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/session-ui",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.5",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/simulation",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.5",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/stats-app",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.5",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/stats-core",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.5",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { toGeoAggregate, toModelAggregate, toProviderAggregate } from "./inference"
|
||||
import { buildStatsQuery, toGeoAggregate, toModelAggregate, toProviderAggregate } from "./inference"
|
||||
import { modelAuthor, normalizeInferenceModel, statModel, statProvider } from "./model-normalization"
|
||||
|
||||
describe("inference stat normalization", () => {
|
||||
@@ -38,6 +38,22 @@ describe("inference stat normalization", () => {
|
||||
expect(statProvider("unknown", "", "custom-provider")).toBe("custom-provider")
|
||||
})
|
||||
|
||||
test("merges union alpha routes without exposing the provider", () => {
|
||||
;["opencode-go/union-alpha", "opencode/union-alpha"].forEach((model) => {
|
||||
expect(statModel(model, "")).toBe("union-alpha")
|
||||
expect(statProvider(model, "gpt-test-model", "test-provider")).toBe("unknown")
|
||||
expect(
|
||||
toModelAggregate({ ...aggregate(model, "test-provider"), provider_model: "gpt-test-model" }),
|
||||
).toMatchObject([{ model: "union-alpha", provider: "unknown", requests: 1 }])
|
||||
expect(toProviderAggregate(aggregate(model, "test-provider"))).toMatchObject([
|
||||
{ provider: "unknown", requests: 1 },
|
||||
])
|
||||
expect(toGeoAggregate({ ...aggregate(model, "test-provider"), country: "US" })).toMatchObject([
|
||||
{ model: "union-alpha", provider: "unknown", country: "US", requests: 1 },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
test("merges renamed models under their current name", () => {
|
||||
expect(statModel("deepseek-flash", "")).toBe("deepseek-v4.1-flash")
|
||||
expect(statModel("x-preview-f", "")).toBe("ox-alpha")
|
||||
@@ -94,6 +110,20 @@ describe("inference stat normalization", () => {
|
||||
}),
|
||||
).toMatchObject([{ period_key: "2026-W20" }])
|
||||
})
|
||||
|
||||
test("normalizes union alpha routes in stat queries", () => {
|
||||
process.env.SST_RESOURCE_InferenceEvent = JSON.stringify({
|
||||
catalog: "inference",
|
||||
database: "events",
|
||||
table: "generation",
|
||||
})
|
||||
process.env.SST_RESOURCE_StatsSyncConfig = JSON.stringify({ dataset: "zen" })
|
||||
const query = buildStatsQuery(new Date("2026-05-20T00:00:00.000Z"), new Date("2026-05-21T00:00:00.000Z"))
|
||||
|
||||
expect(query).toContain("WHEN lower(model) = 'opencode-go/union-alpha' THEN 'union-alpha'")
|
||||
expect(query).toContain("WHEN lower(model) = 'opencode/union-alpha' THEN 'union-alpha'")
|
||||
expect(query).toContain("WHEN lower(model) IN ('omen-alpha', 'union-alpha') THEN 'unknown'")
|
||||
})
|
||||
})
|
||||
|
||||
function aggregate(model: string, provider: string) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
MODEL_AUTHOR_RULES,
|
||||
MODEL_NAME_ALIASES,
|
||||
RETIRED_STAT_PROVIDERS,
|
||||
STEALTH_MODELS,
|
||||
statModel,
|
||||
statProvider,
|
||||
} from "./model-normalization"
|
||||
@@ -263,6 +264,7 @@ ${Object.entries(MODEL_NAME_ALIASES)
|
||||
|
||||
function statProviderSql(model: string, providerModel: string, provider: string) {
|
||||
return `CASE
|
||||
WHEN lower(${model}) IN (${[...STEALTH_MODELS].map(sqlString).join(", ")}) THEN 'unknown'
|
||||
${MODEL_AUTHOR_RULES.map((item) => ` WHEN strpos(lower(${providerModel}), ${sqlString(item.match)}) > 0 THEN ${sqlString(item.author)}`).join("\n")}
|
||||
${MODEL_AUTHOR_RULES.map((item) => ` WHEN strpos(lower(${model}), ${sqlString(item.match)}) > 0 THEN ${sqlString(item.author)}`).join("\n")}
|
||||
WHEN ${provider} <> '' AND lower(${provider}) NOT IN (${RETIRED_STAT_PROVIDERS.map(sqlString).join(", ")}) THEN ${provider}
|
||||
|
||||
@@ -13,8 +13,11 @@ export const MODEL_AUTHOR_RULES = [
|
||||
{ match: "qwen", author: "qwen" },
|
||||
] as const
|
||||
export const EXCLUDED_MODELS = new Set(["alpha-gpt-next"])
|
||||
export const STEALTH_MODELS = new Set(["omen-alpha", "union-alpha"])
|
||||
export const MODEL_NAME_ALIASES: Record<string, string> = {
|
||||
"deepseek-flash": "deepseek-v4.1-flash",
|
||||
"opencode-go/union-alpha": "union-alpha",
|
||||
"opencode/union-alpha": "union-alpha",
|
||||
"x-preview-f": "ox-alpha",
|
||||
"xiaomi/mimo-v2.5": "mimo-v2.5",
|
||||
}
|
||||
@@ -45,7 +48,10 @@ export function statProvider(
|
||||
providerModel: string | undefined,
|
||||
provider: string | undefined,
|
||||
) {
|
||||
const modelAuthorValue = modelAuthor(statModel(model, providerModel))
|
||||
const normalized = statModel(model, providerModel)
|
||||
if (STEALTH_MODELS.has(normalized.toLowerCase())) return "unknown"
|
||||
|
||||
const modelAuthorValue = modelAuthor(normalized)
|
||||
if (!modelAuthorValue) return undefined
|
||||
|
||||
const providerModelAuthor = modelAuthor(providerModel)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/stats-server",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.5",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/theme",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.5",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/tui",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.5",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -128,7 +128,7 @@ export function createTuiAttention(input: {
|
||||
return {
|
||||
async notify(request) {
|
||||
try {
|
||||
if (!input.config.attention.enabled) return skipped("attention_disabled")
|
||||
if (!input.config.attention.notifications && !input.config.attention.sound) return skipped("attention_disabled")
|
||||
if (disposed || input.renderer.isDestroyed) return skipped("renderer_destroyed")
|
||||
|
||||
const message = normalizeText(request.message, "", MESSAGE_LIMIT)
|
||||
|
||||
@@ -253,20 +253,11 @@ export const settings: Setting[] = [
|
||||
format: (value) => `${value} ms`,
|
||||
keywords: ["leader key", "shortcut timeout"],
|
||||
},
|
||||
{
|
||||
title: "Attention",
|
||||
category: "Alerts",
|
||||
path: ["attention", "enabled"],
|
||||
default: false,
|
||||
values: [false, true],
|
||||
labels: ["off", "on"],
|
||||
keywords: ["alerts"],
|
||||
},
|
||||
{
|
||||
title: "Notifications",
|
||||
category: "Alerts",
|
||||
path: ["attention", "notifications"],
|
||||
default: true,
|
||||
default: false,
|
||||
values: [false, true],
|
||||
labels: ["off", "on"],
|
||||
keywords: ["system notifications", "desktop notifications", "alerts"],
|
||||
@@ -275,10 +266,10 @@ export const settings: Setting[] = [
|
||||
title: "Sounds",
|
||||
category: "Alerts",
|
||||
path: ["attention", "sound"],
|
||||
default: true,
|
||||
default: false,
|
||||
values: [false, true],
|
||||
labels: ["off", "on"],
|
||||
keywords: ["audio", "sound effects"],
|
||||
keywords: ["audio", "sound effects", "alerts"],
|
||||
},
|
||||
{
|
||||
title: "Volume",
|
||||
|
||||
@@ -95,7 +95,6 @@ export const Info = Schema.Struct({
|
||||
).annotate({ description: "Scrolling behavior" }),
|
||||
attention: Schema.optional(
|
||||
Schema.Struct({
|
||||
enabled: Schema.optional(Schema.Boolean).annotate({ description: "Enable attention alerts" }),
|
||||
notifications: Schema.optional(Schema.Boolean).annotate({ description: "Show system notifications" }),
|
||||
sound: Schema.optional(Schema.Boolean).annotate({ description: "Play attention sounds" }),
|
||||
volume: Schema.optional(
|
||||
@@ -242,7 +241,6 @@ export type Info = Schema.Schema.Type<typeof Info>
|
||||
|
||||
export type Resolved = Omit<Info, "attention" | "cursor" | "keybinds" | "leader" | "mouse" | "session" | "tabs"> & {
|
||||
attention: {
|
||||
enabled: boolean
|
||||
notifications: boolean
|
||||
sound: boolean
|
||||
volume: number
|
||||
@@ -285,9 +283,8 @@ export function resolve(input: Info, options: { terminalSuspend: boolean }): Res
|
||||
return {
|
||||
...input,
|
||||
attention: {
|
||||
enabled: input.attention?.enabled ?? false,
|
||||
notifications: input.attention?.notifications ?? true,
|
||||
sound: input.attention?.sound ?? true,
|
||||
notifications: input.attention?.notifications ?? false,
|
||||
sound: input.attention?.sound ?? false,
|
||||
volume: input.attention?.volume ?? 0.4,
|
||||
sound_pack: input.attention?.sound_pack ?? "opencode.default",
|
||||
sounds: input.attention?.sounds ?? {},
|
||||
|
||||
@@ -1751,8 +1751,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
}
|
||||
|
||||
input.trace?.write("send.command", { sessionID: input.sessionID, messageID, command: command.name, delivery })
|
||||
// Command outcomes are not inbox items; the event stream observes any admitted work.
|
||||
await client.session.command(
|
||||
return client.session.command(
|
||||
{
|
||||
sessionID: input.sessionID,
|
||||
name: command.name,
|
||||
|
||||
@@ -712,9 +712,9 @@ describe("V2 mini transport", () => {
|
||||
})
|
||||
|
||||
while (!ui.commits.some((commit) => commit.text === "Done.")) await Bun.sleep(0)
|
||||
expect(
|
||||
ui.commits.filter((commit) => commit.kind === "assistant" || commit.kind === "tool").map((commit) => commit.text),
|
||||
).toEqual(["Done."])
|
||||
expect(ui.commits.filter((commit) => commit.kind === "assistant" || commit.kind === "tool").map((commit) => commit.text)).toEqual([
|
||||
"Done.",
|
||||
])
|
||||
await transport.close()
|
||||
})
|
||||
|
||||
@@ -742,11 +742,7 @@ describe("V2 mini transport", () => {
|
||||
model: { providerID: "test", id: "model" },
|
||||
content: [
|
||||
{ type: "text", text: "I'll check." },
|
||||
canonicalToolPart("read", {
|
||||
status: "completed",
|
||||
input: {},
|
||||
content: [{ type: "text", text: "file" }],
|
||||
}),
|
||||
canonicalToolPart("read", { status: "completed", input: {}, content: [{ type: "text", text: "file" }] }),
|
||||
],
|
||||
time: { created: 2, completed: 3 },
|
||||
},
|
||||
@@ -763,9 +759,7 @@ describe("V2 mini transport", () => {
|
||||
|
||||
while (!ui.commits.some((commit) => commit.text === "Done.")) await Bun.sleep(0)
|
||||
expect(
|
||||
ui.commits
|
||||
.filter((commit) => commit.kind === "user" || commit.kind === "assistant" || commit.kind === "tool")
|
||||
.map((commit) => commit.text),
|
||||
ui.commits.filter((commit) => commit.kind === "user" || commit.kind === "assistant" || commit.kind === "tool").map((commit) => commit.text),
|
||||
).toEqual(["what happened", "Done."])
|
||||
await transport.close()
|
||||
})
|
||||
@@ -2935,7 +2929,10 @@ describe("V2 mini transport", () => {
|
||||
{ sessionID: "ses_1", model: { providerID: "openai", id: "gpt-5", variant: "high" } },
|
||||
{ signal: undefined },
|
||||
)
|
||||
expect(defaultModel).toHaveBeenCalledWith({ location: { directory: "/project" } }, { signal: undefined })
|
||||
expect(defaultModel).toHaveBeenCalledWith(
|
||||
{ location: { directory: "/project" } },
|
||||
{ signal: undefined },
|
||||
)
|
||||
await transport.close()
|
||||
})
|
||||
|
||||
@@ -3409,7 +3406,7 @@ describe("V2 mini transport", () => {
|
||||
data: { sessionID: "ses_1" },
|
||||
})
|
||||
})
|
||||
return ok({ type: "prompt" as const, inboxID: "msg_cmd" })
|
||||
return ok(undefined)
|
||||
})
|
||||
|
||||
await transport.runPromptTurn({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/ui",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.5",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/util",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.5",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@opencode/web",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.5",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "opencode",
|
||||
"displayName": "opencode",
|
||||
"description": "opencode for VS Code",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.5",
|
||||
"publisher": "sst-dev",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/update",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.5",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -2066,22 +2066,8 @@
|
||||
],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/Command.Outcome"
|
||||
}
|
||||
},
|
||||
"required": ["data"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
"204": {
|
||||
"description": "<No Content>"
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
@@ -2134,7 +2120,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Execute a slash command callback. Returns whether the command finished immediately or admitted session input whose execution continues after this request.",
|
||||
"description": "Execute a slash command callback immediately.",
|
||||
"summary": "Run command",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
@@ -2145,18 +2131,6 @@
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"pattern": "^msg_"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Message ID for input the command admits, so its resulting work can be correlated."
|
||||
},
|
||||
"text": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -12493,37 +12467,6 @@
|
||||
"required": ["name"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Command.Outcome": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["immediate"]
|
||||
}
|
||||
},
|
||||
"required": ["type"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["prompt"]
|
||||
},
|
||||
"inboxID": {
|
||||
"type": "string",
|
||||
"pattern": "^msg_",
|
||||
"description": "Admitted inbox item ID. Must equal the invocation message ID."
|
||||
}
|
||||
},
|
||||
"required": ["type", "inboxID"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"CommandExecutionErrorEncoded": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -2066,22 +2066,8 @@
|
||||
],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/Command.Outcome"
|
||||
}
|
||||
},
|
||||
"required": ["data"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
"204": {
|
||||
"description": "<No Content>"
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
@@ -2134,7 +2120,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Execute a slash command callback. Returns whether the command finished immediately or admitted session input whose execution continues after this request.",
|
||||
"description": "Execute a slash command callback immediately.",
|
||||
"summary": "Run command",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
@@ -2145,18 +2131,6 @@
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"pattern": "^msg_"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Message ID for input the command admits, so its resulting work can be correlated."
|
||||
},
|
||||
"text": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -12493,37 +12467,6 @@
|
||||
"required": ["name"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Command.Outcome": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["immediate"]
|
||||
}
|
||||
},
|
||||
"required": ["type"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["prompt"]
|
||||
},
|
||||
"inboxID": {
|
||||
"type": "string",
|
||||
"pattern": "^msg_",
|
||||
"description": "Admitted inbox item ID. Must equal the invocation message ID."
|
||||
}
|
||||
},
|
||||
"required": ["type", "inboxID"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"CommandExecutionErrorEncoded": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user