mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-11-16 00:32:03 +08:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a15db5460c | |||
| 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!
|
||||
|
||||
|
||||
@@ -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:*",
|
||||
@@ -1610,6 +1611,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 +6135,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 +6961,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-rIg+QSwF0kmPRnlPrpV7IlPTrHAxvoBOjdtoFOcUnKc=",
|
||||
"aarch64-linux": "sha256-74rMnqiGy7gAUDWr9HWBc6Sn3kxh/hDfm5kp+I4fjok=",
|
||||
"aarch64-darwin": "sha256-ImASKYxUQDHzH/UuwXUBsMNkitAH5wDYESlevEy9qeA=",
|
||||
"x86_64-darwin": "sha256-txPhXfZOSoQdNXPfddKSdKqdjEWrb9GkBb38h+pZIuo="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -666,7 +666,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
||||
|
||||
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 } : {}),
|
||||
|
||||
@@ -659,7 +659,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"
|
||||
|
||||
@@ -207,7 +207,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({
|
||||
|
||||
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" })
|
||||
}),
|
||||
|
||||
@@ -192,6 +192,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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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"],
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -1189,6 +1196,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 +1641,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 +1699,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(
|
||||
@@ -3061,7 +3084,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 +3110,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 +3137,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,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" } },
|
||||
],
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -72,7 +72,7 @@ export function createHomeSessionsController(home: HomeController) {
|
||||
if (!ctx || !conn) return []
|
||||
const server = ServerConnection.key(conn)
|
||||
return retainHomeSessions(
|
||||
mergeHomeSessionIndex(sessionLoad.data?.() ?? [], ctx.data.session.list()).filter(
|
||||
mergeHomeSessionIndex(sessionLoad.isPending ? [] : (sessionLoad.data?.() ?? []), ctx.data.session.list()).filter(
|
||||
(session) => !removed.keys.includes(`${server}\0${session.id}`),
|
||||
),
|
||||
HOME_SESSION_LIMIT,
|
||||
@@ -256,7 +256,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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -1,15 +1,30 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { migrateSettings } from "./model"
|
||||
import { monoDefault, monoFontFamily, sansDefault, sansFontFamily, terminalFontFamily } from "./model"
|
||||
|
||||
describe("settings migration", () => {
|
||||
test("keeps steer for existing users without a follow-up preference", () => {
|
||||
expect(migrateSettings({ general: { autoSave: true } })).toEqual({
|
||||
general: { autoSave: true, followUpBehavior: "steer" },
|
||||
})
|
||||
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("preserves an explicit follow-up preference", () => {
|
||||
const settings = { general: { followUpBehavior: "queue" } }
|
||||
expect(migrateSettings(settings)).toBe(settings)
|
||||
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'
|
||||
|
||||
@@ -128,7 +128,7 @@ const defaultSettings: Settings = {
|
||||
showCustomAgents: false,
|
||||
mobileTitlebarPosition: "top",
|
||||
terminalPlacement: "side",
|
||||
followUpBehavior: "queue",
|
||||
followUpBehavior: "steer",
|
||||
},
|
||||
appearance: {
|
||||
fontSize: 14,
|
||||
@@ -167,10 +167,7 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
name: "Settings",
|
||||
gate: false,
|
||||
init: () => {
|
||||
const [store, setStore, , ready] = persisted(
|
||||
{ key: "settings.v3", migrate: migrateSettings },
|
||||
createStore<Settings>(defaultSettings),
|
||||
)
|
||||
const [store, setStore, , ready] = persisted("settings.v3", createStore<Settings>(defaultSettings))
|
||||
const showFileTree = withFallback(() => store.general?.showFileTree, defaultSettings.general.showFileTree)
|
||||
const showSearch = withFallback(() => store.general?.showSearch, defaultSettings.general.showSearch)
|
||||
const showStatus = withFallback(() => store.general?.showStatus, defaultSettings.general.showStatus)
|
||||
@@ -383,16 +380,3 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export function migrateSettings(value: unknown) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return value
|
||||
const settings = value as Partial<Settings>
|
||||
if (settings.general?.followUpBehavior) return value
|
||||
return {
|
||||
...settings,
|
||||
general: {
|
||||
...settings.general,
|
||||
followUpBehavior: "steer",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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) => {
|
||||
|
||||
@@ -207,7 +207,7 @@ export const GithubCopilotPlugin = define({
|
||||
} else {
|
||||
for (const id of item.models.keys()) {
|
||||
evt.model.update(item.provider.id, id, (model) => {
|
||||
model.package = "@ai-sdk/github-copilot"
|
||||
model.package = Provider.aisdk("@ai-sdk/github-copilot")
|
||||
if (loaded.baseURL) model.settings = Provider.mergeOverlay(model.settings, { baseURL: loaded.baseURL })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -164,7 +164,7 @@ const layer = () =>
|
||||
|
||||
const create = Effect.fn("Pty.create")(function* (input: CreateInput) {
|
||||
const id = PtyID.ascending()
|
||||
const command = input.command || (yield* shell.preferred())
|
||||
const command = input.command || (yield* shell.resolve({ priority: "config" }))
|
||||
const args = ShellSelect.login(command) ? [...(input.args ?? []), "-l"] : [...(input.args ?? [])]
|
||||
const cwd = input.cwd || location.directory
|
||||
const env = {
|
||||
|
||||
@@ -30,6 +30,9 @@ export const RETENTION = Duration.days(7)
|
||||
export const DIRECTORY = "shell"
|
||||
|
||||
type Info = Shell.Info
|
||||
type CreateInput = Shell.CreateInput & {
|
||||
shell?: string
|
||||
}
|
||||
|
||||
type Active = {
|
||||
// Immutable snapshot; lifecycle updates replace it via immer `produce`.
|
||||
@@ -52,9 +55,8 @@ type Active = {
|
||||
* here; callers (e.g. `ShellTool`) own that association and store the shell ID.
|
||||
*/
|
||||
export interface Interface {
|
||||
readonly name: () => Effect.Effect<string>
|
||||
readonly create: <E = never, R = never>(
|
||||
input: Shell.CreateInput,
|
||||
input: CreateInput,
|
||||
before?: (input: ShellCreateBefore) => Effect.Effect<void, E, R>,
|
||||
) => Effect.Effect<Shell.Info, E | AppProcess.AppProcessError, R>
|
||||
// Currently running commands only; exited shells are retained for get/output but excluded here.
|
||||
@@ -185,8 +187,6 @@ const layer = () =>
|
||||
return session.info
|
||||
})
|
||||
|
||||
const name = () => shell.preferred().pipe(Effect.map(ShellSelect.name))
|
||||
|
||||
const output = Effect.fnUntraced(function* (id: Shell.ID, input?: Shell.OutputInput) {
|
||||
const session = yield* require(id)
|
||||
const cursor = input?.cursor ?? 0
|
||||
@@ -218,7 +218,7 @@ const layer = () =>
|
||||
})
|
||||
|
||||
const create = Effect.fn("Shell.create")(function* <E = never, R = never>(
|
||||
input: Shell.CreateInput,
|
||||
input: CreateInput,
|
||||
before?: (input: ShellCreateBefore) => Effect.Effect<void, E, R>,
|
||||
) {
|
||||
const sessionID = input.metadata?.sessionID
|
||||
@@ -230,7 +230,7 @@ const layer = () =>
|
||||
command: input.command,
|
||||
cwd: input.cwd ?? location.directory,
|
||||
timeout: input.timeout,
|
||||
shell: yield* shell.preferred(),
|
||||
shell: input.shell ?? (yield* shell.resolve({ priority: "config" })),
|
||||
env: {
|
||||
...(sessionEnvironment ?? process.env),
|
||||
TERM: "xterm-256color",
|
||||
@@ -383,7 +383,7 @@ const layer = () =>
|
||||
return session.info
|
||||
})
|
||||
|
||||
return Service.of({ name, create, list, get, wait, timeout, output, remove })
|
||||
return Service.of({ create, list, get, wait, timeout, output, remove })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -41,8 +41,12 @@ export type Draft = {
|
||||
configure: (shell: string) => void
|
||||
}
|
||||
|
||||
export type ResolveInput = {
|
||||
priority: "config" | "compat"
|
||||
}
|
||||
|
||||
export interface Interface extends State.Transformable<Draft> {
|
||||
readonly preferred: () => Effect.Effect<string>
|
||||
readonly resolve: (input: ResolveInput) => Effect.Effect<string>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ShellSelect") {}
|
||||
@@ -70,7 +74,7 @@ function meta(file: string) {
|
||||
return META[name(file)]
|
||||
}
|
||||
|
||||
function ok(file: string) {
|
||||
function compatible(file: string) {
|
||||
return meta(file)?.deny !== true
|
||||
}
|
||||
|
||||
@@ -78,7 +82,7 @@ function rooted(file: string) {
|
||||
return path.isAbsolute(FSUtil.windowsPath(file))
|
||||
}
|
||||
|
||||
function resolve(file: string, options?: Options, bin?: string) {
|
||||
function executable(file: string, options?: Options, bin?: string) {
|
||||
const shell = full(file, options, bin)
|
||||
if (rooted(shell)) {
|
||||
if (stat(shell)?.isFile()) return shell
|
||||
@@ -108,9 +112,9 @@ async function unix() {
|
||||
return ["/bin/bash", "/bin/zsh", "/bin/sh"]
|
||||
}
|
||||
|
||||
function select(file: string | undefined, options?: Options, opts?: { acceptable?: boolean }, bin?: string) {
|
||||
if (file && (!opts?.acceptable || ok(file))) {
|
||||
const shell = resolve(file, options, bin)
|
||||
function select(file: string | undefined, options?: Options, opts?: { compatible?: boolean }, bin?: string) {
|
||||
if (file && (!opts?.compatible || compatible(file))) {
|
||||
const shell = executable(file, options, bin)
|
||||
if (shell) return shell
|
||||
}
|
||||
if (process.platform === "win32") return win(options, bin)[0]
|
||||
@@ -151,8 +155,8 @@ function info(file: string, options?: Options, bin?: string): Item {
|
||||
const n = name(item)
|
||||
return {
|
||||
path: item,
|
||||
name: resolve(n, options, bin) ? n : item,
|
||||
acceptable: ok(item),
|
||||
name: executable(n, options, bin) ? n : item,
|
||||
acceptable: compatible(item),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,38 +167,28 @@ export function args(file: string, command: string) {
|
||||
return ["-c", command]
|
||||
}
|
||||
|
||||
let defaultPreferred: { bin?: string; value: string } | undefined
|
||||
let defaultAcceptable: { bin?: string; value: string } | undefined
|
||||
let defaultConfigured: { bin?: string; value: string } | undefined
|
||||
let defaultCompatible: { bin?: string; value: string } | undefined
|
||||
|
||||
export function preferred(configShell?: string, options?: Options, bin?: string) {
|
||||
if (configShell) return select(configShell, options, undefined, bin)
|
||||
if (options?.gitbash) return select(process.env.SHELL, options, undefined, bin)
|
||||
const cached = defaultPreferred
|
||||
export function resolve(input: ResolveInput, configShell?: string, options?: Options, bin?: string) {
|
||||
const filter = input.priority === "compat" ? { compatible: true } : undefined
|
||||
if (configShell) return select(configShell, options, filter, bin)
|
||||
if (options?.gitbash) return select(process.env.SHELL, options, filter, bin)
|
||||
const cached = input.priority === "compat" ? defaultCompatible : defaultConfigured
|
||||
if (cached && cached.bin === bin) return cached.value
|
||||
const value = select(process.env.SHELL, undefined, undefined, bin) ?? fallback(bin)
|
||||
defaultPreferred = { bin, value }
|
||||
const value = select(process.env.SHELL, undefined, filter, bin) ?? fallback(bin)
|
||||
if (input.priority === "compat") defaultCompatible = { bin, value }
|
||||
if (input.priority === "config") defaultConfigured = { bin, value }
|
||||
return value
|
||||
}
|
||||
preferred.reset = () => {
|
||||
defaultPreferred = undefined
|
||||
}
|
||||
|
||||
export function acceptable(configShell?: string, options?: Options, bin?: string) {
|
||||
if (configShell) return select(configShell, options, { acceptable: true }, bin)
|
||||
if (options?.gitbash) return select(process.env.SHELL, options, { acceptable: true }, bin)
|
||||
const cached = defaultAcceptable
|
||||
if (cached && cached.bin === bin) return cached.value
|
||||
const value = select(process.env.SHELL, undefined, { acceptable: true }, bin) ?? fallback(bin)
|
||||
defaultAcceptable = { bin, value }
|
||||
return value
|
||||
}
|
||||
acceptable.reset = () => {
|
||||
defaultAcceptable = undefined
|
||||
resolve.reset = () => {
|
||||
defaultConfigured = undefined
|
||||
defaultCompatible = undefined
|
||||
}
|
||||
|
||||
export async function list(options?: Options, bin?: string): Promise<Item[]> {
|
||||
const shells = process.platform === "win32" ? win(options, bin) : await unix()
|
||||
return shells.filter((shell) => resolve(shell, options, bin)).map((shell) => info(shell, options, bin))
|
||||
return shells.filter((shell) => executable(shell, options, bin)).map((shell) => info(shell, options, bin))
|
||||
}
|
||||
|
||||
const layer = (options?: Options) =>
|
||||
@@ -214,7 +208,7 @@ const layer = (options?: Options) =>
|
||||
return Service.of({
|
||||
transform: state.transform,
|
||||
reload: state.reload,
|
||||
preferred: () => Effect.sync(() => preferred(state.get().shell, options, global.bin)),
|
||||
resolve: (input) => Effect.sync(() => resolve(input, state.get().shell, options, global.bin)),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -13,6 +13,7 @@ import { NonNegativeInt } from "../../schema.js"
|
||||
import { SessionSchema } from "../../session/schema.js"
|
||||
import { Shell } from "../../shell.js"
|
||||
import { ShellParse } from "../../shell/parse.js"
|
||||
import { ShellSelect } from "../../shell/select.js"
|
||||
import { ToolOutput } from "../../tool-output.js"
|
||||
|
||||
export const name = "shell"
|
||||
@@ -109,6 +110,8 @@ export const Plugin = {
|
||||
const environment = yield* Environment.Service
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const shell = yield* Shell.Service
|
||||
const shellSelect = yield* ShellSelect.Service
|
||||
const compatibleShell = shellSelect.resolve({ priority: "compat" })
|
||||
const permission = yield* Permission.Service
|
||||
const config = yield* Config.Service
|
||||
|
||||
@@ -185,6 +188,7 @@ export const Plugin = {
|
||||
command: input.command,
|
||||
cwd: input.workdir,
|
||||
timeout,
|
||||
shell: yield* compatibleShell,
|
||||
metadata: { sessionID: context.sessionID },
|
||||
},
|
||||
(invocation) =>
|
||||
@@ -340,7 +344,7 @@ export const Plugin = {
|
||||
Effect.gen(function* () {
|
||||
const tool = event.tools[name]
|
||||
if (!tool) return
|
||||
tool.description = description(yield* shell.name())
|
||||
tool.description = description(ShellSelect.name(yield* compatibleShell))
|
||||
}),
|
||||
)
|
||||
}),
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { ToolDefinition } from "@opencode-ai/ai"
|
||||
import { Tool } from "@opencode-ai/schema/tool"
|
||||
import type { StandardJSONSchemaV1, StandardSchemaV1 } from "@standard-schema/spec"
|
||||
import { Cache, Effect, JsonSchema, Schema, SchemaIssue, SchemaRepresentation } from "effect"
|
||||
import { $ZodType, toJSONSchema } from "zod/v4/core"
|
||||
|
||||
const formatEffectIssues = SchemaIssue.makeFormatterStandardSchemaV1()
|
||||
|
||||
@@ -129,13 +130,15 @@ const encodeOutput = (schema: Tool.ValueSchema<any>, value: unknown) => {
|
||||
)
|
||||
}
|
||||
|
||||
const isStandardSchema = (
|
||||
schema: Tool.ValueSchema<any>,
|
||||
): schema is StandardSchemaV1<any, any> & StandardJSONSchemaV1<any, any> =>
|
||||
const isStandardSchema = (schema: Tool.ValueSchema<any>): schema is StandardSchemaV1<any, any> =>
|
||||
typeof schema === "object" && schema !== null && "~standard" in schema
|
||||
|
||||
const isStandardJSONSchema = (
|
||||
schema: StandardSchemaV1<any, any>,
|
||||
): schema is StandardSchemaV1<any, any> & StandardJSONSchemaV1<any, any> => "jsonSchema" in schema["~standard"]
|
||||
|
||||
const validateStandard = (
|
||||
schema: StandardSchemaV1<any, any> & StandardJSONSchemaV1<any, any>,
|
||||
schema: StandardSchemaV1<any, any>,
|
||||
value: unknown,
|
||||
): Effect.Effect<StandardSchemaV1.Result<unknown>> =>
|
||||
Effect.gen(function* () {
|
||||
@@ -150,15 +153,21 @@ const validateStandard = (
|
||||
|
||||
const inputJsonSchema = (schema: Tool.ValueSchema<any>): JsonSchema.JsonSchema => {
|
||||
if (schema === undefined || schema === null) return {}
|
||||
if (isStandardSchema(schema)) return schema["~standard"].jsonSchema.input({ target: "draft-2020-12" })
|
||||
if (isStandardSchema(schema)) return standardJsonSchema(schema, "input")
|
||||
return Schema.isSchema(schema) ? toJsonSchema(schema) : schema
|
||||
}
|
||||
|
||||
const outputJsonSchema = (schema: Tool.ValueSchema<any>): JsonSchema.JsonSchema => {
|
||||
if (isStandardSchema(schema)) return schema["~standard"].jsonSchema.output({ target: "draft-2020-12" })
|
||||
if (isStandardSchema(schema)) return standardJsonSchema(schema, "output")
|
||||
return Schema.isSchema(schema) ? toJsonSchema(schema) : schema
|
||||
}
|
||||
|
||||
const standardJsonSchema = (schema: StandardSchemaV1<any, any>, io: "input" | "output"): JsonSchema.JsonSchema => {
|
||||
if (isStandardJSONSchema(schema)) return schema["~standard"].jsonSchema[io]({ target: "draft-2020-12" })
|
||||
if (schema instanceof $ZodType) return toJSONSchema(schema, { target: "draft-2020-12", io })
|
||||
throw new Error(`Schema vendor "${schema["~standard"].vendor}" does not support JSON Schema conversion`)
|
||||
}
|
||||
|
||||
const toJsonSchema = (schema: Schema.Top): JsonSchema.JsonSchema => {
|
||||
const document = Schema.toJsonSchemaDocument(schema)
|
||||
// Effect emits valid JSON Schema that some inference providers handle poorly. Simplify it
|
||||
|
||||
@@ -33,7 +33,7 @@ import { host } from "../plugin/host"
|
||||
const shellLayer = Layer.succeed(
|
||||
ShellSelect.Service,
|
||||
ShellSelect.Service.of({
|
||||
preferred: () => Effect.succeed("sh"),
|
||||
resolve: () => Effect.succeed("sh"),
|
||||
transform: () => Effect.die("unused shell.transform"),
|
||||
reload: () => Effect.die("unused shell.reload"),
|
||||
}),
|
||||
|
||||
@@ -604,6 +604,7 @@ describe("Config", () => {
|
||||
provider: {
|
||||
bedrock: {
|
||||
npm: "@ai-sdk/amazon-bedrock",
|
||||
models: { claude: { provider: { npm: "@ai-sdk/anthropic" } } },
|
||||
options: {
|
||||
headers: { "x-test": "1" },
|
||||
body: { trace: true },
|
||||
@@ -616,6 +617,7 @@ describe("Config", () => {
|
||||
|
||||
expect(migrated.providers?.bedrock).toMatchObject({
|
||||
package: Provider.aisdk("@ai-sdk/amazon-bedrock"),
|
||||
models: { claude: { package: Provider.aisdk("@ai-sdk/anthropic") } },
|
||||
settings: { region: "us-east-1", profile: "dev" },
|
||||
headers: { "x-test": "1" },
|
||||
body: { trace: true },
|
||||
|
||||
@@ -24,12 +24,12 @@ describe("ConfigShellPlugin.Plugin", () => {
|
||||
yield* ConfigShellPlugin.Plugin.effect(yield* PluginHost.make(plugins))
|
||||
|
||||
const configured = process.platform === "win32" ? FSUtil.windowsPath(process.execPath) : process.execPath
|
||||
expect(yield* shell.preferred()).toBe(configured)
|
||||
expect(yield* shell.resolve({ priority: "config" })).toBe(configured)
|
||||
|
||||
yield* config.setEntries([])
|
||||
yield* bus.publish(Event.Updated, {})
|
||||
for (let attempt = 0; attempt < 200; attempt++) {
|
||||
if ((yield* shell.preferred()) !== configured) return
|
||||
if ((yield* shell.resolve({ priority: "config" })) !== configured) return
|
||||
yield* Effect.sleep("10 millis")
|
||||
}
|
||||
yield* Effect.die(new Error("Timed out waiting for shell config reload"))
|
||||
|
||||
@@ -36,6 +36,18 @@ test("defensively syncs advertised Copilot models", async () => {
|
||||
supports: { tool_calls: true, vision: true, reasoning_effort: ["low", "high"] },
|
||||
},
|
||||
},
|
||||
{
|
||||
model_picker_enabled: true,
|
||||
id: "claude-sonnet",
|
||||
name: "Claude Sonnet",
|
||||
version: "claude-sonnet-2026-06-01",
|
||||
supported_endpoints: ["/v1/messages"],
|
||||
capabilities: {
|
||||
family: "claude",
|
||||
limits: { max_output_tokens: 16384, max_prompt_tokens: 180000 },
|
||||
supports: { tool_calls: true },
|
||||
},
|
||||
},
|
||||
{
|
||||
model_picker_enabled: true,
|
||||
id: "vision-only",
|
||||
@@ -85,6 +97,7 @@ test("defensively syncs advertised Copilot models", async () => {
|
||||
const model = models.get(Model.ID.make("gpt-5"))
|
||||
|
||||
expect(model?.name).toBe("GPT-5 local")
|
||||
expect(model?.package).toBe(Provider.aisdk("@ai-sdk/github-copilot"))
|
||||
expect(model?.settings).toMatchObject({ baseURL: server.url.origin, endpoint: "responses" })
|
||||
expect(model?.cost[0]).toMatchObject({ input: 0, output: 0, cache: { read: 0, write: 0 } })
|
||||
expect(model?.variants.map((variant) => variant.id)).toEqual([
|
||||
@@ -92,6 +105,11 @@ test("defensively syncs advertised Copilot models", async () => {
|
||||
Model.VariantID.make("high"),
|
||||
])
|
||||
expect(model?.capabilities.input).toEqual(["text", "image", "pdf"])
|
||||
expect(models.get(Model.ID.make("claude-sonnet"))?.package).toBe(Provider.aisdk("@ai-sdk/anthropic"))
|
||||
expect(models.get(Model.ID.make("claude-sonnet"))?.settings).toMatchObject({
|
||||
baseURL: `${server.url.origin}/v1`,
|
||||
endpoint: "messages",
|
||||
})
|
||||
expect(models.get(Model.ID.make("vision-only"))?.capabilities.input).toEqual(["text", "image"])
|
||||
expect(models.get(Model.ID.make("utility"))?.enabled).toBe(false)
|
||||
expect(models.has(Model.ID.make("stale"))).toBe(false)
|
||||
|
||||
@@ -1080,7 +1080,11 @@ describe("ModelResolver", () => {
|
||||
expect(openrouter.route.id).toBe("openrouter")
|
||||
expect(openrouter.route.defaults.providerOptions).toEqual({ reasoning: { effort: "high" } })
|
||||
expect(xai.route.id).toBe("openai-responses")
|
||||
expect(xai.route.defaults.providerOptions).toEqual({ reasoningEffort: "high", store: false })
|
||||
expect(xai.route.defaults.providerOptions).toEqual({
|
||||
reasoningEffort: "high",
|
||||
store: false,
|
||||
include: ["reasoning.encrypted_content"],
|
||||
})
|
||||
expect(bedrock.route.id).toBe("bedrock-converse")
|
||||
expect(bedrock.route.defaults.generation).toEqual({ topP: 0.8 })
|
||||
expect(bedrock.route.defaults.http?.body).toEqual({ serviceTier: { type: "priority" } })
|
||||
|
||||
@@ -234,6 +234,31 @@ describe("ModelsDev Service", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("normalizes provider and model AI SDK packages from models.dev", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = makeCache()
|
||||
writeCache(cache, {
|
||||
acme: {
|
||||
...fixture.acme,
|
||||
models: {
|
||||
"acme-1": {
|
||||
...fixture.acme.models["acme-1"],
|
||||
provider: { npm: "@ai-sdk/openai" },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
const state = yield* Ref.make(initialState)
|
||||
const result = yield* provided(
|
||||
state,
|
||||
cache,
|
||||
ModelsDev.Service.use((service) => service.get()),
|
||||
)
|
||||
expect(result[0]?.info.package).toBe(Provider.aisdk("@ai-sdk/openai-compatible"))
|
||||
expect(result[0]?.models[0]?.package).toBe(Provider.aisdk("@ai-sdk/openai"))
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("get() returns empty catalog when KV is empty, fetch disabled, and the bundled snapshot is disabled", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = makeCache()
|
||||
|
||||
@@ -6,6 +6,7 @@ import { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { ModelResolver } from "@opencode-ai/core/model-resolver"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
||||
@@ -198,16 +199,23 @@ describe("GithubCopilotPlugin", () => {
|
||||
it.effect("rewrites models.dev fallback models to the GitHub Copilot package", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* catalog.transform((catalog) => {
|
||||
catalog.provider.update(Provider.ID.githubCopilot, () => {})
|
||||
catalog.model.update(Provider.ID.githubCopilot, Model.ID.make("gpt-5.6-sol"), (model) => {
|
||||
model.package = "@ai-sdk/openai-compatible"
|
||||
model.package = Provider.aisdk("@ai-sdk/openai-compatible")
|
||||
})
|
||||
})
|
||||
yield* addPlugin()
|
||||
expect(required(yield* catalog.model.get(Provider.ID.githubCopilot, Model.ID.make("gpt-5.6-sol"))).package).toBe(
|
||||
"@ai-sdk/github-copilot",
|
||||
)
|
||||
const fallback = required(yield* catalog.model.get(Provider.ID.githubCopilot, Model.ID.make("gpt-5.6-sol")))
|
||||
expect(fallback.package).toBe(Provider.aisdk("@ai-sdk/github-copilot"))
|
||||
|
||||
const resolved = yield* ModelResolver.fromCatalogModel(fallback, undefined, {
|
||||
loadPackage: () => Effect.die("Copilot must not load a native provider package"),
|
||||
loadAISDK: (model) => aisdk.model(model),
|
||||
})
|
||||
expect(resolved.route.id).toBe("ai-sdk:@ai-sdk/github-copilot")
|
||||
expect(resolved.route.providerMetadataKey).toBe("copilot")
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -247,6 +247,10 @@ describe("OpencodePlugin", () => {
|
||||
cost: { input: 1, output: 2, cache_read: 0.1 },
|
||||
limit: { context: 1000, output: 100 },
|
||||
},
|
||||
override: {
|
||||
name: "Override",
|
||||
provider: { npm: "@ai-sdk/anthropic", api: `${origin}/anthropic` },
|
||||
},
|
||||
disabled: { name: "Disabled", status: "deprecated" },
|
||||
},
|
||||
},
|
||||
@@ -308,6 +312,9 @@ describe("OpencodePlugin", () => {
|
||||
settings: { baseURL: `${server.url.origin}/v1`, custom: "value", temperature: 0.5 },
|
||||
headers: { "x-org-id": "org" },
|
||||
})
|
||||
const override = required(yield* catalog.model.get(Provider.ID.make("remote"), Model.ID.make("override")))
|
||||
expect(override.package).toBe(Provider.aisdk("@ai-sdk/anthropic"))
|
||||
expect(override.settings?.baseURL).toBe(`${server.url.origin}/anthropic`)
|
||||
expect(model.variants).toEqual([
|
||||
{
|
||||
id: Model.VariantID.make("custom"),
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { Info } from "@opencode-ai/schema/tool"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { executeTool, toolDefinitions } from "./lib/tool"
|
||||
import { Cause, Deferred, Effect, Exit, Fiber, Layer, Schema, SchemaGetter, SchemaIssue, Scope } from "effect"
|
||||
import { z } from "zod"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const imageStore = Layer.mock(Image.Service, {
|
||||
@@ -556,6 +557,39 @@ describe("Tool", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("registers, advertises, and executes a Zod tool", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Tool.Service
|
||||
yield* transform(
|
||||
service,
|
||||
{
|
||||
zod: {
|
||||
name: "zod",
|
||||
description: "Increment a parsed number",
|
||||
input: z.object({ count: z.string().transform(Number) }),
|
||||
output: z.object({ count: z.number() }),
|
||||
execute: ({ count }) => Effect.succeed({ output: { count: count + 1 } }),
|
||||
},
|
||||
},
|
||||
{ codemode: false },
|
||||
)
|
||||
|
||||
const snapshot = yield* service.snapshot()
|
||||
expect(snapshot.definitions.find((tool) => tool.name === "zod")?.inputSchema).toMatchObject({
|
||||
type: "object",
|
||||
properties: { count: { type: "string" } },
|
||||
required: ["count"],
|
||||
})
|
||||
expect(
|
||||
yield* snapshot.execute({
|
||||
sessionID,
|
||||
...identity,
|
||||
call: { type: "tool-call", id: "call-zod", name: "zod", input: { count: "41" } },
|
||||
}),
|
||||
).toMatchObject({ output: { count: 42 } })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("executes the tool advertised in a model request", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Tool.Service
|
||||
|
||||
@@ -8,15 +8,13 @@ const withShell = async (shell: string | undefined, fn: () => void | Promise<voi
|
||||
const prev = process.env.SHELL
|
||||
if (shell === undefined) delete process.env.SHELL
|
||||
else process.env.SHELL = shell
|
||||
ShellSelect.acceptable.reset()
|
||||
ShellSelect.preferred.reset()
|
||||
ShellSelect.resolve.reset()
|
||||
try {
|
||||
await fn()
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.SHELL
|
||||
else process.env.SHELL = prev
|
||||
ShellSelect.acceptable.reset()
|
||||
ShellSelect.preferred.reset()
|
||||
ShellSelect.resolve.reset()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,16 +34,16 @@ describe("shell", () => {
|
||||
|
||||
test("falls back when configured shell cannot be resolved", async () => {
|
||||
await withShell(undefined, async () => {
|
||||
const preferred = ShellSelect.preferred()
|
||||
const acceptable = ShellSelect.acceptable()
|
||||
expect(ShellSelect.preferred("opencode-missing-shell")).toBe(preferred)
|
||||
expect(ShellSelect.acceptable("opencode-missing-shell")).toBe(acceptable)
|
||||
const configured = ShellSelect.resolve({ priority: "config" })
|
||||
const compatible = ShellSelect.resolve({ priority: "compat" })
|
||||
expect(ShellSelect.resolve({ priority: "config" }, "opencode-missing-shell")).toBe(configured)
|
||||
expect(ShellSelect.resolve({ priority: "compat" }, "opencode-missing-shell")).toBe(compatible)
|
||||
})
|
||||
})
|
||||
|
||||
test("falls back for terminal-only acceptable shells", () => {
|
||||
expect(ShellSelect.name(ShellSelect.acceptable("fish"))).not.toBe("fish")
|
||||
expect(ShellSelect.name(ShellSelect.acceptable("nu"))).not.toBe("nu")
|
||||
test("falls back for terminal-only shells when compatibility is required", () => {
|
||||
expect(ShellSelect.name(ShellSelect.resolve({ priority: "compat" }, "fish"))).not.toBe("fish")
|
||||
expect(ShellSelect.name(ShellSelect.resolve({ priority: "compat" }, "nu"))).not.toBe("nu")
|
||||
})
|
||||
|
||||
test("builds command args per shell family", () => {
|
||||
@@ -65,14 +63,14 @@ describe("shell", () => {
|
||||
if (process.platform === "win32") {
|
||||
test("rejects blacklisted shells case-insensitively", async () => {
|
||||
await withShell("NU.EXE", async () => {
|
||||
expect(ShellSelect.name(ShellSelect.acceptable())).not.toBe("nu")
|
||||
expect(ShellSelect.name(ShellSelect.resolve({ priority: "compat" }))).not.toBe("nu")
|
||||
})
|
||||
})
|
||||
|
||||
test("normalizes Git Bash shell paths from env", async () => {
|
||||
const shell = "/cygdrive/c/Program Files/Git/bin/bash.exe"
|
||||
await withShell(shell, async () => {
|
||||
expect(ShellSelect.preferred()).toBe(FSUtil.windowsPath(shell))
|
||||
expect(ShellSelect.resolve({ priority: "config" })).toBe(FSUtil.windowsPath(shell))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -80,19 +78,19 @@ describe("shell", () => {
|
||||
const bash = ShellSelect.gitbash()
|
||||
if (!bash) return
|
||||
await withShell("/usr/bin/bash", async () => {
|
||||
expect(ShellSelect.acceptable()).toBe(bash)
|
||||
expect(ShellSelect.preferred()).toBe(bash)
|
||||
expect(ShellSelect.resolve({ priority: "compat" })).toBe(bash)
|
||||
expect(ShellSelect.resolve({ priority: "config" })).toBe(bash)
|
||||
})
|
||||
})
|
||||
|
||||
test("resolves bare bash to Git Bash before PATH", async () => {
|
||||
const bash = ShellSelect.gitbash()
|
||||
if (!bash) return
|
||||
expect(ShellSelect.acceptable("bash")).toBe(bash)
|
||||
expect(ShellSelect.preferred("bash")).toBe(bash)
|
||||
expect(ShellSelect.resolve({ priority: "compat" }, "bash")).toBe(bash)
|
||||
expect(ShellSelect.resolve({ priority: "config" }, "bash")).toBe(bash)
|
||||
await withShell("bash", async () => {
|
||||
expect(ShellSelect.acceptable()).toBe(bash)
|
||||
expect(ShellSelect.preferred()).toBe(bash)
|
||||
expect(ShellSelect.resolve({ priority: "compat" })).toBe(bash)
|
||||
expect(ShellSelect.resolve({ priority: "config" })).toBe(bash)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -100,7 +98,7 @@ describe("shell", () => {
|
||||
const shell = which("pwsh") || which("powershell")
|
||||
if (!shell) return
|
||||
await withShell(path.win32.basename(shell), async () => {
|
||||
expect(ShellSelect.preferred()).toBe(shell)
|
||||
expect(ShellSelect.resolve({ priority: "config" })).toBe(shell)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { z } from "zod"
|
||||
import type { Info } from "@opencode-ai/schema/tool"
|
||||
import { Tool } from "../src/tool"
|
||||
import { definition, execute } from "../src/tool/runtime"
|
||||
@@ -139,6 +140,43 @@ test("portable schemas validate and describe typed tools", async () => {
|
||||
expect(result.output).toBe("42")
|
||||
})
|
||||
|
||||
test("Zod schemas validate, transform, and describe typed tools", async () => {
|
||||
const tool: Info = {
|
||||
name: "zod",
|
||||
description: "Zod tool",
|
||||
input: z.object({ count: z.string().transform(Number) }),
|
||||
output: z.object({ count: z.number() }),
|
||||
execute: ({ count }) => Effect.succeed({ output: { count: count + 1 } }),
|
||||
}
|
||||
|
||||
expect(definition(tool)).toEqual({
|
||||
name: "zod",
|
||||
description: "Zod tool",
|
||||
inputSchema: {
|
||||
$schema: "https://json-schema.org/draft/2020-12/schema",
|
||||
type: "object",
|
||||
properties: { count: { type: "string" } },
|
||||
required: ["count"],
|
||||
},
|
||||
outputSchema: {
|
||||
$schema: "https://json-schema.org/draft/2020-12/schema",
|
||||
type: "object",
|
||||
properties: { count: { type: "number" } },
|
||||
required: ["count"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
})
|
||||
expect(await Effect.runPromise(execute(tool, { count: "41" }, {} as Tool.Context))).toMatchObject({
|
||||
output: { count: 42 },
|
||||
})
|
||||
expect(await Effect.runPromise(Effect.flip(execute(tool, { count: 41 }, {} as Tool.Context)))).toEqual(
|
||||
new Tool.Error({
|
||||
message:
|
||||
'Invalid arguments for tool "zod":\n- count: Invalid input: expected string, received number\n\nArguments provided:\n{\n "count": 41\n}\n\nUpdate the arguments and call the tool again.',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("portable schema failures become tool failures", async () => {
|
||||
const input = {
|
||||
"~standard": {
|
||||
|
||||
@@ -32,6 +32,7 @@ import { Permission } from "@opencode-ai/core/permission"
|
||||
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
|
||||
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
||||
import { Shell } from "@opencode-ai/core/shell"
|
||||
import { ShellSelect } from "@opencode-ai/core/shell/select"
|
||||
import { Shell as ShellSchema } from "@opencode-ai/schema/shell"
|
||||
import { ShellTool } from "@opencode-ai/core/tool/plugin/shell"
|
||||
import { ToolOutput } from "@opencode-ai/core/tool-output"
|
||||
@@ -136,6 +137,7 @@ const shellPluginSupervisor = makeLocationNode({
|
||||
Permission.node,
|
||||
PluginRuntime.node,
|
||||
Shell.node,
|
||||
ShellSelect.node,
|
||||
Tool.node,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -10,6 +10,7 @@ const channel = (() => {
|
||||
const nodePtyPkg = `@lydell/node-pty-${process.platform}-${process.arch}`
|
||||
|
||||
const appPlugin = (await import("@opencode-ai/app/vite")).default
|
||||
const picker = (await import("@brendonovich/vite-plugin-opencode")).default()
|
||||
const sentry =
|
||||
process.env.SENTRY_AUTH_TOKEN && process.env.SENTRY_ORG && process.env.SENTRY_PROJECT
|
||||
? (await import("@sentry/vite-plugin")).sentryVitePlugin({
|
||||
@@ -80,7 +81,7 @@ const require = __cjs_mod__.createRequire(import.meta.url);
|
||||
"import.meta.env.OPENCODE_VERSION": JSON.stringify(process.env.OPENCODE_VERSION),
|
||||
"import.meta.env.VITE_OPENCODE_CHANNEL": JSON.stringify(channel),
|
||||
},
|
||||
plugins: [appPlugin, sentry],
|
||||
plugins: [{ ...picker, transformIndexHtml: undefined }, appPlugin, sentry],
|
||||
publicDir: "../../../app/public",
|
||||
root: "src/renderer",
|
||||
build: {
|
||||
|
||||
@@ -35,6 +35,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:*",
|
||||
|
||||
Vendored
+2
@@ -1,3 +1,5 @@
|
||||
declare module "virtual:vite-opencode-picker/client"
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly OPENCODE_CHANNEL: string
|
||||
readonly OPENCODE_VERSION?: string
|
||||
|
||||
@@ -2,7 +2,9 @@ const resizeLoopWarning = "ResizeObserver loop completed with undelivered notifi
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
installConsoleStacks()
|
||||
installResizeObserverStacks()
|
||||
window.addEventListener("error", (event) => {
|
||||
if (event.message === resizeLoopWarning) event.preventDefault()
|
||||
})
|
||||
}
|
||||
|
||||
function installConsoleStacks() {
|
||||
@@ -10,60 +12,6 @@ function installConsoleStacks() {
|
||||
console.error = tracedConsole(console.error.bind(console), "Console error")
|
||||
}
|
||||
|
||||
function installResizeObserverStacks() {
|
||||
if (typeof ResizeObserver !== "function") return
|
||||
|
||||
const NativeResizeObserver = ResizeObserver
|
||||
type ResizeTrace = {
|
||||
created: string
|
||||
last?: { at: number; targets: Element[] }
|
||||
}
|
||||
const observers = new Set<ResizeTrace>()
|
||||
|
||||
class TracedResizeObserver extends NativeResizeObserver {
|
||||
private readonly trace: ResizeTrace
|
||||
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
const trace: ResizeTrace = {
|
||||
created: diagnosticStack("ResizeObserver created"),
|
||||
}
|
||||
super((entries, observer) => {
|
||||
trace.last = { at: performance.now(), targets: entries.map((entry) => entry.target) }
|
||||
callback(entries, observer)
|
||||
})
|
||||
this.trace = trace
|
||||
observers.add(trace)
|
||||
}
|
||||
|
||||
override disconnect() {
|
||||
observers.delete(this.trace)
|
||||
super.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
globalThis.ResizeObserver = TracedResizeObserver
|
||||
window.addEventListener("error", (event) => {
|
||||
if (event.message !== resizeLoopWarning) return
|
||||
const now = performance.now()
|
||||
const active = [...observers]
|
||||
.flatMap((observer) => {
|
||||
if (!observer.last || now - observer.last.at >= 100) return []
|
||||
return [{ ...observer, last: observer.last }]
|
||||
})
|
||||
.sort((a, b) => b.last.at - a.last.at)
|
||||
.slice(0, 5)
|
||||
const detail = active.length
|
||||
? active
|
||||
.map(
|
||||
(observer, index) =>
|
||||
`Recent ResizeObserver ${index + 1}; targets: ${observer.last.targets.map(describeElement).join(", ") || "none"}\n${observer.created}`,
|
||||
)
|
||||
.join("\n")
|
||||
: "No ResizeObserver callback was recorded in the previous 100 ms."
|
||||
console.warn(`[renderer diagnostics] ${resizeLoopWarning}\n${detail}`)
|
||||
})
|
||||
}
|
||||
|
||||
function tracedConsole(write: (...args: unknown[]) => void, label: string) {
|
||||
const pending = new Map<string, { count: number }>()
|
||||
return (...args: unknown[]) => {
|
||||
@@ -85,15 +33,6 @@ function tracedConsole(write: (...args: unknown[]) => void, label: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function describeElement(element: Element) {
|
||||
const id = element.id ? `#${element.id}` : ""
|
||||
const classes = [...element.classList]
|
||||
.slice(0, 3)
|
||||
.map((name) => `.${name}`)
|
||||
.join("")
|
||||
return `${element.tagName.toLowerCase()}${id}${classes}`
|
||||
}
|
||||
|
||||
function diagnosticStack(label: string) {
|
||||
return new Error(label).stack ?? label
|
||||
}
|
||||
|
||||
@@ -20,3 +20,5 @@ startDesktopMenu(api)
|
||||
startDeepLinks(api)
|
||||
|
||||
render(() => <DesktopApp api={api} updater={updater} version={version} />, root)
|
||||
|
||||
if (import.meta.env.DEV) void import("virtual:vite-opencode-picker/client")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as Tool from "./tool.js"
|
||||
|
||||
import { Effect, JsonSchema, Schema } from "effect"
|
||||
import type { StandardJSONSchemaV1, StandardSchemaV1 } from "@standard-schema/spec"
|
||||
import type { StandardSchemaV1 } from "@standard-schema/spec"
|
||||
import type { Agent } from "./agent.js"
|
||||
import type { Session } from "./session.js"
|
||||
import type { SessionMessage } from "./session-message.js"
|
||||
@@ -36,10 +36,7 @@ export type Options = BaseOptions &
|
||||
}
|
||||
)
|
||||
|
||||
export type ValueSchema<A = unknown> =
|
||||
| Schema.Codec<A, any>
|
||||
| (StandardSchemaV1<any, A> & StandardJSONSchemaV1<any, A>)
|
||||
| JsonSchema.JsonSchema
|
||||
export type ValueSchema<A = unknown> = Schema.Codec<A, any> | StandardSchemaV1<any, A> | JsonSchema.JsonSchema
|
||||
|
||||
type InputValue<S> = 0 extends 1 & S
|
||||
? any
|
||||
|
||||
@@ -24,6 +24,17 @@ const patch = (key: string, partIDs: string[], userMessageID = "user-1") =>
|
||||
previousAssistantPart: false,
|
||||
})
|
||||
|
||||
const part = (key: string, partID: string) =>
|
||||
new TimelineRow.AssistantPart({
|
||||
userMessageID: "user-1",
|
||||
group: {
|
||||
key,
|
||||
type: "part",
|
||||
ref: { messageID: "assistant-1", partID },
|
||||
} satisfies PartGroup,
|
||||
previousAssistantPart: false,
|
||||
})
|
||||
|
||||
const user = (userMessageID = "user-1") => new TimelineRow.UserMessage({ userMessageID })
|
||||
const keys = (rows: TimelineRow.TimelineRow[]) => rows.map(TimelineRow.key)
|
||||
|
||||
@@ -33,49 +44,63 @@ describe("reuseTimelineRows", () => {
|
||||
name: "reuses an unchanged context group",
|
||||
previous: [context("context:a", ["a", "b"])],
|
||||
rows: [context("context:a", ["a", "b"])],
|
||||
expected: ["assistant-part:context:a"],
|
||||
expected: ["assistant-part:context:context:a"],
|
||||
reused: [[0, 0]],
|
||||
},
|
||||
{
|
||||
name: "preserves the group key when a member is appended",
|
||||
previous: [context("context:a", ["a"])],
|
||||
rows: [context("context:a", ["a", "b"])],
|
||||
expected: ["assistant-part:context:a"],
|
||||
expected: ["assistant-part:context:context:a"],
|
||||
reused: [],
|
||||
},
|
||||
{
|
||||
name: "preserves a patch group key when a member is appended",
|
||||
previous: [patch("patch:a", ["a"])],
|
||||
rows: [patch("patch:a", ["a", "b"])],
|
||||
expected: ["assistant-part:patch:a"],
|
||||
expected: ["assistant-part:file:patch:a"],
|
||||
reused: [],
|
||||
},
|
||||
{
|
||||
name: "preserves the group key when the first member is removed",
|
||||
previous: [context("context:a", ["a", "b"])],
|
||||
rows: [context("context:b", ["b"])],
|
||||
expected: ["assistant-part:context:a"],
|
||||
expected: ["assistant-part:context:context:a"],
|
||||
reused: [],
|
||||
},
|
||||
{
|
||||
name: "lets only the natural owner retain an old key after a split",
|
||||
previous: [context("context:a", ["a", "b"])],
|
||||
rows: [context("context:a", ["a"]), context("context:b", ["b"])],
|
||||
expected: ["assistant-part:context:a", "assistant-part:context:b"],
|
||||
expected: ["assistant-part:context:context:a", "assistant-part:context:context:b"],
|
||||
reused: [],
|
||||
},
|
||||
{
|
||||
name: "preserves the file group identity when its first member becomes standalone",
|
||||
previous: [patch("part:a", ["a", "b"])],
|
||||
rows: [part("part:a", "a"), patch("part:b", ["b"])],
|
||||
expected: ["assistant-part:part:part:a", "assistant-part:file:part:a"],
|
||||
reused: [],
|
||||
},
|
||||
{
|
||||
name: "preserves the file group identity before a later standalone member",
|
||||
previous: [patch("part:a", ["a", "b"])],
|
||||
rows: [patch("part:b", ["b"]), part("part:a", "a")],
|
||||
expected: ["assistant-part:file:part:a", "assistant-part:part:part:a"],
|
||||
reused: [],
|
||||
},
|
||||
{
|
||||
name: "chooses the earliest prior key when groups merge",
|
||||
previous: [context("context:a", ["a"]), context("context:b", ["b"])],
|
||||
rows: [context("context:b", ["b", "a"])],
|
||||
expected: ["assistant-part:context:a"],
|
||||
expected: ["assistant-part:context:context:a"],
|
||||
reused: [],
|
||||
},
|
||||
{
|
||||
name: "reserves an old key for its natural owner when two new groups compete",
|
||||
previous: [context("context:a", ["a", "b"])],
|
||||
rows: [context("context:b", ["b"]), context("context:a", ["a"])],
|
||||
expected: ["assistant-part:context:b", "assistant-part:context:a"],
|
||||
expected: ["assistant-part:context:context:b", "assistant-part:context:context:a"],
|
||||
reused: [],
|
||||
},
|
||||
{
|
||||
@@ -84,14 +109,14 @@ describe("reuseTimelineRows", () => {
|
||||
name: "reuses context identity when the same parts move to another user message",
|
||||
previous: [context("context:a", ["a", "b"], { userMessageID: "user-1" })],
|
||||
rows: [context("context:b", ["b"], { userMessageID: "user-2" })],
|
||||
expected: ["assistant-part:context:a"],
|
||||
expected: ["assistant-part:context:context:a"],
|
||||
reused: [],
|
||||
},
|
||||
{
|
||||
name: "does not reuse context identity across assistant messages",
|
||||
previous: [context("context:assistant-1:a", ["a"], { messageID: "assistant-1" })],
|
||||
rows: [context("context:assistant-2:a", ["a"], { messageID: "assistant-2" })],
|
||||
expected: ["assistant-part:context:assistant-2:a"],
|
||||
expected: ["assistant-part:context:context:assistant-2:a"],
|
||||
reused: [],
|
||||
},
|
||||
{
|
||||
@@ -105,7 +130,7 @@ describe("reuseTimelineRows", () => {
|
||||
name: "does not create accidental key collisions",
|
||||
previous: [context("context:a", ["a", "b", "c"])],
|
||||
rows: [context("context:b", ["b"]), context("context:a", ["a"]), context("context:c", ["c"])],
|
||||
expected: ["assistant-part:context:b", "assistant-part:context:a", "assistant-part:context:c"],
|
||||
expected: ["assistant-part:context:context:b", "assistant-part:context:context:a", "assistant-part:context:context:c"],
|
||||
reused: [],
|
||||
},
|
||||
])("$name", ({ previous, rows, expected, reused }) => {
|
||||
|
||||
@@ -29,10 +29,10 @@ describe("current session timeline rows", () => {
|
||||
expect(result.activeMessageID).toBe("msg_3")
|
||||
expect(result.rows.map(TimelineRow.key)).toEqual([
|
||||
"user-message:msg_1",
|
||||
"assistant-part:part:msg_2:msg_2:text:0",
|
||||
"assistant-part:part:part:msg_2:msg_2:text:0",
|
||||
"turn-gap:msg_3",
|
||||
"user-message:msg_3",
|
||||
"assistant-part:part:msg_4:msg_4:reasoning:0",
|
||||
"assistant-part:part:part:msg_4:msg_4:reasoning:0",
|
||||
])
|
||||
})
|
||||
|
||||
@@ -79,7 +79,7 @@ describe("current session timeline rows", () => {
|
||||
expect(result.activeMessageID).toBe("msg_assistant")
|
||||
expect(result.rows.map(TimelineRow.key)).toEqual([
|
||||
"notice:msg_notice",
|
||||
"assistant-part:part:msg_assistant:msg_assistant:text:0",
|
||||
"assistant-part:part:part:msg_assistant:msg_assistant:text:0",
|
||||
])
|
||||
})
|
||||
|
||||
@@ -140,10 +140,10 @@ describe("current session timeline rows", () => {
|
||||
expect(result.rows.map(TimelineRow.key)).toEqual([
|
||||
"user-message:msg_user",
|
||||
"notice:msg_agent",
|
||||
"assistant-part:part:msg_assistant_1:msg_assistant_1:text:0",
|
||||
"assistant-part:part:part:msg_assistant_1:msg_assistant_1:text:0",
|
||||
"notice:msg_background",
|
||||
"notice:msg_model",
|
||||
"assistant-part:part:msg_assistant_2:msg_assistant_2:text:0",
|
||||
"assistant-part:part:part:msg_assistant_2:msg_assistant_2:text:0",
|
||||
"notice:msg_restart",
|
||||
"notice:msg_skill",
|
||||
"notice:msg_compaction",
|
||||
@@ -518,9 +518,9 @@ describe("current session timeline rows", () => {
|
||||
|
||||
expect(keys).toEqual([
|
||||
"user-message:msg_user",
|
||||
"assistant-part:context:msg_assistant_1:tool_0",
|
||||
"assistant-part:part:msg_assistant_2:tool_0",
|
||||
"assistant-part:context:msg_assistant_3:tool_0",
|
||||
"assistant-part:context:context:msg_assistant_1:tool_0",
|
||||
"assistant-part:part:part:msg_assistant_2:tool_0",
|
||||
"assistant-part:context:context:msg_assistant_3:tool_0",
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -92,7 +92,7 @@ export namespace TimelineRow {
|
||||
// and its rows regroup under the real user message once older history loads.
|
||||
// The group key already carries the owning message and part IDs.
|
||||
case "AssistantPart":
|
||||
return `assistant-part:${row.group.key}`
|
||||
return `assistant-part:${row.group.type}:${row.group.key}`
|
||||
case "Thinking":
|
||||
return `thinking:${row.userMessageID}`
|
||||
case "Error":
|
||||
|
||||
@@ -716,6 +716,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
category: "Session",
|
||||
slash: { name: "new", aliases: ["clear"] },
|
||||
run: () => {
|
||||
const model = local.model.current()
|
||||
const current =
|
||||
route.data.type === "session"
|
||||
? (data.session.get(route.data.sessionID)?.location ?? location.ref)
|
||||
@@ -729,6 +730,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
location.error?.location,
|
||||
),
|
||||
})
|
||||
if (model) local.model.set(model)
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
|
||||
@@ -329,7 +329,13 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
|
||||
usePaste((event) => {
|
||||
if (keymap.mode.current() !== FORM_MODE) return
|
||||
if (!pasteCustom(stripAnsiSequences(decodePasteBytes(event.bytes)).replace(/\r\n?/g, "\n"))) return
|
||||
const value = stripAnsiSequences(decodePasteBytes(event.bytes)).replace(/\r\n?/g, "\n")
|
||||
if (store.editing && renderer.currentFocusedEditor === textarea) {
|
||||
textarea.insertText(value)
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
if (!pasteCustom(value)) return
|
||||
event.preventDefault()
|
||||
})
|
||||
|
||||
|
||||
@@ -296,6 +296,73 @@ test("session startup prompt is submitted exactly once", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("new session inherits the active session model", async () => {
|
||||
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false, kittyKeyboard: true })
|
||||
setup.renderer.start()
|
||||
const events = createEventStream()
|
||||
const cwd = process.cwd()
|
||||
const location = { directory: cwd, project: { id: "project", directory: cwd } }
|
||||
const session = {
|
||||
id: "dummy",
|
||||
title: "Demo session",
|
||||
projectID: "project",
|
||||
location: { directory: cwd },
|
||||
agent: "build",
|
||||
model: { providerID: "provider", id: "session-model" },
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 0, updated: 0 },
|
||||
}
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname === "/api/location") return json(location)
|
||||
if (url.pathname === "/api/session") return json({ data: [session], cursor: {} })
|
||||
if (url.pathname === "/api/session/dummy") return json({ data: session })
|
||||
if (url.pathname === "/api/session/dummy/message") return json({ data: [], cursor: {} })
|
||||
if (url.pathname === "/api/session/dummy/inbox") return json({ data: [] })
|
||||
if (url.pathname === "/api/session/dummy/permission") return json({ data: [] })
|
||||
if (url.pathname === "/api/agent")
|
||||
return json({ location, data: [{ id: "build", mode: "primary", hidden: false, permissions: [] }] })
|
||||
if (url.pathname === "/api/provider") return json({ location, data: [{ id: "provider", name: "Provider" }] })
|
||||
if (url.pathname === "/api/model")
|
||||
return json({
|
||||
location,
|
||||
data: [
|
||||
{ id: "home-model", providerID: "provider", name: "Home Model", variants: [] },
|
||||
{ id: "session-model", providerID: "provider", name: "Session Model", variants: [] },
|
||||
],
|
||||
})
|
||||
}, events)
|
||||
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
|
||||
|
||||
try {
|
||||
const { run } = await import("../src/app")
|
||||
const task = Effect.runPromise(
|
||||
run({
|
||||
app: { name: "test", version: "test", channel: "test" },
|
||||
server: { endpoint: { url: server.url.toString() } },
|
||||
config: { get: async () => ({ animations: false }), update: async () => ({}) },
|
||||
packages: { resolve: async () => undefined },
|
||||
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
|
||||
args: { sessionID: "dummy" },
|
||||
log: () => {},
|
||||
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
|
||||
)
|
||||
|
||||
await setup.waitForFrame((frame) => frame.includes("Session Model"))
|
||||
await setup.mockInput.typeText("/new")
|
||||
setup.mockInput.pressEnter()
|
||||
await Bun.sleep(50)
|
||||
await setup.renderOnce()
|
||||
const frame = setup.captureCharFrame()
|
||||
expect(frame).toContain("Session Model")
|
||||
setup.renderer.destroy()
|
||||
await task
|
||||
} finally {
|
||||
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
|
||||
await server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test("keeps the prompt display stable while a new location catalog loads", async () => {
|
||||
const setup = await createTestRenderer({ width: 100, height: 30, useThread: false, kittyKeyboard: true })
|
||||
setup.renderer.start()
|
||||
|
||||
@@ -310,6 +310,35 @@ test("pasting on a custom choice opens its editor without submitting", async ()
|
||||
}
|
||||
})
|
||||
|
||||
test("pasting in an active custom editor inserts at the cursor", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const prompt = await mountForm(tmp.path, 80, [
|
||||
{
|
||||
key: "target",
|
||||
type: "string",
|
||||
options: [{ value: "staging", label: "Staging" }],
|
||||
custom: true,
|
||||
},
|
||||
])
|
||||
try {
|
||||
prompt.app.mockInput.pressArrow("down")
|
||||
prompt.app.mockInput.pressEnter()
|
||||
await prompt.app.waitFor(() => prompt.app.renderer.currentFocusedEditor !== null)
|
||||
await prompt.app.mockInput.typeText("prodwest")
|
||||
const editor = prompt.app.renderer.currentFocusedEditor
|
||||
if (editor) editor.cursorOffset = 4
|
||||
|
||||
await prompt.app.mockInput.pasteBracketedText("uction ")
|
||||
await prompt.app.waitFor(() => prompt.app.renderer.currentFocusedEditor?.plainText === "production west")
|
||||
await prompt.app.mockInput.typeText("!")
|
||||
|
||||
expect(prompt.app.renderer.currentFocusedEditor?.plainText).toBe("production !west")
|
||||
expect(prompt.replies).toEqual([])
|
||||
} finally {
|
||||
prompt.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("clipboard shortcut opens a custom choice editor without submitting", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const prompt = await mountForm(
|
||||
|
||||
@@ -199,8 +199,9 @@ test("reflows held tabs when the pointer leaves the strip", async () => {
|
||||
await app.mockMouse.click(22, 0)
|
||||
await app.waitForFrame((frame) => items().length === 3 && Array.from(frame.split("\n")[0] ?? "")[22] === "✕")
|
||||
|
||||
await app.mockMouse.moveTo(0, 1)
|
||||
await app.mockMouse.moveTo(20, 0)
|
||||
await app.mockMouse.moveTo(0, 2, { delayMs: 10 })
|
||||
await app.waitForFrame((frame) => Array.from(frame.split("\n")[0] ?? "")[22] !== "✕")
|
||||
await app.mockMouse.moveTo(20, 0, { delayMs: 10 })
|
||||
await app.waitForFrame((frame) => Array.from(frame.split("\n")[0] ?? "")[20] === "✕", { maxPasses: 100 })
|
||||
expect(Array.from(app.captureCharFrame().split("\n")[0] ?? "")[22]).not.toBe("✕")
|
||||
} finally {
|
||||
|
||||
@@ -89,10 +89,10 @@ function init() {
|
||||
setClosing = setClosingSignal
|
||||
return (
|
||||
<Kobalte
|
||||
modal={stack().length <= layer + 1}
|
||||
modal={stack().at(-1)?.id === id}
|
||||
open={!closing()}
|
||||
onOpenChange={(open: boolean) => {
|
||||
if (open) return
|
||||
if (open || stack().at(-1)?.id !== id) return
|
||||
close(id)
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
:root {
|
||||
--font-family-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
--font-family-sans: "Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
--font-family-sans--font-feature-settings: normal;
|
||||
--font-family-mono:
|
||||
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
--font-family-mono--font-feature-settings: normal;
|
||||
|
||||
--font-size-small: 13px;
|
||||
|
||||
@@ -129,9 +129,9 @@
|
||||
--v2-illustration-illustration-layer-02: var(--v2-grey-400);
|
||||
--v2-illustration-illustration-layer-03: var(--v2-grey-500);
|
||||
|
||||
--font-family-text: "Inter", sans-serif;
|
||||
--v2-font-family-sans: "Inter", sans-serif;
|
||||
--v2-font-family-code: "IBM Plex Mono", var(--font-family-mono);
|
||||
--font-family-text: var(--font-family-sans);
|
||||
--v2-font-family-sans: var(--font-family-sans);
|
||||
--v2-font-family-code: var(--font-family-mono);
|
||||
--line-height-tight: 12px;
|
||||
--line-height-compact: 16px;
|
||||
--line-height-base: 20px;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18 18H6V6H18V18Z" fill="#F5F5F5"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M3 3H21V21H3V3ZM8 8V16H16V8H8Z" fill="#3B7DD8"/>
|
||||
<path d="M16 8H20V12H16V8Z" fill="#FAB283"/>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" width="512" height="512"><svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="512" height="512" fill="#131010"></rect>
|
||||
<path d="M320 224V352H192V224H320Z" fill="#5A5858"></path>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M384 416H128V96H384V416ZM320 160H192V352H320V160Z" fill="white"></path>
|
||||
</svg><style>@media (prefers-color-scheme: light) { :root { filter: none; } }
|
||||
@media (prefers-color-scheme: dark) { :root { filter: none; } }
|
||||
</style></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 297 B After Width: | Height: | Size: 613 B |
@@ -1,5 +1,8 @@
|
||||
import { cp } from "node:fs/promises"
|
||||
import config from "../astro.config"
|
||||
|
||||
const base = config.base?.replace(/\/$/, "") ?? ""
|
||||
const docs = `dist/client${base}/docs`
|
||||
|
||||
await Bun.$`pagefind --site ${`dist/client${base}/docs`}`
|
||||
await cp(`dist/client${base}/docs-index`, docs, { recursive: true })
|
||||
await Bun.$`pagefind --site ${docs}`
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
export {}
|
||||
import { rm } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import astro from "../astro.config"
|
||||
|
||||
const path = "dist/server/wrangler.json"
|
||||
const config = await Bun.file(path).json()
|
||||
const base = astro.base?.replace(/\/$/, "") ?? ""
|
||||
const snapshots = `dist/client${base}/docs-index`
|
||||
|
||||
await Promise.all(
|
||||
(await Array.fromAsync(new Bun.Glob("**/*.html").scan({ cwd: snapshots }))).map((file) =>
|
||||
rm(path.join(`dist/client${base}/docs`, file)),
|
||||
),
|
||||
)
|
||||
await rm(snapshots, { recursive: true })
|
||||
|
||||
const config = await Bun.file("dist/server/wrangler.json").json()
|
||||
|
||||
delete config.kv_namespaces
|
||||
delete config.images
|
||||
delete config.previews
|
||||
|
||||
await Bun.write(path, JSON.stringify(config))
|
||||
await Bun.write("dist/server/wrangler.json", JSON.stringify(config))
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
import { render, type CollectionEntry } from "astro:content"
|
||||
import Callout from "./Callout.astro"
|
||||
import Card from "./Card.astro"
|
||||
import CardGroup from "./CardGroup.astro"
|
||||
import CodeBlock from "./CodeBlock.astro"
|
||||
import DocsLayout from "../layouts/DocsLayout.astro"
|
||||
|
||||
interface Props {
|
||||
entry: CollectionEntry<"docs">
|
||||
}
|
||||
|
||||
const entry = Astro.props.entry
|
||||
const rendered = await render(entry)
|
||||
---
|
||||
|
||||
<DocsLayout
|
||||
title={entry.data.title}
|
||||
description={entry.data.description}
|
||||
currentSlug={entry.id}
|
||||
headings={rendered.headings}
|
||||
showTableOfContents={entry.data.tableOfContents !== false}
|
||||
>
|
||||
<rendered.Content components={{ Callout, Card, CardGroup, CodeBlock }} />
|
||||
</DocsLayout>
|
||||
@@ -54,7 +54,18 @@ const canonical = new URL(Astro.url.pathname, "https://opencode.ai").href
|
||||
</aside>
|
||||
<main id="docs-content">
|
||||
<article class="prose" data-pagefind-body data-pagefind-meta={`title:${Astro.props.title}`}>
|
||||
<h1>{Astro.props.title}</h1>
|
||||
<h1>
|
||||
{Astro.props.title}
|
||||
<button type="button" aria-label="Copy page as Markdown" data-copy-markdown data-pagefind-ignore>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
|
||||
<rect x="8" y="8" width="13" height="13" rx="2" />
|
||||
<path d="M16 8V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h3" />
|
||||
</svg>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
|
||||
<path d="m5 12 4 4L19 6" />
|
||||
</svg>
|
||||
</button>
|
||||
</h1>
|
||||
<slot />
|
||||
</article>
|
||||
</main>
|
||||
@@ -81,5 +92,21 @@ const canonical = new URL(Astro.url.pathname, "https://opencode.ai").href
|
||||
addHeadingAnchors()
|
||||
document.addEventListener("astro:page-load", addHeadingAnchors)
|
||||
</script>
|
||||
<script>
|
||||
document.addEventListener("click", async (event) => {
|
||||
if (!(event.target instanceof Element)) return
|
||||
const button = event.target.closest<HTMLButtonElement>("[data-copy-markdown]")
|
||||
if (!button) return
|
||||
button.dataset.copied = "true"
|
||||
button.setAttribute("aria-label", "Copied")
|
||||
window.setTimeout(() => {
|
||||
delete button.dataset.copied
|
||||
button.setAttribute("aria-label", "Copy page as Markdown")
|
||||
}, 1500)
|
||||
const response = await fetch(window.location.href, { headers: { Accept: "text/markdown" } })
|
||||
if (!response.ok) return
|
||||
await navigator.clipboard.writeText(await response.text())
|
||||
})
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -158,8 +158,7 @@ a:hover {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.site-header nav a[aria-current="page"] {
|
||||
border-bottom: 1px solid;
|
||||
.site-header a:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@@ -788,7 +787,11 @@ main {
|
||||
}
|
||||
|
||||
.prose h1 {
|
||||
display: flex;
|
||||
margin: 0 0 3rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
font-size: 1.875rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
@@ -993,6 +996,30 @@ main {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.prose h1 button {
|
||||
display: inline-flex;
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.prose h1 button:hover,
|
||||
.prose h1 button[data-copied] {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.prose h1 button svg:last-child,
|
||||
.prose h1 button[data-copied] svg:first-child {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.prose h1 button[data-copied] svg:last-child {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.search-dialog {
|
||||
width: min(42rem, calc(100% - 2rem));
|
||||
max-height: min(36rem, calc(100vh - 4rem));
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
import { getCollection, type CollectionEntry } from "astro:content"
|
||||
import DocsPage from "../../docs/components/DocsPage.astro"
|
||||
|
||||
interface Props {
|
||||
entry: CollectionEntry<"docs">
|
||||
}
|
||||
|
||||
export async function getStaticPaths() {
|
||||
return (await getCollection("docs")).map((entry) => ({
|
||||
params: {
|
||||
slug: entry.id === "index" ? undefined : entry.id.replace(/\/index$/, ""),
|
||||
},
|
||||
props: { entry },
|
||||
}))
|
||||
}
|
||||
|
||||
export const prerender = true
|
||||
---
|
||||
|
||||
<DocsPage entry={Astro.props.entry} />
|
||||
@@ -1,36 +1,26 @@
|
||||
---
|
||||
import { getCollection, render, type CollectionEntry } from "astro:content"
|
||||
import Callout from "../../docs/components/Callout.astro"
|
||||
import Card from "../../docs/components/Card.astro"
|
||||
import CardGroup from "../../docs/components/CardGroup.astro"
|
||||
import CodeBlock from "../../docs/components/CodeBlock.astro"
|
||||
import DocsLayout from "../../docs/layouts/DocsLayout.astro"
|
||||
import { getEntry } from "astro:content"
|
||||
import DocsPage from "../../docs/components/DocsPage.astro"
|
||||
|
||||
interface Props {
|
||||
entry: CollectionEntry<"docs">
|
||||
export const prerender = false
|
||||
|
||||
const slug = Astro.params.slug || "index"
|
||||
const entry = (await getEntry("docs", slug)) ?? (await getEntry("docs", `${slug}/index`))
|
||||
if (!entry) return new Response("Not found", { status: 404, headers: { "Cache-Control": "no-store" } })
|
||||
|
||||
const headers = {
|
||||
"Cache-Control": "public, max-age=300, stale-while-revalidate=3600",
|
||||
Vary: "Accept",
|
||||
}
|
||||
|
||||
export async function getStaticPaths() {
|
||||
return (await getCollection("docs")).map((entry) => ({
|
||||
params: {
|
||||
slug: entry.id === "index" ? undefined : entry.id.replace(/\/index$/, ""),
|
||||
},
|
||||
props: { entry },
|
||||
}))
|
||||
const accept = Astro.request.headers.get("accept") ?? ""
|
||||
if (accept.includes("text/markdown") || accept.includes("text/x-markdown")) {
|
||||
return new Response(`# ${entry.data.title}\n\n${entry.body ?? ""}`, {
|
||||
headers: { ...headers, "Content-Type": "text/markdown; charset=utf-8" },
|
||||
})
|
||||
}
|
||||
|
||||
export const prerender = true
|
||||
|
||||
const entry = Astro.props.entry
|
||||
const rendered = await render(entry)
|
||||
Object.entries(headers).forEach(([name, value]) => Astro.response.headers.set(name, value))
|
||||
---
|
||||
|
||||
<DocsLayout
|
||||
title={entry.data.title}
|
||||
description={entry.data.description}
|
||||
currentSlug={entry.id}
|
||||
headings={rendered.headings}
|
||||
showTableOfContents={entry.data.tableOfContents !== false}
|
||||
>
|
||||
<rendered.Content components={{ Callout, Card, CardGroup, CodeBlock }} />
|
||||
</DocsLayout>
|
||||
<DocsPage entry={entry} />
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
---
|
||||
export const prerender = false
|
||||
|
||||
return fetch("https://raw.githubusercontent.com/anomalyco/opencode/v2/install")
|
||||
const response = await fetch("https://raw.githubusercontent.com/anomalyco/opencode/v2/install")
|
||||
const headers = new Headers(response.headers)
|
||||
headers.set("Cache-Control", "no-store")
|
||||
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers,
|
||||
})
|
||||
---
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
"compatibility_date": "2026-07-25",
|
||||
"workers_dev": false,
|
||||
"preview_urls": false,
|
||||
"cache": {
|
||||
"enabled": true,
|
||||
},
|
||||
"assets": {
|
||||
"binding": "ASSETS",
|
||||
"directory": "./dist/client",
|
||||
|
||||
Reference in New Issue
Block a user