mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-25 19:16:15 +00:00
Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c52f10f1c | ||
|
|
bcd1769521 | ||
|
|
691cb456ae | ||
|
|
190f189fbe | ||
|
|
8c126e98da | ||
|
|
ce8a489aaa | ||
|
|
1f7ae3f638 | ||
|
|
5ad0f0dc5a | ||
|
|
e589969398 | ||
|
|
42867d3bbc | ||
|
|
d4cdb99e4c | ||
|
|
0a78b11222 | ||
|
|
28c1806950 | ||
|
|
683f5fdee0 | ||
|
|
e9b5e055f5 | ||
|
|
f327adb0f2 | ||
|
|
442bc92a21 | ||
|
|
f2ff93a5b7 | ||
|
|
1b30098e8d | ||
|
|
1144ef6c5d | ||
|
|
d6deb62379 | ||
|
|
895eff09b0 | ||
|
|
d0252f7179 | ||
|
|
d78c13fce3 | ||
|
|
6bb5200464 | ||
|
|
a02a2f5799 | ||
|
|
63c23c98de | ||
|
|
9a90b94921 | ||
|
|
f03418afde | ||
|
|
19d0009891 |
@@ -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])
|
||||
|
||||
@@ -716,7 +716,7 @@ export const protocol = Protocol.make({
|
||||
reasoningSignatures: {},
|
||||
}),
|
||||
step,
|
||||
onHalt,
|
||||
onHalt: (state) => Effect.succeed(onHalt(state)),
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -718,7 +718,7 @@ export const protocol = Protocol.make({
|
||||
lifecycle: Lifecycle.initial(),
|
||||
}),
|
||||
step,
|
||||
onHalt: finish,
|
||||
onHalt: (state) => Effect.succeed(finish(state)),
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -285,6 +285,7 @@ export const Event = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
type: Schema.String,
|
||||
delta: Schema.optional(Schema.String),
|
||||
arguments: Schema.optional(Schema.String),
|
||||
text: Schema.optional(Schema.String),
|
||||
item_id: Schema.optional(Schema.String),
|
||||
summary_index: Schema.optional(Schema.Number),
|
||||
@@ -665,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 } : {}),
|
||||
@@ -996,12 +997,24 @@ const onFunctionCallArgumentsDelta = Effect.fn("OpenResponses.onFunctionCallArgu
|
||||
state: ParserState,
|
||||
event: Event,
|
||||
) {
|
||||
if (!event.item_id || !event.delta || !state.tools[event.item_id]) return [state, NO_EVENTS] satisfies StepResult
|
||||
if (!event.item_id) return [state, NO_EVENTS] satisfies StepResult
|
||||
const tool = state.tools[event.item_id]
|
||||
if (!tool) return [state, NO_EVENTS] satisfies StepResult
|
||||
const final = event.type === "response.function_call_arguments.done" ? event.arguments : undefined
|
||||
if (event.type === "response.function_call_arguments.done" && final === undefined)
|
||||
return [state, NO_EVENTS] satisfies StepResult
|
||||
if (final !== undefined && !final.startsWith(tool.input))
|
||||
return [
|
||||
{ ...state, tools: ToolStream.start(state.tools, event.item_id, { ...tool, input: final }) },
|
||||
NO_EVENTS,
|
||||
] satisfies StepResult
|
||||
const delta = final === undefined ? event.delta : final.slice(tool.input.length)
|
||||
if (!delta) return [state, NO_EVENTS] satisfies StepResult
|
||||
const result = ToolStream.appendExisting(
|
||||
state.id,
|
||||
state.tools,
|
||||
event.item_id,
|
||||
event.delta,
|
||||
delta,
|
||||
`${state.name} tool argument delta is missing its tool call`,
|
||||
)
|
||||
if (ToolStream.isError(result)) return yield* result
|
||||
@@ -1212,7 +1225,7 @@ export const step = (state: ParserState, event: Event) => {
|
||||
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
|
||||
return Effect.succeed(onOutputItemAdded(state, event))
|
||||
}
|
||||
if (event.type === "response.function_call_arguments.delta")
|
||||
if (event.type === "response.function_call_arguments.delta" || event.type === "response.function_call_arguments.done")
|
||||
return event.item_id
|
||||
? onFunctionCallArgumentsDelta(state, event)
|
||||
: ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
|
||||
|
||||
@@ -7,7 +7,10 @@ import { HttpTransport } from "../route/transport/index.js"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
import {
|
||||
AIError,
|
||||
InvalidProviderOutputReason,
|
||||
LLMEvent,
|
||||
ProviderInternalReason,
|
||||
UnknownProviderReason,
|
||||
Usage,
|
||||
type FinishReason,
|
||||
type FinishReasonDetails,
|
||||
@@ -224,16 +227,22 @@ const OpenAIChatChoice = Schema.StructWithRest(
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
|
||||
const OpenAIChatError = Schema.Struct({
|
||||
code: optionalNull(Schema.Union([Schema.String, Schema.Number])),
|
||||
message: Schema.String,
|
||||
})
|
||||
const OpenAIChatError = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
code: optionalNull(Schema.Union([Schema.String, Schema.Number])),
|
||||
message: Schema.String,
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
|
||||
export const OpenAIChatEvent = Schema.Struct({
|
||||
choices: optionalNull(Schema.Array(OpenAIChatChoice)),
|
||||
usage: optionalNull(OpenAIChatUsage),
|
||||
error: optionalNull(OpenAIChatError),
|
||||
})
|
||||
export const OpenAIChatEvent = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
choices: optionalNull(Schema.Array(OpenAIChatChoice)),
|
||||
usage: optionalNull(OpenAIChatUsage),
|
||||
error: optionalNull(OpenAIChatError),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
export type OpenAIChatEvent = Schema.Schema.Type<typeof OpenAIChatEvent>
|
||||
type OpenAIChatRequestMessage = LLMRequest["messages"][number]
|
||||
|
||||
@@ -256,6 +265,7 @@ export interface ParserState {
|
||||
readonly reasoningEmitted: boolean
|
||||
readonly latestToolIndex?: number
|
||||
readonly nextToolIndex: number
|
||||
readonly requireFinishReason: boolean
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -649,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
|
||||
@@ -726,14 +736,40 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (
|
||||
// Streaming parsers are small state machines: every event returns a new state
|
||||
// plus the common `LLMEvent`s produced by that event. Tool calls are accumulated
|
||||
// because OpenAI streams JSON arguments across multiple deltas.
|
||||
const mapFinishReason = (reason: string | null | undefined): FinishReason => {
|
||||
if (reason === "stop") return "stop"
|
||||
if (reason === "length") return "length"
|
||||
if (reason === "content_filter") return "content-filter"
|
||||
if (reason === "function_call" || reason === "tool_calls") return "tool-calls"
|
||||
if (reason === "error") return "error"
|
||||
return "unknown"
|
||||
}
|
||||
const finishReasonError = (event: OpenAIChatEvent, reason: AIError["reason"]) =>
|
||||
new AIError({
|
||||
module: ADAPTER,
|
||||
method: "stream",
|
||||
body: ProviderShared.encodeJson(event),
|
||||
reason,
|
||||
})
|
||||
|
||||
const mapFinishReason = Effect.fn("OpenAIChat.mapFinishReason")(function* (event: OpenAIChatEvent, reason: string) {
|
||||
switch (reason) {
|
||||
case "error":
|
||||
return yield* finishReasonError(
|
||||
event,
|
||||
new UnknownProviderReason({ message: "Provider reported an error (finish_reason: error)" }),
|
||||
)
|
||||
case "network_error":
|
||||
return yield* finishReasonError(
|
||||
event,
|
||||
new ProviderInternalReason({ message: "Provider reported a network error (finish_reason: network_error)" }),
|
||||
)
|
||||
case "stop":
|
||||
case "end":
|
||||
return "stop" as const
|
||||
case "length":
|
||||
return "length" as const
|
||||
case "content_filter":
|
||||
return "content-filter" as const
|
||||
case "function_call":
|
||||
case "tool_calls":
|
||||
return "tool-calls" as const
|
||||
default:
|
||||
return "unknown" as const
|
||||
}
|
||||
})
|
||||
|
||||
// OpenAI Chat reports `prompt_tokens` (inclusive total) with a
|
||||
// cached-read and cache-write subsets, and `completion_tokens` (inclusive
|
||||
@@ -846,16 +882,20 @@ const reasoningMetadata = (field: ParserState["reasoningField"], details?: Reado
|
||||
|
||||
const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
Effect.gen(function* () {
|
||||
if (event.error)
|
||||
if (event.error) {
|
||||
const body = ProviderShared.encodeJson(event)
|
||||
return yield* new AIError({
|
||||
module: ADAPTER,
|
||||
method: "stream",
|
||||
body,
|
||||
reason: classifyProviderFailure({
|
||||
message: event.error.message,
|
||||
code: event.error.code === undefined || event.error.code === null ? undefined : String(event.error.code),
|
||||
status: typeof event.error.code === "number" ? event.error.code : undefined,
|
||||
rawBody: body,
|
||||
}),
|
||||
})
|
||||
}
|
||||
const events: LLMEvent[] = []
|
||||
const choice = event.choices?.[0]
|
||||
// Moonshot (and a few other OpenAI-compatible providers) attach usage to
|
||||
@@ -864,8 +904,11 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
const usage = mapUsage(event.usage) ?? (choiceUsage ? mapUsage(choiceUsage) : undefined) ?? state.usage
|
||||
const rawFinishReason = choice?.finish_reason
|
||||
const finishReason =
|
||||
rawFinishReason !== undefined && rawFinishReason !== null
|
||||
? { normalized: mapFinishReason(rawFinishReason), raw: choice?.native_finish_reason ?? rawFinishReason }
|
||||
rawFinishReason
|
||||
? {
|
||||
normalized: yield* mapFinishReason(event, rawFinishReason),
|
||||
raw: choice?.native_finish_reason ?? rawFinishReason,
|
||||
}
|
||||
: state.finishReason
|
||||
const delta = choice?.delta
|
||||
const toolDeltas = delta?.tool_calls ?? []
|
||||
@@ -885,7 +928,11 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
toolDeltas.some((tool) => Boolean(tool.id) || Boolean(tool.function?.name) || Boolean(tool.function?.arguments))
|
||||
if (state.finishReason !== undefined) {
|
||||
if (hasLateContent)
|
||||
return yield* ProviderShared.eventError(ADAPTER, "OpenAI Chat received content after the finish reason")
|
||||
return yield* ProviderShared.eventError(
|
||||
ADAPTER,
|
||||
"OpenAI Chat received content after the finish reason",
|
||||
ProviderShared.encodeJson(event),
|
||||
)
|
||||
return [{ ...state, usage }, events] as const
|
||||
}
|
||||
|
||||
@@ -957,14 +1004,19 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
{ id: id || undefined, name: name || undefined, text },
|
||||
"OpenAI Chat tool call delta is missing id or name",
|
||||
)
|
||||
if (ToolStream.isError(result)) return yield* result
|
||||
if (ToolStream.isError(result))
|
||||
return yield* ProviderShared.eventError(ADAPTER, result.reason.message, ProviderShared.encodeJson(event))
|
||||
tools = result.tools
|
||||
if (result.events.length) lifecycle = Lifecycle.stepStart(lifecycle, events)
|
||||
events.push(...result.events)
|
||||
}
|
||||
|
||||
if (finishReason !== undefined && state.finishReason === undefined && Object.keys(pendingTools).length > 0)
|
||||
return yield* ProviderShared.eventError(ADAPTER, "OpenAI Chat tool call delta is missing id or name")
|
||||
return yield* ProviderShared.eventError(
|
||||
ADAPTER,
|
||||
"OpenAI Chat tool call delta is missing id or name",
|
||||
ProviderShared.encodeJson(event),
|
||||
)
|
||||
|
||||
// Finalize accumulated tool inputs eagerly when finish_reason arrives so
|
||||
// valid calls and malformed local calls settle independently.
|
||||
@@ -987,16 +1039,27 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
reasoningEmitted,
|
||||
latestToolIndex,
|
||||
nextToolIndex,
|
||||
requireFinishReason: state.requireFinishReason,
|
||||
},
|
||||
events,
|
||||
] as const
|
||||
})
|
||||
|
||||
const finishEvents = (state: ParserState): ReadonlyArray<LLMEvent> => {
|
||||
const finishEvents = Effect.fn("OpenAIChat.finishEvents")(function* (state: ParserState) {
|
||||
if (state.finishReason === undefined && state.requireFinishReason)
|
||||
return yield* new AIError({
|
||||
module: ADAPTER,
|
||||
method: "stream",
|
||||
reason: new InvalidProviderOutputReason({
|
||||
classification: "incomplete-stream",
|
||||
message: "OpenAI Chat stream ended without finish_reason",
|
||||
route: ADAPTER,
|
||||
}),
|
||||
})
|
||||
const events: LLMEvent[] = []
|
||||
const toolCallEvents =
|
||||
state.finishReason === undefined && Object.keys(state.tools).length > 0
|
||||
? Effect.runSync(ToolStream.finishAll(ADAPTER, state.tools)).events
|
||||
? (yield* ToolStream.finishAll(ADAPTER, state.tools)).events
|
||||
: state.toolCallEvents
|
||||
const hasToolCalls = toolCallEvents.length > 0
|
||||
const reason = state.finishReason
|
||||
@@ -1005,7 +1068,7 @@ const finishEvents = (state: ParserState): ReadonlyArray<LLMEvent> => {
|
||||
normalized:
|
||||
state.finishReason.normalized === "stop" && hasToolCalls ? "tool-calls" : state.finishReason.normalized,
|
||||
}
|
||||
: { normalized: hasToolCalls ? ("tool-calls" as const) : ("unknown" as const) }
|
||||
: { normalized: hasToolCalls ? ("tool-calls" as const) : ("stop" as const) }
|
||||
const metadata = reasoningMetadata(
|
||||
state.reasoningField,
|
||||
state.reasoningDetailsObserved ? state.reasoningDetails : undefined,
|
||||
@@ -1019,7 +1082,7 @@ const finishEvents = (state: ParserState): ReadonlyArray<LLMEvent> => {
|
||||
events.push(...toolCallEvents)
|
||||
Lifecycle.finish(lifecycle, events, { reason, usage: state.usage })
|
||||
return events
|
||||
}
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// Protocol And OpenAI Route
|
||||
@@ -1048,6 +1111,7 @@ export const protocol = Protocol.make({
|
||||
reasoningDetailsObserved: false,
|
||||
reasoningEmitted: false,
|
||||
nextToolIndex: 0,
|
||||
requireFinishReason: request.model.compatibility?.requireFinishReason ?? true,
|
||||
}),
|
||||
step,
|
||||
onHalt: finishEvents,
|
||||
|
||||
@@ -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,8 +1,10 @@
|
||||
import { Effect } from "effect"
|
||||
import { AIError, LLMEvent, type ProviderMetadata, type ToolCall, type ToolInputError } from "../../schema/index.js"
|
||||
import { Effect, Option } from "effect"
|
||||
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"
|
||||
|
||||
type StreamKey = string | number
|
||||
const parsePartialInput = Option.liftThrowable(parse)
|
||||
|
||||
/**
|
||||
* One pending streamed tool call. Providers emit the tool identity and JSON
|
||||
@@ -62,38 +64,39 @@ const inputDelta = (tool: PendingTool, text: string) =>
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
text,
|
||||
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>(
|
||||
@@ -176,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({
|
||||
|
||||
@@ -321,12 +321,30 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
||||
Stream.mapEffect(decodeEvent(route)),
|
||||
protocol.stream.terminal ? Stream.takeUntil(protocol.stream.terminal) : (stream) => stream,
|
||||
)
|
||||
const stream = events.pipe(
|
||||
Stream.mapAccumEffect(
|
||||
() => protocol.stream.initial(request),
|
||||
protocol.stream.step,
|
||||
protocol.stream.onHalt ? { onHalt: protocol.stream.onHalt } : undefined,
|
||||
),
|
||||
const stream = Stream.suspend(() => {
|
||||
let state = protocol.stream.initial(request)
|
||||
const parsed = events.pipe(
|
||||
Stream.mapEffect((event) =>
|
||||
protocol.stream.step(state, event).pipe(
|
||||
Effect.map(([next, output]) => {
|
||||
state = next
|
||||
return output
|
||||
}),
|
||||
),
|
||||
),
|
||||
Stream.flatMap(Stream.fromIterable),
|
||||
)
|
||||
const onHalt = protocol.stream.onHalt
|
||||
return onHalt
|
||||
? parsed.pipe(
|
||||
Stream.concat(
|
||||
Stream.suspend(() =>
|
||||
Stream.unwrap(onHalt(state).pipe(Effect.map(Stream.fromIterable))),
|
||||
),
|
||||
),
|
||||
)
|
||||
: parsed
|
||||
}).pipe(
|
||||
Stream.catchCause((cause) => Stream.fail(streamError(route, `Failed to read ${route} stream`, cause))),
|
||||
requireTerminalEvent(route),
|
||||
)
|
||||
|
||||
@@ -59,8 +59,8 @@ export interface ProtocolStream<Frame, Event, State> {
|
||||
readonly step: (state: State, event: Event) => Effect.Effect<readonly [State, ReadonlyArray<LLMEvent>], AIError>
|
||||
/** Optional request-completion signal for transports that do not end naturally. */
|
||||
readonly terminal?: (event: Event) => boolean
|
||||
/** Optional flush emitted when the framed stream ends. */
|
||||
readonly onHalt?: (state: State) => ReadonlyArray<LLMEvent>
|
||||
/** Optional effectful flush emitted when the framed stream ends. */
|
||||
readonly onHalt?: (state: State) => Effect.Effect<ReadonlyArray<LLMEvent>, AIError>
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -152,6 +152,8 @@ export const ToolInputDelta = Schema.Struct({
|
||||
id: ToolCallID,
|
||||
name: Schema.String,
|
||||
text: Schema.String,
|
||||
/** Best-effort parse of all input fragments received through this delta. */
|
||||
input: Schema.optional(Schema.Unknown),
|
||||
}).annotate({ identifier: "LLM.Event.ToolInputDelta" })
|
||||
export type ToolInputDelta = Schema.Schema.Type<typeof ToolInputDelta>
|
||||
|
||||
|
||||
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 () => {
|
||||
|
||||
@@ -585,12 +585,11 @@ describe("Anthropic Messages route", () => {
|
||||
|
||||
it.effect("infers empty-signature compatibility across Kimi providers", () =>
|
||||
Effect.gen(function* () {
|
||||
const coding = AnthropicMessages.route
|
||||
.with({
|
||||
provider: "kimi-for-coding",
|
||||
endpoint: { baseURL: "https://compatible.test/v1/" },
|
||||
auth: Auth.header("x-api-key", "test"),
|
||||
})
|
||||
const coding = AnthropicMessages.route.with({
|
||||
provider: "kimi-for-coding",
|
||||
endpoint: { baseURL: "https://compatible.test/v1/" },
|
||||
auth: Auth.header("x-api-key", "test"),
|
||||
})
|
||||
const moonshot = AnthropicMessages.route
|
||||
.with({
|
||||
provider: "moonshotai",
|
||||
@@ -771,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(
|
||||
@@ -1129,8 +1230,14 @@ describe("Anthropic Messages route", () => {
|
||||
expect(response.events).toEqual([
|
||||
{ type: "step-start", index: 0 },
|
||||
{ type: "tool-input-start", id: "call_1", name: "lookup" },
|
||||
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' },
|
||||
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' },
|
||||
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"', input: {} },
|
||||
{
|
||||
type: "tool-input-delta",
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
text: ':"weather"}',
|
||||
input: { query: "weather" },
|
||||
},
|
||||
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata: undefined },
|
||||
{
|
||||
type: "tool-call",
|
||||
|
||||
@@ -475,8 +475,14 @@ describe("Bedrock Converse route", () => {
|
||||
])
|
||||
const events = response.events.filter((event) => event.type === "tool-input-delta")
|
||||
expect(events).toEqual([
|
||||
{ type: "tool-input-delta", id: "tool_1", name: "lookup", text: '{"query"' },
|
||||
{ type: "tool-input-delta", id: "tool_1", name: "lookup", text: ':"weather"}' },
|
||||
{ type: "tool-input-delta", id: "tool_1", name: "lookup", text: '{"query"', input: {} },
|
||||
{
|
||||
type: "tool-input-delta",
|
||||
id: "tool_1",
|
||||
name: "lookup",
|
||||
text: ':"weather"}',
|
||||
input: { query: "weather" },
|
||||
},
|
||||
])
|
||||
expect(response.events.at(-1)).toMatchObject({
|
||||
type: "finish",
|
||||
@@ -485,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" }],
|
||||
@@ -502,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" })
|
||||
}),
|
||||
|
||||
@@ -6,6 +6,7 @@ import { AmazonBedrockMantle } from "../../src/providers.js"
|
||||
import { compileRequest, LLMClient } from "../../src/route/client.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
import { dynamicResponse } from "../lib/http.js"
|
||||
import { sseEvents } from "../lib/sse.js"
|
||||
import { recordedTests } from "../recorded-test.js"
|
||||
|
||||
const credentials = {
|
||||
@@ -71,7 +72,9 @@ describe("Amazon Bedrock Mantle provider", () => {
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpClientRequest.toWeb(input.request)
|
||||
seen.push({ url: request.url, authorization: request.headers.get("authorization") ?? undefined })
|
||||
return input.respond("", { headers: { "content-type": "text/event-stream" } })
|
||||
return input.respond(sseEvents({ choices: [{ delta: {}, finish_reason: "stop" }] }), {
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
})
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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({
|
||||
@@ -1164,8 +1179,14 @@ describe("OpenAI Chat route", () => {
|
||||
expect(response.events).toEqual([
|
||||
{ type: "step-start", index: 0 },
|
||||
{ type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata: undefined },
|
||||
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' },
|
||||
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' },
|
||||
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"', input: {} },
|
||||
{
|
||||
type: "tool-input-delta",
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
text: ':"weather"}',
|
||||
input: { query: "weather" },
|
||||
},
|
||||
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata: undefined },
|
||||
{
|
||||
type: "tool-call",
|
||||
@@ -1245,6 +1266,11 @@ describe("OpenAI Chat route", () => {
|
||||
).pipe(Effect.provide(fixedResponse(body)), Effect.flip)
|
||||
|
||||
expect(error.message).toContain("OpenAI Chat tool call delta is missing id or name")
|
||||
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||
if (error.reason._tag !== "InvalidProviderOutput") return
|
||||
expect(decodeJson(error.reason.raw ?? "")).toMatchObject({
|
||||
choices: [{ finish_reason: "tool_calls" }],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1258,6 +1284,7 @@ describe("OpenAI Chat route", () => {
|
||||
deltaChunk({ tool_calls: [{ index: 0, function: { arguments: ':"weather"}' } }] }),
|
||||
)
|
||||
const input = LLMRequest.update(request, {
|
||||
model: LanguageModel.update(model, { compatibility: { requireFinishReason: false } }),
|
||||
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
|
||||
})
|
||||
const response = yield* LLMClient.generate(input).pipe(Effect.provide(fixedResponse(body)))
|
||||
@@ -1265,8 +1292,14 @@ describe("OpenAI Chat route", () => {
|
||||
expect(response.events).toEqual([
|
||||
{ type: "step-start", index: 0 },
|
||||
{ type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata: undefined },
|
||||
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' },
|
||||
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' },
|
||||
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"', input: {} },
|
||||
{
|
||||
type: "tool-input-delta",
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
text: ':"weather"}',
|
||||
input: { query: "weather" },
|
||||
},
|
||||
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata: undefined },
|
||||
{
|
||||
type: "tool-call",
|
||||
|
||||
@@ -353,13 +353,106 @@ describe("OpenAI-compatible Chat route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("treats an empty finish reason as terminal", () =>
|
||||
it.effect("rejects a stream without a required finish reason", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents(deltaChunk({ content: "Hello" }), deltaChunk({}, "")))),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "InvalidProviderOutput",
|
||||
classification: "incomplete-stream",
|
||||
message: "OpenAI Chat stream ended without finish_reason",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("infers stop when finish reasons are optional", () =>
|
||||
Effect.gen(function* () {
|
||||
const compatible = OpenAICompatibleChat.route
|
||||
.with({ provider: "custom", endpoint: { baseURL: "https://api.custom.test/v1" } })
|
||||
.model({ id: "custom-model", compatibility: { requireFinishReason: false } })
|
||||
const response = yield* LLMClient.generate(LLMRequest.update(request, { model: compatible })).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents(deltaChunk({ content: "Hello" }), deltaChunk({}, "")))),
|
||||
)
|
||||
|
||||
expect(response.finishReason).toEqual({ normalized: "unknown", raw: "" })
|
||||
expect(response.finishReason).toEqual({ normalized: "stop" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("normalizes the end finish reason to stop", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents(deltaChunk({ content: "Hello" }), deltaChunk({}, "end")))),
|
||||
)
|
||||
|
||||
expect(response.finishReason).toEqual({ normalized: "stop", raw: "end" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("classifies provider error finish reasons", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents(deltaChunk({}, "network_error")))),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "ProviderInternal",
|
||||
message: "Provider reported a network error (finish_reason: network_error)",
|
||||
})
|
||||
expect(decodeJson(error.body ?? "")).toMatchObject({
|
||||
id: "chatcmpl_fixture",
|
||||
choices: [{ finish_reason: "network_error" }],
|
||||
})
|
||||
|
||||
const generic = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents(deltaChunk({}, "error")))),
|
||||
Effect.flip,
|
||||
)
|
||||
expect(generic.reason).toMatchObject({
|
||||
_tag: "UnknownProvider",
|
||||
message: "Provider reported an error (finish_reason: error)",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves explicit provider error events", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents({
|
||||
id: "chatcmpl_error",
|
||||
error: { code: 502, message: "Provider disconnected", details: { upstream: "vendor" } },
|
||||
trace_id: "trace_1",
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({ _tag: "ProviderInternal", message: "Provider disconnected", status: 502 })
|
||||
expect(decodeJson(error.body ?? "")).toMatchObject({
|
||||
id: "chatcmpl_error",
|
||||
error: { code: 502, message: "Provider disconnected", details: { upstream: "vendor" } },
|
||||
trace_id: "trace_1",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves provider finish outcomes in the common reason algebra", () =>
|
||||
Effect.gen(function* () {
|
||||
const filtered = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents(deltaChunk({}, "content_filter")))),
|
||||
)
|
||||
const future = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents(deltaChunk({}, "future_reason")))),
|
||||
)
|
||||
|
||||
expect(filtered.finishReason).toEqual({ normalized: "content-filter", raw: "content_filter" })
|
||||
expect(future.finishReason).toEqual({ normalized: "unknown", raw: "future_reason" })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -379,6 +472,11 @@ describe("OpenAI-compatible Chat route", () => {
|
||||
)
|
||||
|
||||
expect(error.message).toContain("OpenAI Chat received content after the finish reason")
|
||||
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||
if (error.reason._tag !== "InvalidProviderOutput") return
|
||||
expect(decodeJson(error.reason.raw ?? "")).toMatchObject({
|
||||
choices: [{ delta: { tool_calls: [{ id: "call_1" }] } }],
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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(
|
||||
@@ -1986,6 +2009,11 @@ describe("OpenAI Responses route", () => {
|
||||
item_id: "fc_missing",
|
||||
delta: '{"orphaned":true}',
|
||||
},
|
||||
{
|
||||
type: "response.function_call_arguments.done",
|
||||
item_id: "fc_missing",
|
||||
arguments: '{"orphaned":true}',
|
||||
},
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
),
|
||||
),
|
||||
@@ -1998,22 +2026,22 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects function argument deltas without the spec-required item id", () =>
|
||||
it.effect("rejects function argument events without the spec-required item id", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "response.function_call_arguments.delta", delta: "{}" },
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
const events = [
|
||||
{ type: "response.function_call_arguments.delta", delta: "{}" },
|
||||
{ type: "response.function_call_arguments.done", arguments: "{}" },
|
||||
]
|
||||
|
||||
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||
expect(error.message).toContain("response.function_call_arguments.delta is missing item_id")
|
||||
for (const event of events) {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents(event, { type: "response.completed", response: { id: "resp_1" } }))),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||
expect(error.message).toContain(`${event.type} is missing item_id`)
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -2806,12 +2834,14 @@ describe("OpenAI Responses route", () => {
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
text: '{"query"',
|
||||
input: {},
|
||||
},
|
||||
{
|
||||
type: "tool-input-delta",
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
text: ':"weather"}',
|
||||
input: { query: "weather" },
|
||||
},
|
||||
{
|
||||
type: "tool-input-end",
|
||||
@@ -2855,6 +2885,172 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("emits only missing function arguments from the arguments done event", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "function_call", id: "fc_item_1", call_id: "call_1", name: "lookup", arguments: "" },
|
||||
},
|
||||
{ type: "response.function_call_arguments.delta", item_id: "fc_item_1", delta: '{"query"' },
|
||||
{
|
||||
type: "response.function_call_arguments.done",
|
||||
item_id: "fc_item_1",
|
||||
arguments: '{"query":"weather"}',
|
||||
},
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "function_call",
|
||||
id: "fc_item_1",
|
||||
call_id: "call_1",
|
||||
name: "lookup",
|
||||
arguments: '{"query":"weather"}',
|
||||
},
|
||||
},
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
)
|
||||
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
|
||||
|
||||
expect(response.events.filter((event) => event.type === "tool-input-delta")).toEqual([
|
||||
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"', input: {} },
|
||||
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}', input: { query: "weather" } },
|
||||
])
|
||||
expect(response.events.filter(LLMEvent.is.toolInputEnd)).toHaveLength(1)
|
||||
expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([
|
||||
{
|
||||
type: "tool-call",
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
input: { query: "weather" },
|
||||
providerExecuted: undefined,
|
||||
providerMetadata: { openai: { itemId: "fc_item_1" } },
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("streams complete function arguments supplied only by the arguments done event", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "function_call", id: "fc_item_1", call_id: "call_1", name: "lookup", arguments: "" },
|
||||
},
|
||||
{
|
||||
type: "response.function_call_arguments.done",
|
||||
item_id: "fc_item_1",
|
||||
arguments: '{"query":"weather"}',
|
||||
},
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
)
|
||||
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
|
||||
|
||||
expect(response.events.filter((event) => event.type === "tool-input-delta")).toEqual([
|
||||
{
|
||||
type: "tool-input-delta",
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
text: '{"query":"weather"}',
|
||||
input: { query: "weather" },
|
||||
},
|
||||
])
|
||||
expect(response.events.find(LLMEvent.is.toolCall)).toMatchObject({ input: { query: "weather" } })
|
||||
expect(response.finishReason.normalized).toBe("tool-calls")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not repeat function arguments already supplied by deltas", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "function_call", id: "fc_item_1", call_id: "call_1", name: "lookup", arguments: "" },
|
||||
},
|
||||
{
|
||||
type: "response.function_call_arguments.delta",
|
||||
item_id: "fc_item_1",
|
||||
delta: '{"query":"weather"}',
|
||||
},
|
||||
{
|
||||
type: "response.function_call_arguments.done",
|
||||
item_id: "fc_item_1",
|
||||
arguments: '{"query":"weather"}',
|
||||
},
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
)
|
||||
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
|
||||
|
||||
expect(response.events.filter((event) => event.type === "tool-input-delta")).toHaveLength(1)
|
||||
expect(response.events.find(LLMEvent.is.toolCall)).toMatchObject({ input: { query: "weather" } })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses authoritative arguments done input without emitting a mismatched delta", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "function_call", id: "fc_item_1", call_id: "call_1", name: "lookup", arguments: "" },
|
||||
},
|
||||
{
|
||||
type: "response.function_call_arguments.delta",
|
||||
item_id: "fc_item_1",
|
||||
delta: '{"query":"streamed"}',
|
||||
},
|
||||
{
|
||||
type: "response.function_call_arguments.done",
|
||||
item_id: "fc_item_1",
|
||||
arguments: '{"query":"final"}',
|
||||
},
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
)
|
||||
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
|
||||
|
||||
expect(response.events.filter((event) => event.type === "tool-input-delta")).toEqual([
|
||||
{
|
||||
type: "tool-input-delta",
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
text: '{"query":"streamed"}',
|
||||
input: { query: "streamed" },
|
||||
},
|
||||
])
|
||||
expect(response.events.find(LLMEvent.is.toolCall)).toMatchObject({ input: { query: "final" } })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lets completed output item arguments override the arguments done event", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "function_call", id: "fc_item_1", call_id: "call_1", name: "lookup", arguments: "" },
|
||||
},
|
||||
{
|
||||
type: "response.function_call_arguments.done",
|
||||
item_id: "fc_item_1",
|
||||
arguments: '{"query":"arguments-done"}',
|
||||
},
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "function_call",
|
||||
id: "fc_item_1",
|
||||
call_id: "call_1",
|
||||
name: "lookup",
|
||||
arguments: '{"query":"output-item-done"}',
|
||||
},
|
||||
},
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
)
|
||||
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
|
||||
|
||||
expect(response.events.find(LLMEvent.is.toolCall)).toMatchObject({ input: { query: "output-item-done" } })
|
||||
expect(response.events.filter(LLMEvent.is.toolCall)).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("finalizes a pending function call at response completion", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
@@ -2888,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(
|
||||
{
|
||||
@@ -2914,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(
|
||||
{
|
||||
@@ -2942,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()
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -23,9 +23,11 @@ describe("ToolStream", () => {
|
||||
|
||||
expect(first.events).toEqual([
|
||||
{ type: "tool-input-start", id: "call_1", name: "lookup" },
|
||||
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' },
|
||||
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"', input: {} },
|
||||
])
|
||||
expect(second.events).toEqual([
|
||||
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}', input: { query: "weather" } },
|
||||
])
|
||||
expect(second.events).toEqual([{ type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' }])
|
||||
expect(finished).toEqual({
|
||||
tools: {},
|
||||
events: [
|
||||
@@ -36,6 +38,45 @@ describe("ToolStream", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("exposes cumulative partial string values", () =>
|
||||
Effect.gen(function* () {
|
||||
const result = ToolStream.appendOrStart(
|
||||
ADAPTER,
|
||||
ToolStream.empty<number>(),
|
||||
0,
|
||||
{ id: "call_1", name: "lookup", text: '{"query":"wea' },
|
||||
"missing tool",
|
||||
)
|
||||
if (ToolStream.isError(result)) return yield* result
|
||||
|
||||
expect(result.events.at(-1)).toEqual({
|
||||
type: "tool-input-delta",
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
text: '{"query":"wea',
|
||||
input: { query: "wea" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("defaults partial input to an empty object when the accumulated value cannot be parsed", () =>
|
||||
Effect.gen(function* () {
|
||||
const result = ToolStream.appendOrStart(
|
||||
ADAPTER,
|
||||
ToolStream.empty<number>(),
|
||||
0,
|
||||
{ id: "call_1", name: "lookup", text: "x" },
|
||||
"missing tool",
|
||||
)
|
||||
if (ToolStream.isError(result)) return yield* result
|
||||
|
||||
expect(result.events).toEqual([
|
||||
{ type: "tool-input-start", id: "call_1", name: "lookup" },
|
||||
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: "x", input: {} },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps accumulated identity when later deltas contain empty strings", () =>
|
||||
Effect.gen(function* () {
|
||||
const first = ToolStream.appendOrStart(
|
||||
@@ -91,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",
|
||||
@@ -103,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",
|
||||
@@ -133,12 +202,8 @@ describe("ToolStream", () => {
|
||||
events: [
|
||||
{ type: "tool-input-end", id: "call_valid", name: "lookup" },
|
||||
{ type: "tool-call", id: "call_valid", name: "lookup", input: { query: "weather" } },
|
||||
{
|
||||
type: "tool-input-error",
|
||||
id: "call_invalid",
|
||||
name: "lookup",
|
||||
raw: '{"query":"partial',
|
||||
},
|
||||
{ type: "tool-input-end", id: "call_invalid", name: "lookup" },
|
||||
{ type: "tool-call", id: "call_invalid", name: "lookup", input: { query: "partial" } },
|
||||
],
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectAppVisible } from "../utils/waits"
|
||||
|
||||
const directory = "C:/OpenCode/SessionQueueRegression"
|
||||
const projectID = "proj_session_queue_regression"
|
||||
const sessionID = "ses_session_queue_regression"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
|
||||
type InboxRow = {
|
||||
id: string
|
||||
sessionID: string
|
||||
timeCreated: number
|
||||
type: "user"
|
||||
payload: { text: string; metadata?: Record<string, unknown> }
|
||||
delivery: "steer" | "queue"
|
||||
}
|
||||
|
||||
function createQueueMock(seed: string[]) {
|
||||
const rows: InboxRow[] = seed.map((text, index) => ({
|
||||
id: `inb_seed_${index + 1}`,
|
||||
sessionID,
|
||||
timeCreated: 1700000000000 + index,
|
||||
type: "user",
|
||||
payload: { text },
|
||||
delivery: "queue",
|
||||
}))
|
||||
const events: OpenCodeEvent[] = []
|
||||
const prompts: Record<string, unknown>[] = []
|
||||
const changes: { inboxID: string; action: "cancel" | "steer" }[] = []
|
||||
const log: string[] = []
|
||||
let sequence = 0
|
||||
const emit = (type: OpenCodeEvent["type"], data: OpenCodeEvent["data"]) => {
|
||||
sequence += 1
|
||||
events.push({
|
||||
id: `evt_queue_${sequence}`,
|
||||
type,
|
||||
created: Date.now(),
|
||||
durable: { aggregateID: sessionID, seq: sequence, version: 1 },
|
||||
data,
|
||||
} as OpenCodeEvent)
|
||||
}
|
||||
return {
|
||||
rows,
|
||||
prompts,
|
||||
changes,
|
||||
log,
|
||||
events: () => events.splice(0),
|
||||
onPrompt: (input: { sessionID: string; body: Record<string, unknown> }) => {
|
||||
prompts.push(input.body)
|
||||
log.push(`prompt:${String(input.body.delivery ?? "steer")}`)
|
||||
const row: InboxRow = {
|
||||
id: typeof input.body.id === "string" ? input.body.id : `inb_mock_${sequence}`,
|
||||
sessionID: input.sessionID,
|
||||
timeCreated: Date.now(),
|
||||
type: "user",
|
||||
payload: {
|
||||
text: typeof input.body.text === "string" ? input.body.text : "",
|
||||
...(input.body.metadata === undefined ? {} : { metadata: input.body.metadata as Record<string, unknown> }),
|
||||
},
|
||||
delivery: input.body.delivery === "queue" ? "queue" : "steer",
|
||||
}
|
||||
rows.push(row)
|
||||
emit("session.inbox.enqueued", {
|
||||
sessionID: input.sessionID,
|
||||
inboxID: row.id,
|
||||
item: { type: "user", payload: row.payload, delivery: row.delivery },
|
||||
})
|
||||
},
|
||||
onInboxChange: (input: { sessionID: string; inboxID: string; action: "cancel" | "steer" }) => {
|
||||
changes.push({ inboxID: input.inboxID, action: input.action })
|
||||
log.push(`${input.action}:${input.inboxID}`)
|
||||
const index = rows.findIndex((row) => row.id === input.inboxID)
|
||||
const row = rows[index]
|
||||
if (!row) return
|
||||
if (input.action === "cancel") {
|
||||
rows.splice(index, 1)
|
||||
emit("session.inbox.cancelled", { sessionID: input.sessionID, inboxID: input.inboxID })
|
||||
return
|
||||
}
|
||||
row.delivery = "steer"
|
||||
emit("session.inbox.delivery.changed", {
|
||||
sessionID: input.sessionID,
|
||||
inboxID: input.inboxID,
|
||||
delivery: "steer",
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function openSession(page: Page, mock: ReturnType<typeof createQueueMock>, followUpBehavior?: "queue" | "steer") {
|
||||
if (followUpBehavior) {
|
||||
await page.addInitScript(
|
||||
(behavior) => localStorage.setItem("settings.v3", JSON.stringify({ general: { followUpBehavior: behavior } })),
|
||||
followUpBehavior,
|
||||
)
|
||||
}
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "session-queue-regression",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: {
|
||||
all: [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
models: { "queue-model": { id: "queue-model", name: "Queue Model", limit: { context: 200_000 } } },
|
||||
},
|
||||
],
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: "queue-model" },
|
||||
},
|
||||
sessions: [
|
||||
{
|
||||
id: sessionID,
|
||||
slug: "session-queue-regression",
|
||||
projectID,
|
||||
directory,
|
||||
title: "Session queue regression",
|
||||
version: "dev",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
},
|
||||
],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
sessionStatus: () => ({ [sessionID]: { type: "running" } }),
|
||||
inbox: () => mock.rows.map((row) => ({ ...row, payload: { ...row.payload } })),
|
||||
onPrompt: mock.onPrompt,
|
||||
onInboxChange: mock.onInboxChange,
|
||||
events: mock.events,
|
||||
})
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
const composer = page.locator('[data-component="composer"]')
|
||||
await expectAppVisible(composer)
|
||||
return {
|
||||
composer,
|
||||
input: composer.locator('[data-component="composer-editor"]'),
|
||||
rows: page.locator('[data-component="session-queue-row"]'),
|
||||
}
|
||||
}
|
||||
|
||||
test("follow-up preference controls Enter while Mod+Enter uses the alternate delivery", async ({ page }) => {
|
||||
const mock = createQueueMock([])
|
||||
const view = await openSession(page, mock, "queue")
|
||||
|
||||
await view.input.fill("queue this follow-up")
|
||||
await expect(view.composer.locator('[data-action="composer-alternate-delivery"]')).toContainText("Steer")
|
||||
await view.input.press("Enter")
|
||||
await expect(view.rows.getByText("queue this follow-up", { exact: true })).toBeVisible()
|
||||
|
||||
await view.input.fill("steer this correction")
|
||||
await view.input.press("ControlOrMeta+Enter")
|
||||
await expect.poll(() => mock.prompts.map((prompt) => prompt.delivery)).toEqual(["queue", "steer"])
|
||||
await expect(view.input).toHaveText("")
|
||||
})
|
||||
|
||||
test("dragging reorders queued prompts", async ({ page }) => {
|
||||
const mock = createQueueMock(["first queued prompt", "second queued prompt", "third queued prompt"])
|
||||
const view = await openSession(page, mock)
|
||||
await expect(view.rows).toHaveCount(3)
|
||||
|
||||
const first = view.rows.filter({ hasText: "first queued prompt" })
|
||||
const third = view.rows.filter({ hasText: "third queued prompt" })
|
||||
await first.getByRole("button", { name: "Reorder queued prompt" }).hover()
|
||||
await page.mouse.down()
|
||||
const target = await third.boundingBox()
|
||||
if (!target) throw new Error("The target queue row is not visible")
|
||||
await page.mouse.move(target.x + target.width / 2, target.y + target.height / 2, { steps: 10 })
|
||||
await page.mouse.up()
|
||||
|
||||
await expect(view.rows.locator('[data-action="session-queue-edit"]')).toHaveText([
|
||||
"second queued prompt",
|
||||
"third queued prompt",
|
||||
"first queued prompt",
|
||||
])
|
||||
expect(mock.prompts.map((prompt) => prompt.text)).toEqual([
|
||||
"second queued prompt",
|
||||
"third queued prompt",
|
||||
"first queued prompt",
|
||||
])
|
||||
expect(mock.changes).toEqual([
|
||||
{ inboxID: "inb_seed_1", action: "cancel" },
|
||||
{ inboxID: "inb_seed_2", action: "cancel" },
|
||||
{ inboxID: "inb_seed_3", action: "cancel" },
|
||||
])
|
||||
})
|
||||
|
||||
test("editing restores the existing draft and replaces only the original queue position", async ({ page }) => {
|
||||
const mock = createQueueMock(["first queued prompt", "tighten the error copy", "third queued prompt"])
|
||||
const view = await openSession(page, mock)
|
||||
const original = view.rows.getByText("tighten the error copy", { exact: true })
|
||||
await expect(original).toBeVisible()
|
||||
|
||||
await view.input.fill("my in-progress draft")
|
||||
await original.click()
|
||||
await expect(view.input).toHaveText("tighten the error copy")
|
||||
await view.input.press("Escape")
|
||||
await expect(view.input).toHaveText("my in-progress draft")
|
||||
|
||||
await original.click()
|
||||
await expect(view.input).toHaveText("tighten the error copy")
|
||||
await view.input.fill("tighten the error copy and add a retry hint")
|
||||
await view.input.press("Enter")
|
||||
|
||||
await expect(view.rows.locator('[data-action="session-queue-edit"]')).toHaveText([
|
||||
"first queued prompt",
|
||||
"tighten the error copy and add a retry hint",
|
||||
"third queued prompt",
|
||||
])
|
||||
await expect(view.input).toHaveText("my in-progress draft")
|
||||
expect(mock.prompts.map((prompt) => prompt.text)).toEqual([
|
||||
"tighten the error copy and add a retry hint",
|
||||
"tighten the error copy and add a retry hint",
|
||||
"third queued prompt",
|
||||
])
|
||||
expect(mock.prompts.every((prompt) => prompt.delivery === "queue" && prompt.resume === false)).toBe(true)
|
||||
expect(mock.changes.map((change) => change.action)).toEqual(["cancel", "cancel", "cancel"])
|
||||
expect(mock.log[0]).toBe("prompt:queue")
|
||||
})
|
||||
@@ -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(
|
||||
|
||||
@@ -174,6 +174,39 @@ const Group = HttpApiGroup.make("mock")
|
||||
success: Json,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("sessionPrompt", "/api/session/:sessionID/prompt", {
|
||||
params: SessionParams,
|
||||
payload: JsonPayload,
|
||||
success: Json,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("sessionSwitchAgent", "/api/session/:sessionID/agent", {
|
||||
params: SessionParams,
|
||||
payload: JsonPayload,
|
||||
success: NoContent,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("sessionSwitchModel", "/api/session/:sessionID/model", {
|
||||
params: SessionParams,
|
||||
payload: JsonPayload,
|
||||
success: NoContent,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.delete("sessionInboxCancel", "/api/session/:sessionID/inbox/:inboxID", {
|
||||
params: { ...SessionParams, inboxID: Schema.String },
|
||||
success: NoContent,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("sessionInboxSteer", "/api/session/:sessionID/inbox/:inboxID/steer", {
|
||||
params: { ...SessionParams, inboxID: Schema.String },
|
||||
success: NoContent,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("sessionPermission", "/api/session/:sessionID/permission", {
|
||||
params: SessionParams,
|
||||
|
||||
@@ -35,6 +35,9 @@ export interface MockServerConfig {
|
||||
fileContent?: (path: string) => unknown | Promise<unknown>
|
||||
findFiles?: (input: { query: string; dirs?: string; limit?: number }) => unknown
|
||||
sessionStatus?: Record<string, unknown> | (() => Record<string, unknown>)
|
||||
inbox?: unknown[] | (() => unknown[])
|
||||
onPrompt?: (input: { sessionID: string; body: Record<string, unknown> }) => void
|
||||
onInboxChange?: (input: { sessionID: string; inboxID: string; action: "cancel" | "steer" }) => void
|
||||
}
|
||||
|
||||
type MockStreamWindow = Window & {
|
||||
@@ -397,7 +400,39 @@ function mockHandlers(config: MockServerConfig, state: { cursors: Map<string, st
|
||||
sessionFormReply: () => noContent,
|
||||
sessionFormCancel: () => noContent,
|
||||
sessionBackground: () => noContent,
|
||||
sessionInbox: () => Effect.succeed({ data: [] }),
|
||||
sessionInbox: () =>
|
||||
Effect.sync(() => ({ data: typeof config.inbox === "function" ? config.inbox() : (config.inbox ?? []) })),
|
||||
sessionPrompt: (ctx) =>
|
||||
Effect.sync(() => {
|
||||
const body = record(ctx.payload) ? ctx.payload : {}
|
||||
config.onPrompt?.({ sessionID: ctx.params.sessionID, body })
|
||||
return {
|
||||
data: {
|
||||
id: typeof body.id === "string" ? body.id : `inb_mock_${Date.now()}`,
|
||||
sessionID: ctx.params.sessionID,
|
||||
timeCreated: Date.now(),
|
||||
type: "user",
|
||||
payload: {
|
||||
text: typeof body.text === "string" ? body.text : "",
|
||||
...(body.files === undefined ? {} : { files: body.files }),
|
||||
...(body.agents === undefined ? {} : { agents: body.agents }),
|
||||
...(body.skills === undefined ? {} : { skills: body.skills }),
|
||||
...(body.metadata === undefined ? {} : { metadata: body.metadata }),
|
||||
},
|
||||
delivery: body.delivery === "queue" ? "queue" : "steer",
|
||||
},
|
||||
}
|
||||
}),
|
||||
sessionInboxCancel: (ctx) =>
|
||||
Effect.sync(() =>
|
||||
config.onInboxChange?.({ sessionID: ctx.params.sessionID, inboxID: ctx.params.inboxID, action: "cancel" }),
|
||||
).pipe(Effect.andThen(noContent)),
|
||||
sessionInboxSteer: (ctx) =>
|
||||
Effect.sync(() =>
|
||||
config.onInboxChange?.({ sessionID: ctx.params.sessionID, inboxID: ctx.params.inboxID, action: "steer" }),
|
||||
).pipe(Effect.andThen(noContent)),
|
||||
sessionSwitchAgent: () => noContent,
|
||||
sessionSwitchModel: () => noContent,
|
||||
sessionPermission: (ctx) => {
|
||||
const permissions =
|
||||
typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])
|
||||
|
||||
@@ -39,6 +39,26 @@ export type ComposerSelection = {
|
||||
variant?: string
|
||||
}
|
||||
|
||||
export type ComposerDelivery = "steer" | "queue"
|
||||
|
||||
// Contract between the composer and the session prompt queue. The session
|
||||
// owns the queue (pending inbox items); the composer only asks which delivery
|
||||
// a submit should use and delegates edit confirmation while a queued prompt
|
||||
// is loaded in the editor.
|
||||
export type ComposerQueue = {
|
||||
count: Accessor<number>
|
||||
// Delivery a plain submit uses right now.
|
||||
delivery: Accessor<ComposerDelivery>
|
||||
// Delivery offered on Mod+Enter and the toolbar hint button; undefined hides the hint.
|
||||
alternate: Accessor<ComposerDelivery | undefined>
|
||||
// Inbox ID of the queued prompt currently loaded in the composer for editing.
|
||||
editing: Accessor<string | undefined>
|
||||
confirmEdit: (delivery: ComposerDelivery) => void
|
||||
cancelEdit: () => void
|
||||
// Loads the first queued prompt into the composer. Returns false when the queue is empty.
|
||||
editFirst: () => boolean
|
||||
}
|
||||
|
||||
export type ComposerSession = {
|
||||
id: string
|
||||
directory: string
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { ComposerEditor } from "./editor/editor"
|
||||
import { ModelSelectorPopover } from "@/providers/models/select-dialog"
|
||||
import { DialogSelectModelUnpaid } from "@/providers/models/unpaid"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import { formatKeybind, useCommand } from "@/shell/commands/command"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import type { ComposerModel } from "./model"
|
||||
|
||||
@@ -32,6 +32,7 @@ export function Composer(props: {
|
||||
modelControlsVisible={!props.model.model.loading}
|
||||
attachKeybind={command.keybindParts("file.attach")}
|
||||
attachShortcut={command.keybind("file.attach")}
|
||||
alternateKeybind={[formatKeybind("mod", language.t), formatKeybind("enter", language.t)]}
|
||||
modelControl={
|
||||
<ComposerModelControl
|
||||
loading={props.model.model.loading}
|
||||
|
||||
@@ -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"
|
||||
@@ -45,6 +46,7 @@ export type ComposerEditorProps = {
|
||||
modelControlsVisible?: boolean
|
||||
attachKeybind?: string[]
|
||||
attachShortcut?: string
|
||||
alternateKeybind?: string[]
|
||||
}
|
||||
|
||||
export function ComposerEditor(props: ComposerEditorProps) {
|
||||
@@ -177,10 +179,15 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (props.controller.onKeyDown(event)) return
|
||||
const mod = event.metaKey || event.ctrlKey
|
||||
if (mod && event.key === "ArrowUp" && !event.shiftKey && !event.altKey) {
|
||||
if (view.submit.queue?.editFirst()) event.preventDefault()
|
||||
return
|
||||
}
|
||||
if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
|
||||
event.preventDefault()
|
||||
if (event.repeat) return
|
||||
props.controller.submit()
|
||||
props.controller.submit(mod ? { alternate: true } : undefined)
|
||||
}
|
||||
}}
|
||||
onKeyUp={updateCursor}
|
||||
@@ -248,6 +255,12 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={state.mode === "normal"}>
|
||||
<ComposerEditorAlternateDelivery
|
||||
controller={props.controller}
|
||||
keybind={props.alternateKeybind ?? ["Mod", "Enter"]}
|
||||
/>
|
||||
</Show>
|
||||
<ComposerEditorSubmitButton
|
||||
mode={state.mode}
|
||||
stopping={view.submit.stopping()}
|
||||
@@ -255,7 +268,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
||||
accent={props.accentSubmit}
|
||||
sendLabel={i18n.t("ui.promptInput.send")}
|
||||
stopLabel={i18n.t("ui.promptInput.stop")}
|
||||
onSubmit={props.controller.submit}
|
||||
onSubmit={() => props.controller.submit()}
|
||||
onStop={props.controller.stop}
|
||||
/>
|
||||
</div>
|
||||
@@ -691,6 +704,49 @@ export function ComposerEditorPopover(props: {
|
||||
)
|
||||
}
|
||||
|
||||
// "Steer ⌘⏎" / "Queue ⌘⏎" hint next to the submit button: submits with the
|
||||
// delivery opposite to what plain Enter does. Visible only while the queue
|
||||
// exposes an alternate (turn running and composer holding a value), so it
|
||||
// disappears on its own when the current turn ends.
|
||||
function ComposerEditorAlternateDelivery(props: { controller: ComposerEditorModel; keybind: string[] }) {
|
||||
const i18n = useI18n()
|
||||
const view = props.controller.view
|
||||
const action = createMemo(() => {
|
||||
const queue = view.submit.queue
|
||||
if (!queue || !props.controller.canSubmit()) return undefined
|
||||
if (queue.editing()) return "steer" as const
|
||||
return queue.alternate()
|
||||
})
|
||||
const [button, setButton] = createSignal<HTMLButtonElement>()
|
||||
const presence = createAnimatedPresence(action, () => button() ?? null)
|
||||
return (
|
||||
<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] 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")}
|
||||
<span class="hidden sm:block">
|
||||
<Keybind keys={props.keybind} variant="neutral" />
|
||||
</span>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
export function ComposerEditorSubmitButton(props: {
|
||||
mode: ComposerMode
|
||||
stopping: boolean
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
type ComposerInteractionEvent,
|
||||
} from "../suggestions/machine"
|
||||
import { clonePrompt, promptLength } from "../prompt-parts"
|
||||
import type { ComposerQueue } from "../adapter"
|
||||
|
||||
export type ComposerSelectControl = {
|
||||
options: Accessor<ComposerOption[]>
|
||||
@@ -37,7 +38,8 @@ export type ComposerEditorView = {
|
||||
submit: {
|
||||
stopping: Accessor<boolean>
|
||||
working?: Accessor<boolean>
|
||||
onSubmit: () => void
|
||||
queue?: ComposerQueue
|
||||
onSubmit: (options?: { alternate?: boolean }) => void
|
||||
onStop: () => void
|
||||
}
|
||||
shell?: {
|
||||
@@ -212,6 +214,11 @@ export function createComposerEditor(input: {
|
||||
)
|
||||
}
|
||||
if (handled) return true
|
||||
if (event.key === "Escape" && input.view.submit.queue?.editing()) {
|
||||
event.preventDefault()
|
||||
input.view.submit.queue.cancelEdit()
|
||||
return true
|
||||
}
|
||||
const stop =
|
||||
input.view.submit.working?.() &&
|
||||
((event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "g") ||
|
||||
@@ -354,8 +361,8 @@ export function createComposerEditor(input: {
|
||||
openShell() {
|
||||
dispatch({ type: "mode.shell" })
|
||||
},
|
||||
submit() {
|
||||
input.view.submit.onSubmit()
|
||||
submit(options?: { alternate?: boolean }) {
|
||||
input.view.submit.onSubmit(options)
|
||||
dispatch({ type: "popover.close" })
|
||||
},
|
||||
stop() {
|
||||
|
||||
@@ -16,7 +16,7 @@ import { createSessionTabs } from "@/session/helpers"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { formatServerError } from "@/runtime/server/errors"
|
||||
import { Skill } from "@opencode-ai/schema/skill"
|
||||
import type { ComposerAdapter, ComposerControls } from "./adapter"
|
||||
import type { ComposerAdapter, ComposerControls, ComposerQueue } from "./adapter"
|
||||
import type { ImageAttachmentPart } from "./state"
|
||||
import type { PromptHistoryComment } from "./history/entry"
|
||||
import { createComposerHistory } from "./history/store"
|
||||
@@ -27,7 +27,7 @@ export type ComposerModel = ComposerEditorModel & {
|
||||
readonly model: ComposerControls["model"]
|
||||
}
|
||||
|
||||
export function createComposerModel(adapter: ComposerAdapter): ComposerModel {
|
||||
export function createComposerModel(adapter: ComposerAdapter, options?: { queue?: ComposerQueue }): ComposerModel {
|
||||
const sdk = useWorkspaceLocation()
|
||||
const data = useData()
|
||||
const files = useFile()
|
||||
@@ -80,7 +80,11 @@ export function createComposerModel(adapter: ComposerAdapter): ComposerModel {
|
||||
})
|
||||
const stopping = createMemo(() => adapter.working() && blank())
|
||||
const placeholder = () =>
|
||||
composerPlaceholder(mode(), (key, params) => language.t(key as Parameters<typeof language.t>[0], params as never))
|
||||
composerPlaceholder(
|
||||
mode(),
|
||||
(key, params) => language.t(key as Parameters<typeof language.t>[0], params as never),
|
||||
adapter.working() || (options?.queue?.count() ?? 0) > 0,
|
||||
)
|
||||
|
||||
const historyComments = () => {
|
||||
const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const))
|
||||
@@ -253,6 +257,11 @@ export function createComposerModel(adapter: ComposerAdapter): ComposerModel {
|
||||
resetHistory: () => controller.resetHistory(),
|
||||
setMode: (next) => controller.dispatch({ type: next === "shell" ? "mode.shell" : "mode.normal" }),
|
||||
closePopover: () => controller.dispatch({ type: "popover.close" }),
|
||||
delivery: (alternate) => {
|
||||
const queue = options?.queue
|
||||
if (!queue) return "steer"
|
||||
return (alternate ? queue.alternate() : queue.delivery()) ?? "steer"
|
||||
},
|
||||
notify: {
|
||||
missingSelection: () =>
|
||||
showToast({
|
||||
@@ -360,7 +369,18 @@ export function createComposerModel(adapter: ComposerAdapter): ComposerModel {
|
||||
submit: {
|
||||
stopping,
|
||||
working: adapter.working,
|
||||
onSubmit: () => void submission.submit(new Event("submit")),
|
||||
queue: options?.queue,
|
||||
onSubmit: (submitOptions) => {
|
||||
const queue = options?.queue
|
||||
// Confirming an edit re-admits the queued prompt instead of sending
|
||||
// the composer value as a new prompt. Enter keeps it queued in
|
||||
// place; the alternate action sends it as a steer.
|
||||
if (queue?.editing()) {
|
||||
queue.confirmEdit(submitOptions?.alternate ? "steer" : "queue")
|
||||
return
|
||||
}
|
||||
void submission.submit(new Event("submit"), submitOptions)
|
||||
},
|
||||
onStop: () => void submission.stop(),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -12,4 +12,12 @@ describe("Composer placeholder", () => {
|
||||
test("uses the command and context hint in normal mode", () => {
|
||||
expect(composerPlaceholder("normal", t)).toBe("ui.promptInput.placeholder.normal/@")
|
||||
})
|
||||
|
||||
test("uses the follow-up copy while a turn runs or prompts are queued", () => {
|
||||
expect(composerPlaceholder("normal", t, true)).toBe("ui.promptInput.placeholder.followUp/@")
|
||||
})
|
||||
|
||||
test("keeps the shell placeholder while a turn runs", () => {
|
||||
expect(composerPlaceholder("shell", t, true)).toBe("prompt.placeholder.shell:git status")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
export function composerPlaceholder(
|
||||
mode: "normal" | "shell",
|
||||
t: (key: string, params?: Record<string, string>) => string,
|
||||
followUp?: boolean,
|
||||
) {
|
||||
if (mode === "shell") return t("prompt.placeholder.shell", { example: "git status" })
|
||||
if (followUp) return t("ui.promptInput.placeholder.followUp", { slash: "/", at: "@" })
|
||||
return t("ui.promptInput.placeholder.normal", { slash: "/", at: "@" })
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { Accessor } from "solid-js"
|
||||
import type { PromptHistoryComment } from "./history/entry"
|
||||
import type { ImageAttachmentPart, Prompt } from "./state"
|
||||
import { clonePrompt, promptLength } from "./prompt-parts"
|
||||
import type { ComposerAdapter, ComposerSelection, ComposerSession } from "./adapter"
|
||||
import type { ComposerAdapter, ComposerDelivery, ComposerSelection, ComposerSession } from "./adapter"
|
||||
import { createComposerSubmission } from "./submission-state"
|
||||
import { buildPromptRequest } from "./request"
|
||||
import { setCursorPosition } from "./editor/dom"
|
||||
@@ -21,7 +21,7 @@ type ComposerSubmission = {
|
||||
text: string
|
||||
images: ImageAttachmentPart[]
|
||||
selection: ComposerSelection
|
||||
delivery: "steer"
|
||||
delivery: ComposerDelivery
|
||||
}
|
||||
|
||||
type ComposerSubmitInput = {
|
||||
@@ -33,6 +33,7 @@ type ComposerSubmitInput = {
|
||||
resetHistory: () => void
|
||||
setMode: (mode: "normal" | "shell") => void
|
||||
closePopover: () => void
|
||||
delivery?: (alternate: boolean) => ComposerDelivery
|
||||
notify: {
|
||||
missingSelection: () => void
|
||||
failed: (kind: "shell" | "command" | "prompt", error: unknown) => void
|
||||
@@ -45,7 +46,7 @@ type ComposerSubmitInput = {
|
||||
}
|
||||
|
||||
export function createComposerSubmit(input: ComposerSubmitInput) {
|
||||
const submit = async (event: globalThis.Event) => {
|
||||
const submit = async (event: globalThis.Event, options?: { alternate?: boolean }) => {
|
||||
event.preventDefault()
|
||||
|
||||
const submission = createComposerSubmission({
|
||||
@@ -56,7 +57,7 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
|
||||
selection: item.selection ? { ...item.selection } : undefined,
|
||||
})),
|
||||
})
|
||||
const value = readSubmission(input, submission.prompt, submission.context)
|
||||
const value = readSubmission(input, submission.prompt, submission.context, options?.alternate ?? false)
|
||||
if (!value) {
|
||||
if (input.adapter.working() && input.adapter.kind === "active-session") void input.adapter.interrupt()
|
||||
return
|
||||
@@ -113,7 +114,10 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
|
||||
|
||||
if (command) {
|
||||
clearSubmission(input, submission)
|
||||
void sendCommand(session, value, command).catch((error) =>
|
||||
// Commands always steer: the server applies a command's configured
|
||||
// agent and model immediately at admission, so queueing one would
|
||||
// reconfigure the turn it is supposed to wait behind.
|
||||
void sendCommand(session, { ...value, delivery: "steer" }, command).catch((error) =>
|
||||
failSubmission(input, session, "command", error, restore, value.id),
|
||||
)
|
||||
return
|
||||
@@ -157,6 +161,7 @@ function readSubmission(
|
||||
input: ComposerSubmitInput,
|
||||
prompt: Prompt,
|
||||
context: ComposerSubmission["context"],
|
||||
alternate: boolean,
|
||||
): ComposerSubmission | undefined {
|
||||
const text = prompt.map((part) => ("content" in part ? part.content : "")).join("")
|
||||
const mode = input.mode()
|
||||
@@ -195,7 +200,7 @@ function readSubmission(
|
||||
model: { modelID: model.id, providerID: model.provider.id },
|
||||
variant,
|
||||
},
|
||||
delivery: "steer",
|
||||
delivery: input.delivery?.(alternate) ?? "steer",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,23 +290,30 @@ async function sendCommand(
|
||||
|
||||
async function sendPrompt(session: ComposerSession, value: ComposerSubmission) {
|
||||
const request = await buildSubmissionRequest(session, value)
|
||||
const current = session.current()
|
||||
if (current?.agent !== value.selection.agent) {
|
||||
await session.api.switchAgent({ sessionID: session.id, agent: value.selection.agent })
|
||||
}
|
||||
if (
|
||||
current?.model?.providerID !== value.selection.model.providerID ||
|
||||
current.model.id !== value.selection.model.modelID ||
|
||||
(current.model.variant ?? "default") !== (value.selection.variant ?? "default")
|
||||
) {
|
||||
await session.api.switchModel({
|
||||
sessionID: session.id,
|
||||
model: {
|
||||
id: value.selection.model.modelID,
|
||||
providerID: value.selection.model.providerID,
|
||||
variant: value.selection.variant,
|
||||
},
|
||||
})
|
||||
// Switching agent or model reconfigures the session immediately, and with it
|
||||
// the remainder of a running turn. A steer targets that turn, so its
|
||||
// selection applies now; a queued follow-up must not reconfigure the turn it
|
||||
// waits behind, so it runs with the session selection at delivery time (the
|
||||
// intended selection stays recorded in its metadata).
|
||||
if (value.delivery === "steer") {
|
||||
const current = session.current()
|
||||
if (current?.agent !== value.selection.agent) {
|
||||
await session.api.switchAgent({ sessionID: session.id, agent: value.selection.agent })
|
||||
}
|
||||
if (
|
||||
current?.model?.providerID !== value.selection.model.providerID ||
|
||||
current.model.id !== value.selection.model.modelID ||
|
||||
(current.model.variant ?? "default") !== (value.selection.variant ?? "default")
|
||||
) {
|
||||
await session.api.switchModel({
|
||||
sessionID: session.id,
|
||||
model: {
|
||||
id: value.selection.model.modelID,
|
||||
providerID: value.selection.model.providerID,
|
||||
variant: value.selection.variant,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const admission = {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -677,6 +677,14 @@ export const dict = {
|
||||
"session.background.subagent.one": "{{count}} subagent",
|
||||
"session.background.subagent.other": "{{count}} subagents",
|
||||
"command.session.background": "Move to background",
|
||||
"session.queue.count.one": "{{count}} queued",
|
||||
"session.queue.count.other": "{{count}} queued",
|
||||
"session.queue.steer": "Steer",
|
||||
"session.queue.send": "Send",
|
||||
"session.queue.steerTooltip": "Send without interrupting",
|
||||
"session.queue.remove": "Remove",
|
||||
"session.queue.reorder": "Reorder queued prompt",
|
||||
"session.queue.attachments": "+ attachments",
|
||||
"session.timeline.notice.finished": "{{actor}} finished",
|
||||
"session.timeline.notice.failed": "{{actor}} failed",
|
||||
"session.timeline.notice.cancelled": "{{actor}} cancelled",
|
||||
@@ -961,6 +969,11 @@ export const dict = {
|
||||
"settings.general.row.showCustomAgents.title": "Show agent",
|
||||
"settings.general.row.showCustomAgents.description":
|
||||
"Switch between agents in the composer. When hidden, defaults to Build agent.",
|
||||
"settings.general.row.followUpBehavior.title": "Follow-up behavior",
|
||||
"settings.general.row.followUpBehavior.description":
|
||||
"Choose whether to queue follow-ups or steer the current turn. Use {{keybind}} to switch.",
|
||||
"settings.general.row.followUpBehavior.queue": "Queue",
|
||||
"settings.general.row.followUpBehavior.steer": "Steer",
|
||||
"settings.general.row.reasoningSummaries.title": "Show reasoning summaries",
|
||||
"settings.general.row.reasoningSummaries.description": "Display model reasoning summaries in the timeline",
|
||||
"settings.general.row.shellToolPartsExpanded.title": "Expand shell tool parts",
|
||||
@@ -1132,6 +1145,10 @@ export const dict = {
|
||||
"session.delete.title": "Delete session",
|
||||
"session.delete.confirm": 'Delete session "{{name}}"?',
|
||||
"session.delete.button": "Delete session",
|
||||
"session.locationUnavailable.title": "Working directory unavailable",
|
||||
"session.locationUnavailable.description": "This session is read-only until you move it to another directory.",
|
||||
"session.locationUnavailable.action": "Move session",
|
||||
"session.locationUnavailable.pickerTitle": "Choose a new working directory",
|
||||
|
||||
"workspace.new": "New workspace",
|
||||
"common.viewAll": "View all",
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { moveSessionLocation } from "./location-recovery"
|
||||
|
||||
test("moves an unavailable session to the selected directory", async () => {
|
||||
const moving: boolean[] = []
|
||||
const moved: string[] = []
|
||||
|
||||
const result = await moveSessionLocation({
|
||||
selection: ["/repo/recovered"],
|
||||
moving: false,
|
||||
setMoving: (value) => moving.push(value),
|
||||
move: async (directory) => moved.push(directory),
|
||||
failed: () => undefined,
|
||||
})
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(moved).toEqual(["/repo/recovered"])
|
||||
expect(moving).toEqual([true, false])
|
||||
})
|
||||
|
||||
test("keeps the recovery action available after a failed move", async () => {
|
||||
const moving: boolean[] = []
|
||||
const errors: unknown[] = []
|
||||
const error = new Error("unavailable")
|
||||
|
||||
const result = await moveSessionLocation({
|
||||
selection: "/repo/missing",
|
||||
moving: false,
|
||||
setMoving: (value) => moving.push(value),
|
||||
move: async () => {
|
||||
throw error
|
||||
},
|
||||
failed: (cause) => errors.push(cause),
|
||||
})
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(errors).toEqual([error])
|
||||
expect(moving).toEqual([true, false])
|
||||
})
|
||||
|
||||
test("ignores cancelled and duplicate recovery attempts", async () => {
|
||||
let moves = 0
|
||||
const input = {
|
||||
setMoving: () => undefined,
|
||||
move: async () => {
|
||||
moves++
|
||||
},
|
||||
failed: () => undefined,
|
||||
}
|
||||
|
||||
expect(await moveSessionLocation({ ...input, selection: null, moving: false })).toBe(false)
|
||||
expect(await moveSessionLocation({ ...input, selection: "/repo/next", moving: true })).toBe(false)
|
||||
expect(moves).toBe(0)
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
export async function moveSessionLocation(input: {
|
||||
selection: string | string[] | null
|
||||
moving: boolean
|
||||
setMoving: (moving: boolean) => void
|
||||
move: (directory: string) => Promise<unknown>
|
||||
failed: (error: unknown) => void
|
||||
}) {
|
||||
const directory = Array.isArray(input.selection) ? input.selection[0] : input.selection
|
||||
if (!directory || input.moving) return false
|
||||
|
||||
input.setMoving(true)
|
||||
return input
|
||||
.move(directory)
|
||||
.then(() => true)
|
||||
.catch((error) => {
|
||||
input.failed(error)
|
||||
return false
|
||||
})
|
||||
.finally(() => input.setMoving(false))
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { useDirectoryPicker } from "@/workspaces/selection/picker"
|
||||
import { moveSessionLocation } from "./location-recovery"
|
||||
|
||||
export function SessionLocationUnavailable(props: { sessionID: string }) {
|
||||
const language = useLanguage()
|
||||
const serverSDK = useServerSDK()
|
||||
const pickDirectory = useDirectoryPicker()
|
||||
const [store, setStore] = createStore({ moving: false })
|
||||
|
||||
const chooseDirectory = () => {
|
||||
if (store.moving) return
|
||||
pickDirectory({
|
||||
server: serverSDK.server,
|
||||
title: language.t("session.locationUnavailable.pickerTitle"),
|
||||
onSelect: (result) => {
|
||||
void moveSessionLocation({
|
||||
selection: result,
|
||||
moving: store.moving,
|
||||
setMoving: (moving) => setStore("moving", moving),
|
||||
move: (directory) => serverSDK.api.session.move({ sessionID: props.sessionID, directory }),
|
||||
failed: (error) =>
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("workspace.move.failed"),
|
||||
description: error instanceof Error ? error.message : language.t("common.requestFailed"),
|
||||
}),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<SessionLocationUnavailableView
|
||||
title={language.t("session.locationUnavailable.title")}
|
||||
description={language.t("session.locationUnavailable.description")}
|
||||
action={language.t("session.locationUnavailable.action")}
|
||||
moving={store.moving}
|
||||
onMove={chooseDirectory}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function SessionLocationUnavailableView(props: {
|
||||
title: string
|
||||
description: string
|
||||
action: string
|
||||
moving: boolean
|
||||
onMove: () => void
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-component="session-location-unavailable"
|
||||
class="flex w-full items-center gap-3 rounded-[12px] border border-border-weak-base bg-background-base p-3"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-14-medium text-text-strong">{props.title}</div>
|
||||
<div class="text-13-regular text-text-weak">{props.description}</div>
|
||||
</div>
|
||||
<Button type="button" variant="outline" icon="folder" disabled={props.moving} onClick={props.onMove}>
|
||||
{props.action}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { createMemo, For, Show } from "solid-js"
|
||||
import { DragDropProvider, PointerSensor } from "@dnd-kit/solid"
|
||||
import { isSortable, useSortable } from "@dnd-kit/solid/sortable"
|
||||
import { AutoScroller, Feedback, PointerActivationConstraints } from "@dnd-kit/dom"
|
||||
import { RestrictToVerticalAxis } from "@dnd-kit/abstract/modifiers"
|
||||
import { RestrictToElement } from "@dnd-kit/dom/modifiers"
|
||||
import { arrayMove } from "@dnd-kit/helpers"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import type { SessionQueueView } from "./queue"
|
||||
|
||||
// Pullout above the composer listing the prompts queued behind the current
|
||||
// turn. The panel slides under the composer card (negative margin, opaque
|
||||
// composer background) so the two read as one attached surface.
|
||||
export function SessionQueuePanel(props: { queue: SessionQueueView }) {
|
||||
const language = useLanguage()
|
||||
const count = () => props.queue.rows().length
|
||||
let listRef!: HTMLDivElement
|
||||
return (
|
||||
<Show when={count() > 0}>
|
||||
<div
|
||||
data-component="session-queue-panel"
|
||||
class="relative z-0 -mb-3 rounded-xl bg-v2-background-bg-base px-1.5 pt-1.5 pb-[18px] shadow-[inset_0_0_0_0.5px_var(--v2-border-border-base)]"
|
||||
>
|
||||
<Show when={count() > 3}>
|
||||
<div class="px-1.5 pb-px text-[11px] font-[530] uppercase leading-[var(--line-height-tight)] tracking-[0.05px] text-v2-text-text-muted [font-variant-numeric:tabular-nums]">
|
||||
{language.plural("session.queue.count", count())}
|
||||
</div>
|
||||
</Show>
|
||||
<DragDropProvider
|
||||
sensors={(defaults) => [
|
||||
...defaults.filter((sensor) => sensor !== PointerSensor),
|
||||
PointerSensor.configure({
|
||||
activationConstraints: [new PointerActivationConstraints.Distance({ value: 4 })],
|
||||
}),
|
||||
]}
|
||||
modifiers={[RestrictToVerticalAxis, RestrictToElement.configure({ element: () => listRef })]}
|
||||
plugins={(defaults) => [
|
||||
...defaults.filter((plugin) => plugin !== AutoScroller && plugin !== Feedback),
|
||||
AutoScroller.configure({ acceleration: 8, threshold: { x: 0, y: 0.05 } }),
|
||||
Feedback.configure({ dropAnimation: null }),
|
||||
]}
|
||||
onDragEnd={(event) => {
|
||||
const source = event.operation.source
|
||||
if (event.canceled || !isSortable(source)) return
|
||||
if (source.initialIndex === source.index) return
|
||||
void props.queue.reorder(
|
||||
arrayMove(
|
||||
props.queue.rows().map((row) => row.id),
|
||||
source.initialIndex,
|
||||
source.index,
|
||||
),
|
||||
)
|
||||
}}
|
||||
>
|
||||
{/* Keyed on row IDs so store updates move row elements instead of
|
||||
remounting them, which would kill an in-flight drag. */}
|
||||
<div
|
||||
ref={listRef}
|
||||
class="flex flex-col gap-px"
|
||||
classList={{ "max-h-[131px] overflow-y-auto": count() > 3 }}
|
||||
>
|
||||
<For each={props.queue.rows().map((row) => row.id)}>
|
||||
{(id, index) => <SessionQueueRow queue={props.queue} id={id} index={index()} />}
|
||||
</For>
|
||||
</div>
|
||||
</DragDropProvider>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function SessionQueueRow(props: { queue: SessionQueueView; id: string; index: number }) {
|
||||
const language = useLanguage()
|
||||
const row = createMemo(() => props.queue.rows().find((entry) => entry.id === props.id))
|
||||
const editing = () => props.queue.editing() === props.id
|
||||
// While the turn is stopped the queue stays parked, so the first prompt
|
||||
// shows its actions without hover and its label reads Send: that is how a
|
||||
// parked queue resumes.
|
||||
const active = () => !props.queue.working() && props.index === 0
|
||||
const sortable = useSortable({
|
||||
get id() {
|
||||
return props.id
|
||||
},
|
||||
get index() {
|
||||
return props.index
|
||||
},
|
||||
get disabled() {
|
||||
return props.queue.busy()
|
||||
},
|
||||
})
|
||||
return (
|
||||
<Show when={row()} keyed>
|
||||
{(entry) => (
|
||||
<div
|
||||
ref={sortable.ref}
|
||||
data-component="session-queue-row"
|
||||
class="group/queue-row flex items-center justify-between gap-2 rounded-md py-1 ps-1 pe-2"
|
||||
classList={{
|
||||
"bg-v2-overlay-simple-overlay-hover": editing(),
|
||||
"opacity-60": sortable.isDragSource(),
|
||||
}}
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<button
|
||||
ref={sortable.handleRef}
|
||||
type="button"
|
||||
class="grid shrink-0 cursor-grab touch-none grid-cols-2 gap-x-[2px] gap-y-[2.25px] p-1"
|
||||
aria-label={language.t("session.queue.reorder")}
|
||||
>
|
||||
<For each={Array.from({ length: 6 })}>
|
||||
{() => <span class="size-[2px] bg-v2-background-bg-layer-04" />}
|
||||
</For>
|
||||
</button>
|
||||
<div class="flex min-w-0 flex-col">
|
||||
<button
|
||||
type="button"
|
||||
data-action="session-queue-edit"
|
||||
dir="auto"
|
||||
disabled={props.queue.busy()}
|
||||
class="max-w-full min-w-0 self-start truncate rounded-sm text-start text-[13px] font-[440] leading-[var(--line-height-compact)]"
|
||||
classList={{
|
||||
"text-v2-text-text-faint": editing(),
|
||||
"cursor-text text-v2-text-text-base hover:bg-v2-overlay-simple-overlay-hover": !editing(),
|
||||
}}
|
||||
onClick={() => props.queue.edit(props.id)}
|
||||
>
|
||||
{entry.text || (entry.attachments ? language.t("session.queue.attachments") : "")}
|
||||
</button>
|
||||
<Show when={entry.attachments && entry.text}>
|
||||
<span class="text-[13px] font-[440] leading-[var(--line-height-compact)] text-v2-text-text-muted">
|
||||
{language.t("session.queue.attachments")}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
data-slot="session-queue-actions"
|
||||
class="flex shrink-0 items-center gap-1.5"
|
||||
classList={{
|
||||
"opacity-0 focus-within:opacity-100 group-hover/queue-row:opacity-100 [@media(hover:none)]:opacity-100":
|
||||
!active() && !editing(),
|
||||
"pointer-events-none": props.queue.busy(),
|
||||
}}
|
||||
>
|
||||
<Show when={!editing()}>
|
||||
<Tooltip
|
||||
placement="top"
|
||||
inactive={!props.queue.working()}
|
||||
value={language.t("session.queue.steerTooltip")}
|
||||
>
|
||||
<Button
|
||||
data-action="session-queue-steer"
|
||||
type="button"
|
||||
size="small"
|
||||
variant="ghost-muted"
|
||||
icon="arrow-up"
|
||||
disabled={props.queue.busy()}
|
||||
class="text-v2-text-text-muted ![font-weight:530]"
|
||||
onClick={() => void props.queue.steer(props.id)}
|
||||
>
|
||||
{props.queue.working() ? language.t("session.queue.steer") : language.t("session.queue.send")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
<Tooltip placement="top" value={language.t("session.queue.remove")}>
|
||||
<IconButton
|
||||
data-action="session-queue-remove"
|
||||
type="button"
|
||||
size="small"
|
||||
variant="ghost-muted"
|
||||
icon={<Icon name="outline-xmark" />}
|
||||
disabled={props.queue.busy()}
|
||||
aria-label={language.t("session.queue.remove")}
|
||||
onClick={() => void props.queue.remove(props.id)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
import { createEffect, createMemo, onCleanup, type Accessor } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { SessionInboxInfo } from "@opencode-ai/client/promise"
|
||||
import type { ComposerDelivery } from "@/composer/adapter"
|
||||
import type { ComposerModel } from "@/composer/model"
|
||||
import type { ComposerStateTarget } from "@/composer/submission-state"
|
||||
import type { ImageAttachmentPart, Prompt } from "@/composer/state"
|
||||
import { clonePrompt, promptLength } from "@/composer/prompt-parts"
|
||||
import { buildPromptRequest } from "@/composer/request"
|
||||
import { blobDataUrl } from "@/runtime/persistence/drafts"
|
||||
import { useData } from "@/runtime/server/current"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
|
||||
export type QueuedPrompt = Extract<SessionInboxInfo, { type: "user" }>
|
||||
|
||||
type EditStash = {
|
||||
prompt: Prompt
|
||||
cursor: number
|
||||
mode: "normal" | "shell"
|
||||
retry: ReturnType<ComposerStateTarget["retry"]["current"]>
|
||||
}
|
||||
|
||||
export function createSessionQueue(input: {
|
||||
sessionID: string
|
||||
draft: ComposerStateTarget
|
||||
working: Accessor<boolean>
|
||||
behavior: Accessor<ComposerDelivery>
|
||||
composer: Accessor<ComposerModel | undefined>
|
||||
}) {
|
||||
const data = useData()
|
||||
const server = useServerSDK()
|
||||
const location = useWorkspaceLocation()
|
||||
const language = useLanguage()
|
||||
const [state, setState] = createStore<{ editing?: { id: string; stash: EditStash }; busy: boolean }>({ busy: false })
|
||||
|
||||
const queued = createMemo(() =>
|
||||
data.session.pending
|
||||
.list(input.sessionID)
|
||||
.filter((item): item is QueuedPrompt => item.type === "user" && item.delivery === "queue"),
|
||||
)
|
||||
const rows = createMemo(() =>
|
||||
queued().map((item) => ({
|
||||
id: item.id,
|
||||
text: queuedPromptText(item),
|
||||
attachments: (item.payload.files?.length ?? 0) > 0,
|
||||
})),
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
const editing = state.editing
|
||||
if (!editing || state.busy || queued().some((item) => item.id === editing.id)) return
|
||||
setState("editing", undefined)
|
||||
})
|
||||
onCleanup(() => cancelEdit())
|
||||
|
||||
const notify = () => showToast({ title: language.t("common.requestFailed") })
|
||||
const run = (work: () => Promise<unknown>) => {
|
||||
setState("busy", true)
|
||||
return work()
|
||||
.catch(() => notify())
|
||||
.finally(async () => {
|
||||
await data.session.pending.sync(input.sessionID).catch(() => undefined)
|
||||
setState("busy", false)
|
||||
})
|
||||
}
|
||||
|
||||
const rewrite = async (inboxIDs: string[]) => {
|
||||
const pending = await server.api.session.inbox.list({ sessionID: input.sessionID })
|
||||
if (pending.some((item) => item.delivery === "queue" && item.type !== "user"))
|
||||
throw new Error("Queued control items block reordering")
|
||||
const current = pending.filter((item): item is QueuedPrompt => item.type === "user" && item.delivery === "queue")
|
||||
const ordered = inboxIDs.flatMap((id) => current.filter((item) => item.id === id))
|
||||
if (ordered.length !== current.length) throw new Error("Queued prompts changed before reordering")
|
||||
const changed = ordered.findIndex((item, index) => item.id !== current[index]?.id)
|
||||
if (changed < 0) return
|
||||
|
||||
// Existing inbox APIs cannot reorder rows, so replace only the changed suffix.
|
||||
for (const item of ordered.slice(changed)) {
|
||||
await data.session.prompt({
|
||||
sessionID: input.sessionID,
|
||||
text: item.payload.text,
|
||||
files: item.payload.files?.map((file) => ({
|
||||
uri: `data:${file.mime};base64,${file.data}`,
|
||||
name: file.name,
|
||||
description: file.description,
|
||||
mention: file.mention,
|
||||
})),
|
||||
agents: item.payload.agents,
|
||||
skills: item.payload.skills,
|
||||
metadata: item.payload.metadata,
|
||||
delivery: "queue",
|
||||
resume: false,
|
||||
})
|
||||
}
|
||||
for (const item of current.slice(changed)) {
|
||||
await server.api.session.inbox.cancel({ sessionID: input.sessionID, inboxID: item.id })
|
||||
}
|
||||
}
|
||||
const steer = (id: string) => {
|
||||
if (state.editing?.id === id) cancelEdit()
|
||||
return server.api.session.inbox.steer({ sessionID: input.sessionID, inboxID: id }).catch(() => notify())
|
||||
}
|
||||
const remove = (id: string) => {
|
||||
if (state.editing?.id === id) cancelEdit()
|
||||
return server.api.session.inbox.cancel({ sessionID: input.sessionID, inboxID: id }).catch(() => notify())
|
||||
}
|
||||
const reorder = (inboxIDs: string[]) => {
|
||||
if (state.busy) return Promise.resolve()
|
||||
return run(() => rewrite(inboxIDs))
|
||||
}
|
||||
|
||||
const edit = (id: string) => {
|
||||
if (state.busy) return false
|
||||
if (state.editing?.id === id) return true
|
||||
const item = queued().find((entry) => entry.id === id)
|
||||
if (!item) return false
|
||||
if (state.editing) cancelEdit()
|
||||
const draft = input.draft.current()
|
||||
setState("editing", {
|
||||
id,
|
||||
stash: {
|
||||
prompt: clonePrompt(draft),
|
||||
cursor: input.draft.cursor() ?? promptLength(draft),
|
||||
mode: input.draft.mode.current(),
|
||||
retry: input.draft.retry.current(),
|
||||
},
|
||||
})
|
||||
const text = queuedPromptText(item)
|
||||
input.composer()?.dispatch({ type: "mode.normal" })
|
||||
input.draft.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
|
||||
input.composer()?.restoreFocus(text.length)
|
||||
return true
|
||||
}
|
||||
const cancelEdit = () => {
|
||||
const editing = state.editing
|
||||
if (!editing) return
|
||||
setState("editing", undefined)
|
||||
// Mode first, then prompt, then retry: mode and prompt writes both clear
|
||||
// the retry marker.
|
||||
input.composer()?.dispatch({ type: editing.stash.mode === "shell" ? "mode.shell" : "mode.normal" })
|
||||
input.draft.set(editing.stash.prompt, editing.stash.cursor)
|
||||
if (editing.stash.retry) input.draft.retry.set(editing.stash.retry)
|
||||
input.composer()?.restoreFocus(editing.stash.cursor)
|
||||
}
|
||||
const confirmEdit = (delivery: ComposerDelivery) => {
|
||||
const editing = state.editing
|
||||
if (!editing || state.busy) return
|
||||
const prompt = clonePrompt(input.draft.current())
|
||||
const text = prompt.map((part) => ("content" in part ? part.content : "")).join("")
|
||||
if (!text.trim() && !prompt.some((part) => part.type === "image")) return cancelEdit()
|
||||
const item = queued().find((entry) => entry.id === editing.id)
|
||||
const pristine = item && text.trim() === queuedPromptText(item) && !prompt.some((part) => part.type === "image")
|
||||
if (pristine && delivery === "queue") return cancelEdit()
|
||||
const inboxIDs = queued().map((entry) => entry.id)
|
||||
void run(async () => {
|
||||
const replacement = await editedPromptInput(input.sessionID, location().directory, item, prompt, text)
|
||||
// Admit before cancelling so a failed replacement never discards the original.
|
||||
const admitted = await data.session.prompt({
|
||||
...replacement,
|
||||
delivery,
|
||||
...(delivery === "queue" ? { resume: false } : {}),
|
||||
})
|
||||
await server.api.session.inbox.cancel({ sessionID: input.sessionID, inboxID: editing.id })
|
||||
cancelEdit()
|
||||
if (delivery === "queue") await rewrite(inboxIDs.map((id) => (id === editing.id ? admitted.id : id)))
|
||||
})
|
||||
}
|
||||
const editFirst = () => {
|
||||
const first = queued()[0]
|
||||
if (!first) return false
|
||||
return edit(first.id)
|
||||
}
|
||||
|
||||
return {
|
||||
count: () => queued().length,
|
||||
delivery: () => (input.working() ? input.behavior() : "steer"),
|
||||
alternate: () => {
|
||||
if (state.editing) return "steer"
|
||||
if (!input.working()) return undefined
|
||||
return input.behavior() === "queue" ? "steer" : "queue"
|
||||
},
|
||||
editing: () => state.editing?.id,
|
||||
confirmEdit,
|
||||
cancelEdit,
|
||||
editFirst,
|
||||
rows,
|
||||
busy: () => state.busy,
|
||||
working: input.working,
|
||||
steer,
|
||||
remove,
|
||||
edit,
|
||||
reorder,
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionQueue = ReturnType<typeof createSessionQueue>
|
||||
|
||||
// The slice of the queue the panel renders and drives.
|
||||
export type SessionQueueView = Pick<
|
||||
SessionQueue,
|
||||
"rows" | "editing" | "working" | "busy" | "steer" | "remove" | "edit" | "reorder"
|
||||
>
|
||||
|
||||
export function queuedPromptText(item: QueuedPrompt) {
|
||||
const display = item.payload.metadata?.["displayText"]
|
||||
return typeof display === "string" && display.length > 0 ? display : item.payload.text
|
||||
}
|
||||
|
||||
// Confirming an edit submits the current composer content as the replacement:
|
||||
// mentions and images added during the edit are parsed like a normal
|
||||
// submission, the original's stored attachments are preserved, and the
|
||||
// review-comment notes appended to the original's model-visible text survive.
|
||||
// Ambient composer context (open review comments) stays out: it belongs to
|
||||
// the next fresh prompt, not to a queued edit.
|
||||
async function editedPromptInput(
|
||||
sessionID: string,
|
||||
directory: string,
|
||||
item: QueuedPrompt | undefined,
|
||||
prompt: Prompt,
|
||||
text: string,
|
||||
) {
|
||||
const images = await Promise.all(
|
||||
prompt
|
||||
.filter((part): part is ImageAttachmentPart => part.type === "image")
|
||||
.map(async (part) => ({ ...part, dataUrl: await blobDataUrl(part.blob, part.mime) })),
|
||||
)
|
||||
const request = buildPromptRequest({ prompt, context: [], images, text, sessionDirectory: directory })
|
||||
const payload = item?.payload
|
||||
const display = item ? queuedPromptText(item) : ""
|
||||
const notes = payload && display && payload.text.startsWith(display) ? payload.text.slice(display.length) : ""
|
||||
const mention = (value: { start: number; end: number; text: string } | undefined) => {
|
||||
if (!value) return undefined
|
||||
const start = text.indexOf(value.text)
|
||||
if (start < 0) return undefined
|
||||
return { text: value.text, start, end: start + value.text.length }
|
||||
}
|
||||
// Structured mentions degrade to plain text in the editor, so an original
|
||||
// agent or skill reference survives the edit as long as its mention text
|
||||
// still appears; newly typed structured mentions come from the request.
|
||||
const agents = [
|
||||
...(payload?.agents?.filter(
|
||||
(agent) =>
|
||||
agent.mention &&
|
||||
text.includes(agent.mention.text) &&
|
||||
!request.agents.some((entry) => entry.name === agent.name),
|
||||
) ?? []),
|
||||
...request.agents,
|
||||
]
|
||||
const skills = [
|
||||
...(payload?.skills?.filter(
|
||||
(skill) =>
|
||||
skill.mention && text.includes(skill.mention.text) && !request.skills.some((entry) => entry.id === skill.id),
|
||||
) ?? []),
|
||||
...request.skills,
|
||||
]
|
||||
return {
|
||||
sessionID,
|
||||
text: request.text + notes,
|
||||
files: [
|
||||
...(payload?.files?.map((file) => ({
|
||||
uri: `data:${file.mime};base64,${file.data}`,
|
||||
name: file.name,
|
||||
description: file.description,
|
||||
mention: mention(file.mention),
|
||||
})) ?? []),
|
||||
...request.files.map((file) => ({ uri: file.uri, name: file.name, mention: file.mention })),
|
||||
],
|
||||
agents: agents.map((agent) => ({ name: agent.name, mention: mention(agent.mention) })),
|
||||
skills: skills.map((skill) => ({ id: skill.id, mention: mention(skill.mention) })),
|
||||
metadata: { ...payload?.metadata, displayText: request.displayText },
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,9 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { isScrollKeyTarget, scrollKey, scrollKeyOwner } from "@opencode-ai/ui/scroll-view"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { createEffect, on, onMount } from "solid-js"
|
||||
import { createEffect, on, onMount, Show } from "solid-js"
|
||||
import { Composer } from "@/composer/composer"
|
||||
import { createComposerModel } from "@/composer/model"
|
||||
import { createComposerModel, type ComposerModel } from "@/composer/model"
|
||||
import { useComposerState } from "@/composer/persistence"
|
||||
import { createComposerControls } from "@/composer/selection"
|
||||
import { setCursorPosition } from "@/composer/editor/dom"
|
||||
@@ -27,8 +27,12 @@ import { createSessionRevert } from "../revert"
|
||||
import { SessionComposerRegion } from "./session-composer-region"
|
||||
import { createSessionComposerRegionController } from "./session-composer-region-controller"
|
||||
import { createActiveComposerAdapter } from "./adapter"
|
||||
import { createSessionQueue } from "./queue"
|
||||
import { SessionQueuePanel } from "./queue-panel"
|
||||
import { resolveSessionComposerSelection } from "./selection"
|
||||
import { createSessionRequestModel } from "../requests/model"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { SessionLocationUnavailable } from "./location-unavailable"
|
||||
|
||||
export function createActiveSessionRegion(input: {
|
||||
session: SessionModel
|
||||
@@ -216,6 +220,8 @@ export function ActiveSessionComposerRegion(props: {
|
||||
accentSubmit: boolean
|
||||
onResponseSubmit: () => void
|
||||
}) {
|
||||
const settings = useSettings()
|
||||
const location = useWorkspaceLocation()
|
||||
const region = createSessionComposerRegionController({
|
||||
state: props.model.region.state,
|
||||
parentID: props.session.data.parentID,
|
||||
@@ -231,11 +237,39 @@ export function ActiveSessionComposerRegion(props: {
|
||||
submitted: props.model.submitted,
|
||||
setEditor: props.model.input.setPromptRef,
|
||||
})
|
||||
const composer = createComposerModel(adapter)
|
||||
let composer: ComposerModel | undefined
|
||||
const queue = createSessionQueue({
|
||||
sessionID: requireSessionID(props.session),
|
||||
draft: adapter.state,
|
||||
working: adapter.working,
|
||||
behavior: settings.general.followUpBehavior,
|
||||
composer: () => composer,
|
||||
})
|
||||
composer = createComposerModel(adapter, { queue })
|
||||
return (
|
||||
<SessionComposerRegion
|
||||
controller={region}
|
||||
composer={<Composer model={composer} borderUnderlay accentSubmit={props.accentSubmit} />}
|
||||
composer={
|
||||
<Show
|
||||
when={location().error && !location().current}
|
||||
fallback={
|
||||
<div class="relative">
|
||||
<SessionQueuePanel queue={queue} />
|
||||
<div class="relative z-10">
|
||||
<Composer model={composer} borderUnderlay accentSubmit={props.accentSubmit} />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<SessionLocationUnavailable sessionID={requireSessionID(props.session)} />
|
||||
</Show>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function requireSessionID(session: SessionModel) {
|
||||
const id = session.identity.params.id
|
||||
if (!id) throw new Error("Active Composer requires a Session ID")
|
||||
return id
|
||||
}
|
||||
|
||||
@@ -55,6 +55,28 @@ export function createSessionRevert(input: {
|
||||
await server.api.session.interrupt({ sessionID }).catch(() => undefined)
|
||||
}
|
||||
if (!(await request(() => server.api.session.revert.stage({ sessionID, messageID: message.id })))) return
|
||||
// Reverting to a previous prompt discards the pending queue (and pending
|
||||
// steers): they were written against the history being rewound. Cancel
|
||||
// the authoritative inbox merged with the local snapshot, fire-and-forget
|
||||
// so a slow request cannot delay restoring the composer. The cutoff keeps
|
||||
// the asynchronous sweep away from prompts admitted after the revert; an
|
||||
// old admission still in flight when the list is fetched can survive it,
|
||||
// and fully closing that race needs a server-side revert-discards-inbox
|
||||
// rule.
|
||||
const cutoff = Date.now()
|
||||
const local = data.session.pending
|
||||
.list(sessionID)
|
||||
.filter((item) => item.type === "user")
|
||||
.map((item) => item.id)
|
||||
void server.api.session.inbox
|
||||
.list({ sessionID })
|
||||
.then((rows) => rows.filter((row) => row.type === "user" && row.timeCreated <= cutoff).map((row) => row.id))
|
||||
.catch(() => [])
|
||||
.then((authoritative) => {
|
||||
new Set([...local, ...authoritative]).forEach(
|
||||
(inboxID) => void server.api.session.inbox.cancel({ sessionID, inboxID }).catch(() => undefined),
|
||||
)
|
||||
})
|
||||
restore(target, message)
|
||||
owner.run(() => input.setActiveMessage(previous))
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -7,7 +7,13 @@ import { TextInput } from "@opencode-ai/ui/text-input"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { useUpdaterAction } from "@/shell/updates/action"
|
||||
import { type TerminalPlacement, type WorkspaceDefaultDestination, useSettings } from "@/settings/model"
|
||||
import {
|
||||
type FollowUpBehavior,
|
||||
type TerminalPlacement,
|
||||
type WorkspaceDefaultDestination,
|
||||
useSettings,
|
||||
} from "@/settings/model"
|
||||
import { formatKeybind } from "@/shell/commands/command"
|
||||
import { ExternalLink } from "@/runtime/platform/external-link"
|
||||
import { SettingsList } from "@/settings/list"
|
||||
import { SettingsRow } from "@/settings/row"
|
||||
@@ -148,6 +154,35 @@ const TerminalPlacementSetting: Component = () => {
|
||||
)
|
||||
}
|
||||
|
||||
const FollowUpBehaviorSetting: Component = () => {
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
const options = createMemo((): { value: FollowUpBehavior; label: string }[] => [
|
||||
{ value: "queue", label: language.t("settings.general.row.followUpBehavior.queue") },
|
||||
{ value: "steer", label: language.t("settings.general.row.followUpBehavior.steer") },
|
||||
])
|
||||
|
||||
return (
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.followUpBehavior.title")}
|
||||
description={language.t("settings.general.row.followUpBehavior.description", {
|
||||
keybind: formatKeybind("mod+enter", language.t),
|
||||
})}
|
||||
>
|
||||
<Select
|
||||
data-action="settings-follow-up-behavior"
|
||||
options={options()}
|
||||
current={options().find((option) => option.value === settings.general.followUpBehavior())}
|
||||
value={(option) => option.value}
|
||||
label={(option) => option.label}
|
||||
placement="bottom-end"
|
||||
gutter={6}
|
||||
onSelect={(option) => option && settings.general.setFollowUpBehavior(option.value)}
|
||||
/>
|
||||
</SettingsRow>
|
||||
)
|
||||
}
|
||||
|
||||
const AppearanceSection: Component<{ controller: AppearanceSettingsController }> = (props) => {
|
||||
const language = useLanguage()
|
||||
return (
|
||||
@@ -294,6 +329,7 @@ export const SettingsGeneral: Component<{
|
||||
|
||||
<ShellSetting controller={shell} />
|
||||
<TerminalPlacementSetting />
|
||||
<FollowUpBehaviorSetting />
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.reasoningSummaries.title")}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { monoDefault, monoFontFamily, sansDefault, sansFontFamily, terminalFontFamily } from "./model"
|
||||
|
||||
describe("settings font families", () => {
|
||||
test("defaults normal text to Inter", () => {
|
||||
expect(sansDefault).toBe("Inter")
|
||||
expect(sansFontFamily(undefined)).toStartWith('"Inter", ')
|
||||
expect(sansFontFamily("")).toStartWith('"Inter", ')
|
||||
expect(sansFontFamily(" ")).toStartWith('"Inter", ')
|
||||
})
|
||||
|
||||
test("keeps custom normal fonts ahead of the default", () => {
|
||||
expect(sansFontFamily("Custom Sans")).toStartWith('"Custom Sans", "Inter", ')
|
||||
})
|
||||
|
||||
test("defaults monospace text to IBM Plex Mono", () => {
|
||||
expect(monoDefault).toBe("IBM Plex Mono")
|
||||
expect(monoFontFamily(undefined)).toStartWith('"IBM Plex Mono", ')
|
||||
expect(monoFontFamily("")).toStartWith('"IBM Plex Mono", ')
|
||||
expect(monoFontFamily(" ")).toStartWith('"IBM Plex Mono", ')
|
||||
})
|
||||
|
||||
test("keeps custom monospace fonts ahead of the default", () => {
|
||||
expect(monoFontFamily("Custom Mono")).toStartWith('"Custom Mono", "IBM Plex Mono", ')
|
||||
})
|
||||
|
||||
test("preserves the separate terminal font default", () => {
|
||||
expect(terminalFontFamily(undefined)).toStartWith('"JetBrainsMono Nerd Font Mono", ')
|
||||
})
|
||||
})
|
||||
@@ -7,6 +7,7 @@ import { ScopedKey, type ServerScope } from "@/runtime/server/scope"
|
||||
export type WorkspaceDefaultDestination = "last-used" | "local" | "new"
|
||||
export type WorkspaceLastUsed = "local" | "workspace"
|
||||
export type TerminalPlacement = "side" | "bottom"
|
||||
export type FollowUpBehavior = "queue" | "steer"
|
||||
|
||||
export interface NotificationSettings {
|
||||
agent: boolean
|
||||
@@ -39,6 +40,7 @@ export interface Settings {
|
||||
showCustomAgents: boolean
|
||||
mobileTitlebarPosition: "top" | "bottom"
|
||||
terminalPlacement: TerminalPlacement
|
||||
followUpBehavior: FollowUpBehavior
|
||||
}
|
||||
appearance: {
|
||||
fontSize: number
|
||||
@@ -58,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'
|
||||
|
||||
@@ -126,6 +128,7 @@ const defaultSettings: Settings = {
|
||||
showCustomAgents: false,
|
||||
mobileTitlebarPosition: "top",
|
||||
terminalPlacement: "side",
|
||||
followUpBehavior: "steer",
|
||||
},
|
||||
appearance: {
|
||||
fontSize: 14,
|
||||
@@ -256,6 +259,10 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
setTerminalPlacement(value: TerminalPlacement) {
|
||||
setStore("general", "terminalPlacement", value)
|
||||
},
|
||||
followUpBehavior: withFallback(() => store.general?.followUpBehavior, defaultSettings.general.followUpBehavior),
|
||||
setFollowUpBehavior(value: FollowUpBehavior) {
|
||||
setStore("general", "followUpBehavior", value)
|
||||
},
|
||||
},
|
||||
visibility: {
|
||||
fileTree: showFileTree,
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -237,9 +237,8 @@ export default function PrivacyPolicy() {
|
||||
</td>
|
||||
<td>
|
||||
<ul>
|
||||
<li>Providing, Customizing and Improving the Services</li>
|
||||
<li>Marketing the Services</li>
|
||||
<li>Corresponding with You</li>
|
||||
<li>Passing through to upstream provider to provide services</li>
|
||||
<li>Not stored</li>
|
||||
</ul>
|
||||
</td>
|
||||
<td>
|
||||
|
||||
@@ -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))
|
||||
}),
|
||||
)
|
||||
}),
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
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, SchemaRepresentation } from "effect"
|
||||
import { Cache, Effect, JsonSchema, Schema, SchemaIssue, SchemaRepresentation } from "effect"
|
||||
import { $ZodType, toJSONSchema } from "zod/v4/core"
|
||||
|
||||
const formatEffectIssues = SchemaIssue.makeFormatterStandardSchemaV1()
|
||||
|
||||
const jsonSchemas = Effect.runSync(
|
||||
Cache.make<JsonSchema.JsonSchema, Schema.Codec<unknown> | undefined>({
|
||||
@@ -23,7 +26,7 @@ export const definition = (tool: Tool.Info<any, any>): ToolDefinition => ({
|
||||
|
||||
export const execute = (tool: Tool.Info<any, any>, input: unknown, context: Tool.Context) =>
|
||||
Effect.gen(function* () {
|
||||
const decoded = yield* decodeInput(tool.input, input)
|
||||
const decoded = yield* decodeInput(tool, input)
|
||||
// Tool implementations declare `Tool.Error` but plugins can fail with anything at
|
||||
// runtime. A foreign typed failure would slip past every `catchTag("Tool.Error")`
|
||||
// downstream and leave its call permanently unsettled, so the declared contract is
|
||||
@@ -55,18 +58,43 @@ export const execute = (tool: Tool.Info<any, any>, input: unknown, context: Tool
|
||||
}
|
||||
})
|
||||
|
||||
const decodeInput = (schema: Tool.ValueSchema<any>, value: unknown) => {
|
||||
if (Schema.isSchema(schema))
|
||||
return Schema.decodeUnknownEffect(schema)(value).pipe(
|
||||
Effect.mapError((error) => new Tool.Error({ message: `Invalid tool input: ${error.message}` })),
|
||||
const decodeInput = (tool: Tool.Info<any, any>, value: unknown) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* validateInput(tool.input, value)
|
||||
if (result.issues)
|
||||
return yield* new Tool.Error({ message: formatInputIssues(effectiveName(tool), result.issues, value) })
|
||||
return result.value
|
||||
})
|
||||
|
||||
const validateInput = (
|
||||
schema: Tool.ValueSchema<any>,
|
||||
value: unknown,
|
||||
): Effect.Effect<StandardSchemaV1.Result<unknown>> => {
|
||||
if (isStandardSchema(schema)) return validateStandard(schema, value)
|
||||
return Effect.gen(function* () {
|
||||
const codec = Schema.isSchema(schema) ? schema : yield* Cache.get(jsonSchemas, schema)
|
||||
if (codec === undefined) return { value }
|
||||
return yield* Schema.decodeUnknownEffect(codec)(value, { errors: "all" }).pipe(
|
||||
Effect.match({
|
||||
onFailure: (error) => formatEffectIssues(error.issue),
|
||||
onSuccess: (value) => ({ value }),
|
||||
}),
|
||||
)
|
||||
if (isStandardSchema(schema)) return validateStandard(schema, value, "Invalid tool input")
|
||||
return Cache.get(jsonSchemas, schema).pipe(
|
||||
Effect.flatMap((schema) =>
|
||||
schema === undefined ? Effect.succeed(value) : Schema.decodeUnknownEffect(schema)(value),
|
||||
),
|
||||
Effect.mapError((error) => new Tool.Error({ message: `Invalid tool input: ${error.message}` })),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const formatInputIssues = (tool: string, issues: ReadonlyArray<StandardSchemaV1.Issue>, value: unknown) => {
|
||||
const details = issues.slice(0, 5).map((issue) => {
|
||||
const path =
|
||||
issue.path?.reduce<string>((path, segment) => {
|
||||
const key = typeof segment === "object" ? segment.key : segment
|
||||
if (typeof key === "number") return `${path}[${key}]`
|
||||
return path === "" ? String(key) : `${path}.${String(key)}`
|
||||
}, "") || "root"
|
||||
return `- ${path}: ${issue.message}`
|
||||
})
|
||||
if (issues.length > 5) details.push(`- ...and ${issues.length - 5} more ${issues.length === 6 ? "issue" : "issues"}`)
|
||||
return `Invalid arguments for tool "${tool}":\n${details.join("\n")}\n\nArguments provided:\n${JSON.stringify(value, null, 2)}\n\nUpdate the arguments and call the tool again.`
|
||||
}
|
||||
|
||||
const jsonSchema = (schema: JsonSchema.JsonSchema) => {
|
||||
@@ -86,7 +114,15 @@ const encodeOutput = (schema: Tool.ValueSchema<any>, value: unknown) => {
|
||||
),
|
||||
)
|
||||
if (isStandardSchema(schema))
|
||||
return validateStandard(schema, value, "Tool returned an invalid value for its output schema")
|
||||
return validateStandard(schema, value).pipe(
|
||||
Effect.flatMap((result) =>
|
||||
result.issues
|
||||
? new Tool.Error({
|
||||
message: `Tool returned an invalid value for its output schema: ${result.issues.map((issue) => issue.message).join(", ")}`,
|
||||
})
|
||||
: Effect.succeed(result.value),
|
||||
),
|
||||
)
|
||||
return Schema.decodeUnknownEffect(Schema.Json)(value).pipe(
|
||||
Effect.mapError(
|
||||
(error) => new Tool.Error({ message: `Tool returned a non-JSON value for its output schema: ${error.message}` }),
|
||||
@@ -94,46 +130,44 @@ 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 validateStandard = (
|
||||
schema: StandardSchemaV1<any, any> & StandardJSONSchemaV1<any, any>,
|
||||
value: unknown,
|
||||
prefix: string,
|
||||
) =>
|
||||
Effect.gen(function* () {
|
||||
const pending = yield* Effect.try({
|
||||
try: () => schema["~standard"].validate(value),
|
||||
catch: (error) => standardFailure(prefix, error),
|
||||
})
|
||||
const result =
|
||||
pending instanceof Promise
|
||||
? yield* Effect.tryPromise({ try: () => pending, catch: (error) => standardFailure(prefix, error) })
|
||||
: pending
|
||||
if (result.issues)
|
||||
return yield* new Tool.Error({
|
||||
message: `${prefix}: ${result.issues.map((issue) => issue.message).join(", ")}`,
|
||||
})
|
||||
return result.value
|
||||
})
|
||||
const isStandardJSONSchema = (
|
||||
schema: StandardSchemaV1<any, any>,
|
||||
): schema is StandardSchemaV1<any, any> & StandardJSONSchemaV1<any, any> => "jsonSchema" in schema["~standard"]
|
||||
|
||||
const standardFailure = (prefix: string, error: unknown) =>
|
||||
new Tool.Error({ message: `${prefix}: ${error instanceof Error ? error.message : String(error)}` })
|
||||
const validateStandard = (
|
||||
schema: StandardSchemaV1<any, any>,
|
||||
value: unknown,
|
||||
): Effect.Effect<StandardSchemaV1.Result<unknown>> =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* Effect.try({ try: () => schema["~standard"].validate(value), catch: (error) => error })
|
||||
return result instanceof Promise ? yield* Effect.tryPromise({ try: () => result, catch: (error) => error }) : result
|
||||
}).pipe(
|
||||
Effect.match({
|
||||
onFailure: (error) => ({ issues: [{ message: error instanceof Error ? error.message : String(error) }] }),
|
||||
onSuccess: (result) => result,
|
||||
}),
|
||||
)
|
||||
|
||||
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, {
|
||||
@@ -511,7 +512,11 @@ describe("Tool", () => {
|
||||
}),
|
||||
).toMatchObject({
|
||||
status: "error",
|
||||
error: { type: "tool.execution", message: expect.stringContaining("Invalid tool input") },
|
||||
error: {
|
||||
type: "tool.execution",
|
||||
message:
|
||||
'Invalid arguments for tool "transformed":\n- value: Expected boolean\n\nArguments provided:\n{\n "value": "yes"\n}\n\nUpdate the arguments and call the tool again.',
|
||||
},
|
||||
})
|
||||
expect(executed).toEqual(["yes"])
|
||||
|
||||
@@ -552,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)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -100,7 +100,11 @@ describe("QuestionTool", () => {
|
||||
}),
|
||||
).toMatchObject({
|
||||
status: "error",
|
||||
error: { type: "tool.execution", message: expect.stringContaining("Invalid tool input") },
|
||||
error: {
|
||||
type: "tool.execution",
|
||||
message:
|
||||
'Invalid arguments for tool "question":\n- questions: Expected a value with a length of at least 1\n\nArguments provided:\n{\n "questions": []\n}\n\nUpdate the arguments and call the tool again.',
|
||||
},
|
||||
})
|
||||
expect(capturedInput()).toBeUndefined()
|
||||
}),
|
||||
|
||||
@@ -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,12 +140,54 @@ 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": {
|
||||
version: 1,
|
||||
vendor: "test",
|
||||
validate: (_value: unknown) => ({ issues: [{ message: "expected a string" }] }),
|
||||
validate: (_value: unknown) => ({
|
||||
issues: [
|
||||
{ path: ["value"], message: "expected a string" },
|
||||
{ path: [{ key: "nested" }, { key: "count" }], message: "expected a positive integer" },
|
||||
],
|
||||
}),
|
||||
jsonSchema: {
|
||||
input: () => ({ type: "string" }),
|
||||
output: () => ({ type: "string" }),
|
||||
@@ -166,7 +209,62 @@ test("portable schema failures become tool failures", async () => {
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(error).toEqual(new Tool.Error({ message: "Invalid tool input: expected a string" }))
|
||||
expect(error).toEqual(
|
||||
new Tool.Error({
|
||||
message:
|
||||
'Invalid arguments for tool "invalid":\n- value: expected a string\n- nested.count: expected a positive integer\n\nArguments provided:\n1\n\nUpdate the arguments and call the tool again.',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("Effect schema failures use normalized input issues", async () => {
|
||||
const tool: Info = {
|
||||
name: "effect",
|
||||
description: "Effect tool",
|
||||
input: Schema.Struct({
|
||||
value: Schema.String,
|
||||
nested: Schema.Struct({ count: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)) }),
|
||||
}),
|
||||
execute: () => Effect.succeed({ content: "unused" }),
|
||||
}
|
||||
|
||||
expect(
|
||||
await Effect.runPromise(Effect.flip(execute(tool, { value: 1, nested: { count: 0 } }, {} as Tool.Context))),
|
||||
).toEqual(
|
||||
new Tool.Error({
|
||||
message:
|
||||
'Invalid arguments for tool "effect":\n- value: Expected string\n- nested.count: Expected a value greater than or equal to 1\n\nArguments provided:\n{\n "value": 1,\n "nested": {\n "count": 0\n }\n}\n\nUpdate the arguments and call the tool again.',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("input error prompts limit normalized issues", async () => {
|
||||
const input = {
|
||||
"~standard": {
|
||||
version: 1,
|
||||
vendor: "test",
|
||||
validate: (_value: unknown) => ({
|
||||
issues: Array.from({ length: 6 }, (_, index) => ({ message: `issue ${index + 1}` })),
|
||||
}),
|
||||
jsonSchema: {
|
||||
input: () => ({}),
|
||||
output: () => ({}),
|
||||
},
|
||||
},
|
||||
}
|
||||
const tool: Info = {
|
||||
name: "limited",
|
||||
description: "Limited issues",
|
||||
input,
|
||||
execute: () => Effect.succeed({ content: "unused" }),
|
||||
}
|
||||
|
||||
expect(await Effect.runPromise(Effect.flip(execute(tool, {}, {} as Tool.Context)))).toEqual(
|
||||
new Tool.Error({
|
||||
message:
|
||||
'Invalid arguments for tool "limited":\n- root: issue 1\n- root: issue 2\n- root: issue 3\n- root: issue 4\n- root: issue 5\n- ...and 1 more issue\n\nArguments provided:\n{}\n\nUpdate the arguments and call the tool again.',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("canonical results carry metadata with typed output", async () => {
|
||||
@@ -219,16 +317,31 @@ test("raw JSON schemas validate and decode tool input", async () => {
|
||||
content: [{ type: "text", text: '{"value":"ok"}' }],
|
||||
})
|
||||
expect(await Effect.runPromise(Effect.flip(execute(tool, { value: 1 }, {} as Tool.Context)))).toEqual(
|
||||
new Tool.Error({ message: 'Invalid tool input: Expected string\n at ["value"]' }),
|
||||
new Tool.Error({
|
||||
message:
|
||||
'Invalid arguments for tool "raw":\n- value: Expected string\n\nArguments provided:\n{\n "value": 1\n}\n\nUpdate the arguments and call the tool again.',
|
||||
}),
|
||||
)
|
||||
expect(await Effect.runPromise(Effect.flip(execute(tool, {}, {} as Tool.Context)))).toEqual(
|
||||
new Tool.Error({ message: 'Invalid tool input: Missing key\n at ["value"]' }),
|
||||
new Tool.Error({
|
||||
message:
|
||||
'Invalid arguments for tool "raw":\n- value: Missing key\n\nArguments provided:\n{}\n\nUpdate the arguments and call the tool again.',
|
||||
}),
|
||||
)
|
||||
expect(
|
||||
await Effect.runPromise(Effect.flip(execute(tool, { value: "ok", nested: { count: 0 } }, {} as Tool.Context))),
|
||||
).toEqual(
|
||||
new Tool.Error({
|
||||
message: 'Invalid tool input: Expected a value greater than or equal to 1\n at ["nested"]["count"]',
|
||||
message:
|
||||
'Invalid arguments for tool "raw":\n- nested.count: Expected a value greater than or equal to 1\n\nArguments provided:\n{\n "value": "ok",\n "nested": {\n "count": 0\n }\n}\n\nUpdate the arguments and call the tool again.',
|
||||
}),
|
||||
)
|
||||
expect(
|
||||
await Effect.runPromise(Effect.flip(execute(tool, { value: 1, nested: { count: 0 } }, {} as Tool.Context))),
|
||||
).toEqual(
|
||||
new Tool.Error({
|
||||
message:
|
||||
'Invalid arguments for tool "raw":\n- value: Expected string\n- nested.count: Expected a value greater than or equal to 1\n\nArguments provided:\n{\n "value": 1,\n "nested": {\n "count": 0\n }\n}\n\nUpdate the arguments and call the tool again.',
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -250,7 +363,10 @@ test("raw JSON schemas resolve draft-07 definitions", async () => {
|
||||
content: [{ type: "text", text: '{"value":"ok"}' }],
|
||||
})
|
||||
expect(await Effect.runPromise(Effect.flip(execute(tool, { value: 1 }, {} as Tool.Context)))).toEqual(
|
||||
new Tool.Error({ message: 'Invalid tool input: Expected value\n at ["value"]' }),
|
||||
new Tool.Error({
|
||||
message:
|
||||
'Invalid arguments for tool "draft-07":\n- value: Expected value\n\nArguments provided:\n{\n "value": 1\n}\n\nUpdate the arguments and call the tool again.',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -118,7 +118,8 @@ describe("search tools", () => {
|
||||
status: "error",
|
||||
error: {
|
||||
type: "tool.execution",
|
||||
message: 'Invalid tool input: Pattern must not be empty\n at ["pattern"]',
|
||||
message:
|
||||
'Invalid arguments for tool "grep":\n- pattern: Pattern must not be empty\n\nArguments provided:\n{\n "pattern": ""\n}\n\nUpdate the arguments and call the tool again.',
|
||||
},
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user