mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-25 19:16:15 +00:00
Compare commits
50
Commits
inline-code-fade
...
beta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5829431b0 | ||
|
|
1ca82d154c | ||
|
|
bc1f67e518 | ||
|
|
1f45962c84 | ||
|
|
88242e21a8 | ||
|
|
0fd719067d | ||
|
|
c94a4913c0 | ||
|
|
6c32ba81e2 | ||
|
|
938a82226a | ||
|
|
c46b76b58e | ||
|
|
fbb3730fdd | ||
|
|
96cff7bb7a | ||
|
|
578f8d637a | ||
|
|
e5308a988f | ||
|
|
d177f29dba | ||
|
|
7f51da509b | ||
|
|
004b647311 | ||
|
|
cce86ac166 | ||
|
|
b71291c05a | ||
|
|
0ab2d783e8 | ||
|
|
3deac93d27 | ||
|
|
ed582d1bdb | ||
|
|
db5a10dad1 | ||
|
|
ff59a22ff4 | ||
|
|
9c1787617c | ||
|
|
7601ab9fc4 | ||
|
|
4fb8a6038a | ||
|
|
5c25c38961 | ||
|
|
e3aa13c7d0 | ||
|
|
5963a30621 | ||
|
|
bcd1769521 | ||
|
|
691cb456ae | ||
|
|
190f189fbe | ||
|
|
8c126e98da | ||
|
|
ce8a489aaa | ||
|
|
1f7ae3f638 | ||
|
|
5ad0f0dc5a | ||
|
|
e589969398 | ||
|
|
42867d3bbc | ||
|
|
d4cdb99e4c | ||
|
|
0a78b11222 | ||
|
|
28c1806950 | ||
|
|
683f5fdee0 | ||
|
|
e9b5e055f5 | ||
|
|
f327adb0f2 | ||
|
|
442bc92a21 | ||
|
|
f2ff93a5b7 | ||
|
|
1b30098e8d | ||
|
|
1144ef6c5d | ||
|
|
d6deb62379 |
@@ -135,7 +135,16 @@ jobs:
|
||||
|
||||
const linkedIssues = result.repository.pullRequest.closingIssuesReferences.totalCount;
|
||||
|
||||
if (linkedIssues === 0) {
|
||||
// GitHub only populates closingIssuesReferences when a PR targets the repository's
|
||||
// default branch (dev). PRs targeting other branches like v2 always return totalCount 0.
|
||||
// Fall back to checking the PR description for closing keywords (e.g. Closes #123).
|
||||
const body = pr.body || '';
|
||||
const issueMatch = body.match(/### Issue for this PR\s*\n([\s\S]*?)(?=###|$)/);
|
||||
const issueContent = issueMatch ? issueMatch[1].trim() : body;
|
||||
const hasBodyIssueRef = /(closes|fixes|resolves)\s+#\d+/i.test(issueContent) || /#\d+/.test(issueContent);
|
||||
const hasLinkedIssue = linkedIssues > 0 || hasBodyIssueRef;
|
||||
|
||||
if (!hasLinkedIssue) {
|
||||
await addLabel('needs:issue');
|
||||
await comment('issue', `Thanks for your contribution!
|
||||
|
||||
|
||||
@@ -22,6 +22,36 @@ env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
affected:
|
||||
name: affected packages
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
outputs:
|
||||
app: ${{ steps.packages.outputs.app }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version-file: package.json
|
||||
|
||||
- name: Find affected packages
|
||||
id: packages
|
||||
env:
|
||||
TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha || github.event.before }}
|
||||
TURBO_SCM_HEAD: ${{ github.sha }}
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
echo "app=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
bun x turbo@2.10.2 ls --affected --filter=@opencode-ai/app --output=json > affected.json
|
||||
bun -e 'const result = await Bun.file("affected.json").json(); console.log(`app=${result.packages.count > 0}`)' >> "$GITHUB_OUTPUT"
|
||||
|
||||
unit:
|
||||
name: unit (${{ matrix.settings.name }})
|
||||
strategy:
|
||||
@@ -133,7 +163,8 @@ jobs:
|
||||
|
||||
e2e:
|
||||
name: e2e (${{ matrix.settings.name }})
|
||||
if: github.ref_name != 'v2' && github.head_ref != 'v2'
|
||||
needs: affected
|
||||
if: needs.affected.outputs.app == 'true' && github.ref_name != 'v2' && github.head_ref != 'v2'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
|
||||
@@ -430,6 +430,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@actions/artifact": "4.0.0",
|
||||
"@brendonovich/vite-plugin-opencode": "0.1.1",
|
||||
"@lydell/node-pty": "catalog:",
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
@@ -685,6 +686,7 @@
|
||||
"version": "1.18.4",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@effect/platform-node-shared": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/protocol": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
@@ -1610,6 +1612,8 @@
|
||||
|
||||
"@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.2", "", {}, "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA=="],
|
||||
|
||||
"@brendonovich/vite-plugin-opencode": ["@brendonovich/vite-plugin-opencode@0.1.1", "", { "dependencies": { "@babel/core": "^7.29.0", "@opencode-ai/client": "0.0.0-beta-18050" }, "peerDependencies": { "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-aPG0ct8ctxAqndbNOx7NW0GhU6QY6sOUfi/DaKqH9c5WdxICSsUop6uSkJwPDHP9WpN9eg0dd2D2qwYpG6UdHw=="],
|
||||
|
||||
"@bruits/satteri-darwin-arm64": ["@bruits/satteri-darwin-arm64@0.9.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-iw4nZgx9v30lWo/MTngQqi1pI78KI0DnkSm+lVJGYdmPLgAyDNJigVhpG42/Iq55A6c1Ll8q66ljyyRiQUxwow=="],
|
||||
|
||||
"@bruits/satteri-darwin-x64": ["@bruits/satteri-darwin-x64@0.9.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-6T26Z5Kf3cFW2PSlk9p7zT7yVxvuBSiJvYyz9u8KjYwMTqZyIDOj2wDyNpxKV4+6yUVG7rddq2QwvG/8LJA2+Q=="],
|
||||
@@ -6132,6 +6136,8 @@
|
||||
|
||||
"@babel/preset-env/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"@brendonovich/vite-plugin-opencode/@opencode-ai/client": ["@opencode-ai/client@0.0.0-beta-18050", "", { "dependencies": { "@opencode-ai/protocol": "0.0.0-beta-18050", "@opencode-ai/schema": "0.0.0-beta-18050" }, "peerDependencies": { "effect": "4.0.0-rc.111", "solid-js": ">=1.9.0" }, "optionalPeers": ["effect", "solid-js"] }, "sha512-zWZv5X23iyx+/mxwiAi18YY/VMjQofTaH7RyKMBt7KL6FmaKAWf9Q05zbQhrLX8DYJD3MOtbjBeQCGLsPUDU8g=="],
|
||||
|
||||
"@bruits/satteri-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="],
|
||||
|
||||
"@bruits/satteri-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="],
|
||||
@@ -6956,6 +6962,10 @@
|
||||
|
||||
"@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||
|
||||
"@brendonovich/vite-plugin-opencode/@opencode-ai/client/@opencode-ai/protocol": ["@opencode-ai/protocol@0.0.0-beta-18050", "", { "dependencies": { "@opencode-ai/schema": "0.0.0-beta-18050", "effect": "4.0.0-rc.111" } }, "sha512-HDQMnvGp8IU0MdBRbEuydX1WQm09BZ4HJm9iSMQwzweJuQ2HNscgzHJPIH6P02BsbbtfJ8J7sZGPItrz1tWSgw=="],
|
||||
|
||||
"@brendonovich/vite-plugin-opencode/@opencode-ai/client/@opencode-ai/schema": ["@opencode-ai/schema@0.0.0-beta-18050", "", { "dependencies": { "@standard-schema/spec": "1.1.0", "effect": "4.0.0-rc.111" } }, "sha512-/D6VXaWlytTXR3IOiMLIKuPcfp7FQNUzRPm9z3K7UBFd1Bw4q/WZksaf5RVcBGz+0YRxYMc1V4D7MFlceSgtyg=="],
|
||||
|
||||
"@bruits/satteri-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="],
|
||||
|
||||
"@electron/asar/minimatch/brace-expansion": ["brace-expansion@1.1.18", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw=="],
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
exact = true
|
||||
# Only install newly resolved package versions published at least 3 days ago.
|
||||
minimumReleaseAge = 259200
|
||||
minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@opencode-ai/sdk", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron-builder", "electron-publish", "blume"]
|
||||
minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@brendonovich/vite-plugin-opencode", "@opencode-ai/sdk", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron-builder", "electron-publish", "blume"]
|
||||
|
||||
[test]
|
||||
root = "./do-not-run-tests-from-root"
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-2bkzaLe/n63btVRQNhu8LXCtMZJArX1Kedi5U40l1xw=",
|
||||
"aarch64-linux": "sha256-5Cs9M3hvDKAymo71y8oZ7jj3pEm+MI+HHhuSuV7UvtM=",
|
||||
"aarch64-darwin": "sha256-LsJcuxE/NMu+vUFdpBKHc2z0sC0C5bRMlH1Kj+ns9dY=",
|
||||
"x86_64-darwin": "sha256-KDjmKC3JZD8I5A7gi+dYIl0dgHVt20/DwkM9RKBWiJk="
|
||||
"x86_64-linux": "sha256-3Jx1Q7hl+Y0Log/k2vd5y6dzBpzFKWlhShPESxn1Rm4=",
|
||||
"aarch64-linux": "sha256-EiiI6g01oBIrExCMAUgT3w82P0fvu4FAJhI32C+ze0I=",
|
||||
"aarch64-darwin": "sha256-s+w49HRp1+ewtiTaU65tPWjUiO1NQw3kzfemMEEQZb0=",
|
||||
"x86_64-darwin": "sha256-/Ee5V7pnL/qm3c4ZHeWEjH7FhGVXArXryOugbG5vsz8="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { RequestExecutor } from "./route/executor.js"
|
||||
import { mergeHttpOptions, type AIError } from "./schema/index.js"
|
||||
import { sanitizeSurrogates } from "./utils/sanitize.js"
|
||||
import type { ImageOptions, ImageRequest, ImageRequestFor, ImageResponse } from "./image.js"
|
||||
import type { AIError } from "./schema/index.js"
|
||||
|
||||
export type Execute = RequestExecutor.Interface["execute"]
|
||||
|
||||
@@ -26,7 +27,18 @@ export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer
|
||||
Effect.gen(function* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
return Service.of({
|
||||
generate: (request) => request.model.route.generate(request, executor.execute),
|
||||
generate: (request) =>
|
||||
request.model.route.generate(
|
||||
{
|
||||
...sanitizeSurrogates({
|
||||
...request,
|
||||
model: undefined,
|
||||
http: mergeHttpOptions(request.model.http, request.http),
|
||||
}),
|
||||
model: request.model,
|
||||
},
|
||||
executor.execute,
|
||||
),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Buffer } from "node:buffer"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import { Tool } from "@opencode-ai/schema/tool"
|
||||
import { Route } from "../route/client.js"
|
||||
import { Auth } from "../route/auth.js"
|
||||
@@ -361,6 +361,8 @@ const AnthropicStreamBlock = Schema.Struct({
|
||||
tool_use_id: Schema.optional(Schema.String),
|
||||
content: Schema.optional(Schema.Unknown),
|
||||
})
|
||||
type AnthropicStreamBlock = Schema.Schema.Type<typeof AnthropicStreamBlock>
|
||||
const decodeAnthropicStreamBlock = Schema.decodeUnknownOption(AnthropicStreamBlock)
|
||||
|
||||
const AnthropicStreamDelta = Schema.Struct({
|
||||
type: Schema.optional(Schema.String),
|
||||
@@ -371,13 +373,15 @@ const AnthropicStreamDelta = Schema.Struct({
|
||||
stop_reason: optionalNull(Schema.String),
|
||||
stop_sequence: optionalNull(Schema.String),
|
||||
})
|
||||
type AnthropicStreamDelta = Schema.Schema.Type<typeof AnthropicStreamDelta>
|
||||
const decodeAnthropicStreamDelta = Schema.decodeUnknownOption(AnthropicStreamDelta)
|
||||
|
||||
const AnthropicEvent = Schema.Struct({
|
||||
type: Schema.String,
|
||||
index: Schema.optional(Schema.Number),
|
||||
message: Schema.optional(Schema.Struct({ usage: Schema.optional(AnthropicUsage) })),
|
||||
content_block: Schema.optional(AnthropicStreamBlock),
|
||||
delta: Schema.optional(AnthropicStreamDelta),
|
||||
content_block: Schema.optional(Schema.Unknown),
|
||||
delta: Schema.optional(Schema.Unknown),
|
||||
usage: Schema.optional(AnthropicUsage),
|
||||
// `type` and `message` are both required per Anthropic's spec, but
|
||||
// OpenAI-compatible proxies and gateway translations occasionally drop one
|
||||
@@ -1106,7 +1110,7 @@ const SERVER_TOOL_RESULT_NAMES: Record<AnthropicServerToolResultType, string> =
|
||||
|
||||
const isServerToolResultType = (type: string): type is AnthropicServerToolResultType => type in SERVER_TOOL_RESULT_NAMES
|
||||
|
||||
const serverToolResultEvent = (block: NonNullable<AnthropicEvent["content_block"]>): LLMEvent | undefined => {
|
||||
const serverToolResultEvent = (block: AnthropicStreamBlock): LLMEvent | undefined => {
|
||||
if (!block.type || !isServerToolResultType(block.type)) return undefined
|
||||
const errorPayload =
|
||||
typeof block.content === "object" && block.content !== null && "type" in block.content
|
||||
@@ -1133,7 +1137,10 @@ const onMessageStart = (state: ParserState, event: AnthropicEvent): StepResult =
|
||||
return [usage ? { ...state, usage: mergeUsage(state.usage, usage) } : state, NO_EVENTS]
|
||||
}
|
||||
|
||||
const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepResult => {
|
||||
const onContentBlockStart = (
|
||||
state: ParserState,
|
||||
event: AnthropicEvent & { readonly content_block: AnthropicStreamBlock },
|
||||
): StepResult => {
|
||||
const block = event.content_block
|
||||
if (!block) return [state, NO_EVENTS]
|
||||
|
||||
@@ -1224,11 +1231,12 @@ const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepRes
|
||||
|
||||
const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(function* (
|
||||
state: ParserState,
|
||||
event: AnthropicEvent,
|
||||
event: AnthropicEvent & { readonly delta: AnthropicStreamDelta },
|
||||
) {
|
||||
const delta = event.delta
|
||||
|
||||
if (delta?.type === "text_delta" && delta.text) {
|
||||
if (!state.lifecycle.text.has(`text-${event.index ?? 0}`)) return [state, NO_EVENTS] satisfies StepResult
|
||||
const events: LLMEvent[] = []
|
||||
return [
|
||||
{ ...state, lifecycle: Lifecycle.textDelta(state.lifecycle, events, `text-${event.index ?? 0}`, delta.text) },
|
||||
@@ -1237,6 +1245,7 @@ const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(f
|
||||
}
|
||||
|
||||
if (delta?.type === "thinking_delta" && delta.thinking) {
|
||||
if (!state.lifecycle.reasoning.has(`reasoning-${event.index ?? 0}`)) return [state, NO_EVENTS] satisfies StepResult
|
||||
const events: LLMEvent[] = []
|
||||
return [
|
||||
{
|
||||
@@ -1249,6 +1258,7 @@ const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(f
|
||||
|
||||
if (delta?.type === "signature_delta" && delta.signature) {
|
||||
const index = event.index ?? 0
|
||||
if (!state.lifecycle.reasoning.has(`reasoning-${index}`)) return [state, NO_EVENTS] satisfies StepResult
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
@@ -1301,7 +1311,10 @@ const onContentBlockStop = Effect.fn("AnthropicMessages.onContentBlockStop")(fun
|
||||
return [{ ...state, lifecycle, tools: result.tools, reasoningSignatures }, events] satisfies StepResult
|
||||
})
|
||||
|
||||
const onMessageDelta = (state: ParserState, event: AnthropicEvent): StepResult => {
|
||||
const onMessageDelta = (
|
||||
state: ParserState,
|
||||
event: AnthropicEvent & { readonly delta?: AnthropicStreamDelta },
|
||||
): StepResult => {
|
||||
const usage = mergeUsage(state.usage, mapUsage(event.usage))
|
||||
return [
|
||||
{
|
||||
@@ -1356,11 +1369,49 @@ const onError = (event: AnthropicEvent) =>
|
||||
}),
|
||||
)
|
||||
|
||||
const isKnownStreamBlockType = (type: string) =>
|
||||
type === "text" ||
|
||||
type === "thinking" ||
|
||||
type === "redacted_thinking" ||
|
||||
type === "tool_use" ||
|
||||
type === "server_tool_use" ||
|
||||
isServerToolResultType(type)
|
||||
|
||||
const isKnownStreamDeltaType = (type: string) =>
|
||||
type === "text_delta" || type === "thinking_delta" || type === "signature_delta" || type === "input_json_delta"
|
||||
|
||||
const invalidStreamEvent = (event: AnthropicEvent) =>
|
||||
Effect.fail(
|
||||
ProviderShared.eventError(
|
||||
ADAPTER,
|
||||
"Invalid anthropic/anthropic-messages stream event",
|
||||
ProviderShared.encodeJson(event),
|
||||
),
|
||||
)
|
||||
|
||||
const step = (state: ParserState, event: AnthropicEvent) => {
|
||||
if (!SSE_EVENTS.has(event.type)) return Effect.succeed<StepResult>([state, NO_EVENTS])
|
||||
if (
|
||||
event.type !== "content_block_start" &&
|
||||
event.content_block !== undefined &&
|
||||
Option.isNone(decodeAnthropicStreamBlock(event.content_block))
|
||||
)
|
||||
return invalidStreamEvent(event)
|
||||
if (
|
||||
event.type !== "content_block_delta" &&
|
||||
event.delta !== undefined &&
|
||||
Option.isNone(decodeAnthropicStreamDelta(event.delta))
|
||||
)
|
||||
return invalidStreamEvent(event)
|
||||
if (event.type === "message_start") return Effect.succeed(onMessageStart(state, event))
|
||||
if (event.type === "content_block_start") {
|
||||
const block = event.content_block
|
||||
if (block && (block.type === "tool_use" || block.type === "server_tool_use")) {
|
||||
if (!ProviderShared.isRecord(event.content_block) || typeof event.content_block.type !== "string")
|
||||
return invalidStreamEvent(event)
|
||||
if (!isKnownStreamBlockType(event.content_block.type)) return Effect.succeed<StepResult>([state, NO_EVENTS])
|
||||
const decoded = decodeAnthropicStreamBlock(event.content_block)
|
||||
if (Option.isNone(decoded)) return invalidStreamEvent(event)
|
||||
const block = decoded.value
|
||||
if (block.type === "tool_use" || block.type === "server_tool_use") {
|
||||
if (event.index === undefined)
|
||||
return Effect.fail(ProviderShared.eventError(ADAPTER, `Anthropic ${block.type} missing index`))
|
||||
if (!block.id)
|
||||
@@ -1368,11 +1419,22 @@ const step = (state: ParserState, event: AnthropicEvent) => {
|
||||
ProviderShared.eventError(ADAPTER, `Anthropic tool_use missing id at index ${event.index}`),
|
||||
)
|
||||
}
|
||||
return Effect.succeed(onContentBlockStart(state, event))
|
||||
return Effect.succeed(onContentBlockStart(state, { ...event, content_block: block }))
|
||||
}
|
||||
if (event.type === "content_block_delta") {
|
||||
if (!ProviderShared.isRecord(event.delta)) return invalidStreamEvent(event)
|
||||
if (typeof event.delta.type === "string" && !isKnownStreamDeltaType(event.delta.type))
|
||||
return Effect.succeed<StepResult>([state, NO_EVENTS])
|
||||
const decoded = decodeAnthropicStreamDelta(event.delta)
|
||||
if (Option.isNone(decoded)) return invalidStreamEvent(event)
|
||||
return onContentBlockDelta(state, { ...event, delta: decoded.value })
|
||||
}
|
||||
if (event.type === "content_block_delta") return onContentBlockDelta(state, event)
|
||||
if (event.type === "content_block_stop") return onContentBlockStop(state, event)
|
||||
if (event.type === "message_delta") return Effect.succeed(onMessageDelta(state, event))
|
||||
if (event.type === "message_delta") {
|
||||
const decoded = decodeAnthropicStreamDelta(event.delta)
|
||||
if (Option.isNone(decoded)) return invalidStreamEvent(event)
|
||||
return Effect.succeed(onMessageDelta(state, { ...event, delta: decoded.value }))
|
||||
}
|
||||
if (event.type === "message_stop") return onMessageStop(state)
|
||||
if (event.type === "error") return onError(event)
|
||||
return Effect.succeed<StepResult>([state, NO_EVENTS])
|
||||
|
||||
@@ -212,11 +212,7 @@ const BedrockEvent = Schema.Struct({
|
||||
metrics: Schema.optional(Schema.Unknown),
|
||||
}),
|
||||
),
|
||||
internalServerException: Schema.optional(BedrockStreamException),
|
||||
modelStreamErrorException: Schema.optional(BedrockStreamException),
|
||||
validationException: Schema.optional(BedrockStreamException),
|
||||
throttlingException: Schema.optional(BedrockStreamException),
|
||||
serviceUnavailableException: Schema.optional(BedrockStreamException),
|
||||
exception: Schema.optional(Schema.Struct({ type: Schema.String, details: BedrockStreamException })),
|
||||
})
|
||||
type BedrockEvent = Schema.Schema.Type<typeof BedrockEvent>
|
||||
|
||||
@@ -650,22 +646,14 @@ const step = (state: ParserState, event: BedrockEvent) =>
|
||||
] as const
|
||||
}
|
||||
|
||||
const exception = (
|
||||
[
|
||||
["internalServerException", event.internalServerException],
|
||||
["modelStreamErrorException", event.modelStreamErrorException],
|
||||
["serviceUnavailableException", event.serviceUnavailableException],
|
||||
["throttlingException", event.throttlingException],
|
||||
["validationException", event.validationException],
|
||||
] as const
|
||||
).find((entry) => entry[1] !== undefined)
|
||||
if (exception) {
|
||||
if (event.exception) {
|
||||
return yield* new AIError({
|
||||
module: ADAPTER,
|
||||
method: "stream",
|
||||
reason: classifyProviderFailure({
|
||||
message: exception[1]?.message ?? exception[1]?.originalMessage ?? "Bedrock Converse stream error",
|
||||
code: exception[0],
|
||||
message:
|
||||
event.exception.details.message ?? event.exception.details.originalMessage ?? "Bedrock Converse stream error",
|
||||
code: event.exception.type,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ const consumeFrames = (route: string) => (state: FrameBufferState, chunk: Uint8A
|
||||
"Failed to parse Bedrock Converse event-stream payload",
|
||||
)) as Record<string, unknown>
|
||||
delete parsed.p
|
||||
out.push({ [eventType]: parsed })
|
||||
out.push(messageType === "exception" ? { exception: { type: eventType, details: parsed } } : { [eventType]: parsed })
|
||||
}
|
||||
return [cursor, out] as const
|
||||
})
|
||||
|
||||
@@ -154,7 +154,12 @@ export const driver = (input: DriverInput): WebSocketChannelDriver => {
|
||||
...observation,
|
||||
checkpoint: {
|
||||
protocol: PROTOCOL,
|
||||
value: { version: VERSION, responseID, request, output: output.slice() } satisfies CheckpointValue,
|
||||
value: {
|
||||
version: VERSION,
|
||||
responseID,
|
||||
request,
|
||||
output: event.response?.output ? [...event.response.output] : output.slice(),
|
||||
} satisfies CheckpointValue,
|
||||
},
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -288,6 +288,7 @@ export const Event = Schema.StructWithRest(
|
||||
arguments: Schema.optional(Schema.String),
|
||||
text: Schema.optional(Schema.String),
|
||||
item_id: Schema.optional(Schema.String),
|
||||
output_index: Schema.optional(Schema.Number),
|
||||
summary_index: Schema.optional(Schema.Number),
|
||||
item: Schema.optional(StreamItem),
|
||||
response: Schema.optional(
|
||||
@@ -296,6 +297,7 @@ export const Event = Schema.StructWithRest(
|
||||
id: Schema.optional(Schema.String),
|
||||
service_tier: optionalNull(Schema.String),
|
||||
incomplete_details: optionalNull(Schema.Struct({ reason: Schema.optional(Schema.String) })),
|
||||
output: Schema.optional(Schema.Array(StreamItem)),
|
||||
usage: optionalNull(OpenResponsesUsage),
|
||||
error: optionalNull(OpenResponsesErrorPayload),
|
||||
}),
|
||||
@@ -340,6 +342,7 @@ export interface ParserState {
|
||||
readonly tools: ToolStream.State<string>
|
||||
readonly hasFunctionCall: boolean
|
||||
readonly lifecycle: Lifecycle.State
|
||||
readonly outputItems: Readonly<Record<number, string>>
|
||||
readonly messageItems: ReadonlySet<string>
|
||||
readonly messagePhases: Readonly<Record<string, MessagePhase | null>>
|
||||
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
|
||||
@@ -654,19 +657,12 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
||||
}
|
||||
}
|
||||
|
||||
// With store:false, Responses APIs only accept previous reasoning items when the
|
||||
// complete item has encrypted state. Summary blocks for one item may carry
|
||||
// that state only on the last block, so filter after they have been joined.
|
||||
return store === false
|
||||
? input.filter(
|
||||
(item) => !("type" in item) || item.type !== "reasoning" || typeof item.encrypted_content === "string",
|
||||
)
|
||||
: input
|
||||
return input
|
||||
})
|
||||
|
||||
const lowerOptions = (request: LLMRequest) => {
|
||||
const options = OpenResponsesOptions.resolve(request)
|
||||
const cacheKey = ProviderShared.clampPromptCacheKey(request.promptCacheKey)
|
||||
const cacheKey = ProviderShared.promptCacheKey(request)
|
||||
const parallelToolCalls = resolveParallelToolCalls(request)
|
||||
return {
|
||||
...(options.instructions ? { instructions: options.instructions } : {}),
|
||||
@@ -817,6 +813,9 @@ const onOutputTextDone = (state: ParserState, event: Event, id: string): StepRes
|
||||
return [{ ...state, lifecycle: Lifecycle.textEnd(state.lifecycle, events, id) }, events]
|
||||
}
|
||||
|
||||
export const outputItemID = (state: ParserState, event: Event) =>
|
||||
event.output_index === undefined ? event.item_id : (state.outputItems[event.output_index] ?? event.item_id)
|
||||
|
||||
export const onReasoningDelta = (state: ParserState, event: Event, itemID: string): StepResult => {
|
||||
const item = state.reasoningItems[itemID]
|
||||
if (!event.delta || !item) return [state, NO_EVENTS]
|
||||
@@ -1111,30 +1110,49 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
||||
})
|
||||
|
||||
const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (state: ParserState, event: Event) {
|
||||
const reconciled =
|
||||
event.type === "response.completed"
|
||||
? yield* Effect.reduce(
|
||||
event.response?.output ?? [],
|
||||
() => [state, NO_EVENTS] satisfies StepResult,
|
||||
([current, events], item) => {
|
||||
if (
|
||||
!item.id ||
|
||||
((item.type !== "function_call" || !current.tools[item.id]) &&
|
||||
(item.type !== "reasoning" || !current.reasoningItems[item.id]))
|
||||
)
|
||||
return Effect.succeed([current, events] satisfies StepResult)
|
||||
return onOutputItemDone(current, { type: "response.output_item.done", item }).pipe(
|
||||
Effect.map(([next, emitted]) => [next, [...events, ...emitted]] satisfies StepResult),
|
||||
)
|
||||
},
|
||||
)
|
||||
: ([state, NO_EVENTS] satisfies StepResult)
|
||||
const current = reconciled[0]
|
||||
// Some compatible providers omit output_item.done even after completing the response.
|
||||
const pending =
|
||||
event.type === "response.completed"
|
||||
? yield* ToolStream.finishAll(state.id, state.tools)
|
||||
: { tools: state.tools, events: NO_EVENTS }
|
||||
const events: LLMEvent[] = [...pending.events]
|
||||
? yield* ToolStream.finishAll(current.id, current.tools)
|
||||
: { tools: current.tools, events: NO_EVENTS }
|
||||
const events: LLMEvent[] = [...reconciled[1], ...pending.events]
|
||||
const hasFunctionCall =
|
||||
pending.events.some((event) => LLMEvent.is.toolCall(event) || LLMEvent.is.toolInputError(event)) ||
|
||||
state.hasFunctionCall
|
||||
const lifecycle = Lifecycle.finish(state.lifecycle, events, {
|
||||
current.hasFunctionCall
|
||||
const lifecycle = Lifecycle.finish(current.lifecycle, events, {
|
||||
reason: {
|
||||
normalized: mapFinishReason(event, hasFunctionCall),
|
||||
raw: event.response?.incomplete_details?.reason,
|
||||
},
|
||||
usage: mapUsage(event.response?.usage, state.providerMetadataKey),
|
||||
usage: mapUsage(event.response?.usage, current.providerMetadataKey),
|
||||
providerMetadata:
|
||||
event.response?.id || event.response?.service_tier
|
||||
? providerMetadata(state, {
|
||||
? providerMetadata(current, {
|
||||
responseId: event.response.id,
|
||||
serviceTier: event.response.service_tier,
|
||||
})
|
||||
: undefined,
|
||||
})
|
||||
return [{ ...state, lifecycle, hasFunctionCall, tools: pending.tools }, events] satisfies StepResult
|
||||
return [{ ...current, lifecycle, hasFunctionCall, tools: pending.tools }, events] satisfies StepResult
|
||||
})
|
||||
|
||||
// Build the prettiest summary available from whatever the provider supplied.
|
||||
@@ -1181,7 +1199,11 @@ export const providerFailure = (id: string, event: Event, fallback: string) => {
|
||||
|
||||
const providerError = (state: ParserState, event: Event, fallback: string) => providerFailure(state.id, event, fallback)
|
||||
|
||||
export const step = (state: ParserState, event: Event) => {
|
||||
export const step = (state: ParserState, input: Event) => {
|
||||
const event =
|
||||
input.item_id && outputItemID(state, input) !== input.item_id
|
||||
? { ...input, item_id: outputItemID(state, input) }
|
||||
: input
|
||||
if (event.type === "response.output_text.delta" || event.type === "response.output_text.done") {
|
||||
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
|
||||
return Effect.succeed(
|
||||
@@ -1223,7 +1245,14 @@ export const step = (state: ParserState, event: Event) => {
|
||||
if (event.type === "response.output_item.added") {
|
||||
if (event.item?.type === "message" && !event.item.id)
|
||||
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
|
||||
return Effect.succeed(onOutputItemAdded(state, event))
|
||||
return Effect.succeed(
|
||||
onOutputItemAdded(
|
||||
event.output_index !== undefined && event.item?.id
|
||||
? { ...state, outputItems: { ...state.outputItems, [event.output_index]: event.item.id } }
|
||||
: state,
|
||||
event,
|
||||
),
|
||||
)
|
||||
}
|
||||
if (event.type === "response.function_call_arguments.delta" || event.type === "response.function_call_arguments.done")
|
||||
return event.item_id
|
||||
@@ -1258,6 +1287,7 @@ export const initial = (request: LLMRequest, extension: Extension = BASE): Parse
|
||||
hasFunctionCall: false,
|
||||
tools: ToolStream.empty<string>(),
|
||||
lifecycle: Lifecycle.initial(),
|
||||
outputItems: {},
|
||||
messageItems: new Set<string>(),
|
||||
messagePhases: {},
|
||||
reasoningItems: {},
|
||||
|
||||
@@ -478,12 +478,22 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
|
||||
]
|
||||
: [{ role: "system", content: ProviderShared.joinText(request.system) }]
|
||||
const messages = [...system]
|
||||
const requireAssistantAfterTool =
|
||||
request.model.compatibility?.requireAssistantAfterTool ??
|
||||
["mistral", "devstral", "codestral", "pixtral", "mixtral"].some((family) =>
|
||||
request.model.id.toLowerCase().includes(family),
|
||||
)
|
||||
const bridgeTools = () => {
|
||||
if (requireAssistantAfterTool && messages.at(-1)?.role === "tool") messages.push({ role: "assistant", content: "Done." })
|
||||
}
|
||||
const pendingImages: Array<Schema.Schema.Type<typeof OpenAIChatUserContent>> = []
|
||||
const flushImages = () => {
|
||||
if (pendingImages.length === 0) return
|
||||
bridgeTools()
|
||||
messages.push({ role: "user", content: pendingImages.splice(0) })
|
||||
}
|
||||
for (const message of request.messages) {
|
||||
if (message.role === "user") bridgeTools()
|
||||
if (message.role === "system") {
|
||||
const part = yield* ProviderShared.wrappedSystemUpdate("OpenAI Chat", message)
|
||||
if (pendingImages.length > 0) {
|
||||
@@ -526,6 +536,8 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
|
||||
)
|
||||
continue
|
||||
}
|
||||
if (message.role === "assistant" && message.content.every((part) => part.type === "text" && part.text.trim() === ""))
|
||||
continue
|
||||
if (message.role === "tool") {
|
||||
const lowered = yield* lowerToolMessages(message, options)
|
||||
messages.push(...lowered.messages)
|
||||
@@ -659,7 +671,7 @@ const detectZaiToolStream = (
|
||||
|
||||
const lowerOptions = (request: LLMRequest, supportsStore: boolean) => {
|
||||
const options = OpenAIOptions.resolve(request)
|
||||
const cacheKey = ProviderShared.clampPromptCacheKey(request.promptCacheKey)
|
||||
const cacheKey = ProviderShared.promptCacheKey(request)
|
||||
return {
|
||||
...(supportsStore && options.store !== undefined ? { store: options.store } : {}),
|
||||
// For providers that support `store`, ensure stateless `store:false` is sent
|
||||
|
||||
@@ -17,6 +17,7 @@ export const route = Route.make({
|
||||
protocol: OpenResponses.protocol,
|
||||
endpoint: Endpoint.path(OpenResponses.PATH),
|
||||
transport: OpenResponses.httpTransport,
|
||||
defaults: { providerOptions: { store: false, include: ["reasoning.encrypted_content"] } },
|
||||
})
|
||||
|
||||
export * as OpenAICompatibleResponses from "./openai-compatible-responses.js"
|
||||
|
||||
@@ -166,7 +166,9 @@ const HOSTED_TOOLS = {
|
||||
const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
|
||||
if (event.type === "response.reasoning_text.delta")
|
||||
return event.item_id
|
||||
? Effect.succeed(OpenResponses.onReasoningDelta(state, event, event.item_id))
|
||||
? Effect.succeed(
|
||||
OpenResponses.onReasoningDelta(state, event, OpenResponses.outputItemID(state, event) ?? event.item_id),
|
||||
)
|
||||
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
|
||||
if (event.type === "response.output_item.done" && event.item && ResponsesHostedTools.isItem(event.item, HOSTED_TOOLS))
|
||||
return ResponsesHostedTools.onDone(state, event.item, HOSTED_TOOLS)
|
||||
@@ -207,7 +209,7 @@ export const route = Route.make({
|
||||
endpoint,
|
||||
auth,
|
||||
transport,
|
||||
defaults: { providerOptions: { store: false } },
|
||||
defaults: { providerOptions: { store: false, include: ["reasoning.encrypted_content"] } },
|
||||
})
|
||||
|
||||
export * as OpenAIResponses from "./openai-responses.js"
|
||||
|
||||
@@ -28,10 +28,10 @@ export const OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH = 64
|
||||
|
||||
// OpenAI limits `prompt_cache_key` to 64 chars; DeepSeek and Zai inherit the same
|
||||
// limit via their OpenAI-compatible APIs. Clamp with unicode-aware slicing.
|
||||
export const clampPromptCacheKey = (key: string | undefined): string | undefined => {
|
||||
if (key === undefined) return undefined
|
||||
const chars = Array.from(key)
|
||||
if (chars.length <= OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH) return key
|
||||
export const promptCacheKey = (request: LLMRequest): string | undefined => {
|
||||
if (request.cache === "none" || request.promptCacheKey === undefined) return undefined
|
||||
const chars = Array.from(request.promptCacheKey)
|
||||
if (chars.length <= OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH) return request.promptCacheKey
|
||||
return chars.slice(0, OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH).join("")
|
||||
}
|
||||
|
||||
|
||||
@@ -42,7 +42,61 @@ export function parseJSON(jsonString: string, allowPartial = Allow.ALL): unknown
|
||||
try {
|
||||
return decodeJson(input)
|
||||
} catch {}
|
||||
return _parseJSON(input, allowPartial)
|
||||
|
||||
const repaired = repairJSON(input)
|
||||
if (repaired !== input) {
|
||||
try {
|
||||
return decodeJson(repaired)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
try {
|
||||
return _parseJSON(input, allowPartial)
|
||||
} catch (error) {
|
||||
if (repaired !== input) return _parseJSON(repaired, allowPartial)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const repairJSON = (input: string) => {
|
||||
let repaired = ""
|
||||
let quoted = false
|
||||
|
||||
for (let index = 0; index < input.length; index++) {
|
||||
const character = input[index]
|
||||
if (!quoted) {
|
||||
repaired += character
|
||||
if (character === '"') quoted = true
|
||||
continue
|
||||
}
|
||||
|
||||
if (character === '"') {
|
||||
repaired += character
|
||||
quoted = false
|
||||
continue
|
||||
}
|
||||
|
||||
if (character === "\\") {
|
||||
const next = input[index + 1]
|
||||
if (next === "u" && /^[0-9a-fA-F]{4}$/.test(input.slice(index + 2, index + 6))) {
|
||||
repaired += input.slice(index, index + 6)
|
||||
index += 5
|
||||
continue
|
||||
}
|
||||
if (next !== undefined && '"\\/bfnrtu'.includes(next)) {
|
||||
repaired += `\\${next}`
|
||||
index++
|
||||
continue
|
||||
}
|
||||
repaired += "\\\\"
|
||||
continue
|
||||
}
|
||||
|
||||
const code = character.charCodeAt(0)
|
||||
repaired += code <= 0x1f ? `\\u${code.toString(16).padStart(4, "0")}` : character
|
||||
}
|
||||
|
||||
return repaired
|
||||
}
|
||||
|
||||
const _parseJSON = (jsonString: string, allow: number) => {
|
||||
@@ -148,7 +202,12 @@ const _parseJSON = (jsonString: string, allow: number) => {
|
||||
skipBlank()
|
||||
index++
|
||||
try {
|
||||
object[key] = parseAny()
|
||||
Object.defineProperty(object, key, {
|
||||
value: parseAny(),
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
})
|
||||
} catch (error) {
|
||||
if (Allow.OBJ & allow) return object
|
||||
throw error
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect, Option } from "effect"
|
||||
import { AIError, LLMEvent, type ProviderMetadata, type ToolCall, type ToolInputError } from "../../schema/index.js"
|
||||
import { AIError, LLMEvent, type ProviderMetadata, type ToolCall } from "../../schema/index.js"
|
||||
import { eventError, parseToolInput, type ToolAccumulator } from "../shared.js"
|
||||
import { parse } from "./partial-json.js"
|
||||
|
||||
@@ -59,46 +59,44 @@ const inputStart = (tool: PendingTool) =>
|
||||
providerMetadata: tool.providerMetadata,
|
||||
})
|
||||
|
||||
const inputDelta = (tool: PendingTool, text: string) => {
|
||||
const input = parsePartialInput(tool.input)
|
||||
return LLMEvent.toolInputDelta({
|
||||
const inputDelta = (tool: PendingTool, text: string) =>
|
||||
LLMEvent.toolInputDelta({
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
text,
|
||||
...(Option.isSome(input) ? { input: input.value } : {}),
|
||||
input: Option.getOrElse(parsePartialInput(tool.input), () => ({})),
|
||||
})
|
||||
}
|
||||
|
||||
const toolCall = (route: string, tool: PendingTool, inputOverride?: string) => {
|
||||
const raw = inputOverride ?? tool.input
|
||||
return parseToolInput(route, tool.name, raw).pipe(
|
||||
Effect.map((input): ToolCall | ToolInputError =>
|
||||
LLMEvent.toolCall({
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
input,
|
||||
providerExecuted: tool.providerExecuted ? true : undefined,
|
||||
providerMetadata: tool.providerMetadata,
|
||||
}),
|
||||
),
|
||||
Effect.catch((error) =>
|
||||
tool.providerExecuted
|
||||
? Effect.fail(error)
|
||||
: Effect.succeed(
|
||||
LLMEvent.toolInputError({
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
raw,
|
||||
}),
|
||||
Option.getOrElse(
|
||||
Option.map(parsePartialInput(raw), (input) => input ?? {}),
|
||||
() => ({}),
|
||||
),
|
||||
),
|
||||
),
|
||||
Effect.map(
|
||||
(input): ToolCall =>
|
||||
LLMEvent.toolCall({
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
input,
|
||||
providerExecuted: tool.providerExecuted ? true : undefined,
|
||||
providerMetadata: tool.providerMetadata,
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const finishEvents = (tool: PendingTool, event: ToolCall | ToolInputError): ReadonlyArray<LLMEvent> =>
|
||||
event.type === "tool-input-error"
|
||||
? [event]
|
||||
: [LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }), event]
|
||||
const finishEvents = (tool: PendingTool, event: ToolCall): ReadonlyArray<LLMEvent> => [
|
||||
LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }),
|
||||
event,
|
||||
]
|
||||
|
||||
/** Store the updated tool and produce the optional public delta event. */
|
||||
const appendTool = <K extends StreamKey>(
|
||||
@@ -181,7 +179,7 @@ export const appendExisting = <K extends StreamKey>(
|
||||
|
||||
/**
|
||||
* Finalize one pending tool call: parse the accumulated raw JSON, remove it
|
||||
* from state, and return either a call or a non-executable local input error.
|
||||
* from state, and recover incomplete local arguments when needed.
|
||||
* Missing keys are a no-op because some providers emit stop events for
|
||||
* non-tool content blocks.
|
||||
*/
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { ProviderPackage } from "../provider-package.js"
|
||||
import * as OpenAICompatibleProfiles from "./openai-compatible-profile.js"
|
||||
import * as OpenAIChat from "../protocols/openai-chat.js"
|
||||
import { newBreakpoints, ttlBucket } from "../protocols/utils/cache.js"
|
||||
import { isRecord, ProviderShared } from "../protocols/shared.js"
|
||||
import { isRecord } from "../protocols/shared.js"
|
||||
|
||||
export const profile = OpenAICompatibleProfiles.profiles.openrouter
|
||||
export const id = ProviderID.make(profile.provider)
|
||||
@@ -115,12 +115,10 @@ export const protocol = Protocol.make({
|
||||
reasoning_details: reasoningDetails,
|
||||
}
|
||||
})
|
||||
const cacheKey = ProviderShared.clampPromptCacheKey(request.promptCacheKey)
|
||||
return {
|
||||
...body,
|
||||
messages,
|
||||
...bodyOptions(request.providerOptions),
|
||||
...(cacheKey ? { prompt_cache_key: cacheKey } : {}),
|
||||
} as OpenRouterBody
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -42,7 +42,7 @@ const responsesRoute = Route.make({
|
||||
name: "xAI Responses",
|
||||
rotateAfterMs: RESPONSES_WEBSOCKET_ROTATE_AFTER_MS,
|
||||
}),
|
||||
defaults: { providerOptions: { store: false } },
|
||||
defaults: { providerOptions: { store: false, include: ["reasoning.encrypted_content"] } },
|
||||
})
|
||||
|
||||
const chatRoute = Route.make({
|
||||
|
||||
@@ -7,6 +7,7 @@ import { HttpTransport } from "./transport/index.js"
|
||||
import type { HttpMiddleware, Transport, TransportRuntime, WebSocketChannelExecutor } from "./transport/index.js"
|
||||
import type { Protocol } from "./protocol.js"
|
||||
import { applyCachePolicy } from "../cache-policy.js"
|
||||
import { sanitizeSurrogates } from "../utils/sanitize.js"
|
||||
import * as ProviderShared from "../protocols/shared.js"
|
||||
import type { ProtocolID, ProviderOptions } from "../schema/index.js"
|
||||
import {
|
||||
@@ -400,7 +401,8 @@ export function make<Body, Prepared, Frame, Event, State>(
|
||||
}
|
||||
|
||||
const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest, options?: StreamOptions) {
|
||||
const resolved = applyCachePolicy(resolveRequestOptions(request))
|
||||
const original = applyCachePolicy(resolveRequestOptions(request))
|
||||
const resolved = LLMRequest.update(original, sanitizeSurrogates({ ...LLMRequest.input(original), model: undefined }))
|
||||
const route = resolved.model.route
|
||||
|
||||
const body = yield* route.body
|
||||
|
||||
@@ -155,6 +155,7 @@ export class LanguageModelCompatibility extends Schema.Class<LanguageModelCompat
|
||||
reasoningField: Schema.optional(Schema.String),
|
||||
maxTokensField: Schema.optional(LanguageModelMaxTokensFieldCompatibility),
|
||||
requireFinishReason: Schema.optional(Schema.Boolean),
|
||||
requireAssistantAfterTool: Schema.optional(Schema.Boolean),
|
||||
supportsStore: Schema.optional(Schema.Boolean),
|
||||
supportsUsageInStreaming: Schema.optional(Schema.Boolean),
|
||||
supportsStrictMode: Schema.optional(Schema.Boolean),
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { isRecord } from "./record.js"
|
||||
|
||||
export const sanitizeSurrogates = <T>(value: T): T => {
|
||||
if (typeof value === "string") return value.toWellFormed() as T
|
||||
if (Array.isArray(value)) return value.map(sanitizeSurrogates) as T
|
||||
if (value instanceof Uint8Array || value instanceof Error) return value
|
||||
if (isRecord(value))
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, entry]) => [key.toWellFormed(), sanitizeSurrogates(entry)]),
|
||||
) as T
|
||||
return value
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect, Ref, Schema } from "effect"
|
||||
import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { LLM, mergeProviderOptions } from "../src/index.js"
|
||||
import { LLM, Message, ToolCallPart, mergeProviderOptions } from "../src/index.js"
|
||||
import { AnthropicMessages, OpenAIChat } from "../src/protocols.js"
|
||||
import { Auth, LLMClient } from "../src/route.js"
|
||||
import { compileRequest } from "../src/route/client.js"
|
||||
@@ -247,6 +247,73 @@ describe("request option precedence", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("sanitizes outbound JSON without an HTTP overlay", () =>
|
||||
LLMClient.generate(
|
||||
LLM.request({
|
||||
model: OpenAIChat.route
|
||||
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
|
||||
.model({ id: "gpt-4o-mini" }),
|
||||
prompt: "hello \uD800 \u{1F600}",
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
dynamicResponse((input) =>
|
||||
Effect.gen(function* () {
|
||||
expect(decodeJson(input.text)).toMatchObject({
|
||||
messages: [{ role: "user", content: "hello \uFFFD \u{1F600}" }],
|
||||
})
|
||||
return input.respond(sseEvents(deltaChunk({}, "stop")), {
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
})
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("sanitizes unpaired surrogates throughout outbound JSON", () =>
|
||||
LLMClient.generate(
|
||||
LLM.request({
|
||||
model: OpenAIChat.route
|
||||
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
|
||||
.model({ id: "gpt-4o-mini" }),
|
||||
system: "system \uD800 \u{1F600}",
|
||||
messages: [
|
||||
Message.user("user \uDC00"),
|
||||
Message.assistant([
|
||||
Message.text("assistant \uD800"),
|
||||
ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "input \uDC00" } }),
|
||||
]),
|
||||
Message.tool({ id: "call_1", name: "lookup", result: { output: "result \uD800" } }),
|
||||
],
|
||||
http: { body: { metadata: { "key\uD800": ["overlay \uDC00", "valid \u{1F600}"] } } },
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
dynamicResponse((input) =>
|
||||
Effect.gen(function* () {
|
||||
expect(decodeJson(input.text)).toMatchObject({
|
||||
messages: [
|
||||
{ role: "system", content: "system \uFFFD \u{1F600}" },
|
||||
{ role: "user", content: "user \uFFFD" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: "assistant \uFFFD",
|
||||
tool_calls: [{ function: { arguments: '{"query":"input \uFFFD"}' } }],
|
||||
},
|
||||
{ role: "tool", content: '{"output":"result \uFFFD"}' },
|
||||
],
|
||||
metadata: { "key\uFFFD": ["overlay \uFFFD", "valid \u{1F600}"] },
|
||||
})
|
||||
return input.respond(sseEvents(deltaChunk({}, "stop")), {
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
})
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("applies raw body overlays after protocol lowering", () =>
|
||||
LLMClient.generate(
|
||||
LLM.request({
|
||||
|
||||
File diff suppressed because one or more lines are too long
+11
-6
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -18,6 +18,21 @@ describe("partial JSON", () => {
|
||||
expect(() => parse('"hello', ~Allow.STR)).toThrow(PartialJSON)
|
||||
})
|
||||
|
||||
test("repairs invalid escapes and raw control characters", () => {
|
||||
expect(parse('{"path":"A\\H","text":"first\tsecond"}')).toEqual({
|
||||
path: "A\\H",
|
||||
text: "first\tsecond",
|
||||
})
|
||||
})
|
||||
|
||||
test("preserves prototype keys in partial objects", () => {
|
||||
const object = parse('{"__proto__":{"safe":true}') as Record<string, unknown>
|
||||
|
||||
expect(Object.hasOwn(object, "__proto__")).toBe(true)
|
||||
expect(Object.getPrototypeOf(object)).toBe(Object.prototype)
|
||||
expect(object.__proto__).toEqual({ safe: true })
|
||||
})
|
||||
|
||||
test("controls partial collection values independently", () => {
|
||||
expect(parse('["', Allow.ARR)).toEqual([])
|
||||
expect(parse('["', Allow.ARR | Allow.STR)).toEqual([""])
|
||||
|
||||
@@ -95,7 +95,11 @@ describe("provider package entrypoints", () => {
|
||||
})
|
||||
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
|
||||
expect(selected.route.defaults.http?.body).toEqual({ service_tier: "priority" })
|
||||
expect(selected.route.defaults.providerOptions).toEqual({ reasoningEffort: "low", store: true })
|
||||
expect(selected.route.defaults.providerOptions).toEqual({
|
||||
reasoningEffort: "low",
|
||||
store: true,
|
||||
include: ["reasoning.encrypted_content"],
|
||||
})
|
||||
})
|
||||
|
||||
test("maps Anthropic-compatible settings onto the executable model", async () => {
|
||||
@@ -285,7 +289,10 @@ describe("provider package entrypoints", () => {
|
||||
baseURL: "https://aiplatform.googleapis.com/v1/projects/vertex-project/locations/global/endpoints/openapi",
|
||||
path: "/responses",
|
||||
})
|
||||
expect(responses.route.defaults.providerOptions).toEqual({ store: false })
|
||||
expect(responses.route.defaults.providerOptions).toEqual({
|
||||
store: false,
|
||||
include: ["reasoning.encrypted_content"],
|
||||
})
|
||||
})
|
||||
|
||||
test("rejects conflicting Vertex auth settings at runtime", async () => {
|
||||
|
||||
@@ -770,6 +770,108 @@ describe("Anthropic Messages route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("ignores unknown content block and delta variants", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
|
||||
{ type: "future_event", content_block: 42, delta: 42 },
|
||||
{ type: "content_block_start", index: 0, content_block: { type: "future_block", text: 42 } },
|
||||
{ type: "content_block_delta", index: 0, delta: { text: "ignored" } },
|
||||
{ type: "content_block_delta", index: 0, delta: { type: "future_delta", text: 42 } },
|
||||
{ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "hidden" } },
|
||||
{ type: "content_block_delta", index: 0, delta: { type: "thinking_delta", thinking: "hidden" } },
|
||||
{ type: "content_block_delta", index: 0, delta: { type: "signature_delta", signature: "hidden" } },
|
||||
{ type: "content_block_stop", index: 0 },
|
||||
{ type: "content_block_start", index: 1, content_block: { type: "text", text: "" } },
|
||||
{ type: "content_block_delta", index: 1, delta: { type: "text_delta", text: "Hello" } },
|
||||
{ type: "content_block_stop", index: 1 },
|
||||
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } },
|
||||
{ type: "message_stop" },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.message.content).toEqual([{ type: "text", text: "Hello" }])
|
||||
expect(response.finishReason).toEqual({ normalized: "stop", raw: "end_turn" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects malformed recognized content block variants", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
|
||||
{ type: "content_block_start", index: 0, content_block: { type: "text", text: 42 } },
|
||||
),
|
||||
),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "InvalidProviderOutput",
|
||||
message: "Invalid anthropic/anthropic-messages stream event",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects malformed recognized content delta variants", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
|
||||
{ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } },
|
||||
{ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: 42 } },
|
||||
),
|
||||
),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "InvalidProviderOutput",
|
||||
message: "Invalid anthropic/anthropic-messages stream event",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects malformed payloads on unrelated stream events", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = [
|
||||
{ type: "message_start", message: { usage: { input_tokens: 1 } }, delta: 42 },
|
||||
{ type: "content_block_start", index: 0 },
|
||||
{ type: "content_block_delta", index: 0 },
|
||||
{ type: "content_block_stop", index: 0, content_block: { type: "text", text: 42 } },
|
||||
{ type: "message_delta" },
|
||||
{ type: "message_delta", delta: { stop_reason: 42 } },
|
||||
{ type: "message_stop", delta: { text: 42 } },
|
||||
{ type: "error", error: { type: "overloaded_error", message: "busy" }, content_block: 42 },
|
||||
]
|
||||
|
||||
yield* Effect.forEach(events, (event) =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents(event))),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "InvalidProviderOutput",
|
||||
message: "Invalid anthropic/anthropic-messages stream event",
|
||||
})
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects malformed recognized SSE events", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
|
||||
@@ -491,7 +491,7 @@ describe("Bedrock Converse route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("emits malformed tool input as an unexecuted tool error", () =>
|
||||
it.effect("recovers incomplete tool input at finalization", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = eventStreamBody(
|
||||
["messageStart", { role: "assistant" }],
|
||||
@@ -508,10 +508,10 @@ describe("Bedrock Converse route", () => {
|
||||
)
|
||||
const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
|
||||
|
||||
expect(response.events.find((event) => event.type === "tool-input-error")).toMatchObject({
|
||||
expect(response.events.find((event) => event.type === "tool-call")).toMatchObject({
|
||||
id: "tool_1",
|
||||
name: "lookup",
|
||||
raw: '{"query":"partial',
|
||||
input: { query: "partial" },
|
||||
})
|
||||
expect(response.finishReason).toEqual({ normalized: "tool-calls", raw: "end_turn" })
|
||||
}),
|
||||
@@ -716,6 +716,32 @@ describe("Bedrock Converse route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("ignores unknown normal stream events", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = concat([
|
||||
eventFrame("messageStart", { role: "assistant" }),
|
||||
eventFrame("futureEvent", { message: "Ignore this" }),
|
||||
eventFrame("messageStop", { stopReason: "end_turn" }),
|
||||
])
|
||||
const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
|
||||
|
||||
expect(response.finishReason).toEqual({ normalized: "stop", raw: "end_turn" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fails unknown stream exceptions after message stop", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = concat([
|
||||
eventFrame("messageStart", { role: "assistant" }),
|
||||
eventFrame("messageStop", { stopReason: "end_turn" }),
|
||||
exceptionFrame("futureException", { message: "A future provider failure" }),
|
||||
])
|
||||
const error = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)), Effect.flip)
|
||||
|
||||
expect(error.reason).toMatchObject({ _tag: "UnknownProvider", message: "A future provider failure" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("classifies throttlingException as a rate limit", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = concat([
|
||||
|
||||
@@ -85,6 +85,28 @@ describe("OpenAI Chat route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("omits empty and whitespace-only assistant messages", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.user("Before."),
|
||||
Message.assistant([]),
|
||||
Message.assistant(""),
|
||||
Message.assistant(" \n\t "),
|
||||
Message.assistant("After."),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{ role: "user", content: "Before." },
|
||||
{ role: "assistant", content: "After." },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replays canonical reasoning as OpenAI-compatible reasoning_content", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
@@ -192,6 +214,21 @@ describe("OpenAI Chat route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("omits the prompt cache key when caching is disabled", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Hello",
|
||||
promptCacheKey: "session_123",
|
||||
cache: "none",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body).not.toHaveProperty("prompt_cache_key")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("maps the xAI Chat prompt cache key to conversation affinity", () =>
|
||||
LLMClient.generate(
|
||||
LLM.request({
|
||||
@@ -416,6 +453,30 @@ describe("OpenAI Chat route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("bridges image tool results before their synthetic user message when required", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: LanguageModel.update(model, { compatibility: { requireAssistantAfterTool: true } }),
|
||||
messages: [
|
||||
Message.assistant([ToolCallPart.make({ id: "call_image", name: "read", input: {} })]),
|
||||
Message.tool({
|
||||
id: "call_image",
|
||||
name: "read",
|
||||
result: {
|
||||
type: "content",
|
||||
value: [{ type: "file", uri: "data:image/png;base64,AAECAw==", mime: "image/png", name: "pixel.png" }],
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.messages.map((message) => message.role)).toEqual(["assistant", "tool", "assistant", "user"])
|
||||
expect(prepared.body.messages[2]).toEqual({ role: "assistant", content: "Done." })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("orders parallel tool responses before one aggregated vision message", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
|
||||
@@ -238,6 +238,47 @@ describe("OpenAI-compatible Chat route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("bridges tool results for Mistral-family models and honors compatibility overrides", () =>
|
||||
Effect.gen(function* () {
|
||||
const cases = [
|
||||
{ id: "mistral-small", bridge: true },
|
||||
{ id: "devstral-small", bridge: true },
|
||||
{ id: "codestral-latest", bridge: true },
|
||||
{ id: "pixtral-large", bridge: true },
|
||||
{ id: "open-mixtral-8x22b", bridge: true },
|
||||
{ id: "ordinary-model", bridge: false },
|
||||
{ id: "ordinary-model", override: true, bridge: true },
|
||||
{ id: "mistral-small", override: false, bridge: false },
|
||||
] as const
|
||||
|
||||
yield* Effect.forEach(cases, (item) =>
|
||||
Effect.gen(function* () {
|
||||
const selected = OpenAICompatibleChat.route
|
||||
.with({ provider: "custom", endpoint: { baseURL: "https://api.custom.test/v1" } })
|
||||
.model({
|
||||
id: item.id,
|
||||
compatibility: "override" in item ? { requireAssistantAfterTool: item.override } : undefined,
|
||||
})
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: selected,
|
||||
messages: [
|
||||
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })]),
|
||||
Message.tool({ id: "call_1", name: "lookup", result: "Sunny" }),
|
||||
Message.user("What next?"),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.messages.map((message) => message.role)).toEqual(
|
||||
item.bridge ? ["assistant", "tool", "assistant", "user"] : ["assistant", "tool", "user"],
|
||||
)
|
||||
if (item.bridge) expect(prepared.body.messages[2]).toEqual({ role: "assistant", content: "Done." })
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("posts to the configured compatible endpoint and parses text usage", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
|
||||
@@ -52,10 +52,28 @@ describe("Open Responses-compatible route", () => {
|
||||
{ role: "user", content: [{ type: "input_text", text: "Say hello." }] },
|
||||
],
|
||||
stream: true,
|
||||
store: false,
|
||||
include: ["reasoning.encrypted_content"],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("allows callers to override stateless encrypted reasoning defaults", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = configure({
|
||||
apiKey: "test-key",
|
||||
baseURL: "https://responses.example.test/v1",
|
||||
provider: "example",
|
||||
}).model("example-model")
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({ model, prompt: "Say hello.", providerOptions: { store: true, include: [] } }),
|
||||
)
|
||||
|
||||
expect(prepared.body.store).toBe(true)
|
||||
expect(prepared.body.include).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers chronological system updates as standard developer messages", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = configure({
|
||||
@@ -207,6 +225,105 @@ describe("Open Responses-compatible route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("routes response deltas by output index", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = configure({
|
||||
apiKey: "test-key",
|
||||
baseURL: "https://responses.example.test/v1",
|
||||
}).model("example-model")
|
||||
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Say hello." })).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "response.output_item.added", output_index: 2, item: { type: "message", id: "msg_1" } },
|
||||
{ type: "response.output_text.delta", output_index: 2, item_id: "wrong_message", delta: "Indexed" },
|
||||
{ type: "response.output_item.done", output_index: 2, item: { type: "message", id: "msg_1" } },
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.message.content).toEqual([
|
||||
{ type: "text", text: "Indexed", providerMetadata: { openresponses: { itemId: "msg_1" } } },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("finalizes pending function calls from completed response output", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = configure({
|
||||
apiKey: "test-key",
|
||||
baseURL: "https://responses.example.test/v1",
|
||||
provider: "example",
|
||||
}).model("example-model")
|
||||
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Look it up." })).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "function_call", id: "item_1", call_id: "call_1", name: "lookup", arguments: "" },
|
||||
},
|
||||
{ type: "response.function_call_arguments.delta", item_id: "item_1", delta: '{"query":"par' },
|
||||
{
|
||||
type: "response.completed",
|
||||
response: {
|
||||
output: [
|
||||
{
|
||||
type: "function_call",
|
||||
id: "item_1",
|
||||
call_id: "call_1",
|
||||
name: "lookup",
|
||||
arguments: '{"query":"complete"}',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.events.find(LLMEvent.is.toolCall)).toMatchObject({
|
||||
input: { query: "complete" },
|
||||
providerMetadata: { openresponses: { itemId: "item_1" } },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves terminal reasoning metadata when item completion is missing", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = configure({
|
||||
apiKey: "test-key",
|
||||
baseURL: "https://responses.example.test/v1",
|
||||
}).model("example-model")
|
||||
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Think it through." })).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "reasoning", id: "rs_raw", encrypted_content: null },
|
||||
},
|
||||
{ type: "response.reasoning_summary_text.delta", item_id: "rs_raw", delta: "Thinking" },
|
||||
{
|
||||
type: "response.completed",
|
||||
response: {
|
||||
output: [{ type: "reasoning", id: "rs_raw", encrypted_content: "raw-state" }],
|
||||
},
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.events.find((event) => event.type === "reasoning-end")).toMatchObject({
|
||||
providerMetadata: { openresponses: { itemId: "rs_raw", reasoningEncryptedContent: "raw-state" } },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reconciles raw reasoning finals without streamed deltas", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = configure({
|
||||
|
||||
@@ -117,6 +117,7 @@ describe("OpenAI Responses route", () => {
|
||||
{ role: "user", content: [{ type: "input_text", text: "Say hello." }] },
|
||||
],
|
||||
store: false,
|
||||
include: ["reasoning.encrypted_content"],
|
||||
stream: true,
|
||||
max_output_tokens: 20,
|
||||
temperature: 0,
|
||||
@@ -313,7 +314,12 @@ describe("OpenAI Responses route", () => {
|
||||
expect(prepared.route).toBe("openai-responses")
|
||||
expect(prepared.protocol).toBe("openai-responses")
|
||||
expect(prepared.metadata).toEqual({ transport: "http-json" })
|
||||
expect(prepared.body).toMatchObject({ model: "gpt-4.1-mini", store: false, stream: true })
|
||||
expect(prepared.body).toMatchObject({
|
||||
model: "gpt-4.1-mini",
|
||||
store: false,
|
||||
include: ["reasoning.encrypted_content"],
|
||||
stream: true,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -385,6 +391,7 @@ describe("OpenAI Responses route", () => {
|
||||
model: "gpt-4.1-mini",
|
||||
input: [{ role: "user", content: [{ type: "input_text", text: "Say hello." }] }],
|
||||
store: false,
|
||||
include: ["reasoning.encrypted_content"],
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -511,6 +518,56 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("continues a tool call from authoritative completed response output", () =>
|
||||
Effect.gen(function* () {
|
||||
const firstRequest = {
|
||||
type: "response.create",
|
||||
model: "gpt-5.2",
|
||||
store: false,
|
||||
input: [{ role: "user", content: [{ type: "input_text", text: "Weather?" }] }],
|
||||
}
|
||||
const first = continuationDriver(firstRequest)
|
||||
const firstCreate = yield* first.create(undefined)
|
||||
const saved = checkpoint(
|
||||
yield* first.observe(
|
||||
firstCreate,
|
||||
ProviderShared.encodeJson({
|
||||
type: "response.completed",
|
||||
response: {
|
||||
id: "resp_1",
|
||||
output: [
|
||||
{
|
||||
type: "function_call",
|
||||
id: "fc_1",
|
||||
status: "completed",
|
||||
call_id: "call_1",
|
||||
name: "weather",
|
||||
arguments: '{ "city": "Paris" }',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
const second = continuationDriver({
|
||||
...firstRequest,
|
||||
input: [
|
||||
...firstRequest.input,
|
||||
{ type: "function_call", call_id: "call_1", name: "weather", arguments: '{"city":"Paris"}' },
|
||||
{ type: "function_call_output", call_id: "call_1", output: '{"temperature":22}' },
|
||||
],
|
||||
})
|
||||
|
||||
const create = yield* second.create(saved)
|
||||
|
||||
expect(create.mode).toBe("incremental")
|
||||
expect(ProviderShared.decodeJson(create.message)).toMatchObject({
|
||||
previous_response_id: "resp_1",
|
||||
input: [{ type: "function_call_output", call_id: "call_1", output: '{"temperature":22}' }],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("continues a promoted steer after the completed assistant output", () =>
|
||||
Effect.gen(function* () {
|
||||
const firstInput = [{ role: "user", content: [{ type: "input_text", text: "First" }] }]
|
||||
@@ -759,6 +816,49 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("sanitizes outbound WebSocket requests and HTTP fallback bodies", () =>
|
||||
Effect.gen(function* () {
|
||||
const message = yield* Ref.make("")
|
||||
const body = yield* Ref.make("")
|
||||
yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-4.1-mini"),
|
||||
prompt: "Say \uD800hello \u{1F600}.",
|
||||
http: { body: { metadata: { source: "overlay\uDC00" } } },
|
||||
}),
|
||||
{
|
||||
webSocket: {
|
||||
execute: (exchange) =>
|
||||
Effect.gen(function* () {
|
||||
yield* exchange.driver
|
||||
.create(undefined)
|
||||
.pipe(Effect.flatMap((create) => Ref.set(message, create.message)))
|
||||
return { frames: exchange.fallback(), complete: Effect.void }
|
||||
}),
|
||||
},
|
||||
},
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
dynamicResponse((input) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Ref.set(body, input.text)
|
||||
return input.respond(sseEvents({ type: "response.completed", response: {} }), {
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
})
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const expected = {
|
||||
input: [{ role: "user", content: [{ type: "input_text", text: "Say \uFFFDhello \u{1F600}." }] }],
|
||||
metadata: { source: "overlay\uFFFD" },
|
||||
}
|
||||
expect(JSON.parse(yield* Ref.get(message))).toMatchObject(expected)
|
||||
expect(JSON.parse(yield* Ref.get(body))).toMatchObject(expected)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("builds xAI WebSocket requests without OpenAI handshake headers", () =>
|
||||
Effect.gen(function* () {
|
||||
const deps = Layer.succeed(
|
||||
@@ -1189,6 +1289,7 @@ describe("OpenAI Responses route", () => {
|
||||
{ type: "function_call_output", call_id: "call_1", output: '{"forecast":"sunny"}' },
|
||||
],
|
||||
store: false,
|
||||
include: ["reasoning.encrypted_content"],
|
||||
stream: true,
|
||||
max_output_tokens: undefined,
|
||||
temperature: undefined,
|
||||
@@ -1633,11 +1734,11 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("omits include when no include is set", () =>
|
||||
it.effect("requests encrypted reasoning by default", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(LLM.request({ model, prompt: "hi", providerOptions: { store: false } }))
|
||||
|
||||
expect(prepared.body.include).toBeUndefined()
|
||||
expect(prepared.body.include).toEqual(["reasoning.encrypted_content"])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1691,6 +1792,21 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("omits the prompt cache key when caching is disabled", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Hello",
|
||||
promptCacheKey: "request_cache",
|
||||
cache: "none",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body).not.toHaveProperty("prompt_cache_key")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("parses text and usage stream fixtures", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
@@ -1937,6 +2053,163 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("routes assistant text by output index when its item id disagrees", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "response.output_item.added", output_index: 2, item: { type: "message", id: "msg_1" } },
|
||||
{ type: "response.output_text.delta", output_index: 2, item_id: "wrong_message", delta: "Indexed" },
|
||||
{ type: "response.output_item.done", output_index: 2, item: { type: "message", id: "msg_1" } },
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.text).toBe("Indexed")
|
||||
expect(response.message.content).toEqual([
|
||||
{ type: "text", text: "Indexed", providerMetadata: { openai: { itemId: "msg_1" } } },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("routes interleaved function calls by output index", () =>
|
||||
Effect.gen(function* () {
|
||||
const first = { type: "function_call", id: "fc_1", call_id: "call_1", name: "first", arguments: "" }
|
||||
const second = { type: "function_call", id: "fc_2", call_id: "call_2", name: "second", arguments: "" }
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "response.output_item.added", output_index: 1, item: first },
|
||||
{ type: "response.output_item.added", output_index: 3, item: second },
|
||||
{ type: "response.function_call_arguments.delta", output_index: 1, item_id: "fc_2", delta: '{"a":' },
|
||||
{ type: "response.function_call_arguments.delta", output_index: 3, item_id: "fc_1", delta: '{"b":' },
|
||||
{
|
||||
type: "response.function_call_arguments.done",
|
||||
output_index: 3,
|
||||
item_id: "fc_1",
|
||||
arguments: '{"b":2}',
|
||||
},
|
||||
{
|
||||
type: "response.function_call_arguments.done",
|
||||
output_index: 1,
|
||||
item_id: "fc_2",
|
||||
arguments: '{"a":1}',
|
||||
},
|
||||
{ type: "response.output_item.done", output_index: 1, item: { ...first, arguments: '{"a":1}' } },
|
||||
{ type: "response.output_item.done", output_index: 3, item: { ...second, arguments: '{"b":2}' } },
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.events.filter((event) => event.type === "tool-input-delta")).toMatchObject([
|
||||
{ id: "call_1", text: '{"a":' },
|
||||
{ id: "call_2", text: '{"b":' },
|
||||
{ id: "call_2", text: "2}" },
|
||||
{ id: "call_1", text: "1}" },
|
||||
])
|
||||
expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([
|
||||
expect.objectContaining({ id: "call_1", name: "first", input: { a: 1 } }),
|
||||
expect.objectContaining({ id: "call_2", name: "second", input: { b: 2 } }),
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("routes reasoning summary events by output index", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
output_index: 4,
|
||||
item: { type: "reasoning", id: "rs_1" },
|
||||
},
|
||||
{
|
||||
type: "response.reasoning_summary_part.added",
|
||||
output_index: 4,
|
||||
item_id: "wrong_reasoning",
|
||||
summary_index: 0,
|
||||
},
|
||||
{
|
||||
type: "response.reasoning_summary_text.delta",
|
||||
output_index: 4,
|
||||
item_id: "wrong_reasoning",
|
||||
summary_index: 0,
|
||||
delta: "Thinking",
|
||||
},
|
||||
{
|
||||
type: "response.reasoning_summary_part.done",
|
||||
output_index: 4,
|
||||
item_id: "wrong_reasoning",
|
||||
summary_index: 0,
|
||||
},
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
output_index: 4,
|
||||
item: { type: "reasoning", id: "rs_1", encrypted_content: "state" },
|
||||
},
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.reasoning).toBe("Thinking")
|
||||
expect(response.message.content).toEqual([
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "Thinking",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "state" } },
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("routes native reasoning text deltas by output index", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "response.output_item.added", output_index: 1, item: { type: "reasoning", id: "rs_1" } },
|
||||
{ type: "response.reasoning_text.delta", output_index: 1, item_id: "wrong_reasoning", delta: "Raw" },
|
||||
{ type: "response.output_item.done", output_index: 1, item: { type: "reasoning", id: "rs_1" } },
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.reasoning).toBe("Raw")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("falls back to item ids when an output index was not registered", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
|
||||
{ type: "response.output_text.delta", output_index: 9, item_id: "msg_1", delta: "Fallback" },
|
||||
{ type: "response.output_item.done", item: { type: "message", id: "msg_1" } },
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.text).toBe("Fallback")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects output text events without the spec-required item id", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
@@ -1956,6 +2229,25 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("requires item ids even when their output index is known", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "response.output_item.added", output_index: 0, item: { type: "message", id: "msg_1" } },
|
||||
{ type: "response.output_text.delta", output_index: 0, delta: "Missing item ID" },
|
||||
),
|
||||
),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||
expect(error.message).toContain("response.output_text.delta is missing item_id")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("ignores deltas without a matching output item", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
@@ -2171,6 +2463,147 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves terminal reasoning metadata when output item completion is missing", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLMRequest.update(request, { providerOptions: { store: false } }),
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "reasoning", id: "rs_1", encrypted_content: null },
|
||||
},
|
||||
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 0 },
|
||||
{
|
||||
type: "response.reasoning_summary_text.delta",
|
||||
item_id: "rs_1",
|
||||
summary_index: 0,
|
||||
delta: "Checked the diff.",
|
||||
},
|
||||
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 0 },
|
||||
{
|
||||
type: "response.completed",
|
||||
response: {
|
||||
id: "resp_1",
|
||||
output: [
|
||||
{
|
||||
type: "reasoning",
|
||||
id: "rs_1",
|
||||
encrypted_content: "terminal-state",
|
||||
summary: [{ type: "summary_text", text: "Checked the diff." }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.reasoning).toBe("Checked the diff.")
|
||||
expect(response.events.filter((event) => event.type === "reasoning-end")).toEqual([
|
||||
{
|
||||
type: "reasoning-end",
|
||||
id: "rs_1:0",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "terminal-state" } },
|
||||
},
|
||||
])
|
||||
expect(response.message.content).toContainEqual({
|
||||
type: "reasoning",
|
||||
text: "Checked the diff.",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "terminal-state" } },
|
||||
})
|
||||
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({ model, messages: [response.message], providerOptions: { store: false } }),
|
||||
)
|
||||
expect(prepared.body.input).toEqual([
|
||||
{
|
||||
type: "reasoning",
|
||||
id: "rs_1",
|
||||
summary: [{ type: "summary_text", text: "Checked the diff." }],
|
||||
encrypted_content: "terminal-state",
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not repeat reasoning already finalized by an output item", () =>
|
||||
Effect.gen(function* () {
|
||||
const item = { type: "reasoning", id: "rs_1", encrypted_content: "encrypted-state" }
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "response.output_item.added", item: { ...item, encrypted_content: null } },
|
||||
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", delta: "Thinking" },
|
||||
{ type: "response.output_item.done", item },
|
||||
{ type: "response.completed", response: { output: [item] } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.events.filter((event) => event.type === "reasoning-start")).toHaveLength(1)
|
||||
expect(response.events.filter((event) => event.type === "reasoning-end")).toHaveLength(1)
|
||||
expect(response.message.content.filter((part) => part.type === "reasoning")).toHaveLength(1)
|
||||
expect(response.reasoning).toBe("Thinking")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reconciles pending reasoning and function calls in completed output order", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLMRequest.update(request, { providerOptions: { store: false } }),
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "reasoning", id: "rs_1", encrypted_content: null },
|
||||
},
|
||||
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", delta: "Thinking" },
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", arguments: "" },
|
||||
},
|
||||
{ type: "response.function_call_arguments.delta", item_id: "fc_1", delta: '{"query":"wea' },
|
||||
{
|
||||
type: "response.completed",
|
||||
response: {
|
||||
output: [
|
||||
{ type: "reasoning", id: "rs_1", encrypted_content: "terminal-state" },
|
||||
{
|
||||
type: "function_call",
|
||||
id: "fc_1",
|
||||
call_id: "call_1",
|
||||
name: "lookup",
|
||||
arguments: '{"query":"weather"}',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.events.find((event) => event.type === "reasoning-end")).toMatchObject({
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "terminal-state" } },
|
||||
})
|
||||
expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([
|
||||
expect.objectContaining({ id: "call_1", input: { query: "weather" } }),
|
||||
])
|
||||
expect(response.events.findIndex((event) => event.type === "reasoning-end")).toBeLessThan(
|
||||
response.events.findIndex(LLMEvent.is.toolCall),
|
||||
)
|
||||
expect(response.finishReason.normalized).toBe("tool-calls")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("streams each reasoning summary part as a separate block", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
@@ -2724,7 +3157,7 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("skips non-persisted reasoning ids without encrypted state", () =>
|
||||
it.effect("replays stateless reasoning without encrypted state", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
@@ -2754,6 +3187,12 @@ describe("OpenAI Responses route", () => {
|
||||
expect(prepared.body).toMatchObject({
|
||||
input: [
|
||||
{ role: "user", content: [{ type: "input_text", text: "What changed?" }] },
|
||||
{
|
||||
type: "reasoning",
|
||||
id: "rs_1",
|
||||
summary: [{ type: "summary_text", text: "Checked the previous diff." }],
|
||||
encrypted_content: null,
|
||||
},
|
||||
{ role: "assistant", content: [{ type: "output_text", text: "The parser changed." }] },
|
||||
{ role: "user", content: [{ type: "input_text", text: "Summarize it." }] },
|
||||
],
|
||||
@@ -3028,6 +3467,163 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("treats empty completed output item arguments as authoritative", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "function_call", id: "fc_item_1", call_id: "call_1", name: "lookup", arguments: "" },
|
||||
},
|
||||
{ type: "response.function_call_arguments.delta", item_id: "fc_item_1", delta: '{"query":"streamed"}' },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "function_call", id: "fc_item_1", call_id: "call_1", name: "lookup", arguments: "" },
|
||||
},
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
)
|
||||
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
|
||||
|
||||
expect(response.events.find(LLMEvent.is.toolCall)).toMatchObject({ input: {} })
|
||||
expect(response.events.filter(LLMEvent.is.toolCall)).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses completed response output when output item completion is missing", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "function_call", id: "fc_item_1", call_id: "call_1", name: "lookup", arguments: "" },
|
||||
},
|
||||
{ type: "response.function_call_arguments.delta", item_id: "fc_item_1", delta: '{"query":"wea' },
|
||||
{
|
||||
type: "response.completed",
|
||||
response: {
|
||||
id: "resp_1",
|
||||
output: [
|
||||
{
|
||||
type: "function_call",
|
||||
id: "fc_item_1",
|
||||
call_id: "call_1",
|
||||
name: "lookup",
|
||||
arguments: '{"query":"weather"}',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
|
||||
|
||||
expect(response.events.find(LLMEvent.is.toolCall)).toMatchObject({
|
||||
id: "call_1",
|
||||
input: { query: "weather" },
|
||||
providerMetadata: { openai: { itemId: "fc_item_1" } },
|
||||
})
|
||||
expect(response.events.filter(LLMEvent.is.toolInputEnd)).toHaveLength(1)
|
||||
expect(response.events.filter(LLMEvent.is.toolCall)).toHaveLength(1)
|
||||
expect(response.finishReason.normalized).toBe("tool-calls")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lets completed response output override arguments done", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "function_call", id: "fc_item_1", call_id: "call_1", name: "lookup", arguments: "" },
|
||||
},
|
||||
{
|
||||
type: "response.function_call_arguments.done",
|
||||
item_id: "fc_item_1",
|
||||
arguments: '{"query":"arguments-done"}',
|
||||
},
|
||||
{
|
||||
type: "response.completed",
|
||||
response: {
|
||||
output: [
|
||||
{
|
||||
type: "function_call",
|
||||
id: "fc_item_1",
|
||||
call_id: "call_1",
|
||||
name: "lookup",
|
||||
arguments: '{"query":"completed"}',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
|
||||
|
||||
expect(response.events.find(LLMEvent.is.toolCall)).toMatchObject({ input: { query: "completed" } })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves explicit empty arguments from completed response output", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "function_call", id: "fc_item_1", call_id: "call_1", name: "lookup", arguments: "" },
|
||||
},
|
||||
{ type: "response.function_call_arguments.delta", item_id: "fc_item_1", delta: '{"query":"streamed"}' },
|
||||
{
|
||||
type: "response.completed",
|
||||
response: {
|
||||
output: [{ type: "function_call", id: "fc_item_1", call_id: "call_1", name: "lookup", arguments: "" }],
|
||||
},
|
||||
},
|
||||
)
|
||||
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
|
||||
|
||||
expect(response.events.find(LLMEvent.is.toolCall)).toMatchObject({ input: {} })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not repeat function calls already finalized by an output item", () =>
|
||||
Effect.gen(function* () {
|
||||
const item = {
|
||||
type: "function_call",
|
||||
id: "fc_item_1",
|
||||
call_id: "call_1",
|
||||
name: "lookup",
|
||||
arguments: '{"query":"weather"}',
|
||||
}
|
||||
const body = sseEvents(
|
||||
{ type: "response.output_item.added", item: { ...item, arguments: "" } },
|
||||
{ type: "response.output_item.done", item },
|
||||
{ type: "response.completed", response: { output: [item] } },
|
||||
)
|
||||
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
|
||||
|
||||
expect(response.events.filter(LLMEvent.is.toolInputEnd)).toHaveLength(1)
|
||||
expect(response.events.filter(LLMEvent.is.toolCall)).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not finalize pending function calls from incomplete response output", () =>
|
||||
Effect.gen(function* () {
|
||||
const item = {
|
||||
type: "function_call",
|
||||
id: "fc_item_1",
|
||||
call_id: "call_1",
|
||||
name: "lookup",
|
||||
arguments: '{"query":"partial',
|
||||
}
|
||||
const body = sseEvents(
|
||||
{ type: "response.output_item.added", item: { ...item, arguments: "" } },
|
||||
{ type: "response.function_call_arguments.delta", item_id: "fc_item_1", delta: item.arguments },
|
||||
{
|
||||
type: "response.incomplete",
|
||||
response: { incomplete_details: { reason: "max_output_tokens" }, output: [item] },
|
||||
},
|
||||
)
|
||||
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
|
||||
|
||||
expect(response.events.some(LLMEvent.is.toolCall)).toBeFalse()
|
||||
expect(response.finishReason.normalized).toBe("length")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("finalizes a pending function call at response completion", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
@@ -3061,7 +3657,7 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("emits malformed final function arguments as an unexecuted tool error", () =>
|
||||
it.effect("recovers authoritative incomplete final function arguments", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
{
|
||||
@@ -3087,18 +3683,17 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
).pipe(Effect.provide(fixedResponse(body)))
|
||||
|
||||
expect(response.events.find(LLMEvent.is.toolInputError)).toEqual({
|
||||
type: "tool-input-error",
|
||||
expect(response.events.find(LLMEvent.is.toolCall)).toMatchObject({
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
raw: '{"query":"partial',
|
||||
input: { query: "partial" },
|
||||
})
|
||||
expect(response.finishReason.normalized).toBe("tool-calls")
|
||||
expect(response.events.some(LLMEvent.is.toolCall)).toBeFalse()
|
||||
expect(response.events.some(LLMEvent.is.toolInputError)).toBeFalse()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("settles malformed function arguments when output_item.added is absent", () =>
|
||||
it.effect("recovers incomplete function arguments when output_item.added is absent", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
{
|
||||
@@ -3115,10 +3710,10 @@ describe("OpenAI Responses route", () => {
|
||||
)
|
||||
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
|
||||
|
||||
expect(response.events.find(LLMEvent.is.toolInputError)).toMatchObject({
|
||||
expect(response.events.find(LLMEvent.is.toolCall)).toMatchObject({
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
raw: '{"query":"partial',
|
||||
input: { query: "partial" },
|
||||
})
|
||||
expect(response.finishReason.normalized).toBe("tool-calls")
|
||||
}),
|
||||
|
||||
@@ -190,6 +190,21 @@ describe("OpenRouter", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("omits the prompt cache key when caching is disabled", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: OpenRouter.configure({ apiKey: "test-key" }).model("openai/gpt-4o-mini"),
|
||||
prompt: "Hello",
|
||||
promptCacheKey: "session_123",
|
||||
cache: "none",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body).not.toHaveProperty("prompt_cache_key")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("filters invalid known OpenRouter options while preserving extensions", () =>
|
||||
Effect.gen(function* () {
|
||||
const invalid: Record<string, unknown> = {
|
||||
|
||||
@@ -21,6 +21,17 @@ describe("xAI Responses route", () => {
|
||||
|
||||
const prepared = yield* compileRequest(LLM.request({ model, prompt: "Hello" }))
|
||||
expect(prepared.protocol).toBe("xai-responses")
|
||||
expect(prepared.body.store).toBe(false)
|
||||
expect(prepared.body.include).toEqual(["reasoning.encrypted_content"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("allows callers to opt out of encrypted reasoning", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(LLM.request({ model, prompt: "Hello", providerOptions: { include: [] } }))
|
||||
|
||||
expect(prepared.body.store).toBe(false)
|
||||
expect(prepared.body.include).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -59,6 +70,42 @@ describe("xAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("routes xAI reasoning summaries by output index", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Think" })).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
output_index: 3,
|
||||
item: { type: "reasoning", id: "reasoning_1" },
|
||||
},
|
||||
{
|
||||
type: "response.reasoning_summary_text.delta",
|
||||
output_index: 3,
|
||||
item_id: "wrong_reasoning",
|
||||
summary_index: 0,
|
||||
delta: "Considering.",
|
||||
},
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
output_index: 3,
|
||||
item: { type: "reasoning", id: "reasoning_1", encrypted_content: "opaque" },
|
||||
},
|
||||
{ type: "response.completed", response: { id: "response_1" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.reasoning).toBe("Considering.")
|
||||
expect(response.message.content.find((part) => part.type === "reasoning")).toMatchObject({
|
||||
providerMetadata: { xai: { itemId: "reasoning_1", reasoningEncryptedContent: "opaque" } },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("parses xAI hosted tool items", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Search X" })).pipe(
|
||||
|
||||
@@ -78,6 +78,31 @@ describe("Z.ai Images", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("sanitizes unpaired surrogates in outbound image requests", () =>
|
||||
Image.generate({
|
||||
model: ZAI.configure({ apiKey: "test", http: { body: { metadata: { source: "default\uDC00" } } } }).image("model"),
|
||||
prompt: "A red circle \uD800 on a white background \u{1F600}",
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
ImageClient.layer.pipe(
|
||||
Layer.provide(
|
||||
dynamicResponse((input) => {
|
||||
expect(JSON.parse(input.text)).toMatchObject({
|
||||
prompt: "A red circle \uFFFD on a white background \u{1F600}",
|
||||
metadata: { source: "default\uFFFD" },
|
||||
})
|
||||
return Effect.succeed(
|
||||
input.respond(JSON.stringify({ data: [{ url: "https://example.test/image.jpg" }] }), {
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
)
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("lets raw native options override aliases", () =>
|
||||
Image.generate({
|
||||
model: ZAI.configure({ apiKey: "test" }).image("model"),
|
||||
|
||||
@@ -59,7 +59,7 @@ describe("ToolStream", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("omits partial input when the accumulated value cannot be parsed", () =>
|
||||
it.effect("defaults partial input to an empty object when the accumulated value cannot be parsed", () =>
|
||||
Effect.gen(function* () {
|
||||
const result = ToolStream.appendOrStart(
|
||||
ADAPTER,
|
||||
@@ -72,7 +72,7 @@ describe("ToolStream", () => {
|
||||
|
||||
expect(result.events).toEqual([
|
||||
{ type: "tool-input-start", id: "call_1", name: "lookup" },
|
||||
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: "x" },
|
||||
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: "x", input: {} },
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -132,7 +132,7 @@ describe("ToolStream", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("finalizes malformed local input as a non-executable tool error", () =>
|
||||
it.effect("finalizes incomplete local input using the partial JSON parser", () =>
|
||||
Effect.gen(function* () {
|
||||
const tools = ToolStream.start(ToolStream.empty<string>(), "item_1", {
|
||||
id: "call_1",
|
||||
@@ -144,18 +144,46 @@ describe("ToolStream", () => {
|
||||
expect(finished).toEqual({
|
||||
tools: {},
|
||||
events: [
|
||||
{
|
||||
type: "tool-input-error",
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
raw: '{"query":"partial',
|
||||
},
|
||||
{ type: "tool-input-end", id: "call_1", name: "lookup" },
|
||||
{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "partial" } },
|
||||
],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves valid siblings when one parallel input is malformed", () =>
|
||||
it.effect("repairs malformed string escapes in final local input", () =>
|
||||
Effect.gen(function* () {
|
||||
const tools = ToolStream.start(ToolStream.empty<string>(), "item_1", {
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
input: '{"path":"A\\H","text":"first\tsecond"}',
|
||||
})
|
||||
const finished = yield* ToolStream.finish(ADAPTER, tools, "item_1")
|
||||
|
||||
expect(finished.events).toEqual([
|
||||
{ type: "tool-input-end", id: "call_1", name: "lookup" },
|
||||
{ type: "tool-call", id: "call_1", name: "lookup", input: { path: "A\\H", text: "first\tsecond" } },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("defaults unrecoverable local input to an empty object", () =>
|
||||
Effect.gen(function* () {
|
||||
const tools = ToolStream.start(ToolStream.empty<string>(), "item_1", {
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
input: "invalid",
|
||||
})
|
||||
const finished = yield* ToolStream.finish(ADAPTER, tools, "item_1")
|
||||
|
||||
expect(finished.events).toEqual([
|
||||
{ type: "tool-input-end", id: "call_1", name: "lookup" },
|
||||
{ type: "tool-call", id: "call_1", name: "lookup", input: {} },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("recovers incomplete input alongside valid parallel tool calls", () =>
|
||||
Effect.gen(function* () {
|
||||
const valid = ToolStream.start(ToolStream.empty<number>(), 0, {
|
||||
id: "call_valid",
|
||||
@@ -174,12 +202,8 @@ describe("ToolStream", () => {
|
||||
events: [
|
||||
{ type: "tool-input-end", id: "call_valid", name: "lookup" },
|
||||
{ type: "tool-call", id: "call_valid", name: "lookup", input: { query: "weather" } },
|
||||
{
|
||||
type: "tool-input-error",
|
||||
id: "call_invalid",
|
||||
name: "lookup",
|
||||
raw: '{"query":"partial',
|
||||
},
|
||||
{ type: "tool-input-end", id: "call_invalid", name: "lookup" },
|
||||
{ type: "tool-call", id: "call_invalid", name: "lookup", input: { query: "partial" } },
|
||||
],
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectAppVisible } from "../utils/waits"
|
||||
|
||||
const draftID = "draft_new_session_workspace_branch"
|
||||
const directory = "C:/OpenCode/WorkspaceBranch"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
|
||||
test("selects a base branch for a new workspace", async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: "proj_new_session_workspace_branch",
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "workspace-branch",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: { all: [], connected: [], default: {} },
|
||||
sessions: [],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
vcsBranches: ["feature/api", "main", "origin/release"],
|
||||
})
|
||||
await page.addInitScript(
|
||||
({ directory, draftID, server }) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||
lastProject: { local: directory },
|
||||
}),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([{ type: "draft", draftID, server, directory }]),
|
||||
)
|
||||
},
|
||||
{ directory, draftID, server },
|
||||
)
|
||||
|
||||
await page.goto(`/new-session?draftId=${draftID}`)
|
||||
await expectAppVisible(page.locator('[data-component="composer-editor"]'))
|
||||
await page.getByRole("button", { name: "Local", exact: true }).click()
|
||||
await page.getByRole("menuitem", { name: "New workspace", exact: true }).click()
|
||||
await page.getByRole("button", { name: "from main", exact: true }).click()
|
||||
await page.getByRole("menuitemradio", { name: "feature/api", exact: true }).click()
|
||||
|
||||
const selected = page.getByRole("button", { name: "from feature/api", exact: true })
|
||||
await expect(selected).toBeVisible()
|
||||
await selected.click()
|
||||
await expect(page.getByRole("menuitemradio", { name: "feature/api", exact: true })).toBeChecked()
|
||||
})
|
||||
@@ -139,7 +139,7 @@ test.describe("regression: session timeline local row state", () => {
|
||||
expect(siblingProbe).toEqual({
|
||||
fileMarker: "before",
|
||||
frameMarker: "before",
|
||||
rowKey: `assistant-part:part:${assistantMessageID}:${editPartID}`,
|
||||
rowKey: `assistant-part:file:part:${assistantMessageID}:${editPartID}`,
|
||||
rowMarker: "before",
|
||||
shadowRoots: 0,
|
||||
toolMarker: "before",
|
||||
|
||||
@@ -59,12 +59,12 @@ test("transitions a streaming shell from writing through command execution", asy
|
||||
await expect(tool.locator('[data-slot="collapsible-trigger"]')).toHaveCSS("height", "28px")
|
||||
await expect(tool.locator('[data-component="tool-trigger"]')).toHaveCSS("gap", "6px")
|
||||
await expect(title).toHaveCSS("font-size", "13px")
|
||||
await expect(title).toHaveCSS("font-family", "Inter, sans-serif")
|
||||
await expect(title).toHaveCSS("font-family", /^Inter,/)
|
||||
await expect(title).toHaveCSS("font-weight", "530")
|
||||
await expect(title).toHaveCSS("line-height", "16px")
|
||||
await expect(title).toHaveCSS("color", "rgb(22, 22, 22)")
|
||||
await expect(subtitle).toHaveCSS("font-size", "13px")
|
||||
await expect(subtitle).toHaveCSS("font-family", "Inter, sans-serif")
|
||||
await expect(subtitle).toHaveCSS("font-family", /^Inter,/)
|
||||
await expect(subtitle).toHaveCSS("font-weight", "440")
|
||||
await expect(subtitle).toHaveCSS("line-height", "16px")
|
||||
await expect(subtitle).toHaveCSS("color", "rgb(92, 92, 92)")
|
||||
|
||||
@@ -64,6 +64,76 @@ test("transitions shell and question through running error outcomes", async ({ p
|
||||
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toContainText(/dismissed/i)
|
||||
})
|
||||
|
||||
test("preserves surviving grouped patch state when its first patch fails", async ({ page }) => {
|
||||
const failed = "prt_grouped_patch_failed"
|
||||
const surviving = "prt_grouped_patch_surviving"
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage(
|
||||
[
|
||||
toolPart(failed, "patch", "running", { patchText: "Update src/failed.ts" }),
|
||||
toolPart(
|
||||
surviving,
|
||||
"patch",
|
||||
"running",
|
||||
{ patchText: "Update src/surviving.ts" },
|
||||
{
|
||||
metadata: {
|
||||
files: [
|
||||
{
|
||||
file: "src/surviving.ts",
|
||||
status: "modified",
|
||||
patch: "@@ -1 +1 @@\n-export const value = 1\n+export const value = 2",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
),
|
||||
],
|
||||
{ completed: false },
|
||||
),
|
||||
],
|
||||
})
|
||||
|
||||
const group = page.locator(`[data-timeline-part-ids="${failed},${surviving}"]`)
|
||||
const file = group.locator('[data-scope="apply-patch"] button')
|
||||
await expect(file).toBeVisible()
|
||||
await file.click()
|
||||
await expect(file).toHaveAttribute("aria-expanded", "true")
|
||||
await group.evaluate((element) => {
|
||||
const row = element.closest<HTMLElement>("[data-timeline-key]")
|
||||
if (row) row.dataset.groupIdentity = "preserved"
|
||||
})
|
||||
|
||||
await timeline.send(
|
||||
partUpdated(
|
||||
toolPart(failed, "patch", "error", { patchText: "Update src/failed.ts" }, { error: "Patch failed visibly" }),
|
||||
),
|
||||
)
|
||||
|
||||
const failedRow = page.locator("[data-timeline-key]", {
|
||||
has: page.locator(`[data-timeline-part-id="${failed}"]`),
|
||||
})
|
||||
const survivingRow = page.locator("[data-timeline-key]", {
|
||||
has: page.locator(`[data-timeline-part-id="${surviving}"]`),
|
||||
})
|
||||
await expect(failedRow).toHaveAttribute("data-timeline-key", /^assistant-part:part:/)
|
||||
await expect(survivingRow).toHaveAttribute("data-timeline-key", /^assistant-part:file:/)
|
||||
await expect(failedRow.getByText("Patch failed visibly")).toBeVisible()
|
||||
await expect(survivingRow).toHaveAttribute("data-group-identity", "preserved")
|
||||
await expect(survivingRow.locator('[data-scope="apply-patch"] button')).toHaveAttribute("aria-expanded", "true")
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const previous = await failedRow.boundingBox()
|
||||
const next = await survivingRow.boundingBox()
|
||||
return previous && next ? next.y - (previous.y + previous.height) : Number.NEGATIVE_INFINITY
|
||||
})
|
||||
.toBeGreaterThanOrEqual(-0.5)
|
||||
})
|
||||
|
||||
test("labels all web search provider variants", async ({ page }) => {
|
||||
const parts = [
|
||||
toolPart(
|
||||
|
||||
@@ -68,6 +68,41 @@ test("keyboard navigation follows the visible tab order", async ({ page }) => {
|
||||
await expect(page).toHaveURL(new RegExp(`${hrefC.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
|
||||
})
|
||||
|
||||
test("cramped tabs only show the close button for the active tab", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 360, height: 720 })
|
||||
await mockServer(page)
|
||||
await page.addInitScript(
|
||||
({ server, sessionA, sessionB, sessionC }) => {
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([
|
||||
{ type: "session", server, sessionId: sessionA },
|
||||
{ type: "session", server, sessionId: sessionB },
|
||||
{ type: "session", server, sessionId: sessionC },
|
||||
]),
|
||||
)
|
||||
},
|
||||
{ server, sessionA: sessionA.id, sessionB: sessionB.id, sessionC: sessionC.id },
|
||||
)
|
||||
|
||||
const hrefA = `/server/${base64Encode(server)}/session/${sessionA.id}`
|
||||
const hrefB = `/server/${base64Encode(server)}/session/${sessionB.id}`
|
||||
await page.goto(hrefA)
|
||||
|
||||
const tabA = page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefA}"])`)
|
||||
const tabB = page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefB}"])`)
|
||||
await expect(tabA).toHaveAttribute("data-active", "true")
|
||||
await expect(tabB).toBeVisible()
|
||||
await expect(tabA.locator('[data-slot="tab-close"]')).toBeVisible()
|
||||
await expect(tabB.locator('[data-slot="tab-close"]')).toBeHidden()
|
||||
|
||||
await tabB.locator(`a[href="${hrefB}"]`).click()
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
|
||||
await expect(tabA.locator('[data-slot="tab-close"]')).toBeHidden()
|
||||
await expect(tabB.locator('[data-slot="tab-close"]')).toBeVisible()
|
||||
})
|
||||
|
||||
function session(id: string, title: string) {
|
||||
return {
|
||||
id,
|
||||
|
||||
@@ -100,6 +100,7 @@ const Group = HttpApiGroup.make("mock")
|
||||
.add(HttpApiEndpoint.get("formRequests", "/api/form/request", { success: Json }))
|
||||
.add(HttpApiEndpoint.get("vcs", "/api/vcs", { success: Json }))
|
||||
.add(HttpApiEndpoint.get("vcsStatus", "/api/vcs/status", { success: Json }))
|
||||
.add(HttpApiEndpoint.get("vcsBranches", "/api/vcs/branches", { success: Json }))
|
||||
.add(HttpApiEndpoint.get("vcsDiff", "/api/vcs/diff", { success: Json }))
|
||||
.add(HttpApiEndpoint.get("fsList", "/api/fs/list", { query: Query, success: Json }))
|
||||
.add(
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface MockServerConfig {
|
||||
cursor?: string
|
||||
}
|
||||
vcsDiff?: unknown[]
|
||||
vcsBranches?: string[]
|
||||
messageDelay?: number
|
||||
beforeMessagesResponse?: (input: { sessionID: string; before?: string }) => Promise<void>
|
||||
onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void
|
||||
@@ -296,6 +297,7 @@ function mockHandlers(config: MockServerConfig, state: { cursors: Map<string, st
|
||||
vcs: () =>
|
||||
Effect.succeed({ location: location(config), data: { branch: { current: "main", default: "main" } } }),
|
||||
vcsStatus: () => Effect.succeed({ location: location(config), data: [] }),
|
||||
vcsBranches: () => Effect.succeed({ location: location(config), data: config.vcsBranches ?? ["main"] }),
|
||||
vcsDiff: () => Effect.succeed({ location: location(config), data: config.vcsDiff ?? [] }),
|
||||
fsList: (ctx) =>
|
||||
Effect.promise(() => Promise.resolve(config.fileList?.(ctx.query.path ?? ""))).pipe(
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createEffect, createMemo, For, Show, type JSX } from "solid-js"
|
||||
import { createEffect, createMemo, createSignal, For, Show, type JSX } from "solid-js"
|
||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { createAnimatedPresence } from "@/runtime/animated-presence"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { useI18n } from "@opencode-ai/ui/context/i18n"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
@@ -716,16 +717,23 @@ function ComposerEditorAlternateDelivery(props: { controller: ComposerEditorMode
|
||||
if (queue.editing()) return "steer" as const
|
||||
return queue.alternate()
|
||||
})
|
||||
const [button, setButton] = createSignal<HTMLButtonElement>()
|
||||
const presence = createAnimatedPresence(action, () => button() ?? null)
|
||||
return (
|
||||
<Show when={action()} keyed>
|
||||
<Show when={presence.present() && presence.value()} keyed>
|
||||
{(delivery) => (
|
||||
<Tooltip placement="top" inactive={delivery !== "steer"} value={i18n.t("ui.promptInput.steerHint")}>
|
||||
<Button
|
||||
ref={setButton}
|
||||
data-action="composer-alternate-delivery"
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
class="me-3 gap-1.5 px-1.5 text-v2-text-text-muted ![font-weight:530]"
|
||||
class="me-3 gap-1.5 px-1.5 text-v2-text-text-muted ![font-weight:530] duration-150 motion-reduce:animate-none"
|
||||
classList={{
|
||||
"animate-in fade-in": presence.animate() && presence.show(),
|
||||
"animate-out fade-out fill-mode-forwards": presence.animate() && !presence.show(),
|
||||
}}
|
||||
onClick={() => props.controller.submit({ alternate: true })}
|
||||
>
|
||||
{delivery === "steer" ? i18n.t("ui.promptInput.steer") : i18n.t("ui.promptInput.queue")}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { type HomeProjectSelection, useLayout } from "@/shell/state/layout"
|
||||
import { ServerConnection, useServers } from "@/runtime/server/registry"
|
||||
import { useTabs } from "@/shell/tabs/tabs"
|
||||
import { toggleHomeProjectSelection } from "@/shell/layout/helpers"
|
||||
import { createEffect, createMemo, startTransition } from "solid-js"
|
||||
import { createEffect, createMemo } from "solid-js"
|
||||
|
||||
export function createHomeController() {
|
||||
const layout = useLayout()
|
||||
@@ -49,8 +49,7 @@ export function createHomeController() {
|
||||
selection: {
|
||||
value: selection,
|
||||
set: setSelection,
|
||||
focusServer: (conn: ServerConnection.Any) =>
|
||||
void startTransition(() => setSelection({ server: ServerConnection.key(conn) })),
|
||||
focusServer: (conn: ServerConnection.Any) => setSelection({ server: ServerConnection.key(conn) }),
|
||||
},
|
||||
server: {
|
||||
list: () => servers.visible,
|
||||
|
||||
@@ -5,7 +5,6 @@ import { DialogFooter, DialogHeader, DialogTitleGroup, Dialog } from "@opencode-
|
||||
import { skipToken, useQuery, useQueryClient } from "@tanstack/solid-query"
|
||||
import { DateTime } from "luxon"
|
||||
import { type Accessor, createEffect, createMemo, type JSX, startTransition, untrack } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { notifySessionTabsRemoved } from "@/shell/titlebar/session-events"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import { loadHomeSessionIndex, mergeHomeSessionIndex, retainHomeSessions } from "@/home/sessions/index"
|
||||
@@ -43,7 +42,6 @@ export function createHomeSessionsController(home: HomeController) {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const queryClient = useQueryClient()
|
||||
const [removed, setRemoved] = createStore({ keys: [] as string[] })
|
||||
const projectDirectories = createMemo(() => {
|
||||
const selected = home.selection.value().directory
|
||||
if (!selected) return
|
||||
@@ -70,10 +68,9 @@ export function createHomeSessionsController(home: HomeController) {
|
||||
const ctx = home.server.focusedContext()
|
||||
const conn = home.server.focused()
|
||||
if (!ctx || !conn) return []
|
||||
const server = ServerConnection.key(conn)
|
||||
return retainHomeSessions(
|
||||
mergeHomeSessionIndex(sessionLoad.data?.() ?? [], ctx.data.session.list()).filter(
|
||||
(session) => !removed.keys.includes(`${server}\0${session.id}`),
|
||||
ctx.data.session.apply(
|
||||
mergeHomeSessionIndex(sessionLoad.isPending ? [] : (sessionLoad.data?.() ?? []), ctx.data.session.list()),
|
||||
),
|
||||
HOME_SESSION_LIMIT,
|
||||
Date.now(),
|
||||
@@ -192,15 +189,9 @@ export function createHomeSessionsController(home: HomeController) {
|
||||
const ctx = conn ? home.server.context(conn) : undefined
|
||||
if (!conn || !ctx) return false
|
||||
const ids = [...removedSessionIDs(ctx.data.session.list(), session.id)]
|
||||
await queryClient.cancelQueries({ queryKey: ["home-sessions", conn], exact: true })
|
||||
return ctx.sdk.api.session
|
||||
.remove({ sessionID: session.id })
|
||||
return ctx.data.session
|
||||
.remove(session.id)
|
||||
.then(() => {
|
||||
const removedIDs = new Set(ids)
|
||||
setRemoved("keys", (current) => [...new Set([...current, ...ids.map((id) => `${server}\0${id}`)])])
|
||||
queryClient.setQueryData<SessionInfo[]>(["home-sessions", conn], (current) =>
|
||||
current?.filter((item) => !removedIDs.has(item.id)),
|
||||
)
|
||||
notifySessionTabsRemoved({
|
||||
server: ServerConnection.key(conn),
|
||||
directory: session.location.directory,
|
||||
@@ -216,9 +207,6 @@ export function createHomeSessionsController(home: HomeController) {
|
||||
return false
|
||||
})
|
||||
.finally(() => {
|
||||
// Always refetch: the pre-mutation cancel may have aborted an
|
||||
// in-flight index fetch, and a failed delete must not leave the
|
||||
// index unloaded either.
|
||||
void queryClient.invalidateQueries({ queryKey: ["home-sessions", conn], exact: true })
|
||||
})
|
||||
}
|
||||
@@ -256,7 +244,7 @@ export function createHomeSessionsController(home: HomeController) {
|
||||
data: {
|
||||
records,
|
||||
groups,
|
||||
loading: () => sessionLoad.isLoading,
|
||||
loading: () => sessionLoad.isPending,
|
||||
searchRecords: allRecords,
|
||||
},
|
||||
session: {
|
||||
|
||||
@@ -12,6 +12,7 @@ export function HomeSessions(props: {
|
||||
<HomeSessionsView
|
||||
language={props.sessions.copy.language}
|
||||
groups={props.sessions.data.groups()}
|
||||
loading={props.sessions.data.loading()}
|
||||
showProjectName={props.sessions.session.showProjectName()}
|
||||
server={props.sessions.session.server()}
|
||||
canCreateSession={props.sessions.session.canCreate()}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
import { Key } from "@solid-primitives/keyed"
|
||||
import { createMemo, For, Index, onCleanup, Show, Suspense } from "solid-js"
|
||||
import { createMemo, For, Index, onCleanup, Show } from "solid-js"
|
||||
import { createStore, type SetStoreFunction } from "solid-js/store"
|
||||
import { InlineInput } from "@opencode-ai/ui/inline-input"
|
||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||
@@ -44,6 +44,7 @@ function isBackgroundOpen(event: MouseEvent) {
|
||||
export type HomeSessionsViewProps = {
|
||||
language: ReturnType<typeof useLanguage>
|
||||
groups: HomeSessionGroup[]
|
||||
loading: boolean
|
||||
showProjectName: boolean
|
||||
server: ServerConnection.Key
|
||||
canCreateSession: boolean
|
||||
@@ -97,22 +98,20 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
|
||||
>
|
||||
<div class="sticky top-0 z-30 shrink-0 bg-v2-background-bg-base pb-3 pt-6 lg:pt-12" onWheel={props.onWheel}>
|
||||
<HomeSessionSearch {...props} />
|
||||
<Suspense>
|
||||
<Show when={props.groups.length > 0 && props.canCreateSession}>
|
||||
<div class="pointer-events-none absolute right-0 top-[84px] z-20 flex lg:top-[108px]">
|
||||
<Button
|
||||
data-action="home-new-session"
|
||||
variant="ghost-muted"
|
||||
size="normal"
|
||||
icon="edit"
|
||||
class="pointer-events-auto h-7 px-2 [font-weight:530]"
|
||||
onClick={props.onCreateSession}
|
||||
>
|
||||
{props.language.t("command.session.new")}
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
</Suspense>
|
||||
<Show when={props.groups.length > 0 && props.canCreateSession}>
|
||||
<div class="pointer-events-none absolute right-0 top-[84px] z-20 flex lg:top-[108px]">
|
||||
<Button
|
||||
data-action="home-new-session"
|
||||
variant="ghost-muted"
|
||||
size="normal"
|
||||
icon="edit"
|
||||
class="pointer-events-auto h-7 px-2 [font-weight:530]"
|
||||
onClick={props.onCreateSession}
|
||||
>
|
||||
{props.language.t("command.session.new")}
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="pointer-events-none sticky top-[84px] z-40 h-0 -mr-3 lg:top-[108px]">
|
||||
<div
|
||||
@@ -122,7 +121,8 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
|
||||
/>
|
||||
</div>
|
||||
<div class="-mr-3 min-h-[calc(100cqh-72px)] lg:min-h-[calc(100cqh-96px)]">
|
||||
<Suspense
|
||||
<Show
|
||||
when={!props.loading}
|
||||
fallback={
|
||||
<div class="pt-3">
|
||||
<HomeSessionSkeleton label={props.language.t("common.loading")} />
|
||||
@@ -164,7 +164,7 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
|
||||
</Index>
|
||||
</div>
|
||||
</Show>
|
||||
</Suspense>
|
||||
</Show>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -20,6 +20,7 @@ import { clearSessionMessageHandoff, setSessionMessageHandoff } from "@/session/
|
||||
export function createNewSessionComposerAdapter(props: {
|
||||
draftID: string
|
||||
worktree: () => string
|
||||
branch: () => string | undefined
|
||||
submitted: () => void
|
||||
}) {
|
||||
const route = useSessionKey()
|
||||
@@ -48,6 +49,7 @@ export function createNewSessionComposerAdapter(props: {
|
||||
const sessionDirectory = await resolveSessionDirectory({
|
||||
projectDirectory,
|
||||
worktree,
|
||||
branch: props.branch(),
|
||||
data,
|
||||
serverSDK,
|
||||
language,
|
||||
@@ -73,7 +75,7 @@ export function createNewSessionComposerAdapter(props: {
|
||||
return { ok: false as const, error }
|
||||
},
|
||||
)
|
||||
const afterCreation = async <T,>(run: () => Promise<T>) => {
|
||||
const afterCreation = async <T>(run: () => Promise<T>) => {
|
||||
const result = await creation
|
||||
if (!result.ok) throw result.error
|
||||
return run()
|
||||
@@ -83,7 +85,7 @@ export function createNewSessionComposerAdapter(props: {
|
||||
SessionRouteKey.fromRoute(base64Encode(sessionDirectory), created.id),
|
||||
)
|
||||
const cleanupReady = startTransition(() => {
|
||||
tabs.updateDraft(props.draftID, { worktree: undefined })
|
||||
tabs.updateDraft(props.draftID, { worktree: undefined, branch: undefined })
|
||||
local.session.promote(sessionDirectory, created.id, {
|
||||
agent: selection.agent,
|
||||
model: selection.model,
|
||||
@@ -161,6 +163,7 @@ function createMessageHandoff(key: string, sessionID: string, event: ServerSDK["
|
||||
async function resolveSessionDirectory(input: {
|
||||
projectDirectory: string
|
||||
worktree: string
|
||||
branch?: string
|
||||
data: ReturnType<typeof useData>
|
||||
serverSDK: ReturnType<typeof useServerSDK>
|
||||
language: ReturnType<typeof useLanguage>
|
||||
@@ -172,6 +175,7 @@ async function resolveSessionDirectory(input: {
|
||||
.create({
|
||||
projectID: input.data.location.info({ directory: input.projectDirectory })?.project.id ?? "",
|
||||
strategy: "git",
|
||||
branch: input.branch,
|
||||
directory: getDirectory(
|
||||
input.data.location.info({ directory: input.projectDirectory })?.project.directory ?? input.projectDirectory,
|
||||
),
|
||||
|
||||
@@ -39,6 +39,7 @@ export function createComposerProjectControls(props: { draftId: string }) {
|
||||
server: ServerConnection.key(connection),
|
||||
directory: worktree,
|
||||
worktree: undefined,
|
||||
branch: undefined,
|
||||
})
|
||||
}
|
||||
const addProject = (title: string, serverKey?: string) => {
|
||||
|
||||
@@ -421,7 +421,7 @@ export function PromptProjectSelector(props: {
|
||||
<span class="min-w-0 flex-1 truncate leading-5">{props.controller.labels.add()}</span>
|
||||
</Menu.SubTrigger>
|
||||
<Menu.Portal>
|
||||
<Menu.SubContent class="min-w-[180px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 shadow-[var(--v2-elevation-floating)] focus:outline-none">
|
||||
<Menu.SubContent class="max-h-[224px] min-w-[180px] overflow-y-auto rounded-md border-0 bg-v2-background-bg-layer-01 shadow-[var(--v2-elevation-floating)] focus:outline-none">
|
||||
<For each={props.controller.servers()}>
|
||||
{(server) => <ServerAction server={server!} onSelect={selectAction} />}
|
||||
</For>
|
||||
|
||||
@@ -21,15 +21,20 @@ export default function NewSessionPage(props: { draftId: string }) {
|
||||
tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId),
|
||||
)
|
||||
const workspace = createNewSessionWorkspaceController({
|
||||
selected: () => draftTab()?.worktree,
|
||||
setSelected: (worktree) => {
|
||||
selectedWorktree: () => draftTab()?.worktree,
|
||||
selectedBranch: () => draftTab()?.branch,
|
||||
setSelectedWorktree: (worktree) => {
|
||||
if (search.draftId) tabs.updateDraft(search.draftId, { worktree })
|
||||
},
|
||||
setSelectedBranch: (branch) => {
|
||||
if (search.draftId) tabs.updateDraft(search.draftId, { branch })
|
||||
},
|
||||
onViewAll: openWorkspaces,
|
||||
})
|
||||
const composer = createNewSessionComposerAdapter({
|
||||
draftID: props.draftId,
|
||||
worktree: workspace.selection.value,
|
||||
branch: workspace.bar.branch,
|
||||
submitted: workspace.selection.remember,
|
||||
})
|
||||
const model = createComposerModel(composer.adapter)
|
||||
|
||||
@@ -69,9 +69,12 @@ export function NewSessionView(props: {
|
||||
value={props.workspace.selection.value()}
|
||||
projectRoot={props.workspace.project.root()}
|
||||
workspaces={props.workspace.project.workspaces()}
|
||||
branches={props.workspace.project.branches()}
|
||||
branch={props.workspace.bar.branch()}
|
||||
onboarding={onboardingReady() && !onboarding.used}
|
||||
onChange={select}
|
||||
onCreate={props.workspace.selection.create}
|
||||
onSearch={props.workspace.project.searchBranches}
|
||||
onDone={props.composer.restoreFocus}
|
||||
onViewAll={props.workspace.project.openAll}
|
||||
/>
|
||||
|
||||
@@ -65,6 +65,17 @@ describe("new session workspace selection", () => {
|
||||
).toBe(undefined)
|
||||
})
|
||||
|
||||
test("uses a selected branch for a new workspace", () => {
|
||||
expect(
|
||||
resolveNewSessionBranch({
|
||||
worktree: "create",
|
||||
directory: "/project/feature",
|
||||
createBranch: "release",
|
||||
worktreeBranch: () => "feature",
|
||||
}),
|
||||
).toBe("release")
|
||||
})
|
||||
|
||||
test("uses location VCS state when the project inventory is stale", () => {
|
||||
expect(resolveNewSessionGit({ branch: "dev" })).toBe(true)
|
||||
expect(resolveNewSessionGit({ projectVcs: "git" })).toBe(true)
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { createEffect, createMemo } from "solid-js"
|
||||
import { debounce } from "@solid-primitives/scheduled"
|
||||
import { createEffect, createMemo, createResource } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { useData } from "@/runtime/server/current"
|
||||
@@ -32,8 +34,10 @@ export function normalizeNewSessionWorktree(value: string, directory: string, pr
|
||||
export function resolveNewSessionBranch(input: {
|
||||
worktree: string
|
||||
directory: string
|
||||
createBranch?: string
|
||||
worktreeBranch: (worktree: string) => string | undefined
|
||||
}) {
|
||||
if (input.worktree === "create" && input.createBranch) return input.createBranch
|
||||
const directory = input.worktree === "main" || input.worktree === "create" ? input.directory : input.worktree
|
||||
return input.worktreeBranch(directory)
|
||||
}
|
||||
@@ -43,14 +47,18 @@ export function resolveNewSessionGit(input: { projectVcs?: string; branch?: stri
|
||||
}
|
||||
|
||||
export function createNewSessionWorkspaceController(input: {
|
||||
selected: () => string | undefined
|
||||
setSelected: (worktree: string | undefined) => void
|
||||
selectedWorktree: () => string | undefined
|
||||
selectedBranch: () => string | undefined
|
||||
setSelectedWorktree: (worktree: string | undefined) => void
|
||||
setSelectedBranch: (branch: string | undefined) => void
|
||||
onViewAll: () => void
|
||||
}) {
|
||||
const sdk = useWorkspaceLocation()
|
||||
const serverSDK = useServerSDK()
|
||||
const data = useData()
|
||||
const settings = useSettings()
|
||||
const [state, setState] = createStore({ search: "" })
|
||||
const searchBranches = debounce((search: string) => setState("search", search.trim()), 100)
|
||||
const currentProject = createMemo(() => {
|
||||
const projectID = data.location.info({ directory: sdk().directory })?.project.id
|
||||
const current = projectID ? data.project.get(projectID) : undefined
|
||||
@@ -64,7 +72,7 @@ export function createNewSessionWorkspaceController(input: {
|
||||
)
|
||||
const selected = createMemo(() => {
|
||||
const project = currentProject()
|
||||
const worktree = input.selected()
|
||||
const worktree = input.selectedWorktree()
|
||||
if (!project || !worktree) return
|
||||
return isWorkspaceSelection(project, worktree) ? worktree : undefined
|
||||
})
|
||||
@@ -86,6 +94,14 @@ export function createNewSessionWorkspaceController(input: {
|
||||
}),
|
||||
)
|
||||
const projectRoot = createMemo(() => currentProject()?.worktree ?? sdk().directory)
|
||||
const [branches] = createResource(
|
||||
() => (visible() ? { directory: projectRoot(), search: state.search } : undefined),
|
||||
({ directory, search }) =>
|
||||
serverSDK.api.vcs
|
||||
.branches({ location: { directory }, search, limit: 50 })
|
||||
.then((response) => ({ directory, search, data: response.data }))
|
||||
.catch(() => ({ directory, search, data: [] })),
|
||||
)
|
||||
createEffect(() => {
|
||||
void Promise.all([data.location.syncInfo({ directory: sdk().directory }), data.project.sync()]).catch(
|
||||
() => undefined,
|
||||
@@ -98,6 +114,7 @@ export function createNewSessionWorkspaceController(input: {
|
||||
resolveNewSessionBranch({
|
||||
worktree: value(),
|
||||
directory: sdk().directory,
|
||||
createBranch: input.selectedBranch(),
|
||||
worktreeBranch: (worktree) => data.location.vcs.info({ directory: worktree })?.branch.current,
|
||||
}),
|
||||
)
|
||||
@@ -116,10 +133,19 @@ export function createNewSessionWorkspaceController(input: {
|
||||
const current = value()
|
||||
return current === "create" || (!!project && isWorkspaceDirectory(project, current))
|
||||
}),
|
||||
reset: () => input.setSelected(undefined),
|
||||
reset: () => {
|
||||
input.setSelectedWorktree(undefined)
|
||||
input.setSelectedBranch(undefined)
|
||||
},
|
||||
remember,
|
||||
set: (worktree: string) => {
|
||||
input.setSelected(normalizeNewSessionWorktree(worktree, sdk().directory, currentProject()?.worktree))
|
||||
input.setSelectedBranch(undefined)
|
||||
input.setSelectedWorktree(normalizeNewSessionWorktree(worktree, sdk().directory, currentProject()?.worktree))
|
||||
},
|
||||
create: (branch: string) => {
|
||||
input.setSelectedBranch(branch)
|
||||
input.setSelectedWorktree("create")
|
||||
remember("create")
|
||||
},
|
||||
},
|
||||
project: {
|
||||
@@ -129,6 +155,15 @@ export function createNewSessionWorkspaceController(input: {
|
||||
return project ? workspaceDirectories(project) : []
|
||||
},
|
||||
git: visible,
|
||||
branches: () => {
|
||||
const current = data.location.vcs.info({ directory: sdk().directory })?.branch.current
|
||||
const loaded = branches.latest
|
||||
const list = loaded?.directory === projectRoot() ? loaded.data : []
|
||||
return [
|
||||
...new Set([...list, ...(current && current.toLowerCase().includes(state.search.toLowerCase()) ? [current] : [])]),
|
||||
].slice(0, 50)
|
||||
},
|
||||
searchBranches,
|
||||
openAll: input.onViewAll,
|
||||
},
|
||||
bar: {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createMemo, createSignal, For, Show } from "solid-js"
|
||||
import { createMemo, For, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Menu } from "@opencode-ai/ui/menu"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
@@ -10,20 +11,24 @@ export function PromptWorkspaceSelector(props: {
|
||||
value: string
|
||||
projectRoot: string
|
||||
workspaces: string[]
|
||||
branches: string[]
|
||||
branch?: string
|
||||
onboarding?: boolean
|
||||
onChange: (value: string) => void
|
||||
onCreate: (branch: string) => void
|
||||
onSearch: (search: string) => void
|
||||
onDone: () => void
|
||||
onViewAll: () => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const [search, setSearch] = createSignal("")
|
||||
const [search, setSearch] = createStore({ workspaces: "", branches: "" })
|
||||
let searchInput: HTMLInputElement | undefined
|
||||
let branchSearchInput: HTMLInputElement | undefined
|
||||
let focusSearch = false
|
||||
let pending: { type: "select"; value: string } | { type: "viewAll" } | undefined
|
||||
let pending: { type: "select"; value: string } | { type: "create"; branch: string } | { type: "viewAll" } | undefined
|
||||
const selected = () => (sameDirectory(props.value, props.projectRoot) ? "main" : props.value)
|
||||
const workspaces = createMemo(() => {
|
||||
const query = search().trim().toLowerCase()
|
||||
const query = search.workspaces.trim().toLowerCase()
|
||||
if (!query) return props.workspaces
|
||||
return props.workspaces.filter((workspace) => getFilename(workspace).toLowerCase().includes(query))
|
||||
})
|
||||
@@ -37,12 +42,14 @@ export function PromptWorkspaceSelector(props: {
|
||||
}
|
||||
const onOpenChange = (open: boolean) => {
|
||||
if (open) {
|
||||
setSearch("")
|
||||
setSearch({ workspaces: "", branches: "" })
|
||||
props.onSearch("")
|
||||
return
|
||||
}
|
||||
const action = pending
|
||||
pending = undefined
|
||||
if (action?.type === "select") props.onChange(action.value)
|
||||
if (action?.type === "create") props.onCreate(action.branch)
|
||||
if (action?.type === "viewAll") {
|
||||
props.onViewAll()
|
||||
return
|
||||
@@ -120,21 +127,7 @@ export function PromptWorkspaceSelector(props: {
|
||||
</Menu.Item>
|
||||
<Menu.Item onSelect={() => select("create")}>
|
||||
<Icon name="workspace-new" />
|
||||
<Tooltip
|
||||
placement="right"
|
||||
openDelay={800}
|
||||
value={
|
||||
<span class="flex flex-col gap-0.5">
|
||||
<span>{language.t("workspace.new")}</span>
|
||||
<span class="font-[440] text-v2-text-text-muted">
|
||||
{language.t("session.new.workspace.new.tooltip")}
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
class="min-w-0 flex-1"
|
||||
>
|
||||
<span class="min-w-0 truncate">{language.t("workspace.new")}</span>
|
||||
</Tooltip>
|
||||
<span class="min-w-0 flex-1 truncate">{language.t("workspace.new")}</span>
|
||||
<Show when={selected() === "create"}>
|
||||
<Icon name="check" size="small" class="shrink-0" />
|
||||
</Show>
|
||||
@@ -191,11 +184,11 @@ export function PromptWorkspaceSelector(props: {
|
||||
ref={(element) => {
|
||||
searchInput = element
|
||||
}}
|
||||
value={search()}
|
||||
value={search.workspaces}
|
||||
placeholder={language.t("session.new.workspace.search.placeholder")}
|
||||
aria-label={language.t("session.new.workspace.search.placeholder")}
|
||||
class="h-7 min-w-0 flex-1 border-0 bg-transparent text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint"
|
||||
onInput={(event) => setSearch(event.currentTarget.value)}
|
||||
onInput={(event) => setSearch("workspaces", event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
event.key === "Escape" ||
|
||||
@@ -232,7 +225,94 @@ export function PromptWorkspaceSelector(props: {
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
</Tooltip>
|
||||
<PromptGitStatus branch={props.branch} from={selected() === "create"} class="ms-1" />
|
||||
<Show
|
||||
when={selected() === "create" && props.branch}
|
||||
fallback={<PromptGitStatus branch={props.branch} from={selected() === "create"} class="ms-1" />}
|
||||
>
|
||||
<Tooltip
|
||||
placement="top"
|
||||
value={language.t("session.new.workspace.fromBranch", { branch: props.branch! })}
|
||||
class="ms-1 min-w-0 max-w-[220px]"
|
||||
contentClass="max-w-[calc(100vw-32px)] break-all"
|
||||
>
|
||||
<Menu
|
||||
placement="bottom"
|
||||
gutter={4}
|
||||
onOpenChange={(open) => {
|
||||
onOpenChange(open)
|
||||
if (open) requestAnimationFrame(() => branchSearchInput?.focus())
|
||||
}}
|
||||
>
|
||||
<Menu.Trigger class="flex h-6 min-w-0 max-w-[220px] items-center gap-1.5 rounded-full bg-v2-background-bg-layer-02 px-2.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:bg-v2-background-bg-layer-03 hover:text-v2-text-text-muted focus-visible:bg-v2-background-bg-layer-03 focus-visible:text-v2-text-text-muted focus-visible:outline-none data-[expanded]:bg-v2-background-bg-layer-03 data-[expanded]:text-v2-text-text-muted">
|
||||
<Icon name="branch-out" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span class="min-w-0 truncate">
|
||||
{language.t("session.new.workspace.fromBranch", { branch: props.branch! })}
|
||||
</span>
|
||||
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
</Menu.Trigger>
|
||||
<Menu.Portal>
|
||||
<Menu.Content class="w-[243px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 shadow-[var(--v2-elevation-floating)] focus:outline-none">
|
||||
<div class="flex h-7 shrink-0 items-center gap-2 rounded-sm pl-3 pr-2.5 text-v2-icon-icon-muted">
|
||||
<Icon name="magnifying-glass" size="small" class="shrink-0" />
|
||||
<input
|
||||
ref={(element) => {
|
||||
branchSearchInput = element
|
||||
}}
|
||||
value={search.branches}
|
||||
placeholder={language.t("session.new.workspace.branch.search.placeholder")}
|
||||
aria-label={language.t("session.new.workspace.branch.search.placeholder")}
|
||||
class="h-7 min-w-0 flex-1 border-0 bg-transparent text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint"
|
||||
onInput={(event) => {
|
||||
setSearch("branches", event.currentTarget.value)
|
||||
props.onSearch(event.currentTarget.value)
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
event.key === "Escape" ||
|
||||
event.key === "ArrowDown" ||
|
||||
event.key === "ArrowUp" ||
|
||||
event.key === "Enter"
|
||||
)
|
||||
return
|
||||
event.stopPropagation()
|
||||
}}
|
||||
/>
|
||||
<Show when={search.branches.trim()}>
|
||||
<button
|
||||
type="button"
|
||||
class="flex size-5 items-center justify-center rounded-sm text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover"
|
||||
onPointerDown={(event) => event.preventDefault()}
|
||||
onClick={() => {
|
||||
setSearch("branches", "")
|
||||
props.onSearch("")
|
||||
}}
|
||||
aria-label={language.t("common.clear")}
|
||||
>
|
||||
<Icon name="close-small" size="small" />
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="max-h-[224px] overflow-y-auto">
|
||||
<Menu.RadioGroup value={props.branch}>
|
||||
<For each={props.branches}>
|
||||
{(branch) => (
|
||||
<Menu.RadioItem
|
||||
value={branch}
|
||||
class="h-7 gap-2 rounded-sm px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:var(--v2-font-family-sans)] data-[highlighted]:!bg-v2-overlay-simple-overlay-hover"
|
||||
closeOnSelect
|
||||
onSelect={() => (pending = { type: "create", branch })}
|
||||
>
|
||||
<span class="min-w-0 truncate leading-5">{branch}</span>
|
||||
</Menu.RadioItem>
|
||||
)}
|
||||
</For>
|
||||
</Menu.RadioGroup>
|
||||
</div>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { createMemo, type Accessor } from "solid-js"
|
||||
import createPresence from "solid-presence"
|
||||
|
||||
export function createAnimatedPresence<T>(value: Accessor<T | undefined>, element: Accessor<HTMLElement | null>) {
|
||||
const animation = createMemo<{ show: boolean; animate: boolean; value: T | undefined }>((previous) => {
|
||||
const current = value()
|
||||
const show = current !== undefined
|
||||
return {
|
||||
show,
|
||||
animate: previous !== undefined && (previous.animate || previous.show !== show),
|
||||
value: current ?? previous?.value,
|
||||
}
|
||||
})
|
||||
const presence = createPresence({ show: () => animation().show, element })
|
||||
return {
|
||||
...presence,
|
||||
show: () => animation().show,
|
||||
animate: () => animation().animate,
|
||||
value: () => animation().value,
|
||||
}
|
||||
}
|
||||
@@ -460,7 +460,8 @@ export const dict = {
|
||||
"dialog.project.edit.color": "Color",
|
||||
"dialog.project.edit.color.select": "Select {{color}} color",
|
||||
"dialog.project.edit.worktree.startup": "Workspace startup script",
|
||||
"dialog.project.edit.worktree.startup.description": "Runs after creating a new workspace (worktree).",
|
||||
"dialog.project.edit.worktree.startup.description":
|
||||
"Runs after creating a new workspace (worktree). Use $OPENCODE_WORKTREE_BASE for the base worktree and $OPENCODE_WORKTREE_PATH for the new worktree.",
|
||||
"dialog.project.edit.worktree.startup.placeholder": "e.g. bun install",
|
||||
|
||||
"dialog.releaseNotes.action.getStarted": "Get started",
|
||||
@@ -499,7 +500,7 @@ export const dict = {
|
||||
"context.stats.lastActivity": "Last Activity",
|
||||
|
||||
"context.usage.tokens": "Tokens",
|
||||
"context.usage.usage": "Usage",
|
||||
"context.usage.usage": "Context Usage",
|
||||
"context.usage.cost": "Cost",
|
||||
"context.usage.clickToView": "Click to view context",
|
||||
"context.usage.view": "View context usage",
|
||||
@@ -1151,6 +1152,8 @@ export const dict = {
|
||||
"session.new.workspace.local.tooltip": "Use current checkout",
|
||||
"session.new.workspace.new.tooltip": "Create isolated checkout",
|
||||
"session.new.workspace.fromBranch": "from {{branch}}",
|
||||
"session.new.workspace.createFrom": "Create from branch",
|
||||
"session.new.workspace.branch.search.placeholder": "Search branches",
|
||||
"session.new.workspace.trigger.tooltip": "Select where to run session",
|
||||
"session.new.workspace.search.placeholder": "Search workspaces",
|
||||
"settings.tab.workspaces": "Workspaces",
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
import { createSessionMutations } from "./data"
|
||||
|
||||
const session = { id: "ses_test" } as SessionInfo
|
||||
|
||||
test("keeps a successful removal applied until its event arrives", async () => {
|
||||
const release = Promise.withResolvers<void>()
|
||||
const mutation = createSessionMutations(async () => release.promise)
|
||||
|
||||
const request = mutation.remove(session.id)
|
||||
expect(mutation.apply([session])).toEqual([])
|
||||
release.resolve()
|
||||
await request
|
||||
expect(mutation.apply([session])).toEqual([])
|
||||
|
||||
mutation.deleted(session.id)
|
||||
expect(mutation.apply([session])).toEqual([session])
|
||||
})
|
||||
|
||||
test("rolls back a failed removal", async () => {
|
||||
const release = Promise.withResolvers<void>()
|
||||
const mutation = createSessionMutations(async () => {
|
||||
await release.promise
|
||||
throw new Error("offline")
|
||||
})
|
||||
|
||||
const request = mutation.remove(session.id)
|
||||
expect(mutation.apply([session])).toEqual([])
|
||||
release.resolve()
|
||||
await expect(request).rejects.toThrow("offline")
|
||||
expect(mutation.apply([session])).toEqual([session])
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { Data } from "@opencode-ai/client/solid"
|
||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
import { onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
|
||||
type SessionMutation = { readonly id: string; readonly type: "remove"; readonly sessionID: string }
|
||||
|
||||
export function createDesktopData(input: { data: Data; remove: (sessionID: string) => Promise<void> }) {
|
||||
const mutation = createSessionMutations(input.remove)
|
||||
onCleanup(input.data.on("session.deleted", (event) => mutation.deleted(event.data.sessionID)))
|
||||
|
||||
return {
|
||||
...input.data,
|
||||
session: {
|
||||
...input.data.session,
|
||||
list: () => mutation.apply(input.data.session.list()),
|
||||
apply: mutation.apply,
|
||||
remove: mutation.remove,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function createSessionMutations(remove: (sessionID: string) => Promise<void>) {
|
||||
const [store, setStore] = createStore({ session: [] as SessionMutation[] })
|
||||
|
||||
const clear = (id: string) => {
|
||||
setStore("session", (current) => current.filter((mutation) => mutation.id !== id))
|
||||
}
|
||||
|
||||
return {
|
||||
apply(sessions: readonly SessionInfo[]) {
|
||||
const removed = new Set(
|
||||
store.session.flatMap((mutation) => (mutation.type === "remove" ? [mutation.sessionID] : [])),
|
||||
)
|
||||
return removed.size === 0 ? [...sessions] : sessions.filter((session) => !removed.has(session.id))
|
||||
},
|
||||
remove(sessionID: string) {
|
||||
const mutation = { id: crypto.randomUUID(), type: "remove" as const, sessionID }
|
||||
setStore("session", (current) => [...current, mutation])
|
||||
return Promise.resolve()
|
||||
.then(() => remove(sessionID))
|
||||
.catch((error) => {
|
||||
clear(mutation.id)
|
||||
throw error
|
||||
})
|
||||
},
|
||||
deleted(sessionID: string) {
|
||||
setStore("session", (current) => current.filter((mutation) => mutation.sessionID !== sessionID))
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import type { ServerScope } from "@/runtime/server/scope"
|
||||
import { createPermissionAutoApprover } from "@/session/requests/auto-approve"
|
||||
import { createServerNotificationState } from "@/shell/notifications/notification"
|
||||
import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
import { createDesktopData } from "./data"
|
||||
|
||||
export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext({
|
||||
name: "Global",
|
||||
@@ -134,7 +135,7 @@ function createServerController(
|
||||
) {
|
||||
const connKey = ServerConnection.key(conn)
|
||||
const sdk = createServerSdkContext(conn, scope)
|
||||
const data = createData({
|
||||
const source = createData({
|
||||
api: () => sdk.api,
|
||||
event: {
|
||||
on: sdk.event.on,
|
||||
@@ -143,6 +144,10 @@ function createServerController(
|
||||
connection: sdk.connection,
|
||||
directory: "",
|
||||
})
|
||||
const data = createDesktopData({
|
||||
data: source,
|
||||
remove: (sessionID) => sdk.api.session.remove({ sessionID }),
|
||||
})
|
||||
const sync = createServerSyncContext(sdk, data)
|
||||
createPermissionAutoApprover({ sdk, data })
|
||||
const notification = createServerNotificationState({ sdk, data, key: connKey })
|
||||
|
||||
@@ -66,11 +66,11 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
|
||||
provider_auth: {},
|
||||
get path() {
|
||||
const EMPTY = { state: "", config: "", worktree: "", directory: "", home: "" }
|
||||
if (pathQuery.isLoading) return EMPTY
|
||||
if (pathQuery.isPending) return EMPTY
|
||||
return pathQuery.data ?? EMPTY
|
||||
},
|
||||
get config() {
|
||||
if (configQuery.isLoading) return {}
|
||||
if (configQuery.isPending) return {}
|
||||
return configQuery.data ?? {}
|
||||
},
|
||||
get reload() {
|
||||
|
||||
@@ -168,15 +168,15 @@ export function createTimelineController(input: { session: TimelineSessionSource
|
||||
const sessions = data.session.list().filter((item) => !item.parentID && !item.time?.archived)
|
||||
const index = sessions.findIndex((item) => item.id === id)
|
||||
const next = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
|
||||
const success = await serverSDK.api.session
|
||||
.remove({ sessionID: id })
|
||||
const removed = removedSessionIDs(data.session.list(), id)
|
||||
const success = await data.session
|
||||
.remove(id)
|
||||
.then(() => true)
|
||||
.catch((error) => {
|
||||
showToast({ title: language.t("session.delete.failed.title"), description: errorMessage(error) })
|
||||
return false
|
||||
})
|
||||
if (!success) return false
|
||||
const removed = removedSessionIDs(data.session.list(), id)
|
||||
void navigateAfterRemoval(id, session.parentID, next?.id)
|
||||
notifySessionTabsRemoved({ server: server.key, directory: sdk().directory, sessionIDs: [...removed] })
|
||||
return true
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createEffect, createMemo, createSignal, For, on, Show, type Accessor, type JSX } from "solid-js"
|
||||
import createPresence from "solid-presence"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createAnimatedPresence } from "@/runtime/animated-presence"
|
||||
import type { SessionUserActions } from "@opencode-ai/session-ui/actions"
|
||||
import { Badge } from "@opencode-ai/ui/badge"
|
||||
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
|
||||
@@ -479,17 +479,7 @@ function MessageTimelineView(
|
||||
return row.group.ref.partID
|
||||
})
|
||||
const [backgroundHintRef, setBackgroundHintRef] = createSignal<HTMLDivElement>()
|
||||
const backgroundHintVisibility = createMemo<{ show: boolean; animate: boolean }>(
|
||||
(previous) => {
|
||||
const show = backgroundHintPartID() !== undefined
|
||||
return { show, animate: previous.animate || previous.show !== show }
|
||||
},
|
||||
{ show: backgroundHintPartID() !== undefined, animate: false },
|
||||
)
|
||||
const backgroundHintPresence = createPresence({
|
||||
show: () => backgroundHintVisibility().show,
|
||||
element: () => backgroundHintRef() ?? null,
|
||||
})
|
||||
const backgroundHintPresence = createAnimatedPresence(backgroundHintPartID, () => backgroundHintRef() ?? null)
|
||||
return (
|
||||
<VirtualizedTimeline
|
||||
workspaceSession={workspaceSession}
|
||||
@@ -507,9 +497,9 @@ function MessageTimelineView(
|
||||
class="duration-150 motion-reduce:animate-none"
|
||||
classList={{
|
||||
[`flex h-9 items-start pt-3 ${turnPadding()}`]: true,
|
||||
"animate-in fade-in": backgroundHintVisibility().animate && backgroundHintVisibility().show,
|
||||
"animate-in fade-in": backgroundHintPresence.animate() && backgroundHintPresence.show(),
|
||||
"animate-out fade-out fill-mode-forwards":
|
||||
backgroundHintVisibility().animate && !backgroundHintVisibility().show,
|
||||
backgroundHintPresence.animate() && !backgroundHintPresence.show(),
|
||||
}}
|
||||
>
|
||||
<BackgroundMoveHint />
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { monoDefault, monoFontFamily, sansDefault, sansFontFamily, terminalFontFamily } from "./model"
|
||||
|
||||
describe("settings font families", () => {
|
||||
test("defaults normal text to Inter", () => {
|
||||
expect(sansDefault).toBe("Inter")
|
||||
expect(sansFontFamily(undefined)).toStartWith('"Inter", ')
|
||||
expect(sansFontFamily("")).toStartWith('"Inter", ')
|
||||
expect(sansFontFamily(" ")).toStartWith('"Inter", ')
|
||||
})
|
||||
|
||||
test("keeps custom normal fonts ahead of the default", () => {
|
||||
expect(sansFontFamily("Custom Sans")).toStartWith('"Custom Sans", "Inter", ')
|
||||
})
|
||||
|
||||
test("defaults monospace text to IBM Plex Mono", () => {
|
||||
expect(monoDefault).toBe("IBM Plex Mono")
|
||||
expect(monoFontFamily(undefined)).toStartWith('"IBM Plex Mono", ')
|
||||
expect(monoFontFamily("")).toStartWith('"IBM Plex Mono", ')
|
||||
expect(monoFontFamily(" ")).toStartWith('"IBM Plex Mono", ')
|
||||
})
|
||||
|
||||
test("keeps custom monospace fonts ahead of the default", () => {
|
||||
expect(monoFontFamily("Custom Mono")).toStartWith('"Custom Mono", "IBM Plex Mono", ')
|
||||
})
|
||||
|
||||
test("preserves the separate terminal font default", () => {
|
||||
expect(terminalFontFamily(undefined)).toStartWith('"JetBrainsMono Nerd Font Mono", ')
|
||||
})
|
||||
})
|
||||
@@ -60,12 +60,12 @@ export interface Settings {
|
||||
sounds: SoundSettings
|
||||
}
|
||||
|
||||
export const monoDefault = "System Mono"
|
||||
export const sansDefault = "System Sans"
|
||||
export const monoDefault = "IBM Plex Mono"
|
||||
export const sansDefault = "Inter"
|
||||
export const terminalDefault = "JetBrainsMono Nerd Font Mono"
|
||||
const monoFallback =
|
||||
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
|
||||
const sansFallback = 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
|
||||
'"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
|
||||
const sansFallback = '"Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
|
||||
const terminalFallback =
|
||||
'"JetBrainsMono Nerd Font Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ function ProjectSettingsDialog(props: { project: LocalProject; server: ServerCon
|
||||
<Button type="button" variant="neutral" disabled={model.save.isPending} onClick={model.close}>
|
||||
{language.t("common.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" variant="contrast" disabled={!model.supported || model.save.isPending}>
|
||||
<Button type="submit" variant="contrast" disabled={model.save.isPending}>
|
||||
{model.save.isPending ? language.t("common.saving") : language.t("common.save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { normalizeProjectInfo } from "@/runtime/server/global-sync/utils"
|
||||
import { createMemo } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useGlobal } from "@/runtime/server/runtime"
|
||||
@@ -9,7 +8,6 @@ import { type LocalProject } from "@/shell/state/layout"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
|
||||
export function createEditProjectModel(props: { project: LocalProject; server: ServerConnection.Any }) {
|
||||
const supported = !props.project.id || props.project.id === "global"
|
||||
const dialog = useDialog()
|
||||
const global = useGlobal()
|
||||
const serverCtx = createMemo(() => global.ensureServerCtx(props.server))
|
||||
@@ -72,9 +70,14 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
|
||||
const start = store.startup.trim()
|
||||
|
||||
if (props.project.id && props.project.id !== "global") {
|
||||
// TODO: Restore project edits when the V2 client exposes a project update API.
|
||||
// await serverCtx().sdk.api.project.update({ projectID: props.project.id, name, icon, commands })
|
||||
throw new Error(`Project ${props.project.id} cannot be updated`)
|
||||
await serverCtx().sdk.api.project.update({
|
||||
projectID: props.project.id,
|
||||
name,
|
||||
icon: { color: store.color ?? "", override: store.iconOverride ?? "" },
|
||||
commands: { start },
|
||||
})
|
||||
dialog.close()
|
||||
return
|
||||
}
|
||||
|
||||
serverCtx().sync.project.meta(props.project.worktree, {
|
||||
@@ -88,7 +91,7 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
|
||||
|
||||
function submit(event: SubmitEvent) {
|
||||
event.preventDefault()
|
||||
if (!supported || save.isPending) return
|
||||
if (save.isPending) return
|
||||
save.mutate()
|
||||
}
|
||||
|
||||
@@ -98,7 +101,6 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
|
||||
folderName,
|
||||
defaultName,
|
||||
save,
|
||||
supported,
|
||||
submit,
|
||||
drop,
|
||||
dragOver,
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import type { ServerConnection } from "@/runtime/server/registry"
|
||||
import type { Tab } from "@/shell/tabs/tabs"
|
||||
import { openNotificationSession } from "./notification"
|
||||
|
||||
test("opens notification sessions through the tab router", () => {
|
||||
const server = "local\nhttp://localhost:4096" as ServerConnection.Key
|
||||
const tab = { type: "session" as const, server, sessionId: "session-1" }
|
||||
const calls: string[] = []
|
||||
const tabs = {
|
||||
addSessionTab: (input: Omit<typeof tab, "type">) => {
|
||||
calls.push(`add:${input.sessionId}`)
|
||||
return tab
|
||||
},
|
||||
rememberSessionRoute: (_tab: typeof tab, sessionID: string) => {
|
||||
calls.push(`route:${sessionID}`)
|
||||
},
|
||||
select: (input: Tab) => {
|
||||
calls.push(`select:${input.type === "session" ? input.sessionId : input.draftID}`)
|
||||
},
|
||||
}
|
||||
|
||||
openNotificationSession(tabs, server, "session-1")
|
||||
|
||||
expect(calls).toEqual(["add:session-1", "route:session-1", "select:session-1"])
|
||||
})
|
||||
@@ -51,6 +51,19 @@ type NotificationIndex = {
|
||||
}
|
||||
}
|
||||
|
||||
type NotificationTabs = Pick<ReturnType<typeof useTabs>, "addSessionTab" | "rememberSessionRoute" | "select">
|
||||
|
||||
export function openNotificationSession(
|
||||
tabs: NotificationTabs,
|
||||
server: ServerConnection.Key,
|
||||
sessionID: string,
|
||||
) {
|
||||
const tab = tabs.addSessionTab({ server, sessionId: sessionID })
|
||||
if (tab.type !== "session") return
|
||||
tabs.rememberSessionRoute(tab, sessionID)
|
||||
tabs.select(tab)
|
||||
}
|
||||
|
||||
const MAX_NOTIFICATIONS = 500
|
||||
const NOTIFICATION_TTL_MS = 1000 * 60 * 60 * 24 * 30
|
||||
|
||||
@@ -211,11 +224,6 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat
|
||||
return typeof location !== "undefined" && location.pathname === sessionHref(input.key, sessionID)
|
||||
}
|
||||
|
||||
const navigate = (href: string) => {
|
||||
history.pushState(null, "", href)
|
||||
dispatchEvent(new PopStateEvent("popstate"))
|
||||
}
|
||||
|
||||
const handleSessionIdle = (sessionID: string, eventID: string, time: number) => {
|
||||
void lookup(sessionID).then((session) => {
|
||||
if (meta.disposed) return
|
||||
@@ -237,10 +245,9 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat
|
||||
session: sessionID,
|
||||
})
|
||||
|
||||
const href = sessionHref(input.key, sessionID)
|
||||
if (settings.notifications.agent()) {
|
||||
void platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, () =>
|
||||
navigate(href),
|
||||
openNotificationSession(tabs, input.key, sessionID),
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -274,9 +281,10 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat
|
||||
const description =
|
||||
session?.title ??
|
||||
(typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription"))
|
||||
const href = sessionHref(input.key, sessionID)
|
||||
if (settings.notifications.errors()) {
|
||||
void platform.notify(language.t("notification.session.error.title"), description, () => navigate(href))
|
||||
void platform.notify(language.t("notification.session.error.title"), description, () =>
|
||||
openNotificationSession(tabs, input.key, sessionID),
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -31,9 +31,19 @@ export function migrateTabs(value: unknown): Tab[] {
|
||||
tab.type === "draft" &&
|
||||
typeof tab.draftID === "string" &&
|
||||
typeof tab.directory === "string" &&
|
||||
(tab.worktree === undefined || typeof tab.worktree === "string")
|
||||
(tab.worktree === undefined || typeof tab.worktree === "string") &&
|
||||
(tab.branch === undefined || typeof tab.branch === "string")
|
||||
) {
|
||||
return [{ type: tab.type, server, draftID: tab.draftID, directory: tab.directory, worktree: tab.worktree }]
|
||||
return [
|
||||
{
|
||||
type: tab.type,
|
||||
server,
|
||||
draftID: tab.draftID,
|
||||
directory: tab.directory,
|
||||
worktree: tab.worktree,
|
||||
branch: tab.branch,
|
||||
},
|
||||
]
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
@@ -29,6 +29,7 @@ export type DraftTab = {
|
||||
server: ServerConnection.Key
|
||||
directory: string
|
||||
worktree?: string
|
||||
branch?: string
|
||||
}
|
||||
|
||||
export type Tab = SessionTab | DraftTab
|
||||
|
||||
@@ -143,6 +143,10 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-titlebar-tab]:not([data-active="true"]) [data-slot="tab-close"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-slot="tab-close"] {
|
||||
right: auto;
|
||||
left: 50%;
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { createAnimatedPresence } from "../src/runtime/animated-presence"
|
||||
import { createRoot, createSignal } from "solid-js"
|
||||
|
||||
test("animates visibility changes without animating initial presence", () => {
|
||||
createRoot((dispose) => {
|
||||
const [value, setValue] = createSignal<string | undefined>("steer")
|
||||
const presence = createAnimatedPresence(value, () => null)
|
||||
|
||||
expect(presence.show()).toBe(true)
|
||||
expect(presence.animate()).toBe(false)
|
||||
expect(presence.value()).toBe("steer")
|
||||
expect(presence.present()).toBe(true)
|
||||
|
||||
setValue("queue")
|
||||
expect(presence.animate()).toBe(false)
|
||||
expect(presence.value()).toBe("queue")
|
||||
|
||||
setValue(undefined)
|
||||
expect(presence.show()).toBe(false)
|
||||
expect(presence.animate()).toBe(true)
|
||||
expect(presence.value()).toBe("queue")
|
||||
|
||||
setValue("steer")
|
||||
expect(presence.show()).toBe(true)
|
||||
expect(presence.animate()).toBe(true)
|
||||
expect(presence.value()).toBe("steer")
|
||||
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
test("animates the first appearance when initially hidden", () => {
|
||||
createRoot((dispose) => {
|
||||
const [value, setValue] = createSignal<string | undefined>()
|
||||
const presence = createAnimatedPresence(value, () => null)
|
||||
|
||||
expect(presence.show()).toBe(false)
|
||||
expect(presence.animate()).toBe(false)
|
||||
expect(presence.present()).toBe(false)
|
||||
|
||||
setValue("steer")
|
||||
expect(presence.show()).toBe(true)
|
||||
expect(presence.animate()).toBe(true)
|
||||
expect(presence.value()).toBe("steer")
|
||||
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
@@ -9,6 +9,7 @@ import type { BunPlugin } from "bun"
|
||||
import pkg from "../package.json"
|
||||
import { buildAppArchive } from "./app-assets"
|
||||
import { verifyArtifact, verifySimulationGraph } from "./verify-artifact"
|
||||
import { resolveOpencodePty } from "./opencode-pty"
|
||||
|
||||
const dir = path.resolve(import.meta.dirname, "..")
|
||||
const binary = "opencode2"
|
||||
@@ -78,6 +79,23 @@ const appAssetsPlugin: BunPlugin = {
|
||||
}
|
||||
|
||||
for (const item of targets) {
|
||||
const opencodePty = await resolveOpencodePty({
|
||||
platform: item.os,
|
||||
arch: item.arch,
|
||||
...(item.os === "linux" ? { libc: item.abi ?? "glibc" } : {}),
|
||||
})
|
||||
const opencodePtyPlugin: BunPlugin = {
|
||||
name: "opencode-pty-binary",
|
||||
setup(build) {
|
||||
build.onLoad({ filter: /persistent-pty[/\\]pty-binding\.ts$/ }, () => ({
|
||||
loader: "js",
|
||||
contents: opencodePty
|
||||
? `import file from ${JSON.stringify(opencodePty.source)} with { type: "file" }
|
||||
export default { path: file, version: ${JSON.stringify(opencodePty.version)}, sha256: ${JSON.stringify(opencodePty.sha256)} }`
|
||||
: "export default undefined",
|
||||
}))
|
||||
},
|
||||
}
|
||||
const simulationInputs = new Set<string>()
|
||||
const simulationGraphPlugin: BunPlugin = {
|
||||
name: "opencode-simulation-graph",
|
||||
@@ -105,7 +123,7 @@ for (const item of targets) {
|
||||
const result = await Bun.build({
|
||||
entrypoints: ["./src/index.ts"],
|
||||
tsconfig: "./tsconfig.json",
|
||||
plugins: [appAssetsPlugin, solidPlugin, parcelWatcherPlugin, simulationGraphPlugin],
|
||||
plugins: [appAssetsPlugin, solidPlugin, parcelWatcherPlugin, opencodePtyPlugin, simulationGraphPlugin],
|
||||
external: ["node-gyp"],
|
||||
format: "esm",
|
||||
minify: true,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url"
|
||||
import { getNodeAssets } from "@opentui/core/node-assets"
|
||||
import { attentionSoundAssets, type NodeTarget, photonWasmAsset, shellParserWasmAssets } from "../src/node/target"
|
||||
import { collectFiles } from "./files"
|
||||
import { resolveOpencodePty } from "./opencode-pty"
|
||||
|
||||
const dir = path.resolve(import.meta.dirname, "..")
|
||||
|
||||
@@ -18,6 +19,11 @@ export type NodeAsset = {
|
||||
}
|
||||
|
||||
export async function collectNodeAssets(target: NodeTarget) {
|
||||
const opencodePty = await resolveOpencodePty({
|
||||
platform: target.platform,
|
||||
arch: target.arch,
|
||||
...(target.platform === "linux" ? { libc: "glibc" as const } : {}),
|
||||
})
|
||||
const ptyEntry = fileURLToPath(import.meta.resolve(target.nodePtyPackage))
|
||||
const ptyRoot = path.resolve(path.dirname(ptyEntry), "..")
|
||||
const assets: NodeAsset[] = [
|
||||
@@ -41,6 +47,7 @@ export async function collectNodeAssets(target: NodeTarget) {
|
||||
key,
|
||||
source: path.resolve(dir, "../ui/src/assets/audio", path.basename(key)),
|
||||
})),
|
||||
...(opencodePty && target.opencodePtyAsset ? [{ key: target.opencodePtyAsset, source: opencodePty.source }] : []),
|
||||
...(await collectFiles(ptyRoot))
|
||||
.filter((relative) => !relative.endsWith(".map") && !relative.endsWith(".pdb"))
|
||||
.map((relative) => ({
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { spawnSync } from "node:child_process"
|
||||
import { createHash } from "node:crypto"
|
||||
import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
|
||||
const VERSION = "0.1.5"
|
||||
const RELEASE = `https://github.com/anomalyco/opencode-pty/releases/download/v${VERSION}`
|
||||
const SHA256 = {
|
||||
"aarch64-apple-darwin": "d5156e44a6783381aadbd968dbd27c1d83e7e0f1b6042c7c934e6d33541d334f",
|
||||
"aarch64-unknown-linux-gnu": "075d99ffb269cbd0846d3d404fdee93965a53cd6eaf046dbd1064785a7ce9351",
|
||||
"aarch64-unknown-linux-musl": "22fb55c944ff05fbe03e84de67333e9fd037ad4e04ffc93d8a3f0b2193c29421",
|
||||
"x86_64-apple-darwin": "773e363b5385c1bd56021e69ada95132efd615ed5b9c3734f878ad644ae22b01",
|
||||
"x86_64-unknown-linux-gnu": "d9cac2a7c09d013188f696c45ded5eb5764d308e52dd31cb2de68bf4fc675624",
|
||||
"x86_64-unknown-linux-musl": "2a176302de3d24f8ae3fbacf0b4afce7b4af3e00abd619906187a487b5e50bd6",
|
||||
} as const
|
||||
|
||||
export type OpencodePtyAsset = {
|
||||
readonly source: string
|
||||
readonly version: string
|
||||
readonly sha256: string
|
||||
}
|
||||
|
||||
type Target = {
|
||||
readonly platform: string
|
||||
readonly arch: string
|
||||
readonly libc?: "glibc" | "musl"
|
||||
}
|
||||
|
||||
const pending = new Map<string, Promise<OpencodePtyAsset | undefined>>()
|
||||
|
||||
export function resolveOpencodePty(target: Target) {
|
||||
const rustTarget = targetName(target)
|
||||
if (!rustTarget) return Promise.resolve(undefined)
|
||||
const existing = pending.get(rustTarget)
|
||||
if (existing) return existing
|
||||
const result = acquire(rustTarget).catch((error) => {
|
||||
pending.delete(rustTarget)
|
||||
throw error
|
||||
})
|
||||
pending.set(rustTarget, result)
|
||||
return result
|
||||
}
|
||||
|
||||
async function acquire(target: keyof typeof SHA256): Promise<OpencodePtyAsset> {
|
||||
const root = path.resolve(import.meta.dirname, "../.cache/opencode-pty", VERSION, target)
|
||||
const executable = path.join(root, "opencode-pty")
|
||||
const cached = await readFile(executable).catch(() => undefined)
|
||||
if (cached)
|
||||
return {
|
||||
source: executable,
|
||||
version: VERSION,
|
||||
sha256: createHash("sha256").update(cached).digest("hex"),
|
||||
}
|
||||
|
||||
await mkdir(root, { recursive: true })
|
||||
const archiveName = `opencode-pty-${VERSION}-${target}.tar.gz`
|
||||
const response = await fetch(`${RELEASE}/${archiveName}`)
|
||||
if (!response.ok) throw new Error(`Failed to download ${archiveName}: ${response.status}`)
|
||||
const archive = new Uint8Array(await response.arrayBuffer())
|
||||
const actual = createHash("sha256").update(archive).digest("hex")
|
||||
if (actual !== SHA256[target]) throw new Error(`Checksum mismatch for ${archiveName}`)
|
||||
|
||||
const temporary = await mkdtemp(path.join(os.tmpdir(), "opencode-pty-build-"))
|
||||
try {
|
||||
const archivePath = path.join(temporary, archiveName)
|
||||
await writeFile(archivePath, archive)
|
||||
run("tar", ["-xzf", archivePath, "-C", temporary])
|
||||
const source = path.join(temporary, `opencode-pty-${VERSION}-${target}`, "opencode-pty")
|
||||
const bytes = await readFile(source)
|
||||
const staged = path.join(root, `opencode-pty.${process.pid}.${crypto.randomUUID()}.tmp`)
|
||||
await writeFile(staged, bytes, { flag: "wx", mode: 0o755 })
|
||||
await rename(staged, executable).catch(async (error) => {
|
||||
await rm(staged, { force: true })
|
||||
if (!(await readFile(executable).catch(() => undefined))) throw error
|
||||
})
|
||||
const installed = await readFile(executable)
|
||||
return {
|
||||
source: executable,
|
||||
version: VERSION,
|
||||
sha256: createHash("sha256").update(installed).digest("hex"),
|
||||
}
|
||||
} finally {
|
||||
await rm(temporary, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
function targetName(target: Target): keyof typeof SHA256 | undefined {
|
||||
const arch = target.arch === "arm64" ? "aarch64" : target.arch === "x64" ? "x86_64" : undefined
|
||||
if (!arch) return undefined
|
||||
if (target.platform === "darwin") return arch === "aarch64" ? "aarch64-apple-darwin" : "x86_64-apple-darwin"
|
||||
if (target.platform === "linux" && target.libc === "musl")
|
||||
return arch === "aarch64" ? "aarch64-unknown-linux-musl" : "x86_64-unknown-linux-musl"
|
||||
if (target.platform === "linux") return arch === "aarch64" ? "aarch64-unknown-linux-gnu" : "x86_64-unknown-linux-gnu"
|
||||
return undefined
|
||||
}
|
||||
|
||||
function run(command: string, args: readonly string[]) {
|
||||
const result = spawnSync(command, args, { stdio: "inherit" })
|
||||
if (result.error) throw result.error
|
||||
if (result.status !== 0) throw new Error(`${command} exited with status ${result.status ?? "unknown"}`)
|
||||
}
|
||||
@@ -3,10 +3,13 @@ import { Service } from "@opencode-ai/client/effect/service"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { ServiceConfig } from "../../../services/service-config"
|
||||
import { ServerConnection } from "../../../services/server-connection"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.service.commands.stop,
|
||||
Effect.fn("cli.service.stop")(function* () {
|
||||
yield* Service.stop(yield* ServiceConfig.options())
|
||||
const options = yield* ServiceConfig.options()
|
||||
yield* ServerConnection.shutdownPersistentPty(options).pipe(Effect.ignore)
|
||||
yield* Service.stop(options)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -13,6 +13,7 @@ export function nodeTarget(platform: string, arch: string) {
|
||||
const parcelWatcherPackage = `@parcel/watcher-${targetPlatform}-${targetArch}${targetPlatform === "linux" ? "-glibc" : ""}`
|
||||
const fffPackage = `@ff-labs/fff-bin-${targetPlatform}-${targetArch}${targetPlatform === "linux" ? "-gnu" : ""}`
|
||||
const fffFfiPackage = `@yuuang/ffi-rs-${targetPlatform}-${targetArch}${targetPlatform === "linux" ? "-gnu" : targetPlatform === "win32" ? "-msvc" : ""}`
|
||||
const opencodePtyAsset = targetPlatform === "win32" ? undefined : "opencode-pty/opencode-pty"
|
||||
|
||||
return {
|
||||
platform: targetPlatform,
|
||||
@@ -25,6 +26,7 @@ export function nodeTarget(platform: string, arch: string) {
|
||||
fffAsset: `${fffPackage}/${targetPlatform === "darwin" ? "libfff_c.dylib" : targetPlatform === "win32" ? "fff_c.dll" : "libfff_c.so"}`,
|
||||
fffFfiPackage,
|
||||
fffFfiAsset: `${fffFfiPackage}/ffi-rs.${targetPlatform}-${targetArch}${targetPlatform === "linux" ? "-gnu" : targetPlatform === "win32" ? "-msvc" : ""}.node`,
|
||||
opencodePtyAsset,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -62,6 +62,15 @@ function managedService(options: EnsureOptions) {
|
||||
}
|
||||
}
|
||||
|
||||
export const shutdownPersistentPty = Effect.fn("cli.server-connection.shutdown-persistent-pty")(function* (
|
||||
options: EnsureOptions,
|
||||
) {
|
||||
const endpoint = yield* Service.discover({ ...options, version: undefined })
|
||||
if (!endpoint) return
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
yield* Effect.tryPromise(() => client.experimental.persistentPty.shutdown())
|
||||
})
|
||||
|
||||
const resolveManaged = Effect.fnUntraced(function* (options: EnsureOptions, mismatch: NonNullable<Args["mismatch"]>) {
|
||||
if (mismatch === "replace") return yield* Service.ensure(options)
|
||||
if (mismatch === "ignore") return yield* Service.ensure({ ...options, version: undefined })
|
||||
|
||||
@@ -8,6 +8,7 @@ test("collects each SEA asset key once", async () => {
|
||||
const keys = assets.map((asset) => asset.key)
|
||||
|
||||
expect(new Set(keys).size).toBe(keys.length)
|
||||
if (process.platform !== "win32") expect(keys.filter((key) => key === "opencode-pty/opencode-pty")).toHaveLength(1)
|
||||
expect(assets.filter((asset) => asset.key === shellParserWasmAssets.runtime)).toEqual([
|
||||
{
|
||||
key: shellParserWasmAssets.runtime,
|
||||
|
||||
@@ -120,6 +120,7 @@ function nodePrelude(input: NodeBuildInput) {
|
||||
input.target.platform === "darwin"
|
||||
? `${input.target.nodePtyPackage}/prebuilds/darwin-${input.target.arch}/spawn-helper`
|
||||
: undefined
|
||||
const opencodePtyAsset = input.target.opencodePtyAsset
|
||||
const promiseModule = `const sdk = globalThis[Symbol.for("opencode.plugin.v2.promise")]
|
||||
if (!sdk) throw new Error("OpenCode Promise plugin SDK is unavailable")
|
||||
export const Agent = sdk.Agent
|
||||
@@ -200,13 +201,17 @@ if (__ocIsSea()) {
|
||||
const __ocAssetRoot = __ocIsSea()
|
||||
? __ocPath.join(__ocCacheRoot, ${JSON.stringify(`${input.assetHash}-${input.target.platform}-${input.target.arch}`)})
|
||||
: __ocFileURLToPath(new URL("./assets/", import.meta.url))
|
||||
const __ocPersistentPty = ${JSON.stringify(opencodePtyAsset)}
|
||||
if (__ocIsSea()) {
|
||||
const __ocPtySpawnHelper = ${JSON.stringify(nodePtySpawnHelper)}
|
||||
for (const __ocKey of __ocAssetKeys()) {
|
||||
const __ocTarget = __ocPath.join(__ocAssetRoot, __ocKey)
|
||||
if (__ocExists(__ocTarget)) continue
|
||||
__ocMkdir(__ocPath.dirname(__ocTarget), { recursive: true })
|
||||
const __ocTemporary = \`${"${__ocTarget}"}.${"${process.pid}"}.${"${crypto.randomUUID()}"}.tmp\`
|
||||
__ocWrite(__ocTemporary, new Uint8Array(__ocRawAsset(__ocKey)))
|
||||
if ((__ocKey === __ocPtySpawnHelper || __ocKey === __ocPersistentPty) && process.platform !== "win32")
|
||||
__ocChmod(__ocTemporary, 0o755)
|
||||
try {
|
||||
__ocRename(__ocTemporary, __ocTarget)
|
||||
} catch (__ocError) {
|
||||
@@ -214,8 +219,6 @@ if (__ocIsSea()) {
|
||||
if (!__ocExists(__ocTarget)) throw __ocError
|
||||
}
|
||||
}
|
||||
const __ocPtySpawnHelper = ${JSON.stringify(nodePtySpawnHelper)}
|
||||
if (__ocPtySpawnHelper) __ocChmod(__ocPath.join(__ocAssetRoot, __ocPtySpawnHelper), 0o755)
|
||||
}
|
||||
process.env.OPENCODE_NODE_ASSETS_DIR = __ocAssetRoot
|
||||
process.env.OTUI_ASSET_ROOT = __ocAssetRoot
|
||||
@@ -227,6 +230,7 @@ process.env.OPENCODE_TREE_SITTER_BASH_WASM_PATH = __ocPath.join(__ocAssetRoot, $
|
||||
process.env.OPENCODE_TREE_SITTER_POWERSHELL_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(shellParserWasmAssets.powershell)})
|
||||
process.env.FFF_BINARY_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.fffAsset)})
|
||||
process.env.OPENCODE_FFF_FFI_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.fffFfiAsset)})
|
||||
if (__ocPersistentPty && !process.env.OPENCODE_PTY_BIN) process.env.OPENCODE_PTY_BIN = __ocPath.join(__ocAssetRoot, __ocPersistentPty)
|
||||
try {
|
||||
globalThis.__OPENCODE_FFF_FFI = require(process.env.OPENCODE_FFF_FFI_PATH)
|
||||
} catch {}
|
||||
|
||||
@@ -1350,6 +1350,15 @@ export interface CredentialApi<E = never> {
|
||||
export type ProjectListOutput = ReadonlyArray<Project.Info>
|
||||
export type ProjectListOperation<E = never> = () => Effect.Effect<ProjectListOutput, E>
|
||||
|
||||
export type ProjectUpdateInput = {
|
||||
readonly projectID: Project.ID
|
||||
readonly name?: string | undefined
|
||||
readonly icon?: Project.Icon | undefined
|
||||
readonly commands?: Project.Commands | undefined
|
||||
}
|
||||
export type ProjectUpdateOutput = Project.Info
|
||||
export type ProjectUpdateOperation<E = never> = (input: ProjectUpdateInput) => Effect.Effect<ProjectUpdateOutput, E>
|
||||
|
||||
export type ProjectCurrentInput = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
@@ -1358,6 +1367,7 @@ export type ProjectCurrentOperation<E = never> = (input?: ProjectCurrentInput) =
|
||||
|
||||
export interface ProjectApi<E = never> {
|
||||
readonly list: ProjectListOperation<E>
|
||||
readonly update: ProjectUpdateOperation<E>
|
||||
readonly current: ProjectCurrentOperation<E>
|
||||
}
|
||||
|
||||
@@ -1582,6 +1592,152 @@ export interface PtyApi<E = never> {
|
||||
readonly connect: { readonly token: PtyConnectTokenOperation<E> }
|
||||
}
|
||||
|
||||
export type ExperimentalPersistentPtyListInput = { readonly sessionID: Session.ID }
|
||||
export type ExperimentalPersistentPtyListOutput = ReadonlyArray<{
|
||||
readonly id: Pty.ID
|
||||
readonly title: string
|
||||
readonly command: string
|
||||
readonly args: ReadonlyArray<string>
|
||||
readonly cwd: string
|
||||
readonly status: "running" | "exited"
|
||||
readonly pid: number
|
||||
readonly exitCode?: number | undefined
|
||||
readonly sessionID: Session.ID
|
||||
readonly foregroundProcess: string | null
|
||||
readonly size: { readonly cols: number; readonly rows: number }
|
||||
readonly output: { readonly head: number; readonly tail: number }
|
||||
}>
|
||||
export type ExperimentalPersistentPtyListOperation<E = never> = (
|
||||
input: ExperimentalPersistentPtyListInput,
|
||||
) => Effect.Effect<ExperimentalPersistentPtyListOutput, E>
|
||||
|
||||
export type ExperimentalPersistentPtyCreateInput = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly command: string
|
||||
readonly args: ReadonlyArray<string>
|
||||
readonly cwd: string
|
||||
readonly title: string
|
||||
readonly env: { readonly [x: string]: string }
|
||||
readonly size?: { readonly cols: number; readonly rows: number } | undefined
|
||||
}
|
||||
export type ExperimentalPersistentPtyCreateOutput = {
|
||||
readonly id: Pty.ID
|
||||
readonly title: string
|
||||
readonly command: string
|
||||
readonly args: ReadonlyArray<string>
|
||||
readonly cwd: string
|
||||
readonly status: "running" | "exited"
|
||||
readonly pid: number
|
||||
readonly exitCode?: number | undefined
|
||||
readonly sessionID: Session.ID
|
||||
readonly foregroundProcess: string | null
|
||||
readonly size: { readonly cols: number; readonly rows: number }
|
||||
readonly output: { readonly head: number; readonly tail: number }
|
||||
}
|
||||
export type ExperimentalPersistentPtyCreateOperation<E = never> = (
|
||||
input: ExperimentalPersistentPtyCreateInput,
|
||||
) => Effect.Effect<ExperimentalPersistentPtyCreateOutput, E>
|
||||
|
||||
export type ExperimentalPersistentPtyShutdownOutput = void
|
||||
export type ExperimentalPersistentPtyShutdownOperation<E = never> = () => Effect.Effect<
|
||||
ExperimentalPersistentPtyShutdownOutput,
|
||||
E
|
||||
>
|
||||
|
||||
export type ExperimentalPersistentPtyGetInput = { readonly ptyID: Pty.ID }
|
||||
export type ExperimentalPersistentPtyGetOutput = {
|
||||
readonly id: Pty.ID
|
||||
readonly title: string
|
||||
readonly command: string
|
||||
readonly args: ReadonlyArray<string>
|
||||
readonly cwd: string
|
||||
readonly status: "running" | "exited"
|
||||
readonly pid: number
|
||||
readonly exitCode?: number | undefined
|
||||
readonly sessionID: Session.ID
|
||||
readonly foregroundProcess: string | null
|
||||
readonly size: { readonly cols: number; readonly rows: number }
|
||||
readonly output: { readonly head: number; readonly tail: number }
|
||||
}
|
||||
export type ExperimentalPersistentPtyGetOperation<E = never> = (
|
||||
input: ExperimentalPersistentPtyGetInput,
|
||||
) => Effect.Effect<ExperimentalPersistentPtyGetOutput, E>
|
||||
|
||||
export type ExperimentalPersistentPtyUpdateInput = {
|
||||
readonly ptyID: Pty.ID
|
||||
readonly attachmentID?: string | undefined
|
||||
readonly size: { readonly cols: number; readonly rows: number }
|
||||
}
|
||||
export type ExperimentalPersistentPtyUpdateOutput = {
|
||||
readonly id: Pty.ID
|
||||
readonly title: string
|
||||
readonly command: string
|
||||
readonly args: ReadonlyArray<string>
|
||||
readonly cwd: string
|
||||
readonly status: "running" | "exited"
|
||||
readonly pid: number
|
||||
readonly exitCode?: number | undefined
|
||||
readonly sessionID: Session.ID
|
||||
readonly foregroundProcess: string | null
|
||||
readonly size: { readonly cols: number; readonly rows: number }
|
||||
readonly output: { readonly head: number; readonly tail: number }
|
||||
}
|
||||
export type ExperimentalPersistentPtyUpdateOperation<E = never> = (
|
||||
input: ExperimentalPersistentPtyUpdateInput,
|
||||
) => Effect.Effect<ExperimentalPersistentPtyUpdateOutput, E>
|
||||
|
||||
export type ExperimentalPersistentPtySnapshotInput = { readonly ptyID: Pty.ID }
|
||||
export type ExperimentalPersistentPtySnapshotOutput = {
|
||||
readonly info: {
|
||||
readonly id: Pty.ID
|
||||
readonly title: string
|
||||
readonly command: string
|
||||
readonly args: ReadonlyArray<string>
|
||||
readonly cwd: string
|
||||
readonly status: "running" | "exited"
|
||||
readonly pid: number
|
||||
readonly exitCode?: number | undefined
|
||||
readonly sessionID: Session.ID
|
||||
readonly foregroundProcess: string | null
|
||||
readonly size: { readonly cols: number; readonly rows: number }
|
||||
readonly output: { readonly head: number; readonly tail: number }
|
||||
}
|
||||
readonly text: string
|
||||
readonly checkpoint: globalThis.Uint8Array
|
||||
readonly cursor: { readonly x: number; readonly y: number }
|
||||
}
|
||||
export type ExperimentalPersistentPtySnapshotOperation<E = never> = (
|
||||
input: ExperimentalPersistentPtySnapshotInput,
|
||||
) => Effect.Effect<ExperimentalPersistentPtySnapshotOutput, E>
|
||||
|
||||
export type ExperimentalPersistentPtyRemoveInput = { readonly ptyID: Pty.ID }
|
||||
export type ExperimentalPersistentPtyRemoveOutput = void
|
||||
export type ExperimentalPersistentPtyRemoveOperation<E = never> = (
|
||||
input: ExperimentalPersistentPtyRemoveInput,
|
||||
) => Effect.Effect<ExperimentalPersistentPtyRemoveOutput, E>
|
||||
|
||||
export type ExperimentalPersistentPtyConnectTokenInput = {
|
||||
readonly ptyID: Pty.ID
|
||||
readonly "x-opencode-ticket"?: string | undefined
|
||||
}
|
||||
export type ExperimentalPersistentPtyConnectTokenOutput = PtyTicket.ConnectToken
|
||||
export type ExperimentalPersistentPtyConnectTokenOperation<E = never> = (
|
||||
input: ExperimentalPersistentPtyConnectTokenInput,
|
||||
) => Effect.Effect<ExperimentalPersistentPtyConnectTokenOutput, E>
|
||||
|
||||
export interface ExperimentalApi<E = never> {
|
||||
readonly persistentPty: {
|
||||
readonly list: ExperimentalPersistentPtyListOperation<E>
|
||||
readonly create: ExperimentalPersistentPtyCreateOperation<E>
|
||||
readonly shutdown: ExperimentalPersistentPtyShutdownOperation<E>
|
||||
readonly get: ExperimentalPersistentPtyGetOperation<E>
|
||||
readonly update: ExperimentalPersistentPtyUpdateOperation<E>
|
||||
readonly snapshot: ExperimentalPersistentPtySnapshotOperation<E>
|
||||
readonly remove: ExperimentalPersistentPtyRemoveOperation<E>
|
||||
readonly connectToken: ExperimentalPersistentPtyConnectTokenOperation<E>
|
||||
}
|
||||
}
|
||||
|
||||
export type ShellListInput = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
@@ -1664,6 +1820,7 @@ export type WorktreeCreateInput = {
|
||||
readonly projectID: Project.ID
|
||||
readonly strategy: Worktree.StrategyID
|
||||
readonly from?: AbsolutePath | undefined
|
||||
readonly branch?: string | undefined
|
||||
readonly directory: AbsolutePath
|
||||
readonly name?: string | undefined
|
||||
}
|
||||
@@ -1720,6 +1877,14 @@ export type VcsStatusInput = {
|
||||
export type VcsStatusOutput = { readonly location: Location.Info; readonly data: ReadonlyArray<Vcs.FileStatus> }
|
||||
export type VcsStatusOperation<E = never> = (input?: VcsStatusInput) => Effect.Effect<VcsStatusOutput, E>
|
||||
|
||||
export type VcsBranchesInput = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly search?: string | undefined
|
||||
readonly limit?: number | undefined
|
||||
}
|
||||
export type VcsBranchesOutput = { readonly location: Location.Info; readonly data: Vcs.BranchList }
|
||||
export type VcsBranchesOperation<E = never> = (input?: VcsBranchesInput) => Effect.Effect<VcsBranchesOutput, E>
|
||||
|
||||
export type VcsDiffInput = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly mode: Vcs.Mode
|
||||
@@ -1731,6 +1896,7 @@ export type VcsDiffOperation<E = never> = (input: VcsDiffInput) => Effect.Effect
|
||||
export interface VcsApi<E = never> {
|
||||
readonly get: VcsGetOperation<E>
|
||||
readonly status: VcsStatusOperation<E>
|
||||
readonly branches: VcsBranchesOperation<E>
|
||||
readonly diff: VcsDiffOperation<E>
|
||||
}
|
||||
|
||||
@@ -1822,6 +1988,7 @@ export interface AppApi<E = never> {
|
||||
readonly skill: SkillApi<E>
|
||||
readonly event: EventApi<E>
|
||||
readonly pty: PtyApi<E>
|
||||
readonly experimental: ExperimentalApi<E>
|
||||
readonly shell: ShellApi<E>
|
||||
readonly reference: ReferenceApi<E>
|
||||
readonly worktree: WorktreeApi<E>
|
||||
|
||||
@@ -141,6 +141,8 @@ import type {
|
||||
CredentialRemoveInput,
|
||||
CredentialRemoveOutput,
|
||||
ProjectListOutput,
|
||||
ProjectUpdateInput,
|
||||
ProjectUpdateOutput,
|
||||
ProjectCurrentInput,
|
||||
ProjectCurrentOutput,
|
||||
FormRequestListInput,
|
||||
@@ -192,6 +194,21 @@ import type {
|
||||
PtyRemoveOutput,
|
||||
PtyConnectTokenInput,
|
||||
PtyConnectTokenOutput,
|
||||
ExperimentalPersistentPtyListInput,
|
||||
ExperimentalPersistentPtyListOutput,
|
||||
ExperimentalPersistentPtyCreateInput,
|
||||
ExperimentalPersistentPtyCreateOutput,
|
||||
ExperimentalPersistentPtyShutdownOutput,
|
||||
ExperimentalPersistentPtyGetInput,
|
||||
ExperimentalPersistentPtyGetOutput,
|
||||
ExperimentalPersistentPtyUpdateInput,
|
||||
ExperimentalPersistentPtyUpdateOutput,
|
||||
ExperimentalPersistentPtySnapshotInput,
|
||||
ExperimentalPersistentPtySnapshotOutput,
|
||||
ExperimentalPersistentPtyRemoveInput,
|
||||
ExperimentalPersistentPtyRemoveOutput,
|
||||
ExperimentalPersistentPtyConnectTokenInput,
|
||||
ExperimentalPersistentPtyConnectTokenOutput,
|
||||
ShellListInput,
|
||||
ShellListOutput,
|
||||
ShellCreateInput,
|
||||
@@ -222,6 +239,8 @@ import type {
|
||||
VcsGetOutput,
|
||||
VcsStatusInput,
|
||||
VcsStatusOutput,
|
||||
VcsBranchesInput,
|
||||
VcsBranchesOutput,
|
||||
VcsDiffInput,
|
||||
VcsDiffOutput,
|
||||
DebugLocationListOutput,
|
||||
@@ -932,6 +951,14 @@ const adaptGroupCredential = (raw: RawClient["server.credential"]) => ({
|
||||
const EndpointProjectList = (raw: RawClient["server.project"]) => () =>
|
||||
preserveEffect<ProjectListOutput>()(raw["project.list"]({}).pipe(Effect.mapError(mapClientError)))
|
||||
|
||||
const EndpointProjectUpdate = (raw: RawClient["server.project"]) => (input: ProjectUpdateInput) =>
|
||||
preserveEffect<ProjectUpdateOutput>()(
|
||||
raw["project.update"]({
|
||||
params: { projectID: input["projectID"] },
|
||||
payload: { name: input["name"], icon: input["icon"], commands: input["commands"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const EndpointProjectCurrent = (raw: RawClient["server.project"]) => (input?: ProjectCurrentInput) =>
|
||||
preserveEffect<ProjectCurrentOutput>()(
|
||||
raw["project.current"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
@@ -939,6 +966,7 @@ const EndpointProjectCurrent = (raw: RawClient["server.project"]) => (input?: Pr
|
||||
|
||||
const adaptGroupProject = (raw: RawClient["server.project"]) => ({
|
||||
list: EndpointProjectList(raw),
|
||||
update: EndpointProjectUpdate(raw),
|
||||
current: EndpointProjectCurrent(raw),
|
||||
})
|
||||
|
||||
@@ -1179,6 +1207,100 @@ const adaptGroupPty = (raw: RawClient["server.pty"]) => ({
|
||||
connect: { token: EndpointPtyConnectToken(raw) },
|
||||
})
|
||||
|
||||
const EndpointExperimentalPersistentPtyList =
|
||||
(raw: RawClient["server.experimental"]) => (input: ExperimentalPersistentPtyListInput) =>
|
||||
preserveEffect<ExperimentalPersistentPtyListOutput>()(
|
||||
raw["persistentPty.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const EndpointExperimentalPersistentPtyCreate =
|
||||
(raw: RawClient["server.experimental"]) => (input: ExperimentalPersistentPtyCreateInput) =>
|
||||
preserveEffect<ExperimentalPersistentPtyCreateOutput>()(
|
||||
raw["persistentPty.create"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: {
|
||||
command: input["command"],
|
||||
args: input["args"],
|
||||
cwd: input["cwd"],
|
||||
title: input["title"],
|
||||
env: input["env"],
|
||||
size: input["size"],
|
||||
},
|
||||
}).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const EndpointExperimentalPersistentPtyShutdown = (raw: RawClient["server.experimental"]) => () =>
|
||||
preserveEffect<ExperimentalPersistentPtyShutdownOutput>()(
|
||||
raw["persistentPty.shutdown"]({}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const EndpointExperimentalPersistentPtyGet =
|
||||
(raw: RawClient["server.experimental"]) => (input: ExperimentalPersistentPtyGetInput) =>
|
||||
preserveEffect<ExperimentalPersistentPtyGetOutput>()(
|
||||
raw["persistentPty.get"]({ params: { ptyID: input["ptyID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const EndpointExperimentalPersistentPtyUpdate =
|
||||
(raw: RawClient["server.experimental"]) => (input: ExperimentalPersistentPtyUpdateInput) =>
|
||||
preserveEffect<ExperimentalPersistentPtyUpdateOutput>()(
|
||||
raw["persistentPty.update"]({
|
||||
params: { ptyID: input["ptyID"] },
|
||||
payload: { attachmentID: input["attachmentID"], size: input["size"] },
|
||||
}).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const EndpointExperimentalPersistentPtySnapshot =
|
||||
(raw: RawClient["server.experimental"]) => (input: ExperimentalPersistentPtySnapshotInput) =>
|
||||
preserveEffect<ExperimentalPersistentPtySnapshotOutput>()(
|
||||
raw["persistentPty.snapshot"]({ params: { ptyID: input["ptyID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const EndpointExperimentalPersistentPtyRemove =
|
||||
(raw: RawClient["server.experimental"]) => (input: ExperimentalPersistentPtyRemoveInput) =>
|
||||
preserveEffect<ExperimentalPersistentPtyRemoveOutput>()(
|
||||
raw["persistentPty.remove"]({ params: { ptyID: input["ptyID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const EndpointExperimentalPersistentPtyConnectToken =
|
||||
(raw: RawClient["server.experimental"]) => (input: ExperimentalPersistentPtyConnectTokenInput) =>
|
||||
preserveEffect<ExperimentalPersistentPtyConnectTokenOutput>()(
|
||||
raw["persistentPty.connectToken"]({
|
||||
params: { ptyID: input["ptyID"] },
|
||||
headers: { "x-opencode-ticket": input["x-opencode-ticket"] },
|
||||
}).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const adaptGroupExperimental = (raw: RawClient["server.experimental"]) => ({
|
||||
persistentPty: {
|
||||
list: EndpointExperimentalPersistentPtyList(raw),
|
||||
create: EndpointExperimentalPersistentPtyCreate(raw),
|
||||
shutdown: EndpointExperimentalPersistentPtyShutdown(raw),
|
||||
get: EndpointExperimentalPersistentPtyGet(raw),
|
||||
update: EndpointExperimentalPersistentPtyUpdate(raw),
|
||||
snapshot: EndpointExperimentalPersistentPtySnapshot(raw),
|
||||
remove: EndpointExperimentalPersistentPtyRemove(raw),
|
||||
connectToken: EndpointExperimentalPersistentPtyConnectToken(raw),
|
||||
},
|
||||
})
|
||||
|
||||
const EndpointShellList = (raw: RawClient["server.shell"]) => (input?: ShellListInput) =>
|
||||
preserveEffect<ShellListOutput>()(
|
||||
raw["shell.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
@@ -1248,7 +1370,13 @@ const EndpointWorktreeCreate = (raw: RawClient["server.worktree"]) => (input: Wo
|
||||
preserveEffect<WorktreeCreateOutput>()(
|
||||
raw["worktree.create"]({
|
||||
params: { projectID: input["projectID"] },
|
||||
payload: { strategy: input["strategy"], from: input["from"], directory: input["directory"], name: input["name"] },
|
||||
payload: {
|
||||
strategy: input["strategy"],
|
||||
from: input["from"],
|
||||
branch: input["branch"],
|
||||
directory: input["directory"],
|
||||
name: input["name"],
|
||||
},
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
@@ -1300,6 +1428,13 @@ const EndpointVcsStatus = (raw: RawClient["server.vcs"]) => (input?: VcsStatusIn
|
||||
raw["vcs.status"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const EndpointVcsBranches = (raw: RawClient["server.vcs"]) => (input?: VcsBranchesInput) =>
|
||||
preserveEffect<VcsBranchesOutput>()(
|
||||
raw["vcs.branches"]({
|
||||
query: { location: input?.["location"], search: input?.["search"], limit: input?.["limit"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const EndpointVcsDiff = (raw: RawClient["server.vcs"]) => (input: VcsDiffInput) =>
|
||||
preserveEffect<VcsDiffOutput>()(
|
||||
raw["vcs.diff"]({ query: { location: input["location"], mode: input["mode"], context: input["context"] } }).pipe(
|
||||
@@ -1310,6 +1445,7 @@ const EndpointVcsDiff = (raw: RawClient["server.vcs"]) => (input: VcsDiffInput)
|
||||
const adaptGroupVcs = (raw: RawClient["server.vcs"]) => ({
|
||||
get: EndpointVcsGet(raw),
|
||||
status: EndpointVcsStatus(raw),
|
||||
branches: EndpointVcsBranches(raw),
|
||||
diff: EndpointVcsDiff(raw),
|
||||
})
|
||||
|
||||
@@ -1377,6 +1513,7 @@ const adaptClient = (raw: RawClient) => ({
|
||||
skill: adaptGroupSkill(raw["server.skill"]),
|
||||
event: adaptGroupEvent(raw["server.event"]),
|
||||
pty: adaptGroupPty(raw["server.pty"]),
|
||||
experimental: adaptGroupExperimental(raw["server.experimental"]),
|
||||
shell: adaptGroupShell(raw["server.shell"]),
|
||||
reference: adaptGroupReference(raw["server.reference"]),
|
||||
worktree: adaptGroupWorktree(raw["server.worktree"]),
|
||||
|
||||
@@ -4,15 +4,18 @@ export type AgentApi = Client["agent"]
|
||||
export type CommandApi = Client["command"]
|
||||
export type ConfigApi = Client["config"]
|
||||
export type EventApi = Client["event"]
|
||||
export type GenerateApi = Client["generate"]
|
||||
export type IntegrationApi = Client["integration"]
|
||||
export type McpApi = Client["mcp"]
|
||||
export type ModelApi = Client["model"]
|
||||
export type PluginApi = Client["plugin"]
|
||||
export type PermissionApi = Client["permission"]
|
||||
export type ProviderApi = Client["provider"]
|
||||
export type ReferenceApi = Client["reference"]
|
||||
export type WebSearchApi = Client["websearch"]
|
||||
export type SessionApi = Client["session"]
|
||||
export type SkillApi = Client["skill"]
|
||||
export type VcsApi = Client["vcs"]
|
||||
|
||||
export interface CatalogApi {
|
||||
readonly provider: ProviderApi
|
||||
|
||||
@@ -135,6 +135,8 @@ import type {
|
||||
CredentialRemoveInput,
|
||||
CredentialRemoveOutput,
|
||||
ProjectListOutput,
|
||||
ProjectUpdateInput,
|
||||
ProjectUpdateOutput,
|
||||
ProjectCurrentInput,
|
||||
ProjectCurrentOutput,
|
||||
FormRequestListInput,
|
||||
@@ -188,6 +190,21 @@ import type {
|
||||
PtyRemoveOutput,
|
||||
PtyConnectTokenInput,
|
||||
PtyConnectTokenOutput,
|
||||
ExperimentalPersistentPtyListInput,
|
||||
ExperimentalPersistentPtyListOutput,
|
||||
ExperimentalPersistentPtyCreateInput,
|
||||
ExperimentalPersistentPtyCreateOutput,
|
||||
ExperimentalPersistentPtyShutdownOutput,
|
||||
ExperimentalPersistentPtyGetInput,
|
||||
ExperimentalPersistentPtyGetOutput,
|
||||
ExperimentalPersistentPtyUpdateInput,
|
||||
ExperimentalPersistentPtyUpdateOutput,
|
||||
ExperimentalPersistentPtySnapshotInput,
|
||||
ExperimentalPersistentPtySnapshotOutput,
|
||||
ExperimentalPersistentPtyRemoveInput,
|
||||
ExperimentalPersistentPtyRemoveOutput,
|
||||
ExperimentalPersistentPtyConnectTokenInput,
|
||||
ExperimentalPersistentPtyConnectTokenOutput,
|
||||
ShellListInput,
|
||||
ShellListOutput,
|
||||
ShellCreateInput,
|
||||
@@ -218,6 +235,8 @@ import type {
|
||||
VcsGetOutput,
|
||||
VcsStatusInput,
|
||||
VcsStatusOutput,
|
||||
VcsBranchesInput,
|
||||
VcsBranchesOutput,
|
||||
VcsDiffInput,
|
||||
VcsDiffOutput,
|
||||
DebugLocationListOutput,
|
||||
@@ -1270,6 +1289,18 @@ export function make(options: ClientOptions) {
|
||||
{ method: "GET", path: `/api/project`, successStatus: 200, declaredStatuses: [401, 400], empty: false },
|
||||
requestOptions,
|
||||
),
|
||||
update: (input: ProjectUpdateInput, requestOptions?: RequestOptions) =>
|
||||
request<ProjectUpdateOutput>(
|
||||
{
|
||||
method: "PATCH",
|
||||
path: `/api/project/${encodeURIComponent(input.projectID)}`,
|
||||
body: { name: input["name"], icon: input["icon"], commands: input["commands"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [404, 401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
current: (input?: ProjectCurrentInput, requestOptions?: RequestOptions) =>
|
||||
request<ProjectCurrentOutput>(
|
||||
{
|
||||
@@ -1621,6 +1652,108 @@ export function make(options: ClientOptions) {
|
||||
),
|
||||
},
|
||||
},
|
||||
experimental: {
|
||||
persistentPty: {
|
||||
list: (input: ExperimentalPersistentPtyListInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: ExperimentalPersistentPtyListOutput }>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/experimental/session/${encodeURIComponent(input.sessionID)}/terminal`,
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 503, 401],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
create: (input: ExperimentalPersistentPtyCreateInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: ExperimentalPersistentPtyCreateOutput }>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/experimental/session/${encodeURIComponent(input.sessionID)}/terminal`,
|
||||
body: {
|
||||
command: input["command"],
|
||||
args: input["args"],
|
||||
cwd: input["cwd"],
|
||||
title: input["title"],
|
||||
env: input["env"],
|
||||
size: input["size"],
|
||||
},
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 503, 401],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
shutdown: (requestOptions?: RequestOptions) =>
|
||||
request<ExperimentalPersistentPtyShutdownOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/experimental/persistent-pty/shutdown`,
|
||||
successStatus: 204,
|
||||
declaredStatuses: [503, 401, 400],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
get: (input: ExperimentalPersistentPtyGetInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: ExperimentalPersistentPtyGetOutput }>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/experimental/persistent-pty/${encodeURIComponent(input.ptyID)}`,
|
||||
successStatus: 200,
|
||||
declaredStatuses: [404, 503, 401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
update: (input: ExperimentalPersistentPtyUpdateInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: ExperimentalPersistentPtyUpdateOutput }>(
|
||||
{
|
||||
method: "PUT",
|
||||
path: `/api/experimental/persistent-pty/${encodeURIComponent(input.ptyID)}`,
|
||||
body: { attachmentID: input["attachmentID"], size: input["size"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [404, 503, 401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
snapshot: (input: ExperimentalPersistentPtySnapshotInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: ExperimentalPersistentPtySnapshotOutput }>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/experimental/persistent-pty/${encodeURIComponent(input.ptyID)}/snapshot`,
|
||||
successStatus: 200,
|
||||
declaredStatuses: [404, 503, 401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
remove: (input: ExperimentalPersistentPtyRemoveInput, requestOptions?: RequestOptions) =>
|
||||
request<ExperimentalPersistentPtyRemoveOutput>(
|
||||
{
|
||||
method: "DELETE",
|
||||
path: `/api/experimental/persistent-pty/${encodeURIComponent(input.ptyID)}`,
|
||||
successStatus: 204,
|
||||
declaredStatuses: [404, 503, 401, 400],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
connectToken: (input: ExperimentalPersistentPtyConnectTokenInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: ExperimentalPersistentPtyConnectTokenOutput }>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/experimental/persistent-pty/${encodeURIComponent(input.ptyID)}/connect-token`,
|
||||
headers: { "x-opencode-ticket": input["x-opencode-ticket"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [403, 404, 503, 401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
},
|
||||
},
|
||||
shell: {
|
||||
list: (input?: ShellListInput, requestOptions?: RequestOptions) =>
|
||||
request<ShellListOutput>(
|
||||
@@ -1736,6 +1869,7 @@ export function make(options: ClientOptions) {
|
||||
body: {
|
||||
strategy: input["strategy"],
|
||||
from: input["from"],
|
||||
branch: input["branch"],
|
||||
directory: input["directory"],
|
||||
name: input["name"],
|
||||
},
|
||||
@@ -1819,6 +1953,18 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
branches: (input?: VcsBranchesInput, requestOptions?: RequestOptions) =>
|
||||
request<VcsBranchesOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/vcs/branches`,
|
||||
query: { location: input?.["location"], search: input?.["search"], limit: input?.["limit"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
diff: (input: VcsDiffInput, requestOptions?: RequestOptions) =>
|
||||
request<VcsDiffOutput>(
|
||||
{
|
||||
|
||||
@@ -338,6 +338,21 @@ export type Pty = {
|
||||
exitCode?: number
|
||||
}
|
||||
|
||||
export type PersistentPtyInfo = {
|
||||
id: string
|
||||
title: string
|
||||
command: string
|
||||
args: Array<string>
|
||||
cwd: string
|
||||
status: "running" | "exited"
|
||||
pid: number
|
||||
exitCode?: number
|
||||
sessionID: string
|
||||
foregroundProcess: string | null
|
||||
size: { cols: number; rows: number }
|
||||
output: { head: number; tail: number }
|
||||
}
|
||||
|
||||
export type FormMetadata1 = { [x: string]: any }
|
||||
|
||||
export type FormWhen1 = { key: string; op: "eq" | "neq"; value: string | number | boolean }
|
||||
@@ -393,6 +408,8 @@ export type VcsFileStatus = {
|
||||
status: "added" | "deleted" | "modified"
|
||||
}
|
||||
|
||||
export type VcsBranchList = Array<string>
|
||||
|
||||
export type WebSearchProvider = { id: string; name: string }
|
||||
|
||||
export type WebSearchResult = { url: string; title?: string; content?: string; time: { published?: number } }
|
||||
@@ -996,6 +1013,15 @@ export type PtyDeleted = {
|
||||
data: { id: string }
|
||||
}
|
||||
|
||||
export type PersistentPtyRemoved = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "persistent-pty.removed"
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; ptyID: string }
|
||||
}
|
||||
|
||||
export type ShellExited = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -1286,6 +1312,7 @@ export type ModelCompatibility = {
|
||||
reasoningField?: ModelReasoningField
|
||||
maxTokensField?: ModelMaxTokensField
|
||||
requireFinishReason?: boolean
|
||||
requireAssistantAfterTool?: boolean
|
||||
}
|
||||
|
||||
export type ModelCost = {
|
||||
@@ -1391,6 +1418,7 @@ export type PermissionRequest = {
|
||||
save?: Array<string>
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
source?: PermissionSource
|
||||
message?: string
|
||||
}
|
||||
|
||||
export type PermissionAsked = {
|
||||
@@ -1407,6 +1435,7 @@ export type PermissionAsked = {
|
||||
save?: Array<string>
|
||||
metadata?: { [x: string]: any }
|
||||
source?: PermissionSource
|
||||
message?: string
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1437,6 +1466,22 @@ export type PtyUpdated = {
|
||||
data: { info: Pty }
|
||||
}
|
||||
|
||||
export type PersistentPtyAdded = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "persistent-pty.added"
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; terminal: PersistentPtyInfo }
|
||||
}
|
||||
|
||||
export type PersistentPtySnapshot = {
|
||||
info: PersistentPtyInfo
|
||||
text: string
|
||||
checkpoint: string
|
||||
cursor: { x: number; y: number }
|
||||
}
|
||||
|
||||
export type FormStringField1 = {
|
||||
key: string
|
||||
title?: string
|
||||
@@ -2131,6 +2176,8 @@ export type V2Event =
|
||||
| PtyUpdated
|
||||
| PtyExited
|
||||
| PtyDeleted
|
||||
| PersistentPtyAdded
|
||||
| PersistentPtyRemoved
|
||||
| ShellCreated
|
||||
| ShellExited
|
||||
| ShellDeleted
|
||||
@@ -2277,6 +2324,14 @@ export type McpServerNotFoundError = {
|
||||
export const isMcpServerNotFoundError = (value: unknown): value is McpServerNotFoundError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "McpServerNotFoundError"
|
||||
|
||||
export type ProjectNotFoundError = {
|
||||
readonly _tag: "ProjectNotFoundError"
|
||||
readonly projectID: string
|
||||
readonly message: string
|
||||
}
|
||||
export const isProjectNotFoundError = (value: unknown): value is ProjectNotFoundError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ProjectNotFoundError"
|
||||
|
||||
export type FormNotFoundError = { readonly _tag: "FormNotFoundError"; readonly id: string; readonly message: string }
|
||||
export const isFormNotFoundError = (value: unknown): value is FormNotFoundError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "FormNotFoundError"
|
||||
@@ -4308,6 +4363,27 @@ export type CredentialRemoveOutput = void
|
||||
|
||||
export type ProjectListOutput = Array<Project>
|
||||
|
||||
export type ProjectUpdateInput = {
|
||||
readonly projectID: { readonly projectID: string }["projectID"]
|
||||
readonly name?: {
|
||||
readonly name?: string
|
||||
readonly icon?: { readonly url?: string; readonly override?: string; readonly color?: string }
|
||||
readonly commands?: { readonly start?: string }
|
||||
}["name"]
|
||||
readonly icon?: {
|
||||
readonly name?: string
|
||||
readonly icon?: { readonly url?: string; readonly override?: string; readonly color?: string }
|
||||
readonly commands?: { readonly start?: string }
|
||||
}["icon"]
|
||||
readonly commands?: {
|
||||
readonly name?: string
|
||||
readonly icon?: { readonly url?: string; readonly override?: string; readonly color?: string }
|
||||
readonly commands?: { readonly start?: string }
|
||||
}["commands"]
|
||||
}
|
||||
|
||||
export type ProjectUpdateOutput = Project
|
||||
|
||||
export type ProjectCurrentInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
@@ -5469,6 +5545,99 @@ export type PtyConnectTokenOutput = {
|
||||
data: PtyTicketConnectToken
|
||||
}
|
||||
|
||||
export type ExperimentalPersistentPtyListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
|
||||
|
||||
export type ExperimentalPersistentPtyListOutput = { data: Array<PersistentPtyInfo> }["data"]
|
||||
|
||||
export type ExperimentalPersistentPtyCreateInput = {
|
||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||
readonly command: {
|
||||
readonly command: string
|
||||
readonly args: ReadonlyArray<string>
|
||||
readonly cwd: string
|
||||
readonly title: string
|
||||
readonly env: { readonly [x: string]: string }
|
||||
readonly size?: { readonly cols: number; readonly rows: number }
|
||||
}["command"]
|
||||
readonly args: {
|
||||
readonly command: string
|
||||
readonly args: ReadonlyArray<string>
|
||||
readonly cwd: string
|
||||
readonly title: string
|
||||
readonly env: { readonly [x: string]: string }
|
||||
readonly size?: { readonly cols: number; readonly rows: number }
|
||||
}["args"]
|
||||
readonly cwd: {
|
||||
readonly command: string
|
||||
readonly args: ReadonlyArray<string>
|
||||
readonly cwd: string
|
||||
readonly title: string
|
||||
readonly env: { readonly [x: string]: string }
|
||||
readonly size?: { readonly cols: number; readonly rows: number }
|
||||
}["cwd"]
|
||||
readonly title: {
|
||||
readonly command: string
|
||||
readonly args: ReadonlyArray<string>
|
||||
readonly cwd: string
|
||||
readonly title: string
|
||||
readonly env: { readonly [x: string]: string }
|
||||
readonly size?: { readonly cols: number; readonly rows: number }
|
||||
}["title"]
|
||||
readonly env: {
|
||||
readonly command: string
|
||||
readonly args: ReadonlyArray<string>
|
||||
readonly cwd: string
|
||||
readonly title: string
|
||||
readonly env: { readonly [x: string]: string }
|
||||
readonly size?: { readonly cols: number; readonly rows: number }
|
||||
}["env"]
|
||||
readonly size?: {
|
||||
readonly command: string
|
||||
readonly args: ReadonlyArray<string>
|
||||
readonly cwd: string
|
||||
readonly title: string
|
||||
readonly env: { readonly [x: string]: string }
|
||||
readonly size?: { readonly cols: number; readonly rows: number }
|
||||
}["size"]
|
||||
}
|
||||
|
||||
export type ExperimentalPersistentPtyCreateOutput = { data: PersistentPtyInfo }["data"]
|
||||
|
||||
export type ExperimentalPersistentPtyShutdownOutput = void
|
||||
|
||||
export type ExperimentalPersistentPtyGetInput = { readonly ptyID: { readonly ptyID: string }["ptyID"] }
|
||||
|
||||
export type ExperimentalPersistentPtyGetOutput = { data: PersistentPtyInfo }["data"]
|
||||
|
||||
export type ExperimentalPersistentPtyUpdateInput = {
|
||||
readonly ptyID: { readonly ptyID: string }["ptyID"]
|
||||
readonly attachmentID?: {
|
||||
readonly attachmentID?: string
|
||||
readonly size: { readonly cols: number; readonly rows: number }
|
||||
}["attachmentID"]
|
||||
readonly size: {
|
||||
readonly attachmentID?: string
|
||||
readonly size: { readonly cols: number; readonly rows: number }
|
||||
}["size"]
|
||||
}
|
||||
|
||||
export type ExperimentalPersistentPtyUpdateOutput = { data: PersistentPtyInfo }["data"]
|
||||
|
||||
export type ExperimentalPersistentPtySnapshotInput = { readonly ptyID: { readonly ptyID: string }["ptyID"] }
|
||||
|
||||
export type ExperimentalPersistentPtySnapshotOutput = { data: PersistentPtySnapshot }["data"]
|
||||
|
||||
export type ExperimentalPersistentPtyRemoveInput = { readonly ptyID: { readonly ptyID: string }["ptyID"] }
|
||||
|
||||
export type ExperimentalPersistentPtyRemoveOutput = void
|
||||
|
||||
export type ExperimentalPersistentPtyConnectTokenInput = {
|
||||
readonly ptyID: { readonly ptyID: string }["ptyID"]
|
||||
readonly "x-opencode-ticket"?: { readonly "x-opencode-ticket"?: string | undefined }["x-opencode-ticket"]
|
||||
}
|
||||
|
||||
export type ExperimentalPersistentPtyConnectTokenOutput = { data: PtyTicketConnectToken }["data"]
|
||||
|
||||
export type ShellListInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
@@ -5593,24 +5762,35 @@ export type WorktreeCreateInput = {
|
||||
readonly strategy: {
|
||||
readonly strategy: string
|
||||
readonly from?: string
|
||||
readonly branch?: string
|
||||
readonly directory: string
|
||||
readonly name?: string
|
||||
}["strategy"]
|
||||
readonly from?: {
|
||||
readonly strategy: string
|
||||
readonly from?: string
|
||||
readonly branch?: string
|
||||
readonly directory: string
|
||||
readonly name?: string
|
||||
}["from"]
|
||||
readonly branch?: {
|
||||
readonly strategy: string
|
||||
readonly from?: string
|
||||
readonly branch?: string
|
||||
readonly directory: string
|
||||
readonly name?: string
|
||||
}["branch"]
|
||||
readonly directory: {
|
||||
readonly strategy: string
|
||||
readonly from?: string
|
||||
readonly branch?: string
|
||||
readonly directory: string
|
||||
readonly name?: string
|
||||
}["directory"]
|
||||
readonly name?: {
|
||||
readonly strategy: string
|
||||
readonly from?: string
|
||||
readonly branch?: string
|
||||
readonly directory: string
|
||||
readonly name?: string
|
||||
}["name"]
|
||||
@@ -5663,6 +5843,29 @@ export type VcsStatusOutput = {
|
||||
data: Array<VcsFileStatus>
|
||||
}
|
||||
|
||||
export type VcsBranchesInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly search?: string | undefined
|
||||
readonly limit?: number | undefined
|
||||
}["location"]
|
||||
readonly search?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly search?: string | undefined
|
||||
readonly limit?: number | undefined
|
||||
}["search"]
|
||||
readonly limit?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly search?: string | undefined
|
||||
readonly limit?: number | undefined
|
||||
}["limit"]
|
||||
}
|
||||
|
||||
export type VcsBranchesOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
data: VcsBranchList
|
||||
}
|
||||
|
||||
export type VcsDiffInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
|
||||
@@ -27,7 +27,6 @@ test("exposes every standard HTTP API group", () => {
|
||||
"event",
|
||||
"pty",
|
||||
"shell",
|
||||
"question",
|
||||
"reference",
|
||||
"worktree",
|
||||
"workspace",
|
||||
@@ -47,11 +46,11 @@ test("exposes every standard HTTP API group", () => {
|
||||
expect(Object.keys(client.integration.command)).toEqual(["connect", "status", "cancel"])
|
||||
expect(Object.keys(client.websearch)).toEqual(["providers", "query"])
|
||||
expect(Object.keys(client.file)).toEqual(["read", "list", "find"])
|
||||
expect(Object.keys(client.vcs)).toEqual(["get", "status", "diff"])
|
||||
expect(Object.keys(client.vcs)).toEqual(["get", "status", "branches", "diff"])
|
||||
expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove", "connect"])
|
||||
expect(Object.keys(client.pty.connect)).toEqual(["token"])
|
||||
expect(Object.keys(client.shell)).toEqual(["list", "create", "get", "timeout", "output", "remove"])
|
||||
expect(Object.keys(client.project)).toEqual(["list", "current"])
|
||||
expect(Object.keys(client.project)).toEqual(["list", "update", "current"])
|
||||
expect(Object.keys(client.worktree)).toEqual(["list", "create", "remove", "refresh"])
|
||||
})
|
||||
|
||||
@@ -82,6 +81,29 @@ test("config.get returns ordered config entries for a location", async () => {
|
||||
expect(request?.url).toBe("http://localhost:3000/api/config?location%5Bdirectory%5D=%2Ftmp%2Fproject")
|
||||
})
|
||||
|
||||
test("project.update uses the global project contract", async () => {
|
||||
let request: Request | undefined
|
||||
const project = {
|
||||
id: "proj_test",
|
||||
canonical: "/tmp/project",
|
||||
commands: { start: "bun install" },
|
||||
time: { created: 1, updated: 2 },
|
||||
sandboxes: [],
|
||||
}
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: async (input, init) => {
|
||||
request = input instanceof Request ? input : new Request(input, init)
|
||||
return Response.json(project)
|
||||
},
|
||||
})
|
||||
|
||||
expect(await client.project.update({ projectID: "proj_test", commands: { start: "bun install" } })).toEqual(project)
|
||||
expect(request?.method).toBe("PATCH")
|
||||
expect(request?.url).toBe("http://localhost:3000/api/project/proj_test")
|
||||
expect(await request?.json()).toEqual({ commands: { start: "bun install" } })
|
||||
})
|
||||
|
||||
test("generate.text uses the locationless public contract", async () => {
|
||||
let request: Request | undefined
|
||||
const client = OpenCode.make({
|
||||
|
||||
@@ -41,6 +41,12 @@
|
||||
"node": "./src/pty/pty.node.ts",
|
||||
"default": "./src/pty/pty.bun.ts"
|
||||
},
|
||||
"#persistent-pty-binary": {
|
||||
"workerd": "./src/persistent-pty/binary.workerd.ts",
|
||||
"bun": "./src/persistent-pty/binary.bun.ts",
|
||||
"node": "./src/persistent-pty/binary.node.ts",
|
||||
"default": "./src/persistent-pty/binary.bun.ts"
|
||||
},
|
||||
"#fff": {
|
||||
"workerd": "./src/filesystem/fff.workerd.ts",
|
||||
"bun": "./src/filesystem/fff.bun.ts",
|
||||
|
||||
@@ -176,7 +176,7 @@ function evaluateTemplate(
|
||||
: withArguments.trim()
|
||||
const matches = Array.from(text.matchAll(shellRegex))
|
||||
if (matches.length === 0) return text
|
||||
const shell = yield* services.shell.preferred()
|
||||
const shell = yield* services.shell.resolve({ priority: "config" })
|
||||
const outputs = yield* Effect.forEach(
|
||||
matches,
|
||||
(match) => {
|
||||
|
||||
@@ -109,6 +109,7 @@ export interface Interface {
|
||||
readonly create: (input: {
|
||||
repository: Repository
|
||||
directory: AbsolutePath
|
||||
ref?: string
|
||||
}) => Effect.Effect<Repository, WorktreeError>
|
||||
readonly remove: (input: {
|
||||
repository: Repository
|
||||
@@ -644,11 +645,12 @@ const layer = Layer.effect(
|
||||
const worktreeCreate = Effect.fn("Git.worktree.create")(function* (input: {
|
||||
repository: Repository
|
||||
directory: AbsolutePath
|
||||
ref?: string
|
||||
}) {
|
||||
yield* worktreeRun(
|
||||
"create",
|
||||
input.repository,
|
||||
["worktree", "add", "--detach", input.directory, "HEAD"],
|
||||
["worktree", "add", "--detach", "--", input.directory, input.ref ?? "HEAD"],
|
||||
input.directory,
|
||||
)
|
||||
const repository = yield* discover(input.directory)
|
||||
|
||||
@@ -11,6 +11,7 @@ import { SessionSchema } from "./session/schema.js"
|
||||
import { SessionStore } from "./session/store.js"
|
||||
import { Wildcard } from "./util/wildcard.js"
|
||||
import { PermissionSaved } from "./permission/saved.js"
|
||||
import { PluginHooks } from "./plugin/hooks.js"
|
||||
|
||||
const PermissionEffect = Permission.Effect
|
||||
export { PermissionEffect as Effect }
|
||||
@@ -70,9 +71,10 @@ export class BlockedError extends Schema.TaggedError<BlockedError>()("Permission
|
||||
rules: Permission.Ruleset,
|
||||
permission: Schema.String,
|
||||
resources: Schema.Array(Schema.String),
|
||||
reason: Schema.String.pipe(Schema.optional),
|
||||
}) {
|
||||
override get message() {
|
||||
return `Permission denied: ${this.permission}`
|
||||
return this.reason ?? `Permission denied: ${this.permission}`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,11 +101,6 @@ export function merge(...rulesets: Permission.Ruleset[]): Permission.Ruleset {
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly allowsAll: (input: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly action: string
|
||||
readonly agent?: Agent.ID
|
||||
}) => Effect.Effect<boolean, SessionErrors.NotFoundError>
|
||||
readonly ask: (input: AssertInput) => Effect.Effect<AskResult, SessionErrors.NotFoundError>
|
||||
readonly assert: (input: AssertInput) => Effect.Effect<void, Error | SessionErrors.NotFoundError>
|
||||
readonly reply: (input: ReplyInput) => Effect.Effect<void, NotFoundError>
|
||||
@@ -128,6 +125,7 @@ const layer = Layer.effect(
|
||||
const agents = yield* Agent.Service
|
||||
const sessions = yield* SessionStore.Service
|
||||
const saved = yield* PermissionSaved.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const pending = new Map<ID, Pending>()
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
@@ -159,24 +157,6 @@ const layer = Layer.effect(
|
||||
return agent?.permissions ?? missingAgentPermissions
|
||||
})
|
||||
|
||||
const allowsAll = Effect.fnUntraced(function* (input: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly action: string
|
||||
readonly agent?: Agent.ID
|
||||
}) {
|
||||
const rules = yield* configured(input.sessionID, input.agent)
|
||||
const relevant = rules.filter((rule) => Wildcard.match(input.action, rule.action))
|
||||
for (let index = relevant.length - 1; index >= 0; index--) {
|
||||
const rule = relevant[index]
|
||||
if (rule.resource !== "*") {
|
||||
if (rule.effect !== "allow") return false
|
||||
continue
|
||||
}
|
||||
return rule.effect === "allow"
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
function denied(input: Pick<Request, "action" | "resources">, rules: Permission.Ruleset) {
|
||||
return input.resources.some((resource) => evaluate(input.action, resource, rules).effect === "deny")
|
||||
}
|
||||
@@ -191,10 +171,19 @@ const layer = Layer.effect(
|
||||
const all = [...rules, ...(yield* savedRules())]
|
||||
const effects = input.resources.map((resource) => evaluate(input.action, resource, all).effect)
|
||||
const effect: Permission.Effect = effects.includes("deny") ? "deny" : effects.includes("ask") ? "ask" : "allow"
|
||||
return { effect, rules: all }
|
||||
const event = yield* hooks.trigger("permission", "evaluate", {
|
||||
sessionID: input.sessionID,
|
||||
agent: input.agent,
|
||||
action: input.action,
|
||||
resources: input.resources,
|
||||
metadata: input.metadata,
|
||||
source: input.source,
|
||||
effect,
|
||||
})
|
||||
return { effect: event.effect, message: event.message, rules: all }
|
||||
})
|
||||
|
||||
function request(input: AssertInput): Request {
|
||||
function request(input: AssertInput, message?: string): Request {
|
||||
return {
|
||||
id: input.id ?? ID.create(),
|
||||
sessionID: input.sessionID,
|
||||
@@ -203,6 +192,7 @@ const layer = Layer.effect(
|
||||
save: input.save,
|
||||
metadata: input.metadata,
|
||||
source: input.source,
|
||||
message,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,39 +213,42 @@ const layer = Layer.effect(
|
||||
|
||||
const ask = Effect.fn("Permission.ask")(function* (input: AssertInput) {
|
||||
const result = yield* evaluateInput(input)
|
||||
const value = request(input)
|
||||
const value = request(input, result.message)
|
||||
if (result.effect === "ask") yield* create(value, input.agent)
|
||||
return { id: value.id, effect: result.effect }
|
||||
})
|
||||
|
||||
const assert = Effect.fn("Permission.assert")((input: AssertInput) =>
|
||||
Effect.uninterruptibleMask((restore) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* evaluateInput(input)
|
||||
if (result.effect === "deny") {
|
||||
return yield* new BlockedError({
|
||||
rules: relevant(input, result.rules),
|
||||
permission: input.action,
|
||||
resources: input.resources,
|
||||
})
|
||||
}
|
||||
if (result.effect === "allow") return
|
||||
const item = yield* create(request(input), input.agent)
|
||||
return yield* restore(Deferred.await(item.deferred)).pipe(
|
||||
// Deliberate defect tunnel: leaves wrap execution in blanket `mapError`, which
|
||||
// must not convert a user's decline into model-facing tool output. The decline
|
||||
// resurfaces as a typed failure at SessionModelRequest.executeTool. A decline
|
||||
// WITH feedback (CorrectedError) intentionally stays typed so the leaf can turn
|
||||
// it into ToolFailure and the model continues.
|
||||
Effect.catchTag("Permission.DeclinedError", (error) => Effect.die(error)),
|
||||
Effect.ensuring(
|
||||
Effect.sync(() => {
|
||||
pending.delete(item.request.id)
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
Effect.gen(function* () {
|
||||
const result = yield* evaluateInput(input)
|
||||
return yield* Effect.uninterruptibleMask((restore) =>
|
||||
Effect.gen(function* () {
|
||||
if (result.effect === "deny") {
|
||||
return yield* new BlockedError({
|
||||
rules: relevant(input, result.rules),
|
||||
permission: input.action,
|
||||
resources: input.resources,
|
||||
reason: result.message,
|
||||
})
|
||||
}
|
||||
if (result.effect === "allow") return
|
||||
const item = yield* create(request(input, result.message), input.agent)
|
||||
return yield* restore(Deferred.await(item.deferred)).pipe(
|
||||
// Deliberate defect tunnel: leaves wrap execution in blanket `mapError`, which
|
||||
// must not convert a user's decline into model-facing tool output. The decline
|
||||
// resurfaces as a typed failure at SessionModelRequest.executeTool. A decline
|
||||
// WITH feedback (CorrectedError) intentionally stays typed so the leaf can turn
|
||||
// it into ToolFailure and the model continues.
|
||||
Effect.catchTag("Permission.DeclinedError", (error) => Effect.die(error)),
|
||||
Effect.ensuring(
|
||||
Effect.sync(() => {
|
||||
pending.delete(item.request.id)
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
const reply = Effect.fn("Permission.reply")((input: ReplyInput) =>
|
||||
@@ -337,12 +330,12 @@ const layer = Layer.effect(
|
||||
return Array.from(pending.values(), (item) => item.request).filter((request) => request.sessionID === sessionID)
|
||||
})
|
||||
|
||||
return Service.of({ allowsAll, ask, assert, reply, get, forSession, list })
|
||||
return Service.of({ ask, assert, reply, get, forSession, list })
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [Bus.node, Location.node, Agent.node, SessionStore.node, PermissionSaved.node],
|
||||
deps: [Bus.node, Location.node, Agent.node, SessionStore.node, PermissionSaved.node, PluginHooks.node],
|
||||
})
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { PersistentPty } from "./persistent-pty/index.js"
|
||||
@@ -0,0 +1,82 @@
|
||||
import { createHash } from "node:crypto"
|
||||
import { chmod, lstat, mkdir, open, readFile, rename, rm } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import asset from "./pty-binding.js"
|
||||
|
||||
export async function resolveBinary(bin: string) {
|
||||
if (process.env.OPENCODE_PTY_BIN) return process.env.OPENCODE_PTY_BIN
|
||||
if (!asset) return "opencode-pty"
|
||||
return install(bin, asset)
|
||||
}
|
||||
|
||||
async function install(
|
||||
bin: string,
|
||||
input: { readonly path: string; readonly version: string; readonly sha256: string },
|
||||
) {
|
||||
const root = path.join(bin, "opencode-pty")
|
||||
await privateDirectory(root)
|
||||
const directory = path.join(root, `${input.version}-${input.sha256.slice(0, 16)}`)
|
||||
await privateDirectory(directory)
|
||||
const destination = path.join(directory, "opencode-pty")
|
||||
if (await exists(destination, input.sha256)) return destination
|
||||
|
||||
const bytes = new Uint8Array(await Bun.file(input.path).arrayBuffer())
|
||||
if (sha256(bytes) !== input.sha256) throw new Error("Embedded opencode-pty checksum mismatch")
|
||||
const temporary = path.join(directory, `opencode-pty.${process.pid}.${crypto.randomUUID()}.tmp`)
|
||||
try {
|
||||
const file = await open(temporary, "wx", 0o700)
|
||||
try {
|
||||
await file.writeFile(bytes)
|
||||
await file.sync()
|
||||
} finally {
|
||||
await file.close()
|
||||
}
|
||||
await chmod(temporary, 0o755)
|
||||
await rename(temporary, destination).catch(async (error) => {
|
||||
if (!(await exists(destination, input.sha256))) throw error
|
||||
})
|
||||
} finally {
|
||||
await rm(temporary, { force: true })
|
||||
}
|
||||
return validate(destination, input.sha256)
|
||||
}
|
||||
|
||||
async function privateDirectory(directory: string) {
|
||||
await mkdir(directory, { recursive: true, mode: 0o700 })
|
||||
const info = await lstat(directory)
|
||||
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error(`Unsafe opencode-pty directory: ${directory}`)
|
||||
const uid = typeof process.getuid === "function" ? process.getuid() : undefined
|
||||
if (uid !== undefined && info.uid !== uid)
|
||||
throw new Error(`opencode-pty directory is owned by another user: ${directory}`)
|
||||
await chmod(directory, 0o700)
|
||||
}
|
||||
|
||||
async function exists(file: string, expected: string) {
|
||||
try {
|
||||
await validate(file, expected)
|
||||
return true
|
||||
} catch (error) {
|
||||
if (isMissing(error)) return false
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function validate(file: string, expected?: string) {
|
||||
const info = await lstat(file)
|
||||
if (!info.isFile() || info.isSymbolicLink()) throw new Error(`Unsafe opencode-pty executable: ${file}`)
|
||||
const uid = typeof process.getuid === "function" ? process.getuid() : undefined
|
||||
if (uid !== undefined && info.uid !== uid)
|
||||
throw new Error(`opencode-pty executable is owned by another user: ${file}`)
|
||||
if (expected && sha256(await readFile(file)) !== expected)
|
||||
throw new Error(`Cached opencode-pty checksum mismatch: ${file}`)
|
||||
await chmod(file, 0o755)
|
||||
return file
|
||||
}
|
||||
|
||||
function sha256(bytes: Uint8Array) {
|
||||
return createHash("sha256").update(bytes).digest("hex")
|
||||
}
|
||||
|
||||
function isMissing(error: unknown): error is NodeJS.ErrnoException {
|
||||
return error instanceof Error && "code" in error && error.code === "ENOENT"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export async function resolveBinary() {
|
||||
return process.env.OPENCODE_PTY_BIN || "opencode-pty"
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user